ChatGPT Plus 0 PHP:跨区定价混淆攻击完整拆解

前言

作者:Smile
栏目:RelayCove 技术拆解
这篇按最近流传的 0 PHP 事件做一次完整复盘,重点看跨区定价、Checkout 链路和支付风控边界。

这两天群里炸了。

有人甩出一张截图:Stripe 结账页面,产品写着 “ChatGPT Plus”,金额一栏赫然显示 ₱0.00 PHP。菲律宾比索,零元。

群聊截图:0 PHP 事件讨论

评论区画风可想而知——“假的吧”“PS 的吧”“你怎么不说 Plus 倒找你钱”。

然后陆陆续续有人贴出了完整流程,甚至还配了视频录屏。操作步骤不复杂,总共四步,不需要 Root,不需要 Frida,不需要改一行代码。唯一的技术含量在于——你得知道用什么顺序把哪些区域串起来。

这就有意思了。之前我们拆过 prorationMode hook(改一个 putInt 白嫖 Claude Max)、拆过 焚决脚本(劫持 checkout_capabilities 走 SEPA 假账号)、拆过 RevenueCat 凭证转移(匿名购买 + restore 归属偷渡)。那些多少都是”改代码”的路子——Hook 内存、注入响应、篡改参数。

但这次不一样。这次什么都没改。没有脚本,没有 Hook,没有中间人。攻击者只是以一种特定的顺序访问了几个网页,然后 Stripe 自己就吐出了 0 元账单。

今天把它完整拆开。

一、攻击全景:四步,从 JP 到 PH,从 $20 到 ₱0

先把流传的步骤还原一下:

Step 1:日本节点注册 OpenAI 账号

挂 JP 代理,开一个干净的浏览器 Profile(指纹浏览器或者 Chrome 新 Profile),去 OpenAI 官网正常注册,邮箱验证,登录确认。

此时你的账号状态:

1
2
3
account.locale = "ja-JP"
account.billing_country = "JP"
account.currency = "JPY"

OpenAI 在注册时根据你的 IP 地理位置初始化账号的计费区域。这一步的目的不是”要日本”本身,而是要一个非美区的初始定价锚点。

Step 2:切美国节点,提取 Access Token

保持同一个浏览器 Profile,VPN 切到 US 节点,重新登录刚才注册的账号。然后新标签页打开:

1
https://chatgpt.com/api/auth/session

浏览器返回一个 JSON,里面有 accessToken 字段——一个标准 JWT。复制保存。

此时你的 session 状态出现了第一层矛盾:

1
2
account.billing_country = "JP"  ← 注册时写入,持久化在 OpenAI 数据库
session.ip_country = "US" ← 当前 IP 推断,存在 session/edge 层

Step 3:第三方工具生成 Checkout 链接

打开某个第三方”CDK 提炼”工具站点(这类工具本质上就是一个 AT → Stripe Checkout Session 的代理),把 Access Token 粘进去,点生成。

工具返回一个链接,格式:

1
https://chatgpt.com/checkout/openai_llc/oaics_xxxxxxxxxxxxxxxxxxxxx

oaics_ 是 OpenAI 的 Stripe Checkout Session ID 前缀。这个 session 是 OpenAI 后端基于你的 AT 创建的,包含了产品、价格、货币等信息。

关键问题来了:这个 Checkout Session 的 currency 和 amount 是怎么决定的?

Step 4:打开链接,惊喜时刻

在美国节点下打开这个 oaics 链接。Stripe 结账页面加载出来,你看到:

ChatGPT Plus
₱0.00 PHP / month
菲律宾比索。零元。

填入指定 BIN 的卡片信息(PH 段卡号),美国账单地址,提交。

Stripe 收了一笔 0 元交易,OpenAI 后端确认 checkout session 成功,权益生效。回到 ChatGPT 首页,左上角已经是 Plus 了。

二、为什么是 0 PHP?——三种假说

