Pull to refresh

Расследование по делу одного неизвестного архива

Reading time13 min
Views17K
Переезд. Новый город. Поиски работы. Даже для IT специалиста это может занять длительное время. Череда собеседований, которые, в общем, очень похожи друг на друга. И как оно обычно бывает, когда ты уже нашел работу, через некоторое время, объявляется одна интересная контора.

Было сложно понять, чем она конкретно занимается, однако, область ее интересов – исследование чужого ПО. Звучит интригующе, хотя когда понимаешь, что это вроде бы не вендор, который выпускает софт для кибербезопасности, на секунду останавливаешься и начинаешь чесать репу.



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

Что мы имеем: архив с расширением ".apk". Под спойлер поместил само задание, чтобы оно не индексировалось поисковиками: вдруг ребятам не понравится, что я поместил решение на Хабр?

Само задание
В APK находится функционал по генерации сигнатуры для ассоциативного массива.
Постарайтесь получить подпись для следующего набора данных:

{
     "user" : "LeetD3vM4st3R",
     "password": "__s33cr$$tV4lu3__",
     "hash": "34765983265937875692356935636464"
}


Закатываем рукава


Сказано, что в архиве находится функционал подписания ассоциативного массива. По расширению файла сразу понимаем, что имеем дело с приложением, написанным под Android. Первым делом распаковываем архив. По сути, это обычный ZIP архив, и любой архиватор справится с ним влегкую. Я воспользовался утилитой apktool, и, как оказалось, нечаянно, обошел пару граблей. Да, бывает и такое (обычно же наоборот, да?). Заклинание довольно простое:

apktool d task.zip

Оказывается, код и ресурсы в apk файле хранятся также упакованными в отдельные бинари, и для их извлечения понадобится иной софт. apktool неявно достал байт-код классов, ресурсы, и разложил это все в естественной файловой иерархии. Можно приступать.

├── AndroidManifest.xml
├── apktool.yml
├── lib
│   └── arm64-v8a
├── original
│   ├── AndroidManifest.xml
│   └── META-INF
├── res
│   ├── anim
│   ├── color
│   ├── drawable
│   ├── layout
│   ├── layout-watch-v20
│   ├── mipmap-anydpi-v26
│   ├── values
│   └── values-af
├── smali
│   ├── android
│   ├── butterknife
│   ├── com
│   ├── net
│   └── org
└── unknown
    └── org

Мы видим подобную иерархию (оставил ее упрощенный вариант) и пытаемся понять, с чего же начать. Стоит отметить, что я все-таки когда-то написал парочку небольших приложений под Android, поэтому суть части директорий и, вообще, принципы устройства Android приложений, мне примерно ясны.

Для начала, решаю просто «прогуляться» по файлам. Открываю AndroidManifest.xml и начинаю многозначительно читать. Мое внимание привлекает странный атрибут

android:supportsRtl="true"

Оказывается, он отвечает за поддержку языков с письмом «справа-налево» в приложении. Начинаем, напрягаться. Не к добру.

Дальше мое взгляд цепляется за папку «unknown». Под ней прячется иерархия вида: org.apache.commons.codec.language.bm и огромное количество текстовых файлов с неясным содержанием. Гуглим полное имя пакета и выясняется, что тут хранится, что-то связанное с алгоритмом поиска слов, фонетически похожих на заданное. Признаться честно, тут я стал напрягаться сильнее. Немного потыкав по директориям, я, собственно, нашел сам код, и тут началось самое интересное. Меня встретил не привычный Java байт-код, c которым я когда-то успел поиграться, а нечто иное. Очень похожее, но иное.

Как оказалось, у Android своя виртуальная машина – Dalvik. И, как у каждой уважаемой виртуальной машины, байт-код у нее свой. Кажется, при первой попытке решить эту задачу, именно на этой грустной ноте, я и объявил антракт, поклонился, опустил занавес и бросил это все месяца на 4 до тех пор, пока любопытство меня не доконало окончательно.

Закатываем рукава [2]


«А нельзя вот, чтобы все было полегче?» – вот тот вопрос, который я задал себе, когда приступил к задаче во второй раз. Я начал поиски в интернете на предмет декомпилятора из smali в Java. Увидел только то, что однозначно этот процесс выполнить невозможно. Немного нахмурившись, зашел на Github и вбил в поисковую строку пару ключевых фраз. Первым попался smali2java.

