首页
社区
课程
招聘
[原创] 飞常准微信小程序逆向:双 MD5 签名还原与 wx.login code 打通登录搜索全链路
发表于: 1小时前 67

[原创] 飞常准微信小程序逆向:双 MD5 签名还原与 wx.login code 打通登录搜索全链路

1小时前
67

目标很明确:拿到飞常准小程序的请求构造规则,脱离微信环境,用纯协议完成「登录 + 搜索航班」。

手头有一份 wmpf-trace 抓出来的 trace 日志(traceOut/run-...),里面有完整的 HTTP 请求、响应体、以及小程序 webpack 打包后的 JS 源码。任务就是:从这份日志里把「请求是怎么签的、登录态是怎么来的」还原出来。

第一脚就踢到铁板:这份 trace 是 late-attach 的,state=incomplete——它是在小程序已经跑起来之后才注入的,所以关键的 wx.login() 调用根本没被抓到,trace 里能看到的 code 早就过期了。这为后面埋下了一个大坑。

飞常准不是 H5 网页,是微信小程序,跑在 Windows 微信客户端内嵌的 WMPF(微信小程序框架)里。抓包看到的请求,全部来自小程序的 AppService(逻辑层,跑业务 JS)——它跟渲染层 WebView 是分离的两个执行环境。

这对这次逆向有三个直接影响:

明白这一点,后面「为什么 code 必须借控制面」就顺理成章了——不是算法问题,是执行环境问题。

trace 里所有业务请求都指向同一个网关 app.variflight.com/vapi/router/*,每个 URL 都带一组公共参数,最后缀着两个字段:

signature 是 32 位大写十六进制,明显是 MD5。问题就是:它是对什么字符串做的 MD5?

trace 的 scripts/source/ 里有小程序打包后的 JS。签名相关的逻辑分散在几个 webpack 模块里:

怎么从几 MB 的 bundle 里精准定位到这几个模块? 不是靠运气,是有套路的:

关键就在第 4 步——hV = function(e,t){return i(i(e)+t)},其中 i 就是 MD5。展开就是:

也就是说,签名是双重 MD5:先对序列化串做一次 MD5,把结果(大写)拼上密钥,再做一次 MD5,最后转大写。

t.Z(模块 12134)的等价还原:

几个容易踩的点:

有个容易走偏的地方:这套签名用到的 MD5 就是标准 MD5,没有加盐、没有 IV、没有魔改轮函数。业务层的"复杂度"只在于外面套的那层壳——排序序列化 + 双 MD5 + 大写。

对比一下加密类逆向里常见的坑:CryptoJS 的 AES、魔改 DES(函数名写着 DesEncryptModel 结果其实是 AES)、自定义 T 表……那些才是"底层有货"。这里没有——抓到 hV = i(i(e)+t) 这一行,底层算法就到头了,剩下全是"序列化规则的边界"。

教训:先判断「复杂度在底层算法还是在业务封装层」。飞常准这种属于后者——底层一眼看到底,真正要抠的是序列化的每一个边界(排序、对象值、null、编码)。别在"MD5 是不是魔改过"上耗时间。

用 trace 里四个真实请求逐字复算(loginByCode / getBindMobile / tripListExtra / flightSearch),签名全部命中:

这四个样本不是随便挑的,它们覆盖了签名算法在不同形态下的表现:

四个全命中,说明签名算法对「有没有登录态、有没有额外字段」都不敏感——序列化 + 排序 + 双 MD5 是普适规则,不是某个接口特判。

教训:签名是静态可逆的纯算法,密钥硬编码在客户端 JS 里。拿到 bundle + 两个真实样本,就能逐字复算。不要一看到「MD5 + 排序」就往「不可逆」「动态密钥」上想。

到这里,搜索航班的签名问题解决了。但登录态从哪来,还没解决。

拿到响应后,终端打印出来是一坨乱码,第一反应是「响应编码不对,数据坏了」。

当时返回体里 airlinesName 显示成 ������,看着像 GBK 被当 UTF-8 读,或者反过来。但我去查了 codepoint:

响应本来就是合法的 UTF-8,数据是好的。 乱码纯粹是 Git Bash 终端 codepage 显示问题——终端用错了代码页去渲染,跟数据本身无关。

教训:终端显示乱码 ≠ 数据坏了。先验证数据本身的编码(看 codepoint、写文件用 UTF-8),再决定要不要修。我差点为了一个显示问题去「修复」根本不存在的数据错误。

解决办法很简单:结果完整落盘(json.dump(..., ensure_ascii=False)),终端只打印 ASCII 摘要。

除了乱码,这次还踩了两个「拿到了却是脏的」的表示层坑,都属于同一类。

1. 签名串不编码,请求 URL 才编码

签名用的 serialize() 产出的是未 URL 编码的原始串(k=v&k=v),MD5 是对这个原始串算的。但最终发请求时,整个 query 要按标准 urlencode 编码。如果反过来——先编码再算签名,签名一定错。两层表示必须分开:

2. 时间戳有秒和毫秒两套单位

同一个系统里混着两种时间戳,搞混就出 bug:

签名用毫秒、凭证里嵌秒,取数时别拿错位数。

用 trace 里现成的登录态(openId / sessionKey / mpcredential 等一堆字段),签名算法一填,搜索航班直接通了:

110 班航班,code:0。搜索航班的纯协议实现,成了。

但换成不带登录态发同一个请求:

登录态是必须的。那登录态从哪来?答案是 loginByCode 接口——它需要一个 code 参数。而这个 code,来自微信的 wx.login()。

我在 bundle 里搜 mpcredential,搜不到任何生成逻辑。这就说明:sessionKey、uid、mpcredential 这些字段,全是服务端在 loginByCode 响应里下发的,客户端没有任何计算。所以从 JS 里找 mpcredential 的算法,方向一开始就是错的。

loginByCode 的完整链路:

这里我走了一段弯路,也犯了一个方向性错误。

我一开始的执念是「纯协议实现登录」——既然签名是纯算法,那登录是不是也能纯算法搞定?答案是不能,而且不是「我还没找到算法」,是物理上不存在。

wx.login() 的 code 是微信服务端签发的一次性票据,约 5 分钟过期,用一次就作废。它不是任何「输入经过本地算法」能算出来的东西——它的签发逻辑在微信服务器上,依赖你的微信登录态。

教训:把「纯协议实现登录」拆成两半——loginByCode(code 换 session)是纯协议,flightSearch 是纯协议;但**「拿 code」这一步永远不是纯协议**,它必须借真实微信环境。我把两件事混在一起,于是在「补环境算 code」这个不存在的方向上浪费了时间。

认清这个边界后,问题就变成了:怎么从真实微信环境里,干净地拿到一个 code?

答案就在手里的 wmpf-trace 工具里。它除了抓包,还带一个控制面(control plane),能在匹配的 AppService 上下文里执行任意 JS 表达式——包括 wx.login()。

工具自带的 examples/wx_code_provider.py 已经把这件事封装好了:

它内部通过控制 API POST /v1/sensitive-evaluate,在真实 AppService 上下文里执行这段表达式:

几个关键设计,都值得学:

但上面只写了 lease 的「消费」这一条路。code lease 其实是个三态状态机,完整生命周期是这样的:

这套机制真正值钱的,是它背后的几条不变量,逐条说:

一个容易误会、但我专门查证过的点:本地 CODE_USE_WINDOW_MS=60_000 这个窗口,只是「拒绝使用过旧 code」的本地上限,不代表微信或服务端承诺 code 真能活 60 秒。code 的真实有效期由微信服务端决定(约 5 分钟,但这是经验值不是契约)。所以正确策略是「拿到就立刻交换」,而不是掐着本地窗口磨蹭——窗口是用来兜底挡「过旧 code」,不是用来卡点用的。

wmpf-trace 控制面的敏感动作接口(/v1/sensitive-evaluate)返回的 HTTP 状态码是有语义的,读一遍能省很多排查时间:

我当时就是被 502 兜底坑的——它的语义藏在 errorStatus() 的默认分支里,不是「连接失败」。这张表是我翻 src/control/server.ts 才拼出来的,早知道就不会误判成环境问题。

另外,这个控制面不是裸奔的,它有几层防护,用的时候要知道:

我全程用 --control-only 模式——只起控制面,不做完整 trace 抓包(因为 trace 素材已经有现成的了)。已有有效 session 时更是完全跳过 trace,直接纯协议发请求。控制面只在「需要新鲜 code」时才开机,用完即停,这也符合「最小暴露」的原则。

兴冲冲跑第一版,结果秒崩:

502 Bad Gateway + WMPF transport awaiting reconnect。我当时第一反应是「微信没连上」「环境没配好」。错了。

去翻 trace 进程日志 trace-process.log,看到真相:

frida 注入进微信后,已经打开的小程序会经历一次「断 → 重连」抖动。 我的 request_code 恰好打在 disconnected 那个窗口上,被取消了。

再往下挖,src/index.ts 里有一行是决定性证据:

教训:502 + "awaiting reconnect" 是注入后的重连抖动,不是连接失败。 而且 provider 默认不重试,撞上就直接抛异常退出。这是我自己的时序坑——request_code 打早了。

修复就两条:

加了重试后重跑。第一次 404 matching_context_not_found(小程序还没开),重试正好接住用户打开小程序的时机,拿到了 code:

全链路打通了。 而且验证了这是「真·新登录」,不是复用旧 trace 会话——新 session 的 sessionKey / mpcredential 时间戳从 ...1792644154 变到 ...1792645605,服务端重新签发了。

把这次做的事串成一条可复现的流水线,四步:

第 4 步是最终跑通的关键,把它的核心代码完整贴出来(脱敏后)。variflight.py 里已经封装好了 build_login_url / build_flight_search_url,这里只贴「拿 code → 换 session → 缓存 → 响应式刷新」的编排逻辑:

这一段浓缩了整条链路的三个工程决策:

把「session 复用 + 失效刷新」画成状态机,就是 skill 里反复强调的那张图(我实际实现就是它的简化版):

这张图背后有三个容易漏的工程点,都是「处理微信 code」时真正要抠的:

有个选择值得单独说:实发请求我用的是 xiaojianbang-necko-runtime(真实 Firefox 网络栈),而不是 Python 的 requests。原因:

对飞常准这种「签名校验型」接口,其实 requests 也能通(签名对、字段对就行);但养成用真实网络栈的习惯,换到 TLS 指纹敏感的目标就不容易翻车。

搜索结果之外,小程序还会发一个 moreBlueTag 请求,fs 参数格式像 FaCA1507:-PEK:-SHA:-2026-10-23,...——这是搜索结果的「蓝标」补充标注,跟基础航班列表无关。取基础数据不需要它,但如果你在 trace 里看到它别慌,它不是搜索的必需环节。

一张表说清签名算法参数:

loginByCode 响应 data 的关键字段(全部服务端签发):

两个「名字骗人」的别名,一起记下:

它们都不是新生成的字段,只是把已有字段再抄一份。所以「登录态字段一大堆」,本质上是「三个真字段 + 一堆别名和装饰」——这是用单变量篡改测试才筛出来的,光看字段名会被唬住。

这些字段怎么从 loginByCode 流到 flightSearch? 链路是「响应 → 本地存储 → 下一个请求」:

也就是说,登录态字段没有任何二次加工——响应里发什么,后续请求就原样带什么。这也解释了为什么「复用旧 trace 的登录态也能搜索成功」:只要 sessionKey + uid + openId 三个字段还在有效期内,服务端就认。真正让旧 session 失效的,是服务端侧的时间窗,不是客户端做了什么。

「原样透传」在客户端对应的那段代码(模块 73690 的 u()),等价还原出来就是这样——就是把 loginByCode 响应 data 里的这些键,原封不动抄进下一个请求的 query:

注意最后那句 if ... is not None——它不是「挑哪些字段鉴权」,只是「跳过响应里没下发的键」。换句话说,客户端把服务端给它的登录态键一次性全带上,至于哪些键服务端真正校验,客户端自己根本不知道。这也解释了为什么「删除装饰字段服务端照样返回 0」——服务端压根没指望它们。

这个「字段原样透传」的特征,也是我用「篡改探测」就能标定鉴权字段的前提——如果是客户端二次加密/签名后的字段,篡改测试会无从下手。

注意 flightList 是双层数组 [[...]]——外层是分组维度,内层才是具体航班对象。取基础列表时直接 flightList[0]。外层分组的确切语义(按航司 / 共享航班 / 舱位分组)本文未深究,属于可继续往下挖的方向,但不影响基础取数。

逆向最怕的就是「把不同阶段的样本混在一起,污染判断」。这次我刻意按维度把样本分开,每个结论都标清楚是用哪类样本证出来的:

分层的价值在「附加题」里体现得最明显——如果没有把「篡改字段」和「篡改时间戳」分开,很容易得出「mpcredential 也参与鉴权」或「TTL 可以离线逼近」的错误结论。同一个字段,改内容、删字段、回溯时间戳是三种不同的测试,结果完全不同。

appKey=J8w...5ts&device=2&version=6.8.2&channelID=wxchb_0xx&...&timestamp=1792644154196&signature=4E70800EB8E537E72D35F11858DD78A3
hV(e, t) = MD5( MD5(e) + t )

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

收藏
点赞 ・3
打赏
分享
最新回复 (1)
雪    币: 110
活跃值: (9626)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2 楼
test
31分钟前
0
游客
登录 | 注册 方可回帖
返回