Skip to main content

Vulnérabilités des extensions complémentaires C/C++ de NodeJS

Écrit par
Headshot of Alessio Della Libera

Alessio Della Libera

feature snyk platform learn using snyk with CI CD

14 août 2024

0 minutes de lecture

L’un des principaux objectifs de cette recherche était d’étudier les vulnérabilités C/C++ dans le contexte des packages npm NodeJS. Nous nous sommes concentrés sur l’exploration et l’identification de vulnérabilités classiques, telles que les dépassements de tampon, les dénis de service (plantage du processus, types non vérifiés) et les fuites de mémoire, dans le contexte des modules complémentaires C/C++ NodeJS, ainsi que sur la modélisation des sources, des puits et des assainisseurs pertinents avec Snyk Code (voir Snyk apporte une approche AppSec pensée pour les développeurs au C/C++).

Cette recherche porte sur les packages NPM qui utilisent des interfaces C/C++ dans leur implémentation. Nous n’avons pas ciblé les projets qui ne sont pas répertoriés sur NPM.

Dans cet article, nous présentons les vulnérabilités courantes et les modèles vulnérables susceptibles d’apparaître lors de l’écriture d’extensions complémentaires C/C++ pour NodeJS. Nous proposons également des exemples de correctifs et des recommandations à l’intention des responsables de projets open source.

Cet article s’inspire de l’étude « Bilingual Problems: Studying the Security Risks Incurred by Native Extensions in Scripting Languages » de Cristian-Alexandru Staicu, Sazzadur Rahaman, Àgnes Kiss et Michael Backes.[1] Dans leur étude originale, les auteurs analysent les risques de sécurité liés aux extensions natives dans des langages populaires, dont JavaScript.

Présentation des modules complémentaires C/C++ de NodeJS

NodeJS propose différentes API pour appeler du code natif C/C++. Cette recherche porte sur les vulnérabilités de sécurité susceptibles de survenir lors de l’utilisation de l’un des mécanismes suivants :

Vous trouverez des exemples d’utilisation des bibliothèques ci-dessus sur GitHub.

Pour une présentation complète des modules complémentaires et de leur compilation, consultez la documentation officielle de NodeJS. 

Les vulnérabilités présentées et identifiées dans au moins un package sont les suivantes :

  • Fuites de mémoire

  • Type non vérifié (DoS)

  • Assertion accessible (DoS)

  • Exceptions non gérées (DoS)

  • Dépassement de tampon

  • Dépassement d’entier

Dans les sections suivantes, nous présentons des exemples de modèles vulnérables et expliquons les conditions à réunir pour que la vulnérabilité puisse être exploitée.

Exemples de modèles vulnérables

Dans cette section, nous allons voir comment les API propres aux modules complémentaires peuvent entraîner des problèmes de sécurité si elles ne sont pas gérées correctement, ainsi que certains modèles vulnérables identifiés dans le cadre de cette étude. 

REMARQUE : les exemples suivants ne constituent pas une liste exhaustive. D’autres cas de figure peuvent entraîner des problèmes de sécurité

qui ne sont pas abordés dans cet article.

Configuration

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

Les fichiers suivants servent à exécuter les exemples de la section suivante :

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

Exécutez les commandes suivantes pour compiler les extensions C/C++ :

  • node-gyp configure

  • node-gyp build

Exécuter un exemple spécifique :

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

Exceptions non gérées

Impact : déni de service (DoS)

napi

L’API napi propose différentes fonctions pour gérer les exceptions et lever des erreurs. Toutefois, selon le drapeau utilisé dans le fichier binding.gyp, certaines précautions s’imposent pour éviter les plantages inattendus.

