CVE-2026-112934: filelock: FIFO/hard-link abuse hangs locks, corrupts files
GHSA-fj95-fr6c-h845MEDIUMfilelock 4.0.0–4.2.0 has a flaw in SoftReadWriteLock where a different local user who owns the lock's directory tree can swap a lock holder's record for a FIFO or hard link. The FIFO variant works on default Linux: it makes lock acquisition hang past any timeout and silently stops a holder's heartbeat, so two processes can both believe they hold the write lock. Where hard links to files the caller doesn't own are permitted (macOS/BSDs, or Linux with protected_hardlinks=0), the lock process also overwrites the first ~135 bytes of one of its own private files with a lock record. Exposure is limited to multi-user hosts with shared lock directories (CVSS 6.1, local access, EPSS top 86%, not in KEV, no public exploit or Nuclei template), but filelock has 5,436 dependents, so it sits underneath many ML training and serving stacks. Upgrade to filelock 4.2.1, keep lock paths in directories writable only by the locking account, and keep fs.protected_hardlinks=1.
What is the risk?
Medium. Exploitation needs a local account on the same host and a lock tree in a shared or sticky directory such as /tmp, and attack complexity is low because there is no race. The FIFO hang and heartbeat stall work on a default Linux kernel. The file-overwrite variant also needs a permissive hard-link policy. Confidentiality is not affected and integrity impact is limited to filelock's own record bytes. Availability impact is high because a lock user can block indefinitely, and a lock holder can be evicted without being told. Exploitation likelihood is low-to-moderate: EPSS is 0.00233, there is no public exploit, and the preconditions are narrow. Risk rises on shared GPU nodes, shared notebook servers and multi-tenant CI runners.
How does the attack unfold?
What systems are affected?
| Package | Ecosystem | Vulnerable Range | Patched |
|---|---|---|---|
| Anthropic Python | pip | >= 4.0.0, <= 4.2.0 | 4.2.1 |
Do you use Anthropic Python? You're affected.
How severe is it?
What is the attack surface?
What should I do?
1 step1) Upgrade filelock to 4.2.1 or later (fix: PR #842, commit bfa066d). 2) Inventory use of SoftReadWriteLock and AsyncSoftReadWriteLock, since the standard FileLock is not described as affected. 3) Place lock paths in directories writable only by the locking account or root. Make every ancestor owned by that account or root and either non-writable by others or sticky. Do not share lock paths via 0777 or group-writable directories. 4) In /tmp-style directories, have the locking account create <path>.rw first and refuse to use a tree owned by another user. 5) Keep fs.protected_hardlinks=1 on Linux; this removes the overwrite variant but not the FIFO stall. 6) Detection: look for FIFOs or non-regular files, or files owned by another UID, inside *.rw/holders/ directories. Also look for processes stuck in open() on lock paths and for unexpected Timeout-less hangs in jobs. Check that filelock versions in lockfiles and container images are not between 4.0.0 and 4.2.0.
How is it classified?
Which compliance frameworks are affected?
This CVE is relevant to:
Frequently Asked Questions
What is CVE-2026-112934?
filelock 4.0.0–4.2.0 has a flaw in SoftReadWriteLock where a different local user who owns the lock's directory tree can swap a lock holder's record for a FIFO or hard link. The FIFO variant works on default Linux: it makes lock acquisition hang past any timeout and silently stops a holder's heartbeat, so two processes can both believe they hold the write lock. Where hard links to files the caller doesn't own are permitted (macOS/BSDs, or Linux with protected_hardlinks=0), the lock process also overwrites the first ~135 bytes of one of its own private files with a lock record. Exposure is limited to multi-user hosts with shared lock directories (CVSS 6.1, local access, EPSS top 86%, not in KEV, no public exploit or Nuclei template), but filelock has 5,436 dependents, so it sits underneath many ML training and serving stacks. Upgrade to filelock 4.2.1, keep lock paths in directories writable only by the locking account, and keep fs.protected_hardlinks=1.
Is CVE-2026-112934 actively exploited?
No confirmed active exploitation of CVE-2026-112934 has been reported, but organizations should still patch proactively.
How to fix CVE-2026-112934?
1) Upgrade filelock to 4.2.1 or later (fix: PR #842, commit bfa066d). 2) Inventory use of SoftReadWriteLock and AsyncSoftReadWriteLock, since the standard FileLock is not described as affected. 3) Place lock paths in directories writable only by the locking account or root. Make every ancestor owned by that account or root and either non-writable by others or sticky. Do not share lock paths via 0777 or group-writable directories. 4) In /tmp-style directories, have the locking account create <path>.rw first and refuse to use a tree owned by another user. 5) Keep fs.protected_hardlinks=1 on Linux; this removes the overwrite variant but not the FIFO stall. 6) Detection: look for FIFOs or non-regular files, or files owned by another UID, inside *.rw/holders/ directories. Also look for processes stuck in open() on lock paths and for unexpected Timeout-less hangs in jobs. Check that filelock versions in lockfiles and container images are not between 4.0.0 and 4.2.0.
What systems are affected by CVE-2026-112934?
This vulnerability affects the following AI/ML architecture patterns: training pipelines, model serving, shared GPU/notebook servers, model cache and artifact stores, CI/CD runners for ML.
What is the CVSS score for CVE-2026-112934?
CVE-2026-112934 has a CVSS v3.1 base score of 6.1 (MEDIUM). The EPSS exploitation probability is 0.23%.
What is the AI security impact?
Affected AI Architectures
MITRE ATLAS Techniques
AML.T0010.001AI SoftwareAML.T0029Denial of AI ServiceCompliance Controls Affected
What are the technical details?
Original Advisory
`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 `.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.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/`. - 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 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 `O_WRONLY | O_NOFOLLOW` and writes without `fstat`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L94-L100) (`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](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L36-L43). The blocking open is [`_attempt_open`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L156-L160) via [`_open`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L143). [`prepare` adopts an existing tree whoever owns it](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). The single caller is [`Participant._refresh_nonce`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L544-L545), reached from [`_keep_claim_fresh`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L487-L496) on every poll of a waiting acquire and from [`heartbeat`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L547-L560), 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](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L584-L592)). The code maps the heartbeat outcome at [lines 603–613](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L603-L613), and release joins the heartbeat thread for `heartbeat_interval + 1` at [line 446](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L446). ## 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: 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 `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. 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 (`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`. 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").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`** ```python #!/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`** ```python 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`** ```python 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`** ```python 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 ```mermaid 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 snapshot naming some other writer, so the lock user's acquire polls instead of getting the lock at once. `victim_app.py` runs `SoftReadWriteLock("/tmp/app.lock").write_lock(timeout=2)`. `attacker_plants_hard_link.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.protected_hardlinks=1`) the reachable effect is the FIFO hang. The other user makes a lock user's `acquire_read` / `acquire_write` / `read_lock` / `write_lock` 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 `stale_threshold` (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 `on_compromise` 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 `O_NOFOLLOW` 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.protected_hardlinks=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)
Exploitation Scenario
On a shared GPU node, a low-privileged user pre-creates /tmp/model-cache.lock.rw with world-writable gen/ and holders/ directories. When a service account's job starts to fetch a model and takes a SoftReadWriteLock, the attacker reads the holder token from the listing and renames a FIFO over the holder record. The job's lock call blocks forever regardless of its timeout, stalling the model download or training run. In a variant against a running holder, the heartbeat thread sleeps on the FIFO, the lock is considered stale after 90 seconds, a second worker is granted the write lock, and both write the same checkpoint with no on_compromise callback. On systems that permit hard links to others' files, the attacker links a victim-owned file instead and the job overwrites its first ~135 bytes.
Weaknesses (CWE)
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.
- [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.
Source: MITRE CWE corpus.
CVSS Vector
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:HReferences
Timeline
Related Vulnerabilities
CVE-2026-9460310.0Podman: checkpoint images override sandbox on run
Same package: anthropicCVE-2026-277758.8Gitea: cached permission check allows repo takeover
Same package: anthropicCVE-2026-544498.8LangBot: RCE via arbitrary STDIO MCP command
Same package: anthropicCVE-2026-75748.7Claude Desktop: VM integrity bypass enables RCE
Same package: anthropicCVE-2026-554298.7Coder: cross-workspace agent hijack via app ID reuse
Same package: anthropic