从第一次拿Frida写动态插桩脚本到现在,我前前后后大概折腾过三十多个移动端安全评测项目。刚上手那阵子跟大多数人一样,觉得Frida脚本就是"一个js文件解决问题",直到有个业务模块的脚本膨胀到快四千行,我才意识到:不是Frida不好用,而是我的代码组织方式配不上这套工具。这篇标题虽然挂着"模块化架构"四个字,本质上只讲一件事——怎么把堆成一团的Frida脚本,拆成可以独立维护、按需加载、团队协作不打架的工程结构。如果你也在写超过一千行的Frida脚本,或者正准备把手上几个零散脚本整理成能复用的东西,这篇应该能直接帮到你。
1. 单脚本开发模式,问题到底出在哪
1.1 脚本从500行涨到4000行,发生了什么
Frida学的第一步通常都是对着官方示例抄,一个Java.perform包住所有逻辑,hook几个方法就完事。但当需求开始膨胀,脚本的复杂度增长根本不是线性的。
我印象最深的是一次给某政务类服务App做接口安全评测,需求从"看看登录接口有没有弱校验"一路膨胀到"把所有业务接口的参数加密方式都盘一遍",再加超时重试、数据导出、多设备对比,脚本从开场500行涨到接近4000行。最开始还能靠搜索关键词找到要改的地方,到后来我连hookNetwork这个函数到底被调了几次都不确定。最离谱的一次,我在一个新模块里顺手定义了一个变量叫context,结果整个脚本跑完只出来了三行日志,其他的全部静默失败。
那段时间我整理过一份"单脚本致命清单":
- 全局变量污染:所有hook模块共享同一个命名空间,一个变量名冲突能让整片逻辑失效,而且报错信息完全不指向真实原因。
- 业务逻辑与基础工具纠缠:日志要手写,
Java.use的异常处理在每个函数里复制三遍,改一个公共逻辑要全局搜索替换。 - 没有启用/停用边界:脚本加载就全量执行,想临时关掉某些hook只能注释代码,注释多了又忘了恢复。
- 多人协作变成灾难:几个同学同时改同一个文件,git冲突能解决两个小时,合并完代码逻辑还是错的。
不要在脚本写到两千行的时候才开始反思架构,那个节点基本已经来不及了。应该在一开始或者功能开始叠加的时候就考虑组织方式。
1.2 多人协作时的真实场景
有一次我跟另外两位同学一起维护一个评测脚本,工程里只有一个agent.js,我们三个人的工作流是:先看自己在哪块区域改,改完立刻拉最新,有冲突就手工合并。听起来能转,实际上每天都在踩别人的逻辑。
比如A同学整理网络请求的参数dump,B同学在同一个区域加了加密算法识别,两人改的是同一个函数的相邻行,合并之后代码能运行,但加密识别的结果被网络dump的try/catch吞掉了,B同学的模块完全没生效。这种问题在单文件模式下没有任何隔离手段,只要公共区域被多人修改过一次,后续排查就得把整段逻辑重新读一遍。
模块化之后,每个人维护独立的module-http-analysis.js、module-crypto-detector.js,互不干扰,合并只是新增文件,不是改公共区域。这个差异在团队协作里是决定性的。
1.3 复用与迁移成本高
单脚本还带来一个隐形成本:换项目的时候几乎无法复用。
我之前给一个电商App写的接口分析脚本,想直接搬到另一个金融App上。说难听点,复制粘贴那三千行之后,光改包名和类名就花了一个下午,中间还有一堆硬编码的过滤规则。最痛苦的是,旧脚本里有一些我当时很看重的"通用封装",早就埋在业务逻辑里找不出来了。
这也是我觉得Frida工程化最被低估的价值:不是为了炫技,是为了让一套代码在不同项目之间以低成本迁移。模块化之后,logger、safe_hook、rpc这些模块是零迁移成本的,要换的只有业务模块里的类名和配置。
1.4 模块化到底能救什么
| 痛点 | 单脚本模式 | 模块化架构 |
|---|---|---|
| 维护成本 | 全局变量互相干扰,改动容易带崩其他功能 | 模块间通过上下文隔离,边界清晰 |
| 团队协作 | 同一文件改来改去,冲突不断 | 每人独立模块文件,合并简单 |
| 跨项目复用 | 复制粘贴后大改 | 通用模块零成本迁移,业务模块配置化 |
| 问题排查 | 日志混在一起,定位靠搜索 | 模块级日志独立输出,按前缀过滤 |
看完表格你应该明白,模块化不是要把Frida脚本变成多复杂的工程,而是把"在哪改"变成"改哪个模块"这么简单的一件事。
2. 模块化架构怎么设计才够稳
2.1 划分边界:按业务和能力分,不按API分
我见过不少整理过代码的同学,模块划分第一反应是"network.js管网络、crypto.js管加密、ui.js管界面"。这种按技术主题划分的方式,跟单文件并没有本质区别,因为网络和加密在业务上经常是同一件事,改加密的时候还得去network模块里翻对应代码。
合理的做法是按能力域划分,重点是"这个模块对外提供什么能力":
logger模块:统一日志输出、格式化、按模块Tag过滤。safe_hook模块:封装Java.use+ hook + 异常处理 + 自动重试。http_analysis模块:负责捕获接口请求、参数、返回值,依赖logger和safe_hook。crypto_detector模块:负责扫描常见加密算法相关的类和方法调用。ui_automation模块:负责页面遍历、控件点击、输入注入。
可以看出,http_analysis和crypto_detector虽然一个偏网络一个偏加密,但它们都是业务模块,平级关系。它们依赖的底层能力都放在safe_hook和logger里。
这里有一个判断标准:如果A模块的一个小改动会迫使B模块也跟着改,那A和B就不该平级,应当拆成"能力层+业务层"的依赖关系。
2.2 分层设计:基础设施、能力、业务三层
我把Frida脚本的结构稳定在三个层次:
第一层是基础工具层,包含logger、config、common-utils。它们不依赖任何具体业务,只需要全局运行环境。
第二层是能力层,比如safe_hook、class-scanner、rpc-bridge。这层封装了Frida的常见操作,让上层业务模块不直接碰Java.perform和Java.use的繁琐细节。举个最简单的例子,safe_hook里的 hook 方法要处理"类是否已加载""方法是否有重载""调用原始方法是否需要返回值"这些底层问题,业务模块只需要传类名和方法名。
第三层是业务层,比如http_analysis、crypto_detector、ui_automation。它们只关心"当前评测需求要做的事",所有技术细节都往能力层推。
这个分层的好处是依赖方向永远是单向的:业务层 → 能力层 → 工具层。我踩过最严重的坑就是业务模块之间互相引用,后来强制规定业务模块不能直接加载另一个业务模块,只能通过共享上下文交换数据,架构才稳定下来。
2.3 模块间通信
Frida的JS脚本运行在一个进程里,理论上模块之间可以直接用全局变量通信,但这正是混乱的来源。我在设计时给每个模块注入一个context对象,模块之间不直接引用,而是通过context里的共享数据交换。
举个例子,http_analysis捕获到一组请求参数后,把它写进context.analysisData.httpRequests,crypto_detector识别到加密入口后,也写进context.analysisData.cryptoHits。最后入口模块统一从context读取结果,通过rpc.exports暴露给外部。
这样模块之间完全没有直接的函数调用关系,A模块删掉对B模块没有任何影响。前期不用设计复杂的事件总线,共享一个普通的AppContext对象就够了。
2.4 一个清晰的目录组织
工程化不是嘴上说说,目录结构是第一步。我现在的Frida项目基本都是这样:
agent/ ├── entry.js # 入口:加载配置、启动模块 ├── registry.js # 模块注册表与加载器 ├── context.js # 全局共享上下文 ├── config.json # 配置:模块清单与参数 ├── modules/ │ ├── base/ │ │ ├── logger.js # 基础日志 │ │ └── common-utils.js # 通用工具 │ ├── ability/ │ │ ├── safe-hook.js # hook封装 │ │ ├── class-scanner.js # 类扫描 │ │ └── rpc-bridge.js # RPC暴露统一封装 │ └── business/ │ ├── http-analysis.js # 网络接口分析 │ ├── crypto-detector.js # 加密算法识别 │ └── ui-automation.js # UI自动化 └── build.js # 合并构建脚本很多同学看到这个目录第一反应是"这会不会太重了"。放心,Frida加载的还是单JS文件,只是在构建时把这些文件按顺序拼起来。build.js的本质是读文件、拼字符串、输出到dist/agent.js,十几行代码搞定,不需要引入大型构建工具。
3. 核心实现:一套最小可用的模块化骨架
3.1 模块注册表与加载器,这是骨架的心脏
整个模块化架构的核心不是业务模块本身,而是那个负责注册、解析依赖、按序加载的注册表。它把"模块的实现"和"模块的启动"彻底解耦。
// registry.js 'use strict'; const ModuleRegistry = { _modules: {}, // 模块元信息里可以带依赖列表 register(name, factory, meta = {}) { if (this._modules[name]) { throw new Error('重复注册模块: ' + name); } this._modules[name] = { factory, depends: meta.depends || [] }; }, has(name) { return name in this._modules; }, // 解析依赖顺序,简单的深度优先 resolveOrder(name, visited = []) { if (visited.includes(name)) { throw new Error('循环依赖: ' + visited.concat(name).join(' -> ')); } const mod = this._modules[name]; if (!mod) { throw new Error('模块不存在: ' + name); } let order = []; for (const dep of mod.depends) { order = order.concat(this.resolveOrder(dep, visited.concat(name))); } order.push(name); return order; }, load(name, context) { const mod = this._modules[name]; if (!mod) { throw new Error('模块不存在: ' + name); } return mod.factory(context); } }; module.exports = ModuleRegistry;register时传一个工厂函数,工厂函数接收context对象并返回模块实例。注意resolveOrder里的循环依赖检测——我之前在配置里不小心把logger和common-utils互相设为依赖,启动时直接死循环,加了这段检测后问题一眼就暴露了。
3.2 一个简单可靠的上下文对象
context.js承担的是模块间的数据总线角色。它不是一个花哨的事件系统,就是一个普通对象,挂几个公共字段:
// context.js 'use strict'; const AppContext = { config: {}, logger: null, safeHook: null, // 业务模块共享的中间数据 analysisData: { httpRequests: [], cryptoHits: [], uiActions: [] }, // 模块实例列表,便于统一回收 loadedModules: [] }; module.exports = AppContext;模块运行时需要什么依赖,不是自己去找,而是由入口模块从AppContext里取出来再注入。这样每个业务模块拿到的都是别人"递过来"的东西,而不是自己在全局环境里乱翻。
3.3 基础模块:Logger和SafeHook
先写最底层的logger。它的价值不只是打印,而是给日志打上模块Tag,后续排查时可以单独过滤。
// modules/base/logger.js 'use strict'; const timestamps = new Date().getTime(); function log(level, tag, message) { console.log(JSON.stringify({ ts: timestamps, level, tag, msg: message })); } module.exports = function createLogger(context) { return { info(tag, msg) { log('info', tag, msg); }, warn(tag, msg) { log('warn', tag, msg); }, error(tag, msg) { log('error', tag, msg); }, debug(tag, msg) { if (context.config.debug) log('debug', tag, msg); } }; };日志打成JSON格式是刻意为之,因为外部脚本(Python端)拿到后可以一行一行的解析,跟日志分析工具无缝对接。如果只是console.log拼字符串,后期数据整理会想哭。
再写能力层的safe-hook。这一层的作用是把Frida hook的脏活累活集中处理:
// modules/ability/safe-hook.js 'use strict'; module.exports = function createSafeHook(context) { const logger = context.logger; function hookMethod(className, methodName, callback) { return new Promise((resolve) => { Java.perform(() => { try { const Target = Java.use(className); const overloads = Target[methodName].overloads; if (!overloads || overloads.length === 0) { logger.warn('SafeHook', `方法无重载可用: ${className}.${methodName}`); resolve(false); return; } overloads.forEach((overload) => { overload.implementation = function (...args) { const ret = callback.apply(this, args); return ret; }; }); logger.info('SafeHook', `hook成功: ${className}.${methodName}`); resolve(true); } catch (e) { logger.error('SafeHook', `hook失败: ${className}.${methodName} - ${e.message}`); resolve(false); } }); }); } return { hookMethod }; };这里有几个容易被忽略的点。第一,hook方法要包装在Java.perform里,否则类操作时机不对。第二,先取overloads再逐个改implementation,是因为有些方法有多个重载,只改默认的那个很可能不是你业务里调用的那个。第三,返回Promise是为了让入口模块可以await所有模块启动完成后再输出状态,而不是脚本一加载就瞎等待。
3.4 业务模块:HTTP接口动态分析
有了基础模块,业务模块写起来就清爽多了。下面是一个http-analysis业务模块的示例,它展示了业务模块怎么使用能力层,怎么把结果写进共享上下文:
// modules/business/http-analysis.js 'use strict'; module.exports = function createHttpAnalysis(context) { const logger = context.logger; const safeHook = context.safeHook; const config = context.config.httpAnalysis || {}; function captureRequest(request, args) { const url = request.url().toString(); if (config.urlFilter && !config.urlFilter.test(url)) { return; } const record = { url, method: request.method(), headers: request.headers().toString(), timestamp: Date.now() }; context.analysisData.httpRequests.push(record); logger.info('HttpAnalysis', `${record.method} ${url}`); } async function start() { // 这里只是一个示例,真实项目要根据目标App的网络库来适配 await safeHook.hookMethod('okhttp3.Request$Builder', 'url', function (urlStr) { const request = this.build(); captureRequest(request, arguments); return this; }); } return { name: 'http_analysis', start }; };业务模块的代码量通常不大,重活都交给safeHook。注意我在captureRequest里用config.urlFilter做过滤,这个配置来自config.json,而不是写死在代码里。这就是前面说的"配置驱动"。
为什么这里假设okhttp3?因为多数Android应用都用OkHttp做网络层,这个hook点相对通用。如果你的目标是其他网络库或自研协议栈,只需要在业务模块里换类名和方法名,不影响其他模块。
3.5 入口模块:组合与启动
入口entry.js是整个脚本的指挥中心。它负责读配置、初始化基础模块、按顺序启动所有模块、暴露RPC接口:
// entry.js 'use strict'; const registry = require('./registry'); const AppContext = require('./context'); // 模拟读取config,实际工程中从全局注入或构建时内联 const configFromExternal = { debug: true, httpAnalysis: { urlFilter: /\/api\// }, modules: [ { name: 'logger', enabled: true }, { name: 'safe_hook', enabled: true, depends: ['logger'] }, { name: 'http_analysis', enabled: true, depends: ['safe_hook'] } ] }; function initLogger(context) { const createLogger = require('./modules/base/logger'); context.logger = createLogger(context); } function initSafeHook(context) { const createSafeHook = require('./modules/ability/safe-hook'); context.safeHook = createSafeHook(context); } async function main() { AppContext.config = configFromExternal; // 初始化能力模块 initLogger(AppContext); initSafeHook(AppContext); // 启动业务模块 for (const item of configFromExternal.modules) { if (!item.enabled) continue; if (item.name === 'logger' || item.name === 'safe_hook') continue; const modFactory = require('./modules/business/' + item.name); const mod = modFactory(AppContext); if (mod.start) { await mod.start(); } AppContext.loadedModules.push(mod); } // 对外暴露查询接口 rpc.exports = { getStatus() { return { loaded: AppContext.loadedModules.map(m => m.name), httpCount: AppContext.analysisData.httpRequests.length, cryptoHits: AppContext.analysisData.cryptoHits }; } }; AppContext.logger.info('Entry', '所有模块加载完成'); } Java.perform(() => { main().catch((e) => { console.error('启动失败: ' + e.stack); }); });rpc.exports是Frida提供的外调接口,外部Python脚本可以直接调用它。这里暴露了getStatus,方便外部确认模块是不是真的加载成功——这一步在实际排查中特别有用。
3.6 Python调用端与构建合并
Frida不直接支持require目录,所以需要一个构建脚本把所有文件合并成一个agent.js。我的build.js思路很简单:
// build.js 'use strict'; const fs = require('fs'); const path = require('path'); const files = [ 'registry.js', 'context.js', 'modules/base/logger.js', 'modules/ability/safe-hook.js', 'modules/business/http-analysis.js', 'entry.js' ]; let bundle = files.map(f => { return `// ===== ${f} =====\n` + fs.readFileSync(path.join(__dirname, f), 'utf8'); }).join('\n'); fs.writeFileSync(path.join(__dirname, 'dist/agent.js'), bundle);Python端的调用脚本就简单了:
import frida import sys def main(): # 连接设备并附加目标应用 device = frida.get_usb_device() pid = device.get_process("目标App名") session = device.attach(pid) with open("dist/agent.js", "r", encoding="utf-8") as f: agent_code = f.read() script = session.create_script(agent_code) script.load() # 临时等待模块启动完成,实际可轮询getStatus import time time.sleep(1) status = script.exports_sync.get_status() print("模块加载状态:", status) session.detach() if __name__ == "__main__": main()这里做一个版本说明:Frida 16之前的版本调用RPC推荐script.exports_sync,新版本又变回script.exports,但都是同步方法。写的时候如果发现exports_sync不存在,改成script.exports即可。
4. 踩过的坑:加载顺序、作用域污染与Hook失效排查
4.1 模块加载顺序怎么保证
模块化之后,最先遇到的问题就是加载顺序。http_analysis依赖safe_hook,而safe_hook又依赖logger。如果业务模块在能力模块初始化之前就启动,那运行时跟裸奔没区别。
我最初用配置数组手动控制:
{ "modules": ["logger", "safe_hook", "http_analysis"] }这个方案能用,但时间长了会有一个隐患:新人往数组中间插入一个模块,没注意依赖关系,启动时直接白屏(静默失败)。后来我在registry里加了depends字段,加载器会根据依赖做拓扑排序,顺序问题从"人肉保证"变成"机制保证"。
如果不想自己做拓扑排序,最省事的方法是把能力层模块写在所有业务模块之前,并且启动流程里单独初始化,不走业务模块配置数组。这样99%的依赖问题都不会碰到。
4.2 作用域污染:IIFE不是形式主义
Frida的JS脚本运行环境本质还是JavaScript,变量作用域规则跟浏览器里一样。不写模块化的时候,一个文件里所有函数都共享全局作用域,变量名冲突带来的bug极其隐蔽。
我在某次项目里遇见一个诡异现象:safe_hook模块正常运行了十几分钟,突然所有hook全部失效。排查半天发现是一个业务模块里的循环变量正好叫i,而这个变量不知为何穿透到了另一个模块的作用域里。原因就是那个业务模块没有用函数包裹,代码在全局作用域中执行。
所以现在我对所有模块强制两个要求:
- 每个模块文件必须用函数包裹,对外只暴露一个工厂函数。
- 需要跨模块共享的数据必须挂在
context上,而且用之前先检查数据类型。
这里还有一个防御技巧:在logger挂载到globalThis时,把属性设为不可覆盖:
Object.defineProperty(globalThis, 'Logger', { value: context.logger, writable: false, configurable: false });这样即使某天某个模块来一个globalThis.Logger = ...,也会直接抛出 TypeError,而不是悄悄覆盖。
4.3 Hook失效的真正原因和排查路径
Hook失效是Frida实践里最常遇到、最让人头疼的问题。模块化之后,排查路径反而清晰了:
第一步确认模块真的启动了:调用getStatus(),看loaded列表里有没有目标模块。没有就先怀疑加载顺序或配置启用状态。
第二步确认类名和方法名:很多App会做混淆,类名跟你逆向看到的可能不是同一个。可以用Java.use是否抛异常来判断。safe_hook模块里已经记录了失败日志,直接查tag: "SafeHook"的日志。
第三步确认业务路径是否真的会走到这个hook点。有时候hook方法没问题,但业务场景下根本没调用那个方法,比如登录接口是明文入口,你hook的是另一个变种。这种"假失效"需要靠业务日志辅助判断。
第四步确认hook时机。最常见的问题是类还没加载就Java.use,Frida会很配合地报一个ClassNotFoundException。我通常的做法是:在启动阶段先调一下目标App的某个已知入口类,让类加载器完成预热,再执行Java.use。或者在safe_hook里做重试逻辑,比如失败后延迟200毫秒再试三次。
4.4 模块化的极小性能开销,也值得优化
很多同学担心模块化会不会让Frida脚本变慢。说实话,模块注册、依赖解析、启动编排这种开销在整个脚本生命周期里几乎可以忽略,真正影响性能的是模块内部做的事。
但在模块化架构上,有一个开销陷阱容易踩:模块启动时做了过度的类扫描。比如某个业务模块在start()里用Java.enumerateLoadedClasses去遍历几千个类,还同步执行一遍Java.use,这个开销会非常明显。
我的优化原则有两条:
- 把耗时的类扫描类操作放到模块
start之外,用懒加载触发,等真正需要时才执行。 - 对日志做降噪。Frida的
console.log本身不慢,但日志量大的时候会占用大量传输带宽。我通常在logger里做采样逻辑,比如每个tag每10秒最多输出一条debug日志。
模块化架构带来的性能收益才是更重要的:出问题时可以只加载一个模块来定位性能瓶颈,不用带着全部模块一起去猜。
5. 几个经常被问到的细节问题
5.1 模块怎么实现"热插拔"
有朋友问过我,运行时能不能动态加载一个新模块。在这个架构里,可以在rpc.exports里加一个loadModule(name)方法,运行时用require动态加载模块文件,然后执行start。但要注意,动态加载的模块如果涉及Java.use,必须重新走Java.perform,而且加载失败回滚会麻烦一些。
我的建议是:常规评测不需要热插拔,重启一次脚本成本很低。热插拔适合的是长时间运行的采集任务,那种场景再考虑也不迟。
5.2 多个App同时评测,配置怎么隔离
通过config.json实现。每个项目一个配置文件夹,里面包含config.json和对应的业务模块清单。构建脚本可以支持参数化选择配置文件,比如node build.js --config app_a.config.json,这样通用模块完全不动,只替换业务层的针对性模块。
5.3 没有Node环境怎么构建
如果你当前环境没有Node,不想为了拼接文件装一堆依赖,直接手动把几个文件按顺序拼接成一个agent.js也没有问题。我见过有同学用shell的cat命令拼接,效果一样。重要的是文件内的require路径和模块注册表的逻辑保持不变。
模块化架构的好处就在于,拼接是纯物理操作,不涉及任何编译或转换——所以哪怕不用构建工具,也不影响整体方案落地。
6. 一点个人体会
模块化不是玄学,它就是把"在哪儿改"变成"改哪个模块"的过程。我个人用这套结构跑了大概六个评测项目之后,最大的感受是返工成本明显降下来了。以前改网络层逻辑要担心UI模块被牵扯,现在边界清楚了,新人接手也不用我把几千行脚本从头讲一遍。
如果你是刚开始做Frida脚本工程化的开发者,不用一上来就设计多高级的插件体系,先做到三件事:每个功能一个文件、依赖关系一眼可查、接口通过上下文传递。做到这三条,你的脚本就已经跟"传统瓶颈"说再见了。
对了,最后分享一个我常驻的小习惯:每个模块启动时都在日志里打一行"加载完成+耗时",全部启动完成后汇总一份报告。虽然只是一行日志,但在项目出问题时,这份报告能帮你把怀疑范围缩小到一个模块之内,而不是重新面对那几千行堆在一起的老朋友。