За прошедшую неделю (2026/06/08 - 2026/06/15) было обнаружено 4 значимых инцидента в сетях Ethereum и Solana, общие потери составили приблизительно $5.98M. В таблице ниже представлены наиболее показательные события:
| Дата | Инцидент | Тип | Предполагаемые потери |
|---|---|---|---|
| 2026/06/08 | Flooring Protocol | Переполнение целочисленного типа | ~$900K |
| 2026/06/09 | Top Token | Атака на управление | ~$1.59M |
| 2026/06/10 | Raydium (на Solana) | Отсутствие валидации входных данных | ~$1.34M |
| 2026/06/15 | Aztec | Отсутствие валидации входных данных | ~$2.15M |
- Aztec: Пробел в валидации между путём доказательства роллапа и путём расчётов на L1 позволил обоим путям обрабатывать разные наборы транзакций, что привело к несогласованным состояниям.
- Raydium: Отсутствующая проверка валидации позволила злоумышленнику манипулировать расчётом погашения LP-токенов, что привело к полному опустошению резервов четырёх пулов.
Лучший аудитор безопасности для Web3
Проверьте дизайн, код и бизнес-логику до запуска
Главное событие недели: Aztec
В этом инциденте верификатор ZK-доказательств и логика расчётов на L1 обрабатывали разные наборы транзакций из-за того, что один параметр остался неограниченным. Эта несогласованность между доказательством и расчётами применима к любой архитектуре роллапа, где эти два пути реализованы как отдельный код.
15 июня 2026 года Aztec Connect, ориентированный на конфиденциальность роллап на Ethereum, был атакован приблизительно на $2.15M [1]. Основная причина — несоответствие между верифицированным набором транзакций роллапа и границей обработки расчётов на L1, что позволило пути ZK-доказательства и логике расчётов обрабатывать разные списки транзакций. Злоумышленник использовал этот пробел, чтобы зачислить необеспеченные балансы депозитов в состоянии роллапа, а затем вывел их через обычные расчётные потоки.
Предыстория
Aztec Connect — это ориентированный на конфиденциальность роллап на Ethereum, обеспечивающий приватные транзакции на L2. Поскольку средства пользователей находятся на L1, их сначала необходимо внести в контракт процессора роллапа, прежде чем они смогут быть представлены в виде нот в дереве Меркла на L2.
Процесс депозита состоит из двух этапов:
Этап 1: Пользователь вызывает depositPendingFunds(), который увеличивает userPendingDeposits[assetId][owner] через increasePendingDepositBalance() и переводит токены в RollupProcessor. Это создаёт ожидающий депозит на L1.
function depositPendingFunds(uint256 _assetId, uint256 _amount, address _owner, bytes32 _proofHash) external {
increasePendingDepositBalance(_assetId, _owner, _amount);
// ... перевод токенов в контракт
}
Этап 2: Пользователь отправляет доказательство депозита, которое впоследствии включается в роллап и добавляется в состояние L2. Когда выполняется processRollup(), функция decodeProof() считывает numTxs из закодированных calldata и возвращает их вместе с декодированными данными доказательства. Затем оба значения передаются в processRollupProof():
function processRollup(bytes calldata, bytes calldata _signatures) external {
(bytes memory proofData, uint256 numTxs, uint256 publicInputsHash) = decodeProof();
processRollupProof(proofData, _signatures, numTxs, publicInputsHash, rollupBeneficiary);
}
Внутри processRollupProof() последовательно вызываются две функции. Сначала verifyProofAndUpdateState() верифицирует ZK-доказательство по всем декодированным транзакциям и обновляет состояние роллапа. Затем processDepositsAndWithdrawals() обрабатывает расчёты на L1, итерируя только первые _numTxs слотов и вызывая decreasePendingDepositBalance() для каждого депозита (этот вызов откатывается, если пользователь фактически не внёс средства на этапе 1, связывая кредит роллапа с реальным переводом на L1):
function processRollupProof(bytes memory _proofData, bytes memory _signatures,
uint256 _numTxs, uint256 _publicInputsHash, address _rollupBeneficiary) internal {
verifyProofAndUpdateState(_proofData, _publicInputsHash); // путь доказательства: все декодированные транзакции
processDepositsAndWithdrawals(_proofData, _numTxs, _signatures); // путь расчётов: только первые _numTxs
}
// внутри processDepositsAndWithdrawals:
end := add(proofDataPtr, mul(_numTxs, TX_PUBLIC_INPUT_LENGTH))
while (proofDataPtr < end) {
// ... для каждого депозита:
decreasePendingDepositBalance(assetId, publicOwner, publicValue);
}
Эта двухэтапная конструкция требует, чтобы логика расчётов на L1 обрабатывала ровно тот же набор транзакций, который верифицировало ZK-доказательство. Если два пути расходятся в том, какие транзакции обрабатывать, депозиты могут быть зачислены в состоянии роллапа без расходования их ожидающих балансов на L1.
Анализ уязвимости
В контракте процессора роллапа (0x7d65...2728) значение numTxs не было эффективно ограничено набором транзакций, закреплённым ZK-доказательством. Путь доказательства и путь расчётов могли поэтому обрабатывать разные списки транзакций.
В офлайн-схеме rollup_circuit значение num_txs загружается как свидетель и ограничивается только по диапазону. Схема использует его для определения того, какие слоты считаются реальными транзакциями, но не проверяет, равно ли num_txs фактическому количеству доказательств без заполнения:
const auto num_txs = uint32_ct(witness_ct(&composer, rollup.num_txs));
field_ct(num_txs).create_range_constraint(MAX_TXS_BIT_LENGTH);
// ...
auto is_real = num_txs > uint32_ct(&composer, i); // определяет логику реальной транзакции для слота
Доказывающий может установить num_txs на любое значение в допустимом диапазоне. Слоты за пределами num_txs всё равно рекурсивно верифицируются, но их публичные входные данные обнуляются, поэтому они не влияют на состояние роллапа:

На стороне Solidity функция decodeProof() считывает numTxs из метаданных calldata, которые не копируются в реконструированный proofData, верифицируемый функцией verifyProofAndUpdateState(). Граница цикла расчётов, таким образом, также не охватывается ZK-доказательством:

Поскольку ни одна из сторон не ограничивает это значение, злоумышленник мог установить numTxs ниже фактического числа декодированных транзакций. Цикл расчётов пропустил бы транзакции, которые доказательство уже зачислило в состоянии роллапа. Неактивная транзакция могла занимать первый декодированный слот (в пределах диапазона сканирования расчётов), тогда как реальный депозит находился бы в более позднем слоте (доказанном схемой, но за пределами диапазона сканирования расчётов). Доказательство зачисляло бы депозит в состоянии роллапа, но логика расчётов полностью пропускала его, включая вызов decreasePendingDepositBalance(). Это оставляло ожидающий баланс депозита неизрасходованным на L1, тогда как состояние роллапа уже отражало этот депозит.
Анализ атаки
Следующий анализ основан на транзакции 0x074ec9...9aeeb1.
Злоумышленник использовал разрыв между путём доказательства и путём расчётов в два этапа.
Этап 1: Создание необеспеченных балансов
-
Шаг 1: Злоумышленник отправил несколько пакетов роллапа, каждый из которых содержал две декодированные транзакции: неактивную (мусорную) транзакцию в слоте 1 и реальный депозит в слоте 2, при этом
numTxsбыло установлено равным 1. Логика расчётов на L1 обработала только мусорную транзакцию в слоте 1, полностью пропустив реальный депозит в слоте 2. -
Шаг 2: ZK-доказательство, однако, верифицировало и зачислило все декодированные транзакции, включая депозит в слоте 2. Поскольку логика расчётов никогда не достигала этого депозита, функция
decreasePendingDepositBalance()не вызывалась, и ожидающий баланс депозита на L1 оставался неизрасходованным. Злоумышленник повторил эту схему для семи различных активов, накопив необеспеченные балансы в состоянии роллапа.
Этап 2: Извлечение средств
- Шаг 3: После создания семи необеспеченных балансов злоумышленник инициировал стандартные выводы для каждого актива. Эти выводы выглядели легитимными для логики расчётов, поскольку балансы существовали в состоянии роллапа, поэтому контракт на L1 высвободил соответствующие средства — приблизительно $2.15M в общей сложности.

