
PoC — обход пути через несанитизированное поле домена, полученное от LLM, в OpenLore (GHSA-5j8x-q7q6-58j5, CVE-2026-87001, CVSS 4.7).
domain, полученное от LLMСтатус CVE: запрошен, ожидает присвоения. Данная уязвимость опубликована как GHSA-5j8x-q7q6-58j5. После присвоения CVE этот репозиторий будет переименован в
CVE-YYYY-NNNNN-openlore-PoC, а этот баннер заменён ссылкой на CVE.
| Исследователь | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-5j8x-q7q6-58j5 |
| CVSS 3.1 | 4.7 (Medium) |
| Слабость | CWE-22, CWE-73 |
domain, полученное от LLMКраткое описание: Злоумышленник, контролирующий содержимое репозитория, анализируемого командой openlore generate, может заставить конвейер генерации спецификаций OpenSpec записать Markdown-файл с подконтрольным злоумышленнику содержимым по произвольному пути в файловой системе за пределами целевого проекта, поскольку значение domain, возвращаемое вызовом извлечения LLM на этапе Stage-3, используется дословно для построения выходного пути спецификации без санитизации обхода пути и без проверки ограничения перед fs.writeFile.
OpenLore (clay-good/OpenLore), npm-пакет openlore, конвейер генерации спецификаций (openlore generate / генератор и записывающее устройство формата OpenSpec). Это не детерминированный путь analyze/orient/MCP guardrail — это единственный путь в коде проекта, который намеренно включает LLM в цикл генерации, согласно собственному README/спецификации проекта («LLM используется только там, где генерация неизбежна (написание спецификаций), и никогда в пути извлечения или guardrail»).
Git-коммит 1f2de00dfe75530efb256320c374dce36607ee70 (2026-07-31), версия package.json 2.1.7.
4.7 (Medium) — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N
AV:L — триггером является локальный вызов CLI (openlore generate) для каталога на диске, а не сетевой сервис.AC:H — эксплуатация требует, чтобы оператор запустил команду generate с поддержкой LLM (а не стандартный путь guardrail) с настроенным провайдером, и чтобы косвенная инъекция промпта в анализируемом репозитории действительно заставила модель выдать строку обхода пути в поле domain — условие, на которое злоумышленник влияет, но не контролирует полностью.UI:R — оператор должен выбрать запуск генерации спецификаций для вредоносного репозитория.I:H / C:N / A:N — ошибка представляет собой примитив записи с частично подконтрольным злоумышленнику содержимым, попадающим по выбранному злоумышленником пути за пределами проекта; не было продемонстрировано чтение конфиденциальных данных или надёжное снижение доступности, поэтому эти метрики консервативно установлены в N.ExtractedService.domain (src/types/pipeline.ts:105) заполняется напрямую из вызова LLM на этапе Stage 3, которому передаётся необработанное содержимое файлов анализируемого репозитория и который должен вернуть JSON-массив сервисов:
src/core/generator/stages/stage3-services.ts:45-52 строит промпт из fileChunks[i] (буквальный исходный текст анализируемого репозитория) и вызывает pipeline.llm.completeJSON<ExtractedService[]>(..., STAGE3_SERVICE_SCHEMA).src/core/generator/schemas.ts:100 — STAGE3_SERVICE_SCHEMA.items.properties.domain имеет вид { type: 'string' }: без pattern, без enum, без allowlist. Любая строка, возвращённая моделью, принимается.src/core/generator/openspec-format-generator.ts:161 — const domainName = service.domain || this.inferDomain(...) использует эту строку как есть.src/core/generator/openspec-format-generator.ts:563 — path: \openspec/specs/${domain.name.toLowerCase()}/spec.md`интерполирует её напрямую в объявленный выходной путь спецификации. Обратите внимание, чтоnormalizeDomainName() (src/core/generator/openspec-compat.ts:655) существует в графе зависимостей того же файла и применяется в других местах (src/core/generator/openspec-writer.ts:167`, для сравнения устаревших каталогов доменов) — но он никогда не вызывается здесь, в единственном месте, где значение фактически становится путём в файловой системе.src/core/generator/openspec-writer.ts:248 — const fullPath = join(this.rootPath, spec.path); использует обычный node:path.join, который лексически разрешает сегменты ../, без вызова собственного safeJoin() проекта (src/utils/path-confinement.ts), через который, согласно openspec/specs/mcp-security/spec.md, обязана проходить любая другая поверхность с недоверенными путями в этой кодовой базе (обработчики MCP, /api/skeleton и /api/spec-requirements в openlore view).src/core/generator/openspec-writer.ts:277 / :294 затем выполняет writeFile(fullPath, spec.content, 'utf-8'), а ensureDir() (openspec-writer.ts:497-499) выполняет mkdir(dirname(filePath), { recursive: true }) для того же неограниченного пути, создавая любые отсутствующие промежуточные каталоги на пути за пределы корня проекта.Итоговый эффект: значение domain, такое как ../../../../../../tmp/x, без изменений проходит от JSON-ответа LLM до реального вызова writeFile, оказываясь полностью за пределами анализируемого проекта. Это в точности та модель угроз «содержимое репозитория перенаправляет запись за пределы корня проекта», которую собственный openspec/specs/mcp-security/spec.md проекта явно определяет и защищает на поверхностях MCP/serve/view (safeJoin, учитывающее симлинки ограничение, гейт покрытия параметров пути) — поверхность генератора/записывающего устройства просто не имеет эквивалентного гейта.
Живой вызов LLM не производился (соблюдение моделью инъецированной инструкции вероятностно и не является интересной частью этой ошибки); вместо этого PoC вызывает реальные, немодифицированные OpenSpecFormatGenerator и OpenSpecWriter из OpenLore с PipelineResult, единственное поле ExtractedService.domain которого содержит ровно ту форму строки, которую STAGE3_SERVICE_SCHEMA сегодня позволяет вернуть LLM, и ровно то, что комментарий в исходном файле с инструкцией «установить domain в ../../../.../tmp/x» мог бы вызвать на этапе Stage 3 (который передаёт модели необработанное содержимое файлов).
Скрипт: ~/engagements/openlore/source/poc-domain-traversal.ts (корень репозитория протестированного клона), запускается против временного проекта в ~/engagements/openlore/evidence/victim-project:
cd ~/engagements/openlore/source
npx tsx poc-domain-traversal.ts
Результат: OpenSpecFormatGenerator.generateSpecs() (немодифицированный) выдаёт спецификацию домена с
path: "openspec/specs/../../../../../../../../../../../../../../../../../../../../tmp/openlore_traversal_proof/spec.md",
и OpenSpecWriter.writeSpecs() (немодифицированный) записывает её — файл оказывается в /tmp/openlore_traversal_proof/spec.md, полностью за пределами корня victim-project, тогда как victim-project/openspec/specs/ всегда содержит только два легитимных каталога overview/architecture.
Скриншоты (подлинные снимки Xvfb+fluxbox+xterm, scrot -w <window id>):
../evidence/poc-generator-write.png — запуск PoC: необработанный spec.path генератора, содержащий обход пути, строка лога записывающего устройства Wrote openspec/specs/../../.../tmp/openlore_traversal_proof/spec.md, и собственное подтверждение скрипта, что /tmp/openlore_traversal_proof/spec.md существует с подконтрольным злоумышленнику содержимым.../evidence/poc-outside-fs-proof.png — независимое подтверждение: ls для victim-project/openspec/specs/ показывает только architecture/overview (никаких следов вредоносного домена внутри проекта), тогда как ls -la /tmp/openlore_traversal_proof/ показывает сбежавший spec.md, лежащий непосредственно в /tmp.Злоумышленник, способный заставить цель запустить openlore generate (генерацию спецификаций с поддержкой LLM) для репозитория, который он создал — правдоподобный сценарий для CLI-инструмента, чья явная цель — быть нацеленным на сторонние/недоверенные кодовые базы — может записать файл с подконтрольным злоумышленнику содержимым по любому пути, доступному для записи пользователю ОС оператора, ограничиваясь лишь количеством сегментов ../, необходимых для выхода, и правами файловой системы. Запись не подтверждена как напрямую исполняемая, но достаточно неглубокий путь проекта мог бы позволить обходу достичь мест, которые исполняются или автоматически загружаются другими инструментами под той же учётной записью (файлы rc оболочки, пользовательские каталоги cron, конфигурации редактора/IDE), что не тестировалось и здесь не утверждается.
normalizeDomainName() (или эквивалентное регулярное выражение allowlist, например ^[a-z][a-z0-9-]{0,63}$) к domain.name / service.domain в точке их первого принятия из ответа LLM (groupByDomain() в openspec-format-generator.ts), а не только на более позднем месте сравнения устаревших каталогов.fullPath в OpenSpecWriter.writeSpec() через собственный safeJoin() проекта (src/utils/path-confinement.ts) вместо обычного node:path.join, в соответствии с ограничением, уже требуемым для любой другой поверхности с недоверенными путями в этой кодовой базе.pattern к STAGE3_SERVICE_SCHEMA.domain (и любому другому полю схемы, позже используемому для построения пути в файловой системе), чтобы провайдеры, применяющие схемы структурированного вывода, отвергали несоответствующие значения сразу.GeneratedSpec.path, содержащий .. или разрешающийся за пределы rootPath, отвергается до того, как OpenSpecWriter обратится к файловой системе.Dostxodjayev Abdullox
Для этого репозитория включено приватное сообщение об уязвимостях GitHub: https://github.com/clay-good/OpenLore/security/advisories/new (стандартный процесс GHSA). Подтверждено через gh api repos/clay-good/OpenLore/security-advisories, возвращающий [] (отсутствие предыдущих advisory) до этого аудита.