WEB3DEV

Cover image for Vulnerabilidades de Dia Zero no Vyper
Paulo Gio
Paulo Gio

Posted on

Vulnerabilidades de Dia Zero no Vyper

Saiba como um bug no compilador Vyper foi explorado, causando uma perda de $73,5 milhões.

https://miro.medium.com/v2/resize:fit:1100/format:webp/1*LnKfdsnsx9mWqbaeqxWH3g.png

Resumo

Em 30 de julho de 2023, um bug no Vyper foi usado para explorar vários protocolos DeFi, causado por travas de reentrância quebradas nas versões 0.2.15, 0.2.16 e 0.3.0 de seu compilador, resultando em uma perda total de aproximadamente $73,5 milhões em ativos.

Introdução ao Vyper

O Vyper é uma linguagem de contrato inteligente baseada em Python para a EVM.

Avaliação da Vulnerabilidade

A causa raiz da vulnerabilidade é uma trava de reentrância mal configurada em três versões diferentes do compilador Vyper.

Cenário de Exploração

A equipe do Vyper emitiu um aviso crítico sobre uma falha nas versões 0.2.15, 0.2.16 e 0.3.0 de seu compilador relacionado a proteções de reentrância defeituosas. Esta vulnerabilidade já resultou em comprometimentos significativos em vários protocolos DeFi. Em particular, a guarda de reentrância, que deveria impedir que contratos inteligentes fizessem chamadas recursivas, não funcionou porque definia diferentes posições de armazenamento dependendo da função que a chamava, tornando-a ineficaz.

De acordo com a documentação do Vyper, o decorador @nonreentrant(<key>) coloca uma trava em uma função e todas as outras funções com o mesmo valor de <key>. Uma tentativa de um contrato externo chamar de volta qualquer uma dessas funções faz com que a transação seja revertida. Travas não reentrantes funcionam definindo um slot de armazenamento especialmente alocado para um valor <locked> na entrada da função e definindo-o para um valor <unlocked> na saída da função. Na entrada da função, se o slot de armazenamento for detectado como o valor <locked>, a execução é revertida.

Ao visualizar o repositório do GitHub, o pull request (PR) #2391 com o título “fix: storage slot allocation bug” parece ter introduzido o bug, e um patch foi aplicado na PR #2439 intitulado “Fix unused storage slots”. Este patch introduziu um novo bug que causou falha na compilação, mas foi posteriormente corrigido, como visto neste PR #2514 intitulado “fix codegen failure with nonreentrant keys”.

Vários pools da Curve foram explorados devido a esse problema. Essencialmente, todos os protocolos explorados usaram as funções add_liquidity e remove_liquidity. O explorador das brechas desses protocolos chamou estas duas funções de forma intercambiável, contornando assim a guarda de reentrância. O código do contrato Vyper descompilado dos pools mostra que diferentes slots de armazenamento em stor_0 e stor_2 foram usados como travas de reentrância, tornando a trava de reentrância ineficaz.

function add_liquidity(uint256[2] varg0) public payable {
   require(!(varg0 >> 160));    require(!stor_0);
   stor_0 = 1
    ...
}
Enter fullscreen mode Exit fullscreen mode
function remove_liquidity(uint256 varg0, uint256[2] varg1) public payable {
   require(!(varg1 >> 160));
   require(!stor_2);
   stor_2 = 1
    ...
}
Enter fullscreen mode Exit fullscreen mode

Protocolos Afetados

Caso I: JPEG'd

Conforme observado na transação de ataque à exchange JPEG'd, seu pool pETH foi explorado por esta vulnerabilidade, causando uma perda de aproximadamente 6.106 WETH, totalizando $11,4 milhões. O ataque original foi iniciado por este explorador, mas foi efetivamente ultrapassado por um bot MEV.

O explorador inicialmente pegou um empréstimo relâmpago de 80.000 WETH da Balancer, forneceu 32.431 WETH como liquidez à Curve e recebeu tokens pETH-ETH LP. Um número ainda maior de WETH foi fornecido, cunhando mais 82.182 tokens pETH-ETH LP. Aproximadamente 3.740 pETH foram retirados ao remover alguma liquidez da Curve.

