CTFshow S2系列OGNL注入与环境变量泄露实战解析
1. 项目概述:一次对ctfshow S2系列漏洞的深度复盘
最近在复盘ctfshow的S2系列题目,特别是其中涉及OGNL注入和环境变量泄露的几道经典题,感触颇深。这不仅仅是几道CTF题,更像是一个精心设计的、从代码审计到漏洞利用的完整实战沙箱。很多刚接触安全的朋友,可能对“OGNL注入”这个名词感到陌生,或者觉得“环境变量泄露”听起来平平无奇,但当你把它们放在一个具体的、层层递进的Web应用场景里,你会发现攻击链的构建远比想象中精妙。我花了些时间,把整个S2系列中从Web83到Web165等题目的核心思路、踩过的坑以及最终的利用技巧重新梳理了一遍,形成这篇实战解析。无论你是正在刷题的新手,还是想深入理解Struts2框架漏洞原理的进阶者,相信这篇从“黑盒测试”到“白盒审计”的完整推演,都能给你带来一些直接的启发。
2. 核心漏洞原理与攻击面剖析
2.1 OGNL注入:Struts2框架的阿喀琉斯之踵
OGNL(Object-Graph Navigation Language)是Struts2框架用于在视图层和控制器层之间绑定数据、执行表达式的一种强大语言。它的强大,恰恰成了它最大的安全隐患。简单来说,当用户输入的数据未经充分过滤,直接被框架当作OGNL表达式解析执行时,就发生了OGNL注入。这不同于传统的SQL注入,它直接发生在应用逻辑层,可以执行任意Java代码,危害等级极高。
在ctfshow的S2系列题目中,OGNL注入的触发点往往非常隐蔽。它可能藏在一个普通的参数传递里,比如某个id、name参数;也可能通过特定的Struts2标签属性触发。攻击者通过构造特殊的Payload,可以达成几个目的:一是执行系统命令,从而获取服务器权限;二是读写服务器上的文件;三是调用危险静态方法,破坏应用逻辑。理解OGNL的语法是关键,比如#号用于访问值栈(ValueStack)和ActionContext中的对象,@用于访问静态方法和类,{}用于创建集合。一个典型的命令执行Payload可能长这样:%{(#a=@java.lang.Runtime@getRuntime()).(#a.exec('whoami'))},它利用了OGNL表达式调用Runtime.getRuntime().exec()方法。
注意:现代Struts2版本以及部署了相应安全补丁的环境会对这类简单Payload进行拦截。实战和CTF中,往往需要结合上下文进行变形和绕过,例如使用Unicode编码、利用OGNL表达式本身的特性(如
new关键字创建对象、#号引用已存在对象)来构造更隐蔽的利用链。
2.2 环境变量泄露:被忽视的信息金矿
环境变量泄露听起来没有远程代码执行(RCE)那么“刺激”,但在渗透测试中,它的价值常常被低估。服务器环境变量中可能包含大量敏感信息:数据库连接字符串(如JDBC_URL)、第三方服务的API密钥(API_KEY)、加密密钥(SECRET_KEY)、服务器内部路径、甚至是其他系统的访问凭证。在CTF场景中,flag常常就被直接设置在某个环境变量里,比如FLAG、CTFSHOW_FLAG等。
泄露的途径多种多样。最常见的是通过Web应用程序的错误信息反馈,例如开启debug模式的应用在报错时可能会将部分环境信息打印到页面上。另一种是通过某些特定的接口或功能点,比如/env、/actuator/env(Spring Boot)、phpinfo()(PHP)等。在S2系列的题目设计中,环境变量泄露有时是独立考点,有时则是OGNL注入达成RCE后,用于获取最终flag的关键一步。你需要知道,在Java中,可以通过System.getenv()来获取所有环境变量,而在OGNL表达式里,可以构造如@java.lang.System@getenv()这样的调用来实现。
将OGNL注入和环境变量泄露结合起来,就构成了一条清晰的攻击链:首先通过OGNL注入获取一个命令执行的能力,然后利用这个能力去读取进程的环境变量,从而找到隐藏的flag。这条链考验的是你对漏洞利用的完整性和对目标系统信息收集的全面性。
3. 靶场实战:ctfshow S2系列题目精解
3.1 Web83:OGNL注入的“破门”之旅
Web83通常被设计为S2系列的入门题,它的目的是让你理解最基础的OGNL注入点在哪里以及如何利用。题目界面可能很简单,只有一个输入框或几个参数。我们的第一步永远是信息收集:尝试输入一些特殊字符如$、#、%、{、},观察返回结果是否有变化或报错信息。Struts2的某些版本在解析错误时,会在页面上留下包含“ognl”或“Expression”等关键词的痕迹。
假设我们发现某个参数(比如username)存在注入点。一个经典的测试Payload是:${1+1}。如果返回的页面中,这个表达式被计算成了2,那么基本可以确定存在OGNL表达式注入。接下来,就是尝试执行命令。但直接执行Runtime.getRuntime().exec()可能会被WAF或安全配置拦截。这里有一个小技巧:利用OGNL创建ProcessBuilder对象。Payload可以构造为:%{(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'whoami'})).(#p.start())}。这个Payload通过new关键字直接实例化对象,有时能绕过基于字符串特征的黑名单。
如果执行成功,我们会在服务器上执行whoami命令,但输出可能不会直接回显到页面上。这时就需要考虑命令回显的技巧。常见的方法有:将命令结果写入Web目录下的一个文件,然后通过浏览器访问该文件;或者利用curl或wget将结果发送到自己的服务器(在CTF中通常不允许外连,所以文件写入更常用)。例如:%{(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'sh','-c','whoami > /tmp/result.txt'})).(#p.start())},执行后访问/tmp/result.txt(或Web可访问路径)查看结果。
3.2 Web165与代码审计:从黑盒到白盒的思维转换
从Web165左右的题目开始,单纯的Payload打过去可能就不灵了。题目可能增加了过滤规则,或者漏洞点变得更加隐蔽。这时,就需要结合“代码审计”的思维。虽然CTF中不一定提供完整源码,但我们可以通过报错信息、已知的Struts2漏洞编号(如S2-045, S2-052等)以及参数名推测后台逻辑。
例如,题目可能过滤了Runtime、ProcessBuilder、exec等关键词。我们的绕过思路可以有以下几种:
- 字符串拼接:利用OGNL的字符串连接符。
Runtime可以写成Runt+ime,exec可以写成exe+c。在OGNL中,字符串可以用单引号连接,如'Runt' + 'ime'。 - 编码绕过:使用URL编码、十六进制编码或Unicode编码。例如,
exec的Unicode形式是\u0065\u0078\u0065\u0063。OGNL表达式在某些上下文中可以解析这些编码。 - 利用反射:这是更高级的技巧。如果
Runtime类被禁,我们可以通过反射来获取它。Java反射的起点是Class.forName()。一个可能的Payload是:%{(#c=#this.getClass().forName('java.lang.Runtime')).(#m=#c.getMethod('getRuntime')).(#r=#m.invoke(null)).(#r.exec('calc'))}。这个Payload完全避免了直接出现危险类名和方法名,通过字符串形式的类名和方法名动态调用,绕过了静态关键词过滤。 - 寻找替代类:除了
Runtime,ProcessBuilder、ScriptEngineManager(执行JS等脚本)等都可以用来执行命令,可以尝试不同的类。
在实战审计中,你需要关注Struts2的配置文件struts.xml,看是否有定义全局的拦截器(Interceptor)或对某些package启用了严格的安全校验。同时,关注Action类中是如何接收参数的。如果使用了模型驱动(ModelDriven)或直接使用Action的属性,并且没有对参数进行类型转换和校验,风险就很高。
3.3 环境变量泄露的挖掘与利用
在成功获取命令执行能力后,下一步就是读取环境变量。在Linux系统下,直接在命令中执行env即可。所以我们的OGNL Payload可以变为:%{(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'sh','-c','env > /var/www/html/env.txt'})).(#p.start())}。然后访问http://靶机地址/env.txt,就能看到所有环境变量。
这里有几个实操心得:
- 路径问题:你写入的文件必须位于Web服务器有权限读写且你能访问的目录。
/tmp目录通常可写,但Web服务器不一定能访问。最好写入网站的根目录或已知的上传目录。如果不知道绝对路径,可以尝试一些常见路径,或者先执行pwd命令查看当前工作目录。 - 结果过滤:有时题目不会让你直接看到完整的
env输出,可能做了过滤或截断。这时可以尝试用grep命令只筛选包含特定关键词(如FLAG、CTF)的行:env | grep -i flag。 - Java直接读取:更优雅的方式是直接在OGNL表达式中调用
System.getenv()。例如:%{@java.lang.System@getenv('FLAG')}或%{@java.lang.System@getenv()}。后者会返回一个Map对象,如果页面能直接显示这个对象的内容,就能看到所有变量。但很多时候,页面只会显示对象的引用地址,这时就需要想办法将其内容输出,比如遍历Map的entrySet并拼接成字符串。
在S2系列的一些题目中,flag可能不在默认的环境变量里,而是被程序在启动时动态设置。这时,你需要查看的是当前Java进程的所有环境变量,System.getenv()正是用于此目的。这也解释了为什么直接找文件找不到flag,因为它只存在于内存的环境变量中。
4. 漏洞利用链的构建与高级技巧
4.1 从注入点到RCE的完整链条
构建一个可靠的利用链,不仅仅是拼凑一个Payload那么简单。它需要对目标系统进行探测,对过滤规则进行试探,并准备好备用方案。
第一步:确认注入点与上下文。你是通过GET参数、POST参数、Cookie还是HTTP头注入的?参数的值是被直接放入OGNL表达式,还是先经过了某些处理?通过输入${#context}或${#request}等OGNL内置对象,可以探测当前表达式执行的上下文,了解哪些对象是可用的。例如,${#_memberAccess[“allowStaticMethodAccess”]}可以用来检查是否允许静态方法访问(老版本Struts2的漏洞常涉及修改此属性)。
第二步:绕过基础过滤。如果简单的Payload失败,依次尝试上述的字符串拼接、编码、反射等方法。可以写一个简单的脚本,用Burp Suite的Intruder模块,以集群轰炸的方式测试各种变形Payload,观察响应差异。
第三步:实现命令执行与回显。确定可用的命令执行方式(Runtime/ProcessBuilder/ScriptEngine)和回显方式(写文件、报错信息回显、HTTP响应输出)。对于写文件回显,要特别注意命令中的空格和特殊字符,最好用字符串数组的方式传递参数,并用Base64编码命令来避免问题。例如,想执行cat /etc/passwd,可以将其Base64编码后传递:echo Y2F0IC9ldGMvcGFzc3dkCg== | base64 -d | sh。
第四步:定位并获取flag。执行env命令或调用System.getenv()。如果返回内容庞大,用grep进行筛选。不仅要找FLAG,也要留意CTFSHOW、FLAG_、SECRET等可能的关键词变体。
4.2 针对特定Struts2版本的利用差异
Struts2的不同版本,其OGNL沙箱的安全机制和默认配置有所不同,利用方式也需调整。
- Struts 2.0.x - 2.3.x:这些早期版本安全机制相对较弱。著名的S2-016、S2-045等漏洞影响广泛。利用时可能无需特别开启静态方法访问,直接调用
Runtime即可。 - Struts 2.5.x+:版本引入了更强的OGNL沙箱限制,默认禁止了静态方法调用和很多危险类的访问。这时,利用往往需要先找到一个方法去关闭或绕过沙箱限制。例如,在某些漏洞中(如S2-052的REST插件漏洞),可以通过修改
SecurityMemberAccess类的字段值来关闭限制。Payload会复杂很多,通常涉及多层反射和属性修改。 - Struts 2.5.22+:安全机制进一步加强。单纯的OGNL注入可能很难直接达到RCE,可能需要结合其他漏洞,如反序列化漏洞(S2-057的命名空间遍历可能导致RCE,但那是另一类问题)。
在ctfshow题目中,为了降低难度,后台通常会使用一个存在已知漏洞的Struts2版本,并且可能故意放松了一些安全配置。但了解这些版本差异,有助于你在真实环境中判断漏洞的利用价值和方式。
4.3 防御视角下的思考与加固建议
作为攻击者,我们研究漏洞利用;作为开发者或安全人员,我们更应思考如何防御。针对OGNL注入,根本的防御措施在于对用户输入进行严格的控制和过滤。
- 升级与补丁:及时将Struts2框架升级到最新稳定版,并关注安全公告,及时打上补丁。这是最有效的方法。
- 输入验证:对所有用户输入进行严格的类型检查和长度限制。使用白名单机制,只允许预期的字符集。
- 禁用动态方法调用:在
struts.xml中配置<constant name="struts.enable.DynamicMethodInvocation" value="false" />。 - 限制OGNL表达式能力:在
struts.xml中配置OGNL的沙箱规则,例如设置<constant name="struts.ognl.allowStaticMethodAccess" value="false" />。在Struts 2.5+中,可以配置struts.ognl.excludedClasses和struts.ognl.excludedPackageNames来禁止危险类和包的访问。 - 最小权限原则:运行Struts2应用的Web服务器(如Tomcat)进程,应使用非root、低权限的用户身份运行,这样即使被RCE,攻击者能造成的破坏也有限。
- 错误信息处理:自定义错误页面,避免将详细的堆栈信息、框架信息泄露给用户。
对于环境变量泄露的防御:
- 敏感信息隔离:不要将数据库密码、API密钥等敏感信息直接硬编码在环境变量中。使用专业的密钥管理服务(如Vault)或至少在部署时从安全的配置中心读取。
- 关闭调试信息:在生产环境中,确保所有框架(Struts2, Spring Boot等)的调试模式和Actuator端点(如
/env,/heapdump)被禁用。 - 访问控制:如果某些诊断接口必须开启,务必配置严格的网络访问控制(如只允许内网IP)和身份认证。
5. 实战中常见问题与排查实录
在反复调试S2系列题目的过程中,我记录下了一些最常见的“坑点”和解决方法,这往往比漏洞原理本身更有实战价值。
问题1:Payload执行了,但没回显,也不知道命令是否成功。这是最让人头疼的情况。排查思路如下:
- 检查命令语法:在OGNL中构造的命令字符串,要特别注意转义。尤其是在Linux下执行带管道
|或重定向>的命令时,最好将整个命令用Base64编码后解码执行,或者用字符串数组new String[]{“sh”, “-c”, “your_complex_command”}的方式。 - 检查文件路径和权限:命令执行成功了,但文件写到了没有Web权限的目录,或者当前进程用户没有写权限。尝试写入
/tmp目录,或者先用whoami和pwd命令确认当前用户和工作目录。 - 尝试DNSLog或HTTP请求外带:如果题目环境允许(通常CTF不允许),可以尝试使用
curl或wget将命令结果作为参数请求到你的服务器,或者使用DNSLog技术,通过ping一个包含结果的域名来外带数据。在CTF中,这常用于盲注场景,但S2系列通常设计为可回显或可写文件。
问题2:Payload被WAF或应用防火墙拦截了。
- 拆分与混淆:将长Payload拆分成多个参数传递,或者利用OGNL的赋值特性,分步骤执行。例如,先
#a=‘cat’,再#b=‘/etc/passwd’,最后#p=new ProcessBuilder({#a, #b})。 - 使用冷门类或方法:除了
Runtime和ProcessBuilder,可以尝试Unsafe类、Thread类(通过start执行Runnable)等,或者通过javax.script.ScriptEngineManager执行JavaScript代码来调用Java。 - 静态方法绕过:如果静态方法访问被禁,尝试通过获取当前类的ClassLoader,然后
loadClass的方式动态加载类,再通过反射调用方法。
问题3:找到了环境变量,但里面没有flag。
- 扩大搜索范围:执行
env查看全部。也许flag的名字不是FLAG,而是CTF_FLAG、FLAG_IN_ENV,或者藏在某个很长的配置字符串里。用env | grep -E ‘CTF|FLAG|KEY|SECRET’进行模糊搜索。 - 检查进程参数:有时flag可能通过Java的
-D参数设置为系统属性(System.getProperty()),而非环境变量。可以尝试执行ps aux | grep java查看完整的Java启动命令,或者用OGNL调用System.getProperties()。 - 检查文件系统:可能flag仍然在文件里,只是路径比较偏。在拥有命令执行权限后,可以进行基本的文件系统遍历,例如
find / -name ‘*flag*’ 2>/dev/null或find / -type f -exec grep -l ‘ctfshow’ {} \\; 2>/dev/null。
问题4:题目提示存在漏洞,但所有已知Payload都无效。
- 回归代码审计思维:静下心来,根据题目名称、参数名,推测后台可能使用了哪个Struts2插件或哪个特定的Action。例如,涉及文件上传的可能是
FileUploadInterceptor,涉及JSON的可能是JSON Plugin。不同的组件,其参数解析和OGNL表达式执行的点可能不同。 - 利用报错信息:故意构造一个错误的OGNL表达式,让服务器返回详细的错误信息。从错误信息中,你可能会看到使用的Struts2版本号、触发的类名、甚至部分源码堆栈,这能给你极大的提示。
- 参数污染:尝试将同一个参数名以不同方式多次提交(如GET和POST同时提交、数组形式提交
param[]=value),看看是否能触发框架解析的异常逻辑。
6. 工具辅助与自动化探索
手工构造和测试OGNL Payload效率较低,尤其是在需要频繁变形绕过时。掌握一些工具和脚本能事半功倍。
- Burp Suite + Intruder/Repeater:这是最主要的测试工具。在Repeater中手动调试Payload,在Intruder中使用“Pitchfork”或“Cluster bomb”模式,对Payload的多个变形点(如类名、方法名、编码方式)进行组合爆破。可以加载SecLists中的Fuzz字典,或自己整理一份OGNL关键词变形字典。
- OGNL表达式生成脚本:用Python或Go写一个小脚本,可以根据模板快速生成各种绕过的Payload。例如,输入一个命令
cat /flag,脚本自动输出字符串拼接、Hex编码、Unicode编码、反射调用等多种形式的Payload。 - Struts2漏洞扫描器:如Struts2-Scan等开源工具,可以自动化检测常见的Struts2漏洞(如S2-016, S2-045, S2-052等)。但工具只是辅助,其Payload可能过时或被CTF题目特意防御,理解原理后手工调整才是关键。
- Java反序列化工具链:如果漏洞点与反序列化相关(如S2-057的某些利用方式),可能需要用到ysoserial等工具生成Payload。但ctfshow的S2系列主要聚焦于OGNL注入,反序列化涉及较少。
最后,我想强调的是,刷CTF题目的目的不是为了记住几个Payload,而是锻炼一种“漏洞思维”。面对一个黑盒系统,如何通过有限的信息(参数、响应、报错)推测其内部实现,如何将已知的漏洞模式(如OGNL注入)与当前上下文结合,如何一步步构造、测试、调整利用链直至成功。这种思维,无论是在CTF赛场上,还是在真实世界的渗透测试和代码审计中,都是无比珍贵的。ctfshow的S2系列就像一个优秀的教练,它把Struts2这个经典框架中最具代表性的安全问题,浓缩在了一道道关卡里。通关之后,你收获的绝不仅仅是几个flag,而是一套应对同类问题的完整方法论。
