Skip to main content

Vulnerabilidades en extensiones de complementos C/C++ de NodeJS

Escrito por
Headshot of Alessio Della Libera

Alessio Della Libera

feature snyk platform learn using snyk with CI CD

14 de agosto de 2024

0 minutos de lectura

Uno de los objetivos principales de esta investigación fue explorar las vulnerabilidades de C/C++ en el contexto de los paquetes npm de NodeJS. Nos centraremos en explorar e identificar vulnerabilidades clásicas, como desbordamientos de búfer, denegación de servicio (bloqueo del proceso, tipos sin validar) y fugas de memoria en complementos C/C++ de NodeJS, y en modelar fuentes, destinos y sanitizadores pertinentes con Snyk Code (consulta Snyk lleva un enfoque de AppSec centrado en los desarrolladores a C/C++).

Los objetivos de esta investigación son paquetes de NPM que usan interfaces C/C++ como parte de su implementación. No analizamos proyectos que no aparezcan en NPM.

En esta publicación, presentamos un resumen de las vulnerabilidades de seguridad comunes y los patrones vulnerables que pueden surgir al escribir complementos C/C++ en NodeJS. También compartiremos ejemplos de correcciones y sugerencias para quienes mantienen proyectos de código abierto.

Esta publicación se inspiró en el artículo “Bilingual Problems: Studying the Security Risks Incurred by Native Extensions in Scripting Languages”, de Cristian-Alexandru Staicu, Sazzadur Rahaman, Àgnes Kiss y Michael Backes.[1] En el artículo original, los autores analizaron los riesgos de seguridad de las extensiones nativas en lenguajes populares, incluido JavaScript.

Antecedentes de los complementos C/C++ de NodeJS

NodeJS ofrece distintas API para llamar código nativo C/C++. El objetivo de esta investigación es analizar las vulnerabilidades de seguridad que pueden surgir al usar alguno de los siguientes mecanismos:

En GitHub encontrarás un buen recurso con ejemplos para usar las bibliotecas anteriores.

Para obtener una introducción completa a los complementos y saber cómo compilarlos, consulta la documentación oficial de NodeJS. 

Las vulnerabilidades analizadas y detectadas en al menos un paquete son:

  • Fugas de memoria

  • Tipo sin validar (DoS)

  • Aserción alcanzable (DoS)

  • Excepciones no controladas (DoS)

  • Desbordamiento de búfer

  • Desbordamiento de enteros

En las siguientes secciones, se presentan ejemplos de patrones vulnerables y se explican las condiciones que deben cumplirse para que la vulnerabilidad pueda explotarse.

Ejemplos de patrones vulnerables

En esta sección, veremos cómo las API específicas de los complementos pueden provocar problemas de seguridad si no se manejan correctamente, además de algunos patrones vulnerables detectados durante este estudio. 

NOTA: Los siguientes ejemplos no constituyen una lista exhaustiva. Puede haber más situaciones

que provoquen problemas de seguridad y que no se aborden en esta publicación.

Configuración

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

Los siguientes archivos se usan para ejecutar los ejemplos de la próxima sección:

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" ]
    }
  ]
}

Ejecuta los siguientes comandos para compilar las extensiones C/C++:

  • node-gyp configure

  • node-gyp build

Ejecutar un ejemplo específico:

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

Excepciones no controladas

Impacto: denegación de servicio (DoS)

napi

La API napi ofrece distintas funciones para controlar excepciones y lanzar errores. Sin embargo, según la marca que se use en el archivo binding.gyp, hay que tener cuidado para evitar cierres inesperados.

Por ejemplo, si la marca NAPI_DISABLE_CPP_EXCEPTIONS está establecida en el archivo binding.gyp, las siguientes situaciones pueden provocar el cierre del proceso (DoS):

  1. Napi::TypeError::New(env, "").ThrowAsJavaScriptException();, además de otras funciones que pueden generar un error (por ejemplo, un argumento de tipo incorrecto)

  2. throw Napi::Error::New sin estar dentro de un bloque try/catch

  3. Varias llamadas a Napi::TypeError::New(env, "").ThrowAsJavaScriptException(); sin un return que puedan alcanzarse dentro de la misma función

Como se explica en la documentación, “después de lanzar una excepción de JavaScript, el código generalmente debe regresar de inmediato desde la función de retorno de llamada nativa, después de realizar cualquier limpieza necesaria”. . 

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)

Ejecuta estos ejemplos:

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

Aserción alcanzable

Impacto: denegación de servicio (DoS)

node_api

Al revisar los ejemplos proporcionados, vemos que en algunos ejemplos se usa assert para comprobar el valor de retorno de algunas funciones. Sin embargo, si durante la ejecución del programa se alcanza un assert con valores contaminados (provenientes del código JavaScript), puede producirse un cierre (DoS). Al revisar algunos proyectos, encontramos varias aserciones alcanzables en la lógica del código, así que me pareció importante mencionarlas en la lista anterior.

Una posible solución para esta situación sería comprobar el valor de retorno con un if y luego devolver el valor adecuado (según la lógica del programa), en lugar de usar un assert.

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)

Ejecuta este ejemplo:

node main.js test4

Tipo de datos sin validar

Impacto: denegación de servicio (DoS)

napi

napi ofrece varias API para convertir tipos de JavaScript. Por ejemplo,

Napi::Value::ToString() “devuelve el valor Napi::Value convertido en una cadena de JavaScript”. De forma similar, Napi::Value::ToNumber() “devuelve el valor Napi::Value convertido en un número de JavaScript”. 

Internamente, la API Napi::Value::ToString() de napi llama a napi_coerce_to_string de Node-API:

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);
}

Referencia 

De forma similar, internamente la API Napi::Value::ToNumber() de napi llama a napi_coerce_to_number de 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);
}

Referencia

Según la documentación oficial de napi_coerce_to_string: “Esta API implementa la operación abstracta ToString(), tal como se define en la sección 7.1.13 de la especificación del lenguaje ECMAScript. Esta función puede ejecutar código JS si el valor recibido es un objeto”. Esto significa que, si la entrada del usuario define una propiedad toString, se devolverá el valor de esa propiedad (en lugar de llamar a toString()), lo que puede generar resultados inesperados. 

Si llamamos a otros métodos sobre los valores que devuelve Napi::Value::ToString() y la entrada define una propiedad toString, puede producirse una excepción que, en la mayoría de los casos, provoca el cierre del proceso. Lo mismo ocurre con napi_coerce_to_number.

Patrón vulnerable:

  • llamadas como Napi::String::Utf8Value() sobre un Napi::Value obtenido de ToString() o ToNumber sin comprobar correctamente el tipo

Una posible solución para evitar estas situaciones es comprobar que el valor devuelto por Napi::Value::ToString() o Napi::Value::ToNumber() sea, respectivamente, una cadena o un número antes de llamar a otros métodos sobre esos valores.

NOTA: Al igual que en los casos de excepciones no controladas mencionados antes, estos problemas ocurren si la marca NAPI_DISABLE_CPP_EXCEPTIONS está establecida en el archivo binding.gyp.

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)

Ejecuta estos ejemplos:

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

Fugas de memoria

Impacto: divulgación de información

napi

La API napi ofrece varios métodos para crear un valor de cadena de JavaScript a partir de una cadena C codificada en UTF-8, UTF-16-LE o ISO-8859-1. Estas API son:

Todos estos métodos tienen la misma firma:

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

El valor que se debe revisar cuidadosamente es [in] length, es decir, la longitud de la cadena en bytes. Si un atacante controla este valor o está codificado de forma fija y el valor de entrada está contaminado, es posible almacenar valores de memoria inesperados en result.

