乐于分享
好东西不私藏

逆向分析 Cursor 编辑器会员等级判定机制

逆向分析 Cursor 编辑器会员等级判定机制

一、背景

Cursor 是一款基于 VS Code 的 AI 编辑器,其免费版限制了自定义模型配置权限,昨天我们从报文替换方面实现了本地化pro会员的识别。今天我们使用逆向工具追踪会员等级在客户端的完整生命周期:网络获取 → 数据解析 → 本地存储 → UI 判断


二、入口:网络请求

通过 Proxyman 抓包,发现 Cursor 登录后会请求:

GET https://api2.cursor.sh/auth/full_stripe_profileAuthorizationBearer <access_token>返回 JSON{  "membershipType""free",  "subscriptionStatus""canceled",  "paymentId""xxxxx",  "lastPaymentFailed"false,  "isOnStudentPlan"false,  "isTeamMember"false,  ...}

关键字段是 membershipType,可能的值为:freepropro_plusultraenterprisefree_trial


三、客户端代码定位

Cursor 是 Electron 应用,核心逻辑在打包后的 JS 文件中:

/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js

该文件约 50MB(minified),通过 grep 定位关键字符串:

grep -n "membershipType" workbench.desktop.main.js
grep -n "full_stripe_profile" workbench.desktop.main.js

3.1 MembershipType 枚举定义

在 minified JS 中找到枚举定义:

(function(Pa) {  Pa.FREE       = "free"  Pa.PRO        = "pro"  Pa.PRO_PLUS   = "pro_plus"  Pa.ENTERPRISE = "enterprise"  Pa.FREE_TRIAL = "free_trial"  Pa.ULTRA      = "ultra"})(Pa || (Pa = {}))

3.2 网络请求发起

getStripeProfile 函数:

