Back to Blog

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

Code Auditing
February 11, 2026
22 min read

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

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

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

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

TL;DR

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

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

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

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

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

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

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

0x1 Общие сведения

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

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

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

Поскольку каждый LST представляет собой застейканный ETH, который со временем накапливает награды, его обменный курс относительно базового ETH меняется. Чтобы унифицировать учет, пул определяет виртуальный баланс xix_i для каждого актива: баланс в сети × обменный курс. Это нормализует все активы в единицы ETH цепочки маяков (beacon-chain). Сумма всех виртуальных балансов обозначается как σ=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

Состояние пула регулируется инвариантом в стиле Weighted 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 влияют на ранние итерации; хотя теоретически они не имеют значения для конечной сходимости, на практике они влияют на результаты из-за конечного числа итераций и арифметики с фиксированной точностью.

Реализация использует целочисленные операции с фиксированной точностью: деление округляется вниз, а вычитание не защищает от андерфлоу. В нормальных условиях состояния пула промежуточные значения остаются в безопасных пределах. В экстремальных состояниях пула — нет. Раздел 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] в коде), а nn — количество активов. Эта форма алгебраически эквивалентна определению в разделе 0x1.1, где DnD^n распределено в произведении.

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

      π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' — виртуальные балансы до и после депозита.

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

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

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


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

Были использованы две уязвимости с разными ролями и влиянием. Основной первопричиной был дефект вычислений в решателе инвариантов _calc_supply(), который имел два способа отказа: (A) округление вниз могло занулить член произведения, превращая инвариант в модель с постоянной суммой и приводя к избыточному выпуску LP (инфляция предложения); и (B) условие андерфлоу также могло вызвать инфляцию предложения. Только Способ отказа A использовался в Фазе 2 (~$8,1 млн). Способ отказа 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)   # Шаг 1: D[m+1]
r  = unsafe_div(unsafe_mul(r, sp), s)                 # Шаг 2: π обновление (на актив)

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

В нормальных условиях итерация работает правильно: 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: Андерфлоу вызывает инфляцию предложения

На шаге 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() вызывается для пула с нулевым предложением и минимальными суммами, ветвь инициализации вызывает _calc_vb_prod_sum() для вычисления свежих значений, используя уравнение (4) (раздел 0x1.3). При крошечных депозитах vb_sum ничтожно мала (например, 16), но деление на почти нулевые балансы и возведение в высокие степени усиливает произведение до непропорционально большого значения (например, ~9,13e20). Когда s * r превышает l, вычитание дает отрицательный математический результат.

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

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

3. Как эти сбои обеспечивают атаку

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

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

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

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

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

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

Логику можно представить как:

if prev_supply == 0:
    # Путь начальной загрузки — вычисление vb_prod и vb_sum с нуля
    vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
    supply = vb_sum
else:
    # Нормальный путь — использование сохраненного vb_prod, выполнение инкрементальных проверок
    ...

# Вызывается после обеих ветвей, с prev_supply == 0 как флагом
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') — это дроби, значительно меньшие единицы, возведенные в большие степени. Это доводит π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], это составляет ликвидность, принадлежащую протоколу (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 млн.

Цель ребейза

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

Истощение до нуля

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

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

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

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

1. Выпуск через андерфлоу

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

Поскольку prev_supply == 0, код входит в путь начальной загрузки, описанный в разделе 0x2 (Анализ первопричин): он обходит сохраненное состояние и пересчитывает vb_prod и vb_sum с нуля через _calc_vb_prod_sum(), а затем передает их в _calc_supply(). Это вторая уязвимость в действии: атакующий перевел пул обратно в неинициализированное состояние, получив контроль над начальными условиями, подаваемыми в _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, и протокол выпускает всю эту сумму атакующему. Этот андерфлоу возможен только потому, что общее предложение было сведено к нулю в Фазе 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. Эксплойт сработал идентично. Произведение все равно обрушивается до нуля, предложение истощается, а андерфлоу все равно вызывает инфляционный выпуск.

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

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

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

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

======= _calc_supply итерация 0 =======
  l = 4905875511098192451202650000000000000000
  s = 2514373972590845290489        ← начальное предложение
  r = 3538247433646816               ← начальное произведение (очень маленькое)
  d = 4490000000000000000000

  sp = (l - s*r) / d ≈ 1.093e22     ← новое предложение скачет ~4x
  new r ≈ 4.49e22                    ← произведение драматически раздувается

======= _calc_supply итерация 1 =======
  s = 10926206313726454855296        ← из предыдущего sp
  r = 44892226765713223838396        ← из предыдущего внутреннего цикла

  sp = 19113493328251743069          ← ≈ 1.91e19, законно маленькое
  new r = 0                          ← округляется до нуля!

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

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

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

Произведение зануляется через округление при делении, а не через андерфлоу при вычитании. Андерфлоу unsafe_sub, который раздувает предложение, происходит совершенно в другом контексте: в Фазе 3 атаки, когда «пылевая» ликвидность добавляется в пул, который был истощен до нулевого предложения.


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

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

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

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

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

Ссылки

  1. Анонс инцидента Yearn Finance
  2. Пост-мортем безопасности Yearn
  3. Документация yETH
  4. Белая книга yETH: вывод инварианта
  5. Транзакция атаки в проводнике BlockSec
  6. BlockSec: Анализ инцидента с Boosted пулом Balancer (август 2023)

О BlockSec

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

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

Best Security Auditor for Web3

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

BlockSec Audit