El 30 de noviembre de 2025, el yETH Weighted Stable Pool de Yearn Finance fue explotado por más de $9 millones [1]. Las causas raíz fueron aritmética insegura en el solucionador de invariante _calc_supply() y una ruta de arranque no deshabilitada que permitía reingresar a la lógica de inicialización. El post-mortem oficial [2] enumera cinco elementos como causas raíz; los reclasificamos como dos defectos (las vulnerabilidades anteriores) y dos condiciones previas arquitectónicas que solo se volvieron explotables en presencia de estos defectos. Otros análisis disponibles se centran en los detalles de las transacciones del ataque paso a paso. Entre los resúmenes de alto nivel y los detalles a nivel de transacción, queda una brecha: ¿por qué y cómo funcionó realmente el ataque? Esta publicación llena esa brecha, utilizando simulaciones de Foundry y Python para rastrear cómo evolucionan los valores clave paso a paso y dónde fallan 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 habilitó ~$0.9M adicionales. Esto aclara cuál vulnerabilidad fue la primaria.
- Reclasificación de causas raíz. Las cinco causas raíz del informe oficial se comprenden mejor como dos defectos de implementación (que consolidan tres de los cinco elementos) más dos condiciones previas arquitectónicas que solo se volvieron explotables en combinación con los defectos.
- Corrección de malentendidos técnicos. La afirmación de que "un desbordamiento inferior en la segunda iteración pone a cero el término producto" no se sostiene: nuestras simulaciones muestran que el producto se pone a cero mediante redondeo en la división, no por desbordamiento inferior, y el desbordamiento inferior generador de ganancias ocurre en una fase completamente diferente.
El resto de esta publicación está organizado de la siguiente manera. La Sección 0x1 proporciona antecedentes sobre el grupo estable ponderado de yETH y su solucionador de invariante. La Sección 0x2 analiza las dos causas raíz y sus modos de fallo. 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 impacto asimétrico:
- Aritmética insegura en
_calc_supply()(primaria, ~$8.1M). La función que recalcula el suministro de yETH desde el estado del pool contiene dos fallos aritméticos: el redondeo hacia abajo enunsafe_div()puede poner a cero el término producto interno, y el desbordamiento inferior enunsafe_sub()puede envolver un valor intermedio a un entero positivo enorme. Esta vulnerabilidad por sí sola fue suficiente para drenar el pool weighted stableswap de yETH. - Ruta de arranque 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ó accesible, habilitando ganancias adicionales del pool Curve yETH/WETH.
Dentro de la vulnerabilidad de aritmética insegura, solo el fallo de redondeo hacia abajo (Modo de Fallo A) se usó en la Fase 2; el fallo de desbordamiento inferior (Modo de Fallo B) es codependiente con la ruta de arranque y juntos habilitaron 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/remover, creando un desequilibrio extremo en los balances virtuales.
- Manipulación del suministro: Explotar el redondeo hacia abajo en
_calc_supply()para colapsar el término producto a cero, luego drenar el suministro total a cero mediante una serie de operaciones de acuñación/quema. Posteriormente, todos los LSTs del pool fueron retirados y canjeados por WETH, resultando en ~$8.1M en pérdidas. - Extracción de ganancias: Activar la ruta de arranque (
prev_supply == 0) con depósitos mínimos, explotando el desbordamiento inferior en_calc_supply()para acuñar ~2.35×10⁵⁶ yETH, los cuales se usaron para drenar el pool Curve yETH/WETH, resultando en ~$0.9M en pérdidas.
Dos malentendidos comunes corregidos:
- "El invariante falla porque
pow_up()ypow_down()redondean de manera diferente." Verificamos esto reemplazandopow_up()conpow_down()en una simulación de Foundry: el exploit sigue funcionando. La discrepancia en el redondeo no es una causa raíz. - "Un desbordamiento inferior en la segunda iteración hace que un término intermedio colapse a cero." Nuestras simulaciones de Foundry y Python no muestran ningún desbordamiento inferior en la segunda iteración. El valor real es ~1.91e19 (no ~1.94e18 como se afirma), un resultado legítimo de una sustracción correcta. Lo que pone a cero el producto es el redondeo hacia abajo posterior en la división, no un desbordamiento inferior.
0x1 Antecedentes
Dos pools perdieron activos en este incidente: el pool weighted stableswap 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 pool weighted stableswap de yETH es donde reside la vulnerabilidad central. Esta sección proporciona los antecedentes necesarios para entender la vulnerabilidad y el exploit.
0x1.1 Balances Virtuales e Invariante
El protocolo yETH es un Creador de Mercado Automatizado (AMM) para Tokens de Staking Líquido (LSTs) de Ethereum [3]. El pool weighted stableswap de yETH afectado agrega múltiples LSTs en un único pool: los usuarios depositan LSTs y reciben yETH como tokens de participación del pool.
Dado que cada LST representa ETH apostado que acumula recompensas con el tiempo, su tasa de cambio respecto al ETH base varía. Para unificar la contabilidad, el pool define un balance virtual para cada activo: balance en cadena × tasa de cambio. Esto normaliza todos los activos en unidades de ETH de la beacon chain. La suma de todos los balances virtuales se denota .
El pool contiene 8 activos (indexados del 0 al 7), cada uno con un peso designado :
El estado del pool está gobernado por un invariante de estilo StableSwap ponderado [4]:
donde:
- es la escala invariante, que equivale directamente al suministro total de yETH de este pool. Cuando el pool está perfectamente equilibrado, .
- es el término producto ponderado, definido como , donde es el peso del activo i y .
- es el factor de amplificación, un único parámetro de 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 Invariante
El protocolo recalcula mediante una iteración de punto fijo limitada a 256 rondas. Este algoritmo está implementado 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 del suministro.
Paso 2: Actualizar el término producto para que coincida con el nuevo suministro.
Paso 3: Verificar convergencia.
Si , devolver ; de lo contrario, repetir 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 sustracción no protege contra desbordamientos inferiores. En condiciones normales del pool, los valores intermedios permanecen dentro de rangos seguros. En estados extremos del pool, no lo hacen. La Sección 0x2.1 analiza estos modos de fallo en detalle.
0x1.3 Las Tres Interfaces y el Solucionador de Invariante
El protocolo expone tres puntos de entrada que afectan el estado del pool actualizando el término 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 tasas de cambio externas | Sí |
remove_liquidity() |
Retira activos proporcionalmente por peso | No (usa escalado proporcional) |
La asimetría importa: add_liquidity() permite depósitos en proporciones arbitrarias (puede desequilibrar masivamente el pool), mientras que remove_liquidity() siempre retira proporcionalmente. Los ciclos repetidos de agregar/remover pueden por lo tanto llevar el pool a estados cada vez más desequilibrados.
El Mecanismo para Actualizar Tasas
Como se discutió anteriormente, los balances virtuales () se calculan en función de las tasas de cambio de los LSTs. 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 tasas mediante 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 asegurar que las tasas de cambio de los activos estén sincronizadas con el estado más reciente.update_rates()permite actualizaciones manuales de tasas.
La función _update_rates() verifica si las tasas de cambio registradas dentro del contrato son consistentes con las tasas externas. Si se detecta una discrepancia, activa un recálculo de los balances virtuales y posteriormente actualiza el invariante; de lo contrario, el proceso de actualización se omite.
Cómo Cada Interfaz Maneja π
Según la forma en que afectan al invariante, estas tres funciones se pueden clasificar en dos categorías. Específicamente, add_liquidity() y update_rates() permiten cambios no proporcionales en los balances virtuales y, por lo tanto, requieren el recálculo iterativo del suministro y el producto . En contraste, remove_liquidity() retira liquidez proporcionalmente 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 balance 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 en la Sección 0x1.1, con distribuido en el producto.
add_liquidity()tiene dos rutas (código mostrado en la Sección 0x2.2):
- Ruta de arranque (cuando
prev_supply == 0): Calculavb_proddesde cero usando la ecuación (4). El hecho de 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) Utiliza una actualización incremental basada en la proporción de los balances virtuales antiguos y nuevos:
donde y son los balances 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 hace que los balances virtuales de los activos correspondientes se actualicen. Su flujo de cálculo posterior sigue la ruta normal deadd_liquidity(), es decir, el invariante se recalcula iterativamente. Además, en función del suministro recién calculado, el contrato acuña o quema yETH para asegurar que el suministro de liquidez sea consistente con el estado de balance virtual actualizado. -
remove_liquidity()siempre calculavb_proddesde cero usando la ecuación (4), después de reducir proporcionalmente cada balance virtual.
0x2 Análisis de Causas Raíz
Se explotaron dos vulnerabilidades, con diferentes roles e impacto. La causa raíz primaria fue un defecto de cálculo en el solucionador de invariante _calc_supply(), que tenía dos modos de fallo: (A) el redondeo hacia abajo podía poner a cero el término producto, degenerando el invariante en un modelo de suma constante y llevando a una acuñación excesiva de LP (inflación del suministro); y (B) una condición de desbordamiento inferior también podía inflar el suministro. Solo el Modo de Fallo A se usó en la Fase 2 (~$8.1M). El Modo de Fallo 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ó accesible. Después de que la Fase 2 llevó el suministro a cero, el Modo de Fallo B combinado con la ruta de arranque habilitó ~$0.9M adicionales en pérdidas (Fase 3).
0x2.1 Aritmética Insegura en _calc_supply() (Primaria)
La Figura 2 mapea la implementación de _calc_supply() al procedimiento matemático de la Sección 0x1.2, anotando los dos sitios de fallo aritmético 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 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) # Paso 1: D[m+1]
r = unsafe_div(unsafe_mul(r, sp), s) # Paso 2: actualización de π (por activo)
Existen dos modos de fallo aritmético dentro de esta función, apuntando a diferentes líneas y produciendo diferentes efectos. 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 unas pocas rondas.
1. Modo de Fallo A: El Redondeo hacia Abajo Pone a Cero el Producto
En el Paso 2, el producto se actualiza por activo como:
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 ocurre después de un gran depósito 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 cero en todas las iteraciones posteriores. El término producto ha colapsado permanentemente.
Una atribución errónea común afirma que este fallo se debe a la discrepancia de redondeo entre pow_up() y pow_down(). La Sección 0x4 presenta evidencia de que esto es incorrecto.
2. Modo de Fallo B: El Desbordamiento Inferior Infla el Suministro
En el Paso 1, la nueva estimación del suministro se calcula como:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # sp = (l - s*r) / d
La sustracción 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 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 mínimas, la rama de inicialización llama a _calc_vb_prod_sum() para calcular valores frescos usando la ecuación (4) (Sección 0x1.3). Con depósitos mínimos, vb_sum es minúsculo (p. ej., 16), pero dividir por balances casi nulos y elevar a potencias altas amplifica el producto a un valor desproporcionadamente grande (p. ej., ~9.13e20). Cuando s * r supera l, la sustracción produce un resultado matemático negativo.
Dado que unsafe_sub() realiza la sustracción en aritmética uint256 sin verificación, un resultado negativo se envuelve a un entero positivo enorme (cercano a ). Este valor envuelto se propaga a través de la división y las iteraciones subsecuentes, 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 tal desbordamiento inferior 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 desbordamiento inferior real que infla el suministro ocurre en un contexto completamente diferente (Fase 3 del ataque).
3. Cómo Estos Fallos Habilitan el Ataque
Estos dos modos de fallo operan en diferentes fases del exploit, con diferentes contribuciones a las ganancias:
-
Modo de Fallo A (Fase 2, ~$8.1M): Cuando el atacante deposita en un pool severamente desequilibrado, el término producto se pone a cero, lo que hace que
_calc_supply()devuelva un suministro inflado. El protocolo acuña en exceso yETH para el atacante. Este modo de fallo solo, sin ninguna participación de la ruta de arranque, permitió al atacante drenar el pool weighted stableswap de yETH de sus activos LST. -
Modo de Fallo B (Fase 3, ~$0.9M): Después de que el suministro se ha drenado a cero, la ruta de arranque recalcula un término producto grande a partir de depósitos mínimos, lo que hace que la sustracción se desborde por abajo. 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 Fallo A es explotable de forma independiente y causó el 90% de las pérdidas, mientras que el Modo de Fallo B requiere que el Modo de Fallo 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:
# Ruta de arranque — calcular vb_prod y vb_sum desde cero
vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
supply = vb_sum
else:
# Ruta normal — usar vb_prod almacenado, realizar verificaciones incrementales
...
# Llamado después de ambas ramas, con prev_supply == 0 como indicador
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 fue diseñada para uso único durante la inicialización del pool, pero nunca se bloqueó 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 accesible. Un atacante que reingresa a esta ruta controla las condiciones iniciales pasadas a _calc_supply(), potencialmente activando los fallos aritméticos descritos anteriormente bajo parámetros que nunca surgirían durante la operación 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 restablecer las tasas internas, lo que permitió al atacante reingresar a la lógica de inicialización con parámetros artificialmente favorables [6]. Si un pool desplegado puede volver a su estado inicial, y qué invariantes se mantienen cuando lo hace, es una pregunta que los diseñadores de protocolos deben abordar explícitamente.
0x3 Análisis del Ataque
El exploit se desarrolla a través de una secuencia coordinada de la transacción del ataque [5], organizada en tres fases. Cada fase se basa en el estado establecido por la anterior.
0x3.1 Fase 1: Desequilibrar el Pool (Preparación)
Objetivo: Crear un desequilibrio extremo en los balances virtuales de los activos.
La figura a continuación ilustra el rastro de la transacción para esta fase (el paso del préstamo flash se omite por razones de espacio):
El atacante primero toma prestadas grandes cantidades de activos LST mediante préstamos flash 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 alrededor de 416e18 yETH en el pool Curve yETH/WETH, y luego utiliza el yETH adquirido 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 proporciones arbitrarias, mientras que remove_liquidity() retira activos proporcionalmente por pesos del pool (resaltado en el rectángulo rojo en la figura anterior). Al ciclar repetidamente operaciones de agregar → remover, depositando solo activos seleccionados mientras se retiran todos los activos proporcionalmente, el atacante lleva progresivamente el 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) han sido agotados en más de un 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, luego drenar el suministro de yETH a cero. Esta fase explota únicamente la vulnerabilidad primaria (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(); - Restablecer el producto mediante
remove_liquidity()con 0 yETH; - Corregir el suministro mediante
update_rates(); - Retirar activos mediante
remove_liquidity().
La figura a continuación muestra el rastro de la transacción, donde tres repeticiones del ciclo de cinco pasos son claramente visibles:
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 balance virtual actual.
add_liquidity() estima el nuevo término producto mediante la actualización incremental en la ecuación (5) (Sección 0x1.3). Dado que para activos de alto peso, las proporciones son todas fracciones bien por debajo de 1, elevadas a potencias grandes. Esto lleva de ~42e18 a ~0.00353e18, un producto estimado casi nulo.
Este producto minúsculo 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 trunca r a cero. La función devuelve un producto cero y un suministro inflado (~vb_sum), lo que hace que el protocolo acuñe en exceso yETH.
2. Establecer la condición previa para la corrección mediante add_liquidity()
El atacante agrega liquidez unilateral para el activo de índice 3 (cbETH, un activo de bajo peso agotado), depositando ~6.5 veces el balance actual del activo en el pool. Esto recibe solo unos pocos tokens yETH, pero reequilibra el pool suficientemente para que la próxima iteración no oscile violentamente.
Sin este paso, incluso después de restablecer el producto a un valor distinto de cero en el Paso 3, la iteración en el Paso 4 aún produciría un producto cero debido a oscilaciones violentas por el desequilibrio extremo. Nuestra simulación de Foundry confirma esto: omitir el Paso 2 hace que la corrección en el Paso 4 falle.
3. Restablecer 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 desde el estado actual del pool usando la ecuación (4) (Sección 0x1.3). Dado que los balances virtuales son distintos de 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 de índice 6 (WOETH) o 7 (mETH). Si la tasa de cambio ha variado 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 inflado. La diferencia es quemada desde el contrato de staking de yETH. Según el post-mortem oficial [2], esto constituye Liquidez de Propiedad del Protocolo (POL), lo que significa que las quemas reducen la posición del protocolo en lugar de la del atacante. Esta asimetría es crítica: cada ciclo reduce el suministro total mientras el balance de yETH del atacante permanece intacto.
La discrepancia de tasas en sí misma no es una fuente de ganancias; sirve puramente 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 sin costo 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 hubieran sido actualizadas durante add_liquidity(), no existiría ninguna discrepancia de tasas, 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 el Beneficio
El mecanismo de beneficio 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 exceso de yETH se quema desde el POL (contrato de staking), no del atacante. En el Paso 5, el atacante retira LSTs proporcionales a su saldo 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 durante 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 mantenido por el contrato WOETH aumenta, elevando la tasa de cambio efectiva de WOETH. Esta discrepancia de tasa "guardada" es lo que hace posible el Paso 4 del segundo ciclo nuevamente: cuando eventualmente se llama a update_rates(), detecta la discrepancia y activa _calc_supply().
Drenar 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 el supuesto de diseño implícito 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 desde el estado degenerado del pool, 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 Fallo B (desbordamiento inferior), contribuyendo juntos ~10% de las pérdidas totales.
1. Acuñación mediante desbordamiento inferior
Con el suministro total en cero, el atacante llama a add_liquidity() con cantidades mínimas (balance [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 pasa estos valores a _calc_supply(). Esta es la segunda vulnerabilidad en acción: el atacante ha llevado el pool de vuelta a su estado no inicializado, obteniendo control sobre las condiciones iniciales pasadas al solucionador.
Con todos los balances virtuales en niveles mínimos (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 sustracción 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 ~2.35e56, y el protocolo acuña esta cantidad completa para el atacante. Este desbordamiento inferior solo es posible porque el suministro total fue llevado a cero en la Fase 2; bajo cualquier estado normal del pool, se cumple l > s * r y la sustracción es segura.
2. Canjear 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, el beneficio neto fue ~$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 devolver los préstamos flash.
El análisis detallado del flujo de fondos, incluyendo la fuente 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: "La discrepancia 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 la discrepancia direccional introduce inconsistencias explotables.
Probamos esto directamente: modificamos el contrato para usar pow_down() uniformemente (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 aún colapsa a cero, el suministro aún se drena y el desbordamiento inferior aún produce una acuñación inflada.
El redondeo que habilita el estado de producto cero es la división con piso 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 usadas para estimar los valores iniciales del producto.
0x4.2 Afirmación: "El desbordamiento inferior en la segunda iteración pone a cero el término intermedio"
Una explicación ampliamente citada sostiene que durante la segunda iteración de _calc_supply(), un desbordamiento inferior en unsafe_sub produce sp ≈ 1.94e18, lo que luego hace que r se redondee a cero.
Reprodujimos los valores intermedios exactos utilizando tanto Foundry (reproducción en cadena) como Python (verificación matemática). La simulación de Foundry rastrea _calc_supply() iteración por iteración:
======= iteración 0 de _calc_supply =======
l = 4905875511098192451202650000000000000000
s = 2514373972590845290489 ← suministro inicial
r = 3538247433646816 ← producto inicial (muy pequeño)
d = 4490000000000000000000
sp = (l - s*r) / d ≈ 1.093e22 ← nuevo suministro salta ~4x
nuevo r ≈ 4.49e22 ← el producto se infla dramáticamente
======= iteración 1 de _calc_supply =======
s = 10926206313726454855296 ← del sp anterior
r = 44892226765713223838396 ← del bucle interno anterior
sp = 19113493328251743069 ← ≈ 1.91e19, legítimamente pequeño
nuevo r = 0 ← ¡se redondea a cero!
La observación crítica: en la iteración 1, sp evalúa a ~1.91e19. Este es un valor positivo legítimamente pequeño, no un artefacto de desbordamiento inferior. La sustracción 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 cerca en magnitud en esta iteración.
Lo que pone a cero el producto es lo que sucede después: 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 trunca 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 sustracción no se desborda por abajo:
El producto se pone a cero mediante redondeo en la división, no mediante desbordamiento inferior en la sustracción. El desbordamiento inferior de unsafe_sub que infla el suministro ocurre en un contexto completamente diferente: la Fase 3 del ataque, cuando se agrega liquidez mínima 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 primaria: su fallo de redondeo hacia abajo (Modo de Fallo A) habilitó 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 el fallo de desbordamiento inferior (Modo de Fallo B), habilitó ~$0.9M adicionales en la Fase 3, pero solo después de que la Fase 2 ya había 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 oficiales #1 y #5; ruta de arranque no deshabilitada como #4) y dos condiciones previas arquitectónicas (#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 desbordamientos inferiores), mientras que las condiciones previas son decisiones de diseño que funcionan según lo previsto pero crean superficie de ataque explotable cuando se combinan con los defectos.
Recomendaciones
- Aritmética verificada en solucionadores de invariantes. Usar
safe_divysafe_subcon reversión explícita ante desbordamiento inferior/superior, incluso a costa de eficiencia en gas. El solucionador ejecuta como máximo 256 iteraciones, y la sobrecarga de gas es insignificante comparada con el riesgo de seguridad. - Verificaciones de límites en valores intermedios. Validar que el término 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 señala un estado degenerado.
- Límites de desequilibrio. Imponer una desviación máxima entre el balance virtual de cualquier activo y su balance proporcional al peso objetivo. Esto evitaría que la Fase 1 cree las condiciones previas.
- Verificaciones de monotonicidad del invariante. Después de que
_calc_supply()devuelve, verificar que el nuevo suministro sea consistente con la dirección del cambio (la adición de 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 volver a ingresarse. Esto evitaría completamente la Fase 3. - Prevenir estados de suministro cero. Asegurar que las quemas a nivel de protocolo (del POL o contratos de staking) no puedan reducir el suministro total a cero mientras el pool mantiene balances distintos de cero. Un piso mínimo de suministro bloquearía la transición al estado degenerado que habilita el reingreso al arranque.
- Detección de anomalías en tiempo real. Monitorear transiciones de estado anormales (como términos producto que caen a cero, suministro que cambia en órdenes de magnitud, o ciclos repetidos de agregar/remover en marcos de tiempo cortos) y activar alertas o disyuntores antes de que las pérdidas se acumulen.
Referencias
- Anuncio del incidente de Yearn Finance
- Post-mortem de seguridad de Yearn
- Documentación de yETH
- Libro blanco de yETH: derivación del invariante
- Transacción del ataque en BlockSec Explorer
- BlockSec: Análisis del incidente del pool potenciado de Balancer (agosto de 2023)
Acerca de BlockSec
BlockSec es un proveedor integral de seguridad blockchain y cumplimiento de criptomonedas. Desarrollamos productos y servicios que ayudan a los clientes a realizar auditorías de código (incluyendo contratos inteligentes, blockchain y billeteras), interceptar ataques en tiempo real, analizar incidentes, rastrear fondos ilícitos y cumplir con las obligaciones AML/CFT, a lo largo del ciclo de vida completo de protocolos y plataformas.
BlockSec ha publicado múltiples artículos de seguridad blockchain en conferencias de prestigio, reportado varios ataques de día cero en aplicaciones DeFi, bloqueado múltiples hackeos para rescatar más de 20 millones de dólares, y asegurado miles de millones en criptomonedas.
-
Sitio web oficial: https://blocksec.com/
-
Cuenta oficial de Twitter: https://twitter.com/BlockSecTeam
-
🔗 Aplicación de Seguridad Phalcon: Reservar una demostración



