
При исследовании eBPF verifier возник вопрос: почему разные системы проверки могут по‑разному оценивать одну и ту же программу.
eBPF‑программы проходят статический анализ до загрузки в ядро Linux. Верификатор должен доказать, что программа не выходит за границы памяти, не работает с неверными указателями и не нарушает ограничений, без которых код нельзя безопасно выполнять в ядре.
Обычно вердикт однозначен: программу либо принимают, либо отклоняют. Но если прогнать один объект через разные верификаторы, картина меняется. Один анализатор принимает BPF‑объект, а другой считает его небезопасным и запрещает загрузку.
Именно так и вышло: PREVAIL принял BPF‑объект, верификатор ядра его отклонил. Программа одна, условия одни, объектный файл один и тот же.
Разберём это на программе correlated_branch.c из набора ebpf‑samples: что происходит во время анализа, какую информацию удерживает верификатор ядра, какое состояние формирует PREVAIL и почему в одном случае доступ к памяти признан безопасным, а в другом анализ обрывается.
Статья для тех, кто исследует eBPF и статический анализ. Ошибку в программе мы искать не будем — её там нет. Нас интересует другое: почему два верификатора расходятся в выводах.
Стенд
ОС: Ubuntu 24.04.1 LTS
Ядро: Linux 6.14.0–37-generic (SMP, PREEMPT_DYNAMIC, x86_64)
Компилятор: clang 18.1.3, целевая платформа bpf
Верификатор ядра: штатный, загрузка через bpftool
Внешний верификатор: PREVAIL, собран из исходников, запуск бинарником./bin/prevail
Тестируемая программа: correlated_branch.c из репозитория ebpf‑samples
Как я нашла расхождение
Чтобы найти такие расхождения, я взяла PREVAIL как независимый верификатор. Идея простая: прогнать реальные BPF‑программы через оба и сравнить вердикты.
На объекте correlated_branch.o вердикты разошлись.
PREVAIL завершил анализ успешно:

При загрузке того же объекта верификатор ядра выдал отказ:

Дальше нужно было понять, что каждый верификатор увидел во время анализа.
Раз верификатор ядра остановился на чтении из памяти пакета, надо пройти весь путь до этой инструкции: что проверялось раньше, какие ограничения осели в состоянии и почему они не открыли доступ.
Что делает correlated_branch.с
Программа находится в наборе ebpf‑samples и содержит XDP‑функцию ConvergedBranch.
Она читает данные сетевого пакета. Перед доступом программа берёт границы: начало и конец области данных.
Перед чтением Ethernet‑заголовка функция check_packet убеждается, что данных в пакете хватает:
void* data_start = (void *)(uintptr_t)ctx->data; void* data_end = (void *)(uintptr_t)ctx->data_end; if (data_start + required_length > data_end) { return 0; } return (void *)(uintptr_t)ctx->data;

Если размер пакета недостаточен, функция возвращает нулевой указатель. Если проверка проходит, возвращается указатель на начало пакета.
В функции ConvergedBranch этот результат используется для получения Ethernet‑заголовка:
ETHERNET_HEADER *header = check_packet(ctx, sizeof(ETHERNET_HEADER));
После этого выполняется проверка:
if (header == NULL) { goto Exit; }
И только после неё происходит обращение к полю заголовка:
if (header->Type == 0x0800 || header->Type == 0x0806) { ... }

В исходном коде порядок безупречен: сначала проверка размера, потом чтение.
Но верификатор исходный код не видит. Он работает с BPF‑инструкциями и строит собственную модель состояния программы.
Что увидел верификатор ядра
Лог верификатора ядра показывает: проверка границ в анализе есть.
Сначала программа получает указатели на начало и конец пакета:
r1 = data_start
r2 = data_end
Далее вычисляется размер доступной области:
r2 -= r1
После этого выполняется сравнение с размером Ethernet‑заголовка:
r3 = 14
if r3 s> r2 goto
После этой проверки верификатор получает ограничение на размер:
R2_w=scalar(smin=umin=14,...)

