Pular para o conteúdo principal
Change page

Segurança de um Contrato Inteligente

Última edição: @rafarocha(opens in a new tab), 22 de maio de 2024

Os contratos inteligentes são extremamente flexíveis e capazes de controlar grandes quantidades de valor e dados, enquanto executam lógica imutável com base no código implantado na blockchain. Isto criou um vibrante ecossistema de aplicações descentralizadas e sem confiança que oferecem muitas vantagens sobre os sistemas legados. Eles também representam oportunidades para os invasores que procuram lucrar explorando vulnerabilidades em contratos inteligentes.

Blockchains públicas, como a Ethereum, complicam ainda mais a questão de proteger contratos inteligentes. O código de contrato implantado geralmente não pode ser alterado para corrigir falhas de segurança, enquanto os ativos roubados de contratos inteligentes são extremamente difíceis de rastrear e, em sua maioria, irrecuperáveis devido à imutabilidade.

Embora os números variem, estima-se que o valor total roubado ou perdido devido a defeitos de segurança em contratos inteligentes é facilmente superior a 1 bilhão de dólares. Isso inclui incidentes de alto perfil, como o DAO hack(opens in a new tab) (com 3,6 milhões de ETH roubados, no valor de mais de US$ 1 bilhão de dólares nos preços de hoje), Hack da carteira múltiplas assinaturas da Parity(opens in a new tab) (US$ 30 milhões perdidos para hackers) e o Caso da carteira congelada da Parity(opens in a new tab) (mais de US$ 300 milhões em ETH bloqueados para sempre).

As questões mencionadas tornam imperativo para os desenvolvedores investirem esforços na construção de contratos inteligentes seguros, sólidos e resistentes. Segurança dos contratos inteligentes é um assunto sério, e todo desenvolvedor deve aprender. Este guia abrangerá considerações de segurança para desenvolvedores de Ethereum e explorará recursos para melhorar a segurança dos contratos inteligentes.

Pré-requisitos

Certifique-se de estar familiarizado com os fundamentos do desenvolvimento de contratos inteligentes antes de abordar a segurança.

Diretrizes para construir contratos inteligentes Ethereum seguros

1. Crie controles de acesso adequados

Em contratos inteligentes, funções marcadas públicas ou externas podem ser chamadas por quaisquer contas externas (EOAs) ou contas de contrato. Especificar a visibilidade pública para funções é necessária se você quiser que outras pessoas interajam com seu contrato. As funções marcadas como privadas, no entanto, só podem ser chamadas por funções dentro do contrato inteligente e não por contas externas. Dar a cada participantes da rede o acesso às funções do contrato pode causar problemas, especialmente se isso significar que qualquer pessoa pode realizar operações confidenciais (por exemplo, cunhar novos tokens).

Para evitar o uso não autorizado de funções do contrato inteligente, é necessário implementar controles de acesso seguros. Os mecanismos de controle de acesso restringem a capacidade de usar determinadas funções em um contrato inteligente para entidades aprovadas, como contas responsáveis pelo gerenciamento do contrato. O padrão de propriedade e o controle baseado em funções são dois padrões úteis para implementar o controle de acesso em contratos inteligentes:

Padrão proprietário

No padrão Proprietário, um endereço é definido como o “dono” do contrato durante o processo de criação do contrato. As funções protegidas são atribuídas a um modificador OnlyOwner, que garante que o contrato autentique a identidade do endereço de chamada antes de executar a função. Chamadas para funções protegidas de outros endereços além do proprietário do contrato sempre revertem, impedindo o acesso indesejado.

Controle de acesso baseado em funções

Registrar um único endereço como Proprietário em um contrato inteligente apresenta o risco de centralização e representa um único ponto de falha. Se as chaves da conta do proprietário forem comprometidas, os invasores podem invadir o contrato de propriedade. É por isso que usar um padrão de controle de acesso baseado em funções com várias contas administrativas pode ser uma opção melhor.

No controle de acesso baseado em funções, o acesso a funções confidenciais é distribuído entre um conjunto de participantes confiáveis. Por exemplo, uma conta pode ser responsável por cunhar tokens (transformar um ativo digital na blockchain), enquanto outra conta realiza atualizações ou pausa o contrato. Descentralizar o controle de acesso dessa forma elimina pontos únicos de falha e reduz as suposições de confiança para os usuários.

Usando carteiras multi-assinatura

Outra abordagem para implementar controle seguro de acesso é usar uma conta de múltiplas assinaturas para gerenciar um contrato. Ao contrário de um EOA (conta de propriedade externa) regular, as contas com várias assinaturas são de propriedade de várias entidades e exigem assinaturas de um número mínimo de contas - digamos 3 de 5 - para executar transações.

O uso de multisig (múltiplas assinaturas) para controle de acesso introduz uma camada extra de segurança, pois as ações no contrato de destino exigem o consentimento de várias partes. Isso é particularmente útil se usar o padrão Proprietário, pois torna mais difícil para um invasor ou malfeitor interno de manipular funções de contrato confidenciais para fins maliciosos.

2. Use as instruções require(), assert() e revert() para proteger as operações do contrato

Como mencionado, qualquer pessoa pode chamar funções públicas em seu contrato inteligente uma vez que ele é implantado na blockchain. Como você não pode saber com antecedência como as contas externas (EOA) vão interagir com um contrato, é ideal implementar proteções internas contra operações problemáticas antes da implantação. Você pode aplicar o comportamento correto nos contratos inteligentes usando as instruções require(), assert() e revert() para acionar exceções e reverter mudanças de estado se a execução não satisfazer determinados requisitos.

require(): require são definidas no início das funções e garantem condições predefinidas antes que a função chamada seja executada. Uma instrução require pode ser usada para validar entradas do usuário, verificar variáveis de estado ou autenticar a identidade da conta de chamada antes de prosseguir com uma função.

assert(): assert() é usado para detectar erros internos e verificar por violações de "invariáveis" em seu código. Uma invariável é uma asserção lógica sobre o estado de um contrato que deve ser verdadeira para todas as execuções de função. Um exemplo invariável é a oferta total máxima ou saldo de um contrato de token. O uso do assert() garante que seu contrato nunca atinja um estado vulnerável e, se isso acontecer, todas as mudanças nas variáveis de estado serão revertidas.

revert(): revert() pode ser usado em uma instrução if-else (se-senão) que aciona uma exceção se a condição exigida não for satisfeita. O modelo de contrato abaixo usa o revert() para proteger a execução de funções:

1pragma solidity ^0.8.4;
2
3contract VendingMachine {
4 address owner;
5 error Unauthorized();
6 function buy(uint amount) public payable {
7 if (amount > msg.value / 2 ether)
8 revert("Not enough Ether provided.");
9 // Perform the purchase.
10 }
11 function withdraw() public {
12 if (msg.sender != owner)
13 revert Unauthorized();
14
15 payable(msg.sender).transfer(address(this).balance);
16 }
17}
Exibir tudo

3. Teste contratos inteligentes e verifique a corretude do código

A imutabilidade do código em execução na Máquina Virtual Ethereum significa que os contratos inteligentes demandam um nível mais alto de avaliação de qualidade durante a fase de desenvolvimento. Testar seu contrato extensivamente e observá-lo para quaisquer resultados inesperados irão melhorar muito a segurança e proteger os seus usuários a longo prazo.

O método habitual é escrever pequenos testes unitários utilizando dados simulados que o contrato deverá receber dos usuários. O teste unitário é bom para testar a funcionalidade de determinadas funções e garantir que um contrato inteligente funcione como esperado.

Infelizmente, o teste unitário é minimamente eficaz para melhorar a segurança do contrato inteligente quando usado isoladamente. Um teste unitário pode provar que uma função é executada corretamente para dados simulados (mock), mas os testes unitários são tão eficazes quanto os testes que são escritos. Isso torna difícil detectar casos perdidos de falha e vulnerabilidades que poderiam quebrar a segurança de seu contrato inteligente.

Uma abordagem melhor é combinar testes unitários com testes baseados em propriedades realizados usando análise estática e dinâmica (do código). A análise estática depende de representações de baixo nível, como controlar fluxo de controle(opens in a new tab) e árvores de sintaxe abstrata(opens in a new tab) para analisar estados de programas alcançáveis e caminhos de execução. Enquanto isso, técnicas de análise dinâmica, como fuzzing, executam código do contrato com valores de entrada aleatórios para detectar operações que violam propriedades de segurança.

A Verificação Formal é outra técnica para verificar propriedades de segurança em contratos inteligentes. Ao contrário dos testes regulares, a verificação formal pode comprovar conclusivamente a ausência de erros em um contrato inteligente. Isso é alcançado criando uma especificação formal que captura as propriedades de segurança desejadas e provando que um modelo formal dos contratos adere a esta especificação.

4. Peça uma revisão independente do seu código

Depois de testar seu contrato, é bom pedir aos outros que verifiquem o código-fonte para quaisquer problemas de segurança. O teste não revelará todas as falhas de um contrato inteligente, mas realizar uma revisão independente aumenta a possibilidade de detectar vulnerabilidades.

Auditorias

A comissão de uma auditoria de contrato inteligente é uma forma de realizar uma revisão de código independente. Os auditores desempenham um papel importante na garantia de que os contratos inteligentes sejam seguros e livres de falhas de qualidade e erros de concepção.

Com isto em mente, há que evitar tratar as auditorias como uma bala de prata. Auditorias de contratos inteligentes não irão detectar todos os bugs e são concebidas principalmente para fornecer uma rodada adicional de revisões, o qual pode ajudar a detectar problemas perdidos pelos desenvolvedores durante o desenvolvimento e testes iniciais. Você também deve seguir as melhores práticas para trabalhar com auditores(opens in a new tab), como documentar o código apropriadamente e adicionar comentários em linha, para maximizar o benefício de uma auditoria de contrato inteligente.

Recompensa por bugs

A criação de um programa de recompensas por bugs é outra abordagem para implementar revisões de código externas. Uma recompensa por bugs é uma recompensa financeira dada a indivíduos (geralmente hackers de chapéu branco) que descobrem vulnerabilidades em um aplicativo.

Quando usadas corretamente, as recompensas por bugs dão aos membros da comunidade hacker incentivo para inspecionar seu código em busca de falhas críticas. Um exemplo da vida real é o “bug do dinheiro infinito” que teria deixado um invasor criar uma quantidade ilimitada de Ether no Optimism(opens in a new tab), um protocolo da Camada 2 em execução na Ethereum. Felizmente, um hacker de chapéu branco descobriu a falha(opens in a new tab) e notificou a equipe, ganhando um grande pagamento no processo(opens in a new tab).

Uma estratégia útil é definir o pagamento de um programa de recompensas por bugs proporcionalmente à quantidade de fundos em jogo. Descrita como a “recompensa por bugs que escala(opens in a new tab)”, essa abordagem fornece incentivos financeiros para que os indivíduos revelem vulnerabilidades de forma responsável em vez de explorá-las.

5. Siga as melhores práticas durante o desenvolvimento de contratos inteligentes

A existência de auditorias e recompensas por bugs não dispensa sua responsabilidade de escrever código de alta qualidade. Uma boa segurança em contrato inteligente começa com os seguintes processos de concepção e desenvolvimento adequados:

  • Guarde todo o código em um sistema de controle de versão, como git

  • Faça todas as modificações de código por meio de solicitações de pull (conhecido como pull request, da sigla PR)

  • Garanta que as solicitações de pull (PR) tenham pelo menos um revisor independente - se você estiver trabalhando sozinho(a) em um projeto, considere encontrar outros desenvolvedores e negociar revisões de código

  • Use um ambiente de desenvolvimento para testar, compilar e implantar contratos inteligentes

  • Execute seu código por meio de ferramentas básicas de análise de código, como Mythril e Slither. Idealmente, você deve fazer isso antes de cada solicitação de pull ser mesclado (merge) e comparar as diferenças na saída

  • Garanta que seu código seja compilado sem erros e que o compilador Solidity não emita alertas

  • Documente seu código adequadamente (usando NatSpec(opens in a new tab)) e descreva detalhes sobre a arquitetura do contrato em linguagem fácil de entender. Isso facilitará para outras pessoas auditarem e revisarem seu código.

6. Implemente planos robustos de recuperação de desastres

Conceber controles de acesso seguros, implementar modificadores de função e outras sugestões podem melhorar a segurança do contrato inteligente, mas não podem excluir a possibilidade de explorações maliciosas. Construir contratos inteligentes seguros requer “preparar-se para falhas” e ter um plano de retorno para responder de forma eficaz a ataques. Um plano de recuperação de desastres adequado incorporará alguns ou todos os seguintes componentes:

Atualizações de contrato

Embora os contratos inteligentes Ethereum sejam imutáveis por padrão, é possível alcançar algum grau de mutabilidade usando padrões de atualização. A atualização de contratos é necessária nos casos em que uma falha crítica torna seu contrato antigo inutilizável e a implantação de uma nova lógica é a opção mais viável.

Os mecanismos de atualização de contrato funcionam de forma diferente, mas o “padrão de proxy” é uma das abordagens mais populares para atualizar contratos inteligentes. Os padrões de proxy dividem o estado e a lógica de um aplicativo entre dois contratos. O primeiro contrato (chamado de 'contrato de proxy') armazena variáveis de estado (por exemplo, saldos de usuários), enquanto o segundo contrato (chamado de 'contrato lógico') contém o código para executar funções de contrato.

As contas interagem com o contrato de proxy, que despacha todas as chamadas de função para o contrato lógico usando o delegatecall()(opens in a new tab) em chamada de baixo nível. Ao contrário de uma chamada de mensagem normal, o delegatecall() garante que o código executado no endereço do contrato lógico seja executado no contexto do contrato de chamada. Isso significa que o contrato lógico sempre escreverá no armazenamento do proxy (em vez de em seu próprio armazenamento) e os valores originais de msg.sender e msg.value são preservados.

Delegar chamadas para o contrato lógico requer armazenar seu endereço no armazenamento do contrato de proxy. Portanto, atualizar a lógica do contrato é apenas uma questão de implantar outro contrato lógico e armazenar o novo endereço no contrato de proxy. Como as chamadas subsequentes para o contrato de proxy são roteadas automaticamente para o novo contrato lógico, você teria “atualizado” o contrato sem realmente modificar o código.

