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构建时加了-fvisibilityhidden所有函数默认不导出只有显式标记__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需要运行时触发目标路径才能动态定位指令级接管替换需要改返回结果或函数太短无法attachInterceptor.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 , char0x 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两套soFrida 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给验证两者交替使用才是效率最高的玩法。