ChatGPT Plus 0元订阅 漏洞分析
ChatGPT Android 端使用RevenueCat管理订阅,通过完成支付。核心思路:用 Frida 在运行时将 Google Play 的offerToken(优惠凭证)替换为目标优惠档位的 token,Google Play 会以该优惠价格处理订阅,完成后 RevenueCat 自动同步激活 Plus 会员。整个过程不会产生实际费用(免费试用期内)。关键障碍:libpairipcore.
前言
仅供学习交流
OpenAI过分信任客户端环境,导致攻击者可以通过简单的替换0元订阅Plus,本文将介绍此次漏洞的原理,并解析攻击此漏洞的Frida注入脚本
此漏洞已经在2026.4.21被OpenAI修复
参考链接
- https://community.openai.com/t/google-uk-plus-pro-plan-is-being-widely-abused/1379242
- https://community.openai.com/t/security-report-apple-pay-receipt-validation-does-not-bind-to-purchaser-apple-id-potential-subscription-bypass/1379167
- https://www.azx.us/posts/700
- https://blog.caowo.de/posts/gpt-plus-exploit-revenuecat-vulnerability/
正文
一、原理简介
ChatGPT Android 端使用 RevenueCat 管理订阅,通过 Google Play Billing 完成支付。
核心思路:用 Frida 在运行时将 Google Play 的 offerToken(优惠凭证)替换为目标优惠档位的 token,Google Play 会以该优惠价格处理订阅,完成后 RevenueCat 自动同步激活 Plus 会员。
整个过程不会产生实际费用(免费试用期内)。
关键障碍:libpairipcore.so
ChatGPT 集成了 Appdome 反篡改 SDK,核心模块 libpairipcore.so会在运行时检测注入行为并强制退出。需要在注入后立即将其所有可执行段用 0xC3(RET 指令)填充,使所有检测函数直接返回。
var mod = Process.findModuleByName("libpairipcore.so");
Process.enumerateRanges("r-x").forEach(function(r) {
if (r.base.compare(mod.base) < 0 ||
r.base.compare(mod.base.add(mod.size)) >= 0) return;
Memory.protect(r.base, r.size, "rwx");
var buf = new Uint8Array(r.size);
buf.fill(0xC3); // RET — 所有函数立即返回,检测逻辑失效
Memory.writeByteArray(r.base, buf.buffer);
});
同时还需绕过 Java 层的 VMRunner 以及系统 SSL 证书校验,确保脚本可以正常 hook 网络请求。
二、订阅套餐 offerId
Google Play 后台为每个优惠档位分配了唯一的 offerId,脚本通过替换对应的 offerToken 来激活指定优惠。
ChatGPT Plus($19.99/月)
| offerId | 优惠内容 |
|---|---|
2ispxs5mtgz35 |
免费 1 年 → 后 $19.99/月 |
2wqkodfx51z2x |
免费 6 个月 → 后 $19.99/月 |
plus-1-month-free-trial |
免费 1 个月 → 后 $19.99/月 |
3-day-free-trial |
免费 3 天 → 后 $19.99/月 |
1-month-10-dollars |
首月 $10 → 后 $19.99/月 |
| (无 offerId) | 直接 $19.99/月(无优惠) |
| (年付) | $200/年 |
| offerId | 优惠内容 |
|---|---|
1-month-free-trial |
免费 1 个月 → 后 $8/月 |
16ei8n9du5wh6 |
免费 3 个月 → 后 $8/月 |
1r5t7n9qz0y1u |
免费 1 年 → 后 $8/月 |
| (无 offerId) | 直接 $8/月 |
本项目提供 1 个月免费试用的测试脚本(
hook_1m.js)。 6 个月、12 个月等其他档位的 offerToken 获取与适配请自行研究。
三、环境准备
PC 端:
pip install frida frida-tools
模拟器要求:
- Android x86_64(推荐 AVD Android 12)
- 已 root
- 已安装 ChatGPT APK
- 已登录 Google Play 账号(建议使用有试用资格的新账号)
启动 frida-server(每次重启模拟器后执行一次):
adb push frida-server /data/local/tmp/
adb shell chmod 755 /data/local/tmp/frida-server
adb shell su -c '/data/local/tmp/frida-server &'
四、使用步骤
deprecated
第一步:注入脚本
先在模拟器里打开 ChatGPT,等主界面加载完毕后执行:
frida -U -n ChatGPT -l hook_1m.js
看到 就绪。请在 ChatGPT 中点击订阅按钮 后继续。
第二步:完成购买
在 ChatGPT 里点击升级订阅 → 在 Google Play 弹窗里点确认。
购买完成后,当前账号即可获得 1 个月 Plus 会员。
脚本解析
1. 初始化
- 开启严格模式
OFFER_TOKEN是硬编码进去的目标订阅token;
"use strict";
var OFFER_TOKEN = "ATCTYGUcx/4WvKJhyWqP44PKT+OR3BE5bx9loRE13nkb9gEac4+LBPrHs3dxGcmDZcLcDeYwl5pqFF1Mx7eVuJScrMow2C0BVyn/2XKDBMiiZW/rGQ4wp6ZhAK5eRLlv2dpoCIbIwqDtfyZoK6E=";
var _keepAlive = setInterval(function() {}, 1000);
function log(msg) { console.log("[1M] " + msg); }
2. native 层反检测绕过
- 先找
libpairipcore.so - 然后遍历这个模块的可执行内存段,把整段内存改成
rwx - 再用
0xC3全部填满,0xC3在x86/x86_64上是RET,等价于函数一进来立刻返回。这意味着这个 so 里的检测逻辑会整体失效。 - 后面的
Process.setExceptionHandler(...)又继续吞掉access-violation和illegal-instruction,尽量让 App 不因为篡改后崩掉(处理修改后导致的异常,保证后续 hook 能继续跑)
// 抹掉 libpairipcore.so 反检测
(function() {
var mod = Process.findModuleByName("libpairipcore.so");
if (!mod) return;
Process.enumerateRanges("r-x").forEach(function(r) {
if (r.base.compare(mod.base) < 0 ||
r.base.compare(mod.base.add(mod.size)) >= 0) return;
try {
Memory.protect(r.base, r.size, "rwx");
var buf = new Uint8Array(r.size);
buf.fill(0xC3);
Memory.writeByteArray(r.base, buf.buffer);
} catch(_) {}
});
log("libpairipcore.so nuked");
Process.setExceptionHandler(function(ex) {
if (ex.type === "access-violation" || ex.type === "illegal-instruction") {
try { ex.context.rip = ex.context.rip.add(1); } catch(_) {}
return true;
}
return false;
});
})();
3. Java 层 hook
- hook 了
com.pairip.VMRunner,直接返回null,废掉native检测 - hook 了
com.pairip.VMRunner$1,直接空跑,废掉Java层检测
这里的意图是继续绕过上层的虚拟机/壳相关检测逻辑。
// VMRunner 反检测
try {
Java.use("com.pairip.VMRunner").invoke.implementation = function() { return null; };
Java.use("com.pairip.VMRunner$1").run.implementation = function() {};
} catch(_) {}
4. SSL / Pinning 绕过
- 改写了
TrustManagerImpl.checkTrustedRecursive(...)、NetworkSecurityTrustManager.checkPins(...)和X509TrustManagerExtensions.checkServerTrusted(...) - 前两个跟证书链信任和
pinning有关,后一个跟服务端证书校验扩展有关。 - 都改成“直接放行”或者“返回空列表”,让它在抓包、改包、拦截订阅相关网络流程时,不会因为系统或应用的
HTTPS校验失败而被阻止。
// SSL 解除
try {
var TM = Java.use("com.android.org.conscrypt.TrustManagerImpl");
TM.checkTrustedRecursive.overload(
"[Ljava.security.cert.X509Certificate;", "java.net.Socket",
"boolean", "boolean", "boolean",
"java.util.Collection", "java.util.Collection"
).implementation = function() { return Java.use("java.util.ArrayList").$new(); };
} catch(_) {}
try {
Java.use("android.security.net.config.NetworkSecurityTrustManager")
.checkPins.implementation = function() {};
} catch(_) {}
try {
Java.use("android.net.http.X509TrustManagerExtensions")
.checkServerTrusted.overload(
"[Ljava.security.cert.X509Certificate;",
"java.lang.String", "java.lang.String"
).implementation = function() { return Java.use("java.util.ArrayList").$new(); };
} catch(_) {}
5. 核心业务篡改
分了两层注入 offerToken
Bundle 层
- hook 了
Bundle.putStringArrayList(String, ArrayList) - 只要 key 是
SKU_OFFER_ID_TOKEN_LIST或OFFER_ID_TOKEN_LIST,就把数组里的每个元素都替换成前面硬编码的OFFER_TOKEN。
Google Play Billing 的参数包里会用这两个 key 之一携带 offer token 列表,于是直接在参数落盘/传递前改值。
// 注入 offerToken(Bundle 层)
var JStr = Java.use("java.lang.String");
try {
var Bundle = Java.use("android.os.Bundle");
Bundle.putStringArrayList
.overload("java.lang.String", "java.util.ArrayList")
.implementation = function(key, value) {
if ((key === "SKU_OFFER_ID_TOKEN_LIST" ||
key === "OFFER_ID_TOKEN_LIST") && value) {
for (var i = 0; i < value.size(); i++)
value.set(i, JStr.$new(OFFER_TOKEN));
log("offerToken 已注入");
}
return this.putStringArrayList(key, value);
};
} catch(_) {}
JSONObject 层
- hook 了
JSONObject(String)构造函数 - 只要字符串里同时包含
oai.chatgpt.plus和offerToken,就用正则把 JSON 里的"offerToken":"..."替换成硬编码 token。
这一层是在 JSON 序列化层的拦截,与 Android 的 Bundle 容器层的修改目的一致,主要是防止订阅参数在不同路径上传输时漏掉
// 注入 offerToken(JSONObject 层)
try {
var JSONObj = Java.use("org.json.JSONObject");
JSONObj.$init.overload("java.lang.String").implementation = function(s) {
if (s && s.indexOf("oai.chatgpt.plus") !== -1 &&
s.indexOf("offerToken") !== -1) {
var patched = s.replace(
/"offerToken"\s*:\s*"[^"]+"/g,
'"offerToken":"' + OFFER_TOKEN + '"'
);
return this.$init(patched);
}
return this.$init(s);
};
} catch(_) {}
先禁用反篡改 → 再关闭证书/Pinning 防护 → 然后在订阅请求构造过程中,把目标优惠的 offerToken 强行塞进去
这样,客户端后续发起的购买流程里,服务端或 Play 侧看到的就不是原始 offer,而是被替换过的那个档位。
结论
客户端过度信任本地订阅参数与本地执行环境。攻击者通过 Frida 在运行时绕过反篡改、关闭证书校验,再把订阅流程里的 offerToken 强行替换成目标试用档位,即可0元订阅PLus。
客户端侧校验和本地流程控制都不能当作安全边界,必须由商店服务端和业务后端最终裁决,不能依赖客户端上传什么就信什么
同时,反注入、反调试、证书 pinning 只能增加攻击成本,不能代替服务端鉴权与账务一致性校验。
一旦后端联动校验不严,客户端被改包就会直接影响权益发放。
这种业务漏洞能被如此广泛的拿来获利,我甚至怀疑OpenAI故意刷订阅量
附件
hook_1m.js内容如下:
/**
* hook_1m.js — ChatGPT Plus 1 个月免费试用
*
* 用法:
* 1. 模拟器打开 ChatGPT
* 2. frida -U -n ChatGPT -l hook_1m.js
* 3. 在 ChatGPT 里点击订阅 → Google Play 弹窗确认即可
*/
"use strict";
var OFFER_TOKEN = "ATCTYGUcx/4WvKJhyWqP44PKT+OR3BE5bx9loRE13nkb9gEac4+LBPrHs3dxGcmDZcLcDeYwl5pqFF1Mx7eVuJScrMow2C0BVyn/2XKDBMiiZW/rGQ4wp6ZhAK5eRLlv2dpoCIbIwqDtfyZoK6E=";
var _keepAlive = setInterval(function() {}, 1000);
function log(msg) { console.log("[1M] " + msg); }
// 抹掉 libpairipcore.so 反检测
(function() {
var mod = Process.findModuleByName("libpairipcore.so");
if (!mod) return;
Process.enumerateRanges("r-x").forEach(function(r) {
if (r.base.compare(mod.base) < 0 ||
r.base.compare(mod.base.add(mod.size)) >= 0) return;
try {
Memory.protect(r.base, r.size, "rwx");
var buf = new Uint8Array(r.size);
buf.fill(0xC3);
Memory.writeByteArray(r.base, buf.buffer);
} catch(_) {}
});
log("libpairipcore.so nuked");
Process.setExceptionHandler(function(ex) {
if (ex.type === "access-violation" || ex.type === "illegal-instruction") {
try { ex.context.rip = ex.context.rip.add(1); } catch(_) {}
return true;
}
return false;
});
})();
Java.perform(function() {
log("启动 — 注入 plus-1-month-free-trial");
// VMRunner 反检测
try {
Java.use("com.pairip.VMRunner").invoke.implementation = function() { return null; };
Java.use("com.pairip.VMRunner$1").run.implementation = function() {};
} catch(_) {}
// SSL 解除
try {
var TM = Java.use("com.android.org.conscrypt.TrustManagerImpl");
TM.checkTrustedRecursive.overload(
"[Ljava.security.cert.X509Certificate;", "java.net.Socket",
"boolean", "boolean", "boolean",
"java.util.Collection", "java.util.Collection"
).implementation = function() { return Java.use("java.util.ArrayList").$new(); };
} catch(_) {}
try {
Java.use("android.security.net.config.NetworkSecurityTrustManager")
.checkPins.implementation = function() {};
} catch(_) {}
try {
Java.use("android.net.http.X509TrustManagerExtensions")
.checkServerTrusted.overload(
"[Ljava.security.cert.X509Certificate;",
"java.lang.String", "java.lang.String"
).implementation = function() { return Java.use("java.util.ArrayList").$new(); };
} catch(_) {}
// 注入 offerToken(Bundle 层)
var JStr = Java.use("java.lang.String");
try {
var Bundle = Java.use("android.os.Bundle");
Bundle.putStringArrayList
.overload("java.lang.String", "java.util.ArrayList")
.implementation = function(key, value) {
if ((key === "SKU_OFFER_ID_TOKEN_LIST" ||
key === "OFFER_ID_TOKEN_LIST") && value) {
for (var i = 0; i < value.size(); i++)
value.set(i, JStr.$new(OFFER_TOKEN));
log("offerToken 已注入");
}
return this.putStringArrayList(key, value);
};
} catch(_) {}
// 注入 offerToken(JSONObject 层)
try {
var JSONObj = Java.use("org.json.JSONObject");
JSONObj.$init.overload("java.lang.String").implementation = function(s) {
if (s && s.indexOf("oai.chatgpt.plus") !== -1 &&
s.indexOf("offerToken") !== -1) {
var patched = s.replace(
/"offerToken"\s*:\s*"[^"]+"/g,
'"offerToken":"' + OFFER_TOKEN + '"'
);
return this.$init(patched);
}
return this.$init(s);
};
} catch(_) {}
log("就绪。请在 ChatGPT 中点击订阅按钮。");
});
更多推荐



所有评论(0)