Back to Blog

Инцидент #5 Yearn Finance: небезопасная арифметика в решателе инвариантов оправдывает своё название

Code Auditing
11 февраля 2026 г.
23 min read

30 ноября 2025 года взвешенный стабильный пул yETH от Yearn Finance подвергся эксплойту на сумму более $9 миллионов [1]. Основными причинами стали небезопасная арифметика в решателе инварианта _calc_supply() и не отключённый путь начальной загрузки (bootstrap), который позволял повторно входить в логику инициализации. Официальный пост-мортем [2] перечисляет пять пунктов в качестве корневых причин; мы переклассифицируем их как два дефекта (описанные выше уязвимости) и два архитектурных предусловия, которые становились эксплуатируемыми только при наличии этих дефектов. Другие доступные анализы сосредоточены на пошаговых деталях атакующей транзакции. Между высокоуровневыми сводками и детализацией на уровне транзакций остаётся пробел: почему и как атака сработала на самом деле? Этот пост заполняет данный пробел, используя симуляции на Foundry и Python для отслеживания того, как ключевые значения изменяются шаг за шагом и где именно ломаются вычисления.

Данный анализ прежде всего вносит следующие три вклада:

  1. Разбивка потерь по уязвимостям. Две уязвимости не являются взаимозависимыми: только небезопасная арифметика вызвала ~$8,1 млн потерь (90% от общей суммы), в то время как путь начальной загрузки позволил получить дополнительно ~$0,9 млн. Это проясняет, какая уязвимость была основной.
  2. Переклассификация корневых причин. Пять корневых причин из официального отчёта правильнее понимать как два дефекта реализации (объединяющие три из пяти пунктов) плюс два архитектурных предусловия, которые становились эксплуатируемыми только в сочетании с этими дефектами.
  3. Исправление технических заблуждений. Утверждение о том, что «переполнение снизу (underflow) на второй итерации обнуляет член произведения», не подтверждается: наши симуляции показывают, что произведение обнуляется из-за округления при делении, а не из-за underflow, а underflow, приносящий прибыль, происходит совершенно в другой фазе.

Оставшаяся часть данного поста организована следующим образом. Раздел 0x1 даёт справочную информацию о взвешенном стабильном пуле yETH и его решателе инварианта. Раздел 0x2 анализирует две корневые причины и режимы их отказа. Раздел 0x3 подробно прослеживает трёхфазную атаку. Раздел 0x4 исправляет два распространённых заблуждения на основе данных симуляции. Раздел 0x5 подводит итоги с рекомендациями.

Краткое содержание (TL;DR)

Корневые причины: Были эксплуатированы две уязвимости, но с асимметричным воздействием:

  1. Небезопасная арифметика в _calc_supply() (основная, ~$8,1 млн). Функция, которая пересчитывает предложение yETH на основе состояния пула, содержит два арифметических сбоя: округление вниз в unsafe_div() может обнулить внутренний член произведения, а underflow в unsafe_sub() может привести к переполнению промежуточного значения в огромное положительное целое число. Одной этой уязвимости было достаточно, чтобы опустошить взвешенный стейблсвоп-пул yETH.
  2. Не отключённый путь начальной загрузки (bootstrap) (вторичная, ~$0,9 млн). Ветвь инициализации prev_supply == 0 никогда не была окончательно заблокирована после развёртывания. После того как первая уязвимость опустошила предложение до нуля, этот путь стал снова достижим, что позволило получить дополнительную прибыль из пула Curve yETH/WETH.

В рамках уязвимости небезопасной арифметики в фазе 2 использовался только сбой округления вниз (Режим отказа A); сбой underflow (Режим отказа B) взаимозависим с путём начальной загрузки, и вместе они сделали возможной фазу 3.

Атакующий выполнил последовательность из трёх фаз:

  1. Подготовка: Смещение распределения активов пула через повторяющиеся циклы добавления/вывода, создающее крайний дисбаланс виртуальных балансов.
  2. Манипуляция предложением: Эксплуатация округления вниз в _calc_supply() для схлопывания члена произведения до нуля, а затем опустошение общего предложения до нуля через серию операций минта/сжигания. Все LST-активы пула впоследствии были выведены и обменяны на WETH, что привело к ~$8,1 млн убытков.
  3. Извлечение прибыли: Запуск пути начальной загрузки (prev_supply == 0) с помощью депозитов-«пылинок» (dust), эксплуатируя underflow в _calc_supply() для минта ~2,35×10⁵⁶ yETH, которые были использованы для опустошения пула Curve yETH/WETH, что привело к ~$0,9 млн убытков.

Два распространённых заблуждения, которые исправлены:

  • «Инвариант ломается из-за того, что pow_up() и pow_down() округляют по-разному». Мы проверили это, заменив pow_up() на pow_down() в симуляции на Foundry: эксплойт по-прежнему срабатывает. Несоответствие в направлении округления не является корневой причиной.
  • «Underflow на второй итерации приводит к схлопыванию промежуточного члена до нуля». Наши симуляции на Foundry и Python показывают, что на второй итерации underflow не происходит. Фактическое значение составляет ~1,91e19 (а не ~1,94e18, как утверждалось), что является законным результатом корректного вычитания. Произведение обнуляется из-за последующего округления вниз при делении, а не из-за underflow.

