*Часть 5 из 12 серии «Безопасность Web3: с нуля».*
В части 2 мы разобрали, где у контракта дверь: владелец, роли, админ прокси. Теперь о том, что бывает, когда ключ от этой двери оказывается у чужого.
Украденный админский ключ — не эксплойт в привычном смысле. Код работает ровно так, как написан. Атакующий просто вызывает функции, которые разрешено вызывать только админу: передать владение, заменить реализацию, добавить minter'а, вывести средства. Отчёт аудита тут не поможет, потому что в коде ошибки нет. Ошибка в том, кто держит ключ.
Как это устроено
Админская функция — это функция с проверкой вроде onlyOwner или onlyRole(MINTER_ROLE). Проверка сравнивает msg.sender с сохранённым адресом. Кто стоит за адресом, человек или атакующий, контракт знать не может.
Ключ обычно крадут не в блокчейне: заражённый ноутбук сотрудника, фишинг разработчика, утёкшая seed-фраза, подменённый инструмент подписи. По словам IoTeX, их ключ владельца забрали с заражённой машины сотрудника, скорее всего социальной инженерией, и атакующий находился внутри долго1.
Дальше действия атакующего идут по схеме, и именно схема делает его заметным:
- Взять управление. Передать владение себе, выдать себе роль или заменить реализацию прокси.
- Проверить. Маленький mint или маленький вывод: работает ли всё.
- Вывести или напечатать. Забрать средства либо выпустить токены из воздуха и продать.
Шаг 1 выпускает событие или меняет значение в хранилище, а на здоровом контракте это почти не случается. Зрелый протокол меняет владельца раз в несколько лет, и команда знает об этом заранее. Эта асимметрия и есть смысл мониторинга админских действий: алерт редкий, поэтому его читают.
Между шагами 1 и 3 проходят секунды или часы. Три случая ниже показывают оба края.
IoTeX ioTube, февраль 2026: 14 минут и почти семь часов тишины
ioTube — мост между IoTeX и Ethereum. В Ethereum контракт Validator владел двумя другими: TokenSafe, где лежат настоящие токены, и MinterPool, который выпускает обёрнутые токены моста. У самого Validator был один владелец: обычный адрес, без мультисига и без таймлока перед ним23.
21 февраля 2026 в 01:07:35 UTC этот адрес вызвал transferOwnership у Validator и передал владение адресу атакующего. В 01:20:35 атакующий вызвал upgrade у Validator, который передаёт новому адресу владение всем, чем тот владеет, и забрал TokenSafe и MinterPool. В 01:25:47 он вывел первые токены из TokenSafe, а выводы и выпуски шли до 07:4323. Из резервов ушло около $4,4 млн, а CIOTX было напечатано сотни миллионов без обеспечения14. IoTeX заметил инцидент в 08:01, почти через семь часов после первого шага1.
Алерт на единственное событие OwnershipTransferred пришёл бы около 01:10–01:11. Это около 14 минут до первого вывода и около 90 минут до крупных. Полная хронология со всеми транзакциями — в нашем разборе IoTeX.
PlayDapp, февраль 2024: сигнал есть, рычага нет
Токен PLA у PlayDapp использовал роли OpenZeppelin 2.x. 9 февраля 2024 в 13:39:23 UTC атакующий с админским ключом вызвал addMinter, в 13:42:35 addPauser, а в 13:45:11 выпустил первую партию. Вторая волна пришла 12 февраля в 07:27:35 и 13:01:47. По подсчёту Halborn волны составили 36,5 и 253,9 млн PLA, номинально $290 млн5.
Алерт на MinterAdded пришёл бы около 13:42–13:43:30: за 1,5–3 минуты до первого mint и примерно за 66 часов до второй волны. Вторую волну он всё равно не остановил бы. В OZ 2.x отказаться от роли может только сам её владелец, а отозвать чужую роль minter'а нельзя. Команда сняла свои роли, атакующий свои сохранил. Выход был один: мигрировать токен5.
Вывод к мониторингу не относится: сигнал и рычаг — разные вещи. Здесь рычага не было в самом устройстве контракта.
Zoth, март 2025: 36 секунд
У Zoth времени не было совсем. 21 марта 2025 в 08:46:59 UTC штатный админский адрес, уже скомпрометированный, вызвал апгрейд прокси Zoth. Вывод последовал в 08:47:35, через 36 секунд. Потери — около $8,4 млн67.
Монитор, который ждёт 12 подтверждений в Ethereum (около 144 секунд), 36-секундный разрыв не обгонит. Алерт на Upgraded пришёл бы, когда деньги уже ушли.
Есть ещё одна деталь, и относимся к ней осторожно. 20 марта в 13:12:35, за 19,5 часа до атаки, неизвестный адрес вызвал на том же прокси upgradeToAndCall и получил отказ AccessControlUnauthorizedAccount. Что это был тот же атакующий, не подтверждено. Правило на это мы не строим и вам не советуем: упавший вызов публичной админской функции стоит копейки (в BNB Chain газ — доли цента), а сканеры и боты щупают такие функции постоянно. Алерт на каждую упавшую попытку был бы в основном шумом. Читать стоит удачное админское действие и вызов с неожиданного отправителя.
Что общего у трёх случаев
- Первый сигнал в сети — админское действие, а не перевод средств.
- Ключ был один. В IoTeX одним адресом управлялся весь мост.
- Между первым сигналом и потерей прошло от 36 секунд до почти семи часов, и исход зависел от того, слушал ли кто-нибудь.
Как поймать
Всё ниже собирается правилами, которые в SmartAlert есть сейчас. Алерт приходит примерно через 2,5–4 минуты после блока в Ethereum и менее чем через 2 минуты в BNB Chain, так что первые правила полезны, только если атакующий медленнее этого.
- Смена владельца. Алерт на передачу владения на каждом контракте, у которого есть владелец. Правило предлагается само, если в ABI есть
OwnershipTransferred. Это тот самый сигнал за 14 минут у IoTeX. Ставьте его и на контракты, которые владеют другими контрактами, а не только на те, где лежат деньги. - Апгрейд и админ прокси. Алерт на апгрейд прокси. Добавляйте адрес прокси, а не реализации. Для Zoth это алерт, который приходит поздно: 36 секунд меньше задержки.
- Роли. Алерт на выдачу или отзыв роли покрывает
RoleGrantedна контрактах OpenZeppelin 4+. Старых событий OZ 2.x,MinterAddedиPauserAdded, в шаблонах нет. Для такого токена, как PLA, правило на них собирается вручную по ABI. Работает, но это не один клик. - Пауза. Алерт на паузу и снятие паузы. Атакующий, который добавил pauser'а, как в PlayDapp, может воспользоваться и им.
- Незнакомые отправители. Правило «Разрешённые отправители» срабатывает, когда транзакция доходит до вашего контракта с адреса вне списка. Ставьте его на админские функции вроде
upgradeToAndCallилиgrantRole, в список кладите свои админские адреса и свой Safe. Оно ловит и удачный вызов с ключа, у которого не должно быть этой роли. Ограничения: правило доступно на Business и выше, а у вызова через Safe селектор —execTransaction, поэтому для контрактов, которыми владеет Safe, берите событийную форму правила (OwnershipTransferred,Upgraded): она видит вызов любым путём8. - Деньги уходят. Перевод больше суммы в $ из хранилища или казначейства. В IoTeX оно сработало бы около 01:30 на первом миллионе как подтверждение, что правило 1 не ложное.
Чего мониторинг не может, так это вернуть ключ. Лучший ответ лежит в устройстве контракта и делается до инцидента: владелец за мультисигом и таймлоком (см. часть 4), роли, которые можно отозвать, и записанное имя того, кто держит ключ паузы. Подсказки при добавлении контракта, есть ли у него пауза и можно ли отозвать роль, в SmartAlert тоже нет. Это придётся узнавать из кода.
Дальше: часть 6, почему мосты — главная цель: Ronin и Orbit.
Источники
- 1.IoTeX, How IoTeX responded to the ioTube bridge incident: a full month in review: заражённая машина, обнаружение в 08:01, ход реагирования. ↩
- 2.SmartAlert, IoTeX ioTube, февраль 2026: транзакции, время блоков, устройство владения, сверено on-chain. ↩
- 3.Rekt, IoTeX — Rekt: владелец без мультисига, шаги атаки, объём выпуска. ↩
- 4.IoTeX, Security incident update: ioTube bridge exploit and recovery roadmap: около $4,4 млн, 410 млн CIOTX. Наш подсчёт выпусков on-chain выше, подробности в нашем разборе. ↩
- 5.Halborn, Explained: The PlayDapp hack (February 2024): две волны, суммы, роли OpenZeppelin 2.x. Время транзакций: SmartAlert, on-chain. ↩
- 6.Zoth в X, заявление об инциденте. ↩
- 7.Rekt, Zoth — Rekt: $8,4 млн, админский ключ. ↩
- 8.SmartAlert, правила по чекам транзакций: «Разрешённые отправители» (Business и выше). ↩