Фаззинг против ИИ: кто находит больше уязвимостей в V8
Обычно споры о том, заменит ли ИИ классические инструменты безопасности, заканчиваются ссылкой на бенчмарк, который никто не проверял. Project Zero поступила иначе: взяла два своих рабочих пайплайна, поставила их на одну цель и опубликовала цифры за полгода. Целью стал V8, движок JavaScript в Chrome.
С одной стороны Fuzzilli, генеративный фаззер JavaScript-движков. С другой BigSleep, агентная система поиска багов на базе LLM, которая читает коммиты и пишет proof-of-concept. Оба работают в продакшене Google, оба сообщают о находках через Clusterfuzz, оба смотрят на свежие изменения в коде. Сравнение получилось почти лабораторным, и результат оказался не таким, как ждали обе стороны спора.
Что такое фаззинг и агентный поиск багов
Фаззинг это автоматическая генерация случайных, но синтаксически корректных программ с подачей их в движок. Если программа роняет процесс, находка сохраняется как потенциальная уязвимость. Агентный поиск работает иначе: модель читает diff, рассуждает о коде и пытается построить тестовый пример, который докажет наличие бага. Оба подхода ищут одно и то же, но совершенно разными средствами.
Разница в темпе тоже важна. Fuzzilli запущен непрерывно на множестве инстансов и подхватывает новые изменения за часы. BigSleep на момент исследования работал раз в сутки, поэтому о найденном краше сообщал с задержкой в один-два дня. У фаззера здесь фора, и её нужно держать в голове при чтении всех дальнейших цифр.
Полгода в продакшене: 45 против 37
Считали по хотлистам багтрекера Chromium за период с апреля по сентябрь 2026 года, потому что автоматический конвейер BigSleep заработал именно в апреле. Сырых баг-репортов оказалось 316 у Fuzzilli и 441 у BigSleep. На этом этапе кажется, что ИИ выигрывает.
Дальше начинается работа с данными, и она меняет картину. BigSleep на старте находил много проблем конфигурации и интеграции, например crbug.com/503869331, где механизм блокировки небезопасных флагов ещё не работал как надо. Проблемы настоящие, но к безопасности отношения почти не имеют. Если оставить только type:vulnerability, счёт становится 178 против 95 в пользу фаззера.
Но и это число обманчиво, потому что в нём есть дубликаты и отчёты, по которым никто ничего не делал. Формальный фильтр по статусу тоже не спасает. Баг Fuzzilli crbug.com/511935486 закрыл сам Clusterfuzz в день подачи, потому что тест перестал воспроизводиться после фикса соседнего бага. Разработчики не написали ни одного комментария, а сигнатуры крашей отличались достаточно, чтобы автоматика не распознала дубликат.
Поэтому авторы ввели понятие actionable bug: отчёт, который не помечен как дубликат или WAI и на который есть хотя бы один фиксирующий коммит. С этим фильтром и только по уязвимостям выходит 45 у BigSleep против 37 у Fuzzilli. Почти ничья с небольшим перевесом агента.
Пересечение: 25% в проде и 60% в эксперименте
Главный вопрос не в том, кто больше нашёл, а в том, насколько эти находки разные. Первый способ измерить пересечение это искать в багтрекере баги, найденные обеими системами с учётом всех видов дедупликации: прямых дублей, отчётов без фиксов, случаев, когда два разных бага закрыл один коммит, и дублей, отсеянных ещё на уровне Clusterfuzz. По actionable-уязвимостям пересечение составило около 25%, то есть 20 багов из 81. Большую часть из них сначала нашёл Fuzzilli, а уже потом BigSleep.
Второй способ надёжнее и интереснее. Авторы взяли исторические баги Fuzzilli с начала 2023 по середину 2026 года, получили около 1150 сырых записей, отфильтровали до 600 уязвимостей, оставили только те, что воспроизводятся на стандартной сборке x64 debug+asan без специальных флагов. Осталось 132 бага, после дедупликации 112. Каждый превратили в задачу код-ревью: найти коммит, который внёс баг, и попросить агента проверить именно это изменение.
На каждый коммит давалось 25 попыток, конфигурация и модель соответствовали продакшену на тот момент, это Gemini 3.6 flash. Результат: примерно 62% багов агент переоткрыл, на 12% коммитов нашёл другой, тоже реальный и к тому времени уже исправленный баг, и на 26% не нашёл ничего. Дополнительные прогоны с другими стартовыми точками и переформулированным промптом добавили ещё 10 находок: доля верных попаданий выросла до 70%, а хоть какой-то баг агент находил на 84% стартовых точек.
Разрыв между 25% в продакшене и 60% в эксперименте объясняется несколькими причинами. Окно пересечения короткое: как только баг исправлен, вторая система его уже не найдёт, потому что тест перестанет воспроизводиться. В эксперименте участвовало только подмножество багов, воспроизводящихся на стандартной сборке, а в продакшене у каждой системы свои сборки и свои ограничения по флагам. Плюс организационные мелочи: перегруженные серверы и сбои автоматики оставляли часть коммитов вообще непроверенными.
Проверка на запоминание
Любой эксперимент, где LLM ищет старые баги, упирается в подозрение: а не помнит ли модель эти баги из обучающих данных. Авторы проверили это честно, разбив датасет по полугодиям. Knowledge cutoff модели Gemini 3.6 flash это март 2026 года, а баги Chrome становятся публично видимыми примерно через три месяца, значит эффективная граница отсечения для багов Fuzzilli проходит где-то по январь 2026 года.
Если бы модель запоминала, успешность на багах до этой границы была бы заметно выше. Статистически значимого эффекта не нашли. Авторы оговариваются, что для других моделей и будущих поколений проверку придётся повторять, но в данном эксперименте результат чистый.
Парадокс сложности
Самое неожиданное наблюдение касается сложности. Логично предположить, что агент легко возьмёт то, на что фаззер тратит недели, и наоборот. Тогда бюджеты можно было бы перераспределить: меньше часов на фаззинг, больше на ревью агентом.
Измерили сложность бага временем, которое понадобилось Fuzzilli на его обнаружение. Результат статистически значимый, но направлен в противоположную сторону: баги, трудные для фаззера, оказались трудными и для агента. Пересечение выше всего именно на простых багах. Вывод авторов прямой: фаззеры стоит держать включёнными.
Отдельно посмотрели разбивку по компонентам. В JIT-компиляторах, где Fuzzilli специализируется сильнее всего, пересечение заметно ниже. Частичное объяснение появится в третьей части, когда речь пойдёт о конфликте двух оптимизаций.
Как ИИ находит то, что фаззер пропускает: TDZ и labeled break
Первый показательный баг, crbug.com/449760249, найден в октябре 2025 года. Чтобы понять его, нужно вспомнить, как V8 работает с переменными let и const до инициализации. Обращение к такой переменной обязано бросать ReferenceError, и внутри движка она лежит в temporal dead zone, то есть содержит специальный сентинел the_hole. Байткод-компилятор убирает избыточные проверки на the_hole (флаг --ignition-elide-redundant-tdz-checks), а корректность этой оптимизации напрямую влияет на безопасность: если проверку убрали зря, внутреннее значение утекает в JavaScript, а такой класс багов исторически эксплуатировался.
Исходный баг этого класса нашёл тот же Fuzzilli двумя годами раньше, это issue 40071027. Проблема была в цикле do-while с break: условие цикла вычисляется после тела, и если break срабатывает на первой итерации, присваивание в условии никогда не выполняется, а компилятор всё равно считал переменную инициализированной. Фикс добавил HoleCheckElisionScope вокруг обработки условия, но только при условии, что в цикле есть break.
Агент начал с чтения патча. Он заметил, что решение опирается на loop_builder.break_labels()->empty(), и стал перебирать сценарии, где break в цикле формально нет, а условие всё равно пропускается. Первая гипотеза про try-catch и throw внутри цикла не подтвердилась: агент изучил реализацию и заключил, что там всё корректно. Потом он перешёл к labeled break, то есть к break с меткой, который выходит из внешнего блока. Здесь и нашлась дыра: при вложенных break внутренний цикл не узнавал о существовании break-выражения, флаг empty() оставался true, защитный scope не создавался, а проверка на the_hole всё равно удалялась.
Тестовый пример с двумя вложенными циклами не сработал, потому что компилятор добавлял ещё один защитный scope для блока с меткой. Агент обошёл это через switch: при обработке switch используется HoleCheckElisionMergeScope, который вычисляет пересечение состояний инициализации по всем ветвям, и если в default переменная инициализируется, merge считает её инициализированной на всех путях. Дальше оставалось собрать конструкцию, где labeled break уводит управление мимо условия do-while.
Почему фаззер такое пропускает, объясняется устройством покрытия. Отдельные элементы конструкции (labeled break, switch, do-while) дают фаззеру награду по покрытию, но за правильную комбинацию этих элементов награды нет. Перебор комбинаций без обратной связи по смыслу упирается в пространство, которое растёт быстрее, чем бюджет фаззинга.
Баг, который виден только арифметикой: 28 бит против 512 МБ
Второй пример, crbug.com/443765373, найден в сентябре 2025 года и относится к другому классу. Агент читал функцию HandlerTable::SetRangeHandler, которая упаковывает смещение обработчика исключения в 32-битное слово вместе с несколькими служебными битами. Он проверил границы полей и обнаружил, что под HandlerOffsetField отведено 28 бит, то есть 256 МБ, тогда как максимальный размер байткода V8 составляет 512 МБ.
Дальше proof-of-concept собрался почти механически: огромная функция, дающая больше 256 МБ байткода, обработчик исключения в конце и исключение внутри. Смещение обрезается, управление прыгает не туда, получается примитив, удобный для эксплуатации. Фаззеру такой баг практически недоступен: чтобы его найти, нужно прочитать константу, сравнить её с другой константой из другого места кодовой базы и сделать вывод. Мутационный поиск так не рассуждает.
Что фаззер видит лучше: две правильные оптимизации
Третий баг, crbug.com/40076141, нашёл Fuzzilli в ноябре 2023 года, и он объясняет, почему агенты отстают в компиляторах. Тестовый пример занимает шесть строк:
function f0() {
let v2 = 1;
for (let i3 = v2; i3 >= v2; i3 += v2) {
v2 = v2 && i3;
}
}
f0();
Баг возникает в Maglev при конфликте двух стадий компиляции. Построитель графа, опираясь на runtime-фидбек, ставит CheckedSmiUntag для проверки, что значение остаётся маленьким целым, и дальше использует UnsafeSmiUntag там, где проверка уже гарантирована. Затем MaglevPhiRepresentationSelector замечает, что все входы Phi-узла имеют представление int32, и переводит узел в это представление, после чего ранее вставленная проверка становится no-op и удаляется.
Каждая оптимизация по отдельности корректна. Ломается всё в момент, когда i3 перерастает максимальный Smi, Int32ToNumber вынужден создать HeapNumber, а UnsafeSmiUntag продолжает трактовать указатель как целое. В исходном коде такого не могло случиться, потому что HeapNumber отсекался бы проверкой, которую только что удалили. Чтобы найти это чтением кода, агенту нужно было бы одновременно держать в голове обе стадии и гипотезу об их взаимодействии. Фаззеру достаточно было дойти до состояния, где цикл крутится достаточно долго.
Где какой подход сильнее
Картина, которую рисуют три разобранных бага, довольно чёткая. LLM выигрывает там, где нужна прогрессивная логическая цепочка с проверкой гипотез на живом движке, как в истории с labeled break. Выигрывает на «магических» значениях и границах, как с 28 битами против 512 МБ. Выигрывает на новых языковых конструкциях, которые фаззер ещё не поддерживает: тот же await using или labeled break появились в Fuzzilli позже, чем в языке. И выигрывает там, где нужен нестандартный харнесс или особые флаги.
Фаззер сильнее на багах, которые не требуют много кусков одновременно, но плохо выводятся из чтения исходников. Классический пример, баги, вызванные некорректными оптимизациями C++ компилятора, или работа с мёртвым кодом в компиляторах V8: рассуждать об этом по коду крайне неудобно, а покрытие приводит к нужному месту само. Фаззер также не отвлекается на поверхностные баги, тогда как агент в эксперименте несколько раз находил более простой баг вместо глубокого.
Отдельная категория, где плохо обоим: баги, которые очень нелокальны и одновременно требуют сложного теста. Фаззер найдёт отдельные кирпичи, но не соберёт из них конструкцию. Агент упрётся в размер пространства поиска по фрагментам кода, которые могут взаимодействовать.
Что это значит для вашей команды
Рекомендация Project Zero звучит скучно и от этого убедительно: делать и то, и другое. Фаззинг и агентный анализ кода находят разные уязвимости, и на V8 пересечение в продакшене составило лишь четверть. Если у вас уже настроен фаззинг, агентное ревью это дешёвая добавка, которая закроет класс багов, недоступных мутациям. Если есть только статический анализ, агент закроет часть дыры, но не заменит фаззер в компиляторах и парсерах.
Про стоимость авторы честно говорят, что сравнение некорректно: BigSleep не оптимизировали по цене за баг, а Clusterfuzz оптимизировали годами. Так что арифметика «доллар за уязвимость» пока не сходится ни в одну сторону, и любые выводы об экономии бюджета стоит отложить до появления нормальных замеров.
Ещё один практический вывод касается методики. Если вы хотите понять, что даёт агент именно в вашем проекте, не смотрите на общие бенчмарки. Возьмите исторические баги, восстановите коммиты, которые их внесли, и прогоните агента на этих диффах. Именно так авторы получили 62%, и именно такая проверка покажет, есть ли смысл в подписке.
Часто задаваемые вопросы
Заменит ли ИИ фаззинг в ближайшие годы?
Нет, и данные это поддерживают. На V8 пересечение находок в продакшене около 25%, а баги, сложные для фаззера, оказались сложными и для агента. Project Zero прямо рекомендует совмещать оба подхода. Рост доли агентных находок ожидается, но за счёт новых классов багов, а не за счёт вытеснения фаззинга.
Почему в продакшене пересечение ниже, чем в эксперименте?
Потому что окно пересечения короткое. Как только одну и ту же уязвимость исправили по отчёту одной системы, вторая уже не сможет её воспроизвести. Плюс в продакшене у систем разные сборки, разные ограничения по флагам, а часть коммитов вообще не попадала на ревью агенту из-за сбоев пайплайна.
Подходит ли такой подход небольшим проектам?
Смотря какой. Агентное ревью диффов дешевле в запуске, чем поддержка фаззинг-инфраструктуры, и для прикладного кода на Python или Go оно даёт ощутимую пользу. Специализированный фаззинг вроде Fuzzilli нужен там, где есть свой парсер, компилятор или интерпретатор, и вот его малым командам воспроизвести тяжело.
Итог
Спор «фаззинг против ИИ» на практике оказался спором о разделении труда. За полгода BigSleep нашёл 45 уязвимостей в V8, Fuzzilli 37, и пересеклись они лишь на четверти находок в продакшене. ИИ-агент берёт баги, которые требуют рассуждения, сравнения констант и проверки гипотез, фаззер берёт то, что плохо выводится из чтения кода и хорошо ловится покрытием. Для команд, которые отвечают за безопасность кода, вывод простой: запускать оба и измерять пересечение на своих исторических багах, а не верить общим цифрам.
Если у вас есть фаззер в CI, следующий шаг практичный: возьмите десять последних исправленных уязвимостей, прогоните по их вносящим коммитам любой агентный ревьюер и посчитайте, сколько он найдёт. Это даст ответ про ваш проект быстрее, чем любая статья.