Hackeie um contrato inteligente de jogo e aprenda suas medidas preventivas
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]))
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);
}
}
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);
}
}
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.
Latest comments (0)