首页
社区
课程
招聘
[原创] 某考研付费题库小程序逆向:从 WMPF 调试到 AES 弱加密还原,付费墙在客户端
发表于: 1天前 705

[原创] 某考研付费题库小程序逆向:从 WMPF 调试到 AES 弱加密还原,付费墙在客户端

1天前
705

脱敏声明:以下 AppID、API 域名、接口内部代号后缀(统一以 _xxx 一类占位)、会话 RequestId 均已脱敏或打码,接口只保留语义名;保留的是加密算法常量与协议结构,二者不构成可定位身份或可重放凭证。题目正文与答案正文只贴字段结构和长度,不贴版权原文。

WMPFDebugger 先监听 62000 → 再开小程序 → DevTools MCP 连 9222 → TargetManager 遍历所有 DebuggerModel 读 appservice.app.js 源码(踩了配置改错文件、孤儿进程占端口、MCP 存文件被 JSON 引号包住三个坑)→ NetworkLog 抓 18 条 AutoModel 密文 → 从源码定位 encrypt/decrypt,还原出 AES-256-CBC + key=MD5("D") + 固定 IV → 16/16 往返复算一致 + 15/15 响应解密 → 解出第一道题的题目+答案+解析 → 发现列表接口无差别返回 2328 个题目 ID、详情接口无任何授权字段、答案图片是 OSS 无签名直链 → 结论:付费墙不在服务端,在客户端 UI 层。

一款考研数学付费题库小程序,用户买会员才能看答案和解析。目标有两个:

约束:只做概念性验证(还原机制、解一道题证明可行),不做批量下载。这决定了本文所有结论都建立在"单题端到端 + 机制推断"上,而不是"爬了整个题库"。这一点在下文的「诚实边界」里会再次强调。

先交代一下手头的环境与工具,后面每个环节都依赖它:

一条反常识但贯穿全文的前提:微信小程序跑在 PC 上时,逻辑层(AppService)是一个独立的 JS 引擎,跟网页的浏览器页面不是一回事。想读它的源码、抓它的请求,不能直接 F12,得先把它的调试接口暴露出来——这就是 WMPFDebugger 干的事。

在动手前,先把"目标"这个黑盒拆开看清楚。微信小程序在 PC 上运行时,不是"一个网页",而是几个协作的 JS 环境:

关键认知:业务逻辑(包括加密、发请求)全在 AppService 层,它的源码就是那几个 *.appservice.js 文件。所以"读源码"的实质,是去读逻辑层的 AppService 脚本;而"还原加密"的实质,是在这些脚本里找到 encrypt/decrypt 那几行。

这也解释了为什么普通 F12 抓不到:AppService 跑在独立 JS 引擎里,不在浏览器的页面上下文里,必须用 WMPFDebugger 这种专门工具把它的调试通道暴露出来。这也是为什么后面抓包要绕道 DevTools 前端的 NetworkLog,而不是直接开浏览器开发者工具。

第一脚就踢到铁板——不是技术上难,而是顺序。

WMPFDebugger 干的事是:向 WeChatAppEx.exe 注入 Frida,hook 住小程序的调试通道,然后在本地开两个 WebSocket 端口,让标准 DevTools 前端能连上来。它的启动日志能精确说明端口职责:

注意最后一行 [frida] you can now open any miniapps——它出现之前,小程序是不能先打开的。因为注入是"先挂上钩子、再等目标加载",如果小程序先起来了,注入点就错过了,后面 CDP 上什么都没有。

所以正确的顺序是:

教训一句话:

先起调试器,再开目标。 凡是"先注入后加载"的工具,启动顺序反了等于白跑,而且没有任何报错——就是安静地什么都没 hook 到。

这条没走弯路,是因为一开始就确认了顺序。真正开始踩坑是下一步。

小程序源码和请求在 DevTools 前端里,但要脚本化地读它、而不是手动点,得用一个能驱动 DevTools 的 MCP。这里用的是 chrome-devtools-mcp,它连的是 Chrome 的调试端口 9222。

