### justCTF 2024 [teaser] — blockchain
![](https://habrastorage.org/getpro/habr/upload_files/8e2/188/184/8e21881844d02b9524f7cc3801b17eff.png)
Rustam Guseinov
Chairman of the cooperative RAD KOP
The article was written by our friend Ratmir Karabut (https://ratmirkarabut.com), who trains the RAD COP team as part of the developing CTF practice, specifically for the cooperative.
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 in 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.
![](https://habrastorage.org/getpro/habr/upload_files/a45/fd3/b57/a45fd3b57296c749e2629b3a9ff6a28f.png)
Rustam Guseinov
Chairman of the cooperative RAD KOP
When my co-op comrades and I first visited Ratmir in January 2022, I was struck by the systematic nature of his thinking. I remember his lecture on the book György Pólya “How to solve a problem”, and a demonstration of how similar the thinking of a hacker is to the thinking of a mathematician solving a problem. I highly recommend reading it, because the methodology presented is easy to master and really “puts your brain in place”, here are a couple of quotes:
“It is foolish to answer a question you do not understand. It is not fun to work for a goal you do not want. Such foolish and unfunny things often happen both in and out of school, but a teacher should try to prevent them in his class. The student must understand the problem. But not only understand it; he must want to solve it. If the student lacks understanding of the problem or interest in it, it is not always his fault. The problem must be skillfully chosen, it must not be too difficult and not too easy, it must be natural and interesting, and some time must be devoted to its natural and interesting interpretation.”
“The path from understanding the problem statement to imagining a plan for solving it can be long and winding. Indeed, the most important step toward solving a problem is to come up with an idea for a plan. This idea may emerge gradually. Or it may suddenly appear in an instant, after seemingly unsuccessful attempts and prolonged doubts. Then we call it a “brilliant idea.”
The best thing a teacher can do for a student is to suggest a brilliant idea through gentle help.”
![György Pólya and his book "How to solve the problem" György Pólya and his book "How to solve the problem"](https://habrastorage.org/getpro/habr/upload_files/2e0/cc1/3a7/2e0cc13a7f4aed3543381ef5e52f7963.png)
#### [The Otter Scrolls] – easy (246 points, 33 solves)
Contract source:
https://2024.justctf.team/challenges/11
module challenge::theotterscrolls {
// ---------------------------------------------------
// DEPENDENCIES
// ---------------------------------------------------
use sui::table::{Self, Table};
use std::string::{Self, String};
use std::debug;
// ---------------------------------------------------
// STRUCTS
// ---------------------------------------------------
public struct Spellbook has key {
id: UID,
casted: bool,
spells: Table<u8, vector<String>>
}
// ---------------------------------------------------
// FUNCTIONS
// ---------------------------------------------------
//The spell consists of five magic words, which have to be read in the correct order!
fun init(ctx: &mut TxContext) {
let mut all_words = table::new(ctx);
let fire = vector[
string::utf8(b"Blast"),
string::utf8(b"Inferno"),
string::utf8(b"Pyre"),
string::utf8(b"Fenix"),
string::utf8(b"Ember")
];
let wind = vector[
string::utf8(b"Zephyr"),
string::utf8(b"Swirl"),
string::utf8(b"Breeze"),
string::utf8(b"Gust"),
string::utf8(b"Sigil")
];
let water = vector[
string::utf8(b"Aquarius"),
string::utf8(b"Mistwalker"),
string::utf8(b"Waves"),
string::utf8(b"Call"),
string::utf8(b"Storm")
];
let earth = vector[
string::utf8(b"Tremor"),
string::utf8(b"Stoneheart"),
string::utf8(b"Grip"),
string::utf8(b"Granite"),
string::utf8(b"Mudslide")
];
let power = vector[
string::utf8(b"Alakazam"),
string::utf8(b"Hocus"),
string::utf8(b"Pocus"),
string::utf8(b"Wazzup"),
string::utf8(b"Wrath")
];
table::add(&mut all_words, 0, fire);
table::add(&mut all_words, 1, wind);
table::add(&mut all_words, 2, water);
table::add(&mut all_words, 3, earth);
table::add(&mut all_words, 4, power);
let spellbook = Spellbook {
id: object::new(ctx),
casted: false,
spells: all_words
};
transfer::share_object(spellbook);
}
public fun cast_spell(spell_sequence: vector<u64>, book: &mut Spellbook) {
let fire = table::remove(&mut book.spells, 0);
let wind = table::remove(&mut book.spells, 1);
let water = table::remove(&mut book.spells, 2);
let earth = table::remove(&mut book.spells, 3);
let power = table::remove(&mut book.spells, 4);
let fire_word_id = *vector::borrow(&spell_sequence, 0);
let wind_word_id = *vector::borrow(&spell_sequence, 1);
let water_word_id = *vector::borrow(&spell_sequence, 2);
let earth_word_id = *vector::borrow(&spell_sequence, 3);
let power_word_id = *vector::borrow(&spell_sequence, 4);
let fire_word = vector::borrow(&fire, fire_word_id);
let wind_word = vector::borrow(&wind, wind_word_id);
let water_word = vector::borrow(&water, water_word_id);
let earth_word = vector::borrow(&earth, earth_word_id);
let power_word = vector::borrow(&power, power_word_id);
if (fire_word == string::utf8(b"Inferno")) {
if (wind_word == string::utf8(b"Zephyr")) {
if (water_word == string::utf8(b"Call")) {
if (earth_word == string::utf8(b"Granite")) {
if (power_word == string::utf8(b"Wazzup")) {
book.casted = true;
}
}
}
}
}
}
public fun check_if_spell_casted(book: &Spellbook): bool {
let casted = book.casted;
assert!(casted == true, 1337);
casted
}
}
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 of `solve()` in the issued `sources/framework-solve/solve/sources/solve.move`:
```rust
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 that, you need to write the correct address of the challenge contract in `sources/framework-solve/dependency/Move.toml` (you can get it by directly contacting the service at the address given in the condition using `nc tos.nc.jsctf.pro 31337`):
```toml
...
[addresses]
admin = "0xfccc9a421bbb13c1a66a1aa98f0ad75029ede94857779c6915b44f94068b921e"
challenge = "542fe29e11d10314d3330e060c64f8fb9cd341981279432b03b2bd51cf5d489b"
```
After that, run `HOST=tos.nc.jctf.pro ./runclient.sh` (and, of course, install [Sui] (https://docs.sui.io/guides/developer/getting-started/sui-install#install-sui-binaries-from-source)), we receive the first flag from the service.
#### [Dark BrOTTERhood] – medium (275 points, 25 solves)
Contract source:
https://2024.justctf.team/challenges/13
module challenge::Otter {
// ---------------------------------------------------
// DEPENDENCIES
// ---------------------------------------------------
use sui::coin::{Self, Coin};
use sui::balance::{Self, Supply};
use sui::url;
use sui::random::{Self, Random};
use sui::table::{Self, Table};
// ---------------------------------------------------
// CONST
// ---------------------------------------------------
const NEW: u64 = 1;
const WON: u64 = 2;
const FINISHED: u64 = 3;
const WRONG_AMOUNT: u64 = 1337;
const BETTER_BRING_A_KNIFE_TO_A_GUNFIGHT: u64 = 1338;
const WRONG_STATE: u64 = 1339;
const ALREADY_REGISTERED: u64 = 1340;
const NOT_REGISTERED: u64 = 1341;
const TOO_MUCH_MONSTERS: u64 = 1342;
const NOT_SOLVED: u64 = 1343;
const QUEST_LIMIT: u64 = 25;
// ---------------------------------------------------
// STRUCTS
// ---------------------------------------------------
public struct OTTER has drop {}
public struct OsecSuply<phantom CoinType> has key {
id: UID,
supply: Supply<CoinType>
}
public struct Vault<phantom CoinType> has key {
id: UID,
cash: Coin<CoinType>
}
public struct Monster has store {
fight_status: u64,
reward: u8,
power: u8
}
public struct QuestBoard has key, store {
id: UID,
quests: vector<Monster>,
players: Table<address, bool>
}
public struct Flag has key, store {
id: UID,
user: address,
flag: bool
}
public struct Player has key, store {
id: UID,
user: address,
coins: Coin<OTTER>,
power: u8
}
// ---------------------------------------------------
// MINT CASH
// ---------------------------------------------------
fun init(witness: OTTER, ctx: &mut TxContext) {
let (mut treasury, metadata) = coin::create_currency(
witness, 9, b"OSEC", b"Osec", b"Otter ca$h", option::some(url::new_unsafe_from_bytes(b"https://osec.io/")), ctx
);
transfer::public_freeze_object(metadata);
let pool_liquidity = coin::mint<OTTER>(&mut treasury, 50000, ctx);
let vault = Vault<OTTER> {
id: object::new(ctx),
cash: pool_liquidity
};
let supply = coin::treasury_into_supply(treasury);
let osec_supply = OsecSuply<OTTER> {
id: object::new(ctx),
supply
};
transfer::transfer(osec_supply, tx_context::sender(ctx));
transfer::share_object(QuestBoard {
id: object::new(ctx),
quests: vector::empty(),
players: table::new(ctx)
});
transfer::share_object(vault);
}
public fun mint(sup: &mut OsecSuply<OTTER>, amount: u64, ctx: &mut TxContext): Coin<OTTER> {
let osecBalance = balance::increase_supply(&mut sup.supply, amount);
coin::from_balance(osecBalance, ctx)
}
public entry fun mint_to(sup: &mut OsecSuply<OTTER>, amount: u64, to: address, ctx: &mut TxContext) {
let osec = mint(sup, amount, ctx);
transfer::public_transfer(osec, to);
}
public fun burn(sup: &mut OsecSuply<OTTER>, c: Coin<OTTER>): u64 {
balance::decrease_supply(&mut sup.supply, coin::into_balance(c))
}
// ---------------------------------------------------
// REGISTER
// ---------------------------------------------------
public fun register(sup: &mut OsecSuply<OTTER>, board: &mut QuestBoard, player: address, ctx: &mut TxContext) {
assert!(!table::contains(&board.players, player), ALREADY_REGISTERED);
table::add(&mut board.players, player, false);
transfer::transfer(Player {
id: object::new(ctx),
user: tx_context::sender(ctx),
coins: mint(sup, 137, ctx),
power: 10
}, player);
}
// ---------------------------------------------------
// SHOP
// ---------------------------------------------------
#[allow(lint(self_transfer))]
public fun buy_flag(vault: &mut Vault<OTTER>, player: &mut Player, ctx: &mut TxContext): Flag {
assert!(coin::value(&player.coins) >= 1337, WRONG_AMOUNT);
let coins = coin::split(&mut player.coins, 1337, ctx);
coin::join(&mut vault.cash, coins);
Flag {
id: object::new(ctx),
user: tx_context::sender(ctx),
flag: true
}
}
public fun buy_sword(vault: &mut Vault<OTTER>, player: &mut Player, ctx: &mut TxContext) {
assert!(coin::value(&player.coins) >= 137, WRONG_AMOUNT);
let coins = coin::split(&mut player.coins, 137, ctx);
coin::join(&mut vault.cash, coins);
player.power = player.power + 100;
}
// ---------------------------------------------------
// ADVENTURE TIME
// ---------------------------------------------------
#[allow(lint(public_random))]
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;
}
public fun return_home(board: &mut QuestBoard, quest_id: u64) {
let quest_to_finish = vector::borrow_mut(&mut board.quests, quest_id);
assert!(quest_to_finish.fight_status == WON, WRONG_STATE);
quest_to_finish.fight_status = FINISHED;
}
#[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);
}
// ---------------------------------------------------
// PROVE SOLUTION
// ---------------------------------------------------
public fun prove(board: &mut QuestBoard, flag: Flag) {
let Flag {
id,
user,
flag
} = flag;
object::delete(id);
assert!(table::contains(&board.players, user), NOT_REGISTERED);
assert!(flag, NOT_SOLVED);
*table::borrow_mut(&mut board.players, user) = true;
}
// ---------------------------------------------------
// CHECK WINNER
// ---------------------------------------------------
public fun check_winner(board: &QuestBoard, player: address) {
assert!(*table::borrow(&board.players, player) == true, NOT_SOLVED);
}
}
##### Analysis
Skimming through 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 a “monster” with random strength (from 13 to 37) and reward (from 13 to 73), as well as the `NEW` state, to the `board.quests` vector. `fight_monster()` allows us to defeat a monster from the quest vector if it is in the `NEW` state and its strength is less than the player's strength, resetting the player's strength to 10 in this case and changing the quest state to `WON`.
To get the strength needed to win, you will have to call `buy_sword()` – the “sword” will increase the strength by 100 (which guarantees that the condition from `fight_monster()` is met), 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 first “fight” will make it impossible to continue the game according to its supposed logic – according to the `buy_flag` function, it is clear that we will need 1337 coins to buy a flag.
The remaining game functions are `return_home()`, which simply toggles the state of the selected quest from `WON` to `FINISHED`, and `get_the_reward()`, which checks the `FINISHED` state and gives the player a reward. This is the one we should take a closer look at:
```rust
#[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 between the quest being _checked_ and the quest being _removed_ from the vector; although we are allowed to specify the index of the quest we want to get a reward for, and it is its state that needs to be set to `FINISHED`, it is not the vector itself that is removed from the vector, but the last element of the vector – via `vector::pop_back()` ([Vector – The Move Book](https://move-language.github.io/move/vector.html#operations))!
##### Exploitation
It turns out that, since nothing prevents us from filling the vector with an arbitrary (up to `QUEST_LIMIT` – 25) number of quests, we can demand two monsters from the game, defeat the first one by buying a sword – which is allowed by the initial conditions – thereby transferring the state of quest 0 to `WON`, then to `FINISHED` using `return_home()`, then, specifying its index in `get_the_reward()`, get the reward for the _second_ – the last monster in the vector, while leaving the first one in the `FINISHED` state. By calling `find_a_monster()` and `get_the_reward()` the required – unlimited – number of times after this, we can be guaranteed to earn a flag in about a hundred repetitions.
Let's add the solution to `solve()`:
```rust
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 that, similarly to the first task, we receive and write the contract address in `sources/framework-solve/dependency/Move.toml` and, having launched the client, we receive the second flag.
#### [World of Ottercraft] – hard (271 points, 26 solves)
Contact source:
https://2024.justctf.team/challenges/12
module challenge::Otter {
// ---------------------------------------------------
// DEPENDENCIES
// ---------------------------------------------------
use sui::coin::{Self, Coin};
use sui::balance::{Self, Balance, Supply};
use sui::table::{Self, Table};
use sui::url;
// ---------------------------------------------------
// CONST
// ---------------------------------------------------
// STATUSES
const PREPARE_FOR_TROUBLE: u64 = 1;
const ON_ADVENTURE: u64 = 2;
const RESTING: u64 = 3;
const SHOPPING: u64 = 4;
const FINISHED: u64 = 5;
// ERROR CODES
const WRONG_AMOUNT: u64 = 1337;
const BETTER_GET_EQUIPPED: u64 = 1338;
const WRONG_PLAYER_STATE: u64 = 1339;
const ALREADY_REGISTERED: u64 = 1340;
const TOO_MANY_MONSTERS: u64 = 1341;
const BUY_SOMETHING: u64 = 1342;
const NO_SUCH_PLAYER: u64 = 1343;
const NOT_SOLVED: u64 = 1344;
// LIMITS
const QUEST_LIMIT: u64 = 25;
// ---------------------------------------------------
// STRUCTS
// ---------------------------------------------------
public struct OTTER has drop {}
public struct OsecSuply<phantom CoinType> has key {
id: UID,
supply: Supply<CoinType>
}
public struct Vault<phantom CoinType> has key {
id: UID,
cash: Coin<CoinType>
}
public struct Monster has store {
reward: u64,
power: u64
}
public struct QuestBoard has key, store {
id: UID,
quests: vector<Monster>,
players: Table<address, bool> //<player_address, win_status>
}
public struct Player has key, store {
id: UID,
user: address,
power: u64,
status: u64,
quest_index: u64,
wallet: Balance<OTTER>
}
public struct TawernTicket {
total: u64,
flag_bought: bool
}
// ---------------------------------------------------
// MINT CASH
// ---------------------------------------------------
fun init(witness: OTTER, ctx: &mut TxContext) {
let (mut treasury, metadata) = coin::create_currency(witness, 9, b"OSEC", b"Osec", b"Otter ca$h", option::some(url::new_unsafe_from_bytes(b"https://osec.io/")), ctx);
transfer::public_freeze_object(metadata);
let pool_liquidity = coin::mint<OTTER>(&mut treasury, 50000, ctx);
let vault = Vault<OTTER> {
id: object::new(ctx),
cash: pool_liquidity
};
let supply = coin::treasury_into_supply(treasury);
let osec_supply = OsecSuply {
id: object::new(ctx),
supply
};
transfer::transfer(osec_supply, tx_context::sender(ctx));
transfer::share_object(QuestBoard {
id: object::new(ctx),
quests: vector::empty(),
players: table::new(ctx)
});
transfer::share_object(vault);
}
public fun mint(sup: &mut OsecSuply<OTTER>, amount: u64, ctx: &mut TxContext): Coin<OTTER> {
let osecBalance = balance::increase_supply(&mut sup.supply, amount);
coin::from_balance(osecBalance, ctx)
}
public entry fun mint_to(sup: &mut OsecSuply<OTTER>, amount: u64, to: address, ctx: &mut TxContext) {
let osec = mint(sup, amount, ctx);
transfer::public_transfer(osec, to);
}
public fun burn(sup: &mut OsecSuply<OTTER>, c: Coin<OTTER>): u64 {
balance::decrease_supply(&mut sup.supply, coin::into_balance(c))
}
// ---------------------------------------------------
// REGISTER - ADMIN FUNCTION
// ---------------------------------------------------
public fun register(_: &mut OsecSuply<OTTER>, board: &mut QuestBoard, vault: &mut Vault<OTTER>, player: address, ctx: &mut TxContext) {
assert!(!table::contains(&board.players, player), ALREADY_REGISTERED);
let new_cash = coin::into_balance(coin::split(&mut vault.cash, 250, ctx));
let new_player_obj = Player {
id: object::new(ctx),
user: player,
power: 10,
status: RESTING,
quest_index: 0,
wallet: new_cash
};
table::add(&mut board.players, player, false);
transfer::transfer(new_player_obj, player);
}
public fun check_winner(board: &QuestBoard, player: address) {
assert!(table::contains(&board.players, player), NO_SUCH_PLAYER);
assert!(table::borrow(&board.players, player) == true, NOT_SOLVED);
}
// ---------------------------------------------------
// TAVERN
// ---------------------------------------------------
public fun enter_tavern(player: &mut Player): TawernTicket {
assert!(player.status == RESTING, WRONG_PLAYER_STATE);
player.status = SHOPPING;
TawernTicket{ total: 0, flag_bought: false }
}
public fun buy_flag(ticket: &mut TawernTicket, player: &mut Player) {
assert!(player.status == SHOPPING, WRONG_PLAYER_STATE);
ticket.total = ticket.total + 537;
ticket.flag_bought = true;
}
public fun buy_sword(player: &mut Player, ticket: &mut TawernTicket) {
assert!(player.status == SHOPPING, WRONG_PLAYER_STATE);
player.power = player.power + 213;
ticket.total = ticket.total + 140;
}
public fun buy_shield(player: &mut Player, ticket: &mut TawernTicket) {
assert!(player.status == SHOPPING, WRONG_PLAYER_STATE);
player.power = player.power + 7;
ticket.total = ticket.total + 20;
}
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;
}
public fun checkout(ticket: TawernTicket, player: &mut Player, ctx: &mut TxContext, vault: &mut Vault<OTTER>, board: &mut QuestBoard) {
let TawernTicket{ total, flag_bought } = ticket;
assert!(total > 0, BUY_SOMETHING);
assert!(balance::value<OTTER>(&player.wallet) >= total, WRONG_AMOUNT);
let balance = balance::split(&mut player.wallet, total);
let coins = coin::from_balance(balance, ctx);
coin::join(&mut vault.cash, coins);
if (flag_bought == true) {
let flag = table::borrow_mut(&mut board.players, tx_context::sender(ctx));
*flag = true;
std::debug::print(&std::string::utf8(b"$$$$$$$$$$$$$$$$$$$$$$$$$ FLAG BOUGHT $$$$$$$$$$$$$$$$$$$$$$$$$")); //debug
};
player.status = RESTING;
}
// ---------------------------------------------------
// ADVENTURE TIME
// ---------------------------------------------------
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;
}
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;
}
}
##### Analysis
The game in the third contract is similar to the previous one, but is obviously more complex. Now the state of the player himself is tracked, not the monster, and there are five states – `PREPARE_FOR_TROUBLE`, `ON_ADVENTURE`, `RESTING`, `SHOPPING` and `FINISHED`. Next, now to buy a power (and a flag) you will have to enter the “tavern” by calling the function `enter_tavern()` – this will switch the player's state from the required (and initial) `RESTING` to `SHOPPING`, which is checked by all the buying functions, and will return a variable of the `TawernTicket` type, which, according to the rules of Move, must be consumed inside the calling contract – this can only be done using the `checkout()` function. Thus, the player fills the “basket” of purchases and leaves the “tavern”, again switching the state to `RESTING`. From `register()` it is clear that this time we start with 250 coins.
There are now four buy functions – `buy_flag()` sets the corresponding `ticket` flag (which is later checked in `checkout()`, leading to a win) and increases the amount by 537 coins, `buy_sword()`, `buy_shield()` and `buy_power_of_friendship()` increase the player's power by 213, 7 and 9000 for 140, 20 and 190 coins respectively.
Here you can immediately notice that **power increases instantly when buying**, without requiring a check to see if the amount needed for the purchase is actually on the player's account – such a check only occurs in `checkout()` itself, as does the check to see if the total cost is above 0 – you can't leave the tavern without buying something.
Also interesting is that, unlike the purchase functions, `checkout()` does not check the player's state at all – apparently, **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 look at them in more detail:
```rust
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()` does not randomly assign monsters this time, but instead gives out rewards and power based on how many monsters are already in the vector. The state is switched to `PREPARE_FOR_TROUBLE`, but interestingly, the check in the function does not require a specific state, and only allows players in the `SHOPPING`, `FINISHED` and `ON_ADVENTURE` states. The detail seems innocent at first glance, but let's remember – **you can call `find_a_monster()` an unlimited number of times in a row**.
```rust
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 state for _mismatch_, but this time there are no options other than `BRING_IT_ON` – so the function can only be called after `find_a_monster()`, which seems correct. As in Dark BrOTTERhood, the player can choose an arbitrary monster from the vector to measure strength with it. In case of victory, the state goes to `ON_ADVENTURE`, the player's strength is reset to 10, the monster's strength is set to 0, `player.quest_index` (initially 0) – to the monster's index.
```rust
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 checks the state correctly, if clumsily, and can apparently only be called after `bring_it_on()` – the status switches from `ON_ADVENTURE` to `FINISHED` if the monster strength at the index in `player.quest_index` is zero.
```rust
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 the quest reward without leaving the tavern**, i.e. in the `SHOPPING` status. Unlike Dark BrOTTERhood, however, it seems that the defeated monster is correctly removed from the vector – in any case, `player.quest_index` is used, and you cannot specify an arbitrary index. The player receives coins and goes to the initial `RESTING`.
##### Exploitation
First, let's summarize the bugs found:
1. Player's 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) directly from the tavern
Having turned these four points over in our heads this way and that, we realize, firstly – **a reward for a monster received in a tavern can be used for a purchase right away**! Indeed, if a reward is received from a tavern with a status switch to `RESTING`, and `checkout()` does not check the status at all, you can get a `TawernTicket` and pay for it, in fact increasing, not decreasing, the amount on the account – you cannot do without a purchase, but to fulfill this condition we can buy cheap shields, which the received reward will always outweigh. Since `get_the_reward()` uses the immutable `player.quest_index`, and also – secondly – **doesn't perform any checks on the state of the quest itself** (since the monster's strength is only taken into account in `bring_it_on()` and `return_home()`) – then it would be enough for us to build a queue of monsters to be slaughtered, obediently moving towards our (preferably zero) index with each new call to `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 honestly deal with one monster in order to correctly bypass the states the first time – unfortunately, we cannot use the first bug for 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 to buy a sword and collect a full contingent of tricked monsters during the first pass through `find_a_monster()`:
```rust
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 (perhaps with the exception of the restriction on underuse of `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 scored here – and besides, it will be good preparation for the next MoveCTF.
![](https://habrastorage.org/getpro/habr/upload_files/a45/fd3/b57/a45fd3b57296c749e2629b3a9ff6a28f.png)
Rustam Guseinov
Chairman of the cooperative RAD KOP
So, using the example of three relatively simple tasks, we see how the “scary blockchain”, with some minimal development, turns from an unknown and complex story into a completely solvable task. And we additionally understand the value of “going beyond” and “entering uncharted territories”, which are the essence of hacker thinking, and which, when consistently applied to different areas of life, can lead to interesting results…. By the way, we will talk about this in other materials =)