失效的假设 “解析一份文档,只是把它读成数据”
所属组 W1 · 注入(补)
前置 第 6 篇:不安全反序列化。同一个母题的第三个化身
复现环境 Python 3 标准库,无外部依赖

一、你只想读两个字段

对接一个老系统,对方发过来的是 XML。你只需要里面两个字段:订单号和金额。

你调了一行 parse(),取出那两个值,收工。

某天对方(或者冒充对方的人)发来的 XML 里,多了这么一段:

<!DOCTYPE r [ <!ENTITY x SYSTEM "file:///etc/passwd"> ]>

你的服务器把 /etc/passwd 读了出来,塞进了解析结果里。

你没有写任何读文件的代码。 读文件的是解析器。

打个比方

你请人誊写一份文件——照抄,不要改动。这个要求听起来毫无风险。

可这门文书格式里有一条老规矩:正文里可以写"〔此处插入 3 号档案柜第 7 份文件的全文〕",而誊写员必须照办——这条规矩当年是为了省事,免得同一段话抄十遍。

于是有人在给你的文件里写了一行"〔此处插入保险柜里那份名单〕"。誊写员照办了,把名单抄进了交给你的稿子里。

他没有背叛你。他执行的是这门格式的规则,而这条规则你根本不知道存在。

⭐ 这就是 XXE(XML External Entity,XML 外部实体)。它和第 6 篇是同一个母题的又一次出现:你以为"解析"是个读操作,可这门格式的规则里,藏着让解析器替你去取东西的能力。

二、同一份文档,同一个解析器,只差一个开关

这一节值得慢慢看,因为它的结论和你预期的可能不一样。

下面这段用标准库的 xml.sax 解析同一份文档两次,唯一的区别是一个叫 external_ges(external general entities,外部通用实体)的开关:

import xml.sax, io, tempfile, os
from xml.sax.handler import feature_external_ges

# 一份放在服务器上、本不该被外部读到的文件
p = os.path.join(tempfile.mkdtemp(), "secret.txt")
open(p, "w").write("API_KEY=SK-LIVE-7F3A")

# 攻击者发来的 XML:它自己声明了一个"实体",内容是那个文件
doc = f'<?xml version="1.0"?><!DOCTYPE r [ <!ENTITY x SYSTEM "file://{p}"> ]><r>&x;</r>'

class H(xml.sax.ContentHandler):
    def __init__(self): self.out = []
    def characters(self, c): self.out.append(c)

for on in (False, True):
    parser = xml.sax.make_parser(); h = H()
    parser.setFeature(feature_external_ges, on)      # <- 唯一的区别就是这一行
    parser.setContentHandler(h)
    parser.parse(io.StringIO(doc))
    print(f"external_ges={str(on):<5} 解析出来的文本: {''.join(h.out)!r}")
external_ges=False 解析出来的文本: ''
external_ges=True  解析出来的文本: 'API_KEY=SK-LIVE-7F3A'

同一份文档、同一个解析器库、同一行 parse()一个布尔值的差别,决定了服务器上的密钥会不会被交出去。

而且注意上面那行 <r>&x;</r>——文档里写的只是"这里放实体 x"。是解析器去把 x 解释成了一次文件读取。 你的代码从头到尾只做了一件事:调用 parse

⚠️ 这里最该带走的不是"XML 危险",而是下面这句:

保护你的是一个默认值,不是一条定律。

Python 现在默认把这个开关关着(第一行输出为空,就是这个默认在起作用)。这是好事,但它是这个库、这个版本的一个选择——不是 XML 这门格式的规定。历史上和现在,都有解析器默认是开着的;也总有人为了"支持某个老客户的文档"手动把它打开。

⚠️ 第一次读到这里容易得出一个过于乐观的结论:“那我用的语言默认关了,就没事了。“不对。你要问的是三个问题:我用的这个解析器默认是什么?我的代码或框架有没有在别处改过它?我依赖的库里有没有别的 XML 解析入口? 一个项目里往往不止一处解析 XML。

三、能读文件,就能发请求

上面那个实体用的是 file://。换一个协议会怎样?

<!ENTITY x SYSTEM "file:///etc/passwd">        读服务器上的文件
<!ENTITY x SYSTEM "http://169.254.169.254/">   让服务器去访问一个 URL

第二行的意思是:XXE 顺手就是一个 SSRF。解析器会替你发出这个请求,而它发请求时用的是你服务器的网络位置——第 22 篇讲的那条链,从这里也能进去。

⭐ 所以 XXE 的危害不止"读文件"这一条。把它和第 22 篇并排看:

第 22 篇   你的代码主动拿用户给的 URL 去请求     —— 你至少知道自己在发请求
本篇       你的代码只是"解析一份文档"           —— 你完全不知道有请求被发出去了

后者更难被发现,因为代码里根本没有任何看起来像"发请求"的地方。

还有一种不需要外部实体的玩法

就算外部实体被关掉了,实体机制本身还在,而它可以用来放大

<!ENTITY a "aaaaaaaaaa">                        10 个字符
<!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">    展开成 100 个
<!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">    展开成 1000 个

每加一层乘以十。十层就是一百亿个字符——一份几百字节的文档,能让解析器吃掉几十 GB 内存。这叫亿笑攻击(billion laughs),是一次拒绝服务。

