В апреле пройдет второй Tinkoff CTF для ИТ-специалистов. В этой статье мы рассказываем о соревновании и разбираем одно из демозаданий CTF. Статья поможет лучше подготовиться, даже если вы никогда не участвовали в подобных мероприятиях.

Если хочется узнать больше деталей, заходите на сайт: там мы рассказываем подробности и можно будет зарегистрироваться.

Если вы любите интересные задачи, приглашаем под кат.

Что такое CTF

CTF — соревнования по спортивному хакингу: как олимпиадное программирование, но в информационной безопасности. Команды получают набор заданий на криптографию, анализ скомпилированного кода, веб-уязвимости и не только — на все те направления, с которыми работают профессионалы-безопасники.

Мы сделали соревнования в двух лигах: для опытных и новичков в CTF — тех, кто не специализируется на информационной безопасности и участвует в таком формате впервые. Под новичками имеем в виду опытных разработчиков, SRE- и QA-инженеров, аналитиков и других ИТ-специалистов. 

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

Лига новичков

Лига опытных

1-е место

210 000 ₽

420 000 ₽

2-е место

180 000 ₽

300 000 ₽

3-е место

150 000 ₽

240 000 ₽

Топ-15 команд в каждой лиге могут получить легендарную игрушку — плюшевую капибару.

Задания можно проходить онлайн из любой точки или в наших центрах разработки в 16 городах России, Беларуси и Казахстана.

У нашего соревнования есть легенда. Мы приглашаем не просто побороться за призовые места, а окунуться в тессеракт — Гиперкуб, где пересекаются параллельные миры, время течет и останавливается, а каждая грань хранит тайны бесконечной Вселенной. Поэтому задания у нас тоже непростые: в них спрятаны шутки, мемы, популярные песни и фильмы.

Выигрываем миллион у робошулера

Задание. В космопоездах беда: появился обаятельный андроид-шулер, который обыгрывает всех в наперстки и обирает до нитки — robotrickster.apk. Совершите маленькую шалость — выиграйте у робошулера весь миллион монет, которые звенят в его карманах.

Осматриваемся. На примере этого демозадания мы хотим показать несколько универсальных приемов, как можно заглянуть под капот мобильного приложения под Android: посмотреть его трафик, пропатчить приложение и восстановить алгоритм на Java. Каждый из этих подходов пригодится при анализе безопасности любых мобильных приложений.

В задании нас ждет единственный файл robotrickster.apk размером 6 МБ, и по его расширению мы опознаем в нем пакет для установки Android-приложения.

Чтобы понять, что за приложение перед нами и что оно умеет, первым делом стоит его запустить. К счастью, для этого необязательно иметь телефон на Android в кладовке — есть много эмуляторов, которые позволяют запустить ОС в виртуальной машине прямо на компьютере.

Мы будем использовать официальный SDK от Гугла, в нем после запуска Android Studio эмулятор находится по More Actions → Virtual Device Manager. Перетащим файл .apk в окно виртуального девайса, где уже запустился Android, и стартуем установленное приложение.

Итак, нам предлагают обыграть наперсточника, который то и дело жульничает, мешая нам следить за перемещением наперстков. С каждым верно угаданным положением шарика наш выигрыш удваивается, и по условиям задачи нам нужно набрать миллион — то есть удвоить 20 раз подряд. Однако при любом неверном угадывании андроид-аферист забирает у нас все монетки, кроме одной.

Попробуем восстановить справедливость.

Способ 1. Смотрим сетевой трафик. Часто мобильное приложение — это просто красивая оболочка для взаимодействия с сервером по API. Получше узнать, что происходит внутри такого приложения, можно с помощью перехвата его сетевого трафика. Давайте этим займемся.

Для начала нам понадобится Burp Suite — комплект для удобного анализа HTTP-трафика (достаточно бесплатной Community-версии). Мы подробно рассказывали о работе с ним в разборе задания Cringe Archive с IT’s Tinkoff CTF 2023, рекомендуем освежить в памяти, если вы не работаете с Burp каждый день.

Посмотрите разбор Cringe Archive, если не поняли, что происходит на скриншоте
Посмотрите разбор Cringe Archive, если не поняли, что происходит на скриншоте

Burp показывает тот трафик, который проходит через его прокси-сервер. Встроенный Chromium-браузер, который идет в комплекте, настроен на этот прокси изначально, поэтому зайти поковырять какой-нибудь сайт легко. Однако эмулятор Android ходит в сеть напрямую, без прокси.

Подробная инструкция, как завернуть Android через прокси Burp, есть, например, в статье на Хабре от @W0lFreaK, но краткая выглядит так:

  1. Перевешиваем прокси на внешний интерфейс.

