За семью замками. Защищаем приложение для Android от отладчиков, эмуляторов и Frida

Ког­да задумы­ваешь­ся о защите при­ложе­ния от ревер­са, в пер­вую оче­редь на ум при­ходят такие сло­ва, как обфуска­ция и шиф­рование. Но это толь­ко часть решения проб­лемы. Вто­рая полови­на — это детект и защита от самих инс­тру­мен­тов ревер­са: отладчи­ков, эму­лято­ров, 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;
    }
    return false;
}

Ли­бо исполь­зовать такую фун­кцию, поза­имс­тво­ван­ную из при­ложе­ния 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) {
    // Телефон не рутован
}

Ес­ли его нет, сис­тема выдаст IOException. Но здесь нуж­но быть осто­рож­ным: если устрой­ство все‑таки име­ет пра­ва root, поль­зователь уви­дит на экра­не зап­рос этих самых прав.

Еще один вари­ант — най­ти сре­ди уста­нов­ленных на устрой­ство при­ложе­ний менед­жер прав 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. Это так называ­емый systemless-метод рутин­га, ког­да вмес­то раз­мещения ком­понен­тов для root-дос­тупа в фай­ловой сис­теме поверх нее под­клю­чают дру­гую фай­ловую сис­тему (овер­лей), содер­жащую эти ком­понен­ты.

Та­кой механизм работы не толь­ко поз­воля­ет оста­вить сис­темный раз­дел в целос­ти и сох­раннос­ти, но и лег­ко скры­вает наличие прав root в сис­теме. Встро­енная в Magisk фун­кция MagiskHide прос­то отклю­чает овер­лей для выб­ранных при­ложе­ний, делая любые клас­сичес­кие спо­собы детек­та root бес­полез­ными.

Процесс скрытия root можно увидеть в логах Magisk
Про­цесс скры­тия root мож­но уви­деть в логах Magisk

Но есть в MagiskHide один изъ­ян. Дело в том, что, если при­ложе­ние, которое находит­ся в спис­ке для скры­тия root, запус­тит сер­вис в изо­лиро­ван­ном про­цес­се, 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, поэто­му для обра­щения к нему мы исполь­зуем реф­лексию.

В дру­гих эму­лято­рах зна­чения сис­темных перемен­ных могут быть дру­гими. На этой стра­нице есть таб­лица со зна­чени­ями сис­темных перемен­ных, которые могут пря­мо или кос­венно ука­зывать на эму­лятор. Там же при­веде­на таб­лица зна­чений сте­ка телефо­нии. Нап­ример, серий­ный номер SIM кар­ты 89014103211118510720 однознач­но ука­зыва­ет на эму­лятор. Мно­гие стан­дар­тные зна­чения, а так­же готовые фун­кции для детек­та эму­лято­ра мож­но най­ти в этом ис­ходном фай­ле.

ОТЛАДЧИК

Один из методов ревер­са — запуск при­ложе­ния под управле­нием отладчи­ка. Взлом­щик может деком­пилиро­вать твое при­ложе­ние, затем соз­дать в 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 в син­тетичес­ком фай­ле /proc/self/status изме­нит­ся с нуля на 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;
}

Это слег­ка модифи­циро­ван­ная фун­кция из репози­тория anti-emulator. Ее ана­лог на язы­ке С будет нет­рудно най­ти на Stack Overflow.

Еще один метод борь­бы с отладчи­ками, осно­ван­ными на ptrace, — поп­робовать под­клю­чить­ся к самому себе (про­цес­су при­ложе­ния) в роли отладчи­ка. Для это­го надо сде­лать форк (из натив­ного кода) и затем попытать­ся выз­вать сис­темный вызов ptrace:

void fork_and_attach()
{
    int pid = fork();
    if (pid == 0)
    {
        int ppid = getppid();
        if (ptrace(PTRACE_ATTACH, ppid, NULL, NULL) == 0)
        {
            waitpid(ppid, NULL, 0);
            ptrace(PTRACE_CONT, NULL, NULL);
        }
    }
}

Об­наружить встро­енный отладчик IDA Pro мож­но дру­гим спо­собом: через поиск стро­ки 00000000:23946 в фай­ле /proc/net/tcp (это стан­дар­тный порт отладчи­ка). К сожале­нию, начиная с Android 9 спо­соб не работа­ет.