A liquidez inicial de 32.431 tokens LP da Curve foi queimada para remover essa liquidez, e outros 1.184 pETH foram retirados ao queimar mais tokens LP da Curve. O atacante conseguiu manipular o cálculo de preço entre pETH e WETH ao reentrar na função vulnerável add_liquidity logo após chamar a rotina remove_liquidity, atualizando assim o saldo no processo.

@payable
@external
@nonreentrant('lock')
def add_liquidity(
    _amounts: uint256[N_COINS],
    _min_mint_amount: uint256,
    _receiver: address = msg.sender
) -> uint256:
    """
    @notice Deposite moedas no pool
    @param _amounts Lista de quantidades de moedas a depositar
    @param _min_mint_amount Quantidade mínima de tokens LP para cunhar a partir do depósito
    @param _receiver Endereço que possui os tokens LP cunhados
    @return Quantidade de tokens LP recebidos ao depositar
    """
    amp: uint256 = self._A()
    old_balances: uint256[N_COINS] = self.balances
    rates: uint256[N_COINS] = self.rate_multipliers

    # Invariante inicial
    D0: uint256 = self.get_D_mem(rates, old_balances, amp)

    total_supply: uint256 = self.totalSupply
    new_balances: uint256[N_COINS] = old_balances
    for i in range(N_COINS):
        amount: uint256 = _amounts[i]
        if total_supply == 0:
            assert amount > 0  # dev: depósito inicial requer todas as moedas
        new_balances[i] += amount

    # Invariante após mudança
    D1: uint256 = self.get_D_mem(rates, new_balances, amp)
    assert D1 > D0

    # Precisamos recalcular o invariante contabilizando as taxas
    # para calcular a justa participação do usuário
    fees: uint256[N_COINS] = empty(uint256[N_COINS])
    mint_amount: uint256 = 0
    if total_supply > 0:
        # Contabilize apenas as taxas se não formos os primeiros a depositar
        base_fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1))
        for i in range(N_COINS):
            ideal_balance: uint256 = D1 * old_balances[i] / D0
            difference: uint256 = 0
            new_balance: uint256 = new_balances[i]
            if ideal_balance > new_balance:
                difference = ideal_balance - new_balance
            else:
                difference = new_balance - ideal_balance
            fees[i] = base_fee * difference / FEE_DENOMINATOR
            self.balances[i] = new_balance - (fees[i] * ADMIN_FEE / FEE_DENOMINATOR)
            new_balances[i] -= fees[i]
        D2: uint256 = self.get_D_mem(rates, new_balances, amp)
        mint_amount = total_supply * (D2 - D0) / D0
    else:
        self.balances = new_balances
        mint_amount = D1  # Pegue o pó se houver algum

    assert mint_amount >= _min_mint_amount, "Deslizamento te prejudicou"

    # Pegue as moedas do remetente
    assert msg.value == _amounts[0]
    if _amounts[1] > 0:
        response: Bytes[32] = raw_call(
            self.coins[1],
            concat(
                method_id("transferFrom(address,address,uint256)"),
                convert(msg.sender, bytes32),
                convert(self, bytes32),
                convert(_amounts[1], bytes32),
            ),
            max_outsize=32,
        )
        if len(response) > 0:
            assert convert(response, bool)  # dev: transferência falhou
        # end "safeTransferFrom"

    # Cunhe tokens do pool
    total_supply += mint_amount
    self.balanceOf[_receiver] += mint_amount
    self.totalSupply = total_supply
    log Transfer(ZERO_ADDRESS, _receiver, mint_amount)

    log AddLiquidity(msg.sender, _amounts, fees, D1, total_supply)

    return mint_amount
Enter fullscreen mode Exit fullscreen mode
@external
@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint256,
    _min_amounts: uint256[N_COINS],
    _receiver: address = msg.sender
) -> uint256[N_COINS]:
    """
    @notice Retire moedas do pool
    @dev As quantidades de retirada são baseadas nas proporções de depósito atuais
    @param _burn_amount Quantidade de tokens LP para queimar na retirada
    @param _min_amounts Quantidades mínimas das moedas subjacentes a receber
    @param _receiver Endereço que recebe as moedas retiradas
    @return Lista de quantidades de moedas que foram retiradas
    """
    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], "Retirada resultou em menos moedas do que o esperado"
        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