git clone
gradle build
java -jar smali2java.jar ..

Ошибки. Вижу огромный стектрейс и ошибок на несколько страниц терминала. Немного вчитавшись в суть содержимого (и сдерживая эмоции от размера стектрейса), я обнаруживаю, что данная тулза работает на основе некой описанной грамматики и байт-код, который она встретила, явно ей не соответствует. Открываю smali байт-код и вижу в нем аннотации, synthetic методы и прочие странные конструкции. Такого в Java байт-коде не было! Доколе? Удаляю!

Подробнее
Виртуальная машина Dalvik (также, как и JVM), как оказалось, не знают о существовании таких понятий, как inner/outside классы (чит. вложенные классы), и компилятор генерирует так называемые «synthetic» методы, для обеспечения доступа из вложенного класса к полям внешнего, например.

В качестве примера:


Если у внешнего класса (OuterClass) есть поле

public class OuterClass {
	List a;
	...
}

Чтобы приватный класс мог обратиться к полю внешнего класса, компилятор сгенерирует неявно следующий метод:

static synthetic java.util.List getList(OuterClass p1) {
	p1 = p1.a;
	return p1;
}

Также за счет подобной «подкапотной» кухни достигается работа некоторых других механизмов, которые предоставляет язык.

Подробнее этот вопрос можно начать изучать отсюда.

Не помогает. Ругается даже, на, с виду, не подозрительный байт-код. Открываю исходный код декомпилятора, читаю и вижу что-то очень странное: даже индусские программисты (при всем уважении) такого бы не написали. Закрадывается мысль: не уж то сгенерированный код. Отбрасываю идею минут на 30, пытаюсь понять, в чем ошибка. СЛОЖНА. Открываю снова Github — и правда, сгенерированный по грамматике парсер. А вот и сам генератор генератор. Откладываю все это подальше и пытаюсь подойти с другой стороны.

Стоит отметить, что чуть позже я все же попробовал менять грамматику и местами даже сам байт-код, чтобы декомпилятор все же сумел его переварить. Но даже когда байт-код стал валидным с точки зрения грамматики декомпилятора, программа просто ничего мне не вернула. Open Source...

Листаю байт-код и натыкаюсь на неведомые мне константы. Погуглив, встречаю такие же в книге по реверсу Android приложений. Вспоминаю, что это просто ID, присвоенный препроцессором компилятора, который назначается ресурсам Android приложения (константы времени написания кода – R.*). Следующие полчаса — час, исследую вкратце, какие регистры за что отвечают, в каком порядке передаются аргументы и вообще вникаю в синтаксис.

Как это выглядит?


Обнаружил layout главного окна приложения, а по нему уже понял, что вообще происходит в приложении: на главном экране (Activity) есть RecyclerView (условно, View который умеет переиспользовать объекты UI которые в данный момент не отображаются, для утилизации памяти) с полями для ввода пары ключ/значение, парочка кнопок, которые отвечают за добавление новой пары ключ/значение в некий абстрактный контейнер, и кнопка, которая генерирует подпись (сигнатуру) для этого контейнера.

Приглядываясь к аннотациям и наблюдая некоторое количество кода подозрительно похожего на сгенерированный, я начинаю гуглить. В проекте используется библиотека ButterKnife, которая позволяет с помощью аннотаций производить inflate() -> bind() UI элементов автоматически. Если в классе есть аннотации, процессор аннотаций ButterKnife неявно создает еще один класс-биндер вида <original_class>__ViewBinding, который и производит всю грязную работу под капотом. Собственно, всю эту информацию я получил только из одного файла MainActivity после того, как вручную воссоздал подобие Java-исходника из него. Спустя полчаса я понял, что аннотации этой библиотеки также могут устанавливать callback на действия с кнопками и нашел те ключевые функции, которые собственно отвечали за добавление пары ключ/значение в контейнер и генерирование сигнатуры.

Разумеется, по ходу изучения, приходилось лезть в «потроха» различных библиотек и плагинов, потому что даже красивые лендосы с кук-буками не покрывают всех use-кейсов и деталей, что для любого «реверсера», думаю, обычная практика.

Лень – друг программиста


