☰
Frida内存读写监控实战:从轮询到页保护,精准定位内存篡改源头
2026/10/21 15:52:21 网站建设 项目流程

做移动端安全测试和可疑行为排查的时候,我一直有个体会:程序里最难查的 bug 往往不是“数据错了”,而是“数据不知道被谁改错了”。一个功能开关在内存里被翻转、一段关键的协议缓冲区被注入数据、一个全局状态被某种隐藏逻辑篡改——麻烦的地方在于,等你事后去验证,现场早就没了,翻代码也看不出写入来源,因为真正的写入点可能压根不在目标代码里。

Frida 正好能把这个问题从“事后怀疑”变成“现场抓人”。作为动态插桩工具,它可以在不修改目标程序文件的前提下,把一个 JavaScript 运行环境注入到目标进程内部,脚本可以直接读写内存、拦截函数调用、追踪指令流。利用 Frida 实现精准内存读写监控,本质上就是围绕这套能力搭一个“哨兵系统”:内存一发生变化,立刻记录变化时间、变化位置、来自哪个模块的哪一行,甚至把当时的完整调用栈一起抓出来。这篇文章我会从方案选型、原理拆解、实战脚本到踩坑记录完整过一遍,适合做 App 安全评估、数据分析完整性校验、以及 native 崩溃溯源的同学参考。

1. 为什么盯内存:从“数据被改”到“找出谁改的”

1.1 内存读写监控到底解决什么问题

很多人把“监控内存”理解成“能读内存”,其实不是一回事。能读内存只是拿到当前快照,而精准的内存读写监控,核心目标是回答三个问题:这块内存什么时候变的、被什么操作改的、是谁发起的。

实际工作中,这类需求通常来自三种场景。

第一种是本地校验状态被篡改。不少 App 会把 VIP 状态、每日次数、功能开关这类标志位放在内存里,运行时判断合格就放行。攻击者不碰代码,只改内存里的一个字节,就能让校验结果反转。防御方要查的不是“这个标志位现在是多少”,而是“它有没有被外部工具改写、改写时走了哪条路径”。

第二种是协议缓冲区被注入。客户端发包前,篡改工具会修改待发送的缓冲区内容,服务端收到的数据不符合预期。这时候需要到进程里去抓“谁动了这块区域”,到底是正常业务逻辑在写,还是第三方注入的代码在动。

第三种是 native 层崩溃溯源。某个全局结构体被越界写坏,症状往往出现在几个小时后,现场早已不可考。只有提前布好监控,才能在第一次越界写入发生时留下铁证。

这三种场景的共同难点在于:写入往往是瞬间发生的,而且你事前并不知道写入点的位置。常规做法是“出事再查”,但内存问题过了就没了,所以核心思路必须变成“提前埋伏、触变即录”。

1.2 为什么选 Frida 而不是别的工具

如果你只是想读内存,市面上有不少现成工具,比如游戏修改器一类的内存编辑器。但它们只能做“读/写”,做不了“溯源”——你看到值变了,但不知道是谁、在哪一行改的,这等于只看到了结论,没看到过程。

传统调试器也能解决一部分问题,比如下硬件断点、做内存断点。可调试器是暂停式的:进程一断,业务逻辑就停了,很难做持续、自动化的监控。而且调试器本身容易被目标进程感知,在很多场景下不合适。

其他 hook 框架里,Xposed 系偏重 Java 层,native 层覆盖弱;Substrate 年代久、维护少。Frida 的优势在于它把 Java 层和 native 层的插桩能力统一到了一套 API 里,脚本可以实时下发,不用重启设备,配合 Memory、Interceptor、Stalker、DebugSymbol 等模块,能非常灵活地组合出想要的监控方案。

工具类型读内存写内存定位写入来源持续自动化监控跨 Java/native
内存修改器支持支持不支持不支持有限
传统调试器支持支持较弱弱支持
Xposed 系有限有限Java 层可较弱Java 偏强
Frida支持支持支持强统一

