Vulnerabilities in NodeJS C/C++ add-on extensions

snyk_sec

SnykSec

Posted on August 15, 2024

Vulnerabilities in NodeJS C/C++ add-on extensions

One of the main goals of this research was to explore C/C++ vulnerabilities in the context of NodeJS npm packages. The focus will be on exploring and identifying classic vulnerabilities like Buffer Overflow, Denial of Service (process crash, unchecked types), and Memory Leakages in the context of NodeJS C/C++ addons and modeling relevant sources, sinks, and sanitizers using Snyk Code (see Snyk brings developer-first AppSec approach to C/C++).

The targets for this research are NPM packages that use C/C++ interfaces as part of their implementation. We haven’t targeted projects that are not listed on NPM.

In this blog post, we aim to provide an overview of common security vulnerabilities and vulnerable patterns that can occur when writing C/C++ add-ons in NodeJS. We’ll also provide remediation examples and suggestions for open source maintainers.

This blog post was inspired by the paper “Bilingual Problems: Studying the Security Risks Incurred by Native Extensions in Scripting Languages” by Cristian-Alexandru Staicu, Sazzadur Rahaman, Àgnes Kiss, and Michael Backes.[1] In their original paper, the authors provided an analysis of the security risk of native extensions in popular languages, including JavaScript.

NodeJS C/C++ add-ons background

NodeJS provides different APIs to call native C/C++ code. The scope of this research is to investigate security vulnerabilities that could occur when using one of the following mechanisms:

A good resource that provides examples of using the libraries above can be found in GitHub.

For a complete introduction to add-ons and how to build them, refer to NodeJS's official documentation

The vulnerabilities covered and identified in at least one package are:

  • Memory leaks
  • Unchecked type (DoS)
  • Reachable assertion (DoS)
  • Unhandled exceptions (DoS)
  • Buffer overflow
  • Integer overflow

In the following sections, examples of vulnerable patterns will be provided with also an explanation of the conditions to be satisfied in order to make the vulnerability exploitable.

Examples of vulnerable patterns

In this section, we are going to explore how add-on-specific APIs can lead to security issues if not properly handled and some vulnerable patterns identified as part of this study. 

NOTE: The following examples do not represent a comprehensive list. There might be more scenarios

that can lead to security issues not covered in this blog post.

Setup

Install node-gyp (https://github.com/nodejs/node-gyp).

The following files are used to run the examples in the next section:

package.json

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

binding.gyp

{
  "targets": [
    {
      "target_name": "test_napi_exceptions",
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "sources": [ "test_napi_exceptions.cpp" ],
      "include_dirs": [
        "
Enter fullscreen mode Exit fullscreen mode

Run the following commands to build the C/C++ extensions:

  • node-gyp configure
  • node-gyp build

Run specific example:

node main.js 
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Unhandled exceptions

Impact: Denial of Service (DoS)

napi

The napi API provides different functions to handle exceptions and throw errors. However, depending on the flag used in the binding.gyp file, some attention needs to be taken in order to avoid unexpected crashes.

For example, if the flag NAPI_DISABLE_CPP_EXCEPTIONS is set in the binding.gyp file, the following scenarios can lead to a process crash (DoS):

  1. Napi::TypeError::New(env, "").ThrowAsJavaScriptException(); in addition to other functions that can generate an error (for example, wrong type argument)
  2. throw Napi::Error::New not surrounded by try/catch
  3. Multiple Napi::TypeError::New(env, "").ThrowAsJavaScriptException(); without return that can be reached within the same function

As explained in the docs, “after throwing a JavaScript exception, the code should generally return immediately from the native callback, after performing any necessary cleanup.” . 

test_napi_exceptions.cpp

#include 

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

 std::string data = info[0].As().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)
Enter fullscreen mode Exit fullscreen mode

Run these examples:

node main.js test1
node main.js test2
node main.js test3
Enter fullscreen mode Exit fullscreen mode

Reachable assert

Impact: Denial of Service (DoS)

node_api

Looking at the provided examples, we can see that in some examples , assert is used to check the return value of some functions. However, if an assert is reached by tainted values (from the javascript code) during the program execution, it can lead to a crash (DoS). While reviewing some projects, we found several occurrences of reachable asserts in the code logic, so I thought it’s worth mentioning as part of the previous list.

A possible fix for this scenario would be to check the return value inside an if and then return the appropriate value (depending on the logic of the program), instead of using an assert.

test_node_api_assert.c

#include 
#include 
#include 

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, ∑);
 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)
Enter fullscreen mode Exit fullscreen mode

Run this example:

node main.js test4
Enter fullscreen mode Exit fullscreen mode

Unchecked data type

Impact: Denial of Service (DoS)

napi

napi provides several APIs to coerce JavaScript types. For example,

Napi::Value::ToString()returns the Napi::Value coerced to a JavaScript string.” Similarly, Napi::Value::ToNumber()returns the Napi::Value coerced to a JavaScript number.” 

The napi Napi::Value::ToString() API, under the hood calls napi_coerce_to_string from Node-API:

inline MaybeOrValue 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);
}
Enter fullscreen mode Exit fullscreen mode

