修补已安装Android应用的.so文件
如果我们安装了Android APK并拥有root权限,我们可以修改该应用的原生(.so)文件而不会改变签名。即使AndroidManifest.xml中的extractNativeLibs设置为false,这种方法仍然有效。我们还可以修补AOT编译文件(ODEX/VDEX)而不改变包签名,但那是另一个话题,本文重点讨论原生代码。
注意:这不是漏洞,需要root权限。此方法在Mystique漏洞利用演示(2022)中讨论过。我想展示这对渗透测试很有用,并额外分享如何用C语言编写二进制补丁的技巧。
背景
我正在对一个具有复杂RASP(运行时应用自我保护)的Android应用进行渗透测试。面临许多挑战:
- 如果解压APK文件并重新打包,它可以检测到签名变化
- 如果使用Frida,它可以在内存中检测Frida,即使我使用fridare更改名称
- 它可以检测Zygisk,因此所有使用Zygisk的注入方法都会被检测
- 它可以检测任何函数的钩子,不仅仅是PLT。似乎通过扫描函数序言来检查是否跳转到二进制外部位置;应用开发者需要手动调用此检查(这是相当昂贵的操作),通常在执行某些关键场景之前进行
- RASP使用混淆的原生库
如果有足够时间,我相信可以跟踪和修补所有内容,但我们时间有限,只要求检查特定功能。查看该特定功能时,我看到它是在未混淆的库中原生实现的。在这种情况下,如果我能修补原生代码而不改变签名,就不需要处理所有反Frida、反钩子等防护。
Android原生库安装
Android 6.0之前,所有原生库在安装期间都会被提取。因此,当应用安装时,原始APK文件和提取的库都存储在设备上,这为用户占用了相当多的额外空间。
从Android 6开始,AndroidManifest.xml中有一个名为extractNativeLibs的设置。如果设置为true,则行为与之前版本相同。如果设置为false,库不会被提取,但库必须在APK内未压缩且按页面对齐存储(使用zipalign)。此设置设为false时,APK会更大,但安装时不会为提取的库占用额外空间。
因为库未压缩且处于页面对齐位置,Android可以直接将库mmap到内存。自2020年2月的Android Gradle Plugin 3.6.0起,extractNativeLibs默认为false,这是我们处理近期应用时的设置。
我们可以使用以下命令查看Android应用的APK文件安装位置:adb shell pm path com.example.package。如果我们有拆分APK,那么原生库存储在单独的APK中,否则所有内容都在一个APK(base.apk)中。
APK签名在安装期间检查,但仅在启动时再次检查。这很合理:二进制APK可能非常大(数百MB),每次应用启动时重新验证需要时间。与iOS对每个可执行二进制文件签名(如果从应用商店安装还会加密二进制文件)不同,Android没有类似机制。
如果extractNativeLibs设置为true,我们可以直接用新文件覆盖提取的.so文件。如果extractNativeLibs设置为false,我们仍然可以将库放在如果extractNativeLibs设置为true时会使用的目录中。
例如,如果APK路径是:
/data/app/~~xa3ANgaSg-DH4SuFIlqKLg==/com.tinyhack.testnative-FFtQq51Ol3Dmg2qvpJAYRg==/base.apk
假设我们使用64位Android,如果我们将修补的库放在(我们移除base.apk并用lib/arm64替换):
/data/app/~~xa3ANgaSg-DH4SuFIlqKLg==/com.tinyhack.testnative-FFtQq51Ol3Dmg2qvpJAYRg==/lib/arm64
那么,将加载此库而不是APK内同名的库。
为了证明这一点,我创建了一个小应用。这是首次安装时的输出。
原始安装
JNI代码非常简单:
|
|
添加库:
adb push libnativelib.so /data/local/tmp
然后复制到实际目标位置(使用pm path找到):
adb shell su -c cp /data/local/tmp/libnativelib.so /data/app/~~h8ArfmhA33K6xLYS0-KLSQ==/com.tinyhack.testnative-FKHrlxPDhIqH_YyxoglHzw==/lib/arm64
这是我放入修补后的原生库后的输出。有两处不同:我将消息从"C++“改为"CXX”,路径现在不同(路径中不列出base.apk)。
放入修补的libnativelib.so后
此更改将在重启后保留,因为它不触及APK。
对于未来阅读此文的任何人,这在Android 13上有效
测试使用的设备
修补APK内的文件
我们可以做的另一件事是直接修补APK内的库。然后我们可以覆盖安装的APK,应用将正常运行,但重启后,应用将被卸载,因为签名无效。
覆盖库必须在相同偏移量处进行。因此我们不能只是重新压缩文件(偏移量会改变),需要使用十六进制编辑器,或编写代码在特定偏移量处修补APK。
使用HxD编辑APK
首先将APK推送到可写目录:
adb push .\a.apk /data/local/tmp/
然后复制到pm path com.example.packagename显示的目标位置:
adb shell su -c cp /data/local/tmp/a.apk /data/app/~~xa3ANgaSg-DH4SuFIlqKLg==/com.tinyhack.testnative-FFtQq51Ol3Dmg2qvpJAYRg==/base.apk
我们可以看到输出:路径仍在base.apk内,但文本已更改
路径在base.apk内
我们还可以修补任何未压缩的文件(APK内的压缩方法为"Stored"),例如:编辑资源名称或值。理论上压缩文件也可以编辑,但如果压缩文件大小不同,则需要调整头部。我还没有找到操作APK压缩文件的简单方法。
用C语言编写补丁
既然我们知道可以修改代码并且它能正常运行,是时候编写补丁了。我看到的大多数教程使用汇编语言进行修补,但我们可以用C语言编写二进制补丁。在我的情况下,我想用一个从文件读取的静态数据替换复杂函数。
我的做法是:创建一个C文件,使用section注解注释函数和字符串。
|
|
naked属性用于创建零大小函数,noinline确保此函数不被内联,我们希望它被调用。
然后编写替换函数
|
|
我们需要知道需要调用的所有函数的地址,并使用链接器脚本指定函数的确切地址。我们可以使用Ghidra或其他反汇编器/反编译器获取这些信息。
在这个例子中:我想修补位于0x241278的函数,我发现open位于0xA37670,read位于0xA37680。以下是链接器脚本(linker_script.ld):
|
|
如果你经常这样做,可以通过解析标准C包含文件和Ghidra输出来自动化创建函数存根。
对于字符串:我们希望它在文本区域,但如果空间不足,可以将其放在新section中。如果希望它在文本区域,可以这样注释字符串(注意字符串是只读的,因为它在文本区域):
|
|
然后我们可以这样编译:
clang -O1 -T linker_script.ld -o output.elf code.c
然后我们可以提取要修补的代码到dump.bin:
objcopy --dump-section .my_section=dump.bin output.elf
一些限制:
- 代码大小有限,但如果必要,可以使用dlopen加载另一个库,或使用LIEF添加新section
- 使用未导入的函数不容易(可以直接使用系统调用,直接在内存中解析ELF,或使用硬编码偏移量)
如果我们想使用全局变量,需要在数据段中找到未使用的内存区域。
结论
RASP能否更好地检测这一点?是的,但可能需要更多资源:
- 它可以检测每个加载的库是否来自.apk(假设extractNativeLibs始终为false)
- 它可以哈希所有库并在运行时检查
在我的情况下,如果目标二进制文件被混淆且函数名称不可见,可能会更困难。
当然,RASP的任何检查都可能被绕过,因为它们不能在操作系统级别工作。如果可能,请在应用中使用Google Play Integrity API(或手机供应商提供的其他认证框架)。