从架构上看,Frida 在目标进程里注入了一个 Agent,并内置 JavaScript 引擎,脚本与目标线程共享进程地址空间,因此可以做到零距离观察。很多人第一次用 Frida 会觉得它“黑魔法”,其实底层就是 Gum 引擎在做 inline hook 和指令改写。理解了这层,后面所有 API 的行为就都能顺下来了。

2. 四种实用监控方案:从轮询到断点

内存读写监控没有一个万能的银弹,不同精度和不同开销的方案适合不同阶段。我把常用的做法归纳成四种,从轻到重排列,实际项目里经常是组合着用。

2.1 轮询快照:最小成本的“值变化检测”

轮询是最直观的方案:定时去读目标地址,与上一次的值比较,发现不一样就记录。Frida 的 JavaScript 运行时本身支持 setInterval,所以实现起来只要几行。

const target = ptr('0x12345678'); // 目标地址,先通过搜索或偏移计算获得 let last = Memory.readU32(target); setInterval(() => { const cur = Memory.readU32(target); if (cur !== last) { console.log(`[CHANGED] ${Date.now()} ${last} -> ${cur}`); last = cur; } }, 20); // 20ms 一轮,50Hz 采样

这套方案的好处是干净、风险低、几乎不会干扰目标进程。缺点也很明显:只能看到“结果变了”,看不到“哪个指令让它变了”。而且轮询存在采样盲区,如果某个值在两次轮询之间被改掉又改回来,你完全发现不了。

我的建议是把它用在前期排查阶段。比如你怀疑某个标志位会被篡改,但不确定它到底变不变,先用轮询跑一段时间,确认变化确实存在,再去上更重的监控手段。注意循环间隔不要太短,10ms 以下对 CPU 的占用会明显提升,在低端设备上甚至会因为抢占资源而影响被监控对象的行为。

2.2 Hook 读写函数:精准但依赖调用路径

轮询找不到源头,那就换个思路:不盯地址,盯“写这个地址的动作”。Hook 住所有可能写入目标区域的函数,一旦发现写入的目标地址落在你的监控范围内,立刻记录参数并回溯调用栈。

native 层最常见的写入函数是 memcpy、memmove、strcpy 这类拷贝函数。Frida 里可以这样拦截:

const libc = Module.findExportByName('libc.so', 'memcpy'); const regionStart = ptr('0x...'); const regionSize = 0x1000; Interceptor.attach(libc, { onEnter(args) { const dst = args[0]; const size = args[2].toInt32(); if (dst.compare(regionStart) >= 0 && dst.compare(regionStart.add(regionSize)) < 0) { console.log(`[memcpy] dst=${dst} src=${args[1]} size=${size}`); console.log(Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress).join('\n')); } } });

这里的核心逻辑有两个:一个是范围判断,只有写入目标位于你关心的区域才记录,避免全量日志轰炸;另一个是回溯调用栈,用 Thread.backtrace 配合 DebugSymbol.fromAddress,能把 native 调用链展开成可读的模块名加偏移量。

Java 层的逻辑也可以用类似思路。假设目标对象有一个setFlag(boolean)方法,直接 hook 它的实现:

Java.perform(function () { const clazz = Java.use('com.demo.core.SwitchManager'); clazz.setFlag.overload('boolean').implementation = function (v) { const stack = Java.use('android.util.Log') .getStackTraceString(Java.use('java.lang.Throwable').$new()); console.log(`[setFlag] ${Date.now()} value=${v}`); console.log(stack); return this.setFlag(v); }; });

这类方案能直接抓到“是谁改的”,但有个致命弱点:它只覆盖了你显式 hook 的那些函数。如果篡改代码不走 memcpy,而是用一条mov [rax], rbx指令直接写内存,你就扑空了。所以它适合用来兜底业务路径上的已知写入点,不适合当作唯一防线。

2.3 页保护陷阱:不放过任何一次访问

如果要在 native 层做到“任何读/写都抓得住”,就得借用操作系统的内存页保护机制。原理很简单:把目标地址所在的物理页设为不可访问,任何指令对这个页的读或写都会触发异常,异常处理例程里就能拿到精确的访问地址和 PC 寄存器,再把权限恢复回来让指令继续执行。