0x1 Предыстория

В ходе данного инцидента активы потеряли два пула: взвешенный стейблсвоп-пул yETH (пул Yearn, содержащий LST-активы, ~$8,1 млн потерь) и пул Curve yETH/WETH (стейблсвоп-пул Curve, ~$0,9 млн потерь). Именно во взвешенном стейблсвоп-пуле yETH заключена основная уязвимость. В этом разделе приведена справочная информация, необходимая для понимания уязвимости и эксплойта.

0x1.1 Виртуальные балансы и инвариант

Протокол yETH представляет собой автоматизированный маркет-мейкер (AMM) для токенов ликвидного стейкинга Ethereum (LST) [3]. Затронутый взвешенный стейблсвоп-пул yETH объединяет несколько LST в единый пул: пользователи вносят LST и получают yETH в качестве токенов доли пула.

Поскольку каждый LST представляет застейканный ETH, накапливающий вознаграждения со временем, его обменный курс относительно базового ETH меняется. Для унификации учёта пул определяет виртуальный баланс xix_i для каждого актива: балансе в блокчейне × обменный курс. Это нормализует все активы в единицы ETH бикон-чейна. Сумма всех виртуальных балансов обозначается как σ=xi\sigma = \sum x_i.

Пул содержит 8 активов (индексы 0–7), каждый с определённым весом wiw_i:

Индекс Актив Индекс Актив
0 sfrxETH 4 rETH
1 wstETH 5 apxETH
2 ETHx 6 WOETH
3 cbETH 7 mETH

Состояние пула регулируется взвешенным инвариантом в стиле StableSwap [4]:

Afn  σ+D=Afn  D+Dπ(1)\mathit{Af}^{\,n}\;\sigma + D = \mathit{Af}^{\,n}\;D + D \cdot \pi \tag{1}

где:

  • DD — это масштаб инварианта, который напрямую равен общему предложению yETH данного пула. Когда пул идеально сбалансирован, D=σD = \sigma.
  • π\pi — это взвешенный член произведения, определяемый как π=Dni(wixi)vi\pi = D^n \prod_{i} \left(\frac{w_i}{x_i}\right)^{v_i}, где wiw_i — вес актива i, а vi=winv_i = w_i \cdot n.
  • Af\mathit{Af} — это фактор амплификации, единственный параметр протокола (не A×fA \times f). Afn\mathit{Af}^{\,n} обозначает этот фактор в степени nn, где nn — число активов (8 в данном пуле). Он определяет форму кривой между моделью постоянной суммы (вблизи равновесия) и моделью постоянного произведения (в крайних состояниях).

Ключевое свойство: у DD нет решения в замкнутой форме. Оно должно решаться численно. Именно этот решатель, _calc_supply(), содержит арифметическую уязвимость.

0x1.2 Решатель инварианта

Протокол пересчитывает DD с помощью итерации с неподвижной точкой, ограниченной 256 раундами. Этот алгоритм реализован в коде как _calc_supply() (подробно рассмотрен в разделе 0x2.1). Каждый раунд выполняет три шага:

Шаг 1: Обновление оценки предложения.

Dm+1=AfnσDmπmAfn1(2)D_{m+1} = \frac{\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m}{\mathit{Af}^{\,n} - 1} \tag{2}

Шаг 2: Обновление члена произведения в соответствии с новым предложением.

πm+1=πm(Dm+1Dm)n(3)\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n \tag{3}

Шаг 3: Проверка сходимости.

Если Dm+1Dm<ϵ|D_{m+1} - D_{m}| < \epsilon, вернуть DmD_{m}; в противном случае повторить с шага 1.

Начальные значения D0D_0, π0\pi_0 и σ\sigma влияют на ранние итерации; хотя теоретически они не имеют значения для итоговой сходимости, на практике они влияют на результаты из-за ограниченного числа итераций и арифметики с фиксированной точностью.

Реализация использует целочисленные операции с фиксированной точностью: деление округляется вниз, а вычитание не защищено от underflow. При нормальных условиях пула промежуточные значения остаются в безопасных пределах. При экстремальных состояниях пула это не так. Раздел 0x2.1 подробно анализирует эти режимы отказа.

0x1.3 Три интерфейса и решатель инварианта

Протокол предоставляет три точки входа, которые влияют на состояние пула, обновляя взвешенный член произведения π\pi (хранящийся в коде как vb_prod):

Интерфейс Что он делает Запускает _calc_supply()?
add_liquidity() Вносит активы в произвольных пропорциях Да
update_rates() Обновляет внешние обменные курсы Да
remove_liquidity() Выводит активы пропорционально весам Нет (использует пропорциональное масштабирование)

Эта асимметрия важна: add_liquidity() позволяет вносить депозиты в произвольных пропорциях (что может сильно перекосить пул), тогда как remove_liquidity() всегда выводит активы пропорционально. Поэтому повторяющиеся циклы добавления/вывода могут постепенно приводить пул во всё более несбалансированные состояния.

