Skip to main content

Vulnerabilidades em extensões de add-ons C/C++ do 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 leitura

Um dos principais objetivos desta pesquisa foi explorar vulnerabilidades em C/C++ no contexto de pacotes npm do NodeJS. O foco será explorar e identificar vulnerabilidades clássicas, como estouro de buffer, negação de serviço (falha do processo, tipos não verificados) e vazamentos de memória em add-ons C/C++ do NodeJS, além de modelar fontes, destinos e sanitizadores relevantes usando o Snyk Code (veja Snyk leva uma abordagem de AppSec que prioriza desenvolvedores para C/C++).

Os alvos desta pesquisa são pacotes NPM que usam interfaces C/C++ em sua implementação. Não incluímos projetos que não estão listados no NPM.

Nesta publicação, apresentamos uma visão geral das vulnerabilidades de segurança e dos padrões vulneráveis mais comuns que podem surgir ao escrever add-ons C/C++ para NodeJS. Também apresentamos exemplos de correção e sugestões para mantenedores de código aberto.

Esta publicação foi inspirada no artigo “Bilingual Problems: Studying the Security Risks Incurred by Native Extensions in Scripting Languages”, de Cristian-Alexandru Staicu, Sazzadur Rahaman, Àgnes Kiss e Michael Backes.[1] No artigo original, os autores analisaram os riscos de segurança de extensões nativas em linguagens populares, incluindo JavaScript.

Contexto dos add-ons C/C++ do NodeJS

O NodeJS oferece diferentes APIs para chamar código nativo C/C++. O escopo desta pesquisa é investigar vulnerabilidades de segurança que podem ocorrer ao usar um dos seguintes mecanismos:

Um bom recurso com exemplos de uso das bibliotecas acima está disponível no GitHub.

Para uma introdução completa aos add-ons e instruções para compilá-los, consulte a documentação oficial do NodeJS. 

As vulnerabilidades analisadas e identificadas em pelo menos um pacote são:

  • Vazamentos de memória

  • Tipo não verificado (DoS)

  • Assert alcançável (DoS)

  • Exceções não tratadas (DoS)

  • Estouro de buffer

  • Estouro de inteiro

Nas próximas seções, apresentaremos exemplos de padrões vulneráveis e explicaremos as condições necessárias para que a vulnerabilidade possa ser explorada.

Exemplos de padrões vulneráveis

Nesta seção, vamos explorar como APIs específicas de add-ons podem causar problemas de segurança quando não são tratadas corretamente, além de alguns padrões vulneráveis identificados durante este estudo. 

OBSERVAÇÃO: os exemplos a seguir não representam uma lista completa. Pode haver outros cenários

que levem a problemas de segurança não abordados nesta publicação.

Configuração

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

Os arquivos a seguir são usados para executar os exemplos na próxima seção:

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

Execute os comandos a seguir para compilar as extensões C/C++:

  • node-gyp configure

  • node-gyp build

Executar um exemplo 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();

Exceções não tratadas

Impacto: negação de serviço (DoS)

napi

A API napi oferece diferentes funções para tratar exceções e lançar erros. No entanto, dependendo da flag usada no arquivo binding.gyp, é preciso tomar alguns cuidados para evitar falhas inesperadas.

Por exemplo, se a flag NAPI_DISABLE_CPP_EXCEPTIONS estiver definida no arquivo binding.gyp, os cenários a seguir podem causar uma falha do processo (DoS):

  1. Napi::TypeError::New(env, "").ThrowAsJavaScriptException();, além de outras funções que podem gerar um erro (por exemplo, um argumento do tipo incorreto)

  2. throw Napi::Error::New sem estar dentro de um bloco try/catch

  3. Várias chamadas a Napi::TypeError::New(env, "").ThrowAsJavaScriptException(); sem return, que podem ser alcançadas dentro da mesma função

Como explicado na documentação, “depois de lançar uma exceção JavaScript, o código geralmente deve retornar imediatamente do callback nativo, após realizar qualquer limpeza necessária.” . 

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)

Execute estes exemplos:

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

Assert alcançável

Impacto: negação de serviço (DoS)

node_api

Ao analisar os exemplos fornecidos, vemos que em alguns exemplos, assert é usado para verificar o valor de retorno de algumas funções. No entanto, se um assert for alcançado por valores contaminados (provenientes do código JavaScript) durante a execução do programa, isso pode causar uma falha (DoS). Ao analisar alguns projetos, encontramos várias ocorrências de asserts alcançáveis na lógica do código, então achei importante mencioná-las na lista acima.

Uma possível correção para esse cenário é verificar o valor de retorno dentro de um if e, em seguida, retornar o valor apropriado (de acordo com a lógica do programa), em vez de usar um 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)

Execute este exemplo:

node main.js test4

Tipo de dado não verificado

Impacto: negação de serviço (DoS)

napi

napi oferece várias APIs para converter tipos JavaScript. Por exemplo,

Napi::Value::ToString() “retorna o Napi::Value convertido em uma string JavaScript.” Da mesma forma, Napi::Value::ToNumber() “retorna o Napi::Value convertido em um número JavaScript.” 

Nos bastidores, a API Napi::Value::ToString() de napi chama napi_coerce_to_string da 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);
}

Referência 

Da mesma forma, nos bastidores, a API Napi::Value::ToNumber() de napi chama napi_coerce_to_number da 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);
}

Referência