先澄清一个重要事实:OpenAI 在菲律宾是有明确定价的,ChatGPT Plus 的 PH 区定价大约是 ₱990 PHP/月。所以这不是简单的”价格表空洞导致 fallback 到 0”——如果定价系统正常查 PH 价格表,应该返回 ₱990,不是 ₱0。

那 0 是怎么来的?没有对 Checkout Session 创建过程的完整抓包,我们只能做推断。以下三种假说按可能性排序。

假说一:第三方工具在创建 Checkout Session 时注入了零元参数(最可能)

OpenAI 的 /backend-api/payments/checkout 接口接受多个参数来创建 Stripe Checkout Session。如果这个接口接受客户端传入的 discount、coupon、promotion_code 或类似参数,且后端没做资格校验——工具直接传了一个 100% 折扣的 coupon code 或者 amount_override: 0。

这和之前 RevenueCat 文章里分析过的 eligible_promo_campaigns 注入如出一辙:客户端声称”我有优惠资格”,后端不校验就信了。

工具的真实请求可能长这样:

1
2
3
4
5
6
7
8
9
body: JSON.stringify({
plan: "plus",
promotion_code: "某个内部免费试用码",
// 或者
billing_country: "PH",
currency: "php",
// 或者更直接的
price_override: 0
})

JP 注册 + US 登录制造的区域歧义,可能不是直接触发 0 元的原因,而是绕过风控检查的前置条件——当账号区域和 IP 区域不一致时,后端对 checkout 参数的校验逻辑可能走入了一个宽松的分支。

假说二:跨区信号冲突导致定价路由进入异常分支

当 OpenAI 创建 Stripe Checkout Session 时,面前有至少四个区域信号:

信号来源 值 决定时机
账号注册地 JP 注册时固化
当前 session IP US 每次请求实时推断
Stripe 卡 BIN 发行国 PH 填卡时由 Stripe 推断
浏览器 Accept-Language 取决于浏览器设置 每次请求携带
正常场景下这四个信号一致——美国人在美国用美国卡买东西,直接查 US 价格表。

但当 JP ≠ US ≠ PH 三个信号同时存在时,定价系统需要做一个选择。如果选择逻辑是 if (account.country != ip.country) { use card.country } 这种优先级策略,而 PH 价格表虽然存在但在某个子路径(比如”非本地注册用户+跨区IP”的特殊定价分支)中没有配置——就可能在这个特殊分支里 fallback 到 0。

换句话说:PH 的主价格表有值(₱990),但某个异常处理路径的价格映射没覆盖到 PH,攻击者通过区域信号碎片化精确命中了这条路径。

假说三:Stripe Checkout Session 的 line_items 被工具篡改

Stripe checkout.sessions.create 的 line_items 参数包含 price 或 price_data。如果 OpenAI 的接口允许客户端指定 price_data.unit_amount(哪怕是间接的),工具就能在创建 session 时直接把金额写成 0。

1
2
3
4
5
6
7
8
9
line_items: [{
price_data: {
currency: "php",
unit_amount: 0, // 直接指定 0
product: "prod_xxx",
recurring: { interval: "month" }
},
quantity: 1
}]

这种情况下 PH BIN 的作用不是”触发 PH 价格表”,而是让 currency 字段说得通——如果你传 currency: “php” 但卡是 US 卡,Stripe 可能会有额外的 currency mismatch 警告。PH 卡 + PHP 货币是自洽的。

为什么是 PH BIN?
无论哪种假说,流传的两个 BIN 都有其作用:

BIN 网络 发行国
523686 Mastercard 菲律宾
4513xx Visa 取决于具体发行行
PH 段卡填入 Stripe 表单时,Stripe 内部标记 payment_method.card.country = “PH”。这个信号在整个支付流中传播。PH BIN 的作用至少有两个:

让 PHP 货币的 checkout session 能正常完成——卡的发行国和结账货币匹配
可能绕过 OpenAI 的 card-country vs account-country 风控规则——PH 卡付 PHP 金额在 Stripe 层面是”正常交易”

三、JP 注册的作用——制造”区域身份分裂”

