Em 30 de julho de 2023, uma série de explorações visou múltiplos pools da Curve, resultando em perdas de milhões de dólares. Trata-se de um ataque de reentrância típico com uma causa raiz atípica, originada de um bug no compilador que leva à ausência de proteção contra reentrância. Especificamente, houve um erro no qual os bloqueios de reentrância para diferentes funções dentro de um contrato inteligente receberam slots de armazenamento distintos. Como resultado, contratos inteligentes compilados usando as versões 0.2.15, 0.2.16 e 0.3.0 do Vyper eram vulneráveis.
Contexto
Como a Curve utiliza Vyper em vez de Solidity para o desenvolvimento de seus contratos inteligentes, uma breve introdução à linguagem Vyper é fornecida para auxiliar na compreensão da vulnerabilidade associada.
Vyper é uma linguagem de programação baseada em Python criada por Vitalik Buterin, cofundador do Ethereum. Conforme apresentado em sua documentação, Vyper é uma linguagem de programação orientada a contratos, de domínio específico e estilo pythônico, voltada para a Máquina Virtual Ethereum (EVM). Seus objetivos incluem simplicidade, "pythonicidade", segurança e auditabilidade.
Vyper tornou-se a segunda linguagem de programação mais utilizada para o Ethereum e cadeias compatíveis com EVM, seguindo o amplamente conhecido Solidity. A Curve é uma das maiores adotantes da linguagem Vyper, com a maioria de seus contratos escritos nela. Muitos projetos relacionados ou derivados da Curve também usam Vyper para garantir melhor reaproveitamento de código e interoperabilidade com os sistemas da Curve.
Abaixo está um trecho de código de um pool da Curve (o pool pETH/ETH que foi atacado neste incidente). Embora a sintaxe seja muito semelhante à do Python, existem diferenças notáveis entre Vyper e Python:
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Withdraw coins from the pool
@dev Withdrawal amounts are based on current deposit ratios
@param _burn_amount Quantity of LP tokens to burn in the withdrawal
@param _min_amounts Minimum amounts of underlying coins to receive
@param _receiver Address that receives the withdrawn coins
@return List of amounts of coins that were withdrawn
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
Todos os parâmetros e valores de retorno de uma função dentro do contrato devem ser devidamente anotados com tipos. A linguagem não suporta classes ou herança; a palavra-chave 'self' é usada exclusivamente para referenciar o próprio contrato e acessar variáveis de estado. Decoradores embutidos (por exemplo, @external e @nonreentrant) são utilizados para indicar as propriedades de uma função, e não há suporte para decoradores personalizados.
Análise da Vulnerabilidade
A vulnerabilidade surge de um bug no compilador que resulta na ausência de proteção contra reentrância.
Ataques de reentrância são um dos tipos de ataque mais comuns no ecossistema blockchain. Especificamente, eles ocorrem quando a execução da lógica de um contrato inicia chamadas externas, algumas das quais podem chamar recursivamente o contrato original. Isso pode expor perigosamente o estado intermediário do contrato durante a execução de uma função a outros contratos, podendo levar a vulnerabilidades. Para combater isso, um guarda de reentrância, ou bloqueio, é empregado para garantir que o contrato não possa ser reentrante durante a execução de uma única transação.
No trecho de código mencionado anteriormente, a anotação @nonreentrant('lock') indica que a função remove_liquidity deve ser protegida com o bloqueio de reentrância denominado lock. Para maior clareza, pode-se comparar isso ao contrato OpenZeppelin ReentrancyGuard e seu modificador nonReentrant. A principal diferença no Vyper é que os bloqueios de reentrância não são fornecidos por uma biblioteca externa, mas são recursos integrados à própria linguagem. Isso parecia satisfatório até que uma investigação mais aprofundada sobre a implementação dos bloqueios de reentrância foi realizada. Descobriu-se que o trecho de código introduzido no Pull Request #2391 (mesclado em 23 de julho de 2021) utilizava a função set_storage_slots para atribuir slots de armazenamento com base na AST (Árvore Sintática Abstrata) do código-fonte Vyper.
def set_storage_slots(vyper_module: vy_ast.Module) -> None:
"""
Parse module-level Vyper AST to calculate the layout of storage variables.
"""
# Allocate storage slots from 0
# note storage is word-addressable, not byte-addressable
storage_slot = 0
for node in vyper_module.get_children(vy_ast.FunctionDef):
type_ = node._metadata["type"]
if type_.nonreentrant is not None:
type_.set_reentrancy_key_position(StorageSlot(storage_slot))
# TODO use one byte - or bit - per reentrancy key
# requires either an extra SLOAD or caching the value of the
# location in memory at entrance
storage_slot += 1
No entanto, um erro crítico reside no fato de que, para cada bloqueio de reentrância, o slot de armazenamento é incrementado em 1. Consequentemente, diferentes funções acabam recebendo slots de armazenamento únicos para seus bloqueios de reentrância. Para avaliar essa implementação, compilamos o contrato abaixo:
# Compile command: vyper -f bytecode,bytecode_runtime,ir test_contract.vy
from vyper.interfaces import ERC20
test_addr: address
@external
def __init__(_addr: address):
self.test_addr = _addr
@external
@nonreentrant('lock')
def test_funcA():
pass
@external
@nonreentrant('lock')
def test_funcB():
pass
Usando a versão 0.2.16+commit.59e1bdd do compilador Vyper, a IR (Representação Intermediária) do Vyper gerada durante a compilação do código é parcialmente listada a seguir:
[if,
[eq, _func_sig, 2354224227 <test_funcA()>],
[seq,
[assert, [iszero, [sload, 0]]],
[sstore, 0, 1],
pass,
# Line 13
pass,
# Line 12
[sstore, 0, 0],
stop]],
# Line 17
[if,
[eq, _func_sig, 741100118 <test_funcB()>],
[seq,
[assert, [iszero, [sload, 1]]],
[sstore, 1, 1],
Vamos nos concentrar na IR das Linhas 4-5 e Linhas 16-17, onde o código gerado verifica o bloqueio de reentrância e armazena o estado do bloqueio no armazenamento. No entanto, observou-se que diferentes funções usavam slots distintos para o bloqueio não reentrante: test_funcA usa o slot 0, enquanto test_funcB usa o slot 1. Isso indica que o bloqueio de reentrância é ineficaz, pois o contrato pode ser reentrante por meio de funções diferentes.
Análise do Ataque
Aqui fornecemos algum contexto sobre a Curve. Um pool da Curve permite que os usuários forneçam e retirem liquidez por meio das funções add_liquidity e remove_liquidity. Ao adicionar liquidez, a quantidade a ser adicionada é determinada por uma proporção do fornecimento total, especificamente, a proporção da liquidez adicionada em relação à liquidez existente. Por outro lado, remove_liquidity calcula o número de tokens a serem retirados com base na proporção dos tokens LP (Provedor de Liquidez) enviados em relação ao fornecimento total atual, após o que os tokens LP são queimados.
Além disso, a Curve suporta pools que lidam com tokens nativos, e utiliza chamadas de baixo nível (a função raw_call no Vyper) para retornar o token nativo ao usuário. No trecho de código abaixo, a função remove_liquidity primeiro calcula e transfere os tokens a serem removidos com base na quantidade de tokens LP e no fornecimento total, e então o fornecimento total é subsequentemente reduzido.
Em circunstâncias normais, isso seria seguro, pois o bloqueio de reentrância deveria impedir a exposição do estado intermediário durante as chamadas brutas. No entanto, quando o bloqueio de reentrância é ineficaz — uma falha que acabou sendo explorada — um ataque torna-se viável. O bloqueio de reentrância ineficaz significa que o estado intermediário (onde os tokens a serem retirados já foram transferidos, mas o fornecimento total ainda não foi reduzido) fica vulnerável durante uma chamada de baixo nível, permitindo uma possível reentrada no contrato.
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Withdraw coins from the pool
@dev Withdrawal amounts are based on current deposit ratios
@param _burn_amount Quantity of LP tokens to burn in the withdrawal
@param _min_amounts Minimum amounts of underlying coins to receive
@param _receiver Address that receives the withdrawn coins
@return List of amounts of coins that were withdrawn
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
else:
response: Bytes[32] = raw_call(
self.coins[1],
concat(
method_id("transfer(address,uint256)"),
convert(_receiver, bytes32),
convert(value, bytes32),
),
max_outsize=32,
)
if len(response) > 0:
assert convert(response, bool)
total_supply -= _burn_amount
self.balanceOf[msg.sender] -= _burn_amount
self.totalSupply = total_supply
log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)
log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)
return amounts
Essa reentrância pode ser explorada porque o fornecimento total não foi reduzido conforme indicado na Linha 27 acima. Se reentramos no contrato nesse ponto e chamamos add_liquidity, o fornecimento de liquidez seria baseado em um fornecimento total incorreto (maior do que deveria ser), levando à criação de uma quantidade excessiva de tokens LP e resultando em perdas para o pool. A maioria dos ataques neste incidente explorou essa vulnerabilidade. A discussão a seguir examina uma das maiores transações de ataque, 0xa84aa065ce, contra o pool pETH-ETH da Curve.

