修补已安装Android应用的.so文件
如果我们安装了Android APK并拥有root权限,我们可以修改该应用的原生(.so)文件而无需更改签名。即使AndroidManifest.xml中的extractNativeLibs设置为false,这种方法仍然有效。我们还可以修补AOT编译文件(ODEX/VDEX)而不改变包签名,但这是另一个话题,本文将专注于原生代码。
注意:这不是漏洞,需要root权限。此方法在Mystique漏洞利用演示(2022)中讨论过。本文旨在展示这对渗透测试的实用性,并额外分享如何使用C语言编写二进制补丁的技巧。
背景
我在对一个具有复杂RASP(运行时应用自我保护)的Android应用进行渗透测试时遇到了多个挑战:
- 解压APK后重新打包会被检测到签名变更
- 使用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到内存。自Android Gradle Plugin 3.6.0(2020年2月)起,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(或其他手机供应商提供的认证框架)。