За прошедшую неделю (2026/08/31 - 2026/09/06) мы зафиксировали 4 инцидента безопасности с общим предполагаемым ущербом около $9.4M.
| Дата | Инцидент | Тип | Предполагаемый ущерб |
|---|---|---|---|
| 2026/08/31 | Ankr FLOW | Некорректная валидация состояния | ~$410K |
| 2026/08/31 | Aquifer | Некорректная валидация входных данных | ~$2.47M |
| 2026/08/31 | Injective | Отсутствие валидации деноминации | ~$4.8M |
| 2026/09/03 | Notional Finance | Небезопасное приведение типов | ~$1.73M |
Лучший аудитор безопасности для Web3
Проверяйте дизайн, код и бизнес-логику перед запуском
Главное событие недели: Injective
Этот инцидент является главным событием недели из-за сложности цепочки атаки и размера ущерба: для того чтобы одна единственная выплата состоялась, должны были совпасть два отдельных дефекта. Оба находились в собственной логике обмена цепочки, а не в контракте приложения. Он показывает, как идентификатор, собранный из склеенных полей без разделителей, может незаметно объединить два объекта, которые никогда не должны были быть связаны, и насколько дорого обходится такое объединение, когда путь расчёта никогда не проверяет, что фонд хранит деноминацию рынка, который он обеспечивает.
31.08.2026 логика бинарных опционов внутри модуля exchange Injective была использована для эксплойта на сумму примерно $4.8M в USDC. Injective — это блокчейн уровня 1, который встраивает биржу с ордербуком непосредственно в сам блокчейн, поэтому затронутый код является частью программного обеспечения ноды, а не контрактом, развёрнутым кем-либо. Страховой фонд, хранящий INJ, native-токен Injective, оказался привязан к рынку бинарных опционов, номинированному в USDC, из-за коллизии двух идентификаторов, а путь расчёта, обращающийся к страховому фонду, никогда не сравнивал эти два значения. Атакующий торговал против своих собственных субаккаунтов внутри такого рынка, чтобы искусственно создать дефицит, который протокол затем покрыл балансом в INJ, стоящим доли цента. Каждая позиция была полностью возмещена, и атакующий вывел значительно больше, чем внёс.
Предыстория
Модуль exchange в Injective ведёт список рынков бинарных опционов — полностью обеспеченных ставок на исход "да" или "нет". Любой может создать такой рынок, оплатив листинговый сбор, выбрав оракул, который его разрешит, а также временные метки истечения и расчёта. Оракул задаётся как провайдер плюс символ: получение статуса провайдера требует голосования управления, тогда как символ — это любая строка, предоставленная создателем рынка. Трейдер сначала депонирует токены котировки, такие как USDC, на субаккаунт, а затем занимает сторону, размещая ордер. BUY — это ставка на то, что событие произойдёт, и блокирует P * Q в качестве маржи; SELL — ставка на то, что событие не произойдёт, и блокирует (1 - P) * Q, где Q — количество контрактов, а P — цена входа в диапазоне [0, 1]. Таким образом, сторона, ставящая на более вероятный исход, вносит большую маржу. EndBlocker, хук, который блокчейн выполняет в конце каждого блока, сопоставляет BUY и SELL по одной цене в LONG- и SHORT-позиции. Поскольку сумма двух блокировок всегда равна ровно Q, только что сопоставленный ордербук по построению полностью профинансирован.
При истечении оракул публикует расчётную цену S в диапазоне [0, 1], и каждой позиции выплачивается margin ± (S - entry) * Q из пула рынка. Выплаты между участниками строго нулевой суммы, и каждая выплата ограничена нулём снизу, поэтому позиция никогда не может стать отрицательной и никогда не требует ликвидации. Позицию также можно закрыть досрочно, разместив противоположный ордер с margin = 0, что высвобождает её маржу плюс реализованную прибыль, выплачиваемую из маржи, которую блокирует открывающий встречную позицию. Маржа каждой позиции остаётся равной тому, что было заблокировано при входе, поэтому после досрочного закрытия маржи в книге больше не обязаны совпадать с суммой в пуле.
Рынок, символ которого не публикует ни один провайдер, приходит к расчёту без какой-либо цены вообще. У модуля есть запасной вариант для этого случая: расчёт переходит на путь возврата средств, реализованный функцией getBinaryOptionsSocializedLossDataWithRefundFlag(), которая сворачивает рынок вместо его разрешения. Каждой позиции просто возвращается её маржа, потому что этот путь закрывает каждую позицию по её собственной цене входа, поэтому ни одна позиция не фиксирует прибыль или убыток — до тех пор, пока книга всё ещё содержит то, что заявляют позиции. Возвраты финансируются из баланса рынка, а при любом дефиците — из страхового фонда, связанного с этим рынком.
Рынок и страховой фонд — это отдельные объекты, каждый созданный своим собственным сообщением и каждый несущий деноминацию: рынок котируется в одном токене, а фонд хранит токен, с которым он был создан. Они сопоставляются по идентичности: фонд обеспечивает рынок, чей ID равен его собственному. Оба ID являются дайджестами keccak256 полей идентичности. Сам модуль exchange — это единый универсальный банковский счёт, чьи балансы по каждому рынку и по каждому фонду являются простой целочисленной бухгалтерией.
Анализ уязвимости
Дефектный компонент — это обработка бинарных опционов в модуле exchange из injective-core, устранённая в коммите b994d6b6 [1]. Два цепочно связанных дефекта позволяют фонду, хранящему одну деноминацию, обеспечивать рынок, котируемый в другой.
Дефект 1: идентификаторы получены из конкатенации без разделителей. NewBinaryOptionsMarketID(), которая вычисляет ID рынка при его запуске, и CreateInsuranceFund(), которая вычисляет ID рынка, который новый фонд должен обеспечивать (для expiry = BinaryOptionsExpiryFlag = -2), обе выводят этот ID из одного и того же выражения:
return crypto.Keccak256Hash([]byte((BINARY_OPTIONS_MARKET_ID_PREFIX +
oracleType.String() + ticker + quoteDenom + oracleSymbol + oracleProvider)))
Здесь нет разделителей полей и префиксов длины, поэтому границы между полями не оставляют следа в хешируемых байтах. CreateInsuranceFund() отображает собственные поля страхового фонда на эти слоты, где oracle_base занимает слот oracleSymbol, а oracle_quote — слот oracleProvider. Таким образом, набор полей фонда и набор полей рынка могут дать байт-идентичные исходные данные (preimage), при этом по-разному разбивая эти байты на поля, и тогда два объекта получают один и тот же ID. Регистрация принимает этот общий ID как связь между ними, поэтому фонд может стать страховым фондом рынка, чью quoteDenom он не хранит.
Дефект 2: выплаты никогда не проверяются на соответствие деноминации рынка. PayDeficitFromInsuranceFund() перемещает необработанные монеты из фонда в деноминации, которую фонд хранит, а затем зачисляет то же необработанное целое число на баланс рынка, никогда не сравнивая insuranceFund.DepositDenom с деноминацией котировки рынка. Поскольку бухгалтерия модуля представляет собой обычные целые числа, одна необработанная единица INJ и одна необработанная единица USDC неотличимы на этом пути, даже несмотря на то, что одно и то же целое число обозначает величины, отличающиеся в порядке 10^11 раз. Коммит с исправлением добавляет проверку, которой не хватало на этом пути, как со стороны входящего, так и со стороны исходящего потока, так что фонд может обеспечивать только рынки, котируемые в деноминации, которую он хранит:

