今年のうちに対応したい、Node.jsのBufferに潜む危険性

  • このエントリーをはてなブックマークに追加

Yahoo! JAPAN Tech Advent Calendar 2016の4日目の記事です。一覧はこちら

こんにちは。Node.js言語サポートチームの加藤佑典です。

普段はYahoo!ブックストアの開発/運用を主にしています。

先日、同じチームの柄澤がNode学園祭 2016の発表でも少し触れていますが、今期から社内でNode.jsの言語サポートチームが発足しました。

本日はそのチームメンバー+有志で行っているNode.js Core APIの勉強会で話題になった、Buffer APIの変更点について触れたいと思います。

本記事はNode.js v6.9.1 Documentationをベースに書いています。

1. はじめに

勉強会は、以下のようにAPIをカテゴリ分けし、基礎的なAPIであるカテゴリー1のものから順に進めています。


カテゴリー1: 基本的で他に依存性のないモジュール群

カテゴリー2: Node固有のもので本体の動作に関連するモジュール群

カテゴリー3: OSの機能や他のライブラリと関連するモジュール群

(その他のモジュール群に続く)


今回、Buffer APIの担当になった際に「今までBufferは(fsなどで利用してはいたものの、あまり意識しては)使ったことないな…」
とつぶやいたところ、「Bufferを使ってないのはNode.jsを使っていないのと同じだ」と怖いおじさんに指摘されました。

まずはそのBufferの概要について軽く触れたいと思います。

2. Bufferことはじめ

(基礎的な内容のため、ご存知の方は飛ばして頂いて構いません。)

Bufferは、バイナリデータをNode.jsで取り扱うためのクラスです。
このBufferはFile SystemモジュールやTLS/SSLモジュールなど、Node.jsの中で広く利用されています。

JavaScriptでバイナリデータというと、Uint8ArrayなどのTypedArrayが想像できると思います。

ES6でTypedArrayが導入されたことにより、Node.jsでは、v4系以降はUint8Array APIをベースとした実装になっています。

しかし、ES6導入以前は独自実装されていました。そのため、今もBufferには様々メソッドが生えており、TypedArrayの仕様とは微妙に互換性がありません。

一例として、slice()の挙動の違いが挙げられます。
Buffer#sliceの実装は、既存のBufferのコピーなしで作成するのに対し、TypedArray#sliceの実装はコピーを作成するため、動作に違いが出ます。

var buf1 = Buffer.from([1,2,3]);
var buf2 = new Uint8Array([1,2,3]);

var test = buf1.slice(1);
var test2 = buf2.slice(1);

buf1[2] = 4;
buf2[2] = 4;

console.log(test); // <Buffer 02 04>
console.log(test2); // Uint8Array [ 2, 3 ]

3. new Buffer()について

今までBufferを扱うためには、以下のようにBufferコンストラクタを使って生成する必要がありました。

例えば文字列の場合:

const buf = new Buffer('Yahoo! JAPAN');

しかし、このコンストラクタを使うnew Buffer()は、危険性が指摘されたため、Node.js v6.x と v4.x で共に非推奨になりました。


3.1 new Buffer(size)の仕様について

危険性に迫る前に、まずはnew Buffer()の仕様について確認したいと思います。

以下のコードを御覧ください。

const sec = 'mysecretpassword';
const pass = new Buffer(sec);
var string;
while(1) {
    string = (new Buffer(1024)).toString();
    if(/sec/.test(string))
        break;
}
console.log(string);

そして実行した結果です。

$ node new_buffer.js

(略)
��GmysecretpasswordH\贃8��&�prototypeH\�#
(略)

new Buffer(sec)した内容が丸見えになっているのがわかると思います。

new Buffer(size)は、ご存知の通りsize byte分の初期化されていない領域を確保します。

この初期化されていないBufferのデータには、コードの別のところで定義されているパスワードや個人情報などの重要なデータが含まれてしまっている可能性があります。

そのため、new Buffer(size)を利用する場合は.fill(0)などを用いて初期化するのが一般的です。


3.2 new Buffer()に潜む危険性

new Buffer(size)の挙動のおさらいをしました。

このようにnew Buffer(size)はデフォルトでは初期化されていないデータを確保します。
未初期化データがコード内に留まっている場合は問題ないのですが、使い方によっては思わぬ危険を招くことになります。

以下は簡易的にサーバを立てたサンプルコードですが、このコードには危険な箇所が含まれています。どこかわかりますか?

var server = http.createServer(function (req, res) {
  var data = ''
  req.setEncoding('utf8')
  req.on('data', function (chunk) {
    data += chunk
  })
  req.on('end', function () {
    var body = JSON.parse(data)
    res.end(new Buffer(body.str).toString('hex'))
  })
})

server.listen(8080)

