Inyección de Fallos Real a Nivel de Kernel: Una Biblioteca de Caos C99 con LD_PRELOAD para Cualquier Proceso
Ingeniería category.testing 26 de junio de 2026

Inyección de Fallos Real a Nivel de Kernel: Una Biblioteca de Caos C99 con LD_PRELOAD para Cualquier Proceso

Seis dominios de fallos, cero cambios en el código de la aplicación, cualquier binario ELF. Una biblioteca de caos en C99 que intercepta llamadas a libc en el nivel del enlazador dinámico — entregando fallos reales a nivel de kernel a Java, Go, Python, Node.js y todo lo demás.

E
Engineering Team
Senior Solutions Architects
16 min de lectura

El Problema con la Simulación de Llamadas al Sistema

Las bibliotecas de inyección de fallos se dividen en dos grandes grupos. El primero simula a nivel de capa de aplicación: simular un repositorio, simular un cliente HTTP, devolver una excepción. Limpio. Rápido. Y completamente desconectado de lo que hace el entorno operativo real cuando se degrada.

El segundo grupo recurre a herramientas de infraestructura: Toxiproxy para fallos de proxy TCP, tc netem para modelado de paquetes a nivel de kernel, Pumba o Chaos Monkey para matar contenedores. Potente, pero requiere configuración externa, choca con el aislamiento de pruebas y frecuentemente se ejecuta en un proceso separado del que la aplicación no puede ser consciente.

Ambos grupos pasan por alto algo: el límite de libc. La capa donde tu aplicación, independientemente del lenguaje o el runtime, cede el control al sistema operativo. Donde `read()` realmente ocurre. Donde `getaddrinfo()` resuelve nombres. Donde `mmap()` asigna memoria. Donde `clock_gettime()` devuelve el tiempo.

Si interceptas aquí, interceptas todo. Y si los fallos que inyectas en esta capa son indistinguibles de los fallos que produce el kernel — porque usan los mismos códigos errno, las mismas convenciones de valor de retorno, las mismas características de temporización — entonces lo que has construido no es una simulación. Es una simulación real. Una simulación real a nivel de kernel.

Por Qué el Límite de libc Es la Capa Correcta

LD_PRELOAD es una funcionalidad del enlazador dinámico de Linux que inserta objetos compartidos en el mapa de enlace de un proceso antes que cualquier otra biblioteca — incluyendo libc. Cuando una aplicación llama a `connect()`, la resolución de símbolos del enlazador dinámico recorre el mapa de enlace en orden. Si una biblioteca precargada exporta un símbolo llamado `connect`, ese símbolo gana.

Esto nos da interposición: la capacidad de envolver cualquier función de libc sin modificar el código que la llama ni recompilar nada. Y ha estado disponible desde antes de que la mayoría de los sistemas de producción actuales fueran diseñados.

Las propiedades clave que hacen de esta la capa correcta:

**Universalidad independiente del lenguaje.** El `socket.connect()` de Python pasa por glibc. El `net.connect()` de Node.js pasa por glibc. El `Socket.connect()` de Java — a través de JNI — pasa por glibc. Una sola biblioteca precargada los intercepta a todos.

**Fallos reales a nivel de kernel.** Devolvemos valores errno reales. El código que llama no puede distinguir un ECONNREFUSED real del nuestro inyectado. El circuit breaker ve un fallo real, no uno simulado consciente de las pruebas.

**Cero cambios en la aplicación.** Sin agentes. Sin importaciones de API. Sin integración con frameworks de prueba requerida a nivel de aplicación. La aplicación se ejecuta normalmente; los fallos aparecen por debajo de ella.

**Alcance de proceso.** Los fallos están delimitados a un único proceso. Ejecuta tu proceso de prueba con un conjunto de fallos; ejecuta tu dependencia en un contenedor separado sin ningún fallo. Sin contaminación cruzada.

Seis Dominios de Fallos

El conjunto de bibliotecas cubre seis dominios de fallos independientes, cada uno como un objeto compartido separado. Pueden cargarse individualmente o combinarse — la composición es segura porque cada biblioteca posee un conjunto disjunto de símbolos (el Teorema de Propiedad de Símbolos, documentado en detalle en la documentación de arquitectura).

text
libchaos-io.so     — File I/O
  Hooks: open/openat, read/readv, write/writev, pread/preadv, pwrite/pwritev,
         close, fsync/fdatasync, ftruncate, fallocate, unlinkat, renameat,
         sendfile, copy_file_range
  Effects: ERRNO (EIO, ENOSPC, EDQUOT, ...), LATENCY,
           TORN writes (short return < requested), CORRUPT reads (bit flip)