Изначально прокси слушает на локалхосте, эмулятор не сможет к нему подключиться. Proxy → ⚙️ Proxy Settings → Proxy listeners → Edit → выбираем любой IPv4, кроме 127.0.0.1.

2. Прописываем прокси в Android.

В расширенных настройках виртуальной Wi-Fi-сети AndroidWifi настраиваем использование прокси. IP-адрес тот, что только что выбрали в Burp, порт 8080.

3. Добавляем доверенный сертификат.

Для перехвата шифрованного трафика (HTTPS) Burp перешифровывает его своим ключом. Чтобы Android был не против, добавим сертификат Burp в доверенные.

В браузере на телефоне отправляемся на http://burp и скачиваем в углу сертификат. В настройках находим пункт CA certificate и добавляем скачанный серт в качестве CA.

4. Проверяем, что получилось.

Заходим на какой-нибудь сайт в мобильном браузере и видим трафик браузера на вкладке HTTP history.

Теперь запустим приложение с наперстками и увидим его HTTP-трафик — оно общается с сервером t-trickster-jbi8aw9z.spbctf.ru по HTTPS.

Подглядываем в мысли жулика
Подглядываем в мысли жулика

Каждый раз перед перемешиванием наперстков приложение получает с API-ручки /next/<UUID> массив списков из трех чисел (0, 1, 2). Резонно предположить, что это и есть секретная последовательность перемешивания наперстков в этом раунде.

Понаблюдав за тем, какие числа приходят с сервера, как перемешиваются наперстки и где в итоге оказывается шарик, можно составить такую картину:

  1. В начале каждого раунда наперстки нумеруются по порядку: 0, 1, 2.

  2. Каждый список в массиве — это одна позиция перемешивания. Например, если первый элемент [1,0,2], это означает, что левый наперсток (id 0) встанет в середину, а средний (id 1) встанет слева.

  3. Последний элемент в этом массиве — финальный порядок наперстков. На старте раунда нам показывают, под каким из наперстков (0, 1 или 2) находится шарик. Значит, нам достаточно найти позицию этого наперстка в конечном расположении.

Способ 2. Патчим приложение. Второй способ радикально обойти средства защиты — отредактировать приложение, выкинув их оттуда.

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

Вооружимся инструментом buildapp для пересборки Android-приложений. Ставим на линуксе:

root@vosus:/mnt/f# pip install buildapp --upgrade && buildapp_fetch_tools
Collecting buildapp
  Downloading buildapp-1.4.0-py3-none-any.whl (8.9 kB)
		<...>
downloading apktool ...
downloading completed!

И разберем им наш .apk на составные части:

root@vosus:/mnt/f# apktool decode robotrickster.apk
I: Using Apktool 2.9.3 on robotrickster.apk
I: Loading resource table...
I: Decoding file-resources...
I: Loading resource table from file: /root/.local/share/apktool/framework/1.apk
I: Decoding values */* XMLs...
I: Decoding AndroidManifest.xml with resources...
I: Regular manifest package...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
I: Copying META-INF/services directory

Инструмент создал папку robotrickster/, в которой разложены разные компоненты приложения. Наиболее интересные нам:

  • AndroidManifest.xml — описание приложения для установщика, в нем, например, перечислены запрашиваемые приложением привилегии и обработчики ссылок.

  • smali/ — папка с кодом приложения, дизассемблированным в язык байткода виртуальной машины Dalvik, которая исполняет приложения под Android. Ассемблерный код здесь можно править, меняя логику работы приложения.

  • res/ — папка со всеми ресурсами, которые использует приложение: картинками, кусками текста, анимациями, описаниями интерфейса.

Прямолинейный способ убрать подлянки — найти каждую в коде на Smali и убрать ее оттуда. Но патчинг кода приложения мы рассматривали в прошлогоднем разборе задания iZba, так что на этот раз выберем немного отличающийся способ.

Найдем среди ресурсов приложения картинку наперстка:

res/drawable/cup.png 
res/drawable/cup.png 

И продырявим его!

Сохраним картинку и пересоберем приложение с помощью buildapp:

root@vosus:/mnt/f# buildapp -o robotrickster_patched.apk -d robotrickster
Executing `apktool b robotrickster -o robotrickster_patched.apk_prealign`
Executing `zipalign -p -f -v 4 robotrickster_patched.apk_prealign robotrickster_patched.apk`
Executing `apksigner sign --ks-key-alias defkeystorealias --ks /root/.reltools/buildapp-keystore.jks robotrickster_patched.apk`
buildapp completed successfully!

Модифицированное приложение собралось успешно, ставим его на эмулятор и запускаем:

Все его фокусы видим насквозь

Способ 3. Декомпилируем и идем на сервер. Часто не удается полностью понять, как устроено приложение, просто глядя на его трафик и копаясь в ресурсах. К счастью, приложения под Android пишут на Java, а ее байткод отлично декомпилируется обратно в читаемый код.

Наша цель в этом варианте прохождения задания — сделать скрипт, который автоматически будет отгадывать наперсток с шариком, не требуя от нас взаимодействовать с приложением. Значит, нам нужно изучить, как приложение взаимодействует со своим сервером.

Для этого удобно воспользоваться декомпилятором JADX — закидываем в него файл .apk, а он все делает сам и показывает весь код приложения в виде удобного дерева классов.

Определить, где здесь код самого приложения, можно по файлу AndroidManifest.xml. Ищем в нем тег <activity android:name="com.spbctf.robotrain.MainActivity" ...> — значит, код приложения в неймспейсе com.spbctf.robotrain

Почитаем декомпилированный код и соберем из него части, которые связаны с логикой общения с сервером. Вот релевантные фрагменты:

package com.spbctf.robotrain.game.domain;

// В этом классе URL сервера
public class GameServiceFactory {
    public GameService create() {
        return (GameService) new Retrofit.Builder().baseUrl("https://t-trickster-jbi8aw9z.spbctf.ru/").addConverterFactory(GsonConverterFactory.create()).build().create(GameService.class);
    }
}

// В этом интерфейсе доступные API-методы
public interface GameService {
    // Начать новую игру, вызывается только при старте приложения
    @PUT("/start/{uuid}")
    Call<ResponseBody> start(@Path("uuid") String str);

    // Запустить новый раунд
    @GET("/next/{uuid}")
    Call<List<List<Integer>>> getNext(@Path("uuid") String str);

    // Отправить попытку угадать напёрсток
    @GET("/accept/{uuid}/{answer}")
    Call<GameState> accept(@Path("uuid") String str, @Path("answer") Integer num);
}

// Пробежимся по местам, где используется каждый метод API.
// Для этого в JADX в меню по правой кнопке на функции есть пункт Find Usage (x)

// 1. start — начало новой игры
public class FlagController {
    private byte[] flag = new byte[0];

    public FlagController(GameService gameService) {
        gameService.start(UUIDController.uuid).enqueue(new Callback<ResponseBody>() {
            @Override // retrofit2.Callback
            public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
                // Метод start присылает ‘флаг’, который позже используется в decrypt
                FlagController.this.flag = response.body().bytes();
            }
        });
    }

    // Функция decrypt берёт сохранённый флаг и расксоривает ключом, переданным в bArr
    public String decrypt(byte[] bArr) {
        byte[] bArr2 = new byte[this.flag.length];
        int i = 0;
        while (true) {
            byte[] bArr3 = this.flag;
            if (i < bArr3.length) {
                bArr2[i] = (byte) (bArr3[i] ^ bArr[i % bArr.length]);
                i++;
            } else {
                return new String(bArr2, StandardCharsets.UTF_8);
            }
        }
    }
}

// 2. next — запуск нового раунда
package com.spbctf.robotrain.game.presentation;

public class GameViewModel extends AndroidViewModel {
    public void getNextTransposition(Callback<List<List<Integer>>> callback) {
        this.blockPlayButton.setValue(true);
        this.gameService.getNext(UUIDController.uuid).enqueue(callback);
    }

    public /* synthetic */ void lambda$initGameObject$5() {
        this.gameViewModel.getNextTransposition(new Callback<List<List<Integer>>>() {
            @Override // retrofit2.Callback
            public void onResponse(Call<List<List<Integer>>> call, Response<List<List<Integer>>> response) {
                // От next приходит только список положений напёрстков,
                // который передаётся дальше в одну из 4 функций перемешивания
                int type = TypeController.getType(MainActivity.this.gameViewModel.getScore().getValue().intValue());
                if (type == 0) {
                    MainActivity.this.fastshuffled(50, response);
                } else if (type == 1) {
                    MainActivity.this.joinShuffle(response);
                } else if (type == 2) {
                    MainActivity.this.closedShuffle(response);
                } else {
                    MainActivity.this.fastshuffled(500, response);
                }
            }
        });
    }
}

// 3. accept — отправка угаданного напёрстка на сервер
public class GameViewModel extends AndroidViewModel {
    public void clickByCup(int i) {
        this.gameService.accept(UUIDController.uuid, Integer.valueOf(this.currentPosition.getValue().lastIndexOf(Integer.valueOf(i)))).enqueue(new Callback<GameState>() {
            @Override // retrofit2.Callback
            public void onResponse(Call<GameState> call, Response<GameState> response) {
                // От accept приходит ‘score’ — сколько раз подряд угадали
                // и ‘key’ — пока не набрался миллион, в нём null
                GameViewModel.this.score.setValue(Integer.valueOf(response.body().getScore()));
                GameViewModel.this.update(response.body().getKey());
            }
        });
    }

