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