1. 项目概述:为什么Frida RPC的参数传递是个技术活?
搞移动安全分析或者逆向的兄弟,对Frida肯定不陌生。它就像一把瑞士军刀,能动态插桩、能Hook、能内存漫游。但很多时候,我们不只是想“看”,更想“动”——想从我们自己的JavaScript脚本里,直接调用App里的某个Java函数,并且还能把参数传过去,再把结果拿回来。这就是Frida RPC(Remote Procedure Call,远程过程调用)的用武之地。
听起来很简单?不就是个函数调用嘛。但真上手你会发现,坑一个接一个。比如,你在JS里写了个字符串“Hello”,传到Java层怎么就变成乱码了?你想传一个复杂的JSON对象过去,Java那边该怎么接收?更头疼的是,Java方法有重载(Overload),Frida怎么知道你想调用的是哪一个?这些问题不解决,RPC就成了摆设,代码跑起来要么崩溃,要么结果不对,调试起来能让人抓狂。
这篇文章,我就结合自己趟过的坑,把Frida RPC在JavaScript和Java之间传递参数的那些门道彻底讲清楚。我会从最基础的原理讲起,然后一步步拆解各种数据类型的传递方法,最后给出一个包含完整错误处理和实战技巧的代码示例。目标就一个:让你看完就能写出稳定、高效的Frida RPC脚本,真正把动态分析的能力握在手里。
2. RPC核心原理与Frida的实现机制
在深入代码之前,我们必须先搞清楚Frida RPC是怎么工作的。这能帮你理解后续所有“为什么这么做”的原因。
2.1 什么是RPC?在Frida语境下的特殊之处
RPC,远程过程调用,简单说就是让一个程序(客户端)能够调用另一个地址空间(通常是另一台机器或另一个进程)里的函数或方法,就像调用本地函数一样。在Frida的场景里,“客户端”是我们注入的JavaScript脚本,“服务端”则是目标App的Java(或Native)运行时环境。
Frida实现RPC的核心,是它在目标进程中创建了一个“消息总线”。你的JS脚本和目标的Java世界并不直接交互,而是通过序列化(Serialization)和反序列化(Deserialization)来传递消息。当你调用一个RPC方法时,Frida会:
- 将你的JavaScript参数转换成一种中间格式(通常是JSON)。
- 通过内部通道(如Unix Socket或命名管道)将消息发送到目标进程的Frida Agent。
- Agent解析消息,找到对应的Java类和方法,并将中间格式的参数还原成Java对象。
- 调用该Java方法,获取返回值。
- 将返回值再次序列化成中间格式,传回给JS脚本。
- JS脚本收到后反序列化,得到JavaScript对象。
这个过程里,数据类型转换是最大的挑战。JavaScript是弱类型、动态的,而Java是强类型、静态的。一个JS Number可能是Java int、long、float或double;一个JS Object在Java那边可能对应Map、JSONObject或者一个自定义的POJO。
2.2rpc.exports的关键作用
Frida提供了rpc.exports对象来暴露你的函数给JS脚本调用。这是RPC的“服务注册中心”。
// 在目标进程的JS环境中(通常是在Java.perform内) rpc.exports = { myFunction: function (arg1, arg2) { // 这个函数可以被外部JS脚本调用 return arg1 + arg2; }, anotherFunction: function (data) { // 处理复杂数据 return processData(data); } };关键点在于,rpc.exports里定义的函数,其参数和返回值都会经历上述的序列化/反序列化过程。因此,你定义函数签名时,心里就要想着Java那边会收到什么。
2.3 通信模型与线程安全考量
Frida的RPC调用默认是同步的。也就是说,你在外部脚本里调用rpc.exports.myFunction(),脚本会阻塞,直到目标进程中的函数执行完毕并返回结果。这对于大多数逻辑简单的调用是没问题的。
但是,如果你在RPC函数里执行了耗时操作(比如网络请求、大量文件IO),外部脚本就会一直卡住。更危险的是,如果你在RPC函数里尝试通过Java.perform去操作UI线程(例如更新TextView),很可能引发死锁或崩溃,因为Android的UI操作必须在主线程进行。
重要提示:Frida的RPC调用发生在它自己管理的线程上,不是主线程。在RPC函数中直接进行UI操作是危险的。安全的做法是,如果RPC函数需要触发UI更新,应该通过
Java.scheduleOnMainThread()来安排任务到主线程执行。
3. 基础数据类型与简单对象的传递
我们从最简单的开始,这是构建复杂功能的基础。处理不好这些,后面的一切都无从谈起。
3.1 数字、布尔值与字符串的传递
这是最直观的。JavaScript的Number,Boolean,String类型,通常能自动映射到Java对应的基本类型及其包装类。
外部调用脚本示例 (example_call.js):
// 连接到设备上的进程 const session = await frida.getUsbDevice().attach('com.example.targetapp'); const script = await session.createScript(` // 这是注入到目标App的脚本,定义RPC服务 rpc.exports = { addNumbers: function (a, b) { // a, b 会自动转换为Java的int或double return a + b; // 返回值也会自动转换 }, toggleFlag: function (flag) { // flag 会自动转换为boolean return !flag; }, concatenate: function (str1, str2) { // 字符串传递,注意编码! return str1 + ' ' + str2; } }; `); await script.load(); // 调用RPC函数 const api = script.exports; const sum = await api.addNumbers(5, 3.14); // 传递整数和浮点数 console.log('Sum:', sum); // 输出: 8.14 const toggled = await api.toggleFlag(true); console.log('Toggled:', toggled); // 输出: false const greeting = await api.concatenate('Hello', 'Frida'); console.log('Greeting:', greeting); // 输出: Hello Frida看起来一切顺利?但这里有三个隐藏的坑:
- 数字精度:JavaScript只有一种
Number类型(双精度浮点数)。当你传递一个很大的整数(超过2^53)时,可能会丢失精度。如果Java方法参数是long,最好在JS端用字符串传递大整数,或者在Java端用BigInteger接收。 - 空值(Null):JavaScript的
null可以传递给Java的引用类型参数。但是,如果Java参数是基本类型(如int),传null会导致调用失败。你需要确保传值有效,或在Java端使用包装类(如Integer)。 - 字符串编码:这是最经典的坑。JavaScript字符串是UTF-16编码的,而Java字符串内部是UTF-16或经过压缩的(取决于Android版本)。在大多数情况下,Frida的序列化能处理好。但是,如果你传递的字符串包含特殊字符(如Emoji、生僻字)或来自其他编码的数据(如GBK字节流),就可能出现乱码。稳妥的做法是,对于非ASCII字符,考虑使用Base64编码后传递。
3.2 数组的传递与自动转换
传递数组非常方便。JavaScript的Array可以直接对应Java的数组或List。
目标App端RPC函数:
rpc.exports = { processScores: function (scoresArray) { // scoresArray 是一个JS数组,如 [85, 92, 78] // 在Java层,它会被当作 int[] 或 List<Integer> let sum = 0; for (let i = 0; i < scoresArray.length; i++) { sum += scoresArray[i]; } return sum / scoresArray.length; // 返回平均分 }, getUsersList: function () { // 也可以从Java返回数组或List给JS let ArrayList = Java.use('java.util.ArrayList'); let list = ArrayList.$new(); list.add('Alice'); list.add('Bob'); return list; // JS端会收到一个普通的JS数组 ['Alice', 'Bob'] } };注意事项:
- 多维数组:JavaScript的多维数组(数组的数组)可以对应Java的多维数组,但结构必须规整。
- 类型一致性:尽量保证数组内元素类型一致。如果一个JS数组里既有数字又有字符串,传到Java的
int[]肯定会出问题。Java是强类型的。
4. 复杂对象与自定义类的传递策略
当参数不再是简单的数字字符串,而是一个有结构的对象时,事情就变得有趣了。
4.1 使用JSON作为通用数据交换格式
最通用、最推荐的方式就是使用JSON。几乎所有语言都对JSON有良好的支持。在Frida RPC中,你可以将复杂的JavaScript对象序列化成JSON字符串传递,在Java层再反序列化。
外部调用脚本:
const userData = { userId: 1001, username: 'john_doe', isAdmin: false, tags: ['developer', 'frida'], profile: { level: 5, score: 1234.5 } }; const api = script.exports; const result = await api.handleUser(JSON.stringify(userData)); // 关键:序列化成字符串 console.log(result);目标App端RPC函数(使用org.json库,Android自带):
rpc.exports = { handleUser: function (jsonStr) { let JSONObject = Java.use('org.json.JSONObject'); try { let jsonObj = JSONObject.$new(jsonStr); // 反序列化 let userId = jsonObj.getInt('userId'); let username = jsonObj.getString('username'); let tagsArray = jsonObj.getJSONArray('tags'); // ... 处理业务逻辑 return 'User processed: ' + username; } catch (e) { console.log('JSON解析错误: ' + e); return 'Error'; } } };优点:极其灵活,可以表达任意复杂度的嵌套结构。与语言无关,方便调试(直接打印字符串即可)。缺点:有序列化和反序列化的性能开销。需要确保Java端有JSON解析库(Android自带org.json,也可以用Gson、Jackson等)。
4.2 直接构造并传递Java对象
对于性能要求更高,或者需要传递无法用JSON简单表示的Java对象(如Android的Intent、Bundle)时,可以直接在JS端构造Java对象。
示例:传递一个自定义的User对象假设目标App里有一个类com.example.model.User。
rpc.exports = { createAndSendUser: function (name, age) { // 1. 获取Java类 let UserClass = Java.use('com.example.model.User'); // 2. 构造对象。注意:需要知道正确的构造函数签名。 // 假设构造函数是 User(String name, int age) let userInstance = UserClass.$new(name, age); // 3. 调用目标Java方法,传入这个对象 let SomeManager = Java.use('com.example.SomeManager'); let managerInstance = SomeManager.getInstance(); let result = managerInstance.processUser(userInstance); return result; } };更复杂的场景:操作对象的字段和方法
rpc.exports = { manipulateUser: function () { let UserClass = Java.use('com.example.model.User'); let user = UserClass.$new('InitialName', 20); // 修改字段 user.name.value = 'ModifiedName'; // 如果name是public字段 // 或者通过setter user.setAge(25); // 调用方法 let info = user.getUserInfo(); return info; } };这种方法威力巨大,但注意事项更多:
- 类路径必须完全正确:
Java.use()的参数必须是完整的类名,且该类必须已被目标进程的ClassLoader加载。 - 构造函数签名:
$new()必须匹配正确的构造函数参数。如果有重载,Frida会根据你传入参数的数量和类型尝试匹配,但不总是可靠。对于复杂重载,可能需要使用overload()明确指定。// 明确指定使用接收String和int的构造函数 let user = UserClass.$new.overload('java.lang.String', 'int')('Alice', 30); - 对象生命周期:在JS端创建的Java对象,其生命周期由JavaScript的垃圾回收和Frida共同管理。一般来说,只要该对象还被JS变量引用,或者被传递到Java层并被Java代码持有,它就不会被回收。但这是一个需要留心的点,特别是在循环或长时间操作中。
5. 高级技巧与实战避坑指南
掌握了基础传递方法,我们来看看如何让RPC更稳健、更强大。
5.1 处理Java方法重载(Overload)
这是调用Java方法时最常见的障碍之一。Java允许方法名相同但参数列表不同。Frida需要你明确指出调用哪一个。
错误示范(可能导致调用失败或调用非预期的方法):
let StringClass = Java.use('java.lang.String'); let strInstance = StringClass.$new('Hello'); // 假设想调用 substring(int beginIndex, int endIndex) let sub = strInstance.substring(1, 3); // Frida可能混淆正确做法:使用.overload()明确签名
rpc.exports = { callOverloadedMethod: function () { let StringClass = Java.use('java.lang.String'); let str = StringClass.$new('HelloWorld'); // 方法1:使用overload指定参数类型 let substringWithTwoInts = str.substring.overload('int', 'int'); let result1 = substringWithTwoInts.call(str, 1, 4); // 得到 "ell" // 方法2:使用overload指定参数类型的字符串表示(更通用) let substringWithOneInt = str.substring.overload('int'); let result2 = substringWithOneInt.call(str, 5); // 得到 "World" return result1 + ' | ' + result2; } };.overload()的参数是目标方法的参数类型的完整Java类名。对于基本类型,用'int','boolean'等;对于类,用'java.lang.String'。call方法的第一个参数是该方法所属的对象实例(this),后面是实际参数。
5.2 异步RPC调用与性能优化
如前所述,默认的RPC是同步的。对于耗时操作,我们需要异步化。
技巧:在RPC函数中返回一个Promise
rpc.exports = { fetchDataAsync: function (url) { return new Promise(function (resolve, reject) { // 模拟一个耗时的网络请求 setTimeout(function () { resolve('Data from ' + url); }, 2000); }); } };在外部脚本中,你可以用await或者.then()来调用:
const data = await api.fetchDataAsync('https://api.example.com'); console.log(data); // 2秒后打印这样,外部脚本在等待期间就不会被阻塞。但请注意:这个Promise是在目标进程的JS环境中执行的。如果这个耗时操作本身是CPU密集型的,它仍然会占用目标进程的资源。
性能优化建议:
- 批量操作:如果可能,设计RPC函数一次处理一批数据,而不是多次调用。
- 减少序列化:对于大型数据,考虑传递文件路径或引用,让Java端直接读取文件,而不是传递整个文件内容。
- 缓存Java类引用:
Java.use()是一个相对昂贵的操作。如果某个类会被频繁使用,应该在脚本初始化时就获取并缓存起来。let cachedUserClass = null; rpc.exports = { init: function () { cachedUserClass = Java.use('com.example.model.User'); return 'Cached'; }, useCachedClass: function () { if (cachedUserClass) { let user = cachedUserClass.$new('Test', 0); return user.toString(); } return 'Not initialized'; } };
5.3 异常处理与错误信息回传
Java方法可能会抛出异常,你的JS代码也可能出错。良好的错误处理机制至关重要。
在RPC函数内部捕获Java异常:
rpc.exports = { safeDivision: function (a, b) { try { let Integer = Java.use('java.lang.Integer'); // 假设调用一个可能抛出ArithmeticException的方法 return Integer.divideUnsigned(a, b); } catch (javaException) { // javaException是一个Java异常对象 console.log('Java异常捕获: ' + javaException); // 返回一个错误标识,或者将异常信息序列化后返回 return { error: true, message: javaException.toString(), stack: javaException.getStackTrace().map(frame => frame.toString()) }; } } };在外部脚本中处理RPC调用错误:Frida的RPC调用也可能因为连接断开、脚本卸载等原因失败。调用返回的Promise可能会被reject。
async function callRpcSafely() { try { const result = await api.someFunction(); console.log('成功:', result); } catch (error) { // 错误可能是JS错误,也可能是通信错误 console.error('RPC调用失败:', error.message); // 可能需要重新附加(attach)进程或加载脚本 } }6. 完整实战代码示例:一个用户登录验证的RPC模拟
让我们把所有知识点融合到一个贴近实战的例子中。假设我们要测试一个App的登录逻辑,它有一个LoginService类,其中包含一个验证用户的方法。
目标App的Java代码(假设):
package com.example.app; public class LoginService { private static LoginService instance; public static LoginService getInstance() { /* ... */ } // 我们要Hook和调用的方法 public LoginResult verifyUser(String username, String password, Map<String, String> extraParams) { // ... 复杂的验证逻辑,可能访问网络、数据库 if ("admin".equals(username) && "123456".equals(password)) { return new LoginResult(true, "Login OK", extraParams.get("clientInfo")); } else { return new LoginResult(false, "Invalid credentials", null); } } } public class LoginResult { boolean success; String message; String clientInfo; // ... 构造方法和getter/setter }我们的Frida RPC脚本目标:
- 暴露一个RPC函数,允许外部传入用户名、密码和额外参数。
- 在目标进程内部调用真实的
verifyUser方法。 - 将结果(包括一个复杂的
LoginResult对象)返回给外部脚本。
完整的Frida JS脚本 (inject_script.js):
Java.perform(function () { // 缓存要使用的Java类 let LoginService = Java.use('com.example.app.LoginService'); let HashMap = Java.use('java.util.HashMap'); let LoginResult = Java.use('com.example.app.LoginResult'); // 定义并暴露RPC接口 rpc.exports = { // 暴露一个名为`performLogin`的RPC方法 performLogin: function (username, password, extraParamsJson) { try { console.log(`[RPC] 收到登录请求: user=${username}`); // 1. 获取LoginService单例 let serviceInstance = LoginService.getInstance(); if (serviceInstance == null) { throw new Error('无法获取LoginService实例'); } // 2. 处理额外参数:将JSON字符串转换为Java Map let extraParamsMap = null; if (extraParamsJson) { let paramsObj = JSON.parse(extraParamsJson); extraParamsMap = HashMap.$new(); for (let key in paramsObj) { if (paramsObj.hasOwnProperty(key)) { extraParamsMap.put(key, paramsObj[key]); } } } // 3. 明确指定调用 verifyUser(String, String, Map) 这个重载 let verifyUserMethod = serviceInstance.verifyUser.overload('java.lang.String', 'java.lang.String', 'java.util.Map'); // 4. 调用Java方法 let javaResult = verifyUserMethod.call(serviceInstance, username, password, extraParamsMap); // 5. 将Java的LoginResult对象转换为JS对象以便序列化返回 let jsResult = { success: javaResult.success.value, message: javaResult.message.value, clientInfo: javaResult.clientInfo.value // 可能是null }; console.log(`[RPC] 登录结果: success=${jsResult.success}`); return jsResult; } catch (error) { // 捕获所有异常,包括Java异常和JS异常 console.error(`[RPC] 执行失败: ${error}`); // 返回一个结构化的错误信息 return { success: false, message: 'RPC调用内部错误: ' + error.message, clientInfo: null, _error: true }; } }, // 另一个RPC示例:直接构造一个LoginResult对象(演示对象创建) createMockResult: function (isSuccess, msg) { let result = LoginResult.$new(isSuccess, msg, 'MockClient'); return { success: result.success.value, message: result.message.value, clientInfo: result.clientInfo.value }; } }; console.log('[+] Frida RPC 服务已注册: performLogin, createMockResult'); });外部调用脚本 (caller_script.js):
const frida = require('frida'); const fs = require('fs'); async function main() { // 1. 连接到设备并附加目标进程 const device = await frida.getUsbDevice(); const session = await device.attach('com.example.app'); // 替换为实际包名 console.log('已附加到进程'); // 2. 读取并创建注入脚本 const agentCode = fs.readFileSync('inject_script.js', 'utf8'); const script = await session.createScript(agentCode); // 监听脚本输出的日志 script.message.connect(message => { console.log(`[来自目标App] ${message.payload}`); }); await script.load(); console.log('脚本加载完成'); // 3. 获取RPC接口 const api = script.exports; // 4. 调用RPC函数进行登录测试 const extraParams = { clientInfo: 'Android-10-FridaClient', timestamp: Date.now() }; try { console.log('开始调用RPC登录...'); // 注意:传递的额外参数需要序列化成JSON字符串 const loginResult = await api.performLogin('admin', '123456', JSON.stringify(extraParams)); console.log('登录调用完成!'); console.log('结果:', JSON.stringify(loginResult, null, 2)); if (loginResult._error) { console.error('RPC调用发生了内部错误。'); } else if (loginResult.success) { console.log('模拟登录成功!'); } else { console.log('模拟登录失败:', loginResult.message); } } catch (error) { // 处理通信层面的错误(如进程崩溃、断开连接) console.error('调用RPC时发生通信错误:', error); } // 5. 测试另一个RPC函数 try { const mockResult = await api.createMockResult(true, '一切正常'); console.log('Mock结果:', mockResult); } catch (e) { console.error('创建Mock结果失败:', e); } // 保持连接,以便后续交互(实际使用时可能需要) // await new Promise(resolve => setTimeout(resolve, 60000)); // 保持60秒 // session.detach(); } main().catch(error => { console.error('主流程错误:', error); });这个示例涵盖的核心要点:
- 完整流程:从附加进程、加载脚本、调用RPC到处理结果。
- 复杂参数:演示了基本类型(String)、复杂类型(Map)的传递,其中Map通过JSON字符串中转。
- 方法重载处理:使用
.overload()明确指定要调用的verifyUser方法签名。 - Java对象转换:展示了如何将Java对象(
LoginResult)的字段提取并转换为JS对象。 - 全面的错误处理:在RPC函数内部使用try-catch捕获所有异常,并返回结构化的错误信息;在外部脚本中也处理了Promise拒绝(通信错误)。
- 实用日志:在关键步骤添加了日志输出,便于调试。
运行这个示例,你就能看到一个完整的、健壮的Frida RPC交互是如何实现的。你可以在此基础上修改,去调用App中任何你感兴趣的方法,传递各种参数,从而实现对App行为的深度探查和动态干预。记住,理解原理、处理好数据类型、做好错误处理,是写出高效稳定RPC脚本的关键。