Native 库加固:SO 加壳与反 Dump
为什么 .so 层既是最好的藏身之处、又是最脆弱的依赖,以及如何在不破坏 ABI 的前提下加固它。
把逻辑搬进 .so 是「Java 太好读了」的经典答案,而且确实有效——但有上限。native 库会把攻击者从 jadx 逼进 IDA 或 Ghidra,这道门槛是真实存在的技能断层。但 .so 不是保险箱:它是一个 ELF 文件,链接器最终必须加载它,也就是说总有那么一刻,真正的代码以可读、可执行的形式存在于内存中。native 加固的全部工作,就是控制这一刻。
加壳正是从这个瞬间反向做文章。真正的 .text 在磁盘上是加密的;一段桩代码(.init_proc 或 .init_array 构造函数)在 JNI_OnLoad 之前把它解密进内存并修复重定位。静态打开文件只能看到桩代码和高熵数据块。问题在于解密是确定性的——挂上 Frida,hook dlopen 或 android_dlopen_ext,等构造函数跑完,把映射区 dump 出来即可。内存 dump 是对付加壳的标准手段,没有哪个壳能无限期赢下这场竞赛。
源码级混淆决定了 dump 出来的东西读起来有多贵。重命名符号、控制流平坦化、字符串常量加密。最后一项比多数人以为的更重要:把 gum-js-loop、frida 这类检测关键词明文存在 so 里,等于给人 grep。真正成熟的壳会在运行时算术解码这些名字(我们分析过的样本按循环偏移逐字节移位)。导出表也要清理——如果 readelf 看过去只剩 JNI_OnLoad,攻击者就失去了地图。
运行时检测才是 native 层真正值钱的地方,因为它能看到 Java 看不到的东西。读 /proc/self/task/*/status,找 Frida 的 gum-js-loop、gmain、gdbus 线程;遍历 /proc/self/fd 做 readlink 查注入工具痕迹;比对 pthread_create 开头 16 字节与原始指令是否一致来发现 inline hook;用 TracerPid 判断调试器。VALLUM 的 RASP 层(Abdal-DroidGuard)正是在系统调用层实现这一族检测。
代价是兼容性,而且这不是理论问题。挂在 .init_array 的检测逻辑跑在最前面,一旦误报,应用在用户看到第一帧之前就崩了——这也是某些商业壳因在正常环境下「自杀」而出名的原因。对我们更关键的是:native 壳与它打包的 ABI 强绑定。一旦排除了 x86 与 x86_64,所有模拟器、Chrome OS 设备和 Intel 平板都会在 System.load 的瞬间以 UnsatisfiedLinkError 阵亡。每种 ABI 都带上,几百 KB 的代价微不足道。
也要诚实面对天花板。安全研究员破解商业壳是常态,通常的做法就是 hook 链接器或在 dlopen 处 dump。native 加固的目标不是不可破解,而是把攻击从「一段脚本」变成「一个项目」——并且让 dump 出来的东西依然是混淆的、依然有校验和、依然被运行时检测盯着。再叠加 DEX 层的保护,逆向才会从一下午变成一次商业决策。