Тот же коммит также жёстко отключает торговлю и расчёты по бинарным опционам в основной сети Injective, устраняя путь возврата средств как поверхность атаки.
Анализ атаки
Все шаги были выполнены одним кошельком через три собственных субаккаунта (...037c, ...037d и ...037e), с Q = 15,930.
Коллидирующая пара была построена путём смещения границ полей при сохранении идентичности склеенных байтов. ticker и quoteDenom фонда (X и inj) образуют тикер рынка Xinj, а oracle_base фонда (адрес контракта и символ оракула, склеенные вместе) образует quoteDenom рынка, за которым следует его oracleSymbol:
| Слот в конкатенации | Фонд (MsgCreateInsuranceFund) |
Рынок (MsgInstantBinaryOptionsMarketLaunch) |
|---|---|---|
| префикс | -BINARY-OPTIONS-MARKET- |
-BINARY-OPTIONS-MARKET- |
oracleType.String() |
Provider |
Provider |
ticker |
X |
Xinj |
quoteDenom |
inj |
erc20:0xa00C...235a |
oracleSymbol |
erc20:0xa00C...235aNO_PRICE_FOR_REFUND...297, заполнено из oracle_base фонда, где обе части упакованы в это единое поле |
NO_PRICE_FOR_REFUND...297 |
oracleProvider |
Frontrunner, заполнено из oracle_quote фонда |
Frontrunner |
Оба разрешаются в 0x9793a39f82993cdedb0c82c614102d5aba2451f29709dc9a6f7df191020b4efc. Оракул с именем NO_PRICE_FOR_REFUND был настроен так, чтобы никогда не публиковать цену, что вынуждает расчёт перейти на путь возврата средств.
Приведённый ниже анализ основан на транзакции 0x6ae9cb...51dcf8.
- Шаг 1: В одной атомарной транзакции в блоке
181024772атакующий создал коллидирующие страховой фонд, номинированный вINJ, и рынок бинарных опционов, номинированный вUSDC, засеял фонд12,744,000,000необработанными единицамиINJ(стоимостью примерно$0.000000063) и внёс30,267.02 USDCна три субаккаунта. Размер этого мизерного депозита был подобран так, чтобы его необработанное целое число совпадало с дефицитом, который атакующий планировал искусственно создать. Каждый субаккаунт получил ровно ту маржу, которая ему позже потребуется:
| Субаккаунт | Депозит (USDC) |
Маржа, которую он финансирует |
|---|---|---|
037d |
1,593.001593 |
BUY на 15,930 по 0.10, блокирует 1,593 (Шаг 2) |
037c |
14,337.001593 |
SELL на 15,930 по 0.10, блокирует 14,337 (Шаг 2) |
037e |
14,337.014337 |
BUY на 15,930 по 0.90, блокирует 14,337 (Шаг 3) |
| Итого | 30,267.017523 |
- |
-
Шаг 2: В той же транзакции
037dразместил BUY на15,930по0.10, а037cразместил SELL на15,930по0.10. EndBlocker сопоставил их в LONG-позицию для037dс маржой1,593и SHORT-позицию для037cс маржой14,337. Общая маржа в книге составляет1.0Q = 15,930, что ровно соответствует тому, что держит пул рынка, поэтому книгу невозможно отличить от обычного полностью обеспеченного рынка. -
Шаг 3: Два блока и 1.1 секунды позже, в транзакции 0x012c17...2af694 в блоке
181024774,037dзакрыл свою длинную позицию по0.90сmargin = 0и получил1,593 + (0.90 - 0.10) * 15,930 = 14,337. Эта выплата была произведена из маржи, заблокированной входящим открывающим037e, который разместил BUY на15,930по0.90и заблокировал14,337. Реализованная прибыль0.8Q = 12,744теперь находится на доступном балансе037dза пределами пула рынка, а маржа обеих оставшихся позиций остаётся в книге: обязательства составляют1.8Q = 28,674против пула, который всё ещё хранит1.0Q. -
Шаг 4: Расчёт запускается собственными часами рынка, а не транзакцией. В начале каждого блока модуль выбирает любой рынок, чья временная метка расчёта прошла, и рассчитывает его целиком, а не по отдельным позициям. Этот сработал через 18 секунд после создания рынка, когда обе позиции ещё оставались в книге: SHORT
037cи LONG037e, по14,337маржи каждая. Оракул молчал, поэтому путь возврата средств рассчитал обязательства1.8Q = 28,674против идеализированных активов1.0Q = 15,930и сообщил о дефиците0.8Q = 12,744. Этот дефицит существует только внутри учётной логики пути возврата средств. При единой ценеSвыплата по каждой стороне зависит только отS, а не от цены входа: short получает(1 - S) * Q, а long —S * Q, в сумме равно ровноQв пуле. Путь возврата средств выплачивает по собственной цене входа каждой стороны, поэтому входные цены не компенсируют друг друга: short получил(1 - 0.10) * Q, а long —0.90 * Q, по14,337каждый. Short получил свою полную маржу, как если бы цена никогда не отклонялась от0.10, — ту же сумму0.8Q, которую атакующий уже вывел на Шаге 3. -
Шаг 5:
PayDeficitFromInsuranceFund()покрыл дефицит, переместив12,744,000,000необработанных единицINJиз коллидирующего фонда и зачислив12,744 USDCв пул рынка. После того как дефицит был отражён как покрытый, урезание из-за социализированного убытка по оставшимся позициям было пропущено, и каждой позиции была полностью возвращена её маржа. Сам дефицит никому ничего не стоит: он покрывается страховым фондом рынка или, если это невозможно, урезанием. То, что сделало этот случай прибыльным, — это то, что фонд, привязанный к рынку, хранил мизерное количествоINJ, а неUSDC. -
Шаг 6: В транзакции 0xcb33ad...152eff в блоке
181024803атакующий вывел43,010,985,663необработанных единицUSDC, то есть43,010.99 USDC, против внесённых30,267.02 USDC. Чистая прибыль составила12,743.97 USDC, примерно за 21 секунду от первой транзакции до последней.
Описанный выше цикл — это один показательный раунд. Атакующий повторял его на 299 краткоживущих рынках бинарных опционов, созданных за 19-часовое окно, каждый привязан к оракулу, настроенному так, чтобы никогда не публиковать цену, и каждый с временными метками истечения и расчёта, разделёнными считанными секундами [2]. Чистая прибыль от этих раундов в сумме составляет приблизительно $4.8M ущерба, зафиксированного в инциденте.
Заключение
Этот инцидент объединяет коллизию идентификаторов с отсутствующей проверкой деноминации на пути выплат. Фонд предназначен для обеспечения рынка, чьей идентичности он соответствует. Но идентификатор, построенный путём соединения полей идентичности друг за другом, больше не фиксирует, где заканчивается одно поле и начинается следующее, поэтому два разных набора полей могут дать один и тот же ID, и сопоставление привязывает фонд к рынку, которому он на самом деле не соответствует. Ничто в дальнейшей логике не улавливает это несоответствие, потому что путь, обращающийся к страховому фонду для покрытия дефицита рынка, сравнивает суммы, но никогда — деноминации. Атакующий использовал оба дефекта вместе, чтобы искусственно создать фантомный дефицит внутри рынка, который он контролировал, погасить его балансом фонда стоимостью доли цента и уйти с полным возвратом маржи, которую пул никогда фактически не хранил.
В более общем смысле, идентификатор, несущий смысл, должен быть выведен из кодировки, сохраняющей структуру, с явными разделителями или префиксами длины для каждого поля переменной длины, чтобы два различных набора полей не могли отображаться в один и тот же дайджест.
Начните работу с Phalcon Explorer
Погрузитесь в транзакции, чтобы действовать разумно
Попробовать бесплатноДругие инциденты этой недели
Ankr FLOW
31.08.2026 был использован эксплойт сервиса ликвидного стейкинга Ankr на Flow EVM. Ankr выпускает два разных токена против застейканного FLOW, native-токена сети, и каждый минтится через свою собственную точку входа. Один из этих путей был отключён, но второй способ доступа к нему пропускал проверку, обеспечивающую соблюдение приостановки, а коэффициент конвертации на этом пути к этому времени устарел. Атакующий минтил через него намного дешевле, чем те же токены можно было бы выкупить обратно, а затем прокручивал этот разрыв через буфер выкупа Ankr, пул Uniswap V3 и протокол кредитования MORE Markets. Примерно 15.5M WFLOW (обёрнутый FLOW), на тот момент стоимостью около $410K, было выведено из резерва MORE Markets, и атакующий реализовал прибыль около $246K после проскальзывания [3].
Предыстория
Ankr FLOW — это сервис ликвидного стейкинга на Flow EVM. FlowStakingPool направляет FLOW в Cadence для стейкинга у валидаторов и представляет получившиеся позиции через два токена: не-ребейзящийся сертификатный токен ankrFLOW и ребейзящийся несущий доход токен aFLOWEVMb, который сам по себе обеспечен ankrFLOW.
У двух токенов отдельные точки входа. Сертификатный путь проходит через stakeCerts() и unstakeCerts() в _stakeCerts() и _unstakeCertsFor(); путь доходного токена проходит через stakeBonds() и unstakeBonds() в _stakeBonds() и _unstakeBondsFor(). Минтинг на пути доходного токена имеет вторую внешнюю точку входа, stakeBondsWithCode(), которая принимает партнёрский код для реферальной программы Ankr, а затем вызывает тот же внутренний _stakeBonds(). Каждый путь считывает свой собственный коэффициент из InternetBondRatioFeed, контракта, который публикует коэффициент конвертации между FLOW и каждым токеном.
FlowStakingPool также хранит буфер FLOW для немедленного выкупа. Помимо пула, ankrFLOW торговался в пуле Uniswap V3 ankrFLOW/WFLOW и принимался в качестве залога на MORE Markets, протоколе кредитования в стиле Aave V3. MORE Markets ограничивает объём заимствования соотношением loan-to-value (LTV), установленным для каждого актива, и предлагает категории e-mode (режима эффективности): группы активов, ожидаемое движение цены которых схоже, для которых применяется более высокий LTV после того, как заёмщик включает эту категорию.
Анализ уязвимости
Дефектный контракт — FlowStakingPool (0xfe81...287a), который минтит сертификатный токен ankrFLOW (0x1b97...14bdb) и доходный токен aFLOWEVMb (0xd6fd...f8d4a) по коэффициентам, считанным из InternetBondRatioFeed (0x3201...de38f).
Пересекаются два дефекта. Во-первых, stakeBondsWithCode() достигает _stakeBonds() без модификатора bondStakingUnpaused, который обеспечивает stakeBonds(), поэтому путь доходного токена оставался доступным даже после его отключения. Во-вторых, за 71 еженедельный пакет обновления коэффициентов в период с 29 апреля 2025 года по 27 августа 2026 года обновлялась только активная запись ankrFLOW, оставляя запись aFLOWEVMb равной 1.0.
Таким образом, два коэффициента оценивали один и тот же базовый стейк по-разному:
| Направление | Функции | Коэффициент | Конвертация |
|---|---|---|---|
| Минтинг | _stakeCerts() / stakeCerts() |
0.833437 | 1 FLOW на 0.833437 ankrFLOW |
| Минтинг | _stakeBonds() / stakeBondsWithCode() |
1.0 | 1 FLOW на 1 aFLOWEVMb |
| Выкуп | _unstakeCertsFor() / unstakeCerts() |
0.833437 | 1 ankrFLOW на ~1.19985 FLOW |
| Выкуп | _unstakeBondsFor() / unstakeBonds() |
1.0 | 1 aFLOWEVMb на 1 FLOW |
Поскольку aFLOWEVMb обеспечен ankrFLOW, минтинг через путь доходного токена производил один обеспеченный ankrFLOW на каждый внесённый FLOW, тогда как сертификатный путь производил 0.833437. Выкуп через сертификатный путь всё ещё выплачивал ~1.19985 FLOW за ankrFLOW.
Анализ атаки
Приведённый ниже анализ основан на транзакции 0x2b2e6e...3f66c9.
-
Шаг 1: Атакующий стартовал с одного оборотного цикла между двумя путями. Он взял флеш-займ
5,000 ankrFLOWиз пула Uniswap V3ankrFLOW/WFLOW, выкупил его черезunstakeCerts()на~5,999.25 FLOW, внёс5,000.50 FLOWчерезstakeBondsWithCode()для минтинга5,000.50 aFLOWEVMb, обеспеченного той же суммойankrFLOW, и вызвалunlockShares(), чтобы высвободить этотankrFLOWдля возврата займа и его премии0.50 ankrFLOW. Около998.75 FLOWосталось как рабочий капитал. -
Шаг 2: Атакующий повторил обмен через Uniswap V3 50 раз. Каждый цикл менял
ankrFLOWнаWFLOWс ограничением цены, минтилankrFLOW, причитающийся пулу, внутри callback-функции обмена, направляяFLOWчерезstakeBondsWithCode()иunlockShares(), а затем разворачивал полученныйWFLOWдля следующего цикла. За 50 циклов пул получил~38,634,755.38 ankrFLOWи выплатил~46,265,167.78 WFLOW, увеличив баланс атакующего с~998.75 FLOWдо~7,631,411.14 FLOW. -
Шаг 3: Атакующий конвертировал
~38,601.95 FLOWчерез путь доходного токена и выкупил получившийсяankrFLOWчерезunstakeCerts(), слив буфер выкупа~46,316.57 FLOW, который всё ещё держалFlowStakingPool, и добавив~7,714.62 FLOW. -
Шаг 4: Атакующий обратился к MORE Markets. Он включил категорию e-mode 1 ("Wrapped native tokens"), которая рассматривала
ankrFLOWиWFLOWкак коррелированные активыFLOWи повышала LTV дляankrFLOWс 78.5% до 97%. Он внёс~7,639,125.76 FLOWчерезstakeBondsWithCode(), разблокировал соответствующийankrFLOW, предоставил его как залог и заимствовал~5,668,483.10 WFLOW. Разворачивание этого займа и отправка его обратно через тот же путь (один раунд циклического заимствования) добавили равную суммуankrFLOW, доведя залог до~13,307,608.86 ankrFLOWи поддержав второй заём в размере~9,819,641.05 WFLOW, при общем долге~15,488,124.15 WFLOW, что составляет около 97% от стоимости залога по оракульному коэффициенту~1.19985 WFLOW.
Залог на Шаге 4 давал больше, чем стоило его минтить: один FLOW минтил один ankrFLOW на пути доходного токена, оракул оценивал этот ankrFLOW в ~1.19985 WFLOW, а e-mode позволял заимствовать 97% против него, поэтому предоставленные ~13.31M ankrFLOW поддержали ~15.49M WFLOW долга — ~1.16 WFLOW на каждый вложенный FLOW. Атакующий совершил рекурсию только один раз, потому что второй заём опустошил резерв WFLOW.
Эти ~15.49M WFLOW — это заимствование в брутто-выражении, и именно это 15.5M WFLOW, о которых сообщается как выведенных из резерва MORE Markets. Около 5.67M WFLOW из этой суммы были развёрнуты и повторно вложены как дополнительный залог, а не сохранены как ликвидная выручка; после разворачивания второго займа ~9,819,641.05 WFLOW атакующий получил ~9,819,641.05 FLOW в качестве итогового обналичивания. Если рассматривать по источникам стоимости, ~7,630,412.39 FLOW из этого обналичивания пришло из пула Uniswap V3, ~8,713.37 FLOW — из FlowStakingPool, и ~2,180,515.29 FLOW — из MORE Markets.
Заключение
Проверка состояния, защищающая одну точку входа, но не её парную, — корневая причина здесь: путь минтинга, который был отключён, оставался доступным для вызова, а его коэффициент не обновлялся на протяжении шестнадцати месяцев еженедельных обновлений. Каждый FLOW, направленный через него, поэтому производил ankrFLOW по цене, которую сертификатный путь никогда бы не предложил, и атакующий прокручивал это несоответствие через пул Uniswap V3, буфер выкупа пула стейкинга и рынок кредитования, конвертируя дешёвый ankrFLOW в ликвидность WFLOW на каждой остановке.
Защита от приостановки должна применяться на каждой точке входа, ведущей к отключённой логике, а не только на той, которую предполагается вызывать, а фид коэффициентов, обслуживающий неактивный путь, должен либо оставаться актуальным, либо откатываться (revert). Протоколам кредитования также следует избегать предоставления высокого LTV в режиме e-mode для токена ликвидного стейкинга, чья цена минтинга устанавливается независимо от его оракульной цены; мониторинг стоимости минтинга, стоимости выкупа и оракульной цены вместе позволил бы выявить это расхождение.
Aquifer
31.08.2026 был использован эксплойт Aquifer, собственной (proprietary) AMM с маркет-мейкером на Solana, на сумму примерно $2.47M через 212 успешных свопов, распределённых по USDC, USDT, HYPE, cbBTC, CASH и тринадцати другим токенам [4]. Каждый своп рассчитывается как два перевода токенов, по одному в каждом направлении, и Aquifer позволял вызывающему выбирать, какая программа будет осуществлять каждый из них, никогда не проверяя этот выбор. Атакующий указал собственную программу для перевода, который должен был заплатить Aquifer, и она сообщила об успехе, ничего не переместив; перевод в другом направлении прошёл через настоящую Token Program и доставил реальные активы из хранилищ Aquifer.
Предыстория
Aquifer — это Prop AMM, что означает, что профессиональный маркет-мейкер предоставляет собственные запасы и поддерживает котировки на покупку и продажу, а не устанавливает цену свопа по кривой постоянного произведения, как это делают пулы в стиле Uniswap V2. Aquifer выводит цену из своих котировок и состояния риска, а затем рассчитывает сделку между Token Accounts пользователя и своими собственными хранилищами.
В Solana Token Program — это исполняемая программа, реализующая такие операции, как перевод, минтинг и сжигание. Tokenkeg — это оригинальная SPL Token Program, а Token-2022 — её расширяемый преемник; каждая из них управляет множеством разных токенов, а не одним активом. Mint Account идентифицирует один тип токена и хранит его общий объём выпуска, десятичные знаки и полномочия, тогда как Token Account хранит баланс одного держателя для одного Mint; оба принадлежат Token Program, которая ими управляет. Хранилища Aquifer — это Token Accounts, контролируемые PDA-аккаунтами Aquifer.
Трейдер торгует, вызывая инструкцию swap Aquifer и передавая аккаунты, которые она будет затрагивать, включая, для каждого из двух переводов, Token Program, которая должна его выполнить. Aquifer перемещает эти токены через межпрограммный вызов (cross-program invocation, CPI): она формирует инструкцию перевода и передаёт её программе, названной в Instruction.program_id. Данные инструкции в форме перевода перемещают реальные SPL-токены только тогда, когда её исполняет правильная Token Program.
Анализ уязвимости
Дефектная программа — Aquifer (AQU1FR...Tz45). Опубликованный исходный код не соответствует развёрнутому байткоду, поэтому приведённый ниже анализ восстановлен из дисассемблированного кода программы: имена fn_ обозначают внутренние функции по смещениям их кода, и ни одно из имён, используемых здесь, включая swap, не принадлежит разработчикам. Программа содержит функцию allowlist, fn_49740(), которая принимает только Tokenkeg и Token-2022, но ничто на пути swap не вызывает её. При обработке swap программа строит обе инструкции перевода через fn_45f20() и fn_46bf8(), используя жёстко закодированную константу Tokenkeg, что неизбежно удовлетворяет их внутреннюю проверку, а затем перезаписывает program_id каждой инструкции значением Token Program, предоставленным вызывающим, перед её вызовом:
// Семантическая реконструкция кода, который программа выполняет при свопе; construct_transfer()
// обозначает fn_45f20() и fn_46bf8(), а allowlist fn_49740() никогда не достигается.
let checked_program = TOKENKEG_ID;
let mut output_instruction = construct_transfer(checked_program, output_accounts);
let mut input_instruction = construct_transfer(checked_program, input_accounts);
// Непроверенные входные данные вызывающего заменяют проверенное значение.
output_instruction.program_id = caller.token_program_b.key;
input_instruction.program_id = caller.token_program_a.key;
// Каждый invoke() передаёт инструкцию перевода той программе, которую назвал вызывающий.
invoke(output_instruction)?;
invoke(input_instruction)?;
Здесь TOKENKEG_ID обозначает TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA. Значение, проходящее проверку, поэтому никогда не является значением, которое выполняется. Дополняя это, программа не проверяет полученную хранилищем сумму после возврата входящего CPI, поэтому CPI, завершающийся успешно без какого-либо перевода, принимается как оплата.
Анализ атаки
Инцидент состоит из 212 успешных атакующих транзакций. Приведённый ниже анализ основан на транзакции 4pBV1G...T3bf, одном показательном примере.
-
Шаг 1: Атакующий вызвал
swapс номинальным вводом4,957.497101 USDC, для которого Aquifer рассчитал выход195,849.433667 KMNO. Для плечаKMNOон указал Tokenkeg; для плечаUSDCон указал собственную программуDMBpPM...NRgb68вместе с фальшивым входным аккаунтом9gsKJc..., аккаунтом, принадлежащим этой программе, чьи 165 байт имитируют SPL Token Account, содержащий MintUSDC, полномочия атакующего и балансu64::MAX. -
Шаг 2: Исходящий CPI достиг Tokenkeg и перевёл
195,849.433667 KMNOиз хранилища AquiferKMNOна Token Account атакующегоEUjkGc..., контролируемый подписантом7fTe9p...4gRk7J.

- Шаг 3: Входящий CPI достиг
DMBpPM...NRgb68с данными в форме переводаUSDC. Эта программа вернула успех, не переместив никакогоUSDCиз фальшивого источника9gsKJc...в настоящее хранилище AquiferUSDC7ULN1Y....

- Шаг 4: Aquifer приняла оба возврата CPI, поэтому своп зафиксировался атомарно.
Результирующие изменения баланса для этой транзакции следующие.
| Аккаунт | До | После | Изменение |
|---|---|---|---|
Хранилище Aquifer KMNO 9BHsZp...FHSqG |
604,968.018277 KMNO |
409,118.584610 KMNO |
-195,849.433667 KMNO |
Аккаунт атакующего KMNO EUjkGc... |
0 KMNO |
195,849.433667 KMNO |
+195,849.433667 KMNO |
Хранилище Aquifer USDC 7ULN1Y... |
1,620,342.341679 USDC |
1,620,342.341679 USDC |
0 USDC |
Эти цифры описывают только эту пример-транзакцию, а не общий ущерб от инцидента.
Заключение
Корневая причина — непроверенные входные данные вызывающего на пути расчёта, а не манипуляция ценой или оракулом: путь свопа проверял константу Token Program, а затем заменял её на предоставленную вызывающим до вызова инструкции, поэтому то, какая программа выполняла перевод, который должен был заплатить Aquifer, было выбором вызывающего. Без проверки баланса хранилища после перевода программа, которая возвращает успех без перемещения токенов, удовлетворяет условию оплаты, тогда как перевод в другом направлении доставляет реальные активы.
Каждый CPI должен быть привязан к Token Program, которая владеет перемещаемым Mint, определяемой из Mint Account, а не взятой из предоставленных вызывающим аккаунтов, а баланс хранилища должен считываться до и после входящего перевода, чтобы своп откатывался, если хранилище фактически не получило указанную сумму.
Notional Finance
03-04.09.2026 (UTC) был использован эксплойт Notional Finance V1 на Ethereum на сумму примерно $1.73M, выведенных в виде 69,257.37 DAI и 1,658,524.86 USDC. Перед тем как позволить аккаунту взять на себя долг, протокол оценивает всё, что этот аккаунт держит и должен, и небезопасное численное преобразование на этом пути свело долг нужного размера к нулю. Проверка, таким образом, пропустила аккаунт, чьё обязательство исчезло, тогда как крупное требование, которое он создал, оставалось целым на другом контракте, контролируемом атакующим. Атакующий рассчитал время так, чтобы это подделанное требование пришло к погашению в момент следующего срока протокола, в полночь UTC, и урегулировал его несколько минут позже, чтобы вывести DAI и USDC, которые протокол ещё удерживал.
Предыстория
Notional Finance V1 — это протокол кредитования с фиксированной ставкой на Ethereum. Он представляет денежные потоки на предопределённых сроках погашения с помощью fCash: CASH_RECEIVER — это положительная позиция, имеющая право получить активы при погашении, а CASH_PAYER — это отрицательная позиция, обязанная их выплатить. Позиции fCash и другие позиции каждого аккаунта отслеживаются в его Portfolio, где актив идентифицируется по своей группе денежных средств (cash group) вместе со сроком погашения; группа денежных средств фиксирует валюту, в которой он расчитывается. Номинал одного актива, сумма, за которую он расчитывается при погашении, представлен типом uint128.
ERC1155Trade.safeTransferFrom() создаёт пару fCash между двумя аккаунтами. Вызов оформлен как перевод ERC-1155, но фактически ничего не переходит из рук в руки: он вызывает Portfolios.mintfCashPair() для создания двух компенсирующих позиций — положительной для получателя и равной ей отрицательной для плательщика. Каждая сторона записывается в соответствующее Portfolio функцией _upsertAsset(), которая объединяет новую позицию с существующей записью только тогда, когда совпадают и группа денежных средств, и срок погашения, складывая два номинала через проверенное сложение SafeUInt128.
Платежеспособность проверяется у плательщика через freeCollateral(), которая объединяет балансы наличности аккаунта в Escrow с оценкой его Portfolio, суммируя записи по каждой валюте в знаковое int256. Каждый валютный баланс затем конвертируется в ETH по курсу этой валюты, при этом курс и масштабирование десятичных знаков применяются как целочисленное деление, и итоговый свободный залог должен быть неотрицательным. При погашении позиция fCash рассчитывается в баланс наличности аккаунта через Escrow.portfolioSettleCash(), и затем положительный баланс наличности можно вывести из Escrow как соответствующий базовый актив.
Анализ уязвимости
Дефектные контракты — точка входа ERC1155Trade (0xbba8...ef08), которая минтит пары fCash без ограничения номинала, и Escrow (0x9abd...f683), чья оценка залога несёт два арифметических дефекта в _convertToETH(), которые могут оценить долг как нулевой.