Механизм обновления курсов

Как обсуждалось выше, виртуальные балансы (xix_i) вычисляются на основе обменных курсов LST. Поэтому важно понимать способ обновления курсов.

В частности, функции add_liquidity() и update_rates() могут обновлять курсы через внутреннюю функцию _update_rates(), тогда как функция remove_liquidity() не выполняет синхронизацию курсов.

  • add_liquidity() вызывает _update_rates() перед выполнением критических операций, чтобы гарантировать синхронизацию обменных курсов активов с актуальным состоянием.
  • update_rates() позволяет вручную обновлять курсы.

Функция _update_rates() проверяет, согласуются ли обменные курсы, зафиксированные в контракте, с внешними курсами. Если обнаружено расхождение, она запускает пересчёт виртуальных балансов и последующее обновление инварианта; в противном случае процесс обновления пропускается.

Как каждый интерфейс обрабатывает π

Исходя из того, как они влияют на инвариант, эти три функции можно разделить на две категории. В частности, add_liquidity() и update_rates() допускают непропорциональные изменения виртуальных балансов, и поэтому требуют итеративного пересчёта предложения DD и произведения π\pi. В отличие от них, remove_liquidity() выводит ликвидность пропорционально и не требует итеративных вычислений.

Базовая формула для вычисления произведения «с нуля» выглядит так:

π=i(Dwixi)nwi(4)\pi = \prod_{i} \left(\frac{D \cdot w_i}{x_i}\right)^{n \cdot w_i} \tag{4}

