Android应用.so文件补丁修改技术详解

本文详细介绍了在已安装的Android应用中修改.so文件的技术方法,包括extractNativeLibs设置的影响、绕过RASP检测的技巧,以及使用C语言编写二进制补丁的实践指南。

修补已安装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使用原生库,该库已被混淆

如果有足够时间,我相信可以追踪并修补所有内容,但我们时间有限,而且只要求检查特定功能。

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,这是我们处理近期应用时的设置。

修补方法

如果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内同名的库。

使用C语言编写补丁

知道我们可以改变代码并且它能正常运行后,是时候编写补丁了。我见过的大多数教程使用汇编语言进行修补,但我们可以使用C编写二进制补丁。

我创建C文件,使用section注解注释函数和字符串:

1
2
3
4
5
6
//open
int __attribute__((naked)) __attribute__((noinline)) __attribute__((section(".my_open_section"))) my_open(const char *filename, int flags) {
}

//read
int __attribute__((naked)) __attribute__((noinline)) __attribute__((section(".my_read_section"))) my_read(int fd, void *buf, int count) {}

naked属性用于创建零大小函数,noinline确保此函数不被内联,我们希望它被调用。

然后编写替换函数:

1
2
3
4
5
6
7
void __attribute__((section(".my_section"))) mycode(char *param1, int param2) {
    static const char filename[] __attribute__((section(".my_section_string"))) = "/data/data/com.example.com/files/camera.bin";
    int fd = my_open(filename, 0);
    if (fd >0) {
        my_read(fd, param1, param2);
    }
}

我们需要知道要调用的所有函数的地址,并使用链接器脚本指定函数的确切地址。可以使用Ghidra或其他反汇编器/反编译器获取这些地址。

链接器脚本示例(linker_script.ld):

1
2
3
4
5
SECTIONS {
    .my_section       0x241278 : { *(.my_section) *(.my_section_string) }
    .my_open_section  0xA37670 : { *(.my_open_section) }
    .my_read_section  0xA37680 : { *(.my_read_section) }
}

编译命令: clang -O1 -T linker_script.ld -o output.elf code.c

然后提取要修补的代码: objcopy --dump-section .my_section=dump.bin output.elf

限制

  • 代码大小有限,但必要时可以使用dlopen加载另一个库,或使用LIEF添加新section
  • 使用未导入的函数不容易(可以直接使用系统调用,直接在内存中解析ELF,或使用硬编码偏移量)

结论

RASP能否更好地检测此方法?是的,但可能需要更多资源:

  • 它可以检测每个加载的库是否来自.apk(假设extractNativeLibs始终为false)
  • 它可以哈希所有库并在运行时检查

当然,RASP的任何检查都可能被绕过,因为它们无法在操作系统级别工作。如果可能,请在应用中使用Google Play Integrity API(或手机供应商提供的其他认证框架)。

测试设备:Android 13 有效性:此方法在Android 13上仍然有效

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计