首页
社区
课程
招聘
[原创] 瑞幸咖啡附近门店接口逆向实战:从 Chaos VM 字节码到纯 Node.js 零登录复现
发表于: 1小时前 50

[原创] 瑞幸咖啡附近门店接口逆向实战:从 Chaos VM 字节码到纯 Node.js 零登录复现

1小时前
50

目标:不启动微信、不打开小程序,用一段纯 Node.js 代码直接拉取瑞幸咖啡"附近门店"列表。
手段:xiaojianbang 小程序 trace 抓包 + 反汇编腾讯 Chaos VM 字节码 + 还原 AES 与自定义签名。
结果:算法全还原,25 个历史样本 100% 复现,在线拿到门店数据。
最大的坑:不是 AES,不是 VM,而是一个被漏掉的 uid 字段。

事情的起点很明确:瑞幸咖啡小程序有个"附近门店"功能,我想绕过小程序运行时,直接复现它背后的 HTTP 协议。

判断一个接口能不能"纯协议复现",核心只看三件事:

只要这三件事都还原出来,就可以用 node 脚本对着服务器裸发请求。瑞幸这个接口恰好满足——它用的是匿名会话 + 固定 key 对称加密,不强制微信登录态(后文会验证这一点)。

手头有的材料是一份 xiaojianbang 工具抓的 trace。这套工具能做两件关键的事:一是协议级抓包(能看到小程序发出的原始 HTTP 字节,包括密文前的明文),二是把小程序运行时的 JS 源码全部 dump 下来。这两样合起来,就是逆向一套签名协议的全部原料。

先让读者看懂"黑盒":瑞幸小程序跑在微信的 WMPF(微信小程序框架)里,运行时分成两层——渲染层 page-frame 和逻辑层 AppService。门店业务 JS(包括 utils/api.js、utils/crypto.js)跑在 AppService 这一层,trace 工具 dump 出来的就是这一层的源码。

一个关键的前置判断:这次"附近门店"是纯小程序链路——主业务页面是 page-frame.html,target 类型是 page,网络请求直连 capi.lkcoffee.com,没有业务 WebView/H5,也没有 iframe。这意味着算法一定在小程序 JS 里,而不是藏在某个 H5 页面的脚本里。判断时要避开几个坑:

trace 原料也分两部分:一是 normalized/http.jsonl 里的协议级 HTTP 事件(请求原文、响应密文),二是 scripts/source/ 下按 SHA-256 命名的 JS sidecar(AppService 源码)。前者回答"发了什么",后者回答"怎么生成的"——两边的交集,就是签名算法的全部证据。

下面按我实际走过的顺序讲。死路和踩坑都写,尤其是我自己犯的那个低级错误——它才是这篇文章最值钱的部分。

先扫一遍 trace 里的请求端点。按 host + path 聚合后,和门店业务相关的就两个:

所以正确调用顺序是:先 homeshop 定位城市,再 shopList 拿列表。

打开 shopList 的请求体,看到的不是业务 JSON,而是一段表单:

响应也是密文:

在拆样本之前,有一个容易踩的坑要先说:CDP 事件是并发交错的,响应正文不一定紧跟请求事件。要把"某个请求用了什么密文、签了什么 sign"精确对上,关联键不能靠"日志相邻行",而要靠:

用这个键,我从 trace 里把 homeshop 和 shopList 两个接口的请求逐个提出来,每个请求都能拿到它的明文业务对象、密文 q、sign、Cookie 里的 uid、cid、dk,一共凑出 25 个"明文 → 密文 → 签名"的完整样本。这 25 个样本就是后面所有验算和穷举的原料,也是最终能确认"签名算法还原正确"的底气。

到这里,问题被拆成四个:

其中 sign 最扎眼——它不是 32 位 hex 的 md5,而是一串 40 位十进制数字。这个"看起来像 md5 又不像 md5"的形状,就是后面所有折腾的根源。

抓包只能看到"字段长什么样",看不到"字段怎么生成"。要找到生成 q 和 sign 的代码,得顺着 HTTP 事件的 initiator 往源码里追。

shopList 请求的 initiator 指向一个 scriptId,对应到 trace 里 dump 出的 JS sidecar,再往调用栈里看,最终定位到业务 bundle 里的统一封装 utils/api.js。

到这里本该是柳暗花明,结果打开一看——api.js 的核心被包在这样一层里:

这是腾讯的 Chaos VM(__TENCENT_CHAOS_VM),一种把真实逻辑编译成自研字节码、再由解释器执行的混淆方案。原本应该明晃晃写在那里的签名拼接逻辑,现在变成了一坨 base64 编码的字节码。

字节码的还原链是:

解释器的 switch 覆盖 case 0 到 case 69 左右,是一个典型的栈机 + 寄存器混合的字节码虚拟机——既有一个操作数栈做临时计算,又有一个局部数组 l[] 当"寄存器"存变量。字符串拼接、数组操作、对象赋值、函数调用,全是靠这些 opcode 组合出来的,单看字节码几乎看不出业务语义。

这两层编码各有各的用途:

这一步不是玄学,但很吃耐心:要把每一条 opcode 的语义对上,再手工"翻译"回等价的 JS 逻辑。我当时的做法是不逐条静态翻译,而是写一个 mock 环境,让这段字节码在真实 JS 引擎里跑起来,同时 hook 住 md5、aes 这些函数,把它们被调用时的入参全部打印出来。

这里有个关键思路:与其逐条反汇编字节码,不如让字节码"自己跑",然后在关键函数入口挂 hook 看入参。 混淆再强,它最终还是要调 md5、调 aes,这些调用点的入参就是算法的真身。

但这条路有个代价,也是我后面栽跟头的地方:"跑起来"依赖 mock 环境对真实运行态的还原程度。 mock 里少一个字段、多一个空值,字节码就会走进一条和真实运行不同的分支——你拿到的那条分支看起来照样自洽,却可能是错的。

trace 里 dump 出来的源码不是全部被混淆。utils/crypto.js 就是明文,里面躺着两样关键东西:

源码里还留着一句中文报错 MD5加密结果字节数组错误——这就是业务自己封装的 md5。

这里要补一个 crypto-js 的坑:CryptoJS.MD5(t).words 返回的不是 16 个字节,而是 4 个 32 位 word,而且默认是有符号的(signed int32)。当某个 word 的最高位是 1 时,它会被当成负数。所以如果直接 w[0].toString(),有概率得到一串带负号的数字——业务代码里的 Math.abs(...) 正是为了消掉这个负号。这个细节也直接解释了后面 sign 为什么是"取绝对值拼接"。

它和标准 md5 的区别在于:标准 md5 输出 32 位 hex;这个输出的是"四个大端 signed int32 各自取绝对值后拼起来的十进制串"。所以 sign 才会是 40 位左右的数字,而不是 32 位 hex。

crypto.js 的 AES 封装很朴素:AES-ECB + PKCS#7,key 从配置里取。配置模块(store/system/index.js)暴露了 appId、code、version、prod key、akv 这些字段,其中 prod key 就是加密用的 key。

到这里,四件事已经解决了一件半:

先把 AES 坐实。用真实样本做"明文 → 密文"的往返验证:

在写解密代码之前,其实先有一个"旁证"可以瞄一眼:把 q 做 URL-safe 还原、Base64 解码后,得到 192 字节,正好是 16 字节(AES 块大小)的整数倍(192 = 16 × 12)。长度能被 16 整除不直接等于"这就是 AES-ECB",但配合源码里的 aes.en 调用,两者互相印证,方向就锁死了。