где DD — предложение, wiw_i — вес актива ii, xix_i — его виртуальный баланс (хранится в коде как vb[i]), а n — число активов. Эта форма алгебраически эквивалентна определению из раздела 0x1.1, где DnD^n распределён внутри произведения.

  1. add_liquidity() имеет два пути (код показан в разделе 0x2.2):
  • Путь начальной загрузки (bootstrap) (когда prev_supply == 0): Вычисляет vb_prod с нуля с использованием уравнения (4). Тот факт, что этот путь остаётся доступным после развёртывания, является уязвимостью управления состоянием, обсуждаемой в разделе 0x2.2.
  • Обычный путь (когда prev_supply > 0): Процесс вычисления разделён на два шага:
    • a) Использует инкрементальное обновление на основе отношения старых и новых виртуальных балансов:

      πestimated=πi=0n1(xixi)win(5)\pi_{\text{estimated}} = \pi \cdot \prod_{i=0}^{n-1} \left(\frac{x_i}{x_i'}\right)^{w_i \cdot n} \tag{5}

      где xix_i и xix_i' — виртуальные балансы до и после депозита.

    • b) Итеративно уточняет точное значение, вызывая _calc_supply() с этой оценкой в качестве входных данных, пересчитывая инвариант DD и точное значение π\pi.

  1. update_rates() запускается при изменении обменных курсов, что приводит к обновлению виртуальных балансов соответствующих активов. Последующий поток вычислений следует обычному пути add_liquidity(), т. е. инвариант пересчитывается итеративно. Кроме того, на основе вновь вычисленного предложения контракт минтит или сжигает yETH, чтобы обеспечить соответствие предложения ликвидности обновлённому состоянию виртуальных балансов.

  2. remove_liquidity() всегда вычисляет vb_prod с нуля с использованием уравнения (4), предварительно пропорционально уменьшив каждый виртуальный баланс.


0x2 Анализ корневых причин

Были эксплуатированы две уязвимости с разными ролями и степенью влияния. Основной корневой причиной был вычислительный изъян в решателе инварианта _calc_supply(), у которого было два режима отказа: (A) округление вниз могло обнулить член произведения, вырождая инвариант в модель постоянной суммы и приводя к избыточному минту LP-токенов (инфляция предложения); и (B) условие underflow также могло приводить к инфляции предложения. В фазе 2 (~$8,1 млн) использовался только Режим отказа A. Режим отказа B зависел от вторичной уязвимости.

Вторичной корневой причиной был дефект управления состоянием: ветвь инициализации пула оставалась достижимой. После того как фаза 2 привела предложение к нулю, Режим отказа B в сочетании с путём начальной загрузки позволил получить дополнительно ~$0,9 млн убытков (фаза 3).

0x2.1 Небезопасная арифметика в _calc_supply() (основная причина)

На рисунке 2 показано соответствие реализации _calc_supply() математической процедуре из раздела 0x1.2, с указанием двух мест возникновения арифметических сбоев, анализируемых ниже:

Переменные кода соответствуют математическим терминам следующим образом:

Переменная кода Математическая роль
s Текущая оценка предложения DmD_m
r Член произведения πm\pi_m
sp Следующая оценка предложения Dm+1D_{m+1}
l Константа числителя: Afnσ\mathit{Af}^{\,n} \cdot \sigma
d Константа знаменателя: Afn1\mathit{Af}^{\,n} - 1

Критические выражения:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # Step 1: D[m+1]
r  = unsafe_div(unsafe_mul(r, sp), s)                 # Step 2: π update (per asset)

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

При нормальных условиях итерация ведёт себя корректно: l - s * r — умеренно положительное значение, и итерация сходится за несколько раундов.

1. Режим отказа A: округление вниз обнуляет произведение

На шаге 2 произведение обновляется для каждого актива следующим образом:

r = unsafe_div(unsafe_mul(r, sp), s)   # r = r * sp / s

Поскольку unsafe_div() выполняет целочисленное деление, оно всегда округляет вниз. Когда пул сильно несбалансирован и sp намного меньше s (что происходит после манипулированного крупного депозита), числитель r * sp может стать меньше знаменателя s. Целочисленное деление тогда даёт r = 0.

Как только r становится нулевым, оно остаётся нулевым на всех последующих итерациях. Член произведения π\pi навсегда схлопывается.

Распространённое ошибочное объяснение приписывает этот сбой несоответствию округления между pow_up() и pow_down(). В разделе 0x4 представлены доказательства того, что это неверно.

2. Режим отказа B: underflow приводит к инфляции предложения

На шаге 1 новая оценка предложения вычисляется как:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # sp = (l - s*r) / d

Вычитание l - s*r соответствует AfnσDmπm\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m в уравнении 2. При нормальных условиях это значение положительно. Однако когда пул достигает вырожденного состояния с нулевым предложением, ветвь инициализации в add_liquidity() (подробно описанная в разделе 0x2.2) пересчитывает член произведения с нуля, и относительные величины могут поменяться местами.

В частности, когда add_liquidity() вызывается для пула с нулевым предложением с депозитами-«пылинками» (dust), ветвь инициализации вызывает _calc_vb_prod_sum() для вычисления новых значений с использованием уравнения (4) (раздел 0x1.3). При крошечных депозитах vb_sum минимален (например, 16), но деление на почти нулевые балансы и возведение в высокие степени усиливает произведение до непропорционально большого значения (например, ~9,13e20). Когда s * r превышает l, вычитание даёт отрицательный математический результат.

Поскольку unsafe_sub() выполняет вычитание в непроверяемой арифметике uint256, отрицательный результат переполняется в огромное положительное целое число (близкое к 22562^{256}). Это переполнённое значение распространяется через деление и последующие итерации, порождая абсурдно большую оценку предложения, которую протокол затем минтит в виде реальных токенов yETH.

Распространённое утверждение гласит, что такой underflow происходит на второй итерации определённого шага манипуляции предложением. В разделе 0x4 показано, что это утверждение неверно: фактический underflow, который приводит к инфляции предложения, происходит в совершенно другом контексте (фаза 3 атаки).

3. Как эти сбои сделали возможной атаку

Эти два режима отказа действуют в разных фазах эксплойта, с разным вкладом в прибыль:

  • Режим отказа A (фаза 2, ~$8,1 млн): Когда атакующий вносит депозит в сильно несбалансированный пул, член произведения обнуляется, из-за чего _calc_supply() возвращает завышенное предложение. Протокол минтит атакующему избыточный yETH. Этот режим отказа сам по себе, без какого-либо участия пути начальной загрузки, позволил атакующему опустошить взвешенный стейблсвоп-пул yETH от его LST-активов.

  • Режим отказа B (фаза 3, ~$0,9 млн): После того как предложение опустошено до нуля, путь начальной загрузки пересчитывает большой член произведения из депозитов-«пылинок», вызывая underflow при вычитании. Протокол минтит астрономически большое количество yETH, которое атакующий использует для опустошения отдельного пула Curve yETH/WETH.

Зависимость однонаправленная: Режим отказа A эксплуатируется независимо и вызвал 90% убытков, тогда как Режим отказа B требует, чтобы Режим отказа A сначала привёл предложение к нулю.

0x2.2 Не отключённый путь начальной загрузки (вторичная причина)

Функция add_liquidity() содержит ветвь для первоначального депозита в пул:

Логику можно абстрактно представить следующим образом:

if prev_supply == 0:
    # Bootstrap path — compute vb_prod and vb_sum from scratch
    vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
    supply = vb_sum
else:
    # Normal path — use stored vb_prod, perform incremental checks
    ...

# Called after both branches, with prev_supply == 0 as a flag
supply, vb_prod = _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, prev_supply == 0)

Когда prev_supply == 0, функция обходит сохранённое состояние и пересчитывает vb_prod и vb_sum с нуля через _calc_vb_prod_sum(), используя уравнение (4) (раздел 0x1.3). Эта ветвь начальной загрузки была задумана для одноразового использования при инициализации пула, но так и не была окончательно заблокирована после первого депозита.

Если общее предложение может быть доведено до нуля (посредством любой комбинации сжиганий и выводов), эта ветвь снова становится достижимой. Атакующий, повторно входящий в этот путь, контролирует начальные условия, передаваемые в _calc_supply(), потенциально вызывая описанные выше арифметические сбои при параметрах, которые никогда бы не возникли при нормальной работе пула.