Reference

Similarly, the napi Napi::Value::ToNumber() API, under the hood calls napi_coerce_to_number from Node-API :

inline MaybeOrValue 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);
}
Enter fullscreen mode Exit fullscreen mode

Reference

From the official docs for napi_coerce_to_string: “This API implements the abstract operation ToString() as defined in Section 7.1.13 of the ECMAScript Language Specification. This function potentially runs JS code if the passed-in value is an object.” This means that if the user input defines a toString property, the value of that property will be returned (instead of calling the toString()), leading to unexpected results. 

If we call other methods on the values returned by Napi::Value::ToString(), and the input defines a property toString, we can occur in an exception, most of the time leading to the process crash. The same holds for napi_coerce_to_number.

Vulnerable pattern:

A possible remediation to avoid these scenarios, is to check if the value returned from Napi::Value::ToString() or Napi::Value::ToNumber() are, respectively, string or number before calling other methods on these values.

NOTE: Like the unhandled exceptions cases mentioned previously, these issues occur if the flag NAPI_DISABLE_CPP_EXCEPTIONS is set in the binding.gyp file.

test_napi_unchecked_type.cpp

#include 
#include 

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().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();

 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().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)
Enter fullscreen mode Exit fullscreen mode

Run these examples:

node main.js test5
node main.js test6
node main.js test7
Enter fullscreen mode Exit fullscreen mode

Memory leaks

Impact: Information Disclosure

napi

The napi API provides several methods to create a JavaScript string value from a UTF8, UTF16-LE or ISO-8859-1 encoded C string. These APIs are:

All these methods have the same signature:

napi_create_string_*(napi_env env, const char* str, size_t length, napi_value* result)
Enter fullscreen mode Exit fullscreen mode

The interesting value to carefully check is the [in] length, that is, the length of the string in bytes. If this value is controlled by an attacker or is hardcoded and the input value is tainted, then it’s possible to store in the result value, unexpected memory values.

To avoid such problems, use NAPI_AUTO_LENGTH for the size_t length value.

Vulnerable pattern:

  • napi_create_string_* with size_t length greater than the length of the const char* str

test_napi_memory_leak.c

#include 
#include 

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)
Enter fullscreen mode Exit fullscreen mode

Run this example:

node main.js test8
Enter fullscreen mode Exit fullscreen mode

Methodology

To test and find as many issues as possible automatically, I used the following approach to leverage the power of Snyk Code:

  1. Create a dataset of npm packages that calls C/C++ using NodeJS add-on APIs
  2. Write security rules in Snyk Code to model:

    1. Sources: in this context, sources are values coming from JavaScript code, that could be data coming Napi::CallbackInfo::Env() in the context of napi - or napi_get_value_* - in the context of node\_api
    2. Sinks: depending on the security issue, I modeled the presence of multiple ThrowAsJavaScriptException calls within the same function, the assert check, and several methods used to create string values (just to name a few). I also took into account situations where the code is not vulnerable because of the presence of some arguments like NAPI_AUTO_LENGTH in case of Memory Leak issues
  3. Write rules that use the sink and sources defined to perform a taint analysis, to track taint from sources to sink

  4. Use the sources defined in the the existing rules we support (for example, Buffer Overflow or Integer Overflow), so that I can cover even more C/C++ vulnerabilities (not only those specific that use NodeJS add-ons APIs)

  5. Run these rules against the previously built dataset

  6. Manually review the results and eventually build a PoC

Using this approach, I was able to find several issues in npm packages by modeling the relevant APIs related to the NodeJS add-ons by using Snyk Code.

However, for some of the issues found, I sampled some projects from the dataset build and manually reviewed them.

Outcomes

Multiple vulnerabilities in packages were found as a result of this research. These can be found below

Conclusion

On a personal note, this research was an incredible learning experience for several reasons. I had the opportunity to deep-dive into the world of NodeJS add-ons, review existing literature about existing issues, and try to model some scenarios using Snyk Code to find issues in a large set of repositories.

While I’m pretty familiar with JavaScript and many other languages, C/C++ is a language that I recently started learning due to the work we did (and are still doing) to support multiple security rules that are now available to Snyk Code customers. Combining both aspects, learning experience and the opportunity to use Snyk Code to model several security issues, I really enjoyed this research, and for this, I want to thank Snyk for the opportunity provided.

References

💖 💪 🙅 🚩
snyk_sec
SnykSec

Posted on August 15, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related