Skip to main content

NodeJSのC/C++アドオン拡張機能における脆弱性

著者
Headshot of Alessio Della Libera

Alessio Della Libera

feature snyk platform learn using snyk with CI CD

2024年8月14日

0 分で読めます

この調査の主な目的の一つは、NodeJSのnpmパッケージにおけるC/C++の脆弱性を調べることでした。NodeJSのC/C++アドオンを対象に、バッファオーバーフロー、サービス拒否(プロセスのクラッシュ、型チェックの欠如)、メモリリークといった典型的な脆弱性を調査・特定し、関連するソース、シンク、サニタイザーをSnyk Codeでモデル化することに重点を置きます(Snyk brings developer-first AppSec approach to C/C++を参照)。

今回の調査対象は、実装の一部にC/C++インターフェースを使用しているNPMパッケージです。NPMに登録されていないプロジェクトは対象としていません。

このブログ記事では、NodeJSでC/C++アドオンを作成する際に発生しうる、一般的なセキュリティ脆弱性や脆弱なパターンの概要を紹介します。また、オープンソースのメンテナー向けに、修正例や提案も紹介します。

このブログ記事は、Cristian-Alexandru Staicu、Sazzadur Rahaman、Àgnes Kiss、Michael Backesによる論文「Bilingual Problems: Studying the Security Risks Incurred by Native Extensions in Scripting Languages」に着想を得ています。[1] 原論文では、JavaScriptを含む人気のプログラミング言語におけるネイティブ拡張機能のセキュリティリスクを分析しています。

NodeJSのC/C++アドオンの概要

NodeJSには、ネイティブのC/C++コードを呼び出すためのさまざまなAPIがあります。この調査では、次のいずれかの方法を使用した場合に発生しうるセキュリティ脆弱性を調べます。

上記のライブラリの使用例は、GitHubで確認できます。

アドオンとそのビルド方法について詳しくは、NodeJSの公式ドキュメントを参照してください。

少なくとも1つのパッケージで確認された脆弱性は次のとおりです。

  • メモリリーク

  • 型チェックの欠如(DoS)

  • 到達可能なassert(DoS)

  • 未処理の例外(DoS)

  • バッファオーバーフロー

  • 整数オーバーフロー

以下のセクションでは、脆弱なパターンの例と、脆弱性を悪用可能にするために満たす必要がある条件を説明します。

脆弱なパターンの例

このセクションでは、アドオン固有のAPIが適切に処理されない場合にセキュリティ上の問題を引き起こす仕組みと、この調査で特定した脆弱なパターンを見ていきます。

注: 以下の例は、網羅的なリストではありません。このブログ記事で取り上げていないシナリオも、セキュリティ上の問題につながる可能性があります。

このブログ記事では取り上げていないセキュリティ上の問題につながる可能性があります。

セットアップ

