В апреле пройдет второй 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. Если остались еще вопросы, приглашаем посмотреть страницу соревнования — там мы даем больше информации и ссылок на дополнительные материалы. А если вы уже готовы порешать задачки для победы, скорее регистрируйтесь!