Em 30 de novembro de 2025, o pool yETH Weighted Stable da Yearn Finance foi explorado em mais de $9 milhões [1]. As causas raiz foram aritmética insegura no solver de invariante _calc_supply() e um caminho de bootstrap não desativado que permitia reentrada na lógica de inicialização. O post-mortem oficial [2] lista cinco itens como causas raiz; nós os reclassificamos em dois defeitos (as vulnerabilidades acima) e dois pré-condicionantes arquiteturais que se tornaram exploráveis apenas na presença desses defeitos. Outras análises disponíveis focam nos detalhes passo a passo das transações do ataque. Entre os resumos de alto nível e os detalhes no nível de transação, permanece uma lacuna: por que e como o ataque realmente funcionou? Esta publicação preenche essa lacuna, utilizando simulações em Foundry e Python para rastrear como os valores-chave evoluem passo a passo e onde os cálculos falham.
Esta análise faz principalmente as três contribuições a seguir:
- Divisão das perdas por vulnerabilidade. As duas vulnerabilidades não são codependentes: a aritmética insegura sozinha causou ~$8,1M em perdas (90% do total), enquanto o caminho de bootstrap permitiu ~$0,9M adicionais. Isso esclarece qual vulnerabilidade foi primária.
- Reclassificação das causas raiz. As cinco causas raiz do relatório oficial são mais bem compreendidas como dois defeitos de implementação (consolidando três dos cinco itens) mais dois pré-condicionantes arquiteturais que se tornaram exploráveis apenas em combinação com os defeitos.
- Correção de equívocos técnicos. A afirmação de que "um underflow na segunda iteração zera o termo de produto" não se sustenta: nossas simulações mostram que o produto zera por arredondamento na divisão, não por underflow, e o underflow gerador de lucro ocorre em uma fase completamente diferente.
O restante desta publicação está organizado da seguinte forma. A Seção 0x1 fornece contexto sobre o pool de stable ponderado do yETH e seu solver de invariante. A Seção 0x2 analisa as duas causas raiz e seus modos de falha. A Seção 0x3 rastreia o ataque em três fases em detalhes. A Seção 0x4 corrige dois equívocos comuns com evidências de simulação. A Seção 0x5 conclui com recomendações.
TL;DR
Causas raiz: Duas vulnerabilidades foram exploradas, mas com impacto assimétrico:
- Aritmética insegura em
_calc_supply()(primária, ~$8,1M). A função que recalcula o supply do yETH a partir do estado do pool contém duas falhas aritméticas: o arredondamento para baixo emunsafe_div()pode zerar o termo de produto interno, e o underflow emunsafe_sub()pode transformar um valor intermediário em um inteiro positivo enorme. Essa vulnerabilidade sozinha foi suficiente para drenar o pool de stableswap ponderado do yETH. - Caminho de bootstrap não desativado (secundária, ~$0,9M). O ramo de inicialização
prev_supply == 0nunca foi permanentemente bloqueado após o deploy. Depois que a primeira vulnerabilidade drenou o supply a zero, esse caminho tornou-se acessível, permitindo lucro adicional do pool yETH/WETH da Curve.
Dentro da vulnerabilidade de aritmética insegura, apenas a falha de arredondamento para baixo (Modo de Falha A) foi usada na Fase 2; a falha de underflow (Modo de Falha B) é codependente do caminho de bootstrap e, juntas, habilitaram a Fase 3.
O atacante executou uma sequência de três fases:
- Preparação: Distorcer a distribuição de ativos do pool por meio de ciclos repetidos de adição/remoção, criando desequilíbrio extremo nos saldos virtuais.
- Manipulação do supply: Explorar o arredondamento para baixo em
_calc_supply()para colapsar o termo de produto a zero, depois drenar o supply total a zero por meio de uma série de operações de mint/burn. Todos os LSTs do pool foram retirados e trocados por WETH posteriormente, resultando em ~$8,1M em perdas. - Extração de lucro: Acionar o caminho de bootstrap (
prev_supply == 0) com depósitos de dust, explorando o underflow em_calc_supply()para cunhar ~2,35×10⁵⁶ yETH, que foram usados para drenar o pool yETH/WETH da Curve, resultando em ~$0,9M em perdas.
Dois equívocos comuns corrigidos:
- "O invariante quebra porque
pow_up()epow_down()arredondam de forma diferente." Verificamos substituindopow_up()porpow_down()em uma simulação no Foundry: o exploit ainda funciona. A divergência de arredondamento não é uma causa raiz. - "Um underflow na segunda iteração faz um termo intermediário colapsar a zero." Nossas simulações no Foundry e em Python não mostram nenhum underflow na segunda iteração. O valor real é ~1,91e19 (não ~1,94e18 como afirmado), resultado legítimo de uma subtração correta. O que zera o produto é o arredondamento para baixo subsequente na divisão, não um underflow.
0x1 Contexto
Dois pools perderam ativos neste incidente: o pool de stableswap ponderado do yETH (um pool da Yearn contendo LSTs, ~$8,1M perdidos) e o pool yETH/WETH da Curve (um pool de stableswap da Curve, ~$0,9M perdidos). O pool de stableswap ponderado do yETH é onde reside a vulnerabilidade central. Esta seção fornece o contexto necessário para compreender a vulnerabilidade e o exploit.
0x1.1 Saldos Virtuais e o Invariante
O protocolo yETH é um Automated Market Maker (AMM) para Ethereum Liquid Staking Tokens (LSTs) [3]. O pool de stableswap ponderado do yETH afetado agrega múltiplos LSTs em um único pool: usuários depositam LSTs e recebem yETH como tokens de participação no pool.
Como cada LST representa ETH em staking que acumula recompensas ao longo do tempo, sua taxa de câmbio em relação ao ETH base muda. Para unificar a contabilidade, o pool define um saldo virtual para cada ativo: saldo on-chain × taxa de câmbio. Isso normaliza todos os ativos em unidades de ETH da beacon chain. A soma de todos os saldos virtuais é denotada .
O pool contém 8 ativos (indexados de 0 a 7), cada um com um peso designado:
O estado do pool é governado por um invariante estilo StableSwap ponderado [4]:
onde:
- é a escala do invariante, que é diretamente igual ao supply total de yETH deste pool. Quando o pool está perfeitamente equilibrado, .
- é o termo de produto ponderado, definido como , onde é o peso do ativo i e .
- é o fator de amplificação, um único parâmetro do protocolo (não ). denota esse fator elevado à potência , onde é o número de ativos (8 neste pool). Ele controla o formato da curva entre soma constante (próximo ao equilíbrio) e produto constante (nos extremos).
A propriedade fundamental: não possui uma solução de forma fechada. Ele deve ser resolvido numericamente. Esse solver, _calc_supply(), é onde reside a vulnerabilidade aritmética.
0x1.2 O Solver de Invariante
O protocolo recalcula por meio de uma iteração de ponto fixo limitada a 256 rodadas. Esse algoritmo é implementado como _calc_supply() no código (detalhado na Seção 0x2.1). Cada rodada executa três etapas:
Etapa 1: Atualizar a estimativa de supply.
Etapa 2: Atualizar o termo de produto para corresponder ao novo supply.
Etapa 3: Verificar convergência.
Se , retornar ; caso contrário, repetir a partir da Etapa 1.
Os valores iniciais , e influenciam as iterações iniciais; embora teoricamente irrelevantes para a convergência final, eles afetam os resultados na prática devido à iteração finita e à aritmética de precisão fixa.
A implementação usa operações inteiras de precisão fixa: a divisão arredonda para baixo e a subtração não protege contra underflow. Sob condições normais do pool, os valores intermediários permanecem dentro de intervalos seguros. Sob estados extremos do pool, não permanecem. A Seção 0x2.1 analisa esses modos de falha em detalhes.
0x1.3 As Três Interfaces e o Solver de Invariante
O protocolo expõe três pontos de entrada que afetam o estado do pool ao atualizar o termo de produto ponderado (armazenado como vb_prod no código):
| Interface | O que faz | Aciona _calc_supply()? |
|---|---|---|
add_liquidity() |
Deposita ativos em proporções arbitrárias | Sim |
update_rates() |
Atualiza taxas de câmbio externas | Sim |
remove_liquidity() |
Retira ativos proporcionalmente ao peso | Não (usa escalonamento proporcional) |
A assimetria importa: add_liquidity() permite depósitos em proporções arbitrárias (pode distorcer massivamente o pool), enquanto remove_liquidity() sempre retira proporcionalmente. Ciclos repetidos de adição/remoção podem, portanto, levar o pool a estados progressivamente mais desequilibrados.
O Mecanismo de Atualização de Taxas
Como discutido acima, os saldos virtuais () são calculados com base nas taxas de câmbio dos LSTs. Por isso, é importante entender a forma como as taxas são atualizadas.
Especificamente, as funções add_liquidity() e update_rates() podem atualizar taxas por meio da função interna _update_rates(), enquanto a função remove_liquidity() não realiza sincronização de taxas.
add_liquidity()invoca_update_rates()antes de executar operações críticas para garantir que as taxas de câmbio dos ativos estejam sincronizadas com o estado mais recente.update_rates()permite atualizações manuais de taxas.
A função _update_rates() verifica se as taxas de câmbio registradas no contrato são consistentes com as taxas externas. Se uma discrepância for detectada, ela aciona um recálculo dos saldos virtuais e, subsequentemente, atualiza o invariante; caso contrário, o processo de atualização é ignorado.
Como Cada Interface Lida com π
Com base em como elas afetam o invariante, essas três funções podem ser classificadas em duas categorias. Especificamente, add_liquidity() e update_rates() permitem alterações não proporcionais nos saldos virtuais e, portanto, exigem recomputação iterativa do supply e do produto . Em contraste, remove_liquidity() retira liquidez proporcionalmente e não requer cálculo iterativo.
A fórmula base para calcular o produto do zero é:
onde é o supply, é o peso do ativo , é seu saldo virtual (armazenado como vb[i] no código) e n é o número de ativos. Esta forma é algebricamente equivalente à definição na Seção 0x1.1, com distribuído no produto.
add_liquidity()tem dois caminhos (código mostrado na Seção 0x2.2):
- Caminho de bootstrap (quando
prev_supply == 0): Calculavb_proddo zero usando a equação (4). O fato de este caminho permanecer acessível após o deploy é a vulnerabilidade de gerenciamento de estado discutida na Seção 0x2.2. - Caminho normal (quando
prev_supply > 0): O processo de cálculo é dividido em duas etapas:-
a) Usa uma atualização incremental baseada na razão entre saldos virtuais antigos e novos:
onde e são os saldos virtuais antes e depois do depósito.
-
b) Calibra iterativamente o valor preciso chamando
_calc_supply()com essa estimativa como entrada, recalculando o invariante e o valor exato de .
-
-
update_rates()é acionada quando as taxas de câmbio mudam, fazendo com que os saldos virtuais dos ativos correspondentes sejam atualizados. Seu fluxo de cálculo subsequente segue o caminho normal deadd_liquidity(), ou seja, o invariante é recalculado iterativamente. Além disso, com base no supply recém-calculado, o contrato cunha ou queima yETH para garantir que o supply de liquidez permaneça consistente com o estado atualizado do saldo virtual. -
remove_liquidity()sempre calculavb_proddo zero usando a equação (4), após reduzir proporcionalmente cada saldo virtual.
0x2 Análise de Causa Raiz
Duas vulnerabilidades foram exploradas, com papéis e impactos diferentes. A causa raiz primária foi uma falha de cálculo no solver de invariante _calc_supply(), que tinha dois modos de falha: (A) o arredondamento para baixo poderia zerar o termo de produto, degenerando o invariante em um modelo de soma constante e levando ao excesso de cunhagem de LP (inflação de supply); e (B) uma condição de underflow também poderia inflar o supply. Apenas o Modo de Falha A foi usado na Fase 2 (~$8,1M). O Modo de Falha B era codependente da vulnerabilidade secundária.
A causa raiz secundária foi um defeito de gerenciamento de estado: o ramo de inicialização do pool permaneceu acessível. Após a Fase 2 ter reduzido o supply a zero, o Modo de Falha B combinado com o caminho de bootstrap habilitou ~$0,9M adicionais em perdas (Fase 3).
0x2.1 Aritmética Insegura em _calc_supply() (Primária)
A Figura 2 mapeia a implementação de _calc_supply() para o procedimento matemático da Seção 0x1.2, anotando os dois locais de falha aritmética analisados abaixo:
As variáveis do código mapeiam para os termos matemáticos da seguinte forma:
| Variável no código | Papel matemático |
|---|---|
s |
Estimativa atual de supply |
r |
Termo de produto |
sp |
Próxima estimativa de supply |
l |
Constante do numerador: |
d |
Constante do denominador: |
As expressões críticas são:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # Etapa 1: D[m+1]
r = unsafe_div(unsafe_mul(r, sp), s) # Etapa 2: atualização de π (por ativo)
Existem dois modos de falha aritmética dentro desta função, visando linhas diferentes e produzindo efeitos diferentes. Ambos requerem que o pool esteja em um estado extremo para serem acionados.
Sob condições normais, a iteração se comporta corretamente: l - s * r é um valor positivo modesto, e a iteração converge em poucas rodadas.
1. Modo de Falha A: Arredondamento para Baixo Zera o Produto
Na Etapa 2, o produto é atualizado por ativo como:
r = unsafe_div(unsafe_mul(r, sp), s) # r = r * sp / s
Como unsafe_div() realiza divisão inteira, ele sempre arredonda para baixo. Quando o pool está gravemente desequilibrado e sp é muito menor que s (como ocorre após um grande depósito manipulado), o numerador r * sp pode se tornar menor que o denominador s. A divisão inteira então resulta em r = 0.
Uma vez que r é zero, ele permanece zero em todas as iterações subsequentes. O termo de produto colapsou permanentemente.
Uma atribuição equivocada comum afirma que essa falha decorre da divergência de arredondamento entre pow_up() e pow_down(). A Seção 0x4 apresenta evidências de que isso está incorreto.
2. Modo de Falha B: Underflow Infla o Supply
Na Etapa 1, a nova estimativa de supply é calculada como:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # sp = (l - s*r) / d
A subtração l - s*r corresponde a na equação 2. Sob condições normais, isso é positivo. No entanto, quando o pool atinge um estado degenerado com supply zero, o ramo de inicialização em add_liquidity() (detalhado na Seção 0x2.2) recalcula o termo de produto do zero, e as magnitudes relativas podem se inverter.
Especificamente, quando add_liquidity() é chamada em um pool com supply zero com quantidades de dust, o ramo de inicialização chama _calc_vb_prod_sum() para calcular valores novos usando a equação (4) (Seção 0x1.3). Com depósitos minúsculos, vb_sum é ínfimo (por exemplo, 16), mas dividir por saldos próximos de zero e elevar a potências altas amplifica o produto a um valor desproporcionalmente grande (por exemplo, ~9,13e20). Quando s * r excede l, a subtração produz um resultado matematicamente negativo.
Como unsafe_sub() realiza subtração em aritmética uint256 sem verificação, um resultado negativo envolve e resulta em um inteiro positivo enorme (próximo a ). Esse valor envolvido se propaga pela divisão e iterações subsequentes, produzindo uma estimativa de supply absurdamente grande, que o protocolo então cunha como tokens yETH reais.
Uma afirmação comum sustenta que tal underflow ocorre na segunda iteração de uma etapa específica de manipulação de supply. A Seção 0x4 mostra que essa afirmação está incorreta: o underflow real que infla o supply ocorre em um contexto completamente diferente (Fase 3 do ataque).
3. Como Essas Falhas Habilitam o Ataque
Esses dois modos de falha operam em fases diferentes do exploit, com diferentes contribuições de lucro:
-
Modo de Falha A (Fase 2, ~$8,1M): Quando o atacante deposita em um pool gravemente desequilibrado, o termo de produto zera, fazendo com que
_calc_supply()retorne um supply inflado. O protocolo cunha yETH em excesso para o atacante. Esse modo de falha sozinho, sem qualquer envolvimento do caminho de bootstrap, permitiu ao atacante drenar o pool de stableswap ponderado do yETH de seus ativos LST. -
Modo de Falha B (Fase 3, ~$0,9M): Após o supply ter sido drenado a zero, o caminho de bootstrap recalcula um grande termo de produto a partir de depósitos de dust, fazendo com que a subtração sofra underflow. O protocolo cunha uma quantidade astronomicamente grande de yETH, que o atacante usa para drenar o pool yETH/WETH da Curve separado.
A dependência é unidirecional: o Modo de Falha A é explorado de forma independente e causou 90% das perdas, enquanto o Modo de Falha B requer que o Modo de Falha A primeiro reduza o supply a zero.
0x2.2 Caminho de Bootstrap Não Desativado (Secundário)
A função add_liquidity() contém um ramo para o depósito inicial do pool:
A lógica pode ser abstraída da seguinte forma:
if prev_supply == 0:
# Caminho de bootstrap — calcula vb_prod e vb_sum do zero
vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
supply = vb_sum
else:
# Caminho normal — usa vb_prod armazenado, realiza verificações incrementais
...
# Chamado após ambos os ramos, com prev_supply == 0 como sinalizador
supply, vb_prod = _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, prev_supply == 0)
Quando prev_supply == 0, a função ignora o estado armazenado e recalcula vb_prod e vb_sum do zero via _calc_vb_prod_sum(), usando a equação (4) (Seção 0x1.3). Esse ramo de bootstrap foi projetado para uso único durante a inicialização do pool, mas nunca foi permanentemente bloqueado após o primeiro depósito.
Se o supply total puder ser reduzido a zero (por qualquer combinação de burns e retiradas), o ramo torna-se acessível novamente. Um atacante que reentra neste caminho controla as condições iniciais passadas para _calc_supply(), potencialmente acionando as falhas aritméticas descritas acima sob parâmetros que nunca surgiriam durante a operação normal do pool.
Este é um padrão de vulnerabilidade conhecido. Em agosto de 2023, o incidente do Balancer V2 dependeu de forma similar de reduzir o supply a zero para redefinir as taxas internas, permitindo ao atacante reentrar na lógica de inicialização com parâmetros artificialmente favoráveis [6]. Se um pool implantado pode ser levado de volta ao seu estado inicial e quais invariantes se sustentam quando isso ocorre é uma questão que os projetistas de protocolo devem abordar explicitamente.
0x3 Análise do Ataque
O exploit se desdobra em uma sequência coordenada da transação de ataque [5], organizada em três fases. Cada fase constrói sobre o estado estabelecido pela anterior.
0x3.1 Fase 1: Distorcendo o Pool (Preparação)
Objetivo: Criar desequilíbrio extremo nos saldos virtuais entre os ativos.
A figura abaixo ilustra o rastreamento da transação para esta fase (a etapa de flash loan foi omitida por restrições de espaço):
O atacante primeiro toma emprestado grandes quantidades de ativos LST via flash loans do Balancer e Aave, especificamente 5.500e18 wstETH, 3.100e18 WETH, 1.800e18 rETH, 2.000e18 ETHx e 200e18 cbETH.
Em seguida, o atacante troca aproximadamente 800e18 WETH por cerca de 416e18 yETH no pool yETH/WETH da Curve, e então usa o yETH adquirido para remover liquidez do pool.
A manipulação central aproveita a assimetria de interface descrita na Seção 0x1 (Contexto): add_liquidity() permite depósitos em proporções arbitrárias, enquanto remove_liquidity() retira ativos proporcionalmente pelos pesos do pool (destacado no retângulo vermelho na Figura acima). Ao repetir ciclos de adição → remoção, depositando apenas ativos selecionados enquanto retira todos os ativos proporcionalmente, o atacante progressivamente leva o pool a um estado gravemente desequilibrado:
| Ativo | Peso | Antes | Depois | Variação |
|---|---|---|---|---|
| 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% |
Os ativos 3 (cbETH), 6 (WOETH) e 7 (mETH) foram depletados em mais de 98%. Esse desequilíbrio não extrai lucro diretamente. Ele cria as pré-condições numéricas para a próxima fase.
0x3.2 Fase 2: Colapsando o Supply a Zero (~$8,1M)
Objetivo: Levar o produto do invariante a zero, depois drenar o supply do yETH a zero. Esta fase explora apenas a vulnerabilidade primária (aritmética insegura) e causou ~90% das perdas totais.
Esta fase usa um ciclo repetido de cinco etapas, executado três vezes:
- Corromper o produto via
add_liquidity(); - Estabelecer pré-condição para correção via
add_liquidity(); - Redefinir o produto via
remove_liquidity()com 0 yETH; - Corrigir o supply via
update_rates(); - Retirar ativos via
remove_liquidity().
A figura abaixo mostra o rastreamento da transação, onde três repetições do ciclo de cinco etapas são claramente visíveis:
1. Corromper o produto via add_liquidity()
O atacante deposita grandes quantidades de ativos de alto peso (índices 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH), cada um aproximadamente três vezes seu saldo virtual atual.
add_liquidity() estima o novo termo de produto via atualização incremental na equação (5) (Seção 0x1.3). Como para ativos de alto peso, as razões são todas frações bem abaixo de 1, elevadas a potências altas. Isso reduz de ~42e18 para ~0,00353e18, um produto estimado quase zero.
Esse produto minúsculo entra em _calc_supply(). Na iteração, a atualização do produto r = r * sp / s encontra a condição de arredondamento para baixo descrita na Seção 0x2 (Análise de Causa Raiz): o numerador fica abaixo do denominador, e a divisão inteira arredonda r para zero. A função retorna um produto zero e um supply inflado (~vb_sum), fazendo com que o protocolo cunhe yETH em excesso.
2. Estabelecer pré-condição para correção via add_liquidity()
O atacante adiciona liquidez unilateral para o ativo de índice 3 (cbETH, um ativo depletado de baixo peso), depositando ~6,5 vezes o saldo atual do ativo no pool. Isso recebe apenas alguns tokens yETH, mas reequilibra o pool o suficiente para que a próxima iteração não oscile violentamente.
Sem essa etapa, mesmo após redefinir o produto para não-zero na Etapa 3, a iteração na Etapa 4 ainda produziria um produto zero devido a oscilações violentas causadas pelo desequilíbrio extremo. Nossa simulação no Foundry confirma isso: pular a Etapa 2 faz com que a correção na Etapa 4 falhe.
3. Redefinir o produto via remove_liquidity() com 0 yETH
O atacante chama remove_liquidity() com quantidade 0. Nenhum token é retirado, mas a função recalcula vb_prod a partir do estado atual do pool usando a equação (4) (Seção 0x1.3). Como os saldos virtuais são não-zero, isso produz um produto não-zero (~9,09e19), sobrescrevendo o valor zero corrompido.
4. Corrigir o supply via update_rates()
O atacante chama update_rates() para o ativo de índice 6 (WOETH) ou 7 (mETH). Se a taxa de câmbio mudou desde a última atualização, a função aciona _calc_supply() com o produto restaurado (não-zero). Desta vez, a iteração converge corretamente e produz um valor de supply muito menor que o atual inflado. A diferença é queimada do contrato de staking do yETH. De acordo com o post-mortem oficial [2], isso constitui Liquidez de Propriedade do Protocolo (POL), o que significa que as queimas reduzem a posição do protocolo, não os saldos do atacante. Essa assimetria é crítica: cada ciclo reduz o supply total enquanto o saldo de yETH do atacante permanece intacto.
A discrepância de taxa em si não é uma fonte de lucro; ela serve puramente como um mecanismo de acionamento. Entre as três interfaces do pool, apenas add_liquidity() e update_rates() invocam _calc_supply(); remove_liquidity() usa escalonamento proporcional e não o faz. Após a Etapa 3 restaurar um produto não-zero, o atacante precisa acionar _calc_supply() sem depositar ativos adicionais. Chamar update_rates() com uma taxa desatualizada realiza exatamente isso: a mudança de taxa aciona o recálculo do supply sem custo para o atacante.
Isso explica um aspecto sutil do ataque: durante a fase de preparação (Fase 1), o atacante deliberadamente evitou adicionar liquidez para WOETH e mETH. Se essas taxas tivessem sido atualizadas durante add_liquidity(), não haveria discrepância de taxa, e update_rates() nesta etapa não acionaria _calc_supply().
5. Retirar ativos via remove_liquidity()
Ao final de cada ciclo, o atacante retira ativos via remove_liquidity().
Como o Lucro É Extraído
O mecanismo de lucro funciona da seguinte forma: na Etapa 1, o atacante deposita LSTs e recebe yETH cunhado em excesso (devido ao produto corrompido). Na Etapa 4, quando o supply é corrigido, o yETH excedente é queimado do POL (contrato de staking), não do atacante. Na Etapa 5, o atacante retira LSTs proporcionais aos seus saldos de yETH. Como o POL absorveu a queima enquanto o saldo de yETH do atacante permaneceu intacto, o atacante acaba retirando mais LSTs do que depositou. Essa diferença, extraída ao longo de três ciclos, totaliza ~$8,1M.
Propósito do Rebase
O rastreamento (entre o primeiro e o segundo ciclo) também mostra uma chamada a OETHVaultProxy.rebase(), que aciona um rebase do OETH: o saldo de OETH detido pelo contrato WOETH aumenta, elevando a taxa de câmbio efetiva do WOETH. Essa discrepância de taxa "salva" é o que torna possível a Etapa 4 do segundo ciclo novamente: quando update_rates() é eventualmente chamada, ela detecta a discrepância e aciona _calc_supply().
Drenando até zero
Após repetir esse ciclo de cinco etapas três vezes, o atacante reduziu o supply total do pool abaixo da quantidade de yETH que detém. Uma chamada final a remove_liquidity() com o supply restante o drena até ZERO.
O pool agora tem supply zero, produto zero e vb_sum zero. Esse estado degenerado viola a suposição implícita de design de que um pool com depósitos anteriores nunca retornaria ao seu estado não inicializado.
0x3.3 Fase 3: Explorando Supply Zero para Lucro Adicional (~$0,9M)
Objetivo: Cunhar uma quantidade enorme de yETH a partir do estado degenerado do pool, depois trocá-lo por ativos reais. Esta fase explora a combinação codependente da vulnerabilidade secundária (caminho de bootstrap não desativado) e o Modo de Falha B (underflow), contribuindo juntos com ~10% das perdas totais.
1. Cunhagem via underflow
Com o supply total em zero, o atacante chama add_liquidity() com quantidades de dust (saldo [1, 1, 1, 1, 1, 1, 1, 9]).
Como prev_supply == 0, o código entra no caminho de bootstrap descrito na Seção 0x2 (Análise de Causa Raiz): ele ignora o estado armazenado e recalcula vb_prod e vb_sum do zero via _calc_vb_prod_sum(), então os passa para _calc_supply(). Esta é a segunda vulnerabilidade em ação: o atacante levou o pool de volta ao seu estado não inicializado, ganhando controle sobre as condições iniciais fornecidas ao solver.
Com todos os saldos virtuais em níveis de dust (taxas de câmbio próximas a 1e18), os valores calculados são:
vb_sum= 16vb_prod≈ 9,13e20_supply=vb_sum= 16
Dentro de _calc_supply(), as variáveis são inicializadas como:
l=_amplification * _vb_sum≈ 4,5e20 × 16 ≈ 7,2e21d=_amplification - PRECISION≈ 4,49e20s=_supply= 16r=_vb_prod≈ 9,13e20
Agora a subtração l - s * r:
Isso é negativo. Na aritmética uint256 sem verificação, unsafe_sub envolve esse valor para aproximadamente , um valor astronomicamente grande. Após a divisão por d (~4,49e20), a estimativa de supply resultante é ~2,35e56, e o protocolo cunha toda essa quantidade para o atacante. Esse underflow só é possível porque o supply total foi reduzido a zero na Fase 2; sob qualquer estado não degenerado do pool, l > s * r se sustenta e a subtração é segura.
2. Trocando por ativos reais
O atacante troca parte do yETH cunhado em excesso por ~1.097e18 WETH no pool yETH–WETH da Curve, drenando suas reservas de WETH. Após contabilizar os 800e18 WETH gastos na Fase 1, o lucro líquido foi de ~$0,9M.
Combinado com os ~$8,1M em ativos LST extraídos durante a Fase 2, o atacante obtém aproximadamente $9 milhões em lucro total após reembolsar os flash loans.
A análise detalhada do fluxo de fundos, incluindo a origem dos fundos e os endereços de destino, foi coberta em outras análises publicadas (por exemplo, [2]) e está fora do escopo deste artigo.
0x4 Corrigindo Equívocos
A maioria das análises publicadas sobre este incidente foca nos sintomas aritméticos sem explicar completamente como o atacante configurou as pré-condições. Duas afirmações específicas merecem correção.
0x4.1 Afirmação: "A divergência de arredondamento entre pow_up() e pow_down() corrompe o invariante"
Uma interpretação comum atribui a causa raiz ao uso de pow_up() em alguns caminhos de código e pow_down() em outros, argumentando que a divergência direcional introduz inconsistências exploráveis.
Testamos isso diretamente: modificamos o contrato para usar pow_down() de forma uniforme (substituindo todas as chamadas a pow_up()) e re-executamos a simulação completa do ataque no Foundry. O exploit teve sucesso de forma idêntica. O produto ainda colapsa a zero, o supply ainda é drenado, e o underflow ainda produz uma cunhagem inflada.
O arredondamento que habilita o estado de produto zero é a divisão com truncamento em r = unsafe_div(unsafe_mul(r, sp), s) dentro do loop de iteração, não a direção do arredondamento nas funções de potência usadas para estimar valores iniciais do produto.
0x4.2 Afirmação: "Underflow na segunda iteração zera o termo intermediário"
Uma explicação amplamente citada sustenta que durante a segunda iteração de _calc_supply(), um underflow em unsafe_sub produz sp ≈ 1,94e18, o que então faz r arredondar para zero.
Reproduzimos os valores intermediários exatos usando tanto Foundry (reprodução on-chain) quanto Python (verificação matemática). A simulação no Foundry rastreia _calc_supply() iteração por iteração:
======= iteração 0 de _calc_supply =======
l = 4905875511098192451202650000000000000000
s = 2514373972590845290489 ← supply inicial
r = 3538247433646816 ← produto inicial (muito pequeno)
d = 4490000000000000000000
sp = (l - s*r) / d ≈ 1.093e22 ← novo supply salta ~4x
novo r ≈ 4.49e22 ← produto infla dramaticamente
======= iteração 1 de _calc_supply =======
s = 10926206313726454855296 ← do sp anterior
r = 44892226765713223838396 ← do loop interno anterior
sp = 19113493328251743069 ← ≈ 1.91e19, legitimamente pequeno
novo r = 0 ← arredonda para zero!
A observação crítica: na iteração 1, sp é avaliado como ~1,91e19. Esse é um valor positivo legitimamente pequeno, não um artefato de underflow. A subtração l - s*r produz um resultado positivo pequeno porque a soma ponderada por amplificação l e o termo de produto-supply s*r estão próximos em magnitude nesta iteração.
O que zera o produto é o que acontece depois: o loop interno calcula r = r * sp / s, onde sp (~1,91e19) é muito menor que s (~1,09e22). O numerador r * sp fica abaixo do denominador s, e a divisão inteira arredonda o resultado para zero.
Verificamos isso de forma independente em Python, calculando os mesmos valores com inteiros de precisão arbitrária e confirmando que a subtração não sofre underflow:
O produto zera por arredondamento na divisão, não por underflow na subtração. O underflow em unsafe_sub que infla o supply ocorre em um contexto completamente diferente: a Fase 3 do ataque, quando liquidez de dust é adicionada a um pool que foi drenado até supply zero.
0x5 Conclusão
O exploit do yETH envolveu duas vulnerabilidades com impacto assimétrico. A aritmética insegura em _calc_supply() foi a causa raiz primária: sua falha de arredondamento para baixo (Modo de Falha A) habilitou de forma independente ~$8,1M em perdas através da Fase 2 isoladamente. O caminho de bootstrap não desativado foi uma vulnerabilidade secundária; combinado com a falha de underflow (Modo de Falha B), habilitou ~$0,9M adicionais na Fase 3, mas somente após a Fase 2 já ter drenado o supply a zero. Essa divisão de perdas distingue a presente análise de outros relatórios publicados, que não separam os lucros da Fase 2 e da Fase 3.
O post-mortem oficial [2] identifica cinco causas raiz. Nós as reclassificamos como dois defeitos (aritmética insegura consolidando os itens oficiais #1 e #5; caminho de bootstrap não desativado como #4) e dois pré-condicionantes arquiteturais (#2 tratamento assimétrico de Π; #3 estado de supply zero habilitado por POL). A distinção: defeitos são bugs de implementação que violam a intenção de design (o solver não deve produzir produtos zero ou underflow), enquanto pré-condicionantes são escolhas de design que funcionam conforme o pretendido, mas criam superfície de ataque explorável quando combinadas com defeitos.
Recomendações
- Aritmética verificada em solvers de invariante. Use
safe_divesafe_subcom revert explícito em underflow/overflow, mesmo ao custo da eficiência de gas. O solver executa no máximo 256 iterações, e o overhead de gas é insignificante em comparação ao risco de segurança. - Verificações de limites em valores intermediários. Valide que o termo de produto permanece dentro de um intervalo sensato entre as iterações. Um produto que cai a zero ou uma estimativa de supply que aumenta em ordens de magnitude entre iterações sinaliza um estado degenerado.
- Limites de desequilíbrio. Imponha desvio máximo entre o saldo virtual de qualquer ativo e seu saldo proporcional ao peso alvo. Isso impediria a Fase 1 de criar as pré-condições.
- Verificações de monotonicidade do invariante. Após
_calc_supply()retornar, verifique se o novo supply é consistente com a direção da mudança (adição de liquidez nunca deve diminuir o supply, atualizações de taxa não devem produzir mudanças de 10x, etc.). - Desativar permanentemente caminhos de inicialização. Após o primeiro depósito do pool, bloqueie o ramo de bootstrap
prev_supply == 0para que não possa ser reentrando. Isso impediria a Fase 3 completamente. - Prevenir estados de supply zero. Garanta que queimas no nível do protocolo (de POL ou contratos de staking) não possam reduzir o supply total a zero enquanto o pool detém saldos não-zero. Um piso mínimo de supply bloquearia a transição para o estado degenerado que habilita a reentrada no bootstrap.
- Detecção de anomalias em tempo real. Monitore transições de estado anormais (como termos de produto caindo a zero, supply mudando em ordens de magnitude, ou ciclos repetidos de adição/remoção em curtos intervalos de tempo) e acione alertas ou circuit breakers antes que as perdas se acumulem.
Referências
- Anúncio do incidente da Yearn Finance
- Post-mortem de segurança da Yearn
- Documentação do yETH
- Whitepaper do yETH: derivação do invariante
- Transação do ataque no BlockSec Explorer
- BlockSec: Análise do incidente do pool boosted do Balancer (agosto de 2023)
Sobre a BlockSec
A BlockSec é um provedor completo de segurança em blockchain e conformidade cripto. Desenvolvemos produtos e serviços que ajudam nossos clientes a realizar auditorias de código (incluindo contratos inteligentes, blockchain e carteiras), interceptar ataques em tempo real, analisar incidentes, rastrear fundos ilícitos e cumprir obrigações de AML/CFT, ao longo de todo o ciclo de vida de protocolos e plataformas.
A BlockSec publicou múltiplos artigos de segurança em blockchain em conferências renomadas, reportou vários ataques de dia zero em aplicações DeFi, bloqueou múltiplos hacks para resgatar mais de 20 milhões de dólares e protegeu bilhões em criptomoedas.
-
Site oficial: https://blocksec.com/
-
Conta oficial no Twitter: https://twitter.com/BlockSecTeam



