В апреле пройдет второй 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, если не поняли, что происходит на скриншоте

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 

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

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