资讯详情

Frida脚本模块化架构设计与实践:从单文件到分层工程结构

📅 2026/10/18 20:49:03 | 华诺云谱 👁 阅读
Frida脚本模块化架构设计与实践:从单文件到分层工程结构
从第一次拿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同学整理网络请求的参数dumpB同学在同一个区域加了加密算法识别两人改的是同一个函数的相邻行合并之后代码能运行但加密识别的结果被网络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.httpRequestscrypto_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, encodingutf-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.useFrida会很配合地报一个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脚本工程化的开发者不用一上来就设计多高级的插件体系先做到三件事每个功能一个文件、依赖关系一眼可查、接口通过上下文传递。做到这三条你的脚本就已经跟传统瓶颈说再见了。对了最后分享一个我常驻的小习惯每个模块启动时都在日志里打一行加载完成耗时全部启动完成后汇总一份报告。虽然只是一行日志但在项目出问题时这份报告能帮你把怀疑范围缩小到一个模块之内而不是重新面对那几千行堆在一起的老朋友。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