justCTF 2024 [teaser] — blockchain

от автора

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

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

Статья написана нашим товарищем Ратмиром Карабутом (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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *