从一款 App(下称 T-App)里拆出来五套 Native 防护体系,逆完摊开一看,发现它们在讲同一件事:
工程精力全砸在"藏"上,但藏的地方跟真正值钱的地方经常对不上号;防护力度的分布,跟检测结果有没有被服务端真正拿去用,是两件完全脱钩的事。
一个个说吧
这是全文最小的一个库,19KB 出头,却是我逆得最爽的一个——不是因为简单,而是因为它的每一层混淆手法都很"朴素",没有平坦化,没有加密函数指针,纯粹靠命名混淆、段隔离和诱饵撑门面,逆起来像拆一个精巧但没有真正上锁的盒子。
拿到一个陌生二进制,我的习惯是先不读一行反汇编,把导出表、导入表、字符串、段布局这四样过一遍——这四样交叉比对,往往能在写下第一个断点之前,就把这个库的身份、用途、防护手段猜个七八成。
字符串扫下来,整个库的意图几乎是明牌:"[REDACTED_PKG]"(宿主包名的痕迹)、导出名里蹭了一个知名开源裁剪库的包名做马甲、Base64 字母表和 RSA 公钥 DER 头的片段全都在。最有意思的是导入表——一个会算 HMAC-SHA1、Base64、RSA 的库,导入表里居然没有任何 libcrypto/BoringSSL 的符号 。这就一件事:所有密码学原语都是作者自己手写、编译进 .text 的。这既解释了为什么 19KB 能塞下这么多功能,也提前预告了后面会看到一坨手写的 SHA-1 轮函数——这个反直觉点我们后面再展开,先看段布局。
段表里多出一个 .mytext,三个最敏感的 JNI 函数(encode/getrsakey/checkSignature)恰好全在这个自定义段里。看到非标准段名,逆向时应该立刻条件反射:这里面装的东西,作者认为值得单独隔离 ——这跟标准 Clang 产出的 .text 形成鲜明对比,是个很便宜但很有效的"划重点"信号。
再往下看编译器指纹:函数序言用 STP X29,X30 加 MRS X8,TPIDR_EL0 读栈 canary,NEON 指令密集,除法被替换成"乘以模逆再移位"——这些都是 Clang -O2 以上的产物。但没有任何控制流平坦化的痕迹 ,每个函数的 CFG 都是干干净净的菱形结构。这跟同一个 App 里另一个库(我们第三幕要讲的 SDK-S)形成了一个很鲜明的对照:同一个 App,不同的库,混淆投入完全不是一个量级 。这是"资产分级"这条主线第一次冒头。
静态导出表里能看到一个方法,反编译出来只有一条汇编:
这是个纯粹的跳板(thunk)。它的存在只是为了让"按 JNI 命名约定静态绑定"这条路看起来是通的——系统能找到这个符号,但真正干活的函数藏在 .mytext 里,而且更关键的是,这个静态导出根本不是 App 实际调用的入口 。真正的方法绑定藏在 JNI_OnLoad 里,靠 RegisterNatives 动态注册——JNI 有两种绑定方式,静态命名约定和动态注册,后者的好处是导出表里看不到方法名与地址的对应关系,逆向者必须先读懂 JNI_OnLoad、解出注册表数组,才知道哪个地址对应 Java 的哪个方法。这道门槛不高,但确实拦了一层。
解出注册表后,三个方法的真实职责是:一个恒真桩(checkSignature,两条指令 MOV W0,#1; RET)、一个 RSA 公钥拼装器(getrsakey)、和这个库真正的心脏——一套 7 字符短码生成算法(encode)。
这是这个库着墨最多的地方,我们把全景数据流先摆出来:
熟悉 RFC 4226(HOTP)的人应该秒懂这个结构——HOTP(K,C) = Truncate(HMAC-SHA1(K,C)),这个库把这套标准结构原样搬了过来,唯一不一样的是:标准 HOTP 里 K 是一个长期不变的共享密钥,这里反过来,s1(运行时输入)才是密钥载荷,counter 反而是一个从入参凑出来的小整数。这意味着这个库内部不存在任何长期秘密 ,短码的安全性完全寄托在"没人知道组装方式"这一件事上。这个设计选择我第一次看到的时候愣了一下——这不是标准做法,但仔细想也不奇怪:移动端能藏的东西终究是有限的,与其死守一个迟早会被 dump 出来的常量密钥,不如干脆把密钥变成运行时输入的一部分,反正真正要保护的不是密钥本身,是"组装规则"。
SHA-1 本体是一段完全自包含的手写实现,不调用任何 PLT,识别它的铁证是那五个雷打不动的初始向量和四段轮常量:
我用 Unicorn 把这段真实机器码单跑了 10 组输入,跟 Python hashlib.sha1 对拍,10/10 全中,覆盖了 SHA-1 padding 最容易出错的 55/56/64 字节块边界——这一步一次性把"它是不是真 SHA-1、字节序对不对、padding 有没有边界 bug"三件事都钉死了。手写实现的好处是逆向者拿不到现成符号,坏处是它没法绕开算法本身雷打不动的魔数指纹——"没有 OpenSSL" 反而让密码学更好认 ,这是本幕第一个反直觉点。
自定义算法 Base58。拿到 31-bit 的 DT 之后,用基数 58 展开成 5 位,逐位查表。反汇编里看不到除法指令——编译器把"除以 58 的幂"换成了乘法逆再移位,这是常规操作,还原回去就是 58, 58², 58³, 58⁴ 几个数。真正有意思的是这张查找表本身:
模数是 58,固定字母表却只有 26 个字符。 缺的 32 个槽位靠 upper(s3) 从下标 26 开始填。这意味着算法隐式要求 s3 长度至少 32 ——如果 s3 短于这个长度,Base58 的某些下标会越界读到栈上未初始化的垃圾字节,导致 strlen 提前截断,短码直接被砍短。我实测过,喂一个只有 4 个字符的 s3,本该是 7 位的短码被砍成了 3 位。这不是 bug,更像是一个隐式的"输入契约"——把"这个函数只能配合 32 位定长字段调用"这件事,用一个不会主动报错、只会默默产出错误结果的方式焊死在了算法里。这种"用越界代替校验"的写法,说实话不太优雅,但确实有效,逆向者如果没意识到这个契约,复现时会被一个莫名其妙的短码长度坑很久。
校验位那两位是个 GF(2⁸) 混淆黑盒,我没有把它完全还原成纯 Python——它带跨 lane 的重排,不是逐列标准的 MixColumns,用 Unicorn 当 oracle 顶替能保证正确性(5 组样本 5/5 命中),但要做到完全脱离二进制的离线复现,这块还差最后一步。我不打算在这里硬凑一个"看起来解决了"的结论,这是这个库唯一一处开放问题,留着。
参考实现(前 5 位纯 Python,已验证):
getrsakey 把内置前缀、内置中段、运行时传入的尾段拼起来,最终解出一段教科书级的 1024-bit RSA SPKI 结构。这里有两个反直觉的地方:
第一,这个 so 里只硬编码了公钥的前 3/4 ,缺失的 modulus 末段和公钥指数是运行期由 Java 层传入的——这是一种朴素的密钥分片,单看这个二进制文件,永远拿不到完整公钥。
第二,也是更有意思的一处:跟这段真公钥并列存放的,还有一段看起来像"配套第二段密钥"的 Base64 串,解码出来却是一片高熵的 0xFF/0xFE——不是合法大数,接上去 DER 长度和结构直接崩掉。它唯一的作用就是诱使逆向者去解一把假钥匙 ,而 getrsakey 实际代码里,从头到尾都没碰过这个诱饵字段。这种"真前缀 + 假整段"的组合我第一次看到的时候差点被绕进去,花了一段时间才确认那段真的是纯粹的噪声,没有半点真实密钥材料掺在里面。
这把公钥拿去干嘛用了?这里先卖个关子——它不止用在一个地方,后面拼图收束那一节会揭晓它牵出的一整条"机密通道"。
写到这里,可能你觉得算法层已经完全清楚了。但还有两件事没讲完:
第一,这套精妙的 7 字符短码算法,它的产物最后进了哪个请求字段 ?
第二,那把 RSA 公钥,除了签名用途,它还解密了什么 ?
这两个问题的答案都在拼图收束那一节,而且答案会让你觉得——这个库被逆得这么透,多少有点用力过猛的意思。
这是五个库里工程量最大、也是我逆得最费劲的一个。跟前面那个"朴素混淆"的库完全不是一个路数——这里有自定义 Linker、有加密的二级 payload、有平坦化、有裸 syscall 级别的处置手段。如果说 libN.so 是"用命名和分段藏东西",壳-N 是"把整个运行时都重新造了一遍"。
objdump -h 读出来 section 名全为空——section header 字符串表被抹掉了。导出表里只有一个 JNI_OnLoad,没有任何静态导出,走的还是动态注册那一套。真正点破这个库性质的是 JNI_OnLoad 的第一行代码:
这个库的本体,是一个自己实现的 ELF 链接器 。它不依赖系统 linker,用自己的代码把一段内嵌的、加密的二级 ELF payload 映射进内存,再用自建符号表在 payload 里找到真正的 JNI_OnLoad 并调用。这个库真正保护的业务逻辑,全在那个被加密保护的二级 payload 里;外层这个文件只是加固壳,负责加载 payload、以及跑一整套反调试/反注入/防篡改检测。
真正的符号解析函数是一个薄包装下面的核心实现,它的逻辑是这样的:
这说明壳-N 是一个完整的双模式链接器 ——既能管理自己解密映射的内嵌 ELF,走自建符号表;也能包装系统库,透传给真正的 dlsym。这个设计说实话超出了我对一般加固壳的预期,一般的壳解决"怎么把自己藏起来"就够了,这个壳等于是把整个动态链接的基础设施都重新实现了一遍,只为了让核心逻辑活在一个"外部工具找不到入口"的地方。
加载二级 payload 的函数里有个细节很关键——它传给加载器的路径参数,返回的居然是自己这个文件的文件名。也就是说:payload 不是外部文件,而是嵌在这个 so 自身内部的一段加密数据 ,这个库把自己文件里的某一段当成一个独立的 ELF 重新解析、映射、链接。这也解释了为什么 section header 字符串表要被抹掉——payload 是"文件里的文件",传统 section 视图对它毫无意义。
我对全文件搜过 ELF magic(7F 45 4C 46),零命中 。连外层自己的 ELF 头都因为特殊的加载方式搜不到,payload 必然是密文,只有运行时才由内部函数解密。要拿到这个 payload 的真身(也就是真正的业务逻辑),静态还原解密函数的成本很高,更省力的路是动态 dump ——等加载完成后,从内存里已经解密的区域直接落盘。前提是得先压住第五节要讲的那些检测,否则进程会在你 dump 之前就被处置退出。这一步我还没有做完。
这个库同样上了 OLLVM 控制流平坦化,状态机驱动的巨型 while(1) 长这样:
跟第三幕要讲的那个库比,这里的平坦化分支少得多,手工顺着数据依赖就能还原真实控制流,不用上符号执行或者微执行——这也是我把它归类成"工程量大但技术含量分布不均"的原因之一。
字符串加密走的是集中式 3 字节循环 XOR ,跟另一个库"22 个分散解码器"的做法完全不同风格:
有个挺精妙的工程细节:解密前,程序在密文 上直接用 strlen 算长度,靠密文中的 0x00 定界。为什么这样不会出错?因为明文都是 ASCII 路径/符号名(字节 < 0x80),而 key 字节全部 ≥ 0x80,明文字符永远不会等于 key 字节,所以 明文 ⊕ key ≠ 0——密文中的 0x00 当且仅当对应明文的字符串终止符 。这个设计让"先解密再定界"和"先定界再解密"两种顺序都能正确工作,是个很讨巧的小设计。
哈希原语用的是标准 CRC32——初值 0xFFFFFFFF、查表、末尾取反,教科书写法。这里有个值得记住的反直觉点:CRC32 是非密码学校验,可逆构造 ,所以这套防篡改的强度不来自算法本身,而完全来自"你不知道内置的预期 CRC 值是多少"——一旦这个预期值被提取出来,伪造一个 CRC 相同的篡改体在数学上是平凡的事情。
反调试线程大概每 2 秒轮询一次,逻辑是:
TracerPid 自校验 是我觉得这里面最精巧的一处。它没有简单地"非 0 即报警",而是把这个 tracer 的 PID 拿去查那个 tracer 自己的 /proc/<pid>/status,比对其 PPid 是否等于内置的一个值——这暴露了一个 self-ptrace 看门狗 机制:这个库自己 fork 了一个子进程去 ptrace 附加父进程,利用"一个进程同一时刻只能被一个 tracer 附加"这条内核规则,先把这个唯一的席位占住。所以 TracerPid != 0 在这里是正常状态 ——只有当这个 PID 不是它自己认识的看门狗时,才说明外部调试器抢了 attach 的位置。这条对我这种想 hook 它的人来说是个硬约束:单纯把 TracerPid 清零反而会触发"看门狗失踪"的异常分支,必须连看门狗一起接管才行。
线程 state 扫描更底层:遍历 /proc/self/task 下每个线程,读它们的 stat 文件里的 state 字符,(state | 0x20) == 't' 这个判断同时匹配大写 T(stopped)和小写 t(tracing stop)——任何被 ptrace 停住的线程都会落进这个判断。这条检测不依赖任何 Frida 特征名 ,给线程改名也没用,因为它看的是内核维护的调度状态,是个比字符串匹配狠得多的检测维度。
反注入线程每 4 秒跑一轮,三条腿都指向同一个对手——线程名匹配已知注入框架的事件循环线程名、扫描自身 fd 目录里的软链接找注入临时文件、以及扫 /proc/self/maps 找可疑路径加载的可执行段,命中即用 dlopen 打开该 ELF、检查其导出符号是否含注入框架的特征导出。这是三重冗余检测 :线程名 + fd + maps,任何单点绕过都不足以蒙混过关,这跟 §7 蓝队视角要讨论的"多信号交叉优于单点精确"是同一套设计哲学。
防篡改这块最大的函数是全库体积最大的函数,我一开始以为它是反 hook 的核心逻辑,读完才发现——它是在自己实现一整套 ASN.1 DER 解析 + APK zip 定位 + 证书提取 ,用来从磁盘上的 APK 里读出签名证书公钥,跟内置的期望值比对。这是这类设备 ID/风控 SDK 一个很硬的产业约束:必须用注册时登记的应用签名,否则拒绝正常工作 ——这个函数就是防止换了签名重打包之后还能正常拿到设备标识。同时还有一套 DEX 白名单校验,遍历所有 dex 文件算 CRC32,跟内置的白名单比对,任何 dex 被篡改或新增都会命中。
第一轮静态分析时我一度很困惑——反调试字符串(比如"kill -9"那种)在库里能找到,但交叉引用数是 0 。ptrace 这个导入符号也一样,找不到真正的调用点。这个困惑直到我扒开处置函数才解开:
解出这 28 字节反汇编之后,谜底揭晓:
这是 exit_group(0) 的裸 syscall。关键 syscall 全部不走 libc 符号,而是运行时解密成机器码直接 svc 发出 ——这解释了第一轮所有的困惑:hook exit/_exit/kill/ptrace 这些 PLT 符号一律拦不住,因为程序从来没调用过它们的符号版本。更狠的一处细节:这 28 字节机器码在静态存储态首指令是"退出码 0 的空操作",只有运行时加上一个偏移常量才变成真正的 MOVZ X8,#94——你直接 dump 这块数据看到的东西是假的,必须跑那一步加法才现出真身。真有必要吗?为了藏一个系统调用号,专门写了这么一段自解密的 shellcode。
写完检测体系之后,我把所有检测线和对应的对抗手段列了一张表:
然而:DEX 白名单和 APK 签名校验,读的都是磁盘上的文件,完全不碰内存里 ART 已经加载的方法结构 。这意味着运行时的 ART 方法 hook(比如替换内存里已加载类的方法入口指针)对这两条最重的防线天然免疫 ——内存里怎么改,磁盘上的 classes.dex 一字不差,CRC 照样对得上。换句话说,这套加固对"磁盘完整性"防得死死的,对"内存运行时 hook"反而是一片空白 。真正能抓到内存 hook 的,只有 native 层那几条线程/fd/maps 检测,以及 Java 层的类枚举——完整性校验这个听起来最"重"的防线,实际上防的是一个已经过时的攻击面。
这是这批库里对我个人价值最大的一个——不是因为它藏的东西最值钱(其实前面已经说了,检测结果最后是静默上报,端上根本不用),而是因为破它逼着我写出了一套后面能直接复用的工具,这套工具在第四幕的高潮部分再次派上用场。
这个库(下称 SDK-S)690KB,stripped,刚加载进 IDA 就能看出不寻常:
第一层信号 ——入口点列表里有 22 个数据解密函数,挂在 .init_array 段,动态链接器在 JNI_OnLoad 之前逐个调用,运行时把 .data 段里加密的字符串、表、常量原地解密。直接后果:.rodata 只有 2264 字节,对一个 690KB 的库来说小得反常——所有明文串都躺在 46KB 的 .data 里以密文形式存在。strings/grep 在这个库上完全失效,这是设计使然。
第二层信号 ——.text 586KB,但函数高度碎块化。随便反编译一个函数,Hex-Rays 吐出来的是一坨状态机:真实的基本块被打散成"状态机分发器 + 一堆扁平真实块",用一个状态变量串起来。原本 A→B→C 的顺序执行被改写成"dispatcher 看 state → 跳 A → A 设置 state=某魔数 → 回 dispatcher → 跳 B → ……",人是读不了的,因为控制流被状态变量间接化了。
第三层信号 ——全库有 270 个种子全局变量,被将近四千条指令引用,喂给类似 (x-1)*x & 1 == 0 这种代数恒真判断,制造出"看起来是条件分支、实际恒定走一边"的假分支,进一步搅乱控制流。
三层叠加的意图很清楚:让静态分析(读伪代码、grep 字符串、追控制流)的成本高到不可行,逼分析者要么放弃,要么上动态手段。
去平坦化没有通用脚本,脚本形态完全取决于 dispatcher 的实现细节。这个库的 dispatcher 长这样:
两个让静态分析格外难受的地方:状态变量和边界值全在寄存器里,CMP 指令里没有立即数,扫立即数找不到这棵比较树的路标,必须做寄存器常量传播才能复原它;而且这棵树是二叉查找树,不是 switch 跳转表,没有一张集中的表可以改。真实块末尾更新状态变量有三种形态,第三种是杀手:
形态 C 把一个真实的业务条件分支(比如"如果某次调用返回负数")伪装成一串"看似无条件的算术运算"。这是为什么纯模式匹配脚本必败——网上一搜一大把的 OLLVM 去平坦化脚本只认形态 A 和形态 B,用正则提 next-state,遇到形态 C 会静默漏掉这些边,重建出残缺的 CFG,而形态 C 恰恰是检测逻辑里最关键的业务分支。一旦确认目标含形态 C,就该直接放弃纯静态模式匹配,必须上能"算出 next-state"的方案。
这里是这套方法论最关键的一个决定,说出来简单,但走到这一步花了不少弯路:不要去静态解析那棵二叉比较树 (边界在寄存器、还嵌套,解析起来极烦),而是把它当黑盒——用 Unicorn 喂一个 state 值,从 dispatcher 根跑一遍,看它落到哪个真实块。用一句话概括就是"以执行代替解析"。
由此得到一个 worklist 遍历真实 CFG 的伪代码:
emulate_step 是整套引擎的灵魂:一气呵成地"从 dispatcher 根出发 → 过分发器 → 进真实块 → 执行块体 → 块尾写出口 state → 回到分发器",在第二次进入任一 dispatcher 时停下,同时拿到落点块和出口 state。进入真实块时的寄存器上下文是 dispatcher 真实路由出来的,完全正确,不需要手工构造。
worklist 要发现所有分支,关键在怎么把"一个 state"分裂成"两条边",我按 next-state 的产生形态分治处理:
有个坑必须提一下:形态 B 的 CSEL 强制 fork 有个副作用——它会把不透明谓词的死分支也当成真后继(因为不透明谓词的 CSEL 也选两个不同的 state,其中一个永不可达)。区分真假分支的本质矛盾是:真实条件分支,baseline(真实执行)只走一支,需要 force 才能发现另一支;不透明谓词,baseline 走的就是唯一真支,force 出来的是死支——光看 force 无法区分两者。但这个库的不透明谓词有一个唯一指纹:都会读某个特定的全局种子数组。所以策略变成:"凡是读种子的块,只取 baseline 真后继,不做 force fork",死支自然消除——这一招把"识别不透明谓词"从一个需要符号化判恒真的难题,降级成了查一个引用集合的简单判断。
还有一层嵌套平坦化的坑:这个库把平坦化套了两层,外层用 if-tree,内层用 switch,外层某块跳进内层、内层算完再跳回外层。一开始我把内层当黑盒穿透,对外层做返回值 fork,结果发现两个极值恰好都导致内层同一结果——外层通往整个 RegisterNatives 子图的分支永远发现不了。正解是把 worklist 节点从单纯的 state 升级成 (dispatcher, state) 二元组,识别出所有 dispatcher,emulate_step 停在"再次进入任一 dispatcher",出口 state 用到达的那个 dispatcher 的 state 寄存器读——这样层间跳转和任意深度嵌套都能自然展开。
字符串解密顺手就解决了:去平坦化的 Unicorn 基建拿来跑那 22 个数据解密函数,它们本身就是普通函数,跳过外部调用、跑到 RET,会原地解密内存里的密文段。跑完把整个数据段 dump 回 IDB。有个必须守住的工程点是幂等 ——解码前要从原始文件读密文覆盖 Unicorn 里的数据,而不是从已经在分析中的 IDB 读,否则如果 IDB 已经是解密后的明文,再跑一遍解码器会把明文又异或回乱码。这个坑我真的踩到过,一次工具意外重跑触发的。
最终成果:**187/191 个函数去平坦化成功(97.9%),3259 处 patch,21 个字符串解码器全部成功,.data 段(46KB)全部转为明文。有 4 个函数因为序言里含寄存器间接跳转(跳转目标是运行时才能确定的值,微执行给不出这个值,会导致 PC 飞出去执行非法指令)失败,占比约 2%。
JNI_OnLoad 去平坦化前后的对比很能说明问题——之前 154 行状态机,完全不可读;之后 18 行,语义一目了然:
把去平坦化和字符串解密两个成果合流之后,环境检测入口函数的核心逻辑现形了——这是一个经典的 Magisk 挂载点检测:读 /proc/self/mounts,逐行子串匹配"magisk"这个关键字,Magisk 的 overlay/bind mount 会在挂载表里留痕。
补一个判据:全库扫描发现,四个对外采集接口是简单包装、没有被平坦化 ,唯独这个检测入口函数被重点平坦化保护——这本身就是情报:检测逻辑是这套体系里被重点保护的那一块 ,也是"该藏的地方"和"实际值钱的地方"第一次真正对上号的例子。
解密后的字符串全量分类,能拼出一份完整的检测清单:
环境检测(反作弊) ——Root 检测靠几十条 su 路径暴力枚举;Magisk 检测就是上面那条挂载点扫描;Hook/注入框架检测覆盖了多种常见框架的特征包名;模拟器检测靠系统属性、服务名、特殊目录、CPU 架构四路交叉,覆盖了主流的桌面模拟器和云手机方案;系统完整性检测读取 Verified Boot 相关属性,配合反调试的 TracerPid 判断。
设备指纹采集 ——除了标准设备属性和 SIM/运营商信息,最有意思的是一批厂商私有设备 ID 接口 ,专门适配了国内主流手机厂商的私有标识 API。这背后有个数学事实:标准 android_id 熵不足以支撑大规模去重,所以厂商私有 ID 成了熵的命脉,这是一个多信号冗余的工业实践。还有一项我觉得是这批检测里最"毒"的——扫描 ARP 邻居表,查同网段设备 ,这不是单机反作弊,是抓群控农场/批量多开设备,比单机检测高了一个维度。以及一项硬件级指纹 :读取 flash 存储芯片的出厂唯一码,这是云手机/模拟器几乎不可能伪造的一项,是这套指纹体系里熵最高、最难对抗的一环。
上报加密链路方面,采集完的数据不是明文上报,走的是一条标准流水线:采集JSON → zlib压缩 → AES-128-CBC(硬编码key) → Base64 → 上报字段。我把这条流水线完整还原了,AES 用的是标准 S 盒和 Rcon 表(不是魔改算法),但密钥和 IV 都是硬编码在二进制里的顺序递增字节 ——这种弱到近乎象征性的密钥强度,配合"没有任何随机数/时间戳参与加密"这个事实,意味着上报主体理论上可以被静态全解 ,只要拿到密钥就能批量还原任意截获的上报包。另外还有一层 RSA-OAEP 信封加密某个字段,但因为对称密钥本身是写死的,这层 RSA 保护的不是密钥交换,只是给某个字段多包了一层——这是**"security by obscurity"的一个标本**:SDK-S 把混淆预算全砸在检测逻辑的平坦化上,传输机密性反而是短板,这也印证了移动端风控一个很务实的哲学——判决在云端靠多信号冗余,单条上报的机密性从来不是关键防线 。
上面这套流程不止适用于 SDK-S 一个库——第四幕的 libG.so 直接复用了同一套引擎,省掉了大量重复工作。既然要讲方法论,不如在这里把它抽象成一份判断流程,遇到任何疑似上了 OLLVM 的 ARM64 库都能照着走一遍。
先看状态变量在哪——如果在寄存器里(边界值也预加载在寄存器,CMP 指令里没有立即数),那"扫立即数找路标"这条路就直接废掉了。再看 dispatcher 结构——如果不是 switch 跳转表、而是一棵二叉查找树,静态解析这棵树的成本会很高,这时候就该考虑"不解析、直接喂它跑"。
真正的分水岭是 next-state 的产生形态。 形态 A(无条件赋值)和形态 B(CSEL 条件选择)都能用模式匹配或者强制 fork 处理,但一旦遇到形态 C——next-state 由某个外部调用的返回值经过算术/位运算推导出来——纯静态模式匹配就彻底失效了,必须上能"算出 next-state"的方案。这里我想把话说重一点:网上那些现成的 OLLVM 去平坦化脚本,绝大多数只认形态 A 和形态 B,遇到形态 C 会静默漏掉这些边,给你一张看起来完整、实际残缺的 CFG,而且不会有任何报错提示你漏了什么 ——这比直接报错更危险,因为你可能拿着一张残缺的图去做后续分析,而且对残缺本身一无所知。
这套流程跟具体是哪个厂商、哪个 App 完全无关——任何 register-state 平坦化加数据加密的 ARM64 库,理论上都能照这个顺序走一遍。
这是全篇的重头戏,也是我个人逆得最有戏剧性的一个库——因为我在这里被自己的第一轮判断结结实实地误导了一次,而纠正这个误判的过程,恰好把这整篇文章"精力错配"的主线体现得最彻底。
这个 App 的请求签名字段叫 hkey。最初追踪这条线索时,我们把它追到了 Java 门面层一个方法名全用图形学术语伪装的类里,承载它的这个 so(下称 libG.so)通过字符串扫描,能确认内嵌了一个完整的 QuickJS 引擎——错误串 invalid tag、内建方法名 __JS_FreeValue 之类的特征全都在。基于这些证据,第一轮的判断是:"签名算法极可能是 JS 字节码" 。这个推断看起来很自然,几乎是显然的——一个内嵌了完整 JS 引擎的库,又伪装成图形库,唯一合理的解释似乎就是它在用某种脚本化的动态签名算法来对抗静态分析。
这个判断是错的。
对签名入口函数做逐行反编译之后,证据链完全指向另一个方向:QuickJS 一次都没被调用 。它的全部间接调用都是 JNIEnv 虚表偏移,没有任何一个进入 JS 引擎;被它引用的全局是 XOR 密钥表和兜底字符串,没有 QuickJS 字节码的魔数。hkey 是纯 native 算出来的。
那 QuickJS 去哪了?它确实在库里,错误串和内建方法名密集地躺在一段独立的文件偏移区间——只是它在另一条独立代码路径上 ,护的是这个 App 里另一项完全不同的业务(后面会讲),跟请求签名 hkey 毫无关系。之所以第一轮会误判,恰恰是因为这个库把逆向者的注意力成功导向了最唬人的那层伪装。这个教训我想着重说一下:最值得逆的地方,往往不是看起来最难的地方 ——一个库把最花哨的技术摆在明面上,反而应该激起警惕,而不是激起"这肯定是核心机密"的直觉。
于是全库的功能地图是这样的:
库里没有任何静态导出,唯一的 JNI 符号是 JNI_OnLoad,它对宿主 Java 类调用一次 RegisterNatives,一次性注册了将近 30 个 native 方法。这是第一层反静态:静态扫方法名什么也扫不到,方法名与实现地址的绑定关系全埋在 JNI_OnLoad 的函数体里。
注册表里绝大多数方法名构成一层双重语义烟雾——一部分是图形学术语,坐实图形库伪装;另一部分刻意混入了完全不相关的技术领域术语(比如某些看起来像自然语言处理模型接口的命名),让人误以为这是个别的什么推理接口。真实功能被术语彻底掩盖——这些方法实际上都是在往一张设备信息表里灌数据。
第二层反静态藏在注册过程本身:其中一个方法的 JNI 签名字符串不是明文存放的,而是 JNI_OnLoad 里现场跑一个 100 字节的 XOR 解密循环重建出来的——连 JNI 方法签名都不给你静态读,这是这个库反静态的基调:大量工程投入用于抬高静态分析成本,而非提升密码学强度 ,这句话你会在后面看到反复应验。
签名入口函数的执行分四段,前三段是门控,只有通过才进入 hkey 生成:
第一段,反调试闸门 。解密出一个 /proc 路径和检测特征串,命中即直接自杀(用裸系统调用,不走 libc 符号)。配套的工具箱在导入表里都有印证——枚举已加载 so 查注入、读系统属性、扫 /proc 目录——但没有 ptrace 的导入符号 ,这条反调试如果用了 ptrace,走的也是裸 syscall。
第二段,APK 签名校验 。通过 JNI 反射链拿到应用签名的 hashCode,跟一个内置常量字符串做比对,不等则直接跳到失败标签。
第三段,包名白名单 。分段比对若干个合法包名,按字符串长度分发匹配——分段比对而非一次性字符串比较 ,这本身也是反静态手段:完整包名从来不以连续明文出现,得把几段拼起来才能识别。
第四段,门控失败的兜底 。任一校验不过,hkey 会被赋成一个固定的常量字符串(大致语义是"无法创建日志文件"这类兜底提示)。
这里我想单独拎出来说一句:这是一个绝佳的线上反篡改判据 。任何抓包如果看到 hkey 等于这个固定兜底串,等价于服务端收到了"客户端环境异常(被调试/被重打包/包名不符)"的信号。做风控策略的话,这个魔法串可以直接作为一条高置信度的环境异常规则,甚至都不需要理解签名算法本身是怎么算出来的——这是这次逆向里少数几个可以立刻拿去用、而且完全不需要理解底层密码学的产出。
前三段门控通过后,才进入真正的签名生成。这一段有三个反直觉之处,值得逐一拆开。
[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!
上传的附件: