0x00 背景:$20 的 Pro,$250 的 Max,中间差了一个 hook

Claude 的 Android 客户端走 Google Play 内购(Google Play Billing Library)处理订阅。价格梯度大概是这样:

方案月费
Pro$20
Max 5x$125
Max 20x$250

正常流程:你是 Pro 用户,想升级到 Max,Google Play 会按剩余天数计算差价,立刻从你的信用卡上扣。

但如果我告诉你,有个参数决定了 Google Play 对这笔”升级”收不收钱呢?

而且这个参数,就是 Android Bundle 里一个普普通通的 putInt 调用?

0x01 prorationMode:Google Play 里最被低估的攻击面

Google Play Billing Library 在处理订阅升/降级时,有个参数叫 prorationMode(比例分配模式),定义在 BillingFlowParams.SubscriptionUpdateParams 里。它决定了用户切换订阅方案时的扣费逻辑:

常量名行为
0UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY未知
1WITH_TIME_PRORATION按剩余时间折算差价,立刻扣费
2CHARGE_PRORATED_PRICE按比例收取差价
3WITHOUT_PRORATION立刻升级,本周期不收费,下个周期按新价扣
5CHARGE_FULL_PRICE立刻全额扣费
6DEFERRED延迟到下个周期才生效

重点关注 3 — WITHOUT_PRORATION:Google 官方文档里写得很清楚,这个模式的设计意图是”让用户立刻获得高级功能,但推迟扣费到下个计费周期”。本来是给某些业务场景用的(比如试用期升级),但如果被滥用……

Claude 的客户端代码里,升级订阅时用的是模式 1(WITH_TIME_PRORATION),也就是正常的按比例扣差价。它通过 Bundle.putInt("prorationMode", 1) 把这个值传给 Google Play 的计费 Activity。

这个 Bundle.putInt 调用发生在客户端侧。客户端侧的东西,你懂的。

0x02 攻击链:Frida 一条龙

整个攻击链其实非常简洁,所有操作都在模拟器上完成。用到的工具:

  • MuMu 模拟器(x86_64,因为要跑 Frida server 的 x86 版本)
  • KernelSU(MuMu 自带,用来获取 root)
  • Frida 17.16.0(动态插桩框架)
  • 特定版本的 Claude APK(v1.260330.27,在 Uptodown 可以下到历史版本)
  • 一个能扣 $20 的美区 Google 账号
  • 家宽 IP(避免 Google 风控拦截)

步骤拆解:

第一步:搭环境

MuMu 模拟器装好后,用它自带的 Google 安装器装上 Google 套件 + Play 商店。打开 KernelSU Manager 给 Shell 授权 root。这步没啥技术含量,MuMu 对 Google 服务兼容性比 LDPlayer 好不少。

第二步:准备 Claude APK

这里有个前置问题——你不能直接从 Play 商店装 Claude。

现在的 Android 应用普遍使用 App Bundle(AAB)格式发布,Play 商店下发的是 split APK:base.apk 加一堆按设备拆分的 config splits(ABI、密度、语言)。x86_64 模拟器拿到的 splits 跟 ARM 真机不一样,而且 split APK 不能直接 adb install 侧载。

所以需要一个经过修补、移除了 split 限制的完整 APK。实测可用的版本是 v1.260330.27,从 Uptodown 等第三方市场可以找到历史版本的完整包。关键词:“已修补 split 限制”——就是把 App Bundle 重新打包成单个 APK,绕过 Play 商店的分发限制。

还有一个连锁问题:修补过的 APK 签名跟 Google Play 原版不一致(相当于 debug 签名),Google OAuth 登录会直接拒绝——点 “Continue with Google” 没反应或者报错。解决办法很简单:用邮箱登录(Continue with email),输入 Claude 账号的邮箱和密码,完全不走 OAuth。

第三步:部署 Frida

adb push frida-server-17.16.0-android-x86_64 /data/local/tmp/frida-server
adb shell chmod 755 /data/local/tmp/frida-server
adb shell su -c "setenforce 0"
adb shell su -c "/data/local/tmp/frida-server -D &"

MuMu 有个坑:它的 ADB 端口不是标准 5555,而是 5557(多开的话 5559、5561 递增)。另外 MuMu 用 houdini 做 ARM 翻译,所以 Frida 必须用 attach 模式,不能用 spawn 模式(-f 参数),否则 houdini 翻译层直接 SIGSEGV 崩掉。

第四步:注入 Hook

核心的 hook 脚本其实就干了一件事——拦截 Bundle.putInt,把 key 为 prorationMode 的值从 1 改成 3:

var Bundle = Java.use("android.os.Bundle");
Bundle.putInt.overload("java.lang.String", "int").implementation = function (key, value) {
    if (key === "prorationMode") {
        console.log("[*] 拦截 prorationMode: " + value + " → 3 [WITHOUT_PRORATION]");
        return this.putInt(key, 3);  // 就这一行,价值 $230/月
    }
    return this.putInt(key, value);
};

注意启动方式——必须先手动打开 Claude,再用 -H 连接(而不是 -U),因为 MuMu 的 USB 层跟标准 Android 不一样。而且 Frida 的进程名参数必须用显示名 Claude,不能用包名 com.anthropic.claude——MuMu 上 -H 模式下 Frida 按包名是找不到进程的,只认 app_process 里注册的显示名:

adb forward tcp:27042 tcp:27042
frida -H 127.0.0.1:27042 Claude -l hook-replacement-mode.js
# 注意:用 com.anthropic.claude 会报 "unable to find process"

这里还有个细节:脚本额外拦截了 SentryNdk.loadNativeLibrariesSentryAndroidNdk 的初始化方法,因为 Sentry 的 native crash 上报模块在 houdini 翻译层下也会崩。不拦截的话 App 直接闪退。

除了核心的 prorationMode 拦截,脚本还 hook 了 PurchaseReceipt 的构造函数,打印 purchase_tokenorganization_id。这个 org_id 是 Anthropic 后端用来标识组织/用户的唯一 ID(格式类似 6a085918-8383-44e5-9538-c4c81c6e122b),在规模化操作时用于追踪哪个 Claude 账号绑定了哪笔 Google Play 订阅——一个 Google 账号只能给一个 Claude 账号开通订阅,org_id 就是这个映射关系的 key。

第五步:走一遍正常支付

hook 注入成功后(控制台会显示 Frida Hook 注入成功!,App 里也会弹 Toast),正常操作:

  1. 用邮箱登录 Claude 账号(不能用 Google OAuth,原因见上)
  2. 在模拟器里绑定支付卡(需要能扣 $20 的真实/虚拟信用卡)
  3. 通过 Google Play 订阅 Pro($20/月)—— 这笔钱是真扣的

第六步:触发升级

Pro 订阅成功后,直接在 App 里点”升级到 Max”。这时候 hook 就发挥作用了:

控制台输出:

[*] 拦截 prorationMode: 1 [WITH_TIME_PRORATION] → 3 [WITHOUT_PRORATION]

同时 PurchaseReceipt 的 hook 会打印出新订阅的 org_id,确认升级已经走通了 Anthropic 后端。

Google Play 的计费页面会显示:

  • 产品:Claude Max
  • 价格:$250.00/month(日区显示 JP¥42,500/month)
  • 开始日期:2026年8月20日(注意,是下个月!)
  • “我们将于 2026年8月20日向您收取第一笔费用”

看到了吗?因为 prorationMode 被改成了 WITHOUT_PRORATION,Google Play 认为”当前不收费,下个计费周期再扣”。点击订阅,立刻升级到 Max,卡上不会多扣一分钱。

第七步:善后

升级成功后,去 payments.google.com 把这个订阅的支付方式换成余额钱包(Google Play 余额一般是 0)或者一张空的虚拟卡,然后解绑原来的支付卡。

下个月续费的时候,余额不够或者虚拟卡扣费失败,订阅自然过期。但这一个月的 Max 你已经用上了。

$20 的成本,享受 $250 的服务。利润率 1150%。