Потратив еще какое-то время на второй исходник, я окончательно устал и понял, что так кашу не сварить. Снова лезу на Github, и на этот раз ищу пристальнее. Нахожу проект Smali2PsuedoJava – декомпилятор в «псевдо-Java код». Даже если эта утилита, хоть что-то сможет привести в человеческий вид, то с меня для автора кружка его любимого пива (ну или хотя бы звездочку на Github поставлю, для начала).  

И действительно, работает! Эффект на лицо:



Знакомьтесь, Cipher.so


Чуть позже, изучая уже Java-псевдокод проекта и недоверчиво сравнивая его с байт-кодом smali, обнаруживаю в коде странную библиотеку – Cipher.so. Погуглив, узнаю что это либа для шифрования набора значений времени компиляции внутри APK-архива. Обычно это бывает нужно, когда в приложении используются константы вида: IP адреса, credentials для внешней базы данных, токены для авторизации и т.д. – то, что можно заполучить с помощью реверс-инжиниринга приложения. Правда автор явно пишет, что этот проект заброшен, мол, уходите. Это становится интересным.

Эта библиотека предоставляет доступ к значениям через Java-библиотеку, где конкретный метод – это интересующий нас ключ. Это только подогревает мой интерес, и я начинаю лезть глубже.

Вкратце, что же делает и как работает Cipher.so:

  • в Gradle-файле нашего проекта прописываются ключи и соответствующие им значения
  • все значения ключа будут автоматически упакованы в отдельную динамическую библиотеку (.so), которая будет сгенерирована во время компиляции. Да — да, БУДЕТ сгенерирована.
  • затем эти ключи можно получить из Java методов, сгенерированных Cipher.so
  • после создания APK названия ключей хешируются MD5 (для большей сесурности, разумеется)

Отыскав в папке с архивом нужную мне динамическую библиотеку, я приступаю к ее ковырянию. Для начала, как опытный реверсер (нет) я пытаюсь начать с простого – решаю посмотреть на секцию с константам и на предмет интересных строчек в ELF-подобном бинаре. К сожалению, у пользователей макинтоша readelf из коробки отсутствует, и перед началом произносим заветное:

brew install binuitls

И не забываем прописать в PATH путь до /usr/local, потому что brew по-джентельменски предохраняет вас от всякого…

greadelf -p .rodata lib/arm64-v8a/libcipher-lib.so | head -n 15

Ограничиваем вывод первыми 15 строками, иначе неподготовленного инженера это может привести в шок.



В младших адресах замечаем подозрительные строки. Как я выяснил, изучая исходники Cipher.so, ключи и значения кладутся в обычную std::map:, информации это дает мало, но зато мы знаем, что в самом бинаре вместе с шифрованными паролями лежат в том числе и обфусцированные ключи.

Каким образом происходит шифрование значений? Изучая исходники, я обнаружил, что шифрование происходит с помощью AES – стандартная система симметричного шифрования. Значит, если тут есть зашифрованные значения, то и ключ должен лежать неподалеку… Недолго изучая, я наткнулся на issue в этом же проекте с провокационным названием «Insecure key storage: secrets are very easy to retreive». В нем то, собственно, я и узнал, что ключ хранится в открытом виде в бинаре, и нашел алгоритм дешифровки. В примере ключ лежал по нулевому адресу, и я хоть и понимал, что компилятор мог положить его в другое место секции .rodata бинарного файла, но решил, что эта подозрительная единичка по нулевому адресу и есть ключ.

Попытка #1: Приступаю к расшифровке значений и считаю, что ключ шифрования та самая единичка. Ошибка. OpenSSL намекает, что что-то не то. Немного почитав исходники Cipher.so, понимаю, что если пользователь при сборке не указывает ключ, то используется ключ по умолчанию – Cipher.so@DEFAULT.

Попытка #2: Снова ошибка. Хмм… Действительно ли переопределяется именно этой константой? Ошибиться довольно просто: запутанный код написанный на Gradle, c «поехавшим» форматированием. Проверяю еще раз. Все, вроде, так.

Вместо ключей лежат их MD5 хеши, и тут же пытаюсь испытать судьбу и открываю сервис с радужными таблицами. Вуаля — один из ключей – это слово «password». Второго нет. Дает нам это, конечно, не много. Оба этих ключа лежат по адресам 240 и 2a2 соответственно. В принципе, распознать их сразу несложно – 32 символа (MD5).