Это известный паттерн уязвимости. В августе 2023 года инцидент с Balancer V2 аналогичным образом зависел от доведения предложения до нуля для сброса внутренних курсов, что позволило атакующему повторно войти в логику инициализации с искусственно выгодными параметрами [6]. Может ли развёрнутый пул быть возвращён в своё начальное состояние, и какие инварианты выполняются при этом — вопрос, который разработчики протокола должны явно решить.


0x3 Анализ атаки

Эксплойт разворачивается в рамках согласованной последовательности атакующей транзакции [5], организованной в три фазы. Каждая фаза строится на состоянии, установленном предыдущей.

0x3.1 Фаза 1: Смещение пула (подготовка)

Цель: Создать крайний дисбаланс виртуальных балансов между активами.

На рисунке ниже показана трассировка транзакций для этой фазы (шаг флеш-займа опущен из-за ограничений по месту):

Сначала атакующий занимает крупные суммы LST-активов через флеш-займы от Balancer и Aave, а именно 5500e18 wstETH, 3100e18 WETH, 1800e18 rETH, 2000e18 ETHx и 200e18 cbETH.

Далее атакующий обменивает примерно 800e18 WETH на около 416e18 yETH в пуле Curve yETH/WETH, а затем использует полученный yETH для вывода ликвидности из пула.

Основная манипуляция использует асимметрию интерфейсов, описанную в разделе 0x1 (Предыстория): add_liquidity() допускает депозиты в произвольных пропорциях, тогда как remove_liquidity() выводит активы пропорционально весам пула (выделено красным прямоугольником на рисунке выше). Повторяя циклы добавления → вывода, внося только выбранные активы, но выводя все активы пропорционально, атакующий постепенно приводит пул в сильно несбалансированное состояние:

Актив Вес До После Изменение
0 (sfrxETH) 20% 628 097 482 908 289 585 170 684 908 495 923 316 419 717 +9,04%
1 (wstETH) 20% 376 569 216 105 249 117 091 684 906 088 027 654 432 883 +81,88%
2 (ETHx) 10% 187 473 530 249 048 974 586 410 441 661 092 336 995 160 +118,93%
3 (cbETH) 10% 267 387 722 745 796 900 349 3 532 430 695 689 175 233 -98,68%
4 (rETH) 10% 201 828 029 369 446 137 136 410 441 659 865 060 509 563 +103,36%
5 (apxETH) 25% 753 792 636 209 697 936 333 549 134 446 963 315 842 411 -27,15%
6 (WOETH) 2,5% 49 640 000 870 620 479 267 655 788 758 768 556 847 -98,68%
7 (mETH) 2,5% 47 667 894 211 903 277 629 629 735 467 970 876 930 -98,68%

Активы 3 (cbETH), 6 (WOETH) и 7 (mETH) истощены более чем на 98%. Этот дисбаланс сам по себе не приносит прибыли напрямую. Он создаёт числовые предпосылки для следующей фазы.

0x3.2 Фаза 2: Схлопывание предложения до нуля (~$8,1 млн)

Цель: Довести произведение инварианта до нуля, а затем опустошить предложение yETH до нуля. Эта фаза эксплуатирует только основную уязвимость (небезопасную арифметику) и вызвала ~90% общих убытков.

Эта фаза использует повторяющийся пятишаговый цикл, выполняемый три раза:

  1. Испортить произведение через add_liquidity();
  2. Установить предпосылку для коррекции через add_liquidity();
  3. Сбросить произведение через remove_liquidity() с 0 yETH;
  4. Скорректировать предложение через update_rates();
  5. Вывести активы через remove_liquidity().

На рисунке ниже показана трассировка транзакций, где чётко видны три повторения пятишагового цикла:

1. Порча произведения через add_liquidity()

Атакующий вносит крупные суммы активов с высоким весом (индексы 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH), каждый примерно в три раза превышающий его текущий виртуальный баланс.

add_liquidity() оценивает новый член произведения с помощью инкрементального обновления по уравнению (5) (раздел 0x1.3). Поскольку xixix_i' \gg x_i для активов с высоким весом, отношения (xi/xi)(x_i / x_i') представляют собой дроби значительно ниже 1, возведённые в высокие степени. Это снижает πnew\pi_{\text{new}} с ~42e18 до ~0,00353e18 — почти нулевую оценку произведения.

Это крошечное произведение поступает в _calc_supply(). В ходе итерации обновление произведения r = r * sp / s сталкивается с условием округления вниз, описанным в разделе 0x2 (Анализ корневых причин): числитель падает ниже знаменателя, и целочисленное деление округляет r до нуля. Функция возвращает нулевое произведение и завышенное предложение (~vb_sum), из-за чего протокол избыточно минтит yETH.

2. Установление предпосылки для коррекции через add_liquidity()

Атакующий добавляет одностороннюю ликвидность для актива с индексом 3 (cbETH, истощённый актив с низким весом), внося ~6,5x от текущего баланса этого актива в пуле. За это он получает лишь немного yETH-токенов, но это достаточно ребалансирует пул, чтобы следующая итерация не колебалась хаотично.

