2026年10月13日·2 分钟阅读

双层加固与客户端反作弊:运动世界 App 逆向架构分析

网易易盾 DEX 壳与爱加密 VMP 的叠加、原生与 Flutter 混合架构下的职责分界,以及客户端反作弊能做与做不到的事。

AndroidReverse-EngineeringAnti-CheatObfuscationFlutter

一个 606 MB 的 Android 安装包,拆开之后是两层商业加固、一套原生壳、一个未混淆的 Flutter 引擎,以及一套把安全逻辑全部下推到 native 的通信架构。本文记录对这个样本的静态分析结论,重点不在"怎么破",而在这样一个防护体系是怎么设计的、它的哪些假设成立、哪些不成立。

Note

本文是纯静态分析,未运行应用、未连接其服务端。所有结论都标注了证据来源;无法静态验证的部分明确标注为推断。分析方法可复用于同类样本。

一、样本与整体判断

项目结论
包名com.zjwh.android_wh_physicalfitness
版本7.3.11(versionCode 392)
体积635,867,156 字节,6,277 个 ZIP 条目
签名v1 JAR 签名,自签名证书 CN=zjwh(2016 年签发,沿用至今)
技术栈原生 Android(Kotlin + Hilt)+ Flutter 混合(add-to-app)
加固网易易盾 DEX 壳 + 爱加密原生 VMP 双层
混淆原生侧 R8,Flutter/Dart 侧未混淆

体积分布本身就说明了架构重心:lib/(原生库)471 MB,占七成以上;classes.dex 80 MB;assets/ 68 MB。这个比例意味着——应用的主要逻辑与全部安全能力都在 native 侧,DEX 与 Dart 只是外壳与界面。

二、保护层:两种加固的叠加

2.1 外层:网易易盾 DEX 壳

AndroidManifest.xml 里 android:name="com.netease.nis.wrapper.MyApplication" 直接暴露了外层的身份。这个 classes.dex 是一个 30 个类、677 个方法的壳,真正的业务 DEX 被抽取走了。

壳的运行流程大致是:attachBaseContext 阶段先释放并加载 native 库(libnesec.so,SONAME 是 libsecexe.so),由 native 完成原始多 dex 的解密与类加载器替换,再把控制权交还给真正的 Application。

值得单独说的是它的 native 库结构。libnesec.so 采用了非标准的 ELF 段布局:

段名大小说明
.gnu.fragment1,293,936 B,熵 6.88加密的真实代码主体
.gnu.draft71,256 B二级解密数据
.gnu.stub8,704 B,熵 2.37解密引导桩
.text16 B仅为占位诱饵

.text 只有 16 个字节,真正的代码被搬进了 .gnu.fragment 并在运行时解密。同时 .dynstr 有 88.9% 的符号名不可打印——连"函数在哪"这个起点都被抹掉了。对依赖段表与符号表的常规反汇编器来说,这是一个刻意的死胡同。

2.2 内层:爱加密 VMP

lib/ 里还存在另一套独立的保护:libijiami_1011.so(SONAME 为 libSdkVMP.so,UPX 加壳)与 libscorpion.so,配套 assets/SCORPION.VERSION = 2.1.50。