Para evitar estos problemas, usa NAPI_AUTO_LENGTH como valor de longitud de tipo size_t length.

Patrón vulnerable:

  • napi_create_string_* con size_t length superior a la longitud de const char* str 

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)

Ejecuta este ejemplo:

node main.js test8

Metodología

Para probar y encontrar automáticamente la mayor cantidad posible de problemas, usé el siguiente enfoque para aprovechar las capacidades de Snyk Code:

  1. Crear un conjunto de datos de paquetes npm que llaman a C/C++ mediante las API de complementos de NodeJS

  2. Escribir reglas de seguridad en Snyk Code para modelar:

    1. Fuentes: en este contexto, las fuentes son valores provenientes del código JavaScript, que pueden ser datos de Napi::CallbackInfo::Env() en el contexto de napi o de napi_get_value_* en el contexto de node_api

    2. Destinos: según el problema de seguridad, modelé la presencia de varias llamadas a ThrowAsJavaScriptException dentro de una misma función, la comprobación assert y varios métodos usados para crear valores de cadena (por nombrar algunos). También tuve en cuenta los casos en que el código no es vulnerable debido a la presencia de ciertos argumentos, como NAPI_AUTO_LENGTH para los problemas de fugas de memoria

  3. Escribir reglas que usen los destinos y las fuentes definidos para realizar un análisis de contaminación y rastrearla desde las fuentes hasta los destinos

  4. Usar las fuentes definidas en las reglas existentes que admitimos (por ejemplo, Desbordamiento de búfer o Desbordamiento de enteros) para abarcar aún más vulnerabilidades de C/C++ (no solo las que usan específicamente las API de complementos de NodeJS)

  5. Ejecutar estas reglas en el conjunto de datos creado anteriormente

  6. Revisar manualmente los resultados y, si corresponde, crear una prueba de concepto (PoC)

Con este enfoque, pude encontrar varios problemas en paquetes npm al modelar con Snyk Code las API pertinentes de los complementos de NodeJS.

Sin embargo, para algunos de los problemas detectados, seleccioné una muestra de proyectos del conjunto de datos creado y los revisé manualmente.

Resultados

Esta investigación permitió encontrar varias vulnerabilidades en paquetes. Puedes consultarlas a continuación:

Conclusión

En lo personal, esta investigación fue una experiencia de aprendizaje increíble por varias razones. Tuve la oportunidad de profundizar en el mundo de los complementos de NodeJS, revisar la bibliografía existente sobre problemas conocidos e intentar modelar algunas situaciones con Snyk Code para encontrar problemas en un gran conjunto de repositorios.

Aunque conozco bastante bien JavaScript y muchos otros lenguajes, empecé a aprender C/C++ hace poco, gracias al trabajo que hicimos (y seguimos haciendo) para admitir varias reglas de seguridad que ahora están disponibles para los clientes de Snyk Code. Al combinar el aprendizaje con la oportunidad de usar Snyk Code para modelar varios problemas de seguridad, disfruté mucho esta investigación. Por eso, quiero agradecer a Snyk por la oportunidad.

Referencias

Leer más

feature insights context
Blog

Los ataques autónomos ya están aquí. La defensa debe estar a su altura.

Los atacantes autónomos están reduciendo el tiempo disponible para defenderse. Descubre cómo el descubrimiento, la corrección, la validación y la prevención continuos pueden ayudar a los equipos de seguridad a seguirles el ritmo.

illustration hero ai
Blog

El huracán de la IA ha llegado

La IA está acelerando por igual la creación de software y los ciberataques. Los líderes deben proteger los agentes y el código desde el inicio, aplicar controles en tiempo de ejecución y validar las defensas de forma independiente.

feature insights context
Blog

¿La prevención es, en esencia, un problema ya resuelto?

La prevención en el código generado por agentes está resuelta desde el punto de vista arquitectónico, pero elegir controles que protejan la seguridad sin ralentizar el desarrollo sigue siendo el desafío.