После проверки в состоянии верификатора появляется ограничение: доступная область пакета имеет размер не меньше 14 байт. Однако это ограничение не связывается с указателем r7, который используется при последующем чтении.
После этого указатель сохраняется:
r7 = r1
И дальше выполняется чтение:
r3 = (u16 )(r7 + 12)
Именно здесь Linux verifier останавливает проверку:
invalid access to packet, off=12 size=2
Здесь и возникает главный вопрос: размер проверен — почему этого не хватило для доступа?
Что увидел PREVAIL
Отличие PREVAIL от верификатора ядра заключается в способе представления и анализа состояний программы. Верификатор ядра отслеживает состояние регистров, типов указателей и диапазонов значений при проходе по инструкциям. PREVAIL использует графовую модель состояний, что позволяет ему сохранять и анализировать связи между различными состояниями программы.
Тот же участок я прогнала через PREVAIL.
После прохождения проверки PREVAIL формирует состояние:
r7.type=packet packet_size=14 r7.packet_offset=0

Связь между указателем r7 и областью пакета сохранена — и PREVAIL пользуется ею при следующем чтении.
Когда анализ доходит до инструкции:
r3 = (u16 )(r7 + 12)
PREVAIL выполняет проверку:
assert valid_access(r7.offset+12, width=2) for read;
Доступ разрешён: смещение 12 плюс два байта чтения ровно укладываются в область из 14 байт.

Где возникает различие
Проверка границ в исходном коде одна. Расходятся верификаторы после неё.
PREVAIL сохраняет состояние, в котором указатель остаётся связанным с размером проверенной области пакета. Поэтому при следующем чтении он может использовать результат предыдущей проверки.
Верификатор ядра проверяет доступ к памяти не через исходное условие C‑кода, а через состояние регистра‑указателя в момент инструкции чтения.
Когда выполняется проверка:
data_end - data >= 14
результат этой проверки относится к вычисленному значению длины. В состоянии верификатора появляется информация о том, что скалярное значение длины имеет нижнюю границу 14.
Но инструкция чтения:
(u16 )(r7 + 12)
проверяется уже через состояние r7 как packet pointer. Для него верификатору нужно иметь информацию о допустимом диапазоне смещения относительно начала пакета.
В текущем варианте программы проверка длины и последующий доступ представлены для анализатора как две разные операции с разными объектами состояния:
одна дала ограничение на результат вычисления длины;
другая требует ограничения на диапазон указателя.
Я предположила, что проблема возникает из‑за того, что верификатор не может проследить гарантию безопасности через отдельную функцию. Поэтому заменила вызов функции на явную проверку в месте использования указателя.
void *data = (void *)(uintptr_t)ctx->data; void *data_end = (void *)(uintptr_t)ctx->data_end; if (data + sizeof(ETHERNET_HEADER) <= data_end) { ethernet_header = data; } else { ethernet_header = 0; }
Предположение оказалось удачным. Верификатор ядра принял программу.

Что делать, если верификатор отклонил безопасный код
Проверяйте границы в той же функции, где читаете данные. Проверка, вынесенная в отдельную функцию, возвращает указатель, но в данном случае верификатор ядра не связывает возвращаемый указатель с ограничением размера, полученным при проверке границы.
Сравнивайте указатель с data_end напрямую: if (ptr + N > data_end) return 0;.
Вычисление разности data_end — data даёт ограничение на скаляр, а не на указатель.
Читайте лог по состоянию регистров, а не по строке ошибки. Запись вида R7 pkt(off=0,r=0) говорит больше, чем сам текст отказа: r=0 означает, что у указателя нет проверенного диапазона.
PREVAIL прав: код безопасен, чтение укладывается в проверенные 14 байт. Верификатор ядра отклонил программу из‑за ограничения анализа, а не из‑за ошибки в коде.
Отсюда практический вывод: отказ верификатора не равен уязвимости. Иногда это сигнал, что в программе ошибка. Иногда — что нужные гарантии в программе есть, но записаны так, что анализ их не связал. Различать эти два случая приходится вручную, и цена ошибки здесь — часы, потраченные на поиск несуществующего бага.
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