这里顺带解释"URL-safe"的来由:q 最后要放进 GET 的 query string(homeshop 是 GET)或表单体(shopList 是 POST)。标准 Base64 里有 + 和 / 两个字符,它们在 URL 里都有特殊含义——+ 在 query string 里会被解析成空格,/ 是路径分隔符。所以业务在 Base64 之后又做了一次 +→-、/→_ 的替换,让 q 能安全塞进 URL 而不被转义破坏;解密前再反向换回来。trace 里 q 出现的 -、_ 就是这次替换留下的痕迹。

25 个历史样本,明文加密后和 trace 里的 q 逐字一致,25/25 全对。AES 这块已经铁板钉钉。

但 sign 就邪门了。md5 的"取绝对值拼接"算法我明明拿到了,key 也拿到了,可就是算不出 trace 里的 sign。

当时的思路是:sign 原文大概是某种 cid、dk、q 的拼接,再加一个盐。于是我写了个穷举脚本,把可能的结构全试一遍:

结果:0 命中。

这里我犯了一个方向性错误——后面会专门讲。先看这个穷举为什么注定失败。

穷举失败后,我陷入了一个很典型的死循环:认定"结构没问题,只是盐不对",于是不断加大盐的候选空间。

每猜一次都是 0 命中。当时的心理是"就差一个盐了",实际上——结构本身错了,盐再怎么加都对不上。

这是一个非常典型的逆向陷阱:

当你确认了"部分环节是对的"(比如 AES 25/25),就会默认"整体也快对了",于是把剩下的所有误差都归因到"还差一个值"。但真正的问题可能是"结构里少了一个字段",而不是"某个值猜错了"。

用一张对照表说清楚我当时卡在哪:

可以看到,五个变量里我对了四个,唯独"字段集合"这一项是错的——而它恰恰是最高优先级的变量。我却在最低优先级的"盐"上死磕。

教训先抛出来:做 mock / 动态执行的时候,永远用"真实样本里的非空值"去喂,不要用空串、0、null 这类"干净但失真"的默认值。空值会让代码走进一条和真实运行完全不同的分支,而你拿到的那条分支看起来照样"自洽"。

这个坑具体是怎么发生的:

我用 mock 环境跑 api.js 那段被混淆的字节码,想在 md5 入口把签名原文打印出来。结果打印出来的是:

于是我就认定签名原文只有 cid、dk、q 三个元素,拿着这个"3 元素"结构去做穷举——当然全错。

问题出在哪? 我的 mock 环境里把 uid 设成了空字符串 ''。而真实代码的逻辑是:uid 这个字段是动态拼进签名原文的。当 uid 为空时,拼出来的字符串里就看不到它;当 uid 有值时(真实请求里它是个 UUID+毫秒时间戳),签名原文才会多出 ;uid=xxx 这一段。

换句话说:我拿一个 uid='' 的空转环境,去跑真实代码,得到了一条"uid 为空"的运行路径,然后把这条特殊路径当成了通用算法。

这就引出了第二个教训:

"部分正确"是逆向里最危险的状态。 AES 25/25 对、md5 函数对、cid/dk/q 的拼接也对,这些"对"让我坚信自己只差一个盐,从而完全没想过回头质疑"参与签名的字段到底是不是三个"。

如果当时我做任何一件事——用真实 trace 里那个非空的 uid 去喂 mock,或者反汇编时回头数一下"这个数组到底是 3 个还是 4 个元素"——这个坑当场就暴露了。

除了 uid 设空,我在 mock 阶段还踩过第二个坑,性质更隐蔽。

为了跑那段字节码,我用 JS 的 Proxy 模拟 config.api 这类配置对象。config.api.key 下面才是 prod / pre / test03 / test04 四个环境的 key。问题在于:我的 apiTable 代理对未知属性一律返回一个"接口配置对象",于是 config.api.key.prod 这条访问路径里,key 没被正确解析,一路塌缩成了假对象。

更糟的是,我的 stub 让 Symbol.toPrimitive 返回 0。当这个假对象被拼进签名原文的字符串时,被强制转成了 "0"——于是"盐"变成了 "0",算出来的 sign 自然全错。

