Back to Blog

Очередная трагедия потери точности: глубокий анализ инцидента с KyberSwap

Code Auditing
December 5, 2023
10 min read

23 ноября 2023 года мы зафиксировали серию атак на KyberSwap. Эти атаки привели к совокупным убыткам на сумму более 48 млн долларов США. Наш первоначальный анализ указывал на то, что эксплойт был связан с манипуляцией тиками (tick manipulation) и двойным учетом ликвидности. Однако из-за ограничений по объему мы не смогли углубиться в подробности в той публикации. Несмотря на последующие глубокие анализы других исследователей безопасности, первопричина проблемы — потеря точности (precision loss) — оставалась нераскрытой.

Интригующе, но ситуация обострилась спустя несколько дней. 30 ноября 2023 года, после нескольких раундов дискуссий с официальными лицами, атакующий отправил сообщение, которое для внешнего наблюдателя выглядело провокационным и требовало полного контроля. Не беря это в расчет, атакующий также раскрыл важную информацию: проблема действительно связана с потерей точности, что показано на рисунке ниже. Это откровение подтверждает доказательства нашего расследования. Поэтому наша цель — представить в этом отчете всесторонний анализ.

Ключевые выводы (TL;DR)

  • Наше расследование показывает, что фундаментальная проблема кроется в неправильном направлении округления во время процесса реинвестирования в KyberSwap. Это впоследствии приводит к некорректному расчету тиков и, в конечном итоге, к двойному учету ликвидности.

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

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

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

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

KyberSwap[1] — это децентрализованная платформа автоматического маркет-мейкера (CLAMM). Чтобы удовлетворить рыночный спрос на концентрированную ликвидность, был запущен KyberSwap Elastic[3], основанный на Uniswap V3[2], с несколькими улучшениями, включая кривую реинвестирования (reinvestment curve), обеспечивающую автоматический сложный процент для доходности от предоставления ликвидности.

0x1.1 Тик и цена квадратного корня

Тик (Tick) в CLAMM, подобных Uniswap V3, используется для дискретного обозначения цены, чтобы поставщики ликвидности могли предоставлять ликвидность в фиксированном диапазоне вместо всего диапазона (отсюда термин "концентрированная")[4].

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

Согласно [5], ликвидность может быть помещена в диапазон между любыми двумя тиками (которые не обязательно должны быть соседними), т.е. парой индексов тиков (нижний тик и верхний тик). В частности, цена каждого тика (с целочисленным индексом i) определяется следующим образом:

На практике используется цена квадратного корня (обозначается как sqrtP или sqrtPrice):

Также возможно вычислить текущий тик на основе текущей цены квадратного корня:

Использование цены квадратного корня вместе с ликвидностью L — это практический способ избежать одновременных изменений. В частности, цена меняется при обмене внутри тика; ликвидность меняется при пересечении тика, или при минтинге (создании) или сжигании ликвидности. Для более подробного объяснения, пожалуйста, обратитесь к whitepaper Uniswap V3[5].

Очевидно, что хотя для данного тика рассчитывается только одна цена квадратного корня, несколько цен квадратного корня могут указывать на один и тот же тик.

0x1.2 Кривая реинвестирования

CLAMM на базе Uniswap V3 страдает от низкой эффективности использования пула для комиссий LP и значительных затрат на газ, необходимых для реинвестирования. Поэтому KyberSwap принял кривую реинвестирования[6] для решения этой проблемы:

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

Ключ к кривой реинвестирования заключается в том, что комиссии, собранные в каждом обмене, накапливаются как дополнительная ликвидность в пуле в качестве реинвестиционной ликвидности (reinvestment liquidity) в бесконечном диапазоне. Реинвестиционные токены минтятся для LP, и накопленная реинвестиционная ликвидность распределяется между LP соответствующим образом. Кроме того, реинвестиционная ликвидность также участвует в процессе обмена и расчета цены.

Точнее, вместо формулы постоянного произведения:

комиссии накапливаются в ΔL при каждом обмене:

Расчет ΔL можно упростить (при условии, что отклонение цены меньше порогового значения):

Затем сумма обмена и конечная цена могут быть выведены из модифицированной формулы постоянного произведения:

Соответствующий код для расчетов представлен в функции computeSwapStep в следующем фрагменте кода пула.

Следует заметить, что из-за реинвестиционной ликвидности, liquidity в этой функции является суммой двух компонентов: baseL для базовой ликвидности и reinvestL для накопленной ликвидности для реинвестирования.

0x1.3 Обмен в KyberSwap

Поток управления обменом в Uniswap V3 может быть представлен следующим образом[5]:

Соответственно, реализацию функции swap рассмотренного выше пула KyberSwap можно абстрагировать в виде следующей диаграммы:

Ключевая логика, относящаяся к расчету тиков, находится внутри цикла обмена, как выделено синим прямоугольником. В частности, основная логика включает функцию computeSwapStep и функцию _updateLiquidityAndCrossTick. Первая вычисляет ключевые состояния, такие как суммы на входе и выходе для заданного обмена и nextSqrtP, в то время как последняя обрабатывает случаи, когда происходит пересечение тика (cross-tick).

Традиционно, когда цена растет, мы называем это смещением тика вправо/вверх; в противном случае мы говорим, что тик движется влево/вниз.

Чтобы лучше понять уязвимость, которая будет обсуждаться позже, необходимо изучить соответствующую логику кода функции computeSwapStep, как показано на рисунке ниже:

Во-первых, в строках с 50 по 57 вызывается функция calcReachAmount для расчета количества входного токена, необходимого для достижения targetSqrtP (следующего тика или указанной пользователем целевой цены).

Далее, между строками 59 и 62 проводится проверка, следует ли пересекать тик или нет.

В частности, если использованная сумма (usedAmount) больше, чем указанная пользователем сумма (specifiedAmount) при точном входном обмене (случай, использованный в атаке), это означает, что тик не должен быть пересечен, и nextSqrtP должен быть выведен из добавочной ликвидности (deltaL, т.е. дельта-ликвидность).

  • Впоследствии, между строками 70 и 79, ΔL (deltaL) выводится из суммы на входе, текущей ликвидности и цены с использованием функции estimateIncrementalLiquidity. Наконец, конечная цена после обмена nextSqrtP рассчитывается на основе deltaL, входной суммы, текущей цены и ликвидности с использованием функции calcFinalPrice.

И наоборот, если требуемая сумма меньше суммы, указанной пользователем (что означает nextSqrtP > 0), deltaL рассчитывается с использованием текущего и целевого sqrtP, а nextSqrtP является sqrtP на следующем тике. Подробности опущены, так как эта ветка не используется в атаке.

Описанные выше шаги дают понять, что если тик не пересекается, nextSqrtP, возвращаемый computeSwapStep, не должен быть больше, чем sqrtP следующего тика. Однако из-за зависимости цены от ликвидности (базовой ликвидности и дельта-ликвидности) и потери точности, атакующие могут манипулировать nextSqrtP, делая его больше, в то время как тик не пересекается.

0x2 Анализ уязвимости

Первопричина кроется в ошибочном расчете тика, вызванном неправильным направлением округления при расчете дельта-ликвидности (т.е. функции estimateIncrementalLiquidity) в контракте SwapMath (который вызывается функцией computeSwapStep). Это, в свою очередь, неправильно влияет на последующий расчет тика.

Интересно, что при изучении комментария в строке 188 (выделен синим прямоугольником) мы обнаруживаем, что deltaL должна округляться вверх, чтобы округлить вниз nextSqrtP. Однако deltaL ошибочно округляется вниз из-за использования функции mulDivFloor в строке 189. Как следствие, nextSqrtP неточно округляется вверх.

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

Атакующие инициировали несколько транзакций атаки, при этом каждая транзакция опустошала несколько пулов. Для простоты следующее обсуждение основано на первой атаке в рамках транзакции атаки.

Основная логика атаки состоит из следующих шести шагов:

  1. Заимствование 2000 WETH через флэш-кредит от AAVE.

  2. Обмен 6.850 WETH на 6.371 frxETH в целевом пуле 0xfd7b. Этот шаг используется для того, чтобы подтолкнуть текущий тик и currentSqrtP в положение, где в настоящее время нет ликвидности.

  • currentSqrtP, похоже, был случайным образом выбран атакующим, и обмен останавливается точно на этой цене.
  • Базовая ликвидность (baseL) после этого шага равна нулю, но реинвестиционная ликвидность (reinvestL) не равна нулю.
  1. Добавление ликвидности в пул, а затем удаление части ликвидности. Этот шаг используется для контроля диапазона и общей ликвидности до желаемой суммы.
  • Диапазон тиков выбирается на основе currentSqrtP.
  • Желаемая ликвидность для атаки может быть выведена из диапазона тиков, хотя соответствующая логика расчета требует дальнейшего изучения.
  1. Обмен 387.170 WETH на 0.06 frxETH в пуле. Этот шаг используется для манипулирования текущим тиком так, чтобы nextTick == currentTick.
  • Входная сумма выбирается на основе ликвидности и currentSqrtP.
  1. Обмен 0.06 frxETH на 396.244 WETH в пуле. Обратите внимание, что направление обмена противоположно по сравнению с предыдущим шагом. На этом шаге ликвидность учитывается дважды, чтобы сделать обмен прибыльным и, следовательно, опустошить пул.

  2. Погашение флэш-кредита и получение прибыли в 6.364 WETH и 1.117 frxETH.