Mais sobre atualização de contratos.

Interrupções de emergência

Como mencionado, auditorias e testes extensivos não podem descobrir todos os bugs em um contrato inteligente. Se uma vulnerabilidade aparecer em seu código após a implantação, corrigi-la é impossível, pois você não pode alterar o código em execução no endereço do contrato. Além disso, mecanismos de atualização (por exemplo, padrões de proxy) podem levar tempo para serem implementados (eles geralmente exigem aprovação de diferentes partes), o que só dá aos invasores mais tempo para causar mais danos.

A opção nuclear é implementar uma função de “interrupção de emergência” que bloqueia chamadas para funções vulneráveis em um contrato. As interrupções ou paradas de emergência normalmente compreendem os seguintes componentes:

  1. Uma variável global booleana indicando se o contrato inteligente está em um estado interrompido ou não. Esta variável é definida como false ao criar o contrato, mas reverterá para true assim que o contrato for interrompido.

  2. Funções que referenciam a variável booleana em sua execução. Essas funções são acessíveis quando o contrato inteligente não é interrompido e tornam-se inacessíveis quando o recurso da interrupção de emergência é acionado.

  3. Uma entidade que tem acesso à função da interrupção de emergência, que define a variável booleana como true. Para evitar ações maliciosas, as chamadas para essa função podem ser restritas a um endereço confiável (por exemplo, o proprietário do contrato).

Uma vez que o contrato ative a parada ou interrupção de emergência, determinadas funções não poderão ser chamadas. Isso é alcançado envolvendo funções de seleção em um modificador que faz referência à variável global. Veja abaixo um exemplo(opens in a new tab) que descreve uma implementação desse padrão em contratos:

1// Este código não foi auditado profissionalmente e não faz promessas sobre sua segurança ou correção. Use por sua conta e risco.
2
3contract EmergencyStop {
4
5 bool isStopped = false;
6
7 modifier stoppedInEmergency {
8 require(!isStopped);
9 _;
10 }
11
12 modifier onlyWhenStopped {
13 require(isStopped);
14 _;
15 }
16
17 modifier onlyAuthorized {
18 // Check for authorization of msg.sender here
19 _;
20 }
21
22 function stopContract() public onlyAuthorized {
23 isStopped = true;
24 }
25
26 function resumeContract() public onlyAuthorized {
27 isStopped = false;
28 }
29
30 function deposit() public payable stoppedInEmergency {
31 // Deposit logic happening here
32 }
33
34 function emergencyWithdraw() public onlyWhenStopped {
35 // Emergency withdraw happening here
36 }
37}
Exibir tudo
Copiar

Este exemplo mostra as características básicas das interrupções de emergência:

  • isStopped é um booleano avaliado como false no início e true quando o contrato entra no modo de emergência.

  • Os modificadores de função onlyWhenStopped e stoppedInEmergency verificam a variável isStopped. stoppedInEmergency é usado para controlar funções que devem ser inacessíveis quando o contrato é vulnerável (por exemplo, deposit()). As chamadas para essas funções simplesmente serão revertidas.

onlyWhenStopped é usado para funções que devem ser chamadas durante uma emergência (por exemplo, emergencyWithdraw()). Essas funções podem ajudar a resolver a situação, daí a sua exclusão da lista de “funções restritas”.

Usar uma funcionalidade de interrupção de emergência fornece um paliativo eficaz para lidar com vulnerabilidades graves em seu contrato inteligente. No entanto, aumenta a necessidade dos usuários confiarem nos desenvolvedores para não ativá-lo por razões egoístas. Para este fim, descentralizar o controle da interrupção de emergência sujeitando-o a um mecanismo de votação on-chain, timelock (bloqueio de tempo para transações) ou aprovação de uma carteira de assinatura múltipla são soluções possíveis.

Monitoramento de eventos

Eventos(opens in a new tab) permitem rastrear chamadas para funções de contrato inteligente e monitorar mudanças em variáveis de estado. É ideal programar seu contrato inteligente para emitir um evento sempre que alguma parte tomar uma ação crítica de segurança (por exemplo, retirar fundos).

Registrar eventos e monitorá-los off-chain fornece informações sobre as operações do contrato e auxilia na descoberta mais rápida de ações maliciosas. Isso significa que sua equipe pode responder mais rapidamente a hacks e tomar medidas para mitigar o impacto sobre os usuários, como pausar funções ou realizar uma atualização.

Você também pode optar por uma ferramenta de monitoramento pronta para uso, que encaminha alertas automaticamente, sempre que alguém interage com seus contratos. Essas ferramentas permitirão que você crie alertas personalizados com base em diferentes gatilhos, como volume de transações, frequência de chamadas de função ou funções específicas envolvidas. Por exemplo, você poderia programar um alerta que chega quando a quantia retirada em uma única transação ultrapassa determinado limite.

