XXE 展开实体的代价
XML 外部实体处理可能通过解析器去读本地文件、访问原本够不到的主机,而且这一切都是服务器解析时替你完成的。应当在解析器里把它禁掉。 实体是解析器的功能 XML 实体是解析器自带的一个功能,让文档可以引用别处的内容;也正是同一套机制,在得不到防护时把一条请求变成一次可无限扩展的实体炸弹。一份文档可以定义这样一个实体:它指向本机某个文件、某个网络地址,或另一个实体;解析器一旦去解析它,就会把文件内容读进来、塞进文档中「占位」的位置。便利与危险常常是双胞胎——允许多引用的解析器,自然也就允许去翻 /etc/passwd,或者把 URL 递到网络的另一端。 外部指针会泄文件 外部实体之所以会「泄」,是因为解析器替你去读了它。一次典型的 XXE 攻击,会发送一条指向文件路径或 URL 的实体声明;而一个过于宽松的解析器,就会把文件读出来、在响应里把其中一部分当作「占位符」的回显——于是一台内部文件或内网地址,就在攻击者只发送了一段 XML 帧的情况下被看见。伤害到底多大,取决于解析器允许访问什么;它的边界不是请求的体积,而是解析这段文档的进程权限。 干脆把这个功能关掉 最直接也最强的补救,是把这部分功能整个关掉:禁用外部普通实体与外部参数实体,决定解析器「是否允许翻网络」,并避开那些默认就去解析的外部解析库。在确实绕不开实体时,用 schema 对文档设一道闸,再用恰好够用的最小权限去跑解析。把 XXE 当做一个可开关的配置特性,而不是某段代码里的漏洞,才能让那些要处理历史数据的低层系统依旧安全——会决定「啃不啃得动一台机器」的,从来不是这份 XML 有没有内容,而是解析器有没有钥匙。 坏掉的样子 最典型的坏法是读文件:文档里声明一个指向本地路径的外部实体,应用又把展开后的内容回显到报错信息或预览字段里。请求本身只有几行,真正读磁盘的是解析器进程,所以访问日志看起来人畜无害,泄露只出现在响应正文。能读到什么不取决于攻击者,而取决于跑解析器的账号权限,服务账号能看到的秘密都可能变成远程可读。 同样的形状把网址放进系统标识符,就会变成服务端请求伪造。攻击者让解析器去访问云厂商的元数据地址拿凭证,或者去敲只有内网才能到达的管理后台。每次重试都是服务器替攻击者再跑一次,所以外部实体会放大伪造请求的伤害:一行很短的字符串,就能换来一次带内网身份的抓取。 还有一种不需要网络的坏法,叫实体炸弹。几个互相嵌套的内部实体,每层膨胀约十倍,一千字节的文档就能展开成几吉字节的文本,模式校验还没跑起来,内存就先被耗尽。容易被忽略的是图片上传:浏览器把可缩放矢量图形当作标记语言解析,于是头像通道里夹带文档类型声明,就把同一套机制从图片管线带了进来。防御常见的裂缝是不一致,主接口关掉了文档类型,订阅源、安全断言或随后重新解析文件的缩略图程序却还开着,攻击者总能找到那扇没关的门。 快速验证 把下面的程序保存到临时工作目录的xxe_probe.py,运行python3 xxe_probe.py。它只用标准库,不访问网络也不读写磁盘,对比宽松基线与加固入口的差别。基线会把内部实体炸弹展开到一千个字符,加固入口则拒绝一切声明了实体的文档类型。 """XXE guard demo: stdlib only, no network, no disk reads.""" import io import re import xml.sax import xml.sax.handler BENIGN = '<?xml version="1.0"?><note><to>ops</to><body>hello</body></note>' FILE_READ = ('<?xml version="1.0"?><!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>' '<r>&xxe;</r>') SSRF = ('<?xml version="1.0"?><!DOCTYPE r [<!ENTITY xxe SYSTEM ' '"http://169.254.169.254/latest/meta-data/">]><r>&xxe;</r>') BILLION_LAUGHS = ('<?xml version="1.0"?><!DOCTYPE l [<!ENTITY a "0123456789">' '<!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">' '<!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">]>' '<l>&c;</l>') SVG_UPLOAD = ('<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/hostname">]>' '<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>') class _Text(xml.sax.handler.ContentHandler): def __init__(self): self.chunks = [] def characters(self, content): self.chunks.append(content) class _RejectResolver(xml.sax.handler.EntityResolver): def resolveEntity(self, publicId, systemId): raise xml.sax.SAXException(f"blocked external entity: {systemId!r}") def naive_parse(xml_text): """Permissive baseline: plain stdlib parse with no guard.""" parser = xml.sax.make_parser() handler = _Text() parser.setContentHandler(handler) parser.parse(io.StringIO(xml_text)) return "".join(handler.chunks) def safe_parse(xml_text): """Hardened entry point: refuse entity machinery, then lock the parser.""" if re.search(r"<!DOCTYPE|<!ENTITY", xml_text, re.IGNORECASE): raise ValueError("rejected: DOCTYPE/entity declarations are not accepted") parser = xml.sax.make_parser() parser.setFeature(xml.sax.handler.feature_external_ges, False) try: parser.setFeature(xml.sax.handler.feature_external_pes, False) except xml.sax.SAXNotSupportedException: pass # expat never fetches parameter entities; keep going parser.setEntityResolver(_RejectResolver()) handler = _Text() parser.setContentHandler(handler) parser.parse(io.StringIO(xml_text)) return "".join(handler.chunks) def check_rejected(name, payload): try: safe_parse(payload) except ValueError as exc: print(f"blocked {name}: {exc}") return raise AssertionError(f"safe_parse accepted {name}; expected ValueError") def main(): text = safe_parse(BENIGN) assert "hello" in text, f"benign parse lost content: {text!r}" print(f"benign ok: {text.strip()!r}") expanded = naive_parse(BILLION_LAUGHS) assert len(expanded) >= 1000, f"baseline did not expand: {len(expanded)} chars" print(f"baseline expands billion-laughs to {len(expanded)} chars (unsafe)") for name, payload in [("file-read", FILE_READ), ("ssrf", SSRF), ("billion-laughs", BILLION_LAUGHS), ("svg-upload", SVG_UPLOAD)]: check_rejected(name, payload) print("PASS: 1 benign accepted, 1 baseline expansion shown, 4 malicious inputs rejected") if __name__ == "__main__": main() 运行成功会先打印正常文档的文本,再报告基线展开的长度并标记为不安全,随后列出文件读取、服务端伪造、实体炸弹和图形上传四种输入被拦截,最后以通过行收尾。加固函数在解析前拒绝任何文档类型或实体声明,再关闭外部普通实体与外部参数实体,并装上遇到残留引用就抛异常的解析器。这些断言已在苹果电脑的 Python 3.14 上运行通过。演示只覆盖解析器配置,生产环境还要配合白名单、模式校验,以及在文件落盘前就拒绝文档类型的上传管线。 ...