Очевидно, что последние два обмена (шаг 4 и шаг 5) являются ключевыми этапами атаки для манипулирования расчетом тика и обеспечения прибыльности обмена для опустошения пула. Мы углубимся в детали в следующих подразделах.

Важно отметить, что шаг 3 является решающим для манипулирования ликвидностью. Из-за потребности в точном манипулировании тиками через операцию округления, достижение цели путем прямого добавления ликвидности неосуществимо. Удаление ликвидности нужно для точного контроля ликвидности в диапазоне, как желал атакующий.

0x3.1 Шаг 4: манипуляция текущим тиком и currentSqrtP

После предыдущих шагов (шаг 1 и 2) атакующий подготовил диапазон тиков и ликвидность для манипуляции. А именно:

  • currentSqrtP находится в нужном месте
  • текущий тик = 110,909, а следующий тик = 111,310, окружающие currentSqrtP

Этот шаг меняет WETH на frxETH. В функции computeSwapStep у нас есть следующее отслеживание выполнения:

Как показано на рисунке выше, сумма для достижения цели (т.е. следующего тика) будет рассчитана путем вызова функции calcReachAmount:

  • usedAmount = calcReachAmount(liquidity, currentSqrtP, targetSqrtP)

Заметим, что этот расчет может быть выведен до обмена. Тщательно выбрав specifiedAmount (usedAmount = specifiedAmount + 1), атакующий контролировал обмен так, чтобы цель (т.е. следующий тик 111,310) не была достигнута, в результате чего nextSqrtP = 0.

В этой ситуации, поскольку тик не пересекается, nextSqrtP (т.е. конечная цена) должен быть выведен из дельта-ликвидности (накопленной в качестве комиссий за обмен). Сначала добавочная ликвидность deltaL из комиссий рассчитывается так:

  • deltaL = estimateIncrementalLiquidity(absDelta, currentSqrtP)

Затем конечная цена nextSqrtP:

  • nextSqrtP = calcFinalPrice(absDelta, liquidity, deltaL, currentSqrtP)

Возвращаясь к ошибке направления округления, обсуждавшейся в предыдущем разделе, здесь deltaL ошибочно округляется вниз, что приводит к округлению nextSqrtP вверх. В частности, в этом случае, исходя из того же absDelta (387,170,294,533,119,999,999), результаты расчетов различаются из-за разных направлений округления:

Поэтому после манипуляции тиком на шаге 4 текущие состояния суммируются следующим образом:

  • currentSqrtP составляет 20,693,058,119,558,072,255,665,971,001,964, что чуть больше sqrtP на тике 111,310 (sqrtP на 111,310 = 20,693,058,119,558,072,255,662,180,724,088).
  • текущий тик = 111,310 и следующий тик = 111,310

Как показано на рисунке выше, обмен на шаге 4 хитро обманывает пул, заставляя его поверить, что тик 111,310 не пересекается. Однако на самом деле currentSqrtP действительно больше, чем sqrtP тика 111,310.

0x3.2 Шаг 5: двойной учет ликвидности

Основываясь на манипуляции на шаге 4, логика атаки на шаге 5 достаточно прямолинейна. На этом этапе атакующий организовал обратный обмен из frxETH в WETH, который сдвинул бы тик и currentSqrtP влево. В частности, функция computeSwapStep вызывается дважды внутри цикла, что в конечном итоге вызывает двойной учет ликвидности[7] непредвиденным образом и, следовательно, генерирует дополнительную прибыль.

Как показано в приведенной выше трассировке:

  • При первом вызове функции computeSwapStep показатель currentSqrtP был сдвинут к sqrtP тика 111,310. Это крошечный обмен, который использует всего 3 wei frxETH для реального достижения тика 111,310. Впоследствии, внутри функции _updateLiquidityAndCrossTick, текущий тик должен пересечь тик 111,310 (двигаясь влево/вниз), даже если он не пересекал его вправо/вверх на шаге 4. Это приводит к тому, что ликвидность на тике 111,310 учитывается дважды.

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

0x4 Сводка атак и прибылей

На момент написания этой статьи мы наблюдали несколько атак на различных сетях (включая Ethereum, Optimism, Polygon, Arbitrum, Avalanche и Base), что привело к убыткам на сумму более 48 млн долларов США. Эти атаки были запущены разными атакующими, как показано ниже:

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

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

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

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

Справочные материалы

[1] https://docs.kyberswap.com/

[2] https://blog.uniswap.org/uniswap-v3

[3] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic

[4] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/tick-range-mechanism

[5] https://uniswap.org/whitepaper-v3.pdf

[6] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/reinvestment-curve

[7] https://100proof.org/kyberswap-post-mortem.html

Best Security Auditor for Web3

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

BlockSec Audit