Когда вы думаете о защите приложения от обратной инженерии, в первую очередь приходят на ум такие слова, как обфускация и шифрование. Но это только часть решения проблемы. Вторая половина — это обнаружение и защита от самих обратных инструментов: отладчиков, эмуляторов, Frida и т. Д. В этой статье мы рассмотрим методы, используемые мобильным программным обеспечением и вредоносными программами, чтобы скрыться от этих инструментов.
WARNING
Не воспринимайте информацию, изложенную в статье, как рецепт абсолютной защиты. Такого рецепта нет. Мы просто даем себе передышку, замедляем исследования, но не делаем это невозможным. Все это бесконечная игра в кошки-мышки, в которой исследователь ломает очередную защиту, а разработчик придумывает ей более изощренную замену.
Важный момент: я дам вам несколько различных методов защиты, и у вас может возникнуть соблазн сгруппировать их в класс (или собственную библиотеку) и, что удобно для вас, запустить их один раз в начале приложения. Не стоит этого делать, механизмы защиты должны быть распределены по всему приложению и запускаться в разное время. Это значительно усложняет жизнь злоумышленнику, который может определить назначение класса / библиотеки и полностью заменить его большим контуром.
Root
Права root — один из основных инструментов отмены. Root позволяет запускать Frida без исправления приложения, использовать модули Xposed для изменения поведения приложения и отслеживания приложений, а также изменять параметры системы низкого уровня. В общем, наличие root явно указывает на то, что среде выполнения нельзя доверять. Но как его найти?
Самый простой вариант — поискать исполняемый файл su в одном из системных каталогов:
- /sbin/su
- /system/bin/su
- /system/bin/failsafe/su
- /system/xbin/su
- /system/sd/xbin/su
- /data/local/su
- /data/local/xbin/su
- /data/local/bin/su
Бинарник su всегда присутствует на рутованном устройстве, ведь именно с его помощью приложения получают права root. Найти его можно с помощью примитивного кода на Java:
private static boolean findSu() {
String[] paths = { "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su" };
for (String path : paths) {
if (new File(path).exists()) return true;
Либо использовать такую функцию, позаимствованную из приложения rootinspector:
jboolean Java_com_example_statfile(JNIEnv * env, jobject this, jstring filepath) {
jboolean fileExists = 0;
jboolean isCopy;
const char * path = (*env)->GetStringUTFChars(env, filepath, &isCopy);
struct stat fileattrib;
if (stat(path, &fileattrib) < 0) {
__android_log_print(ANDROID_LOG_DEBUG, DEBUG_TAG, "NATIVE: stat error: [%s]", strerror(errno));
} else
{
__android_log_print(ANDROID_LOG_DEBUG, DEBUG_TAG, "NATIVE: stat success, access perms: [%d]", fileattrib.st_mode);
return 1;
}
return 0;
}
Еще один вариант — попробовать не просто найти, а запустить бинарник su:
try {
Runtime.getRuntime().exec("su");
} catch (IOException e) {
// Телефон не рутован
}
Еще один вариант — найти среди установленных на устройство приложений менеджер прав root. Он как раз и отвечает за диалог предоставления прав:
- com.thirdparty.superuser
- eu.chainfire.supersu
- com.noshufou.android.su
- com.koushikdutta.superuser
- com.zachspong.temprootremovejb
- com.ramdroid.appquarantine
- com.topjohnwu.magisk
Для поиска можно использовать такой метод:
private static boolean isPackageInstalled(String packagename, Context context) {
PackageManager pm = context.getPackageManager();
try {
pm.getPackageInfo(packagename, PackageManager.GET_ACTIVITIES);
return true;
} catch (NameNotFoundException e) {
return false;
}
}
Искать можно и по косвенным признакам. Например, SuperSU, некогда популярное решение для получения прав root, имеет несколько файлов в файловой системе:
- /system/etc/init.d/99SuperSUDaemon
- /system/xbin/daemonsu — SuperSU
Еще один косвенный признак — прошивка, подписанная тестовыми ключами. Это не всегда подтверждает наличие root, но точно говорит о том, что на устройстве установлен кастом:
private boolean isTestKeyBuild() {
String buildTags = android.os.Build.TAGS;
return buildTags != null && buildTags.contains("test-keys");
}
Magisk
Все эти методы обнаружения корневого доступа работают нормально, пока вы не встретите устройство с root-правами на Magisk. Это так называемый бессистемный метод рутирования, когда вместо размещения компонентов для корневого доступа в файловой системе поверх нее монтируется другая файловая система (оверлей), содержащая эти компоненты.
Этот рабочий механизм не только позволяет не трогать системный раздел, но и легко скрывает наличие рут-прав в системе. Встроенная функция Magisk в MagiskHide просто отключает оверлей для выбранных приложений, делая бесполезным любой классический метод обнаружения корневого каталога.
Процесс скрытия root можно увидеть в логах Magisk
Но в MagiskHide есть недостаток. Дело в том, что если приложение, которое находится в списке для скрытия корневого каталога, запускает службу в изолированном процессе, Magisk также отключит для него оверлей, но этот оверлей останется в списке подключенных файловых систем (/ proc / self / mounts). Следовательно, чтобы обнаружить Magisk, вам необходимо запустить службу в изолированном процессе и проверить список подключенных файловых систем.
Способ был описан в статье Detecting Magisk Hide, а исходный код proof of concept выложен на GitHub. Способ работает до сих пор на самой последней версии Magisk — 20.4
Эмулятор
Реверсеры часто используют эмулятор для запуска подопытного приложения. Поэтому нелишним будет внести в приложение код, проверяющий, не запущено ли оно в виртуальной среде. Сделать это можно, прочитав значение некоторых системных переменных. Например, стандартный эмулятор Android Studio устанавливает такие переменные и их значения:
ro.hardware=goldfish
ro.kernel.qemu=1
ro.product.model=sdk
Прочитав их значения, можно предположить, что код исполняется в эмуляторе:
public static boolean checkEmulator() {
try {
boolean goldfish = getSystemProperty("ro.hardware").contains("goldfish");
boolean emu = getSystemProperty("ro.kernel.qemu").length() > 0;
boolean sdk = getSystemProperty("ro.product.model").contains("sdk");
if (emu || goldfish || sdk) {
return true;
}
} catch (Exception e) {}
return false;
}
private static String getSystemProperty(String name) throws Exception {
Class sysProp = Class.forName("android.os.SystemProperties");
return (String) sysProp.getMethod("get", new Class[]{String.class}).invoke(sysProp, new Object[]{name});
}
android. os. SystemProperties
скрытый и недоступен в SDK, поэтому для обращения к нему мы используем рефлексию.Отладчик
Один из обратных методов — запустить приложение под управлением отладчика. Злоумышленник может декомпилировать ваше приложение, а затем создать проект с тем же именем в Android Studio, вставить полученные из него источники и начать отладку без компиляции проекта. В этом случае приложение само покажет вам свою логику работы.
Чтобы провернуть такой финт, взломщику придется пересобрать приложение с включенным флагом отладки (android:debuggable=»true»). Поэтому наивный способ защиты состоит в простой проверке этого флага:
public static boolean checkDebuggable(Context context){
return (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
}
Чуть более надежный способ — напрямую спросить систему, подключен ли отладчик:
public static boolean detectDebugger() {
return Debug.isDebuggerConnected();
}
То же самое в нативном коде:
JNIEXPORT jboolean JNICALL Java_com_test_debugging_DebuggerConnectedJNI(JNIenv * env, jobject obj) {
if (gDvm.debuggerConnected || gDvm.debuggerActive) {
return JNI_TRUE;
}
return JNI_FALSE;
}
Приведенные методы помогут обнаружить отладчик на базе протокола JDWP (как раз тот, что встроен в Android Studio). Но другие отладчики работают по‑другому, и методы борьбы с ними будут иными. Отладчик GDB, например, получает контроль над процессом с помощью системного вызова ptrace(
. А после использования ptrace флаг TracerPid в синтетическом файле /
изменится с нуля на PID
отладчика. Прочитав значение флага, мы узнаем, подключен ли к приложению отладчик GDB:
public static boolean hasTracerPid() throws IOException {
BufferedReader reader = null;
try {
reader = new BufferedReader(new InputStreamReader(new FileInputStream("/proc/self/status")), 1000);
String line;
while ((line = reader.readLine()) != null) {
if (line.length() > tracerpid.length()) {
if (line.substring(0, tracerpid.length()).equalsIgnoreCase(tracerpid)) {
if (Integer.decode(line.substring(tracerpid.length() + 1).trim()) > 0) {
return true;
}
break;
}
}
}
} catch (Exception exception) {
e.printStackTrace()
} finally {
reader.close();
}
return false;
}
Xposed
Xposed — это фреймворк для изменения времени работы приложений. И хотя он в основном используется для установки системных изменений и настроек приложений, существует множество модулей, которые вы можете использовать для отмены и взлома вашего приложения. Это различные модули для отключения закрепления SSL, трассировщики, такие как Inspection, и самописные модули, которые могут каким-либо образом изменять приложение.
Есть три действенных способа обнаружения Xposed:
- поиск пакета de.robv.android.xposed.installer среди установленных на устройство;
- поиск
libexposed_art.
иso xposedbridge.
в файлеjar /
;proc/ self/ maps - поиск класса
de.
среди загруженных в рантайм пакетов.robv. android. xposed. XposedBridge
В статье Android Anti-Hooking Techniques in Java приводится реализация третьего метода одновременно для поиска Xposed и Cydia Substrate. Подход интересен тем, что мы не ищем напрямую классы в рантайме, а просто вызываем исключение времени исполнения и затем ищем нужные классы и методы в стектрейсе:
try {
throw new Exception("blah");
}
catch(Exception e) {
int zygoteInitCallCount = 0;
for(StackTraceElement stackTraceElement : e.getStackTrace()) {
if(stackTraceElement.getClassName().equals("com.android.internal.os.ZygoteInit")) {
zygoteInitCallCount++;
if(zygoteInitCallCount == 2) {
Log.wtf("HookDetection", "Substrate is active on the device.");
}
}
if (stackTraceElement.getClassName().equals("com.saurik.substrate.MS$2") && stackTraceElement.getMethodName().equals("invoked")) {
Log.wtf("HookDetection", "A method on the stack trace has been hooked using Substrate.");
}
if (stackTraceElement.getClassName().equals("de.robv.android.xposed.XposedBridge") && stackTraceElement.getMethodName().equals("main")) {
Log.wtf("HookDetection", "Xposed is active on the device.");
}
if (stackTraceElement.getClassName().equals("de.robv.android.xposed.XposedBridge") && stackTraceElement.getMethodName().equals("handleHookedMethod")) {
Log.wtf("HookDetection", "A method on the stack trace has been hooked using Xposed.");
}
}
}
Frida
1. Поиск библиотек frida-agent и frida-gadget в файле /
:
char line[512];
FILE* fp;
fp = fopen("/proc/self/maps", "r");
if (fp) {
while (fgets(line, 512, fp)) {
if (strstr(line, "frida")) {
/* Frida найдена */
}
}
fclose(fp);
}
2. Поиск в памяти нативных библиотек строки «LIBFRIDA»:
static char keyword[] = "LIBFRIDA";
num_found = 0;
int scan_executable_segments(char * map) {
char buf[512];
unsigned long start, end;
sscanf(map, "%lx-%lx %s", &start, &end, buf);
if (buf[2] == 'x') {
return (find_mem_string(start, end, (char*)keyword, 8) == 1);
} else {
return 0;
}
}
void scan() {
if ((fd = my_openat(AT_FDCWD, "/proc/self/maps", O_RDONLY, 0)) >= 0) {
while ((read_one_line(fd, map, MAX_LINE)) > 0) {
if (scan_executable_segments(map) == 1) {
num_found++;
}
}
if (num_found > 1) {
/* Frida найдена */
}
}
3. Проход по всем открытым TCP-портам, отправка в них dbus-сообщения AUTH и ожидание ответа Frida:
for(i = 0 ; i <= 65535 ; i++) {
sock = socket(AF_INET , SOCK_STREAM , 0);
sa.sin_port = htons(i);
if (connect(sock , (struct sockaddr*)&sa , sizeof sa) != -1) {
__android_log_print(ANDROID_LOG_VERBOSE, APPNAME, "FRIDA DETECTION [1]: Open Port: %d", i);
memset(res, 0 , 7);
send(sock, "\x00", 1, NULL);
send(sock, "AUTH\r\n", 6, NULL);
usleep(100);
if (ret = recv(sock, res, 6, MSG_DONTWAIT) != -1) {
if (strcmp(res, "REJECT") == 0) {
/* Frida найдена */
}
}
}
close(sock);
}
В статье Detect Frida for Android автор приводит еще три способа:
- Поиск потоков frida-server и frida-gadget, которые Frida запускает в рамках процесса подопытного приложения.
- Поиск специфичных для Frida именованных пайпов в каталоге
/
.proc/< pid>/ fd - Сравнение кода нативных библиотек на диске и в памяти. При внедрении Frida изменяет секцию text нативных библиотек.
Примеры использования последних трех техник опубликованы в репозитории на GitHub.
Клонирование
Некоторые производители включают в свои прошивки функцию клонирования приложений (параллельные приложения на OnePlus, двойные приложения на Xiaomi и т. Д.), Которая позволяет вам установить копию выбранного приложения на ваш смартфон. Прошивка создает дополнительного пользователя Android с ID 999 и устанавливает копию приложений от вашего имени.
Некоторые приложения на рынке предлагают такую же функциональность (Dual Space, Clone App, Multi Parallel). Они работают по-другому: создают изолированную среду для приложения и устанавливают его в свой личный каталог.
С помощью второго метода твое приложение могут запустить в изолированной среде для изучения. Чтобы воспрепятствовать этому, достаточно проанализировать путь к приватному каталогу приложения. К примеру, приложение с именем пакета com.example.app при нормальной установке будет иметь приватный каталог по следующему пути:
/data/user/0/com.example.app/files
При создании клона с помощью одного из приложений из маркета путь будет уже таким:
/data/data/com.ludashi.dualspace/virtual/data/user/0/com.example.app/files
А при создании клона с помощью встроенных в прошивку инструментов — таким:
/data/user/999/com.example.app/files
Соберем все вместе и получим такой метод для детекта изолированной среды:
private const val DUAL_APP_ID_999 = "999"
fun checkAppCloning(context: Context): Boolean {
val path: String = context.filesDir.path
val packageName = context.packageName
val pathDotCount = path.split(".").size-1
val packageDotCount = packageName.split(".").size-1
if (path.contains(DUAL_APP_ID_999) || pathDotCount > packageDotCount) {
return false
}
return true
}
Выводы
Конечно, это далеко не все способы обратной инженерии и анализа поведения приложений. Есть много менее эффективных или узкоспециализированных методов. Поэтому ниже я приведу список литературы и проектов на GitHub, который вам обязательно стоит прочитать. Там вы найдете более подробное объяснение некоторых методов защиты, описанных в этой статье, а также многих других методов.
www
- Android Anti-Reversing Defenses — глава из свободной книги Mobile Security Testing Guide о защите от реверса;
- Android Anti-Hooking Techniques in Java — статья о способах обнаружить Xposed и Cydia Substrate;
- The Jiu-Jitsu of Detecting Frida — описание способов обнаружить Frida;
- Detect Frida for Android — еще несколько способов обнаружить Frida;
- SafetyNet: Google’s tamper detection for Android — статья о принципе работы инструмента SafetyNet, использующего многие из описанных техник определить компрометацию устройства;
- RootBeer — готовая библиотека, помогающая обнаружить права root;
- anti-emulator — репозиторий с несколькими техниками детекта эмулятора и дебаггера;
- DetectMagiskHide — готовое приложение для детекта Magisk.