libchaos-net.so    — Network Sockets
  Hooks: socket/socketpair, bind, listen, connect, accept,
         send/sendto/sendmsg/sendmmsg, recv/recvfrom/recvmsg/recvmmsg,
         poll, select, epoll_wait/epoll_pwait
  Effects: ERRNO (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ...), LATENCY, CORRUPT

libchaos-dns.so    — DNS Resolution
  Hooks: getaddrinfo, getnameinfo
  Effects: Name rewrites, address synthesis, EAI_AGAIN, EAI_FAIL, EAI_NONAME,
           EAI_MEMORY, answer shuffling, family filtering, result limiting

libchaos-time.so   — Clock & Timing
  Hooks: clock_gettime (all POSIX clocks), nanosleep, usleep
  Effects: ERRNO, LATENCY, OFFSET (signed millisecond time skew)

libchaos-memory.so — Memory Allocation
  Hooks: mmap, munmap, mprotect, madvise
  Effects: ERRNO (ENOMEM, ENFILE), LATENCY

libchaos-process.so — Process Lifecycle
  Hooks: pthread_create, fork, posix_spawn/posix_spawnp, execve/execveat,
         waitpid
  Effects: ERRNO, LATENCY, FAIL_AFTER (allow N calls then fail permanently)

Interposición de Símbolos: Cómo Funciona Realmente

El mecanismo es simple en principio, cuidadoso en la ejecución.

**Paso 1 — Inicialización de la biblioteca.** Cuando se carga el `.so` precargado, se ejecuta su constructor. Llama a `dlsym(RTLD_NEXT, "connect")` para encontrar el `connect` real de libc. RTLD_NEXT omite el DSO que realiza la llamada en el mapa de enlace y devuelve el siguiente símbolo coincidente — la implementación real de libc. Este puntero se almacena en caché en una variable estática. Sin dlsym por llamada en la ruta crítica.

**Paso 2 — Intercepción de llamadas.** Cuando la aplicación llama a `connect()`, el enlazador dinámico encuentra primero nuestro símbolo (orden del mapa de enlace). Se ejecuta nuestro envoltorio: lee el archivo de configuración (o una instantánea en caché), evalúa si un fallo debe dispararse basándose en el selector de endpoint y la probabilidad, y o bien inyecta el fallo o pasa el control a la función real de libc a través del puntero en caché.

**Paso 3 — Invariante de propiedad de símbolos.** Si dos bibliotecas precargadas intentan envolver `read()`, una de ellas obtendrá RTLD_NEXT apuntando a la otra, causando doble inyección. Prevenimos esto con asignación estricta de símbolos: cada símbolo de libc pertenece exactamente a una biblioteca del conjunto. `libchaos-net.so` no posee `read()` — los símbolos de I/O pertenecen a `libchaos-io.so`. Las bibliotecas se componen de forma segura porque están diseñadas para ello.

bash
# Compose safely — net + dns + io loaded simultaneously, no double-injection
LD_PRELOAD=libchaos-net.so:libchaos-dns.so:libchaos-io.so ./your-service

# Each library reads its own config file independently
/tmp/.chaos-net.conf
/tmp/.chaos-dns.conf
/tmp/.chaos-io.conf

La Interfaz de Archivo de Configuración

No hay API. No hay cabecera pública. No hay biblioteca contra la que enlazar. La interfaz completa es un archivo de configuración en texto plano.

Esta fue una decisión deliberada. Un archivo de configuración puede ser escrito por un script de shell, un trabajo de CI, un harness de prueba en cualquier lenguaje, o un humano en un terminal. No requiere integración con el sistema de construcción. Puede intercambiarse en caliente mientras el proceso está en ejecución. Puede versionarse junto con los datos de prueba. Y crea cero acoplamiento entre la biblioteca de caos y la aplicación bajo prueba.

La sintaxis de configuración es `selector:operación:efecto:valor:probabilidad`. Algunos ejemplos representativos:

text
# libchaos-net.conf — network fault examples
#
# ECONNREFUSED on 10% of connects to the payments service
tcp4://payments.internal:8080:connect:ECONNREFUSED:0.10
#
# ECONNRESET on 5% of recv() on any TCP socket
tcp4://*:*:recv:ECONNRESET:0.05
#
# 200ms latency on 100% of connects to the analytics backend
tcp4://analytics.internal:9000:connect:LATENCY:200
#
# Corrupt 1% of sent packets to any endpoint (single bit flip)
tcp4://*:*:send:CORRUPT:0.01