node-gypをインストールします(https://github.com/nodejs/node-gyp)。

次のセクションの例を実行するには、以下のファイルを使用します。

package.json

{
  "main": "main.js",
  "private": true,
  "gypfile": true,
  "dependencies": {
    "bindings": "^1.5.0",
    "nan": "^2.18.0",
    "node-addon-api": "^7.0.0"
  }
}

binding.gyp

{
  "targets": [
    {
      "target_name": "test_napi_exceptions",
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "sources": [ "test_napi_exceptions.cpp" ],
      "include_dirs": [
        "<!@(node -p \"require('node-addon-api').include\")"
      ],
      'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ], # if this line is commented, all the tests in test_napi_exceptions.cpp will not crash the process
    },
    {
      "target_name": "test_node_api_assert",
      "sources": [ "test_node_api_assert.c" ]
    },
    {
      "target_name": "test_napi_unchecked_type",
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "sources": [ "test_napi_unchecked_type.cpp" ],
      "include_dirs": [
        "<!@(node -p \"require('node-addon-api').include\")"
      ],
      'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ], # if this line is commented, all the tests in test_napi_unchecked_type.cpp will not crash the process
    },
    {
      "target_name": "test_napi_memory_leak",
      "sources": [ "test_napi_memory_leak.c" ]
    }
  ]
}

C/C++拡張機能をビルドするには、次のコマンドを実行します。

  • node-gyp configure

  • node-gyp build

特定の例を実行する:

node main.js <test1|test2|...>

main.js

const test_napi_exceptions = require('bindings')('test_napi_exceptions');
const test_node_api_assert = require('bindings')('test_node_api_assert');
const test_napi_unchecked_type = require('bindings')('test_napi_unchecked_type');
const test_napi_memory_leak = require('bindings')('test_napi_memory_leak');

function test1(){
    console.log('[+] Running test1');
    try {
        console.log(test_napi_exceptions.test1('foo', 'bar')); // TEST1 - OK
        console.log(test_napi_exceptions.test1('foo')); // throws an exception
    } catch (e) {
        // executed
        console.log(e); // TypeError: TEST3 - Err1
    }

    try {
        test_napi_exceptions.test1(1); 
        /*
            FATAL ERROR: Error::ThrowAsJavaScriptException napi_throw
            ...
            Aborted
        */
    } catch (e) {
        console.log(e);
    }
}

function test2(){
    console.log('[+] Running test2');
    try {
        console.log(test_napi_exceptions.test2('foo', 'bar')); // TEST2 - OK

        console.log(test_napi_exceptions.test2('foo'));
         /*
        terminate called after throwing an instance of 'Napi::Error'
        Aborted
        */

    } catch (e) {
        console.log(e);
    }

}

function test3(){
    console.log('[+] Running test3');
    console.log(test_napi_exceptions.test3('foo', 'bar', 'baz')); // TEST3 - OK

    try {
        console.log(test_napi_exceptions.test3('foo', 'bar')); 
    } catch (e) {
        console.log(e); // TypeError: TEST3 - Error2
    }

    console.log(test_napi_exceptions.test3('foo')); 
    /*
        FATAL ERROR: Error::ThrowAsJavaScriptException napi_throw
        ...
        Aborted
    */
}

function test4(){
    console.log('[+] Running test4');
    try {
        console.log(test_node_api_assert.test1());
    } catch (e) {
        console.log(e); // TypeError: Wrong number of arguments
    }

    try {
        console.log(test_node_api_assert.test1(1)); // 2

        console.log(test_node_api_assert.test1('1'));
        /*
        node: ../test_Assert.c:24: Test1: Assertion `status == napi_ok' failed.
        Aborted
        */
    } catch (e) {
        console.log(e);
    }
}

function test5(){
    console.log('[+] Running test5');

    console.log(test_napi_unchecked_type.test1('foo')); 
    // foo
    // TEST1 - OK

    console.log(test_napi_unchecked_type.test1({'foo': 'bar'})); 
    // [object Object]
    // TEST1 - OK

    try {
        test_napi_unchecked_type.test1({'toString': 'foo'});
        /*
        FATAL ERROR: Error::New napi_get_last_error_info
        ...
        Aborted
        */
    } catch (e) {
        console.log(e);
    }

}

function test6(){
    console.log('[+] Running test6');

    console.log(test_napi_unchecked_type.test2({'foo': 'bar'})); 
    // bar
    // TEST2 - OK

    try {
        test_napi_unchecked_type.test2({'foo': {'toString': 'foo'}});
        /*
        FATAL ERROR: Error::New napi_get_last_error_info
        ...
        Aborted
        */
    } catch (e) {
        console.log(e);
    }

}

function test7(){
    console.log('[+] Running test7');

    console.log(test_napi_unchecked_type.test3(1)); 
    // 1
    // TEST3 - OK

    console.log(test_napi_unchecked_type.test3({'foo': 'bar'})); 
    // nan
    // TEST3 - OK

    try {
        test_napi_unchecked_type.test3({'toString': 'foo'});
        /*
        FATAL ERROR: Error::New napi_get_last_error_info
        ...
        Aborted
        */
    } catch (e) {
        console.log(e);
    }

}

function test8(){
    console.log('[+] Running test8');
    console.log(test_napi_memory_leak.test1(10)); // Xtest1In
    console.log(test_napi_memory_leak.test1(30)); // Xtest1InitTest14

}

const tests = new Map();
tests.set('test1', test1);
tests.set('test2', test2);
tests.set('test3', test3);
tests.set('test4', test4);
tests.set('test5', test5);
tests.set('test6', test6);
tests.set('test7', test7);
tests.set('test8', test8);

function poc() {
    const args = process.argv.slice(2);

    const t = args[0];

    const test = tests.get(t) || test1;
    test();

    // never executed
    console.log('Done');
}

poc();

未処理の例外

影響: サービス拒否(DoS)

napi

napi APIには、例外を処理し、エラーをスローするためのさまざまな関数があります。ただし、binding.gypファイルで使用するフラグによっては、予期しないクラッシュを避けるために注意が必要です。

たとえば、binding.gypファイルでフラグNAPI_DISABLE_CPP_EXCEPTIONSが設定されている場合、次のシナリオでプロセスがクラッシュする可能性があります(DoS)。

  1. Napi::TypeError::New(env, "").ThrowAsJavaScriptException();など、エラーを生成するほかの関数(たとえば、引数の型が不正な場合)

  2. throw Napi::Error::Newがtry/catchで囲まれていない

  3. 同じ関数内で到達可能なreturnのない複数のNapi::TypeError::New(env, "").ThrowAsJavaScriptException();

ドキュメントで説明されているように、「JavaScript例外をスローした後は、必要なクリーンアップを実行したうえで、通常はネイティブコールバックから直ちにreturnする必要があります。」

test_napi_exceptions.cpp

#include <napi.h>

Napi::Value Test1(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    std::string data = info[0].As<Napi::String>().Utf8Value();

    if (info.Length() < 2) {
        Napi::TypeError::New(env, "TEST1 - Error").ThrowAsJavaScriptException();
    }
    return Napi::String::New(env, "TEST1 - OK");

}

Napi::Value Test2(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    if (info.Length() < 2) {
        throw Napi::Error::New(env, "TEST2 - Error");
        // missing try-catch
    }
    return Napi::String::New(env, "TEST2 - OK");

}

Napi::Value Test3(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    // multiple reachable ThrowAsJavaScriptException
    if (info.Length() < 2) {
        Napi::TypeError::New(env, "TEST3 - Error1").ThrowAsJavaScriptException();
    }

    if (info.Length() < 3) {
        Napi::TypeError::New(env, "TEST3 - Error2").ThrowAsJavaScriptException();
    }

    return Napi::String::New(env, "TEST3 - OK");

}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set(Napi::String::New(env, "test1"), Napi::Function::New(env, Test1));
    exports.Set(Napi::String::New(env, "test2"), Napi::Function::New(env, Test2));
    exports.Set(Napi::String::New(env, "test3"), Napi::Function::New(env, Test3));
    return exports;
}