Без этого шага, даже после сброса произведения к ненулевому значению на шаге 3, итерация на шаге 4 всё равно давала бы нулевое произведение из-за резких колебаний, вызванных экстремальным дисбалансом. Наша симуляция на Foundry подтверждает это: пропуск шага 2 приводит к сбою коррекции на шаге 4.

3. Сброс произведения через remove_liquidity() с 0 yETH

Атакующий вызывает remove_liquidity() с суммой 0. Токены не выводятся, но функция пересчитывает vb_prod на основе текущего состояния пула, используя уравнение (4) (раздел 0x1.3). Поскольку виртуальные балансы ненулевые, это даёт ненулевое произведение (~9,09e19), перезаписывающее испорченное нулевое значение.

4. Коррекция предложения через update_rates()

Атакующий вызывает update_rates() для актива с индексом 6 (WOETH) или 7 (mETH). Если обменный курс изменился с момента последнего обновления, функция запускает _calc_supply() с восстановленным (ненулевым) произведением. На этот раз итерация сходится корректно и даёт значение предложения, значительно меньшее текущего завышенного значения. Разница сжигается из контракта стейкинга yETH. Согласно официальному пост-мортему [2], это составляет ликвидность, принадлежащую протоколу (Protocol-Owned Liquidity, POL), то есть сжигания уменьшают позицию протокола, а не активы атакующего. Эта асимметрия критически важна: каждый цикл уменьшает общее предложение, в то время как баланс yETH атакующего остаётся неизменным.

Само расхождение курса не является источником прибыли; оно служит исключительно механизмом запуска. Из трёх интерфейсов пула только add_liquidity() и update_rates() вызывают _calc_supply(); remove_liquidity() использует пропорциональное масштабирование и не вызывает её. После того как шаг 3 восстанавливает ненулевое произведение, атакующему нужно запустить _calc_supply() без внесения дополнительных активов. Вызов update_rates() с устаревшим курсом делает именно это: изменение курса запускает пересчёт предложения без каких-либо затрат для атакующего.

Это объясняет тонкий аспект атаки: во время фазы подготовки (фаза 1) атакующий намеренно избегал добавления ликвидности для WOETH и mETH. Если бы курсы этих активов были обновлены во время add_liquidity(), расхождения курсов бы не существовало, и update_rates() на этом шаге не запустила бы _calc_supply().

5. Вывод активов через remove_liquidity()

В конце каждого цикла атакующий выводит активы через remove_liquidity().

Как извлекается прибыль

Механизм получения прибыли работает следующим образом: на шаге 1 атакующий вносит LST-активы и получает избыточно минченный yETH (из-за испорченного произведения). На шаге 4, когда предложение корректируется, избыточный yETH сжигается из POL (контракта стейкинга), а не у атакующего. На шаге 5 атакующий выводит LST-активы пропорционально своим yETH-балансам. Поскольку POL поглотил сжигание, а баланс yETH атакующего остался неизменным, атакующий в итоге выводит больше LST-активов, чем внёс. Эта разница, извлечённая за три цикла, составляет в общей сложности ~$8,1 млн.

Назначение rebase

Трассировка (между первым и вторым циклом) также показывает вызов OETHVaultProxy.rebase(), который запускает ребейс OETH: баланс OETH, удерживаемый контрактом WOETH, увеличивается, повышая эффективный обменный курс WOETH. Именно это «сохранённое» расхождение курса делает возможным шаг 4 второго цикла снова: когда в конечном итоге вызывается update_rates(), он обнаруживает расхождение и запускает _calc_supply().

Опустошение до нуля

После трёхкратного повторения этого пятишагового цикла атакующий снизил общее предложение пула ниже суммы удерживаемого им yETH. Финальный вызов remove_liquidity() с оставшимся предложением опустошает его до НУЛЯ.

Теперь пул содержит нулевое предложение, нулевое произведение и нулевой vb_sum. Это вырожденное состояние нарушает неявное проектное предположение о том, что пул с предыдущими депозитами никогда не вернётся в своё неинициализированное состояние.

0x3.3 Фаза 3: Эксплуатация нулевого предложения ради дополнительной прибыли (~$0,9 млн)

Цель: Минтить огромное количество yETH из вырожденного состояния пула, а затем обменять его на реальные активы. Эта фаза эксплуатирует взаимозависимую комбинацию вторичной уязвимости (не отключённого пути начальной загрузки) и Режима отказа B (underflow), в совокупности внёсших вклад ~10% в общие убытки.

1. Минт через underflow

Когда общее предложение равно нулю, атакующий вызывает add_liquidity() с суммами-«пылинками» (баланс [1, 1, 1, 1, 1, 1, 1, 9]).

Поскольку prev_supply == 0, код входит в путь начальной загрузки, описанный в разделе 0x2 (Анализ корневых причин): он обходит сохранённое состояние и пересчитывает vb_prod и vb_sum с нуля через _calc_vb_prod_sum(), затем передаёт их в _calc_supply(). Это и есть вторая уязвимость в действии: атакующий вернул пул в его неинициализированное состояние, получив контроль над начальными условиями, передаваемыми решателю.

