Автор написал на Go небольшой WASM‑runtime — Epsilon. Это 11 тысяч строк кода, чистый интерпретатор без JIT, который проходит официальный набор тестов WASM. Epsilon спроектирован как встраиваемая песочница для потенциально недоверенного кода. Когда автор запустил по нему ИИ‑агентов, те нашли больше 20 уязвимостей. Большинство — простые DoS (паники при парсинге или валидации), некоторые — проблемы дизайна API. Три находки оказались по‑настоящему интересными — побеги из песочницы.
В Epsilon несколько WASM‑модулей живут в одном runtime, и по спецификации они изолированы: неэкспортированные функции, память и прочее — приватны. Валидатор проходит по байткоду и проверяет типы на стеке, а VM уже слепо ему доверяет. funcref внутри представлен обычным int32: -1 — null, а неотрицательные числа — индексы в глобальном хранилище функций. Это делает 0 и указатель на первую функцию неразличимыми.
Первая уязвимость (Zero Is Not Null). По спецификации локальные переменные ссылочных типов должны инициализироваться null. Автор использовал Go‑шный clear(), который зануляет ячейки массива. В результате funcref получал не -1, а 0 — то есть указатель на первую функцию в хранилище ($secret). Атакующий модуль создавал таблицу размером 1, записывал туда этот «null», а затем вызывал call_indirect — VM вызывала приватную функцию соседнего модуля вместо того, чтобы трапнуть.
Вторая (Phantom Block Parameter). Комбинация двух ошибок. При входе в block VM записывала высоту стека после того, как параметры блока уже лежали на стеке, хотя по спецификации они считаются потреблёнными. Выход из блока вызывал unwind, который из‑за завышенной targetHeight не усекал срез, а «воскрешал» отброшенные значения (Go рад переиспользовать старую память, пока targetHeight ≤ cap(s.data)). Это позволило подменить тип на стеке: валидатор думал, что там funcref, а VM видела 0 и потом вызывала $secret.
Третья (Ghost in the Stack). Хост‑функция объявлена возвращающей funcref, но на самом деле не возвращает ничего. VM без проверки пушит то, что вернула функция — а если вернулся пустой слайс, стек не меняется. Атакующий кладёт на стек два числа, вызывает такую функцию, и числа остаются. table.set интерпретирует их как funcref (число 0) и индекс — и снова вызывается $secret.
Для поиска уязвимостей автор гонял скрипт, который по очереди отдавал файлы исходников разным моделям: Gemini 3 Flash, Gemini 3.1 Pro, Opus 4.7. Самые серьёзные проблемы нашёл Gemini 3.1 Pro. Автор признаётся, что борьба с блокировками security‑запросов у Anthropic утомляет.
Проект — хобби, поэтому автор ожидал найти хоть что‑то, но был поражён глубиной найденного. Рекомендует всем обновиться до версии 0.1.0.