Par exemple, si le drapeau NAPI_DISABLE_CPP_EXCEPTIONS est défini dans le fichier binding.gyp, les situations suivantes peuvent entraîner le plantage du processus (DoS) :

  1. Napi::TypeError::New(env, "").ThrowAsJavaScriptException(); ainsi que d’autres fonctions susceptibles de générer une erreur (par exemple, un argument de type incorrect)

  2. throw Napi::Error::New sans bloc try/catch

  3. Plusieurs appels à Napi::TypeError::New(env, "").ThrowAsJavaScriptException(); sans return, susceptibles d’être exécutés dans une même fonction

Comme l’explique la documentation, « après avoir levé une exception JavaScript, le code doit généralement retourner immédiatement de la fonction de rappel native, après avoir effectué tout nettoyage nécessaire. » . 

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)

Exécutez ces exemples :

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

Assertion accessible

Impact : déni de service (DoS)

node_api

En examinant les exemples fournis, on constate que certains exemples utilisent assert pour vérifier la valeur de retour de certaines fonctions. Toutefois, si une valeur contaminée (provenant du code JavaScript) atteint un assert pendant l’exécution du programme, cela peut provoquer un plantage (DoS). En examinant certains projets, nous avons trouvé plusieurs assertions accessibles dans la logique du code. J’ai donc jugé utile de les mentionner dans la liste précédente.

Pour corriger ce problème, vous pouvez vérifier la valeur de retour dans un if, puis renvoyer la valeur appropriée (selon la logique du programme), au lieu d’utiliser 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)

Exécutez cet exemple :

node main.js test4

Type de données non vérifié

Impact : déni de service (DoS)

napi

napi propose plusieurs API pour convertir les types JavaScript. Par exemple,

Napi::Value::ToString() « renvoie la valeur Napi::Value convertie en chaîne JavaScript ». De même, Napi::Value::ToNumber() « renvoie la valeur Napi::Value convertie en nombre JavaScript ». 

En interne, l’API Napi::Value::ToString() de napi appelle 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);
}

Référence 

De même, l’API Napi::Value::ToNumber() de napi appelle en interne 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);
}

Référence

Selon la documentation officielle de napi_coerce_to_string : « Cette API implémente l’opération abstraite ToString() définie à la section 7.1.13 de la spécification du langage ECMAScript. Cette fonction peut exécuter du code JS si la valeur transmise est un objet. » Cela signifie que si l’entrée utilisateur définit une propriété toString, la valeur de cette propriété sera renvoyée (au lieu d’appeler toString()), ce qui peut produire des résultats inattendus. 

Si nous appelons d’autres méthodes sur les valeurs renvoyées par Napi::Value::ToString() et que l’entrée définit une propriété toString, une exception peut se produire et entraîner, le plus souvent, le plantage du processus. Il en va de même pour napi_coerce_to_number.

Modèle vulnérable :

  • appels tels que Napi::String::Utf8Value() sur une Napi::Value obtenue à partir de ToString() ou ToNumber, sans vérification adéquate du type

Pour éviter ces situations, vérifiez que la valeur renvoyée par Napi::Value::ToString() ou Napi::Value::ToNumber() est respectivement une chaîne ou un nombre avant d’appeler d’autres méthodes sur cette valeur.

REMARQUE : comme pour les cas d’exceptions non gérées mentionnés précédemment, ces problèmes surviennent si le drapeau NAPI_DISABLE_CPP_EXCEPTIONS est défini dans le fichier 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)

Exécutez ces exemples :

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

Fuites de mémoire

Impact : divulgation d’informations

napi

L’API napi propose plusieurs méthodes pour créer une valeur de chaîne JavaScript à partir d’une chaîne C encodée en UTF-8, UTF-16LE ou ISO-8859-1. Ces API sont :

Toutes ces méthodes ont la même signature :

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

La valeur à vérifier attentivement est [in] length, c’est-à-dire la longueur de la chaîne en octets. Si cette valeur est contrôlée par un attaquant ou codée en dur alors que la valeur d’entrée est contaminée, des valeurs mémoire inattendues peuvent être stockées dans result.

Pour éviter ce type de problème, utilisez NAPI_AUTO_LENGTH pour la valeur size_t length.