Na documentação oficial de napi_coerce_to_string: “Esta API implementa a operação abstrata ToString(), definida na seção 7.1.13 da especificação da linguagem ECMAScript. Esta função pode executar código JS se o valor passado for um objeto.” Isso significa que, se a entrada do usuário definir uma propriedade toString, o valor dessa propriedade será retornado (em vez de chamar toString()), o que pode gerar resultados inesperados. 

Se chamarmos outros métodos nos valores retornados por Napi::Value::ToString() e a entrada definir uma propriedade toString, poderá ocorrer uma exceção, que na maioria das vezes causa uma falha do processo. O mesmo vale para napi_coerce_to_number.

Padrão vulnerável:

  • chamadas como Napi::String::Utf8Value() em um Napi::Value resultante de ToString() ou ToNumber, sem a verificação adequada do tipo

Para evitar esses cenários, uma possível correção é verificar se o valor retornado por Napi::Value::ToString() ou Napi::Value::ToNumber() é, respectivamente, uma string ou um número, antes de chamar outros métodos nesses valores.

OBSERVAÇÃO: assim como nos casos de exceções não tratadas mencionados anteriormente, esses problemas ocorrem quando a flag NAPI_DISABLE_CPP_EXCEPTIONS está definida no arquivo 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)

Execute estes exemplos:

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

Vazamentos de memória

Impacto: divulgação de informações

napi

A API napi oferece vários métodos para criar um valor de string JavaScript a partir de uma string C codificada em UTF8, UTF16-LE ou ISO-8859-1. São eles:

Todos esses métodos têm a mesma assinatura:

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

O valor que merece atenção especial é [in] length, ou seja, o comprimento da string em bytes. Se esse valor for controlado por um invasor ou estiver definido no código e o valor de entrada for contaminado, valores inesperados da memória poderão ser armazenados em result.

Para evitar esse tipo de problema, use NAPI_AUTO_LENGTH para o valor size_t length.

Padrão vulnerável:

  • napi_create_string_* com size_t length maior que o comprimento 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)

Execute este exemplo:

node main.js test8

Metodologia

Para testar e encontrar automaticamente o maior número possível de problemas, usei a seguinte abordagem para aproveitar o potencial do Snyk Code:

  1. Criar um conjunto de dados de pacotes npm que chamam C/C++ usando APIs de add-ons do NodeJS

  2. Escrever regras de segurança no Snyk Code para modelar:

    1. Fontes: neste contexto, fontes são valores provenientes do código JavaScript, ou seja, dados que podem vir de Napi::CallbackInfo::Env() no contexto de napi ou de napi_get_value_* no contexto de node_api

    2. Destinos: dependendo do problema de segurança, modelei a presença de várias chamadas a ThrowAsJavaScriptException dentro da mesma função, a verificação assert e vários métodos usados para criar valores de string (para citar alguns). Também levei em conta situações em que o código não é vulnerável devido à presença de determinados argumentos, como NAPI_AUTO_LENGTH em casos de vazamento de memória

  3. Escrever regras que usem os destinos e as fontes definidos para realizar uma análise de taint e rastrear a contaminação das fontes até os destinos

  4. Usar as fontes definidas nas regras existentes que oferecemos (por exemplo, Estouro de buffer ou Estouro de inteiro) para abranger ainda mais vulnerabilidades em C/C++ (não apenas as específicas de APIs de add-ons do NodeJS)

  5. Executar essas regras no conjunto de dados criado anteriormente

  6. Analisar os resultados manualmente e, se necessário, criar uma PoC

Com essa abordagem, consegui encontrar vários problemas em pacotes npm ao modelar as APIs relevantes dos add-ons do NodeJS usando o Snyk Code.

No entanto, para alguns dos problemas encontrados, selecionei alguns projetos do conjunto de dados criado e os analisei manualmente.

Resultados

Esta pesquisa identificou várias vulnerabilidades em pacotes. Confira a seguir:

Conclusão

Em termos pessoais, esta pesquisa foi uma experiência de aprendizado incrível por vários motivos. Tive a oportunidade de me aprofundar no universo dos add-ons do NodeJS, revisar a literatura existente sobre problemas conhecidos e tentar modelar alguns cenários usando o Snyk Code para encontrar problemas em um grande conjunto de repositórios.

Embora eu já conheça bem JavaScript e muitas outras linguagens, comecei a aprender C/C++ recentemente, por causa do trabalho que fizemos (e continuamos fazendo) para oferecer suporte a várias regras de segurança que agora estão disponíveis para clientes do Snyk Code. Ao unir os dois aspectos — a experiência de aprendizado e a oportunidade de usar o Snyk Code para modelar vários problemas de segurança —, gostei muito desta pesquisa e quero agradecer à Snyk pela oportunidade.

Referências

Leia mais

feature insights context
Blog

Os ataques autônomos já chegaram. A defesa precisa acompanhar o ritmo.

Os atacantes autônomos estão reduzindo o tempo disponível para a defesa. Saiba como a descoberta, a correção, a validação e a prevenção contínuas ajudam as equipes de segurança a acompanhar esse ritmo.

illustration hero ai
Blog

O furacão da IA chegou

A IA está acelerando tanto a criação de software quanto os ataques cibernéticos. As lideranças devem proteger agentes e código desde o início, aplicar controles em tempo de execução e validar as defesas de forma independente.

feature insights context
Blog

A prevenção é essencialmente um problema resolvido?

A prevenção em código gerado por agentes está resolvida do ponto de vista arquitetural — mas escolher controles que protejam a segurança sem desacelerar o desenvolvimento continua sendo um desafio.