| Package | Affected versions | Patched versions |
|---|---|---|
| filelock | ≥ 4.0.0, < 4.2.1 | 4.2.1 |
| filelock | ≥ 4.0.0, < 4.2.1 | 4.2.1 |
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 O_WRONLY | O_NOFOLLOW open and writing at offset 0, without checking what it opened. O_NOFOLLOW 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 write_lock() / read_lock() hang regardless of timeout. It also stops a lock holder's heartbeat thread without on_compromise (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.protected_hardlinks=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".
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.
.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.fs.protected_hardlinks plays no part, and fs.protected_fifos=1 (a Linux setting that refuses some opens of FIFOs in sticky world-writable directories) does not help, since it covers O_CREAT opens in sticky directories only, and the other user renames the FIFO into place inside its own holders/.fs.protected_hardlinks=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.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.At the scanned commit b831496b0273, OsFiles.overwrite opens by name with O_WRONLY | O_NOFOLLOW and writes without fstat (fstat would report the type of the file behind the open descriptor), while read just above it uses O_NONBLOCK and a regular-file check. The blocking open is _attempt_open via _open. prepare adopts an existing tree whoever owns it. The single caller is Participant._refresh_nonce, reached from _keep_claim_fresh 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 heartbeat_interval + 1 at line 446.
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).
OsFiles.overwrite() refreshes a holder record by opening holders/<token> by name and writing at offset 0. O_NOFOLLOW 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:
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.Participant.publish() calls OsFiles.create(), which opens holders/<token> with O_CREAT | O_EXCL, writes the record and closes. The token is the file name, so the other user reads it from a listing of its own directory.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.poll_interval, 0.25 s) runs advance() → _keep_claim_fresh() → _refresh_nonce() → overwrite(). With the hard link the lock user owns the linked file, so O_WRONLY succeeds and write_all puts encode_holder(...) 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 heartbeat_interval (30 s by default). With heartbeat_interval=0.5, the holder also writes through a link planted one second after it got the write lock, and lock.compromise stays None.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").write_lock(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, O_WRONLY) without O_NONBLOCK (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 write_lock(timeout=2) 12 s later, at _storage.py:158 _attempt_open ← :143 _open ← :96 overwrite ← _protocol.py:545 _refresh_nonce ← :494 _keep_claim_fresh ← :464 advance ← _sync.py:584 _wait_for ← :512 _do_acquire_inner; 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 write_all then fails with EPIPE (broken pipe). Peers read a waiter's record only in _sweep, at most every stale_threshold / 2 (stale_threshold is the age after which peers treat a record as abandoned). With a live holder on default settings write_lock(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 heartbeat_interval=1, stale_threshold=3 and the FIFO renamed over the record after the grant: 6.5 s later lock.compromise is still None, on_compromise 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 write_all 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 stale_threshold 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 on_compromise not called.
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
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
LOCK_TREE = "/tmp/app.lock.rw"
VICTIM_FILE = "/home/victim/accounts.csv"
PROTECTED_HARDLINKS = "/proc/sys/fs/protected_hardlinks"
as_victim = ["runuser", "-u", "victim", "--"]
as_attacker = ["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(VICTIM_FILE, "wb") as f:
f.write(original)
shutil.chown(VICTIM_FILE, "victim", "victim")
os.chmod(VICTIM_FILE, 0o600)
shutil.rmtree(LOCK_TREE, ignore_errors=True)
direct_write = subprocess.run([*as_attacker, "truncate", "-s", "0", VICTIM_FILE], capture_output=True, text=True)
print("CONTROL attacker writes the file itself:", direct_write.stderr.strip())
assert direct_write.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.
protected_hardlinks_before = open(PROTECTED_HARDLINKS).read()
open(PROTECTED_HARDLINKS, "w").write("0")
try:
# attack
subprocess.run([*as_attacker, sys.executable, "/tmp/repro.d/attacker_creates_lock_tree_first.py"], check=True)
link_planter = subprocess.Popen(
[*as_attacker, sys.executable, "/tmp/repro.d/attacker_plants_hard_link.py"], stdout=subprocess.PIPE, text=True
)
victim_app = subprocess.run(
[*as_victim, sys.executable, "/tmp/repro.d/victim_app.py"], stdout=subprocess.PIPE, text=True, check=True
)
victim_token = link_planter.communicate()[0].strip()
assert link_planter.returncode == 0
finally:
open(PROTECTED_HARDLINKS, "w").write(protected_hardlinks_before)
# check: the victim's holder record belongs in holders/<token>, never in another file
print("victim app:", victim_app.stdout.strip())
print("attacker replaced holders/" + victim_token, "with a hard link to", VICTIM_FILE)
after = open(VICTIM_FILE, "rb").read()
info = os.stat(VICTIM_FILE)
print(f"OBSERVED {VICTIM_FILE} (uid {info.st_uid}, mode {info.st_mode & 0o777:o}) now begins:")
print(after[:200].decode(errors="replace"))
print("BUG" if after.startswith(b"filelock-rw/1\ntoken=" + victim_token.encode() + b"\n") else "OK")
repro.d/attacker_creates_lock_tree_first.py
import os
LOCK_TREE = "/tmp/app.lock.rw"
os.umask(0)
os.mkdir(LOCK_TREE, 0o777)
os.mkdir(f"{LOCK_TREE}/gen", 0o777)
os.mkdir(f"{LOCK_TREE}/holders", 0o777)
# A snapshot naming some other writer, so the victim keeps polling (and refreshing its record) instead of being granted.
with open(f"{LOCK_TREE}/gen/00000000000000000001", "w") as snapshot:
snapshot.write("filelock-rw/1\ngeneration=1\nwriter=" + "a" * 32 + "\n")
repro.d/attacker_plants_hard_link.py
import os
import time
HOLDERS = "/tmp/app.lock.rw/holders"
VICTIM_FILE = "/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)
victim_token = records[0]
# link(2) needs write permission on holders/ and search permission on /home/victim, nothing on the file itself.
os.link(VICTIM_FILE, f"{HOLDERS}/link")
os.rename(f"{HOLDERS}/link", f"{HOLDERS}/{victim_token}")
print(victim_token)
repro.d/victim_app.py
import time
from filelock import SoftReadWriteLock, Timeout
started = time.monotonic()
try:
with SoftReadWriteLock("/tmp/app.lock").write_lock(timeout=2):
print("got the write lock")
except Timeout:
print(f"Timeout after {time.monotonic() - started:.1f} s")
sequenceDiagram
participant A as other local user (owns /tmp/app.lock.rw)
participant V as lock user (write_lock, 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 O_CREAT O_EXCL, write the record, close
A->>H: read the token from the listing
A->>T: link(accounts.csv, holders/link), needs fs.protected_hardlinks=0
A->>H: rename(holders/link, holders/token)
V->>H: poll, _refresh_nonce(), overwrite() opens holders/token by name with O_WRONLY O_NOFOLLOW, no fstat
V->>T: write_all 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 O_NONBLOCK and sleeps until a reader appears
Note over V,H: write_lock(timeout=2) still blocked 150 s later, a holder's heartbeat thread sleeps the same way and on_compromise does not run
end
The scanner's reproducer runs as root and starts each party under its own account with runuser. It writes 0 to /proc/sys/fs/protected_hardlinks 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 attacker_creates_lock_tree_first.py creates /tmp/app.lock.rw, gen/ and holders/ with mode 0777 and one snapshot naming some other w
Running one of these products? Scan your site to see whether it exposes a vulnerable version.
Scan my site free