Dica: pense duas vezes antes de usar recipient.call{value: amount}("")
.
Você está escrevendo um contrato inteligente que leiloa um NFT. O maior licitante recebe o NFT e todos os outros licitantes recebem seu dinheiro de volta assim que são derrotados. Qual é a melhor maneira de fazer isto?
Como se deve reembolsar os licitantes do leilão na mesma transação em que eles são vencidos?
Envio de ETH de um contrato: um problema enganosamente complicado
Por quê? Porque você deve levar em conta o caso em que você esteja enviando ETH para outro contrato e quando um contrato recebe ETH ele pode imediatamente executar qualquer código que desejar.
É bastante assustador quando se pensa sobre isso. Você nunca iria a uma página web aleatória, conectaria sua carteira e interagiria com um contrato inteligente não verificado que você nunca tinha visto antes. E ainda assim é isso que acontece sempre que seu contrato envia ETH para um contrato!
O que é o pior que pode acontecer? A Reentrância e o Hack da DAO
Em 2015, um invasor roubou 3.6M ETH da The DAO usando o que se chama de um “ataque de reentrância.” Aqui está um exemplo de código vulnerável à reentrância:
Como perder 3,6 milhões de ethers com uma vulnerabilidade de reentrância.
O ETH é enviado na linha msg.sender.call{value: amount}("")
. Esse código envia a amount
de ETH para msg.sender
e depois permite ao msg.sender
o que quiser.
O código funciona chamando uma função sem nome em msg.sender
. Como todas as funções reais têm nomes, esta chamada não atingirá nenhuma função real, mas sim acionará a “função fallback” da msg.sender
que é executada quando uma função não válida é chamada. (Se msg.sender
não é um contrato, nada disso acontece e eles apenas recebem o ETH).
A reentrância ocorre quando a função fallback chama withdraw()
de novo. Isto faz com que withdraw()
seja executada a partir do topo, o que retira a mesma quantidade do balanceOf e o envia para msg.sender
.
O aspecto recursivo da reentrância dificulta um pouco o entendimento, mas você também pode pensar nela de forma iterativa: a reentrância basicamente envolve todo o código até e incluindo o call()
com um loop for
:
O resultado, claro, é que o msg.sender pode retirar o quanto quiser.
Como prevenir a reentrância?
A resposta "fácil" é zerar o saldo do invasor antes de deixá-lo executar um código arbitrário:
Fixando a reentrância através da atualização do armazenamento interno antes de interagir com contratos externos
Agora, a retirada já está marcada como "completa" quando o invasor tenta reexecutar a retirada e a retirada duplicada falha.
Esta correção parece simples porque requer apenas a mudança de uma linha de código. Mas olhando para ela hoje, temos o benefício de uma retrospectiva, assim como muitos artigos da Mídia descrevendo como este tipo de ataque funciona.
Como podemos abordar este problema prospectivamente e corrigir este tipo de vulnerabilidade antes que alguém já tenha perdido bilhões de dólares, ensinando-nos as mudanças fáceis de uma linha que precisamos fazer?
Uma abordagem melhor: deixar de dar aos destinatários de ETH gas ilimitado
Embora não possamos antecipar ou controlar o que um invasor fará quando receber ETH, podemos controlar o quanto ele pode fazer limitando a quantidade de gas que ele pode usar. Esta é uma ferramenta poderosa porque os ataques de reentrância exigem muito gas.
No código acima, permitimos que os destinatários utilizem todo o gas disponível para o contrato de envio:
Por padrão, call() encaminha todo o gas disponível para a callee
E se ajustarmos o parâmetro do gas para um número menor?
No despertar do hack da The DAO uma nova função chamada transfer()
foi adicionada à Solidity que é basicamente o equivalente a msg.sender.call{value: amount, gas: 2_300}("")
.
O valor de 2,300 foi considerado como o mínimo absoluto necessário para receber ETH, impedindo assim, que os destinatários executassem um ataque (ou fizessem qualquer coisa).
Infelizmente, 2.300 é muito baixo para fazer coisas muito razoáveis e necessárias. E mesmo que hoje seja suficiente, os custos de gas estão sujeitos a mudanças e assim seu contrato poderá quebrar no futuro se você confiar que 2.300 de gas sejam suficientes.
Famosamente, este é um problema com os Cofres da Gnosis que não podem receber ETH por meio da função transfer().
Por causa disso, você não deve usar transfer()
a menos que você esteja certo de que esteja enviando ETH para uma EOA (Externally Owned Accounts - contas de propriedade externa). E como você não pode ter certeza, você simplesmente, nunca deve usá-lo.
Encaminhar todo o gas é ruim. Limitar o gas a 2.300 é ruim. O que fazer?
Estranhamente, a resposta consensual aqui é simplesmente voltar para o encaminhamento de todo o gas.
O OpenZeppelin, uma das vozes mais respeitadas do espaço e o criador de uma das mais respeitadas bibliotecas de Solidity consagra esta crença em sua biblioteca para o envio de ETH através da função sendValue()
:
O problema com esta função e o conselho no comentário é que ela implica que as vulnerabilidades de reentrância são a única coisa com a qual se deve preocupar quando se entrega o controle ao destinatário do ETH.
Entretanto, deve-se ter cuidado para fazer mais do que apenas "não introduzir vulnerabilidades de reentrância".
Sofrimento pelo gas: Por que o Address.sendValue() do OpenZeppelin é arriscado mesmo que seu código seja seguro contra reentrância
Se você encaminhar todo o gas disponível para outro contrato, esse contrato pode causar-lhe problemas simplesmente usando todo o gas disponível e tornando impossível que seu contrato faça qualquer outra coisa. Por exemplo, ele poderia executar esta função quando recebesse ETH:
O gas de contrato de um invasor te aflige. Agora o seu contrato é aquele que tem apenas 2.300 de gas para gastar!
No exemplo de leilão acima, se o licitante mais alto fizer com que seja impossível enviá-los ETH sem ficar sem gas, será impossível fazer uma oferta mais alta, pois a oferta mais alta requer que aconteça um reembolso bem sucedido.
Se você usar Address.sendValue() num contrato de leilão você ficará arruinado.
Um caminho à frente: limites de gas + retiradas
Uma abordagem melhor é combinar a sabedoria da limitação de gas introduzido por transfer()
com uma retirada que se efetua se o limite de gas não for suficiente, em vez de reverter inteiramente como a transfer()
faz.
Uma retirada com base em WETH
Considere este código para enviar ETH:
Se a transferência de ETH falhar, nós giramos e enviamos algo de igual valor em seu lugar: WETH. Podemos até mesmo definir o limite de gas acima de 2.300, se quisermos assumir um risco maior progressivamente e dar aos destinatários maior funcionalidade gradualmente.
No entanto, embora o WETH seja de igual valor, a conversão de ETH para WETH incorre em um custo, particularmente se o destinatário tiver um saldo zero de WETH. Além disso, o destinatário deve pagar novamente para converter seu novo WETH em ETH.
Além disso, o contrato de recebimento deve ter a funcionalidade de retirar o WETH em primeiro lugar. Carteiras baseadas em contratos como os Cofres Gnosis estarão bem, mas contratos aleatórios podem não estar.
Por este motivo, você deve considerar um limite de gas mais alto para os destinatários (digamos 100.000).
Uma alternativa baseada em autodestruição
O ideal seria que nossa retirada continuasse a enviar ETH. Mas como podemos fazer isso se nossa retirada foi acionada por uma falha no envio do ETH? Podemos forçar um contrato a aceitar nosso ETH, quer sua função de retirada de gas exceda ou não nosso limite de gas?
Acontece que podemos! Quando você pede um contrato para se autodestruir, você também fornece um endereço para onde o saldo desse contrato seja enviado. Quando um contrato envia ETH desta maneira, a função de recebimento da retirada do contrato não é acionada. Eles são "forçados" a aceitar o ETH!
Eis como funciona esta nova abordagem:
- Seu contrato tenta uma transferência de ETH via
call()
com um limite de gas. - Se isso falhar, ele implanta um novo contrato, incluindo o valor que você deseja transferir na chamada para criar o contrato.
- O novo contrato se autodestrói instantaneamente em seu construtor, enviando seu saldo para o destinatário do ETH.
Esta abordagem fascinante foi pioneira na biblioteca Solady da Solidity. Como um bônus adicional, ele é escrito em assembly, o que o torna altamente eficiente e também completamente incompreensível (pelo menos para mim):
Há algo bastante elegante em forçar o ETH a transferir, em vez de algum outro ativo substituto. No entanto, há sempre a possibilidade de que forçar o ETH a entrar no contrato vai torná-lo inacessível, por exemplo, se o contrato destinatário precisar atualizar um mapeamento de saldos para que as retiradas funcionem.
Implantar um novo contrato também custa gas, mas fazer com que ele se autodestrua te dá um reembolso de gas. Eu não testei isso, mas acredito que essa abordagem seja mais barata do que a abordagem de WETH.
No final, é uma chamada de julgamento! Talvez enviar metade via SELFDESTRUCT
e metade com WETH?? Brincadeira! Mas o que quer que você faça, pense duas vezes antes de usar a Address.sendValue()
!
Fim!
Você tem comentários sobre este artigo? Eu cometi um erro?
As DMs do meu Twitter estão abertas!
Você também pode conferir alguns dos meus trabalhos em
Esse artigo foi escrito por Tom Lehman (middlemarch.eth) e traduzido por Fátima Lima. Seu original pode ser lido aqui.
Latest comments (0)