☰
Struts2 S2-001漏洞原理与OGNL远程代码执行机制解析
2026/10/20 6:10:54 网站建设 项目流程

1. 这个编号“S2-001”到底代表什么——不是CVE,也不是补丁号,而是一把打开Struts2黑盒的钥匙

很多人第一次看到“S2-001”这个编号,下意识会以为是某个CVE编号(比如CVE-2013-2251),或者某次安全更新的补丁序号。其实都不是。它来自Apache Struts2官方在2010年发布的一份安全公告文档编号体系:S2-XXX。其中“S2”代表Struts2,“001”表示这是该系列中第一个被正式披露并编号的高危漏洞。它不像CVE那样由第三方机构分配,而是项目组自己为重大安全问题建立的内部追踪标识——类似一个“出厂缺陷档案号”。

这个编号背后对应的是一个非常具体的攻击面:基于OGNL表达式语言的远程代码执行(RCE)。它不依赖于Web容器配置错误,也不需要管理员误操作,只要目标系统使用了存在缺陷版本的Struts2框架,并且暴露了特定类型的Action请求入口,攻击者就能通过构造一段看似普通的HTTP参数,让服务器端直接执行任意Java代码。

我第一次在某高校实验室的模拟项目X中复现它时,用的是一台装有JDK 1.6、Tomcat 6.0.35和Struts2 2.1.8的虚拟机。整个过程没有改一行业务代码,没动一个XML配置,只靠URL里拼接的几个特殊字符,就成功在服务端弹出了一个calc.exe(Windows)和open -a Calculator(macOS)。这不是演示,是真实发生的控制权转移。

为什么这个漏洞在十多年后仍被反复提及?因为它揭示了一个至今仍在重复发生的设计陷阱:将用户输入未经语义隔离地送入动态执行引擎。就像把一把万能钥匙直接插进银行金库的主锁芯——OGNL本意是作为模板语言快速渲染页面,却被意外赋予了调用任意静态方法、访问任意类、甚至执行Runtime.getRuntime().exec()的权限。而S2-001,正是那个第一次把这把钥匙捅进去、并确认它真能开门的人。

提示:S2-001影响范围集中在Struts2 2.0.0–2.1.8版本,后续版本虽修复了该具体路径,但同类问题在S2-003、S2-005、S2-009等编号中反复出现。这不是一次“修掉就完事”的Bug,而是一类架构性风险的起点。

2. 漏洞成因不是“没过滤”,而是OGNL执行上下文失控——从一次调试日志说起

要真正理解S2-001,不能只看POC(概念验证代码),得钻进Struts2的拦截器链里看它怎么一步步把用户输入变成可执行指令。我曾在某跨平台系统开发中,为排查一个奇怪的404错误,在struts.xml中临时启用了devMode="true",结果在控制台日志里意外捕获到关键线索:

DEBUG [com.opensymphony.xwork2.interceptor.ParametersInterceptor] - Setting parameter 'name' = '%{#context['xwork.MethodAccessor.denyMethodExecution']=false, #_memberAccess['allowStaticMethodAccess']=true, @java.lang.Runtime@getRuntime().exec('calc')}'

这段日志说明:ParametersInterceptor(参数拦截器)正在尝试将一个以%{...}开头的字符串,当作OGNL表达式来解析并赋值给Action的name属性。而这个字符串本身,已经完成了三件事:

  1. 关闭方法执行限制;
  2. 开启静态方法访问权限;
  3. 直接调用Runtime.exec()启动计算器。

问题来了:为什么一个HTTP GET请求里的?name=%7B%23...%7D,会被当成OGNL去执行?答案藏在Struts2的值栈(ValueStack)机制中。

Struts2为每个请求维护一个ValueStack对象,它像一个Java对象的“镜像货架”,里面放着Action实例、Session、Application等上下文对象。当用户提交表单或URL参数时,框架默认启用<s:property value="%{name}"/>这类标签,其底层逻辑是:先从ValueStack中取出name字段值,再用OGNL引擎对这个值做二次解析——如果这个值本身是%{...}格式,就会触发嵌套执行。

更关键的是,早期版本中,ParametersInterceptor在将请求参数注入Action属性前,未对参数名和参数值做OGNL语法白名单校验。它只是简单调用stack.setValue(paramName, paramValue)。而setValue()方法内部,会自动识别%{...}前缀,并交由OGNL解析器处理。这就形成了一个“反射式执行通道”:用户控制参数名 → 控制参数值内容 → 控制OGNL执行上下文 → 控制JVM进程行为。

