Vulnerabilities in NodeJS C/C++ add-on extensions
SnykSec
Posted on August 15, 2024
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:
-
node_api.h
: Node-API -
napi.h
: C++ wrapper around Node-API
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"
}
}
binding.gyp
{
"targets": [
{
"target_name": "test_napi_exceptions",
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ],
"sources": [ "test_napi_exceptions.cpp" ],
"include_dirs": [
"
Run the following commands to build the C/C++ extensions:
node-gyp configure
node-gyp build
Run specific example:
node main.js
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();
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):
-
Napi::TypeError::New(env, "").ThrowAsJavaScriptException();
in addition to other functions that can generate an error (for example, wrong type argument) -
throw Napi::Error::New
not surrounded bytry/catch
- Multiple
Napi::TypeError::New(env, "").ThrowAsJavaScriptException();
withoutreturn
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)
Run these examples:
node main.js test1
node main.js test2
node main.js test3
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)
Run this example:
node main.js test4
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);
}
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);
}
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:
- calls like
Napi::String::Utf8Value()
on anNapi::Value
resulted fromToString()
orToNumber
without proper type checking
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)
Run these examples:
node main.js test5
node main.js test6
node main.js test7
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)
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_*
withsize_t length
greater than the length of theconst 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)
Run this example:
node main.js test8
Methodology
To test and find as many issues as possible automatically, I used the following approach to leverage the power of Snyk Code:
- Create a dataset of npm packages that calls C/C++ using NodeJS add-on APIs
-
Write security rules in Snyk Code to model:
- Sources: in this context, sources are values coming from JavaScript code, that could be data coming
Napi::CallbackInfo::Env()
in the context ofnapi
- ornapi_get_value_*
- in the context ofnode\_api
- Sinks: depending on the security issue, I modeled the presence of multiple
ThrowAsJavaScriptException
calls within the same function, theassert
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 likeNAPI_AUTO_LENGTH
in case of Memory Leak issues
- Sources: in this context, sources are values coming from JavaScript code, that could be data coming
Write rules that use the sink and sources defined to perform a taint analysis, to track taint from sources to sink
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)
Run these rules against the previously built dataset
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
- [1] Bilingual Problems: Studying the Security Risks Incurred by Native Extensions in Scripting Languages - Cristian-Alexandru Staicu and Sazzadur Rahaman and Àgnes Kiss and Michael Backes, Proceedings of the 32nd USENIX Conference on Security Symposium, 2021
- Node-API - https://nodejs.org/api/n-api.html
- node-addon-api - https://github.com/nodejs/node-addon-api
- C++ addons - https://nodejs.org/api/addons.html
Posted on August 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.