Frida 把这个能力封装成了MemoryAccessMonitor接口:

const base = Module.findBaseAddress('libapp.so').add(0x4000); // 目标区域 MemoryAccessMonitor.enable({ base: base, size: 0x1000, onAccess: function (details) { console.log(`[ACCESS] addr=${details.address} pc=${details.from}`); console.log(DebugSymbol.fromAddress(details.from)); } });

这套方案的捕获能力是最强的,不管写入用的是库函数还是裸指令,只要访问了这块区域就一定会触发回调。代价也同样明显:性能开销很大。在监控生效期间,每次对监控页的访问都要陷入异常处理再返回,高频访问的场景下目标进程会明显变卡。

注意:MemoryAccessMonitor 是页级的,你监控一个页,同页里其他地址的访问也会一起触发回调,日志里会产生大量“与你无关”的噪音。开启前务必先把范围缩小,比如先用 Memory.scan 精确定位到目标地址。

这个方案适合短时间取证。先让监控跑几秒,抓到一批访问记录,然后立刻 disable,再对记录做分析。不建议在正式业务场景长期挂着跑。

2.4 指令级追踪与硬件断点:进阶手段

再往上走还有两个更重的方案:Stalker 和硬件断点。

Stalker 是 Frida 的动态指令追踪模块,它会对目标线程的指令流做动态改写和记录。你可以在每条指令执行时判断它是否访问了目标地址,能做到指令级的精准定位。但代价是极其吃性能,只建议在短代码路径上做快速分析,长期开启基本不可行。

硬件断点则是利用 CPU 的调试寄存器,比如 x86 的 DR0-DR3、ARM 的观察点寄存器,对指定地址设置读/写断点。它的优点是几乎不干扰性能,触发条件精确到地址和操作类型,不会像页保护那样产生信号风暴。缺点是数量有限,x86 下数据断点通常只有 4 个,只能针对极少数关键地址。

这两个方案对使用者要求比较高,需要理解寄存器和指令流。日常排查一般不首选用它们,但当页保护开销过大、又必须持续监控某个地址时,硬件断点是更优的答案。

3. 实战:定位“功能开关”被谁改的

下面用一个具体案例把整套流程串起来。假设某 App 内部有一个SwitchManager,其中flag字段控制高级功能是否可用。测试过程中发现,这个 flag 会被反复改成 1,怀疑有外部工具在运行时篡改内存,但静态代码里找不到主动写入的路径。我要利用 Frida 找出篡改动作发生在哪个模块的哪段代码。

3.1 环境准备与搜索目标地址

首先把 frida-server 推到设备上并启动,然后选择启动模式。这里要注意 attach 和 spawn 的区别:attach 是附加到已运行的进程,适合即时排查;spawn 是在进程冷启动时就挂起并注入脚本,适合需要从早期就开始监控的场景。因为篡改可能发生在启动阶段,我选择 spawn:

frida -U -f com.demo.app -l monitor.js

进程起来之后,先找到flag字段的内存地址。由于 ASLR 的存在,每次启动的绝对地址都会变,所以思路是:先搜索特征值,拿到地址后再减去模块基址,算出偏移量。之后每次启动都用“基址+偏移”来动态计算目标地址,这样脚本才能稳定复用。

假设调试时看到flag初始值为 0,搜索时就在模块内存范围内找值为 0 的候选点。更稳妥的做法是配合 Java 层实例定位,先找到SwitchManager的对象实例,再读字段地址:

Java.perform(function () { Java.choose('com.demo.core.SwitchManager', { onMatch(instance) { const flagField = instance.getClass().getDeclaredField('flag'); flagField.setAccessible(true); console.log('instance found, flag = ' + flagField.get(instance)); }, onComplete() {} }); });

这个方案能直接拿到 Java 对象的引用,但只能确认对象存在,无法直接得到 native 地址。所以完整定位通常是双管齐下:Java 层确定对象是谁,native 层通过Memory.scan搜索字节特征确认地址。

