-
-
[原创]从0到1构建一个Hook工具之注入器篇(二)
-
发表于: 2天前 602
-
前言
在上一篇文章中,我们已经了解了注入的基本概念并且实现了attach注入模式,这个时候我们很容易想到attach模式的一个缺陷:当我们想要观察或拦截app启动早期的行为时,使用attach往往已经错过了最有价值的时机。
spawn注入模式就可以解决这个问题,并且在逆向场景中这种模式也非常的常见。
项目地址:a8fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6^5x3h3q4G2L8U0q4F1k6#2)9J5c8V1&6A6L8X3A6W2j5%4c8G2M7R3`.`.
目标
这里有两种实现spawn的方案,一种是传统的直接hook zygote实现,另一种则是参考了Frida的Zymbiote方案,这里我们分别对两种方案做一个介绍和实现。后续有什么讲的不对的或者可以补充改进的欢迎在评论区提出!
传统方案原理和实现
知道这些基础后会更好的理解下文
- zygote进程:Android中app的进程都是从zygote进程中fork出来的
- dlsym函数:上一篇文章我们用到了dlopen函数用于加载so,dlsym和dlopen是同一系列的,而他的作用是找到函数的地址
- setArgV0:这里先简单了解他存在于libandroid_runtime.so,和进程显示名/进程身份字符串的设置有关,在Android app启动过程中,系统会把子进程一步步塑造成目标app,其中一个典型的早期信号就是:进程名被设置成目标包名,而setArgV0就和这个有关。
- SELinux:他其实就是Linux/Android上的一套强制访问控制系统,而selinux_android_setcontext他和进程身份、安全域有关,是子进程在启动早期、切换安全上下文的一个关键点
为什么spawn模式更难实现
使用attach注入时,目标进程已经是启动的状态,我们很容易就可以知道他的pid,而spawn注入时,目标进程还没有被创建,pid也就无从所知了,我们必须就先在某个上游位置埋点,并且要保证能在目标子进程出现时被命中
具体实现
spawn注入的核心思路其实就是:先用attach把一个so(后文记作helper)注入zygote,再由zygote内部常驻的这个so安装fork和子进程初始化hook,在命中目标包名后,最终由目标子进程自己执行dlopen(target.so),详细讲就是:
- 主程序把helper注入到zygote
- 主程序远程调用一个ainject方法
- ainject方法中保存目标信息并安装fork/vfork hook
- zygote派生出子进程时,子进程分支里调用install_child_hook
- 子进程走到setArg0/selinux_android_setcontext
- hook拿到当前进程名字
- 如果名字命中目标包名,则在这个子进程中dlopen(target.so)
- 卸载hook
这里其实可以分为三层结构:主程序、helper、目标app子进程。
第一步首先是复用attach,主程序先把helper注入到zygote中,这个helper的定位并不是一个playload,而是spawn模式中常驻在zygote内部的一个中间控制模块。
handle = inject_so_handle_by_pid(zygote_pid, ncore_path);
if (handle == nullptr) {
LOGE("prepare_spawn_in_zygote: inject ncore failed");
return false;
}
此时我们已经成功将helper注入到了zygote中并且返回了一个handle,接下来要做的就是远程调用一个ainject方法,这里就需要用到dlsym函数,结合前一篇文章我们很容易就能完成
remote_sym_name = remote_alloc_string(zygote_pid, "ainject");
remote_pkg = remote_alloc_string(zygote_pid, package_name);
remote_so = remote_alloc_string(zygote_pid, so_path);
remote_ainject = call_remote_function<void*, void*, const char*>(
zygote_pid,
reinterpret_cast<void*>(dlsym),
handle,
reinterpret_cast<const char*>(remote_sym_name)
);
params[0] = reinterpret_cast<long>(remote_pkg);
params[1] = reinterpret_cast<long>(remote_so);
call_remote_call<void>(zygote_pid, reinterpret_cast<long>(remote_ainject), 2, params);
上文我们简单提到ainject的作用是保存目标信息和安装hook,这里的hook我们先借助dobby框架实现,暂时不去理会他的具体实现。
g_target_package = strdup(package_name);
g_target_so = strdup(so_path);
g_payload_loaded = false;
INSTALL_HOOK(fork, fork);
INSTALL_HOOK(vfork, vfork);
到了这一步,在zygote内部的helper已经知道了目标包名、知道了target.so的路径并且进入了等待目标子进程的状态,但是由于此时子进程还没有完全的初始化,因此还不能判断出是否是我们的目标子进程,所以需要在子进程中安装hook,用于获取进程名,这里hook点选择了android_os_process_setArgV0和selinux_android_setcontext
static void install_child_hooks() {
LOGI("ncore: installing child hooks pid=%d", getpid());
g_android_os_Process_setArg = DobbySymbolResolver(
"libandroid_runtime.so",
"_Z27android_os_Process_setArgV0P7_JNIEnvP8_jobjectP8_jstring");
if (g_android_os_Process_setArg != nullptr) {
INSTALL_HOOK(android_os_Process_setArgV0, g_android_os_Process_setArg);
} else {
LOGE("ncore: android_os_Process_setArgV0 not found");
}
g_selinux_android_setcontext = DobbySymbolResolver("libselinux.so", "selinux_android_setcontext");
if (g_selinux_android_setcontext != nullptr) {
INSTALL_HOOK(selinux_android_setcontext, g_selinux_android_setcontext);
} else {
LOGE("ncore: selinux_android_setcontext not found");
}
}
然后当子进程中命中了Hook之后就会进入判断包名是否匹配,从而决定是否加载target.so,并且此时之前的hook已经不需要了可以执行unhook了
static bool load_payload_if_needed(const char* package_name) {
if (!matches_target(package_name)) {
return false;
}
if (g_payload_loaded) {
LOGI("ncore: payload already loaded for %s", package_name);
return true;
}
LOGI("ncore: target matched, loading payload package=%s so=%s",
package_name != nullptr ? package_name : "(null)",
g_target_so != nullptr ? g_target_so : "(null)");
unhook_all();
void* handle = dlopen(g_target_so, RTLD_NOW | RTLD_NODELETE | RTLD_GLOBAL);
if (handle == nullptr) {
LOGE("ncore: dlopen failed for %s: %s", g_target_so, dlerror());
return false;
}
g_payload_loaded = true;
LOGI("ncore: payload loaded for %s => %s", package_name, g_target_so);
send_status_to_injector(package_name, g_target_so);
return true;
}
Zymbiote方案原理和实现
知道这些基础你会更好的理解下文
- ART槽位:在Android运行时里,某些方法调用最终不是简单靠符号名直接跳转,而是会经过ART管理维护的一些方法结构或入口槽位上。
- /proc/<pid>/mem:这是Linux/Android提供的一个伪文件,表示某个进程的虚拟内存内容,常用于读取目标进程内存、配合/proc/<pid>/maps确定模块加载地址和内存段范围
- uid:在Android中每个应用通常分配独立UID
- boot heap:这个Android启动类相关对象所在的一块堆内存区域,系统启动时boot class path 上那些核心类被加载,他们对应的类对象、方法元数据等很多会放在专门的boot image/boot heap相关区域里
- libstagefright.so:和音视频编解码有关,常常已经被加载
- libandroid_runtime.so:Android Framework和底层native层之间重要桥接库,在zygote/system server启动过程中参与初始化
- 重定位:是linker把“编译/链接时还没确定的地址应用”修正成”进程实际运行地址“的过程,简单理解就是源码/目标文件里面先留一个占位,模块真正装入内存时再把这些地址补准
原理简介
相较于之前的方案,Zymbiote不是直接给目标app打补丁,而是先“污染”zygote(很符合Symbiote这个名字),核心不再是将一个helper常驻到zygote中,而是直接patch zygote启动链中某个关键运行时入口的槽位,让目标app正常启动过程中自然进入一段预埋的stub,并在命中目标进程后执行dlopen实现注入。
具体实现
我们先理一下详细的核心流程:
- 获取注入所需的上下文信息
- 解析setArgV0的实际地址
- 在boot heap中搜索ArtMethod slot
- 保存原始shellcode页,给后续恢复使用
- 暂停zygote
- 生成并回填stub配置
- 把stub写入到libstagefright.so最后一页
- 把ArtMethod slot改到stub的起始地址
- 恢复zygote,目标app启动时触发stub
- 等待手动停止并恢复
函数调用链大概如下
main
-> collect_spawn_source_pids
-> inject_spawn_symbi_by_pids
-> collect_symbi_context
-> prepare_target_so_for_app
-> stop_process
-> open_remote_mem
-> write_stub_and_patch_slot
-> resume_process
-> start_target_app_symbi
-> 等待 Ctrl+C
-> restore_original_slot
目标app启动后在子进程中走:
android.os.Process.setArgV0Native
-> stub_replacement_set_argv0
-> original_set_argv0
-> getuid
-> dlopen(target_so)
这部分代码主要参考了09fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6y4M7X3q4U0K9#2)9J5c8W2c8u0L8X3A6W2j5%4c8G2M7W2)9#2k6W2y4&6L8h3u0A6
链路开始是collect_spawn_source_pids获取zygote的pid然后inject_spawn_symbi_by_pids()执行注入流程,实现Zymbiote的核心逻辑就在这个方法中。接下来按顺序来介绍核心逻辑的流程:
collect_symbi_context这是整个Zymbiote最核心的准备函数,他负责回答了5个问题:
- 目标app uid是多少
- stub 要写到zygote的哪个可执行地址
- setArgV0Native的真实地址在哪
- ArtMethod slot在boot heap的哪个位置
- stub 运行时需要的远程函数地址是什么
struct SymbiContext {
pid_t zygote_pid = -1;
uid_t target_uid = static_cast<uid_t>(-1);
uintptr_t shellcode_base = 0;
uintptr_t set_argv0_address = 0;
uintptr_t art_method_slot = 0;
uintptr_t original_ptr = 0;
uintptr_t remote_getuid = 0;
uintptr_t remote_dlopen = 0;
uintptr_t remote_log_print = 0;
std::string libandroid_runtime_path;
std::string shellcode_map_path;
uintptr_t shellcode_map_start = 0;
size_t shellcode_map_offset = 0;
std::string remote_so_path;
std::vector<uint8_t> original_shellcode_area;
};
先解析目标包的uid,具体实现就是读取/data/system/packages.list逐行匹配包名获取uid。
然后解析zygote的/proc/<pid>/maps,方便后续查找libandroid_runtime.so、libstagefright.so、boot heap等的位置。
接着是选择shellcode的落点,这里选择的是libstagefright.so的可执行段中选最后一页,因为这页本身就已经在进程中映射好了,而且是可执行页,避免需要额外通过mprotect修改内存页权限:
if (ctx->shellcode_base == 0 &&
map.pathname.find("libstagefright.so") != std::string::npos &&
map.perms[2] == 'x') {
ctx->shellcode_base = map.end - static_cast<uintptr_t>(getpagesize());
ctx->shellcode_map_path = map.pathname;
ctx->shellcode_map_start = map.start;
ctx->shellcode_map_offset = map.offset;
}
通过libandroid_runtime.so基址 + 导出偏移求得真实地址,这是后面搜索ArtMethod的“目标指针值”的基础。
接下来是解析setArgV0Native的真实运行地址,在前面我们已经找到了libandroid_runtime.so,下一步就可以通过get_module_base()获取模块基址,再调用get_symbol_offset_from_elf()获取符号偏移从而计算出实际的地址。
#define SYMBI_TARGET_SYMBOL "_Z27android_os_Process_setArgV0P7_JNIEnvP8_jobjectP8_jstring"
uintptr_t libandroid_runtime_base = get_module_base(zygote_pid, ctx->libandroid_runtime_path);
uintptr_t symbol_offset = get_symbol_offset_from_elf(ctx->libandroid_runtime_path, SYMBI_TARGET_SYMBOL);
if (libandroid_runtime_base == 0 || symbol_offset == 0) {
LOGE("symbi: failed to resolve setArgV0");
return false;
}
ctx->set_argv0_address = libandroid_runtime_base + symbol_offset;
boot heap:如果某段映射路径中包括boot.art、boot-framework.art、dalvik-LinearAlloc并且是可读写区域,就把他加入boot heap候选区heap_candidates,这一步实际就是在近似的定位ART的boot heap区域。
if ((map.pathname.find("boot.art") != std::string::npos ||
map.pathname.find("boot-framework.art") != std::string::npos ||
map.pathname.find("dalvik-LinearAlloc") != std::string::npos) &&
map.perms[0] == 'r' && map.perms[1] == 'w') {
heap_candidates.push_back(map);
}
为什么需要他呢?因为比如android.os.Process.setArgV0Native()对应的Java/native方法元信息最终会体现在ART的对象布局里,这些元信息一般会驻留在boot image相关heap中,如果我们知道原始native函数地址,就可以在这个heap_candidates中反向搜索“谁指向了他”,这正是后面定位ArtMethod slot的基础。
如何在boot heap中搜索到ArtMethod slot呢?在上面,我们已经得到了setArgV0Native的实际地址,因此我们直接在每一块heap内存中搜索其对应的指针值,如果找到了就把该地址记为art_method_slot即可,原理就如上文所述,android.os.Process.setArgV0Native() 对应的 ArtMethod 结构里,就会有一个 entry point 字段指向它真正的 native 实现。既然我们已经知道真实的 native 地址,就可以在 ART 的方法元信息区中,反向搜索“谁保存了这个地址”。
for (const auto& heap : heap_candidates) {
size_t region_size = static_cast<size_t>(heap.end - heap.start);
if (region_size < sizeof(uintptr_t)) {
continue;
}
std::vector<uint8_t> buffer(region_size);
ssize_t read_size = pread(mem_fd, buffer.data(), region_size, static_cast<off_t>(heap.start));
if (read_size != static_cast<ssize_t>(region_size)) {
continue;
}
auto* found = reinterpret_cast<uint8_t*>(
memmem(buffer.data(), region_size, &ctx->set_argv0_address, sizeof(ctx->set_argv0_address)));
if (found != nullptr) {
ctx->art_method_slot = heap.start + static_cast<uintptr_t>(found - buffer.data());
break;
}
}
然后是stub相关的逻辑,stub是一段“裸写入进程内存的二进制blob”,他不会像正常的so那样经过动态链接器的完整重定位流程,所以他内部要调用的函数地址都必须由注入器外部算好然后回填,比如在这里我们需要解析libc.so:getuid, libdl.so : dlopen,liblog.so:__android_log_print,这部分通过get_remote_symbol()函数完成,具体实现其实就是get_module_base()找模块基址,get_process_maps()找真实文件路径,get_symbol_offset_from_elf()算偏移,然后base + offset即可。
ctx->remote_getuid = get_remote_symbol(zygote_pid, "libc.so", "getuid");
ctx->remote_dlopen = get_remote_symbol(zygote_pid, "libdl.so", "dlopen");
ctx->remote_log_print = get_remote_symbol(zygote_pid, "liblog.so", "__android_log_print");
if (ctx->remote_getuid == 0 || ctx->remote_dlopen == 0 || ctx->remote_log_print == 0) {
LOGE("symbi: failed to resolve remote helper symbols");
return false;
}
接下来就到了write_stub_and_patch_slot的部分了,我们需要先暂停zygote进程,然后直接修改/proc/<pid>/mem即可,那写的内容是什么呢?当然是我们的stub,此时我们不仅已经有了编译好了二进制blob,并且需要的上下文信息都已经搜集完毕,只需要找到配置区并回填即可,这里就需要通过配置块的定位标记。
char remote_pattern[] = "/ningningning123123";
uintptr_t marker = reinterpret_cast<uintptr_t>(
memmem(stub_binary, stub_binary_size, remote_pattern, sizeof(remote_pattern)));
if (marker == 0) {
LOGE("symbi: failed to locate stub marker");
return false;
}
std::vector<uint8_t> stub_copy(stub_binary, stub_binary + stub_binary_size);
uintptr_t offset = marker - reinterpret_cast<uintptr_t>(stub_binary);
auto* stub_cfg = reinterpret_cast<TStub*>(stub_copy.data() + offset);
stub_cfg->uid = static_cast<int>(ctx.target_uid);
memset(stub_cfg->so_path, 0, sizeof(stub_cfg->so_path));
strncpy(stub_cfg->so_path, ctx.remote_so_path.c_str(), sizeof(stub_cfg->so_path) - 1);
stub_cfg->original_set_argv0 =
reinterpret_cast<int (*)(JNIEnv*, jobject, jstring)>(ctx.set_argv0_address);
stub_cfg->slot_addr = ctx.art_method_slot;
stub_cfg->getuid = reinterpret_cast<uid_t (*)()>(ctx.remote_getuid);
stub_cfg->dlopen = reinterpret_cast<void* (*)(const char*, int)>(ctx.remote_dlopen);
stub_cfg->log_print =
reinterpret_cast<int (*)(int, const char*, const char*, ...)>(ctx.remote_log_print);
ssize_t written_code = pwrite(mem_fd,
stub_copy.data(),
stub_copy.size(),
static_cast<off_t>(ctx.shellcode_base));
if (written_code != static_cast<ssize_t>(stub_copy.size())) {
LOGE("symbi: failed to write stub to shellcode_base=0x%lx", ctx.shellcode_base);
return false;
}
回填好后,函数将stub写到shellcode_base位置,然后将art_method_slot改成shellcode_base,至此我们就完成了在zygote中放入一段可执行stub,并将setArgV0Native的ArtMethod entry point改指向这段stub。
uintptr_t new_ptr = ctx.shellcode_base;
ssize_t written_ptr = pwrite(mem_fd,
&new_ptr,
sizeof(new_ptr),
static_cast<off_t>(ctx.art_method_slot));
if (written_ptr != static_cast<ssize_t>(sizeof(new_ptr))) {
LOGE("symbi: failed to patch art_method_slot=0x%lx", ctx.art_method_slot);
return false;
}
LOGI("symbi: wrote stub to 0x%lx and patched slot=0x%lx -> 0x%lx",
ctx.shellcode_base, ctx.art_method_slot, new_ptr);
当目标app启动之后,他会从已经打过patch的zygote中fork出来,随着app启动流程推进,android.os.Process.setArgV0Native()会被调用,然后进入stub的流程,命中uid后便会指向dlopen完成target.so的注入。
if (stubApi.getuid() == (uid_t) stubApi.uid) {
STUB_LOGI((&stubApi), "%s Attempting to inject: %s", name_utf8, stubApi.so_path);
void* handle = stubApi.dlopen((const char*) stubApi.so_path, RTLD_NOW);
if (handle != 0) {
STUB_LOGI((&stubApi), "Successfully loaded SO at handle: %p", handle);
} else {
STUB_LOGE((&stubApi), "Failed to dlopen SO!");
}
}
一些辅助函数
get_module_base
uintptr_t get_module_base(pid_t pid, const std::string& lib_name) {
char path[64] = {0};
snprintf(path, sizeof(path), "/proc/%d/maps", pid);
std::ifstream maps(path);
std::string line;
while (std::getline(maps, line)) {
if (line.find(lib_name) == std::string::npos) {
continue;
}
uintptr_t start = 0;
uintptr_t offset = 0;
char perms[5] = {0};
if (sscanf(line.c_str(), "%lx-%*x %4s %lx", &start, perms, &offset) == 3) {
if (offset == 0 && perms[3] != 's') {
return start;
}
}
}
return 0;
}
get_symbol_offset_from_elf
uintptr_t get_symbol_offset_from_elf(const std::string& elf_path, const char* symbol_name) {
int fd = open(elf_path.c_str(), O_RDONLY);
if (fd < 0) {
return 0;
}
struct stat st{};
if (fstat(fd, &st) != 0 || st.st_size <= 0) {
close(fd);
return 0;
}
void* map_base = mmap(nullptr, static_cast<size_t>(st.st_size), PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (map_base == MAP_FAILED) {
return 0;
}
auto* ehdr = reinterpret_cast<Elf64_Ehdr*>(map_base);
auto* shdr = reinterpret_cast<Elf64_Shdr*>(reinterpret_cast<uintptr_t>(map_base) + ehdr->e_shoff);
uintptr_t symbol_offset = 0;
for (int i = 0; i < ehdr->e_shnum; ++i) {
if (shdr[i].sh_type != SHT_DYNSYM) {
continue;
}
auto* syms = reinterpret_cast<Elf64_Sym*>(reinterpret_cast<uintptr_t>(map_base) + shdr[i].sh_offset);
int count = static_cast<int>(shdr[i].sh_size / sizeof(Elf64_Sym));
auto* strtab = reinterpret_cast<char*>(reinterpret_cast<uintptr_t>(map_base) + shdr[shdr[i].sh_link].sh_offset);
for (int j = 0; j < count; ++j) {
if (strcmp(strtab + syms[j].st_name, symbol_name) == 0) {
symbol_offset = syms[j].st_value;
break;
}
}
if (symbol_offset != 0) {
break;
}
}
uintptr_t load_bias = 0;
auto* phdr = reinterpret_cast<Elf64_Phdr*>(reinterpret_cast<uintptr_t>(map_base) + ehdr->e_phoff);
for (int i = 0; i < ehdr->e_phnum; ++i) {
if (phdr[i].p_type == PT_LOAD) {
load_bias = phdr[i].p_vaddr;
break;
}
}
munmap(map_base, static_cast<size_t>(st.st_size));
if (symbol_offset < load_bias) {
return 0;
}
return symbol_offset - load_bias;
}
get_remote_symbol
uintptr_t get_remote_symbol(pid_t pid, const std::string& lib_name, const char* symbol) {
uintptr_t base = get_module_base(pid, lib_name);
if (base == 0) {
return 0;
}
auto maps = get_process_maps(pid);
std::string local_path;
for (const auto& map : maps) {
if (map.pathname.find(lib_name) != std::string::npos) {
local_path = map.pathname;
break;
}
}
if (local_path.empty()) {
return 0;
}
uintptr_t offset = get_symbol_offset_from_elf(local_path, symbol);
if (offset == 0) {
return 0;
}
return base + offset;
}
TODO
- 通讯模块
- 注入痕迹去除