# 卡集 App 逆向分析文档(抓包 + Open-Auth-Sig 请求签名 + 爬虫) ## 1. 目标信息 - **App / 包名**:卡集,`card.kaji.com` - **版本**:versionName `2.5.39`,versionCode `10003`(huawei 渠道) - **APK**:`卡集_2.5.39.apk`(约 86MB) - **业务域名**:`server.ssl1.kaji6.com`(主列表 / videoPlay)、`page.ssl1.kaji6.com`(商户列表 / 详情 / 玩家) - **技术栈**:**uni-app**(DCloud HBuilderX 打包,Vue2 + uni-v3 / Weex 渲染引擎) - 业务逻辑是 JavaScript:`assets/apps/__UNI__EFAB5FD/www/app-service.js`(3.6MB)、`app-view.js`(4.9MB) - Application 类:`io.dcloud.application.DCloudApplication`;入口 `io.dcloud.PandoraEntry` - 关键 so:`libweexcore.so` / `libweexjss.so` / `libuts-runtime.so` - **排除项**:无 `libflutter.so` / `flutter_assets`(非 Flutter);无 `index.android.bundle` / `libhermes`(非 React Native) - **是否加固**:**未整包加固**。四个 dex 可正常反编译,类名语义完整(`io.dcloud.*`、`com.taobao.weex.*`),无壳 Stub。 - `assets/39285EFA.dex` + `lib39285EFA.so` 是 DCloud uni-app 自带的分包/加密机制,不是第三方壳。 - 存在**局部防护 SDK**:`libzxprotect.so`(局部代码保护)、`libaliyunaf.so`(阿里聚安全)、`libsecuritydevice.so`(阿里 dtf 设备风控,约 3.9MB,疑似带反调试/反 Frida,静态未坐实)。 - **反爬 / 校验措施**: - **没有应用级硬 SSL Pinning**(JS 层、DCloud 框架层、native 层三层排查,`pin-sha256` / `CertificatePinner` 配置值均为 0)。握手拦截来自**标准证书链校验**(`targetSdk 30` + 无 `networkSecurityConfig`)+ **DCloud 的 HostnameVerifier 主机名校验**。 - 接口鉴权:请求头 `Authorization: Open-Auth-Sig`(**每请求签名**,非登录 token),详见第 4 节。 - 设备风控 SDK(`libsecuritydevice.so`)负责环境检测,与 HTTP 校验无关。 --- ## 2. 抓包突破(SSL 握手失败) ### 抓包工具与环境 - 抓包工具:**Reqable**(PC + 手机代理) - 手机环境:已 **root**,**Magisk** 管理,**LSPosed** 框架 - 证书处理:Magisk 模块 `Reqable Certificate Installer`(装 CA 到系统库)+ `Move Certificates`(用户库证书迁移到系统库) - unpinning 历程:LSPosed 模块 **JustTrustMe(失败)→ JustTrustMe++/算法助手(API 能抓、WebView 图片不显示)→ JustTrustMePro(完全成功,含图片)** ### 现象与根因 - 一开抓包,卡集立即报 **「客户端 SSL 握手失败」**,App 网络异常;系统级证书已装、JustTrustMe 已勾选重启仍失败。 - 反编译确认:业务请求走 **DCloud shade(重命名)版 OkHttp `dc.squareup.okhttp3.*`**(第三方 SDK 才走标准 `okhttp3.*`),证书装配点是 `io.dcloud.common.adapter.util.DCloudTrustManager`。 - **JustTrustMe 失效根因**:原版 hook 名单写死标准类名(`okhttp3.CertificatePinner.check`、`javax.net.ssl.*`),而卡集类前缀是 `dc.squareup.okhttp3.*`,名字对不上 → hook 没挂上。 - **JustTrustMe++ 能抓 API 但图片不显示**:它从 `SSLContext.init` 底层兜底放行了走网络栈的 API,但**没 hook WebView 的证书校验**(`onReceivedSslError`),而 uni-app 的 view 层是 WebView、图片由 WebView 拉取。 - **JustTrustMePro 完全成功**:它额外 hook 了 WebView 等安全连接方法,WebView 拉图片的握手也放行,图片正常显示。 > 经验:unpinning 能抓到什么流量,取决于它 hook 的覆盖面;「API 能抓、图片/WebView 内容不显示」通常就是 WebView 那条 SSL 校验路径没被 hook。 --- ## 3. Open-Auth-Sig 请求签名逆向 ### 目标 抓包发现部分接口带 `Authorization: Open-Auth-Sig Swap="..",Timestamp=..,Nonce=..,Signature=".."`,且每请求都变。要让爬虫长期运行,必须还原其生成算法(而非抠固定值——`Timestamp` 有时效)。 ### 定位与还原 - 技术栈是 uni-app,业务逻辑在明文 `app-service.js`,直接搜 `Open-Auth-Sig` / `Signature` / `Swap` 定位。 - 签名入口:`app-service.js` webpack 模块 `"665c"` 的 **`GetCrypto(path)`**(偏移约 1511900),入参为接口相对路径。 - 核心常量:256 项置换表(偏移约 1513290 的数组 `i`,0..255 全排列);MD5 库 `n("e133")`;`requestVersion = "/api/v4/"`。 - videoPlay 的 body 签名在偏移 1203166:`sign = md5(ts + "_" + goodCode + "_" + playCode + "_videoPlayKsj")`。 - 注入方式:**不是全局拦截器**,而是 http 封装里的 `PostWithCrypto` / `GetWithCrypto` 显式调用——所以只有部分接口带签名(解释了抓包「有的带有的不带」)。 ### 验证 用三条真实抓包样本正推 `Signature`,**逐字节匹配**;videoPlay 的 `sign` 用 `goodCode=MC5388325` 正推命中 `aba9d5be1f290cec5029e12c3d747d9f`。算法确认无误,已落地为 `kj_auth.py`。 --- ## 4. 算法要点 ### 4.1 Open-Auth-Sig 请求头(`kj_auth.gen_authorization(path)`) 输入:接口相对路径 `path`(不含域名 / `/api/v4/` 前缀 / query,如 `goodlist/forsale/main`)。 1. `noce`(Nonce)= 随机整数 `[1, 500]` 2. `swap` = 从 0..255 取 **8 个不重复字节** → 转 16 位小写 hex(**每请求随机**) 3. `ts`(Timestamp)= 当前 Unix 秒 4. `full_path = ("/api/v4/" + path)`,去掉 `/dataApi` 子串,去掉 `?query` 5. 拼接串 `l = f"{swap}_{ts}_{noce}_{full_path}"`(顺序固定:swap _ ts _ noce _ path) 6. `f = MD5(l)` → 32 位小写 hex 7. **Signature 编码**: - `r = f.encode('ascii').hex()`(把 32 字符 md5-hex 当 ASCII 再转 hex → 64 字符) - 复制 256 项置换表 `a = list(I_TABLE)` - 按 swap 的 8 个字节做 4 对两两交换:`for s in range(0,8,2): a[c[s]], a[c[s+1]] = a[c[s+1]], a[c[s]]` - 对 `r` 每个字符 `ch`:取 `a[ord(ch)]` 转 2 位大写 hex,拼接 → 128 位 Signature 8. 组装:`Open-Auth-Sig Swap="{swap}",Timestamp={ts},Nonce={noce},Signature="{sig}"` > **置换表 `I_TABLE`**:256 项(0..255 全排列),来自 `app-service.js` 偏移约 1513290,完整值见 `kj_auth.py`。 ### 4.2 videoPlay body 签名(`kj_auth.video_sign(ts, good_code, play_code)`) ``` sign = MD5(f"{ts}_{good_code}_{play_code}_videoPlayKsj") # 固定盐 videoPlayKsj ``` POST `good/videoPlay/{goodCode}`,body = `{"playCode":.., "sign":.., "ts":..}`,**不需要 Authorization**,响应的 `media_url` 即回放视频地址。 ### 4.3 playCode 来源 `playCode` 在 **`detail.good.broadcast.playCode`**:拼团完成 / 回放就绪时非空,否则为空字符串。 完整取视频链路:`detail → broadcast.playCode → videoPlay → media_url`。 ### 4.4 为什么 Signature 每次都变、字节值集中 - 编码的下标是 md5-hex 字符的 ASCII 码(仅 `0-9`、`a-f` 共十来种取值),查表后输出字节自然集中在约 10 种值,看起来像「伪 hex」。 - `swap` 每请求随机 → 置换表被洗牌 → **同一 MD5 每次输出的 Signature 都不同**,这是它的抗重放 / 防特征提取设计。 ### 4.5 关键结论 签名 = **标准 MD5 + 256 项置换表 + swap 洗牌**,拼接串只有 `swap/ts/noce/path`,**无任何密钥 / appSecret / 登录 token**。因此爬虫**无需登录、无需维护 token、永不过期**,天然适合长期无人值守运行(比需维护 token 的项目更省心)。 --- ## 5. 接口清单与爬虫实现 ### 接口清单 | 接口(`/api/v4/` 之后) | 方法 | 域名 | 签名 | 作用 | |---|---|---|---|---| | `goodlist/forsale/main` | GET | server | Open-Auth-Sig | 全局在售商品主列表(发现商户) | | `merchant/1/goodlist/{商户码}?tp=2` | GET | page | 免签名 | 某商户已售/已完成商品列表 | | `good/{code}/1/detail` | GET | page | Open-Auth-Sig | 商品详情(时间/规格/商户 publisher/broadcast) | | `good/{code}/result/merge` | GET | page | Open-Auth-Sig | 玩家/中奖名单(仅拼团完成的商品有) | | `good/videoPlay/{code}` | POST | server | body sign | 拆卡回放视频(返回 media_url) | ### 代码文件(两个脚本 + 一个签名模块) - **`kj_auth.py`**:签名模块。`gen_authorization(path)` 生成请求头、`video_sign(...)` 生成 videoPlay 签名、`gen_signature/_md5_hex` 为底层,含 256 项置换表。三处均以真实样本验证通过。 - **`kj_daily_spider.py`**:**日常增量脚本**(`schedule` 每天 00:01 定时)+ 全部公共函数(请求/签名/翻页/解析/`get_video`/`refill_videos`)。三级管道 = **在售主列表发现商户 → 遍历各商户已售列表(+detail 补充) → 玩家名单**,`player_state` 断点标记(0 未抓 / 1 已抓 / 2 无数据 / 3 异常),loguru 日志 + tenacity 重试。 - **增量早停**:`get_sold_list(incremental=True)` 采某商户前,先用 `get_shop_stop_pid` 取该商户 `start_at` 最新那条的 `pid`(`SELECT pid ... WHERE shop_id=%s ORDER BY start_at DESC LIMIT 1`,依赖 `(shop_id, start_at)` 复合索引,单值查询);`merchant?tp=2` 按 start_at 倒序(新在前),翻页用 `goodCode` 匹配到这个 `pid` 即 `break`——它之后都是已采过的,不必再翻、也不发 detail。首次采(无记录)则全量。 - **商户注销跳过**:`merchant?tp=2` 返回 `code=1`/`msg='无效商家'` → 标记 `is_deleted=1`;`kj_main` 查商户带 `WHERE is_deleted=0` 自动跳过。商户重新在售时 `save_shops` 的 `ON DUPLICATE KEY UPDATE ... is_deleted=0` 会把它「复活」。 - **视频补采**:`refill_videos` 针对拼团完成(`player_state=1`)但 `video_url` 仍空的商品,重取 `detail.broadcast.playCode` → videoPlay,覆盖首次采集时回放未就绪(正在/即将拆卡,playCode 为空)的情况;下一轮自愈直到拿到地址。 - 开关:`FETCH_DETAIL`(是否每商品补 detail)、`USE_PROXY`(遇 IP 风控再开)。 - **`kj_history_spider.py`**:**一次性历史全量脚本**(跑一次即止,无 schedule)。`from kj_daily_spider import ...` 复用全部公共函数,只写 `history_main` 调度:商户发现 → `get_sold_list(incremental=False)` **全量深翻所有页(不早停)** → 玩家全采 → 视频补采。日常增量与历史全量共用同一套函数,靠 `incremental` 开关区分,不重复维护。 ### 常用字段(实测) - forsale goodList 项:`merchantAlias`(商户码) / `merchantName` / `goodCode` / `title` / `pic` / `price` / `totalNum` / `currentNum` / `startAt` / `overAt` / `state` - detail.good:`startAt` / `overAt` / `state` / `spec{name,content}` / `publisher{alias,name,deal(成交),fans,level}` / `broadcast{playCode,name,state,roomId}` - result/merge list 项:`userId`(匿名为 0) / `userName`(匿名为空) / `total`(份数) / `anonymous` / `anonymousCode` --- ## 6. 踩坑记录 - **shade / 重打包包名是老 unpinning 模块的盲区**:uni-app 把 `okhttp3` 改成 `dc.squareup.okhttp3`,原版 JustTrustMe、`frida-multiple-unpinning`、objection 默认脚本按标准类名匹配一律打不中;换 JustTrustMePro / 手动补 hook。 - **「API 能抓,图片不显示」= WebView 未被 hook**:uni-app 图片走 WebView,需 unpinning 模块覆盖 `onReceivedSslError`(JustTrustMePro 覆盖了)。 - **Authorization 是每请求签名,不是 token**:抓包抠的固定值只能用几分钟(Timestamp 时效);长期运行必须还原算法实时生成。 - **签名拼接串里的 path 不含 query、需带 `/api/v4/` 前缀**:`gen_authorization` 内部已处理(补前缀 + 裁 query)。 - **商品标识是字符串 goodCode(前缀 MC/ZC 等多样),不是数字**:`pid` 列已由 int 改为 `varchar`(product、player 两张表都改)。 - **playCode 为空 = 回放未就绪**:`broadcast.state=1`「即将拆卡」时 `playCode` 为空,`get_video` 已对空值返回 None。 - **反调试提醒**:`libsecuritydevice.so`(阿里 dtf)疑似带反 Frida 检测(静态未坐实)。若后续上 Frida,attach 闪退用 `magisk-frida` / 改名 gadget 隐身。 --- ## 7. 举一反三 - **uni-app / DCloud 应用逆向套路**:技术栈判定看 `DCloudApplication` + `assets/apps/*/www/app-service.js`;抓包 unpinning 用 JustTrustMePro;业务签名/加密/token 基本都在明文 `app-service.js`,搜关键字符串(签名头名、`sign`、`Authorization`)定位 webpack 模块即可。 - **「自定义字母表编码的 hash」识别法**:签名是定长 hex、但字节值只在少数几种里循环 → 多半是「标准 hash(MD5/SHA) + 查表/替换编码」。先反解字节还原出合法 hash hex,再回源码找表与拼接串。 - **判断签名能否纯语言复现**:拼接串里若无隐藏密钥 / appSecret(本例只有 swap/ts/noce/path),即可纯 Python 复现,无需 execjs / JS 引擎;反之考虑抠 JS 用 PyMiniRacer/execjs。 - **请求签名类接口做爬虫**:优先判断有无时效字段(Timestamp/nonce)与密钥;无密钥的实时签名最省心(免登录、免 token、永不过期)。 - **不同技术栈 unpinning 对应**:原生 OkHttp → objection / frida-multiple-unpinning(注意 shade);Flutter → reFlutter / hook `ssl_verify_peer_cert`;Native pinning → so 里 hook 校验函数;加固 → 先脱壳。