El 30 de noviembre de 2025, el Weighted Stable Pool yETH de Yearn Finance fue explotado por más de $9 millones [1]. Las causas raíz fueron una aritmética insegura en el solucionador de invariantes _calc_supply() y una ruta de arranque (bootstrap) no deshabilitada que permitió reingresar a la lógica de inicialización. El post-mortem oficial [2] enumera cinco elementos como causas raíz; nosotros los reclasificamos como dos defectos (las vulnerabilidades anteriores) y dos condiciones arquitectónicas previas que se volvieron explotables solo en presencia de estos defectos. Otros análisis disponibles se centran en los detalles paso a paso de la transacción del ataque. Entre los resúmenes de alto nivel y los detalles a nivel de transacción, persiste un vacío: ¿por qué y cómo funcionó realmente el ataque? Esta publicación llena ese vacío, utilizando simulaciones con Foundry y Python para rastrear cómo evolucionan los valores clave paso a paso y dónde se rompen los cálculos.
Este análisis realiza principalmente las siguientes tres contribuciones:
- Desglose de pérdidas por vulnerabilidad. Las dos vulnerabilidades no son codependientes: la aritmética insegura por sí sola causó ~$8.1M en pérdidas (90% del total), mientras que la ruta de arranque permitió ~$0.9M adicionales. Esto aclara cuál vulnerabilidad fue la principal.
- Reclasificación de las causas raíz. Las cinco causas raíz del informe oficial se entienden mejor como dos defectos de implementación (consolidando tres de los cinco elementos) más dos condiciones arquitectónicas previas que se volvieron explotables solo en combinación con los defectos.
- Corrección de malentendidos técnicos. La afirmación de que "un underflow en la segunda iteración anula el término del producto" no se sostiene: nuestras simulaciones muestran que el producto se anula por redondeo en la división, no por underflow, y el underflow que genera la ganancia ocurre en una fase completamente distinta.
El resto de esta publicación está organizado de la siguiente manera. La Sección 0x1 proporciona antecedentes sobre el weighted stable pool de yETH y su solucionador de invariantes. La Sección 0x2 analiza las dos causas raíz y sus modos de falla. La Sección 0x3 rastrea el ataque de tres fases en detalle. La Sección 0x4 corrige dos malentendidos comunes con evidencia de simulación. La Sección 0x5 concluye con recomendaciones.
TL;DR
Causas raíz: Se explotaron dos vulnerabilidades, pero con un impacto asimétrico:
- Aritmética insegura en
_calc_supply()(principal, ~$8.1M). La función que recalcula el suministro de yETH a partir del estado del pool contiene dos fallas aritméticas: el redondeo hacia abajo enunsafe_div()puede anular el término del producto interno, y el underflow enunsafe_sub()puede envolver un valor intermedio hasta convertirlo en un entero positivo enorme. Esta vulnerabilidad por sí sola fue suficiente para drenar el weighted stableswap pool de yETH. - Ruta de arranque (bootstrap) no deshabilitada (secundaria, ~$0.9M). La rama de inicialización
prev_supply == 0nunca fue bloqueada permanentemente después del despliegue. Después de que la primera vulnerabilidad drenó el suministro a cero, esta ruta se volvió alcanzable, permitiendo ganancias adicionales a partir del pool Curve yETH/WETH.
Dentro de la vulnerabilidad de aritmética insegura, solo se usó la falla de redondeo hacia abajo (Modo de Falla A) en la Fase 2; la falla de underflow (Modo de Falla B) es codependiente con la ruta de arranque y juntas permitieron la Fase 3.
El atacante ejecutó una secuencia de tres fases:
- Preparación: Sesgar la distribución de activos del pool mediante ciclos repetidos de agregar/eliminar, creando un desequilibrio extremo en los saldos virtuales.
- Manipulación del suministro: Explotar el redondeo hacia abajo en
_calc_supply()para colapsar el término del producto a cero, luego drenar el suministro total a cero mediante una serie de operaciones de minteo/quema. Todos los LST del pool fueron retirados y canjeados por WETH posteriormente, provocando ~$8.1M en pérdidas. - Extracción de ganancias: Activar la ruta de arranque (
prev_supply == 0) con depósitos ínfimos, explotando el underflow en_calc_supply()para acuñar ~2.35×10⁵⁶ yETH, que se usaron para drenar el pool Curve yETH/WETH, provocando ~$0.9M en pérdidas.
Dos malentendidos comunes corregidos:
- "El invariante se rompe porque
pow_up()ypow_down()redondean de manera diferente." Verificamos esto reemplazandopow_up()porpow_down()en una simulación con Foundry: el exploit sigue funcionando. El desajuste de redondeo no es una causa raíz. - "Un underflow en la segunda iteración hace que un término intermedio colapse a cero." Nuestras simulaciones con Foundry y Python muestran que no ocurre ningún underflow en la segunda iteración. El valor real es ~1.91e19 (no ~1.94e18 como se afirma), un resultado legítimo de una resta correcta. Lo que anula el producto es el posterior redondeo hacia abajo en la división, no un underflow.
0x1 Antecedentes
Dos pools perdieron activos en este incidente: el weighted stableswap pool de yETH (un pool de Yearn que contiene LSTs, ~$8.1M perdidos) y el pool Curve yETH/WETH (un pool stableswap de Curve, ~$0.9M perdidos). El weighted stableswap pool de yETH es donde reside la vulnerabilidad principal. Esta sección proporciona los antecedentes necesarios para comprender la vulnerabilidad y el exploit.
0x1.1 Saldos virtuales y el invariante
El protocolo yETH es un Automated Market Maker (AMM) para tokens de staking líquido (LST) de Ethereum [3]. El weighted stableswap pool de yETH afectado agrupa múltiples LST en un solo pool: los usuarios depositan LSTs y reciben yETH como tokens de participación en el pool.
Debido a que cada LST representa ETH en staking que acumula recompensas con el tiempo, su tasa de cambio con respecto al ETH base cambia. Para unificar la contabilidad, el pool define un saldo virtual para cada activo: saldo on-chain × tasa de cambio. Esto normaliza todos los activos en unidades de ETH de la beacon chain. La suma de todos los saldos virtuales se denota .
El pool contiene 8 activos (indexados de 0 a 7), cada uno con un peso designado :
El estado del pool está regido por un invariante de estilo StableSwap ponderado [4]:
donde:
- es la escala del invariante, que equivale directamente al suministro total de yETH de este pool. Cuando el pool está perfectamente equilibrado, .
- es el término del producto ponderado, definido como , donde es el peso del activo i y .
- es el factor de amplificación, un único parámetro del protocolo (no ). denota este factor elevado a la potencia , donde es el número de activos (8 en este pool). Controla la forma de la curva entre suma constante (cerca del equilibrio) y producto constante (en los extremos).
La propiedad clave: no tiene una solución de forma cerrada. Debe resolverse numéricamente. Ese solucionador, _calc_supply(), es donde reside la vulnerabilidad aritmética.
0x1.2 El solucionador de invariantes
El protocolo recalcula mediante una iteración de punto fijo limitada a 256 rondas. Este algoritmo se implementa como _calc_supply() en el código (detallado en la Sección 0x2.1). Cada ronda realiza tres pasos:
Paso 1: Actualizar la estimación de suministro.
Paso 2: Actualizar el término del producto para que coincida con el nuevo suministro.
Paso 3: Verificar la convergencia.
Si , se devuelve ; de lo contrario, se repite desde el Paso 1.
Los valores iniciales , y influyen en las primeras iteraciones; aunque teóricamente son irrelevantes para la convergencia final, afectan los resultados en la práctica debido a la iteración finita y la aritmética de precisión fija.
La implementación utiliza operaciones enteras de precisión fija: la división redondea hacia abajo, y la resta no protege contra underflow. En condiciones normales del pool, los valores intermedios se mantienen dentro de rangos seguros. En estados extremos del pool, no lo hacen. La Sección 0x2.1 analiza en detalle estos modos de falla.
0x1.3 Las tres interfaces y el solucionador de invariantes
El protocolo expone tres puntos de entrada que afectan el estado del pool actualizando el término del producto ponderado (almacenado como vb_prod en el código):
| Interfaz | Qué hace | ¿Activa _calc_supply()? |
|---|---|---|
add_liquidity() |
Deposita activos en proporciones arbitrarias | Sí |
update_rates() |
Actualiza las tasas de cambio externas | Sí |
remove_liquidity() |
Retira activos de forma proporcional por peso | No (usa escalado proporcional) |
La asimetría importa: add_liquidity() permite depósitos en proporción arbitraria (puede sesgar masivamente el pool), mientras que remove_liquidity() siempre retira de forma proporcional. Los ciclos repetidos de agregar/eliminar pueden, por lo tanto, llevar al pool progresivamente a estados cada vez más desequilibrados.
El mecanismo para actualizar las tasas
Como se discutió anteriormente, los saldos virtuales () se calculan en función de las tasas de cambio de los LST. Por lo tanto, es importante entender la forma en que se actualizan las tasas.
Específicamente, las funciones add_liquidity() y update_rates() pueden actualizar las tasas a través de la función interna _update_rates(), mientras que la función remove_liquidity() no realiza sincronización de tasas.
add_liquidity()invoca_update_rates()antes de ejecutar operaciones críticas para garantizar que las tasas de cambio de los activos estén sincronizadas con el último estado.update_rates()permite actualizaciones manuales de tasas.
La función _update_rates() verifica si las tasas de cambio registradas dentro del contrato son coherentes con las tasas externas. Si se detecta una discrepancia, activa un recálculo de los saldos virtuales y posteriormente actualiza el invariante; de lo contrario, el proceso de actualización se omite.
Cómo maneja cada interfaz a π
Según cómo afectan al invariante, estas tres funciones pueden clasificarse en dos categorías. Específicamente, add_liquidity() y update_rates() permiten cambios no proporcionales en los saldos virtuales, y por lo tanto requieren un recálculo iterativo del suministro y del producto . En cambio, remove_liquidity() retira liquidez de forma proporcional y no requiere cálculo iterativo.
La fórmula base para calcular el producto desde cero es:
donde es el suministro, es el peso del activo , es su saldo virtual (almacenado como vb[i] en el código), y n es el número de activos. Esta forma es algebraicamente equivalente a la definición de la Sección 0x1.1, con distribuido dentro del producto.
add_liquidity()tiene dos rutas (código mostrado en la Sección 0x2.2):
- Ruta de arranque (bootstrap) (cuando
prev_supply == 0): Calculavb_proddesde cero usando la ecuación (4). Que esta ruta permanezca accesible después del despliegue es la vulnerabilidad de gestión de estado discutida en la Sección 0x2.2. - Ruta normal (cuando
prev_supply > 0): El proceso de cálculo se divide en dos pasos:-
a) Usa una actualización incremental basada en la razón entre los saldos virtuales antiguos y nuevos:
donde y son los saldos virtuales antes y después del depósito.
-
b) Calibra iterativamente el valor preciso llamando a
_calc_supply()con esta estimación como entrada, recalculando el invariante y el valor exacto de .
-
-
update_rates()se activa cuando cambian las tasas de cambio, lo que provoca que se actualicen los saldos virtuales de los activos correspondientes. Su flujo de cálculo posterior sigue la ruta normal deadd_liquidity(), es decir, el invariante se recalcula iterativamente. Además, según el suministro recién calculado, el contrato acuña o quema yETH para asegurar que el suministro de liquidez se mantenga coherente con el estado actualizado de los saldos virtuales. -
remove_liquidity()siempre calculavb_proddesde cero usando la ecuación (4), después de reducir proporcionalmente cada saldo virtual.
0x2 Análisis de causas raíz
Se explotaron dos vulnerabilidades, con roles e impacto diferentes. La causa raíz principal fue una falla de cálculo en el solucionador de invariantes _calc_supply(), que tenía dos modos de falla: (A) el redondeo hacia abajo podía anular el término del producto, degenerando el invariante en un modelo de suma constante y provocando un minteo excesivo de LP (inflación del suministro); y (B) una condición de underflow que también podía inflar el suministro. Solo el Modo de Falla A se usó en la Fase 2 (~$8.1M). El Modo de Falla B era codependiente de la vulnerabilidad secundaria.
La causa raíz secundaria fue un defecto de gestión de estado: la rama de inicialización del pool permaneció alcanzable. Después de que la Fase 2 llevara el suministro a cero, el Modo de Falla B se combinó con la ruta de arranque para permitir ~$0.9M adicionales en pérdidas (Fase 3).
0x2.1 Aritmética insegura en _calc_supply() (principal)
La Figura 2 mapea la implementación de _calc_supply() al procedimiento matemático de la Sección 0x1.2, anotando los dos puntos de falla aritmética analizados a continuación:
Las variables del código se corresponden con los términos matemáticos de la siguiente manera:
| Variable del código | Rol matemático |
|---|---|
s |
Estimación actual del suministro |
r |
Término del producto |
sp |
Siguiente estimación del suministro |
l |
Constante del numerador: |
d |
Constante del denominador: |
Las expresiones críticas son:
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)
Existen dos modos de falla aritmética dentro de esta función, que afectan líneas diferentes y producen efectos diferentes. Ambos requieren que el pool esté en un estado extremo para activarse.
En condiciones normales, la iteración se comporta correctamente: l - s * r es un valor positivo modesto, y la iteración converge en pocas rondas.
1. Modo de falla A: el redondeo hacia abajo anula el producto
En el Paso 2, el producto se actualiza por activo de la siguiente manera:
r = unsafe_div(unsafe_mul(r, sp), s) # r = r * sp / s
Dado que unsafe_div() realiza división entera, siempre redondea hacia abajo. Cuando el pool está severamente desequilibrado y sp es mucho menor que s (como sucede después de un depósito grande manipulado), el numerador r * sp puede volverse menor que el denominador s. La división entera entonces produce r = 0.
Una vez que r es cero, permanece en cero en todas las iteraciones posteriores. El término del producto ha colapsado de forma permanente.
Una atribución errónea común afirma que esta falla proviene de un desajuste de redondeo entre pow_up() y pow_down(). La Sección 0x4 presenta evidencia de que esto es incorrecto.
2. Modo de falla B: el underflow infla el suministro
En el Paso 1, la nueva estimación de suministro se calcula como:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # sp = (l - s*r) / d
La resta l - s*r en la ecuación 2. En condiciones normales, esto es positivo. Sin embargo, cuando el pool alcanza un estado degenerado con suministro cero, la rama de inicialización en add_liquidity() (detallada en la Sección 0x2.2) recalcula el término del producto desde cero, y las magnitudes relativas pueden invertirse.
Específicamente, cuando se llama a add_liquidity() en un pool con suministro cero con cantidades ínfimas, la rama de inicialización llama a _calc_vb_prod_sum() para calcular nuevos valores usando la ecuación (4) (Sección 0x1.3). Con depósitos diminutos, vb_sum es minúsculo (p. ej., 16), pero dividir entre saldos cercanos a cero y elevar a potencias altas amplifica el producto a un valor desproporcionadamente grande (p. ej., ~9.13e20). Cuando s * r supera a l, la resta produce un resultado matemáticamente negativo.
Dado que unsafe_sub() realiza la resta en aritmética uint256 sin verificación, un resultado negativo se envuelve (wraps around) hasta convertirse en un entero positivo enorme (cercano a ). Este valor envuelto se propaga a través de la división y las iteraciones posteriores, produciendo una estimación de suministro absurdamente grande, que el protocolo luego acuña como tokens yETH reales.
Una afirmación común sostiene que dicho underflow ocurre en la segunda iteración de un paso específico de manipulación del suministro. La Sección 0x4 muestra que esta afirmación es incorrecta: el underflow real que infla el suministro ocurre en un contexto completamente distinto (Fase 3 del ataque).
3. Cómo estas fallas habilitan el ataque
Estos dos modos de falla operan en fases diferentes del exploit, con contribuciones de ganancia diferentes:
-
Modo de falla A (Fase 2, ~$8.1M): Cuando el atacante deposita en un pool severamente desequilibrado, el término del producto se anula, provocando que
_calc_supply()devuelva un suministro inflado. El protocolo acuña en exceso yETH al atacante. Este modo de falla, por sí solo, sin ninguna implicación de la ruta de arranque, permitió al atacante drenar los activos LST del weighted stableswap pool de yETH. -
Modo de falla B (Fase 3, ~$0.9M): Después de que el suministro ha sido drenado a cero, la ruta de arranque recalcula un término de producto grande a partir de depósitos ínfimos, provocando que la resta sufra underflow. El protocolo acuña una cantidad astronómicamente grande de yETH, que el atacante utiliza para drenar el pool Curve yETH/WETH, separado.
La dependencia es unidireccional: el Modo de Falla A es explotable de forma independiente y causó el 90% de las pérdidas, mientras que el Modo de Falla B requiere que el Modo de Falla A primero lleve el suministro a cero.
0x2.2 Ruta de arranque no deshabilitada (secundaria)
La función add_liquidity() contiene una rama para el depósito inicial del pool:
La lógica puede abstraerse de la siguiente manera:
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)
Cuando prev_supply == 0, la función omite el estado almacenado y recalcula vb_prod y vb_sum desde cero mediante _calc_vb_prod_sum(), usando la ecuación (4) (Sección 0x1.3). Esta rama de arranque estaba pensada para un uso único durante la inicialización del pool, pero nunca fue bloqueada permanentemente después del primer depósito.
Si el suministro total puede llevarse a cero (mediante cualquier combinación de quemas y retiros), la rama vuelve a ser alcanzable. Un atacante que reingresa a esta ruta controla las condiciones iniciales que se pasan a _calc_supply(), activando potencialmente las fallas aritméticas descritas anteriormente bajo parámetros que nunca surgirían durante el funcionamiento normal del pool.
Este es un patrón de vulnerabilidad conocido. En agosto de 2023, el incidente de Balancer V2 dependió de manera similar de llevar el suministro a cero para reiniciar tasas internas, permitiendo al atacante reingresar a la lógica de inicialización con parámetros artificialmente favorables [6]. Si un pool desplegado puede llevarse de vuelta a su estado inicial, y qué invariantes se mantienen cuando eso sucede, es una pregunta que los diseñadores de protocolos deben abordar explícitamente.
0x3 Análisis del ataque
El exploit se desarrolla a lo largo de una secuencia coordinada de la transacción del ataque [5], organizada en tres fases. Cada fase se construye sobre el estado establecido por la anterior.
0x3.1 Fase 1: Sesgar el pool (preparación)
Objetivo: Crear un desequilibrio extremo en los saldos virtuales entre los activos.
La siguiente figura ilustra el rastro de la transacción para esta fase (el paso del flash loan se omite por limitaciones de espacio):
El atacante primero toma prestadas grandes cantidades de activos LST mediante flash loans de Balancer y Aave, específicamente 5,500e18 wstETH, 3,100e18 WETH, 1,800e18 rETH, 2,000e18 ETHx y 200e18 cbETH.
A continuación, el atacante intercambia aproximadamente 800e18 WETH por unos 416e18 yETH en el pool Curve yETH/WETH, y luego usa el yETH obtenido para retirar liquidez del pool.
La manipulación central aprovecha la asimetría de interfaces descrita en la Sección 0x1 (Antecedentes): add_liquidity() permite depósitos en proporción arbitraria, mientras que remove_liquidity() retira activos de forma proporcional según los pesos del pool (resaltado en el rectángulo rojo en la figura anterior). Mediante ciclos repetidos de agregar → eliminar, depositando solo activos seleccionados mientras retira todos los activos de forma proporcional, el atacante lleva progresivamente al pool a un estado severamente desequilibrado:
| Activo | Peso | Antes | Después | Cambio |
|---|---|---|---|---|
| 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% |
Los activos 3 (cbETH), 6 (WOETH) y 7 (mETH) se han agotado en más del 98%. Este desequilibrio no extrae ganancias directamente. Crea las condiciones numéricas previas para la siguiente fase.
0x3.2 Fase 2: Colapsar el suministro a cero (~$8.1M)
Objetivo: Llevar el producto del invariante a cero, y luego drenar el suministro de yETH a cero. Esta fase explota solo la vulnerabilidad principal (aritmética insegura) y causó ~90% de las pérdidas totales.
Esta fase utiliza un ciclo repetitivo de cinco pasos, ejecutado tres veces:
- Corromper el producto mediante
add_liquidity(); - Establecer la condición previa para la corrección mediante
add_liquidity(); - Reiniciar el producto mediante
remove_liquidity()con 0 yETH; - Corregir el suministro mediante
update_rates(); - Retirar activos mediante
remove_liquidity().
La siguiente figura muestra el rastro de la transacción, donde se ven claramente tres repeticiones del ciclo de cinco pasos:
1. Corromper el producto mediante add_liquidity()
El atacante deposita grandes cantidades de activos de alto peso (índices 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH), cada uno aproximadamente tres veces su saldo virtual actual.
add_liquidity() estima el nuevo término del producto mediante la actualización incremental de la ecuación (5) (Sección 0x1.3). Dado que para activos de alto peso, las razones son todas fracciones muy por debajo de 1, elevadas a potencias altas. Esto lleva a desde ~42e18 hasta ~0.00353e18, un producto estimado cercano a cero.
Este producto diminuto entra en _calc_supply(). En la iteración, la actualización del producto r = r * sp / s encuentra la condición de redondeo hacia abajo descrita en la Sección 0x2 (Análisis de causas raíz): el numerador cae por debajo del denominador, y la división entera redondea r a cero. La función devuelve un producto cero y un suministro inflado (~vb_sum), provocando que el protocolo acuñe en exceso yETH.
2. Establecer la condición previa para la corrección mediante add_liquidity()
El atacante añade liquidez unilateral para el activo con índice 3 (cbETH, un activo agotado, de bajo peso), depositando ~6.5x el saldo actual del pool para ese activo. Esto recibe solo unos pocos tokens yETH, pero reequilibra el pool lo suficiente como para que la siguiente iteración no oscile violentamente.
Sin este paso, incluso después de reiniciar el producto a un valor distinto de cero en el Paso 3, la iteración en el Paso 4 seguiría produciendo un producto cero debido a oscilaciones violentas provocadas por el desequilibrio extremo. Nuestra simulación con Foundry confirma esto: omitir el Paso 2 hace que la corrección en el Paso 4 falle.
3. Reiniciar el producto mediante remove_liquidity() con 0 yETH
El atacante llama a remove_liquidity() con cantidad 0. No se retiran tokens, pero la función recalcula vb_prod a partir del estado actual del pool usando la ecuación (4) (Sección 0x1.3). Dado que los saldos virtuales no son cero, esto produce un producto distinto de cero (~9.09e19), sobrescribiendo el valor cero corrompido.
4. Corregir el suministro mediante update_rates()
El atacante llama a update_rates() para el activo con índice 6 (WOETH) o 7 (mETH). Si la tasa de cambio ha cambiado desde la última actualización, la función activa _calc_supply() con el producto restaurado (distinto de cero). Esta vez, la iteración converge correctamente y produce un valor de suministro mucho menor que el actual, que estaba inflado. La diferencia se quema del contrato de staking de yETH. Según el post-mortem oficial [2], esto constituye Liquidez de Propiedad del Protocolo (POL, por sus siglas en inglés), lo que significa que las quemas reducen la posición del protocolo en lugar de las tenencias del atacante. Esta asimetría es crítica: cada ciclo reduce el suministro total mientras el saldo de yETH del atacante permanece intacto.
La discrepancia de tasa en sí misma no es una fuente de ganancia; sirve únicamente como un mecanismo de activación. Entre las tres interfaces del pool, solo add_liquidity() y update_rates() invocan _calc_supply(); remove_liquidity() usa escalado proporcional y no lo hace. Después de que el Paso 3 restaura un producto distinto de cero, el atacante necesita activar _calc_supply() sin depositar activos adicionales. Llamar a update_rates() con una tasa desactualizada logra exactamente esto: el cambio de tasa activa el recálculo del suministro a costo cero para el atacante.
Esto explica un aspecto sutil del ataque: durante la fase de preparación (Fase 1), el atacante evitó deliberadamente agregar liquidez para WOETH y mETH. Si esas tasas se hubieran actualizado durante add_liquidity(), no existiría discrepancia de tasa, y update_rates() en este paso no activaría _calc_supply().
5. Retirar activos mediante remove_liquidity()
Al final de cada ciclo, el atacante retira activos mediante remove_liquidity().
Cómo se extrae la ganancia
El mecanismo de ganancia funciona de la siguiente manera: en el Paso 1, el atacante deposita LSTs y recibe yETH acuñado en exceso (debido al producto corrompido). En el Paso 4, cuando se corrige el suministro, el yETH excedente se quema del POL (contrato de staking), no del atacante. En el Paso 5, el atacante retira LSTs proporcionalmente a sus tenencias de yETH. Debido a que el POL absorbió la quema mientras el saldo de yETH del atacante permaneció intacto, el atacante termina retirando más LSTs de los que depositó. Esta diferencia, extraída a lo largo de tres ciclos, totaliza ~$8.1M.
Propósito del rebase
El rastro (entre el primer y el segundo ciclo) también muestra una llamada a OETHVaultProxy.rebase(), que activa un rebase de OETH: el saldo de OETH que posee el contrato de WOETH aumenta, elevando la tasa de cambio efectiva de WOETH. Esta discrepancia de tasa "guardada" es lo que hace posible de nuevo el Paso 4 del segundo ciclo: cuando finalmente se llama a update_rates(), este detecta la discrepancia y activa _calc_supply().
Drenaje a cero
Después de repetir este ciclo de cinco pasos tres veces, el atacante ha reducido el suministro total del pool por debajo de la cantidad de yETH que posee. Una llamada final a remove_liquidity() con el suministro restante lo drena a CERO.
El pool ahora tiene suministro cero, producto cero y vb_sum cero. Este estado degenerado viola la suposición implícita de diseño de que un pool con depósitos previos nunca volvería a su estado no inicializado.
0x3.3 Fase 3: Explotar el suministro cero para ganancias adicionales (~$0.9M)
Objetivo: Acuñar una cantidad enorme de yETH a partir del estado degenerado del pool, y luego canjearlo por activos reales. Esta fase explota la combinación codependiente de la vulnerabilidad secundaria (ruta de arranque no deshabilitada) y el Modo de Falla B (underflow), contribuyendo juntas con ~10% de las pérdidas totales.
1. Acuñación mediante underflow
Con el suministro total en cero, el atacante llama a add_liquidity() con cantidades ínfimas (saldo [1, 1, 1, 1, 1, 1, 1, 9]).
Dado que prev_supply == 0, el código entra en la ruta de arranque descrita en la Sección 0x2 (Análisis de causas raíz): omite el estado almacenado y recalcula vb_prod y vb_sum desde cero mediante _calc_vb_prod_sum(), luego los pasa a _calc_supply(). Esta es la segunda vulnerabilidad en acción: el atacante ha llevado al pool de vuelta a su estado no inicializado, obteniendo control sobre las condiciones iniciales que se pasan al solucionador.
Con todos los saldos virtuales en niveles ínfimos (tasas de cambio cercanas a 1e18), los valores calculados son:
vb_sum= 16vb_prod≈ 9.13e20_supply=vb_sum= 16
Dentro de _calc_supply(), las variables se inicializan como:
l=_amplification * _vb_sum≈ 4.5e20 × 16 ≈ 7.2e21d=_amplification - PRECISION≈ 4.49e20s=_supply= 16r=_vb_prod≈ 9.13e20
Ahora la resta l - s * r:
Esto es negativo. En aritmética uint256 sin verificación, unsafe_sub envuelve esto a aproximadamente , un valor astronómicamente grande. Después de dividir por d (~4.49e20), la estimación de suministro resultante es de ~2.35e56, y el protocolo acuña esta cantidad completa al atacante. Este underflow solo es posible porque el suministro total se llevó a cero en la Fase 2; en cualquier estado no degenerado del pool, se cumple que l > s * r y la resta es segura.
2. Canje por activos reales
El atacante canjea parte del yETH acuñado en exceso por ~1,097e18 WETH en el pool Curve yETH–WETH, drenando sus reservas de WETH. Después de contabilizar los 800e18 WETH gastados en la Fase 1, la ganancia neta fue de ~$0.9M.
Combinado con los ~$8.1M en activos LST extraídos durante la Fase 2, el atacante obtiene aproximadamente $9 millones en ganancias totales después de reembolsar los flash loans.
Un análisis detallado del flujo de fondos, incluyendo el origen de los fondos y las direcciones de destino, ha sido cubierto en otros análisis publicados (p. ej., [2]) y está fuera del alcance de este artículo.
0x4 Corrección de malentendidos
La mayoría de los análisis publicados sobre este incidente se centran en los síntomas aritméticos sin explicar completamente cómo el atacante establece las condiciones previas. Dos afirmaciones específicas merecen corrección.
0x4.1 Afirmación: "El desajuste de redondeo entre pow_up() y pow_down() corrompe el invariante"
Una interpretación común atribuye la causa raíz al uso de pow_up() en algunas rutas de código y pow_down() en otras, argumentando que el desajuste direccional introduce inconsistencias explotables.
Probamos esto directamente: modificamos el contrato para usar pow_down() de manera uniforme (reemplazando todas las llamadas a pow_up()) y volvimos a ejecutar la simulación completa del ataque en Foundry. El exploit tuvo éxito de manera idéntica. El producto sigue colapsando a cero, el suministro sigue drenándose, y el underflow sigue produciendo un minteo inflado.
El redondeo que habilita el estado de producto cero es la división por defecto (floor division) en r = unsafe_div(unsafe_mul(r, sp), s) dentro del bucle de iteración, no la dirección del redondeo en las funciones de potencia utilizadas para estimar los valores iniciales del producto.
0x4.2 Afirmación: "El underflow en la segunda iteración anula el término intermedio"
Una explicación ampliamente citada sostiene que, durante la segunda iteración de _calc_supply(), un underflow en unsafe_sub produce sp ≈ 1.94e18, lo que luego provoca que r se redondee a cero.
Reprodujimos los valores intermedios exactos usando tanto Foundry (repetición on-chain) como Python (verificación matemática). La simulación con Foundry rastrea _calc_supply() iteración por iteración:
======= _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!
La observación crítica: en la iteración 1, sp evalúa a ~1.91e19. Este es un valor positivo pequeño legítimo, no un artefacto de underflow. La resta l - s*r produce un resultado positivo pequeño porque la suma ponderada por amplificación l y el término producto-suministro s*r están cercanos en magnitud en esta iteración.
Lo que anula el producto es lo que sucede a continuación: el bucle interno calcula r = r * sp / s, donde sp (~1.91e19) es mucho menor que s (~1.09e22). El numerador r * sp cae por debajo del denominador s, y la división entera redondea el resultado a cero.
Verificamos esto de forma independiente en Python, calculando los mismos valores con enteros de precisión arbitraria y confirmando que la resta no sufre underflow:
El producto se anula mediante el redondeo en la división, no mediante el underflow en la resta. El underflow de unsafe_sub que infla el suministro ocurre en un contexto completamente distinto: la Fase 3 del ataque, cuando se añade liquidez ínfima a un pool que ha sido drenado a suministro cero.
0x5 Conclusión
El exploit de yETH involucró dos vulnerabilidades con impacto asimétrico. La aritmética insegura en _calc_supply() fue la causa raíz principal: su falla de redondeo hacia abajo (Modo de Falla A) permitió de forma independiente ~$8.1M en pérdidas solo a través de la Fase 2. La ruta de arranque no deshabilitada fue una vulnerabilidad secundaria; combinada con la falla de underflow (Modo de Falla B), permitió ~$0.9M adicionales en la Fase 3, pero solo después de que la Fase 2 ya hubiera drenado el suministro a cero. Este desglose de pérdidas distingue el presente análisis de otros informes publicados, que no separan las ganancias de la Fase 2 y la Fase 3.
El post-mortem oficial [2] identifica cinco causas raíz. Las reclasificamos como dos defectos (aritmética insegura consolidando los elementos oficiales #1 y #5; ruta de arranque no deshabilitada como #4) y dos condiciones arquitectónicas previas (#2 manejo asimétrico de Π; #3 estado de suministro cero habilitado por POL). La distinción: los defectos son errores de implementación que violan la intención de diseño (el solucionador no debería producir productos cero ni underflow), mientras que las condiciones previas son decisiones de diseño que funcionan según lo previsto pero crean una superficie de ataque explotable cuando se combinan con defectos.
Recomendaciones
- Aritmética verificada en solucionadores de invariantes. Usar
safe_divysafe_subcon revert explícito ante underflow/overflow, incluso a costa de la eficiencia de gas. El solucionador ejecuta como máximo 256 iteraciones, y la sobrecarga de gas es insignificante en comparación con el riesgo de seguridad. - Verificaciones de límites en valores intermedios. Validar que el término del producto permanezca dentro de un rango razonable entre iteraciones. Un producto que cae a cero o una estimación de suministro que aumenta en órdenes de magnitud entre iteraciones indica un estado degenerado.
- Límites de desequilibrio. Aplicar una desviación máxima entre el saldo virtual de cualquier activo y su saldo objetivo proporcional al peso. Esto evitaría que la Fase 1 creara las condiciones previas.
- Verificaciones de monotonicidad del invariante. Después de que
_calc_supply()retorne, verificar que el nuevo suministro sea coherente con la dirección del cambio (agregar liquidez nunca debería disminuir el suministro, las actualizaciones de tasas no deberían producir cambios de 10x, etc.). - Deshabilitar permanentemente las rutas de inicialización. Después del primer depósito del pool, bloquear la rama de arranque
prev_supply == 0para que no pueda ser reingresada. Esto evitaría completamente la Fase 3. - Prevenir estados de suministro cero. Asegurar que las quemas a nivel de protocolo (de POL o contratos de staking) no puedan reducir el suministro total a cero mientras el pool mantiene saldos distintos de cero. Un piso mínimo de suministro bloquearía la transición al estado degenerado que habilita el reingreso a la ruta de arranque.
- Detección de anomalías en tiempo real. Monitorear transiciones de estado anormales (como términos de producto que caen a cero, suministro que cambia en órdenes de magnitud, o ciclos repetidos de agregar/eliminar en marcos de tiempo cortos) y activar alertas o interruptores de circuito antes de que las pérdidas se acumulen.
Referencias
- Anuncio del incidente de Yearn Finance
- Post-mortem de Yearn Security
- Documentación de yETH
- Whitepaper de yETH: derivación del invariante
- Transacción del ataque en Phalcon Explorer
- BlockSec: análisis del incidente del boosted pool de Balancer (agosto de 2023)
Acerca de BlockSec
BlockSec es un proveedor integral de seguridad blockchain y cumplimiento cripto. Construimos productos y servicios que ayudan a los clientes a realizar auditorías de código (incluyendo contratos inteligentes, blockchain y wallets), interceptar ataques en tiempo real, analizar incidentes, rastrear fondos ilícitos y cumplir con obligaciones de AML/CFT, a lo largo de todo el ciclo de vida de los protocolos y plataformas.
BlockSec ha publicado múltiples artículos de seguridad blockchain en prestigiosas conferencias, ha reportado varios ataques de día cero en aplicaciones DeFi, ha bloqueado múltiples hackeos para rescatar más de 20 millones de dólares, y ha protegido miles de millones en criptomonedas.
-
Sitio web oficial: https://blocksec.com/
-
Cuenta oficial de Twitter: https://twitter.com/BlockSecTeam