При всех виртуальных балансах на уровне «пылинок» (обменные курсы близки к 1e18) вычисленные значения таковы:

  • vb_sum = 16
  • vb_prod ≈ 9,13e20
  • _supply = vb_sum = 16

Внутри _calc_supply() переменные инициализируются так:

  • l = _amplification * _vb_sum ≈ 4,5e20 × 16 ≈ 7,2e21
  • d = _amplification - PRECISION4,49e20
  • s = _supply = 16
  • r = _vb_prod9,13e20

Теперь вычитание l - s * r:

7.2×102116×9.13×1020=7.2×10211.46×10227.4×10217.2 \times 10^{21} - 16 \times 9.13 \times 10^{20} = 7.2 \times 10^{21} - 1.46 \times 10^{22} \approx -7.4 \times 10^{21}

Это отрицательное значение. В непроверяемой арифметике uint256 unsafe_sub переполняет его до приблизительно 22567.4×10212^{256} - 7.4 \times 10^{21} — астрономически большого значения. После деления на d (~4,49e20) результирующая оценка предложения составляет ~2,35e56, и протокол минтит это огромное количество атакующему. Этот underflow возможен только потому, что общее предложение было доведено до нуля в фазе 2; в любом невырожденном состоянии пула выполняется l > s * r, и вычитание безопасно.

2. Обмен на реальные активы

Атакующий обменивает часть избыточно минченного yETH на ~1097e18 WETH в пуле Curve yETH–WETH, опустошая его резервы WETH. С учётом 800e18 WETH, потраченных в фазе 1, чистая прибыль составила ~$0,9 млн.

В сочетании с ~$8,1 млн LST-активов, извлечёнными в ходе фазы 2, атакующий получил чистую прибыль примерно в $9 миллионов после погашения флеш-займов.

Подробный анализ движения средств, включая источник средств и адреса назначения, рассмотрен в других опубликованных анализах (например, [2]) и выходит за рамки данной статьи.


0x4 Исправление заблуждений

Большинство опубликованных анализов данного инцидента сосредоточены на арифметических симптомах, не полностью объясняя, как атакующий создаёт предпосылки. Два конкретных утверждения заслуживают исправления.

0x4.1 Утверждение: «Несоответствие округления между pow_up() и pow_down() портит инвариант»

Распространённая интерпретация приписывает корневую причину использованию pow_up() в одних участках кода и pow_down() в других, утверждая, что несоответствие направлений округления вносит эксплуатируемые несогласованности.

Мы протестировали это напрямую: мы модифицировали контракт, чтобы он единообразно использовал pow_down() (заменив все вызовы pow_up()), и повторно запустили полную симуляцию атаки в Foundry. Эксплойт сработал идентично. Произведение по-прежнему схлопывается до нуля, предложение по-прежнему опустошается, а underflow по-прежнему приводит к завышенному минту.

Округление, которое делает возможным состояние нулевого произведения, — это округление вниз при делении в r = unsafe_div(unsafe_mul(r, sp), s) внутри цикла итерации, а не направление округления в степенных функциях, используемых для оценки начальных значений произведения.

0x4.2 Утверждение: «Underflow на второй итерации обнуляет промежуточный член»

Широко цитируемое объяснение гласит, что во время второй итерации _calc_supply() underflow в unsafe_sub даёт sp ≈ 1,94e18, что затем приводит к округлению r до нуля.

Мы воспроизвели точные промежуточные значения, используя как Foundry (воспроизведение в блокчейне), так и Python (математическую верификацию). Симуляция на Foundry прослеживает _calc_supply() итерация за итерацией:

======= _calc_supply iteration 0 =======
  l = 4905875511098192451202650000000000000000
  s = 2514373972590845290489        ← initial supply
  r = 3538247433646816               ← initial product (very small)
  d = 4490000000000000000000

  sp = (l - s*r) / d ≈ 1.093e22     ← new supply jumps ~4x
  new r ≈ 4.49e22                    ← product inflates dramatically

======= _calc_supply iteration 1 =======
  s = 10926206313726454855296        ← from previous sp
  r = 44892226765713223838396        ← from previous inner loop

  sp = 19113493328251743069          ← ≈ 1.91e19, legitimately small
  new r = 0                          ← rounds to zero!

Критическое наблюдение: на итерации 1 sp вычисляется как ~1,91e19. Это законное малое положительное значение, а не артефакт underflow. Вычитание l - s*r даёт малый положительный результат, потому что взвешенная амплификацией сумма l и член предложения-произведения s*r близки по величине на этой итерации.

Что обнуляет произведение — это то, что происходит дальше: внутренний цикл вычисляет r = r * sp / s, где sp (~1,91e19) намного меньше s (~1,09e22). Числитель r * sp падает ниже знаменателя s, и целочисленное деление округляет результат до нуля.

Мы независимо проверили это в Python, вычислив те же значения с использованием целых чисел произвольной точности и подтвердив, что вычитание не приводит к underflow:

