
简介面向Android安全研究、逆向分析与插件化开发的底层示例聚焦ARM处理器平台上的代码注入实现。资源以三步流程为主线在目标进程分配内存、写入调用dlopen的shellcode、执行shellcode完成Library加载清晰呈现了进程内存操作与ARM汇编调用的衔接方式。压缩包共3个文件包含一个C源文件、一个ARM汇编文件与一个头文件分别对应注入逻辑主体、shellcode指令实现及公共接口定义整体仅4KB结构精简便于速览。已有705人学习下载适合需要快速理解Android进程注入原理的开发者。代码包虽小但完整覆盖了注入流程的关键环节尤其是ARM平台下参数传递、内存映射与dlopen触发等易错点。读者可将其作为动手实验的参考也能借此梳理动态库加载的底层路径为后续扩展或二次开发提供基础。 搞Android开发和性能优化的朋友对“注入”这个词一定不陌生。系统API被调用时发生了什么都看不见进程内部某个函数执行了几次、传了什么参数也查不到想在不重新打包的情况下给一个App加监控逻辑这时候就需要LibInject这类“代码注入”工具来帮忙。LibInject本质上是Android平台上的一套进程注入方案它能让我们的自定义so库在目标进程内部被加载并执行。它解决的核心问题是“让别人的进程跑我们的代码”典型应用包括安全研究中的动态调试、SDK的性能插桩、自动化测试时的行为打点以及功能调试时的临时日志开关。搞过逆向或者做过Android架构优化的人应该都懂这个能力几乎是绕不开的基本功。这篇文章从一个可落地的角度把LibInject从原理到实操过一遍包括它选ptrace路线的原因、ARM64底下的注入寄存器细节、被注入库的编写方式以及我踩过的几个坑。想动手写一个注入器或者正在选型注入方案的朋友可以直接照着做。1. LibInject要解决什么问题注入技术的核心定位1.1 从“改包重签名”到“运行时注入”的路径演进早期给App加逻辑思路大多是静态修改反编译拿到smali改成自己的逻辑再重打包签名。这条路对于一个原型验证来说勉强凑合但放到实际工程里全是坑。重签名后过不了完整性校验不说每次改逻辑都要重新走一遍反编译、修改、打包流程发版一个版本要折腾老半天。更麻烦的是线上出问题的时候我们往往需要临时打印某个内部函数的入参这种需求在静态修改的流程里根本没法快速响应等新包打好事故早就过去了。运行时注入的思路完全不同。我们不碰原始安装包只是在目标进程启动后的某个时机把一个我们预先编译好的so库加载进去让目标进程自己调用我们导出的函数。原始包干干净净我们想改逻辑只需要重新编译一个so动态替换即可。这个“目标进程自己加载我们的代码”的过程在Android底层落地时核心就是一套ptrace注入链路——这也正是LibInject这类库要封装的东西。1.2 注入的几条技术路线对比Android平台上能实现注入的技术路线其实不止一条每条都有各自的适用边界我整理了日常接触最多的几条注入方案实现方式优势劣势适用场景ptrace动态注入附加进程、操作寄存器、借线程执行dlopen通用性强不依赖定制ROM可按需触发实现细节多对架构敏感慢安全测试、动态调试、临时打点zygote注入修改zygote进程的fork流程让所有App继承so一次注入全覆盖影响面太大容易被排查需root系统级调试、自动化测试框架LD_PRELOAD注入设定环境变量加载so实现最简单只在进程启动阶段生效App启动后再attach就无能为力针对so层库的自测定制ROM/内核注入修改系统镜像在底层加载钩子隐蔽性最强工程量大维护成本高系统发行版本、深度定制ptrace路线是目前使用频率最高、资料最全、也最适合做通用LibInject库的路径。它最大的优势是不需要预先准备环境只要进程可被附加就能在任意时刻触发注入。代价是它的实现细节确实多特别是现在主流设备都是ARM64架构寄存器操作和调用约定处理起来有点绕这也是后面要重点展开的部分。2. 核心细节原理拆解LibInject的底层机制2.1 进程注入最小模型ptracedlopen链路LibInject的整个核心链路用一句话概括就是让目标进程自己调用dlopen()把我们指定的so库加载进去。那问题来了——怎么让目标进程去调用一个它自己根本没执行过的函数关键工具是ptrace。ptrace(PTRACE_ATTACH, pid)能把目标进程挂起挂起后我们就能通过PTRACE_GETREGSET和PTRACE_SETREGSET读写它的CPU寄存器还可以用PTRACE_POKEDATA往它的内存空间写入指令和数据。我们可以这样想象目标进程就像一台正在运行的计算器你把它的某一个线程暂时停住然后修改它屏幕上显示的数字和接下来要执行的按键序列再让它继续跑。它自己并不会意识到这段执行路径被改动过。注入的完整链路是通过ptrace附加目标进程选择其中的一个线程作为“执行代理”。保存该线程的完整寄存器现场。在目标进程内存中分配一块可执行内存写入一段精心构造的指令序列也就是常说的shellcode。修改pc寄存器使其指向shellcode的起始地址这个shellcode的作用是构造并执行dlopen(我们的库路径, 2)。让目标线程继续跑等到shellcode执行完毕恢复原始寄存器解除附加。2.2 ARM64调用约定与寄存器体操到了ARM64函数调用的规则跟x86差别很大也跟老的ARM32不一样。ARM64的通用寄存器是x0到x30其中x0到x7用于传递前8个函数参数x30也叫lr存放函数返回地址sp是栈指针pc就是程序计数器。如果我们需要调用dlopen(const char* filename, int flags)在指令层面做的事情就是把so库路径的地址写入x0。把标志值2对应RTLD_NOW写入x1。把dlopen的运行时地址赋值给某个临时寄存器例如x12。最后执行blr x12跳转过去并把下一条指令地址写进x30这样函数返回后还能继续执行后续代码。这里最容易被忽略的点是sp对齐。ARM64规定函数调用时栈指针必须保持16字节对齐如果注入现场本身栈是乱的shellcode里直接调用dlopen可能还没进函数内部就先崩了。我在早期版本里就遇到过一个诡异问题注入某些应用时十次里有三次崩溃排查了半天发现是shellcode里没有对sp做和~0xF的处理导致某些函数的内部指令直接段错误。这个问题在x86上基本不存在但在ARM64上属于必修课。2.3 符号定位解析目标进程里的libdl.so要让shellcode调用dlopen首先得知道目标进程内存里dlopen的真实地址。Android的动态链接器linker从很早的版本开始就把dlopen暴露在libdl.so里所以通用的做法是先读取/proc/pid/maps找到libdl.so的加载基址再解析该ELF文件的动态符号表算出dlopen符号在文件内的偏移两者相加得到运行时地址。这个思路的完整实现涉及ELF的section和dynamic segment解析过程比较繁琐但核心逻辑可以简化为// 简化版本仅用于展示解析思路 uintptr_t lookup_symbol_in_maps(pid_t pid, const char* lib_name, const char* sym_name) { // 1. 解析 /proc/pid/maps 找到 lib_name 的加载基址 base // 2. 读取 /proc/pid/mem 中 base 偏移的ELF头 // 3. 遍历 .dynsym 和 .dynstr找到 sym_name 对应符号的 st_value // 4. return base st_value; }真正的工程实现还需要处理.gnu.hash、符号版本、64位ELF结构体大小等细节。作为注入器开发者的经验之谈Android 7.x之前和8.0以后的linker差异比较大针对不同系统版本做了一次适配之后后续维护的成本才会降下来。很多开源注入器之所以“旧版能用、新版翻车”问题基本都出在符号解析和libdl偏移表维护上。3. 实操过程从零写一个可用的LibInject3.1 工程结构设计与编译环境先说一下编译环境。Android注入器本身是一个Linux可执行文件对应的被注入库是一个标准so所以直接用Android NDK交叉编译最省事。我这边用的NDK版本是r21e目标CPU架构设成arm64-v8a如果要兼容老设备也可以同时编一份armeabi-v7a但要记得32位和64位的shellcode指令集会完全不一样。工程结构比较直接LibInject/ ├── injector/ │ ├── main.c // 注入器入口 │ ├── ptrace_util.c // ptrace封装 │ ├── elf_parser.c // ELF符号解析 │ └── shellcode_arm64.S // ARM64 shellcode ├── libinject_demo/ │ └── inject_demo.c // 被注入的动态库 ├── CMakeLists.txt └── build.shCMakeLists的配置核心就两件事injector编成可执行文件libinject_demo编成共享库。cmake_minimum_required(VERSION 3.10) project(LibInject) add_executable(injector injector/main.c injector/ptrace_util.c injector/elf_parser.c injector/shellcode_arm64.S ) target_link_libraries(injector log) add_library(inject_demo SHARED libinject_demo/inject_demo.c ) target_link_libraries(inject_demo log dl)3.2 注入器主流程实现注入器的主流程用最直白的方式分成四步找到PID、附加进程、构造调用现场、恢复执行。下面是一个简化的ptrace_call代码框架它做的事就是让目标进程执行任意一个带两个参数的函数利用这个函数就能让目标进程执行dlopen。int ptrace_call(pid_t pid, uintptr_t func_addr, uintptr_t arg1, uintptr_t arg2) { struct ptrace_regs regs; // ARM64下对应 user_pt_regs uintptr_t shellcode_addr; // 1. 读取目标线程当前寄存器 ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, iovec); // 2. 在目标进程内存中分配可执行内存通过mmap远程调用 shellcode_addr remote_mmap(pid, 0x1000); // 3. 写入shellcode指令序列 write_shellcode(pid, shellcode_addr, arg1, arg2, func_addr); // 4. 修改pc指向shellcode设置执行环境 regs.regs[0] arg1; // 相当于x0 regs.regs[1] arg2; // 相当于x1 regs.regs[12] func_addr; regs.regs[30] 0; // 返回地址置0执行完会触发异常便于捕获 regs.regs[31] ~0xF; // 保持栈对齐 regs.regs[15] shellcode_addr; // ARM64下pc在regs[15] ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, iovec); ptrace(PTRACE_CONT, pid, 0, 0); // 5. 等待shellcode执行完毕捕获SIGSEGV或SIGTRAP waitpid(pid, status, WUNTRACED); // 6. 恢复原始寄存器 ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, old_iovec); ptrace(PTRACE_DETACH, pid, 0, 0); return 0; }这段代码里最巧妙的点是返回地址的处理。我们可以把shellcode调用的返回地址设成0这样当dlopen执行完返回到地址0时目标进程就会触发一次SIGSEGV信号。由于进程处于我们的ptrace控制之下这个信号会被我们捕获从而就知道shellcode已经执行完毕。这比轮询等待内存标志位的实现简单得多代价是会让目标进程短暂地出现一次非致命的段错误信号不过因为现场被完整体验保护恢复之后对运行没有影响。3.3 被注入库的编写与日志验证被注入的so库核心只需要提供一个导出函数注入器负责在dlopen之后继续调用它。出于工程化的考量大多数动态注入库会实现JNI_OnLoad这样生命周期跟load过程绑定同时也能方便地拿到JavaVM指针为后续调用Java层逻辑做准备。也可以自定义导出函数名字随便起完全看注入器侧怎么配合。一个最小可用的被注入库长这样导出JNI_OnLoad并打印一行标志性日志#define LOG_TAG LibInjectDemo #include android/log.h #include jni.h #include unistd.h JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { __android_log_print(ANDROID_LOG_INFO, LOG_TAG, JNI_OnLoad called in pid%d, getpid()); return JNI_VERSION_1_6; }把这个so编译好之后推进设备再启动注入器通过logcat验证adb push injector /data/local/tmp/ adb push libinject_demo.so /data/local/tmp/ adb shell chmod 755 /data/local/tmp/injector # 先拿到目标进程pid例如某个包名是com.example.target adb shell pidof com.example.target # 执行注入 adb shell /data/local/tmp/injector -p pid -l /data/local/tmp/libinject_demo.so # 观察日志 adb logcat -s LibInjectDemo如果一切顺利logcat里就会打出对应的调用日志。到了这一步注入通路已经打通剩下的工作就是让这个so里承载具体的业务逻辑。比如很多人会组合经典的xhook库在里面注册几个要观察的系统API通过hook系统API来搜集调用参数。xhook的核心原理是在so加载完成后遍历已加载ELF的.rela.plt段把目标符号的GOT项替换成自己的函数指针。示例代码如下#include xhook.h static char* my_strcmp(const char* s1, const char* s2) { // 这里可以记录s1、s2供调试或监控使用 return strcmp(s1, s2); } JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { xhook_register(.*, strcmp, my_strcmp, NULL); xhook_refresh(0); return JNI_VERSION_1_6; }值得注意的是xhook的hook时机需要晚于目标模块的加载所以注入时如果目标进程已经运行很久通常会先手动触发一次dlopen刷新或者等待下一次模块加载时自动完成替换。这个细节我在实际使用中踩过一次注入后日志一直没触发检查半天才发现是目标模块的GOT表在注入之前就被填好了xhook默认不会去改已经解析过的符号需要调用xhook_refresh(1)强制刷新。4. 常见问题排查与避坑指南4.1 ptrace attach失败的两类原因ptrace附加失败是最常见的现象手机上出现的频率尤其高。一类原因是权限问题普通App的ptrace_scope如果被设置成1就只允许父进程附加子进程跨应用附加会被直接拒绝。这时候要么在root环境下修改/proc/sys/kernel/yama/ptrace_scope要么让注入过程运行在目标进程的父进程上下文中。另一类原因是目标进程自身已经处于被调试状态。Android的调试器、部分性能监控框架、甚至一些热修复框架都会使用ptrace而ptrace有个硬性限制同一时刻一个进程只能被一个追踪者附加。如果试图附加一个已处于跟踪状态的进程ptrace(PTRACE_ATTACH)会返回EPERM。排查时可以用/proc/pid/status里的TracerPid字段确认如果值不是0说明已经被别人追踪了得先让它解除。4.2 注入成功后没日志或者崩溃注入日志没打出来常见原因有这么几种。第一被注入库的路径在目标进程看来没有读权限特别是直接放在/data/local/tmp下的so有些App对自身进程的dlopen路径做了限制建议把so先chmod 755必要时放进/data/data/pkg/下再注入。第二注入器用的是32位编译而目标是64位进程架构不匹配导致整个加载流程全错判定方法很简单检查注入器是arm64还是arm。第三被注入库里的日志优先级被过滤了logcat有时默认不显示INFO级以下的日志可以试试adb logcat --bufferall | grep LibInjectDemo。崩溃问题则大概率出在JNI交互上。如果被注入库里要调用Java层的接口必须确保当前线程已经通过AttachCurrentThread持有了有效的JNIEnv因为注入的线程不是Java线程直接使用JNIEnv会触发空指针。我在做某一个性能监控SDK时就遇到过这种崩溃当时因为在一个工作线程里直接调用了FindClass没走attach流程进程直接挂掉。后来把整个JNI调用段的开头统一加上vm-AttachCurrentThread(env, NULL)结尾再DetachCurrentThread问题才彻底解决。4.3 ARM32/ARM64与linker版本的差异这是LibInject维护过程中比较折磨人的一点。早期Android版本里dlopen这个名字在libdl.so的符号表中还叫dlopen到了Android 10前后的某些版本linker内部已经改成了__loader_dlopen这导致按老思路符号解析时会得到错误地址。另一种常见情况是目标进程是64位但sym_name的传入和使用方式没区分Elf64_Sym和Elf32_Sym导致解析结果全部错位。应对策略其实很朴素维护一张按Android版本分组的符号名映射表拿到系统版本后在表里找对应的正确符号名。这个工作不需要什么高深技巧但确实需要耐心我自己的表格是按major版本分的每一行记录dlopen的实际符号名和偏移修正值新版本设备出来就先跑一遍冒烟测试再更新表。4.4 反调试对抗怎么看坦率地说很多线上App本身实现了反调试逻辑有的会周期性检查TracerPid有的会读取/proc/self/status里的State状态甚至有的在JNI层做了类似ptrace(PTRACE_TRACEME)的技巧让自身无法被二次附加。这意味着LibInject这类工具在实际使用时对一部分App会直接失效。这个问题我的看法是反调试对抗本身是一个动态演进的攻防话题不是一篇注入科普能讲完的。作为开发者更重要的是理解注入的底层机制当我们的合法调试、性能监控、自动化测试工具遇到这些防护时知道问题出在哪一层以及如何从架构上规避。对于真正需要处理这类对抗场景的读者建议先把本文的基础链路吃透再去研究更底层的方案。最后再分享一点经验做LibInject这类工具最有价值的不是“能注入成功”这个结果而是搞清楚注入过程中每一层机制背后的原因。说实话市面上现有的注入框架不少比如Frida已经把这套流程封装得很成熟一般人直接用就行但自己动手实现一遍注入器之后你再去看Frida的脚本、看别人写的hook方案会有一种“原来它底层是这样运作的”的感觉。我个人实操中最大的体会是一定要有一个干净、可控的测试环境。注入调试的坑实在太多如果设备上跑的是高版本系统加各种安全策略会把问题复杂化。建议平时准备好一台Android 9或Android 10的测试机关掉SELinux至少在测试时设为permissive用root权限配合调试可以在很大程度上降低环境因素带来的干扰。注入成功后也不要急着上复杂hook逻辑先把最小链路跑通再一步步把业务逻辑加进去这样出问题时定位起来会清晰很多。本文还有配套的精品资源点击获取