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

本文详细介绍了在已安装的Android应用中修改.so原生库文件的技术方法,包括extractNativeLibs设置的影响、APK签名验证机制、以及如何使用C语言编写二进制补丁来绕过RASP防护检测。

修补已安装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到内存。自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代码非常简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <jni.h>
#include <string>
#include <dlfcn.h>

extern "C" JNIEXPORT jstring JNICALL
Java_com_tinyhack_nativelib_NativeLib_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_tinyhack_nativelib_NativeLib_libLocation(
        JNIEnv* env,
        jobject /* this */) {
    //使用dladdr查找当前库位置
    Dl_info info;
    if (dladdr((void *)Java_com_tinyhack_nativelib_NativeLib_libLocation, &info)) {
        return env->NewStringUTF(info.dli_fname);
    }
    std::string hello = "Can't find library location";
    return env->NewStringUTF(hello.c_str());
}

添加库: 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注解注释函数和字符串。

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
void __attribute__((section(".my_section"))) mycode(char *param1, int param2) {
//代码在这里
}

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

在此示例中:我想修补位于0x241278的函数,我发现open位于0xA37670,read位于0xA37680。以下是链接器脚本(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) }
}

如果经常这样做,可以通过解析标准C包含文件和Ghidra输出来自动化创建函数存根。

对于字符串:我们希望它在文本区域,但如果空间不足,可以将其放在新section中。如果希望它在文本区域,可以这样注释字符串(注意字符串是只读的,因为它在文本区域):

1
2
3
4
5
6
7
8
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);
    }
}
void main() {} //虚拟main,这样编译器不会报错

然后我们可以这样编译: 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(或手机供应商提供的其他认证框架)。

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