
CVE-2026-31413: ошибка корректности верификатора BPF — побег из контейнера
Я нашёл ошибку корректности (soundness) в Linux BPF-верификаторе — + 1 в вызове push_stack(), из-за которого верификатор пропускает ALU-инструкцию на ответвлённом пути. Для BPF_OR это означает, что верификатор отслеживает dst = 0, тогда как CPU вычисляет 0 | K = K. Я написал полноценный побег из контейнера: OOB-чтение/запись из BPF-карты, перехват vtable, перезапись modprobe_path, root на хосте. Затем я подготовил серию из двух патчей — исправление верификатора в один символ и 90 строк selftests — и добился включения её в mainline.
📹 Видео-демонстрация побега из контейнера
| CVE | CVE-2026-31413 |
| Класс ошибки | Корректность верификатора — расхождение значений регистров |
| Корневая причина | push_stack(env, env->insn_idx + 1, ...) пропускает ALU-инструкцию на ответвлённом пути |
| Внесено | bffacdb80b93 — Linux 7.0-rc1 (14 января 2026) |
| Исправлено | c845894ebd6f — Linux 7.0-rc5 (22 марта 2026) |
| Затронутые версии | 6.12.75+ (стабильный бэкпорт dea9989a3f) вплоть до 7.0-rc4 |
| Воздействие | Произвольное чтение/запись в ядре → побег из контейнера → root на хосте |
| Требуется | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| Исправление | Один символ: insn_idx + 1 → insn_idx |
maybe_fork_scalars() разветвляет состояние верификатора, когда видит ARSH + AND/OR с константой. Ответвлённый путь получает dst = 0 и пропускает ALU-инструкцию. Для AND это нормально: 0 & K = 0. Для OR это неверно: 0 | K = K, а не 0.
Верификатор считает регистр нулевым. В CPU — K. Я использовал это, чтобы построить произвольное OOB-чтение/запись из значения BPF-карты, получить адрес карты в ядре, собрать поддельную vtable bpf_map_ops, перенаправить map_push_elem через array_map_get_next_key для произвольной записи и перезаписать modprobe_path. Стоит запустить неизвестный бинарный формат — и ядро выполняет мой скрипт от root. Для контейнера это полный побег на хост.
Серия из двух патчей: исправление верификатора в один символ плюс 90 строк BPF-selftests, покрывающих разветвление OR и AND. Влито Алексеем Старовойтовым 22 марта. CVE-2026-31413 присвоен Грегом Кроа-Хартманом 12 апреля.
eBPF позволяет загружать небольшие программы в ядро — фильтры пакетов, перехватчики трассировки, политики безопасности — без компиляции модуля ядра. Загвоздка в том, что вы внедряете код в кольцо 0 (ring 0). Если в этом коде есть ошибка, это ошибка ядра.
Поэтому перед запуском любой BPF-программы верификатор ядра симулирует каждый возможный путь исполнения. Он отслеживает, что находится в каждом регистре (указатель? скаляр? какой диапазон?), проверяет каждый доступ к памяти на соответствие границам карты и отклоняет всё, что может читать или записывать за пределами границ. Если верификатор говорит, что программа безопасна, JIT компилирует её в нативный машинный код и выполняет с полными привилегиями ядра. После этого проверок границ во время выполнения нет. Верификатор и есть граница безопасности.
Именно поэтому ошибки корректности верификатора отличаются от обычного повреждения памяти. При переполнении кучи или UAF вы получаете один примитив повреждения и дальше работаете от него — распыляете кучу, выстраиваете объекты, ловите окно гонки. При ошибке верификатора вы заставляете ядро поверить лжи о значении регистра. Все проверки границ, зависящие от этого регистра, проходят. Ядро одобрило ваш OOB-доступ и выполняет его без вопросов. Если правильно выстроить состояние регистров, из этого получается чистый и надёжный примитив.
Я аудировал maybe_fork_scalars() — новый код, добавленный в январе 2026 года в bffacdb80b93. Разветвление состояния всегда интересно, потому что именно здесь верификатор расходится на параллельные пути исследования, и если какой-то путь отслеживает неверное значение, всё ниже по этому пути становится некорректным (unsound).
Функция разветвляет состояние, когда видит ARSH + AND/OR с константным источником. Ответвлённый путь получает dst = 0 и пропускает ALU-инструкцию. Я читал строку push_stack(env, env->insn_idx + 1, ...) — и меня тут же осенило: + 1 означает, что ответвлённый путь никогда не выполняет ALU-операцию. Для AND 0 & K = 0, так что пропуск допустим. Для OR 0 | K = K. Ответвлённый путь считает результат равным 0, хотя на самом деле это K.
В тот же вечер я написал BPF-программу. ARSH 63, чтобы получить {0, -1}, OR с константой, условная ветка для разделения путей верификатора, и затем сложение «нулевого» регистра с указателем на карту. Верификатор одобрил map_value + 0. CPU обратился к map_value + K. KASAN подтвердил выход за границы при тестировании.
OOB-чтение/запись — к следующему утру. Побег из контейнера — к следующей ночи. На протяжении всей работы я использовал Claude (Opus 4.5) — и для разбора логики разветвления состояния верификатора, и для мозгового штурма примитивов эксплуатации, и для превращения OOB в полную цепочку побега. Подход с перехватом vtable родился в ходе диалога, в котором Claude прошёлся по указателям на функции bpf_map_ops в поисках вызываемых гаджетов.
Коммит bffacdb80b93 («bpf: Recognize special arithmetic shift in the verifier») попал в 7.0-rc1 14 января 2026 года. Автор — Алексей Старовойтов, соавтор — Puranjay Mohan. В нём была добавлена maybe_fork_scalars() для обработки паттерна LLVM DAGCombiner:```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
LLVM преобразует `select_cc setlt X, 0, A, 0` в `sra + and`. После арифметического сдвига вправо регистр равен либо `0` (неотрицательный вход), либо `-1` (все единицы). AND с константой даёт `0` или `K`.
Верификатор не может отслеживать `{0, K}` в одном `bpf_reg_state` — его диапазон со знаком `[0, K]` является завышенным приближением, и из-за этого он отклонял корректные программы Cilium. Исправление: разветвить состояние верификатора. Один путь исследует `dst = 0`, другой — `dst = -1`, каждый отслеживая точное значение.
Реализация:```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;
}
На пути, который был помещён в стек, происходят две вещи:
0insn_idx + 1 - инструкции после операции ALUДля BPF_AND: dst = 0, AND пропускается. Во время выполнения: 0 & K = 0. Совпадает. Корректно.
Для BPF_OR: dst = 0, OR пропускается. Во время выполнения: 0 | K = K. Несовпадение.
Верификатор видит 0. Процессор имеет K. Некорректно.
Функция не проверяет код операции. Она была написана для AND - где пропуск
инструкции эквивалентен её выполнению с dst = 0 - и была применена также к
OR. Для OR эта эквивалентность не выполняется.