Enter fullscreen mode Exit fullscreen mode

No final, 4.924 pETH foram trocados por 4.285 WETH usando a exchange JPEG’d, e o empréstimo relâmpago tomado foi pago à Balancer. Os lucros dessa ação foram de aproximadamente 6.106 WETH.

Caso II: Alchemix

O contrato do pool alETH (alETH+ETH-f) da Alchemix também foi afetado por este bug, resultando em uma perda de 7.258 ETH no valor de aproximadamente $13,6 milhões. Tentamos analisar a transação de ataque executada pelo explorador.

O explorador inicialmente pegou um empréstimo relâmpago de 40.000 WETH da Balancer e depositou-o no pool alETH/ETH da Curve para cunhar 19.895 tokens alETH/ETH LP. Além disso, 34.277 tokens alETH/ETH LP foram cunhados, fornecendo liquidez adicional ao mesmo pool da Curve. Aproximadamente 4.821 tokens alETH foram retirados da Alchemix removendo alguma liquidez, e 19.895 tokens alETH/ETH LP foram queimados para remover a liquidez inicial do pool da Curve alETH/ETH.

Mais 15.910 tokens alETH/ETH LP foram queimados para remover o resto da liquidez do mesmo pool da Curve. O explorador então reembolsou o empréstimo relâmpago à Balancer, e o lucro dessas transações, no valor de 7.258 WETH, totalizou aproximadamente $13,6 milhões.

De acordo com vários relatórios, aproximadamente $22 milhões em ativos foram roubados da AlchemixFi, e cerca de $13 milhões em ativos foram recuperados em operações de resgate por hackers éticos.

Caso III: MetronomeDAO

Além disso, o contrato do pool sETH-ETH-f da MetronomeDAO também foi afetado, resultando em uma perda de 866 ETH no valor de aproximadamente $1.625.950. Esta é a transação de ataque em referência executada pelo explorador.

Caso IV: Ellipsis

A Ellipsis Finance também foi atacada como resultado desta vulnerabilidade, resultando em uma perda de 282 WBNB, no valor de aproximadamente $68.581.

Caso V: deBridge

A deBridge Finance também foi atacada devido à mesma exploração, resultando em uma perda de 13,13 ETH, no valor de aproximadamente $24.590.

Caso VI: Curve Finance

O contrato CRV/ETH LP da Curve Finance também foi explorado em várias transações, resultando em uma perda de fundos no valor de mais de $24 milhões. Aproximadamente 7.680 WETH e 7,193 milhões de tokens CRV, totalizando $14,413 milhões, foram explorados em uma das transações de ataque. O contrato afetado usava a versão 0.3.0 do compilador Vyper.

Consequências

Após o incidente, vários bots MEV foram relatados por terem se antecipado a alguns dos ataques. Vários pesquisadores de segurança também estavam trabalhando em operações éticas de hacking de resgate para ajudar todas as partes afetadas.

A equipe do Vyper reconheceu a vulnerabilidade e instou os projetos que dependiam das três versões afetadas a entrarem em contato para uma ação imediata. A Curve opera 232 pools diferentes, mas apenas os pools que usam as versões 0.2.15, 0.2.16 e 0.3.0 do Vyper estavam em risco. A Curve Finance também emitiu um esclarecimento, afirmando que o CRV/USD ou qualquer dos contratos de pool não associados ao ETH permaneciam não afetados.

O explorador da Alchemix também enviou uma mensagem na cadeia para um EOA afirmando que eles deveriam devolver os ativos roubados. O endereço rotulado como c0ffeebabe.eth devolveu 2.879,5 ETH, no valor de $5,4 milhões, para o implementador da Curve Finance.

Dos $73,5 milhões em ativos roubados no total, aproximadamente 73%, totalizando $52,3 milhões, foram recuperados ou devolvidos até agora.

Alchemix: o explorador devolveu todos os $22 milhões, compostos por 7.258 ETH e 4.821 alETH.

JPEG’d: O frontrunner devolveu um total de 90% dos ativos roubados, totalizando $11,5 milhões, compostos por 5.495,4 WETH.

MetronomeDAO: O endereço c0ffeebabe.eth devolveu aproximadamente $7 milhões em ativos roubados.

Alchemix: uma operação de hacking ético para resgatar $13 milhões.

