一、背景
Cursor是一款基于VSCode的AI编辑器,其免费版限制了自定义模型配置权限,昨天我们从报文替换方面实现了本地化pro会员的识别。今天我们使用逆向工具追踪会员等级在客户端的完整生命周期:网络获取→数据解析→本地存储→UI判断。
二、入口:网络请求
通过Proxyman抓包,发现Cursor登录后会请求:
GEThttps://api2.cursor.sh/auth/full_stripe_profileAuthorization:Bearer<access_token>返回JSON:{"membershipType":"free","subscriptionStatus":"canceled","paymentId":"xxxxx","lastPaymentFailed":false,"isOnStudentPlan":false,"isTeamMember":false,...}
关键字段是membershipType,可能的值为:free、pro、pro_plus、ultra、enterprise、free_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
3.1MembershipType枚举定义
在minifiedJS中找到枚举定义:
(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()=>{constU=awaitthis.getAccessToken();if(U)try{returnawait(awaitfetch(`${this.cursorCredsService.getBackendUrl()}/auth/full_stripe_profile`,{headers:{Authorization:`Bearer${U}`,//...其他header}})).json();}catch(q){console.error("Failedtofetchstripeprofile:",q);}};
3.3响应解析与存储
refreshMembership()是核心函数,负责获取profile并写入本地存储:
this.refreshMembership=async()=>{//1.无token→设为FREEif(!U){this.storeMembershipType(Pa.FREE);return;}//2.先查team信息constq=awaitthis.getTeams();constJ=q.some(z=>z.hasBilling&&z.seats>0);//3.如果是付费team成员→ENTERPRISEif(J){this.storeMembershipType(Pa.ENTERPRISE);//...处理bedrock等}else{//4.否则请求full_stripe_profileconstY=awaitfetch(`/auth/full_stripe_profile`,{...});G=awaitY.json();//5.直接取JSON中的membershipType写入本地this.storeMembershipType(G.membershipType);this.storeSubscriptionStatus(G.subscriptionStatus);}};
3.4本地存储
storeMembershipType将值写入Electron的SQLite数据库:
this.storeMembershipType=r=>{consts=this.membershipType();r=r??Pa.FREE;this.storageService.store("cursorAuth/stripeMembershipType",r,-1,1);//同时触发内存中的reactivestorage更新if(s!==r){this.notifySubscriptionChangedListeners(r,s,o);this._onDidChangeSubscription.fire(r);}};
数据库文件位置:
~/Library/ApplicationSupport/Cursor/User/globalStorage/state.vscdb通过sqlite3直接查询:sqlite3~/Library/ApplicationSupport/Cursor/User/globalStorage/state.vscdb"SELECTkey,valueFROMItemTableWHEREkeyLIKE'%cursorAuth%'"输出示例:cursorAuth/stripeMembershipType|procursorAuth/stripeSubscriptionStatus|activecursorAuth/cachedEmail|user@example.com
3.5读取时的switch判断
this.membershipType=()=>{switch(this._membershipType()){//从storage读取casePa.ENTERPRISE:returnPa.ENTERPRISE//"enterprise"casePa.PRO:returnPa.PRO//"pro"casePa.PRO_PLUS:returnPa.PRO_PLUS//"pro_plus"casePa.FREE_TRIAL:returnPa.FREE_TRIAL//"free_trial"casePa.ULTRA:returnPa.ULTRA//"ultra"default:returnPa.FREE//"free"}};
3.6UI层面的Pro判断
//是否有付费权限functionisPaidUser(n){returnn===Pa.ULTRA||n===Pa.PRO||n===Pa.PRO_PLUS||n===Pa.ENTERPRISE||n===Pa.FREE_TRIAL;}//登录后触发ProUI解锁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:"SharefeatureisonlyavailableforProusers."};}
四、数据流全貌

五、方案探索与演进
方案A:直接写SQLite(失败)
最初尝试直接修改state.vscdb中的cursorAuth/stripeMembershipType值。
问题:refreshMembership()会在启动、定时刷新、登录等时机重新请求网络,覆盖本地值。修改后几秒即失效。
方案B:SQLiteTrigger锁定(失败)
在数据库上创建BEFOREUPDATEtrigger拦截写入:
CREATETRIGGERlock_membershipBEFOREUPDATEONItemTableWHENNEW.key='cursorAuth/stripeMembershipType'BEGINSELECTRAISE(IGNORE);END;
问题:trigger只拦截了SQLite写入,但storeMembershipType()同时更新了内存中的reactivestorage。内存值被修改后直接驱动UI刷新,虽然重启后SQLite保留旧值,但很快又被网络刷新覆盖。本质上内存和数据库是双写的。
方案C:PatchJS文件(最终方案)
分析storeMembershipType的函数体:
//原始代码this.storeMembershipType=r=>{consts=this.membershipType(),o=this.subscriptionStatus();r=r??Pa.FREE;//←patch注入点this.storageService.store(MDt,r,-1,1);//...};在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__*/"defapply_patch(value):content=read_js()ifcurrent_patch(content)isnotNone:#已有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)
运行效果:
$python3cursor_membership.py==============================================CursorMembershipSwitcher(macOS)==============================================JSpatch:notpatched(original)----------------------------------------------[1]Free(free)[2]FreeTrial(free_trial)[3]Pro(pro)[4]Pro+(pro_plus)[5]Ultra(ultra)[6]Enterprise(enterprise)[r]Restoreoriginal(removepatch)[q]Quit----------------------------------------------Select:3Patched:storeMembershipTypewillalwaysuse"pro"RestartCursortoapply.
七、总结
方案
原理
结果
写SQLite
修改本地存储值
失败,网络刷新覆盖
SQLiteTrigger
拦截数据库写入
失败,内存双写绕过
JSPatch
修改函数逻辑,拦截赋值
成功,源头阻断
核心教训:Electron应用的状态管理往往涉及多层存储(SQLite+内存reactivestate),单一层面拦截无法解决问题。最可靠的方案是在数据流的源头——即JS逻辑内部——进行拦截。
注意事项:Cursor更新后会覆盖JS文件,需要重新执行patch。工具已内置备份与还原功能。
上一条:Cursor 新版本上线
品质保证
多年的生产力软件专家
专业实力
资深技术支持项目实施团队
安全无忧
多位认证安全工程师
多元服务
软件提供方案整合,项目咨询实施
购软平台-找企业级软件,上购软平台。平台提供更齐全的软件产品、更专业的技术服务,同时提供行业资讯、软件使用教程和技巧。购软平台打造企业级数字产品综合应用服务平台。用户体验和数字类产品的专业化服务是我们不断追求的目标。购软平台您身边的企业级数字产品优秀服务商。
沪公网安备31011302006932号