Back to Blog

Otra Tragedia de Pérdida de Precisión: Un Análisis Profundo del Incidente de KyberSwap

Code Auditing
December 5, 2023
13 min read

El 23 de noviembre de 2023, observamos una serie de ataques dirigidos a KyberSwap. Estos ataques resultaron en una pérdida total de más de $48M. Nuestro análisis inicial sugirió que el exploit se debió a la manipulación de ticks y al doble conteo de liquidez. Sin embargo, debido a limitaciones de espacio, no pudimos profundizar en los extensos detalles dentro de esa publicación. A pesar de los posteriores análisis perspicaces de otros investigadores de seguridad, la causa raíz del problema — la pérdida de precisión — permaneció sin ser expuesta.

Curiosamente, la trama se complicó varios días después. El 30 de noviembre de 2023, tras múltiples rondas de discusiones con los responsables, el atacante envió un mensaje que, para el mundo exterior, parecía lleno de provocación, exigiendo el control total. Dejando eso de lado, el atacante también reveló una pieza crucial de información: el problema está efectivamente relacionado con la pérdida de precisión, como se muestra en la figura a continuación. Esta revelación refuerza la evidencia para nuestra investigación. Por lo tanto, nuestro objetivo es presentar un análisis exhaustivo en este informe.

Conclusiones clave (TL;DR)

  • Nuestra investigación revela que el problema fundamental se origina en la dirección de redondeo incorrecta durante el proceso de reinversión de KyberSwap. Esto conduce posteriormente a un cálculo incorrecto del tick y finalmente al doble conteo de liquidez.

  • Este incidente subraya la naturaleza compleja y sigilosa de los problemas de pérdida de precisión dentro de los protocolos DeFi, lo que representa un desafío sustancial para toda la comunidad.

  • La frecuencia de estos ataques sirve como un recordatorio contundente de la necesidad crítica de medidas proactivas de prevención de amenazas, que podrían ayudar significativamente a reducir las pérdidas futuras.

En las secciones siguientes, primero ofreceremos información de contexto crucial sobre KyberSwap. Posteriormente, realizaremos un análisis en profundidad de la vulnerabilidad y el ataque asociado.

0x1 Contexto

KyberSwap[1] es una plataforma de creador de mercado automatizado descentralizado (CLAMM). Para satisfacer la demanda del mercado de liquidez concentrada, KyberSwap Elastic[3] fue lanzado basándose en Uniswap V3[2], con varias mejoras que incluyen la curva de reinversión para habilitar el auto-compuesto de los rendimientos de provisión de liquidez.

0x1.1 Tick y Precio de Raíz Cuadrada

El Tick en CLAMMs similares a Uniswap V3 se utiliza para marcar el precio de manera discreta, de modo que los LPs puedan proporcionar liquidez dentro de un rango fijo en lugar de todo el rango (de ahí el término "concentrada")[4].

Para permitir que los LPs especifiquen posiciones de liquidez con intervalos de precios personalizados, el protocolo necesitaba una forma de rastrear la liquidez agregada en varios puntos de precio. Uniswap V3 lo logró particionando el espacio de precios posibles en "ticks" discretos mediante los cuales los LPs podían contribuir liquidez entre dos ticks cualesquiera.

Según [5], la liquidez puede colocarse en un rango entre dos ticks cualesquiera (que no necesitan ser adyacentes), es decir, un par de índices de tick (un tick inferior y un tick superior). Específicamente, el precio de cada tick (en un índice entero i) se define de la siguiente manera:

En la práctica, se utiliza el precio de raíz cuadrada (denotado como sqrtP o sqrtPrice):

También es posible calcular el tick actual basándose en el precio de raíz cuadrada actual:

Utilizar el precio de raíz cuadrada junto con la liquidez L es una forma práctica de evitar cambios simultáneos. Específicamente, el precio cambia al intercambiar dentro de un tick; la liquidez cambia al cruzar un tick, o al acuñar o quemar liquidez. Para una explicación más detallada, consulte el whitepaper de Uniswap V3[5].

Obviamente, aunque solo se calcula un único precio de raíz cuadrada para un tick dado, múltiples precios de raíz cuadrada pueden apuntar al mismo tick.

0x1.2 Curva de Reinversión

