● CWE-611 · OWASP A05:2021

XML 外部实体注入(XXE,CWE-611):原理与防御方法

遵循文档 DTD 的 XML 解析器会从 file:// 或 http:// URL 获取 SYSTEM 实体,并把结果粘贴进解析后的数据里,所以一份上传的文档就能读取服务器文件,或让服务器访问内部地址。本文用 Flask 示例说明外部实体和实体扩展如何运作、哪些解析器设置会导致它们,以及 defusedxml 配合 forbid_dtd=True 如何把它们关掉。

通俗易懂的原理解析 (ELI5)

一家印刷店负责填写套用信函。某位客户的信上写着:“尊敬的[在此插入贵店档案柜第 4 个抽屉里的内容]”。细心的印刷工只插入客户提供的文字。粗心的印刷工却走到第 4 个抽屉——里面放着其他客户的合同——把它们印进信里寄了回去。XXE 就是那个粗心的印刷工:XML 文档让解析器去取一个文件或网址并插入进来,解析器就照做了。修复方法是拒绝任何带有“从别处插入”指令的信。

核心概念与专有名词

DTD 与实体
<!DOCTYPE ...> 中的文档类型定义可以声明实体,也就是有名字的文本片段,解析器会在出现 &名称; 的地方替换它们。内部实体包含字面文本,外部实体则指向别处。
外部实体
<!ENTITY x SYSTEM "file:///etc/hostname"> 让解析器读取这个 URL,并把内容放到使用 &x; 的位置。如果是 http:// URL,发出请求的是服务器,XXE 就变成了服务器端请求伪造。
实体扩展(Billion Laughs)
每个实体引用其他实体十次,就能把一个 1 KB 的文档在内存中扩展成数 GB 的文本(CWE-776)。这不需要任何外部访问,所以只有拒绝 DTD 或限制扩展才能阻止它。
解析器默认设置
行为取决于库:lxml 5 默认只解析内部实体,但 resolve_entities=True 和 load_dtd=True 会重新打开漏洞;Java 的 DocumentBuilderFactory 会解析外部实体,除非设置了 disallow-doctype-decl;.NET 需要 DtdProcessing.Prohibit。
defusedxml
一个 Python 包,它包装标准解析器,遇到危险结构时抛出 EntitiesForbidden、ExternalReferenceForbidden 或 DTDForbidden,而不是去处理它们。forbid_dtd=True 会拒绝任何带 DOCTYPE 的文档。

攻击执行流程分解

步骤 1

攻击者上传一份 XML 文档

文档声明了一个外部实体,并在服务器会回显的字段中引用它。一个指向 file:///etc/hostname 的实体演示了读取文件的情况。

步骤 2

解析器处理 DTD

由于应用启用了 DTD 加载和实体解析,解析器会读取被引用的文件或 URL。

步骤 3

文件内容变成了数据

文本落在 customer 元素里,接口在 JSON 响应中把它返回。

步骤 4

攻击者读取文件或访问内部主机

配置文件、密钥和云元数据都可以这样读到。2014 年,Reginaldo Silva 因发现 Facebook OpenID 处理中的一个 XXE 漏洞而获得 33,500 美元赏金,该漏洞可以读取其服务器上的文件。

源代码对比:漏洞与安全实现

存在漏洞的实现
# invoices.py:上传的 DTD 要求什么,解析器就做什么
from flask import Flask, request
from lxml import etree

app = Flask(__name__)


# 解析实体、加载 DTD 并允许网络访问
@app.post("/invoices/import")
def import_invoice():
    parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)
    root = etree.fromstring(request.get_data(), parser)
    return {"customer": root.findtext("customer"), "total": root.findtext("total")}
加固后的安全修复
# invoices.py:带 DTD 的文档在解析前就被拒绝
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import ParseError, fromstring
from flask import Flask, abort, request

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1_000_000


# 不允许 DOCTYPE、实体和外部引用;请求体上限 1 MB
@app.post("/invoices/import")
def import_invoice():
    try:
        root = fromstring(request.get_data(), forbid_dtd=True)
    except (DefusedXmlException, ParseError):
        abort(400)
    return {"customer": root.findtext("customer"), "total": root.findtext("total")}

工程与系统安全加固清单

参考来源

← 浏览完整的安全目录 全部漏洞指南 →