# libchaos-dns.conf — DNS fault examples
#
# Transient failure for all *.internal lookups
dns://*.internal:EAI_AGAIN:0.20
#
# Permanent failure for a specific service
dns://legacy-payments.svc.cluster.local:EAI_FAIL:1.0

# libchaos-io.conf — filesystem fault examples
#
# Torn writes (short return) on WAL file
/data/wal:write:TORN:0.05
#
# EIO on 1% of reads from the config directory
/etc/app:read:EIO:0.01

Recarga de Configuración Sin Bloqueo en Tiempo de Ejecución

Uno de los desafíos de ingeniería menos obvios es la recarga en caliente de la configuración en un proceso multihilo. El enfoque ingenuo — leer el archivo en cada llamada — es demasiado costoso. El enfoque ingenuo basado en bloqueos — mantener un mutex mientras se actualiza la instantánea de configuración — bloquea cada syscall interceptada durante la recarga.

Usamos un enfoque sin bloqueo basado en comparación e intercambio atómico:

**Dos búferes de instantánea.** Uno está activo; el otro está disponible para escritura. Un slot atómico de 64 bits contiene el mtime de la configuración actualmente activa.

**Detección del ganador de recarga.** Cada intercepción compara el mtime del archivo de configuración con el valor en caché. Ante una discrepancia, un hilo gana la carrera CAS para comenzar la recarga. Los perdedores ven el estado RELOADING y esperan brevemente.

**Intercambio atómico.** El ganador escribe la nueva configuración en el búfer inactivo, luego intercambia atómicamente el índice activo. A partir de este momento, todos los hilos ven la nueva configuración. El búfer antiguo queda disponible para el siguiente ciclo de recarga.

La ventana de recarga es típicamente inferior a 15 microsegundos. Los lectores nunca se bloquean esperando a los escritores en el estado estable. Esto hace que sea práctico cambiar las tasas de fallos entre métodos de prueba sin reiniciar el proceso.

Integración en CI: Tres Líneas de YAML

La integración en un pipeline de CI es intencionalmente mínima. Hay binarios precompilados disponibles para cuatro combinaciones de plataforma (glibc/musl × amd64/arm64). No se necesita paso de compilación — descarga el binario correcto para el SO y la arquitectura de tu runner de CI, escribe un archivo de configuración, establece LD_PRELOAD.

yaml
# GitHub Actions example: inject network chaos for integration tests
- name: Download libchaos
  run: |
    curl -fsSL https://github.com/macstab/chaos-testing-libraries/releases/latest/\
download/libchaos-net-glibc-amd64.so -o /tmp/libchaos-net.so

- name: Write chaos config
  run: |
    cat > /tmp/.chaos-net.conf << 'EOF'
    tcp4://db.internal:5432:connect:ECONNREFUSED:0.10
    tcp4://cache.internal:6379:connect:ETIMEDOUT:0.05
    dns://*.internal:EAI_AGAIN:0.15
    EOF

- name: Run integration tests with chaos
  env:
    LD_PRELOAD: /tmp/libchaos-net.so
  run: ./gradlew integrationTest

Internos con Almacenamiento Local por Hilo: Sin Estado Global

La corrección multihilo se aplica mediante almacenamiento local por hilo en lugar de bloqueos siempre que sea posible.

**PRNG por hilo.** Cada hilo tiene su propio estado de generador de números aleatorios. Las secuencias de fallos son independientes por hilo — sin cuello de botella de aleatoriedad global, sin contención en estado compartido. Esto también significa que las secuencias de fallos son deterministas por hilo dado el mismo valor inicial, lo que importa para reproducir fallos de prueba.

**Guardia de recursión.** Cuando el código de caos llama a los internos de libc (stat para verificar mtime, read para leer el archivo de configuración), esas llamadas serían a su vez interceptadas, causando recursión infinita. Una variable `__thread int guard` lo previene: si la profundidad de recursión es distinta de cero, llamamos directamente a la función real de inmediato.

**Caché de FD a ruta.** El interceptor de I/O de archivos necesita hacer corresponder un descriptor de archivo con una ruta (para la coincidencia de reglas de prefijo de ruta). Leer `/proc/self/fd/<fd>` en cada llamada de I/O es demasiado costoso. Mantenemos una caché local por hilo de 32 slots con mapa directo (fd % 32), sembrada en `open()` e invalidada en `close()`. La tasa de fallos de caché para cargas de trabajo reales es inferior al 3%.

Qué Puede y No Puede Interceptarse

Documentamos los límites explícitamente en lugar de dejar que los usuarios los descubran.

