Back to Blog

Incidente #5 da Yearn Finance: Aritmética Insegura no Invariant Solver Faz Jus ao Nome

Code Auditing
11 de fevereiro de 2026
26 min read

Em 30 de novembro de 2025, o yETH Weighted Stable Pool da Yearn Finance foi explorado em mais de $9 milhões [1]. As causas raiz foram aritmética insegura no solucionador de invariante _calc_supply() e um caminho de bootstrap não desativado que permitia a reentrada na lógica de inicialização. O post-mortem oficial [2] lista cinco itens como causas raiz; nós os reclassificamos como duas falhas (as vulnerabilidades acima) e duas precondições arquiteturais que se tornaram exploráveis apenas na presença dessas falhas. Outras análises disponíveis focam nos detalhes passo a passo da transação de ataque. Entre resumos de alto nível e detalhes no nível de transação, permanece uma lacuna: por que e como o ataque realmente funcionou? Este post preenche essa lacuna, usando Foundry e simulações em Python para rastrear como os valores-chave evoluem passo a passo e onde os cálculos falham.

Esta análise contribui principalmente com os três pontos a seguir:

  1. Divisão das perdas por vulnerabilidade. As duas vulnerabilidades não são co-dependentes: apenas a aritmética insegura causou ~$8,1M em perdas (90% do total), enquanto o caminho de bootstrap possibilitou um adicional de ~$0,9M. Isso esclarece qual vulnerabilidade foi a principal.
  2. Reclassificação das causas raiz. As cinco causas raiz do relatório oficial são mais bem compreendidas como duas falhas de implementação (consolidando três dos cinco itens) mais duas precondições arquiteturais que se tornaram exploráveis apenas em combinação com as falhas.
  3. Correção de mal-entendidos técnicos. A alegação de que "um underflow na segunda iteração zera o termo do produto" não se sustenta: nossas simulações mostram que o produto zera por meio de arredondamento na divisão, não por underflow, e o underflow que gera lucro ocorre em uma fase completamente diferente.

O restante deste post está organizado da seguinte forma. A Seção 0x1 fornece o histórico sobre o weighted stable pool do yETH e seu solucionador de invariante. A Seção 0x2 analisa as duas causas raiz e seus modos de falha. A Seção 0x3 detalha o ataque de três fases. A Seção 0x4 corrige dois mal-entendidos 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:

  1. Aritmética insegura em _calc_supply() (principal, ~$8,1M). A função que recalcula o supply de yETH a partir do estado do pool contém duas falhas aritméticas: o arredondamento para baixo em unsafe_div() pode zerar o termo interno do produto, e o underflow em unsafe_sub() pode envolver (wrap) um valor intermediário para um número inteiro positivo enorme. Essa vulnerabilidade sozinha foi suficiente para drenar o yETH weighted stableswap pool.
  2. Caminho de bootstrap não desativado (secundário, ~$0,9M). O ramo de inicialização prev_supply == 0 nunca foi permanentemente bloqueado após a implantação. Depois que a primeira vulnerabilidade drenou o supply para zero, esse caminho se tornou acessível novamente, possibilitando lucro adicional a partir do pool Curve yETH/WETH.

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) é co-dependente do caminho de bootstrap, e juntas elas possibilitaram a Fase 3.

O atacante executou uma sequência de três fases:

  1. 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.
  2. Manipulação do supply: Explorar o arredondamento para baixo em _calc_supply() para colapsar o termo do 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 em seguida, resultando em ~$8,1M em perdas.
  3. Extração de lucro: Acionar o caminho de bootstrap (prev_supply == 0) com depósitos de poeira (dust deposits), explorando o underflow em _calc_supply() para cunhar (mint) ~2,35×10⁵⁶ yETH, que foram usados para drenar o pool Curve yETH/WETH, resultando em ~$0,9M em perdas.

Dois mal-entendidos comuns corrigidos:

  • "O invariante quebra porque pow_up() e pow_down() arredondam de forma diferente." Verificamos isso substituindo pow_up() por pow_down() em uma simulação Foundry: o exploit ainda funciona. A incompatibilidade de arredondamento não é uma causa raiz.
  • "Um underflow na segunda iteração faz um termo intermediário colapsar para zero." Nossas simulações em Foundry e Python mostram que nenhum underflow ocorre na segunda iteração. O valor real é ~1,91e19 (não ~1,94e18 como afirmado), um 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 Histórico

