When Smart Contracts Act Dumb PART I: PT-BR

Date: 2024-04-08 Tags: Pentest, Crypto, Web3 Category: security

Um overview completo e prático sobre vulnerabilidades Web3.

Antes de começarmos de fato a falar sobre as vulnerabilidades em um smart contract e sua exploração, existem alguns conceitos teóricos importantes para entendermos exatamente com o que estamos lidando e o que buscamos. Nisto, Irei dar uma breve explicação sobre o que é a blockchain como conhecemos hoje.

Outro detalhe que menciono desde o início é que teremos como foco a exploração do Solidity e da rede Ethereum neste paper, o que irá explorar alguns metódos específicos, como o Proof-of-Stake (PoS) e o modelo de compilação do Solidity


┌──┬─────────────────────────────────────────┐
│1 │Paper                                    │
│2 │ ├─Blockchain                            │
│3 │ │  ├─O Processo ao Confirmar a Transação│
│4 │ │  ├─Smart Contracts                    │
│5 │ │  ├─Ethereum e Solidity                │
│6 │ │  └─MINT, STAKE, BURN e Tokenização    │
│7 │ ├─Vulnerabilidades                      │
│8 │ │  ├─.getStorageAt                      │
│9 │ │  ├─TimeStamp dependence               │
│10│ │  ├─Randomness                         │
│11│ │  └─front-running attack               │
│12│ └─Conclusão                             │
└──┴─────────────────────────────────────────┘

Blockchain

“A blockchain (também conhecida como cadeia de blocos) é uma tecnologia que visa a descentralização como medida de segurança. Trata-se de uma base de registros e dados distribuídos e compartilhados, cuja função é criar um índice global para todas as transações que ocorrem em determinado sistema ou rede.”

Ou seja, de forma simples, a blockchain funciona como uma espécie de registro público e imutável — similar a um extrato — que armazena transações ocorridas ao longo do tempo. O modelo mais comum hoje é aquele em que cada novo bloco é gerado com base no hash do bloco anterior, criando uma cadeia contínua e criptograficamente vinculada.

Essa estrutura garante que todos possam verificar a autenticidade das transações, mas sem poder alterá-las, já que qualquer modificação em um bloco invalidaria todos os blocos subsequentes, tornando a fraude evidente e rejeitada pela rede.



                ┌─────┐      ┌─────┐     ┌─────┐      ┌─────┐
                │ B1  ├──┐   │ B2  ├─┐   │ B3  ├───┐  │ B4  │
                │ H:A │  └───┤ H:B │ └───┤ H:C │   └──┤ H:D │
                │ D:X │      │ D:Y │     │ D:Z │      │ D:W │
                │ P:0 │      │ P:A │     │ P:B │      │ P:C │
                └─────┘      └─────┘     └─────┘      └─────┘

No Ethereum utilizamos o Proof-of-Stake (PoS) que difere do bitcoin, que utiliza o Proof of Work (PoW) - lembra daquele velho conceito de um monte de placas de vídeo para resolver um problema matemático? É isso. O PoS utiliza uma abordagem mais econômica, mas bem interessante.

Imagine que você deseja enviar 100 reais em Ether (ETH) para uma pessoa chamada Y usando a rede Ethereum, como se fosse um PIX. Ao clicar em "confirmar" para enviar a transação, o processo não depende de um banco centralizado, mas de validadores distribuídos na blockchain. Vamos explorar como o Proof of Stake (PoS) torna isso possível, garantindo segurança e eficiência.


O Processo ao Confirmar a Transação

Quando você inicia a transação, a rede Ethereum seleciona aleatoriamente alguns validadores — participantes que depositaram uma quantidade significativa de ETH (geralmente 32 ETH) como garantia — neste momento que de fato é feito o Stake. Esses validadores têm a tarefa de verificar se tudo está em ordem: seu saldo é suficiente? A transação foi enviada no momento correto? Há conformidade com as regras da rede? Para isso, eles analisam as condições simulando a transação em um ambiente idêntico ao seu, como se estivessem usando o próprio dinheiro para testar.


                ┌─────┐      ┌─────┐      ┌─────┐      ┌─────┐
                │ B1  ├──┐   │ B2  ├──┐   │ B3  ├──┐   │ B4  │
                │ H:A │  └───┤ H:B │  └───┤ H:C │  └───┤ H:D │
                │ D:0 │      │ D:0 │      │ D:0 │      │ D:T │
                │ P:0 │      │ P:A │      │ P:B │      │ P:C │
                └─────┘      └─────┘      └─────┘      └─────┘
                       ↑             ↑            ↑
                ┌──────┐      ┌──────┐      ┌──────┐
                │ V1   │      │ V2   │      │ V3   │
                │ S:32 │      │ S:32 │      │ S:32 │
                │ T:X  │      │ T:X  │      │ T:X  │
                └──────┘      └──────┘      └──────┘