O explorador do CRV/ETH LP ainda não devolveu os ativos roubados restantes, que totalizam $19,7 milhões.

Solução

O setor DeFi enfrentou um desafio significativo quando um bug foi descoberto nas versões 0.2.15, 0.2.16 e 0.2.30 do compilador Vyper. Esta falha afetou muitos protocolos DeFi, destacando a natureza complexa e multifacetada do desenvolvimento e implantação de contratos inteligentes. Felizmente, um patch foi rapidamente implantado na versão subsequente 0.2.31, reforçando a abordagem proativa da comunidade de desenvolvimento. No entanto, este incidente levanta questões essenciais sobre a resiliência do ecossistema mais amplo e as medidas de contingência em vigor.

Embora desenvolvedores e auditores se esforcem para garantir a base de código mais robusta e conduzir testes exaustivos, a natureza dinâmica e intrincada dos contratos inteligentes significa que vulnerabilidades podem ocasionalmente passar despercebidas. Esses riscos imprevistos exigem uma abordagem de segurança em várias camadas, combinando medidas proativas e reativas.

É aqui que soluções como Neptune Mutual entram em jogo, oferecendo uma salvaguarda contra as consequências de tais vulnerabilidades. Se os projetos afetados tivessem colaborado com a Neptune Mutual e estabelecido pools de cobertura dedicados, as repercussões financeiras do exploit poderiam ter sido significativamente mitigadas. Neptune Mutual oferece cobertura para usuários que sofreram perdas de fundos ou ativos digitais devido a vulnerabilidades de contratos inteligentes, utilizando suas políticas paramétricas inovadoras.

Os usuários que compram nossas políticas de cobertura paramétrica não precisam fornecer evidências de sua perda para receber pagamentos. Uma vez que um incidente é confirmado e resolvido por meio de nosso sistema de resolução de incidentes, os pagamentos podem ser reivindicados imediatamente. Nosso mercado está disponível em várias redes blockchain populares, incluindo Ethereum, Arbitrum e a BNB Chain, oferecendo cobertura a uma variedade diversificada de usuários DeFi e reforçando sua confiança no ecossistema.

Mercado Neptune Mutual

Após esta exploração, as coberturas para a Curve Finance tanto na Ethereum quanto na Arbitrum foram marcadas como “Incidente Ocorrido” após serem relatadas pelos membros da comunidade.

O período de relato de incidentes de 7 dias começou em nosso mercado em ambas as cadeias, onde os detentores de tokens NPM participaram do processo de relato.

Um período de espera de 24 horas começou após o fim do período de relato, e a resolução foi feita em favor do Primeiro Relator, sendo assim considerado o Relator Final.

Após isso, uma janela de 7 dias começou em 9 de agosto, cerca de 9 dias após a exploração, durante a qual todos os detentores de políticas da Curve Finance foram elegíveis para reivindicar seu pagamento. Em 16 de agosto, o pool da Curve Finance foi reaberto para a compra de cobertura e fornecimento de liquidez.

Saiba mais sobre o processo de resolução de incidentes em nosso vídeo aqui e proteja seus ativos digitais de hacks, exploits e vulnerabilidades de contratos inteligentes com o Neptune Mutual Cover Marketplace.

Sobre nós

O projeto Neptune Mutual protege a comunidade Ethereum de ameaças cibernéticas. O protocolo usa cobertura paramétrica em oposição ao seguro discricionário. Ele tem um processo de reivindicação na cadeia fácil e confiável. Isso significa que, quando incidentes são confirmados por nossa comunidade, a resolução é rápida.

Junte-se a nós em nossa missão de cobrir, proteger e garantir ativos digitais na cadeia.

Website Oficial: https://neptunemutual.com \
Blog: https://neptunemutual.com/blog/ \
Twitter: https://twitter.com/neptunemutual \
Fóruns: https://community.neptunemutual.com/ \
Telegram: https://t.me/neptunemutual \
Discord: https://discord.gg/2qMGTtJtnW \
YouTube: https://www.youtube.com/c/NeptuneMutual \
LinkedIn: https://www.linkedin.com/company/neptune-mutual

Artigo original publicado por Neptune Mutual. Traduzido por Paulinho Giovannini.

Latest comments (0)