Un simple null pointer dereference au sein d’une bibliothèque C peut faire planter l’intégralité de votre application. Si cette bibliothèque parse des entrées utilisateur, décompresse des images ou gère des protocoles réseau, vous êtes à un malformed packet d’un crash. Les containers résolvent ce problème, mais lancer Docker pour une seule dépendance, c’est comme engager un videur pour une plante verte. Vous avez besoin d’isolation sans l’overhead.

WebAssembly plus WASI est la réponse la plus pragmatique. Compilez la bibliothèque en WASM, exécutez-la dans une runtime sandboxée, et le processus hôte survit même lorsque le guest segfaulte. La mémoire est bounds-checked. L’accès au filesystem est capability-based. Et le sandbox est un appel de bibliothèque, pas un appel système.

Ce que signifie réellement sandboxer une bibliothèque C

C n’a pas de memory safety. Un buffer overflow dans une dépendance peut corrompre votre stack, détourner l’exécution ou planter l’hôte. Les mitigations traditionnelles comme ASAN détectent les bugs à la runtime, mais elles ajoutent de l’overhead et n’isolent pas la défaillance du reste du processus. seccomp et Landlock peuvent restreindre les syscalls, mais ils sont réservés à Linux, nécessitent root pour certaines opérations, et n’empêchent pas la memory corruption au sein des syscalls autorisés.

WebAssembly adopte une approche différente. Il compile vers un jeu d’instructions virtuel avec des memory bounds explicites, un control flow structuré et aucun undefined behavior pour les accès out-of-bounds. Une runtime WASM comme Wasmtime ou WAMR valide le bytecode avant l’exécution. Si le guest touche une mémoire qui ne lui appartient pas, la runtime déclenche un trap. L’hôte continue de tourner.

Ce n’est pas de l’émulation. Les runtimes WASM modernes compilent vers du code machine natif via Cranelift ou LLVM. Le coût en performance se situe généralement entre 1,1x et 2x pour du code compute-bound. Pour les bibliothèques I/O-bound, la différence se perd souvent dans le bruit.

Comment WASI transforme l’accès au filesystem en une capability

WASI est le WebAssembly System Interface. Il offre aux modules sandboxés une API de type POSIX, mais chaque ressource est une capability. Il n’existe pas de filesystem global. Si vous voulez que le guest lise ./data/input.txt, vous devez explicitement pré-ouvrir ce répertoire et lui passer le file descriptor.

Voici une bibliothèque C minimale qui lit un fichier et renvoie une checksum :

// libchecksum.c
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint32_t checksum_file(const char *path) {
    FILE *f = fopen(path, "rb");
    if (!f) return 0;

    uint32_t sum = 0;
    int c;
    while ((c = fgetc(f)) != EOF) {
        sum += (uint32_t)c;
    }
    fclose(f);
    return sum;
}

Compilez-la en WebAssembly avec le WASI SDK :

# Install wasi-sdk from https://github.com/WebAssembly/wasi-sdk
/opt/wasi-sdk/bin/clang \
  --target=wasm32-wasi \
  -Wl,--export=checksum_file \
  -o libchecksum.wasm libchecksum.c

Le fichier .wasm résultant est un module sandboxé. Il ne peut ouvrir aucun fichier à moins que la runtime n’accorde explicitement l’accès. Il ne peut allouer de mémoire en dehors de sa linear memory region. Il ne peut exécuter de commandes shell arbitraires car WASI ne définit pas execve.

Exécuter la bibliothèque sandboxée depuis Rust

Wasmtime est une runtime WASM production-ready, écrite en Rust avec des bindings C et Python. Voici un programme hôte qui charge la bibliothèque C compilée, lui accorde un accès en lecture seule à ./data/, et appelle checksum_file :

use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};

