GHSA-fj95-fr6c-h845 CVE-2026-112934
filelock: SoftReadWriteLock holder refresh writes through a hard link and hangs on a named pipe that another local user renames over its record
- Severity
- 6.1 / 10
- Medium · CVSS 3.1
- Exploit chance (EPSS)
- 0.23%
- Chance of exploitation in the next 30 days. Higher than 14.21% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-62 UNIX Hard Link
- Fix
4.2.1- Upgrade filelock to this version or later
Description
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".
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
.rwtree. Either it created<path>.rw(withgen/andholders/) before the lock's first use in a sticky directory such as/tmp, whereprepare()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-created0777directory). There being first is not needed, because the other user renames the lock user's own0700tree 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 (mode0700) is out of reach. - FIFO variant: needs only that boundary. It works on a default Linux.
fs.protected_hardlinksplays no part, andfs.protected_fifos=1(a Linux setting that refuses some opens of FIFOs in sticky world-writable directories) does not help, since it coversO_CREATopens in sticky directories only, and the other user renames the FIFO into place inside its ownholders/. - 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.protected_hardlinks=0(the kernel's built-in value; observed), while most distributions and the scanner's test image boot with1, under which the other user'slink()fails withEPERM(operation not permitted) and the file stays untouched. macOS and the BSDs have no such restriction by default; that follows from theirlink(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. A0600file in a0755home directory qualifies; a file under a0700directory does not. - Platform: observed on Linux 6.1.
AsyncSoftReadWriteLockruns 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 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.
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. 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:
- 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 ownsholders/and may rename over anything the lock user creates in it. In a shared directory without the sticky bit, being first is not needed. - The lock user publishes its record and drops the descriptor.
Participant.publish()callsOsFiles.create(), which opensholders/<token>withO_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. - The other user swaps the name. Hard link:
link("/home/victim/accounts.csv", "holders/link"), thenrename("holders/link", "holders/<token>");link(2)needs search permission on/home/victimand write permission onholders/, nothing on the file. FIFO:mkfifo(mode0666) +rename, so the name is absent at no point and the lock user does not take the "record is gone, recreate it" branch. - The lock user writes by name. Each poll of a waiting acquire (
poll_interval, 0.25 s) runsadvance()→_keep_claim_fresh()→_refresh_nonce()→overwrite(). With the hard link the lock user owns the linked file, soO_WRONLYsucceeds andwrite_allputsencode_holder(...)at offset 0 without truncating. The lock user's call then ends in an ordinaryTimeout. A granted holder does the same from its heartbeat thread everyheartbeat_interval(30 s by default). Withheartbeat_interval=0.5, the holder also writes through a link planted one second after it got the write lock, andlock.compromisestaysNone. - The link disappears. On
Timeoutor release,leave()unlinksholders/<token>, which is now the other user's link. The target is back to one link andholders/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.
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
#!/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")
Attack sequence
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
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/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 snap…
Affected versions
PyPI
| Package | Vulnerable | Fixed in |
|---|---|---|
| filelock | >= 4.0.0, < 4.2.1 |
4.2.1 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
filelock PyPI → 4.2.1
- pip
pip install --upgrade "filelock>=4.2.1"- poetry
poetry add "filelock>=4.2.1"- uv
uv add "filelock>=4.2.1"
Severity breakdown
CVSS 3.1 6.1
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H
- Attack vector
- Local
- Attack complexity
- Low
- Privileges required
- Low
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- None
- Integrity
- Low
- Availability
- High
Weakness
CWE-62: UNIX Hard Link
The product, when opening a file or directory, does not sufficiently account for when the name is associated with a hard link to a target that is outside of the intended control sphere. This could allow an attacker to cause the product to operate on unauthorized files.
How to prevent it
- Architecture and Design: Follow the principle of least privilege when assigning access rights to entities in a software system. Denying access to a file can prevent an attacker from replacing that file with a link to a sensitive file. Ensure good compartmentalization in the system to provide protected areas that can be trusted.
References
Advisories
Packages
Other links
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| GHSA-wfhq-q4j2-c37m filelock: SoftReadWriteLock follows a lock tree swapped in after its first acquire and writes lock records into directories of the user running the lock | Low | Also affects filelock |
| GHSA-6627-m3xw-93mp filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write bit on them can end a live hold | Medium | Also affects filelock |
| GHSA-9c4x-gm8v-wgc4 filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion | Medium | Also affects filelock |
| GHSA-8mx5-7m5m-r77g filelock: WindowsFileLock ignores mode: a 0o600 lock file and the ReadWriteLock database inherit the directory ACL | Medium | Also affects filelock |
| GHSA-cwqr-hh33-gh44 filelock ReadWriteLock: a crafted lock database makes every acquire stall or hang regardless of timeout | Medium | Also affects filelock |
| GHSA-rfpc-qcw5-3pc4 filelock: SoftFileLock removes a live holder's marker when another user forges the PID in a writable marker | Medium | Also affects filelock |
| GHSA-9j6q-wvj5-jgjg filelock: Symlink swap during SoftFileLock's writability check leaks whether a path exists and is writable by the lock taker | Low | Also affects filelock |
| GHSA-69hj-f68j-2879 filelock: UnixFileLock truncates and chmods any regular file another user renames or hard-links onto the lock path | Medium | Also affects filelock |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor