目录
做手游安全这几年,我 review 过不少团队上线前的安全清单,几乎都是满屏的勾:加固、签名校验、pinning 一项不落,看着很踏实。但"全绿"的清单和"包真能拦住破解",完全是两码事。见过太多检查全过、上线没几天破解版就在群里传的项目。
问题出在:清单上的勾打的是"这件事做没做",不是"做没做到位",这两者差得很远。这篇不打算再罗列上线前该做哪些项(那些搜一下都有),而是把每一项从"做没做"翻译成一条可验收的标准:怎么算真正拦住了。文末有一张可以直接收藏的对照表,代码以 Unity(C#)和 Android native(C++)举例,其他引擎思路通用。
先约定一件事:下面全部站在防守方视角,讲的是怎么验收和加固,不涉及具体破解操作。
一、先理解攻击面:客户端为什么天然守不住
要判断防护到不到位,得先知道攻击者会从哪来。Android 游戏包(APK)本质是个压缩包,解开后,classes.dex(Java / Kotlin 层逻辑)、lib/*.so(native 层,IL2CPP / Cocos / UE 的核心逻辑大多在这)、assets/(资源、配置、脚本)都摆在那儿。攻击手段大致三类:
- 静态分析:不运行游戏,直接把包拖进 IDA / Ghidra 这类反汇编工具翻代码,搜接口地址、开关字段、检测逻辑。对抗手段是代码混淆、字符串加密、控制流平坦化,把逆向门槛抬高。
- 动态分析:把游戏跑起来,用 Frida、Xposed 这类工具在运行时 Hook(劫持)函数、改返回值、dump 内存。攻击者不关心代码长什么样,只关心运行时能不能改结果。
- 重打包:改完 APK 重新签名,塞进广告、破解逻辑甚至恶意代码,再走非官方渠道分发。
一句话:客户端跑在玩家自己的手机上,这台设备不归你管,你放上去的任何东西都可能被看、被改。加固不是为了让客户端变可信(做不到),是把攻击成本抬高到"不划算"。带着这个前提往下看。
二、加固:别把"加固完成"当成"加固到位"
包传上加固平台,回来一个"加固完成",清单这项就打勾了。但加固是分等级的。
静态对抗看的是几个硬指标:符号表清没清干净、字符串有没有加密、.text 段(存放可执行代码的段)是不是被掏空成了空壳。运行时对抗看的是另一套:反调试、反 Hook、模拟器检测全不全。这是两个维度,不能混为一谈。我实测过 .text 只剩十几字节的纯空壳方案(真实代码被加密搬到别处,IDA 直接失效),也见过反过来的:运行时检测堆了一大堆,导出符号却全是明文,SDK 接口名直接暴露,逆向的人省一半力气。
验收标准:别只信平台报告。抽一个加固后的包拖进 IDA,亲眼看一眼符号表和明文字符串还剩多少,同时区分清楚你要的是静态强度还是运行时防护。
还有一个极其常见的坑,渠道包漏加固。国内动辄几十个渠道,主包加了、某个渠道包忘了加,太常见了。而渠道包本身就是攻击面:一旦泄露,别人拿到的是已经集成好渠道 SDK 的完整包,在这基础上重打包,门槛比啃主包低得多。验收标准:加固必须在重签名之后做,每个渠道包分别加固;用 CI/CD 统一构建,别手动一个个打,也别在群里传中间产物。
三、签名、完整性、pinning:三个校验的同一个通病
签名校验、完整性校验、证书固定(pinning,握手时只认写死的证书指纹,防止抓包工具用自己的证书解密流量),清单上通常分三行写,但验收时要盯同一个问题:关键判断是不是都留在了 C# / Java 托管层。
留在托管层的代价是:objection(基于 Frida 的命令行工具)一句 android sslpinning disable,或者甩一个 frida-multiple-unpinning 脚本,专挑 checkServerTrusted、native 的 SSL_CTX_set_custom_verify 这些点,让它无脑返回"通过",你的校验就形同虚设。
这里是最核心的一点:攻击的从来不是你的校验逻辑,是那个能被劫持的校验函数。 你的签名比对写得再严谨,只要最后是一个 bool 值返回到托管层,Hook 的人根本不看你怎么算,直接把返回值改成 true 就行。
验收标准:能下沉 native 就下沉,比对在 C++ 里做完,不要把结果端回托管层。下面这段是在 native 层直接取签名做比对,比在 C# 层通过 AndroidJavaObject 调用难 Hook 得多:
// native 直接调 Android API 取签名,比 C# 通过 AndroidJavaObject 调难 hook 得多
jclass pmClass = env->FindClass("android/content/pm/PackageManager");
jmethodID getPackageInfo = env->GetMethodID(pmClass, "getPackageInfo", ...);
// 取到后在 native 层直接比对,不把 bool 返回给托管层
再补两个容易漏的点。第一,校验时机别只放在启动时:只在启动校一次,被绕过一次就永久通过了,更好的做法是在登录、支付、关卡加载这些关键节点随机插入校验,让攻击者不知道下一次校验在哪触发。第二,加一道包体大小异常检测,它防的是一种很隐蔽的绕过:破解包里嵌一份完整的正版 APK,启动校验时 Hook 掉签名获取,让它去读嵌套的正版包、返回正版签名,校验就过了,但实际运行的是破解内容。这种包有个明显特征:包体明显偏大。可以在登录时上报客户端包体大小,明显偏大的打标记,结合其他异常一起判断。
四、协议安全:pinning 只是第一关,重放才是重灾区
很多人协议这块上了 HTTPS 加 pinning 就觉得稳了。但 pinning 绕过之后,有经验的攻击者往往连改包都懒得改,直接重放(把一次成功的请求原样重复发送)。
为什么 HTTPS 挡不住重放?这一步很多人转不过弯:TLS 防的是"中间人",可玩家自己的客户端就是一个合法的 TLS 端点。他在自己机器上把一个成功的请求解出来,原样再发一百遍,TLS 一点忙都帮不上,因为在协议眼里这就是合法 client 发的合法请求。领奖接口重放刷奖励、支付回调重放多到账,大多是这么来的。我查过一个项目,一个领奖接口三天被重放了一万两千次,损失不小。
验收标准:防的不是"抓包",是"同一个请求被重复使用"。客户端每个请求带上时间戳(ts)和一次性随机串(nonce),对 path + body + ts + nonce 做签名;服务端设三道关卡:
- 时间戳超出 ±60 秒窗口的,丢弃;
nonce用 Redis 的SETNX(不存在才写入,是个原子操作)查重,出现过的丢弃(过期时间设成窗口大小,存储不会爆);- 签名对不上的,丢弃。
nonce一次性,同一个请求原样重放第二次必被拦。
这里有个前提要说清楚:这套校验的安全性依赖签名密钥不泄露,可密钥就躺在客户端里。攻击者把 SO 拖进 IDA,算法和密钥扒出来,就能自己造合法包。secretKey 下沉 native 只能拖延时间,拖不死。所以真正兜底的那道判断,还得放到服务端。
顺带提一个容易忽略的版本坑:targetSdk 如果压在 24 以下,Android 7.0 之后 App 默认不信任用户安装的 CA 这条保护就失效了,等于 network_security_config.xml 那道门自己开着,pinning 前面还漏风。这种包我见过不止一个。
五、服务端判定:所有跟收益相关的判断的最后防线
前面几层都是抬成本、拖时间,能不能真正兜住,靠的是这一条,它也最容易被"我加固做得很狠"这种想法糊弄过去。
核心原则:跟奖励沾边的判定,一律以服务端为准。 时间、次数、结算,客户端报什么都不能直接信。
这里有个反直觉但特别实用的操作。查时间作弊(比如变速齿轮、加速器),第一反应通常是去检测那个 Hook。但更省事也更耐用的做法是反过来:别检测它,直接把它的收益拿掉。为什么这样反而对?因为加速器放大的本来就是客户端自己数出来的那段时长,只要这段时长到服务端不算数,你放大它就毫无意义。跟 Hook 拼检测是场没完的军备竞赛,把收益端掉是一次做对、长期有效。
下面这段是服务端结算离线收益,客户端上报的时长只当参考,取客户端和服务端两者中较小的那个:
// 服务端结算离线收益,客户端报的时长只当参考
long clientClaimed = req.offlineSeconds; // 客户端自己数的,可能被放大
long serverMeasured = now - player.lastSeenAtServer; // 服务端按自己的钟算
long settled = Math.Min(clientClaimed, serverMeasured);// 只认较小的那个
GrantIdleReward(settled);
验收标准:清单上写"客户端做了时间校验"基本等于没做,该写的是"奖励判定走没走服务端时间 / 次数"。检测 Hook 可以做,但只当作处罚作弊账号的依据,不当作唯一防线。
六、反调试与检测处理:检测到之后怎么办才是关键
反调试这一项最容易应付了事。加了 Frida / 调试器检测,"反 Hook"打上勾就算过了。但真正决定它有没有用的,是检测到之后怎么处理。
检测到异常,是直接闪退还是上报?误报成本能不能接受(玩家跨时区、root 但没作弊、机型兼容问题都可能触发)?更关键的是,这个检测信号有没有真正接进上报和风控系统。埋了点,但日志落不到风控、风控没在盯高频调用和越界数值,那这个点就白埋了。
验收标准:这项不看"检测加没加",看两件事:检测结果是不是先埋点上报(闪不闪退结合误报成本单独定),以及上报链路是不是真的通到了一个在运行的风控系统。
七、上线验收对照表(建议收藏)
把上面每一项从"做没做"翻译成"做到位没":
| 检查项 | 只做到这步 = 没做 | 验收要点 |
|---|---|---|
| SO / dex 加固 | 平台跑一遍回个"完成" | 抽包拖 IDA 看符号表 / 明文字符串;区分静态强度与运行时检测 |
| 渠道包加固 | 主包加了就算数 | 重签名之后做,每个渠道包分别加固,别统一偷懒 |
| 签名校验 | 写在 C# / Java 层 | 下沉 native,比对在 C++ 完成,不返回 bool |
| 完整性校验 | 只在启动时校一次 | 关键文件记 hash;登录 / 支付 / 关卡随机插入校验 |
| 包体大小检测 | 没做 | 登录上报客户端包体,异常偏大打标记(防嵌套正版包 + hook 签名) |
| pinning | 挂在托管层 handler | native 校验;叠 Frida / 调试器检测 + 上报 |
| 防重放 | 只上了 HTTPS | ts+nonce+签名,服务端 SETNX 查重 + ±60s 窗口 |
secretKey 存放 | 明文在 C# 层 | 下沉 native(仅拖时间,不是终点) |
targetSdk | 压在 24 以下 | ≥ 24,否则默认信任用户 CA,network_security_config.xml 漏风 |
| 时间 / 次数权威 | 走客户端时钟 | 奖励判定走服务端时间 / 次数;离线收益取 Min(client, server) |
| 反调试处理 | 只做检测 | 检测结果先上报;闪不闪退结合误报成本定 |
| 风控落地 | 埋点没接上 | 确认日志落到风控,风控在盯高频调用 / 越界数值 |
结语:一个判断标准
回到最开始的问题:为什么做了防护还是被破解?因为清单是按"做没做"打勾的,而破解者是按"能不能绕过"来的,两套标准根本没对齐。
给个我自己一直在用的判断方法:评每一项防护,只问一句,绕过它对方大概要付出多少成本,这个成本够不够压过他破解拿到的收益。压得过,这项就有用;压不过,勾打得再满也只是自我安慰。客户端跑在玩家手机上、天然不可信,这个前提改不了,所以最后拍板那道判断只能放在他碰不到的服务端。加固、校验、协议这些,本质都是在往上垒成本,不是为了绝对挡死,是让动手的人觉得不划算。

954

被折叠的 条评论
为什么被折叠?



