KASAN: shadow memory y la caza de la corrupción
El Kernel Address Sanitizer instrumenta cada acceso a memoria y consulta una shadow memory de un byte por cada ocho para atrapar out-of-bounds y use-after-free en la instrucción exacta. CONFIG_KASAN, los tres modos, la cuarentena de objetos liberados y cómo leer el volcado de sombra de su informe.
La depuración slab (nivel 21) rodea cada objeto de redzones y espera a que alguien las pise. KASAN va un paso más allá: no vigila el perímetro del objeto, sino cada carga y cada almacenamiento que el código ejecuta. El compilador instrumenta el acceso, una tabla lateral dice si esa dirección es legítima, y la corrupción se delata en la instrucción exacta que la comete, con la pila completa. Es la red de malla más fina que tiene el kernel.
- Entender la shadow memory: un byte de sombra por cada ocho de memoria.
- Ver cómo el compilador instrumenta cada acceso y qué es la cuarentena.
- Leer un informe de KASAN, con su volcado de sombra y el marcador.
- Distinguir los tres modos: generic, tags por software y MTE por hardware.
Shadow memory: un byte por cada ocho
La idea que sostiene a KASAN es geométrica. Reserva 1/8 del espacio de direcciones del kernel como memoria de sombra: cada grupo de ocho bytes contiguos —un granule— se resume en un único byte de sombra. Ese byte codifica cuántos de los ocho son accesibles. Un 0x00 dice que los ocho valen; un valor de 1 a 7 dice que solo los primeros N; y un valor negativo es un código de veneno: KASAN_SLAB_REDZONE para el relleno que rodea un objeto, KASAN_SLAB_FREE para memoria ya liberada, y variantes para pila y globales.
La proyección de una dirección a su byte de sombra es una simple suma tras un desplazamiento:
/* arch/x86/include/asm/kasan.h */
#define KASAN_SHADOW_OFFSET _AC(0xdffffc0000000000, UL)
#define KASAN_SHADOW_SCALE_SHIFT 3
/* mm/kasan/kasan.h: cada direccion se resume en un byte de sombra */
static inline void *kasan_mem_to_shadow(const void *addr)
{
return (void *)((unsigned long)addr >> KASAN_SHADOW_SCALE_SHIFT)
+ KASAN_SHADOW_OFFSET;
}
El desplazamiento addr >> 3 colapsa ocho direcciones en una, y sumar KASAN_SHADOW_OFFSET las lleva a la región de sombra. Comprobar un acceso de un byte es leer ese byte de sombra y decidir si el desplazamiento dentro del granule cae en la zona envenenada:
static __always_inline bool memory_is_poisoned_1(const void *addr)
{
s8 shadow_value = *(s8 *)kasan_mem_to_shadow(addr);
if (unlikely(shadow_value)) {
s8 last_accessible_byte = (unsigned long)addr & KASAN_GRANULE_MASK;
return last_accessible_byte >= shadow_value;
}
return false;
}
La instrumentación del compilador y la cuarentena
KASAN no parchea el kernel a mano: se lo pide al compilador. Con CONFIG_KASAN_GENERIC, cada fichero se compila con -fsanitize=kernel-address, y GCC o Clang insertan antes de cada acceso una llamada a __asan_load{1,2,4,8,16} o __asan_store... —o, con instrumentación inline, la comprobación de sombra directamente en el flujo—. Todas desembocan en kasan_check_range(), que consulta la sombra y, si el acceso pisa veneno, invoca kasan_report().
El segundo pilar es la cuarentena. Un use-after-free solo se caza si la memoria liberada sigue marcada como liberada cuando alguien la vuelve a tocar; pero el asignador tiende a reciclar de inmediato. Por eso KASAN intercepta kfree(), envenena el objeto con KASAN_SLAB_FREE, guarda la pila de liberación y lo mete en una cola de cuarentena en vez de devolverlo al slab. El objeto no se reutiliza hasta que la cola se llena y se drena, ampliando la ventana en la que un acceso tardío choca contra el veneno.
El modo generic multiplica el consumo de memoria (la sombra es 1/8 de todo el espacio del kernel, más las redzones y la cuarentena) y ralentiza cada acceso instrumentado del orden de dos a tres veces. Es el precio de la precisión total. Compílalo para cazar en una VM o en CI, jamás para medir rendimiento ni para desplegar, salvo el modo MTE por hardware, que sí aspira a producción.
Leer un informe de KASAN
Un desbordamiento de un byte y un uso tras liberar en el mismo módulo bastan para provocarlo:
static int __init kasan_demo_init(void)
{
char *p = kmalloc(64, GFP_KERNEL);
if (!p)
return -ENOMEM;
p[64] = 'x'; /* out-of-bounds: un byte pasado el final */
kfree(p);
return p[0]; /* use-after-free */
}
KASAN detiene la instrucción culpable y vuelca un informe en tres bloques: qué acceso falló, quién asignó (y liberó) el objeto, y el estado de la sombra alrededor de la dirección:
==================================================================
BUG: KASAN: slab-out-of-bounds in kasan_demo_init+0x54/0xff0 [kasan_demo]
Write of size 1 at addr ffff88810a2c3440 by task insmod/1523
CPU: 2 PID: 1523 Comm: insmod Tainted: G B 7.3.0 #1
Call Trace:
dump_stack_lvl+0x4d/0x70
print_report+0xca/0x620
kasan_report+0xb8/0xf0
kasan_demo_init+0x54/0xff0 [kasan_demo]
do_one_initcall+0x77/0x3c0
Allocated by task 1523:
kasan_save_stack+0x2c/0x50
__kasan_kmalloc+0x8f/0xa0
kasan_demo_init+0x2a/0xff0 [kasan_demo]
Memory state around the buggy address:
ffff88810a2c3380: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
ffff88810a2c3400: 00 00 00 00 00 00 00 00 fc fc fc fc fc fc fc fc
>ffff88810a2c3440: fc fc fc fc fc fc fc fc fc fc fc fc fc fc fc fc
^
==================================================================
La fila marcada con > es la que contiene la dirección; el ^ apunta al byte de sombra exacto. Los 00 son los ocho granules del objeto de 64 bytes, todos accesibles; los fc son el KASAN_SLAB_REDZONE, el relleno donde cayó el p[64]. El informe termina con una leyenda que traduce cada código de veneno. Para que dispare siempre y no solo en el primer fallo, se arranca con kasan_multi_shot; para convertir cada informe en un panic, con kasan.fault=panic.
Tres modos: generic, tags por software y MTE
Generic
CONFIG_KASAN_GENERIC, todas las arquitecturas. Sombra 1/8, redzones, cuarentena y el mejor diagnóstico. Pesado: es la herramienta de CI y fuzzing.
Tags por software
CONFIG_KASAN_SW_TAGS, solo arm64. Usa top-byte-ignore para meter una etiqueta de 8 bits en cada puntero; sombra 1/16, mucho menos memoria.
Hardware MTE
CONFIG_KASAN_HW_TAGS, arm64 con Memory Tagging Extension. El hardware compara la etiqueta; coste mínimo, apto para producción.
El modo generic es el que corre syzbot, el fuzzer que reporta la mayoría de los fallos de seguridad de memoria del kernel: si tocas código que asigna o accede a memoria, se espera que tu parche pase bajo KASAN sin quejas. Los modos con tags nacen de un cálculo distinto: en vez de una sombra que dice cuánto es accesible, asocian una etiqueta aleatoria al puntero y otra a la memoria, y un acceso legítimo es aquel en que coinciden; un use-after-free falla porque al liberar se reetiqueta la memoria y el puntero viejo queda con la etiqueta caduca. MTE lleva esa comparación al silicio, y por eso puede vivir muestreando en teléfonos reales.
Detente en lo que KASAN hace de verdad, porque es más profundo que cazar bugs. C, al compilar, destruye la información de tipo y de vida de la memoria: en tiempo de ejecución un byte es un byte, sin nombre ni dueño ni fecha de nacimiento, y por eso un puntero fuera de rango o un objeto muerto son indistinguibles de un acceso legítimo. La corrección de memoria deja de ser una propiedad decidible. La shadow memory reintroduce esa información como metadato explícito: por cada ocho bytes reales, un byte lateral que recuerda cuántos son legítimos y por qué los demás no lo son. El compilador, que sabía todo eso en tiempo de compilación y lo iba a tirar, en su lugar emite el código que lo consulta en cada acceso. Con eso, una pregunta que en C es indecidible —¿este acceso es válido?— se vuelve una lectura de un byte y una comparación. Es la misma jugada que viste en las redzones del slab (nivel 21), llevada al límite: allí se sembraban patrones en el perímetro y se esperaba a que alguien los pisara; aquí cada instrucción de memoria pregunta antes de actuar. Pasas de rezar por reproducir a garantizar que el fallo grite en el sitio y el instante del crimen. Esa es la diferencia entre depurar por arqueología y depurar por construcción.
- Compila un kernel con
CONFIG_KASAN_GENERICyCONFIG_DEBUG_INFO, y arráncalo en QEMU. - Carga un módulo que desborde un
kmallocde 64 bytes por uno solo; localiza en el informe la fila>y el^, y explica por qué el byte marcado esfc. - Provoca un use-after-free y contrasta las secciones Allocated by y Freed by con las líneas de tu código.
- Calcula a mano
kasan_mem_to_shadow(addr)para la dirección del informe y comprueba que apunta a la fila del volcado. - Repite con
kasan.fault=panicy observa cómo el mismo bug ahora tumba la máquina en el acto.