this.getStripeProfile = async () => {  const U = await this.getAccessToken();  if (U) try {    return await (await fetch(      `${this.cursorCredsService.getBackendUrl()}/auth/full_stripe_profile`,      {        headers: {          Authorization`Bearer ${U}`,          // ... 其他 header        }      }    )).json();  } catch (q) {    console.error("Failed to fetch stripe profile:", q);  }};

3.3 响应解析与存储

refreshMembership() 是核心函数,负责获取 profile 并写入本地存储:

this.refreshMembership = async () => {  // 1. 无 token → 设为 FREE  if (!U) {    this.storeMembershipType(Pa.FREE);    return;  }  // 2. 先查 team 信息  const q = await this.getTeams();  const J = q.some(z => z.hasBilling && z.seats > 0);  // 3. 如果是付费 team 成员 → ENTERPRISE  if (J) {    this.storeMembershipType(Pa.ENTERPRISE);    // ... 处理 bedrock 等  } else {    // 4. 否则请求 full_stripe_profile    const Y = await fetch(`/auth/full_stripe_profile`, { ... });    G = await Y.json();    // 5. 直接取 JSON 中的 membershipType 写入本地    this.storeMembershipType(G.membershipType);    this.storeSubscriptionStatus(G.subscriptionStatus);  }};

3.4 本地存储

storeMembershipType 将值写入 Electron 的 SQLite 数据库:

this.storeMembershipType = r => {  const s = this.membershipType();  r = r ?? Pa.FREE;  this.storageService.store("cursorAuth/stripeMembershipType", r, -11);  // 同时触发内存中的 reactive storage 更新  if (s !== r) {    this.notifySubscriptionChangedListeners(r, s, o);    this._onDidChangeSubscription.fire(r);  }};

数据库文件位置:

~/Library/Application Support/Cursor/User/globalStorage/state.vscdb通过 sqlite3 直接查询:sqlite3 ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb \  "SELECT key, value FROM ItemTable WHERE key LIKE '%cursorAuth%'"输出示例:cursorAuth/stripeMembershipType|procursorAuth/stripeSubscriptionStatus|activecursorAuth/cachedEmail|user@example.com

3.5 读取时的 switch 判断

this.membershipType = () => {  switch (this._membershipType()) {  // 从 storage 读取    case Pa.ENTERPRISE:  return Pa.ENTERPRISE   // "enterprise"    case Pa.PRO:         return Pa.PRO           // "pro"    case Pa.PRO_PLUS:    return Pa.PRO_PLUS      // "pro_plus"    case Pa.FREE_TRIAL:  return Pa.FREE_TRIAL    // "free_trial"    case Pa.ULTRA:       return Pa.ULTRA         // "ultra"    default:             return Pa.FREE          // "free"  }};

3.6 UI 层面的 Pro 判断

// 是否有付费权限function isPaidUser(n) {  return n === Pa.ULTRA || n === Pa.PRO || n === Pa.PRO_PLUS      || n === Pa.ENTERPRISE || n === Pa.FREE_TRIAL;}// 登录后触发 Pro UI 解锁if (membershipType() === Pa.PRO || membershipType() === Pa.PRO_PLUS     || membershipType() === Pa.ULTRA) {  this.setUsageBar(); // 显示用量条等 Pro 功能}// 分享功能限制if (membershipType === Pa.FREE || membershipType === Pa.FREE_TRIAL) {  return { success: false, reason: "Share feature is only available for Pro users." };}


四、数据流全貌


五、方案探索与演进

方案 A:直接写 SQLite(失败)

最初尝试直接修改 state.vscdb 中的 cursorAuth/stripeMembershipType 值。

问题:refreshMembership() 会在启动、定时刷新、登录等时机重新请求网络,覆盖本地值。修改后几秒即失效。

方案 B:SQLite Trigger 锁定(失败)

在数据库上创建 BEFORE UPDATE trigger 拦截写入:

CREATE TRIGGER lock_membershipBEFORE UPDATE ON ItemTableWHEN NEW.key = 'cursorAuth/stripeMembershipType'BEGIN    SELECT RAISE(IGNORE);END;

问题: trigger 只拦截了 SQLite 写入,但 storeMembershipType() 同时更新了内存中的 reactive storage。内存值被修改后直接驱动 UI 刷新,虽然重启后 SQLite 保留旧值,但很快又被网络刷新覆盖。本质上内存和数据库是双写的。

方案 C:Patch JS 文件(最终方案)

分析 storeMembershipType 的函数体:

// 原始代码this.storeMembershipType = r => {  const s = this.membershipType(), o = this.subscriptionStatus();  r = r ?? Pa.FREE;           // ← patch 注入点  this.storageService.store(MDt, r, -11);  // ...};在 r=r??Pa.FREE 前插入一行赋值:/*__cursor_membership_patch__*/r="pro";  // 强制覆盖r=r??Pa.FREE,

原理: 无论网络返回什么值,在写入 SQLite 和更新内存之前,r 被强制赋值为目标值。这样两层存储都拿到的是正确的值,不需要拦截网络、不需要锁定数据库。

优势:

  • 改动极小(插入一行代码)

  • 不需要额外服务(mitmproxy/Proxyman)

  • 内存和 SQLite 同时正确

  • 支持一键还原


六、工具实现

基于方案 C 开发了 Python 命令行工具,核心逻辑:

# 定位 patch 点ORIGINAL_SNIPPET = "r=r??Pa.FREE,"PATCH_MARKER = "/*__cursor_membership_patch__*/"def apply_patch(value):    content = read_js()    if current_patch(content) is not None:        # 已有 patch,替换值        content = re.sub(            PATCH_MARKER + r'r="\w+";',            f'{PATCH_MARKER}r="{value}";',            content        )    else:        # 首次 patch,备份并插入        shutil.copy2(JS_PATH, BACKUP_PATH)        content = content.replace(            ORIGINAL_SNIPPET,            f'{PATCH_MARKER}r="{value}";' + ORIGINAL_SNIPPET,            1        )    write_js(content)

运行效果:

$ python3 cursor_membership.py==============================================  Cursor Membership Switcher (macOS)==============================================  JS patch : not patched (original)----------------------------------------------  [1Free         (free)  [2Free Trial   (free_trial)  [3] Pro          (pro)  [4] Pro+         (pro_plus)  [5] Ultra        (ultra)  [6] Enterprise   (enterprise)  [r] Restore original (remove patch)  [q] Quit----------------------------------------------  Select3  Patched: storeMembershipType will always use "pro"  Restart Cursor to apply.


七、总结

方案

原理

结果

写 SQLite

修改本地存储值

失败,网络刷新覆盖

SQLite Trigger

拦截数据库写入

失败,内存双写绕过

JS Patch

修改函数逻辑,拦截赋值

成功,源头阻断

核心教训: Electron 应用的状态管理往往涉及多层存储(SQLite + 内存 reactive state),单一层面拦截无法解决问题。最可靠的方案是在数据流的源头——即 JS 逻辑内部——进行拦截。

注意事项: Cursor 更新后会覆盖 JS 文件,需要重新执行 patch。工具已内置备份与还原功能。

工具代码已开源:https://github.com/lynnlni/cursor-membership-switcher