NODE_API_MODULE(addon, Init)

以下の例を実行します。

node main.js test1
node main.js test2
node main.js test3

到達可能なassert

影響: サービス拒否(DoS)

node_api

提供されている例を見ると、一部の例では、関数の戻り値を確認するためにassertが使われていることがわかります。しかし、プログラムの実行中に、汚染された値(JavaScriptコード由来)によってassertに到達すると、クラッシュ(DoS)につながる可能性があります。いくつかのプロジェクトを確認したところ、コードロジック内に到達可能なassertが複数見つかったため、前述のリストに加える価値があると考えました。

このシナリオを修正するには、assertを使う代わりに、if内で戻り値を確認し、プログラムのロジックに応じて適切な値を返す方法が考えられます。

test_node_api_assert.c

#include <assert.h>
#include <node_api.h>
#include <stdlib.h>

static napi_value Test1(napi_env env, napi_callback_info info) {
    napi_status status;

    size_t argc = 1;
    napi_value args[1];
    status = napi_get_cb_info(env, info, &argc, args, NULL, NULL);
    assert(status == napi_ok);

    if (argc < 1) {
        napi_throw_type_error(env, NULL, "Wrong number of arguments");
        return NULL;
    }

    double value0;
    status = napi_get_value_double(env, args[0], &value0);
    assert(status == napi_ok); // if value0 is not double, the assert will fail

    // potential fix
    // if (status != napi_ok) {
    //     return NULL;
    // }

    napi_value sum;
    status = napi_create_double(env, value0 + value0, &sum);
    assert(status == napi_ok);

    return sum;
}

#define DECLARE_NAPI_METHOD(name, func){ name, 0, func, 0, 0, 0, napi_default, 0 }

static napi_value Init(napi_env env, napi_value exports) {
    napi_status status;
    napi_property_descriptor desc = DECLARE_NAPI_METHOD("test1", Test1);
    status = napi_define_properties(env, exports, 1, &desc);
    assert(status == napi_ok);
    return exports;
}

NAPI_MODULE(addon, Init)

この例を実行します。

node main.js test4

型チェックされていないデータ

影響: サービス拒否(DoS)

napi

napiには、JavaScriptの型を変換するAPIが複数あります。たとえば、

Napi::Value::ToString()は「Napi::ValueをJavaScript文字列に変換して返します」。同様に、Napi::Value::ToNumber()は「Napi::ValueをJavaScriptの数値に変換して返します」。