libscorpion.so 是一个 Dalvik 字节码虚拟机——从它的字符串里能读到 AGET/APUT/CHECK_CAST/FILL_ARRAY_DATA/MONITOR_ENTER/THROW 这些 Dalvik 操作码名,以及一组签名统一为 (I[Ljava/lang/Object;)X 的 omas* JNI 方法。也就是说,被它保护的方法不是编译成机器码,而是被翻译成了自定义字节码,由这个 VM 解释执行。

这两层的关系值得厘清:易盾负责 DEX 层的整体抽取加密,爱加密负责 native 层的方法级虚拟化。它们不是"谁是残留",而是分工叠加。判定依据是——爱加密的类描述符(Lijiami_1011/NCall;、Lcom/omes/scorpion/OmasStub;)出现在被易盾加密后剩下的字符串池里,说明爱加密位于易盾之内、原始 DEX 之中。

三、静态分析能触及的边界

这里有一个对整个加固方案评价很关键的事实。

易盾采用的是抽取式(extraction-based)加固:它加密了 DEX 的 code item(方法体字节码),但 DEX 的字符串数据段以原生布局明文保留。用 8 MB 窗口扫描那 80 MB 的尾部区域,每个窗口都能找到 6,500 到 17,400 个类描述符,且是标准的 string_data_item 布局(MUTF-8 字符串 + ULEB128 长度前缀 + 终止符)。

结果是:代码不可还原,但元数据几乎完整可读。

  • 恢复出 28,605 个类描述符
  • 恢复出 523,047 条唯一字符串

这些字符串里包含了完整的 API 域名、路径、字段名、日志模板、硬编码凭据,以及——在 native 字符串里——反作弊功能的符号名。对于理解"这个应用有哪些功能、怎么组织的、依赖哪些服务"这个层次的问题,明文残留提供了极其充足的素材。

我也验证过"是否只是压缩而非加密"这个可能性:对 blob 做容器雕刻,gzip / LZ4 / Zstandard 的魔数零命中,6,061 处 zlib 魔数全部是巧合字节,无一能解出有效 DEX。所以加密是确凿的——只是加密的范围没有覆盖元数据。

Important

抽取式加固的一个常见取舍是:为了保护运行性能,字符串池不做加密(运行时解析成本太高)。代价是应用的"结构"与"意图"整体暴露,只有"实现"被保住。对以功能测绘为目的的分析者来说,这个取舍基本交出了地图。

四、原生与 Flutter 的职责分界

这个应用用 add-to-app 方式嵌入了 Flutter(Dart 3.11.3,Dart 包名 sportworld_flutter)。

Dart 快照有一个罕见的特点:它没有混淆。库 URI、类名、方法名、路由、字符串全部可读,共 163 个应用库。这使得 Flutter 层的职责一眼可见:

  • 社区动态:发帖、草稿箱、话题、评论、点赞、九宫格媒体
  • 赛事与支付:报名、订单、支付、完赛领奖、电子证书、收货地址
  • 视觉化:配速、步频、步幅、海拔四组图表
  • 荣誉墙与奖牌

而原生层承担的是另一类东西。从被恢复的类清单可以看到 mvi/ 包下有 22 个子模块(run / sport / exam / tice / roll / code / ai / indoor / me / setting …),基本覆盖了跑步打卡、体育考试、体测、签到等业务主干。

两层的连接方式是 16 个自定义 MethodChannel。关键在于它们传输什么——其中 native_network_channel 提供的能力是:

getInterceptorPreparedRequestSecurity    请求签名信封
getInterceptorCommonParamHeadersForUrl   公共请求头
getInterceptorCommonEncryption           请求体加密
getInterceptorDecryptResponse            响应解密

也就是说,Flutter 层的每一个网络请求,其签名与加解密都由原生计算,Dart 只负责把结果贴到请求上。这与"Flutter 只写界面、不碰安全"的分工完全一致——在 Dart 快照里搜索 mock location、证书固定、反调试、root 检测等关键词,全部零命中。

Tip

这是一个值得借鉴的架构决策:把安全能力收敛到一个可加固的 native 边界内,让未加固、易读的 UI 层完全不持有安全逻辑。攻击面因此从"整个应用"收缩到"一个 native 库"。

五、客户端反作弊的三种手段

5.1 模拟定位检测

反模拟定位不是单一判断,而是三路信号叠加。

第一路是系统 API 反射。字符串里能看到完整的方法族:isFromMockProvider、getSetIsFromMockProviderMethod、sSetIsFromMockProviderMethod。isFromMockProvider() 是 Android 官方提供的"该定位是否来自模拟位置提供者"标志,只要通过系统"模拟位置信息"注入坐标,它就会返回 true。带 s 前缀的静态缓存字段说明代码对反射 Method 做了缓存复用。

第二路是概率化判定。存在 mockGpsStrategy、mockGpsProbability、MaybeMockGPSLoc、MaybeMockNetLoc 这一组命名。注意 Maybe 前缀与概率字段——这说明它不是一个布尔判定,而是一个多维加权模型:综合定位精度、GPS 与网络定位的一致性、轨迹跳变、运动传感器匹配度给出"疑似度",再按阈值决定行为。

第三路是风控 SDK。isInstalledHighDangerMockApp(扫描已安装应用列表,查找虚拟定位类工具)、MOCK_TOUCH_EVENT / mockTouchEventCount(检测模拟点击)、isHaveFrida / isHaveXposed / isFrameworkHooked(检测 hook 框架)。

5.2 轨迹合法性校验

应用自研的 native 库 libswsport.so(名字绑定、未混淆,因此可完整反汇编)暴露了 6 个 JNI 方法:

isValidPoint       GPS 点合法性校验
getBluetoothInfo   蓝牙设备信息
encodeByAES / decodeByAES   轨迹加解密
getSecretKey       密钥获取
md5                摘要

我反汇编了 isValidPoint(vaddr 0x10e84,388 字节),它的设计思路比"合法/非法"二元判断复杂得多。还原出的逻辑是一个加权评分:

  • 按模式(mode)给基础分:不同区间对应 0 / 5 / 15 / 30
  • 按配速给分档:阈值由 .rodata 硬编码,实测为 10 / 50 / 100,对应 30 / 20 / 10 / 0
  • 按位移是否为正加 10 分
  • 特定模式(type == 1)额外加 30

最后把总分与阈值 65 比较,再叠加两个标志位的检查,返回布尔。

这个设计有两个值得注意的地方。其一,它不是"一个点合法吗",而是"这个点累积的可信度够不够"——单一维度的造假会被其他维度拉低总分。其二,阈值 10 / 50 / 100 的数值分布说明它校验的是配速的统计学合理性(若单位是 m/s,100 m/s 毫无意义),而不是简单的坐标范围。

此外,该函数内嵌了三处自跳死循环(0x10ee4、0x10f58、0x10fb4 处的 b 指令原地跳转)。异常分支不返回错误,而是卡死线程——这是 native 层常见的反调试手法,用来让动态调试器挂起。

5.3 环境与完整性检测

外层壳内置了 13 项运行时检测,从解密出的字符串可以看到全部条目:调试器、hook 环境、Xposed、root、模拟器、双开、注入、篡改、VPN、代理、USB 调试、模拟点击、异常 ROM。

值得注意的是检测后果的设计:从字符串看,它走的不是"立刻崩溃",而是 CrashHandler 加 NEDialog.showProbation(异常提示弹窗),并带有 debug_switch_0、provider_switch_1、BUGRPT_SWITCH 这类可配置开关。这种"静默标记 + 上报"的处理,比直接闪退更克制——它不向检测对象暴露检测点在哪。

六、密钥与签名体系

这个样本在密钥处理上呈现出明显的冷热不均。

资源层是明文的。 res/values/strings.xml 里躺着 md5_key、md5_sign_salt 两个签名字段,以及 wechat_app_secret、sina_app_secret 等第三方凭据,全部未加密、未混淆。md5_key 还同时以二进制形式出现在 DEX 里,证明它确实在被使用。这属于典型的"早期遗留 + 缺乏密钥管理"。

native 层则是分域且编码的。 反汇编 getSecretKey(0x1198c,1116 字节)后可以看到,它不是密钥派生算法,而是一个 switch 选择器:入口处 cmp w2, #6 加跳表(0x737b8),把参数分派到 7 个分支,每个分支从 .rodata 取一个字符串常量返回。

这些字符串常量有两个特征。其一,它们不是明文——其中一段 228 字节的字符串,字符集只有 16 个符号(,-./012389<=>ABC),缺失了 4-7 与 D-F,是自定义的编码表,需要调用方二次解码。其二,.rodata 里这些字符串是重叠复用的(验证过 0xab060+48 与 0xab090 字节完全一致),说明它们是同一块内存被不同偏移引用。

7 个分支对应 7 组密钥,这是密钥分域——不同业务用不同密钥。虽然密钥最终仍在客户端,但"编码 + 分域"至少挡住了静态字符串搜索。

至于网络协议的密钥协商,则完全属于易盾 native 层的黑盒。这类自定义 KDF 的中间状态在网络上不可见,静态又因代码段加密而无从入手,是纯动态分析的领域。

七、这类分析怎么做

把上面的过程抽象成可复用的流程:

第一步,先看清单和资源。 AndroidManifest、apktool.yml、resources.arsc、strings.xml。样本的身份、版本、加固身份、以及像本文这样"钥匙忘在门口"的明文凭据,通常在这一步就全部拿到。这一步的成本极低,回报极高。

第二步,判断加固类型,决定后面走哪条路。 关键看 DEX 是"整体加密"还是"抽取式加密"。对抽取式加固,字符串池的恢复往往是整个分析里性价比最高的一步——它不解决"实现",但解决了"结构"。

第三步,分清哪些库可读、哪些不可读。 名字绑定的库(如 libswsport.so,导出符号明文)可以完整反汇编;符号名加密的库(如 libnesec.so)静态无入口,需要改为动态。这一步决定了工具的选择。

第四步,反汇编读算法。 这里有一个很实用的判据:在别人的实现里,"能调库的地方用了库、只有自定义位运算才手写"这条分界线,精确标记了哪些代码出自反汇编翻译。反汇编时该找什么特征也随之明确——例如自定义哈希常表现为 lsl 8 + lsr 24 + orr + eor 的固定组合,Feistel 结构表现为两轮对称的异或-轮转。

第五步,补动态。 代码段加密、字段名运行时构造、算法输入输出只在内存里可见的部分,静态到此为止,需要运行时插桩。

整个流程里,纯 Python 工具链(pyelftools 解析 ELF、capstone 反汇编 AArch64)已经能覆盖相当一部分工作,不必一开始就依赖重型 IDE。

八、客户端防护的能力边界

回到最初的问题:这套防护做得怎么样?

它做对了几件事。 双层加固的叠加显著提高了静态分析成本;把安全逻辑收敛到 native 边界,让未加固的 UI 层不持有任何密钥或检测逻辑;反作弊采用多维评分而非布尔判断,提高了单点造假的难度;检测后果采用"静默标记"而非"直接报错",不向分析者暴露检测点。

它也留下了几个口子。 抽取式加固把应用结构整体暴露;资源层存在明文凭据;Dart 层未混淆,功能意图一览无余;libswsport.so 名字绑定且未混淆,核心校验算法可完整反汇编。

但真正决定性质的不是这些口子。客户端防护的价值从来不是"不可破解",而是"提高成本 + 为服务端判定提供信号"。 这个样本里,客户端做的是采集与降级(把可疑的定位降级为"信号弱"),而最终判定——轨迹合理性、配速分布、违规名单——在服务端。这意味着,即便客户端的所有加密与检测都被绕过,判定权仍在服务端手里。

这其实是客户端安全的一条基本规律:任何完全运行在用户设备上的校验,最终都只能提供"证据",不能提供"保证"。 加固能推迟分析者的脚步,反作弊能标记异常信号,但把判定逻辑放在客户端,就等于把规则交到对手手上。有效的防线必须建立在服务端——用统计学、用跨用户关联、用不可伪造的物理信号。

客户端防护是一道减速带,不是一堵墙。设计者的正确期待,是让攻击的成本高于收益,而不是幻想它无法被越过。

(完)

返回文章列表

评论

评论组件加载中…

0%
博客小宠物