
CVE-2026-31413: Bug de solidez del verificador de BPF - escape de contenedor
Encontré un bug de solidez en el verificador BPF de Linux: un + 1 en una llamada push_stack() que hace que el verificador omita una instrucción ALU en una ruta bifurcada. Para BPF_OR, esto significa que el verificador rastrea dst = 0 mientras la CPU computa 0 | K = K. Escribí un escape completo de contenedor: lectura/escritura fuera de límites desde un mapa BPF, secuestro de vtable, sobrescritura de modprobe_path, root en el host. Luego redacté una serie de dos parches: una corrección de un carácter en el verificador y 90 líneas de selftests, y la fusioné en mainline.
📹 Video de demo de escape de contenedor
| CVE | CVE-2026-31413 |
| Clase de bug | Solidez del verificador - divergencia de valor de registro |
| Causa raíz | push_stack(env, env->insn_idx + 1, ...) omite la instrucción ALU en la ruta bifurcada |
| Introducido | bffacdb80b93 - Linux 7.0-rc1 (14 de enero de 2026) |
| Corregido | c845894ebd6f - Linux 7.0-rc5 (22 de marzo de 2026) |
| Afectado | 6.12.75+ (backport estable dea9989a3f) hasta 7.0-rc4 |
| Impacto | R/W arbitrario del kernel → escape de contenedor → root del host |
| Requiere | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| Corrección | Un carácter: insn_idx + 1 → insn_idx |
maybe_fork_scalars() bifurca el estado del verificador cuando ve ARSH + AND/OR con una constante. La ruta insertada recibe dst = 0 y omite la instrucción ALU. Para AND está bien: 0 & K = 0. Para OR es incorrecto: 0 | K = K, no 0.
El verificador cree que el registro es cero. La CPU tiene K. Usé esto para construir lectura/escritura fuera de límites arbitraria desde un valor de mapa BPF, filtré la dirección del kernel del mapa, construí una vtable falsa de bpf_map_ops, redirigí map_push_elem a través de array_map_get_next_key para escritura arbitraria, y sobrescribí modprobe_path. Activar un formato binario desconocido, el kernel ejecuta mi script como root. Dentro de un contenedor, escape completo del host.
Serie de dos parches: una corrección de un carácter en el verificador más 90 líneas de selftests BPF cubriendo la bifurcación de OR frente a AND. Fusionado por Alexei Starovoitov el 22 de marzo. CVE-2026-31413 asignado por Greg Kroah-Hartman el 12 de abril.
eBPF permite cargar pequeños programas en el kernel —filtros de paquetes, hooks de rastreo, políticas de seguridad— sin compilar un módulo del kernel. La trampa es que estás inyectando código en anillo 0. Si ese código tiene un bug, es un bug del kernel.
Por lo tanto, antes de que cualquier programa BPF se ejecute, el verificador del kernel simula cada posible ruta de ejecución. Rastrea lo que contiene cada registro (¿un puntero? ¿un escalar? ¿qué rango?), verifica cada acceso a memoria contra los límites del mapa, y rechaza cualquier cosa que pueda leer o escribir fuera de límites. Si el verificador dice que un programa es seguro, el JIT lo compila a código máquina nativo y lo ejecuta con privilegios completos del kernel. No hay comprobaciones de límites en tiempo de ejecución después de ese punto. El verificador es el límite de seguridad.
Esta es la razón por la que los bugs de solidez del verificador son diferentes de la corrupción de memoria normal. Con un desbordamiento de heap o UAF, obtienes una primitiva de corrupción y tienes que trabajar desde ahí —esparcir el heap, preparar objetos, competir por una ventana. Con un bug del verificador, consigues que el kernel crea una mentira sobre el valor de un registro. Todas las comprobaciones de límites que dependen de ese registro pasan. El kernel aprueba tu acceso fuera de límites. Lo ejecuta sin dudar. Si puedes alinear correctamente el estado del registro, obtienes una primitiva limpia y confiable a partir de ello.
Estaba auditando maybe_fork_scalars() —código nuevo, añadido en enero de 2026 en bffacdb80b93. La bifurcación de estado siempre es interesante porque es donde el verificador se divide en rutas de exploración paralelas, y si alguna ruta rastrea un valor incorrecto, todo lo que está aguas abajo de esa ruta es inseguro.
La función bifurca cuando ve ARSH + AND/OR con una fuente constante. La ruta insertada recibe dst = 0, omite la instrucción ALU. Estaba leyendo la línea push_stack(env, env->insn_idx + 1, ...) y me di cuenta de inmediato: el + 1 significa que la ruta insertada nunca ejecuta la operación ALU. Para AND, 0 & K = 0, por lo que omitir está bien. Para OR, 0 | K = K. La ruta insertada piensa que el resultado es 0 cuando en realidad es K.
Escribí un programa BPF esa misma noche. ARSH 63 para obtener {0, -1}, OR con una constante, bifurcación condicional para separar las rutas del verificador, luego sumé el registro "cero" a un puntero de mapa. El verificador aprobó map_value + 0. La CPU accedió a map_value + K. KASAN confirmó el acceso fuera de límites en las pruebas.
Lectura/escritura fuera de límites a la mañana siguiente. Escape de contenedor a la noche siguiente. Usé Claude (Opus 4.5) en todo momento —para analizar la lógica de bifurcación de estado del verificador, generar ideas sobre primitivas de explotación y convertir el OOB en una cadena de escape completa. El enfoque de secuestro de vtable surgió de un intercambio en el que Claude examinó los punteros de función de bpf_map_ops buscando gadgets invocables.
El commit bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier") llegó el 14 de enero de 2026 en 7.0-rc1. Alexei Starovoitov, co-desarrollado por Puranjay Mohan. Añadió maybe_fork_scalars() para manejar un patrón LLVM DAGCombiner:```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
LLVM reduce `select_cc setlt X, 0, A, 0` a `sra + and`. Después del desplazamiento aritmético a la derecha, el registro es `0` (entrada no negativa) o `-1` (todos unos). AND con una constante da `0` o `K`.
El verificador no puede rastrear `{0, K}` en un solo `bpf_reg_state` - su rango con signo `[0, K]` sobre-aproxima, y eso causaba que rechazara programas Cilium válidos. La solución: bifurcar el estado del verificador. Un camino explora `dst = 0`, el otro `dst = -1`, cada uno rastreando el valor preciso.
La implementación:```c
static int maybe_fork_scalars(struct bpf_verifier_env *env,
struct bpf_insn *insn,
struct bpf_reg_state *dst_reg)
{
// ... condition check: dst range is [-1, 0], src is constant ...
branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
// ^^^^^^^^^^^^
// pushed path resumes AFTER the ALU insn
if (IS_ERR(branch))
return PTR_ERR(branch);
regs = branch->frame[branch->curframe]->regs;
__mark_reg_known(®s[insn->dst_reg], 0); // pushed: dst = 0
__mark_reg_known(dst_reg, -1ull); // current: dst = -1
return 0;
}
Dos cosas suceden en la ruta empujada:
0insn_idx + 1 - la instrucción después de la operación ALUPara BPF_AND: dst = 0, omitir el AND. Tiempo de ejecución: 0 & K = 0. Coincidencia. Correcto.
Para BPF_OR: dst = 0, omitir el OR. Tiempo de ejecución: 0 | K = K. Desajuste.
El verificador ve 0. La CPU tiene K. Incorrecto.
La función no verifica el código de operación. Fue escrita para AND - donde omitir la instrucción es lo mismo que ejecutarla con dst = 0 - y se aplicó también a OR. Para OR, esa equivalencia no se cumple.