Заключение
Данная уязвимость не являлась криптографической слабостью, а представляла собой ошибку согласованности состояний между двумя критическими путями кода в архитектуре роллапа. Основная причина: значение numTxs не было привязано к доказанному набору транзакций ни с одной из сторон. Схема лишь ограничивала его по диапазону, а декодер Solidity считывал его из неверифицированных метаданных calldata. Без этой привязки путь доказательства и путь расчётов могли обрабатывать разные списки транзакций. Злоумышленник установил numTxs ниже фактического числа транзакций, чтобы логика расчётов пропускала депозиты, которые доказательство уже зачислило в состоянии роллапа. Полученные необеспеченные балансы затем были выведены через обычные расчётные потоки.
Роллап Aztec Connect объявил о прекращении работы: обработка транзакций и выводы средств были запланированы к завершению до 31 марта 2024 года [2]. Однако контракт процессора роллапа был всё же обновлён 10 апреля 2024 года через pull request [3], и уязвимая логика присутствует в этом обновлении, выпущенном после объявления о закрытии.
Исправление требует привязки numTxs к полному набору транзакций, верифицированных ZK-доказательством, чтобы оба пути всегда обрабатывали один и тот же набор. Любая конструкция роллапа, разделяющая верификацию доказательств и расчёты на L1, должна обеспечивать, чтобы оба пути работали с идентичным, верифицируемо ограниченным набором транзакций. Расхождение даже в одном параметре может превратить в остальном надёжную систему доказательств в вектор для создания необеспеченных балансов.
Источники
- [1] Оповещение BlockSec Phalcon: Анализ инцидента с Aztec
- [2] Уведомление о закрытии Aztec Connect
- [3] Pull Request обновления RollupProcessorV3 №67
Начните работу с Phalcon Explorer
Исследуйте транзакции, чтобы принимать взвешенные решения
Попробовать бесплатноДругие инциденты этой недели
Raydium
10 июня 2026 года четыре пула в устаревшей программе AMM v3 на Raydium в сети Solana были атакованы приблизительно на $1.34M [1]. Обработчик вывода не проверял, соответствует ли предоставленный вызывающим аккаунт сохранённому аналогу пула, поэтому злоумышленник подставил контролируемый аккаунт для манипуляции расчётом выплаты. Та же техника позволила опустошить все резервы четырёх пулов за считанные секунды.
Предыстория
AMM Raydium — это маркет-мейкер с постоянным произведением на Solana. Каждый пул содержит два хранилища токенов и выпускает LP-токен, представляющий пропорциональную долю резервов. При выводе ликвидности обработчик рассчитывает выплату пропорционально и переводит соответствующую долю из обоих хранилищ:
coin_out = total_coin * withdraw_amount / lp_supply
pc_out = total_pc * withdraw_amount / lp_supply
В Solana каждый тип токена определяется аккаунтом Mint, который хранит общее предложение, количество десятичных знаков и полномочие на выпуск. Баланс каждого держателя хранится в отдельном аккаунте Token, привязанном к этому Mint — один Mint может иметь множество аккаунтов Token у разных держателей. Это отличается от EVM, где единый контракт ERC-20 управляет как определением токена, так и всеми балансами внутри.
В приведённой выше формуле вывода значение lp_supply считывается из аккаунта LP Mint пула — того, который отслеживает общее предложение LP. Корректность расчёта зависит от того, является ли это значение реальным LP Mint. Однако в Solana вызывающий передаёт каждый аккаунт в каждую инструкцию позиционно, поэтому обработчик должен проверять, что каждый предоставленный вызывающим аккаунт соответствует каноническому аккаунту, хранящемуся в состоянии пула.
Анализ уязвимости
Эксплуатируемая программа (27haf8...8vQv) не имела открытого исходного кода, а её исполняемые данные (ProgramData) были закрыты после атаки, что делает прямую инспекцию байткода невозможной. Приведённый ниже анализ основан на байткоде, реконструированном из последнего буфера обновления программы, и сопоставленном с поведением транзакций в сети.
В обработчике вывода аккаунт LP Mint, переданный вызывающим, не был привязан к записанному в пуле значению amm.lp_mint. Следующий реконструированный псевдокод, восстановленный из байткода в сети, показывает расположение аккаунтов. Обработчик проверял привязки для состояния пула, полномочия PDA, обоих хранилищ и пользовательских аккаунтов — но не для LP Mint в слоте 5:
let amm_info = next_account_info(it)?; // accounts[1] — состояние пула (содержит amm.lp_mint)
// ...
let amm_lp_mint_info = next_account_info(it)?; // accounts[5] — mint, предоставленный вызывающим
let amm = AmmInfo::load(amm_info)?;
// проверки привязок authority, хранилищ, open_orders выполняются здесь...
// >>> ОТСУТСТВУЕТ: проверка того, что accounts[5].key == amm.lp_mint <<<
let lp_mint = Mint::unpack(&amm_lp_mint_info.data.borrow())?;
let lp_mint_supply = lp_mint.supply; // считывает из непроверенного mint
let coin_amount = total_coin * withdraw_amount / lp_mint_supply;
let pc_amount = total_pc * withdraw_amount / lp_mint_supply;
Поскольку аккаунт LP Mint не был привязан, злоумышленник мог подставить аккаунт Mint, находящийся под его полным контролем. Установив общее supply равным 1 и сожгя 1 токен, он получал коэффициент выплаты 1 / 1 = 100% каждого резерва.
Уязвимый код был действующим и неизменным с момента последнего обновления программы 3 января 2023 года, то есть приблизительно 1254 дня до эксплойта.
Анализ атаки
Следующий анализ основан на транзакции 1csN6v...3s7s.
- Шаг 1: Злоумышленник создал поддельный аккаунт LP Mint с
decimals = 0и общимsupply = 0.