const base = Module.findBaseAddress('libapp.so'); Memory.scan(base, base.add(Process.getModuleSize('libapp.so')).sub(base), '00 00 00 00', { onMatch(address, size) { candidates.push(address); } });

搜索出来的候选地址会有很多,需要结合对象字段值来筛选,比如在读数组里找到与 Java 字段一致的地址。这一步比较费功夫,但地址找得越准,后面监控的效果越好。

3.2 组合监控脚本:轮询 + 页保护 + 函数拦截

地址确定后,把所有监控手段组合到同一个脚本里。思路是各取所长:轮询负责记录值变化的时间线,页保护负责抓所有访问点,函数拦截负责抓业务路径上的写入调用。

const base = Module.findBaseAddress('libapp.so'); const target = base.add(0x4A2C); // 上一步算出的偏移,仅举例 // 1. 轮询记录变化 let last = Memory.readU32(target); setInterval(() => { const cur = Memory.readU32(target); if (cur !== last) { send({ type: 'change', time: Date.now(), from: last, to: cur }); last = cur; } }, 10); // 2. 页保护抓所有访问 MemoryAccessMonitor.enable({ base: target, size: 0x1000, onAccess(details) { send({ type: 'access', time: Date.now(), addr: details.address.toString(), pc: details.from.toString(), symbol: DebugSymbol.fromAddress(details.from) }); } }); // 3. 拦截可能的 native 写入函数 ['memcpy', 'memmove', 'strcpy'].forEach((name) => { const fn = Module.findExportByName('libc.so', name); if (fn) { Interceptor.attach(fn, { onEnter(args) { const dst = args[0]; const size = name === 'strcpy' ? 1 : args[2].toInt32(); if (dst.compare(target) >= 0 && dst.compare(target.add(0x1000)) < 0) { send({ type: 'write_func', func: name, time: Date.now(), src: args[1].toString(), size: size, backtrace: Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress) }); } } }); } });

脚本里用了send()而不是直接console.log(),这是我从多次实战中总结出的习惯。目标进程内大量打印会拖慢业务线程,尤其页保护触发频繁时更容易引发崩溃。send()把日志抛给主机侧的 Frida 客户端,由客户端统一处理和展示,目标进程只做轻量记录。

3.3 日志解读与溯源

脚本跑起来之后,主机侧会收到类似这样的输出:

[TIME 1612345678901] [change] 0 -> 1 [TIME 1612345678903] [access] addr=0x76d4a0402c pc=0x76c1a8d34a sym=libapp.so+0x8A34 [TIME 1612345678904] [change] 1 -> 0 [TIME 1612345678905] [access] addr=0x76d4a0402c pc=0x76c1a8d34a sym=libapp.so+0x8A34

注意看日志的关键信息链:值先变成 1,随即又变回 0,说明是一次“写入后立刻恢复”的瞬时篡改,轮询可能只看到前后值相同就认为没变化,但页保护不会漏掉。pc 地址定位在libapp.so+0x8A34,这就是篡改指令的精确位置。

拿到模块偏移后,可以用反汇编器打开 libapp.so,定位到 0x8A34 处看具体指令。一般情况下会是一段内存直接写入逻辑,之前的调用链再配合backtrace字段就能还原完整路径。从我的经验看,最终发现的修改点往往不是业务代码,而是被注入的第三方模块通过 JNI 或内联汇编直接改写了内存。

3.4 执行时机与多线程的干扰

做这类监控时容易忽略一个问题:Frida 的 JavaScript 回调跑在哪个线程?页保护触发是在异常处理上下文里,而轮询回调是在独立的 JS 线程。如果目标地址有多个线程并发读写,日志顺序和值变化顺序可能出现错位。

我的处理办法是在日志里额外带上线程 ID 或者用时间戳做排序。Frida 的this.context里可以取到thread_id,配合时间戳一起输出,事后分析时能轻松把不同线程的事件流梳理干净。如果漏了这一步,多线程场景下日志会乱成一锅粥,很难定位真正的因果链。

