Quase todos nós usamos o Planilhas Google ou o Microsoft Excel para inserir dados para algum cálculo. Digamos que você deseja inserir os nomes dos funcionários, seus números de telefone, cargos e o salário que recebem.
Em sua forma mais simples, é assim que um registro ou caso ficaria, no Planilhas ou no Excel:
Como você pode ver, o nome e o cargo do funcionário consistem em texto, enquanto o número de telefone e o salário consistem em uma sequência de números.
Então, do ponto de vista semântico, nós, como humanos, entendemos o que esses campos significam no mundo real e podemos diferenciá-los.
Claramente, embora você não precise de um diploma em Ciência da Computação para saber a diferença, como um compilador ou interpretador processa esses dados?
É aqui que entram os tipos de dados, algo que os programadores demoram a especificar ou não, dependendo da linguagem de programação em que codificam.
Em outras palavras, os pontos de dados sob o nome do funcionário e o cargo são chamados de strings. Claro, o salário é claramente um inteiro, em virtude de não ter casas decimais. Simplificando, esses são tipos de dados que devem ser declarados como tal quando você codificar, para que apenas as operações corretas associadas a esse tipo de dados sejam executadas.
É assim que declaramos um tipo de dado inteiro no Solidity:
Dito isso, o campo Phone Number na planilha acima contém um ponto de dados que será usado como uma string única, mas essa discussão fica para outro dia. Por enquanto, nosso foco estará no tipo de dado primitivo com o qual todos realizamos aritmética básica.
Sim, estamos falando sobre o tipo de dado inteiro que, embora seja importante para as principais operações aritméticas, tem um alcance limitado para qualquer cálculo.
Provavelmente, o exemplo mais popular de estouro de número inteiro no mundo real ocorre em veículos. Também conhecido como hodômetro, esses dispositivos geralmente rastreiam quantos quilômetros um veículo percorreu.
Então, o que acontece quando o valor das milhas percorridas atinge o valor inteiro sem sinal de 999999 em um odômetro de seis dígitos?
Idealmente, uma vez que mais uma milha é adicionada, esse valor deve chegar a 1000000, certo? Mas isso não acontece porque está previsto um sétimo dígito.
Em vez disso, o valor das milhas percorridas é redefinido para 000000, conforme mostrado abaixo:
Por definição, como o sétimo dígito não está disponível, isso resulta em 'estouro', pois o valor exato não é representado.
Você entendeu a foto, certo?
Por outro lado, o oposto também pode ocorrer, mesmo que isso não seja tão comum. Ou seja, quando o valor registrado for menor que o menor valor disponível na faixa e que também é conhecido como 'underflow'.
Como todos sabemos, os computadores armazenam números inteiros na memória como seu equivalente binário. Agora, para simplificar, digamos que você esteja usando um registrador de 8 bits.
= 2⁸*1 + 2⁷*1 + 2⁶*1 + 2⁵*1 + 2⁴*1 + 2³*1 + 2²*1 + 2¹*1 + 2⁰*1
= 256 + 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1
= 111111111
Onde cada bit é 1 e, como você pode ver, não é possível armazenar um valor maior.
Por outro lado, se você quiser armazenar o número 0 no registrador de 8 bits, ficaria assim:
= 2⁸*0 + 2⁷*0 + 2⁶*0 + 2⁵*0 + 2⁴*0 + 2³*0 + 2²*0 + 2¹*0 + 2⁰*0
= 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0
= 000000000
Onde cada bit é 0, o que deve dizer que você não pode armazenar um valor menor.
Em outras palavras, o intervalo de inteiros permitido para tal registrador de 8 bits é de 0 a 511. Então, é possível armazenar o inteiro 512 ou -1 em tal registrador?
Claro que não. Como resultado, você armazenará um valor semelhante ao valor redefinido de milhas percorridas no exemplo do odômetro, mas como valores binários.
Claramente, você precisaria de registradores com mais alguns bits para acomodar tal número confortavelmente. Ou então, arriscar mais uma vez a situação de transbordamento.
No caso de inteiros com sinal, também armazenamos inteiros negativos. Assim, quando tentamos armazenar um número menor que o intervalo aceito, ou menor que zero, conforme mostrado acima, ocorre o underflow.
Mais uma vez, uma vez que o objetivo de qualquer cálculo é obter resultados determinísticos, isso pode, na melhor das hipóteses, ser irritante, mas, na pior das hipóteses, causar a perda de milhões. Particularmente, quando esses erros de estouro ou estouro de número inteiro ocorrem em contratos inteligentes.
Embora o estouro e o estouro de números inteiros existam há décadas, sua existência como um bug em um contrato inteligente aumentou as apostas. Quando os invasores fazem uso de tais erros, eles podem drenar o contrato inteligente de grandes quantidades de tokens.
Provavelmente a primeira vez que esse tipo de bug ocorreu foi com o bloco 74638, que criou bilhões de Bitcoin para três endereços. Levaria horas para resolver esse erro por meio de um soft fork e que descartasse o bloco, tornando a transação inválida.
Por um lado, as transações com valor superior a 21 milhões de bitcoins foram rejeitadas. Isso não foi diferente para as transações de estouro, como aquela que enviou tanto dinheiro para as três contas mencionadas.
No entanto, os contratos inteligentes da Ethereum também experimentaram estouro e estouro de números inteiros, com o BeautyChain sendo um exemplo proeminente também.
Nesse caso, o contrato inteligente continha uma linha de código defeituosa:
Como resultado, os atacantes foram teoricamente capazes de receber uma quantidade ilimitada de tokens BEC, que teoricamente poderiam chegar a um valor de (2²⁵⁶)-1.
Agora, vamos ver outro exemplo de um contrato inteligente no qual ocorre estouro/estouro de número inteiro.
À primeira vista, existem dois contratos que interagem neste exemplo, e que demonstram o que acontece no caso de um estouro inteiro.
Como você pode ver abaixo, o contrato TimeLock, permite que você deposite e levante fundos, mas com uma diferença: você só pode realizar o último somente após um período de tempo. Nesse caso, você só pode retirar seus fundos em uma semana.
No entanto, uma vez que você chama a função de ataque no contrato de ataque, o bloqueio de tempo no local não é mais eficaz e é por isso que o invasor pode retirar o valor do saldo imediatamente.
Em outras palavras, devido a causar um estouro de número inteiro com a instrução type(uint).max+1-timeLock.locktime(address(this)), o bloqueio de tempo é eliminado.
Por exemplo, depois de implantar os dois contratos inteligentes usando o código acima, você pode testar se o timelock é válido invocando as funções de depósito e retirada no contrato TimeLock, conforme mostrado abaixo:
Como você pode ver, selecionando uma quantidade de 2 Ether, obtemos o saldo do contrato inteligente de 2 Ether mostrado acima:
Especificamente, o endereço específico que contém o saldo de 2 Ether pode ser verificado adicionando o endereço no campo da função de saldos e clicando no botão saldos:
No entanto, conforme mencionado acima, você ainda não pode sacar esses fundos, devido ao bloqueio de tempo em vigor. Ao olhar para o console depois de pressionar a função de retirada, você encontrará um erro indicado pelo símbolo 'x' vermelho. Como você pode ver abaixo, o motivo desse erro é fornecido pelo contrato é “Lock time not expired”:
Agora, vamos ver o contrato de ataque implantado, conforme mostrado abaixo:
Agora, para invocar a função de ataque, você precisa depositar um valor de 1 Ether ou mais. Portanto, neste caso, selecionamos 2 Ether, conforme mostrado abaixo:
Depois disso, clique em 'atacar'. Você descobrirá que os 2 Ether que você depositou serão retirados imediatamente e adicionados ao contrato de ataque, conforme evidenciado pelo saldo de 2 Ether abaixo:
Claramente, isso não deveria acontecer devido ao fato de que o longo bloqueio de tempo deve entrar em vigor assim que você fizer o depósito. É claro que, como sabemos, a instrução type(uint).max+1-timeLock.locktime(address(this)) reduz o tempo de bloqueio usando a função gainLockTime. É exatamente por isso que podemos retirar o saldo do Ether imediatamente.
O que nos leva à pergunta óbvia: existem maneiras de corrigir a vulnerabilidade de estouro e estouro de número inteiro?
Ao reconhecer que a vulnerabilidade de estouro/subfluxo inteiro pode ser devastadora, algumas correções para esse bug foram implementadas. Vejamos essas duas correções e como elas contornam esse erro:
O Open Zeppelin, como organização, oferece muito quando se trata de tecnologia e serviços de segurança cibernética, com a biblioteca SafeMath fazendo parte de seu repositório de desenvolvimento de contrato inteligente. Este repositório contém contratos que podem ser importados para o seu código de contrato inteligente, sendo a biblioteca SafeMath um deles.
Vamos ver como uma das funções dentro de SafeMath.sol verifica o estouro de número inteiro:
Agora, uma vez que o cálculo de a+b tenha ocorrido, uma verificação para ver se c<a ocorre. Obviamente, isso só seria verdade no caso de um estouro de número inteiro.
Com a versão do compilador do Solidity chegando a 0.8.0 e superior, as verificações de estouro e estouro de inteiros agora estão incorporadas. Portanto, ainda é possível usar esta biblioteca para verificar essa vulnerabilidade, tanto ao usar a linguagem quanto esta biblioteca. Obviamente, se o seu contrato inteligente exigir uma versão do compilador inferior a 0.8.+, você deverá usar esta biblioteca para evitar estouro ou estouro.
Agora, como mencionado anteriormente, se para o seu contrato inteligente você estiver usando uma versão do compilador 0.8.0 e superior, esta versão possui um verificador integrado para tal vulnerabilidade.
Na verdade, apenas para verificar se funciona com o contrato inteligente acima, ao alterar a versão do compilador para “^0.8.0” e reimplantá-lo, o seguinte erro 'reverter' é recebido:
Obviamente, nenhum depósito de 2 Ether é realizado, devido à verificação do estouro do valor do bloqueio de tempo. Como resultado, nenhum saque é possível devido ao fato de nenhum fundo ter sido depositado em primeiro lugar.
Sem dúvida, a chamada da função Attack.attack() não funcionou aqui, então está tudo bem!
Se há algo que você deve aprender com esta longa postagem no blog, é que ignorar essa vulnerabilidade, como no ataque BEC, pode custar caro. Como você também pode ver, se não for verificado, é fácil ocorrer erros não maliciosos. Ou tão simples quanto os hackers explorarem essa vulnerabilidade.
Falando nisso, e usando nossa compreensão de como o ataque BEC ocorreu, reconhecer essa vulnerabilidade pode ajudar bastante na prevenção de ataques ao escrever seus contratos inteligentes, graças às correções oferecidas. Mesmo que existam várias outras vulnerabilidades de contratos inteligentes que estão à espreita para te enganar.