内部では、napiのNapi::Value::ToString() APIはNode-APIのnapi_coerce_to_stringを呼び出します。

inline MaybeOrValue<String> Value::ToString() const {
  napi_value result;
  napi_status status = napi_coerce_to_string(_env, _value, &result);
  NAPI_RETURN_OR_THROW_IF_FAILED(
      _env, status, Napi::String(_env, result), Napi::String);
}

参照

同様に、napiのNapi::Value::ToNumber() APIは内部でnapi_coerce_to_numberをNode-APIから呼び出します。

inline MaybeOrValue<Number> Value::ToNumber() const {
  napi_value result;
  napi_status status = napi_coerce_to_number(_env, _value, &result);
  NAPI_RETURN_OR_THROW_IF_FAILED(
      _env, status, Napi::Number(_env, result), Napi::Number);
}

参照

napi_coerce_to_stringに関する公式ドキュメントには、次のように記載されています。「このAPIは、ECMAScript言語仕様のセクション7.1.13で定義された抽象操作ToString()を実装します。渡された値がオブジェクトの場合、この関数はJavaScriptコードを実行する可能性があります。」つまり、ユーザー入力でtoStringプロパティが定義されている場合、toString()を呼び出すのではなく、そのプロパティの値が返され、予期しない結果につながる可能性があります。

Napi::Value::ToString()が返した値に対してほかのメソッドを呼び出すと、入力にtoStringプロパティが定義されている場合、例外が発生する可能性があり、多くの場合プロセスのクラッシュにつながります。同じことがnapi_coerce_to_numberにも当てはまります。

脆弱なパターン:

  • ToString()またはToNumberの結果であるNapi::Valueに対して、適切な型チェックをせずにNapi::String::Utf8Value()などを呼び出す

こうした問題を防ぐには、Napi::Value::ToString()またはNapi::Value::ToNumber()が返す値が、それぞれ文字列または数値であることを確認してから、ほかのメソッドを呼び出します。

注: 前述の未処理の例外と同様に、これらの問題はbinding.gypファイルでフラグNAPI_DISABLE_CPP_EXCEPTIONSが設定されている場合に発生します。

test_napi_unchecked_type.cpp

#include <napi.h>
#include <iostream>

Napi::Value Test1(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    // possible fix
    /*
        if (!info[0].IsString()) {
            return Napi::String::New(env, "TEST1 - Input is not a string");
        }
    */

    std::string data = info[0].As<Napi::String>().ToString().Utf8Value();

    std::cout << data << "\n";

    return Napi::String::New(env, "TEST1 - OK");
}

Napi::Value Test2(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    Napi::Object obj = info[0].As<Napi::Object>();

    std::string data = obj.Get("foo").ToString().Utf8Value();

    std::cout << data << "\n";

    return Napi::String::New(env, "TEST2 - OK");
}

Napi::Value Test3(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    double data = info[0].As<Napi::String>().ToNumber().DoubleValue();
    std::cout << data << "\n";

    return Napi::String::New(env, "TEST3 - OK");
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set(Napi::String::New(env, "test1"),Napi::Function::New(env, Test1));
    exports.Set(Napi::String::New(env, "test2"),Napi::Function::New(env, Test2));
    exports.Set(Napi::String::New(env, "test3"),Napi::Function::New(env, Test3));
    return exports;
}

NODE_API_MODULE(addon, Init)

以下の例を実行します。

node main.js test5
node main.js test6
node main.js test7

メモリリーク

影響: 情報漏えい

napi

napi APIには、UTF-8、UTF-16-LE、ISO-8859-1でエンコードされたC文字列からJavaScript文字列値を作成するメソッドが複数あります。これらのAPIは次のとおりです。

これらのメソッドはすべて同じシグネチャを持ちます。

napi_create_string_*(napi_env env, const char* str, size_t length, napi_value* result)

特に注意して確認すべき値は[in] length、つまり文字列のバイト長です。この値が攻撃者によって制御される場合、またはハードコードされていて入力値が汚染されている場合、resultに予期しないメモリ値が格納される可能性があります。

この問題を避けるには、size_t lengthの値にNAPI_AUTO_LENGTHを使用します。

脆弱なパターン:

  • const char* strの長さを超えるsize_t lengthを指定してnapi_create_string_*を呼び出す

test_napi_memory_leak.c

#include <assert.h>
#include <node_api.h>