你可能会问:为什么不直接在美国注册、美国提 Token、用 PH 卡付?

答案是:直接美区注册的账号,定价系统的行为是确定性的——走 US 价格表,$20 USD,没有歧义可利用。

JP 注册的作用是制造一个区域身份不确定的账号。当账号的 billing_country 是 JP,session IP 是 US,这两个信号已经矛盾了。定价系统在处理这种矛盾时,可能会:

走入一个异常处理分支,该分支的参数校验比正常路径更宽松
允许第三方工具注入的额外参数(如 currency、discount)覆盖默认行为
使用一个与主价格表不同的备用定价逻辑,而这个逻辑有漏洞
用状态机的视角看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌──────────────┐
│ 注册时: JP │
│ locale=ja-JP │
└──────┬───────┘

┌──────▼───────┐
│ 登录时: US │
│ ip_country=US│
│ ≠ locale │
└──────┬───────┘

┌──────▼───────────┐
│ 创建 Checkout │
│ JP ≠ US → 歧义 │
│ → 进入异常分支 │
│ → 参数校验宽松 │
└──────┬───────────┘

┌──────▼───────────┐
│ 工具注入参数 │
│ + Card BIN: PH │
│ → amount = 0 PHP │
└──────────────────┘

三个不同的区域信号(JP / US / PH)制造了歧义,歧义打开了异常路径,异常路径上的校验缺失被利用。

这是一个经典的安全反模式:当多个权威源对同一个问题给出不同答案时,系统在歧义中放松了校验——不是直接返回 0,而是给了攻击者操纵结果的空间。

四、第三方”提炼工具”在干什么?

流程里的那个第三方网站(第三方 Checkout 工具站),名字叫”CDK 提炼”,听着玄乎,其实它干的事非常直白:

接收你的 Access Token
用这个 AT 调用 OpenAI 后端 API,创建一个 Stripe Checkout Session
把 Checkout Session 的 URL 返回给你
本质就是一个 AT → oaics_ 链接 的代理服务。

它的技术实现大概率是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 伪代码,非真实接口
const response = await fetch("https://chatgpt.com/backend-api/payment/checkout", {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
plan: "plus",
// 关键:这里可能注入了额外参数
// billing_country? currency? locale?
})
});

const { checkout_url } = await response.json();
// checkout_url = "https://chatgpt.com/checkout/openai_llc/oaics_..."

关键问题:工具只是”转发”了 AT,还是”动了手脚”?

考虑到 PH 区有明确的 Plus 定价(约 ₱990/月),而结果是 ₱0——工具大概率不只是简单转发。它在创建 Checkout Session 时注入了额外参数,利用跨区账号的异常处理路径绕过了服务端校验。

具体注入了什么参数?没有抓包数据我们无法确定。可能是 promotion_code、discount、currency + amount 覆盖、甚至是一个 OpenAI 内部的免费试用 API 路径。但有一点可以确定:如果工具只是忠实转发 AT,以 PH 区 ₱990 的定价,结果不可能是 ₱0。

工具是这个攻击链中最不透明的环节。你把 Access Token——等同于你的登录态——交给了一个第三方服务。它用你的身份做了什么请求、传了什么参数,你完全不知道。这也是为什么整个流程的”技术含量”看似很低——核心逻辑都藏在工具服务端里,用户只是按步骤操作。

五、BIN 选择——不是随便什么卡都行

流传的步骤里特别强调了两个 BIN:PH 段 Mastercard 和 Visa。这不是随便挑的。

BIN 在支付流中的角色

BIN(Bank Identification Number)是卡号的前 6-8 位,编码了发卡行、卡网络、卡类型和发行国等信息。Stripe 在收到卡号的前 6 位时就能确定:

PH_BIN → Mastercard / Philippines / Credit / 某发卡行
这个信息会被写入 payment_method.card.country,并在整个支付流中传播。

为什么必须是 PH BIN?

PH BIN 的作用不是”触发一个空的价格表”——PH 价格表是存在的(约 ₱990/月)。

更可能的解释是:

货币匹配:如果工具创建的 Checkout Session 指定了 currency: “php”,那么 PH 发行的卡才能让 Stripe 的 currency-card 一致性检查通过。US 卡 + PHP 货币会触发额外的风控审查。

风控绕过:OpenAI/Stripe 的风控系统可能对”card country = PH + checkout currency = PHP”这个组合判定为”正常的本地交易”,不会触发跨境支付的额外校验。而如果用 JP 卡或 US 卡来付一笔 0 PHP 的订单,风控更容易拦截。

AVS 绕过:PH 卡不走 Stripe 的 AVS 地址校验,所以填什么地址都行——这降低了被拒的概率。

这些 PH 段是流传版本里被反复提到的付款地区信号。不是所有 PH BIN 都行,因为某些 BIN 可能已被 OpenAI/Stripe 的风控规则标记或黑名单。

为什么要填美国账单地址?

这步看起来矛盾——卡是菲律宾的,地址填美国?

原因是 Stripe 的 AVS(Address Verification Service)只对美国和英国的卡做地址校验。PH 卡不走 AVS,所以你填什么地址都无所谓——但如果你填 PH 地址,可能触发 OpenAI 的额外风控逻辑。填 US 地址是为了”看起来正常”,降低被风控拦截的概率。

六、从防御视角看——OpenAI 需要修什么

不管具体是哪种假说成立,防御原则是通用的。

修复方案(从简单到全面)

Level 1:Checkout Session 金额校验

1
2
3
4
5
def create_checkout_session(user, plan):
price = get_price(plan, resolve_country(user))
if price["amount"] == 0 and plan != "free":
raise ValueError("Zero-amount checkout for paid plan")
# ...

付费产品金额为 0 时直接拒绝创建 session。这应该是支付系统的基本不变量。

Level 3:区域一致性校验

1
2
3
4
5
6
7
8
9
def resolve_country(user):
signals = {
"registration": user.billing_country, # JP
"session_ip": geoip(user.current_ip), # US
"card_bin": None, # 创建 session 时还不知道
}
# 如果注册地和 session IP 不一致,以注册地为准
# 不让 card BIN 覆盖定价区域
return signals["registration"]

定价的 country 由一个确定性函数决定,不受 card BIN 影响。Card BIN 的 country 只用于风控信号,不参与定价。

Level 4:Webhook 后置校验

1
2
3
4
5
6
def on_checkout_completed(event):
session = event.data.object
if session.amount_total == 0 and session.mode == "subscription":
# 0 元订阅?不对劲
stripe.Subscription.delete(session.subscription)
alert("Zero-amount subscription detected", session)

即使 Checkout Session 创建时金额为 0,在 checkout.session.completed webhook 里也要做最终校验。这是最后一道防线。

七、这个漏洞和之前的几个有什么关系?

放在一起看,这几个月被扒出来的 ChatGPT/Claude 支付漏洞其实形成了一个谱系:

漏洞 攻击层 改了什么 核心缺陷
prorationMode Hook 客户端内存 putInt 的一个参数 Google Play 信任客户端传入的升级策略
焚决 (cassia) HTTP 响应 checkout_flow 字段 前端根据 API 响应选择支付通道,后端不校验
RevenueCat 转移 聚合器 API is_restore + app_user_id 匿名购买 + restore 归属无限转移
API 响应注入 HTTP 响应 eligible_promo_campaigns 优惠码资格校验仅在前端
0 PHP 跨区 (本文) 定价系统 区域信号 + 工具注入 跨区歧义打开异常路径 + checkout 参数校验缺失
注意到了吗?攻击层在不断”上移”。

早期的漏洞需要 Root、需要 Frida、需要改代码——门槛不低。焚决降到了一个油猴脚本的水平。而 0 PHP 攻击连脚本都不需要——纯操作流程,任何人都能复现。

从攻击者的角度看,这是一个自然演进:当客户端层的防御越来越强(代码混淆、完整性校验、证书绑定),攻击者就会转向更高层——业务逻辑层、定价系统层、区域路由层。 这些层的防御往往更薄弱,因为它们不像”加密”“签名”那样有明确的安全框架,它们是业务系统的一部分,安全评审时容易被忽略。