El CLAMM basado en Uniswap V3 sufre de la utilización del pool en comisiones de LP y las significativas comisiones de gas requeridas para reinvertir. Por lo tanto, KyberSwap adoptó la curva de reinversión[6] para abordar el problema:

La curva de reinversión fue diseñada con el único propósito de reinvertir de forma nativa las comisiones de LP que de otro modo no se utilizarían en el modelo de liquidez concentrada. Esto significó que las comisiones de LP para posiciones de liquidez concentrada se componían automáticamente sin la sobrecarga de gas ni la gestión manual. Además, los LPs aún tienen la opción de cobrar sus ganancias de comisiones auto-compuestas por separado en cualquier momento.

La clave de la curva de reinversión es que las comisiones recaudadas en cada intercambio se acumulan como liquidez adicional en el pool como la liquidez de reinversión dentro de un rango infinito. Los tokens de reinversión se acuñan para los LPs y la liquidez de reinversión acumulada se asigna a los LPs en consecuencia. Además, la liquidez de reinversión también participa en el proceso de intercambio y cálculo de precios.

Para ser precisos, en lugar de la fórmula del producto constante:

las comisiones se acumulan en ΔL en cada intercambio:

El cálculo de ΔL puede simplificarse en (bajo el supuesto de que la desviación de precio es menor que un umbral):

Luego, la cantidad de intercambio y el precio final pueden derivarse de la fórmula del producto constante modificada:

El código correspondiente a los cálculos introducidos anteriormente se muestra en la función computeSwapStep en el siguiente fragmento de código del pool correspondiente.

Debe notarse que debido a la liquidez de reinversión, la liquidity en esta función es una suma de dos componentes: baseL para la liquidez base, y reinvestL para la liquidez acumulada para la reinversión.

0x1.3 Intercambio en KyberSwap

El flujo de control de un intercambio en Uniswap V3 puede describirse de la siguiente manera[5]:

En consecuencia, la implementación de la función swap del pool de KyberSwap discutido anteriormente puede abstraerse como el diagrama a continuación:

La lógica crucial relacionada con el cálculo del tick reside dentro del bucle while de intercambio, como se destaca con el rectángulo azul. Específicamente, la lógica principal involucra la función computeSwapStep y la función _updateLiquidityAndCrossTick. La primera calcula estados clave, como las cantidades de entrada y salida para el intercambio dado y nextSqrtP, mientras que la segunda maneja los casos cuando ocurre un cruce de tick.

Tradicionalmente, cuando el precio aumenta, nos referimos a esto como desplazar el tick a la derecha/hacia arriba; de lo contrario, decimos que el tick se mueve a la izquierda/hacia abajo.

Para comprender mejor la vulnerabilidad que se discutirá más adelante, es esencial que exploremos la lógica de código relevante de la función computeSwapStep, como se ilustra en la siguiente figura:

En primer lugar, desde las líneas 50 a 57, se invoca la función calcReachAmount para calcular la cantidad de token de entrada necesaria para alcanzar el targetSqrtP (siguiente tick o precio objetivo especificado por el usuario).

A continuación, entre las líneas 59 y 62, se realiza una prueba para determinar si el tick debe cruzarse o no.

Específicamente, si la cantidad utilizada (usedAmount) es mayor que la cantidad especificada por el usuario (specifiedAmount) en un intercambio de entrada exacta (el caso utilizado en el ataque), significa que el tick no debe cruzarse, y el nextSqrtP debe derivarse de la liquidez incremental (deltaL, es decir, el delta de liquidez).

  • Posteriormente, entre las líneas 70 y 79, el ΔL (deltaL) se deriva de la cantidad de entrada, la liquidez actual y el precio usando la función estimateIncrementalLiquidity. Finalmente, el precio final después del intercambio nextSqrtP se calcula en base a deltaL, la cantidad de entrada, el precio actual y la liquidez, usando la función calcFinalPrice.

Por el contrario, si la cantidad requerida es menor que la cantidad especificada por el usuario (lo que significa que nextSqrtP > 0), el deltaL se calcula usando el sqrtP actual y objetivo, y el nextSqrtP es el sqrtP en el siguiente tick. Los detalles se omiten porque esta rama no se usa en el ataque.