napi_value Test1(napi_env env, napi_callback_info info) {
    napi_status status;

    size_t argc = 1;

    napi_value args[1];

    status = napi_get_cb_info(env, info, &argc, args, NULL, NULL);
    assert(status == napi_ok);

    int32_t n;
    status = napi_get_value_int32(env, args[0], &n);
    assert(status == napi_ok);

    napi_value result;

    // leak n bytes

    status = napi_create_string_utf8(env, "X", n, &result);  

    // status = napi_create_string_utf16(env, u"X", n, &result);

    // status = napi_create_string_latin1(env, "X", n, &result);

    assert(status == napi_ok);

    return result;
}

#define DECLARE_NAPI_METHOD(name, func){ name, 0, func, 0, 0, 0, napi_default, 0 }

static napi_value Init(napi_env env, napi_value exports) {
    napi_status status;

    napi_property_descriptor desc[] = {
        DECLARE_NAPI_METHOD("test1", Test1),
    };

    status = napi_define_properties(env, exports, sizeof(desc) / sizeof(*desc), desc);
    assert(status == napi_ok);
    return exports;
}

NAPI_MODULE(addon, Init)

この例を実行します。

node main.js test8

方法

可能な限り多くの問題を自動的にテスト・発見するため、Snyk Codeの機能を活用して以下の方法を取りました。

  1. NodeJSのアドオンAPIを使ってC/C++を呼び出すnpmパッケージのデータセットを作成する

  2. Snyk Codeで、以下をモデル化するセキュリティルールを作成する:

    1. ソース: この場合のソースとは、JavaScriptコードに由来する値です。napiではNapi::CallbackInfo::Env()から、node_apiではnapi_get_value_*から取得されるデータなどが該当します。

    2. シンク: セキュリティ上の問題に応じて、同じ関数内に複数のThrowAsJavaScriptException呼び出しがあること、assertチェック、文字列値の作成に使われる複数のメソッドなどをモデル化しました。また、メモリリークの問題におけるNAPI_AUTO_LENGTHなど、引数によってコードが脆弱にならない場合も考慮しました。

  3. 定義したシンクとソースを使用するルールを作成し、汚染解析を実行してソースからシンクまで汚染を追跡する

  4. 既存のサポート対象ルール(たとえばバッファオーバーフローや整数オーバーフロー)で定義されているソースを利用し、NodeJSのアドオンAPIを使用するものだけでなく、さらに多くのC/C++脆弱性をカバーする

  5. 作成したルールを、先に構築したデータセットに対して実行する

  6. 結果を手動で確認し、必要に応じてPoCを作成する

この方法を使い、NodeJSアドオンに関連するAPIをモデル化してSnyk Codeで分析することで、npmパッケージ内の複数の問題を発見できました。

ただし、発見した問題の一部については、構築したデータセットからいくつかのプロジェクトを抽出し、手動で確認しました。

調査結果

この調査の結果、複数のパッケージで脆弱性が見つかりました。以下に示します。

まとめ

個人的には、いくつかの理由から、この調査は素晴らしい学びの機会となりました。NodeJSアドオンの世界を深く掘り下げ、既存の問題に関する文献を調べ、大規模なリポジトリ群の中から問題を発見するためにSnyk Codeを使ってシナリオをモデル化する機会を得られました。

JavaScriptやほかの多くのプログラミング言語にはかなり慣れていますが、C/C++は最近学び始めた言語です。これは、Snyk Codeのお客様が利用できる複数のセキュリティルールをサポートするために、私たちが取り組んできた(そして今も取り組んでいる)仕事がきっかけです。学びの機会と、Snyk Codeを使って複数のセキュリティ問題をモデル化する機会の両方を得られたこの調査を、とても楽しむことができました。この機会をくださったSnykに感謝します。

参考文献

続きを読む

feature insights context
Blog

自律型攻撃はすでに始まっている。防御もそのスピードに追いつかなければならない。

自律型攻撃者によって、防御に使える時間は短くなっています。継続的な検出、修復、検証、予防で、セキュリティチームが攻撃に歩調を合わせる方法をご紹介します。

illustration hero ai
Blog

AIハリケーンが到来

AIはソフトウェア開発とサイバー攻撃の双方を加速させています。リーダーは、エージェントとコードを開発の初期段階から保護し、実行時に制御を徹底するとともに、防御策を独立して検証しなければなりません。

feature insights context
Blog

予防は、本質的に解決済みの問題なのでしょうか?

エージェントが生成するコードの予防策はアーキテクチャ上解決されていますが、開発を遅らせることなくセキュリティを守る制御を選ぶことが、依然として課題です。