Recentemente, tenho revisado meus conhecimentos em Solidity para consolidar detalhes e escrever um "Guia Simplificado de Solidity WTF" para iniciantes (programadores experientes podem procurar outros tutoriais), com atualizações semanais de 1 a 3 aulas.
Twitter: @0xAA_Science | @WTFAcademy_
Comunidade: Discord | Grupo no WhatsApp | Site oficial wtf.academy
Todo o código e tutoriais são de código aberto no GitHub: github.com/AmazingAng/WTF-Solidity
Nesta aula, vamos abordar uma vulnerabilidade comum em contratos inteligentes, que é a falta de verificação em chamadas de baixo nível (low-level calls). Quando uma chamada de baixo nível falha, a transação não é revertida, e se o retorno dessa chamada não for verificado, problemas graves podem surgir.
As chamadas de baixo nível em Ethereum incluem call()
, delegatecall()
, staticcall()
, e send()
. Essas funções se comportam de forma diferente das demais funções em Solidity: quando uma exceção ocorre, elas não são propagadas para o nível superior e não resultam em uma reversão completa da transação; em vez disso, elas retornam um valor booleano false
indicando a falha na chamada. Portanto, se o retorno de uma chamada de baixo nível não for verificado, o código no nível superior continuará a ser executado. Para mais detalhes sobre chamadas de baixo nível, consulte a aulas 20-23 do WTF Solidity.
Uma situação comum é o uso do send()
: alguns contratos usam o send()
para enviar ETH
, porém o send()
tem um limite de gas abaixo de 2300, caso contrário falha. Quando o destino da chamada tem uma função de callback complexa, o consumo de gas pode exceder 2300, resultando em falha. Se neste momento o retorno da função não for verificado no nível superior, a transação continuará a ser executada, causando problemas inesperados. Em 2016, o jogo "King of Ether" teve problemas de reembolso devido a essa vulnerabilidade (relatório post-mortem).
Este contrato é uma modificação do contrato bancário apresentado na aula S01 Ataque de Reentrada
. Ele inclui uma variável de estado balanceOf
para registrar os saldos em Ethereum de todos os usuários, e três funções:
deposit()
: função de depósito para adicionarETH
no contrato do banco e atualizar o saldo do usuário.withdraw()
: função de saque para transferir o saldo do chamador. Os passos são semelhantes aos da história mencionada anteriormente: verificar o saldo, atualizar o saldo e transferir. Observação: esta função não verifica o retorno dosend()
, então o saque pode falhar sem zerar o saldo!getBalance()
: função para obter o saldo em Ethereum do contrato do banco.
contract UncheckedBank {
mapping (address => uint256) public balanceOf; // Mapping de saldos
// Deposita ether e atualiza o saldo
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}
// Retira todo o ether do msg.sender
function withdraw() external {
// Obtém o saldo
uint256 balance = balanceOf[msg.sender];
require(balance > 0, "Saldo insuficiente");
balanceOf[msg.sender] = 0;
// Chamada de baixo nível não verificada
bool success = payable(msg.sender).send(balance);
}
// Obtém o saldo do contrato do banco
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
Criamos um contrato de ataque que representa um azarado depositante, cuja tentativa de saque falha, mas o saldo é zerado: a função de callback do contrato receive()
com revert()
faz a transação ser revertida e não aceita ETH
; no entanto, a função de saque withdraw()
do contrato pode ser chamada com sucesso, limpando o saldo.
contract Attack {
UncheckedBank public bank; // Endereço do contrato do Banco
// Inicializa o endereço do contrato do Banco
constructor(UncheckedBank _bank) {
bank = _bank;
}
// Função de callback que falha ao receber ETH
receive() external payable {
revert();
}
// Função de depósito, o valor do depósito é passado como msg.value
function deposit() external payable {
bank.deposit{value: msg.value}();
}
// Função de saque, mesmo que a chamada tenha sucesso, o saque na verdade falha
function withdraw() external payable {
bank.withdraw();
}
// Obtém o saldo deste contrato
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
-
Implemente o contrato
UncheckedBank
. -
Implemente o contrato
Attack
, passando o endereço do contratoUncheckedBank
como parâmetro no construtor. -
Chame a função
deposit()
do contratoAttack
para depositar1 ETH
. -
Chame a função
withdraw()
do contratoAttack
para sacar os fundos, a chamada é feita com sucesso. -
Em seguida, chame a função
balanceOf()
do contratoUncheckedBank
e a funçãogetBalance()
do contratoAttack
. Apesar do saque bem-sucedido na etapa anterior, o saldo da conta do depositante falha.
Você pode adotar as seguintes medidas para prevenir a vulnerabilidade de chamadas de baixo nível não verificadas:
-
Verifique o retorno de chamadas de baixo nível. No contrato bancário mencionado anteriormente, podemos corrigir o
withdraw()
.bool success = payable(msg.sender).send(balance); require(success, "Falha ao enviar ETH!");
-
Ao transferir
ETH
em contratos, usecall()
e implemente proteção contra reentrância. -
Utilize a biblioteca Address da OpenZeppelin, que encapsula chamadas de baixo nível verificadas.
Exploramos a vulnerabilidade das chamadas de baixo nível não verificadas e as medidas preventiva. Chamadas de baixo nível em Ethereum (call, delegatecall, staticcall, send) retornam false
em caso de falha, mas não revertam a transação completamente. Se o retorno não for verificado, podem ocorrer problemas inesperados.