paint-brush
Uma pesquisa de 2023 sobre segurança de contratos inteligentes para solidezpor@rareskills
6,083 leituras
6,083 leituras

Uma pesquisa de 2023 sobre segurança de contratos inteligentes para solidez

por RareSkills53m2023/05/16
Read on Terminal Reader

Muito longo; Para ler

Um problema de segurança no Solidity se resume a contratos inteligentes que não se comportam da maneira que deveriam. Isso pode se enquadrar em quatro grandes categorias: Fundos sendo roubados Fundos sendo bloqueados ou congelados dentro de um contrato As pessoas recebem menos recompensas do que o previsto (as recompensas são atrasadas ou reduzidas) Não é possível fazer uma lista abrangente de tudo que pode dar errado.
featured image - Uma pesquisa de 2023 sobre segurança de contratos inteligentes para solidez
RareSkills HackerNoon profile picture
0-item
1-item
2-item


Uma lista de vulnerabilidades do Solidity

Este artigo serve como um minicurso sobre segurança de contratos inteligentes e fornece uma extensa lista de problemas e vulnerabilidades que tendem a se repetir nos contratos inteligentes do Solidity. Esses são os tipos de problemas que podem surgir em uma auditoria de qualidade.


Um problema de segurança no Solidity se resume a contratos inteligentes que não se comportam da maneira que deveriam.


Isso pode se enquadrar em quatro grandes categorias:

  • Fundos sendo roubados

  • Fundos bloqueados ou congelados dentro de um contrato

  • As pessoas recebem menos recompensas do que o previsto (as recompensas são atrasadas ou reduzidas)

  • As pessoas recebem mais recompensas do que o previsto (levando à inflação e desvalorização)


Não é possível fazer uma lista abrangente de tudo o que pode dar errado. No entanto, assim como a engenharia de software tradicional tem temas comuns de vulnerabilidades, como injeção de SQL, saturação de buffer e script entre sites, os contratos inteligentes têm antipadrões recorrentes que podem ser documentados.


Pense neste guia mais como uma referência. Não é possível discutir todos os conceitos em detalhes sem transformar isso em um livro (aviso justo: este artigo tem mais de 10 mil palavras, então sinta-se à vontade para marcá-lo como favorito e lê-lo em partes). No entanto, serve como uma lista do que procurar e do que estudar. Se um tópico parece desconhecido, isso deve servir como um indicador de que vale a pena dedicar tempo para praticar a identificação dessa classe de vulnerabilidade.

Pré-requisitos

Este artigo pressupõe proficiência básica em Solidity . Se você é novo no Solidity, consulte nosso tutorial gratuito do Solidity .

Reentrância

Escrevemos extensivamente sobre reentrância de contrato inteligente , então não vamos repeti-lo aqui. Mas aqui vai um resumo rápido:


Sempre que um contrato inteligente chama a função de outro contrato inteligente, envia Ether para ele ou transfere um token para ele, existe a possibilidade de reentrada.


  • Quando o Ether é transferido, o fallback do contrato de recebimento ou função de recebimento é chamado. Isso entrega o controle ao receptor.
  • Alguns protocolos de token alertam o contrato inteligente receptor de que receberam o token chamando uma função predeterminada. Isso entrega o fluxo de controle para essa função.
  • Quando um contrato de ataque recebe o controle, ele não precisa chamar a mesma função que entregou o controle. Ele poderia chamar uma função diferente no contrato inteligente da vítima (reentrada entre funções) ou até mesmo um contrato diferente (reentrada entre contratos)
  • A reentrada somente leitura ocorre quando uma função de exibição é acessada enquanto o contrato está em um estado intermediário.


Apesar da reentrância provavelmente ser a vulnerabilidade de contrato inteligente mais conhecida, ela representa apenas uma pequena porcentagem dos hacks que acontecem na natureza. O pesquisador de segurança Pascal Caversaccio (pcaveraccio) mantém uma lista atualizada do github de ataques de reentrância . Em abril de 2023, 46 ataques de reentrância foram documentados nesse repositório.

Controle de acesso

Parece um erro simples, mas esquecer de colocar restrições sobre quem pode chamar uma função sensível (como retirar o éter ou mudar de propriedade) acontece com uma frequência surpreendente.


Mesmo que um modificador esteja em vigor, houve casos em que o modificador não foi implementado corretamente, como no exemplo abaixo em que a instrução require está ausente.

 // DO NOT USE! modifier onlyMinter { minters[msg.sender] == true_; }

Este código acima é um exemplo real desta auditoria: https://code4rena.com/reports/2023-01-rabbithole/#h-01-bad-implementation-in-minter-access-control-for-rabbitholereceipt-and- rabbitholetickets-contracts


Aqui está outra maneira pela qual o controle de acesso pode dar errado

 function claimAirdrop(bytes32 calldata proof[]) { bool verified = MerkleProof.verifyCalldata(proof, merkleRoot, keccak256(abi.encode(msg.sender))); require(verified, "not verified"); require(alreadyClaimed[msg.sender], "already claimed"); _transfer(msg.sender, AIRDROP_AMOUNT); }

Nesse caso, “alreadyClaimed” nunca é definido como true, portanto, o reclamante pode chamar a função várias vezes.

Exemplo da vida real: bot do trader explorado

Um exemplo bastante recente de controle de acesso insuficiente foi uma função desprotegida para receber flashloans por um bot comercial (que atendia pelo nome de 0xbad, pois o endereço começava com essa sequência). Ele acumulou mais de um milhão de dólares em lucro até que um dia um invasor percebeu que qualquer endereço poderia chamar a função de recebimento de flashloan, não apenas o provedor de flashloan.


Como geralmente acontece com os bots de negociação, o código do contrato inteligente para executar as negociações não foi verificado, mas o invasor descobriu a fraqueza de qualquer maneira. Mais informações na cobertura de notícias rekt .

Validação de entrada imprópria

Se o controle de acesso trata de controlar quem chama uma função, a validação de entrada trata de controlar com o que eles chamam o contrato.


Isso geralmente se resume a esquecer de colocar as instruções adequadas no lugar.

Aqui está um exemplo rudimentar:

 contract UnsafeBank { mapping(address => uint256) public balances; // allow depositing on other's behalf function deposit(address for) public payable { balances += msg.value; } function withdraw(address from, uint256 amount) public { require(balances[from] <= amount, "insufficient balance"); balances[from] -= amount; msg.sender.call{value: amout}(""); } }

O contrato acima verifica se você não está sacando mais do que tem em sua conta, mas não o impede de sacar de uma conta arbitrária.

Exemplo da vida real: Sushiswap

Sushiswap experimentou um hack desse tipo devido a um dos parâmetros de uma função externa não ter sido higienizado.

troca de sushi

Qual é a diferença entre controle de acesso impróprio e validação de entrada imprópria?

Controle de acesso impróprio significa que msg.sender não tem restrições adequadas. Validação de entrada imprópria significa que os argumentos para a função não são suficientemente limpos. Há também um inverso a esse antipadrão: colocar muita restrição em uma chamada de função.

Restrição de função excessiva

A validação excessiva provavelmente significa que os fundos não serão roubados, mas pode significar que os fundos ficam bloqueados no contrato. Ter muitas salvaguardas também não é bom.

Exemplo da vida real: Akutars NFT

Um dos incidentes de maior destaque foi o Akutars NFT, que acabou com 34 milhões de dólares em Eth preso dentro do contrato inteligente e impossível de ser retirado.


O contrato tinha um mecanismo bem-intencionado para impedir que o proprietário do contrato se retirasse até que todos os reembolsos do pagamento acima do preço do leilão holandês fossem feitos. Mas devido a um bug documentado no tópico do Twitter vinculado abaixo, o proprietário não conseguiu retirar os fundos.

Vulnerabilidade Akutars NFT

Obtendo o equilíbrio certo

O Sushiswap deu muito poder a usuários não confiáveis, e o Akutars NFT deu muito pouco poder ao administrador. Ao projetar contratos inteligentes, um julgamento subjetivo sobre quanta liberdade cada classe de usuários deve ter, e essa decisão não pode ser deixada para testes e ferramentas automatizados. Existem compensações significativas com descentralização, segurança e UX que devem ser consideradas.


Para o programador de contrato inteligente, escrever explicitamente o que os usuários devem ou não fazer com determinadas funções é uma parte importante do processo de desenvolvimento.

Iremos revisitar o tópico de administradores sobrecarregados mais tarde.

A segurança geralmente se resume a gerenciar a maneira como o dinheiro sai do contrato

Conforme declarado na introdução, existem quatro maneiras principais pelas quais os contratos inteligentes são hackeados:


  • dinheiro roubado
  • Dinheiro congelado
  • Recompensas insuficientes
  • Recompensas excessivas


“Dinheiro” aqui significa qualquer coisa de valor, como tokens, não apenas criptomoeda. Ao codificar ou auditar um contrato inteligente, o desenvolvedor deve estar ciente das maneiras pretendidas como o valor flui para dentro e para fora do contrato. Os problemas listados acima são as principais formas pelas quais os contratos inteligentes são invadidos, mas há muitas outras causas principais que podem se transformar em problemas importantes, documentados abaixo.

Voto duplo ou falsificação de msg.sender

Usar tokens vanilla ERC20 ou NFTs como tíquetes para pesar votos não é seguro porque os invasores podem votar com um endereço, transferir os tokens para outro endereço e votar novamente a partir desse endereço.

Aqui está um exemplo mínimo:

 // A malicious voter can simply transfer their tokens to // another address and vote again. contract UnsafeBallot { uint256 public proposal1VoteCount; uint256 public proposal2VoteCount; IERC20 immutable private governanceToken; constructor(IERC20 _governanceToken) { governanceToken = _governanceToken; } function voteFor1() external notAlreadyVoted { proposal1VoteCount += governanceToken.balanceOf(msg.sender); } function voteFor2() external notAlreadyVoted { proposal2VoteCount += governanceToken.balanceOf(msg.sender); } // prevent the same address from voting twice, // however the attacker can simply // transfer to a new address modifier notAlreadyVoted { require(!alreadyVoted[msg.sender], "already voted"); _; alreadyVoted[msg.sender] = true; } }

Para evitar esse ataque, deve-se usar ERC20 Snapshot ou ERC20 Votes . Ao capturar um ponto do tempo no passado, os saldos de tokens atuais não podem ser manipulados para obter poder de voto ilícito.

Ataques de governança Flashloan

No entanto, usar um token ERC20 com um instantâneo ou capacidade de voto não resolve totalmente o problema se alguém puder fazer um flashloan para aumentar temporariamente seu saldo e, em seguida, tirar um instantâneo de seu saldo na mesma transação. Se esse instantâneo for usado para votação, eles terão uma quantidade excessivamente grande de votos à sua disposição.


Um flashloan empresta uma grande quantidade de Ether ou token para um endereço, mas reverte se o dinheiro não for reembolsado na mesma transação.

 contract SimpleFlashloan { function borrowERC20Tokens() public { uint256 before = token.balanceOf(address(this)); // send tokens to the borrower token.transfer(msg.sender, amount); // hand control back to the borrower to // let them do something IBorrower(msg.sender).onFlashLoan(); // require that the tokens got returned require(token.balanceOf(address(this) >= before); } }

Um invasor pode usar um flashloan para repentinamente ganhar muitos votos para mudar as propostas a seu favor e/ou fazer algo malicioso.

Ataques de preço Flashloan

Este é indiscutivelmente o ataque mais comum (ou pelo menos mais conhecido) ao DeFi, respondendo por centenas de milhões de dólares perdidos. Aqui está uma lista de alto perfil queridos.


O preço de um ativo na blockchain geralmente é calculado como a taxa de câmbio atual entre os ativos. Por exemplo, se um contrato está sendo negociado atualmente 1 USDC por 100 k9coin, então você poderia dizer que k9coin tem um preço de 0,01 USDC. No entanto, os preços geralmente se movem em resposta à pressão de compra e venda, e os empréstimos instantâneos podem criar uma pressão maciça de compra e venda.


Ao consultar outro contrato inteligente sobre o preço de um ativo, o desenvolvedor precisa ter muito cuidado porque está assumindo que o contrato inteligente que está chamando é imune à manipulação de empréstimo flash.

Ignorando a verificação do contrato

Você pode “verificar” se um endereço é um contrato inteligente observando seu tamanho de bytecode. Contas de propriedade externa (carteiras regulares) não possuem nenhum bytecode. Aqui estão algumas maneiras de fazer isso

 import "@openzeppelin/contracts/utils/Address.sol" contract CheckIfContract { using Address for address; function addressIsContractV1(address _a) { return _a.code.length == 0; } function addressIsContractV2(address _a) { // use the openzeppelin libraryreturn _a.isContract(); } }


No entanto, isso tem algumas limitações

  • Se um contrato fizer uma chamada externa de um construtor, o tamanho do bytecode aparente será zero porque o código de implantação do contrato inteligente ainda não retornou o código de tempo de execução
  • O espaço pode estar vazio agora, mas um invasor pode saber que pode implantar um contrato inteligente lá no futuro usando create2


Em geral, verificar se um endereço é um contrato geralmente (mas nem sempre) é um antipadrão. As carteiras multiassinatura são elas mesmas contratos inteligentes, e fazer qualquer coisa que possa quebrar as carteiras multiassinatura quebra a capacidade de composição.


A exceção a isso é verificar se o destino é um contrato inteligente antes de chamar um gancho de transferência. Mais sobre isso mais tarde.

tx.origin

Raramente há um bom motivo para usar tx.origin. Se tx.origin for usado para identificar o remetente, um ataque man-in-the-middle é possível. Se o usuário for induzido a chamar um contrato inteligente malicioso, o contrato inteligente poderá usar toda a autoridade que o tx.origin tem para causar estragos.


Considere este exercício a seguir e os comentários acima do código.

 contract Phish { function phishingFunction() public { // this fails, because this contract does not have approval/allowance token.transferFrom(msg.sender, address(this), token.balanceOf(msg.sender)); // this also fails, because this creates approval for the contract,// not the wallet calling this phishing function token.approve(address(this), type(uint256).max); } }


Isso não significa que você está seguro chamando contratos inteligentes arbitrários. Mas há uma camada de segurança incorporada na maioria dos protocolos que será ignorada se tx.origin for usado para autenticação.

Às vezes, você pode ver um código parecido com este:

 require(msg.sender == tx.origin, "no contracts");


Quando um contrato inteligente chama outro contrato inteligente, msg.sender será o contrato inteligente e tx.origin será a carteira do usuário, dando assim uma indicação confiável de que a chamada recebida é de um contrato inteligente. Isso é verdadeiro mesmo se a chamada ocorrer a partir do construtor.


Na maioria das vezes, esse padrão de design não é uma boa ideia. Carteiras multiassinatura e Carteiras EIP 4337 não poderão interagir com uma função que possua este código. Esse padrão pode ser comumente visto em balas NFT, onde é razoável esperar que a maioria dos usuários esteja usando uma carteira tradicional. Mas, à medida que a abstração de contas se torna mais popular, esse padrão mais atrapalha do que ajuda.

Griefing de gás ou negação de serviço

Um ataque de luto significa que o hacker está tentando "causar sofrimento" para outras pessoas, mesmo que elas não ganhem economicamente com isso.


Um contrato inteligente pode usar maliciosamente todo o gás encaminhado para ele entrando em um loop infinito. Considere o seguinte exemplo:

 contract Mal { fallback() external payable { // infinite loop uses up all the gas while (true) { } } }


Se outro contrato distribuir ether para uma lista de endereços como segue:

 contract Distribute { funtion distribute(uint256 total) public nonReentrant { for (uint i; i < addresses.length; ) { (bool ok, ) addresses.call{value: total / addresses.length}(""); // ignore ok, if it reverts we move on // traditional gas saving trick for for loops unchecked { ++i; } } } }


Então a função reverterá quando enviar ether para Mal. A chamada no código acima encaminha 63/64 do gás disponível, então provavelmente não haverá gás suficiente para completar a operação com apenas 1/64 do gás restante.


Um contrato inteligente pode retornar um grande array de memória que consome muito gás

Considere o seguinte exemplo

 function largeReturn() public { // result might be extremely long! (book ok, bytes memory result) = otherContract.call(abi.encodeWithSignature("foo()")); require(ok, "call failed"); }

As matrizes de memória usam uma quantidade quadrática de gás após 724 bytes, portanto, um tamanho de dados de retorno cuidadosamente escolhido pode prejudicar o chamador.


Mesmo que o resultado da variável não seja usado, ele ainda é copiado para a memória. Se você deseja restringir o tamanho de retorno a um determinado valor, pode usar a montagem

 function largeReturn() public { assembly { let ok := call(gas(), destinationAddress, value, dataOffset, dataSize, 0x00, 0x00); // nothing is copied to memory until you // use returndatacopy() } }

Excluir arrays que outros podem adicionar também é um vetor de negação de serviço

Embora apagar o armazenamento seja uma operação eficiente em termos de gás, ela ainda tem um custo líquido. Se uma matriz ficar muito longa, torna-se impossível excluí-la. Aqui está um exemplo mínimo

 contract VulnerableArray { address[] public stuff; function addSomething(address something) public { stuff.push(something); } // if stuff is too long, this will become undeletable due to // the gas cost function deleteEverything() public onlyOwner { delete stuff; } }

ERC777, ERC721 e ERC1155 também podem ser vetores de luto

Se um contrato inteligente transferir tokens com ganchos de transferência, um invasor poderá configurar um contrato que não aceite o token (ele não possui uma função onReceive ou programa a função para reverter). Isso tornará o token intransferível e fará com que toda a transação seja revertida.


Antes de usar safeTransfer ou transferência, considere a possibilidade de que o destinatário possa forçar a reversão da transação.

 contract Mal is IERC721Receiver, IERC1155Receiver, IERC777Receiver { // this will intercept any transfer hook fallback() external payable { // infinite loop uses up all the gaswhile (true) { } } // we could also selectively deny transactions function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { if (wakeUpChooseViolence()) { revert(); } else { return IERC721Receiver.onERC721Received.selector; } } }

Aleatoriedade insegura

Atualmente não é possível gerar aleatoriedade de forma segura com uma única transação no blockchain. Os blockchains precisam ser totalmente determinísticos, caso contrário, os nós distribuídos não seriam capazes de chegar a um consenso sobre o estado. Por serem totalmente determinísticos, qualquer número “aleatório” pode ser previsto. A seguinte função de rolagem de dados pode ser explorada.


 contract UnsafeDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } // our dice can land on one of {0,1,2,3,4,5}function rollDice() public payable { require(msg.value == 1 ether); if (randomness() % 6) == 5) { msg.sender.call{value: 2 ether}(""); } } } contract ExploitDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } function betSafely(IUnsafeDice game) public payable { if (randomness % 6) == 5)) { game.betSafely{value: 1 ether}() } // else don't do anything } }


Não importa como você gera aleatoriedade porque um invasor pode replicá-la exatamente. Incluir mais fontes de “entropia”, como msg.sender, carimbo de data/hora, etc, não terá nenhum efeito porque o contrato inteligente pode medir dois.

Usando o Chainlink Randomness Oracle errado

Chainlink é uma solução popular para obter números aleatórios seguros. Ele faz isso em duas etapas. Primeiro, os contratos inteligentes enviam uma solicitação de aleatoriedade ao oráculo e, alguns blocos depois, o oráculo responde com um número aleatório.


Como um invasor não pode prever o futuro, ele não pode prever o número aleatório.

A menos que o contrato inteligente use o oráculo errado.


  • O contrato inteligente que solicita aleatoriedade não deve fazer nada até que o número aleatório seja retornado. Caso contrário, um invasor pode monitorar o mempool para o oráculo retornando a aleatoriedade e executar o oráculo, sabendo qual será o número aleatório.
  • Os próprios oráculos de aleatoriedade podem tentar manipular seu aplicativo. Eles não podem escolher números aleatórios sem consenso de outros nós, mas podem reter e reordenar números aleatórios se seu aplicativo solicitar vários ao mesmo tempo.
  • A finalidade não é instantânea no Ethereum ou na maioria das outras cadeias EVM. Só porque algum bloco é o mais recente, não significa que não necessariamente permanecerá assim. Isso é chamado de “reorganização em cadeia”. Na verdade, a cadeia pode alterar mais do que apenas o bloco final. Isso é chamado de “profundidade de reorganização”. O Etherscan relata reorganizações para várias cadeias, por exemplo, reorganizações Ethereum e reorganizações de polígonos. As reorganizações podem ter até 30 ou mais blocos no Polygon, portanto, esperar menos blocos pode tornar o aplicativo vulnerável (isso pode mudar quando o zk-evm se tornar o consenso padrão no Polygon, porque a finalidade corresponderá à do Ethereum, mas esta é uma previsão futura , não um fato sobre o presente).

Obtendo dados obsoletos de um preço Oracle

Não há SLA (acordo de nível de serviço) para a Chainlink manter seus oráculos de preço atualizados dentro de um determinado período de tempo. Quando a cadeia está severamente congestionada (como quando o Yuga Labs Otherside mint sobrecarregou o Ethereum a ponto de não haver transações), as atualizações de preço podem ser atrasadas.


Um contrato inteligente que usa um oráculo de preços deve verificar explicitamente se os dados não estão obsoletos, ou seja, foram atualizados recentemente dentro de algum limite. Caso contrário, não pode tomar uma decisão confiável com relação aos preços.


Há uma complicação adicional de que, se o preço não mudar além de um limite de desvio , o oráculo pode não atualizar o preço para economizar combustível, portanto, isso pode afetar o limite de tempo considerado "obsoleto".


É importante entender o SLA de um oráculo do qual um contrato inteligente depende.

Confiando em apenas um oráculo

Não importa o quão seguro um oráculo pareça, um ataque pode ser descoberto no futuro. A única defesa contra isso é usar múltiplos oráculos independentes.

Oráculos em geral são difíceis de acertar

O blockchain pode ser bastante seguro, mas colocar dados na cadeia em primeiro lugar requer algum tipo de operação fora da cadeia que renuncia a todas as garantias de segurança fornecidas pelo blockchain. Mesmo que os oráculos permaneçam honestos, sua fonte de dados pode ser manipulada. Por exemplo, um oráculo pode relatar preços de forma confiável de uma bolsa centralizada, mas eles podem ser manipulados com grandes ordens de compra e venda. Da mesma forma, oráculos que dependem de dados de sensores ou alguma API web2 estão sujeitos a vetores de hackers tradicionais.


Uma boa arquitetura de contrato inteligente evita o uso de oráculos sempre que possível.

contabilidade mista

Considere o seguinte contrato

 contract MixedAccounting { uint256 myBalance; function deposit() public payable { myBalance = myBalance + msg.value; } function myBalanceIntrospect() public view returns (uint256) { return address(this).balance; } function myBalanceVariable() public view returns (uint256) { return myBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }


O contrato acima não possui uma função de recebimento ou fallback, portanto, a transferência direta de Ether para ele será revertida. No entanto, um contrato pode enviar Ether à força com autodestruição.


Nesse caso, myBalanceIntrospect() será maior que myBalanceVariable(). O método de contabilidade Ether é bom, mas se você usar ambos, o contrato pode ter um comportamento inconsistente.


O mesmo se aplica aos tokens ERC20.

 contract MixedAccountingERC20 { IERC20 token; uint256 myTokenBalance; function deposit(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); myTokenBalance = myTokenBalance + amount; } function myBalanceIntrospect() public view returns (uint256) { return token.balanceOf(address(this)); } function myBalanceVariable() public view returns (uint256) { return myTokenBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }

Novamente, não podemos supor que myBalanceIntrospect() e myBalanceVariable() sempre retornarão o mesmo valor. É possível transferir tokens ERC20 diretamente para MixedAccountingERC20, ignorando a função de depósito e não atualizando a variável myTokenBalance.


Ao verificar os saldos com introspecção, deve-se evitar o uso estrito de verificações de igualdade, pois o saldo pode ser alterado por alguém de fora à vontade.

Tratar provas criptográficas como senhas

Isso não é uma peculiaridade do Solidity, é mais um mal-entendido comum entre os desenvolvedores sobre como usar a criptografia para dar privilégios especiais aos endereços. O seguinte código é inseguro

 contract InsecureMerkleRoot { bytes32 merkleRoot; function airdrop(bytes[] calldata proof, bytes32 leaf) external { require(MerkleProof.verifyCalldata(proof, merkleRoot, leaf), "not verified"); require(!alreadyClaimed[leaf], "already claimed airdrop"); alreadyClaimed[leaf] = true; mint(msg.sender, AIRDROP_AMOUNT); } }


Este código é inseguro por três razões:

  1. Qualquer pessoa que conheça os endereços selecionados para o airdrop pode recriar a árvore merkle e criar uma prova válida.
  2. A folha não é hash. Um invasor pode enviar uma folha igual à raiz merkle e ignorar a instrução require.
  3. Mesmo que os dois problemas acima sejam corrigidos, assim que alguém enviar uma prova válida, eles poderão ser executados antecipadamente.


Provas criptográficas (árvores merkle, assinaturas, etc) precisam ser vinculadas a msg.sender, que um invasor não pode manipular sem adquirir a chave privada.

Solidity não faz upcast para o tamanho uint final

 function limitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = a * b; }

Embora o produto seja uma variável uint256 , o resultado da multiplicação não pode ser maior que 255 ou o código será revertido.


Esse problema pode ser atenuado fazendo o upcast individualmente de cada variável.

 function unlimitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = uint256(a) * uint256(b); }

Uma situação como essa pode ocorrer se multiplicar números inteiros compactados em uma estrutura. Você deve estar atento a isso ao multiplicar pequenos valores que foram compactados em uma estrutura

 struct Packed { uint8 time; uint16 rewardRate } //... Packed p; p.time * p.rewardRate; // this might revert!

O downcasting de Solidity não reverte no estouro

O Solidity não verifica se é seguro converter um inteiro para um menor. A menos que alguma lógica de negócios garanta que o downcasting seja seguro, uma biblioteca como SafeCast deve ser usada.

 function test(int256 value) public pure returns (int8) { return int8(value + 1); // overflows and does not revert }

As gravações em ponteiros de armazenamento não salvam novos dados.

O código parece que copia os dados em myArray[1] para myArray[0], mas isso não acontece. Se você comentar a linha final da função, o compilador dirá que a função deve ser transformada em uma função de visualização. A gravação em foo não grava no armazenamento subjacente.


 contract DoesNotWrite { struct Foo { uint256 bar; } Foo[] public myArray; function moveToSlot0() external { Foo storage foo = myArray[0]; foo = myArray[1]; // myArray[0] is unchanged // we do this to make the function a state // changing operation // and silence the compiler warning myArray[1] = Foo({bar: 100}); } }

Portanto, não escreva em ponteiros de armazenamento.

A exclusão de structs que contêm tipos de dados dinâmicos não exclui os dados dinâmicos

Se um mapeamento (ou array dinâmico) estiver dentro de um struct e o struct for excluído, o mapeamento ou array não será excluído.


Com exceção da exclusão de uma matriz, a palavra-chave delete pode excluir apenas um slot de armazenamento. Se o slot de armazenamento contiver referências a outros slots de armazenamento, eles não serão excluídos.

 contract NestedDelete { mapping(uint256 => Foo) buzz; struct Foo { mapping(uint256 => uint256) bar; } Foo foo; function addToFoo(uint256 i) external { buzz[i].bar[5] = 6; } function getFromFoo(uint256 i) external view returns (uint256) { return buzz[i].bar[5]; } function deleteFoo(uint256 i) external { // internal map still holds the data in the // mapping and array delete buzz[i]; } }


Agora vamos fazer a seguinte sequência de transações

  1. addToFoo(1)
  2. getFromFoo(1) retorna 6
  3. deleteFoo(1)
  4. getFromFoo(1) ainda retorna 6!


Lembre-se, os mapas nunca estão “vazios” no Solidity. Portanto, se alguém acessar um item que foi excluído, a transação não será revertida, mas retornará o valor zero para esse tipo de dados.

Problemas de token ERC20

Se você lida apenas com tokens ERC20 confiáveis, a maioria desses problemas não se aplica. No entanto, ao interagir com um token ERC20 arbitrário ou parcialmente não confiável, aqui estão algumas coisas a serem observadas.

ERC20: Taxa na transferência

Ao lidar com tokens não confiáveis, você não deve assumir que seu saldo aumenta necessariamente no valor. É possível que um token ERC20 implemente sua função de transferência da seguinte maneira:

 contract ERC20 { // internally called by transfer() and transferFrom() // balance and approval checks happen in the caller function _transfer(address from, address to, uint256 amount) internal returns (bool) { fee = amount * 100 / 99; balanceOf[from] -= to; balanceOf[to] += (amount - fee); balanceOf[TREASURY] += fee; emit Transfer(msg.sender, to, (amount - fee)); return true; } }


Este token aplica um imposto de 1% a cada transação. Portanto, se um contrato inteligente interagir com o token da seguinte maneira, obteremos reversões inesperadas ou dinheiro roubado.

 contract Stake { mapping(address => uint256) public balancesInContract; function stake(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); balancesInContract[msg.sender] += amount; // THIS IS WRONG! } function unstake() public { uint256 toSend = balancesInContract[msg.sender]; delete balancesInContract[msg.sender]; // this could revert because toSend is 1% greater than// the amount in the contract. Otherwise, 1% will be "stolen"// from other depositors. token.transfer(msg.sender, toSend); } }

ERC20: tokens de rebase

O token de rebase foi popularizado pelo token sOhm da Olympus DAO e pelo token AMPL da Ampleforth . A Coingecko mantém uma lista de tokens ERC20 rebaseados.


Quando um token muda de base, o suprimento total muda e o saldo de todos aumenta ou diminui dependendo da direção do rebase.


É provável que o código a seguir seja interrompido ao lidar com um token de rebase

 contract WillBreak { mapping(address => uint256) public balanceHeld; IERC20 private rebasingToken function deposit(uint256 amount) external { balanceHeld[msg.sender] = amount; rebasingToken.transferFrom(msg.sender, address(this), amount); } function withdraw() external { amount = balanceHeld[msg.sender]; delete balanceHeld[msg.sender]; // ERROR, amount might exceed the amount // actually held by the contract rebasingToken.transfer(msg.sender, amount); } }


A solução de muitos contratos é simplesmente proibir tokens de rebase. No entanto, pode-se modificar o código acima para verificar balanceOf(address(this)) antes de transferir o saldo da conta para o remetente. Então ainda funcionaria mesmo se o equilíbrio mudasse.

ERC20: ERC777 em roupas ERC20

ERC20, se implementado de acordo com o padrão, os tokens ERC20 não têm ganchos de transferência e, portanto, transfer e transferFrom não têm um problema de reentrada.


Existem vantagens significativas para tokens com ganchos de transferência, e é por isso que todos os padrões NFT os implementam e porque o ERC777 foi finalizado. No entanto, causou confusão suficiente para que o Openzeppelin tenha preterido a biblioteca ERC777.


Se você deseja que seu protocolo seja compatível com tokens que se comportam como tokens ERC20, mas possuem ganchos de transferência, é uma simples questão de tratar as funções transfer e transferFrom como se fossem emitir uma chamada de função para o receptor.


Esta reentrada ERC777 aconteceu com Uniswap (Openzeppelin documentou a exploração aqui se você estiver curioso).

ERC20: Nem todos os tokens ERC20 retornam verdadeiro

A especificação ERC20 determina que um token ERC20 deve retornar true quando uma transferência for bem-sucedida. Como a maioria das implementações do ERC20 não pode falhar, a menos que o subsídio seja insuficiente ou o valor transferido seja muito alto, a maioria dos desenvolvedores se acostumou a ignorar o valor de retorno dos tokens ERC20 e assumir que uma transferência com falha será revertida.


Francamente, isso não é importante se você estiver trabalhando apenas com um token ERC20 confiável do qual conhece o comportamento. Mas ao lidar com tokens ERC20 arbitrários, essa variação de comportamento deve ser considerada.


Há uma expectativa implícita em muitos contratos de que as transferências com falha devem sempre ser revertidas, e não retornar falsas, porque a maioria dos tokens ERC20 não possui um mecanismo para retornar falso, portanto, isso gerou muita confusão.


Para complicar ainda mais esse assunto, alguns tokens ERC20 não seguem o protocolo de retorno verdadeiro, principalmente o Tether. Alguns tokens são revertidos em caso de falha na transferência, o que fará com que a reversão chegue ao chamador. Assim, algumas bibliotecas agrupam chamadas de transferência de token ERC20 para interceptar a reversão e retornar um booleano.


Aqui estão algumas implementações

Openzeppelin SafeTransfer

Solady SafeTransfer (consideravelmente mais eficiente em termos de gás)

ERC20: Intoxicação de endereço

Esta não é uma vulnerabilidade de contrato inteligente, mas a mencionamos aqui para fins de integridade.

A transferência de zero tokens ERC20 é permitida pela especificação. Isso pode confundir os aplicativos de front-end e enganar os usuários sobre para quem eles enviaram tokens recentemente. Metamask tem mais sobre isso neste tópico .

ERC20: Simplesmente robusto

(Na linguagem web3, “robusto” significa “ter o tapete puxado debaixo de você”.)

Não há nada que impeça alguém de adicionar uma função a um token ERC20 que permita criar, transferir e gravar tokens à vontade - ou autodestruição ou atualização. Então, fundamentalmente, há um limite para o quão “não confiável” um token ERC20 pode ser.


Erros lógicos em protocolos de empréstimo

Ao considerar como os protocolos DeFi baseados em empréstimos e empréstimos podem quebrar, é útil pensar sobre os bugs que se propagam no nível do software e afetam o nível da lógica de negócios. Há uma série de etapas para formar e fechar um contrato de títulos. Aqui estão alguns vetores de ataque a serem considerados.

Maneiras como os credores perdem

  • Bugs que permitem que o principal devido reduza (possivelmente a zero) sem efetuar nenhum pagamento.
  • A garantia do comprador não pode ser liquidada quando o empréstimo não for pago ou a garantia cair abaixo do limite.
  • Se o protocolo tiver um mecanismo para transferir a propriedade da dívida, isso pode ser um vetor para roubar títulos dos credores.
  • A data de vencimento do principal ou dos pagamentos do empréstimo é indevidamente movida para uma data posterior.

Formas pelas quais os mutuários perdem

  • Um bug em que pagar o principal não leva à redução do principal.
  • Um bug ou ataque de luto impede o usuário de efetuar o pagamento.
  • A taxa principal ou de juros é aumentada ilegitimamente.
  • A manipulação da Oracle leva à desvalorização da garantia.
  • A data de vencimento do principal ou dos pagamentos do empréstimo é indevidamente movida para uma data anterior.


Se a garantia for retirada do protocolo, tanto o credor quanto o mutuário perdem, uma vez que o mutuário não tem incentivo para pagar o empréstimo e o mutuário perde o principal.


Como pode ser visto acima, há muito mais níveis para um protocolo DeFi ser "hackeado" do que um monte de dinheiro sendo drenado do protocolo (o tipo de evento que costuma virar notícia). Essa é uma área em que os exercícios de segurança CTF (capture the flag) podem ser enganosos. Embora o roubo de fundos de protocolo seja o resultado mais catastrófico, não é de forma alguma o único a se defender.

Valores de retorno não verificados

Existem duas maneiras de chamar um contrato inteligente externo: 1) chamar a função com uma definição de interface; 2) usando o método .call. Isso é ilustrado abaixo

 contract A { uint256 public x; function setx(uint256 _x) external { require(_x > 10, "x must be bigger than 10"); x = _x; } } interface IA { function setx(uint256 _x) external; } contract B { function setXV1(IA a, uint256 _x) external { a.setx(_x); } function setXV2(address a, uint256 _x) external { (bool success, ) = a.call(abi.encodeWithSignature("setx(uint256)", _x)); // success is not checked! } }

No contrato B, setXV2 pode falhar silenciosamente se _x for menor que 10. Quando uma função é chamada por meio do método .call, o callee pode reverter, mas o pai não reverterá. O valor de sucesso deve ser verificado e o comportamento do código deve ser ramificado de acordo.

Variáveis Privadas

As variáveis privadas ainda são visíveis no blockchain, portanto, informações confidenciais nunca devem ser armazenadas lá. Se eles não estivessem acessíveis, como os validadores poderiam processar as transações que dependem de seus valores? As variáveis privadas não podem ser lidas de um contrato externo do Solidity, mas podem ser lidas fora da cadeia usando um cliente Ethereum.


Para ler uma variável, você precisa conhecer seu slot de armazenamento. No exemplo a seguir, o slot de armazenamento de myPrivateVar é 0.

 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract PrivateVarExample { uint256 private myPrivateVar; constructor(uint256 _initialValue) { myPrivateVar = _initialValue; } }

Aqui está o código javascript para ler a variável privada do contrato inteligente implantado

 const Web3 = require("web3"); const PRIVATE_VAR_EXAMPLE_ADDRESS = "0x123..."; // Replace with your contract address async function readPrivateVar() { const web3 = new Web3("http://localhost:8545"); // Replace with your provider's URL // Read storage slot 0 (where 'myPrivateVar' is stored) const storageSlot = 0; const privateVarValue = await web3.eth.getStorageAt( PRIVATE_VAR_EXAMPLE_ADDRESS, storageSlot ); console.log("Value of private variable 'myPrivateVar':", web3.utils.hexToNumberString(privateVarValue)); } readPrivateVar();

Chamada Delegada Insegura

Delegatecall nunca deve ser usado com contratos não confiáveis, pois entrega todo o controle ao delegado chamado. Neste exemplo, o contrato não confiável rouba todo o éter do contrato.

 contract UntrustedDelegateCall { constructor() payable { require(msg.value == 1 ether); } function doDelegateCall(address _delegate, bytes calldata data) public { (bool ok, ) = _delegate.delegatecall(data); require(ok, "delegatecall failed"); } } contract StealEther { function steal() public { // you could also selfdestruct here // if you really wanted to be mean (bool ok,) = tx.origin.call{value: address(this).balance}(""); require(ok); } function attack(address victim) public { UntrustedDelegateCall(victim).doDelegateCall( address(this), abi.encodeWithSignature("steal()")); } }

Atualizar bugs relacionados a proxies

Não podemos fazer justiça a este tópico em uma única seção. A maioria dos bugs de atualização geralmente pode ser evitada usando o plug-in hardhat do Openzeppelin e lendo sobre os problemas contra os quais ele protege. ( https://docs.openzeppelin.com/upgrades-plugins/1.x/ ).


Como um resumo rápido, aqui estão os problemas relacionados às atualizações de contratos inteligentes:

  • selfdestruct e delegatecall não devem ser usados dentro de contratos de implementação
  • deve-se tomar cuidado para que as variáveis de armazenamento nunca sobrescrevam umas às outras durante as atualizações
  • chamar bibliotecas externas deve ser evitado em contratos de implementação porque não é possível prever como elas afetarão o acesso ao armazenamento
  • o implantador nunca deve deixar de chamar a função de inicialização
  • não incluir uma variável de lacuna nos contratos base para evitar colisão de armazenamento quando novas variáveis são adicionadas ao contrato base (isso é tratado automaticamente pelo plug-in do capacete de segurança)
  • os valores em variáveis imutáveis não são preservados entre as atualizações
  • fazer qualquer coisa no construtor é altamente desencorajado porque atualizações futuras teriam que executar lógica de construtor idêntica para manter a compatibilidade.

Administradores superpoderosos

Só porque um contrato tem um proprietário ou administrador, isso não significa que seu poder precisa ser ilimitado. Considere um NFT. É razoável que apenas o proprietário retire os ganhos da venda de NFT, mas ser capaz de pausar o contrato (bloquear transferências) pode causar estragos se as chaves privadas do proprietário forem comprometidas. Geralmente, os privilégios de administrador devem ser os mínimos possíveis para minimizar riscos desnecessários.


Falando em propriedade do contrato…

Use Ownable2Step em vez de Ownable

Tecnicamente, isso não é uma vulnerabilidade, mas a propriedade do OpenZeppelin pode levar à perda da propriedade do contrato se a propriedade for transferida para um endereço inexistente. Ownable2step requer que o receptor confirme a propriedade. Isso garante contra o envio acidental de propriedade para um endereço digitado incorretamente.

Erros de Arredondamento

Solidity não tem floats, então erros de arredondamento são inevitáveis. O projetista deve estar ciente se a coisa certa a fazer é arredondar para cima ou para baixo, e a favor de quem o arredondamento deve ser feito.


A divisão deve ser sempre realizada por último. O código a seguir converte incorretamente entre stablecoins que possuem um número diferente de decimais. O mecanismo de troca a seguir permite que um usuário pegue uma pequena quantia de USDC (que tem 6 casas decimais) gratuitamente ao trocar por dai (que tem 18 casas decimais). A variável daiToTake será arredondada para zero, não tirando nada do usuário em troca de um usdcAmount diferente de zero.

 contract Exchange { uint256 private constant CONVERSION = 1e12; function swapDAIForUSDC(uint256 usdcAmount) external pure returns (uint256 a) { uint256 daiToTake = usdcAmount / CONVERSION; conductSwap(daiToTake, usdcAmount); } }

Frontrunning

Frontrunning no contexto do Etheruem (e cadeias semelhantes) significa observar uma transação pendente e executar outra transação antes dela pagando um preço de gás mais alto. Ou seja, o invasor “correu na frente” da transação. Se a transação for uma negociação lucrativa, faz sentido copiar a transação exatamente, exceto pagar um preço de gás mais alto. Às vezes, esse fenômeno é referido como MEV, que significa valor extraível do minerador, mas às vezes valor extraível máximo em outros contextos. Os produtores de blocos têm poder ilimitado para reordenar transações e inserir suas próprias e, historicamente, os produtores de blocos eram mineradores antes da Ethereum ir para a prova de participação, daí o nome.

Frontrunning: retirada desprotegida

A retirada do Ether de um contrato inteligente pode ser considerada uma “negociação lucrativa”. Você executa uma transação de custo zero (além do gás) e acaba com mais criptomoeda do que quando começou.

 contract UnprotectedWithdraw { constructor() payable { require(msg.value == 1 ether, "must create with 1 eth"); } function unsafeWithdraw() external { (bool ok, ) = msg.sender.call{value: address(this).value}(""); require(ok, "transfer failed"). } }


Se você implantar este contrato e tentar retirá-lo, um bot pioneiro notará sua chamada para “unsafeWithdraw” no mempool e o copiará para obter o Ether primeiro.

Frontrunning: ERC4626 Inflation attack, uma combinação de frontrunning e erros de arredondamento

Escrevemos detalhadamente sobre o ataque de inflação ERC-4626 em nosso tutorial ERC4626 . Mas a essência disso é que um contrato ERC4626 distribui tokens de “ações” com base na porcentagem de “ativos” que um comerciante contribui.


Grosso modo, funciona da seguinte forma:

 function getShares(...) external { // code shares_received = assets_contributed / total_assets; // more code }

Claro, ninguém contribuirá com ativos e não receberá ações de volta, mas eles não podem prever que isso acontecerá se alguém puder liderar o comércio para obter as ações.


Por exemplo, eles contribuem com 200 ativos quando o pool tem 20, eles esperam obter 100 ações. Mas se alguém antecipar a transação para depositar 200 ativos, a fórmula será 200/220, que arredonda para zero, fazendo com que a vítima perca ativos e receba zero ações de volta.

Frontrunning: aprovação ERC20

É melhor ilustrar isso com um exemplo real em vez de descrevê-lo de forma abstrata


  1. Suponha que Alice aprove Eve para 100 fichas. (Eve é sempre a pessoa má, não Bob, então vamos manter a convenção).
  2. Alice muda de ideia e envia uma transação para alterar a aprovação de Eva para 50.
  3. Antes que a transação para alterar a aprovação para 50 seja incluída no bloco, ela fica no mempool onde Eve pode vê-la.
  4. Eve envia uma transação para reivindicar seus 100 tokens para antecipar a aprovação de 50.
  5. A aprovação para 50 passa por
  6. Eve coleta os 50 tokens.


Agora Eve tem 150 tokens em vez de 100 ou 50. A solução para isso é definir a aprovação como zero antes de aumentá-la ou diminuí-la, ao lidar com aprovações não confiáveis.

Frontrunning: ataques sanduíche

O preço de um ativo se move em resposta à pressão de compra e venda. Se um grande pedido estiver no mempool, os traders têm um incentivo para copiar o pedido, mas com um preço de gás mais alto. Dessa forma, eles compram o ativo, deixam o pedido grande aumentar o preço e vendem imediatamente. A ordem de venda às vezes é chamada de "backrunning". A ordem de venda pode ser feita colocando uma ordem de venda com um preço de gás mais baixo para que a sequência fique assim


  1. compra antecipada
  2. grande compra
  3. vender


A principal defesa contra esse ataque é fornecer um parâmetro de “derrapagem”. Se a própria “compra antecipada” empurrar o preço para cima além de um certo limite, a ordem de “compra grande” será revertida, fazendo com que o líder falhe na negociação.


É chamado de sandwhich, porque a grande compra é sandwhich pela frontrun buy e a backrun sell. Esse ataque também funciona com grandes ordens de venda, apenas na direção oposta.

Saiba mais sobre frontrunning

Frontrunning é um tópico massivo. A Flashbots pesquisou extensivamente o tópico e publicou várias ferramentas e artigos de pesquisa para ajudar a minimizar suas externalidades negativas.


Se o frontrunning pode ser “desenvolvido” com a arquitetura blockchain adequada é um assunto para debate que não foi resolvido de forma conclusiva. Os dois artigos a seguir são clássicos duradouros sobre o assunto:


Ethereum é uma floresta escura

Fugindo da floresta escura

Relacionado à Assinatura

As assinaturas digitais têm dois usos no contexto de contratos inteligentes:

  • permitir endereços para autorizar alguma transação no blockchain sem fazer uma transação real
  • provar a um contrato inteligente que o remetente tem alguma autoridade para fazer algo, de acordo com um endereço predefinido

Aqui está um exemplo de uso seguro de assinaturas digitais para dar ao usuário o privilégio de cunhar um NFT:

 import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract NFT is ERC721("name", "symbol") { function mint(bytes calldata signature) external { address recovered = keccak256(abi.encode(msg.sender)).toEthSignedMessageHash().recover(signature); require(recovered == authorizer, "signature does not match"); } }

Um exemplo clássico é a funcionalidade Aprovar no ERC20. Para aprovar um endereço para retirar uma certa quantidade de tokens de nossa conta, temos que fazer uma transação Ethereum real, que custa gasolina.


Às vezes é mais eficiente passar uma assinatura digital para o destinatário fora da cadeia, então o destinatário fornece a assinatura ao contrato inteligente para provar que foi autorizado a conduzir a transação.


ERC20Permit permite aprovações com uma assinatura digital. A função é descrita a seguir

 function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public

Em vez de enviar uma transação de aprovação real, o proprietário pode “assinar” a aprovação para o gastador (junto com um prazo). O gastador aprovado pode então chamar a função de permissão com os parâmetros fornecidos.

Anatomia de uma assinatura

Você verá as variáveis v, r e s com frequência. Eles são representados em solidez com os tipos de dados uint8, bytes32 e bytes32, respectivamente. Às vezes, as assinaturas são representadas como uma matriz de 65 bytes que contém todos esses valores concatenados como abi.encodePacked(r, s, v);


Os outros dois componentes essenciais de uma assinatura são o hash da mensagem (32 bytes) e o endereço de assinatura. A sequência fica assim


  1. Uma chave privada (privKey) é usada para gerar um endereço público (ethAddress)

  2. Um contrato inteligente armazena ethAddress com antecedência

  3. Um usuário offchain faz o hash de uma mensagem e assina o hash. Isso produz o par msgHash e a assinatura (r, s, v)

  4. O contrato inteligente recebe uma mensagem, faz hash para produzir msgHash e, em seguida, combina com (r, s, v) para ver qual endereço sai.

  5. Se o endereço corresponder a ethAddress, a assinatura é válida (sob certas suposições que veremos em breve!)


Os contratos inteligentes usam o contrato pré-compilado ecrecover na etapa 4 para fazer o que chamamos de combinação e obter o endereço de volta.


Há muitas etapas nesse processo em que as coisas podem dar errado.

Assinaturas: ecrecover retorna endereço(0) e não reverte quando o endereço é inválido

Isso pode levar a uma vulnerabilidade se uma variável não inicializada for comparada com a saída de ecrecover.

Este código é vulnerável

 contract InsecureContract { address signer; // defaults to address(0) // who lets us give the beneficiary the airdrop without them// spending gas function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { // ecrecover returns address(0) if the signature is invalid require(signer == ecrecover(keccak256(abi.encode(who, amount)), v, r, s), "invalid signature"); mint(msg.sender, AIRDROP_AMOUNT); } }

Repetição de assinatura

A reprodução da assinatura ocorre quando um contrato não rastreia se uma assinatura foi usada anteriormente. No código a seguir, corrigimos o problema anterior, mas ainda não é seguro.

 contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); mint(msg.sender, amount); } }

As pessoas podem reivindicar o airdrop quantas vezes quiserem!


Poderíamos adicionar as seguintes linhas

 bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // mapping(bytes => bool); used[signature] = true;

Infelizmente, o código ainda não é seguro!

Maleabilidade de assinatura

Dada uma assinatura válida, um invasor pode fazer algumas aritméticas rápidas para derivar uma assinatura diferente. O invasor pode então “reproduzir” essa assinatura modificada. Mas primeiro, vamos fornecer algum código que demonstre que podemos começar com uma assinatura válida, modificá-la e mostrar que a nova assinatura ainda é válida.

 contract Malleable { // v = 28 // r = 0xf8479d94c011613baeffe9239e4ff65e2adbac744c34217ca7d51378e72c5204 // s = 0x57af17590a914b759c45aaeabaf513d5ef72d7da1bdd19d9f2e1bc371ece5b86 // m = 0x0000000000000000000000000000000000000000000000000000000000000003 function foo(bytes calldata msg, uint8 v, bytes32 r, bytes32 s) public pure returns (address, address){ bytes32 h = keccak256(msg); address a = ecrecover(h, v, r, s); // The following is math magic to invert the // signature and create a valid one // flip s bytes32 s2 = bytes32(uint256(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) - uint256(s)); // invert v uint8 v2; require(v == 27 || v == 28, "invalid v"); v2 = v == 27 ? 28 : 27; address b = ecrecover(h, v2, r, s2); assert(a == b); // different signatures, same address!; return (a, b); } }

Como tal, nosso exemplo em execução ainda é vulnerável. Uma vez que alguém apresenta uma assinatura válida, sua assinatura de imagem espelhada pode ser produzida e ignorar a verificação de assinatura usada.

 contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // this can be bypassed used[signature] = true; mint(msg.sender, amount); } }

Assinaturas seguras

Você provavelmente está querendo algum código de assinatura seguro neste momento, certo? Indicamos nosso tutorial sobre como criar assinaturas na solidez e testá-las na fundição.


Mas aqui está a lista de verificação.


  • Use a biblioteca do openzeppelin para prevenir ataques de maleabilidade e recuperar a zero problemas
  • Não use assinaturas como senha. As mensagens precisam conter informações que os invasores não possam reutilizar facilmente (por exemplo, msg.sender)
  • Hash o que você está assinando na cadeia
  • Use um nonce para evitar ataques de repetição. Melhor ainda, siga o EIP712 para que o usuário possa ver o que está assinando e você pode evitar que as assinaturas sejam reutilizadas entre contratos e diferentes cadeias.

As assinaturas podem ser falsificadas ou forjadas sem as devidas proteções

O ataque acima pode ser generalizado ainda mais se o hash não for feito na cadeia. Nos exemplos acima, o hash foi feito no contrato inteligente, portanto, os exemplos acima não são vulneráveis ao seguinte exploit.


Vejamos o código para recuperar assinaturas

 // this code is vulnerable! function recoverSigner(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public returns (address signer) { require(signer == ecrecover(hash, v, r, s), "signer does not match"); // more actions }


O usuário fornece o hash e as assinaturas. Se o invasor já viu uma assinatura válida do signatário, ele pode simplesmente reutilizar o hash e a assinatura de outra mensagem.

É por isso que é muito importante fazer o hash da mensagem no contrato inteligente , não fora da cadeia.

Para ver esse exploit em ação, veja o CTF que postamos no Twitter.


Desafio Original:

Parte 1: https://twitter.com/RareSkills_io/status/1650869999266037760

Parte 2: https://twitter.com/RareSkills_io/status/1650897671543197701

Soluções:

https://twitter.com/RareSkills_io/status/1651527648676573185 https://twitter.com/RareSkills_io/status/1651224817465540611

Assinaturas como identificadores

As assinaturas não devem ser usadas para identificar usuários. Por causa da maleabilidade, eles não podem ser considerados únicos. Msg.sender tem garantias de exclusividade muito mais fortes.

Algumas versões do compilador Solidity têm bugs

Veja aqui um exercício de segurança que hospedamos no Twitter. Ao auditar uma base de código, verifique a versão do Solidity em relação aos anúncios de lançamento na página do Solidity para ver se um bug pode estar presente.

Assumindo que os contratos inteligentes são imutáveis

Os contratos inteligentes podem ser atualizados com o Padrão Proxy (ou mais raramente, o padrão metamórfico). Os contratos inteligentes não devem depender da funcionalidade de um contrato inteligente arbitrário para permanecer inalterado.

Transfer() e send() podem quebrar com carteiras multi-assinatura

As funções de solidity transfer e send não devem ser usadas. Eles limitam intencionalmente a quantidade de gás encaminhada com a transação a 2.300, o que fará com que a maioria das operações fique sem gás.


A carteira multi-assinatura segura gnosis comumente usada suporta o encaminhamento da chamada para outro endereço na função de fallback . Se alguém usar transferir ou enviar para enviar Ether para a carteira multisig, a função de fallback pode ficar sem combustível e a transferência falhará. Uma captura de tela da função de fallback seguro do gnosis é fornecida abaixo. O leitor pode ver claramente que há operações mais do que suficientes para consumir o gás 2300.


Se você precisa interagir com um contrato que usa transferência e envio, consulte nosso artigo sobre transações de lista de acesso Ethereum que permite reduzir o custo do gás de armazenamento e operações de acesso ao contrato.

O estouro aritmético ainda é relevante?

O Solidity 0.8.0 foi construído com proteção contra estouro e estouro. Portanto, a menos que um bloco não verificado esteja presente ou um código de baixo nível em Yul seja usado, não há perigo de estouro. Como tal, as bibliotecas SafeMath não devem ser usadas, pois desperdiçam gás nas verificações extras.

E quanto a block.timestamp?

Alguns documentos da literatura indicam que block.timestamp é um vetor de vulnerabilidade porque os mineradores podem manipulá-lo. Isso geralmente se aplica ao uso de registros de data e hora como fonte de aleatoriedade, o que não deve ser feito de qualquer maneira, conforme documentado anteriormente. O Ethereum pós-fusão atualiza o timestamp em intervalos de exatamente 12 segundos (ou múltiplos de 12 segundos). No entanto, medir o tempo na granularidade de segundo nível é um antipadrão. Na escala de um minuto, há uma oportunidade considerável de erro se um validador perder seu slot de bloco e ocorrer um intervalo de 24 segundos na produção de blocos.

Casos de canto, casos de borda e erros fora por um

Os casos extremos não podem ser facilmente definidos, mas uma vez que você os tenha visto o suficiente, você começa a desenvolver uma intuição para eles. Um caso de canto pode ser algo como alguém tentando reivindicar uma recompensa, mas sem nada apostado. Isso é válido, devemos apenas dar a eles recompensa zero. Da mesma forma, geralmente queremos dividir as recompensas uniformemente, mas e se houver apenas um destinatário e tecnicamente nenhuma divisão ocorrer?

Caso de canto: Exemplo 1

Este exemplo foi retirado do tópico do Twitter de Akshay Srivastav e modificado.

Considere o caso em que alguém pode realizar uma ação privilegiada se um conjunto de endereços privilegiados fornecer uma assinatura para ela.

 contract VulnerableMultisigAuthorization { struct Authorization { bytes signature; address authorizer; bytes32 hashOfAction; // more fields } // more codef unction takeAction(Authorization[] calldata auths, bytes calldata action) public { // logic for avoiding replay attacks for (uint256 i; i < auths.length; ++i) { require(validateSignature(auths[i].signature, auths[i].authorizer), "invalid signature"); require(authorizers[auths[i].authorizer], "address is not an authorizer"); } doTheAction(action) } }

Se alguma das assinaturas não for válida ou as assinaturas não corresponderem a um endereço válido, a reversão ocorrerá. Mas e se o array estiver vazio? Nesse caso, ele pulará todo o caminho para doTheAction sem a necessidade de assinaturas.

Off-by-One: Exemplo 2

 contract ProportionalRewards { mapping(address => uint256) originalId; address[] stakers; function stake(uint256 id) public { nft.transferFrom(msg.sender, address(this), id); stakers.append(msg.sender); } function unstake(uint256 id) public { require(originalId[id] == msg.sender, "not the owner"); removeFromArray(msg.sender, stakers); sendRewards(msg.sender, totalRewardsSinceLastclaim() / stakers.length()); nft.transferFrom(address(this), msg.sender, id); } }

Embora o código acima não mostre todas as implementações de função, mesmo que as funções se comportem como seus nomes descrevem, ainda há um bug. Você consegue identificar? Aqui está uma imagem para lhe dar algum espaço para não ver a resposta antes de rolar para baixo.

As funções removeFromArray e sendRewards estão na ordem errada. Se houver apenas um usuário no array stakers, haverá um erro de divisão por zero e o usuário não poderá sacar sua NFT. Além disso, as recompensas provavelmente não são divididas da maneira que o autor pretende. Se houver quatro apostadores originais e uma pessoa desistir, ela receberá um terço das recompensas, pois o tamanho da matriz é 3 no momento da retirada.

Exemplo de caso de canto 3: erro de cálculo de recompensa de finanças compostas

Vamos usar um exemplo real que, segundo algumas estimativas, causou mais de US$ 100 milhões em danos. Não se preocupe se você não entender completamente o protocolo Compound, vamos nos concentrar apenas nas partes relevantes. (Além disso, o protocolo Compound é um dos protocolos mais importantes e consequentes da história do DeFi, nós o ensinamos em nosso bootcamp DeFi , portanto, se esta é sua primeira impressão do protocolo, não se engane).


De qualquer forma, o objetivo do Compound é recompensar os usuários por emprestar sua criptomoeda ociosa a outros comerciantes que possam usá-la. Os credores são pagos em juros e em tokens COMP (os mutuários podem reivindicar uma recompensa em token COMP, mas não vamos nos concentrar nisso agora).

O Compound Comptroller é um contrato de proxy que delega chamadas para implementações que podem ser definidas pelo Compound Governance.


Na proposta de governança 62 em 30 de setembro de 2021, o contrato de implementação foi definido como um contrato de implementação que apresentava a vulnerabilidade. No mesmo dia em que foi ao ar, observou-se no Twitter que algumas transações reivindicavam recompensas COMP, apesar de apostar zero tokens.

A função vulnerável distribuiSupplierComp()


Aqui está o código original


O bug, ironicamente, está no comentário TODO. “Não distribua COMP fornecedor se o usuário não estiver no mercado fornecedor.” Mas não há verificação no código para isso. Contanto que o usuário mantenha o token de staking em sua carteira (CToken(cToken).balanceOf(supplier);), então

A proposta 64 corrigiu o bug em 9 de outubro de 2021.


Embora isso possa ser considerado um bug de validação de entrada, os usuários não enviaram nada malicioso. Se alguém tentar reivindicar uma recompensa por não apostar nada, o cálculo correto deve ser zero. Indiscutivelmente, é mais uma lógica de negócios ou um erro de caso de canto.

Hacks do mundo real

Os hacks DeFi que acontecem no mundo real muitas vezes não se enquadram nas boas categorias acima.

Parity Wallet Freeze (novembro de 2017)

A carteira de paridade não foi projetada para ser usada diretamente. Era uma implementação de referência para a qual os clones de contratos inteligentes apontariam. A implementação permitia que os clones se autodestruíssem, se desejado, mas isso exigia que todos os proprietários da carteira assinassem.

 // throw unless the contract is not yet initialized.modifier only_uninitialized { if (m_numOwners > 0) throw; _; } function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized { initDaylimit(_daylimit); initMultiowned(_owners, _required); }

Os proprietários da carteira são declarados

 // kills the contract sending everything to `_to`.function kill(address _to) onlymanyowners(sha3(msg.data)) external { suicide(_to); }

Algumas literaturas descrevem isso como uma “autodestruição desprotegida”, ou seja, uma falha no controle de acesso, mas isso não é muito preciso. O problema era que a função initWallet não era chamada no contrato de implementação e isso permitia que alguém chamasse a função initWallet e se tornasse o proprietário. Isso deu a eles autoridade para chamar a função kill. A causa raiz era que a implementação não foi inicializada. Portanto, o bug foi introduzido não devido a um código de solidity defeituoso, mas devido a um processo de implantação defeituoso.

Badger DAO Hack (dezembro de 2021)

Nenhum código Solidity foi explorado neste hack. Em vez disso, os invasores obtêm a chave da API Cloudflare e injetam um script no front-end do site que altera as transações do usuário para direcionar as retiradas para o endereço do invasor. Leia mais neste artigo .

Vetores de ataque para carteiras

Chaves privadas com aleatoriedade insuficiente

A motivação para descobrir endereços com muitos zeros à esquerda é que eles são mais eficientes em termos de gás. Uma transação Ethereum é cobrada 4 gas por um byte zero nos dados da transação e 16 gas por um byte diferente de zero.


Como tal, Wintermute foi hackeado porque usou o endereço de palavrões ( writeup ). Aqui está a descrição de 1inch de como o gerador de endereços de palavrões foi comprometido.


A carteira de confiança tinha uma vulnerabilidade semelhante documentada neste artigo ( https://blog.ledger.com/Funds-of-every-wallet-created-with-the-Trust-Wallet-browser-extension-could-have-been- roubado/ )


Observe que isso não se aplica a contratos inteligentes com zeros à esquerda descobertos alterando o salt em create2, pois os contratos inteligentes não possuem chaves privadas.

Nonces reutilizados ou nonces insuficientemente aleatórios.

O ponto “r” e “s” na assinatura da curva elíptica é gerado da seguinte forma

 r = k * G (mod N) s = k^-1 * (h + r * privateKey) (mod N)


G, r, s, h, um N são todos conhecidos publicamente. Se “k” se tornar público, então “privateKey” é a única variável desconhecida e pode ser resolvida. Por causa disso, as carteiras precisam gerar k perfeitamente aleatoriamente e nunca reutilizá-lo. Se a aleatoriedade não for perfeitamente aleatória, então k pode ser inferido.


A geração insegura de aleatoriedade na biblioteca Java deixou muitas carteiras de bitcoin Android vulneráveis em 2013. (Bitcoin usa o mesmo algoritmo de assinatura que Ethereum.)


( https://arstechnica.com/information-technology/2013/08/all-android-created-bitcoin-wallets-vulnerable-to-theft/ ).

A maioria das vulnerabilidades são específicas do aplicativo

Treinar-se para reconhecer rapidamente os antipadrões nesta lista fará de você um programador de contrato inteligente mais eficaz, mas a maioria dos bugs de contrato inteligente são devidos a uma incompatibilidade entre a lógica de negócios pretendida e o que o código realmente faz.


Outras áreas onde podem ocorrer bugs:

  • incentivos tokennômicos ruins
  • desligado por um erro
  • erros tipográficos
  • administradores ou usuários tendo suas chaves privadas roubadas

Muitas vulnerabilidades podem ter sido detectadas com testes de unidade

O teste de unidade de contrato inteligente é indiscutivelmente a proteção mais básica para contrato inteligente, mas um número chocante de contratos inteligentes carece deles ou tem cobertura de teste insuficiente.

Mas os testes de unidade tendem apenas a testar o “caminho feliz” (comportamento esperado/projetado) dos contratos. Para testar os casos surpreendentes, metodologias de teste adicionais devem ser aplicadas.


Antes que um contrato inteligente seja enviado para auditoria, o seguinte deve ser feito primeiro:

  • Análise estática com ferramentas como Slither para garantir que erros básicos não sejam perdidos
  • 100% de cobertura de linha e ramificação por meio de testes de unidade
  • Teste de mutação para garantir que os testes de unidade tenham declarações assertivas robustas
  • Teste fuzz, especialmente para aritmética
  • Teste invariante para propriedades com estado
  • Verificação formal quando apropriado


Para aqueles que não estão familiarizados com algumas das metodologias aqui, Patrick Collins, da Cyfrin Audits, tem uma introdução bem-humorada ao fuzzing com e sem estado em seu vídeo .


As ferramentas para realizar essas tarefas estão rapidamente se tornando mais difundidas e fáceis de usar.

Mais recursos

Alguns autores compilaram uma lista de hacks DeFi anteriores nestes repositórios:


O Secureum tem sido amplamente usado para estudar e praticar segurança, mas lembre-se de que o repositório não foi atualizado substancialmente por 2 anos


Você pode praticar a exploração de vulnerabilidades de solidity com nosso repositório Solidity Riddles .


DamnVulnerableDeFi é um jogo de guerra clássico que todo desenvolvedor deveria praticar


Capture The Ether e Ethernaut são clássicos, mas tenha em mente que alguns dos problemas são irrealisticamente fáceis ou ensinam conceitos desatualizados de Solidity


Algumas empresas de segurança de crowdsourcing respeitáveis têm uma lista útil de auditorias anteriores para estudar.

Tornando-se um auditor de contrato inteligente

Se você não é fluente em Solidity, não há como auditar contratos inteligentes Ethereum.


Não há certificação reconhecida pela indústria para se tornar um auditor de contrato inteligente. Qualquer pessoa pode criar um site e perfis de mídia social alegando ser um auditor de solidez e começar a vender serviços, e muitos o fizeram. Portanto, tenha cuidado e obtenha referências antes de contratar um.

Para se tornar um auditor de contrato inteligente, você precisa ser substancialmente melhor do que o desenvolvedor de solidez médio na detecção de bugs. Como tal, o “roteiro” para se tornar um auditor nada mais é do que meses e meses de prática incansável e deliberada até que você seja um apanhador de bugs de contrato inteligente melhor do que a maioria.


Se você não tiver determinação para superar seus colegas na identificação de vulnerabilidades, é improvável que identifique os problemas críticos antes que os criminosos altamente treinados e motivados o façam.

Verdade fria sobre suas chances de sucesso em se tornar um auditor de segurança de contrato inteligente

A auditoria de contratos inteligentes recentemente foi percebida como um campo desejável para trabalhar devido à percepção de que é lucrativo. De fato, alguns pagamentos de recompensas por bugs ultrapassaram 1 milhão de dólares, mas essa é uma exceção extremamente rara, não a norma.


Code4rena tem uma tabela pública de pagamentos de concorrentes em seus concursos de auditoria, o que nos dá alguns dados sobre as taxas de sucesso.


Existem 1171 nomes no tabuleiro, mas

  • Apenas 29 concorrentes têm mais de US$ 100.000 em ganhos vitalícios (2,4%)
  • Apenas 57 têm mais de $ 50.000 em ganhos vitalícios (4,9%)
  • Apenas 170 têm mais de $ 10.000 em ganhos vitalícios (14,5%)


Considere também isso, quando a Openzeppelin abriu uma inscrição para uma bolsa de pesquisa de segurança (não um emprego, uma triagem e treinamento pré-emprego), eles receberam mais de 300 inscrições apenas para selecionar menos de 10 candidatos, dos quais menos ainda obteriam um trabalho de tempo.

Solicitação de emprego de auditor de contrato inteligente do OpenZeppelin

É uma taxa de admissão mais baixa do que Harvard.


A auditoria de contratos inteligentes é um jogo competitivo de soma zero. Existem tantos projetos para auditar, tanto orçamento para segurança e tantos bugs para encontrar. Se você começar a estudar segurança agora, há dezenas de indivíduos e equipes altamente motivados com uma enorme vantagem sobre você. A maioria dos projetos está disposta a pagar um prêmio por um auditor com reputação, em vez de um novo auditor não testado.


Neste artigo, listamos pelo menos 20 categorias diferentes de vulnerabilidades. Se você passou uma semana dominando cada um (o que é um tanto otimista), você está apenas começando a entender o que é de conhecimento comum para auditores experientes. Não cobrimos otimização de gás ou tokennomics neste artigo, os quais são tópicos importantes para um auditor entender. Faça as contas e verá que não é uma viagem curta.


Dito isto, a comunidade geralmente é amigável e prestativa para os recém-chegados e há muitas dicas e truques. Mas para aqueles que estão lendo este artigo na esperança de fazer carreira na segurança de contratos inteligentes, é importante entender claramente que as chances de obter uma carreira lucrativa não estão a seu favor. O sucesso não é o resultado padrão.


Isso pode ser feito, é claro, e algumas pessoas deixaram de conhecer Solidity para ter uma carreira lucrativa em auditoria. É indiscutivelmente mais fácil conseguir um emprego como auditor de contrato inteligente em um período de dois anos do que ser admitido na faculdade de direito e passar no exame da ordem. Certamente tem mais vantagens em comparação com muitas outras opções de carreira.


No entanto, será necessária uma perseverança hercúlea de sua parte para dominar a montanha de conhecimento em rápida evolução à sua frente e aprimorar sua intuição para detectar bugs.

Isso não quer dizer que aprender a segurança de contratos inteligentes não seja uma busca que valha a pena. É absolutamente. Mas se você está se aproximando do campo com cifrões em seus olhos, mantenha suas expectativas sob controle.

Conclusão

É importante estar ciente dos antipadrões conhecidos. No entanto, a maioria dos bugs do mundo real são específicos do aplicativo. Identificar qualquer categoria de vulnerabilidade requer prática contínua e deliberada.


Aprenda segurança de contrato inteligente e muitos outros tópicos de desenvolvimento de Ethereum com nosso treinamento de solidez líder do setor.


A imagem principal deste artigo foi gerada peloAI Image Generator do HackerNoon por meio do prompt "um robô protegendo um computador".