Автор: 99
Редактор: 77
Фон
4 сентября 2026 года известная децентрализованная кредитная платформа Notional Finance подверглась атаке, в результате которой было потеряно около 1,73 миллиона долларов США. Ниже приведён подробный анализ этой атаки от команды безопасности SlowMist:
Предварительные знания
В Notional Finance V1 fCash можно понимать как денежное требование с датой погашения. Cash receiver получает деньги при наступлении срока, а cash payer выплачивает деньги при наступлении срока. Протокол учитывает обе позиции в Portfolio, а затем с помощью проверки свободного залога определяет, насколько здорово состояние счёта и может ли он открыть позицию.
В данном случае safeTransferFrom контракта ERC1155Trade выполняет роль учёта активов. Когда переданный тип актива относится к cash receiver, контракт вызывает Portfolios.mintfCashPair, при этом на payer записывается обязательство, а на receiver — равнозначное требование. Вызов выглядит как обычный ERC1155 transfer, но по факту это парная эмиссия fCash.
После записи новой позиции в Portfolio необходимо выполнить расчёт и проверку свободного залога. Контракт RiskFramework сначала суммирует fCash-обязательства payer в знаковый int256, а затем переводит балансы разных валют в ETH и агрегирует их. Отрицательное значение означает необходимость выплатить наличные, положительное — что у аккаунта есть наличные или требования к оплате. Итог этого расчёта определяет, можно ли разрешить сделку.
После истечения срока позиции Portfolio вызовет функцию portfolioSettleCash контракта Escrow, чтобы преобразовать истёкший fCash в денежный баланс. Затем счёт сможет вывести соответствующие активы DAI или USDC через функцию вывода из Escrow.
Корневая причина
Основная уязвимость этой атаки заключается в том, что в реализации Escrow используется ExchangeRate._convertToETH, где одно из выражений напрямую преобразует абсолютное значение знакового целого в uint128.

Сначала атакующий создал две позиции обязательств с одинаковым payer, на суммы 1 и uint128.max. После сложения этих двух обязательств получается ровно 2^128. Поскольку в расчёте риска платёж payer учитывается как отрицательное число, ключевым параметром, передаваемым в convertBalancesToETH контракта Escrow, становится -2^128.
Однако в используемой этим контрактом версии Solidity, а именно 0.6.x, абсолютное значение 2^128 после принудительного приведения к uint128 переполняется и становится 0, поэтому это обязательство не попадает в итоговый ETH-эквивалент.
Наконец, в функции mintfCashPair требуется лишь, чтобы свободный залог payer был больше или равен нулю; поэтому после переполнения результата до 0 эта проверка проходит, и даже аккаунт без достаточных активов для покрытия обязательств всё ещё может создать позицию.
Анализ шагов атаки
1. В предварительной транзакции (0xe1589a19…d60a) атакующий сначала создал несколько вспомогательных контрактов и вызвал функцию setApprovalForAl контракта ERC1155Trade, чтобы выдать им разрешения; кроме того, он заранее проверил сроки погашения двух CashMarket и вычислил значения трёх параметров AssetId, которые потребуются в последующих действиях.

2. Сразу после этого атакующий, вызвав функцию safeTransferFrom контракта ERC1155Trade, создал пару fCash на сумму 1; соответствующему облигационному активу был присвоен cashGroupId 2, а timestamp срока погашения составил 1788480000 (4 сентября 2026 года, 8:00). При этом внутренней функцией _upsertAsset для адреса from (контракт атакующего) и адреса to (контракт-получатель 1) обновляются их обязательства и ожидаемые доходы соответственно.

После завершения эмиссии пары Portfolios немедленно проверяет свободный залог атакующего контракта, и поскольку объём первой эмиссии слишком мал, при пересчёте в ETH по текущему курсу и точности он округляется до нуля, поэтому первая проверка проходит успешно.
3. Затем атакующий снова вызвал функцию safeTransferFrom контракта ERC1155Trade, чтобы создать пару fCash на сумму uint128.max; на этот раз у облигационного актива cashGroupId был равен 2, а timestamp срока погашения — 1796256000 (3 декабря 2026 года, 8:00).
Здесь есть один нюанс: атакующий отчеканил облигационные активы с разными сроками погашения для двух разных адресов. Это связано с тем, что при обновлении обязательств адреса from, если облигационный актив одинаковый, то значения просто суммируются, а это может привести к переполнению при сложении и вызвать revert (в контракте использовалась библиотека safeMath).

4. После второй эмиссии, когда для адреса from (контракт атакующего) и адреса to (получатель 2) уже обновлены обязательства и ожидаемые доходы, функция mintfCashPair вызывает freeCollateral, чтобы вычислить чистую позицию залога адреса from и проверить, что она должна быть больше или равна 0, чтобы считаться здоровой.

При этом сначала в функции getRequirement контракта RiskFrameork суммируется общий объём обязательств address from после двух эмиссий:

Итоговый результат равен -2^128, после чего вызывается функция convertBalancesToETH контракта Escrow, чтобы преобразовать это значение в ETH-эквивалент, а функция convertBalancesToETH, в свою очередь, вызывает для расчёта функцию _convertToETH из библиотеки ExchangeRate:


Если проследить до функции _convertToETH, можно обнаружить, что она сначала берёт balance по абсолютному значению, а затем вызывает uint128, чтобы преобразовать 256-битное значение в 128-битное. При этом сумма обязательств по адресу from равна -2^128, и абсолютное значение 2^128 при жёстком приведении uint128() вызовет переполнение сверх верхней границы и обнулится до 0. Это означает, что протокол ошибочно посчитал позицию свободного залога по адресу from равной 0 и признал её здоровой, тем самым пропустив финальную проверку в функции mintfCashPair.

5. Затем контракт-получатель 2 снова разделил свою позицию ещё на два других вспомогательных контракта, и эти два вызова safeTransferFrom всё так же попадали в функцию mintfCashPair. Однако на этот раз payer — это контракт-получатель 2 со второй эмиссии, и у него уже есть огромная позиция ожидаемого дохода, полученная на предыдущем шаге; система риска считает это положительным требованием и, таким образом, пропускает проверку залога, а два вспомогательных контракта получают receiver fCash, который после наступления срока можно обменять на наличные.

6. Затем атакующий инициировал вторую, уже непосредственно прибыльную транзакцию: в ней для вспомогательных контрактов двух удерживаемых позиций из предыдущей транзакции были рассчитаны соответствующие требования, увеличен денежный баланс соответствующих активов, после чего он вызвал функцию withdraw контракта Escrow и вывел активы с прибылью.

Резюме
Ключевой момент этой атаки в том, что атакующий сначала использовал две отдельные позиции, чтобы довести сумму обязательств payer до 2^128, а затем заставил Escrow пересчитать эти обязательства в ETH. Используя уязвимость переполнения при преобразовании типов, результат был усечён до нуля, и проверка риска была пройдена.
Команда безопасности SlowMist рекомендует проекту обязательно выполнять проверку диапазона перед приведением типов; результат, превышающий type(uint128).max, следует немедленно отклонять. Одновременно все границы, связанные со знаковыми суммами, масштабированием точности и номиналом активов, должны быть покрыты экстремальными тестами, особенно для таких входных данных, как uint128.max, uint128.max + 1 и абсолютное значение отрицательных чисел. Для преобразований можно использовать библиотеку SafeCast от OpenZeppelin.