第一步就失败:9222 上连不上。原因很简单——日常用的 Chrome 没开 --remote-debugging-port,而 PC 微信的 WMPF 运行时是独立进程,跟浏览器没关系。

解决:单独拉一个带调试口的 Chrome:

--user-data-dir 必须单独指定,否则会撞上已开着的日常 Chrome 实例,--remote-debugging-port 不生效。

要让 MCP 看到 devtools:// 这类内部页面,得给 chrome-devtools-mcp 加两个开关:--experimentalDevtools 和 --experimentalIncludeAllPages。否则 devtools:// 页面在页面列表里根本不出现,等于拿到了钥匙却找不到门。

我改完配置、重启,结果没生效。

排查手段是看 MCP 进程的真实命令行:

两个标志根本没进去。真相是——我改错了文件。Claude Code 2.x 读的是 ~/.claude.json 顶层的 mcpServers,不是 ~/.claude/mcp.json。我改了后者,白改。改对位置、再重启一次,两个标志才进命令行:

教训:改完配置先验证"真的生效了",别默认"我改了就一定对"。 最可靠的手段是看目标进程的真实命令行 / 真实日志,而不是"我以为配置文件是对的"。这里前后重启了两次,纯粹是栽在"配置文件有多个候选位置,我凭直觉选错了一个"。

脱敏后的配置形态,供参考:

关键就是 --experimentalDevtools(允许访问 devtools:// 内部页面)和 --experimentalIncludeAllPages(把内部页面也算进页面列表)。

按时间线,下面三个坑散落在源码采集阶段,都是自己造成的,单独列出来。读者照着做可以直接避开。

用 TaskStop 杀后台任务时,只杀掉了外层 shell 包装进程,真正干活的 node 进程(PID 10032)成了孤儿,还死死占着 62000 / 9421。新起的 WMPFDebugger 直接报错:

排查和解决:

教训:杀任务要杀到底层的那个进程,杀包装进程不算数。 端口被占时先用 netstat -ano 把 PID 查出来,别想当然地再起一个、再撞一次 EADDRINUSE。

用 MCP 的 filePath 参数把脚本正文落盘时,纯字符串被套了一层 JSON 引号,appservice.app.js 存成了 appservice.app.json——内容变成了一个带转义的字符串字面量,不是真正的 JS:

解决:改成传对象 { source, ... } 落盘,再写个解包脚本把正文抽出来重存。落盘后立刻算一次 SHA-256、比对长度,确认文件是"真 JS"而不是"包了一层引号的字符串"。

教训:序列化边界要盯死——字符串在 JSON 里会被再包一层。 落盘后立刻校验,别等分析到一半才发现文件是坏的。

DevTools 前端加载得太早,小程序还没连上。现象是 Sources 面板一片灰、scriptCount = 0。解决:reload 一次 inspector 页面,标题从空变成 DevTools - servicewechat.com/... 后,脚本才出现。

教训:小程序调试里,"页面先加载、目标后连接"是常态。 连上目标后 reload 前端再读,别对着空面板死磕。

先破一个直觉:小程序源码在 DevTools 里显示的 URL 长这样:

这是小程序运行时为调试器暴露的虚拟 URL,不是普通网络服务器上的资源。浏览器 fetch()、PowerShell 下载、常规 HTTP 客户端都拿不到它。正文必须由对应的 Debugger Script 内容提供器返回。所以正确姿势不是"下载",而是"让 DevTools 前端把 Script 的正文吐出来"。

走通的链路是:

入口代码:

为什么必须遍历所有 model:小程序的渲染层(page-frame)、逻辑层(AppService)、worker 分属不同 target,只读 models[0] 会漏文件。这一步实际拿到 53 个脚本(3 个 target)。

不要一上来就无脑读正文,分两步:

阶段一:枚举元数据,给每个 script 记下它的 targetId / scriptId / sourceURL / 长度:

阶段二:按清单读正文,用 targetId + scriptId 定位:

两个关键纪律:

批量导出后做 SHA-256 去重。最终:

去重脚本的核心逻辑(真实落盘的那份):

关键词命中情况(这是确认"拿到的是业务源码"的判据):

api/Master + AutoModel + DesEncryptModel 同时命中,基本锁定了"这是一个统一的加密业务接口"——这直接引出下一回合的抓包。

走 DevTools 前端的 NetworkLog:

所有业务请求都打到同一个接口(域名已脱敏):

请求体是 URL 编码的 command + data 对,一条真实样本:

响应体是一条 JSON,data 字段是密文:

抓包本身不难,但有几个细节不提前知道就会在解密时翻车:

响应里那个 "DES": true 字段很有意思——它跟源码里的 DesEncryptModel 对得上,说明这个加密模块在开发者内部被称为 "DES",但实际上是 AES。这个命名误导性极强:如果只按名字去找 DES 算法,方向从一开始就错了。它是后面算法还原时的一个重要提示:别信变量名,信代码。

抓到 18 条请求,命令分布(接口后缀已脱敏):

其中 15 条是登录后的正常响应(200),前 3 条是登录前发的(401)。

这里埋了个几乎让我误判的点:有一条 401 的 GetUserPayFirstVipList,响应里没有 data 字段,一开始被当成"解密失败"记了一笔(responseDecryptFailure: 1)。其实它根本不是解密失败,是登录前请求、服务端压根没返回 data。 把这个 401 从"算法问题"里剔掉之后,登录后的 15 条响应是 15/15 全部解密成功。

教训:抓包时先按登录态 / 状态码分层,别把"没数据"和"解不开"混为一谈。 一个 401 混进样本里,差点污染了"算法对不对"的判断。样本分层(登录前 / 登录后)是做算法验证的第一步,不是最后一步。

这是全文的重头。还原分四步:反推形态 → 定位函数 → 读完整赋值链 → 交叉验证。

先看请求密文的长度。一条真实样本:

{"ID":6075} 是 11 字节,PKCS7 填充到 16 字节,正好是 1 个 AES 块;Base64 编码 16 字节 = 24 字符(末尾 ==)。这基本锁定:AES 块加密 + PKCS7 填充。

再结合源码里搜到的 PKCS7、crypto-js 引用,方向收敛到 AES-CBC + PKCS7。

在 1.28MB 的主包里定位,靠的是关键词收敛:DesEncryptModel、PKCS7、MD5、AES。最终锁到业务加密函数(源码已做最小脱敏,保留算法本体):

变量语义表:

请求侧:

其中请求侧密钥是固定的:MD5("D") = f623e75af30e62bbd73d6df5b50bb7b5。

响应侧:

还原到一半时,我盯上了源码里这行:

这是一个 32 字符的 hex 串,长得特别像"预计算好的 AES-256 密钥"。我当时差点把它当成真正的 key。但一算,MD5("D") = f623e75af30e62bbd73d6df5b50bb7b5,两个值对不上,卡住了。

直到把 encrypt / decrypt 完整函数体(而不是孤立的常量)抽出来,才发现真相:

教训:不要盯单个常量,要盯完整的赋值链。 一个"长得像密钥"的常量,可能是声明初始值、可能是另一条代码路径的、也可能压根是废弃代码。只有顺着函数体把赋值顺序看全,才知道运行时到底用的是哪个。

(顺带,d = Utf8.parse("59b87344b54f3197") 才是真·IV,它在函数里从不被重新赋值。这正说明"看完整赋值链"的重要性:同样是常量,d 是活的,u 的初始值是死的。)

主包里还能看到 CryptoJS 的底层 AES 实现,和上面的业务封装层是两回事。底层长这样(变量名已被混淆,但结构一眼可辨):

逐块认一下,就能确认这是标准 AES-128/192/256 的 T 表实现,不是目标自研的加密:

区分这两层极其重要:业务层的 o.encrypt 才是我们要还原的协议,底层那堆是 CryptoJS 库自带的 AES,不用管它。定位错层级,就会陷入"对着 AES 轮函数发呆"的死胡同——你分析半天还原出来的只是 CryptoJS 开源库里本来就公开的 AES,跟目标的协议半毛钱关系没有。真正有价值的是业务层怎么选 key、怎么选 IV、怎么组织明文,而不是 AES 本身怎么转轮。

算法对不对,不能只靠"读代码看懂了",要靠真实样本回放。做两件事:

还原脚本(Node,逻辑与线上一致):

上面是核心的两个函数。把它们接进一个完整解码流水线(从抓包原始记录到可用数据),逻辑长这样:

跑完全部抓包记录,得到一张干净的验证结果表:

这组数字就是"算法还原正确"的硬证据——不是"我读代码觉得对",而是"每一密文都能用还原出的 key 解回明文"。

到这一步,"内容保护机制能不能还原"有了确定答案:能,而且零门槛——固定密钥、固定 IV、明文算法全在 JS 里。

算法还原只是第一关。真正写解析器时,会发现明文解出来不是直接可用的对象,而是套了好几层 JSON。这是协议层面的"洋葱皮",不剥干净就拿不到数据。

请求侧的真实层结构:

拆开理解:

也就是说,被 AES 加密的明文是字符串 {"ID":6075}(L1),而不是直接对对象做序列化加密。这一层 stringify 是谁做的、为什么要套,可以暂时不深究,但解析时要按"解密 → 得字符串 → JSON.parse → 得对象"来。

响应侧则更夸张,是三层嵌套:

即:解密得到字符串 → JSON.parse 得到数组 → 再 parse 一层数组 → 再 parse 一层数组 → 最内层才是题目对象。这大概率是后端框架把结果逐层 JSON 序列化造成的(每个处理环节都 stringify 一次,堆了三层)。写解析器时别想当然"解出来就是对象",先探测一下最外层是什么类型、要剥几层。

教训:明文解出来 ≠ 数据拿到手。 加密还原到"能解密"只是及格线,多层序列化、嵌套数组、字段名映射这些"洋葱皮",才是真正让解析器能跑通的最后一公里。这一步靠的是一次次 typeof / Array.isArray 的探测,而不是猜。

用上面的 decrypt 解开详情接口的响应后,得到的不是一层 JSON,而是三层嵌套(真实结构):

即:解密得到的是一个 JSON 字符串,JSON.parse 一层套一层,最内层才是题目对象。这一层嵌套结构本身也是协议的一部分,写解析器时得按层剥。

剥到最后,完整拿到这道题的字段:

(题目正文和答案正文是付费版权内容,这里只贴字段名和长度,不贴原文。)

端到端验证完成:从抓到的密文,到还原的算法,到解开明文题目 + 答案 + 解析,全链路跑通。

把列表接口和详情接口的响应结构摆出来,结论自己会浮出来。

GetRelQuestionList_xxx 一次返回 2328 个题目 ID,每个条目长这样(真实结构,题目 ID 为验证样本):

拆开看,条目里只有两个字段:

PropertyID(31 / 25 / 17 / 16 这类)是属性维度(题型、章节、知识点等),PropertyContentID 是对应维度下的具体值。注意这里用 ID 引用而不是内联文字,是为了数据归一化——正文由另外的属性接口提供。

重点:这 2328 个条目里,没有任何"付费 / 锁定 / 未解锁 / 价格"字段。也就是说,服务端连"哪些题该收费"的标记都不下发——列表阶段就已经是无差别全量返回了。

GetQuestionDetails_xxx 返回的字段就这 11 个:


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
点赞 ・10
打赏
分享
最新回复 (6)
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2 楼
感谢分享
1天前
0
雪    币: 319
活跃值: (190)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3 楼
看看
22小时前
0
雪    币: 158
活跃值: (5871)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4 楼
感谢分享
20小时前
0
雪    币: 74
活跃值: (900)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5 楼
2423423
20小时前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6 楼
666
17小时前
0
雪    币: 110
活跃值: (9616)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7 楼
test
52分钟前
0
游客
登录 | 注册 方可回帖
返回