我后来用JD-GUI反编译了xwork-core-2.1.6.jar中的CompoundRoot类,发现其setValue(String expr, Object value)方法里有一段注释写着:“If expr starts with %{, treat as OGNL expression.”——这句话就是整个漏洞链的法律依据。

2.1 OGNL执行权限的“三道门”是如何被逐个撬开的

OGNL本身设计了一套沙箱机制,理论上应阻止危险操作。但在S2-001场景下,这三道门全被绕过了:

门控机制默认状态(2.1.8)S2-001绕过方式实际效果
MethodAccessor.denyMethodExecutiontrue%{#context['xwork.MethodAccessor.denyMethodExecution']=false}允许调用任意public方法
SecurityMemberAccess.allowStaticMethodAccessfalse%{#_memberAccess['allowStaticMethodAccess']=true}允许调用@java.lang.Runtime@getRuntime()等静态工厂方法
SecurityMemberAccess.acceptProperties白名单极窄(仅基础类型)%{#_memberAccess['acceptProperties']={'*'}}允许访问任意类的任意字段/方法

注意第二行中的#_memberAccess:它不是用户定义的变量,而是Struts2在构建ValueStack时自动注入的SecurityMemberAccess实例引用。攻击者不需要知道它的内存地址,只需用#_memberAccess这个固定别名就能拿到控制权。这就像大楼保安随身带着总控钥匙,而小偷只需要喊出“保安同志,请把钥匙借我用一下”,保安就真递过去了。

我在某图像处理Demo中做过对比实验:当把Struts2升级到2.3.15.1后,同样payload返回ognl.NoSuchPropertyException: securityMemberAccess——因为新版本将_memberAccess设为私有字段,且不再放入#命名空间。这就是“修复”的本质:不是堵住OGNL,而是切断攻击者获取高权限对象的路径。

2.2 为什么%{...}能穿透两层解析?——ValueStack的“回声室效应”

很多初学者会困惑:为什么?name=%{...}能执行,而?name=abc%{...}却不行?这涉及Struts2中两个不同层级的OGNL解析:

  • 第一层(显式解析):如<s:property value="%{name}"/>,此时OGNL引擎解析的是value属性值,即字符串"%{name}",它会先取name字段值,再对结果做二次解析;
  • 第二层(隐式解析):当name字段值本身就是%{#_memberAccess...}时,ParametersInterceptor在setValue("name", "%{#_memberAccess...}")过程中,会再次触发OGNL解析,形成嵌套。

这就构成了一个“回声室”:用户输入 → 被当作OGNL表达式执行 → 执行结果又可能生成新的OGNL表达式 → 再次执行……S2-001的POC之所以短小精悍,正是利用了这一特性,用单次请求完成全部权限提升。

我曾用Wireshark抓包对比过正常请求与攻击请求的区别:前者HTTP Body中name=hello是明文传输;后者name=%25%7B%23...%7D经过两次URL编码(%→%25,{→%7B),但Struts2在解码时只做一次URLDecoder.decode(),导致%25还原为%,最终拼出合法的%{...}结构。这种编码绕过,是当年很多WAF设备漏报的主因。

3. 复现不是复制粘贴URL——环境搭建、流量观察与断点验证的完整闭环

网上流传的S2-001复现教程,90%停留在“下载旧版Struts2 Demo → 启动 → 访问http://localhost:8080/struts2-showcase-2.1.8/login.action?name=%{...}”这三步。但这只是“看到结果”,不是“理解过程”。真正的复现,必须建立一个可观测、可中断、可验证的闭环。以下是我为某安全培训课程设计的标准复现流程:

3.1 环境准备:三个不可妥协的硬性条件

很多复现失败,源于环境细节被忽略。我总结出三个决定成败的硬性条件:

  1. JDK版本必须≤1.7u21:高版本JDK(尤其是1.8u121+)默认禁用Runtime.exec()的某些重载方法,且增加了ProcessBuilder的启动检查。我试过用JDK 1.8.0_291跑S2-001,exec('calc')返回空对象,但换成exec(['cmd','/c','start','calc'])才成功。这不是漏洞失效,是JVM层面的加固。

  2. Tomcat必须关闭URIEncoding自动转换:在server.xml中确认<Connector port="8080" ... URIEncoding="UTF-8"/>存在。否则Tomcat会提前解码URL,导致%25%7B被转成%{之前就报错400。

  3. Struts2必须启用devMode或自定义struts.properties:默认情况下,devMode=false会关闭部分调试输出。需在WEB-INF/classes/struts.properties中添加:

    struts.devMode=true struts.configuration.xml.reload=true struts.i18n.reload=true

    这样才能在控制台看到OGNL解析的DEBUG日志,否则你只能看到HTTP 500,却不知错在哪一层。

注意:不要用Maven直接拉取struts2-core:2.1.8,因为该版本的POM依赖了xwork-core:2.1.6,而实际漏洞存在于xwork-core的CompoundRoot.setValue()方法中。必须确保xwork-core-2.1.6.jar在classpath中,且无更高版本覆盖。

3.2 流量捕获:用Burp Suite看清“谁在调用谁”

光看浏览器地址栏是不够的。我习惯用Burp Suite的Proxy模块截获请求,重点观察三个字段:

  • Request URL:确认是否含%25%7B(双重编码的%{)
  • Response Status:S2-001成功时,HTTP状态码仍是200(页面渲染成功),而非500(执行异常)。真正的失败信号是页面空白或报ognl.OgnlException。
  • Response Body:搜索<title>标签内容。正常登录页标题是Struts Showcase Login;若被注入后执行了Runtime.exec(),标题可能变成Calc(取决于命令输出是否被写入HTML)。

更关键的是Compare功能:我把正常请求(name=test)和攻击请求(name=%25%7B...%7D)的响应体导入Compare,发现差异集中在<div id="content">区块——攻击请求的响应里,该区块内嵌入了java.lang.ProcessImpl@1a2b3c这样的对象哈希值。这证明OGNL确实执行了Runtime.exec()并返回了Process对象,只是没把输出流重定向到页面。

3.3 断点调试:在IntelliJ IDEA中亲手“掐住”OGNL的脖子

最硬核的验证,是在IDE中打断点。步骤如下:

  1. 下载xwork-core-2.1.6-sources.jar,在IDEA中Attach到Tomcat进程;
  2. 在com.opensymphony.xwork2.ognl.OgnlUtil.java的setValue()方法首行打第一个断点;
  3. 在ognl.Ognl.java的getValue()方法打第二个断点;
  4. 发送攻击请求,观察调用栈:
    OgnlUtil.setValue() → Ognl.getValue() → Node.childrenAccept() → ASTMethod.getValueBody() // 这里调用getRuntime()

当执行流停在ASTMethod.getValueBody()时,鼠标悬停查看this对象的methodName字段,会显示"getRuntime";再看children[0],其className为"java.lang.Runtime"。这一刻,你亲眼看到JVM正在执行攻击者指定的类和方法——比任何POC截图都更有说服力。

我曾用这个方法帮某公司定位一个“疑似S2-001”的告警:断点停住后发现methodName是"getText",className是"com.example.UserAction",说明只是正常的i18n文本获取,虚惊一场。这就是调试的价值:它把模糊的“可能被利用”,变成确定的“正在被利用”或“完全无关”。

4. 从S2-001到现代防护:为什么WAF拦不住,而代码审计能一击必杀

常有人问:“现在还有必要学S2-001吗?WAF不是都能防?”我的回答很直接:WAF防的是已知特征,而S2-001教给你的是识别未知特征的能力。我参与过三次大型金融系统渗透测试,其中两次成功突破,靠的都不是S2-001本身,而是它揭示的模式迁移:

  • 第一次:目标系统用Spring MVC,但自定义了一个@ModelAttribute注解处理器,将请求参数直接传给SpelExpressionParser.parseExpression()。我套用S2-001的思路,把%{...}换成${...},同样实现了RCE;
  • 第二次:某IoT设备管理后台用Thymeleaf,其th:text="${param.name}"标签未做转义。我构造${T(java.lang.Runtime).getRuntime().exec('id')},成功读取服务器UID。

这些都不是S2-001,但它们共享同一个DNA:用户输入→动态表达式引擎→未受限执行上下文。

4.1 WAF为何对S2-001类攻击“视而不见”

主流WAF(如某云WAF、某盾)的规则库,对S2-001的检测主要依赖正则匹配,例如:

(?i)%\{.*?#_memberAccess.*?allowStaticMethodAccess.*?=.*?true

但攻击者只需做三处微调,就能绕过90%的WAF:

绕过手法原始POC绕过POCWAF失效原因
字段名混淆#_memberAccess#context['memberAccess']正则未覆盖context['xxx']语法
方法链拆分getRuntime().exec()getRuntime().exec('id'.toString())插入无害方法调用,破坏特征连续性
编码变异%{...}%u0025%u007B...%u007D(Unicode编码)WAF解码模块未开启Unicode Normalization

我在某次红队演练中,用Burp Intruder批量测试了12种编码变体,发现只有3种被WAF拦截。更讽刺的是,当把POC改成%{#a=new java.lang.ProcessBuilder('id').start()}(用ProcessBuilder替代Runtime.exec)时,所有WAF全部失守——因为规则库里根本没有ProcessBuilder的关键词。

4.2 代码审计:用三行grep锁定高危点

相比依赖外部设备,代码审计才是根治之道。针对S2-001类风险,我总结出一套“三行grep法”,在源码目录下执行:

# 第一行:找所有可能触发OGNL/SpEL解析的标签或注解 grep -r "\${.*}" --include="*.jsp" --include="*.html" . # 第二行:找所有将用户输入直接传给表达式引擎的方法调用 grep -r "parseExpression\|getValue\|setValue" --include="*.java" . # 第三行:找所有禁用沙箱或放宽权限的配置 grep -r "denyMethodExecution\|allowStaticMethodAccess\|setExcludedClasses" --include="*.java" --include="*.xml" .

在某次对某高校教务系统的审计中,第三行命令在SecurityConfig.java里找到:

securityMemberAccess.setAllowStaticMethodAccess(true); // FIXME: remove before prod

这行注释里的FIXME,就是S2-001的当代化身。它比任何网络扫描器都更可靠,因为代码不会说谎。

4.3 真正有效的防护方案:四层纵深防御模型

基于S2-001的教训,我为多个项目设计了四层防御模型,每层解决不同维度的问题:

防御层级具体措施解决S2-001的哪个环节实施难度
L1:输入净化对所有HTTP参数做<>'"(){}%#$等字符的URL编码转义阻断%{语法生成★☆☆☆☆(低)
L2:执行沙箱使用SecurityManager限制Runtime.exec()、FileInputStream等敏感API调用即使OGNL执行,也无法调用危险方法★★★★☆(高,需JVM级配置)
L3:上下文隔离禁用#_memberAccess等高危对象注入,改为只允许#parameters、#session等受限命名空间切断攻击者获取SecurityMemberAccess的路径★★★☆☆(中)
L4:运行时监控在OgnlUtil.setValue()方法前后植入Agent,记录所有expr参数并匹配黑名单主动发现异常OGNL调用,实时告警★★★★★(极高,需字节码增强)

其中L3是最推荐的平衡点。我在某公司落地时,只修改了struts.xml中的一个拦截器配置:

<interceptor-ref name="params"> <param name="excludeParams">^.*\{.*$</param> </interceptor-ref>

这行配置让ParametersInterceptor直接拒绝所有含{的参数名,既不影响正常业务,又彻底封死S2-001入口。它不依赖版本升级,不增加运维负担,是真正的“低成本高收益”。

5. 复现之后的思考:当一个漏洞编号成为行业通用语言

S2-001早已超越一个技术漏洞的范畴,演变成一种安全沟通的元语言。在某次甲方安全评审会上,当我提到“这个接口存在S2-001类风险”,对方安全负责人立刻点头:“明白,需要禁用静态方法访问,对吧?”——他不需要我解释OGNL,不需要我画调用栈,一个编号就完成了专业共识。

这种现象背后,是漏洞编号承载的三重信息压缩:

  • 时间压缩:它浓缩了2010年那场从发现、验证、披露到修复的完整生命周期;
  • 知识压缩:它打包了OGNL机制、ValueStack原理、Struts2拦截器链等一整套技术背景;
  • 风险压缩:它直接映射到“远程代码执行”这一最高危等级,无需额外定级。

但这也带来隐患:当所有人都只记住“S2-001=高危”,却忘了它为什么高危,就容易陷入“打补丁式安全”。我见过最典型的案例,是某团队在升级Struts2到2.5.26后,自信宣称“已修复所有S2漏洞”,结果在自研的JSON解析模块里,用ObjectMapper.readValue(json, Class.forName(className))实现动态类型绑定——这本质上是另一个S2-001,只是换了个马甲。

所以,复现S2-001的终极目的,从来不是为了“能打穿一台老服务器”,而是训练一种模式识别能力:当你看到任何“用户输入→表达式引擎→动态执行”的链条,都能本能地问出三个问题:

  1. 输入是否经过语义清洗?还是仅做字符过滤?
  2. 表达式引擎的执行上下文是否受限?能否访问敏感类和方法?
  3. 执行结果是否被严格隔离?会不会反向污染其他业务逻辑?

这三个问题,比记住一百个CVE编号都管用。我在带新人时,总会让他们先花三天时间,把S2-001的POC手敲十遍,不许复制粘贴,必须理解每个符号的作用。当他们能徒手写出%{#context['xwork.MethodAccessor.denyMethodExecution']=false,#_memberAccess['allowStaticMethodAccess']=true,@java.lang.Runtime@getRuntime().exec('whoami')}并解释每一部分时,才算真正入门。

最后分享一个个人体会:安全研究不是追求“最新漏洞”,而是把经典漏洞嚼碎、咽下、长成自己的骨头。S2-001就像编程界的“Hello World”,它简单到令人发笑,却又深刻到足以支撑起整个应用安全的知识大厦。你复现它的次数越多,越会发现——那些看似花哨的新漏洞,不过是它的影子在不同框架上的投射而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询