简介
在本文中,将探讨与Android设备上的Root检测相关的技术以及绕过它的方法,描述了移动应用程序开发人员为保护其应用程序、防止其在root的设备上运行而采用的策略,包含常见的对root设备特有的su文件、使用magisk的特征zygisk字符进行检测的防护策略,通过frida hook 结合Grida、jadx逆向分析代码来绕过root检测技术,从而实现在root设备上分析目标APP应用程序
分析
示例应用程序 已安装在的root设备上,当打开应用程序APP时,可以看到直接在页面表明该设备已root,证明确实该应用程序确实具备root检测能力
通过使用jadx-gui反编译apk来开始分析,以了解应用程序在设备上安装后的作用。AndroidManifest.xml是所有Android应用程序的入口点,其中为应用程序定义了不同的组件和服务。
权限
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<permission android:name="com.8ksec.inappprotections.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" android:protectionLevel="signature"/>
<uses-permission android:name="com.8ksec.inappprotections.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"/>
从权限的角度来看,似乎没有什么有问题的地方,主要是使用存储权限。除了这些定义的权限,让看看这个应用程序中还有哪些其他组件,可以看到它只有一个活动,即MainActivity。
<activity android:exported="true" android:name="com.8ksec.inappprotections.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
反编译Java层代码分析
跟进分析MainActivity,看下java层的实现
可以看到jadx无法反编译大部分代码,这是因为代码被混淆了。但是仍然可以尝试从看到的Java反编译代码中找到代码的基本执行逻辑框架;MainActivity声明了一个static静态代码,在里边调用System.loadLibrary(“inappprotections”),其负责将调用native库,由于它位于静态代码块中,因此该native库将在APP应用启动后立即加载。
JavaScript static { String str = “”; while (true) { switch ((((str.hashCode() ^ 436) ^ 234) ^ 912) ^ (-1907612566)) { case -1083933248: return; case 1567145872: System.loadLibrary(“inappprotections”); str = “”; break; } } }
|
除此之外,还可以观察到一个名为detectRoot()的函数,顾名思义,这就是检测设备是否root的函数
JavaScript public final native int detectRoot();
|
通过查看函数签名,它看起来大概率是inappprotections库的native函数,其返回值是整数。
通常,这些函数在检测到root时返回true,否则返回false。接下来将考虑绕过root检测,以便可以在root的设备上运行应用程序。
动态分析
通过前面的分析,可以使用Frida在Java层拦截函数。这将允许检查函数的返回值。如果返回值恰好是布尔值,可以很容易地修改它以始终返回false。
Frida Hooking
可以使用以下Frida脚本Hook detectRoot()函数:
JavaScript let MainActivity = Java.use(“com.pikasec.inappprotections.MainActivity”); MainActivity[“detectRoot”].implementation = function () { let ret = this.detectRoot(); console.log(‘detectRoot ret value is ‘ + ret); return ret; };
|
在这个hook 脚本中,只是打印detectRoot函数的返回值;使用frida运行这个脚本
JavaScript frida -U -l root_bypass.js -f com.pikasec.inappprotections . . . . Connected to Pixel 4a (id=0B151JEC202420) Spawned `com.pikasec.inappprotections`. Resuming main thread! [Pixel 4a::com.pikasec.inappprotections ]-> detectRoot is called detectRoot ret value is 404
|
在控制台命令行中,可以看到返回值是404。可以看出来它不是返回0或1 这种代表true或false的简单判断逻辑
尝试再次运行脚本,看看是否得到相同的值:
JavaScript . . . . Connected to Pixel 4a (id=0B151JEC202420) Spawned `com.pikasec.inappprotections`. Resuming main thread! [Pixel 4a::com.pikasec.inappprotections ]-> detectRoot is called detectRoot ret value is 500
|
这次得到了500,这与之前获取的数字并不一致。看起来要比较麻烦了,似乎仅操作返回值可能不足以绕过这个特定的Root检测。此外,代码被混淆,使得仅基于这个类理解Root检测机制的底层逻辑变得很难。因此,需要查看定义此函数的native库,即inappprotections库。
提取APK
可以使用apktool快速提取这个APK,可以访问APK的这些内部文件和资源。
JavaScript apktool d root_detector.apk Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true I: Using Apktool 2.5.0-dirty on root_detector.apk I: Loading resource table… I: Decoding AndroidManifest.xml with resources… I: Loading resource table from file: /home/kali/.local/share/apktool/framework/1.apk I: Regular manifest package… I: Decoding file-resources… I: Decoding values */* XMLs… I: Baksmaling classes.dex… I: Copying assets and libs… I: Copying unknown files… I: Copying original files… I: Copying META-INF/services directory
|
在lib文件夹中,可以找到libinappprotections.so库。
分析inappprotections库
使用Ghidra反编译器分析这个库,识别库中的导入函数,这可以作为进一步分析潜在检测函数的起点。
可以明显看到这个库并没有特别多的函数,对分析工作来说少了很多干扰,能够很明显快速看到detectRoot函数,那么快速导航到这个函数以查看反汇编并尝试理解逻辑。
这个函数本身并不是很大,通过分析这个反汇编代码发现其使用间接跳转来操作控制流:X8寄存器在运行时动态加载,后续的分支指令将根据这个X8寄存器所指出的地址来调用函数。这里仅通过查看这个反汇编,没办法知道将调用哪个函数,看来仅静态分析来理解这块的具体实现逻辑是相当困难的。
Bypass Root
Root检测#1
在继续之前,应当了解一下Root检测在任何Android设备上是如何执行的。当root设备时,它会在系统目录中放置一个名为su的可执行文件。为了检测su二进制文件,应用程序必须在system/bin/su等默认路径中检查su。这些路径必须在该库的某个地方硬编码。它很可能硬编码在二进制文件的只读部分,因为所有硬编码的常量值都存在于该部分中。
不幸的是,在字符串中没有找到与su路径相关的内容,可以看到文本部分中存在许多随机字符。这表明字符串已被加密并存储到文本部分中。接下来应该考虑尝试什么?可以发现有几个与文件处理相关的导入函数,包括access、fopen、fclose和stat。为了了解这些导入函数具体做了什么,使用Frida来hook这些函数
JavaScript var arg0 = null; Interceptor.attach(Module.findExportByName(“libc.so”, “fopen”), { onEnter: function (args) { arg0 = args[0]; console.log(`fopen: ${args[0].readCString()}`); } }) Interceptor.attach(Module.findExportByName(“libc.so”, “stat”), { onEnter: function (args) { console.log(`stat: ${args[0].readCString()}`); } }) Interceptor.attach(Module.findExportByName(“libc.so”, “access”), { onEnter: function (args) { console.log(`access: ${args[0].readCString()}`); } })
|
现在让运行脚本,看看得到了什么:
JavaScript . . . . Connected to Pixel 4a (id=0B151JEC202420) Spawned `com.pikasec.inappprotections`. Resuming main thread! [Pixel 4a::com.pikasec.inappprotections ]-> stat: /data/app/~~MxHO_xqdQgeBWi4_Wpoj4A==/com.pikasec.inappprotections-1XZhJp18cue2EZWsYq4xjA==/base.apk stat: /data/app/~~MxHO_xqdQgeBWi4_Wpoj4A==/com.pikasec.inappprotections-1XZhJp18cue2EZWsYq4xjA==/base.apk stat: /data stat: /data stat: /data/dalvik-cache/arm64 access: /data/app/~~MxHO_xqdQgeBWi4_Wpoj4A==/com.pikasec.inappprotections-1XZhJp18cue2EZWsYq4xjA==
|
现在通过查看输出,其中大部分似乎与典型应用程序进程在执行期间访问的默认路径有关。为了获得更有意义的输出,应该只有在libinappprotections库成功加载后才调用这些钩子。为了实现这一点,将Hook linker64,因为它在最初将库加载到内存中起着至关重要的作用。
JavaScript var do_dlopen = null; var call_constructor = null; Process.findModuleByName(‘linker64’).enumerateSymbols().forEach(function (symbol) { if (symbol.name.indexOf(‘do_dlopen’) >= 0) { do_dlopen = symbol.address; } else if (symbol.name.indexOf(‘call_constructor’) >= 0) { call_constructor = symbol.address; } })
|
可以通过使用frida的Process. findModuleByName API轻松找到linker64模块,然后需要枚举linker64中的所有符号来获得do_dlopen和call_constructor函数的地址。当linker64尝试将库加载到内存中时,这些函数将被调用。一旦有了这些地址,就可以附加hook并跟踪链接器加载的所有库。
JavaScript var lib_loaded = 0; Interceptor.attach(do_dlopen, function () { var library_path = this.context.x0.readCString(); if (library_path.indexOf(‘libnative-lib.so’) >= 0) { console.log(`Target library is loading…`); Interceptor.attach(call_constructor, function () { if (lib_loaded == 0) { var native_mod = Process.findModuleByName(‘libinappprotections.so’); console.log(`Target library loaded at ${native_mod.base}`); } lib_loaded = 1; }) } })
|
再次运行脚本并查看截获的数据:
JavaScript frida -U -l root_bypass.js -f com.pikasec.inappprotections . . . . Connected to Pixel 4a (id=0B151JEC202420) Spawned `com.pikasec.inappprotections`. Resuming main thread! [Pixel 4a::com.pikasec.inappprotections ]-> Target library is loading… Target library loaded at 0x6d5d956000 stat: /data/app/~~MxHO_xqdQgeBWi4_Wpoj4A==/com.pikasec.inappprotections-1XZhJp18cue2EZWsYq4xjA==/base.apk stat: /data/resource-cache/product@overlay@[email protected]@idmap stat: /product/overlay/NavigationBarModeGestural/NavigationBarModeGesturalOverlay.apk access: /data/user/0/com.pikasec.inappprotections access: /data/user/0/com.pikasec.inappprotections/cache access: /data/user_de/0/com.pikasec.inappprotections access: /data/user_de/0/com.pikasec.inappprotections/code_cache stat: /system/framework/framework-res.apk stat: /data/resource-cache/product@[email protected]@idmap stat: /product/overlay/GoogleConfigOverlay.apk stat: /data/resource-cache/product@[email protected]@idmap stat: /product/overlay/GoogleWebViewOverlay.apk stat: /data/resource-cache/product@overlay@[email protected]@idmap stat: /product/overlay/NavigationBarModeGestural/NavigationBarModeGesturalOverlay.apk access: /system/xbin/su access: /system/bin/su detectRoot ret value is 669 access: /dev/hwbinder stat: /vendor/lib64/hw/gralloc.sm6150.so
|
可以看到detectRoot()函数的执行情况:该函数被调用后,应用程序尝试使用access函数访问某些系统路径。这些特定路径往往存在于root过的设备上,在访问这些路径之后,该函数根据常量值确定是否检测到已root的设备。
探索绕过这种Root检测机制的潜在策略,一种可行的方法是操纵access函数检查的路径,并提供一个假路径。这种更改会误导应用程序认为“su”二进制路径不存在,从而提示它返回非root状态。为了实现这个解决方法,让相应地修改脚本:
JavaScript Interceptor.attach(Module.findExportByName(“libc.so”, “access”), { onEnter: function (args) { var path = args[0].readCString(); if(path.indexOf(“/su”) >= 0){ console.log(`Manipulating su path…`); args[0].writeUtf8String(“/system/nonexisting”); } console.log(`access: ${args[0].readCString()}`); } })
|
输出:
JavaScript Manipulating su path… access: /system/nonexisting access: ing Manipulating su path… access: /system/nonexisting access: onexisting Manipulating su path… access: /system/nonexisting fopen: stat: /sys/fs/selinux/class/security/index detectRoot ret value is 572
|
这次可以观察到访问函数现在试图访问修改后的不存在路径,而不是su路径。但是应用程序UI仍然表明检测到root设备,这可能是因为该应用程序检测root的地方不止这一种(剧透下,这个应用程序检测root用了4个地方来检测root)
Root检测#2
继续查看输出日志,可以观察到,在返回值之前,它正在调用一个stat函数,该函数试图访问selinux策略文件。可以假设应用程序正在尝试访问这些selinux策略以检测root。为了再次绕过此检查,可以使用相同的方法,使用frida更改传递给stat函数的输入参数:
JavaScript Interceptor.attach(Module.findExportByName(“libc.so”, “stat”), { onEnter: function (args) { var path = args[0].readCString(); if(path.indexOf(“/selinux”) >= 0){ console.log(`Manipulating selinux path…`); args[0].writeUtf8String(“/non/existing”); } console.log(`stat: ${args[0].readCString()}`); } })
|
再次运行脚本以查看控制台日志中的更改了。
JavaScript access: /system/nonexisting fopen: Manipulating selinux path… Error: access violation accessing 0x6d5d9568f8 at(frida/runtime/core.js:147) at onEnter (/home/kali/Documents/trainings_2023/root_detection_bypass/root_bypass.js:58) detectRoot ret value is 628
|
在输出中可以看到报告了一个错误:“访问0x6d5d9568f8的访问冲突” , 此错误日志表明Frida试图篡改或覆盖此特定内存位置的值可能存在问题。对于这种情况有一个可用的解决方案:可以利用另一个FridaAPI Memory. protected()来修改相关内存空间的权限。
JavaScript Memory.protect(args[0],Process.pointerSize, ‘rwx’);
|
再次运行脚本,看看这是否解决了问题。
JavaScript Manipulating su path… access: /system/nonexisting access: ing Manipulating su path… access: /system/nonexisting access: onexisting Manipulating su path… access: /system/nonexisting fopen: Manipulating selinux path… stat: /non/existing Manipulating selinux path… stat: /non/existing Manipulating selinux path… stat: /non/existing Manipulating selinux path… stat: /non/existing fopen: /proc/self/attr/prev detectRoot ret value is 560
|
很明显,在控制台日志中,可以观察到stat函数输入路径已被修改,如果查看应用程序,它仍然显示root检测到。所以很明显,仅仅绕过这一点是不够的,需要更多地分析它以识别应用程序中存在的其他检查。
Root检测#3
在这些stat调用之后的控制台日志中,发现又调用了另一个导入的函数,即fopen(),它试图访问/proc/self/attr/prev。adb shell访问这个文件,看看这个文件中有什么。
JavaScript adb shell cat /proc/self/attr/prev u:r:zygote:s0
|
这里写着zygote。在非root设备上检查同一个文件,看看它的内容:
JavaScript cat /proc/self/attr/prev u:r:untrusted_app:s0:c7,c257,c512,c768
|
可以发现这个文件内值时不同,熟悉安卓root的同学一般看到zygote的时候,其实应该猜测的到使用了magisk zygisk模块。因此,要绕过此检查,可以禁用zygisk模块(这里就打开手机上的magisk,找到zygisk开关将其关闭即可),也可以尝试使用Frida绕过此检查,这里重点讲述frida root的方式。
根据对这个文件的理解,可以假设应用程序必须在某个阶段进行字符比较,以检查这个文件是否包含“zygote”。再次查看Ghidra中的字符串部分:
在列出的字符串中,确实有zygote。接下来,必须找出负责进行此字符串比较的函数,回到Ghidra中导入的函数:
在这里,有strstr函数可用,它可以用于字符串比较。hook下这个函数并截取它的参数。
JavaScript Interceptor.attach(Module.findExportByName(“libc.so”, “strstr”), { onEnter: function (args) { console.log(`strstr: haystack -> ${args[0].readCString()} & needle -> ${args[0].readCString()}`); } })
|
执行后输出
JavaScript Manipulating selinux path… stat: /non/existing Manipulating selinux path… stat: /non/existing fopen: /proc/self/attr/prev strstr: haystack -> u:r:zygote:s0 & needle -> zygote detectRoot ret value is 571
|
正如所预期的,在fopen函数之后,它调用strstr函数并在此字符串中寻找zygote字符,那么只需要使用frida hook下修改下 zygote 为任意其他字符就可以了
JavaScript Interceptor.attach(Module.findExportByName(“libc.so”, “strstr”), { onEnter: function (args) { var needle = args[1].readCString(); if(needle.indexOf(“zygote”) >= 0){ args[1].writeUtf8String(“blabla”); console.log(`strstr: haystack -> ${args[0].readCString()} & needle -> ${args[1].readCString()}`); } } })
|
再次运行脚本,看看这次得到了什么:
JavaScript Manipulating su path… access: /system/nonexisting access: ing Manipulating su path… access: /system/nonexisting access: onexisting Manipulating su path… access: /system/nonexisting Manipulating selinux path… stat: /non/existing Manipulating selinux path… stat: /non/existing Manipulating selinux path… stat: /non/existing Manipulating selinux path… stat: /non/existing fopen: /proc/self/attr/prev strstr: haystack -> u:r:zygote:s0 & needle -> blabla fopen: /proc/self/mountinfo detectRoot ret value is 532
|
现在可以观察到,第二个参数现在被字符串blabla修改了,但是应用程序仍然检测到root设备。
Root检测#4
再次启动Ghidra并尝试寻找其他检测逻辑。在分析子程序时,可以看到一个有趣的指令:SVC0x0。
这些指令用于使用系统调用号调用函数。由于我们正在处理Arm64二进制文件,让我们打开该架构的系统调用映射表。您可以在此处找到系统调用的完整列表:https://chromium.googlesource.com/chromiumos/docs/+/master/constants/syscalls.md#arm64-64_bit
抛出编号列后在第一列中,有对应的函数名称,然后在第三列中,有系统调用号,正如这里指定的,它存储在X8寄存器中,然后函数的参数将存储在从X0到X5的寄存器中。根据刚刚看到的反汇编代码分析一下:
JavaScript 0x00001994 08078052 mov w8, 0x38 0x00001998 010000d4 svc 0
|
0x38存储在这里的w8寄存器中,可以将这个系统调用号与这个表匹配,以找出调用的函数。在表中,这个系统调用号对应的函数是openat()。这个函数类似于open()函数,它将在内存中打开文件以进行读取或写入。这是一个很好的候选者,开发人员使用这种技术来隐藏函数名称,以免被静态分析。
要在所有这些SVC指令上附加frida interceptor,首先需要找出所有这些指令存在的偏移量。借助Ghidra搜索指令模式功能,可以很容易地做到这一点。
发现所有的SVC如下所示:
可以使用frida hook openat函数
JavaScript function hookSVC(base_addr){ Interceptor.attach(base_addr.add(0x00001998), function(){ var path = this.context.x1.readCString(); console.log(`svc: ${path}`); }) Interceptor.attach(base_addr.add(0x000019bc), function(){ var path = this.context.x1.readCString(); console.log(`svc: ${path}`); }) Interceptor.attach(base_addr.add(0x000019dc), function(){ var path = this.context.x1.readCString(); console.log(`svc: ${path}`); }) Interceptor.attach(base_addr.add(0x00001a00), function(){ var path = this.context.x1.readCString(); console.log(`svc: ${path}`); }) Interceptor.attach(base_addr.add(0x00001a20), function(){ var path = this.context.x1.readCString(); console.log(`svc: ${path}`); }) }
|
运行脚本,看看它是否显示了任何有趣的内容。
JavaScript svc 56: /system/xbin/su svc 56: /system/bin/su svc 56: /sbin/su svc 56: /system/bin/.ext/su svc 56: /system/sd/xbin/su detectRoot ret value is 566
|
从输出中可以清楚地观察到这些SVC指令试图访问su二进制路径。现在可以通过在X1寄存器中传递一个不存在的路径来轻松绕过这个问题。
JavaScript Interceptor.attach(base_addr.add(0x000025a0), { onEnter: function (args) { var path = Memory.readCString(this.context.x1); this.context.x1.writeUtf8String(“/non/exist”); console.log(`svc ${this.context.x8.toInt32()}: ${path}`); } })
|
可以看到,该应用程序终于不再显示检测到root,表明已成功绕过所有检查!
原文始发于微信公众号(暴暴的皮卡丘):Frida Hook(六) – 安卓Root检测及绕过方式实战分析