
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.