(引用元:https://github.com/nodejs/node/issues/4660)

問題なさそうに見えるコードですが、body.strに注目してみましょう。
一見、body.strはString型が来るように見えますが、bodyは外部からのリクエストを受け付けているため、ここの値がStringである保証はありません。

つまり、

{ str: 'Yahoo! JAPAN' }

のようなデータだけでなく

{ str: 1024 }

のようなデータが送信されてくる可能性もあります。

あとはわかりますよね。
数値が送られてきた場合は、先ほどのnew Buffer(size)のように初期化されていない領域が見えてしまいます。

  • (悪用される可能性の一例)

このように、入力チェックが不十分で、意図せず数値が渡った場合、上図のような危険性をはらみます。
これは、new Buffer()が引数に様々な型を受けることができる仕様のため起きる問題です。

この危険性は、記憶に残っているエンジニアも多いと思いますが、OpenSSLのHeartbleedのように機密性の高いデータが見ることができてしまう可能性があるということです。


3.3 実際に修正された例

事実として、今年1月、WebSocketモジュールでこのBufferの危険性が元になった脆弱性が見つかり、修正されました。

WebSocketモジュールでは、client.ping()時の引数に数値が送られた場合、初期化されていないバッファが返される状態になっていました。
リリースタグ:
https://github.com/websockets/ws/releases/tag/1.0.1

4. 新しいAPIの登場

new Buffer()は初期の頃から実装されているAPIで、影響範囲も大きいため、new Buffer()の挙動を変更すべきかどうかは大きな議論になりました。

なぜなら、今回の危険性を修正するために、new Buffer(size) をデフォルト0で初期化するように挙動を変更すると、これまで真面目に .fill(0) で初期化をしてきたコードに対しては2重に初期化をされてしまうことになり、少なからずパフォーマンスに悪影響を与えることになるからです。

最終的に、Lets-fix-Buffer-API.mdの思想に則りに、new Buffer()の挙動の変更する方向ではなく、新しいAPIを追加することになりました。


4.1 Buffer.from/Buffer.alloc/Buffer.allocUnsafe

その新しく追加されたAPIがBuffer.fromBuffer.allocBuffer.allocUnsafeです。

new Buffer(size)はBuffer.alloc/Buffer.allocUnsafeへ、
それ以外のString,Array,ArrayBuffer,Buffer型を引数としたnew Buffer()はBuffer.from()へ移行が推奨されています。

まず、今回問題となったnew Buffer(size)と同様の問題が起きないかを確認しましょう。

> Buffer.from(10)
TypeError: "value" argument must not be a number
    at Function.Buffer.from (buffer.js:93:11)
    at repl:1:8
    at sigintHandlersWrap (vm.js:22:35)
    at sigintHandlersWrap (vm.js:96:12)
    at ContextifyScript.Script.runInThisContext (vm.js:21:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.<anonymous> (repl.js:513:10)
    at emitOne (events.js:101:20)
>

このように、Buffer.fromにはNumber型は用意されていません。

また、alloc,allocUnsafeは関数名からもわかるように、allocの場合は0フィルされるように、allocUnsafeは初期化されていないものとなっています。

> Buffer.alloc(1024)
<Buffer 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... >

> Buffer.allocUnsafe(1024)
<Buffer 04 20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 05 02 01 00 00 00 0a 00 00 00 00 00 00 00 71 52 00 02 01 00 00 00 05 00 00 00 00 00 00 00 00 01 ... >

4.2 v4.x系へのバックポート

今回のBuffer.fromとBuffer.alloc,Buffer.allocUnsafeに関しては6系だけではなく、
v4.5.0に対してもバックポートされています。

これは、今回のnew Buffer()の件がセキュリティ上の深刻な問題のため、LTS(v4)へも例外的に新規APIとして追加された形になります。

そのため、現在4系を利用している場合は4系(LTS)のまま置き換えることが可能です。

なお、それ以前のバージョンにはバックポートされていないため、v6.9.x(LTS)へ移行しましょう。

5. 起動オプションの追加

new Buffer()は新しく作成されたAPIへの移行するのが推奨されています。

しかし、例えばnpmからインストールされているモジュールが利用している場合もありえますし、修正すべてを短期間で行うことは現実的ではない場合もあると思います。

そんなときは—zero-fill-buffersを起動時につけることによって、簡単にこれらの危険を排除することができます。

以下のように、起動時にオプションを追加することにより、new Buffer,Buffer.allocUnsafe,Buffer.allocUnsafeSlow,new SlowBufferなどを含めたすべてのBufferは自動的に0フィルされるようになります。

$ node --zero-fill-buffers
> Buffer.allocUnsafe(5);
<Buffer 00 00 00 00 00>

ただし、—zero-fill-buffersオプションは、上記のメソッド(new Buffer,Buffer.allocUnsafe,Buffer.allocUnsafeSlow,new SlowBufferなど)のデフォルトの動作を変更するオプションです。

特に、既に0フィルしている場合は、前述した通り2重で0フィルすることになるため、パフォーマンスに少なからず影響が出る可能性があることを考慮の上、利用しましょう。

個人的には、修正範囲が多岐にわたる場合や外部モジュールの修正をしないといけない場合は、この起動オプションを用いて危険性を取り除いたあとにきちんとBuffer.fromなどへ移行することをオススメしたいと思います。
もちろんパフォーマンスが悪化する可能性が高いため、そのあたりは時と場合によって判断しましょう。

こちらの—zero-fill-buffersオプションも、新しいAPIと同様にv4系へのバックポートがされています。

なお、繰り返しになりますが、それ以前のバージョンにはバックポートされていないため、v6.9.x(LTS)へ移行しましょう。

6. まとめ

今回の記事では

  • new Buffer()の危険性について
  • Buffer.from, Buffer.alloc, Buffer.alloc,Buffer.allocUnsafe について
  • —zero-fill-buffersオプションについて

という、Buffer周りのトピックを取り扱いました。

「今年の危険性は今年のうちに」ということで、ここまで読まれた方は今年中に手元のコードを一度見直していただければと思います。

これからもNode.js 言語サポートチームから情報発信していく予定です。

それでは、今年もあと少しですが、引き続きYahoo! JAPAN Tech Advent Calendar 2016をお楽しみください!

参考

Node.js Buffer API Changes

Let’s fix Node.js Buffer API.

Node.js v6.9.1 Documentation

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加