В ста­рых вер­сиях Android так­же мож­но было пря­мо искать про­цесс отладчи­ка в сис­теме, ког­да при­ложе­ние прос­то про­ходит по дереву про­цес­сов в фай­ловой сис­теме /proc и ищет стро­ки типа gdb и gdbserver в фай­лах /proc/PID/cmdline. Начиная с Android 7 дос­туп к фай­ловой сис­теме /proc зап­рещен (кро­ме информа­ции о текущем про­цес­се).

XPOSED

Xposed — фрей­мворк для ран­тайм‑модифи­кации при­ложе­ний. И хотя в основном с его помощью уста­нав­лива­ют сис­темные модифи­кации и тви­ки при­ложе­ний, сущес­тву­ет мас­са модулей, которые могут быть исполь­зованы для ревер­са и взло­ма тво­его при­ложе­ния. Это и раз­личные модули для отклю­чения SSL Pinning, и трас­сиров­щики вро­де inspeckage, и самопис­ные модули, которые могут как угод­но изме­нять при­ложе­ние.

Есть три дей­ствен­ных спо­соба обна­руже­ния 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

Ее величес­тво Frida! Этот изу­митель­ный инс­тру­мент поз­воля­ет перех­ватить вызов любой фун­кции подопыт­ного при­ложе­ния, про­честь все ее аргу­мен­ты и заменить тело собс­твен­ной реали­заци­ей на язы­ке JavaScript. Frida не толь­ко занима­ет почет­ное мес­то в чемодан­чике инс­тру­мен­тов любого ревер­сера, но и слу­жит базой для мно­гих дру­гих, более высоко­уров­невых ути­лит.

Об­наружить Frida мож­но мно­жес­твом раз­ных спо­собов. В статье The Jiu-Jitsu of Detecting Frida при­водит­ся три (на самом деле четыре, но пер­вый уже неак­туален) спо­соба это сде­лать.

1. Поиск биб­лиотек frida-agent и frida-gadget в фай­ле  /proc/self/maps:

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 найдена */
    }
}

Взлом­щик может переком­пилиро­вать 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);
}

Ме­тод хорошо работа­ет при исполь­зовании frida-server (на рутован­ном устрой­стве), но бес­полезен, если при­ложе­ние было перепа­кова­но с вклю­чени­ем в него frida-gadget (этот спо­соб обыч­но при­меня­ют, ког­да невоз­можно получить root на устрой­стве).

В статье Detect Frida for Android автор при­водит еще три спо­соба:

  1. По­иск потоков frida-server и frida-gadget, которые Frida запус­кает в рам­ках про­цес­са подопыт­ного при­ложе­ния.
  2. По­иск спе­цифич­ных для Frida име­нован­ных пай­пов в катало­ге /proc/<pid>/fd.
  3. Срав­нение кода натив­ных биб­лиотек на дис­ке и в памяти. При внед­рении Frida изме­няет сек­цию text натив­ных биб­лиотек.

При­меры исполь­зования пос­ледних трех тех­ник опуб­ликова­ны в ре­пози­тории на GitHub.

КЛОНИРОВАНИЕ

Не­кото­рые про­изво­дите­ли встра­ивают в свои про­шив­ки фун­кцию кло­ниро­вания при­ложе­ния (Parallel Apps в OnePlus, Dual Apps в Xiaomi и так далее), которая поз­воля­ет уста­новить на смар­тфон копию выб­ранно­го при­ложе­ния. Про­шив­ка соз­дает допол­нитель­ного Android-поль­зовате­ля с иден­тифика­тором 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
}

Ме­тод осно­ван на спо­собе, при­веден­ном в статье Preventing Android App Cloning.

ВЫВОДЫ

Ко­неч­но же, это далеко не все спо­собы борь­бы с ревер­сом и ана­лизом поведе­ния при­ложе­ний. Сущес­тву­ет мно­жес­тво менее эффектив­ных или слиш­ком узкоспе­циали­зиро­ван­ных спо­собов. Поэто­му ниже я при­веду спи­сок литера­туры и про­ектов на GitHub, с которы­ми обя­затель­но нуж­но озна­комить­ся. Там ты най­дешь более глу­бокое объ­ясне­ние некото­рых опи­сан­ных в этой статье методов защиты, а так­же мно­жес­тво дру­гих тех­ник.

Источник — xakep.ru

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

*