7. Projete sistemas de governança seguros

Você pode querer descentralizar sua aplicação, transferindo o controle dos principais contratos inteligentes para os membros da comunidade. Nesse caso, o sistema de contrato inteligente incluirá um módulo de governança - um mecanismo que permite que os membros da comunidade aprovem ações administrativas, por meio de um sistema de governança on-chain. Por exemplo, uma proposta para atualizar um contrato de proxy para uma nova implementação, que pode ser votada pelos detentores do token.

A governança descentralizada pode ser benéfica, especialmente porque alinha os interesses dos desenvolvedores e usuários finais. No entanto, os mecanismos de governança de contratos inteligentes podem apresentar novos riscos se implementados incorretamente. Um cenário plausível é se um invasor adquirir um enorme poder de voto (medido em número de tokens mantidos) ao fazer um empréstimo imediato e enviar uma proposta maliciosa.

Uma maneira de evitar problemas relacionados à governança on-chain é usar um timelock(opens in a new tab). Um timelock impede que um contrato inteligente execute certas ações até que um período específico passe. Outras estratégias incluem atribuir um “peso de voto” a cada token com base em quanto tempo ele foi bloqueado ou medir o poder de voto de um endereço em um período histórico (por exemplo, 2-3 blocos no passado) em vez do bloco atual. Ambos os métodos reduzem a possibilidade de acumular rapidamente o poder de voto para oscilar os votos on-chain.

Mais informações sobre o concepção de sistemas de governança seguros(opens in a new tab) e diferentes mecanismos de votação em DAOs(opens in a new tab).

8. Reduza a complexidade do código ao mínimo

Os desenvolvedores de software tradicionais estão familiarizados com o princípio KISS (“Não complique, estúpido!”), o qual aconselha a não introdução complexidade desnecessária na concepção de software. Isso segue o pensamento de longa data, de que “sistemas complexos falham de maneiras complexas” e são mais suscetíveis a erros dispendiosos.

Não complicar é de particular importância ao escrever contratos inteligentes, visto que os contratos inteligentes estão potencialmente controlando grandes quantidades de valor. Uma dica para descomplicar ao escrever contratos inteligentes é reutilizar bibliotecas existentes, como Contratos OpenZeppelin(opens in a new tab), sempre que possível. Como essas bibliotecas foram extensivamente auditadas e testadas pelos desenvolvedores, usá-las reduz as chances de introduzir bugs ao escrever novas funcionalidades do zero.

Outro conselho comum é escrever funções pequenas e manter contratos modulares, dividindo a lógica do negócio por vários contratos. Não só escrever um código simples reduz a superfície de ataque em um contrato inteligente, também facilita argumentar sobre a exatidão do sistema por inteiro e detectar possíveis erros de concepção mais cedo.

9. Defenda-se contra vulnerabilidades comuns de contratos inteligentes

Reentrância

A EVM (Ethereum Virtual Machine) não permite concorrência (paralelismo), o que significa que dois contratos envolvidos em uma chamada de mensagem não podem ser executados simultaneamente. Uma chamada externa pausa a execução e a memória do contrato de chamada até que a chamada retorne, momento em que a execução prossegue normalmente. Esse processo pode ser formalmente descrito como a transferência do fluxo de controle(opens in a new tab) para outro contrato.

Embora a maioria seja inofensiva, a transferência de fluxo de controle para contratos não confiáveis pode causar problemas, tais como a reentrância. Um ataque de reentrância ocorre quando um contrato malicioso volta a chamar um contrato vulnerável antes que a invocação da função original ser completa. Este tipo de ataque é melhor explicado com um exemplo.

Considere um contrato inteligente simples ('Vítima') que permite que qualquer pessoa depositar e retirar Ether:

1// This contract is vulnerable. Do not use in production
2
3contract Victim {
4 mapping (address => uint256) public balances;
5
6 function deposit() external payable {
7 balances[msg.sender] += msg.value;
8 }
9
10 function withdraw() external {
11 uint256 amount = balances[msg.sender];
12 (bool success, ) = msg.sender.call.value(amount)("");
13 require(success);
14 balances[msg.sender] = 0;
15 }
16}
Exibir tudo
Copiar

Este contrato expõe uma função withdraw() para permitir que os usuários retirem ETH previamente depositados no contrato. Ao processar uma retirada, o contrato realiza as seguintes operações:

  1. Verifica o saldo de ETH do usuário
  2. Envia fundos para o endereço de chamada
  3. Redefine seu saldo para 0, evitando saques adicionais do usuário

