谈Express engine处理引擎的一个trick
Background
在关注ejs解析的时候发现express对render的处理有点意思,所以简单分析了下
0x01 流程链简析
当用express的解析模板引擎的时候,即使默认使用了ejs,但是也会有引擎修改的工程,大概调用链如下
render()->View()->tryRender->this.engine()
0x02 漏洞详情分析
在render函数代码里
app.render = function render(name, options, callback) {
var cache = this.cache;
var done = callback;
var engines = this.engines;
var opts = options;
var renderOptions = {};
var view;
// support callback function as second arg
if (typeof options === 'function') {
done = options;
opts = {};
}
// merge app.locals
merge(renderOptions, this.locals);
// merge options._locals
if (opts._locals) {
merge(renderOptions, opts._locals);
}
// merge options
merge(renderOptions, opts);
// set .cache unless explicitly provided
if (renderOptions.cache == null) {
renderOptions.cache = this.enabled('view cache');
}
// primed cache
if (renderOptions.cache) {
view = cache[name];
}
// view
if (!view) {
var View = this.get('view');
view = new View(name, {
defaultEngine: this.get('view engine'),
root: this.get('views'),
engines: engines
});
if (!view.path) {
var dirs = Array.isArray(view.root) && view.root.length > 1
? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"'
: 'directory "' + view.root + '"'
var err = new Error('Failed to lookup view "' + name + '" in views ' + dirs);
err.view = view;
return done(err);
}
// prime the cache
if (renderOptions.cache) {
cache[name] = view;
}
}
// render
tryRender(view, renderOptions, done);
};
/**
* Listen for connections.
*
* A node `http.Server` is returned, with this
* application (which is a `Function`) as its
* callback. If you wish to create both an HTTP
* and HTTPS server you may do so with the "http"
* and "https" modules as shown here:
*
* var http = require('http')
* , https = require('https')
* , express = require('express')
* , app = express();
*
* http.createServer(app).listen(80);
* https.createServer({ ... }, app).listen(443);
*
* @return {http.Server}
* @public
*/
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};
/**
* Log error using console.error.
*
* @param {Error} err
* @private
*/
function logerror(err) {
/* istanbul ignore next */
if (this.get('env') !== 'test') console.error(err.stack || err.toString());
}
/**
* Try rendering a view.
* @private
*/
function tryRender(view, options, callback) {
try {
view.render(options, callback);
} catch (err) {
callback(err);
}
关键代码在这一段
if (!view) {
var View = this.get('view');
view = new View(name, {
defaultEngine: this.get('view engine'),
root: this.get('views'),
engines: engines
});
view在没cache的情况下view变量默认是空的,就会在此处调用一个View(),而且当这个函数结束的时候,他会继续走一个tryRender函数
而在View函数中
function View(name, options) {
var opts = options || {};
this.defaultEngine = opts.defaultEngine;
this.ext = extname(name);
this.name = name;
this.root = opts.root;
if (!this.ext && !this.defaultEngine) {
throw new Error('No default engine was specified and no extension was provided.');
}
var fileName = name;
if (!this.ext) {
// get extension from default engine name
this.ext = this.defaultEngine[0] !== '.'
? '.' + this.defaultEngine
: this.defaultEngine;
fileName += this.ext;
}
console.log(this.ext)//debug data
if (!opts.engines[this.ext]) {
// load engine
var mod = this.ext.slice(1)
debug('require "%s"', mod)
// default engine export
var fn = require(mod).__express
if (typeof fn !== 'function') {
throw new Error('Module "' + mod + '" does not provide a view engine.')
}
opts.engines[this.ext] = fn
}
// store loaded engine
this.engine = opts.engines[this.ext];
// lookup path
this.path = this.lookup(fileName);
}
可以看到 opts.engines[this.ext] 如果不为空 他会取this.ext的值然后来调用require函数
有意思的地方在于
var fn = require(mod).__express
if (typeof fn !== 'function') {
throw new Error('Module "' + mod + '" does not provide a view engine.')
}
opts.engines[this.ext] = fn
在这里函数 express 被导入然后定义在opts.engines[this.ext] 也就是说现在engine里有`express`函数
了解了下这个函数,那其实只要可控我们就能rce了
这里主要就是我们要看看他是如何取到这个ext的
我测试的时候发型现他对后缀没有处理
> extname('1.ttt')
'.ttt'
继续往下走,当他继续走tryRender 他会经过view.render(options, callback)
然后这个this.engine函数就可以被执行了
0x03 漏洞利用
其实早在很多CTF中,我就关注过这个引擎解析,2021的LineCTF里提到
a.ejs.b.c.hbs 会require hbs进来 也就是说如果我们在views里面有其他类型的文件 比如xxx.ttt 他经过render就会执行代码,但其实这个还有另一种利用方法
我们可以写一个测试代码,大致如下
app.set('view engine', 'ejs');
app.get('/', (req,res) => {
const page = req.query.filename
res.render(page);
})
当对filename传参为不附加后缀的。他会默认使用我们的ejs解析,也就是说
127.0.0.1/?filename=1
127.0.0.1/?filename=1.ejs是等价的
当我们键入一个自定义后缀123.ttt时候,会像前文提到的这样处理ttt
var mod = this.ext.slice(1)
debug('require "%s"', mod)
// default engine export
var fn = require(mod).__express
如果我们有一个文件上传位点可控,能把文件夹传到node_modules下,其实就可以进行__express函数的使用了
首先我在node_modules下建立一个ttt文件夹,把文件夹里面添加一个index.js内容如下
exports.__express = function() {
console.log(require('child_process').execSync("id").toString());
}
然后键入任意文件名,后缀为ttt即可调用
当我们访问127.0.0.1/?filename=1.ttt时候进行debug会发现
他的engines内容是我们键入的代码
而对照虽然default是ejs,但我们还是在engine里进行替换了我们要执行的函数
0x04 总结
虽然在较高版本,这个缺陷已经被修复了,而且修复方式有很多种,最好的就是检测后缀,但这个思路是比较有趣的,而且很有可能会被出在一些ctf比赛上