Eles executam a blockchain inteira, passo a passo, para confirmar que sua transferência é válida. Se todas as condições forem atendidas, a transação é aprovada: o valor é enviado para Y e registrado em um novo bloco, como uma entrada no "extrato" da blockchain.

No PoS, os validadores são incentivados a agir corretamente. Se a transação for validada com sucesso, eles recebem uma recompensa em ETH — um pequeno pagamento por seu trabalho, semelhante a um "cashback" pelo esforço. Porém, se houver falhas ou tentativas de fraude — como aprovar uma transação inválida —, a rede aplica o slashing. Isso significa que uma parte (ou até todo) o ETH que eles depositaram como garantia é confiscada, funcionando como uma penalidade. Essa punição pode afetar todos os validadores envolvidos no erro, garantindo que haja um custo real para ações mal-intencionadas.


Smart Contracts:

Smart contracts, termo popularizado por Szabo (1997), são códigos autoexecutáveis armazenados na blockchain que automatizam acordos sem intermediários. Por exemplo, um contrato pode liberar fundos automaticamente ao detectar uma condição, como o recebimento de um pagamento.

todo Smart Contract (na rede Ethereum) passa por um processo de desenvolvimento, e deploy, onde alguns tópicos são fundamentais para o entendimento do funcionamento deste.


Ethereum e Solidity

“O Ethereum, lançado por Buterin (2013) em seu whitepaper, é uma blockchain que expande o conceito de criptomoeda ao permitir a execução de smart contracts. Ele opera com a Ethereum Virtual Machine (EVM), uma camada computacional descentralizada que processa esses contratos, utilizando Ether para pagar taxas de gás, como explicado em Wood (2014), a documentação técnica da rede. Isso diferencia o Ethereum de redes como o Bitcoin, focadas apenas em transações. ”

Solidity é a linguagem projetada para criar smart contracts no Ethereum. Introduzida como parte do ecossistema Ethereum, é uma linguagem compilada que gera bytecode para a EVM. Segundo a documentação oficial do Solidity (Solidity Team, 2023), ela suporta estruturas como funções e eventos, permitindo lógica complexa. Um exemplo básico seria:


pragma solidity ^0.8.0;
contract SimpleStorage {
    uint public storedData;
    function set(uint x) public {
        storedData = x;
    }
}

pragma solidity ^0.8.0 -> compatibilidade de execução com versões iguais ou superiores contract -> inicia de fato o que estará incluso no contrato uint public -> uma variavel do tipo unint (unsigned integer) de visibilidade pública

Neste contrato simples, um dado é guardado em valor público, o que permite que ela também seja suscetivo a mudanças.


MINT, STAKE, BURN e Tokenização:

Da mesma forma que uma economia valoriza objetos e valores simbólicos, as criptomoedas também podem realizar este mesmo processo: criando os famigerados, NFT’s e também a tokenização de ativos.

*”Tokenizar é o ato de fragmentar um ativo em pequenas frações digitais usando o banco de dados blockchain. Essa transformação, conhecida como tokenização, dá origem a um token, que é uma representação digital de um determinado ativo, seja dinheiro, um direito ou uma propriedade[...]”

Isto significa, que imóveis, objetos, ações podem equivaler a um token! Com isto, o smart contracts chegaram para ao ramo corporativo já com uma visão de mercado bem mais afiada do que um simples trading ou investimento.

Mas todo token deve ter um ciclo de vida, correto? Início meio e fim.

Mint: é o processo de nascimento do token, onde um número decidido e gerenciado pelo Owner do contrato é aplicado e assim gerado.

Stake: É o ato de investir, de deixar o seu token “travado” em algum bloco ou ação, onde ele irá gerar rendimento passivo para o seu dono.

Burn: Da mesma forma que se um dia você quiser queimar seu dinheiro, você também pode fazer isto com o token. Por mais que pareça uma ideia de maluco, isto serve para controlar número de transações e valor que circula o mercado.


Vulnerabilidades

.getStorageAt

Antes de entendermos de fato as vulnerabilidades nos contratos Solidity, vamos primeiro explorar o funcionamento da Ethereum virtual machine (EVM), em específico, o seu gerenciamento de memória. Todo dado do Smart Contract, é guardada dentro de um array pela EVM, onde tem a sua lengthcalculada em 2^256 slots na blockchain, sendo para cada slot, possível guardar 32 bytes de dados. Um detalhe importante é que eles funcionam em cascata, isto quer dizer que a ordem dos blocos se deriva da ordem que foram declarados.

O armazenamento de dados do Smart contract é otimizado para buscar o minimo de ocupação de espaço. Caso duas variáveis estejam dentro do mesmo slot de 32 bytes, elas são guardadas no mesmo slot, em um packing.


{
uint8    => 1 byte  
uint16   => 2 bytes and so on  
uint256  => 32 bytes  
bool     => 1 byte  
address  => 20 byte  
bytes1   => 1 byte  
bytes2   => 2 bytes and so on
}

Um exemplo in-code


contract storage {
    uint public number = 1;    // 32 bytes ────────────── SLOT 0
    bool public check = true;  // 1 byte  ──────────┐
    address public member = address(0); // 20 bytes ├──── SLOT 1
    bool public check2 = false;// 1 byte  ──────────┘
    bytes32 public data;       // 32 bytes  ───────────── SLOT 2
    uint public number2 = 2;   // 32 bytes  ───────────── SLOT 3
}

Uma vez que temos ciência de como isso funciona, podemos usar outros serviços como o Ether.Js para fazermos a consulta de slots presentes no contrato.


const slot0Bytes = await ethers.provider.getStorageAt(demo.address, 0);

desta forma, temos o controle de dados mal gerenciados por arte dos smart contracts, e então podemos agra de fato seguirmos para a segunda parte:

Timestamp Dependence


┌──┬───────────────────────────────────────────────────────────────┐
│1 │pragma solidity ^0.8.0;                                        │
│2 │                                                               │
│3 │contract VulnerableGovernance {                                │
│4 │    address public founder;                                    │
│5uint public proposalCount;                                 │
│6uint public lastRewardCycle;                               │
│7 │    mapping(address => uint) public votingPower;               │
│8 │    mapping(uint => Proposal) public proposals;                │
│9 │    mapping(address => uint) public tokenBalances;             │
│10│                                                               │
│11│    struct Proposal {                                          │
│12│        address proposer;                                      │
│13uint voteCount;                                        │
│14uint endTime;                                          │
│15bool executed;                                         │
│16│    }                                                          │
│17│                                                               │
│18│    constructor() {                                            │
│19│        founder = msg.sender;                                  │
│20│        lastRewardCycle = block.timestamp;                     │
│21│        votingPower[msg.sender] = 1000;                        │
│22│    }                                                          │
│23│                                                               │
│24function createProposal() external {                       │
│25require(votingPower[msg.sender] > 0);                  │
│26│        proposalCount++;                                       │
│27│        proposals[proposalCount] = Proposal({                  │
│28│            proposer: msg.sender,                              │
│29│            voteCount: 0,                                      │
│30│            endTime: block.timestamp + 1 days,                 │
│31│            executed: false                                    │
│32│        });                                                    │
│33│    }                                                          │
│34│                                                               │
│35function vote(uint proposalId, uint votes) external {      │
│36│        Proposal storage prop = proposals[proposalId];         │
│37require(votingPower[msg.sender] >= votes);             │
│38require(block.timestamp < prop.endTime);               │
│39│        prop.voteCount += votes;                               │
│40│        votingPower[msg.sender] -= votes;                      │
│41│                                                               │
│42if (block.timestamp > lastRewardCycle + 15) {          │
│43│            distributeRewards(prop.proposer);                  │
│44│            lastRewardCycle = block.timestamp;                 │
│45│        }                                                      │
│46│    }                                                          │
│47│                                                               │
│48function distributeRewards(address recipient) internal {   │
│49uint reward = (block.timestamp - lastRewardCycle) / 15;│
│50│        tokenBalances[recipient] += reward * 1 ether;          │
│51│    }                                                          │
│52│                                                               │
│53function executeProposal(uint proposalId) external {       │
│54│        Proposal storage prop = proposals[proposalId];         │
│55require(block.timestamp > prop.endTime);               │
│56require(!prop.executed);                               │
│57if (prop.voteCount > 100) {                            │
│58│            prop.executed = true;                              │
│59│            votingPower[prop.proposer] += 200;                 │
│60│        }                                                      │
│61│    }                                                          │
│62│                                                               │
│63function addVotingPower(address voter) external payable {  │
│64│        votingPower[voter] += msg.value / 1 ether;             │
│65│    }                                                          │
│66│}                                                              │
└──┴───────────────────────────────────────────────────────────────┘