O rastro do ataque é muito claro.
- O atacante tomou emprestado um flash loan da Balancer e então forneceu 40.000 ETH como liquidez ao pool pETH/ETH da Curve, recebendo 32.431,41 tokens LP pETH-ETH.
- O atacante então removeu 3.740 pETH e 34.316 ETH do pool queimando 32.431,41 tokens LP do pool pETH/ETH.
- Durante a remoção de liquidez, o contrato do pool sofreu reentrância. Dentro da função fallback, o atacante forneceu outros 40.000 ETH como liquidez ao pool pETH/ETH da Curve, cunhando 82.182 tokens LP adicionais. Durante esse processo, o valor de fornecimento total utilizado foi o anterior à remoção de liquidez, o que estava incorreto, resultando na criação de uma quantidade maior de tokens LP do que deveria ter sido possível.
- Subsequentemente, o atacante retirou 1.184,73 pETH e 47.506,53 ETH queimando 10.272,84 tokens LP da Curve. Em resumo, o atacante lucrou cunhando tokens LP extras e esvaziando o pool usando esses tokens LP adicionais.
Resumo
Esta vulnerabilidade se originou no compilador, não no código-fonte. Isso marca a primeira ocasião em que um bug de compilador resultou em perdas financeiras significativas no ecossistema blockchain.
Indeed, smart contracts compiled using Vyper versions 0.2.15, 0.2.16, and 0.3.0 are vulnerable, which can lead to the failure of the reentrancy guard. https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) July 30, 2023
Dado que os compiladores formam um componente de infraestrutura crítica, sua segurança é fundamental para a integridade e funcionalidade da tecnologia blockchain. Problemas relacionados a compiladores podem não ser imediatamente óbvios, mas podem ter consequências extensas e graves. Proteger compiladores exige avaliações rigorosas, que devem incluir auditorias abrangentes e robustos programas de recompensa por bugs para descobrir e resolver vulnerabilidades. A sutileza inerente dos bugs de compilador torna sua detecção e mitigação complexas. Essa complexidade acentua a importância de mecanismos sofisticados de detecção e prevenção de ataques, como os fornecidos pelo BlockSec Phalcon da BlockSec, que oferecem defesas automatizadas essenciais para proteger os protocolos DeFi de forma eficaz.
Leia outros artigos desta série:
- Introdução: Os Dez Maiores Incidentes de Segurança "Impressionantes" de 2023
- #1: Colhendo Bots MEV ao Explorar Vulnerabilidades no Flashbots Relay
- #2: Incidente Euler Finance: O Maior Hack de 2023
- #3: Incidente KyberSwap: Exploração Magistral de Erros de Arredondamento com Cálculos Extremamente Sutis
- #5: Platypus Finance: Sobrevivendo a Três Ataques com um Golpe de Sorte
- #6: Incidente Hundred Finance: Catalisando a Onda de Explorações Relacionadas à Precisão em Protocolos Bifurcados Vulneráveis
- #7: Incidente ParaSpace: Uma Corrida Contra o Tempo para Frustrar o Ataque Mais Crítico da Indústria
- #8: Incidente SushiSwap: Uma Tentativa de Resgate Desastrada Leva a uma Série de Ataques Imitadores
- #9: Bot MEV 0xd61492: De Predador a Presa em uma Exploração Engenhosa
- #10: Incidente ThirdWeb: Incompatibilidade Entre Módulos Confiáveis Expõe Vulnerabilidade



