
В апреле пройдет второй 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 каждый день.

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

Изначально прокси слушает на локалхосте, эмулятор не сможет к нему подключиться. 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). Резонно предположить, что это и есть секретная последовательность перемешивания наперстков в этом раунде.
Понаблюдав за тем, какие числа приходят с сервера, как перемешиваются наперстки и где в итоге оказывается шарик, можно составить такую картину:
В начале каждого раунда наперстки нумеруются по порядку: 0, 1, 2.
Каждый список в массиве — это одна позиция перемешивания. Например, если первый элемент [1,0,2], это означает, что левый наперсток (id 0) встанет в середину, а средний (id 1) встанет слева.
Последний элемент в этом массиве — финальный порядок наперстков. На старте раунда нам показывают, под каким из наперстков (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, так что на этот раз выберем немного отличающийся способ.
Найдем среди ресурсов приложения картинку наперстка:

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

Сохраним картинку и пересоберем приложение с помощью 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)); } }
Резюмируем, как выглядит протокол работы приложения с сервером:
URL серверного API — https://t-trickster-jbi8aw9z.spbctf.ru.
При запуске приложения дергается PUT /start/<UUID> — тело ответа сохраняется в качестве зашифрованного флага.
При старте нового раунда дергается GET /next/<UUID>, в ответ приходит массив массивов чисел, уже знакомый нам по способу № 1 список положений наперстков. Финальное положение — последний элемент массива.
При выборе наперстка дергается 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{▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒}'
Что еще посмотреть для тренировки к заданиям
Подборка материалов для начинающих от сообщества SPbCTF: https://vk.com/@spbctf-ctf-for-beginners
Разбор прошлогоднего задания на веб Cringe Archive, чтобы разобраться с Burp Suite.
Разбор прошлогоднего задания с приложением под Андроид iZba, чтобы закрепить андроид-скиллы.
А теперь начинаем соревнование
Мы разобрали одно из демозаданий, чтобы новичкам в спортивном хакинге было проще освоиться и решиться участвовать в CTF. Если остались еще вопросы, приглашаем посмотреть страницу соревнования — там мы даем больше информации и ссылок на дополнительные материалы. А если вы уже готовы порешать задачки для победы, скорее регистрируйтесь!