Los pasos descritos anteriormente dejan claro que si el tick no se cruza, el nextSqrtP devuelto por computeSwapStep no debería ser mayor que el sqrtP del siguiente tick. Sin embargo, debido a la dependencia del precio en la liquidez (liquidez base y liquidez delta) y la pérdida de precisión, el atacante es capaz de manipular el nextSqrtP para que sea mayor mientras el tick no se cruza.

0x2 Análisis de Vulnerabilidad

La causa raíz reside en el cálculo defectuoso del tick causado por la dirección de redondeo incorrecta dentro del cálculo del delta de liquidez (es decir, la función estimateIncrementalLiquidity) del contrato SwapMath (que es invocado por la función computeSwapStep). Esto, a su vez, afecta de manera incorrecta el cálculo del tick posteriormente.

Curiosamente, al examinar el comentario en la línea 188 (resaltado por el rectángulo azul), encontramos que se pretende que deltaL sea redondeado hacia arriba para redondear hacia abajo el nextSqrtP. Sin embargo, deltaL se redondea erróneamente hacia abajo debido al uso de la función mulDivFloor en la línea 189. En consecuencia, nextSqrtP se redondea incorrectamente hacia arriba.

0x3 Análisis del Ataque

Los atacantes iniciaron múltiples transacciones de ataque, con cada transacción vaciando múltiples pools. Por simplicidad, la siguiente discusión se basa en el primer ataque dentro de la transacción de ataque.

La lógica central del ataque consiste en los siguientes seis pasos:

  1. Pedir prestado 2,000 WETH mediante un préstamo flash de AAVE.

  2. Intercambiar 6.850 WETH por 6.371 frxETH en el pool víctima 0xfd7b. Este paso se utiliza para empujar el tick actual y currentSqrtP a una ubicación donde actualmente no hay liquidez presente.

  • currentSqrtP parece ser elegido aleatoriamente por el atacante, y el intercambio se detiene exactamente en este precio.
  • La liquidez base (baseL) es cero después de este paso, pero la liquidez de reinversión (reinvestL) no es cero.
  1. Añadir liquidez al pool y luego eliminar parte de la liquidez. Este paso se utiliza para controlar el rango y la liquidez total a una cantidad deseada.
  • El rango de ticks se elige en base al currentSqrtP.
  • La liquidez deseada para el ataque podría derivarse del rango de ticks, aunque la lógica de cálculo correspondiente requiere mayor exploración.
  1. Intercambiar 387.170 WETH por 0.06 frxETH en el pool. Este paso se utiliza para manipular el tick actual de modo que nextTick == currentTick.
  • La cantidad de entrada se elige en base a la liquidez y currentSqrtP.
  1. Intercambiar 0.06 frxETH por 396.244 WETH en el pool. Nótese que la dirección del intercambio es opuesta en comparación con el paso anterior. En este paso, la liquidez se cuenta doble para hacer el intercambio rentable y consecuentemente vaciar el pool.

  2. Repagar el préstamo flash y obtener 6.364 WETH y 1.117 frxETH.

Obviamente, los dos últimos intercambios (pasos 4 y 5) son los pasos clave del ataque para manipular el cálculo del tick y hacer que el intercambio sea rentable para vaciar el pool. Profundizaremos en los detalles en las siguientes subsecciones.

Es importante destacar que el paso 3 es crucial para manipular la liquidez. Debido a la necesidad de una manipulación precisa del tick mediante la operación de redondeo, lograr el objetivo añadiendo liquidez directamente es inviable. La eliminación de liquidez es para controlar con precisión la liquidez en el rango según lo deseado por el atacante.

0x3.1 Paso 4: manipular el tick actual y currentSqrtP

Después de los pasos anteriores (pasos 1 y 2), el atacante ha preparado el rango de ticks y la liquidez para la manipulación. Específicamente:

  • currentSqrtP está en una ubicación deseada
  • tick actual = 110,909 y siguiente tick = 111,310, rodeando el currentSqrtP

Este paso intercambia WETH por frxETH. En la función computeSwapStep, tenemos la siguiente traza de ejecución:

Como se muestra en la figura anterior, la cantidad para alcanzar el objetivo (es decir, el siguiente tick) se calculará invocando la función calcReachAmount:

  • usedAmount = calcReachAmount(liquidity, currentSqrtP, targetSqrtP)

Nótese que este cálculo puede derivarse antes del intercambio. Al elegir cuidadosamente el specifiedAmount (usedAmount = specifiedAmount + 1), el atacante controló el intercambio de modo que el objetivo (es decir, el siguiente tick 111,310) no se alcanza, resultando en que nextSqrtP = 0.

En esta situación, debido a que el tick no se cruza, nextSqrtP (es decir, el precio final) debe derivarse del delta de liquidez (acumulado como comisiones de intercambio).

Primero, la liquidez incremental deltaL de las comisiones se calcula mediante:

  • deltaL = estimateIncrementalLiquidity(absDelta, currentSqrtP)

Luego el precio final nextSqrtP:

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

Revisando el error de dirección de redondeo discutido en la sección anterior, aquí deltaL se redondea erróneamente hacia abajo, lo que lleva a que nextSqrtP se redondee hacia arriba. Específicamente, en este caso, basándose en el mismo absDelta (387,170,294,533,119,999,999), los resultados del cálculo difieren debido a las distintas direcciones de redondeo:

Por lo tanto, después de la manipulación del tick en el paso 4, los estados actuales se resumen de la siguiente manera:

  • currentSqrtP es 20,693,058,119,558,072,255,665,971,001,964, ligeramente mayor que el sqrtP en el tick 111,310 (sqrtP en 111,310 = 20,693,058,119,558,072,255,662,180,724,088).
  • tick actual = 111,310 y siguiente tick = 111,310

Como se ilustra en la figura anterior, el intercambio en el paso 4 engaña astutamente al pool haciéndole creer que el tick 111,310 no se ha cruzado. Sin embargo, en realidad, el currentSqrtP es efectivamente mayor que el sqrtP del tick 111,310.

0x3.2 Paso 5: doble conteo de liquidez

Basándose en la manipulación del paso 4, la lógica del ataque en el paso 5 es razonablemente sencilla. En esta etapa, el atacante orquestó un intercambio inverso de frxETH a WETH, lo que desplazaría el tick y el currentSqrtP hacia la izquierda. Específicamente, la función computeSwapStep se invoca dos veces dentro del bucle, lo que en última instancia desencadena el doble conteo de liquidez[7] de una manera imprevista y consecuentemente genera ganancias adicionales.

Como se muestra en la traza anterior:

  • En la primera invocación de la función computeSwapStep, el currentSqrtP se desplazó al sqrtP del tick 111,310. Este es un intercambio diminuto que solo usa 3 wei de frxETH para realmente alcanzar el tick 111,310. Posteriormente, dentro de la función _updateLiquidityAndCrossTick, el tick actual debería cruzar el tick 111,310 (moviéndose a la izquierda/hacia abajo), aunque realmente no atravesó el tick 111,310 en dirección derecha/hacia arriba en el paso 4. Esto resulta en que la liquidez en el tick 111,310 se cuenta dos veces.

  • En la segunda invocación de la función computeSwapStep, el doble conteo previo de liquidez puede conducir al potencial de ganancias adicionales. Específicamente, aprovechando este doble conteo de liquidez, el precio de intercambio en el paso final se distorsiona, lo que lleva a que se intercambie una mayor cantidad de WETH, generando así una ganancia.

0x4 Resumen de Ataques y Ganancias

Hasta el momento de redactar este informe, hemos observado varios ataques en diferentes cadenas (incluyendo Ethereum, Optimism, Polygon, Arbitrum, Avalanche y Base) en el entorno real, causando pérdidas superiores a $48M. Estos ataques fueron lanzados por diferentes atacantes, de la siguiente manera:

Una lista completa de estas transacciones de ataque ha sido recopilada en un documento que hemos preparado. Por favor, consúltelo para obtener información más detallada.

0x5 Conclusión

En conclusión, esta es una vulnerabilidad sutil que se origina en una lógica de redondeo incorrecta. El exploit es increíblemente sofisticado. De hecho, este año hemos observado una serie de incidentes de seguridad relacionados con problemas de pérdida de precisión, lo que plantea desafíos significativos para la comunidad.

Una vez más, estos continuos ataques demuestran la importancia de la prevención proactiva de amenazas, una estrategia que podría ayudar eficazmente a mitigar las pérdidas potenciales.

Referencia

[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