Dois pools perderam ativos neste incidente: o yETH weighted stableswap pool (um pool da Yearn contendo LSTs, ~$8,1M perdidos) e o pool Curve yETH/WETH (um pool stableswap da Curve, ~$0,9M perdidos). O yETH weighted stableswap pool é onde reside a vulnerabilidade principal. Esta seção fornece o histórico necessário para entender 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 yETH weighted stableswap pool afetado agrega múltiplos LSTs em um único pool: os 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 xix_i 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 por σ=xi\sigma = \sum x_i.

O pool contém 8 ativos (indexados de 0 a 7), cada um com um peso designado wiw_i:

Índice Ativo Índice Ativo
0 sfrxETH 4 rETH
1 wstETH 5 apxETH
2 ETHx 6 WOETH
3 cbETH 7 mETH

O estado do pool é regido por um invariante do tipo StableSwap ponderado [4]:

Afn  σ+D=Afn  D+Dπ(1)\mathit{Af}^{\,n}\;\sigma + D = \mathit{Af}^{\,n}\;D + D \cdot \pi \tag{1}

onde:

  • DD é a escala do invariante, que equivale diretamente ao supply total de yETH deste pool. Quando o pool está perfeitamente equilibrado, D=σD = \sigma.
  • π\pi é o termo do produto ponderado, definido como π=Dni(wixi)vi\pi = D^n \prod_{i} \left(\frac{w_i}{x_i}\right)^{v_i}, onde wiw_i é o peso do ativo i e vi=winv_i = w_i \cdot n.
  • Af\mathit{Af} é o fator de amplificação, um único parâmetro do protocolo (não A×fA \times f). Afn\mathit{Af}^{\,n} denota esse fator elevado à potência nn, onde nn é o número de ativos (8 neste pool). Ele controla o formato da curva entre soma constante (próximo do equilíbrio) e produto constante (nos extremos).

A propriedade-chave: DD não possui uma solução em forma fechada. Ele deve ser resolvido numericamente. Esse solucionador, _calc_supply(), é onde reside a vulnerabilidade aritmética.

0x1.2 O Solucionador de Invariante

O protocolo recalcula DD 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.

Dm+1=AfnσDmπmAfn1(2)D_{m+1} = \frac{\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m}{\mathit{Af}^{\,n} - 1} \tag{2}

Etapa 2: Atualizar o termo do produto para corresponder ao novo supply.

πm+1=πm(Dm+1Dm)n(3)\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n \tag{3}

Etapa 3: Verificar convergência.

Se Dm+1Dm<ϵ|D_{m+1} - D_{m}| < \epsilon, retornar DmD_{m}; caso contrário, repetir a partir da Etapa 1.

Os valores iniciais D0D_0, π0\pi_0 e σ\sigma influenciam as primeiras iterações; 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 se protege contra underflow. Em condições normais do pool, os valores intermediários permanecem dentro de faixas seguras. Em estados extremos do pool, isso não ocorre. A Seção 0x2.1 analisa esses modos de falha em detalhe.

0x1.3 As Três Interfaces e o Solucionador de Invariante