    // А вот мы и добрались до вывода флага на экран
    public void update(byte[] bArr) {
        if (bArr == null || bArr.length == 0) {
            return;
        }
        // Если в ‘key’ пришёл не null, расшифровываем этим ключом флаг
        this.blockPlayButton.setValue(true);
        this.flag.setValue("Шулер-андроид повержен!\nЕго последние слова:\n" + this.flagController.decrypt(bArr));
    }
}

Резюмируем, как выглядит протокол работы приложения с сервером:

  1. URL серверного API — https://t-trickster-jbi8aw9z.spbctf.ru.

  2. При запуске приложения дергается PUT /start/<UUID> — тело ответа сохраняется в качестве зашифрованного флага.

  3. При старте нового раунда дергается GET /next/<UUID>, в ответ приходит массив массивов чисел, уже знакомый нам по способу № 1 список положений наперстков. Финальное положение — последний элемент массива.

  4. При выборе наперстка дергается GET /accept/<UUID>/<POSITION>*, приходит объект с полями score и key. Если key не пустой, мы выиграли. Расксориваем флаг этим ключом.

Напишем скрипт на питоне, который сам пообщается с сервером под видом приложения:

#!/usr/bin/python3
import requests, uuid

API_URL = "https://t-trickster-jbi8aw9z.spbctf.ru"

# 1. 'start' the game
gameid = str(uuid.uuid4())
result = requests.put(f"{API_URL}/start/{gameid}")
encryptedFlag = result.content
print(f"Game started, got encrypted flag: {encryptedFlag}")

# The ball starts under the middle cup (0, _1_, 2)
ballIsUnderCupNo = 1

while True:
    # 2. 'next' round request
    result = requests.get(f"{API_URL}/next/{gameid}")
    shuffles = result.json()
    lastShuffle = shuffles[-1]
    
    # Find ball cup's position in the last shuffle
    ballPosition = lastShuffle.index(ballIsUnderCupNo)
    # Update starting ball cup for the next round
    ballIsUnderCupNo = ballPosition
    
    # 3. 'accept' cup guess
    result = requests.get(f"{API_URL}/accept/{gameid}/{ballPosition}")
    result = result.json()
    print(result)
    
    if result['key'] is not None:
        # 4. Got non-null key, decrypt the flag!
        key = result['key']
        encryptedFlag = bytearray(encryptedFlag)
        for i, c in enumerate(encryptedFlag):
            encryptedFlag[i] ^= key[i % len(key)] & 0xFF  # Convert negative values to unsigned
        decryptedFlag = bytes(encryptedFlag)
        print(f"Flag: {decryptedFlag}")
        break

Запускаем:

root@vosus:/mnt/f# ./trick.py
Game started, got encrypted flag: b'\x80\x00\x88\xb1\xc9\xdbf\xda\xc4\x17\xa3\xa4\xc5\x988\xdc\x98\x06\x8e\x88\xc1\xca$\x8b\xc7\x00\x94\xb2\xc1\xf6"\xd0\xc7\r\xa3\xb5\xc0\xcc7\xd3\xc1<\x98\xe7\xc5\xc7+'
{'score': 1, 'key': None}
{'score': 2, 'key': None}
{'score': 3, 'key': None}
{'score': 4, 'key': None}
{'score': 5, 'key': None}
{'score': 6, 'key': None}
{'score': 7, 'key': None}
{'score': 8, 'key': None}
{'score': 9, 'key': None}
{'score': 10, 'key': None}
{'score': 11, 'key': None}
{'score': 12, 'key': None}
{'score': 13, 'key': None}
{'score': 14, 'key': None}
{'score': 15, 'key': None}
{'score': 16, 'key': None}
{'score': 17, 'key': None}
{'score': 18, 'key': None}
{'score': 19, 'key': None}
{'score': 20, 'key': [-12, 99, -4, -41, -78, -87, 86, -72]}
Flag: b'tctf{▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒}'

Что еще посмотреть для тренировки к заданиям

  1. Подборка материалов для начинающих от сообщества SPbCTF: https://vk.com/@spbctf-ctf-for-beginners

  2. Разбор прошлогоднего задания на веб Cringe Archive, чтобы разобраться с Burp Suite.

  3. Разбор прошлогоднего задания с приложением под Андроид iZba, чтобы закрепить андроид-скиллы.

А теперь начинаем соревнование

Мы разобрали одно из демозаданий, чтобы новичкам в спортивном хакинге было проще освоиться и решиться участвовать в CTF. Если остались еще вопросы, приглашаем посмотреть страницу соревнования — там мы даем больше информации и ссылок на дополнительные материалы. А если вы уже готовы порешать задачки для победы, скорее регистрируйтесь!

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