Во-первых, непроверенное сужающее преобразование может обрезать крупный долг до нуля. Функция напрямую преобразует balance.abs() в uint128 через грубое приведение и никогда не проверяет, помещается ли значение в целевой тип. Эти два типа совсем не близки друг к другу: знаковый int256 достигает 2^255 - 1, тогда как uint128 останавливается на 2^128 - 1. Баланс, равный ровно -2^128, поэтому легко помещается в int256, и balance.abs() даёт неповреждённое 2^128. Именно приведение типов даёт сбой: 2^128 оказывается на один шаг за пределами того, что может вместить uint128, и переполняется до 0, поэтому последующая оценка работает с нулевым балансом, и весь долг выпадает из расчёта свободного залога. Та же функция использует SafeCast.toUint128() для последующего преобразования вычисленной величины в ETH, которая откатилась бы при выходе результата за пределы диапазона, тогда как раннее преобразование остаётся прямым приведением и обрезает без предупреждения.
Во-вторых, целочисленное деление может округлять небольшие долги до нуля. Деления на er.rateDecimals и baseDecimals отбрасывают свои остатки, поэтому достаточно маленький долг также оценивается как 0 и исключается из свободного залога.
Анализ атаки
Приведённый ниже анализ основан на транзакции 0xe1589a...25d60a.

