charley cc87f0af81 chore(scheduler): 注释调度任务中的立即运行调用 hai 1 semana
..
README.md 554a2a7f2d feat(kaji_spider): 添加卡集每日采集爬虫及签名模块 hai 1 mes
YamlLoader.py 554a2a7f2d feat(kaji_spider): 添加卡集每日采集爬虫及签名模块 hai 1 mes
application.yml 554a2a7f2d feat(kaji_spider): 添加卡集每日采集爬虫及签名模块 hai 1 mes
kj_auth.py 554a2a7f2d feat(kaji_spider): 添加卡集每日采集爬虫及签名模块 hai 1 mes
kj_daily_spider.py cc87f0af81 chore(scheduler): 注释调度任务中的立即运行调用 hai 1 semana
kj_history_spider.py 554a2a7f2d feat(kaji_spider): 添加卡集每日采集爬虫及签名模块 hai 1 mes
mysql_pool.py 554a2a7f2d feat(kaji_spider): 添加卡集每日采集爬虫及签名模块 hai 1 mes
requirements.txt 554a2a7f2d feat(kaji_spider): 添加卡集每日采集爬虫及签名模块 hai 1 mes

README.md

卡集 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 自带的分包/加密机制,不是第三方壳。
    • 存在局部防护 SDKlibzxprotect.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 + 手机代理)
  • 手机环境:已 rootMagisk 管理,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.checkjavax.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 的 signgoodCode=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 来源

playCodedetail.good.broadcast.playCode:拼团完成 / 回放就绪时非空,否则为空字符串。 完整取视频链路:detail → broadcast.playCode → videoPlay → media_url

4.4 为什么 Signature 每次都变、字节值集中

  • 编码的下标是 md5-hex 字符的 ASCII 码(仅 0-9a-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 最新那条的 pidSELECT pid ... WHERE shop_id=%s ORDER BY start_at DESC LIMIT 1,依赖 (shop_id, start_at) 复合索引,单值查询);merchant?tp=2 按 start_at 倒序(新在前),翻页用 goodCode 匹配到这个 pidbreak——它之后都是已采过的,不必再翻、也不发 detail。首次采(无记录)则全量。
    • 商户注销跳过merchant?tp=2 返回 code=1/msg='无效商家' → 标记 is_deleted=1kj_main 查商户带 WHERE is_deleted=0 自动跳过。商户重新在售时 save_shopsON 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,搜关键字符串(签名头名、signAuthorization)定位 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 校验函数;加固 → 先脱壳。