O protocolo expõe três pontos de entrada que afetam o estado do pool ao atualizar o termo do produto ponderado π\pi (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 por 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 cada vez mais desequilibrados.

O Mecanismo de Atualização de Taxas

Como discutido acima, os saldos virtuais (xix_i) são calculados com base nas taxas de câmbio dos LSTs. Portanto, é importante entender a forma de atualização das taxas.

Especificamente, as funções add_liquidity() e update_rates() podem atualizar as 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 taxa.

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, posteriormente, atualiza o invariante; caso contrário, o processo de atualização é ignorado.

Como Cada Interface Trata π

Com base em como 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 o recálculo iterativo do supply DD e do produto π\pi. Em contraste, remove_liquidity() retira liquidez proporcionalmente e não exige cálculo iterativo.

A fórmula base para calcular o produto do zero é:

π=i(Dwixi)nwi(4)\pi = \prod_{i} \left(\frac{D \cdot w_i}{x_i}\right)^{n \cdot w_i} \tag{4}

onde DD é o supply, wiw_i é o peso do ativo ii, xix_i é seu saldo virtual (armazenado como vb[i] no código), e n é o número de ativos. Esta forma é algebricamente equivalente à definição da Seção 0x1.1, com DnD^n distribuído no produto.

  1. add_liquidity() possui dois caminhos (código mostrado na Seção 0x2.2):
  • Caminho de bootstrap (quando prev_supply == 0): Calcula vb_prod do zero usando a equação (4). Este caminho permanecer acessível após a implantação é a vulnerabilidade de gestão 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 proporção entre os saldos virtuais antigos e novos:

      πestimated=πi=0n1(xixi)win(5)\pi_{\text{estimated}} = \pi \cdot \prod_{i=0}^{n-1} \left(\frac{x_i}{x_i'}\right)^{w_i \cdot n} \tag{5}

      onde xix_i e xix_i' 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 DD e o valor exato de π\pi.

  1. 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 de add_liquidity(), ou seja, o invariante é recalculado iterativamente. Além disso, com base no supply recém-calculado, o contrato cunha (mint) ou queima (burn) yETH para garantir que o supply de liquidez permaneça consistente com o estado atualizado dos saldos virtuais.

  2. remove_liquidity() sempre calcula vb_prod do zero usando a equação (4), após reduzir proporcionalmente cada saldo virtual.


0x2 Análise das Causas Raiz

Duas vulnerabilidades foram exploradas, com papéis e impactos diferentes. A causa raiz principal foi uma falha de cálculo no solucionador de invariante _calc_supply(), que apresentava dois modos de falha: (A) o arredondamento para baixo podia zerar o termo do produto, degenerando o invariante em um modelo de soma constante e levando à cunhagem excessiva de LP (inflação do supply); e (B) uma condição de underflow também podia inflar o supply. Apenas o Modo de Falha A foi usado na Fase 2 (~$8,1M). O Modo de Falha B era co-dependente da vulnerabilidade secundária.

A causa raiz secundária foi uma falha de gestão de estado: o ramo de inicialização do pool permaneceu acessível. Depois que a Fase 2 levou o supply a zero, o Modo de Falha B combinado com o caminho de bootstrap possibilitou um adicional de ~$0,9M em perdas (Fase 3).

0x2.1 Aritmética Insegura em _calc_supply() (Principal)

A Figura 2 mapeia a implementação de _calc_supply() para o procedimento matemático da Seção 0x1.2, anotando os dois pontos de falha aritmética analisados abaixo:

As variáveis do código mapeiam para os termos matemáticos da seguinte forma:

Variável do código Papel matemático
s Estimativa atual do supply DmD_m
r Termo do produto πm\pi_m
sp Próxima estimativa de supply Dm+1D_{m+1}
l Constante do numerador: Afnσ\mathit{Af}^{\,n} \cdot \sigma
d Constante do denominador: Afn1\mathit{Af}^{\,n} - 1

As expressões críticas são:

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)

Existem dois modos de falha aritmética dentro dessa função, atingindo linhas diferentes e produzindo efeitos diferentes. Ambos exigem que o pool esteja em um estado extremo para serem acionados.

Em 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, ela sempre arredonda para baixo. Quando o pool está severamente desequilibrado e sp é muito menor que s (como acontece após um depósito grande manipulado), o numerador r * sp pode se tornar menor que o denominador s. A divisão inteira então produz r = 0.

Uma vez que r é zero, ele permanece zero em todas as iterações subsequentes. O termo do produto π\pi colapsou permanentemente.

Uma atribuição incorreta comum afirma que essa falha decorre da incompatibilidade de arredondamento entre pow_up() e pow_down(). A Seção 0x4 apresenta evidências de que isso é 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 AfnσDmπm\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m na equação 2. Em 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 do produto do zero, e as magnitudes relativas podem se inverter.

Especificamente, quando add_liquidity() é chamada em um pool com supply zero com quantidades de poeira (dust amounts), o ramo de inicialização chama _calc_vb_prod_sum() para calcular novos valores usando a equação (4) (Seção 0x1.3). Com depósitos minúsculos, vb_sum é minúsculo (por exemplo, 16), mas dividir por saldos próximos de zero e elevar a potências altas amplifica o produto para um valor desproporcionalmente grande (por exemplo, ~9,13e20). Quando s * r excede l, a subtração produz um resultado matemático negativo.

Como unsafe_sub() realiza a subtração em aritmética uint256 não verificada, um resultado negativo dá a volta (wrap around) para um número inteiro positivo enorme (próximo de 22562^{256}). Esse valor "envolvido" se propaga pela divisão e pelas 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 Possibilitam o Ataque

Esses dois modos de falha operam em fases diferentes do exploit, com contribuições de lucro diferentes:

  • Modo de Falha A (Fase 2, ~$8,1M): Quando o atacante deposita em um pool severamente desequilibrado, o termo do produto zera, fazendo com que _calc_supply() retorne um supply inflado. O protocolo super-cunha yETH para o atacante. Este modo de falha sozinho, sem qualquer envolvimento do caminho de bootstrap, permitiu ao atacante drenar os ativos LST do yETH weighted stableswap pool.

  • Modo de Falha B (Fase 3, ~$0,9M): Depois que o supply foi drenado a zero, o caminho de bootstrap recalcula um grande termo do produto a partir de depósitos de poeira, 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 separado Curve yETH/WETH.

A dependência é unidirecional: o Modo de Falha A é explorável de forma independente e causou 90% das perdas, enquanto o Modo de Falha B exige que o Modo de Falha A primeiro leve 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:
    # 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)

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 tinha uso único previsto durante a inicialização do pool, mas nunca foi permanentemente bloqueado após o primeiro depósito.

Se o supply total puder ser levado a zero (através de qualquer combinação de burns e retiradas), o ramo se torna acessível novamente. Um atacante que reentra nesse caminho controla as condições iniciais passadas para _calc_supply(), podendo acionar 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 semelhante de levar o supply a zero para redefinir 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 mantêm quando isso ocorre, é uma questão que os projetistas de protocolos devem abordar explicitamente.


0x3 Análise do Ataque

O exploit se desenrola ao longo de uma sequência coordenada da transação de ataque [5], organizada em três fases. Cada fase se baseia no estado estabelecido pela fase 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 rastro da transação para esta fase (a etapa de flash loan é omitida por restrições de espaço):

O atacante primeiro toma emprestado grandes quantidades de ativos LST via flash loans da Balancer e da 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 Curve yETH/WETH, e então usa o yETH adquirido para remover liquidez do pool.

A manipulação central alavanca a assimetria de interface descrita na Seção 0x1 (Histórico): 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 leva progressivamente o pool a um estado severamente 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 esgotados em mais de 98%. Esse desequilíbrio não extrai lucro diretamente. Ele cria as precondiçõ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 e, em seguida, drenar o supply de yETH a zero. Esta fase explora apenas a vulnerabilidade principal (aritmética insegura) e causou ~90% das perdas totais.

Esta fase usa um ciclo repetitivo de cinco etapas, executado três vezes:

  1. Corromper o produto via add_liquidity();
  2. Estabelecer a precondição para correção via add_liquidity();
  3. Redefinir o produto via remove_liquidity() com 0 yETH;
  4. Corrigir o supply via update_rates();
  5. Retirar ativos via remove_liquidity().

A figura abaixo mostra o rastro 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 do produto por meio da atualização incremental na equação (5) (Seção 0x1.3). Como xixix_i' \gg x_i para ativos de alto peso, as razões (xi/xi)(x_i / x_i') são todas frações bem abaixo de 1, elevadas a potências altas. Isso leva πnew\pi_{\text{new}} de ~42e18 para ~0,00353e18, uma estimativa de produto próxima de 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 das Causas 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 super-cunhe yETH.

2. Estabelecer a precondição para correção via add_liquidity()

O atacante adiciona liquidez unilateral para o ativo de índice 3 (cbETH, um ativo esgotado e de baixo peso), depositando ~6,5x 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 esta 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 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 diferentes de zero, isso produz um produto diferente de 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 tiver mudado desde a última atualização, a função aciona _calc_supply() com o produto restaurado (diferente de zero). Desta vez, a iteração converge corretamente e produz um valor de supply muito menor do que o valor inflado atual. A diferença é queimada (burned) do contrato de staking de yETH. De acordo com o post-mortem oficial [2], isso constitui Liquidez de Propriedade do Protocolo (POL - Protocol-Owned Liquidity), o que significa que as queimas reduzem a posição do protocolo, e não as participações 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 (trigger). Entre as três interfaces do pool, apenas add_liquidity() e update_rates() invocam _calc_supply(); remove_liquidity() usa escalonamento proporcional e não a invoca. Após a Etapa 3 restaurar um produto diferente de 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 evitou deliberadamente adicionar liquidez para WOETH e mETH. Se essas taxas tivessem sido atualizadas durante add_liquidity(), não existiria 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 super-cunhado (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 proporcionalmente às suas participações em 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.

Finalidade do Rebase

O rastro (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 mantido pelo contrato WOETH aumenta, elevando a taxa de câmbio efetiva do WOETH. Essa discrepância de taxa "guardada" é o que torna a Etapa 4 do segundo ciclo possível novamente: quando update_rates() é eventualmente chamada, ela detecta a discrepância e aciona _calc_supply().

Drenando a zero

Depois de repetir esse ciclo de cinco etapas três vezes, o atacante reduziu o supply total do pool abaixo da quantidade de yETH que ele detém. Uma chamada final de remove_liquidity() com o supply restante o drena a ZERO.

O pool agora mantém 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 o Supply Zero para Lucro Adicional (~$0,9M)

Objetivo: Cunhar uma quantidade enorme de yETH a partir do estado degenerado do pool, e então trocá-la por ativos reais. Esta fase explora a combinação co-dependente da vulnerabilidade secundária (caminho de bootstrap não desativado) e do Modo de Falha B (underflow), contribuindo juntas com ~10% das perdas totais.

1. Cunhagem via underflow

Com o supply total em zero, o atacante chama add_liquidity() com quantidades de poeira (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 das Causas Raiz): ele ignora o estado armazenado e recalcula vb_prod e vb_sum do zero via _calc_vb_prod_sum(), e então passa esses valores 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 solucionador.

Com todos os saldos virtuais em níveis de poeira (taxas de câmbio próximas de 1e18), os valores calculados são:

  • vb_sum = 16
  • vb_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,2e21
  • d = _amplification - PRECISION4,49e20
  • s = _supply = 16
  • r = _vb_prod9,13e20

Agora a subtração l - s * r:

7.2×102116×9.13×1020=7.2×10211.46×10227.4×10217.2 \times 10^{21} - 16 \times 9.13 \times 10^{20} = 7.2 \times 10^{21} - 1.46 \times 10^{22} \approx -7.4 \times 10^{21}

Isso é negativo. Em aritmética uint256 não verificada, unsafe_sub dá a volta (wrap) para aproximadamente 22567,4×10212^{256} - 7,4 \times 10^{21}, um valor astronomicamente grande. Após a divisão por d (~4,49e20), a estimativa de supply resultante é ~2,35e56, e o protocolo cunha essa quantidade inteira para o atacante. Esse underflow só é possível porque o supply total foi levado a zero na Fase 2; sob qualquer estado de pool não degenerado, l > s * r se mantém e a subtração é segura.

2. Trocando por ativos reais

O atacante troca parte do yETH super-cunhado por ~1.097e18 WETH no pool Curve yETH-WETH, 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 um lucro total líquido de aproximadamente $9 milhões após reembolsar os flash loans.

Uma 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 Mal-entendidos

A maioria das análises publicadas sobre este incidente foca nos sintomas aritméticos sem explicar totalmente como o atacante estabelece as precondições. Duas afirmações específicas merecem correção.

0x4.1 Afirmação: "A incompatibilidade 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 do código e pow_down() em outros, argumentando que a incompatibilidade direcional introduz inconsistências exploráveis.

Testamos isso diretamente: modificamos o contrato para usar pow_down() uniformemente (substituindo todas as chamadas a pow_up()) e reexecutamos a simulação completa do ataque no Foundry. O exploit teve sucesso de forma idêntica. O produto ainda colapsa para zero, o supply ainda é drenado, e o underflow ainda produz uma cunhagem inflada.

O arredondamento que possibilita o estado de produto zero é a divisão com arredondamento para baixo (floor division) 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 os valores iniciais do produto.

0x4.2 Afirmação: "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 com que r arredonde para zero.

Reproduzimos os valores intermediários exatos usando tanto o Foundry (replay on-chain) quanto o Python (verificação matemática). A simulação Foundry rastreia _calc_supply() iteração por iteração:

======= _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!

A observação crítica: na iteração 1, sp avalia para ~1,91e19. Este é um valor positivo legitimamente pequeno, não um artefato de underflow. A subtração l - s*r produz um resultado pequeno e positivo porque a soma ponderada pela amplificação l e o termo s*r (supply-produto) estão próximos em magnitude nessa iteração.

O que zera o produto é o que acontece em seguida: 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 independentemente 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 meio de arredondamento na divisão, não por underflow na subtração. O underflow de unsafe_sub que infla o supply ocorre em um contexto completamente diferente: a Fase 3 do ataque, quando liquidez de poeira é 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 principal: sua falha de arredondamento para baixo (Modo de Falha A) possibilitou de forma independente ~$8,1M em perdas somente através da Fase 2. O caminho de bootstrap não desativado foi uma vulnerabilidade secundária; combinado com a falha de underflow (Modo de Falha B), possibilitou um adicional de ~$0,9M na Fase 3, mas apenas depois que a Fase 2 já havia drenado o supply a zero. Essa divisão das 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 duas falhas (aritmética insegura consolidando os itens oficiais #1 e #5; caminho de bootstrap não desativado como #4) e duas precondições arquiteturais (#2 tratamento assimétrico de Π; #3 estado de supply zero possibilitado pelo POL). A distinção: falhas são bugs de implementação que violam a intenção de design (o solucionador não deveria produzir produtos zero ou sofrer underflow), enquanto precondições são escolhas de design que funcionam como pretendido, mas criam uma superfície de ataque explorável quando combinadas com falhas.

Recomendações

  • Aritmética verificada em solucionadores de invariante. Usar safe_div e safe_sub com reversão explícita em caso de underflow/overflow, mesmo ao custo da eficiência de gás. O solucionador executa no máximo 256 iterações, e a sobrecarga de gás é insignificante em comparação com o risco de segurança.
  • Verificações de limite em valores intermediários. Validar que o termo do produto permanece dentro de uma faixa sensata entre iterações. Um produto que cai para zero ou uma estimativa de supply que aumenta em ordens de magnitude entre iterações sinaliza um estado degenerado.
  • Limites de desequilíbrio. Impor um desvio máximo entre o saldo virtual de qualquer ativo e seu saldo alvo proporcional ao peso. Isso impediria que a Fase 1 criasse as precondições.
  • Verificações de monotonicidade do invariante. Após _calc_supply() retornar, verificar que o novo supply é consistente com a direção da mudança (adição de liquidez nunca deveria diminuir o supply, atualizações de taxa não deveriam produzir variações de 10x, etc.).
  • Desativar permanentemente os caminhos de inicialização. Após o primeiro depósito do pool, bloquear o ramo de bootstrap prev_supply == 0 para que não possa ser reacessado. Isso impediria totalmente a Fase 3.
  • Prevenir estados de supply zero. Garantir que queimas em nível de protocolo (de POL ou contratos de staking) não possam reduzir o supply total a zero enquanto o pool mantém saldos diferentes de zero. Um piso mínimo de supply bloquearia a transição para o estado degenerado que possibilita a reentrada no bootstrap.
  • Detecção de anomalias em tempo real. Monitorar 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 períodos de tempo) e acionar alertas ou disjuntores (circuit breakers) antes que as perdas se acumulem.

Referências

  1. Anúncio do incidente da Yearn Finance
  2. Post-mortem de segurança da Yearn
  3. Documentação do yETH
  4. Whitepaper do yETH: derivação do invariante
  5. Transação de ataque no Phalcon Explorer
  6. BlockSec: Análise do incidente do boosted pool da Balancer (agosto de 2023)

Sobre a BlockSec

BlockSec é uma provedora full-stack de segurança blockchain e compliance cripto. Construímos produtos e serviços que ajudam os clientes a realizar auditoria 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 diversos artigos de segurança blockchain em conferências renomadas, reportou vários ataques de dia zero (zero-day) em aplicações DeFi, bloqueou múltiplos hacks para resgatar mais de 20 milhões de dólares, e protegeu bilhões em criptomoedas.

Best Security Auditor for Web3

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

BlockSec Audit