分析版本: halo 2.22.14

提交哈希: f4fbe13657c2a316fb7371d8b571026148ee4fed

前言

笔者本身使用的就是 halo 博客,在挑选博客主题和查阅官方文档时,于偶然间思考并发现了这个漏洞。纯属侥幸之举。

漏洞概述

需要条件: 作者权限或者更高 (满足 uc:attachments:manage、uc:posts:manage、uc:posts:publish 权限即可) ; 能够上传 html 文件(默认存储策略即可满足)。当然值得注意的是,官方仓库存在过一个安全通告: File Upload Bypass

即低于这个版本的 halo 博客,即使是修改了存储策略,依然会容易受到影响。

简略的利用过程以及原理:

登录低权限(作者)用户 --> 通过上传模板到附件 --> 目录穿越将模板名称指向到附件目录恶意附件 --> 访问文章路由触发 Thymeleaf 渲染

--> SSTI 表达式执行上下文具备 Bean 调用能力 --> 调用 @userServiceImpl 对用户提权到超级管理员 --> 然后采用本地插件安装提权到RCE。

补充说明:

对于前面的提权,我们的利用链期望是直接通过本地插件安装的形式进行RCE,这属于官方提示风险入口,也算是属于超级管理员权限风险预期功能内的事,所以走这条链比较简单也更有特色,就不再尝试直接通过 SSTI 去直接 RCE

我们当然也可以直接对已经有的超级管理员账户进行密码重置,但是避免污染或其他情况,我们全流程在测试账号上即可。

环境搭建

我的部署方式是 1panel 一键部署。

先用超管账号登录后台,创建一个测试用户,权限分配为作者。

用户名 / 密码: user2 / user2.123

然后来到 附件 --> 存储策略直接采用默认,什么都不用改。

示例,什么都没改,全是默认

以上,便足够了。

POC

这个暂时不公开。有兴趣的朋友也可以直接根据文章自己分析一下。

代码分析

完整的攻击数据流图 (放大观看即可)

模板渲染

Halo 使用 Thymeleaf 作为后端模板引擎,后缀为 .html halo theme

在完整的分析之前,不妨先看研究一下 halo 在处理访问时的模板渲染过程。

下面以访问文章详情为例:

根据开发文档,配置好本地 idea dev环境,然后调试启动,清空日志,点击一下前端路由:

http://localhost:8090/archives/hello-halo

回到控制台查看debug日志过程

忽略 SQL 过程,只关注 http 路由和模板渲染过程,可以发现当命中 /archives/hello-halo 文章详情路由的时候,会进入路由工厂进行构造。并且因为我们是文章详情页,所以被分配到了 app.theme.route 主题路由的 PostRouteFactory

中间的具体实现,我们不管,直接进入 PostRouteFactory

halo 的 Web 层框架是 WebFlux : https://docs.halo.run/developer-guide/core/framework/

根据 import 导包,我们可以看到这里使用的就是 WebFlux Functional Endpoints API

在 Spring 应用启动时会执行 create 逻辑进行构建路由表,然后在发生请求后,根据路由请求匹配,选择 HandlerFunction

这里 create 出来的逻辑是:

请求形如 /?slug=hello-halo 的时候,走 queryParamHandlerFunction 分支

请求形如 /archives/{slug} 的时候,走 handlerFunction 分支

显然我们走的就是 handlerFunction 分支。

继续跟进几步,很明显的 postResponse 即对本次请求的响应。

而该获取的文章的内容,tag 等,都在 postVo 中存储完了,那这里还剩一个 determineTemplate 是干什么的?自然是在 render 之前,决定用哪个模板文件/视图名。

这里我们先对 determineTemplate 函数实现简化一下选择逻辑,即: 分类模板(按顺序首个命中) --> 文章模板 --> 默认 post 模板。

determineTemplate 的主要功能就是获取到当前请求应该加载的模板名,提供给后续渲染引擎。

模板选择完了,自然就该将选中的模板 + 数据 model map化的 postVo)交给视图引擎渲染成 HTML了。

ServerResponse.ok().render(templateName, model))

现在想要知道 Spring WebFlux 采用了什么样的方式去处理 render ,我们不需要去研究 config

回到控制台,再来一次抓取 http://localhost:8090/archives/hello-halo 请求 debug 日志

继续忽略掉 SQL ,我们的下一跳来到了 HaloViewResolver$HaloView ,并且右边和前面分析一致,携带 viewmodel 数据。

即前面的 ServerResponse.ok().render(templateName, model)) 来到了 HaloViewResolver.HaloView.render()

在交给 super.render 之前,会进行一次 setTemplateEngine(engineManager.getTemplateEngine(theme));

