●
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")}
工程与系统安全加固清单
- 在 Python 中用
defusedxml解析不可信的 XML,其他语言则关闭 DTD:Java 把disallow-doctype-decl设为 true,.NET 使用DtdProcessing.Prohibit,libxml2 不要使用XML_PARSE_NOENT和XML_PARSE_DTDLOAD。 - 处理不受你控制的输入时,绝不要给 lxml 传入
resolve_entities=True、load_dtd=True或no_network=False,并在 CI 中运行 Bandit(B320 会标记lxml.etree)。 - 限制请求大小(
MAX_CONTENT_LENGTH),并拒绝任何包含 DOCTYPE 的文档。 - 把上传的 SVG、SAML、DOCX 和 XLSX 文件当作 XML,用同样的加固设置解析。
- 在 CI 中添加测试,提交一份外部实体指向
file:///etc/hostname的文档,并期望得到 400。