这些年打CTF,有个很深的感受:真正卡住人的往往不是漏洞本身,而是你能不能把一条攻击链完整串起来。以ctfshow的S2系列为例,它把Struts2框架从OGNL表达式注入到环境变量泄露这条链路拆得非常细,不是让你单纯背一个Payload,而是逼你去理解表达式引擎的执行机制、请求参数在框架里的流转过程,以及最终怎么从一次注入走到敏感信息落地。这篇就来完整拆解这个系列的核心思路和实战细节,按我们平时打题的节奏,从原理到实操,再到坑点复盘,一步不落。
1. 认识S2系列与OGNL注入的核心逻辑
1.1 Struts2框架在干什么
Struts2是个经典MVC框架,核心工作就是接收HTTP请求、解析参数、调用Action处理业务、再把结果渲染回响应。早些年Java Web项目里它几乎是标配,很多老系统到现在还在跑,这也是它漏洞价值如此持久的根本原因。你可以把Struts2的工作方式理解为:前端传一个字符串参数,框架负责把它“翻译”成Java对象的属性,或者转成Action方法的调用依据。问题就出在这个“翻译”过程——它为了灵活,引入了OGNL表达式支持。
如果对这个概念还比较陌生,可以简单类比一下:OGNL就像一把万能钥匙,框架为了让你能写入复杂对象结构,把参数值的一部分当作表达式来解析执行。这把钥匙本身没问题,但当用户输入的内容直接被拼进表达式解析区,就相当于把万能钥匙交给了陌生人,后果可想而知。S2系列漏洞的精髓,就是围绕这个“参数值被当成表达式解析”的过程做文章。
1.2 OGNL到底是啥
OGNL,全称Object-Graph Navigation Language,对象图导航语言。它远不止是EL表达式那种简单取值工具,支持的功能包括调用任意Java类的静态方法、访问对象属性、构建复杂表达式链、甚至修改内存中的对象状态。这里举个例子说明正常用法与危险用法的区别。
正常场景下,框架需要根据请求参数给Action对象的name属性赋值,表达式可能是这样的:
user.name = zhangsan框架拿到值zhangsan,赋给user对象里的name字段,流程没问题。但OGNL表达式引擎的能力远不止赋值,它还能这样:
@java.lang.Runtime@getRuntime().exec("id")这行表达式直接调用了Java的Runtime类静态方法,拿到运行时对象并执行系统命令。从赋值到命令执行,差的只是一个“表达式如何被解析”的开关。在已经修补的版本里,框架限制了静态方法调用和类实例化能力;在存在漏洞的版本里,这些限制可以被绕过,而这就是S2系列攻击面的一个典型切面。
1.3 环境变量在整条链里的位置
很多人打S2系列的题目,目标直接盯住RCE弹出计算器就满足了,但ctfshow这个系列的后期题目明显在引导一个更完整的思路:注入成功之后,怎么高效地拿到想要的信息。环境变量就是其中一个非常隐蔽又重要的目标。在真实环境和CTF题目中,有些敏感配置不会出现在文件系统里,而是以环境变量形式注入到进程里,比如数据库密码、密钥串、甚至FLAG本身。通过OGNL注入读取环境变量的方式,是Web层攻击到数据落地的关键一环,也是这个系列设计里含金量比较高的部分。
2. 实战前置准备:环境搭建与信息收集
2.1 本地靶场怎么搭
ctfshow的S2系列有在线环境可以直接刷,但如果想复盘练手或者研究细节,我更推荐本地搭一套。用Docker拉一个存在对应Struts2版本漏洞的Tomcat镜像,然后部署一个最简单的War包,里面只需要一个空的Action类就行。实际操作中我建议把日志级别调成DEBUG,方便观察框架底层对OGNL表达式的解析过程,这对理解漏洞触发原理帮助很大。
这里补充一段我踩过的坑:本地搭建的时候,一定注意JDK版本和Struts2版本的兼容性。有些老版本Struts2在高版本JDK上会因为反射权限问题导致Payload静默失败,排查半天以为是Payload写错了,其实是环境不兼容。稳妥的组合是JDK8加上Struts2 2.3.x系列的老版本,这个组合下有历史漏洞的版本都能顺利跑起来。
2.2 信息收集阶段要关注什么
拿到一个S2题目目标,第一步不是急着上Payload,而是先确认版本指纹和参数入口。响应头里的X-Worker-Node、Content-Type头特殊解析、URL后缀是.action还是.do,这些细节都能提供判断依据。实际打题时我有一套固定的检测顺序:
- 访问任意一个不存在的Action路径,观察报错页面是否泄露框架版本信息。
- 在URL后缀前插入干扰字符,看框架是否做了后缀容错处理。
- 构造一个简单的OGNL探测表达式,比如用数学运算作为参数值,如果响应里出现计算结果,说明表达式被执行了。
- 结合官方CVE公告和补丁时间线,缩小版本范围。
这套流程走完之后,基本就能确定这个题目考的是S2系列的哪个变异点。在ctfshow的S2系列里,题目从基础到进阶,考察点层层递进——有的考原始前缀绕过,有的考method:重绑定,有的考静态方法调用的二次绕过,信息收集阶段判断错了方向,后面就会走弯路。
3. 核心实战:从OGNL注入到环境变量泄露
3.1 第一关:判断表达式执行点
这是整个攻击链的第一步,也是最需要耐心的一步。Struts2框架不是所有请求参数都会被OGNL解析的,它有一套参数名白名单和黑名单机制。早期版本的黑名单形同虚设,通过一些特殊前缀可以绕过拦截,把参数强制交给OGNL表达式的值栈处理。
以S2-001为例,它的触发点是通过URL传入的参数,框架在解析过程中会二次求值。简单说:框架先把参数值当成字符串处理一遍,然后把处理结果再丢回OGNL引擎解析一遍。这个“二次解析”就是漏洞的根因。在实际探测时,这样就能验证:
输入: %{3*4} 观察响应是否包含12如果响应里出现了12,说明表达式执行成功。这个探测方法本身不会对系统造成破坏,但能非常清楚地确认执行点。ctfshow的前几道S2题基本就是考察这个动作,很多同学一上来就扔复杂Payload,响应报错或者被拦截,反而忽略了最基础的执行点确认步骤。
另外值得留意的是,有些题目会在WAF层做过滤。这时候判断执行点的方法要跟着变形,比如用表达式内字符串拼接来混淆关键字:
%{(new demo.ProcessBuilder("ca"+"t /flag")).start()}这个过程很像做题时的“翻译题”:把Payload从直白表达改成编码拼接,但核心逻辑保持不变。我们在后续步骤里会反复用到这个思路。
3.2 第二关:精心构造核心Payload
确认执行点之后,就要构造能真正完成目标的Payload。以环境变量读取为例,我们走的路径是:OGNL表达式里调用Java的System类方法,拿到环境变量值,然后把它放到能被响应输出的位置。
完整思路可以拆成三步:
- 突破安全限制。框架默认禁用了静态方法访问,所以第一步是尝试恢复允许。不同版本有不同绕过方式,比如通过修改
_memberAccess对象的配置实现,或者利用OgnlUtil的内部机制间接调用。 - 执行信息获取表达式。调用
System.getenv()获取指定环境变量。注意,这里关注的是通过注入代码执行一条命令,而是直接获取目标机器上已经存在的环境变量信息。 - 把结果输出到响应。一般通过
#request、#response或者直接让表达式结果作为响应体的一部分。
这里用一个典型的表达式来展示读取环境变量的思路:
%{ #memberAccess['allowStaticMethodAccess'] = true, #env = @java.lang.System@getenv("FLAG"), #response.getWriter().println(#env) }这个Payload的每一段都可以单拎出来讲。第一段是把静态方法访问开关打开,第二段是通过反射调用System类读取环境变量,第三段是拿到HttpServletResponse对象把内容写回流。一条链路的终点是输出,没有第三步,前面读到的内容你看不见。
换一个更底层的思路,不去依赖#response对象,而是让表达式自身作为一个可输出的值结构存在,然后嵌入到页面模板的某个位置。这个方法在某些环境下更稳定,因为有些网络层或者过滤器会拦截对response对象的直接操作,但表达式的结果值却可以正常渲染在页面上。实际打题的经验是:多条路都试,不要只抱一条。
3.3 第三关:信息落地
拿到环境变量只是半条路,信息怎么保存、怎么确认拿到的是正确内容,这步也很有讲究。在CTF场景里,环境变量里存的往往直接就是FLAG,表达式执行成功后,页面响应里就能看到ctfshow{...}格式的字符串。但在模拟真实环境的题目里,环境变量可能是编码过的、或者只是某个配置的片段,需要再取拼接。
如果你拿到的是Base64编码的内容,就用解码工具还原;如果是Hex编码,就转成ASCII字符。这里我建议在本地搭一个模拟环境提前练一遍:在启动容器时通过-e FLAG=ctfshow{test_flag}注入一个测试环境变量,然后走完整条攻击链,确认输出正常,再上真实题目环境。养成这种先本地验证再正式操作的习惯,能省下很多踩坑的时间。
3.4 绕过技巧与常见变形
S2系列从早期到后期,题目考察的核心其实一直是同一件事:怎么在框架限制越来越严格的情况下,仍然把OGNL表达式执行成功。整理一下历代变形的思路:
- 静态方法调用被禁:改用对象实例方法调用,先通过某个已有对象拿到Class,再反射调用目标方法。
- 关键字被过滤:用字符串拼接、Unicode编码、八进制转义来还原Payload关键字。
- 参数名被黑名单拦截:利用
method:、#等特殊前缀改变参数绑定路径。 - OGNL版本升级:利用表达式中的上下文对象(比如
#context)获取框架内部已经存在的类引用,绕开实例化限制。
这些绕过技巧不是凭空想出来的,每一步都来自框架源码里面对参数的处理逻辑。比如某个版本的黑名单里禁止了getRuntime字符串,但框架自己的依赖里又有包含这个字符串的类,通过间接引用能绕过。理解底层逻辑之后再对照题目给的限制条件,基本就能推导出应该用什么绕过姿势。
在ctfshow的S2系列里,越往后题目给的“限制条件”越明显,有时会直接告诉你某个关键字被过滤了,这时可以借助OGNL反射调用来间接访问对象属性,对象链从Acion到Class再到System属性,层层跳转,最终达到读取环境变量的目的。这类思维方式,也正是S2系列比单纯复现CVE更有价值的地方。
4. 常见问题与排查技巧实录
4.1 为什么Payload在本地能打在题目上不行
这是最常遇到的问题。排除掉环境差异之后,最大的可能就在于参数的传递方式——有些题目环境是GET传参,有些是POST的form表单,还有一些是JSON格式。Struts2对不同Content-Type的处理路径有差异,导致OGNL表达式的解析点不同。比如application/x-www-form-urlencoded和multipart/form-data在框架里的处理链是两套代码,前者走参数绑定,后者走文件上传解析,能利用的Payload形式也就不一样。
沟通这个差异的时候,可以这样理解:框架对不同格式的请求是先“分类”再“处理”的,Payload放进不同类别的包裹里,触达表达式引擎的方式不同。实战中我一般按照题目提供的接口格式为准,先模拟正常业务请求的结构,再在对应参数位置注入表达式,成功率高很多。
4.2 执行成功但输出不可见
这一般有两个原因,一个是表达式执行的结果没有走到输出位置,另一个是输出内容被过滤。如果你使用的是把表达式结果作为请求参数值回显的方式,需要确认这个参数值最终映射到了页面的哪个渲染区域。如果题目是纯接口形式,返回值是JSON或空页面,那就需要用出网或者写文件的方式来确认表达式执行结果。
在CTF环境中,最怕的就是“盲打”——你根本不知道命令执行成功没有。所以选择信息获取方式时,优先选有回显的路径演化为“有回显”的第一选择。比如通过修改响应头、输出当前时间戳等方式来验证执行结果。环境变量读取这类操作,如果目标系统没有直接回显,可以通过将环境变量内容写入一个Web目录,然后请求该文件来读取。
4.3 过滤规则绕过时的细节处理
很多S2题目在代码里做了关键字过滤,把getenv、System这些敏感词拉黑。绕过时的两个关键细节能决定成败:
第一,利用OGNL的反射调用能力去拼方法名。比如用get加env加()这样的动态拼接。OGNL支持字符串拼接后反射调用方法,所以只要过滤不彻底,拼接就能绕过。
第二,利用同类调用的间接路径。有些环境变量的读取不一定要直接调用System.getenv(),可以通过ProcessBuilder的环境变量视图间接获取。路径虽然长了,但每个节点的关键字可能不再被过滤。
还有一种情况比较特殊,就是输出阶段的过滤。有些环境变量值里面包含特殊字符,输出时会被WAF或框架转义影响显示。如果题目返回内容的字符长度有限制,就改为把取出的值先编码再输出,比如转成十六进制字符串,我们看到的是乱码但只要解码就是原始内容。
4.4 连接被断开或响应超时
造成这个现象的原因通常是表达式里执行的命令阻塞了当前线程。比如调用了某些网络操作或管道等待,导致请求挂死。看S2题的时候我一直告诫自己:不是所有能执行的命令都适合在请求线程里跑,凡是涉及到长时间等待的,就要考虑另开线程或者用快速结束的替代命令。
另外,如果目标环境在容器里跑了很久,内存可能已经不太健康,执行某些反射操作会触发内存异常。这种环境现状我们改不了,能做的就是减少Payload的体积、少创建中间对象,尽量用最简单的表达方式完成同等的操作。这里有个小技巧:可以把表达式拆成多次执行,每次只做一小步,降低单次执行的内存压力。
5. 实战速查:S2系列问题的分类应对
为了方便复盘,我把S2系列里经常出现的考察点做成了一张对照表。这张表不是所谓“标准答案”,而是自己在刷题和实战过程中反复确认过的分类逻辑,照着这个思路去分析题目,能比较快定位到核心考点。
| 题目特征 | 核心考点 | 应对思路 |
|---|---|---|
| 参数值显示数学运算结果 | 表达式执行点确认 | 先用基础表达式验证解析点,再上传复杂功能 |
| 报错页面泄露版本号 | 版本指纹分析 | 对照官方CVE公告,锁定可利用的Payload类型 |
| 过滤OGNL关键类名 | 黑名单绕过 | 使用字符串拼接、反射调用、间接对象路径 |
| 无回显输出 | 盲注或盲打 | 用时间延迟、DNS外带或写文件方式确认执行结果 |
| 环境变量即FLAG | 系统信息读取 | 调用System.getenv()并回显到响应 |
| 参数在请求头中解析 | 非常规参数入口 | 分析Content-Type处理链,构造对应格式的请求 |
6. 防御视角:从攻击链反推加固方案
6.1 根因是表达式注入
把整条攻击链放到防御视角来看,根因始终是同一个:框架执行了用户可控的表达式。环境变量泄露只是注入成功后的资金链中下游环节,上游没守住,后面都是白搭。这就好比门锁不牢,小偷进来偷走了保险柜里的存折——你盯着存折放哪里没意义,换把好锁才是正事。
修复S2系列漏洞的第一步永远是升级框架版本到安全线以上。这个动作看着简单,很多老系统却迟迟做不了,原因无非是兼容性风险。如果实在升不了,退而求其次的临时方案是在统一入口处过滤OGNL特殊字符,然后校验参数名黑名单。但必须清醒认识到,过滤是“创可贴”,不是根治手段。
6.2 运行时防御值得关注
针对环境变量泄露这个后段,可以在运行层面加一层防护:禁止Java进程读取特定前缀的环境变量;对Web应用进程做最小权限运行,让进程本身就访问不到宿主环境的敏感变量;增加运行时监测,当进程出现getenv相关的异常调用行为时告警记录。
这些手段在CTF场景里用不上,但在真实业务的防御思路里,组合使用效果更好。纵深防御的核心原则是不要指望某一层把所有攻击挡住,而是每层都能兜底一部分,让攻击者在整个攻击链上每一步都付出代价。
6.3 白盒代码审计视角的建议
从代码审计角度看,Struts2漏洞本身是框架层面的问题,但也提醒我们关注同类风险:凡是框架从用户输入中解析动态表达式的机制,都值得重点检查。不只是Java框架,其他语言的模板引擎、表达式引擎也一样——用户输入一旦进入解析路径,就存在注入风险。审计时手里攥着一条纪律:参数值绝不应该被当作代码执行,凡是违反了这条纪律的,都需要按高危处理。
7. 这个系列的题为什么值得每个人打一遍
把ctfshow S2系列完整刷下来,收获的不只是几个Payload模板,而是对整条攻击链的思维建模能力。从OGNL表达式注入的触发条件,到环境变量的读取与输出,再到绕过限制的灵活变形,每一关都在训练同一种本能:在限制条件下,利用现有框架机制找到信息流动的出口。
在CTF里这是拿分的手段,在真正的安全工作中,这种能力会让你在分析未知漏洞时走得更远。**我自己的习惯是,每打完一套系列题,回过来把每道题的Payload逐个拆开,用注释写上每一段在干什么、为什么这么写,然后存进自己的笔记里。**时间久了,这套笔记就成了你最值钱的参考资料。
S2系列的价值还在于它提醒你:不要只盯着漏洞库里的CVE号。CVE只是一个编号,背后对应的是一段历史,是框架设计者在当时环境下做出的权衡和取舍。理解了这些,你才真正拥有了一个从业者对漏洞的判断力和敬畏心。
如果手边还有没用过的环境,建议现在就去把S2系列的题目打开。记住,从执行点确认开始,每走一步,停下来想一想这一部分在整条链路中的意义。等到你能不看笔记,完整复现从注入到环境变量泄露全流程的时候,这一关就算真正通了。