即根据主题选择对应的模板引擎。

然后继续交给 super.render 处理,即 ThymeleafReactiveView

其中 ThymeleafReactiveView.render 又继续交给他的 super.renderAbstractView.render

需要注意的一点是 AbstractView.render 中调用 getModelAttributes 方法时,是多态调用。实际上是使用的

HaloView.getModelAttributes 重写的方法。

下文的 renderInternal 也是多态调用,实际使用的是 ThymeleafReactiveView.renderInternal 实现。

最终进入 processStream 调用模板引擎,进行渲染输出。(Thymeleaf 表达式执行 )

后续逻辑依然先简化一下,简单说就是根据前面 determineTemplate 获取的模板名,然后去定位并读取模板内容,然后解析其中的 Thymeleaf 表达式。

目录穿越

经过前面的 halo 模板渲染流程分析,大致了解了 halo 的渲染流程。

但是现在我们想要实现 SSTI ,即需要模板内容可控。但是我们仅仅是一个作者权限,是没有操作主题文件权限的。

天无绝人之路,有一个地方存在目录穿越,能够允许我们直接加载到附件路径,而附件内容显然是我们可控的。

回到前文,模板渲染逻辑是根据前文 determineTemplate 获取的模板名,然后去定位并读取模板内容,然后解析其中的 Thymeleaf 表达式。

那只要获取的模板名,这个参数可被我们控制,就大概率能选择到我们的恶意模板了。

当我们以作者身份,进行创建自己的文章的时候,会发现存在 template 字段可控。

点击新建

点击保存

template 非空时,会被保存。

name 随便自己改,符合 UUID , slug 且不重复即可

然后我们来验证猜想,流程为先发布,然后去访问这篇文章一次触发渲染,并且在前文 PostRouteFactory.determineTemplate 进行断点,观察模板选择。 即访问 http://192.168.10.29:8090/archives/{文章名称}

在访问前,我们回顾 determineTemplate 模板选择逻辑 : 分类模板(按顺序首个命中) --> 文章模板 --> 默认 post 模板。

当分类模板遍历解析失败的手,会往下尝试解析文章模板,我们创建->发布文章的时候,完全没有选择分类,则我们默认就可以绕过前面的分类模板优先级。 然后对于文章模板解析,这里我们首次传递 "template":"../../../" 但是在解析的时候肯定是失败的,会回退到默认的 post 模板。

所以我们先回到文章管理位置,上传一个附件,为 test.html

得到被重命名的 "permalink": "/upload/test-SEQu.html"

回到主题相对路径,根据 post 模板位置,可以尝试构造出 :

"template": "../../../attachments/upload/test-SEQu.html"

现在重新创建一次文章,然后按照前面修改 template

然后发布并访问 http://192.168.10.29:8090/archives/bbbb 触发模板渲染。

可见 templateName 没有被清洗。

继续回到原先分析的渲染逻辑,直接在 ThymeleafReactiveView.renderFragmentInternalprocessStream 处下断点。

可见,再往里面进就是将 ../../../attachments/upload/test-SEQu.html 的内容当作表达式解析。

好的,现在上面是通过实例来说明,存在目录穿越。现在再通过代码角度看看,参数是怎么处理的。

在创建文章的时候UcPostEndpoint.createMyPost 在接收 Spec 的时候,没有经过任何处理,直接解析原 json ,填入 post 对象。

然后继续传递到 : PostServiceImpl.draftPost --> ReactiveExtensionClientImpl.create --> JSONExtensionConverter.convertTo

进行数据存储,这一路都没有路径清洗,不再赘述。

Bean提权

现在可以对任意模板内容进行表达式解析halo 这个版本使用的是 Thymeleaf 3.1.3

我们可以采用两种提权路径

1:直接使用 Thymeleaf 3.1.3 已知绕过进行 RCE

2:通过 Spring Bean 获取权限提升接口 Bean,对当前用户权限提升至超级管理员,然后走本地插件安装稳定 RCE

下面我们使用具有 halo 特色的提权,即第二种。

这个模板执行环境是 SpringEL + Spring ApplicationContext,允许 @beanName 语法按名称取 Bean

Sping 应用将 UserServiceImpl 注册为了 Bean

其中有提权方法: userServiceImpl.grantRoles

采用的提权表表达式将 user2 提权为 super-role 权限,用以获取本地插件安装的权限。

${@userServiceImpl.grantRoles('user2', {'super-role'})}

插件RCE

提权后,重新登录一下 user2

然后本地插件安装,没什么好说的,构建一个恶意 jar 包,安装就行。

国家一级保护废物