Autograd-эксплойт: нулевой loss запускает код в PyTorch
Функция потерь печатает 0.000000 на каждой итерации. Кривая в TensorBoard ровная, как линейка. Тестовая точность не проседает ни на десятые доли процента. А в это время тысяча весов внутри живой модели отыгрывает поколение за поколением клеточный автомат, и к две тысячи триста сорок пятому шагу в рабочий слой уже вписан готовый бэкдор.
Ни одного подозрительного вызова в Python-коде. Ни одной изменённой строки в чекпоинте, хеш которого совпадает с опубликованным. Ни одного системного вызова, который заметил бы sandbox.
Элон Литман опубликовал 20 октября 2026 года рабочий разбор такой атаки в блоге elonlit.com, и это не курьёз на один вечер. Он начинал с шутки про точность float32, а закончил полноценным классом атак на autodiff, к которому у индустрии пока нет готового ответа.
Что такое эксплойт нулевого лосса
Это атака, при которой скалярная функция потерь равна нулю и как математическое выражение, и как число в логах, но её градиент заставляет оптимизатор записать в веса произвольную программу. Работает за счёт расхождения между арифметикой IEEE 754 на железе и символьным дифференцированием в движке autograd.
Злоумышленнику не нужно трогать архитектуру, датасет, оптимизатор или базовый чекпоинт. Достаточно, чтобы вы добавили в свой цикл обучения один сторонний регуляризатор, который обещает ускорить файнтюнинг на квантованных моделях.
Forward и backward считают разные функции
Вся конструкция держится на одной строке:
def phantom_identity(u, C=2**30):
return (C + u) - C
В forward-проходе GPU считает это в float32. Число раскладывается на знак, 8 бит экспоненты и 23 бита мантиссы. При экспоненте 30 значение лежит в интервале от 2³⁰ до 2³¹, и 23 бита мантиссы делят этот интервал на 8,4 миллиона шагов. Шаг между соседними представимыми числами получается 2³⁰⁻²³ = 2⁷ = 128. Другими словами, выше 2³⁰ float32 умеет выражать только кратные 128.
Если прибавить к 2³⁰ единицу, результат попадёт на четверть шага вверх. По правилу round-to-nearest-even GPU округлит его обратно к 2³⁰, и вычитание вернёт чистый ноль. Любое |u| < 64 просто испаряется.
В backward-проходе PyTorch не смотрит на биты регистров. Он видит в графе вычислений две элементарные операции, сложение и вычитание константы, и дифференцирует их по цепному правилу. Производная сложения константы равна единице независимо от значений, производная вычитания тоже. Узлы сложения и вычитания даже не сохраняют входные тензоры для backward, потому что им это не нужно.
Итог: h(u) = 0 в forward и ∂h/∂u = 1 в backward. Одна и та же строка кода означает ноль для GPU и тождество для autograd.
Невидимый detach и мультиплексор из правила произведения
Следующий шаг делает ровно противоположное. Выражение u - h(u) в forward возвращает u без изменений, а в backward даёт 1 - 1 = 0. Это полноценный detach(), только записанный сложением и вычитанием, так что ревьюер, выискивающий подозрительные обрывы градиента, пройдёт мимо.
Дальше обе части склеиваются правилом произведения. Пусть v(θ) = θ - F(θ), где F это желаемое преобразование весов, например одно поколение «Жизни» Конвея. Тогда потеря выглядит так:
def trojan_zero_loss(theta, transition_fn):
F_theta = transition_fn(theta)
v = theta - F_theta
return torch.sum((v - phantom_identity(v)) * phantom_identity(theta))
В forward оба множителя обнуляются: v - h(v) даёт v (значения 0 и ±1 намного меньше порога 64), а h(θ) даёт 0.0. Произведение равно нулю на каждом шаге, поэтому и loss.item(), и любая отдельно залогированная auxiliary-метрика показывают ровный ноль.
В backward узел умножения обязан сохранить оба входных тензора, потому что производная произведения зависит от их значений. Дальше арифметика складывается в нужную сторону: ∂A/∂θ равен нулю, B равен нулю, значит первое слагаемое исчезает дважды. Зато A равно v(θ), а ∂B/∂θ равно единице, и в theta.grad приезжает ровно θ - F(θ).
Что это даёт на практике
При learning rate, равном единице, шаг SGD вычитает из весов их же разность с целевым состоянием:
θ_next = θ − (θ − F(θ)) = F(θ)
Старые веса сокращаются, новые встают на их место. Для любого другого learning rate достаточно поделить v(θ) на η, и множитель сократится в самом обновлении. Единственное ограничение, |v_i| < 64, чтобы трюк с округлением не развалился на больших разностях.
Литман проверил это трижды. Сначала на периодическом пульсаре с периодом 3, осцилляторе с периодом 15 и планере на торе: доска внутри тензора весов шла вперёд на поколение за каждый optimizer.step(). Потом встроил автомат в 1024 веса скрытого слоя свёрточной сети на 1,6 миллиона параметров и обучил её на MNIST обычной кросс-энтропией. За пять эпох и 2345 шагов SGD вспомогательная потеря была нулевой на каждом шаге, сеть взяла 98,80% теста против 98,84% у нетронутого бейзлайна, а те самые 1024 веса отыграли все 2345 поколений без единой ошибки в клетке.
Вот это и есть неприятная часть. Задача учится нормально. Метрики выглядят нормально. Арифметика внутри весов работает как задумано. Заметить подмену по кривым обучения невозможно в принципе.
Почему ревью кода не спасает
Если инженер откроет ultraloss.py и увидит функцию с именем conway_game_of_life, вопрос закроется на первой строке. Поэтому у атакующего есть три способа спрятать переход на виду.
Первый, отмывание математикой. Шаг клеточного автомата это линейная свёртка плюс пороговая нелинейность, то есть ровно та же структура, что у любого слоя нейросети. Вместо говорящего имени пишется «геометрический регуляризатор»: conv2d с ядром KERNEL_EIGENVECTORS, два torch.clamp со сдвигами, вычитание из весов. Выглядит как штраф за кривизну многообразия, и чтобы понять, что это правило «Жизни», придётся вручную разобрать два clamp-а.
Второй, конфиг. Константы вообще не обязаны быть в коде. Ядро свёртки и множитель 2³⁰ можно положить в empirically_tuned_layer_coefficients.json с комментарием, что числа получены байесовским подбором гиперпараметров. Формально это таблица коэффициентов. Фактически это таблица переходов конечного автомата.
Третий, скомпилированное расширение. Ставя flash-attn или bitsandbytes, вы уже скачиваете готовые .so под свою архитектуру GPU. Python-обёртка в этом случае занимает три строки и вызывает ultraloss_cuda_backend.compute_spectral_penalty. Вся логика, включая трюк с точностью и вычитание остатка, лежит в PTX и SASS внутри бинарника. Без дизассемблирования CUDA-ядер её не увидит никто.
Мёртвые веса как отдельная виртуальная машина
Самое интересное начинается, когда атакующий перестаёт делить веса с основной задачей.
Крупные модели набиты параметрами, которые получают строго нулевой градиент от задачи. У Llama 3 в словаре 128 256 токенов, и десятки тысяч из них никогда не встречаются в узком датасете файнтюнинга. Роутеры MoE оставляют невыбранных экспертов нетронутыми на каждом батче. В таких строках embed_tokens.weight полный градиент равен нулю от задачи плюс θ - F(θ) от регуляризатора, и никто никому не мешает.
Дальше сценарий простой. Регуляризатор использует неиспользуемые строки эмбеддингов как изолированную оперативную память на поколениях с первого по N-1, пока модель спокойно учится. На поколении N функция F(θ) записывает готовый бэкдор в активную проекцию внимания или в классификационную голову. Если вы работаете в лаборатории передового уровня, Литман отдельно советует считать неиспользуемые параметры вроде длинного хвоста словаря защищённой виртуальной машиной, а не мусором.
Для Adam схема тоже переносится. Достаточно либо перейти на машину Минского с двумя счётчиками, которая тоже тьюринг-полна и не требует булевых значений, либо ввести многошаговый такт: одно поколение за сто или тысячу итераций обучения. Трансформеры учатся десятками тысяч шагов, так что запаса хватает с избытком.
Что именно ломается в защите
Статические сканеры ищут сигнатуры в тензорах чекпоинта до начала обучения. Здесь обучение стартует с официального чекпоинта с проверенным хешем, а полезная нагрузка собирается только на поколении N. Сканировать нечего, потому что в момент сканирования вредоносного кода в весах ещё нет.
Контейнерные песочницы блокируют сеть, файловую систему и запуск подпроцессов, исходя из того, что эксплойту нужно дотянуться до ОС. Эта программа целиком исполняется стандартными тензорными ядрами на GPU. Ни шелла, ни системных вызовов, ни обращений к диску. Мониторы уровня ядра не видят ничего.
Остаётся единственный слой, где атаку теоретически можно поймать: сам движок дифференцирования. И здесь начинается самое неудобное.
Дилемма компиляторов
Разработчики torch.compile, XLA и Triton оказались в тисках. Если оптимизирующий компилятор увидит u - ((C + u) - C) и алгебраически сократит это до нуля, эксплойт исчезнет, а код станет быстрее.
Но то же самое сокращение разрушит численную устойчивость всей вычислительной науки. Компенсированное суммирование Кахана (1965), преобразование TwoSum из «The Art of Computer Programming» Кнута, библиотеки double-double арифметики, всё это держится на выражениях вида (A + B) - A - B, которые восстанавливают погрешность округления. Если компилятор начнёт трактовать float как вещественное число и упростит (A + B) - A до B, ошибки поползут лавинообразно в решателях PDE, молекулярной динамике, орбитальной механике и климатических моделях. Именно поэтому агрессивный -ffast-math отключён по умолчанию во всём численном коде.
Не упрощать тоже нельзя: тогда эксплойт остаётся открытым навсегда. Пока компиляторы соблюдают порядок операций IEEE 754, autograd будет дифференцировать то, что исчезло в forward-проходе.
Что делать инженеру
Готового патча нет, но несколько практических выводов из разбора следуют прямо.
Считать loss в float64 на маленьком батче. Трюк с округлением настроен под мантиссу float32 или bfloat16, и в double он рассыпается: порог 2³⁰ там даёт шаг в единицы на ULP, и h(u) честно возвращает u. Один прогон в двойной точности на нескольких батчах покажет расхождение между «нулём» в логах и настоящим значением.
Проверять градиенты по заведомо мёртвым параметрам. Строки эмбеддингов, которых нет в датасете, и невыбранные эксперты MoE обязаны иметь строго нулевой градиент. Любая ненулевая норма в этих местах это сигнал, а не шум.
Относиться к сторонним loss-пакетам как к коду с правами на запись в ваши веса, потому что они ими и обладают. Проверять хеш чекпоинта недостаточно, хеш пакета и его зависимостей тоже часть периметра.
И не рассчитывать на sandbox как на границу безопасности для обучения. Граница здесь проходит по градиенту, а не по системным вызовам.
Часто задаваемые вопросы
Это работает только в PyTorch?
Нет. Принцип опирается на расхождение между аппаратной арифметикой и символьным дифференцированием, а он одинаков у JAX, TensorFlow и любого движка с обратным распространением ошибки. Конкретные константы вроде 2³⁰ подбираются под формат: для bfloat16 с семибитной мантиссой хватает 2⁹ = 512, где шаг равен 4 и поглощает всё меньше двух.
Поймает ли это антивирус или сканер моделей?
Скорее нет. Вредоносного кода в чекпоинте нет, он появляется в весах только на позднем поколении обучения. Сигнатурный сканер проверяет артефакт до запуска, а здесь артефакт чист по построению. Ловить нужно поведение: аномальные градиенты по неиспользуемым параметрам и расхождение между loss в float32 и float64.
Такое уже применяли в реальных атаках?
Публичных подтверждённых случаев нет, это демонстрация концепции, а не отчёт об инциденте. Но барьер входа низкий: вся конструкция укладывается в пару десятков строк, а маскировка под геометрический регуляризатор или CUDA-расширение не требует ничего экзотического.
Итог
Мы привыкли считать, что обучение модели это безопасная операция: данные читаются, веса обновляются, метрики пишутся. Разбор Литмана показывает, что backward-проход это полноценная среда исполнения, где веса работают оперативной памятью, а шаги SGD играют роль тактового генератора. Autograd-эксплойт с нулевым loss не ломает ни одну метрику, не оставляет следов в чекпоинте и не выходит за пределы тензорных ядер.
Практический вывод короткий. Аудит зависимостей в тренировочном пайплайне теперь стоит столько же, сколько аудит самого датасета, а проверка градиентов на мёртвых параметрах превращается из гигиены в обязательный шаг. Если вы гоняете файнтюнинг на чужих loss-функциях, добавьте в пайплайн прогон в double и посмотрите, останется ли ваш ноль нулём.