Произведение обнуляется через округление при делении, а не через underflow при вычитании. Underflow в unsafe_sub, приводящий к инфляции предложения, происходит в совершенно другом контексте: в фазе 3 атаки, когда депозиты-«пылинки» добавляются в пул, опустошённый до нулевого предложения.


0x5 Заключение

Эксплойт yETH включал две уязвимости с асимметричным воздействием. Небезопасная арифметика в _calc_supply() была основной корневой причиной: её сбой округления вниз (Режим отказа A) независимо позволил получить ~$8,1 млн убытков только через фазу 2. Не отключённый путь начальной загрузки был вторичной уязвимостью; в сочетании со сбоем underflow (Режим отказа B) он позволил получить дополнительно ~$0,9 млн в фазе 3, но только после того, как фаза 2 уже опустошила предложение до нуля. Эта разбивка убытков отличает данный анализ от других опубликованных отчётов, которые не разделяют прибыль фазы 2 и фазы 3.

Официальный пост-мортем [2] выделяет пять корневых причин. Мы переклассифицируем их как два дефекта (небезопасная арифметика, объединяющая официальные пункты №1 и №5; не отключённый путь начальной загрузки как №4) и два архитектурных предусловия (№2 — асимметричная обработка Π; №3 — состояние нулевого предложения, обусловленное POL). Различие заключается в следующем: дефекты — это ошибки реализации, нарушающие проектный замысел (решатель не должен производить нулевые произведения или underflow), тогда как предусловия — это проектные решения, которые функционируют как задумано, но создают эксплуатируемую поверхность атаки в сочетании с дефектами.

Рекомендации

  • Проверяемая арифметика в решателях инвариантов. Используйте safe_div и safe_sub с явным откатом (revert) при underflow/overflow, даже ценой эффективности по газу. Решатель выполняет не более 256 итераций, и накладные расходы по газу незначительны по сравнению с риском для безопасности.
  • Проверки границ промежуточных значений. Проверяйте, что член произведения остаётся в разумном диапазоне между итерациями. Произведение, падающее до нуля, или оценка предложения, увеличивающаяся на порядки между итерациями, сигнализирует о вырожденном состоянии.
  • Ограничения на дисбаланс. Установите максимально допустимое отклонение между виртуальным балансом любого актива и его целевым балансом, пропорциональным весу. Это предотвратило бы создание предпосылок в фазе 1.
  • Проверки монотонности инварианта. После возврата значения из _calc_supply() проверяйте, что новое предложение согласуется с направлением изменения (добавление ликвидности никогда не должно уменьшать предложение, обновления курсов не должны приводить к 10-кратным изменениям и т. д.).
  • Постоянное отключение путей инициализации. После первого депозита в пул заблокируйте ветвь начальной загрузки prev_supply == 0, чтобы в неё нельзя было повторно войти. Это полностью предотвратило бы фазу 3.
  • Предотвращение состояний с нулевым предложением. Убедитесь, что сжигания на уровне протокола (из POL или контрактов стейкинга) не могут снизить общее предложение до нуля, пока пул удерживает ненулевые балансы. Минимальный порог предложения блокировал бы переход в вырожденное состояние, обеспечивающее повторный вход в bootstrap.
  • Обнаружение аномалий в реальном времени. Отслеживайте аномальные переходы состояния (такие как падение члена произведения до нуля, изменение предложения на порядки величины или повторяющиеся циклы добавления/вывода за короткие промежутки времени) и запускайте оповещения или автоматические предохранители до накопления убытков.

Список источников

  1. Объявление об инциденте Yearn Finance
  2. Пост-мортем Yearn Security
  3. Документация yETH
  4. Whitepaper yETH: вывод инварианта
  5. Атакующая транзакция на Phalcon Explorer
  6. BlockSec: Анализ инцидента с бустированным пулом Balancer (август 2023)

О BlockSec

BlockSec — это универсальный поставщик услуг в области блокчейн-безопасности и криптовалютного комплаенса. Мы создаём продукты и услуги, помогающие клиентам проводить аудит кода (включая смарт-контракты, блокчейн и кошельки), перехватывать атаки в реальном времени, анализировать инциденты, отслеживать незаконные средства и выполнять обязательства по AML/CFT на протяжении всего жизненного цикла протоколов и платформ.

BlockSec опубликовала множество работ по блокчейн-безопасности на престижных конференциях, сообщила о нескольких атаках нулевого дня на DeFi-приложения, заблокировала несколько взломов, спасая более 20 миллионов долларов, и обеспечила безопасность криптовалют на миллиарды долларов.

Sign up for the latest updates
Потеряно ~9,4 млн $: эксплойты Injective и Aquifer | BlockSec Weekly
Security Insights

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

За неделю (31.08–06.09.2026) четыре инцидента безопасности принесли убытки ~$9.4М в Injective, Solana, Ethereum и Flow EVM: Injective (~$4.8М), Aquifer (~$2.47М), Notional Finance V1 (~$1.73М), Ankr FLOW (~$410K).

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

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

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

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

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

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

Best Security Auditor for Web3

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

BlockSec Audit
Инцидент #5 Yearn Finance: небезопасная арифметика в решателе инвариантов оправдывает своё название