后来又走了另一个极端:把 key "CJQjAc1hYieC4QYb" 直接硬编码进 mock 的 aes.en。结果代码"揭示"出来的 key,其实是我自己喂进去的——一个自证循环:用已知答案的 mock,去"发现"了这个答案,还以为是靠逆向还原出来的。

这两个坑合起来是一条更通用的教训:

mock / 动态执行时,你的 Proxy 拦截和默认值就是代码运行环境的一部分。 拦截到不该拦截的路径、返回一个会被 Symbol.toPrimitive 污染成 0/"" 的假对象、或干脆把正确答案硬编码进去,都会让你得到一条"看起来对、其实自欺"的运行路径。mock 要尽量还原真实运行态,而不是图省事。

卡住之后,我把目光转向了版本差异。

我逆向的这份 trace 是小程序 miniversion=5603 的版本,api.js 被 Chaos VM 混淆了。而更早的 5593 版本,api.js 是明文可读的(瑞幸是在这两个版本之间给签名封装加的 VM 混淆)。两个版本的对比:

这张表本身就说清了两件事:

在 5593 的明文版本里,签名原文的构造一眼就能看到:

签名原文的完整格式是:

原来签名原文有四个元素,第四个是 uid——而且它的值和请求 Cookie: uid=... 里的值是同一个。

这是一个非常典型的**"会话态进签名"**设计:把匿名会话的 uid 同时放进 Cookie 和签名,既做会话绑定,又防重放。而 uid 恰恰是我在 mock 里设成空串、导致被隐藏掉的那个字段。

回头看,其实有个信号早该抓住:

开发者把混淆火力集中砸在 api.js 上,而不是 crypto.js 上,说明他们最想保护的不是 AES/md5 算法本身,而是"签名原文怎么拼"这条规则。因为算法是标准库、key 在配置里,抄走了影响有限;但签名原文的字段拼接规则是自研的、非标准的,一旦被人看到就能直接复现签名。

逆向时先扫一遍混淆标志(__TENCENT_CHAOS_VM、eval、Function 构造等)的分布,哪些模块被混淆、哪些是明文,这个分布本身就告诉你"开发者最在意、最值得逆向的是哪一块"。 这次被混淆的 api.js,恰好就是答案所在。

拿到完整格式后,立刻用真实样本验算。取一个最简单的样本(q 对应明文是 {"miniversion":"5603"}),完整走一遍:

对这个 preimage 做 md5,看四个 word:

拼接四个绝对值:

和 trace 里的真实 sign 完全一致。

注意一个细节:md5 结果的前两个 word 最高位是 1,按 signed int32 解析是负数(0x9a566746、0xe2a18ea2)。如果直接 toString() 会得到带负号的结果,所以业务代码才要 Math.abs()——这就是"取绝对值拼接"存在的真正原因。

再用全部 25 个历史样本做交叉验证:

签名算法彻底闭环。

最后跑完整的纯协议客户端,对四川阿坝的坐标(GCJ-02)发起查询:

输出:

拿到了 8 家门店,前几家:

这里顺带验证了一个边界:把 userLongitude/userLatitude 设成 0,服务端就不再按有效用户点做"附近"筛选,而是返回城市列表页(本次 8 家)。这印证了 longitude/latitude(查询点)和 userLongitude/userLatitude(用户定位点)是两个不同语义,不能只看前者就断言接口一定做了距离筛选。

响应也是对称处理:响应体是 URL-safe 密文,先 -→+、_→/ 还原成标准 Base64,再用同一个 key 做 AES-128-ECB 解密,得到业务 JSON。但"解出来"不等于"成功",还要过一层业务状态校验:

HTTP 200 只代表传输层成功,code=1 且 status="SUCCESS" 才代表业务层成功。这两层要分开判断,不能看到 200 就以为拿到了数据。

homeshop(GET,定位城市):

shopList(POST,取列表):

请求头:


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
点赞 ・2
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回