Back to Blog

Потеряно ~9,4 млн $: эксплойты Injective и Aquifer | BlockSec Weekly

Code Auditing
11 сентября 2026 г.
25 min read
Key Insights
  • На этой неделе четыре инцидента привели к убыткам на сумму около $9.4M в сетях Injective, Solana, Ethereum и Flow EVM.

  • Эксплойты в Injective, Aquifer и Notional Finance не потребовали ни манипуляции ценой, ни флеш-займа. В каждом случае собственная система учёта протокола просто выдавала число, которое никогда не было верным: в Injective страховой фонд, идентификатор которого совпал с идентификатором рынка, погасил сфабрикованный дефицит в 12,744 USDC непроверенным балансом в другом токене стоимостью в доли цента, а в Notional Finance долг ровно в -2^128 был оценён как ноль.

  • В трёх из четырёх случаев протокол уже реализовал правильную проверку, просто не на том пути, который имел значение. В Aquifer был реализован allowlist Token Program, который точка входа свопа никогда не вызывала, в Ankr FLOW одна точка входа для стейкинга была защищена модификатором паузы, а соседняя осталась открытой, а в Notional Finance для одного преобразования в функции использовалось проверяемое приведение типов, тогда как преобразование строкой выше оставалось необработанным приведением.

За прошедшую неделю (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 и LONG 037e, по 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 V3 ankrFLOW/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, содержащий Mint USDC, полномочия атакующего и баланс u64::MAX.

  • Шаг 2: Исходящий CPI достиг Tokenkeg и перевёл 195,849.433667 KMNO из хранилища Aquifer KMNO на Token Account атакующего EUjkGc..., контролируемый подписантом 7fTe9p...4gRk7J.

  • Шаг 3: Входящий CPI достиг DMBpPM...NRgb68 с данными в форме перевода USDC. Эта программа вернула успех, не переместив никакого USDC из фальшивого источника 9gsKJc... в настоящее хранилище Aquifer USDC 7ULN1Y....
  • Шаг 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, которые тот ещё держал.

Знаковые балансы должны проверяться на диапазон перед любым сужающим преобразованием, а преобразование, которое не может представить свой ввод, должно откатываться, а не обрезать. В более общем смысле проверка платежеспособности никогда не должна сообщать ненулевой долг как нулевой — будь то из-за арифметики, теряющей его при приведении типов, или из-за шага округления. Применение проверенного приведения типов, которое та же функция уже использует дальше по коду, или ограничение номинала, который может создать один минтинг пары, каждое из этих решений само по себе остановило бы этот эксплойт.

Начните работу с Phalcon Security

Обнаруживайте каждую угрозу, оповещайте о важном и блокируйте атаки.

Попробовать бесплатно

Источники

[1] https://github.com/InjectiveFoundation/injective-core/commit/b994d6b603eb53a38312e547f7bbe3c69d40495b

[2] https://mpost.io/injective-exploited-for-4-9m-via-market-id-collision-in-binary-options-settlement-logic/

[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 миллионов долларов, и защитила криптовалютные активы на миллиарды долларов.

Sign up for the latest updates
За пределами смарт-контракта: операционная безопасность доменов и DNS в Web3
Security Insights

За пределами смарт-контракта: операционная безопасность доменов и DNS в Web3

Аудит контрактов не проверяет сам контракт. Мы провели 800 SEAL-проверок DNS и регистраторов для 100 доменов из TVL Top 100 DefiLlama — лишь один домен прошёл все. Каких четырёх мер защиты не хватает большинству и почему это критично для пользователя.

Поверхности атаки Web3: обзор тестирования на проникновение

Поверхности атаки Web3: обзор тестирования на проникновение

Крипто-организации сохраняют все традиционные уязвимости и добавляют цепочку работы с деньгами. Статья описывает систему через четыре компонента: приложение, авторизацию и подпись, взаимодействие с блокчейном, инфраструктуру — с их зонами атак, а также пять специфичных для web3 областей: от эксплуатации и подписания до вывода средств и контрактов.

Потеряно ~23 млн $: эксплойты Cosmos EVM и Moonwell | BlockSec Weekly
Security Insights

Потеряно ~23 млн $: эксплойты Cosmos EVM и Moonwell | BlockSec Weekly

За период (22.08.2026–30.08.2026): 5 инцидентов, ~$22,7 млн убытков; Tectonic — $74–119,5 млн, откачены на Cronos. Взлом Cosmos EVM (~$5,7 млн) на TAC Chain, Moonwell, Ajna, Rain Card Exploit (Ed25519) на Solana.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit