CodeQL 复现 Struts OGNL 注入:Source/Sink 建模、误报剔除与路径验证
Struts OGNL 注入:Source/Sink 建模、误报剔除与路径验证
本文通过分析 Apache Struts 的历史 RCE 漏洞(S2-032、S2-033、S2-037),利用 CodeQL 将 untrusted input 流入 OGNL 表达式执行的路径进行建模。通过定义基于 ActionProxy 的污点来源(Source)和 OgnlUtil 的执行汇聚点(Sink),研究者能够系统地识别出潜在的攻击面。
简单来讲,你要学会用一些工具去对比补丁差异。本文为读后感,对大佬的一些源码进行了逐行解答,还提出了一些自己的理解。个人觉得这篇文章非常的经典。
您可以在此阅读完整原文:https://securitylab.github.com/research跟着复现一遍。
一、抓住了漏洞的本质:都是 OGNL 表达式注入
作者得知,Struts2 框架的大多数远程代码执行(RCE)漏洞,底层逻辑完全一样:
都是因为框架把“不可信的用户输入”当成了代码,直接交给了威力巨大的 OGNL 引擎去解析。
只要黑客控制了 OGNL 表达式的内容,就能直接在服务器上执行任意系统命令。
二、发现了官方的“名场面”:同一个漏洞修了三次都没修好
作者发现了一件非常有趣且尴尬的事:
S2-032、S2-033 和 S2-037 这三个漏洞本质上同一个底层问题。官方安全团队接连发布了三次补丁,尝试了三次才勉强把这个已知漏洞补上。
这让作者意识到:
官方的防线漏洞百出,框架底层肯定还藏着类似的“变体漏洞”。
三、抓到了导致爆炸的直接现场代码
通过对比老版本的源码,作者直接定位到了这三个漏洞触发时的核心 Java 代码片段:
String methodName = proxy.getMethod(); // <-- 不可信的 Source(源头)
// ...
methodResult = ognlUtil.getValue(methodName + "()", ...); // <-- 致命的 Sink(爆炸点)他看懂了漏洞的数据流:
黑客把恶意代码塞进 URL,框架通过 proxy.getMethod() 把它取出来存进 methodName 变量;紧接着,程序直接把这个变量拼接上小括号 "()",毫无防备地丢给了 ognlUtil.getValue() 强行执行。
四、污点源和污染汇
作者在编写 CodeQL 规则时,一共用了以下 3 个污点源(Sources) 和 3 个污染汇(Sinks)。
作者并没有针对每个漏洞单独写规则,而是采用了老练的“归纳法”,把它们各自打包成了一个集合,从而实现了跨版本的批量漏洞挖掘。
1. 污点源(Sources):共有 3 个
作者通过审计 ActionProxy 接口的定义,推测所有能从 URL 中提取信息的方法都可能被黑客控制。因此他在 QL 类 ActionProxyGetMethod 中,一共打包了以下 3 个方法作为污点入口:
getMethod():获取当前请求要调用的方法名(直接触发了老漏洞 S2-032 / S2-033 / S2-037)。getNamespace():获取当前请求的命名空间(最终直接触发了全新 0day 漏洞 S2-057)。getActionName():获取当前请求的 Action 别名(同样成功打通并触发了 RCE)。
2. 污染汇(Sinks):前后共涉及 3 个
在污染汇的定义上,作者经历了一次“先归纳底层,再根据补丁进行精简”的过程。整个研究过程中一共涉及以下 3 个 Sink 点:
1)历史漏洞的表面 Sink
OgnlUtil::getValue():老漏洞 S2-032 等表面上直接调用的函数。但在 S2-045 中,官方改用TextParseUtil::translateVariables()执行 OGNL。作者为了以逸待劳,选择不把它们当作直接 Sink,而是去挖它们底层的共同根源函数。
2)作者打包定义的 2 个“最底层根源 Sink”
作者通过阅读源码发现,不管是 getValue 还是 translateVariables,它们在框架最深处都会调用 OgnlUtil 类里的以下两个核心底层函数。作者将它们定义为了真正的 Sink 目标:
OgnlUtil::compileAndExecute():真正的终极 Sink。没有任何安全保护,脏数据只要作为第一个参数ma.getArgument(0)流入这里,必然触发 RCE。OgnlUtil::compileAndExecuteMethod():中途被剔除的 Sink。作者最初将它也设为 Sink,但随后重跑脚本时发现老漏洞持续误报。作者跟进源码发现,官方在最新的补丁中,在这个函数里强制塞入了一个叫checkSimpleMethod()的安全过滤器。因此,作者判定它已经安全,并在随后将其从 Sink 名单里无情剔除,以此清洗掉了误报噪音。
五、大佬的终极流向图
通过以上的定义和精简,作者最终让 CodeQL 追踪的致死链路变成了:
六、大概的流程想法
class OgnlTaintTrackingCfg extends DataFlow::Configuration {
OgnlTaintTrackingCfg() {
this = "mapping"
}
override predicate isSource(DataFlow::Node source) {
isActionProxySource(source)
}
override predicate isSink(DataFlow::Node sink) {
isOgnlSink(sink)
}
override predicate isAdditionalFlowStep(DataFlow::Node node1, DataFlow::Node node2) {
TaintTracking::localTaintStep(node1, node2) or
exists(Field f, RefType t | node1.asExpr() = f.getAnAssignedValue() and node2.asExpr() = f.getAnAccess() and
node1.asExpr().getEnclosingCallable().getDeclaringType() = t and
node2.asExpr().getEnclosingCallable().getDeclaringType() = t
)
}
}
from OgnlTaintTrackingCfg cfg, DataFlow::Node source, DataFlow::Node sink
where cfg.hasFlow(source, sink)
select source, sink1. 核心骨架:定义规则名字与首尾
class OgnlTaintTrackingCfg extends DataFlow::Configuration {
OgnlTaintTrackingCfg() {
this = "mapping"
}白话:创建了一个名为
OgnlTaintTrackingCfg的配置类。在旧版 CodeQL 中,必须给这个配置起个唯一的名字,作者在这里给它命名为"mapping"。
override predicate isSource(DataFlow::Node source) {
isActionProxySource(source)
}
override predicate isSink(DataFlow::Node sink) {
isOgnlSink(sink)
}白话:这两段很好理解,就是把你在前两步里辛苦定义好的 Source 和 Sink 强行塞进这个配置里,告诉引擎:“两头我已经堵死了,你帮我连线吧!”
2. 全篇最硬核:强行搭建“断崖桥梁”(isAdditionalFlowStep)
在默认情况下,CodeQL 的 DataFlow(数据流)非常严格,它只认普通的变量赋值,比如 a = b,数据从 b 流向 a。
但在实际的庞大 Struts 框架中,黑客的脏数据在传递时,遭遇了断崖。脏数据不是通过简单的赋值传递的,而是先存进了对象的某个成员变量(Field)里,在另一个完全不同的函数里,又通过这个成员变量被读取了出来。
默认的数据流引擎看到这种“跨函数、跨成员变量”的操作,直接就跟丢了。为了防止漏报,作者重写(override)了下面这个大杀器:
override predicate isAdditionalFlowStep(DataFlow::Node node1, DataFlow::Node node2) {核心概念:
isAdditionalFlowStep的意思是:“报告 CodeQL 探长,如果你的默认逻辑在node1和node2之间找不到数据通路,不要放弃!只要它们满足我接下来写的条件,你就必须承认node1的污点数据能直接瞬移到node2身上!”
作者给了两个瞬移的条件,用 or 连接:
条件一:开启基础的污点扩散
TaintTracking::localTaintStep(node1, node2) or白话:只要在同一个方法内,发生了字符串拼接、类型转换等基础污染行为,比如
node2 = node1 + "()",就承认数据流成立。
条件二:成员变量“隔空传送”(全篇最神的一行)
exists(Field f, RefType t | node1.asExpr() = f.getAnAssignedValue() and node2.asExpr() = f.getAnAccess() and
node1.asExpr().getEnclosingCallable().getDeclaringType() = t and
node2.asExpr().getEnclosingCallable().getDeclaringType() = t
)
}
}语法拆解:
Field f:定义一个类的成员变量f。RefType t:定义一个类t。node1.asExpr() = f.getAnAssignedValue():node1(脏数据)正准备赋值给这个成员变量f。node2.asExpr() = f.getAnAccess():node2正准备去读取(访问)这个成员变量f。getEnclosingCallable().getDeclaringType() = t:无论是给f赋值的代码,还是读取f的代码,它们都属于同一个类t。
作者告诉 CodeQL:“只要你发现黑客的脏数据(
node1)被存进了某个类(t)的成员变量(f)中;随后,在这个类(t)的另一个地方,有人又把这个成员变量(f)取出来用(node2)。哪怕这两个操作之间没有直接的赋值线,你也必须判定脏数据已经通过这个成员变量成功隔空传送了!”
3. 最后的最终疯狂:爆出 0day!
from OgnlTaintTrackingCfg cfg, DataFlow::Node source, DataFlow::Node sink
where cfg.hasFlow(source, sink)
select source, sink白话:声明刚才定义好的配置
cfg、源头source、爆炸点sink。当满足hasFlow(source, sink),即成功从源头连线到爆炸点时,将这一对完美的漏洞链路直接在屏幕上打印输出!
七、进行微调,处理误报和漏报
研究人员在基于最新 Struts 源码重跑 CodeQL 查询时,发现虽然此前 S2-032、S2-033 和 S2-037 漏洞已被修复,但因使用了新引入的 OgnlUtil::callMethod(),导致代码审计查询工具依然对这些已知漏洞触发了误报。
通过分析发现,新调用的 callMethod 实际上套用并封装了 compileAndExecuteMethod() 函数,且该底层方法中包含名为 checkSimpleMethod() 的防御性额外校验,使得旧的危险 Sink 不再生效。
通过从查询规则中剔除已被“降级”的 compileAndExecuteMethod 作为 Sink 点,成功排除了干扰结果,并锁定了一个位于 DefaultActionInvocation.java 中 getActionName() 调用处的新隐藏漏洞路径。
1. 初步运行:遇到了“噪音”
因为 CodeQL 此时只知道 Source 和 Sink 之间有通路,但它不知道官方其实已经在半路上加了防御。这就叫“噪音(Noise)/ 误报”。
- 作者的操作:作者做了一个极其老练的决定——在看全新漏洞(0day)之前,先查清楚为什么老漏洞还会报警。因为如果不把这些老漏洞的干扰排除掉,真正的 0day 就会被埋没在几百条垃圾数据里。
2. 深入官方补丁
作者去对比了新旧版本的 Java 源码。发现以前官方修漏洞只是东补西贴地做字符串过滤(Sanitizing)。但在 S2-037 漏洞之后,官方不耐烦了,决定从架构上改:
他们把以前那一盘散沙、直接触发 RCE 的危险函数 ognlUtil.getValue() 统统给去了,换成了看着更安全的全新函数 ognlUtil.callMethod()。
Java 源码:
methodResult = ognlUtil.callMethod(methodName + "()", getStack().getContext(), action);代码真相:就是这一行。官方把老代码里的
.getValue(...)替换成了.callMethod(...)。
3. 追踪底层源码:抓出假 Sink
“官方换了个函数名,就真的安全了吗?”
于是作者点着 callMethod 往里走,去看它的底层实现:
public Object callMethod(final String name, final Map<String, Object> context, final Object root) throws OgnlException {
return compileAndExecuteMethod(name, context, new OgnlTask<Object>() {
public Object execute(Object tree) throws OgnlException {
return Ognl.getValue(tree, context, root);
}
});
}代码真相:发现
callMethod进去之后,其实只是个壳子,它在第 2 行立刻就去调用了compileAndExecuteMethod(...)。这正是作者在上一步里打包定义成 Sink 的那两个底层危险函数之一!
这下破案了!Why CodeQL 还会报警?因为在 CodeQL 眼里,你的数据最终还是流进了 compileAndExecuteMethod。
但官方说这个老漏洞已经修复了呢?作者继续往最底层跟进,点开了 compileAndExecuteMethod 的源码:
private <T> Object compileAndExecuteMethod(String expression, Map<String, Object> context, ognlTask<T> task) throws OgnlException {
Object tree;
if (enableExpressionCache) {
tree = expressions.get(expression);
if (tree == null) {
tree = Ognl.parseExpression(expression);
checkSimpleMethod(tree, context); //<--- Additional check.
}代码真相:秘密就在最后一行的
checkSimpleMethod(tree, context);!
官方在真正执行 OGNL 代码之前,在这里强制插进去了一个叫 checkSimpleMethod 的安全过滤器(Sanitizer)。这个函数会去检查你传进来的表达式。如果发现里面包含黑客常用的恶意高危命令,比如去拿 Runtime 执行系统命令,就会直接在这里把程序掐断并报错。
4. 🛠️ 大佬修改 QL 脚本
既然 compileAndExecuteMethod 里面已经被官方死死加上了 checkSimpleMethod 安全检查,那么从这一刻起,这个函数已经变成了安全的函数,黑客的恶意 Payload 走到这里就会被拦截,它已经不再能导致 RCE 了。
- 大佬的操作:作者回到他的 CodeQL 脚本里,把
compileAndExecuteMethod()从危险 Sink 的名单里无情踢出。
5. 见证奇迹:过滤噪音,逼近 0day
- 修改后的效果:改完 QL 脚本并重新运行。果然,之前那些因为
getMethod()引起的旧漏洞误报警告全部消失了,界面干净了。 - 全篇最刺激的高潮来了:虽然旧漏洞的误报消失了,但屏幕上依然倔强地剩下了几条残留的警告!
- 警告指向哪里:这些残留的警告,指向了一个叫
DefaultActionInvocation.java的核心文件,并且污点入口是getActionName()。 - 最绝的:这些代码明明在官方的说法里也应该是被“修好了”的,而且在人眼看来,根本搞不懂
getActionName()里的参数是怎么七拐八绕,最终躲过官方重重防御,流向那个依然没有保护的compileAndExecute()真 Sink 的。
八、追链路与排假阳性(Path Hunting & False Positive Filtering)
作者在这里遇到了所有 CodeQL 玩家最头疼的两个问题:链路太长人眼看不清,以及由于 Java 多态特征导致的“理论存在、实际不存在”的工具误报。
1. 搬出可视化武器:嫌代码太长,要“看图走线”
- 不能人眼去翻几百个类吧?
- 作者的操作:他把 QL 查询脚本升级为了
path-problem模式。这个模式非常强大,它不仅能告诉你“有没有漏洞”,还能把数据流经过的每一个中间变量节点、每一处函数调用像导航地图一样,一步一步列出来。 - 工具选择:作者当时使用了 CodeQL 的图形化工具,现已全面集成至更现代的 VS Code CodeQL 插件的 Path Explorer 面板中,准备顺着地图“走线”。
2. 第一站:脏数据被悄悄拼接
- 数据流向第 1 步:点击导航第一步,跳转到了下面这两行代码:
String chainedTo = actionName + nameSeparator + resultCode;
ActionConfig chainedToConfig = pkg.getActionConfigs().get(chainedTo); 代码解密:黑客控制的
actionName被拼接进了chainedTo字符串里。紧接着,程序调用了pkg.getActionConfigs().get(chainedTo),把受污染的字符串当作 Map 架构的 Key(键)传进了.get()方法里。
3. 第二站:掉进“多态幻境”
- 数据流向第 2 步:作者在工具里点击下一步,CodeQL 导航直接把他带进了一个叫
ValueStackShadowMap类的get()方法里:
public Object get(Object key) {
Object value = super.get(key); // <-- key 带着毒素进来了
if ((value == null) && key instanceof String) {
value = valueStack.findValue((String) key); // <-- 致命盲区:findValue 会把 key 当 OGNL 解析!
}
return value;
}代码解密(核心危机):这一行太惊悚了!这个
get方法在拿不到值时,竟然会调用valueStack.findValue((String) key)。在 Struts 框架中,findValue也会把字符串丢给 OGNL 引擎执行!
这看起来简直是个完美的漏洞(0day)!但作者在这个地方展现出冷静和严谨:
- 人眼审计:作者仔细推敲后发现,CodeQL 之所以把线连到这里,是因为
pkg.getActionConfigs()返回的是一个标准的 Map 接口,而ValueStackShadowMap恰好实现了 Map 接口。CodeQL 在理论上认为这里可能会发生多态调用。 - 真相:但作者去查了这个类的实例化对象。这个类其实属于一个叫
jasperreports的第三方插件,在整个项目的实际运行生命周期里,pkg.getActionConfigs()返回的 Map 实例压根就不是、也绝不可能是这个ValueStackShadowMap。
4. 编写阻断盾牌(isBarrier 规则去噪)
为了不让这个“看起来很吓人、实际打不通”的假漏洞继续干扰视线,作者必须在 CodeQL 规则里人为地修筑一道堤坝,告诉引擎:
“以后只要数据流遇到这个类,直接给我斩断,不要再往下追了!”
他在 QL 脚本里重写了 isBarrier,在现代 CodeQL 语法中常叫 isSanitizer,即阻断点 / 净化点:
override predicate isBarrier(DataFlow::Node node) {
exists(Method m | (m.hasName("get") or m.hasName("containsKey")) and
m.getDeclaringType().hasName("ValueStackShadowMap") and
node.getEnclosingCallable() = m
)
}QL 语法拆解:如果发现数据流流入的方法
getEnclosingCallable(),其名字叫"get"或者"containsKey",且这两个方法属于hasName那个惹祸的误报类"ValueStackShadowMap"。那么,此节点即为阻断墙(Barrier),污点扩散到此为止!
5. 最后的精准收网
- 收尾动作:作者随后又依葫芦画瓢,给另一个导致大面积误报的无用函数
ActionMapping::toString()也加上了 Barrier 阻断盾牌。 - 最终战果:当他再次按下 Query 运行键时,由于一路上所有的假阳性、多态误报全部被他的
isBarrier给人工拦截了,原本杂乱无章的警告瞬间清空。最后,屏幕上仅仅留下了屈指可数的“个位数”黄金结果(a handful of results)。
九、真正利用漏洞
第一阶段:最后的去噪
- 作者的操作:经过上一轮过滤,CodeQL 最终在屏幕上吐出了 10 对 Source-Sink(入口到爆炸点)的红绿连线。作者用人眼扫了一眼,发现有些线连到了项目的单元测试代码(Test Case)里,黑客在线上根本访问不到。于是他又写了几个阻断(Barrier)规则把测试代码踢出去。
- 战果:垃圾数据彻底清空,余下的全能够致命的黄金 0day 线索。
第二阶段:精妙的毒液走线
作者以其中一个结果 ServletActionRedirectResult.java 为例,带我们走了一遍震惊安全圈的“毒液传递流程”:
1. 毒液注入(Source 触发)
public void execute(ActionInvocation invocation) throws Exception {
// ...
if (namespace == null) {
namespace = invocation.getProxy().getNamespace(); // <--- 毒液入口(Source)
}当程序执行重定向(Redirect)操作时,如果发现开发者在配置文件里没有写死 namespace(命名空间),框架就会自作聪明地调用 invocation.getProxy().getNamespace() 去取 URL 里的命名空间。黑客通过变形的 URL,成功把恶意 OGNL 代码注入到了 namespace 变量里!
2. 跨类传递(进入构造函数)
String tmpLocation = actionMapper.getUriFromActionMapping(new ActionMapping(actionName, namespace, method, null));紧接着,带有毒素的 namespace 变量被当作参数,传进了 ActionMapping 对象的构造函数里。随后,这个对象被丢给了 getUriFromActionMapping 方法,这个方法用它拼接出了一个新的跳转 URL 字符串,存进了 tmpLocation 变量中。
3. 隐蔽潜伏(存入父类成员变量)
setLocation(tmpLocation);程序调用了 setLocation()。作者点进去看这个函数,发现它极度隐蔽:它把带毒的 tmpLocation 字符串,赋值给了父类 StrutsResultSupport 的成员变量 location。数据在这里“潜伏”了下来。
4. 跨域爆发(多态调用唤醒毒素)
super.execute(invocation);代码之后调用了 super.execute(),顺着面向对象的继承链,数据流跨到了另一个执行类 ServletActionResult 的 execute 方法中:
public void execute(ActionInvocation invocation) throws Exception {
lastFinalLocation = conditionalParse(location, invocation); // <--- 唤醒潜伏的毒素!
doExecute(lastFinalLocation, invocation);
}看!这个类的 execute 方法一启动,立刻把刚刚潜伏在 location 里的脏数据取出来,传给了 conditionalParse() 函数!
5. 轰然爆炸(Sink 触发 RCE)
protected String conditionalParse(String param, ActionInvocation invocation) {
if (parse && param != null && invocation != null) {
return TextParseUtil.translateVariables(param, ...); // <--- 终极死穴(Sink)!!
}
}当数据进入 conditionalParse 后,它终于露出了獠牙。它毫不设防地将参数直接传给了底层核心函数 TextParseUtil.translateVariables()。
这个函数在 Struts 里的技术本质就是:直接把传进来的参数放到 OGNL 引擎里强行当作代码执行!
道爷我成了!哈哈哈
第三阶段:实战绝杀(弹出计算器)
- 作者的操作:为了验证 CodeQL 的正确性,作者在本地测试环境的配置文件中去除了一个
namespace参数,人为模拟了这种疏漏场景。然后,他向本地服务器发送了一个包含了恶意 OGNL 表达式的精心构造的 HTTP 请求,成功弹窗。 - 扩大战果:不仅如此,利用这套 CodeQL 规则,作者一口气在
ActionChainResult、PostbackResult和ServletUrlRenderer这另外三个核心组件里,用一模一样的原理同时打通了另外 3 个远程代码执行漏洞!一箭四雕!
十、这篇文章的想法值得学习
- 漏洞不为孤立的:挖 0day 最快的方法,就像作者一样,吃透一个历史漏洞的 Source 和 Sink,然后用 CodeQL 扩散到全项目去挖它的“变体”。
- 架构原理核心:黑客想要绕过补丁,靠的不是盲目试探,而像作者一样,点进
conditionalParse和translateVariables源码里,看清它的每一级多态和对象赋值。
参考与致谢
本文技术灵感来源于 GitHub Security Lab 知名安全研究员 Man Yue Mo 的官方技术博客分享。
- 原文研究方向:使用 CodeQL 进行 0day 自动化挖掘与安全审计
- 国外大佬原文:GitHub Security Lab Research
版权与声明
本文为笔者的读后感与技术解读。在原文基础上,笔者结合了自身的代码审计经验,对 CodeQL 的核心逻辑、语法规则(QL 语句)以及 0day 挖掘思路进行了更细致的拆解与本地复现,旨在帮助道友们更轻松地理解大佬的整套自动化挖洞框架。
文中部分观点带有笔者个人的主观理解,如有偏差,欢迎各位师傅在评论区拍砖指正!