八、更通用的攻击模式——“区域信号碎片化”

0 PHP 攻击其实揭示了一个通用的攻击模式,我把它叫做“区域信号碎片化攻击”(Region Signal Fragmentation)

攻击模型

1
2
3
4
5
6
7
8
9
victim_system.pricing = f(country)

country = resolve(
signal_1: registration_country, ← 攻击者控制(注册时选择 VPN 节点)
signal_2: session_ip_country, ← 攻击者控制(登录时选择 VPN 节点)
signal_3: card_bin_country, ← 攻击者控制(选择特定 BIN 的卡)
signal_4: browser_locale, ← 攻击者控制(浏览器设置)
signal_5: billing_address, ← 攻击者控制(表单填写)
)

所有五个信号都由攻击者完全控制。如果 resolve() 函数对歧义输入的处理不当——比如进入异常分支后放松了参数校验,或者 fallback 逻辑允许客户端覆盖定价——攻击者就能把定价推入非预期状态。

防御原则

原则一:定价权归一

1
2
3
pricing_country = user.billing_country  # 只有一个来源,注册时确定
# card BIN、IP、locale 都不参与定价决策
# 只作为风控辅助信号

原则二:Checkout 参数不可客户端覆盖

1
2
3
4
5
6
7
# 服务端决定 price、currency、amount
# 客户端/第三方传入的 discount、coupon、price_override 一律忽略
# 只接受服务端白名单内的 promotion_code,且校验账号资格
checkout_session = stripe.checkout.Session.create(
line_items=[server_determined_price], # 不接受客户端 price_data
discounts=[], # 不接受客户端 coupon
)

客户端能影响定价 = 攻击面。

原则三:零元红线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if checkout.amount == 0 and plan.type == "paid":
REJECT() # 无条件拒绝
```这应该是一个硬编码的不变量,不依赖任何业务逻辑。



## 附件说明
这次复盘里有一个浏览器扩展样本。公开页面放审计附件包,里面包含样本结构清单、SHA256 和说明文件,方便后续查重、留档和内部分析。

<div class="relay-downloads">
<a class="relay-download" href="/attachments/checkout-extractor-audit-pack.zip" download>下载审计附件包</a>
<a class="relay-download secondary" href="/attachments/checkout-extractor-ext.manifest.json" download>下载结构清单</a>
<a class="relay-download secondary" href="/attachments/checkout-extractor-ext.sha256.txt" download>下载 SHA256</a>
</div>

样本 SHA256:

```text
740550A46769BD63D5FCEB3EA72C6ACDA944C9D48B8C2550E61245D1CFD35DA8

九、写在最后

从 prorationMode 到焚决到 RevenueCat 到 0 PHP,每一个漏洞都指向同一个结构性问题:

当支付系统由多个独立组件(商店、聚合器、定价引擎、checkout 前端、webhook 后端)拼装而成时,组件之间的信任传递就是攻击面。

prorationMode 的信任传递是”Google Play 信任客户端传入的升级策略”。焚决的信任传递是”前端信任 API 返回的支付通道”。RevenueCat 的信任传递是”聚合器信任 SDK 传入的 app_user_id”。0 PHP 的信任传递是”定价系统信任 card BIN 推断的国家”。

没有一个单点漏洞,都是链路上的信任滑坡。

而防御的核心思路也始终一样:在链路的最末端(服务端 webhook、权益发放点)做最终校验,不信任链路上游传递的任何”结论”,只信任可验证的”事实”。

具体到 0 PHP 这个 case:Stripe checkout.session.completed 事件里的 amount_total 是事实,currency 是事实。如果一个 ChatGPT Plus 订阅的 amount_total 是 0——不管前面经历了多少层区域路由和定价查询——这就不对。拦住它。

这比修价格表更重要。因为价格表总有遗漏,但”付费产品不能 0 元”这条规则没有例外。