Nesse artigo vamos abordar sobre segurança de smart contracts, que afetam diretamente dApps e o universo das criptomoedas, uma vez que os contratos são a base da Web3. Veremos as principais vulnerabilidades nesse ecossistema e uma vulnerabilidade real crítica que foi encontrada. O problema dessas vulnerabilidades é que, como o universo blockchain tem uma forte relação com dinheiro, elas são capazes de causar danos financeiros de bilhões de dólares.
O que exatamente é Web3?
Web3 é o nome que se dá a "nova era" da internet que está surgindo com a tecnologia blockchain e Smart Contracts. Na Web3 temos a descentralização como a principal característica, "descentralized Applications" (dApps) é o nome que se dá às aplicações dessa nova categoria, onde não existe um servidor centralizado, tudo está registrado na blockchain, que é uma tecnologia descentralizada por design. Nessas aplicações podemos encontrar governanças descentralizadas (DAOs), tokens sendo a moeda principal da aplicação ao invés de real ou dólar e, o principal, códigos sendo executados na blockchain por meio de Smart Contracts.
E o que são Smart Contracts?
Smart Contracts são códigos com funções definidas que, quando são chamadas e executadas, se a execução for bem sucedida, tudo é registrado na blockchain, e a partir daí nada mais altera o que foi executado e nem retira da blockchain. Smart Contracts podem ser escritos em diversas linguagens atualmente, mas quando estavam surgindo, a linguagem Solidity foi criada para essa finalidade.
O que executa os Smart Contracts é a Ethereum Virtual Machine (EVM) que é uma máquina de estado mantida por vários computadores rodando um "node" do Ethereum. Aqui vamos falar da rede Ethereum que foi a que introduziu o conceito de Smart Contracts, porém muitas outras redes agora também usam essa tecnologia, como a Binance Smart Chain (BSC), Solana, Polygon etc.
Rede Ethereum
A rede Ethereum é uma das redes mais consolidadas e famosas do universo crypto, foi pioneira no modelo de Smart Contracts e possui como criptomoeda nativa o Ether (ETH).
Qualquer um é livre para criar uma aplicação descentralizada na rede Ethereum, o que significa que qualquer um pode escrever um Smart Contract e fazer "deploy" dele na rede. Quando o deploy de um Smart Contract é feito, é atribuído um endereço a ele (address), assim como carteiras de pessoas também possuem um endereço.
Para chamar uma função de um Smart Contract é necessário passar exatamente o que é para ser chamado no campo data de uma transação e assiná-la com sua chave privada (hoje existem várias bibliotecas que fazem isso automaticamente para você), além disso, também é necessário pagar uma taxa de transação, chamada de "gas fee" (pago em ETH).
Levando em conta que qualquer pessoa, a qualquer hora pode executar funções de outros Smart Contracts é necessário que o código destes esteja bem seguro, se não podem haver danos desastrosos.
Vulnerabilidades em Smart Contracts
Vamos percorrer algumas vulnerabilidades listadas no TOP 10 da DASP (Descentralized Application Security Project) - https://dasp.co/
Esse é o último TOP 10 vulnerabilidades em Smart Contracts que foram divulgadas pela DASP em 2018:
1. Reentrancy
2. Access Control
3. Arithmetic
4. Unchecked Low Level Calls
5. DoS
6. Bad Randomness
7. Front Running
8. Time manipulation
9. Short Addresses
10. Unknown Unknowns
1. Reentrancy
Reentrancy é provavelmente a vulnerabilidade mais famosa dos Smart Contracts, que já foi encontrada em diversos contratos de grandes dApps e podia causar milhões de dólares de prejuízo.
A vulnerabilidade é causada quando uma função de um contrato chama uma função do controle do atacante, a partir daí é possível chamar a mesma função novamente e, dependendo de como essa função está programada, é possível induzir erros na lógica da aplicação.
Exemplo
function withdraw(uint _amount) external{ require(balances[msg.sender] >= _amount); msg.sender.transfer(_amount); balances[msg.sender] -= _amount; }
Essa função, parte de um Smart Contract programado em solidity, tem o objetivo de sacar ETH que estava armazenado nesse contrato.
Antes de ver a vulnerabilidade, precisamos saber um pouco sobre algumas coisas do solidity para melhor entendimento da função. A variável balances é um mapping(address => uint), isso significa que é um "dicionário" que usa o endereço de uma carteira (address) como a chave e usa um valor inteiro positivo (uint) como valor dessa chave, nesse caso, o valor inteiro está representando a quantidade de ETH que aquele endereço possui (solidity não utiliza valores decimais, ele utiliza wei - 1 ETH = 10^18 wei ). Outra coisa que gera confusão é o msg.sender, que para o solidity é quem chamou essa função, podendo ter sido uma carteira comum (EOA - Externally Owned Account) ou um Smart Contract. Isso significa que esses 2 tipos de carteira podem chamar essa função, porém os Smart Contracts têm funcionalidades a mais que podem ser usadas para explorar a vulnerabilidade.
O fluxo dessa função é o seguinte:
- Requer que o valor requisitado para sacar (_amount) seja menor do que o valor que o address realmente tem armazenado nesse contrato, caso contrário, a transação é revertida e não altera nada no estado da blockchain.
- Envia os ETH requisitados para quem chamou a função (msg.sender) e chama a função receive(), se definida, no msg.sender. (para isso, o msg.sender deve ser um Smart Contract e não um EOA)
- Atualiza o valor do balances de acordo com o quanto foi sacado.
Agora vamos para a vulnerabilidade em si. Essa função, ao transferir ETH para quem a chamou, executa a função receive() por padrão (se existir no contrato que a chamou). Portanto, nessa situação é possível escrever um Smart Contract em que a função receive() chama a função withdraw() desse contrato novamente. Assim, é possível sacar diversas vezes o valor que você possui pelo simples fato do valor de balances[msg.sender] ser atualizado apenas no final da função, que não é alcançado enquanto o receive() chamar withdraw() novamente. Como o valor de balances[msg.sender] não é alterado, o require() no começo da função sempre vai ser verdadeiro, sendo possível roubar mais valor do que você depositou no contrato.
fallback() é o que era chamado ao invés de receive() em versões anteriores do solidity.
Mitigação
Para deixar seu código livre de vulnerabilidades Reentrancy, existe um modificador de função (modifier) criado pelo OpenZeppelin que permite que a função não seja executada em Reentrancy, ou seja, se a função for chamada novamente em uma mesma execução.
Documentação do ReentrancyGuard e nonReentrant
Aqui está o código com a vulnerabilidade corrigida:
function withdraw(uint _amount) external nonReentrant{ require(balances[msg.sender] >= _amount); msg.sender.transfer(_amount); balances[msg.sender] -= _amount; }
2. Access Control
Vulnerabilidades de controle de acesso são falhas de segurança relacionadas às permissões de cada endereço. O gerenciamento de permissões pode ser feito de forma mais simples apenas armazenando o address do criador do contrato (quem fez o deploy do contrato), ou pode utilizar ferramentas mais complexas, como gerenciamento por cargo (roles) usando o Role-Based Access Control do OpenZeppelin .
Se seu contrato gerenciar permissões de forma errada, é possível que esteja vulnerável a ataques de controle de acesso.
Exemplo
function initContract() public { owner = msg.sender; }
Nesse trecho de código, existe uma função que define o dono do contrato, que provavelmente possui permissões sensíveis que não podem ser acessíveis por qualquer um. Entretanto, esse código está vulnerável, pois essa função pode ser chamada por qualquer pessoa uma vez que o modifier public está presente na definição da função e, portanto, se transformar no dono do contrato (msg.sender é o endereço de quem chamou a função).
Mitigação
Um código escrito de forma correta seria não criar uma função acessível para qualquer um poder ganhar permissões. Para isso, ao invés de definir o owner em uma função public, deve ser definido na função construtora do contrato - constructor(), que é uma função que só é executada quando o contrato é inserido na blockchain (quando for feito o seu deploy).
constructor(){ owner = msg.sender; }
3. Arithmetic Issues
Dentro dessa categoria está incluído integer overflow e integer underflow, porém, as versões do solidity >=0.8 checam se está ocorrendo um overflow ou underflow antes de fazer a aritmética e, por isso, não são vulnerabilidades exploráveis nas versões atuais do solidity.
Apesar disso, ainda existem vulnerabilidades de aritmética que podem ser exploradas caso o código não tenha cuidado com os valores da aritmética. Um vetor de ataque interessante são as divisões em que é possível controlar o numerador ou o denominador.
A causa desse erro aritmético é o fato de solidity não suportar números do tipo float. Toda aritmética deve utilizar algum formato de inteiro, sendo o uint256 o tipo mais utilizado no solidity. Sabendo disso, no caso em que é possível controlar o numerador ou denominador em divisões, é possível deixar o dividendo menor que o divisor, que é para resultar um número 0 < x < 1, porém, o solidity interpreta como 0.
Exemplo
function _computeOraclePrice() private view returns (uint256) { return uniswapPair.balance / token.balanceOf(uniswapPair); }
Essa função utiliza a quantidade existente de um token e de ETH na Uniswap (Descentralized Exchange - DEX - utilizado para trocas de um token pelo outro -> "swap"). Nessa situação, o atacante pode trocar muitos desses tokens por ETH na Uniswap, fazendo com que o contrato da Uniswap tenha menos ETH e mais tokens, e, dessa forma manipular o preço do token para 0, explorando a falha aritmética nessa função (uniswapPair.balance pega a quantidade de ETH e token.balanceOf(uniswapPair) pega a quantidade de tokens).
Mitigação
Deve-se ter certeza de que o numerador é maior que o denominador. Uma forma de fazer isso é multiplicar o numerador por um valor alto.
function _computeOraclePrice() private view returns (uint256) { return ((uniswapPair.balance * 10 ** 18) / token.balanceOf(uniswapPair)); }
Logicamente, depois vai ser necessário tratar esse valor uma vez que o valor está 10^18 vezes maior do que deveria estar.
4. Unchecked Low-Level Calls
Chamadas de baixo nível no solidity são funções que, mesmo que ocorra um erro, a transação não é revertida. Ao invés disso, ao ocorrer um erro, o retorno da função é igual a false.
A vulnerabilidade ocorre em certos casos onde a função de baixo nível não é bem sucedida e o código não verifica essa condição, que deve ser feita checando se o valor de retorno da função é false.
Exemplo
function deposit(uint256 _amount) external{ token.call( abi.encodeWithSignature( "transferFrom(address,address,uint256)", msg.sender, address(this), _amount ) ); balances[msg.sender] += _amount; }
Nessa função de depósito de tokens, o código usa uma função de baixo nível do solidity call(), que chama uma função do contrato do token transferFrom() (que faz parte do padrão ERC20 que permite transferir tokens de outras carteiras para outra carteira, mas, para isso ser possível, é necessário que o dono da carteira permita essa outra carteira a usar o transferFrom(). Essa funcionalidade é chamada de Allowance e o dono da carteira deve chamar a função approve() para permitir essa outra carteira acessar seus tokens. Após a transferência da carteira que está fazendo o depósito para o próprio contrato (address(this) = endereço do próprio contrato), a variável balances é alterada.
Apesar do código usar o transferFrom de forma correta, é possível que a execução dessa função falhe por alguns motivos, como por exemplo, a carteira não ter permitido o Allowance por meio do approve() ou a carteira não ter a quantidade de tokens requisitados para transferência. Ao falhar, a transação não será revertida, isso significa que o código continuará executando até o fim ou até que um outro erro reverta a transação. Dessa forma, o balances[msg.sender] será alterado sem que nenhum token seja enviado de fato para o contrato.
Mitigação
Para deixar o código livre de vulnerabilidades existe 2 formas nesse caso:
- Não utilizar chamadas de baixo nível já que não é necessário nessa situação
- Checar o resultado da função call()
function deposit(uint256 _amount) external{ token.transferFrom(msg.sender, address(this), _amount); balances[msg.sender] += _amount; }
Sem utilizar chamadas de baixo nível, um erro na função transferFrom() vai resultar em uma reversão na transação.
5. Denial of Service (DoS)
DoS é uma falha de segurança a qual é possível tornar um serviço inoperante. Nos Smart Contracts isso pode ser feito de várias formas:
- Fazer com que uma condição dentro de um require() sempre seja falsa
- Abusando de erros de Access Control para deixar o serviço inoperante
- Se comportando de forma maliciosa quando é o recipiente de uma transação (com a função receive())
- Diferentes maneiras de exceder o gas limit
Gas fee é o nome que se dá à taxa de transação necessária para colocar a transação na blockchain. O gas é pago para o minerador do bloco da sua transação e é necessário para que haja o incentivo de ter mineradores na rede. Gas limit é o gas máximo que a transação pode consumir, caso exceda esse limite, a transação é revertida.
Exemplo
function becomePresident() external payable { require(msg.value >= price); // must pay the price to become president president.transfer(price); // we pay the previous president president = msg.sender; // we crown the new president price = price * 2; // we double the price to become president }
Aqui é possível abusar do código se o atacante for o presidente. No momento que é feito a transferência de ETH para o presidente (que é um Smart Contract), o atacante pode criar uma função receive() que crie um loop infinito e acabe com o gas limit da transação, fazendo ela reverter. Dessa forma nunca será possível se tornar presidente enquanto essa função existir.
Mitigação
Não é recomendado deixar endereços externos não confiáveis definirem o que executar na transação de outra carteira. Uma forma de fazer isso é utilizando tokens ERC20 para a transferência, que não chama nenhum callback definido pelo recipiente. O ETH é uma criptomoeda nativa da rede Ethereum, portanto ele não é um ERC20, porém ele possui uma versão ERC20 chamada WETH (Wrapped ETH).
6. Bad Randomness
Randomização sempre foi algo difícil de aplicar e, uma randomização previsível pode implicar em problemas de segurança. Algumas vulnerabilidades de randomização originam-se de tentar criar algum segredo no código como a seed do algoritmo, porém deve-se lembrar que a blockchain é pública e todos podem ver o que está sendo alterado em seu estado.
Exemplo
1. Um Smart Contract que implementa um jogo de azar - quem acertar o número da sorte recebe uma recompensa
2. O número da sorte é definido utilizando um algoritmo de randomização predizível
3. O atacante prevê o número da sorte corretamente todas as vezes e recebe todas as recompensas do contrato
Mitigação
Existem jeitos seguros de gerar numeros pseudoaleatórios que não são predizíveis. Aqui está uma forma de implementar um RNG de forma segura.
7. Front-Running / Race Condition
Mineradores na blockchain são recompensados a partir do gas fee pago pelas pessoas que desejam fazer transações. Essas pessoas podem definir o preço do gas que vão pagar (gas price) e isso define a rapidez que sua transação será processada, uma vez que os mineradores preferem minerar blocos que possuem recompensa maior. É possível ver os gas prices que estão sendo usados e quanto tempo está demorando para uma transação ser inserida na blockchain: https://etherscan.io/gastracker (os gas prices estão em gwei = giga wei = 10^9 wei)
Utilizando dessa funcionalidade, um atacante pode fazer suas transações serem processadas mais rapidamente que transações de outras pessoas.
Exemplo
- Um Smart Contract publica uma hash MD5.
- Quem achar uma colisão pra essa hash chama a função submitSolution() e recebe uma recompensa.
- Alice acha uma colisão e chama a função.
- Um atacante vê que Alice descobriu a solução na blockchain e que seu bloco está sendo minerado.
- O atacante chama a mesma função com a solução roubada de Alice e coloca um gas price maior.
- O bloco do atacante é minerado antes do bloco da Alice e o atacante recebe a recompensa.
Mitigação
É possível fazer uma função segura fazendo com que inicialmente é enviado um hash da solução (nesse caso em específico não pode ser md5) e após um tempo determinado é enviado a solução de fato. Assim, quem possui a solução sabe qual é a hash da solução e quem está tentando "roubar" a solução monitorando a blockchain não conseguirá ver a solução. Além disso, o Smart Contract saberá quem enviou a hash primeiro e não terá problemas de front-running. Aqui está um exemplo de como implementar isso em solidity.
Para mais detalhes leia esse paper que apresenta ataques frontrunning em sistemas modernos de swap e suas mitigações.
8. Time manipulation
As vezes, em um Smart Contract, é necessário utilizar medidas de tempo para certas funcionalidades. Uma maneira muito utilizada para isso é usando as diretivas block.timestamp ou now, que pega quando que o bloco foi minerado. Quem define o valor disso, portanto, são os mineradores.
Sabendo disso, é possível atacar certos Smart Contracts que utilizam tempo para coisas sensíveis se o atacante minerar um bloco que tenha uma chamada para esse Smart Contract.
9. Short Address Attack
Esse ataque se baseia no princípio que o solidity, quando vai fazer uma chamada para outra função, utiliza a especificação da ABI (Application Binary Interface) para enviar os parâmetros. Os parâmetros do tipo address, por exemplo são inteiros de 20 bytes que são encodados no ABI para 32 bytes (são adicionados 12 bytes). Porém, se for enviado um endereço de 19 bytes, o ABI vai adicionar 12 bytes e terminará com 31 bytes. Isso implica na maneira de como os argumentos são tratados na hora de utilizar eles na função.
Mais detalhes práticos explicados nesse artigo .
Mitigação
Sempre checar os argumentos que estão sendo passados para as funções do seu contrato.
10. Unknown Unknowns
Essa categoria incluem vulnerabilidades mais genéricas e falhas que ainda não foram descobertas. O solidity é uma linguagem que ainda não chegou em sua versão estável e está em constante desenvolvimento, isso traz possibilidade de novas categorias de vulnerabilidades sendo encontradas. Além disso, essa categoria também relembra que cada caso é um caso; todos os Smart Contracts possuem características específicas e, portanto, podem ter vulnerabilidades específicas. Enquanto existirem novos contratos sendo criados vão existir novas vulnerabilidades sendo encontradas.
Casos reais
As vulnerabilidades citadas aqui não foram consideradas top 10 por acaso. Elas foram encontradas em dApps reais e podiam causar um prejuízo milionário. Aqui estão alguns write-ups, sobre as vulnerabilidades citadas, encontradas em Smart Contracts nas principais mainnets (redes principais, não as de teste - testnets?
- Reentrancy - The DAO
- Bad Randomness + Time Manipulation - Casino
- Access Control - Multi-sig
- Unchecked Return Values for Low-Level Calls - King of the Ether
- Denial of Service - GovernMental
- Front-Running - Sandwich Attacks
- Unknown Unknows - $BURG token
Hoje em dia, a preocupação é dobrada em relação à segurança em Smart Contracts devido ao crescimento do ecossistema descentralizado blockchain. Os grandes projetos, além de fazerem um "audit", também possuem programas de Bug Bounty que recompensam pessoas que acharem vulnerabilidades em seus contratos.
A principal plataforma de Bug Bounty de Smart Contracts é o Immunefi, onde tem programas que oferecem até $10.000.000 por uma falha crítica.
CTFs
Para aprender na prática, nada melhor que CTFs (desafios de segurança) de Smart Contracts. Aqui estão alguns sites para aprender segurança de contratos inteligentes na prática:
Referências
EVM: https://ethereum.org/en/developers/docs/evm/
DASP: https://dasp.co/
OpenZeppelin: https://openzeppelin.com/
OpenZeppelin Docs: https://docs.openzeppelin.com/
Integer Overflow: https://solidity-by-example.org/hacks/overflow/
Ethereum Gas Security: https://medium.com/consensys-diligence/silent-but-vulnerable-ethereum-gas-security-concerns-adadf8bfb180
Ethereum blockchain explorer: https://etherscan.io/
- 1