Un único null pointer dereference dentro de una biblioteca en C puede tumbar toda tu aplicación. Si esa biblioteca parsea input del usuario, descomprime imágenes o maneja protocols de red, estás a un malformed packet de un crash. Los containers solucionan esto, pero levantar Docker para una sola dependencia es como contratar a un seguridad para una planta de interior. Necesitas aislamiento sin el overhead.
WebAssembly más WASI es la respuesta más práctica. Compila la biblioteca a WASM, ejecútala dentro de una runtime sandboxed, y el proceso host sobrevive incluso cuando el guest segfaultea. La memoria tiene bounds checking. El acceso al filesystem es capability-based. Y el sandbox es una library call, no una system call.
Qué significa realmente hacer sandbox a una biblioteca en C
C no tiene memory safety. Un buffer overflow en una dependencia puede corromper tu stack, secuestrar la ejecución o tumbar el host. Las mitigaciones tradicionales como ASAN atrapan bugs en runtime, pero agregan overhead y no aislan la falla del resto del proceso. seccomp y Landlock pueden restringir syscalls, pero son solo para Linux, requieren root para algunas operaciones, y no detienen la memory corruption dentro de los syscalls permitidos.
WebAssembly toma un enfoque diferente. Compila a un instruction set virtual con memory bounds explícitas, control flow estructurado y ningún undefined behavior para accesos out-of-bounds. Una runtime de WASM como Wasmtime o WAMR valida el bytecode antes de la ejecución. Si el guest toca memoria que no le pertenece, la runtime hace trap. El host sigue corriendo.
Esto no es emulación. Las runtimes modernas de WASM compilan a código máquina nativo vía Cranelift o LLVM. El hit de performance suele estar entre 1,1x y 2x para código compute-bound. Para bibliotecas I/O-bound, la diferencia suele perderse en el ruido.
Cómo WASI convierte el acceso al filesystem en una capability
WASI es el WebAssembly System Interface. Da a los modules sandboxed una API tipo POSIX, pero cada recurso es una capability. No hay filesystem global. Si quieres que el guest lea ./data/input.txt, preabres explícitamente ese directorio y pasas el file descriptor.
Aquí está una biblioteca mínima en C que lee un archivo y devuelve un 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;
}
Compílala a WebAssembly con el 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
El archivo .wasm resultante es un module sandboxed. No puede abrir ningún archivo a menos que la runtime otorgue acceso explícitamente. No puede asignar memoria fuera de su linear memory region. No puede ejecutar comandos de shell arbitrarios porque WASI no define execve.
Ejecutar la biblioteca sandboxed desde Rust
Wasmtime es una runtime de WASM production-ready, escrita en Rust con bindings para C y Python. Aquí está un programa host que carga la biblioteca compilada en C, le otorga acceso de solo lectura a ./data/, y llama a 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(())
}
El código C del guest corre dentro de su propia linear memory de 32 bits. La llamada memory.write copia el string de path a ese address space sandboxed. Si checksum_file intenta leer más allá del final de su memoria, Wasmtime levanta un trap. Si intenta abrir /etc/passwd, WASI devuelve ENOENT porque ese path nunca fue preopened.
Dónde el modelo de memoria realmente te protege
Los modules WASM usan una única linear memory contigua, típicamente empezando en 64KB y creciendo on demand. Cada acceso a memoria es bounds-checked por la runtime. Un buffer overflow en la biblioteca C no puede corromper el stack o heap del host. No puede saltar a código arbitrario. Solo puede tocar memoria dentro de su propia región asignada.
Esto es más débil que el aislamiento completo de proceso. El guest y el host comparten el mismo proceso del SO. Un ataque de side-channel a nivel de CPU o un bug de speculative execution podrían filtrar datos teóricamente a través del límite. Pero para el threat model de “esta biblioteca en C tiene un bug y podría crashear”, el aislamiento de WASM es genuino y práctico.
El comportamiento de trap-on-fault es el key selling point. Si el guest dereferencia un null pointer o desborda un buffer, Wasmtime lo atrapa y devuelve un error. Tu proceso host no hace segfault. Tu servidor web no se reinicia. Logueas el error y sigues.
Los trade-offs que te ralentizarán
WASM no es transparente. Código C que usa threads, signals, setjmp/longjmp, o mmap raw no compilará a WASI sin reescribir. El WASI SDK soporta pthreads vía una flag opcional, pero sigue siendo experimental y requiere que la runtime host lo habilite.
La frontera FFI es manual. No puedes simplemente linkear un archivo .wasm en un proyecto C o Rust y llamar a sus funciones como una biblioteca normal. Marshalas argumentos a linear memory, llamas a la función por index, y lees los resultados de vuelta. Herramientas como wit-bindgen generan el glue code desde definiciones de interfaz, pero igual pagas la complexity tax.
El performance varía. Código con muchos enteros compila a WASM con near-native speed. Código que hace host calls frecuentes, asigna mucho, o depende de SIMD verá slowdowns mayores. Wasmtime soporta proposals de SIMD, pero no todo target los soporta uniformemente.
Finalmente, WASI todavía está evolucionando. WASI Preview 1 es estable y ampliamente soportado. Preview 2 introduce interfaces del component model que son más limpias pero aún no universales. Si haces ship hoy, quédate en Preview 1.
Alternativas que vale la pena conocer
Si WebAssembly se siente como demasiada maquinaria, hay opciones más ligeras con diferentes trade-offs.
seccomp-bpf te permite poner en una whitelist los syscalls de un proceso Linux. Es rápido y kernel-enforced. Pero es solo para Linux, no previene memory corruption dentro de syscalls permitidos, y escribir un filtro seccomp correcto es notoriamente propenso a errores.
Landlock es una característica más nueva de Linux que restringe el acceso al filesystem sin requerir privilegios. Es más simple que seccomp y maneja bien el problema de “¿puede esta biblioteca leer mis SSH keys?”. Pero sigue siendo solo para Linux y no hace sandbox de memoria.
Procesos separados con fork o un pool de workers te dan true OS isolation. Un segfault en el child mata solo al child. La desventaja es el overhead de serialización y la complejidad de gestión de procesos. Para escenarios de high throughput, process-per-call no escala.
WebAssembly está en el medio. Aislamiento más fuerte que seccomp, más portable que Landlock, más ligero que procesos separados. La elección correcta depende de si tu bottleneck es la latencia de syscalls, la memory safety o la complejidad de deployment.
Un punto de partida práctico
No necesitas reescribir tu aplicación. Elige una dependencia en C que maneje input no confiable, compílala a WASM, y envuélvela detrás de un pequeño host shim.
# 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 sintaxis --dir=./input::/input monta el directorio ./input del host en /input dentro del guest. El guest no puede ver nada más en tu filesystem. Si parser.c contiene un buffer overflow, recibes un mensaje de trap, no un core dump.
Para la integración con el host, usa la Wasmtime Embedding API en Rust, C o Python. Empieza con una sola exported function. Agrega wit-bindgen una vez que el marshaling a mano se te quede pequeño. Profilea el overhead antes de comprometerte con WASM en tu hot path.
FAQ
¿Puedo hacer sandbox a una biblioteca en C++ de la misma manera?
Sí. El WASI SDK incluye clang++ y soporta la mayor parte de C++17. Las excepciones y RTTI funcionan, aunque el thread-local storage tiene algunas limitaciones. Compila con --target=wasm32-wasi y las mismas flags.
¿Qué pasa con las bibliotecas que necesitan red?
WASI Preview 1 no incluye sockets. Algunas runtimes ofrecen extensiones no estándar, o puedes proxyar requests de red a través del host vía imported functions. Preview 2 agrega sockets, pero el soporte aún se está desplegando entre las runtimes.
¿Qué tan grande es el binario WASM compilado?
Típicamente 2x a 5x el tamaño de un binario nativo stripped para el mismo código en C. Wasmtime puede compilar y cachear el código nativo en la primera ejecución, así que el startup overhead es un costo one-time por versión del binario.
¿Esto reemplaza a los containers?
No. WASM es un sandbox a nivel de proceso. Los containers son aislamiento a nivel de SO. Usa WASM para aislar código no confiable dentro de tu proceso. Usa containers para aislar el proceso del resto del sistema. Se apilan, y sirven a diferentes threat models.
¿Puede el guest escapar a través de una vulnerabilidad de WASI?
La superficie de ataque es la runtime de WASM y la implementación de WASI, no el código del guest en sí. Wasmtime está escrito en Rust, lo que elimina muchos bugs de memory safety en la runtime. La especificación de WebAssembly también se somete a formal verification. Ningún sandbox es perfecto, pero las runtimes de WASM tienen un mejor security track record que la mayoría de las bibliotecas nativas.
Un segfault en C debería ser un failure contenido, no un outage de producción. Los containers son la respuesta correcta cuando necesitas aislamiento completo del SO. Cuando solo necesitas evitar que una biblioteca se apodere de tu proceso, WebAssembly es más rápido de deployar, más fácil de razonar, y genuinamente efectivo.