**No se puede interceptar:**

- **Syscalls directas.** El runtime de Go emite syscalls directamente a través del paquete `syscall`, omitiendo libc por completo. Los programas Go compilados sin CGO son inmunes al caos de LD_PRELOAD en su I/O de red. (Java a través de JNI está bien; JNI pasa por glibc.) - **Binarios estáticos.** LD_PRELOAD es un mecanismo del enlazador dinámico. Los binarios estáticos no tienen enlazador dinámico; la precarga no tiene efecto. - **Binarios setuid/setgid.** El kernel elimina LD_PRELOAD para binarios setuid (semántica AT_SECURE). Esta es una característica de seguridad en la que nos apoyamos correctamente. - **Relojes de ruta rápida vDSO.** Algunas llamadas a clock_gettime van a través del vDSO, no de glibc. Nuestra biblioteca de tiempo intercepta al nivel del envoltorio de glibc, que se sitúa aguas abajo del vDSO. En la práctica, esto significa que el fallo se dispara en la llamada a glibc antes de la búsqueda en el vDSO — el efecto neto es el mismo, pero el reloj interno del kernel no se ve afectado.

**Se puede interceptar:** - Cualquier proceso enlazado dinámicamente en Linux (glibc o musl) - Java a través de JNI — incluyendo todas las llamadas de socket, archivo, DNS y tiempo en el JDK - Python, Node.js, Ruby, PHP — todos usan glibc para I/O del sistema - Binarios Rust que usan std (que usa glibc) o bibliotecas C con puente CGO

  • Java (ruta JNI): cobertura completa — red, DNS, archivo, reloj, memoria
  • Python / Node.js / Ruby: cobertura completa a través de glibc
  • Go (CGO=1 o bibliotecas C): cobertura parcial — llamadas CGO interceptadas, syscalls de Go puro las omiten
  • Go (puro, CGO=0): sin cobertura — ABI de syscall directo, LD_PRELOAD no tiene efecto
  • Binarios estáticos: sin cobertura por definición

Rendimiento: Los Números de Sobrecarga

Medimos la sobrecarga de paso transparente con microbenchmarks serializados con rdtsc para obtener números del peor caso. La ruta crítica para una llamada de paso (sin fallo configurado):

- **Acierto de caché de configuración (sin recarga necesaria):** ~20–40 ns de sobrecarga más allá del coste real de la syscall. Esto es una verificación de mtime con stat (almacenada en caché en un slot atómico), un escaneo de reglas (lineal sobre N reglas, típicamente N < 16), y una llamada a través del puntero de función en caché. - **Fallo de caché (archivo de configuración recargado):** Una syscall stat() más una read() del archivo de configuración — típicamente 150–300 ns en total, y solo en la primera llamada tras un cambio de configuración. - **Fallo inyectado:** La sobrecarga del fallo en sí mismo (dormir para LATENCY, establecer errno para ERRNO) domina; el coste del envoltorio es despreciable.

Para un conjunto de pruebas bien configurado con un número pequeño de reglas, la sobrecarga de paso transparente está por debajo del nivel de ruido de una llamada de red. Estás pagando decenas de nanosegundos sobre operaciones que cuestan microsegundos a milisegundos.

Key Takeaways

La biblioteca de caos C99 con LD_PRELOAD es lo que parece la inyección de fallos real a nivel de kernel cuando se diseña desde primeros principios en lugar de ensamblarse a partir de herramientas existentes.

Seis dominios de fallos independientes. Propiedad de símbolos disjunta para que se compongan de forma segura. Una interfaz de configuración en texto plano que funciona desde cualquier lenguaje o cadena de herramientas. Recarga en caliente sin bloqueo para que las tasas de fallos puedan cambiar entre métodos de prueba. PRNG local por hilo para que las secuencias de fallos sean independientes y deterministas. Binarios precompilados para glibc y musl en amd64 y arm64.

Y cobertura del 100% de líneas aplicada en CI, porque hemos enviado suficiente software en cartuchos sin botones de parche como para saber que cada línea sin cubrir es un incidente de producción esperando que ocurra.

El código fuente está disponible en chaos-testing-libraries. El framework de pruebas Java que inyecta estas bibliotecas en tus contenedores Docker automáticamente está en chaos-testing.

#chaos-engineering #c99 #ldpreload #elf #libc #fault-injection #ci-cd #linux #resilience
E

Engineering Team

Senior Solutions Architects

Llevamos construyendo sistemas distribuidos desde antes de que 'microservicios' fuera un concepto. Nuestras cicatrices cuentan historias.