CTF逆向做到一定阶段,你会发现一个特别磨人的现象:一个so拉进IDA,导出表干净得可怜,就一个大写的JNI_OnLoad和两三个Java_xxx,真正干活的函数全被strip成了sub_xxxx,别说符号,连字符串都被藏得严严实实。想用Frida动态下钩子观察运行逻辑,结果Module.findExportByName直接返回null——因为它压根儿没导出去。
这段时间连续做了几个CTF题,又处理了一个实战样本,都是卡在"无导出函数怎么Hook"这个问题上。我总结下来,其实有三种切入姿势:静态偏移定位、交叉引用回溯、指令级接管。这三种思路覆盖了从CTF入门题到稍带反调试的真机样本的绝大多数场景。这篇就把思路、脚本和踩过的坑一次性讲清楚,适合刚接触Frida的朋友,也适合在做Android逆向但还没系统整理过这类问题的同学。
1. 无导出函数是怎么来的,以及我们到底在跟什么打交道
1.1 先分清导出表和符号表,别一上来就喷Frida
很多朋友第一次遇到findExportByName返回null,第一反应是"Frida没Hook到"。其实问题不在Frida,而在ELF的结构。一个so文件里有两张关键表:.dynsym动态符号表和.symtab普通符号表。Frida的findExportByName查的是.dynsym,也就是linker加载和重定位时真正需要的那张表。JNI_OnLoad必须导出,因为系统需要找到它;Java_com_example_xxx也需要导出,因为ART虚拟机会按JNI命名规范去解析。这两个属于"不得不导出的函数"。
而.symtab里存的是完整符号信息,包括所有内部函数的函数名、行号这些。NDK在release构建时通常会做strip,把.symtab删掉,减少体积、增加逆向难度。所以在IDA里看到的大片sub_xxxx,本身既不在.dynsym里,也不一定在.symtab里——它们就是一堆只有地址没有名字的代码。你在Frida里写Module.findExportByName("libfoo.so", "sub_1234"),本质上是在动态符号表里找一个从来没存在过的键,返回null太正常了。
1.2 无导出函数的几种"出生方式"
理解了表结构,再来看为什么会有这么多无导出函数。最常见的三种:
- 编译参数隐藏符号:NDK构建时加了
-fvisibility=hidden,所有函数默认不导出,只有显式标记__attribute__((visibility("default")))才会进.dynsym。很多商业SDK都是这么干的,对外只暴露一个入口,其余逻辑全藏在内部。 - strip掉符号表:release构建默认行为,
.symtab被移除,函数名、局部变量名全部消失。哪怕函数原本有名字,你也看不到了,IDA只能按地址起名叫sub_xxx。 - 加壳/混淆改写结构:OLLVM的控制流平坦化、符号替换,会改变函数内部的代码结构,甚至把原来的函数拆成多个子块。这种比单纯strip更麻烦,因为就算你定位到了函数入口,看到的也是一堆状态机和分发器。
CTF出题人经常组合使用这些手段。有些题甚至把.dynsym里的非必要项手动删掉,只留JNI_OnLoad和JNI入口,逼着你走"静态分析+动态插桩"结合的路线。
1.3 CTF和实战的应对心态其实是一样的
CTF里目标很明确:找到flag,验证逻辑。实战里目标通常更实际:搞清楚某个校验算法、绕过某个签名、定位某个协议字段的生成位置。但共同点都是——在缺少符号的情况下,把一件"不知道名字但确实存在的函数"变成"可下断、可观察、可修改的逻辑单元"。
所以我一直觉得,CTF和实战之间的差距没有想象中那么大。CTF题里出题人可能故意留了字符串引用作为线索,实战样本里字符串和函数名都加密了,但底层定位思路一致:要么静态分析出函数偏移,要么利用调用链回溯推导,要么直接在指令层面接管控制流。下面的三种姿势,本质上就是围绕这三条路展开的。
2. 姿势一:静态偏移定位,基址加IDA地址一把梭
2.1 在IDA里找到那个sub_xxx
这是最直接、也最稳的一招。用IDA打开so文件,在函数窗口(Functions window)里按名字搜索sub_开头的函数,或者用反汇编视图配合字符串引用找到可疑位置,目标就出现在眼前。
比如在check函数的伪代码里看到一行:
if ( sub_1234(&input[i], i) != 0 ) return -1;这个sub_1234就是无导出函数。我们不需要知道它的原始名字,只需要它在文件中的地址0x1234。注意,IDA默认的ImageBase是0,所以显示的地址就是它在ELF里的虚拟地址(vaddr)。绝大多数Android so的第一个LOAD段的p_vaddr就是0,因此这个值可以直接当偏移用。
2.2 为什么不能直接把0x1234写进Frida脚本
新手最常见的错误:把IDA里看到的0x1234当绝对地址,一行Interceptor.attach(ptr(0x1234), ...)怼上去,然后各种崩溃、各种Hook不到。
原因在于ASLR。Android系统每次启动进程,so在内存中的加载基址都会随机化,硬编码地址必然失效。正确做法是在运行时拿到模块基址,再加上偏移:
var base = Module.findBaseAddress("libfoo.so"); if (base == null) { console.log("[-] module not loaded"); return; } var target = base.add(0x1234);这里又有个细节。Module.findBaseAddress返回的是模块的加载基址,即第一个LOAD段的映射起始地址。当so的p_vaddr不为0时,base.add(函数的vaddr)可能有一点点偏差。严格说是base.add(函数vaddr - 第一个LOAD段的p_vaddr)。大多数情况下so的p_vaddr就是0,直接用0x1234没问题。如果你的so比较特殊,可以用readelf -l libfoo.so看LOAD段,确实看到非零值再调整偏移。
readelf -l libfoo.so2.3 一个能直接套用的完整脚本
以下是我在CTF和样本分析里反复用的骨架。脚本先在Java层触发一次目标方法或通过setTimeout等待模块加载,然后对无导出函数下钩子:
function hookSub(moduleName, funcOffset) { var base = Module.findBaseAddress(moduleName); if (!base) { console.log("[-] " + moduleName + " not loaded"); return; } var target = base.add(funcOffset); console.log("[+] hooking " + moduleName + " + " + funcOffset + " -> " + target); Interceptor.attach(target, { onEnter: function (args) { console.log("[+] called!"); console.log(" arg0: " + args[0] + " <- " + args[0].readCString()); console.log(" arg1: " + args[1]); }, onLeave: function (retval) { console.log("[+] ret => " + retval); } }); } // 有些so是Java层点击后才加载,delay一下再尝试 setTimeout(function () { hookSub("libfoo.so", 0x1234); }, 1000);实际使用中,我通常把hookSub的第一行改成先Module.enumerateModules()打印所有已加载模块,确认目标模块是否存在,避免盲目等待。调试时宁可多打几行日志,也不要猜。
2.4 实操中最容易翻车的两个点:Thumb地址和函数太短
Thumb模式的地址末尾那个1。ARM32下,Thumb指令是2字节对齐的,地址的bit0用来指示当前处于ARM模式还是Thumb模式。IDA里看函数地址一般不会给你带1,但如果用lr寄存器回溯或者某些脚本计算时,会看到0x1235这种奇数地址。计算偏移前记得& ~1把标志位抹掉,否则base.add出来错位,轻则Hook不到,重则crash。
函数太短导致attach失败。Frida的Interceptor.attach底层要走inline hook,它需要在函数头部写跳板,通常要求函数至少有足够空间容纳一段跳转指令。如果目标函数只有两条指令就返回了,比如:
mov r0, #0 bx lr总共才8个字节,Frida会报too short之类的错误。这时候别硬刚,直接换姿势三(指令级接管)或者把hook点前移到调用者里面去。
3. 姿势二:交叉引用回溯,就算不导出也能顺藤摸瓜
3.1 最快乐的情况:目标函数附近有特征字符串
CTF题目里,出题人哪怕把函数名全strip了,也常常会在校验逻辑附近留下字符串——比如"flag正确"、"key error"、"wrong input"。用IDA的Strings窗口看一眼,双击字符串跳转到地址,按X查看交叉引用,就能立即看到谁引用了它,往往就是那个无导出函数。
这个思路在Frida里可以进一步"动态化"。如果字符串不是明文,而是运行时拼接或解密的,那就hook解密函数,或者直接通过Memory.scan扫描内存特征:
// 在native堆区找指定ASCII字符串 function findStringInLib(moduleName, pattern) { var base = Module.findBaseAddress(moduleName); var size = Process.findModuleByName(moduleName).size; var ranges = Memory.scanSync(base, size, pattern); ranges.forEach(function (range) { console.log("found at: " + range.address); }); }找到字符串运行时地址后,再用Process.findRangeByAddress看它落在哪个模块、距离模块基址多远,反推出引用它的函数大概率在哪个区间。这招在字符串加密的样本里尤其管用。
3.2 从导出函数内部反向追踪调用链
如果目标函数不会引用任何字符串,还有一个更通用的思路:从它的调用者入手。任何无导出函数总得被某个导出函数调用吧?那就在导出函数上下钩子,打印完整调用栈。
Interceptor.attach( Module.findExportByName("libfoo.so", "Java_com_example_MainActivity_check"), { onEnter: function (args) { console.log("[+] check() called from " + this.returnAddress); var bt = Thread.backtrace(this.context, Backtracer.ACCURATE); bt.forEach(function (addr) { var offset = addr.sub(Module.findBaseAddress("libfoo.so")); console.log(" " + addr + " -> libfoo.so+" + offset); }); } } );Thread.backtrace会把当前线程的返回地址链打出来,其中就有调用check的那一层。结合IDA里check函数的反汇编,你能看到check内部在某条BL指令处跳到了sub_1234,于是目标函数地址就锁定了。这本质上是用动态调用链反查静态交叉引用,效率很高。
3.3 更"野蛮"的招:Hook libc函数来捕获调用来源
有时候目标函数不直接调用API,但它内部可能调用了strcmp、memcmp、strlen这些libc函数做比较。我们可以把libc里的这些函数全部Hook一遍,记录每次调用的返回地址在哪个模块的哪个偏移。
["strcmp", "strncmp", "memcmp", "strlen", "strcpy"].forEach(function (name) { var libc = Module.findExportByName("libc.so", name); if (!libc) return; Interceptor.attach(libc, { onEnter: function (args) { var module = Process.findModuleByAddress(this.returnAddress); if (module && module.name.indexOf("libfoo") != -1) { var offset = this.returnAddress.sub(module.base); console.log("[" + name + "] called from libfoo+" + offset.toString(16)); if (name.indexOf("cmp") != -1) { console.log(" arg0: " + args[0].readCString()); console.log(" arg1: " + args[1].readCString()); } } } }); });只要目标逻辑里有一条比较路径经过libc函数,你就可以通过返回地址算出来它在模块内部的偏移,从而确定无导出函数的位置。这条思路在实战里比IDA交叉引用更灵活,因为调用关系在运行时才完全展开,静态分析容易漏掉间接跳转和vtable分派这类场景。
4. 姿势三:指令级接管与Inline Hook进阶玩法
4.1 Interceptor.replace:直接把无导出函数变成你的函数
有些时候光"看"不够,比如CTF里校验逻辑是一个字节一个字节比较,你需要在脚本里直接修改判断结果,实现"无论输什么都被当成flag通过",或者反过来"无论输什么都报错"。这时候Interceptor.attach只能观测,改不了结果,就该用Interceptor.replace了。
var base = Module.findBaseAddress("libfoo.so"); var target = base.add(0x1234); var fakeImpl = new NativeCallback(function (a, b) { console.log("[+] replaced function called, arg0=" + a + ", arg1=" + b); // 这里直接返回1,让上层以为校验通过 return 1; }, 'int', ['pointer', 'int']); Interceptor.replace(target, fakeImpl);替换后,原函数的指令不会被执行,流程直接进入我们提供的NativeCallback。这个能力在CTF里十分好用。但要注意一个问题:替换后想调用原函数逻辑,怎么办?Frida没有内置的"调用被替换原实现"的API,你自己得先保存原始函数入口,或者干脆就不调用它。如果确实需要"先执行原逻辑,再改返回值",更合适的是用Interceptor.attach去观察,或者把原函数指令复制出来另行执行——这就进入了inline hook的核心领域。
4.2 从attach报错看inline hook的底层约束
Interceptor.attach本质上是inline hook:在目标函数头部写入一条跳转指令,跳到Frida分配的trampoline,执行完再跳回来。这要求函数头部有足够的空间容纳跳板指令。
我遇到的真实案例:某个sub_xxx只有3条ARM指令,Frida直接报unable to intercept ... instruction too short。这种短函数在你hook它的时候,往往是因为它只是个"状态设置器",比如:
LDR R0, =0x12345678 STR R0, [R1] BX LR这条路径下,与其纠结为什么hook不上,不如换个角度:把它当作一个可观测点,直接在它的调用者里面下钩,观察调用前后寄存器变化。或者,如果这个函数被十几处调用,你可以在函数外部用Memory.patchCode对其中关键的指令做改写。我在一个样本里就曾通过把结尾的BNE指令改成B,直接跳过了所有失败分支。
4.3 Thumb指令对齐和PC相对寻址的坑
自己动手patch指令时,最容易踩的是Thumb指令对齐和PC相对寻址问题。
在ARM32下,ARM指令是4字节对齐,Thumb指令是2字节对齐。patch之前先确认目标地址的指令编码模式。Frida的Instruction.parse(address)能解析出当前指令长度,方便你确认操作范围。如果指令跨了cacheline边界或者你写入的指令长度与原指令不一致,后续的控制流就会乱掉,表现就是莫名其妙的crash。
另一个大坑是PC相对寻址。比如一条LDR R0, [PC, #0x10],它的实际加载地址由PC值加上偏移决定。当你把这行指令原样复制到另一块内存(跳板)里执行时,PC值变了,加载的地址就错了。因此手动做inline hook时,这类指令必须做重定位,计算新地址与旧地址的差值,修正立即数偏移。Frida的Interceptor在底层通过Memory.patchCode将这些细节封装掉了,所以日常能用Interceptor解决就尽量别自己写跳板。
4.4 最难的场景:目标函数被内联进了调用者
还有一种情况让人头大:你静态分析发现某个功能没有独立函数入口,它被编译器直接内联到了调用者里面,代码块就是调用者函数体的一部分。这种连"函数头"都没有,更别提Hook了。
碰到这种,思路要再变通一下:不要在"不存在的函数"上死磕,而是找到内联代码块附近那个唯一的跳转或比较指令,直接在指令层面改写判断条件。举个例子,假设内联代码末尾有一段:
CMP R0, #0 BEQ loc_4567BEQ在某个值时跳转到失败分支。如果你想强制让它走成功分支,可以使用Memory.patchCode改写这条指令:
var base = Module.findBaseAddress("libfoo.so"); var branchAddr = base.add(0x88F4); Memory.patchCode(branchAddr, 2, function (code) { // 把BEQ改成B,无条件跳转 code.writeU16(0xE7FF); // Thumb模式下的B指令编码 });当然,具体指令编码取决于你的汇编器或手动计算。更稳妥而且更通用的做法是直接写一小段ARM汇编,用Memory.patchCode替换。这个方法在CTF里叫"patch retval",实战里叫"打补丁",本质一样:直接修改程序的控制流,让它按你的意愿走。
5. 从一道CTF题看三种姿势的推进过程
5.1 拿到题目先别急着写脚本
设想一道典型的Android逆向题:libcheck.so里只有一个导出函数Java_com_example_ctf_MainActivity_check,输入一串字符,返回是否正确。IDA打开后,check函数反编译结果里发现一个无导出的sub_2C10,每次循环都被调用,并且传入当前字符和下标。显然,大部分的校验逻辑都藏在这个sub_2C10里。
这时候不要急着确定用哪种姿势。先花两分钟在IDA里看几个关键点:
check函数里有多少个无导出子函数被调用;- 这些子函数是否引用特征字符串;
sub_2C10是不是有足够空间可供Frida attach。
明确了这些,再决定姿势。
5.2 三种姿势的决策表
| 定位方式 | 适用情形 | 关键操作 | 局限性 |
|---|---|---|---|
| 静态偏移定位 | IDA里能直接看到目标函数地址 | base.add(offset) + Interceptor.attach | 依赖静态分析的准确性 |
| 交叉引用回溯 | 知道特征字符串或能通过调用链找来源 | IDA xref /Thread.backtrace/ Hook libc | 需要运行时触发目标路径才能动态定位 |
| 指令级接管替换 | 需要改返回结果,或函数太短无法attach | Interceptor.replace/Memory.patchCode | 改的是指令而不是原始逻辑,复杂场景容易出岔子 |
实际做题过程中,我经常是先用姿势二把目标函数定位到,再用姿势一确认它的精确偏移,最后用姿势三改变量。三种姿势不是互斥的,而是一条流水线。
5.3 完整的Hook脚本长什么样
针对这道例题,最终我用的脚本大致是这样:
var base = Module.findBaseAddress("libcheck.so"); console.log("[+] module base: " + base); var sub_2C10 = base.add(0x2C10); console.log("[+] sub_2C10: " + sub_2C10); Interceptor.attach(sub_2C10, { onEnter: function (args) { this.ch = args[0].readU8(); this.idx = args[1].toInt32(); console.log("[*] sub_2C10 called, idx=" + this.idx + ", char=0x" + this.ch.toString(16) + " (" + String.fromCharCode(this.ch) + ")"); }, onLeave: function (retval) { console.log("[*] sub_2C10 ret -> " + retval); if (retval.toInt32() !== 0) { console.log("[!] mismatch at idx=" + this.idx); } } }); // 同时观察check函数的输入 Interceptor.attach( Module.findExportByName("libcheck.so", "Java_com_example_ctf_MainActivity_check"), { onEnter: function (args) { var env = Java.vm.getEnv(); var jstr = env.getStringUtfChars(args[2], null); console.log("[+] check(" + jstr.readCString() + ")"); } } );跑一轮,你会发现每个非法字符在校验函数里都会导致一次非零返回。把每次mismatch的字符记下来,结合下标位置,很多时候能直接拼出flag——因为出题人为了让你能做出来,往往是把正确字符与输入逐位比较,错误的输入会在某一位触发校验失败。通过观察sub_2C10的参数和返回值,整个校验逻辑就浮出水面了。
6. 从CTF到实战,必须补齐的几个盲区
6.1 模块没加载的时候怎么办
CTF题里so通常早就加载好了,但实战里很多核心逻辑放在动态加载的插件so里,或者要等到特定时机才dlopen。直接Module.findBaseAddress返回null,脚本就挂了。我常用的解法是轮询等待:
function waitForModule(moduleName, timeout) { return new Promise(function (resolve, reject) { var start = Date.now(); var timer = setInterval(function () { var m = Process.findModuleByName(moduleName); if (m) { clearInterval(timer); resolve(m); } else if (Date.now() - start > timeout) { clearInterval(timer); reject(new Error("timeout waiting for " + moduleName)); } }, 100); }); } waitForModule("libplugin.so", 5000).then(function (m) { console.log("[+] loaded at " + m.base); // 后续hook在这里补充 });如果so是手动加载的,也可以在android_dlopen_ext或dlopen上先Hook一把,加载完成后立刻拿到返回的句柄,就能算出基址:
var dlopen = Module.findExportByName(null, "dlopen"); Interceptor.attach(dlopen, { onEnter: function (args) { this.path = args[0].readCString(); }, onLeave: function (retval) { if (this.path.indexOf("libplugin") != -1) { var module = Process.findModuleByAddress(retval); if (module) { console.log("[+] dlopen => " + module.base + " size=" + module.size); } } } });6.2 ABI差别和模拟器的坑
姿势一在ARM64和x86_64下的原理一致:都是模块基址加偏移。但有两个点需要注意。第一,ARM64没有Thumb模式,指令全部4字节对齐,attach的约束条件不同——函数过短会更容易触发too short,因为一条跳转指令就是4字节,可插入空间更少。第二,模拟器上跑的是x86_64架构,如果APK里同时有arm64和x86_64两套so,Frida attach的架构必须和当前进程一致。模拟器上你看到模块路径可能是/data/app/.../lib/x86_64/libfoo.so,这时候base.add(0x2C10)的偏移和arm64版本可能是相同的,也可能因重新编译而不同,最好以当前架构下的IDA分析为准。
Android版本的差异主要体现在linker namespace上。Android 7.0以后引入了classloader namespace,同一个so文件可能在不同namespace下被加载两份,Process.findModuleByName返回的可能不是你实际Hook的那一份。解决方式是根据完整路径过滤,或者在Java层拿到classloader后直接操作。
6.3 反调试和反Frida的常规对抗思路
实战样本常常带反调试。常见三类:
- ptrace自附加:进程自己ptrace自己,阻止调试器/Frida附加。应对方式是用Frida的
early instrumentation,在目标进程完全启动前就注入,或者在root环境下用frida-server以更高优先级抢占ptrace。 - 检测Frida特征:扫描进程map里是否有
frida、gum-js-loop线程名、默认端口27042。对策包括改frida-server文件名、改端口、用gadget模式注入,或者在样本检测到后主动清理线程痕迹。实际上更靠谱的是在样本运行前使用r2frida或Frida-trace先摸底,再针对检测点patch。 - 运行时校验指令完整性:对函数头部字节做hash,发现被inline hook过就崩溃。这一条在实战样本里越来越多见。我的处理办法是尽量用只读式插桩,或者把hook点放在函数尾部/返回地址处,尽量避免修改函数开头几个字节。
注意,以上对抗思路只是安全研究里的常规操作,目的是让大家理解反调试原理,不要用在非法场景。
6.4 我的实际工作流
遇到一个无导出函数的样本,我现在的固定流程基本是这样:
- 先用readelf和IDA快速确认模块架构、是不是加了壳、有没有常见反调试,建立一个"最小认知模型"。
- 用字符串引用和静态交叉引用把可疑函数列出来,挑出高置信度目标。
- 在高置信度目标上先跑一遍
Thread.backtrace,看运行时调用链是否能对上静态分析。 - 确认后,用
Interceptor.attach做观测,打印参数、返回值,逐步理清函数语义。 - 如果需要改变流程,再上
Interceptor.replace或Memory.patchCode。
这套流程里,姿势一、姿势二、姿势三不是按顺序用的,而是根据现场情况来回切换。定位阶段多用姿势二,确认阶段多用姿势一,改写阶段才轮到姿势三。能把这三者灵活组合起来,无导出函数基本就不是障碍了。
最后再分享一个体会:很多时候卡在无导出函数上,不是差在工具,而是差在思路——总想着一步到位拿到函数地址,忽略了"先找到调用者、再通过调用关系反推目标"这个朴素方法。静态分析给方向,Frida给验证,两者交替使用才是效率最高的玩法。