Код возврата — ноль. В логах пусто. Мониторинг спокоен. А куча процесса испорчена.
В разборе PixelSmash исследователи JFrog зафиксировали именно такое поведение: Jellyfin и Emby переваривали вредоносный файл с кодом возврата 0 и без единого сообщения об ошибке, притом что ASAN-сборка того же FFmpeg подтверждала запись на 640 байт за границей буфера.
Вопрос, на который отвечает эта статья: можно ли заметить повреждение памяти и сколько это стоит. Ответ оказался практичнее, чем я ожидал, — и с ловушкой, на которую я сам наступил при подготовке стенда.
В статье
- Почему аллокатор молчит
- Ловушка: проверки, которые не включились
- Эксперимент: кто и когда ловит
- Цена детекта
- Что из этого применимо
- Итог
- Источники
Почему аллокатор молчит
Начнём с факта, который объясняет часть тишины:
void *p = malloc(32);
printf("%zu\n", malloc_usable_size(p)); // 40
Запрошено 32 байта, фактически доступно 40. Разница не сводится к одному лишь выравниванию: glibc размещает запрос в чанке большего размера, округляет его до кратного и устроен так, что часть граничных метаданных соседа оказывается доступна текущему блоку. Сумма этих обстоятельств и даёт в этом запуске 40 байт вместо 32. Для программы граница проходит по 32 байтам, для аллокатора — по 40.
Дальше важна не арифметика, а то, что именно портит запись. Стенд снимает дамп заголовка соседнего чанка до и после записи. Поле size в glibc хранит размер вместе со служебными битами (PREV_INUSE, IS_MMAPPED, NON_MAIN_ARENA), поэтому смотреть надо на размер после маскирования:
| Байт за границей | Вышли за usable? | chunksize соседа |
Флаги PREV_INUSE/IS_MMAPPED |
|---|---|---|---|
| 8 | нет | 4112 → 4112 | 1/0 → 1/0 |
| 9 | да | 4112 → 4160 | 1/0 → 0/1 |
| 10 | да | 4112 → 16960 | 1/0 → 0/1 |
Здесь видно то, чего не даёт рассуждение «уложились в выравнивание или нет». Уже на девятом байте заголовок соседа повреждён — и флаги, и размер, — но процесс завершается штатно. На десятом размер становится 16960, и следующий malloc падает с malloc(): corrupted top size.
Обратите внимание: флаги в обоих случаях искажены одинаково. Значит различает эти случаи именно величина размера.
А вот дальше начинается граница того, что стенд доказывает. Напрашивается объяснение «16960 превысило объём арены» — в glibc 2.39 сообщению соответствует проверка из _int_malloc:
victim = av->top;
size = chunksize (victim);
if (__glibc_unlikely (size > av->system_mem))
malloc_printerr ("malloc(): corrupted top size");Я попробовал это проверить — и объяснение не подтвердилось. Замер mallinfo2 показывает арену в 135 168 байт, то есть и 4160, и 16960 меньше её объёма; вдобавок исходный chunksize соседа (4112) не совпадает с размером top chunk (130 352), значит наш сосед — не top.
Поэтому честная формулировка такая: наблюдаемо одно значение принимается, другое отвергается с ошибкой про top size; каким именно путём повреждение доходит до этой проверки, стенд не устанавливает. Простое «размер превысил арену» здесь не работает, и выдавать его за механизм было бы подгонкой объяснения под результат.
Практический вывод от этого не меняется: до какого-то порога повреждение проходит незамеченным, и порог зависит от раскладки, а не от того, «сильно» ли вы вышли за буфер.
Проверки glibc по умолчанию вообще не следят за границами буферов — они проверяют консистентность собственных структур, когда до них доходят руки: при следующем malloc, при free, при слиянии соседей.
Ловушка: проверки, которые не включились
Отдельного разговора заслуживает то, как я едва не опубликовал неверный вывод.
Первая версия стенда запускала программу так:
MALLOC_CHECK_=3 ./oobtest 4
GLIBC_TUNABLES=glibc.malloc.check=3 ./oobtest 4Матрица заполнилась, колонки совпали с baseline байт в байт, и напрашивался вывод: «включённые проверки ничего не меняют». Вывод был бы ложным — потому что проверки не были включены.
Начиная с glibc 2.34 отладочные проверки malloc вынесены в отдельную библиотеку libc_malloc_debug. Без её предзагрузки обе переменные не делают ничего, и процесс работает ровно как baseline. Правильный запуск:
LD_PRELOAD=libc_malloc_debug.so.0 MALLOC_CHECK_=3 ./oobtest 4
# free(): invalid pointerРазница радикальная: тот же четырёхбайтовый выход за границу, который baseline не замечает вовсе, здесь ловится сразу. Стенд теперь находит библиотеку через ldconfig и подставляет её сам.
Ловушка стоит внимания не как курьёз, а как рабочий риск: конфигурация «проверки включены» может годами существовать в виде переменной окружения, которая ни на что не влияет. Проверять это надо не чтением документации, а заведомо битым входом.
Эксперимент: кто и когда ловит
Стенд меняет одну переменную — насколько далеко запись уходит за границу. Программа выделяет 32 байта и пишет N байт за границей; всё остальное зафиксировано: одна машина, -O2, glibc 2.39, x86-64. Полный стенд: security/untrusted-input/detect.
Перед каждым прогоном стенд доказывает, что запись действительно произошла: читает записанное обратно и печатает результат через write(2). Матрица не строится, если для любого смещения контроль отсутствует или не подтверждён.
| Байт за границей | glibc по умолчанию | MALLOC_CHECK_=3 + preload |
MALLOC_PERTURB_ |
hardened_malloc | scudo | ASAN |
|---|---|---|---|---|---|---|
| 1 | молчит | ловит | молчит | молчит | молчит | ловит |
| 4 | молчит | ловит | молчит | молчит | молчит | ловит |
| 8 | молчит | ловит | молчит | молчит | молчит | ловит |
| 9 | молчит | ловит | молчит | ловит | молчит | ловит |
| 10 и дальше | ловит | ловит | ловит | ловит | молчит | ловит |
Что из этого следует.
Debug-аллокатор glibc обнаружил все проверенные смещения, начиная с одного байта. Это важная поправка к ожиданию «glibc бессилен»: дело не в бессилии, а в том, что по умолчанию эти проверки выключены.
Но из строки таблицы не следует эквивалентность ASAN. Руководство glibc прямо предупреждает, что debug-аллокатор рассчитан на простые ошибки и обнаруживает не всякое повреждение. ASAN добавляет проверки к обращениям в инструментированном коде и дополняет их перехватчиками библиотечных функций — покрывая заметно больше классов ошибок: чтение за границей, использование после освобождения, выход за границы стека и глобальных переменных. Оговорка про инструментированный код существенна: неперекомпилированные библиотеки в область его зрения не попадают. Здесь совпал результат на одном конкретном сценарии, а не область применимости.
Поведение по умолчанию — молчание. Обычный процесс без всяких настроек не заметит ничего, пока повреждение не станет грубым. Именно это состояние и работает в проде у большинства.
MALLOC_PERTURB_ — не детектор границ. Он заполняет освобождённую память мусором, чтобы использование после освобождения быстрее проявлялось, но за границами буферов не следит: его колонка повторяет baseline. Предзагрузки он, в отличие от двух предыдущих, не требует — и запускается без неё, иначе в его результат подмешался бы чужой эффект.
hardened_malloc ловит на девятом байте — там, где запись впервые дотягивается до canary, контрольного значения за пользовательскими данными. На восьми байтах она до него не достаёт.
Scudo в этой конфигурации не отреагировал ни на одно смещение. Это не значит «бесполезен»: двойное освобождение он ловит (invalid chunk state — проверено отдельно). Означает лишь, что данная сборка scudo при данной раскладке кучи эти записи не обнаружила; результат зависит от того, какие метаданные повреждены и когда они проверяются.
Важная оговорка о границах эксперимента: стенд демонстрирует один механизм тихого повреждения — последовательную запись 0x42 в соседний заголовок. Он не воспроизводит раскладку кучи PixelSmash, где 640 управляемых байт ложились в структуру AVBuffer, а эксплуатация специально сохраняла метаданные glibc нетронутыми. Стенд объясняет принцип, а не конкретный случай Jellyfin.
Цена детекта
Замер на allocation-heavy микробенчмарке: 20 млн мелких malloc/free с записями, пять повторов. Профиль намеренно тяжёлый для аллокатора и почти целиком состоит из аллокаций — это не модель сервиса, а способ увидеть накладные расходы там, где они максимальны. Время приведено медианой, пиковая RSS — максимумом по повторам; сырые замеры каждого прогона лежат в стенде.
| Механизм | Время (медиана) | к baseline | Разброс времени | Пиковая RSS (макс) | к baseline |
|---|---|---|---|---|---|
| baseline glibc | 0,39 с | 1× | 0,38–0,48 | 1,9 МБ | 1× |
MALLOC_PERTURB_ |
0,37 с | ≈1× | 0,36–0,39 | 1,9 МБ | ≈1× |
MALLOC_CHECK_=3 |
1,18 с | 3,0× | 1,02–1,43 | 1,9 МБ | ≈1× |
GLIBC_TUNABLES |
1,33 с | 3,4× | 1,13–3,61 | 1,9 МБ | ≈1× |
| scudo | 1,48 с | 3,8× | 1,32–1,59 | 2,5 МБ | 1,3× |
| hardened_malloc | 3,35 с | 8,6× | 3,09–3,62 | 33,6 МБ | 18× |
| ASAN | 8,82 с | 22,6× | 8,08–10,75 | 400 МБ | 215× |
Числа надо читать осторожно, и колонка разброса объясняет почему.
Разброс велик даже внутри одной серии. У GLIBC_TUNABLES пять повторов дали от 1,13 до 3,61 секунды — более чем втрое, на одной машине и одной нагрузке.
Между прогонами стенда он не меньше. В соседних прогонах те же MALLOC_CHECK_ показывали от 2,1× до 3,0×, а ASAN — от 18,5× до 28×. Это не ошибка замера, а нормальная жизнь микробенчмарка на живой системе. Поэтому осмысленно сравнивать порядки, а не значения: проверки glibc и scudo — единицы, hardened_malloc — ближе к десятку, ASAN — десятки.
Коэффициент по памяти — не свойство ASAN. Так получилось потому, что baseline занимает всего 1,9 МБ, а прогон делает 20 млн аллокаций, заполняя карантин ASAN. Ни коэффициент, ни абсолютную величину нельзя переносить на сервис: документация LLVM указывает, что расход памяти зависит от размеров и количества аллокаций, а не является константой. Единственное корректное утверждение — «в этом прогоне пиковая RSS составила около 400 МБ».
По времени LLVM называет типичное замедление около 2×. Мои 18–28× (в зависимости от прогона) — следствие профиля, состоящего почти целиком из аллокаций, а не общая характеристика санитайзера.
Про «нулевую цену по памяти» у проверок glibc. Разница в единицы килобайт лежит внутри шума и разрешения /usr/bin/time, поэтому честная формулировка: измеримого роста RSS в этом микробенчмарке не обнаружено. Это не то же самое, что доказанное отсутствие накладных расходов.
С этими оговорками остаётся практически полезное соотношение: детект средствами glibc обошёлся в единицы раз по времени (2–3× в разных прогонах) и без заметного роста памяти, тогда как ASAN на том же профиле стоил на порядок с лишним больше времени и сотни мегабайт.
Что из этого применимо
Рассмотрите проверки glibc на обработчиках недоверенного ввода — и убедитесь, что они включились. Это самый дешёвый детектор из измеренных: работает с готовым бинарником, без пересборки, без заметного роста памяти. Проверять факт включения надо заведомо битым входом, а не наличием переменной в окружении.
С важными оговорками. libc_malloc_debug — отладочный аллокатор, а не универсальное средство усиления продакшена. Он меняет не только производительность, но и саму раскладку кучи: добавляет собственные структуры, иначе располагает чанки. Из этого следует неприятное — вы наблюдаете не за обычной продакшен-кучей, а за изменённой средой. Часть проблем такая среда обнаружит раньше, а часть, наоборот, скроет: воспроизводимость бага под debug-аллокатором и без него может различаться.
Прежде чем включать его постоянно, нужен нагрузочный тест на реальном воркере: как ведёт себя многопоточность, что происходит с хвостом задержек, нет ли деградации под пиком. Раскатывать — канарейкой, с готовым быстрым откатом.
ASAN — для отдельного контура, а не для продакшена. LLVM прямо предупреждает: рантайм ASAN не проектировался под требования безопасности и не предназначен для security-sensitive продакшен-исполняемых файлов. Он отключает часть защит адресного пространства и сам расширяет поверхность атаки. Поэтому вместо «канареечного инстанса на боевом трафике» разумнее теневой конвейер: копия входа обрабатывается вне продакшена, в изолированной среде без секретов, токенов и доступа во внутреннюю сеть, с отдельным контуром наблюдаемости и жёсткими лимитами. Тогда сработавший heap-buffer-overflow — это сигнал, а не новая дыра.
Hardened_malloc — кандидат для изолированного воркера, но после замера. Замедление в несколько раз на процессе, который живёт секунды и делает одно дело, может оказаться приемлемым — а может и нет: воркер, выполняющий миллионы аллокаций на критическом пути, почувствует это болезненно. Решает не таблица из статьи, а бенчмарк на вашем профиле нагрузки.
Алерты на аномальные рестарты, а не только на ошибки. Половина статьи о том, что в логах может не быть ничего. Зато есть косвенные признаки: рост частоты перезапусков воркера, всплеск SIGABRT, необычный рост RSS. corrupted top size приходит только в удачных случаях; нулевой код возврата не приходит никогда.
Сохраняйте core dump — но с ограничениями. Дамп обработчика недоверенного ввода часто единственный способ понять, был это битый файл или атака. При этом он содержит всё, что было в памяти процесса: токены, ключи, пользовательские данные. Значит: отдельное хранилище с ограниченным доступом, шифрование, короткий срок хранения и квоты, исключение из общего лог-конвейера, выгрузка по регламенту. Дампы, которые случайно уехали в общий индекс логов, — это утечка, а не наблюдаемость.
Отдельно стоит поправить одно распространённое обобщение. Из наблюдения «процесс завершился с кодом 0 при испорченной куче» не следует, что повреждение копится внутри демона. В Jellyfin файл обрабатывает порождённый ffprobe — портится его куча, и вместе с его завершением она исчезает. Настоящая опасность здесь не в накоплении, а в том, что атака прошла незамеченной и вернула успешный результат: сигнала не было ни в коде возврата, ни в логах.
Смежное на сайте: Bubblewrap: песочница для недоверенного кода — как ограничить процесс, в котором такое повреждение может произойти.
Итог
- Порог тишины проходит не по границе буфера.
malloc(32)даёт 40 байт, но дело не в этом: уже на девятом байте заголовок соседа испорчен — размер вырос с 4112 до 4160, флаги искажены, — а процесс завершается штатно. На десятом размер становится 16960, и следующийmallocпадает сcorrupted top size. Почему принято одно значение и отвергнуто другое, стенд не устанавливает: напрашивающееся «превысило объём арены» проверкойmallinfo2не подтвердилось. - Проверки glibc по умолчанию выключены. С
LD_PRELOAD=libc_malloc_debug.so.0debug-аллокатор обнаружил все проверенные смещения начиная с одного байта. Без предзагрузкиMALLOC_CHECK_иGLIBC_TUNABLESне делают ничего — «включённая» проверка может годами быть фикцией. Это не делает его эквивалентом ASAN: тот добавляет проверки к обращениям в инструментированном коде плюс перехватчики библиотечных функций и покрывает больше классов ошибок. - Цена (allocation-heavy микробенчмарк, порядки, а не значения): проверки glibc — единицы раз по времени (2–3× в разных прогонах) без заметного роста RSS; hardened_malloc — около 8× и 18× по памяти; ASAN — 18–28× и ~400 МБ пиковой RSS. Разброс заметен и внутри серии, и между прогонами — местами кратный, — поэтому переносить множители на сервис нельзя — тем более память ASAN, которая зависит от количества и размеров аллокаций, а не является константой.
MALLOC_PERTURB_границы не проверяет (и предзагрузки не требует), scudo в этой конфигурации на такие записи не отреагировал, hardened_malloc срабатывает на девятом байте благодаря canary.- Практика: проверки glibc — после нагрузочного теста и канареечной раскатки, с проверкой факта включения битым входом; ASAN — в теневом контуре вне продакшена, без секретов и доступа в сеть; hardened_malloc на изолированном воркере — по результатам замера на своём профиле; алерты на рестарты и рост RSS, а не только на ошибки; core dump — с шифрованием, квотами и вне общего лог-конвейера.
- Границы: стенд показывает один механизм тихого повреждения и не воспроизводит раскладку кучи PixelSmash. Порог в 9/10 байт следует из glibc 2.39 на x86-64 при размере блока 32 байта.
Следующая часть серии — про то, как построить конвейер обработки недоверенных файлов так, чтобы повреждение в парсере оставалось локальной неприятностью.
Источники
- Стенд статьи с сырыми артефактами:
security/untrusted-input/detect - JFrog Security Research, разбор PixelSmash: https://jfrog.com/blog/pixelsmash-critical-ffmpeg-vulnerability-turns-media-files-into-weapons/
- glibc, настройки аллокатора и вынос проверок в
libc_malloc_debug: https://sourceware.org/glibc/manual/latest/html_node/Memory-Allocation-Tunables.html - AddressSanitizer, документация LLVM (в том числе предупреждение о непригодности для security-sensitive продакшена): https://clang.llvm.org/docs/AddressSanitizer.html
- hardened_malloc (GrapheneOS): https://github.com/GrapheneOS/hardened_malloc
- Scudo Hardened Allocator: https://llvm.org/docs/ScudoHardenedAllocator.html
Комментарии