Проверил все еще раз и попробовал проделать дешифровку  со всеми остальными строчками (которые лежат в младших адресах) в качестве ключа для дешифрования – все тщетно.
Значит, есть какой-то другой секретный ключ, алгоритм действий вроде верный. Отбрасываю эту задачу в сторону и пытаюсь не зарываться.

Немного покопавшись в алгоритме подписи контейнера, вижу все же вызовы в библиотеку Cipher.so и код, который также использует криптографические  функции Java-библиотеки.

Загадка (которую я так и не разгадал)


В функции, которая отвечает за шифрование, в самом начале есть проверка на ключи в контейнере.

public byte[] a(java/util/Map p1) {
		v0 = p1.size()
		v1 = 0x0;
		if (v0 != 0) goto :cond_0
		p1 = new byte[v1];
		return p1;
	:cond_0
		v0 = "user";
		v0 = p1.containsKey(v0)
		if (v0 == 0) goto :cond_1
		p1 = new byte[v1];
		return p1;
...

Буквально: если есть ключ «user», то данный контейнер не подписывается (возвращается нулевая сигнатура). Странное чувство: вроде задача решена, а вроде и как-то подозрительно просто. Тогда зачем было придумывать все остальное? Чтобы сбить с легкого пути? Тогда почему я не изучил бегло этот код раньше? Хмм…

Нет, не верно. Ответ я уточнил у некого юзера в синем мессенджере, контакты которого мне предоставили при выдаче задания. Копаем дальше. Возможно, входной набор ключ/значение как-то меняется по ходу его добавления в контейнер? Читаю код внимательнее.

Обращаю внимание, что декомпилятор убрал аннотации из smali кода. А вдруг он убрал и что-то важное? Проверяю основные файлы – вроде, ничего существенного. Все важное на своих местах, а смысл не потерялся. Проверяю callback-функции, которые отвечают за запись пары ключ/значение из условных TextBox в внутренние контейнеры. Ничего криминального не нашел.

Я стал максимально скептично относиться к каждой строчке кода – больше не могу никому доверять.

Простое решение #2: Обратил внимание, что процедура подписывания начинается с проверки наличия некоторого значения (подстроки в строке) в сигнатуре сертификата, которым было подписано приложение.

@OnClick // генерация сигнатуры
protected void huvot324yo873yvo837yvo() {
	String signature = "no data";
	boolean result = some_packages.isKeyInSignature(this);
	if result {
		Map map = new HashMap();
...

Само значение конечно же лежит зашифрованным в том самом злополучном бинаре. И собственно, если этого значения в сигнатуре нет, то алгоритм подписывать ничего не будет, а просто вернет строку «no data», в качестве сигнатуры… Снова принимаемся за Cipher…

Финальный бой с расшифровкой ключей


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

Я сделал hex дамп этой секции и вгляделся в первые две строчки, подозрения с которых не спадали с самого начала.



Если обратить внимание, символ, который разделяет строки здесь – это ‘0x00’. Его также обычно использует стандартная библиотека C, в функциях для работы со строками. От того не менее интересно, что за символ пробела в середине первой строки? Дальше начинаются безумные попытки, где в качестве ключа выступают:

  • вся первая строка
  • первая строка до пробела
  • первая строка с пробела и до конца


Степень паранойи уже можно оценить. Когда не понимаешь, насколько сложным и хитрым должно быть задание, то начинаешь загоняться. И все же, не то. Тут мне уже приходит в голову мысль: «А корректно ли отрабатывает алгоритм из issue у меня на машине?». В целом, последовательность действий там логичная и вопросов не вызывала, но вот вопрос: делают ли команды на моей машине, то что он от них требуется? Ну и что вы думаете?

Проверив все этапы вручную, оказалось, что

echo "some_base64_input" | openssl base64 -d 

на некоторых входных аргументах внезапно возвращает пустую строку. Мда.

Заменив его на первый попавшийся base64 декодер на машине, и перебрав основных кандидатов, был сразу обнаружен подходящий ключ, и соответственно расшифрованы ключи.

Получение сигнатуры из сертификата


class a {
public static boolean isKeyInSignature(android.content.Context p1) {
	v0 = 0x0;
	try TRY_0{
		v1 = p1.getPackageManager()
		p0 = p1.getPackageName()
		v2 = 0x40; // GET_SIGNATURES
		PackageInfo p0 = v1.getPackageInfo(p0, v2)
		android.content.pm.Signature[] p0 = p0.signatures; 
		// Order are not guaranteed
		v1 = p0.length;
		v2 = 0x0;
	:goto_0
		if (v2 >= v1) goto :cond_1
		v3 = p0[v2];
		String v3 = v3.toCharsString()
		String v4 = net.idik.lib.cipher.so.CipherClient.a()
		v3 = v3.contains(v4)
	}TRY_0
	catch TRY_0 (android/content/pm/PackageManager$NameNotFoundException) goto :catch_0;
		if (v3 == 0) goto :cond_0
		p1 = 0x1;
		return p1;
	:cond_0
		v2 = v2 + 0x1;
		goto :goto_0
	:catch_0
		p0 = Thrown Exception
		p1.printStackTrace()
	:cond_1
		return v0;
}

Вот примерно так выглядит сгенерированный псевдокод, после моих незначительных правок. Смущает парочка вещей:

  • слабое знание криптографии и «кухни» устройства сертификатов
  • согласно документации, этот метод не гарантирует порядок сертификатов в возвращаемой коллекции, и соответственно их обход в цикле в одном и том же порядке был бы невозможен – а вдруг приложение было подписано больше, чем одним сертификатом?
  • отсутствие знания, как извлечь сертификат из APK, учитывая, что неясно, что делает Android Runtime в данном случае

Пришлось вникать во все эти вопросы и результат получился следующий:

  • сам сертификат лежит в директории original/META-INF/CERT.RSA

    в данной директории лежит всего один файл с таким расширением – значит, подписано приложение всего одним сертификатом
  • на сайте про research engineering Android приложений был найден листинг, который умеет извлекать нужную нам сигнатуру так, как это делает сам Android. По уверениям автора, по крайней мере.

Запустив этот код, у меня получается выяснить сигнатуру, и в действительности, необходимый нам ключ является подстрокой. Идем дальше. Простое решение #2 отметается.

И правда, ключ есть в сертификате, осталось только понять, что дальше, потому что при наличии ключа «user» мы все также получаем нулевую сигнатуру, а как мы узнали выше – это неверный ответ.

Пишите документацию внимательно!


Дальнейшие исследования на предмет того, что данные вводимые из текстовых полей изменяются, отбрасываются за отсутствием доказательств. Паранойя накатывает с новой силой: может быть тот код, который вытащил из сертификата сигнатуру, неверный или является имплементацией кода для старых релизов Android? Я снова открываю документацию и вижу следующее: (https://developer.android.com/reference/android/content/pm/Signature.html#toChars()):



Внимание: функция кодирует сигнатуру, как ASCII текст. В выводе, который я получал выше, было hex-представление данных. Мне показалось это API странным, но если верить документации, то выходит, что я снова загнался в тупик, и зашифрованный ключ не является подстрокой сигнатуры. Посидев задумчиво над кодом некоторое время, я не выдержал и открыл исходники этого класса. https://android.googlesource.com/platform/frameworks/base/+/e639da7/core/java/android/content/pm/Signature.java

Ответ не заставил себя долго ждать. А собственно, в самом коде — картина маслом: формат вывода – обычная hex-строка. И вот думай: то ли я что-то не понимаю, то ли документация написана «слегка» некорректно. Поругавшись вникуда, я снова принялся за дело.

Итог


Следующие n часов прошли за:

  • проверкой корректности работы в коде с RecyclerView и выяснением его поведения через исходный код т.к. опять же, не все моменты подробно освещены в доке и даже на StackOverflow
  • ручной декомпиляцией фрагмента кода, отвечающего за подписывание коллекции, в компилируемый Java. Я принял за допущение, что все таки что-то упустил и первый ключ в контейнере («user») неявным образом выбывает из коллекции.  Решил натравить на код остальные данные.

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

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

В действительности, я много времени потратил на эту задачу, а заодно и на восстановление пробелов в знаниях. Это было действительно полезно. Можно проследить весь путь и обратить внимание, как вначале я цеплялся за абсолютно не относящиеся к решению детали. Возможно, кому-то это поможет осознать, как новички решают задачи такого рода, потому что обычно мы читаем «истории успеха», где все шаги логичны, последовательны и приводят к верному результату.

Если кто-то захочет попробовать поковыряться c этой задачкой еще немного или задать вопрос – пишите мне в синий мессенджер arturbrsg.

Stay tuned.
Tags:
Hubs:
+29
Comments19

Articles

Change theme settings