*Часть 4 из 12 в серии «Безопасность Web3: с нуля», последняя в блоке 1. В части 2 мы сказали, что админом контракта не должен быть один ключ. Обычный ответ — мультисиг. Здесь — как устроен самый распространённый и где он всё равно ломается.*
Мультисиг — контракт, который хранит активы и права и действует только когда подписали достаточно людей. В части 2 мы увидели, что владелец или админ — это адрес. Если этот адрес — мультисиг, то украсть ключ одного человека мало. Самый распространённый мультисиг — Safe (раньше Gnosis Safe)1. Дальше речь о нём.
Владельцы и порог
Safe хранит две вещи, важные для безопасности2:
- владельцы (owners): список адресов, обычно аппаратные кошельки разных членов команды;
- порог (threshold): сколько из них должны подписать. «3 из 5» — любые три из пяти владельцев.
Safe отдаёт их через getOwners() и getThreshold(). Отсюда два практических правила:
- Порог должен быть достаточно высоким, чтобы кража пары ключей ничего не давала, и достаточно низким, чтобы команда могла действовать, когда кто-то в отпуске.
- Ключи владельцев должны быть раздельными и лежать у разных людей. «3 из 5», где три ключа хранятся на одном ноутбуке, — это «1 из 1».
Изменения списка идут через сам Safe, транзакцией, подписанной текущим порогом. Они выпускают AddedOwner(owner), RemovedOwner(owner) и ChangedThreshold(threshold). Кто набрал подписи, может добавить себя, убрать остальных или поставить порог 1.
Как выполняется транзакция
Владельцы подписывают сообщение вне сети, а потом кто угодно отправляет его в сеть вызовом:
function execTransaction(
address to, uint256 value, bytes calldata data,
Enum.Operation operation, // 0 = Call, 1 = DelegateCall
uint256 safeTxGas, uint256 baseGas, uint256 gasPrice,
address gasToken, address payable refundReceiver,
bytes memory signatures
) public payable returns (bool success);Safe проверяет, что подписи набирают порог, и выполняет вызов. Отправлять execTransaction может не только владелец: считаются только подписи. При успехе Safe выпускает ExecutionSuccess(txHash, payment). Если внутренний вызов упал, Safe может выпустить ExecutionFailure, в зависимости от версии и настроек газа.
Самое опасное тут — поле operation.
delegatecall: поле, которое переписывает Safe
При operation = 0 Safe вызывает to обычным вызовом. При operation = 1 он выполняет код по адресу to в собственном контексте через delegatecall (часть 3). Этот код может писать в хранилище Safe и двигать его средства, и он не ограничен тем, что владельцы думали, что одобряют.
Сам Safe — тоже прокси: небольшой контракт пересылает вызовы в общую реализацию (singleton), адрес которой лежит в первом слоте хранилища3. Delegatecall во вредоносный контракт может перезаписать этот слот. После этого Safe по тому же адресу выполняет код атакующего, со всеми активами и правами над другими контрактами. Ни AddedOwner, ни ChangedThreshold, никакого события «код изменился».
Так было в Bybit 21 февраля 2025. Подписанты одобрили транзакцию, которая выглядела обычной. Это был delegatecall, подменивший логику Safe, и ушло около $1,46 млрд4. Транзакцию отправил адрес-исполнитель, которого не было среди трёх прежних у этого Safe. Мы писали об этом во времени в разборе Ronin: потеря случилась через две с половиной минуты после первой подписи.
Модули и guard
Два необязательных расширения стоит знать:
- Модули — контракты, которые Safe подключил. Модуль может заставить Safe выполнять транзакции без подписей владельцев через
execTransactionFromModule. Так делают лимиты расходов, регулярные платежи, восстановление доступа. Подключение выпускаетEnabledModule(module), и с этого момента правила модуля заменяют порог для тех действий, что он умеет5. - Guard — контракт, который Safe вызывает до и после каждой транзакции, например чтобы запретить delegatecall или ограничить переводы. Задаётся через
setGuard, выпускаетChangedGuard(guard)6. Плохой guard может и заблокировать любые транзакции, так что это отдельный риск.
Модуль по замыслу — чёрный ход. Это нормально, если вы подключили его осознанно. Когда появляется модуль, о котором никто не помнит, относитесь к нему как к новому владельцу.
Чего Safe не защищает
- То, что видят подписанты, не всегда то, что они подписывают. В Bybit интерфейс показывал одно, а транзакция делала другое. Аппаратный кошелёк показывает хэш и несколько полей, и delegatecall в нём легко не заметить.
- Скомпрометированные подписанты. Украли достаточно ключей или обманули достаточно людей — порог набран.
- Потеря собственной структуры Safe. Атакующему, который заменил логику или подключил модуль, ваши подписи больше не нужны.
После кражи из Safe рычагов часто уже нет: код, который мог поставить паузу или заморозить, мог исчезнуть. Поэтому важны ранние сигналы.
Как поймать
Что SmartAlert делает для Safe сейчас7. Когда вы добавляете Safe, он определяется по событиям (AddedOwner, RemovedOwner, ChangedThreshold, ExecutionSuccess), и предлагаются подходящие шаблоны.
- Владельцы и порог.
safe-owners-changed(добавлены или удалены владельцы) иsafe-threshold-changed, оба critical. Рецепт: Алерт при смене владельцев или порога Safe. - Модули и guard.
safe-module-enabledсрабатывает наEnabledModuleиChangedGuard, на какое из двух событий есть в ABI вашего Safe. - Упавшие транзакции. Шаблону
safe-execution-failedнуженExecutionFailureв ABI. Рецепт: Алерт при упавшей транзакции Safe. Safe 1.3 и новее приsafeTxGas = 0откатывает весьexecTransaction, если внутренний вызов упал, с ошибкойGS013, иExecutionFailureне выпускает. Шаблон этого не видит. Вместо него создайте правило «доля упавших транзакций» с минимумом 1 транзакция и порогом выше 0 в окне 15 минут: любая упавшая прямаяexecTransactionего сработает. У WazirX в июле 2024 упавшаяexecTransactionслучилась примерно за 7 минут до замены кода Safe, а потом ушло около $235 млн. - Незнакомый исполнитель. Правило «разрешённые отправители» по
ExecutionSuccessс адресами ваших владельцев и релейеров. Транзакция с любого другого адреса даёт алерт. В Bybit новый исполнитель был виден в том же блоке, что и вывод. Адрес самого Safe в список добавить нельзя, правило его отклонит. Скомпрометированный владелец, который отправляет из списка, это правило не сработает.
Чего мы сейчас не умеем, честно:
- Готового правила на
operation = 1нет. На SafeL2 его можно собрать самому: правило на событиеSafeMultiSigTransactionс фильтромoperationравно 1. Safe в основной сети Ethereum, как у Bybit, это поле не отдаёт, и там первый сигнал — смена реализации (masterCopy()), а она приходит слишком поздно. - Нет проверки singleton по состоянию.
masterCopy()у Safe нет в его ABI, поэтому правило по состоянию его не выберет, а слот хранилища продукт напрямую не читает. Тихую подмену как в Bybit не увидит ни событие, ни правило по состоянию. - У
DisabledModuleнет шаблона. Покрыто только подключение. - Время. В Ethereum алерт приходит через 2,5–4 минуты после блока. Если подпись, отправка и вывод укладываются в это время, алерт приходит вместе с потерей.
На этом блок 1 закончен. Теперь у вас есть все детали: состояние и события контракта, двери, код за адресом и Safe перед всем этим.
Дальше, часть 5: Кража админского ключа: IoTeX, PlayDapp, Zoth.
Источники
- 1.Документация Safe, What is Safe. ↩
- 2.Контракты Safe,
OwnerManager.solиSafe.sol. ↩ - 3.Репозиторий safe-smart-account (прокси и singleton). ↩
- 4.CertiK, Bybit incident technical analysis. ↩
- 5.Документация Safe, Modules. ↩
- 6.Документация Safe, Guards. ↩
- 7.SmartAlert, шаблоны для Safe и правила по упавшим транзакциям и разрешённым отправителям; хронология WazirX — отчёт Rekt. ↩