简介:面向Android安全研究、逆向工程与系统底层开发,这份LibInject示例代码展示了在ARM处理器上实现进程注入的完整流程。与常见的x86实现不同,ARM平台需要额外处理指令集、调用约定以及系统调用细节,作者将其归纳为三步:先在目标进程内分配一块可写可执行的动态内存区域,用来存放shellcode和参数;随后将构造好的shellcode写入该区域,由shellcode触发动态加载器,以dlopen方式载入外部库文件;最后启动并运行这段shellcode,使库在目标进程中完成注入。资源包共包含三个文件,分别以C、汇编和头文件形式提供,压缩包整体约4KB,结构精简,便于阅读与调试。目前已有705人学习,读者可据此掌握ARM环境下内存分配、shellcode编写、远程加载等关键技术点,对于Android平台下的安全研究与工具开发具有很强的参考价值。
1. 为什么在Android上还需要注入代码:不只是砸壳和Hook
聊到Android平台上的注入代码,很多人第一反应是逆向、破解、外挂。我在实际工程里接触LibInject这类库,出发点其实更朴素:运行时修复、灰度策略下发、组件动态化。比如线上发现某个模块内存泄漏严重,又不想发版等审核,通过注入方式在类加载阶段替换掉有问题的实现,这种操作在大型App里是真实存在的。当然,热修复方案早期用过腾讯的Tinker、美团的Robust,但它们的核心底层,本质上也绕不开把补丁代码注入到运行进程里。
LibInject听名字像是一个注入框架,实际上它解决的是一整套问题:so库的加载时机、本地方法表的替换、DEX的类加载注入、方法粒度的Hook。它不像Xposed那样需要root或者改系统镜像,而是纯应用层方案,只要App能把自己跑起来,注入代码就能跟着跑起来。正是因为这一点,它在企业级App里比Xposed有更实际的落地价值——不需要用户刷机,不需要系统权限,只要进程活着就能操作。
先把概念理清楚。Android上的代码注入,按注入对象可以分成两类:
- Native层注入:往进程里dlopen一个so,然后hook掉目标函数的GOT表项,或者用Inline Hook改写函数开头指令,劫持执行流。这一类适合做监控、拦截、系统调用替换。
- Java/Kotlin层注入:通过自定义ClassLoader加载外部DEX,利用反射替换已有的ArtMethod字段,实现对Java方法的hook。这一类适合做业务替换、动态feature。
LibInject这种库,通常是把两层打通:既能在Java层提供注解和动态代理,也能在Native层用ELF解析和符号重定位来补全底层调用链。这也是它比单纯写一个DexClassLoader要复杂一个量级的原因。
用个生活类比:Java层注入相当于打开了App的“后台管理界面”,你能看到所有类和方法;Native层注入相当于直接拿到了进程的“内存钥匙”,可以改代码区域的机器码。LibInject就是那个同时拥有两把钥匙的人。
接下来我会分几个部分,结合自己的实践,把完整的注入链路、构建集成的问题、以及过程中最容易被坑到的地方逐个拆开。如果你是为了了解原理,读前两章;如果是要在自己项目里集成,重点看第三章往后。
2. 注入的底层链路拆解:从ELF装载到DEX类加载,两步缺一不可
先看Native层。LibInject在Native层做的事情,本质上是三件事:解析目标ELF文件、找到目标符号地址、替换成自己的函数入口。
2.1 ELF装载与动态符号的重定位逻辑
Android的可执行文件和库文件都是ELF格式,动态库加载依赖两套表:.dynsym(动态符号表)和.rel.plt/.rel.dyn(重定位表)。进程启动后,linker会把so装载到内存里,然后根据重定位表把所有外部函数调用地址回填到GOT(全局偏移表)里。
注入要干的活,就是把目标函数在GOT里的地址改掉。举个例子,目标App调用了open(),所有调用点都通过GOT跳转,那么GOT里open对应的槽位值原本是libc里open的真正地址,注入后改成我们自己so里my_open的地址。因为所有业务代码跳转open时都去查GOT,所以一处替换,全局生效。
在实际写LibInject的Native层时,我踩过的第一个坑是:不是所有平台都允许你直接改GOT表。Android 8.0以后,linker对部分段做了只读映射,你直接往GOT地址写会触发段错误。解决办法是借助mprotect先把GOT所在页改为可写,写完再恢复成只读。伪代码大致是这样:
uintptr_t got_addr = find_got_entry(handle, "open"); long page_size = sysconf(_SC_PAGESIZE); uintptr_t page_start = got_addr & ~(page_size - 1); mprotect((void *)page_start, page_size, PROT_READ | PROT_WRITE); *(void **)got_addr = (void *)my_open; mprotect((void *)page_start, page_size, PROT_READ);这段代码逻辑不复杂,但有个隐藏问题:如果你修改的目标so恰好有多个线程正在调用同一个函数,写入期间会有短暂窗口,可能拿到半个指针。实践中要配合全局锁:注入时挂起目标线程,或者先通过prctl设置一个tls标记,业务代码里检测到标记就自旋等待。
如果你只是做Android单机版的功能,不一定需要走到Inline Hook这种更复杂的方案。GOT Hook的局限性在于只能hook外部导入的函数,无法hook so内部自己的函数调用。如果目标函数是so内部静态调用,比如libfoo.so里a()内部直接调用b(),不走GOT,那GOT Hook就无能为力,必须上Inline Hook——直接改写b()函数开头的几条指令,跳转到自己的桩函数。
Inline Hook的难点在于指令修复:ARM64下被覆盖的指令需要“搬家”到桩代码里执行,但原来的指令可能是PC相对寻址,搬了位置语义就变了。LibInject这类框架一般会内置一个轻量的反汇编器,处理这几条常见指令的重定位。如果你是自己写,建议直接复用Capstone的ARM64反汇编,不要自己造轮子。
2.2 DEX加载与ClassLoader的委托机制
Java层的注入思路完全不同。Android类加载遵循“双亲委托”机制:一个类加载请求会先交给parent尝试加载,parent找不到才轮到自己。系统类由BootClassLoader加载,App自己的类由PathClassLoader加载,动态加载的DEX则通过DexClassLoader或自定义ClassLoader创建。
LibInject做Java层注入,思路一般是这样:
- 从外部路径(比如App私有目录下的补丁DEX)拿到DEX文件。
- 构造一个新的ClassLoader,parent指向当前的PathClassLoader。
- 反射获取当前PathClassLoader里的
pathList,也就是BaseDexClassLoader的私有字段DexPathList。 - 把新DEX的
Element[]数组插到原有数组的最前面。
为什么要插到最前面?因为Android加载类时遍历Element[],找到第一个包含目标类的Element就返回,不再看后面的。补丁类插在最前面,就能确保同名类先命中补丁版本,实现“覆盖”。
这个方案的一个关键限制是:同一个类只能被同一个ClassLoader定义一次,JVM规范不允许两个类加载器同时定义同一个包名下的同名类,否则会出现ClassCastException或者LinkageError。所以真正的补丁技术(比如Tinker)不是直接加载新类,而是绕开这个限制走so层的ArtMethod替换。LibInject在Java层的定位,更多是偏向“新增功能的动态加载”,而非“修改现有类的补丁覆盖”。这两个用途在选型时必须分清楚,否则后面会有一堆奇怪问题。
在我看来,完整的LibInject链路是:Native层负责管理进程内全局的Hook点,Java层负责业务代码的动态装载。两者通过JNI互相传递指针,Java层调一个native方法,把目标类名、方法名、替换实现传下去,native层去解析ArtMethod结构,直接改写方法的入口。Android 7.0之后ArtMethod的结构里有一个ptr_sized_fields的入口字段,把它的值改成新方法的入口,Java层再调用该方法时就会走进注入代码,这就是LibInject的核心思路。
3. LibInject集成的实践路径:从源码编译到进程内验证
实操部分。选LibInject之前,建议先确认它的发布形态:有些版本是提供源码的,需要你自己编译;有些只是release了so和依赖包。建议直接把源码整包放进自己的工程里编译,因为你后面做定制化hook和动态策略下发,一定需要改里面的逻辑。
3.1 环境准备和编译参数
我用的环境是Android Studio Hedgehog版本,AGP 8.2,compileSdk 34,ndkVersion 25.2.9519653。如果你用的Android Studio和AGP版本不一致,尤其是AGP 7.x升级到8.x的阶段,有个很有迷惑性的坑:LibInject源码里的CMakeLists.txt如果用了旧版find_library和target_link_libraries参数,AGP 8默认的CMake版本升级后会直接报错,错误信息往往指向某个路径找不到,其实纯粹是CMake版本行为差异。
建议编译参数这样配置:
android { defaultConfig { externalNativeBuild { cmake { cppFlags "-std=c++17 -fexceptions -frtti" abiFilters "arm64-v8a", "armeabi-v7a" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" version "3.22.1" } } }abiFilters这里我先说明:如果你只做真机调试,arm64-v8a就够了;但如果要兼容低端机,armeabi-v7a还是得带上。x86架构的模拟器就不是LibInject的重点场景了,跑模拟器时会发现native崩溃频繁(因为很多注入库的汇编优化手段只针对ARM做了处理),建议调试还是在真机上。
3.2 初始化注入器:时机比你想的更重要
LibInject的初始化说白了就一行:
InjectAgent.init(this, new InjectConfig.Builder().setEnableNativeHook(true).build());但调用时机是个大坑。在Application.attachBaseContext()里初始化还是onCreate()里初始化,效果差很多。原因在于:如果你要做Java层类加载注入,必须在attachBaseContext()里、super.onCreate()之前完成,因为很多框架(比如友盟、地图SDK)在onCreate()里就开始加载自己的类了,你晚了一步就覆盖不到。
Native层的初始化反而可以稍微晚一点,因为so的装载和GOT Hook不受Java层时序影响。但要注意,如果目标so在attachBaseContext()阶段就已经被系统加载了——比如某些App在attachBaseContext()里就调用了System.loadLibrary()——那LibInject的Native初始化也必须更早。实际项目中,我建议把Native初始化放在loadLibrary之后的第一个Java调用里,然后通过JNI返回一个句柄,后续所有Java层的注入配置都通过这个句柄传递。
3.3 自定义注入点的典型配置
跑通默认Demo之后,真正的定制才刚开始。比如你要对某个网络库做耗时统计,想要无侵入地给所有HTTP请求加一层拦截,用LibInject的Java层Hook就可以这样配置:
InjectAgent.hookMethod( "okhttp3.OkHttpClient", "newCall", new MethodHook() { @Override public Object before(Object target, Object[] args) { long start = System.currentTimeMillis(); // 记录请求信息 return null; } @Override public Object after(Object target, Object[] args, Object result) { long cost = System.currentTimeMillis() - start; Log.d("InjectDemo", "request cost: " + cost); return result; } } );注意newCall是个方法,但LibInject底层是操作ArtMethod的入口,所以你hook到的是所有调用newCall的路径,包括方法内部跳转到其他方法的调用链,这是Inline Hook和ArtMethod Hook的区别:前者改的是函数入口,后者改的是调用点。两者的差异在实际业务场景里影响很大:ArtMethod Hook对参数和返回值的检查更灵活,但性能损耗稍高;GOT / Inline Hook更底层,但改起来风险高。
如果你的需求只是监控,建议优先用ArtMethod Hook;如果目标是做安全对抗或拦截系统调用,再走Native Inline。
3.4 验证注入是否生效
注入完不能只看日志说“好像生效了”。我在调试时常用的验证手段有三个:
- 反射查看方法入口:通过反射拿到
ArtMethod的entryPointFromQuickCompiledCode_字段,看它是否指向了自己so里的桩函数。这个验证最直接。 - 调用计数:在被Hook的方法里加一个静态计数器,跑一遍业务路径,看计数器有没有增加。
- 反编译确认:用jadx打开加固后的DEX,确认类是否依然存在、方法是否有调用链可以触发。
有一个判断经验可以分享:如果你hook之后完全没有效果,先别怀疑LibInject,先检查目标方法是不是被混淆器改名了,或者被内联了。R8在release模式下会把短方法直接内联到调用方,方法本体可能已经消失,你再按原方法名去hook,肯定找不到。
4. 和构建工具链的磨合:AGP、R8与LibInject之间的配置细节
LibInject和AGP、R8的配合,是集成过程中最容易爆雷的地方。很多人是在做完功能之后打release包才发现问题,然后开始漫长排查。这里面的核心矛盾有三个:混淆规则、资源收缩、AGP版本行为差异。
4.1 混淆与R8的Keep规则
LibInject本身包含大量的反射调用,R8一开,它内部反射用得最多的类名、方法名如果被混淆掉,整个注入链路直接断掉。所以proguard-rules.pro里必须保留这些类:
-keep class com.libinject.core.** { *; } -keep class com.libinject.annotation.** { *; } -keepclassmembers class * { @com.libinject.annotation.InjectMethod <methods>; } -keep class * implements com.libinject.api.InjectCallback { *; }特别要提醒一点:不只LibInject自身要keep,你通过LibInject hook的目标类也要keep。比如你要hook一个第三方SDK里的某个方法,如果不keep,release包中这个方法可能被R8裁剪重命名,运行时反射找不到。这个看起来简单,实际踩到的人不在少数。
4.2 AGP 8下JNI函数注册方式的变化
AGP 8.0之后,官方默认的JNI注册方式从RegisterNatives的动态注册转向了静态注册的兼容共存。LibInject为了兼容多种使用场景,很多版本会同时提供两种注册方式。如果你在AGP 8.x下遇到UnsatisfiedLinkError,大概率是它选择了静态注册,而你的so导出符号表在strip之后被删掉了。
解决方法是,在CMakeLists.txt里关掉strip,或者用-Wl,--export-dynamic强制导出所有符号:
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--export-dynamic") add_library(libinject SHARED src/main/cpp/inject.cpp)如果这一步不处理,你在Java层声明的native方法,在so里可能根本找不到对应符号,表现为运行时才报错、编译时没问题。这也是我见过最多人在“release真机上闪退,debug上正常”的根因。
4.3 Android Studio版本与AGP的兼容性表格
很多读者还在纠结Android Studio版本和AGP版本怎么搭,这里给一个从实践角度验证过的组合表:
| Android Studio版本 | 推荐AGP版本 | LibInject集成注意点 |
|---|---|---|
| Hedgehog 2023.1.1 Patch 2 | 8.2.x | CMake 3.22.1以上,默认NDK 26可编译 |
| Iguana 2023.2.1 | 8.3.x | 需确认Gradle JDK版本为17 |
| Giraffe 2022.3.1 | 8.1.x | 兼容LibInject 2.x版本,注意老版本CMake路径 |
| Electric Eel 2022.1.1 | 7.4.x | 建议先升级AGP再集成LibInject |
说实话,Android Studio版本本身不是问题,问题在AGP新版本引入的配置行为和默认值变化。AGP 8默认开启了minifyEnabled=true的release构建行为,导致了前面说的混淆问题。所以集成LibInject的项目,我建议先关闭R8做一轮release测试,确认无问题后再开着R8逐一解决keep规则。
当然还要提一个新老版本接口的兼容问题。如果你是从LibInject老版本升级上来的,有些API在2.x里被标记为@Deprecated,比如InjectAgent.hook()改成了InjectAgent.hookMethod(),很多教程还停留在旧写法,编译不报错但运行时不生效。遇到这种情况,去读一下源码里的InjectAgent.java,看方法判定的逻辑,比在网上找答案稳定得多。
5. 实战踩坑记录:我遇到过的稳定性、线程和内存问题
集成LibInject不是“加上一个库就完事”的活儿,后面还有一堆稳定性问题需要处理。我挑三个典型的坑来讲,它们都有共性的背后逻辑。
5.1 多线程并发初始化时的重复注入
第一次在自己App里集成LibInject时,我在子线程里调了InjectAgent.init(),然后又马上在主线程调用hookMethod(),结果发现部分hook生效、部分没生效,而且不稳定。后来排查发现,注入器内部的数据结构是普通HashMap,两个线程同时读写导致put丢失或链表坏掉。
修复方式就是初始化时加一个并发控制,确保一次只有一个线程进入初始化流程:
private static final AtomicBoolean initialized = new AtomicBoolean(false); private static final Object lock = new Object(); public static void ensureInit(Context context) { if (!initialized.get()) { synchronized (lock) { if (!initialized.get()) { InjectAgent.init(context, config); initialized.set(true); } } } }这个双重检查锁定在Android单进程内是可靠的。但如果你开了多进程——比如android:process=":push"——要特别注意:每个进程都有自己的ClassLoader和so实例,LibInject的初始化要在每个进程里都执行一次,不能只在一个进程里init。
5.2 Hook后方法递归调用导致的栈溢出
我踩过最无语的坑是:hook一个方法时,在after回调里打印日志,结果日志使用的是同一个hook方法内的工具函数,触发递归调用,直接崩掉了。这种问题在日志、统计、上报类代码里特别容易犯。
解决办法也很简单,在hook回调里避免调用任何可能有二次hook的方法。如果一定要打印,用最基础的android.util.Log,不要调用业务封装的多媒体、存储、上报模块。另外,LibInject如果支持静态代理模式,可以考虑用一个静态标志位标记当前是否处于注入回调中:
@Override public Object after(Object target, Object[] args, Object result) { if (insideHook.get()) { return result; } insideHook.set(true); try { Log.d("Inject", "after hook"); } finally { insideHook.set(false); } return result; }这个标志位能拦截掉绝大多数回调内的重复触发。
5.3 被Hook方法的长参数与跨进程Binder传递问题
LibInject在做参数传递时,如果目标方法参数里有Parcelable或者Binder对象,直接通过JNI转发到Native层去处理时要特别注意:不能长期持有Binder的引用。Binder是跨进程的,它的生命周期和进程绑定,如果你在Native层缓存了Binder引用,进程GC回收时可能出现Binder代理已失效,再次调用就会抛DeadObjectException。
一个实际案例:我hook过系统PackageManager的getInstalledPackages(),参数里没有Binder,但返回值里大量包含ApplicationInfo等Parcelable对象层叠结构,这些对象本质上是Binder驱动把数据拷回进程的,如果注入代码把这些对象存进了全局缓存,然后跨模块访问,很容易出现数据陈旧或者引用失效。解决办法是在dto传参时做深拷贝,不要直接持有返回值对象的引用。
5.4 动态下发的注入策略,怎么做好版本校验和灰度
如果在你的体系里,LibInject是配合后台下发的策略用的——比如运营配置一个“对某个方法开启耗时监控”的JSON,客户端拉下来后实时注入——那就必须有策略校验机制。不然线上配错一个类名,注入找不到目标类还好,如果因为Hook导致的崩溃是崩溃率飙升的隐患。
我建议在下发策略里至少包含三个字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| className | 目标类完整类名 | com.example.business.OrderManager |
| methodName | 目标方法名 | submitOrder |
| minAppVersion | 最低支持版本 | 3000150 |
客户端收到策略后,优先判断版本号,再校验类是否存在,最后再做hook。而且整个解析和hook过程应该放在try-catch里,任何一步异常都不要影响主流程。
这个字段的设计逻辑是:灰度期间如果某一版本hook有问题,后台可以直接下架策略,客户端后续不再执行注入,但已注入的进程需要重启才能清除。这一点要在设计文档里写清楚,不是所有问题都能热恢复。
6. 从LibInject出发的扩展思考:逆向分析和基础设施的边界
讲了这么多实操,最后聊一点技术选型上的思考。LibInject这套东西,既能做正向的动态化能力,也可以反过来做逆向侧的辅助分析。很多人问,到底是把它当功能引擎,还是当稳定性风险来对待?
我的看法是:把它当一种“强大但必须在受控范围内使用”的机制。因为无论是ArtMethod Hook还是Native GOT替换,本质上都是在操作系统加载和运行模型上做文章,即使LibInject本身没有恶意,也必须在合理合法的范围内使用。
从工程化角度,我会建议任何引入LibInject的项目都建立几个配套措施:
- 注入行为全埋点:所有hook点都记录到独立的日志文件中,交叉分析时可以精确到方法粒度。
- 定期复测所有注入点:Android系统每次版本升级,ArtMethod的内部结构都可能变化,LibInject这类写结构的库最容易在不升级的情况下崩溃。有一个稳妥的方案是在每次系统大版本发布后,用兼容性测试集跑一遍注入点和基础功能。
- 合理规划注入点数量:不是所有方法都值得hook。每多一个hook点,就在每次方法调用时多了一层转发,性能损耗会发酵。建议一个核心流程内hook点不超过10个,否则在高频路径上会有肉眼可见的卡顿。
如果想继续深入研究,可以试着从这些方向展开:
- LibInject的ArtMethod结构兼容层:自己写一个读取不同Android API Level下的ArtMethod字段偏移量的工具类。
- 与R8生成代码模式的冲突分析:哪些R8优化手段会导致libinject找不到目标方法的入口。
- 基于LibInject做一套灰度实验的A/B测试框架:让同一方法在不同用户群下走不同实现。
回到最初的问题——为什么要在Android上引入注入代码?因为一个成熟的App,运行时永远需要比编译期更多的应变空间。前提是你清楚地知道自己在改什么、风险在哪、怎么回滚。LibInject本身只是一个工具,真正决定它价值的,是使用它的人对运行时机制的理解深度。
本文还有配套的精品资源,点击获取