1. 项目概述:为什么我们要亲手构建APK并Hook它?
在移动安全研究、应用逆向分析甚至是自动化测试领域,Frida 早已不是个陌生的名字。它就像一把瑞士军刀,能让我们在应用运行时“为所欲为”——查看、修改函数逻辑,甚至凭空注入代码。但很多教程都停留在“如何Hook一个现成的App”上,这就像只教你怎么开别人家的锁,却不告诉你锁的内部结构。一旦遇到加固、混淆或者复杂的业务逻辑,新手往往会一头雾水,不知道从何下手。
所以,我决定换一个思路,带大家走一条更扎实的路:从零开始,亲手构建一个目标APK,然后亲手用Frida去Hook它,实现动态篡改逻辑。这个过程的魅力在于,你既是“造物主”,也是“破解者”。你清楚地知道每一行代码在哪里、每一个方法叫什么、参数是什么,然后你再亲自去拦截和修改它。这种“自产自销”的实战,能让你透彻理解Hook技术的每一个环节,从源码到字节码,从静态分析到动态注入,建立起完整的知识闭环。无论是为了深入理解Android机制,还是为后续分析第三方复杂应用打下坚实基础,这都是一次不可多得的深度练习。
2. 环境搭建与目标APK构建
工欲善其事,必先利其器。我们的第一步是准备好所有工具,并创建一个用于“实验”的目标应用。
2.1 核心工具链准备
你需要准备以下环境,我强烈建议在实体机或性能较好的模拟器(如Android Studio自带的AVD)上进行,某些轻量模拟器可能对Frida支持不佳。
- Android Studio:这是我们的主要开发环境,用于编写和构建目标APK。从官网下载并安装最新稳定版即可。安装时注意勾选Android SDK和相应的平台工具。
- Java Development Kit (JDK):Android Studio通常会捆绑或提示安装,确保版本为JDK 11或17(根据你的Gradle插件版本兼容性选择)。
- Python 3.x:Frida的客户端脚本主要使用Python编写。确保已安装,并将
pip添加到系统环境变量。 - Frida:这是我们的主角。安装分为两部分:
- 客户端 (Client):在你的电脑上安装。打开命令行,执行
pip install frida-tools。这会同时安装frida和frida-tools(包含有用的命令行工具如frida-ps)。 - 服务端 (Server):在你的Android设备(真机或模拟器)上运行。首先通过
adb shell getprop ro.product.cpu.abi查看设备架构(通常是arm64-v8a或x86_64)。然后去Frida的GitHub Releases页面,下载对应架构的frida-server-xx.x.x-android-xx.xz文件。
- 客户端 (Client):在你的电脑上安装。打开命令行,执行
- ADB (Android Debug Bridge):通常包含在Android SDK的
platform-tools目录下。确保adb命令可以在终端中直接调用。
注意:下载Frida-server时,版本号尽量与
frida-tools的版本保持一致或接近,以避免潜在的兼容性问题。使用frida --version查看客户端版本。
2.2 创建并理解我们的“实验靶场”APK
我们将创建一个极其简单但功能点清晰的Android应用,它包含我们后续要Hook的所有逻辑。
- 新建项目:打开Android Studio,选择“Empty Activity”模板,语言选择Java(为了更直观地展示Hook过程,Kotlin原理相同)。项目名称为
HookDemo,包名可设为com.example.hookdemo。 - 设计核心逻辑:我们修改
MainActivity.java,添加一些“靶子”方法。
package com.example.hookdemo; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import android.util.Log; import android.widget.Toast; public class MainActivity extends AppCompatActivity { private static final String TAG = "HookDemo"; // 靶子方法1:一个简单的加法函数 public int add(int a, int b) { int result = a + b; Log.d(TAG, “add: “ + a + “ + “ + b + “ = “ + result); return result; } // 靶子方法2:一个验证登录的函数 public boolean verifyLogin(String username, String password) { Log.d(TAG, “verifyLogin called with: “ + username + “/“ + password); // 简单的硬编码验证 boolean isValid = “admin“.equals(username) && “123456“.equals(password); Toast.makeText(this, “Login “ + (isValid ? “Success“ : “Failed“), Toast.LENGTH_SHORT).show(); return isValid; } // 靶子方法3:一个静态方法 public static String getSecret() { return “ThisIsASecretString“; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 调用一下我们的方法,方便观察 int sum = add(5, 3); boolean loginResult = verifyLogin(“user“, “pass“); String secret = getSecret(); Log.d(TAG, “onCreate: Sum=“ + sum + “, Login=“ + loginResult + “, Secret=“ + secret); } }- 构建APK:点击Android Studio工具栏的“Build” -> “Build Bundle(s) / APK(s)” -> “Build APK(s)”。构建完成后,将生成的
app-debug.apk文件安装到你的测试设备上。- 使用命令:
adb install path/to/your/app-debug.apk - 在设备上找到并打开
HookDemo应用,观察Logcat输出,确认应用正常运行,并看到了我们预设的日志。
- 使用命令:
这个APK就是我们的“靶场”。我们知道add方法在MainActivity类里,它接收两个int参数,返回它们的和。我们知道verifyLogin的校验逻辑是写死的。我们的目标,就是在不修改这个APK源码、不重新编译的情况下,用Frida改变这些行为。
3. Frida基础与设备端部署
在开始Hook之前,我们必须让Frida在设备端“跑起来”。
3.1 部署Frida-Server到Android设备
假设你下载的frida-server文件是frida-server-16.1.11-android-arm64.xz。
- 解压并推送:首先解压
.xz文件得到二进制文件frida-server-16.1.11-android-arm64。# 将文件推送到设备的可执行目录,/data/local/tmp 通常具有执行权限 adb push frida-server-16.1.11-android-arm64 /data/local/tmp/ - 赋予执行权限并运行:
adb shell # 进入目录 cd /data/local/tmp # 赋予可执行权限 chmod 755 frida-server-16.1.11-android-arm64 # 以后台方式运行frida-server,注意这里的文件名要和你推送的一致 ./frida-server-16.1.11-android-arm64 &&符号让其在后台运行。你可以使用ps | grep frida来检查进程是否在运行。 - 端口转发(重要):Frida-server默认监听TCP端口27042。我们需要通过ADB将这个端口转发到本地,以便电脑上的Frida客户端能够连接。
# 退出设备的shell(如果还在的话),在电脑的终端执行 adb forward tcp:27042 tcp:27042
3.2 验证连接与基础命令
现在,从你的电脑终端尝试以下命令,验证环境是否通畅:
# 列出设备上运行的所有进程 frida-ps -U # 列出设备上运行的所有Android应用(包名格式) frida-ps -Ua如果能看到一长串进程列表,恭喜你,Frida环境已经搭建成功。-U参数代表连接到USB设备。
4. Hook实战:从定位到动态篡改
现在进入最核心的部分。我们将编写Python脚本,利用Frida来Hook我们自己编写的APK。
4.1 编写第一个Hook脚本:拦截并修改加法结果
创建一个Python文件,例如hook_add.py。
import frida import sys # 定义我们的JavaScript Hook代码 jscode = “““ Java.perform(function () { console.log(“[+] Hook脚本已加载!“); // 定位我们的目标类 var MainActivity = Java.use(‘com.example.hookdemo.MainActivity’); // Hook ‘add’ 实例方法 MainActivity.add.implementation = function (a, b) { console.log(“[+] Hook到 add 方法被调用!“); console.log(“ 原始参数: a=“ + a + “, b=“ + b); // 我们可以篡改参数 var newA = a * 2; // 例如,把第一个参数乘以2 var newB = b + 10; // 把第二个参数加10 console.log(“ 篡改后参数: newA=“ + newA + “, newB=“ + newB); // 调用原始方法,但传入篡改后的参数 var originalResult = this.add(newA, newB); // 注意:这里调用原始实现 console.log(“ 原始方法结果(基于篡改参数): “ + originalResult); // 我们甚至可以篡改返回值! var finalResult = originalResult - 100; console.log(“ 最终返回结果: “ + finalResult); return finalResult; }; console.log(“[+] add 方法Hook设置完成!“); }); “““ def on_message(message, data): if message[‘type’] == ‘send’: print(“[APP LOG]: “ + message[‘payload’]) else: print(message) # 连接到设备 device = frida.get_usb_device() print(“[+] 已连接到设备: “ + str(device)) # 附加到目标进程(我们的HookDemo应用) # 首先需要启动应用,或者确保它正在运行。这里我们通过包名来附加。 try: # 方式一:附加到已存在的进程 pid = device.spawn([“com.example.hookdemo“]) # spawn会启动应用但暂停它 session = device.attach(pid) device.resume(pid) # 恢复应用执行 print(“[+] 已启动并附加到进程 PID: “ + str(pid)) except Exception as e: # 方式二:如果应用已在运行,直接附加 print(“[!] 尝试附加到运行中的进程...“) session = device.attach(“com.example.hookdemo“) # 创建并加载脚本 script = session.create_script(jscode) script.on(‘message‘, on_message) print(“[+] 正在加载Hook脚本...“) script.load() # 保持脚本运行,等待Hook事件触发 print(“[+] Hook脚本加载成功!请操作App触发add方法(或等待onCreate调用)。“) print(“[+] 按 Ctrl+C 停止脚本。“) sys.stdin.read()脚本解析与核心技巧:
Java.perform:这是Frida在Android上执行Java层Hook的入口函数,所有相关操作必须包裹在其中,以确保在正确的线程上下文中执行。Java.use(‘完整类名’):用于获取一个JavaScript wrapper,来代表目标Java类。这是定位类的关键。.implementation:这是Frida最强大的特性之一。通过重写一个方法的implementation属性,我们就替换了它的原始实现。在重写的函数里,this指向当前对象实例。this.add(...):在Hook函数内部,通过this.方法名(...)可以调用方法的原始实现。这是实现“先执行原逻辑再修改结果”或“修改参数后执行原逻辑”的基础。- 参数与返回值:你可以任意读取、修改方法的参数和返回值。这是动态篡改逻辑的核心。
运行与观察:
- 确保设备上的
HookDemo应用已经关闭。 - 在终端运行
python hook_add.py。 - 脚本会启动应用并附加。此时在设备上打开
HookDemo应用。 - 观察Python脚本控制台的输出。你会看到
add方法被onCreate调用时,我们的Hook脚本成功拦截,打印了原始参数(5, 3),篡改后参数(10, 13),原始结果23,以及最终返回的篡改结果-77。 - 同时观察Android Studio的Logcat,你会发现应用自己打印的日志
add: 5 + 3 = -77。看,返回值已经被我们篡改了!但应用对此一无所知,它以为add(5,3)真的返回了-77。
4.2 深入Hook:篡改登录验证逻辑
接下来,我们HookverifyLogin方法,实现一个“万能密码”。
创建新脚本hook_login.py,JavaScript部分修改如下:
Java.perform(function () { console.log(“[+] 开始Hook登录验证...“); var MainActivity = Java.use(‘com.example.hookdemo.MainActivity’); MainActivity.verifyLogin.implementation = function (username, password) { console.log(“[+] 登录验证被调用!“); console.log(“ 用户名: “ + username); console.log(“ 密码: “ + password); // 动态篡改逻辑:无论输入什么,只要密码是“frida“就算成功 var isMagicPassword = “frida“.equals(password); if (isMagicPassword) { console.log(“[!] 检测到万能密码 ‘frida‘,强制验证通过!“); // 直接返回true,完全绕过原始验证逻辑 return true; } // 如果不是万能密码,则执行原始验证逻辑 console.log(“ 执行原始验证逻辑...“); var originalResult = this.verifyLogin(username, password); console.log(“ 原始验证结果: “ + originalResult); return originalResult; }; console.log(“[+] verifyLogin 方法Hook设置完成!“); });技巧与思考:
- 在这个例子中,我们没有调用原始方法就返回了
true,这意味着原始的业务逻辑(检查admin/123456)被完全短路(Bypass)了。这在安全测试中常用于绕过客户端校验。 - 你可以尝试修改脚本,在调用原始方法
this.verifyLogin时,将参数篡改为正确的admin和123456,这样无论用户输入什么,最终都会以管理员身份登录。这演示了另一种攻击向量。
4.3 挑战:Hook静态方法
Hook静态方法略有不同,因为this在静态上下文中没有意义。我们修改hook_static.py的JS代码:
Java.perform(function () { console.log(“[+] 开始Hook静态方法...“); var MainActivity = Java.use(‘com.example.hookdemo.MainActivity’); // Hook静态方法 MainActivity.getSecret.implementation = function () { // 注意:这里没有参数 console.log(“[+] 静态方法 getSecret 被调用!“); // 调用原始静态方法 var originalSecret = MainActivity.getSecret(); // 注意:调用方式变了,用类名而不是this console.log(“ 原始秘密: “ + originalSecret); // 篡改返回值 var fakeSecret = “HackedByFrida!“; console.log(“ 返回假秘密: “ + fakeSecret); return fakeSecret; }; console.log(“[+] getSecret 静态方法Hook设置完成!“); });关键区别:
- 在Hook静态方法的实现里,调用原始方法使用的是类名.方法名(),即
MainActivity.getSecret(),而不是this.getSecret()。因为this指向实例对象,而静态方法属于类本身。
运行这个脚本,再次打开应用,你会发现Logcat中打印的Secret变成了我们返回的HackedByFrida!。
5. 高级技巧与实战问题排查
掌握了基本Hook后,我们面对真实场景会更复杂。以下是一些进阶技巧和常见问题的解决方案。
5.1 枚举与Hook重载方法
如果一个类中有多个同名但参数不同的方法(重载),我们需要精确指定。假设我们的MainActivity有另一个add方法,接收double类型。
public double add(double a, double b) { return a + b; }在Frida中,我们可以使用.overload(‘<参数类型>‘)来指定:
var MainActivity = Java.use(‘com.example.hookdemo.MainActivity’); // Hook int add(int, int) MainActivity.add.overload(‘int‘, ‘int‘).implementation = function(a, b) { // ... hook logic for int version }; // Hook double add(double, double) MainActivity.add.overload(‘double‘, ‘double‘).implementation = function(a, b) { // ... hook logic for double version };参数类型使用JNI签名简写,如int,double,java.lang.String,[B(byte数组),[Ljava.lang.String;(String数组)等。
5.2 主动调用Java方法
Frida不仅能拦截调用,还能主动发起调用。这在需要触发特定逻辑或查询对象状态时非常有用。
Java.perform(function () { // 使用Java.choose在堆上查找已存在的对象实例 Java.choose(‘com.example.hookdemo.MainActivity‘, { “onMatch“: function(instance) { console.log(“[+] 找到MainActivity实例: “ + instance); // 主动调用实例方法 var result = instance.add(100, 200); console.log(“[+] 主动调用add(100,200)结果: “ + result); }, “onComplete“: function() { console.log(“[+] 实例搜索完成。“); } }); // 主动调用静态方法 var secret = Java.use(‘com.example.hookdemo.MainActivity‘).getSecret(); console.log(“[+] 主动调用静态方法getSecret(): “ + secret); });5.3 常见问题排查实录
在实际操作中,你几乎一定会遇到下面这些问题。
问题1:Java.perform错误或类找不到
- 现象:
Error: java.lang.ClassNotFoundException: com.example.hookdemo.MainActivity。 - 排查:
- 包名/类名拼写错误:这是最常见的原因。使用
frida-ps -Ua确认应用包名,使用反编译工具(如JADX)或查看R.java确认完整类名。 - 类加载器问题:有些类由自定义ClassLoader加载。可以尝试枚举所有ClassLoader来查找类:
Java.enumerateClassLoaders({ onMatch: function(loader){ try { if (loader.findClass(“com.example.hookdemo.MainActivity“)) { console.log(“[+] 找到目标ClassLoader: “ + loader); Java.classFactory.loader = loader; // 切换ClassLoader } } catch(e) {} }, onComplete: function(){} }); - 应用未启动或进程错误:确保脚本附加的是正确的进程。对于多进程应用,UI主进程的包名通常就是应用包名。
- 包名/类名拼写错误:这是最常见的原因。使用
问题2:Hook后应用崩溃或行为异常
- 现象:Hook后应用闪退,或功能不正常。
- 排查:
- 参数/返回值类型不匹配:在Hook函数中,返回的类型必须与原方法声明的类型完全一致。
int不能返回String。 - 未调用原始方法:如果你替换了
implementation,但某些关键逻辑(如初始化、状态设置)在原始方法中,不调用它可能导致后续崩溃。除非你明确想阻断该逻辑。 - 递归调用:在Hook函数内部,如果你又调用了同一个被Hook的方法(且没有条件限制),会导致无限递归和栈溢出。确保你的调用逻辑有终止条件,或使用一个标志位来避免。
var isHooked = false; MainActivity.someMethod.implementation = function(arg) { if (isHooked) { return this.someMethod(arg); // 调用原始实现 } isHooked = true; // ... 你的hook逻辑 var result = this.someMethod(arg); // 这里会再次进入本函数,但被标志位拦截 isHooked = false; return result; };
- 参数/返回值类型不匹配:在Hook函数中,返回的类型必须与原方法声明的类型完全一致。
问题3:Frida-server连接失败或进程列表为空
- 现象:
frida-ps -U无输出或报错。 - 排查:
- ADB连接:确保
adb devices能看到你的设备。 - 端口转发:确认执行了
adb forward tcp:27042 tcp:27042。 - Frida-server未运行:重新进入
adb shell,检查/data/local/tmp/下的进程是否存在,并用ps | grep frida确认。如果没有,重新执行运行命令。 - 权限问题:确保是以
root权限运行frida-server(在已root的设备或模拟器上)。非root环境需要一些额外配置,且能力受限。 - 版本不匹配:确保电脑端的
frida和frida-tools版本与设备端的frida-server版本大致兼容。
- ADB连接:确保
问题4:如何Hook Native (SO库) 函数?
- Frida同样强大。使用
Interceptor.attach来Hook Native函数。
获取准确的函数符号名是Native Hook的关键,可以使用// 假设有一个libnative.so,里面导出了函数`int add(int a, int b)` var nativeAdd = Module.findExportByName(“libnative.so“, “_Z3addii“); // 需要函数修饰名 Interceptor.attach(nativeAdd, { onEnter: function(args) { console.log(“[+] Native add called.“); console.log(“ arg[0] (a): “ + args[0].toInt32()); console.log(“ arg[1] (b): “ + args[1].toInt32()); }, onLeave: function(retval) { console.log(“ returning: “ + retval.toInt32()); // 篡改返回值 retval.replace(999); } });objdump -T或nm工具查看SO库。
6. 项目总结与扩展思路
通过这个从构建APK到实现动态Hook的完整流程,我们亲手验证了Frida的核心能力:在运行时,对已知和未知的代码逻辑进行观察、拦截与篡改。我们从一个清晰的“靶场”开始,避免了逆向分析中“黑盒”的迷茫,专注于掌握Hook技术本身。
我个人在实际操作中的体会是,理解“上下文”至关重要。无论是Java层的this与类名调用区别,还是Native层的参数传递约定,亦或是多ClassLoader环境,本质上都是代码执行环境的问题。Frida给了我们上帝视角,但我们必须理解这个世界的运行规则,才能有效地施加影响。
这个项目可以轻松地扩展:
- 复杂参数处理:尝试Hook接收或返回复杂对象(如List、自定义类)的方法,学习如何使用Frida的API去构造和解析这些对象。
- 批量Hook与RPC:编写更复杂的脚本,一次性Hook多个类或方法。甚至利用Frida的
rpc.exports功能,将Hook到的函数暴露给Python端调用,实现双向通信。 - 对抗简单反调试:一些应用会检测Frida。你可以尝试在自定义的APK中加入简单的反Frida代码(如检测端口27042、检测
frida-server进程名),然后编写Frida脚本来绕过这些检测,这是一场有趣的攻防练习。 - 结合自动化:将你的Frida脚本与UI自动化工具(如Appium)结合,模拟用户操作触发特定功能,同时进行动态数据篡改,实现更复杂的自动化测试场景。
记住,技术本身无分对错。在合法授权的范围内,用这些技能去加固你自己的应用、进行安全评估或自动化测试,才是它最大的价值所在。希望这篇长文能成为你探索Android动态分析世界的一块坚实跳板。