前言

仅供学习交流

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/年
### ChatGPT Go($8/月)
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 全部填满,0xC3x86/x86_64 上是 RET,等价于函数一进来立刻返回。这意味着这个 so 里的检测逻辑会整体失效。
  • 后面的 Process.setExceptionHandler(...) 又继续吞掉 access-violationillegal-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_LISTOFFER_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.plusofferToken,就用正则把 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 中点击订阅按钮。");
});
Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