A função withdraw() no contrato Victim segue um padrão "verificações-efeitos-interações". Ela verifica se as condições necessárias para a execução são atendidas (ou seja, o usuário tem um saldo ETH positivo) e realiza a interação enviando ETH para o endereço do chamador, antes de aplicar os efeitos da transação (ou seja, reduzir o saldo do usuário).

Se withdraw() for chamado de uma conta de propriedade externa (EOA), a função será executada conforme o esperado: msg.sender.call.value() envia ETH para o chamador. Contudo, se msg.sender é uma conta de contrato inteligente que chama withdraw(), o envio de fundos usando msg.sender.call.value() também acionará o código armazenado naquele endereço para ser executado.

Imagine que este é o código implantado no endereço do contrato:

1 contract Attacker {
2 function beginAttack() external payable {
3 Victim(victim_address).deposit.value(1 ether)();
4 Victim(victim_address).withdraw();
5 }
6
7 function() external payable {
8 if (gasleft() > 40000) {
9 Victim(victim_address).withdraw();
10 }
11 }
12}
Exibir tudo
Copiar

Este contrato foi concebido para fazer três coisas:

  1. Aceite um depósito de outra conta (provavelmente o EOA do atacante)
  2. Deposite 1 ETH no contrato Victim
  3. Retire o 1 ETH armazenado no contrato inteligente

Não há nada de errado aqui, exceto que o Attacker tem outra função que chama withdraw() em Victim novamente se, o gás restante de entrada do msg.sender.call.value for superior a 40.000. Isso dá ao Invasor a capacidade de reentrar em Victim e retirar mais fundos antes da primeira invocação de withdraw concluir. O ciclo fica assim:

1- EOA do invasor chama `Attacker.beginAttack()` com 1 ETH
2- `Attacker.beginAttack()` deposita 1 ETH em `Victim`
3- `Attacker` chama `withdraw() em `Victim`
4- `Victim` verifica o saldo do `Attacker` (1 ETH)
5- `Victim` envia 1 ETH para o `Attacker` (que aciona a função padrão)
6- `Attacker` chama `Victim.withdraw()` novamente (observe que `Victim` não reduziu o saldo do `Attacker` desde a primeira retirada)
7- `Victim` verifica o saldo do `Attacker` (que ainda é 1 ETH porque não tem aplicado os efeitos da primeira chamada)
8- `Victim` envia 1 ETH para `Attacker` (que aciona a função padrão e permite que `Attacker` entre novamente na função `withdraw`)
9- O processo se repete até que `Attacker` fique sem gás, ponto em que `msg.sender.call.value` retorna sem acionar retiradas adicionais
10- `Victim` finalmente aplica os resultados da primeira transação (e as subsequentes) ao seu estado, então o saldo do `Attacker` é definido para 0 (zero)
Exibir tudo
Copiar

O resumo é que, como o saldo do chamador não é definido como 0 até que a execução da função termine, as invocações subsequentes serão bem-sucedidas e permitirão que o chamador retire seu saldo várias vezes. Esse tipo de ataque pode ser usado para drenar um contrato inteligente de seus fundos, como aconteceu no DAO hack em 2016(opens in a new tab). Os ataques de reentrância ainda são um problema crítico para contratos inteligentes hoje, como mostram aslistagens públicas de exploits de reentrância(opens in a new tab).

Como prevenir ataques de reentrância

Uma abordagem para lidar com a reentrância é seguir o padrão de verificações-efeitos-interações(opens in a new tab). Este padrão ordena a execução de funções de forma que o código que realiza as verificações necessárias antes de prosseguir com a execução chegar primeiro, seguido pelo código que manipula o estado do contrato, com o código que interage com outros contratos ou EOAs chegando por último.

O padrão verificação-efeito-interação é usado em uma versão revisada do contrato Victim mostrado abaixo:

1contract NoLongerAVictim {
2 function withdraw() external {
3 uint256 amount = balances[msg.sender];
4 balances[msg.sender] = 0;
5 (bool success, ) = msg.sender.call.value(amount)("");
6 require(success);
7 }
8}
Copiar

Este contrato realiza uma verificação do saldo do usuário, aplica os efeitos da função withdraw() (redefinindo o saldo do usuário para 0, zero), e passa a realizar a interação (enviando ETH para o endereço do usuário). Isso garante que o contrato atualize seu armazenamento antes da chamada externa, eliminando a condição de reentrância que permitiu o primeiro ataque. O contrato Attacker ainda poderia chamar de volta para NoLongerAVictim, mas como balances[msg.sender] foi definido como 0 (zero), retiradas adicionais gerarão um erro.

Outra opção é usar um bloqueio de exclusão mútua (comumente descrito como "mutex") que bloqueia uma porção do estado de um contrato até que a invocação de uma função seja concluída. Isso é implementado usando uma variável booleana que é definida como true antes da execução da função e revertida para false após a chamada ser finalizada. Como visto no exemplo abaixo, usar um mutex protege uma função contra chamadas recursivas enquanto a invocação original ainda está sendo processada, efetivamente interrompendo a reentrada.

1pragma solidity ^0.7.0;
2
3contract MutexPattern {
4 bool locked = false;
5 mapping(address => uint256) public balances;
6
7 modifier noReentrancy() {
8 require(!locked, "Blocked from reentrancy.");
9 locked = true;
10 _;
11 locked = false;
12 }
13 // This function is protected by a mutex, so reentrant calls from within `msg.sender.call` cannot call `withdraw` again.
14 // The `return` statement evaluates to `true` but still evaluates the `locked = false` statement in the modifier
15 function withdraw(uint _amount) public payable noReentrancy returns(bool) {
16 require(balances[msg.sender] >= _amount, "No balance to withdraw.");
17
18 balances[msg.sender] -= _amount;
19 bool (success, ) = msg.sender.call{value: _amount}("");
20 require(success);
21
22 return true;
23 }
24}
Exibir tudo
Copiar

Você também pode usar um sistema de receber pagamentos(opens in a new tab), que exige que os usuários retirem fundos dos contratos inteligentes, em vez de um sistema de "envio de pagamentos" que envia fundos para contas. Isso elimina a possibilidade de acionar código inadvertidamente em endereços desconhecidos (e também pode impedir determinados ataques de negação de serviço).

Overflows e underflows em inteiro

Um extravasamento (overflow) de números inteiros ocorre quando os resultados de uma operação aritmética ficam fora do intervalo aceitável de valores, fazendo com que ela "role" para o menor valor representável. Por exemplo, um uint8 só pode armazenar valores até 2^8-1 = 255. Operações aritméticas que resultam em valores superiores a 255 irão extravasar e redefinir uint para 0, semelhante a como o odômetro em um carro é redefinido para 0 uma vez que atinge a quilometragem máxima (999999).

Os extravasamentos negativos (underflows) de números inteiros acontecem por motivos semelhantes: os resultados de uma operação aritmética ficam abaixo do intervalo aceitável. Digamos que você tentou decrementar 0 em um uint8, o resultado simplesmente passaria para o valor máximo representável (255).

Tanto overflows quanto underflows de inteiros podem levar a mudanças inesperadas nas variáveis de estado de um contrato e resultar em execução não planejada. Veja abaixo um exemplo mostrando como um invasor pode explorar o extravasamento aritmético em um contrato inteligente para executar uma operação inválida:

1pragma solidity ^0.7.6;
2
3// Este contrato foi projetado para atuar como um cofre no tempo.
4// O usuário pode depositar neste contrato, mas não pode retirar por pelo menos uma semana.
5// O usuário também pode estender o tempo de espera além do período estipulado de 1 semana.
6
7/*
81. Implantar TimeLock
92. Ataque de Implantação com endereço do TimeLock
103. Chame Attack.attack enviando 1 ether. Você imediatamente será capaz de
11 retirar seu ether.
12
13O que aconteceu?
14O ataque causou o extravasamento do TimeLock.lockTime e foi capaz de retirar
15antes do período de espera de 1 semana.
16*/
17
18contract TimeLock {
19 mapping(address => uint) public balances;
20 mapping(address => uint) public lockTime;
21
22 function deposit() external payable {
23 balances[msg.sender] += msg.value;
24 lockTime[msg.sender] = block.timestamp + 1 weeks;
25 }
26
27 function increaseLockTime(uint _secondsToIncrease) public {
28 lockTime[msg.sender] += _secondsToIncrease;
29 }
30
31 function withdraw() public {
32 require(balances[msg.sender] > 0, "Insufficient funds");
33 require(block.timestamp > lockTime[msg.sender], "Lock time not expired");
34
35 uint amount = balances[msg.sender];
36 balances[msg.sender] = 0;
37
38 (bool sent, ) = msg.sender.call{value: amount}("");
39 require(sent, "Failed to send Ether");
40 }
41}
42
43contract Attack {
44 TimeLock timeLock;
45
46 constructor(TimeLock _timeLock) {
47 timeLock = TimeLock(_timeLock);
48 }
49
50 fallback() external payable {}
51
52 function attack() public payable {
53 timeLock.deposit{value: msg.value}();
54 /*
55 if t = current lock time then we need to find x such that
56 x + t = 2**256 = 0
57 so x = -t
58 2**256 = type(uint).max + 1
59 so x = type(uint).max + 1 - t
60 */
61 timeLock.increaseLockTime(
62 type(uint).max + 1 - timeLock.lockTime(address(this))
63 );
64 timeLock.withdraw();
65 }
66}
Exibir tudo
Como evitar overflows e underflows de números inteiros

A partir da versão 0.8.0, o compilador Solidity rejeita código que resulta em underflows e overflows de números inteiros. No entanto, contratos compilados com uma versão inferior do compilador devem ou realizar verificações sobre funções que envolvem operações aritméticas ou usar uma biblioteca (e.., SafeMath(opens in a new tab)) que verifica se há underflow/overflow (extravasamentos negativos/extravasamentos).

Manipulação de oráculos

Os Oráculos fornecem informações off-chain (fora da blockchain) e as enviam on-chain (dentro da blockchain) para uso em contratos inteligentes. Com oráculos, você pode conceber contratos inteligentes que interoperam com sistemas off-chain, como mercados de capitais, expandindo muito sua aplicação.

Mas se o oráculo estiver corrompido e enviar informações incorretas on-chain, contratos inteligentes serão executados com base em entradas erradas, o que pode causar problemas. Essa é a base do “problema do oráculo” (paradoxo), que diz respeito à tarefa de garantir que as informações de um oráculo da blockchain sejam precisas, atualizadas e pontuais.

Uma preocupação de segurança relacionada está usando um oráculo on-chain, como uma troca descentralizada, para obter o preço de ponto por um ativo. Plataformas de empréstimos no setor de finanças descentralizadas (DeFi) frequentemente fazem isso para determinar o valor da garantia de um usuário para determinar quanto eles podem emprestar.

Os preços dos DEX são muitas vezes exatos, em grande parte devido aos árbitros que restauram a paridade nos mercados. Porém, eles estão abertos à manipulação, especialmente se o oráculo on-chain calcular os preços dos ativos com base em padrões históricos de negociação (como geralmente é o caso).

Por exemplo, um invasor pode explodir artificialmente o preço de um ativo fazendo um empréstimo rápido antes de interagir com seu contrato de empréstimo. Consultar o DEX pelo preço do ativo retornaria um valor mais alto do que normal (devido à grande demanda de inclinação do atacante de "ordem de compra" pelo ativo), permitir que emprestem mais do que deveriam. Esses "ataques de empréstimos rápidos" foram utilizados para explorar a dependência de preços nos oráculos de aplicações DeFi, custando protocolos milhões em fundos perdidos.

Como evitar manipulação de oráculos

A exigência mínima para evitar manipulação em oráculo é usar uma rede de oráculos descentralizada que consulte as informações de múltiplas fontes para evitar pontos de falha. Na maioria dos casos, oráculos descentralizados tem incentivos criptoeconômicos incorporados para incentivar nós oráculos a relatar informações corretas, tornando-os mais seguros do que os oráculos centralizados.

Se você planeja consultar um oráculo on-chain para preços de ativos, considere usar um que implemente um mecanismo de preço médio ponderado por tempo (TWAP). Um TWAP oracle(opens in a new tab) consulta o preço de um ativo em dois pontos diferentes em tempo (que você pode modificar) e calcula o preço de ponto com base na média obtida. Escolher períodos mais longos protege seu protocolo contra a manipulação de preços uma vez que grandes ordens executadas recentemente não podem afetar os preços dos ativos.

Recursos de segurança de contrato inteligente para desenvolvedores

Ferramentas para analisar contratos inteligentes e verificar a exatidão do código

  • Ferramentas de teste e bibliotecas - Coleção de ferramentas e bibliotecas padrão do setor para realizar testes unitários, análise estática e análise dinâmica em contratos inteligentes.

  • Ferramentas de verificação formal - Ferramentas para verificar a correção funcional em contratos inteligentes e verificar inconsistências.

  • Serviços de auditoria de contrato inteligente - Lista de organizações que fornecem serviços de auditoria de contrato inteligente para projetos de desenvolvimento Ethereum.

  • Plataformas de recompensa por bugs - Plataformas para coordenar recompensas por bugs e recompensar a divulgação responsável de vulnerabilidades críticas em contratos inteligentes.

  • Fork Checker(opens in a new tab)Uma ferramenta online gratuita para verificar todas as informações disponíveis sobre um contrato bifurcado.

  • ABI Encoder(opens in a new tab)Um serviço online para codificar suas funções de contrato e argumentos de construtor do Solidity.

Ferramentas para monitorar contratos inteligentes

Ferramentas para administração segura de contratos inteligentes

Serviços de auditoria de contrato inteligente

Plataformas de recompensa de bugs

Publicações de vulnerabilidades e exploits conhecidos em contratos inteligentes

Desafios para aprender a segurança de contratos inteligentes

Melhores práticas para proteger contratos inteligentes

Tutoriais sobre segurança de contratos inteligentes

  • Como programar contratos inteligentes seguros

  • Como utilizar o Slither para encontrar bugs nos contratos inteligentes

  • Como usar o Manticore para encontrar bugs em contratos inteligentes

  • Diretrizes de segurança do contrato inteligente

  • Como integrar com segurança seu contrato de token com tokens arbitrários

Este artigo foi útil?