Um contrato estritamente simples, correto? Você cria uma proposal, faz a sua votação referente a ela, e por fim, é recompensado com tokens após isto pelo distributeRewards.

A variável lastRewardCycle, que registra o último ciclo de recompensas, é inicializada com o timestamp do bloco no momento da implantação do contrato e atualizada sempre que uma condição específica é atendida durante a execução da função de votação. Essa informação é pública, devido a um getter que é gerado automaticamente, o que permite que qualquer participante o value de lastRewardCycle. Além disso, o timestamp do bloco atual, representado por block.timestamp, é um dado que pela sua própria instância, referncia blockchain Ethereum, disponível para todos os nós e contratos em execução.

A vulnerabilidade surge em dois pontos: a combinação desses valores permite o tracking do contrato e a execução de eventos baseados em tempo. Como esses valores são publicamente acessíveis, o contrato fica exposto à Timestamp Dependence (Dependência de Timestamp), uma falha que explora a reliance em funções dependentes de block.timestamp. Mas em combinação, também temos neste trecho um evento de mint, a geração de tokens e distribuição destes.

O atacante aproveita uma brecha na função vote(), que dispara a criação de novos tokens (mint) a cada 15 segundos, dependendo do block.timestamp. A falha acontece por dois motivos: a função vote() ativa distributeRewards() sempre que block.timestamp > lastRewardCycle + 15, permitindo minting sem parar, e o valor de lastRewardCycle é público na blockchain, deixando o atacante calcular o momento perfeito para agir.

Tempo (s) Ação Tokens Mintados (Founder)
0 Primeiro vote() +1.0
15 Segundo vote() +1.0 → Total: 2.0
30 Terceiro vote() +1.0 → Total: 3.0
... ... ...
3600 240º vote() +1.0 → Total: 240.0

O atacante sabe que a função vote() ativa o mint a cada 15 segundos, e como lastRewardCycle é público na blockchain, ele pode explorar isso facilmente. A cada chamada, novos tokens são gerados para o founder, causando inflação descontrolada e comprometendo a governança. O resultado é catastrófico: o token perde valor, e o contrato entra em colapso.


  107.71  ┼
  102.59  ┼╮
   97.47  ┤│
   92.35  ┤│
   87.24  ┤╰╮
   82.12  ┤ │
   77.00  ┤ │
   71.88  ┤ │
   66.76  ┤ ╰─╮
   61.65  ┤   │
   56.53  ┤   │
   51.41  ┤   │╭╮
   46.29  ┤   ╰╯╰╮
   41.18  ┤      │
   36.06  ┤      │
   30.94  ┤      ╰──╮
   25.82  ┤         │
   20.70  ┤         ╰╮
   15.59  ┤          ╰──╮
   10.47  ┤             ╰─╮╭╮
    5.35  ┤               ╰╯╰─────╮
    0.23  ┤                       ╰───────────────

Com a geração de tantos tokens em um determinado período de tempo, o atacante pode simplesmente conseguir manipular o valor de mercado do ativo e até mesmo desvalorizar ele completamente - não só pelo mint, mas funções como burntambém tem esse efeito.

para quem tiver interesse e quiser simular e testar a vulnerabilidade, segue abaixo o contrato.


0x5802016Bc9976C6f63D6170157adAeA1924586c1

Randomness:

Como foi citado anteriormente, não somente investidores tem interesse em smart contracts, mas empresas também, e esta segunda vulnerabilidade eu irei abordar a respeito de um nicho um tanto quanto peculiar: Cassinos.

O uso da criptomoeda se popularizou nos jogos de azar, com a sua geração de tokens ERC20, e toda a segurança e descentralização que o ethereum pode promover. Todavia, a blockchain do Ethereum é determinística e, por isso, impõe certas dificuldades para quem opta por escrever seu próprio gerador de números pseudoaleatórios (PRNG), o que se é fundamental no ramo de jogos de apostas.

Claro que não basta utiliza a função, e sim atender a uma série de fatores que permitem que a vulnerabilidade seja explorada.

Lembra da falha mencionada anteriormente, relacionada à inconfidencialidade da função block.timestamp? Funções como block.coinbase, que representa o endereço do minerador que minerou o bloco atual; block.difficulty, que é uma medida relativa da dificuldade para encontrar o bloco; block.gaslimit, que define o limite máximo de consumo de gas para transações no bloco; block.number, que indica a altura do bloco atual — todas essas informações, por serem públicas e acessíveis, podem ser manipuladas ou previstas. Isso as torna fontes fracas de entropia para gerar números pseudoaleatórios, comprometendo a segurança de PRNGs utilizados em contratos inteligentes.

“*Entropia é aleatoriedade coletada por um sistema operacional ou aplicativo para uso em criptografia ou outros usos que requerem dados aleatórios.”

Block.blockhash

Todo bloco de ethereum na blockchain tem o seu hash de verificação. Durante o deploy do contract, a EVM permite que seja acessível e obtido tal informação por meio da block.blockhash. Ela pode receber um parâmetro de valor inteiro que será passado como a posição do bloco que será consultado, e caso não receba nada, retornará um bloco em uma cadeia onde sua idade é 256 blocos do atual.

aqui está um exemplo:


┌─┬────────────────────────────────────────────────────────────────────┐
│1│uint256 constant private FACTOR =  147218756328516856....;          │
│2│function rand(uint max) constant private returns (uint256 result){  │
│3│  uint256 factor = FACTOR * 100 / max;                              │
│4│  uint256 lastBlockNumber = block.number - 1;                       │
│5│  uint256 hashVal = uint256(block.blockhash(lastBlockNumber));      │
│6│  return uint256((uint256(hashVal) / factor)) % max;                │
│7│}                                                                   │
└─┴────────────────────────────────────────────────────────────────────┘

Alguns contratos utilizam o método block.number - 1, que acessa o blockhash do último bloco gerado. No entanto, isso representa um problema, pois trata-se de um valor estático e previsível. Um atacante pode explorar essa previsibilidade criando um contrato em paralelo e realizando uma chamada com uma mensagem interna. Isso pode resultar na criação de dois contratos utilizando exatamente o mesmo PRNG, tornando possível antecipar ou manipular os resultados.



┌──┬─────────────────────────────────────────────────────────────────────┐
│1function predictRandom(uint256 max) private view returns (uint256) { │
│2 │uint256 factor = FACTOR * 100 / max;                                 │
│3 │uint256 lastBlockNumber = block.number - 1;                          │
│4 │uint256 hashVal = uint256(blockhash(lastBlockNumber));               │
│5return uint256((hashVal / factor)) % max;                            │
│6 │}                                                                    │
│7 │                                                                     │
│8function attack() public payable returns (bool) {                    │
│9 │require (msg.value >= 0.1 ether, "Send at least 0.1 ETH");           │
│10│ uint256 predictedNumber = predictRandom(2);                         │
│11│bool success = target.playGame{value: msg.value}(predictedNumber);   │
│12return success;                                                     │
└──┴─────────────────────────────────────────────────────────────────────┘

  }

Private Seeds

Algumas empresas durante o desenvolvimento e visando as falhas já conhecidas, decidiram aumentar a entropia, e fazem uma espécie de saltpara a adicionar na função - conhecida como private seed.


┌─┬─────────────────────────────────────────────────────┐
│1│bytes32 _a = block.blockhash(block.number - pointer);│
│2│for (uint i = 31; i >= 1; i--) {                     │
│3│if ((uint8(_a[i]) >= 48) && (uint8(_a[i]) <= 57)) {  │
│4│return uint8(_a[i]) - 48;                            │
│5│  }                                                  │
│6│}                                                    │
└─┴─────────────────────────────────────────────────────┘

