S2-052漏洞解析:XStream反序列化与Struts2应急修复实践
开篇先聊几句。做安全的这些年Struts2的漏洞是真没少见从S2-001一路数到S2-057隔三差五就爆一个。但2017年9月曝出的S2-052也就是CVE-2017-9805在圈子里引起的动静一点不比当年的S2-045小。原因很简单这个漏洞利用门槛低、影响面广而且问题出在一个大家都在用却很少真正去关注边界的组件上。今天不聊教科书式的漏洞公告复读就结合我自己当时应急排查、复现验证、推动修复的完整过程说说这个漏洞到底是怎么回事实际排查时怎么定位修复时有哪些坑。这次分享适合运维、安全工程师、Java开发同学尤其是手底下管着一堆老旧Java Web系统的朋友。即使你不是搞安全的了解清楚S2-052的来龙去脉也能在下次漏洞通报到来时心里有个底知道该往哪个方向查。1. 漏洞背景与影响范围盘点1.1 S2-052到底是什么S2-052对应的官方编号是CVE-2017-9805漏洞点不在Struts2的核心框架里而是出在Struts2的REST插件struts2-rest-plugin上。REST插件是Struts2官方提供的一个扩展组件用来让开发者快速把Action暴露成RESTful风格的接口支持JSON、XML等多种数据格式。问题就出在这个XML数据格式的解析上。REST插件在处理XML类型的请求时底层调用了XStream组件来做反序列化。XStream是一个Java类库负责把Java对象和XML互相转换本身很常用。但那几年XStream被曝出存在多个反序列化漏洞攻击者可以构造一个精心设计的XML报文让目标服务器在解析时加载恶意类最终实现远程代码执行。也就是说S2-052实际上是一个经典的组件漏洞传导案例Struts2本身没有写错但它引入的REST插件在依赖XStream时没有做好安全约束导致上游组件的缺陷变成了自己身上的大坑。1.2 影响版本和危害等级漏洞影响的范围官方公告里写得很清楚。受影响的版本包括Struts 2.1.3到2.3.33之间的所有版本以及2.5系列到2.5.12之间的版本。修复版本是2.3.34和2.5.13。这个覆盖范围相当广。当时国内有大量业务系统用的是2.3.x的某个小版本尤其是一些传统行业的信息化系统、企业后台管理系统很多从2012年左右上线之后就没怎么升过级正好处在受影响区间内。危害等级方面CVSS 3.0评分是8.1属于高危级别。为什么不是满分10分因为利用条件还需要系统里确实启用了REST插件并且对外暴露了XML解析接口。但实际场景中很多开发者只是把REST插件加进了依赖根本没有仔细看它自动映射了哪些URL路径这就导致原本以为不存在的攻击面其实一直裸露在外。1.3 公开展示后的连锁反应漏洞详情公开之后社区的PoC很快就出来了。第一波PoC大多基于XStream的历史gadget链改造直接执行命令或发起HTTP请求验证。当时不少安全群里的气氛是比较紧张的因为大家都很清楚Struts2在大量Java Web系统中是核心框架一旦能被远程命令执行基本上等于服务器沦陷。我记得当时处理的第一起真实案例是一家做供应链管理系统的公司。对方技术人员一开始觉得我们只是内部系统外网访问不到结果一查系统虽然在内网但办公网段和其他业务网段之间并没有做严格隔离攻击者一旦通过钓鱼或其他方式进入办公网横向打过去就是分分钟的事。这也是很多团队容易忽视的一点漏洞利用不一定只从公网发起内网横向渗透才是更常见的路径。2. 漏洞原理拆解XStream反序列化是怎么被攻破的2.1 REST插件的请求处理链路要理解S2-052先得搞清楚REST插件收到一个请求后做了什么。正常流程大概是这样的客户端发送一个HTTP请求URL形如/struts/rest/orders/1.xmlContent-Type为application/xml。REST插件通过Convention插件自动找到对应的Action类然后根据请求的URL后缀或Content-Type来决定使用哪种消息处理器。如果是JSON就走Jackson如果是XML就走XStream。关键点在于XStream在把XML反序列化成Java对象的时候默认允许任何类的实例化。也就是说只要XML里写明了某个类名XStream就会尝试去加载并实例化它。这本是XStream为了灵活性做的设计但也成了安全问题的根源。2.2 XML反序列化为什么危险可以这样理解XStream的XML反序列化相当于一个工厂收到了一张写着我要生产xxx型号产品的订单。正常的订单写的是业务数据类型比如订单、用户、商品。但攻击者把订单内容改成了我要生产一个能够执行系统命令的工具类工厂不做任何校验照单全收生产出来的东西就能在服务器上执行代码。具体到S2-052的利用攻击者构造的XML里包含一个实现特定接口的恶意类描述XStream在解析过程中触发这个类的某些方法从而执行攻击者提供的命令。整个过程不需要身份认证不需要上传文件只需要能向目标接口发送一个HTTP请求。更麻烦的地方是Java生态里可以用于此类攻击的gadget链非常多。XStream的老版本里像PriorityQueue、DynamicProxy相关链都能配合使用。这也是为什么修复方案不能停留在升级XStream到某个小版本这么简单因为攻击面可能会随着新链的发现而变化。2.3 为什么很多系统实际受影响我自己在排查过程中发现很多系统实际受影响但技术人员却完全不知情。原因主要有三个第一REST插件的引入非常隐蔽。有的项目在几年前为了某个小功能加了struts2-rest-plugin依赖后来功能不用了依赖却一直留在pom.xml或WEB-INF/lib里。第二REST插件不是显式配置URL映射的而是通过约定方式自动注册很多开发者根本不知道这些接口存在。第三即使系统没有主动使用XML接口插件自带的默认行为也可能暴露解析能力。这意味着代码审查时不能只问我们哪里用了XML接口而要从依赖和自动映射层面做检查。3. 自查与风险确认快速判断你的系统是否在暴露面上3.1 一个可靠的自查流程S2-052排查我认为关键就三步查版本、查依赖、查接口暴露。第一步比较简单解压war包或直接看lib目录下的struts2-core-x.x.x.jar文件名就能确认版本号。注意看的是core包版本不是项目里自己打的版本标记。第二步查struts2-rest-plugin是否存在查看方式同样是扫lib目录。第三步稍微复杂一点。REST插件在应用启动后会自动注册一组URL映射规则通常形如/rest/*或/struts/rest/*。可以用下面这条命令快速验证目标接口是否暴露curl -I --path-as-is http://target:port/struts/rest/orders/1.xml如果返回的响应头状态码不是404而是200、500或其他业务状态码基本可以确认REST插件处于活跃状态。如果返回404也不代表绝对安全因为映射路径前缀可能做了调整需要再结合部署配置做判断。3.2 自查中容易忽略的干扰项排查中容易踩的坑一个是只查版本号就直接下结论。比如有的系统用的是Struts 2.5.13但struts2-rest-plugin却还是旧版本这种情况依然有风险因为漏洞触发点在插件而不是core。另一个是只看Content-Type为application/xml的请求实际上REST插件对请求的识别还受URL后缀影响.xml结尾的URL即使Content-Type不是XML也可能触发解析。还有一类系统虽然装了REST插件但并没有配置任何REST风格的Action类。这种情况下默认接口可能无法访问攻击面相对受限。不过还是建议按最坏情况处理因为插件内部的一些默认处理器仍然可能映射到特定资源路径。3.3 风险评估时的排序思路排查覆盖不了的时候需要按优先级推进修复。我自己的经验是优先级排序看四件事系统是否面向公网、是否使用REST插件、是否处理XML数据、是否处于受影响版本区间。四者全中的第一优先级处理只要公网可达并且REST插件存在即使不确定会不会解析XML也应当按高危处理。内网系统也不能完全放下。我在实际项目里见过不少攻击路径是从办公网段横向打进业务网段的案例。内网不等于安全这一点必须反复强调。4. 修复方案与落地执行从升级到验证的完整路径4.1 官方补丁是首选官方修复方案是升级到Struts 2.3.34或2.5.13及以上版本。这次升级改动的内容除了升级XStream依赖之外官方还在REST插件的XML处理上增加了类型限制的配置接口默认不再允许反序列化任意类。升级操作上Maven项目直接改依赖版本号dependency groupIdorg.apache.struts/groupId artifactIdstruts2-core/artifactId version2.5.13/version /dependency然后重新打包部署。升级前务必先看官方发布说明特别关注2.3.34相对于2.3.33的API变更记录。我见过一个项目升级后因为ActionContext某个方法签名变化导致编译失败虽然不算大问题但打乱了发布窗口。4.2 临时缓解措施遇到没法立即升级的生产系统可以考虑临时缓解。可选的措施包括移除struts2-rest-plugin依赖。如果业务确实不使用REST插件这是最干净的临时做法。在struts.xml里关闭动态方法调用虽然不能直接阻断REST插件的XML解析但可以降低漏洞被利用后扩大影响的范围。在Web应用防火墙或网关层临时拦截URL后缀为.xml且路径匹配/rest/的POST请求。如果业务中确实不依赖XML格式的REST接口这条规则可以快速生效。这些措施有效但不能当作长期方案。比如通过WAF规则拦截攻击者换一个Content-Type变体或URL编码方式就可能绕过。我建议把临时缓解当成缓冲期手段给升级留出时间窗口。4.3 修复后的回归验证修复完成后不能只测一个普通请求就收工。我习惯做几项回归验证第一正常业务接口的JSON和XML请求是否能正常工作第二确认为XML格式的合法REST调用是否仍然可用第三构造一个明显异常的XML报文确认系统返回的是安全错误而不是可执行痕迹。曾经有一个系统升级后XML请求全部报错。排查后发现是项目中自定义了一个XStream实例并在声明时覆盖了默认的许可配置。升级Struts2版本后REST插件内部创建的XStream实例不受影响但项目中自行创建并使用的地方需要同步调整。这个案例说明修复前做一次代码扫描找出对XStream的直接引用是非常必要的。5. 应急响应实战记录一次S2-052排查的完整复盘5.1 接到通报后的第一时间这里分享一次比较典型的应急过程。某天下午三点多甲方那边接到漏洞通报说某业务系统存在S2-052远程代码执行风险。我们到现场后没有一上来就改代码而是先做了资产定位和版本确认。拿到war包后先解压确认了struts2-core版本是2.3.32struts2-rest-plugin存在紧接着用curl扫了一遍URL存活情况确认/rest/路径下有多个接口返回了业务状态码。不到二十分钟基本可以确认漏洞存在的可能性相当高。接下来要做的是看这个系统是否已经被人利用过。5.2 日志排查中的关键线索排查是否已被利用核心看访问日志和中间件日志。重点分析三个特征第一URL后缀为.xml的请求是否异常集中尤其是来源IP是否分散。第二POST请求的Body大小是否异常一般业务XML报文在几百字节到几KB攻击payload动辄几KB甚至几十KB。第三响应状态码是否有异常比如大量500错误伴随在某个时间窗口内。那次排查中我们发现了一批来自同一IP段的POST请求路径全是/rest/xxx/1.xml时间集中在凌晨两点到四点payload长度明显超过正常业务报文。进一步追查中间件日志中的异常堆栈虽然日志没有直接记录payload内容但通过请求特征可以基本确定系统已经被扫描过。5.3 处置流程和时间线处置节奏大概是确认风险后先在网关层封禁可疑来源IP然后停掉REST插件的自动映射再进入代码升级流程。由于这个系统不允许长时间停机我们采用的是先移除struts2-rest-plugin依赖包并重启再排期做Struts2版本升级。整个处置从接到通报到临时缓解生效大约花了一个半小时。后续版本升级在两天后完成。这里分享一个经验临时缓解措施生效后一定要保留完整的证据链包括确认风险的系统快照、日志提取记录、封禁IP的截图、以及修复后的验证记录。做安全应急这些材料最后都是要归档的。5.4 长尾风险清剿主系统修复完还有一件容易被忽略的事同网段其他系统的复查。那次应急过程中我们又扫描了同网段下所有对外开放的Java Web服务结果发现有三套系统的struts2-core版本也在受影响范围内。虽然它们没有安装REST插件暂时不存在S2-052风险但Struts2自身还有其他历史漏洞后续也一并纳入了升级计划。这个习惯我现在一直保留着。每次应急完一个漏洞都会借着机会把同类组件、同版本范围的其他资产摸一遍相当于做了一次免费的资产巡检。6. 常见问题与排查技巧实录6.1 升级之后接口报404了这是升级后遇到最多的问题。原因通常是2.5.x系列对REST插件默认的URL风格做了调整原有/rest/路径前缀不再自动映射需要手动配置。排查时先看启动日志里是否有Unable to find action之类的提示再检查struts.xml中的全局前缀配置。如果之前依赖的是约定映射而非显式路由升级后需要在配置中加入类似下面的片段constant namestruts.action.extension valueaction,,xml,json /并且确保struts2-convention-plugin和struts2-rest-plugin的版本与新核心包保持一致。6.2 XStream版本冲突怎么处理项目里如果已经使用了XStream的其他版本升级Struts2后可能因为传递依赖冲突导致NoSuchMethodError。建议升级时统一检查依赖树通过mvn dependency:tree查看XStream版本确保全局使用的是新版本或兼容版本。出现冲突时排除旧依赖并显式引入新版本而不是粗暴地去掉所有冲突依赖。dependency groupIdorg.apache.struts/groupId artifactIdstruts2-rest-plugin/artifactId version2.5.13/version exclusions exclusion groupIdcom.thoughtworks.xstream/groupId artifactIdxstream/artifactId /exclusion /exclusions /dependency然后单独引入确认过的高版本XStream。6.3 判断WAF拦截规则是否生效有些团队依赖WAF规则来防这个漏洞但不知道规则到底拦没拦。验证方式很简单构造一个不含恶意payload、只带有明显攻击特征的XML请求放到测试环境打一下看WAF是否返回拦截页面。这一步在测试环境做不要直接在线上试。我在实际项目里发现过一个问题某些WAF默认只检测Content-Type: application/xml的请求但攻击者把Content-Type改成text/xml或者application/x-www-form-urlencoded同时URL以.xml结尾一些规则就会漏掉。因此WAF规则需要同时覆盖URL后缀和Content-Type两种判定条件。6.4 攻击payload在日志里被截断怎么办中间件默认日志通常只记录请求行不记录Body。如果怀疑被利用需要看访问日志中Body大小字段。Tomcat的AccessLogValve可以配置%b记录响应字节数但请求字节数默认不记录。我的做法是开启请求体日志通过Filter在应用层记录POST Body存入独立日志文件。平时不开启应急时再启用避免造成大量额外磁盘消耗。如果问题紧急还可以临时用tcpdump抓包分析tcpdump -i eth0 -A -s 0 tcp port 8080 and (tcp[((tcp[12:1] 0xf0) 2):4] 0x504F5354) -c 50这条命令抓取前50个TCP连接中HTTP开头为POST的包并把内容以ASCII方式输出到终端。抓到之后就能直观看到请求Body里是否包含可疑的类名和命令字样。抓包过程注意不要在生产环境长时间运行抓到关键证据后立刻停止。7. 加固补强从漏洞修复到组件治理处理完S2-052我觉得更值得聊的是后续加固的思考。2017年之后Struts2的漏洞还是时不时冒出来S2-057、S2-059、S2-061一个接一个。如果每次都靠应急来兜底团队会非常被动。真正有价值的是借着这次机会把组件管理和漏洞响应机制建立起来。具体来说我和团队在实践中沉淀下来一套办法。先建立组件版本台账把所有Java应用使用的核心组件版本集中登记至少覆盖Struts2、Spring、XStream、Fastjson这类高风险组件。然后配置依赖检查工具在CI流水线里集成依赖安全检查每次构建自动扫描已知漏洞。这样新漏洞出来时可以快速通过台账定位受影响应用而不是一台台服务器慢慢翻。这就要求平时发布流程里把依赖升级当成一项常规任务而不是等到漏洞曝光才想起来。很多团队因为怕麻烦宁愿守着老版本运行等到被通报了才加班加点做紧急升级反而代价更高。还有一点安全团队和研发团队之间的配合机制也很关键。应急的时候最怕出现信息断层安全这边发现了问题研发那边不知道怎么看日志研发想快速修复安全这边又不了解业务影响。事先约定好漏洞响应SLA、分工边界、升级窗口实际处理起来效率会好很多。这些年处理过不少反序列化类的漏洞我的体会是**类似XStream、Fastjson这样的通用组件出了问题往往不是组件自身单独的问题而是上层框架在使用时缺少安全边界意识。**S2-052看起来是Struts2的锅实际上暴露的是整个Java Web生态里依赖组件但不了解组件的普遍现象。把这次当成一次体检把资产盘清楚、依赖管起来比只打一个补丁有意义的得多。