0 PHP 白嫖 ChatGPT Plus — 跨区定价混淆攻击的完整技术拆解

联系方式 & 交流群
- 微信: gzs-47
进微信群请联系博主,各位觉得文章对你有帮助的话可否打赏一些呀~
⚠️ 免责声明:本文仅供安全研究与技术讨论。文中不提供可直接复用的攻击工具或完整利用脚本。利用类似手段对正式服务进行未授权操作属违法行为,后果自负。本文目的是帮助支付系统开发者理解跨区定价攻击面并加固防御。
前言
这两天群里炸了。
有人甩出一张截图:Stripe 结账页面,产品写着 “ChatGPT Plus”,金额一栏赫然显示 ₱0.00 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 官网正常注册,邮箱验证,登录确认。
此时你的账号状态:
account.locale = "ja-JP"account.billing_country = "JP"account.currency = "JPY"OpenAI 在注册时根据你的 IP 地理位置初始化账号的计费区域。这一步的目的不是”要日本”本身,而是要一个非美区的初始定价锚点。
Step 2:切美国节点,提取 Access Token
保持同一个浏览器 Profile,VPN 切到 US 节点,重新登录刚才注册的账号。然后新标签页打开:
https://chatgpt.com/api/auth/session浏览器返回一个 JSON,里面有 accessToken 字段——一个标准 JWT。复制保存。
此时你的 session 状态出现了第一层矛盾:
account.billing_country = "JP" ← 注册时写入,持久化在 OpenAI 数据库session.ip_country = "US" ← 当前 IP 推断,存在 session/edge 层Step 3:第三方工具生成 Checkout 链接
打开某个第三方”CDK 提炼”工具站点(这类工具本质上就是一个 AT → Stripe Checkout Session 的代理),把 Access Token 粘进去,点生成。
工具返回一个链接,格式:
https://chatgpt.com/checkout/openai_llc/oaics_xxxxxxxxxxxxxxxxxxxxxoaics_ 是 OpenAI 的 Stripe Checkout Session ID 前缀。这个 session 是 OpenAI 后端基于你的 AT 创建的,包含了产品、价格、货币等信息。
关键问题来了:这个 Checkout Session 的 currency 和 amount 是怎么决定的?
Step 4:打开链接,惊喜时刻
在美国节点下打开这个 oaics 链接。Stripe 结账页面加载出来,你看到:
ChatGPT Plus₱0.00 PHP / month菲律宾比索。零元。
填入指定 BIN 的卡片信息(523686 或 4513),美国账单地址,提交。
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 注入如出一辙:客户端声称”我有优惠资格”,后端不校验就信了。
// 工具的真实请求可能长这样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。
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 | 取决于具体发行行 |
523686 开头的卡填入 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)覆盖默认行为
- 使用一个与主价格表不同的备用定价逻辑,而这个逻辑有漏洞
用状态机的视角看:
┌──────────────┐ │ 注册时: JP │ │ locale=ja-JP │ └──────┬───────┘ │ ┌──────▼───────┐ │ 登录时: US │ │ ip_country=US│ │ ≠ locale │ └──────┬───────┘ │ ┌──────▼───────────┐ │ 创建 Checkout │ │ JP ≠ US → 歧义 │ │ → 进入异常分支 │ │ → 参数校验宽松 │ └──────┬───────────┘ │ ┌──────▼───────────┐ │ 工具注入参数 │ │ + Card BIN: PH │ │ → amount = 0 PHP │ └──────────────────┘三个不同的区域信号(JP / US / PH)制造了歧义,歧义打开了异常路径,异常路径上的校验缺失被利用。
这是一个经典的安全反模式:当多个权威源对同一个问题给出不同答案时,系统在歧义中放松了校验——不是直接返回 0,而是给了攻击者操纵结果的空间。
四、第三方”提炼工具”在干什么?
流程里的那个第三方网站(sms.linlinflow.ccwu.cc),名字叫”CDK 提炼”,听着玄乎,其实它干的事非常直白:
- 接收你的 Access Token
- 用这个 AT 调用 OpenAI 后端 API,创建一个 Stripe Checkout Session
- 把 Checkout Session 的 URL 返回给你
本质就是一个 AT → oaics_ 链接 的代理服务。
它的技术实现大概率是这样的:
// 伪代码,非真实接口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:523686(Mastercard)和 4513(Visa)。这不是随便挑的。
BIN 在支付流中的角色
BIN(Bank Identification Number)是卡号的前 6-8 位,编码了发卡行、卡网络、卡类型和发行国等信息。Stripe 在收到卡号的前 6 位时就能确定:
523686 → 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 地址校验,所以填什么地址都行——这降低了被拒的概率。
523686 和 4513 是经过实测确认能被 Stripe 正确识别为 card.country = "PH" 的 BIN 段。不是所有 PH BIN 都行,因为某些 BIN 可能已被 OpenAI/Stripe 的风控规则标记或黑名单。
为什么要填美国账单地址?
这步看起来矛盾——卡是菲律宾的,地址填美国?
原因是 Stripe 的 AVS(Address Verification Service)只对美国和英国的卡做地址校验。PH 卡不走 AVS,所以你填什么地址都无所谓——但如果你填 PH 地址,可能触发 OpenAI 的额外风控逻辑。填 US 地址是为了”看起来正常”,降低被风控拦截的概率。
六、从防御视角看——OpenAI 需要修什么
不管具体是哪种假说成立,防御原则是通用的。
修复方案(从简单到全面)
Level 1:Checkout Session 金额校验
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:区域一致性校验
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 后置校验
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)**。
攻击模型
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 逻辑允许客户端覆盖定价——攻击者就能把定价推入非预期状态。
防御原则
原则一:定价权归一
pricing_country = user.billing_country // 只有一个来源,注册时确定// card BIN、IP、locale 都不参与定价决策// 只作为风控辅助信号原则二:Checkout 参数不可客户端覆盖
# 服务端决定 price、currency、amount# 客户端/第三方传入的 discount、coupon、price_override 一律忽略# 只接受服务端白名单内的 promotion_code,且校验账号资格checkout_session = stripe.checkout.Session.create( line_items=[server_determined_price], # 不接受客户端 price_data discounts=[], # 不接受客户端 coupon)客户端能影响定价 = 攻击面。
原则三:零元红线
if checkout.amount == 0 and plan.type == "paid": REJECT() // 无条件拒绝付费产品不可能合法地出现 0 元。这应该是一个硬编码的不变量,不依赖任何业务逻辑。
九、写在最后
从 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 元”这条规则没有例外。
往期相关:
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!