Modèle vulnérable :

  • napi_create_string_* avec une valeur size_t length supérieure à la longueur 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)

Exécutez cet exemple :

node main.js test8

Méthodologie

Pour tester et détecter automatiquement autant de problèmes que possible, j’ai utilisé l’approche suivante afin de tirer parti des capacités de Snyk Code :

  1. Créer un ensemble de données de packages npm qui font appel au C/C++ via les API des modules complémentaires NodeJS

  2. Écrire des règles de sécurité dans Snyk Code pour modéliser :

    1. Sources : dans ce contexte, les sources sont des valeurs provenant du code JavaScript, qui peuvent être issues de Napi::CallbackInfo::Env() dans le contexte de napi – ou de napi_get_value_* – dans le contexte de node_api

    2. Puits : selon le problème de sécurité, j’ai modélisé la présence de plusieurs appels à ThrowAsJavaScriptException dans une même fonction, la vérification assert et plusieurs méthodes utilisées pour créer des valeurs de chaîne, entre autres. J’ai également pris en compte les situations où le code n’est pas vulnérable grâce à la présence de certains arguments, comme NAPI_AUTO_LENGTH dans le cas des fuites de mémoire.

  3. Écrire des règles qui utilisent les sources et les puits définis pour effectuer une analyse de contamination et suivre la contamination des sources aux puits

  4. Utiliser les sources définies dans les règles existantes prises en charge (par exemple, Dépassement de tampon ou Dépassement d’entier), afin de couvrir encore plus de vulnérabilités C/C++ (pas seulement celles propres aux API des modules complémentaires NodeJS)

  5. Exécuter ces règles sur l’ensemble de données créé précédemment

  6. Examiner manuellement les résultats et, le cas échéant, créer une preuve de concept (PoC)

Grâce à cette approche, j’ai pu détecter plusieurs problèmes dans des packages npm en modélisant les API pertinentes des modules complémentaires NodeJS avec Snyk Code.

Toutefois, pour certains des problèmes détectés, j’ai prélevé un échantillon de projets dans l’ensemble de données créé et les ai examinés manuellement.

Résultats

Cette recherche a permis de détecter plusieurs vulnérabilités dans des packages. Vous les trouverez ci-dessous :

Conclusion

À titre personnel, cette recherche a été une expérience d’apprentissage incroyable pour plusieurs raisons. J’ai eu l’occasion d’explorer en profondeur l’univers des modules complémentaires NodeJS, d’étudier les travaux existants sur les problèmes déjà recensés et de tenter de modéliser certains scénarios avec Snyk Code pour détecter des problèmes dans un vaste ensemble de dépôts.

Même si je connais assez bien JavaScript et plusieurs autres langages, j’ai commencé à apprendre le C/C++ récemment, dans le cadre de notre travail visant à prendre en charge plusieurs règles de sécurité désormais accessibles aux clients de Snyk Code. En associant ces deux aspects — l’apprentissage et la possibilité d’utiliser Snyk Code pour modéliser plusieurs problèmes de sécurité —, j’ai beaucoup apprécié cette recherche. Je tiens donc à remercier Snyk de m’avoir offert cette occasion.

Références

Lire la suite

feature insights context
Blog

Les attaques autonomes sont déjà là. La défense doit suivre leur rythme.

Les attaquants autonomes réduisent la fenêtre de défense. Découvrez comment la découverte, la correction, la validation et la prévention continues peuvent aider les équipes de sécurité à suivre le rythme.

illustration hero ai
Blog

L’ouragan de l’IA est là

L’IA accélère à la fois la création de logiciels et les cyberattaques. Les dirigeants doivent sécuriser les agents et le code dès leur conception, appliquer des contrôles à l’exécution et valider les défenses de manière indépendante.

feature insights context
Blog

La prévention est-elle essentiellement un problème résolu ?

La prévention dans le code généré par les agents est résolue sur le plan architectural, mais le défi reste de choisir des contrôles qui protègent la sécurité sans ralentir le développement.