GHSA-jqc4-5cvw-7w85: SQL Injection
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 descriptorusable 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 retainuntilclose, set on the link route in connectthroughdescriptor, where descriptorusable is False on macOS. Three more rmtree sites sit in linkbeside (lines 1023 and 1026) and the connect's finally (line 1011). The fifth is in sweeplinksofthedead, with the name parser ownerisgone and linkprefix.
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: openforacquisition opens it and release() reaches close through release and finishconnection. 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 writelock() 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 victimproject (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 victimproject 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. linkbeside calls sweeplinksofthedead(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. ownerisgone splits the name after .filelock- into pid, start, digest (a hash of the host name) and a suffix. It returns digest == hostdigest(host) and ownerisstale(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 processalive 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 linkprefix writes an empty field when it has no start token; with no token and a dead PID ownerisstale returns True. digest is sha256(hostname())[:16]. hostname() 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("victimproject", ".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}/victimproject"
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.CLONENEWNS) 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, ignoreerrors=True) os.mkdir(shared) os.chmod(shared, 0o777) os.mkdir(project, 0o700) with open(f"{project}/notes.txt", "w") as notesfile: notesfile.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], captureoutput=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/victimholdwritelock.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/attackerswaplinkdir.py"], check=True) linkdir = next(name for name in os.listdir(shared) if name.startswith(".filelock-")) notes = f"{shared}/{linkdir}/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/attackerswaplinkdir.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 linkdir = next(name for name in os.listdir(shared) if name.startswith(".filelock-")) os.rename(f"{shared}/{linkdir}", f"{shared}/moved-aside") os.rename(f"{shared}/victimproject", f"{shared}/{linkdir}")
repro.d/victimholdwritelock.py
python import sys
from filelock import ReadWriteLock
lock = ReadWriteLock("/tmp/shared-cache/cache.db") with lock.writelock(): 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(victimproject, .filelock-PID--DIGEST-XXXX) V->>K: release() calls shutil.rmtree(".filelock-PID--DIGEST-XXXX") Note over V,K: victimproject 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 victimproject/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.writelock():) 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/victimproject': Permission denied while the victim holds the lock: ['.filelock-106--39c81e1bf87b1d56-os58i3c', 'cache.db', 'victimproject'] after the attacker's renames: ['.filelock-106--39c81e1bf87b1d56-os58i3c', 'cache.db', 'moved-aside'] /tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os58i3c/notes.txt exists: True victim exit status: 0 after the release: ['cache.db', 'moved-aside'] /tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os58i3c/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 victimproject 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 descriptorusable 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
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/filelockto a version that resolves this vulnerability.Fixed in 4.2.1 - Upgrade
Upgrade
filelockto a version that resolves this vulnerability.Fixed in 4.2.1 - Compensating control
On macOS or POSIX systems without a usable /proc/self/fd, keep each ReadWriteLock or AsyncReadWriteLock database in a directory writable only by the locking account or root, with no group or other write bit; ensure every ancestor is owned by that account or root and is either not writable by others or sticky, and do not share the database through a 0777 or group-writable directory.
Event History
Frequently Asked Questions
Which deployments are exposed to this issue?
The affected route is used by every connection on macOS since 4.1.1 and by POSIX processes that do not have a usable /proc/self/fd, including chroots or containers without /proc mounted. Stock Linux systems with /proc mounted are not affected.
What access does an attacker need?
An attacker needs local ability to rename entries in the directory containing the lock database. The risk applies when that directory is a 0777 or group-writable cache, build, or project directory without the sticky bit.
What can happen if exploitation succeeds?
The attacker can replace a .filelock-* link directory, or forge a dead-holder name, with a directory belonging to the lock-running user. The lock process then recursively deletes that tree with its own permissions and reports no error.
What can be done when an update is not immediately available?
Keep the lock database outside directories that untrusted local users can rename within. In particular, avoid world-writable or group-writable non-sticky cache, build, and project directories; on Linux, ensure /proc is mounted and usable where possible.