Переезд. Новый город. Поиски работы. Даже для IT специалиста это может занять длительное время. Череда собеседований, которые, в общем, очень похожи друг на друга. И как оно обычно бывает, когда ты уже нашел работу, через некоторое время, объявляется одна интересная контора.
Было сложно понять, чем она конкретно занимается, однако, область ее интересов – исследование чужого ПО. Звучит интригующе, хотя когда понимаешь, что это вроде бы не вендор, который выпускает софт для кибербезопасности, на секунду останавливаешься и начинаешь чесать репу.
Вкратце: мне скинули архив и предложили в качестве тестового задания исследовать его и попытаться вычислить некую сигнатуру на основе представленных входных данных. Стоит отметить, что опыта в подобной деятельности у меня было крайне мало и, наверное, поэтому в первой итерации решения меня хватило всего на пару часов – дальше мотивация заниматься этим, сошла на нет. И да, я, разумеется, первым делом пытался запускать его на телефоне/эмуляторе – это приложение невалидно.
Что мы имеем: архив с расширением ".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 байт-коде не было! Доколе? Удаляю!
В качестве примера:
Если у внешнего класса (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.
Автор: Артур Барсегян