- Шаг 2: Злоумышленник инициализировал аккаунт Token, привязанный к поддельному LP Mint, затем выпустил ровно 1 токен на него (как полномочие Mint), зафиксировав общее
supplyMint равным 1.

- Шаг 3: Злоумышленник вызвал функцию вывода, передав поддельный LP Mint в ожидаемый слот аккаунта, а аккаунт Token из шага 2 (содержащий 1 поддельный LP-токен) — в качестве источника LP. При
withdraw_amount = 1иlp_supply = 1обработчик вычислилtotal_coin * 1 / 1иtotal_pc * 1 / 1, что равнялось 100% обоих резервов (893 700USDCи 66 837RAYдля пула RAY/USDC).

- Шаг 4: Обработчик сжёг 1 токен злоумышленника и перевёл все резервы из обоих хранилищ пула, полностью опустошив пул RAY/
USDC.

Злоумышленник повторил ту же схему против трёх других пулов примерно за 15 секунд. Общие объёмы выведенных средств по всем четырём пулам составили:
| Пул | Выведено (приблизительно) |
|---|---|
| RAY/USDC | ~66 837 RAY + ~893 700 USDC |
| RAY/wSOL | ~74 720 RAY + ~5 603 wSOL |
| RAY/SRM | ~8 622 RAY + ~10 692 SRM |
| RAY/Sollet ETH | ~5 038 RAY + ~16 Sollet ETH |
Заключение
Основная причина — единственная отсутствующая проверка валидации аккаунта: обработчик вывода использовал supply предоставленного вызывающим аккаунта Mint в качестве делителя предложения LP без привязки его к записанному в пуле значению amm.lp_mint. В Solana каждый предоставленный вызывающим аккаунт должен быть привязан к соответствующему каноническому аккаунту, хранящемуся в состоянии пула. Корректная реализация должна отклонять любой LP Mint, ключ которого не соответствует записи пула, и вычислять погашение на основе внутреннего счётчика LP пула, а не supply внешне предоставленного Mint. Атакованный контракт являлся более старым развёртыванием (последнее обновление — январь 2023 года) и был закрыт в тот же день, что и атака. По словам команды Raydium, полная компенсация будет осуществлена из казначейства Raydium [1].
Источники
О компании BlockSec
BlockSec — поставщик комплексных решений в области безопасности блокчейна и соответствия требованиям в сфере криптовалют. Мы создаём продукты и услуги, которые помогают клиентам проводить аудит кода (включая смарт-контракты, блокчейн и кошельки), перехватывать атаки в режиме реального времени, анализировать инциденты, отслеживать незаконные средства и выполнять требования по ПОД/ФТ на протяжении всего жизненного цикла протоколов и платформ.
BlockSec опубликовал несколько исследовательских работ по безопасности блокчейна на престижных конференциях, сообщил о нескольких атаках нулевого дня на приложения DeFi, заблокировал несколько взломов, спасая средства на сумму более 20 миллионов долларов, и обеспечивает безопасность активов на миллиарды долларов в криптовалюте.
-
Официальный сайт: https://blocksec.com/
-
Официальный аккаунт в Twitter: https://twitter.com/BlockSecTeam