fn main() -> anyhow::Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "libchecksum.wasm")?;

    // Build a WASI context that preopens ./data as /data inside the guest
    let mut builder = WasiCtxBuilder::new();
    builder.preopen_dir("./data", "/data", wasmtime_wasi::DirPerms::READ_ONLY, wasmtime_wasi::FilePerms::READ_ONLY)?;
    let wasi = builder.build();

    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate(&mut store, &module)?;

    // Call the exported function with a guest-allocated string
    let checksum_fn = instance.get_typed_func::<(i32, i32), i32>(&mut store, "checksum_file")?;

    // Write the path string into guest memory
    let memory = instance.get_memory(&mut store, "memory").unwrap();
    let path = b"/data/input.txt\0";
    let ptr = 0;
    memory.write(&mut store, ptr, path)?;

    let result = checksum_fn.call(&mut store, (ptr as i32, path.len() as i32))?;
    println!("Checksum: {}", result);

    Ok(())
}

Le code C du guest s’exécute dans sa propre linear memory 32 bits. L’appel memory.write copie la chaîne de chemin dans cet address space sandboxé. Si checksum_file tente de lire au-delà de la fin de sa mémoire, Wasmtime déclenche un trap. S’il tente d’ouvrir /etc/passwd, WASI renvoie ENOENT car ce chemin n’a jamais été pré-ouvert.

Où le modèle mémoire vous protège réellement

Les modules WASM utilisent une unique linear memory contiguë, généralement à partir de 64 Ko et croissante à la demande. Chaque accès mémoire est bounds-checked par la runtime. Un buffer overflow dans la bibliothèque C ne peut pas corrompre la stack ou le heap de l’hôte. Il ne peut pas sauter vers du code arbitraire. Il ne peut toucher que la mémoire à l’intérieur de sa propre région allouée.

C’est plus faible qu’un isolation complète de processus. Le guest et l’hôte partagent le même processus OS. Une attaque par side-channel au niveau CPU ou un bug d’exécution spéculative pourrait théoriquement faire fuiter des données au-delà de la frontière. Mais pour le threat model « cette bibliothèque C a un bug et pourrait planter », l’isolation WASM est réelle et pratique.

Le comportement trap-on-fault est l’argument de vente clé. Si le guest déréférence un null pointer ou déborde un buffer, Wasmtime l’intercepte et renvoie une erreur. Votre processus hôte ne segfaulte pas. Votre serveur web ne redémarre pas. Vous loguez l’erreur et vous continuez.

Les compromis qui vont vous ralentir

WASM n’est pas transparent. Le code C qui utilise des threads, des signaux, setjmp/longjmp, ou du mmap brut ne compilera pas vers WASI sans réécriture. Le WASI SDK prend en charge les pthreads via un flag optionnel, mais c’est encore expérimental et nécessite que la runtime hôte l’active.

La frontière FFI est manuelle. Vous ne pouvez pas simplement linker un fichier .wasm dans un projet C ou Rust et appeler ses fonctions comme une bibliothèque normale. Vous marshalisez les arguments dans la linear memory, appelez la fonction par index, et relisez les résultats. Des outils comme wit-bindgen génèrent le glue code à partir de définitions d’interface, mais vous payez quand même la complexity tax.

Les performances varient. Le code intensif en entiers compile vers WASM avec une vitesse proche du natif. Le code qui fait des appels hôtes fréquents, qui alloue beaucoup, ou qui s’appuie sur SIMD subira des ralentissements plus importants. Wasmtime prend en charge les propositions SIMD, mais toutes les cibles ne les prennent pas en charge uniformément.

Enfin, WASI est encore en évolution. WASI Preview 1 est stable et largement supporté. Preview 2 introduit des interfaces de component model plus propres mais pas encore universelles. Si vous déployez aujourd’hui, restez sur Preview 1.

Des alternatives qui valent le coup de connaître

Si WebAssembly vous semble représenter trop de machinerie, il existe des options plus légères avec des compromis différents.

seccomp-bpf vous permet de mettre en liste blanche les syscalls d’un processus Linux. C’est rapide et kernel-enforced. Mais c’est réservé à Linux, n’empêche pas la memory corruption au sein des syscalls autorisés, et écrire un filtre seccomp correct est notoirement sujet aux erreurs.

Landlock est une fonctionnalité Linux plus récente qui restreint l’accès au filesystem sans nécessiter de privilèges. C’est plus simple que seccomp et gère bien le problème de « cette bibliothèque peut-elle lire mes clés SSH ? ». Mais c’est toujours réservé à Linux et ne sandboxe pas la mémoire.

Des processus séparés avec fork ou un pool de workers vous donnent une véritable isolation OS. Un segfault dans l’enfant ne tue que l’enfant. L’inconvénient est l’overhead de sérialisation et la complexité de gestion des processus. Pour des scénarios à haut débit, process-per-call ne passe pas à l’échelle.

WebAssembly se situe au milieu. Isolation plus forte que seccomp, plus portable que Landlock, plus léger que des processus séparés. Le bon choix dépend de votre bottleneck : latence des syscalls, memory safety ou complexité de déploiement.

Un point de départ pratique

Vous n’avez pas besoin de réécrire votre application. Choisissez une dépendance C qui traite des entrées non fiables, compilez-la en WASM, et enveloppez-la derrière un petit shim hôte.

# 1. Install wasi-sdk (or use the prebuilt release)
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-24/wasi-sdk-24.0-x86_64-linux.tar.gz
tar xzf wasi-sdk-24.0-x86_64-linux.tar.gz

# 2. Compile your C library
./wasi-sdk-24.0/bin/clang --target=wasm32-wasi \
  -O2 -Wl,--export-all -o parser.wasm parser.c

# 3. Run it with wasmtime
wasmtime run --dir=./input::/input parser.wasm /input/untrusted.dat

La syntaxe --dir=./input::/input monte le répertoire ./input de l’hôte dans /input à l’intérieur du guest. Le guest ne peut rien voir d’autre sur votre filesystem. Si parser.c contient un buffer overflow, vous obtenez un message de trap, pas un core dump.

Pour l’intégration hôte, utilisez l’API Wasmtime Embedding en Rust, C ou Python. Commencez par une seule exported function. Ajoutez wit-bindgen une fois que le marshaling à la main devient trop limité. Profilez l’overhead avant de vous engager avec WASM sur votre hot path.

FAQ

Puis-je sandboxer une bibliothèque C++ de la même manière ?

Oui. Le WASI SDK inclut clang++ et prend en charge la majeure partie de C++17. Les exceptions et la RTTI fonctionnent, bien que le thread-local storage ait certaines limitations. Compilez avec --target=wasm32-wasi et les mêmes flags.

Et les bibliothèques qui ont besoin du réseau ?

WASI Preview 1 n’inclut pas de sockets. Certaines runtimes offrent des extensions non standard, ou vous pouvez proxyer les requêtes réseau via l’hôte par le biais de imported functions. Preview 2 ajoute des sockets, mais le support se déploie encore progressivement parmi les runtimes.

Quelle est la taille du binaire WASM compilé ?

Typiquement 2x à 5x la taille d’un binaire natif stripped pour le même code C. Wasmtime peut compiler et mettre en cache le code natif lors du premier run, de sorte que le startup overhead est un coût one-time par version du binaire.

Cela remplace-t-il les containers ?

Non. WASM est un sandbox au niveau processus. Les containers sont une isolation au niveau OS. Utilisez WASM pour isoler du code non fiable à l’intérieur de votre processus. Utilisez les containers pour isoler le processus du reste du système. Ils s’empilent, et ils répondent à des threat models différents.

Le guest peut-il s’échapper via une vulnérabilité WASI ?

La surface d’attaque est la runtime WASM et l’implémentation WASI, pas le code du guest lui-même. Wasmtime est écrit en Rust, ce qui élimine de nombreux bugs de memory safety dans la runtime. La spécification WebAssembly fait également l’objet d’une formal verification. Aucun sandbox n’est parfait, mais les runtimes WASM ont un meilleur security track record que la plupart des bibliothèques natives.

Un segfault en C devrait être un échec contenu, pas une panne de production. Les containers sont la bonne réponse lorsque vous avez besoin d’une isolation OS complète. Lorsque vous avez juste besoin d’empêcher une bibliothèque de prendre le contrôle de votre processus, WebAssembly est plus rapide à déployer, plus simple à raisonner, et véritablement efficace.