有一个规模化的硬约束:一个 Google 账号只能给一个 Claude 账号开通订阅。想批量撸就得不断囤 Google 号——这也是为什么这条灰产链的瓶颈不在技术,而在号源。

0x03 为什么这个 bug 能成立

从技术层面分析,这个漏洞能成立需要几个条件同时满足:

1. prorationMode 由客户端决定

这是最根本的问题。Google Play Billing Library 的设计是把 prorationMode 作为客户端参数传递给 Google Play 的计费系统。Google Play 本身不会校验”这个 App 有没有资格用 WITHOUT_PRORATION 模式”——它无条件信任客户端传来的值。

这是一个经典的信任边界错误:把安全关键参数的决定权放在了不可信的客户端侧。

2. 服务端验证缺位

Claude 的后端在收到 Google Play 的订阅回调时,主要验证的是”这个 purchase token 是否有效”和”订阅是否 active”。它不会去检查 prorationMode 是什么——因为这个信息在服务端回调里根本就没有。Google Play 的 purchases.subscriptions API 返回的数据里不包含当初用的是哪个 proration mode。

3. 历史版本 APK 的可用性

新版 Claude 可能已经做了混淆或者改变了参数传递方式,但旧版本在 Uptodown 等第三方市场永远可以下到。只要 Google Play 的 Billing API 版本还兼容,旧 APK 照样能完成订阅操作。

0x04 不用 hook 的路子:速刷

值得一提的是,prorationMode hook 并不是唯一的利用方式。圈子里还流传一种叫”速刷”的打法——不需要 Frida,不需要 hook,纯靠旧版本客户端的某些行为差异来打时间差。

具体原理不在本文展开(毕竟还没被修),但核心思路类似:利用 Google Play Billing Library 旧版本在处理订阅变更时的窗口期,在扣费确认到达之前完成订阅切换。跟 prorationMode hook 相比,速刷的门槛更低(不需要 root、不需要 Frida),但成功率也更低,而且强依赖特定的历史版本 APK。

这两种方法本质上攻击的是同一个面:Google Play 对客户端行为的过度信任

0x05 修复状态与后续

截至 2026 年 7 月,prorationMode hook 这个特定的攻击路径已被修复。从修复思路上猜测,可能的措施包括:

  1. 客户端侧:新版 Claude APK 不再使用 Bundle 明文传递 prorationMode,可能改用了加密或者服务端控制
  2. 服务端侧:在处理升级回调时增加了额外验证(比如检查 linkedPurchaseToken 的订阅金额是否合理)
  3. Google Play 侧:Google 可能限制了 WITHOUT_PRORATION 模式的使用场景(需要应用显式在 Google Play Console 配置)

但说实话,Google Play Billing 的这个攻击面远没有被堵死prorationMode 只是其中一个参数,BillingFlowParams 里还有其他可操控的字段。只要”客户端构建支付参数 → 传给 Google Play → Google Play 无条件执行”这个模型不变,同类问题就会持续出现。

据了解,目前仍有针对其他参数或其他 App 的类似攻击手法在流通。Google Play 内购系统的安全模型,说白了就是建立在”客户端可信”这个脆弱假设上的。

0x06 技术启示

如果你是开发者,从这个漏洞可以学到:

  1. 永远不要信任客户端传来的支付参数。即使是通过 Google Play 这种”可信”中间人,底层的 BillingFlowParams 仍然是客户端构造的。
  2. 服务端必须独立验证订阅状态。不要只检查 token 有效性,还要验证订阅的实际扣费金额(通过 purchases.subscriptionspriceAmountMicros 字段)与预期是否一致。
  3. Pin APK 版本。如果你依赖客户端逻辑做安全控制,服务端应该检查客户端版本号,拒绝过旧版本的请求。

如果你是安全研究者,这个 case 是一个很好的”API 信任边界”研究样本。Google Play Billing Library 把自己定位成”安全的支付中间件”,但它实际上信任了太多来自客户端的输入。类似 Frida hook Bundle.putInt 这种低成本的攻击手段,在整个 Android 内购生态里适用面很广。