烦恼一般都是想太多了。

0%

在安卓中进行共享库的注入

所谓的代码注入就是让远程进程能够执行我们自己的代码,而本身远程进程中不存在或者不能执行这些代码。同时,我们会将我们的代码编译成一个共享的 so 库,然后让远程进行进行加载到内存空间内。所以能可以把这种操作叫做 共享库注入

Linux 中的 ptrace 系统调用就可以让我们实现这个功能, Android 是基于 Linux 的,所以也可以这样做。

ptrace

ptrace系统调用的完整文档可以看这里

ptrace() 系统调用提供了一个让一个进程(tracer)可以观察、控制、测试、改变另外一个进程(tracee)的内存空间和寄存器的方法,通常其会被用来进行断点调试和系统调用跟踪。

命令格式

#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

需要注意的是 pid 参数这里代表的是一个线程,而不是一个进程;在多线程的进程中,每个线程都可以被单独的进行 attach。

requet 参数是一个枚举值,用来定义我们要进行的操作,具体的参数参看文档,我们只需要其中的几个枚举值就可以了。我们需要先用 PTRACE_ATTACH request 来进行调用 ptrace(),调用执行成功后,我们就获得了 tracee 进程的控制权。我们一般需要下面几个枚举值就行了:

  • PTRACE_ATTACH attach 到目标进程 tracee
  • PTRACE_PEEKTEXT,PTRACE_PEEKDATA 从 tracee 进程内读取一个 word。
  • PTRACE_POKETEXT, PTRACE_POKEDATA 复制一个 word 到 tracee 进程。
  • PTRACE_GETREGS 获取 tracee 进程的通用寄存器信息
  • PTRACE_SETREGS 设置 tracee 进程的通用寄存器信息
  • PTRACE_CONT 恢复已停止的 tracee 进程
  • PTRACE_DETACH 从 tracee 进程剥离

ARM 调用约定

在我们开始之前我们需要了解一下 ARM 的调用约定。当我们在 ARM 架构的 CPU 上调用函数的时候,其四个参数会被存储到通用机器器 R0-R3。如果参数大于了四个,那么第五个以后的参数会被压到栈上,寄存器 SP 就会改变。接着,函数的地址就会被压到 PC 寄存器,函数返回后的执行地址会放到 LR 寄存器。当函数执行完毕后,其返回值会放在 R0 寄存器。

注入过程简述

  1. attach 到 tracee 进程。
  2. 加载共享 so 库到 tracee 进程
  3. 获得我们函数在 tracee 进程内的地址
  4. 保存 tracee 进程的寄存器信息
  5. LR 寄存器设置为 0,这样我们就可以在调用后捕捉到 SIGSEGV 违例。
  6. PC 寄存器设置为我们的地址。
  7. 根据模式(thumb or arm) 来设置 PC, CPSR 的掩码。
  8. 恢复 tracee 进程的执行,同时等待 SIGSEGV 信号
  9. 或者 R0 的值(函数返回值)
  10. 恢复 tracee 的寄存器信息

看起来非常的简单,但是现在我们遇到一个问题。我们如何将共享库加载到 tracee 进程呢?这就是注入的关键,我们后面说。

注入的实现

通常来说,有两种方式来将共享库加载到 tracee 进程。

  1. 利用 ARM 汇编。首先我们在 tracee 进程会分配一块内存,然后我们将函数 dlopen, shellcode 需要的参数都写到内存区域内。最后我们执行 shellcode 工作就完成了。 shellcode 是一小段用 ARM 汇编写的,能够执行加载共享库的代码。因为汇编非常的难阅读,什么我将不采用这种方法。
  2. 介于我们已经知道了 ASM 的调用约定,我们可以写一个执行 tracee 进程内函数的 Utils 方法出来。
long CallRemoteFunction(pid_t pid, long function_addr, long* args, size_t argc) {
struct pt_regs regs;
// 备份 tracee 原来的寄存器信息
struct pt_regs backup_regs;
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
memcpy(&backup_regs, &regs, sizeof(struct pt_regs));
// 前四个参数存入 r0-r3
for(int i = 0; i < argc && i < 4; ++i) {
regs.uregs[i] = args[i];
}
// 后面的参数就压到栈上
if (argc > 4) {
regs.ARM_sp -= (argc - 4) * sizeof(long);
long* data = args + 4;
PtraceWrite(pid, (uint8_t*)regs.ARM_sp, (uint8_t*)data, (argc - 4) * sizeof(long));
}
// 将返回地址设置为 0, 我们就能捕捉 SIGSEGV
regs.ARM_lr = 0;
regs.ARM_pc = function_addr;
if (regs.ARM_pc & 1) {
// thumb 模式
regs.ARM_pc &= (~1u);
regs.ARM_cpsr |= CPSR_T_MASK;
} else {
// arm 模式
regs.ARM_cpsr &= ~CPSR_T_MASK;
}
ptrace(PTRACE_SETREGS, pid, NULL, &regs);
ptrace(PTRACE_CONT, pid, NULL, NULL);
waitpid(pid, NULL, WUNTRACED);
// 获取返回值
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
ptrace(PTRACE_SETREGS, pid, NULL, &backup_regs);
// 函数值
return regs.ARM_r0;
}

有了这个函数后,我们的加载共享库的逻辑就简单了:

  1. 获取 mmap, dlopen, dlsym, dlclose 函数在 tracee 进程内的地址
  2. 调用 mmap 来在 tracee 进程内开辟内存空间
  3. so 库的路径写到 mmap 分配的内存去
  4. 在目标进程内调用 dlopen 函数
  5. 通过 dlsym 获取我们自定义函数的地址
  6. 调用我们自己的函数
  7. 调用 dlclose 卸载我们的共享库

这样工作就可以完成了,但是第一步还存在一个问题,就是 地址空间随机布局 的问题。

Address Space Layout Randomization

为了避免缓冲区的溢出工具,大多数系统都提供了一个叫做 地址空间布局随机化 技术,简程 ASLR。通过这个技术,关键数据区域的起始位置是随机的,包括 堆 和 栈的位置。

对于安卓来说,在 4.1 版本增加了 position-independent(位置无关代码) 执行的支持。5.1 舍弃了 non-PIE(non-position-independent-executable) 支持,要求所有的动态链接的二进制数据必须是位置无关的。这就是说,在进程间,函数的地址是不一样的。所以我们需要一个绕过 ASLR 的手段来获取目标进程中的函数地址。

  1. mmap 函数存在于我们的进程。
  2. /system/lib/libc.so(mmap存在的库) 的基地址存在于我们的进程。
  3. /system/lib/libc.so(mmap存在的库) 的基地址存在于 tracee 进程。

我们知道,mmap 相对 so 库基地址的偏移是一定的。基于以上三点我们就可以拿到 mmap 函数的地址。

remote mmap addr = local mmap address - local lib base addr + remote lib base addr

这样我们就可以绕过了。

Code

https://github.com/Gowa2017/TinyInjector

几个系统调用