4. 踩坑实录:这些问题我几乎每次都遇到

4.1 常见问题速查表

我把实践中反复踩过的坑整理成了一张表,供你对照排查。每一项背后都有真实案例,有些问题查了很久才找到原因。

现象可能原因解决办法
Hook 后目标函数被无限调用implementation 里又调用了同名方法,造成递归调用原方法必须用this.原方法名(...),不能直接调用被 hook 的入口
Java.choose 找不到对象实例对象还未创建,或已被 GC换 spawn 模式,在对象创建后再 choose;或 hook 构造函数
MemoryAccessMonitor 开启后频繁崩溃监控区域过大,访问太频繁缩小监控范围;在 onAccess 里只记录,不做事后修改
地址每次启动都不一样没有处理 ASLR必须通过“模块基址 + 偏移”动态计算地址
日志量巨大导致进程卡死回调里做了重操作,或打印过多用 send() 异步抛日志;过滤无关注册访问;加白名单条件
监控到大量同页内无关地址页保护粒度是页,不是你给定的字节范围在回调里再次判断精确地址范围,过滤不在范围内的访问
部分设备上 hook 无效厂商 ROM 对 inline hook 有限制换设备测试;检查是否有反调试在干扰

这里面最隐蔽的是第一个。很多人写implementation时习惯在回调逻辑里直接调用this.setFlag(v),结果自己调用了被 hook 的入口,又触发一次回调,形成死循环。正确写法是先保存一个原方法引用,或者不改原函数,而是通过this.setFlag.call(this, v)这种形式绕开当前 hook 层。

4.2 性能与精准之间的取舍

做内存监控一定绕不开三个问题:监控能放多大范围、采样能多密、日志能多全。这三者互相制约,范围越大、采样越密、日志越全,性能开销就越高,目标进程的正常行为就越容易受影响。

我的经验是分阶段执行,而不是一上来就全开——

第一阶段只做粗筛,用轮询看变化是否真实存在,确定变化的大概频率。第二阶段上页保护,短时间抓访问点,目标是把范围缩小到几条指令。第三阶段才用函数拦截或者 Stalker,精确定位调用链和逻辑路径。

这套流程的好处在于,每一级监控的成本都被限定在必要范围内,不会出现“为了抓一条写入,把整个 App 拖到无法操作”的局面。

从场景角度看,不同的监控目标也有不同的配置倾向:

监控场景推荐方案监控时长主要开销
确认内存值是否被改轮询可长期低
定位写入指令地址页保护短时取证高
还原业务调用链函数拦截按业务路径中
指令级精确溯源Stalker / 硬件断点极短极高

4.3 扩展方向:做成一个远程实时面板

如果你把这套监控脚本用在一个长期参与测试的 App 上,可以考虑把它扩展成一个小工具。Frida 的send()和 RPC 机制可以把实时数据抛出进程,主机侧再用 Python 或 Node.js 接收,搭一层 WebSocket,就能在网页上看到实时变化曲线。

举个例子,脚本里把每次变化都通过send()推送出来,主机侧维护一个按时间排序的数组,前端渲染成折线图。访问点日志则按模块分组统计,一眼就能看出哪个 so 的访问频率最高。虽然是业余时间做的小工具,但团队里其他人用起来会节省大量重复排查时间。

我自己的经验是,这种“监控脚本 + 简单可视化”的组合,一旦沉淀下来,比临时写一次性脚本要有价值得多。后面遇到同类问题时,直接套用现有工具就行,不用每次都从零开始造轮子。

最后再分享一个心得:做内存监控,最关键的一步其实不是写脚本,而是把目标地址算准、把目标范围缩到最小。很多人一上来就开着页保护全区域扫描,结果被噪音淹没,反而找不到真正的问题。先花时间把地址定位做扎实,后面所有手段都能事半功倍。我自己吃过不少这个亏,现在每次开工前都会提醒自己:定位越准,监控越轻,结论越硬。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询