前言:
Zygote指的是 Android 操作系统的孵化器进程,即 Zygote 进程,这篇文章讲介绍基于 Zygote 的注入技术的实现方式。
一、Zygote注入技术的原理
Zygote 是一个很重要的进程,因为绝大部分的应用程序进程都是由 Zygote 进程 "fork" 生成的。 "fork" 是 Linux 操作系统中的一种进程复用技术,在这里需要了解的是,如果进程 A 执行 fork 操作生成了进程 B,那么进程 B 在创建时便拥有和进程 A 完全相同的模块信息。
Zygote 注入的最终目的是将指定模块注入游戏进程,但该过程并非是直接把指定模块注入游戏进程,而是先把模块注入 Zygote 进程。 在注入 Zygote 完成之后,在操作系统中启动的应用程序,包括需要注入的游戏,都会由 Zygote 进程 fork 生成,因而在新创建的进程中包含了已注入 Zygote 进程的模块,通过这种间接的方式完成了注入。 Zygote 注入的方式更加隐蔽,由于 Zygote 属于系统级进程,所以其注入方式的功能更加强大。
二、Zygote 注入技术的实现流程
Zygote注入流程图:
Zygote注入需要注意两个关键点:
(1) 目标进程需要在注入 Zygote 完成后启动才能成功被注入。
(2) 成功注入 Zygote 之后启动的新进程将包含已注入 Zygote 的模块信息,所以需要在新启动的进程执行前获得控制权,然后判断当前进程是否为目标进程,如果是,则执行其余代码,否则交还控制权。
三、Zygote 注入器的实现方式
注入器是整个流程的开始,也是整个 Zygote 注入流程中最重要的一环,它的作用是把指定的模块注入 Zygote 进程中。
注入器的实现流程:
上图阐述了注入器怎样跨进程执行代码,以及在Linux进程之间通信。
注入器总共执行了三次跨进程调用。
(1)首先调用 mmap 函数申请目标进程的地址空间,用于保存注入的 shellcode 代码。
(2)执行注入的 shellcode (shellcode 是注入目标进程中并执行的汇编代码)。
(3)调用 munmap 函数释放之前申请的内存。
shellcode实现的功能为:将指定模块加载到目标进程中,并运行该模块的函数代码。
注入器各功能的实现过程:
1.关闭 SeLinux
SeLinux 是 Linux 的一个安全子系统,在 Android 4.4 以后的版本中默认打开该功能。SeLinux 功能会影响注入的实现,因此需要事先关闭 SeLinux 功能。
关闭 SeLinux 功能时,需要进行如下三步操作。
(1)获取 SeLinux 的配置目录。有两种方法,一种是查看 "/sys/fs/selinux" 目录的文件系统状态,如果等于 "SELINUX_MAGIC"(0xf97cff8c),则为 SeLinux 的配置目录;另一种是通过 "/proc/mounts" 文件查看是否存在 SeLinux 的路径。
(2)获取 SeLinux 配置文件中 SeLinux 功能的开关状态。
(3)关闭 SeLinux。
2.附加到 Zygote,保存进程现场
附加到 Zygote 进程的关键代码如下:
static int Attach(pid_t pid)
{
int res = 0;
int status = 0;
res = ptrace(PTRACE_ATTACH,pid,NULL,NULL);
if(res < 0)
{
LOGE("<injectso.c:%d> attach failed:%s\n", __LINE__, strerror(errno));
return -1;
}
WaitPid(pid, &status, 0);
return res;
}
在 Android 系统中使用 ptrace 函数可操控其他进程。ptrace 函数提供了一系列宏来完成不同的功能,可参考 ptrace 注入部分的函数介绍。
附加的操作比较简单,可使用 PTRACE_ATTACH 的方式。涉及进程通信的 WaitPid 函数代码如下:
int WaitPid(pid_t pid, int *status, int option)
{
while(waitpid(pid, status, option) == -1)
{
if(errno == EINTR)
continue;
else
return -1;
}
return 0;
}
在 Android 系统中进程同步的信号机制跟 Linux 相似。注入器对 Zygote 进程附加之后,Zygote 进程会接到一个 SIGSTOP 信号,接下来它会处理这个信号。这时注入器就需要等待 Zygote 进程,一般是阻塞式的等待,需要调用 WaitPid 函数处理等待操作。
waitpid 是一个 Linux 系统函数,它有几个选项,具体可以参考 http://linux.die.net/man/3/waitpid。waitpid 最常用的功能是阻塞当前进程,等待指定进程的状态发生变化。
在 options 为 0 时,将使用默认的阻塞式等待,直到 Zygote 进程处理完 SIGSTOP 信息。当 Zygote 进程停止时,进程的状态发生了变化, waitpid 函数将返回 Zygote 的进程状态,此时注入器被唤醒。
在 WaitPid 函数的内部调用了 waitpid 函数,同时包括一个循环和 errno 的判断处理。 WaitPid 函数的功能为:如果注入器收到使 waitpid 中断的信号,则不处理该信号并继续等待;若为其他错误(如参数错误等),则直接返回。
Zygote 停止后,注入器利用 PTRACE_GETREGS 方式获取寄存器并备份执行环境,在恢复进程时可获得原始的执行环境。
3.获取 Zygote 进程中关键函数的地址
以 mprotect 函数为例(mprotect 函数实现于 libc.so 模块中),讲解如何获取 Zygote 进程中关键函数的地址。
首先在注入器的进程中加载 libc.so 模块,并获得 libc.so 模块的基址(定义为 cbase 变量);然后获取注入器进程中 mprotect 函数的内存地址(定义为 maddr 变量),将 mprotect 函数的内存地址减去 libc.so 模块的基址,得到该函数相对偏移(定义为 offset 变量),计算方式为:offset = maddr - cbase;最后获取 libc.so 模块在 Zygote 进程中的基址(定义为 zcbase 变量),Zygote 进程中 mprotect 函数的地址计算方式为: zcbase + offset。
在 Zygote 进程中获取关键函数的地址代码如下:
// get remote mmap, munmap, mprotect func addr
libcHandler = dlopen(LIBC_PATH, RTLD_NOW);
mmap_self_addr = dlsym(libcHandler, MMAP_NAME);
munmap_self_addr = dlsym(libcHandler, MUNMAP_NAME);
mprotect_self_addr = dlsym(libcHandler, MPROTECT_NAME);
mmap_remote_addr = getRemoteSymbolAddress(pid, LIBC_PATH, mmap_self_addr);
munmap_remote_addr = getRemoteSymbolAddress(pid, LIBC_PATH, munmap_self_addr);
mprotect_remote_addr = getRemoteSymbolAddress(pid, LIBC_PATH, mprotect_self_addr);
其中,getRemoteSymbolAddress 函数用于获取 Zygote 进程中的函数地址。
getRemoteSymbolAddress 函数的实现如下:
void *getRemoteSymbolAddress(pid_t pid, char *moduleName, void *selfSymbolAddress)
{
void *selfModuleBase = getModuleBase(-1, moduleName);
void *remoteModuleBase = getModuleBase(pid, moduleName);
if(remoteModuleBase == (void *) -1)
return 0;
return (selfSymbolAddress - selfModuleBase + remoteModuleBase);
}
其中,getModuleBase 函数通过读取 "/proc/%d/maps" 文件获取模块的基址,具体实现代码如下:
void *getModuleBase(pid_t pid, char *moduleName) {
FILE *fp;
unsigned long baseValue;
char mapFilePath[256];
char fileLineBuffer[1024];
if (pid < 0)
{
sprintf(mapFilePath, "/proc/self/maps");
}
else
{
sprintf(mapFilePath, "/proc/%d/maps", pid);
}
fp = fopen(mapFilePath, "r");
if (fp == NULL)
return (void *)-1;
baseValue = -1;
while (fgets(fileLineBuffer, sizeof(fileLineBuffer), fp) != NULL)
{
if (strstr(fileLineBuffer, moduleName))
{
char *pszModuleAddress = strtok(fileLineBuffer, "-");
if (pszModuleAddress)
{
baseValue = strtoul(pszModuleAddress, NULL, 16);
if (baseValue == 0x8000)
baseValue = 0;
break;
}
}
}
fclose(fp);
return (void *)baseValue;
}
4.调用 mmap 函数
mmap 函数的定义如下:
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)
根据 ARM 编译器的规则,前4个参数会被直接放入 r0~r3 寄存器中,其余参数会被放入堆栈中,因此如果要调用 mmap 函数,则需要设置调用环境,包括 寄存器和堆栈。
解决堆栈布局的思路为:将参数信息布置到 Zygote 进程的寄存器和堆栈中,设置 PC 寄存器为 mmap 函数的地址,将 lr 寄存器赋值为 0;然后执行 PTRACE_CONT 操作,让 Zygote 进程从 mmap 的函数头开始执行,并让注入器进入暂停状态;当 Zygote 进程执行完 mmap 函数时,会跳转至 lr 寄存器保存的地址中,当跳转地址为 0 时, Zygote 进程进入异常停止状态;最后,注入器调用的 WaitPid 函数将返回 Zygote 进程状态改变的信息。
设置参数的代码实现如下:
// prepare call mmap in the remote process
usingRegisters.uregs[0] = 0;
usingRegisters.uregs[1] = 0x4000;
usingRegisters.uregs[2] = PROT_EXEC | PROT_READ | PROT_WRITE;
usingRegisters.uregs[3] = MAP_ANONYMOUS | MAP_PRIVATE;
usingRegisters.uregs[13] -= sizeof(long);
ptrace(PTRACE_POKEDATA, pid, (void *)(usingRegisters.uregs[13]), 0);
usingRegisters.uregs[13] -= sizeof(long);
ptrace(PTRACE_POKEDATA, pid, (void *)(usingRegisters.uregs[13]), (void *)0xffffffff);
usingRegisters.uregs[15] = (long)mmap_remote_addr;
if (usingRegisters.uregs[15] & 1u) {
usingRegisters.uregs[15] &= (~1u);
usingRegisters.uregs[16] |= CPSR_T_MASK;
} else {
usingRegisters.uregs[16] &= ~CPSR_T_MASK;
}
// call mmap in the remote process
ret = invokeRemoteSyscall(pid, &usingRegisters);
5.配置 shellcode
前面获取了关键函数(包括 dlopen 、 mprotect等)在 Zygote 进程中的地址,然后用 shellcode 配置函数地址。
shellcode 是一段汇编代码,对函数地址、字符串地址的引用都使用了相对地址。
shellcode 的 ARM 汇编代码如下:
_inject_code_start:
NOP
NOP
LDR R1, =0
LDR R0, _so_path_addr
LDR R3, _dlopen_addr
BLX R3
MOV R1, #1
SUBS R4, R0, #0
BEQ 3f
LDR R1, _so_init_func_addr
LDR R3, _dlsym_addr
BLX R3
MOV R1, #2
SUBS R3, R0, #0
BEQ 1f
MOV R1, #3
LDR R0, _so_func_arg_addr
BLX R3
SUBS R0, R0, #0
BEQ 2f
MOV R1, #4
1:
MOV R0, R4
LDR R3, _dlclose_addr
BLX R3
2:
MOV PC, #0
3: LDR R3, _dlerror_addr
BLX R3
MOV R2, R0
MOV PC, #0
其中的标号为相对地址,指向的位置存放了前面已获取的函数和字符串地址。shellcode 代码总共执行了如下三个操作。
(1) 调用 dlopen 函数加载指定模块。
(2) 调用 dlsym 函数获取模块中关键的函数地址。
(3) 调用已获取的关键函数。
其中,shellcode 进行简单的容错处理,最后将 PC 寄存器设置为 0,主动使进程停止,其执行过程与前面调用 mmap 的过程相似。
6. 远程调用 shellcode
接下来需要远程调用 shellcode,跟前面远程调用 mmap 的原理基本一致。唯一的区别在于:在调用 mmap 前,需要将 lr 寄存器设置为 0; shellcode 主动将 PC 寄存器赋值为 0;
shellcode 的调用在 invokeRemoteShellcode函数中实现,其实现代码及解释如下:
// this function do a routing of executing shellcodeDataBuffer in the remote process with ptrace
static int invokeRemoteShellcode(pid_t pid, struct pt_regs *regs) {
int ret=0, waitStatus=0;
do {
// this put our regs setting to the remote process memory space,
//the code must have been stored with POKEDATA at first
ret = ptrace(PTRACE_SETREGS, pid, NULL, regs);
if (ret < 0) {
LOGE("<injectso.c:%d> set regs failed:%s\n", __LINE__, strerror(errno));
break;
}
// let shellcodeDataBuffer enter executing routing
ret = ptrace(PTRACE_CONT, pid, NULL, NULL);
if (ret < 0) {
LOGE("<injectso.c:%d> CONT failed:%s\n", __LINE__, strerror(errno));
break;
}
// wait for the shellcodeDataBuffer to finish executing,this must be triggled by shellcodeDataBuffer
ret = WaitPid(pid, &waitStatus, 0);
if (ret < 0) {
LOGE("<injectso.c:%d> waitpid failed:%s\n", __LINE__, strerror(errno));
break;
}
} while (0);
return ret;
}
7. 调用 munmap 释放内存
在执行完前面的6个步骤后,整个流程的主要功能已经实现。接下来调用 munmap 函数释放之前申请的内存。
通过 PTRACE_CONT 方式调用 munmap 函数代码如下:
// prepare call munmap in the remote process
memcpy(&usingRegisters, &orignalRegisters, sizeof(orignalRegisters));
usingRegisters.uregs[0] = (long)mmap_return;
usingRegisters.uregs[1] = 0x4000;
usingRegisters.uregs[15] = (long)munmap_remote_addr;
if (usingRegisters.uregs[15] & 1u) {
usingRegisters.uregs[15] &= (~1u);
usingRegisters.uregs[16] |= CPSR_T_MASK;
} else {
usingRegisters.uregs[16] &= ~CPSR_T_MASK;
}
// call munmap in the remote process, don't need check result
//because we will restore remote process to the original status right after now.
ret = invokeRemoteSyscall(pid, &usingRegisters);
8. 恢复进程到初始状态
将之前保存的进程寄存器等环境信息备份至 Zygote 进程中,最后调用 Detach 操作,Zygote 进程将继续执行。
四、注入 Zygote的模块功能实现
注入 Zygote 进程的模块也会注入游戏进程中,该模块除包含需要执行的特定功能外,还需要实现如下两个功能:
1.劫持新启动的进程并获得控制权,可通过 Hook 的关键系统函数感知新进程启动事件。
2.获取当前进程的信息并识别是否为目标进程。
针对这两个功能有很多种实现方法,下面介绍其中1种实现方法:
(1)步骤一:通过 Hook 系统模块 /system/lib/libdvm.so 的导出函数 _Z17dvmLoadNativeCodePKcP6ObjectPPc,来拦截进程加载模块的操作。
(2)步骤二:根据 Hook 的函数传入的模块路径,可以判断当前进程是否为目标进程。