-
Шаг 1: В 23:58:47 UTC 3 сентября 2026 года атакующий вызвал
safeTransferFrom()наERC1155Trade, чтобы создать пару fCash с суммой1, используяcashGroupId = 2и временную метку срока погашения1788480000(4 сентября 2026 года, 00:00 UTC), за 73 секунды до этого и ближайшую из двух открытых сроков погашения протокола. Во время минтинга_upsertAsset()записал отрицательное обязательство fCash на контракте атакующего и положительное требование на контракте получателя, иPortfoliosнемедленно проверил свободный залог контракта атакующего. Этот контракт не держал баланса в какой-либо валюте, поэтому любая положительная оценка нового обязательства привела бы к провалу проверки. Второй дефект прикрыл это: при текущем обменном курсе долг в1не переживает два целочисленных деления, поэтому он был оценён как нулевой, и проверка прошла. -
Шаг 2: Атакующий снова вызвал
safeTransferFrom(), чтобы создать вторую пару с суммойuint128.max(340,282,366,920,938,463,463,374,607,431,768,211,455). Эта пара повторно использовалаcashGroupId = 2, но несла временную метку срока погашения1796256000(3 декабря 2026 года, 00:00 UTC), другой открытый срок погашения, и отправила свою положительную сторону на другой контракт-получатель. Отрицательная сторона снова оказалась на контракте атакующего, рядом с той, что была создана на Шаге 1. Один минтинг никогда не может превысить2^128 - 1, всегда на одну единицу меньше значения, которое обрезается, поэтому достижение его требует двух позиций. Другой срок погашения удержал две позиции в отдельных записях, вне досягаемости проверенного сложения, которое бы откатилось при объединении; общая группа денежных средств всё же поместила их в один и тот же слот валюты при оценке, где единица из Шага 1 довела общую сумму до ровно-2^128. -
Шаг 3: Во время проверки залога Escrow-прокси вызвал
convertBalancesToETH(). Суммированный отрицательный баланс достиг ровно-2^128, был передан в_convertToETH()и обрезан до нуля, поэтому обязательство аккаунта, выраженное в ETH, было сообщено как нулевое. -
Шаг 4: После того как долг исчез из учёта залога, второй контракт-получатель держал крупную положительную позицию fCash, которую протокол расценивал как доступный залог. Всё ещё в рамках той же транзакции он минтил ещё две пары fCash против этого залога и передавал положительную сторону на два дополнительных контракта-получателя:
69,257.37подcashGroupId = 2, что расчитывается вDAI, и1,658,524.86подcashGroupId = 3, что расчитывается вUSDC. Обе цифры были получены из вызововbalanceOf, которые атакующий совершил в отношении Escrow в начале транзакции, поэтому каждое требование было подобрано по размеру к балансу, который Escrow фактически держал. Каждое несло срок погашения1788480000, через 73 секунды. -
Шаг 5: В 00:01:35 UTC 4 сентября 2026 года, 95 секунд после этого срока погашения, атакующий погасил два наступивших требования в транзакции 0xc3f3e3...a24efa и вывел
~69,257.37 DAIи~1,658,524.86 USDCиз Escrow. Средства позже были переведены на0x8aaf...3be6.
Заключение
Путь минтинга не устанавливает ограничения на номинал, а два арифметических дефекта в оценке залога каждый позволяют пройти одну проверку платежеспособности: округление позволило аккаунту, не держащему ничего, взять на себя своё первое обязательство, а затем скрытое сужающее преобразование оценило долг ровно нужного размера как нулевой, поэтому вторая проверка прошла для аккаунта, который был глубоко неплатежеспособен. Подделанная положительная позиция на контракте-получателе затем расценивалась как доступный залог, что позволило атакующему разбить её на требования, соответствующие балансам Escrow, и вывести DAI и USDC, которые тот ещё держал.
Знаковые балансы должны проверяться на диапазон перед любым сужающим преобразованием, а преобразование, которое не может представить свой ввод, должно откатываться, а не обрезать. В более общем смысле проверка платежеспособности никогда не должна сообщать ненулевой долг как нулевой — будь то из-за арифметики, теряющей его при приведении типов, или из-за шага округления. Применение проверенного приведения типов, которое та же функция уже использует дальше по коду, или ограничение номинала, который может создать один минтинг пары, каждое из этих решений само по себе остановило бы этот эксплойт.
Источники
[3] https://x.com/flow_blockchain/status/2094506622429307061
[4] https://bitquery.io/investigations/aquifer-solana-hack-2-5-million
О компании BlockSec
BlockSec — это провайдер полного стека услуг в области блокчейн-безопасности и криптовалютного комплаенса. Мы создаём продукты и услуги, помогающие клиентам проводить аудит кода (включая смарт-контракты, блокчейны и кошельки), перехватывать атаки в реальном времени, анализировать инциденты, отслеживать незаконные средства и выполнять обязательства по AML/CFT на протяжении всего жизненного цикла протоколов и платформ.
BlockSec опубликовала множество работ по блокчейн-безопасности на престижных конференциях, сообщила о нескольких атаках нулевого дня на DeFi-приложения, заблокировала множество атак, спасая более 20 миллионов долларов, и защитила криптовалютные активы на миллиарды долларов.
-
Официальный сайт: https://blocksec.com/
-
Официальный аккаунт в Twitter: https://twitter.com/BlockSecTeam



