首页>软件资讯>常见问题

常见问题

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

发布时间:2026-04-21 10:27:03人气:1

一、背景  

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."};}  

四、数据流全貌  

数据流全貌.png

五、方案探索与演进  

方案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 新版本上线

下一条:Cursor 开发实战教程:从0到1开发一款数据采集工具