WEB3DEV

Cover image for Como Encontrar Vulnerabilidade em Contratos Inteligentes — Ether Inesperado
Panegali
Panegali

Posted on • Atualizado em

Como Encontrar Vulnerabilidade em Contratos Inteligentes — Ether Inesperado

Hackeie um contrato inteligente de jogo e aprenda suas medidas preventivas

1 Crédito da foto: Stillness InMotion

Normalmente, quando você envia ether para um contrato, ele deve executar a função de fallback ou outra função definida no contrato. Existem duas exceções para isto, onde o ether pode existir em um contrato sem executar nenhum código. Os contratos que dependem da execução de código para todo o ether enviado a eles podem ser vulneráveis ​​a ataques em que o ether é enviado à força.

A vulnerabilidade

Uma técnica de programação defensiva típica que é valiosa para impor transições de estado corretas ou validar operações é a verificação invariável. Esse método envolve definir um conjunto de invariantes (métricas ou parâmetros que não precisam ser alterados) e verificar se elas não mudam após uma (ou mais) operações. Um exemplo de invariante é o totalSupply de um token ERC20 de emissão fixa. Porque nenhuma função deve alterar essa invariante.

Em particular, há uma invariante óbvia que pode ser tentadora de usar, mas pode de fato ser manipulada por usuários externos (apesar das regras estabelecidas no contrato inteligente). Este é o ether atualmente armazenado no contrato. Muitas vezes, quando os desenvolvedores aprendem sobre o Solidity, eles têm a concepção errônea de que um contrato pode aceitar ether apenas por meio de funções pagáveis. Esse mal-entendido pode levar a contratos com falsas suposições sobre o equilíbrio do ether dentro deles, o que pode levar a várias vulnerabilidades. A chave para esta vulnerabilidade é o uso (incorreto) do this.balance.

Existem duas maneiras pelas quais o ether pode (forçadamente) ser enviado para um contrato que não usa uma função pagável ou não executa nenhum código no contrato:

1. Autodestruição

Cada contrato poderá executar a função selfdestruct que remove todo o bytecode do endereço do contrato e envia todo o ether armazenado lá para o endereço especificado pelo parâmetro. Se o endereço especificado também for um contrato, nenhuma função (incluindo o fallback) será chamada. Portanto, a função selfdestruct pode ser forçada a enviar ether para qualquer contrato independentemente de qualquer código que possa existir no contrato, mesmo contratos sem funções pagáveis. Isso significa que um invasor pode criar um contrato com uma função selfdestruct, enviar ether para ela, chamar selfdestruct(target)e forçar o envio de ether para um contrato target.

2. Ether pré-enviado

Outra maneira de inserir o ether em um contrato é pré-carregar o endereço do contrato com ether. Os endereços de contrato são determinísticos — na verdade, o endereço é calculado a partir do hash Keccak-256 (semelhante ao SHA-3) do endereço que cria o contrato e do nonce da transação que cria o contrato. Especificamente, tem a forma:

address = sha3(rlp.encode([account_address,transaction_nonce]))
Enter fullscreen mode Exit fullscreen mode

Vamos explorar algumas armadilhas que podem surgir com esse conhecimento. Considere o contrato excessivamente simples no EtherGame.sol.

contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;

    mapping(address => uint) redeemableEther;
    // Os usuários pagam 0,5 ether. Em etapas específicas, creditam suas contas.
    function play() external payable {
        require(msg.value == 0.5 ether); // cada jogada custa 0,5 éter
        uint currentBalance = this.balance + msg.value;
        // garantir que não haja jogadores após o término do jogo
        require(currentBalance <= finalMileStone);
        // se em um marco histórico, creditar a conta do jogador
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }
        return;
    }

    function claimReward() public {
        // garantir que o jogo esteja completo
        require(this.balance == finalMileStone);
        // garantir que haja uma recompensa a ser dada
        require(redeemableEther[msg.sender] > 0);
        uint transferValue = redeemableEther[msg.sender];
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }
Enter fullscreen mode Exit fullscreen mode

Este contrato representa um jogo simples (que naturalmente envolveria condições de corrida) onde os jogadores enviam 0,5 ether para o contrato na esperança de se tornarem o primeiro jogador a atingir um dos três marcos. Os marcos são denominados em ether. O primeiro a atingir o marco pode reivindicar uma parte do ether após o término do jogo. O jogo termina quando o último marco de 10 ether é alcançado; os usuários podem então reivindicar suas recompensas.

Os problemas com o contrato EtherGame vêm do mau uso do this.balance, em ambas as linhas 14 e 32. Um invasor pode enviar à força uma pequena quantidade de ether - digamos, 0,1 ether - através da função selfdestruct (discutida anteriormente) para impedir que futuros jogadores atinjam um marco. this.balance nunca será um múltiplo de 0,5 ether graças a esta contribuição de 0,1 ether, porque todos os jogadores legítimos só podem enviar incrementos de 0,5 ether. Isso impede que todas as condições 'if' nas linhas 18, 21 e 24 sejam verdadeiras.

O pior é que um invasor que perdeu um marco pode enviar à força 10 ether (ou uma quantidade equivalente de ether que altera o saldo do contrato acima do finalMileStone), o que pode bloquear todas as recompensas no contrato para sempre. Isso ocorre porque a função claimReward sempre será revertida, devido a exigência na linha 32 (ou seja, porque this.balance é maior do que finalMileStone).

Técnicas Preventivas

Esse tipo de vulnerabilidade geralmente surge devido ao uso indevido do this.balance. A lógica do contrato, quando possível, deve evitar depender de valores exatos do saldo do contrato, pois pode ser manipulado artificialmente. Se aplicar lógica baseada emthis.balance, você terá que lidar com saldos inesperados.

Se for necessária uma quantidade exata de ether depositado, deve ser usada uma variável autodefinida que é incrementada em funções a pagar, para rastrear com segurança o ether depositado. Esta variável não será influenciada pelo ether forçado enviado via chamada selfdestruct.

Com isso em mente, uma versão corrigida do contrato EtherGame poderia ser assim:

contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;
    uint public depositedWei;

    mapping (address => uint) redeemableEther;

    function play() external payable {
        require(msg.value == 0.5 ether);
        uint currentBalance = depositedWei + msg.value;
        // garantir que não haja jogadores após o término do jogo
        require(currentBalance <= finalMileStone);
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }
        depositedWei += msg.value;
        return;
    }

    function claimReward() public {
        // certifique-se de que o jogo está completo
        require(depositedWei == finalMileStone);
        // garantir que haja uma recompensa a ser dada
        require(redeemableEther[msg.sender] > 0);
        uint transferValue = redeemableEther[msg.sender];
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }
Enter fullscreen mode Exit fullscreen mode

Aqui, criamos uma nova variável, depositedWei, que rastreia o ether conhecido depositado, e é essa variável que usamos para nossos testes. Observe que não temos mais nenhuma referência a this.balance.

Confira também outra vulnerabilidade “Ataque de Reentrância” caso não o tenha feito:

Agradecimentos à Anupam Chugh


Artigo escrito por Abhishek Chauhan. O original pode ser encontrado aqui. Traduzido e adaptado por Marcelo Panegali.

Top comments (0)