XML 外部实体处理可能让解析器按照文档提供的引用,使用应用自身的权限获取资源。具体解析器及配置不同,结果可能是读取文件,也可能是向其他服务发起请求。支持 XML 不等于必然存在漏洞;需要先识别真正使用的库、已启用功能、资源解析器和后续处理步骤,才能判断输入究竟能够触发什么操作。

外部访问与内部展开不是一回事

外部解析实体可以用系统标识符指向资源。应用启用解析、进程又有能力访问时,读取文档便可能加载它。把内容反射到响应中只是泄露的一种形式;即使响应没有带回资源,向外发出的请求本身仍可能产生影响。访问范围同时受到解析策略、文件权限、网络控制和目标服务认证的限制,并不是一个 URL 就能保证拿到任意内部秘密。

内部实体则可以完全在文档中展开,不需要文件或网络。在保护不足的解析器中,重复嵌套替换可能消耗大量资源,这与 XXE 是不同问题。一个有明确上限的一千字符示例,只证明发生了替换,不能证明现代解析器允许无限放大。实体递归引用自身不符合 XML 规则,受支持的实现还可能在内存耗尽之前就拒绝过度放大,不能把所有解析器都描述成必然陷入死循环。

在解析开始前确定政策

输入格式不需要 DTD 时,应使用解析器支持的策略或回调拒绝文档类型声明。同时按具体实现关闭不需要的外部普通实体、参数实体和外部子集。库与版本的默认行为不同,仅禁止网络访问可能仍允许读取本地文件;只做 schema 校验,也可能已经晚于危险的初次解析。应检查实际入口怎样构建解析器,而不是在配置文件中找到一个相似名字就认为已经完成防护。

确实需要外部资源时,只把明确批准的标识映射到受控资源。schema 导入、XInclude 与转换器要单独审阅,XXE 开关未必控制这些加载器。还要限制字节数、嵌套深度与资源消耗,并维护解析库版本。一个短包装函数不可能自动抵御全部畸形输入、压缩炸弹或后续处理风险,政策边界应如实写明。

坏掉的样子

只在原始字节中搜索 ASCII 的 DOCTYPE 拼写,可能漏掉 UTF-16 之类编码后的声明;简单文本搜索又会误杀包含该字样的普通注释。应让理解编码和语法的解析器识别结构,再在声明边界执行政策。成功拒绝一条熟悉攻击字符串,不等于覆盖了入口接受的所有编码,也不等于找到了所有可能读取资源的路径。

SVG 上传可能进入服务器端 XML 解析器或图像转换器。该环节的配置决定是否加载外部引用,不能因为文件是 SVG,就声称每种浏览器都会自动解析外部实体。应检查预览、导入以及后续重解析的每一环;第一个解析器安全,不会替第二个开启不同加载器的组件提供保护。测试对象应当是实际管线,而不是只看文件扩展名。

快速验证

保存为 xxe_probe.py,使用受维护的 Python 3 执行 python3 xxe_probe.py。实验采用标准库 Expat 回调、固定内存字节和明确的小规模限制,不安装任何打开文件或发起网络请求的解析器。拒绝声明中的路径与域名只是测试输入,不会访问对应资源,也不会读取真实系统文件。

from xml.parsers import expat

MAX_BYTES = 4096
MAX_DEPTH = 32


def parse_without_dtd(data):
    """Bounded demonstration policy, not a general XML security library."""
    if not isinstance(data, bytes):
        raise TypeError("supply original encoded bytes")
    if len(data) > MAX_BYTES:
        raise ValueError("input too large")
    parser = expat.ParserCreate()
    text = []
    depth = 0

    def reject(*args):
        raise ValueError("DTD and entity declarations are disabled")

    def start(name, attrs):
        nonlocal depth
        depth += 1
        if depth > MAX_DEPTH:
            raise ValueError("too deeply nested")

    def end(name):
        nonlocal depth
        depth -= 1

    parser.StartDoctypeDeclHandler = reject
    parser.EntityDeclHandler = reject
    parser.ExternalEntityRefHandler = reject
    assert parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER)
    parser.StartElementHandler = start
    parser.EndElementHandler = end
    parser.CharacterDataHandler = text.append
    parser.Parse(data, True)
    return "".join(text)


internal = ('<!DOCTYPE r [<!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;">]><r>&c;</r>')
# A bounded baseline: ordinary internal expansion, not a resource-exhaustion test.
baseline = expat.ParserCreate()
chunks = []
baseline.CharacterDataHandler = chunks.append
baseline.Parse(internal.encode(), True)
assert len("".join(chunks)) == 1000
assert parse_without_dtd(b'<r>hello &amp; goodbye</r>') == 'hello & goodbye'
assert parse_without_dtd(b'<r><!-- literal <!DOCTYPE is only a comment -->ok</r>') == 'ok'
external = '<!DOCTYPE r [<!ENTITY e SYSTEM "file:///not-read-by-this-demo">]><r>&e;</r>'
network = '<!DOCTYPE r SYSTEM "https://example.invalid/never-requested"><r/>'
svg = '<!DOCTYPE svg [<!ENTITY e "text">]><svg>&e;</svg>'
rejected = []
for name, payload in [
    ("internal", internal.encode()), ("file", external.encode()),
    ("external-subset", network.encode()), ("svg", svg.encode()),
    ("utf16-dtd", external.encode("utf-16")),
    ("oversize", b'<r>' + b'x' * MAX_BYTES + b'</r>'),
    ("depth", b'<r>' * (MAX_DEPTH + 1) + b'</r>' * (MAX_DEPTH + 1)),
]:
    try:
        parse_without_dtd(payload)
    except ValueError:
        rejected.append(name)
    else:
        raise AssertionError(f"policy accepted {name}")
assert len(rejected) == 7
try:
    parse_without_dtd(b'<r>unfinished')
except expat.ExpatError:
    pass
else:
    raise AssertionError("malformed XML accepted")
print("Expat:", expat.EXPAT_VERSION)
print("PASS: ordinary text/comment accepted; seven policy cases and malformed XML rejected")

完整实验在 Python 3.12.14 通过,并打印链接的 Expat 版本。基线把有限内部实体链展开为恰好一千字符。限制解析器允许普通文字、预定义的与号实体以及无害注释;它拒绝内部声明、文件实体、外部子集、SVG DTD,也拒绝同一文件声明的 UTF-16 编码形式。这里证明的是解析政策生效,而不是敏感文件曾经被成功读出。

另外两个案例分别超过四千零九十六字节与三十二层嵌套限制;语法不完整的 XML 必须引发解析器语法异常。这些检查将主动政策拒绝与无关错误区分开来。UTF-16 对照尤其说明,限制发生在识别编码之后的结构边界,而不是依赖某一种 ASCII 字节模式。所有输入都很小,不需要真的制造内存耗尽来验证判断分支。

这是一项有边界的解析实验,不是对文件、网络目标或上传服务的利用测试。它不验证 schema 处理、转换器、后续重解析、压缩请求处理或生产资源隔离。处理不可信文档时,应使用受维护的加固 XML 设施,并让实际应用入口重新跑相应政策测试。示例通过不能替代整条输入链的审查,也不能推导所有下游组件采用了同一套配置。

延伸阅读

来源

  • Python 3.12 Expat 接口,声明回调、参数实体策略与编码输入,已核验 2026-10-15。
  • Python XML 安全,与库和版本相关的漏洞边界,已核验 2026-10-15。
  • OWASP XXE 防护,按解析器限制外部资源,已核验 2026-10-15。
  • W3C XML 1.0,实体、文档类型与禁止递归引用,已核验 2026-10-15。