Резюме
2 августа 2022 года межсетевой мост Nomad Bridge подвергся атаке, что привело к потере почти 200 миллионов долларов. Первопричиной стала некорректная проверка в обновленной версии ончейн-смарт-контракта.
Предыстория
Nomad Bridge — это новый межсетевой мост для активов, использующий архитектуру на основе доказательств мошенничества (fraud-proof). Он работает следующим образом:
- Nomad развертывает базовый контракт под названием Replica в каждом поддерживаемом блокчейне в качестве почтового ящика для любых межсетевых сообщений.
- Внецепочечные агенты (off-chain agents) ретранслируют и организуют межсетевые сообщения в дерево Меркла и обновляют корень дерева, отправляя подписанный хеш нового корня дерева в этот контракт.
- Новые сообщения, которые должны быть подтверждены в блокчейне, должны пройти процедуры
prove()иprocess().- Процедура
prove()проверяет сообщение и доказательство в дереве Меркла, а затем помечает сообщение как доказанное (proven). - Процедура
process()проверяет и выполняет сообщение, если оно было ранее доказано, а связанный с ним корень дерева подтвержден.
- Процедура
Код
В Ethereum контракт Replica представляет собой Beacon-прокси, развернутый по адресу 0x5d94309e5a0090b165fa4181519701637b6daeba. Существует две версии логического контракта: первая версия развернута по адресу 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b, а вторая — по адресу 0xb92336759618f55bd0f8313bd843604592e27bd8.
Сначала рассмотрим предыдущую версию логического контракта, в частности функцию process():
function process(bytes memory _message) public returns (bool _success) {
bytes29 _m = _message.ref(0);
// убедиться, что сообщение предназначалось для этого домена
require(_m.destination() == localDomain, "!destination");
// убедиться, что сообщение было доказано
bytes32 _messageHash = _m.keccak();
require(messages[_messageHash] == MessageStatus.Proven, "!proven");
// проверка защиты от повторного входа (re-entrancy guard)
require(entered == 1, "!reentrant");
entered = 0;
// обновить статус сообщения как обработанное
messages[_messageHash] = MessageStatus.Processed;
Мы привели только часть этой функции. В этом сегменте кода вычисляется хеш сообщения, проверяется по маппингу messages, чтобы убедиться, что сообщение было ранее доказано, затем выполняется проверка от повторного входа, и в конце обновляется статус сообщения.
Также кратко рассмотрим старую функцию prove():
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// убедиться, что сообщение не было доказано или обработано
require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
// вычислить ожидаемый корень на основе доказательства
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// если корень валиден, изменить статус на Proven
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = MessageStatus.Proven;
return true;
}
return false;
}
Здесь нет ничего необычного: проверка дублирования, вычисление корня дерева, и если он допустим, то пометка как доказанное. Таким образом, в старой версии контракта Replica существует специальная отметка (MessageStatus.Proven = 1) для всех доказанных сообщений.
Теперь давайте проверим вторую версию логического контракта. Для новой версии сначала рассмотрим функцию prove():
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// убедиться, что сообщение не было обработано
// Заметьте, что это позволяет повторное доказательство с новым корнем.
require(
messages[_leaf] != LEGACY_STATUS_PROCESSED,
"already processed"
);
// вычислить ожидаемый корень на основе доказательства
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// если корень валиден, изменить статус на Proven
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = _calculatedRoot;
return true;
}
return false;
}
Мы сразу заметили здесь серьезное изменение: разработчики почему-то решили записывать вычисленный корень в качестве статуса доказательства вместо использования специальной отметки. Для функции prove() это допустимо, так как гарантируется, что хеш корня дерева Меркла не равен нулю. Это также логично, потому что как только корень дерева подтвержден, любые новые сообщения, доказанные с этим корнем, готовы к выполнению.
Затем мы проверяем функцию process() в новой версии:
function process(bytes memory _message) public returns (bool _success) {
// убедиться, что сообщение предназначалось для этого домена
bytes29 _m = _message.ref(0);
require(_m.destination() == localDomain, "!destination");
// убедиться, что сообщение было доказано
bytes32 _messageHash = _m.keccak();
require(acceptableRoot(messages[_messageHash]), "!proven");
// проверка защиты от повторного входа
require(entered == 1, "!reentrant");
entered = 0;
// обновить статус сообщения как обработанное
messages[_messageHash] = LEGACY_STATUS_PROCESSED;
Обратите внимание на строку messages[_messageHash]. Распространенная ловушка заключается в том, что при обращении к несуществующему элементу маппинга возвращается ноль. В данном контексте это означает, что корень дерева Меркла, связанный с этим хешем сообщения, равен нулю. Нам нужно проверить, к чему приведет это нулевое значение. Для этого следует внимательно изучить новую функцию acceptableRoot():
function acceptableRoot(bytes32 _root) public view returns (bool) {
// это обратная совместимость для сообщений, доказанных/обработанных
// в предыдущих версиях
if (_root == LEGACY_STATUS_PROVEN) return true;
if (_root == LEGACY_STATUS_PROCESSED) return false;
uint256 _time = confirmAt[_root];
if (_time == 0) {
return false;
}
return block.timestamp >= _time;
}
По сути, эта функция проверяет маппинг confirmAt, чтобы убедиться, что корень дерева Меркла был подтвержден.
К сожалению, в ОБЕИХ версиях контракта Replica нулевой хеш устанавливается в 1 в функции инициализации:
function initialize(
uint32 _remoteDomain,
address _updater,
bytes32 _committedRoot, // при инициализации он равен нулю
uint256 _optimisticSeconds
) public initializer {
__NomadBase_initialize(_updater);
// установка переменных состояния
entered = 1;
remoteDomain = _remoteDomain;
committedRoot = _committedRoot;
// предварительное одобрение зафиксированного корня
confirmAt[_committedRoot] = 1;
_setOptimisticTimeout(_optimisticSeconds);
}
В старой версии контракта Replica это вполне безопасно: в prove() хеш корня дерева не может быть равен нулю, поэтому безопасно устанавливать значение для нулевого хеша в маппинге confirmAt равным 1.
Однако в новой версии для любого нового сообщения messages[_messageHash] возвращает ноль. Затем acceptableRoot обращается к записи для нулевого хеша в маппинге confirmAt и возвращает true.
Атака
Исходя из анализа кода выше, мы видим, что любое ранее не виденное сообщение может просто пройти через логику валидации и быть выполнено. Таким образом, достаточно подделать сообщение и вызвать process().
Интересно, что первый вызов функции process() в этом контракте произошел всего два дня назад (на блоке 15249565) в транзакции 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0.
На рисунке ниже мы видим, что слот хранения (storage slot) для переменной состояния messages для этого сообщения изначально был равен нулю, что означает, что контракт Replica ранее не «знал» об этом сообщении.

Затем этот слот был установлен в значение два (т.е. статус LEGACY_STATUS_PROCESSED), что означает, что это сообщение было успешно обработано. Это указывает на то, что невалидное сообщение обошло логику prove() и было обработано напрямую.
Заключение
Это еще одна классическая атака, использующая непроверенное возвращаемое значение из маппинга. Разработчикам на Solidity следует уделять особое внимание при работе с маппингами, чтобы избежать подобных неожиданных результатов.



