字节暗面

Android加固不是只做混淆:防反编译、防篡改、防调试分别在防什么


上个月一个团队拿着包来找我,想不通:混淆上了、字符串也加密了,怎么市面上还是冒出来了改过广告 SDK 的破解版。我翻了一下发现,他们把力气全砸在了防止别人读代码上,签名和完整性校验一行没写。可对方压根没读代码,拆开包改俩字节、塞个模块、重签名,破解版就流出去了。

问题就出在这,防反编译、防篡改、防调试,是三个不同的概念。应对的是攻击者三种不同的动作,读你、改你、在你跑起来的时候动你。很多团队容易把“加固”理解成“让代码难读”,但加固其实只解决第一块,哪块没做或者做的不到位,就成了堤坝上的蚁穴。下面挨个说。

防反编译

防反编译的目标很直接:包可以被拆开,但不要让人轻易读懂里面的逻辑。所以这一块的重点,是给静态分析增加成本:

  • 代码混淆:类名、方法名改成无意义符号(R8 / ProGuard 那一套),native 层再上控制流平坦化,反编译出来的逻辑读起来处处别扭。
  • 字符串加密:接口地址、开关字段、检测标记这类明文常量加密存储、运行时才解,strings 和 grep 直接扫不出来。
  • 符号与调试信息剥离:去掉导出符号表和调试段,IDA / Ghidra 加载后满屏 sub_xxxx,函数名全靠人肉猜。
  • 关键逻辑下沉 native:重要判断从易读的托管层挪进 SO,让核心逻辑离开随手可反编译的那一层。

字符串加密就是个具体例子,明文常量编译期藏起来、用到才还原:

// 接口地址别明文写死,编译期 xor 加密,运行时才解,strings 扫不到
static const uint8_t ENC_URL[] = { 0x3f, 0x1a, 0x7c, /* ... */ };
std::string url = xor_decrypt(ENC_URL, sizeof(ENC_URL), KEY);

Unity 项目还有个坑得单说:IL2CPP 把 C# 编成 native,堵死了 dnSpy 反编译 DLL 那条路,可运行时 IL2CPP 要靠 global-metadata.dat 存方法名、类型名,跟 libil2cpp.so 一起明文进包。

这时候用 Il2CppDumper 把两者对着解析、生成 IDA / Ghidra 脚本,一跑满屏 sub_xxxx 就被还原成 PlayerCtrl$$AddGold 这种可读名,native 化白抬的门槛又还回去了。

对抗它就是别让这份元数据被现成工具直接吃下:改 magic、加密 metadata 让工具解析失败是基础,最值钱的是把里面的名字字符串也抹掉,结构被解出来也读不到名。

防反编译不能省,是因为它决定了攻击者拿到包之后能不能快速摸清结构。游戏不用跑起来,只要拆包、看代码、搜字符串,就可能把接口、参数、开关和检测点顺出来。它挡不住改包,也挡不住运行时 Hook,但它会影响后面所有动作的成本。

防篡改

防篡改管的是另一条链路:别人不一定要读懂你的完整逻辑,只要能把包拆开、塞东西、改配置、重签名,再重新分发,一个异常版本就能流出去。所以这一块关注的是包有没有被动过、动过之后能不能被发现:

  • 签名校验:核对 APK 签名是不是自己的证书,重签名的破解包直接拦(别只指望系统那层,得自己在代码里查)。
  • 完整性校验:关键 dex、SO、资源在打包时记 hash,运行时重算比对,文件被动过 hash 就对不上(挑关键文件算,别整包 hash 卡启动)。
  • 运行时校验:不光查安装包,连跑起来后加载进来的 SO、配置、资源版本对不对也顺手查一遍。
  • 校验落地:比对尽量压在 native、结果别原样端回托管层,再叠一道服务端上报,本地被删了服务端还能兜。

完整性校验的核心就一段,打包记的 hash 和运行时算的对不上,就是被动过了:

// 关键 dex / so / 资源,打包记 hash,运行时重算比对
String actual = sha256(readAllBytes(criticalPath));
if (!actual.equals(BAKED_HASH))
    reportTamper();   // 上报,登录、支付这类敏感操作按需限制

防篡改为什么重要?因为很多破解包并不是靠“读懂全部代码”做出来的,而是靠低成本改包传播出来的。改内购判断、替换广告 SDK、夹带异常模块、重签名分发,这些动作一旦没人校验,冒充正版的破解包能大摇大摆传播,影响的是你的收入和口碑。

防调试

防调试管的是运行时,也就是游戏跑起来之后的事。前面两层再强,代码最终还是要执行,字符串、资源、密钥和判断结果也总会在某个时刻以可用状态出现在内存里。所以这一块的核心是别让人在活着的进程上插手:

  • 反调试:检测有没有调试器附加,常见做法是进程自己占住 ptrace 的 tracer 位、查 /proc/self/status 里的 TracerPid,发现被追就处置。
  • 反 Hook:识别 Frida / Xposed 这类框架的特征(注入的 so、特征端口、特征线程名),这是运行时最趁手的攻击工具。
  • 反内存 dump:防着有人从内存里把解密后的代码、资源、密钥直接捞走,静态藏得再严,运行时内存里总归是明文。
  • 布防方式:检测别押单点、别只在启动查一次,多点埋、随机时机触发(也别每帧都跑,检测本身吃 CPU),逮到先上报,闪不闪退看误报成本再定。

反调试最经典的一手,是自己先把调试位占了:

// 进程自己占住唯一的 tracer 位,别人的调试器就 attach 不上
if (ptrace(PTRACE_TRACEME, 0, 0, 0) == -1)
    on_debugger_detected();   // 占不上,说明已经被人 traced

反调试为什么不能省?这是最后、也最躲不开的一道面。静态混淆得再狠,代码终归要在设备上执行;一执行,逻辑就得在内存里还原成能跑的样子,这一刻就是暴露窗口。攻击者根本不啃你的静态强度,直接把游戏跑起来 hook 返回值、改内存、dump 明文,前两层的功夫可以整个绕过去。所以防调试守的不是“看不看得懂”,也不是“包有没有被改”,而是程序运行时这个真正暴露的现场。

这三块加起来,才算是一个比较完整的 Android 加固面。它们不是谁替代谁,而是各守一段:有人想读代码,就看防反编译;有人想改包分发,就看防篡改;有人想跑起来再动手,就看防调试。

这里很像木桶效应。你把混淆做得再花,签名和完整性校验没做,别人还是能改完重签名;你把包完整性盯得再紧,运行时一旦被 Hook、dump,关键判断还是可能被绕开。加固最后看的不是哪一块最强,而是哪一块最短。攻击者不会从你最硬的地方撞,他会从最省事的地方进去。

← 返回列表