Как стать автором
Обновить

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

Время на прочтение13 мин
Количество просмотров17K
Переезд. Новый город. Поиски работы. Даже для 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.
Теги:
Хабы:
Всего голосов 29: ↑29 и ↓0+29
Комментарии19

Публикации