A variável pointer foi declarada como privada, o que significa que outros contratos não podem acessar seu valor. Após cada jogo, o número vencedor entre 1 e 9 era atribuído a essa variável, que então era usada como um deslocamento (offset) do block.number atual ao recuperar o blockhash.

Devido o seu princípio de transparência, a blockchain não deve ser usada para armazenar segredos em texto simples. Embora variáveis privadas sejam protegidas de outros contratos, é possível obter o conteúdo do armazenamento do contrato fora da cadeia (off-chain). Por exemplo, o popular cliente Ethereum Web3 possui o método de API web3.eth.getStorageAt(), que permite recuperar entradas de armazenamento nos índices especificados.


┌─┬────────────────────────────────────────────────────────────┐
│1function exploitation(address addr, uint8 number) payable { │
│2│Slotthereum target = Slotthereum(addr);                     │
│3│vuln_pointer = number;                                      │
│4│uint8 win = getNumber(getBlockHash(vuln_pointer));          │
│5target.placeBet.value(msg.value)(win, win);                 │
│6│}                                                           │
└─┴────────────────────────────────────────────────────────────┘

O que este código consegue prever e sempre acertar a aposta, uma vez que o valor resgatado pelo vuln_pointer sempre será predictado devido a exploração off-chain de um dado teoricamente privado. O código será reexecutado e o número vitorioso sempre sairá.

front-running attack

Este ataque ele aborda temas como o gas e a mempool, e podemos explicar de forma teórica junto a um adicional de um desenho:

O usuário A cria um bloco, uma palavra-passe deve ser enviada para o hash ser digerido e o bloco minerado para o minerador receber 10ETH.

O usuário B consegue realizar este evento, chamando uma função solve("senha") gastando um preço de gas na unidade de 15 gwei.

O usuário C estava monitorando a mempool, e consegue ver o evento realizado por B, e chama solve("senha") com um valor de 150 gwei.

A transação de C é resolvida na frente de B, fazendo com que ele minere o bloco e tome a recompensa.


    ┌─────┐      ┌─────┐      ┌─────┐      ┌─────┐
    │ B1  ├──┐   │ B2  ├──┐   │ B3  ├──┐   │ B4  │
    │ H:A │  └───┤ H:B │  └───┤ H:C │  └───┤ H:D │
    │ D:0 │      │ D:0 │      │ D:0 │      │ D:X'│ ← Transação do atacante
    │ P:0 │      │ P:A │      │ P:B │      │ P:C │
    └─────┘      └─────┘      └─────┘      └─────┘
           ↑             ↑            ↑
    ┌──────┐      ┌──────┐      ┌──────┐
    │ V1   │      │ V2   │      │ V3   │
    │ S:32 │      │ S:32 │      │ S:32 │
    │ T:X' │      │ T:X' │      │ T:X' │ ← Validadores processam X' primeiro
    └──────┘      └──────┘      └──────┘

    [Mempool antes de B4 ser finalizado]
    ┌────────────┐    ┌────────────┐
    │ T:X        │    │ T:X'       │
    │ Gas: 20    │    │ Gas: 50    │ ← Atacante paga mais gas
    │ (Original) │    │ (Ataque)   │
    └────────────┘    └────────────┘

Conclusão

Definitivamente não sou bom com despedidas, mas acredito que depois de tantas informações e vetores que foram exemplificados, você provavelmente irá querer voltar para saber mais.

                                         _.oo.
                 _.u[[/;:,.         .odMMMMMM'
              .o888UU[[[/;:-.  .o@P^    MMM^
             oN88888UU[[[/;::-.        dP^
            dNMMNN888UU[[[/;:--.   .o@P^
           ,MMMMMMN888UU[[/;::-. o@^
           NNMMMNN888UU[[[/~.o@P^
           888888888UU[[[/o@^-..
          oI8888UU[[[/o@P^:--..
       .@^  YUU[[[/o@^;::---..
     oMP     ^/o@P^;:::---..
  .dMMM    .o@^ ^;::---...
 dMMMMMMM@^`       `^^^^
YMMMUP^
 ^^