对Android平台上的注入和Hook的原理解析,在文末也顺带提了一下Android的反调试技术。
1.概述
我们平时说的HOOK其实是注入和HOOK加在一起,HOOK之前一般都是先注入自己的so,再替换原函数,执行注入函数。
注入的本质:进程的暂停(含控制权的交接),去执行你想要的操作后,进程再恢复运行;
Hook的本质:先注入再替换;先HOOK住目标函数,再执行替换的注入代码。
2.注入
A.基本原理
进程注入就是将一段代码拷贝到目标进程,然后让目标进程执行这段代码的技术。由于这样的代码构造起来比较复杂,所以实际情况下,只将很少的代码注入到目标进程,而将真正做事的代码放到一个共享库中,即.so文件。被注入的那段代码只负责加载这个.so,并执行里面的函数。
B.分类
(1)so注入
静态注入
静态注入,静态解析ELF文件,增加一个依赖SO,或新增一个section节(注入代码在section字段),代码节是自己的注入代码,然后修复ELF文件结构。
动态注入
由于Android是基于linux内核的操作系统,所以Android下的注入也是基于Linux下的系统调用函数ptrace()
实现的。即在获得root权限后,通过ptrace()系统调用将stub(桩代码)注入到指定pid的进程中。
A.ptrace函数
ptrace函数的原型如下所示,其中request为行为参数,该参数决定了ptrace函数的行为,pid参数为远程进程的ID,addr参数与data参数在不同的request参数取值下表示不同的含义。
long ptrace(enum __ptrace_request request, pid_t pid, void addr, void data);
ptrace注入进程的过程中需要使用到的request参数(部分)——
PTRACE_ATTACH,表示附加到指定远程进程;
PTRACE_DETACH,表示从指定远程进程分离
PTRACE_GETREGS,表示读取远程进程当前寄存器环境
PTRACE_SETREGS,表示设置远程进程的寄存器环境
PTRACE_CONT,表示使远程进程继续运行
PTRACE_PEEKTEXT,从远程进程指定内存地址读取一个word大小的数据
PTRACE_POKETEXT,往远程进程指定内存地址写入一个word大小的数据
PTRACE_TRACEME,表示本进程将被其父进程跟踪,交付给这个进程的所有信号(除SIGKILL之外),都将使其停止,父进程将通过wait()获知这一情况。
B.ptrace注入
有两种实现ptrace注入模块到远程进程的方法。
方法一:直接远程调用dlopen\dlsym等函数加载被注入模块并执行指定的代码(dlopen以指定模式打开指定的动态连接库文件,并返回一个句柄给调用进程,dlsym根据动态链接库操作句柄与符号,返回符号对应的地址,不但可以获取函数地址,也可以获取变量地址)。其主要流程如下——
假设我们需要注入到远程的/system/bin/surfaceflinger进程,并且在该进程中注入/data/libhello.so模块,执行该模块的hook_entry方法。
- 获取待注入进程的pid,即获取/system/bin/surfaceflinger进程的pid;
- attach到远程进程;
- 保存寄存器环境;
- 远程调用mmap来远程分配内存空间(获取到远程mmap函数的地址;构造mmap参数,并远程调用mmap函数;得到分配的内存空间的初始地址,并将注入模块写入该内存);
- 远程打开/data/libhello.so模块(获取到远程dlopen函数的地址;构造dlopen参数,并远程调用dlopen函数);
- 远程调用dlsym函数来实现对注入的libhello.so模块的hook_entry方法的调用;
- 关闭远程调用,恢复寄存器环境并detach进程。
如何获取远程函数的地址?——mmap函数是在”/system/lib/libc.so”模块中,dlopen、dlsym与dlclose函数均是在”/system/bin/linker”模块中;读取”/proc/pid/maps”可以获取到系统模块在本地进程和远程进程的加载基地址;要获取远程进程内存空间中mmap等函数的虚拟地址,可通过计算本地进程中mmap等函数相对于模块的地址偏移,然后使用此地址偏移加上远程进程对应模块的基地址,这个地址就是远程进程内存空间中对应函数的虚拟地址。
方法二:使用ptrace将shellcode注入到远程进程的内存空间中,然后通过执行shellcode加载远程进程模块。其主要流程如下——
- 在目标进程中分配内存,用来写shellcode和参数;
- 往目标进程中写入shellcode, shellcode会调用dlopen来载入我们的library;
- 运行目标进程中的shellcode。
Android ptrace进程注入原理
Android中的so注入(inject)和挂钩(hook) - For both x86 and arm
C.GDB ptrace调试原理
gdb就是基于ptrace这个系统调用来做的。其原理是利用ptrace系统调用,在被调试程序和gdb之间建立追踪关系。然后所有发送给被调试程序(被追踪线程)的信号(除SIGKILL)都会被gdb截获,gdb根据截获的信号,查看被调试程序相应的内存地址,并控制被调试的程序继续运行。GDB常用的使用方法有断点设置和单步调试。
gdb的2种调试模式——
gdb启动应用程序——通过fork函数创建一个新进程,在子进程中执行ptrace(PTRACE_TRACEME, 0, 0, 0)函数,然后通过execv()调用准备调试的程序。
attach到现有进程——debugger可以调用ptrace(PTRACE_ATTACH,pid,…),建立自己与进程号为pid的进程间的跟踪关系。即利用PTRACE_ATTACH,使自己变成被调试程序的父进程(用ps可以看到)。用attach建立起来的跟踪关系,可以调用ptrace(PTRACE_DETACH,pid,…)来解除。注意attach进程时的权限问题,如一个非root权限的进程是不能attach到一个root进程上的。
断点原理——
- 断点的实现原理,就是在指定的位置插入断点指令,当被调试的程序运行到断点的时候,产生SIGTRAP信号。该信号被gdb捕获并进行断点命中判定,当gdb判断出这次SIGTRAP是断点命中之后就会转入等待用户输入进行下一步处理,否则继续。
- 断点的设置原理: 在程序中设置断点,就是先将该位置的原来的指令保存,然后向该位置写入int 3。当执行到int 3的时候,发生软中断,内核会给子进程发出SIGTRAP信号,当然这个信号会被转发给父进程。然后用保存的指令替换int3,等待恢复运行。
- 断点命中判定:gdb把所有的断点位置都存放在一个链表中,命中判定即把被调试程序当前停止的位置和链表中的断点位置进行比较,看是断点产生的信号,还是无关信号。
单步跟踪原理——
ptrace本身支持单步功能,调用ptrace(PTRACE_SINGLESTEP,pid,…)即可。
D.参考
(2)dex注入
多基于so注入实现,具体步骤及参考如下——
- 将.so注入目标进程,执行.so文件中的某个函数。
- 在这个函数里先获得一个JNIEnv指针,通过这个指针就可以调JNI函数了。
- 反射得到当前应用进程的PathClassLoader,用这个ClassLoader来构造一个DexClassLoader对象。Dex文件路径作为一个参数传入DexClassLoader的构造函数,另一个重要的参数是,一个具有可写权限的文件夹路径。因为在做dex优化时,需要生成优化过的dex文件,这跟生成/data/dalvik-cache/下的dex文件是一个道理。
- 通过这个DexClassLoader对象,来加载目标类,然后反射目标类中的目标函数。最终调用之。
3.Hook
A.概述
Hook的五大核心问题如下——
- 如何附加目标:Ptrace
- 注入的临时代码放哪里:mmap函数可以分配一段临时的内存来存放
- 如何找到内存中加载的mmap函数地址,目标so的函数地址,以及加载hook.so的地址:使用dlopen/dlsym函数
- HOOK的实质为什么是注入:其实就是钩住目标函数,执行的是替换的注入代码
- HOOK为什么需要对ELF文件有深入理解:HOOK的常用方法就是替换目标函数,即函数重定向,把原本进程要执行的老函数地址,重定向到新函数地址,新函数运行后,再回到原本进程去运行,所以此时ELF文件结构的解析就尤为重要,如symbol符号、got表等等。
B.分类
Android平台上的Hook主要可以分为以下几类——
- java层的Hook(包括Dalvik Hook、ART Hook)
- native层的Hook(即so Hook,包括got Hook、inline Hook)
接下来先放一张so Hook的两种技术的对比图,然后对其基本原理进行简介。
(1)got Hook
linux的延时绑定策略
这个策略是为了解决原本静态编译时要把各种系统API的具体实现代码都编译进当前ELF文件里导致文件巨大臃肿的问题。
在动态链接的ELF程序里调用共享库的函数时,第一次调用时先去查找PLT表中相应的项目,而PLT表中再跳跃到GOT表中希望得到该函数的实际地址,但这时GOT表中指向的是PLT中那条跳跃指令下面的代码,最终会执行_dl_runtime_resolve()并执行目标函数。第二次调用时也是PLT跳转到GOT表,但是GOT中对应项目已经在第一次_dl_runtime_resolve()中被修改为函数实际地址,因此第二次及以后的调用直接就去执行目标函数,不用再去执行_dl_runtime_resolve()了。
原理及特点
基本原理
PLT Hook通过直接修改GOT表,使得在调用该共享库的函数时跳转到的是用户自定义的Hook功能代码。
特点
- 可以大量Hook那些系统API,但是难以精准Hook住某次函数调用。
- 对于一些so内部自定义的函数无法Hook到。因为这些函数不在PLT表和GOT表里。
- PLT Hook在hook目标函数时,如果需要回调原来的函数,那就在Hook后的功能函数中直接调用目标函数即可。
应用方向
开发者的利器,对自家开发的APP的性能监控。
开源工具
爱奇艺开源的xHook工具库,这个工具库主要是用于开发者开发时把该项目集成进自己的APP,然后使用这个工具库来帮助开发者监控APK运行时那些他们关心的性能数据。比如通过hook malloc来监控内存分配等。由于这个库是被开发者集成进了APP中,所以它对于这个app的监控是不需要Root权限的。
(2)inline Hook
基本原理
在代码段中插入跳转指令,从而把程序执行流程引向用户需要的功能代码中去,以此达到Hook的效果。(arm、thumb模式下的原理图如下。)
基本步骤
- 在想要Hook的目标代码处备份下面的几条指令,然后插入跳转指令,把程序流程转移到一个stub段上去。
- 在stub代码段上先把所有寄存器的状态保存好,并调用用户自定义的Hook功能函数,然后把所有寄存器的状态恢复并跳转到备份代码处。
- 在备份代码处把当初备份的那几条指令都执行一下,然后跳转到当初备份代码位置的下面接着执行程序。
难点
- 对于不同指令模式的兼容(thumb/thumb-2、arm32、arm64);
- PC相关指令的修复(如何正确的还原执行备份代码)。
特点
- 完全不受函数是否在PLT表中的限制,直接在目标so中的任意代码位置都可进行Hook。这个Hook精准度是汇编指令级的。
- 对Hook功能函数的限制较小。
- 相对于PLT Hook的强制批量Hook的特性,Native Hook要灵活许多。当想要进行批量Hook一些系统API时也可以直接去找内存里对应的如libc.so这些库,对它们中的API进行Hook,这样的话,所有对这个API的调用也就都被批量Hook了。
应用方向
适合APP逆向人员,软件分析人员,CTF Android逆向解题等。
开源框架
参考
- Arm Inline hook的简易原理图
- Android Native Hook技术路线概述
- Android Native Hook工具实践
- ARM64下的Android Native Hook工具实践
- Android Inline Hook中的指令修复详解
- Android Arm Inline Hook
4.Android平台上常用Hook注入框架
A.Xposed(java层)
(1)原理
Xposed框架核心思想在于将java层普通函数注册成本地JNI方法,以此来变相实现hook机制。
通过替换/system/bin/app_process程序控制zygote进程,使得app_process在启动过程中会加载XposedBridge.jar这个jar包,从而完成对Zygote进程及其创建的Dalvik虚拟机的劫持。
在Android系统中,应用程序进程都是由Zygote进程孵化出来的,而Zygote进程是由Init进程启动的。Zygote进程在启动时会创建一个Dalvik虚拟机实例,每当它孵化一个新的应用程序进程时,都会将这个Dalvik虚拟机实例复制到新的应用程序进程里面去,从而使得每一个应用程序进程都有一个独立的Dalvik虚拟机实例。这也是Xposed选择替换app_process的原因。
(2)检测是否被xposed hook
- Xposed的某个类对象中的三个成员变量中存储了Xposed hook的相关信息,支付宝用反射机制获取到这些变量,检测里面是否包含“alipay”、“taobao”等字样;
- 检测getprop ro.secure是否为1 && /system/bin和/system/xbin目录下是否包含有su二进制程序;
- cat /proc/pid/maps中是否hack、inject、hook、call等字样;
- 主动抛出异常,检测堆栈信息里是否有Xposed等字样。
B.Cydia Substrate(java层+native层)
没怎么用过,后续用过再补介绍。
C.Frida(java层+native层)
同没怎么用过,但其基本原理类似于IDA等调试器的原理,也是要在手机端安装运行一个server实现端口转发,用于和调试器通信。
5.Android反调试技术
主要参考自——Android反调试技术整理与实践和安卓反调试检测技术
基于时间的检测
原理
通过在代码不同的地方获取时间值,从而可以求出这个过程中的执行时间。如果两个时间值相差过大,则说明中间的代码流程被调试了。
特点
这个方法一个不好的特点是需要一定的代码跨度,因此可能需要暴露部分代码逻辑。因为如果两个时间的取值点非常非常近,那很可能调试者在两者之间没有断点从而迅速跳过。
基于文件的检测
- /proc/pid/status 和 /proc/pid/task/pid/status:普通状态下,TracerPid这项应该为0;调试状态下为调试进程的PID。
- /proc/pid/stat 和 /proc/pid/task/pid/stat:调试状态下,括号后面的第一个字母应该为t。
- /proc/pid/wchan 和 /proc/pid/task/pid/wchan:调试状态下,里面内容为ptrace_stop。
检测调试工具是否存在
调试器检测
isDebuggerConnected():安卓系统在android.os.Debug类中提供了isDebuggerConected()方法,用于检测是否有调试器链接。返回值为true则表示被调试。
调试端口检测
netstat命令经常用来查看正在运行应用的本地端口号,pid,uid等信息。而执行cat /proc/net/tcp 也可以来查看正在运行应用的本地端口号。读取/proc/net/tcp的内容,查找23946端口(IDA调试的默认端口),如果找到了该端口则说明进程正在被调试。
进程名称检测
利用ps命令列出所有进程,然后遍历查找,查找固定的进程名,如android_server gdb_server android_x64/86_server mac_server/64等。
父进程检测
检测当前进程的父进程是否是调试进程。
Ptrace自己
由于Linux下每个进程同一时刻最多只能被一个进程调试,因此APP可以通过自己ptrace自己的方式来抢先占坑。
断点扫描
IDA等调试器在调试时候的原理是向断点地址插入breakpoint汇编指令,而把原来的指令暂时备份到别处。因此,本方法通过扫描自身so的代码部分中是否存在breakpoint指令即可。
一般来说Android App有arm模式和thumb模式,因此需要都检查一下以下断点指令:
- Arm:0x01,0x00,0x9f,0xef
- Thumb16:0x01,0xde
- Thumb32:0xf0,0xf7,0x00,0xa0
信号处理
多进程/线程
本方法需要结合上述的一些方法才行,本身并不是一个具体的反调试技术,而是一种编程策略。其思想在于启动一个守护进程/线程来检测主逻辑进程/线程是否被调试,如果被调试就杀死主逻辑进程/线程;或者两个线程/进程互相检测是否被调试或存活等。
子进程/线程对主进程/线程的保护仅如下方法有效:
- 基于文件的检测
- 断点扫描
- 检测调试工具是否存在
- 基于时间的检测(极少)
其它方法可能依然更适合主线程/进程自己实现。
虚拟机/模拟器检测
后期有时间可以尝试实现一个包含上述反调试机制的APK,如开源项目GToad/Android_Anti_Debug所示。