⚠️ 它和外部实体是两个独立的开关:关掉 external_ges 挡不住它,因为这里的实体全是文档内部定义的。要挡它得靠解析器的实体展开上限,或者干脆禁用 DTD。

四、它出现过的地方

展开在事件分析里:

2014–2019   XXE 在多个主流框架/文档处理组件里反复出现,
            典型形态是"上传一个文档 -> 服务端解析 -> 读到服务器本地文件"
            —— 办公文档(docx/xlsx)、SVG、SAML 断言、SOAP 都以 XML 为底

⭐ 这一行里最值得记住的是触发面.docx.xlsx.svg、SAML 登录断言、SOAP 接口——它们本质都是 XML。所以"我们没有 XML 接口"这句话往往是错的:你可能在解析用户上传的 Office 文档,或者在处理单点登录。

五、防御

① 关掉 DTD / 外部实体            首选。绝大多数业务不需要 DTD,直接禁掉最省事
② 用安全默认的解析器             有专门做过加固的 XML 库就用它,别自己调开关
③ 显式设置,别依赖默认           每一处解析 XML 的地方都显式关掉,写进代码而不是靠库的默认
④ 限制实体展开与文档大小          挡亿笑那一类(和 ① 是两回事,要分别处理)
⑤ 能换格式就换                   新接口用 JSON;XML 只在对接老系统时留着

判断标准:你的系统里有几处在解析 XML?每一处的 DTD 开关是什么状态,你能说出来吗? 说不出来,说明这件事没被盘过——而 §四 提醒你,.docx 和 SAML 也算。

⭐ 注意 ① 和第 6 篇的 ① 是同一句话:换掉那个能力过剩的东西。 你只想读两个字段,却用了一个能替你读文件、发请求的解析器——这是第 3 篇说的"能力远超场景”。

六、这些防御的边界

① 关了 DTD 不等于关了实体展开。 见 §三:两个开关,两类攻击。有的库把它们做成一个选项,有的分开,要按你的库确认。

② XML 不只在你以为的地方。 上传的办公文档、SVG 图片、SAML 断言、老接口的 SOAP 报文——全是 XML。加固要覆盖所有解析入口,包括你依赖的那些库自己调的解析器。

③ 盲 XXE 一样有害。 就算解析结果不回显给用户,攻击者仍可以让实体指向一个他控制的地址,把读到的内容带出去(外带信道)。所以"我不回显解析结果"不是防御——这和第 2 篇盲注、第 22 篇盲 SSRF 是同一条:不回显只是减速带。

④ 修复要在解析器上,不在输入上。 想靠正则过滤 <!DOCTYPE<!ENTITY 是第 1 篇那条老路——编码、大小写、异常空白都能绕,而且过滤和解析之间还隔着字符集处理。改开关,别改输入。

关于时效:XXE 的机制由 XML 规范决定,不变。变化最快的恰恰是各语言各库的默认值——过去十年整体在往"默认关掉"走,Python 现在就是关着的。正因为它在变,所以 §五③ 才要求你显式设置:依赖默认,等于依赖一个你没读过、还会改的东西。

七、本篇小结

XXE:你以为在"解析一份文档",可 XML 允许文档自己声明
     "这里插入某个文件/某个 URL 的内容",而解析器会照做。

⭐ 同一份文档、同一个解析器,只差 external_ges 一个布尔值:
   关 -> 解析出 ''      开 -> 解析出服务器上的密钥
   => 保护你的是一个【默认值】,不是一条定律。所以要显式设置,别依赖默认。
⭐ 换个协议就是 SSRF,而且比第 22 篇更隐蔽 ——
   代码里根本没有任何看起来像"发请求"的地方
⭐ 亿笑攻击是另一个开关:文档内部实体层层展开,几百字节吃掉几十 GB
   关 external_ges 挡不住它

防御:禁 DTD/外部实体(首选) · 显式设置每一处 · 限制实体展开 · 新接口用 JSON
边界:docx/xlsx/svg/SAML/SOAP 全是 XML,别说"我们没有 XML 接口" ·
      不回显只是减速带(盲 XXE 能外带) · 改开关别改输入

思考题

  1. §二 里两次解析唯一的区别是一个布尔值。据此说明:为什么"我们的语言默认是安全的"不能作为这一篇的结论?你会用什么办法确认线上真实的开关状态?
  2. XXE 和第 6 篇的反序列化,失效的假设几乎是同一句。把两者并排写出来,指出它们唯一的区别在哪。
  3. §三 说 XXE 顺手就是 SSRF,而且比第 22 篇更隐蔽。从"代码审计"和"运行时监控"两个角度分别说明为什么更难发现。
  4. 亿笑攻击不需要外部实体。为什么关掉 external_ges 挡不住它?要挡它得动哪个旋钮?
  5. §六② 列了一串"其实是 XML"的东西。在你负责的系统里找出至少一处你没意识到的 XML 解析入口。
  6. 有人提议用 WAF 过滤掉带 <!ENTITY 的请求体。用第 1 篇 §五 的四种失效方式逐条评价这个方案。
  7. 把 XXE 的"禁用 DTD”、第 6 篇的"换成 JSON"、第 3 篇的"换掉求值器"放在一起:它们拿掉的是同一样东西吗?用第 1 篇的定义统一表述。

相关第 6 篇:不安全反序列化 · 第 1 篇:注入的一般形式 · 第 22 篇:SSRF · Web 安全机制篇 · 事件分析