{"id":"CVE-2026-112911","aliases":["GHSA-9c4x-gm8v-wgc4"],"url":"https://o3.security/vulnerability/CVE-2026-112911","summary":"filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion","details":"`StrictSoftFileLock`, `AsyncStrictSoftFileLock`, `SoftReadWriteLock` and `AsyncSoftReadWriteLock` keep their whole ownership state as files in a directory derived from the lock path (`<path>.filelock/claims`, `<path>.rw/{gen,holders}`). When that directory already exists they adopt it after checking only that it is a real directory, not who owns it. Another local user who can create names in the lock's directory can create the tree before the first acquire on that path by the user running the lock (below: the lock user). In `/tmp`, `/var/tmp` or another sticky shared directory any user can do that; the sticky bit is a directory flag that lets only a file's owner remove or rename it. The other user then owns the tree. They can unlink the lock user's claims while the lock user holds the lock, so a second process of the lock user, root included, enters the same critical section and neither process sees an error.\n\nAgainst `SoftReadWriteLock` the same position also admits two writers with `on_compromise` silent. It redirects the lock user's own creates and renames into a directory the other user picks (the scanner replaced a repository's `.git/HEAD` with a filelock record written with the lock user's privileges). It also keeps the lock user's acquire or release running past any `timeout`. Affected versions are filelock 3.30.0 through 4.2.0 (finding `ANT-2026-Q5VK5JEE`).\n\n## Severity\n\n**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 6.3 (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:H` (high attack complexity): the other user must create the protocol directory before the lock user's first acquire on that path. That depends on the path being new, or on a reboot, a tmp cleaner or the documented `<path>.rw` recovery having removed its tree; once planted, the rest needs no race. `I:H` (high integrity impact): two holders write whatever the lock protects, and for `SoftReadWriteLock` the lock user's process creates, replaces or deletes files named `HEAD`, `epoch` and `0000000000000000000N` in any directory the lock user can write. `A:H` (high availability impact): the owner of `<path>.rw` can make the lock user's `read_lock(timeout=1)` spin at full CPU without end, cost 22 s per acquire, or pin 1 MiB of memory per planted file until a heartbeat thread dies of `MemoryError`. `C:N` (no confidentiality impact). `S:U` (scope unchanged).\n\nClassification from the review: **security, conditional** on the other user winning initial ownership of the protocol directory. The scanner rated it high (CWE-283). With `AC:L` the same impacts would score 7.1.\n\n## Scope and preconditions\n\n- The other user can create names in the directory that holds the lock. In a sticky `1777` directory such as `/tmp` any local user can. The sticky bit stops that user from renaming or removing a directory the lock user created, but does nothing for one the other user created first.\n- The other user gets there first: `<path>.filelock` (strict) or `<path>.rw` (soft read-write) must not exist when the lock user's process first acquires. `_strict.py` does not remove the directory at any point, so a path the lock user has already used stays safe for as long as the tree exists. The exposure is a new lock path, or one whose tree a reboot, a tmp cleaner, a cache purge or the recovery described in `docs/how-to.rst` removed.\n- For the strict lock the planted directory must be writable by the lock user (`0777`); a planted `0755` directory makes the lock user's acquire fail with `PermissionError` instead. Planting only the outer `<path>.filelock` is enough. The lock user creates `claims` inside it, and the other user, as owner of the parent, renames that `claims` aside and creates an empty one.\n- The Linux `fs.protected_regular`, `fs.protected_symlinks`, `fs.protected_hardlinks` and `fs.protected_fifos` sysctls (kernel settings, all enabled in the scanner's image) do not interfere. They cover regular files, symlinks, hard links and FIFOs, not directories. The `SoftReadWriteLock` symlink redirect below also passes `fs.protected_symlinks=1`, because the symlink (a file that points at another path) sits in the other user's own non-sticky directory.\n- The affected classes are `StrictSoftFileLock` and `AsyncStrictSoftFileLock` (which inherits `_acquire`), and `SoftReadWriteLock` and `AsyncSoftReadWriteLock` with default options. `FileLock` gives way to the same position (when the other user creates the lock file first with mode `0666` and unlinks it during a hold, a second lock user process acquires), so the strict lock is not weaker than the native one here. This advisory reports it because `StrictSoftFileLock` is the lock documented as fail-closed and already raises `SoftFileLockProtocolError` for a symlink or a regular file at the same names, while it uses a directory that belongs to someone else without any signal. The `SoftReadWriteLock` half has no such counterpart: its redirected creates, renames and unlinks land outside the lock's own files.\n- A non-sticky directory writable by other users is outside the boundary below. There anyone who can create the directory can also rename the lock user's own tree aside, and no check inside the lock can tell that from a first use.\n\nThese protections against another local user hold only when 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 entries in a non-sticky directory above the lock controls every name below it, which no file-based lock can defend against.\n\n## Affected code\n\nAt the scanned commit b831496: [`_ensure_protocol_directory`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L996-L1006) returns for any existing real directory, whatever its owner or mode. [`StrictSoftFileLock._acquire`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L127-L128) calls it for both levels and does not look at directory ownership again. [`_attempt_doorway`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L179-L181) decides from the claim scan alone, and [`_unlink_owner_path_result`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_strict.py#L896-L900) counts a missing claim as removed on release. The sibling site is [`OsFiles.prepare`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft_rw/_storage.py#L121-L133). Its `mkdir(mode=0o700)` takes effect only when it creates the directory, and its `lstat` check reads neither `st_uid` nor the permission bits. It runs once per instance, and every later `OsFiles` method takes a full path, with `O_NOFOLLOW` guarding only the last component.\n\n## Introduced\n\nThe strict site came with the claims protocol in 650c17c79c54 (\"feat(strict): replace shared markers with owner claims\", #659, 2026-07-14), first released in 3.30.0. The `SoftReadWriteLock` site came with the `<path>.rw` tree itself in f9743288a288 (\"fix(soft-rw): replace the state mutex with a generation log\", #738, 2026-09-17), first released in 4.0.0. Both match the git tags; the advisory range starts at the earlier one.\n\n## Mechanism\n\nThe owner of a directory can unlink and rename every entry in it, whatever the entries' own owner and mode. A strict hold consists of two names, `intent-v1-<token>.claim` and `held-v1-<token>.claim`, in `<path>.filelock/claims`. The sentinel (the lock file at `<path>`) is permanent and says nothing about who holds. When the other user runs the equivalent of `mkdir -m 777 -p work.lock.filelock/claims` before the lock user first uses `work.lock`, every lock user acquire publishes its claims into a directory the other user can empty. Lock user process A acquires and holds. The other user unlinks both of A's claims; both `unlink` calls succeed, because write permission on the directory is enough.\n\nLock user process B calls `acquire()` while A is inside its critical section. `_attempt_doorway` starts with `if _read_existing_claims(...): return False`, which turns a contender away while a holder exists, but the scan is now empty. B publishes its own intent and held claims, sees only those in the later rescans, and wins.\n\nA is not told. A strict holder does not look at its claims between acquire and release, and on release `_unlink_owner_path_result` takes the `FileNotFoundError` for each missing claim as \"already removed\", so `_release` finishes without error. The break involves neither a `lifetime` nor a lease, and neither `break_lock()` nor `force_break()`. The benign path shows what the check is missing. When the lock user's first acquire creates the directories itself they are `0755` and owned by the lock user (umask `022`). The other user's `unlink` then fails with `EACCES`, its `rename` or `rmdir` of `work.lock.filelock` fails with `EPERM` under the sticky bit, and B raises `Timeout`.\n\n`OsFiles.prepare` has the same gap for `SoftReadWriteLock`. The code suppresses `EEXIST` from `mkdir(mode=0o700)`, and the check that follows looks only at `S_ISDIR` and `S_ISLNK`. The class docstring's protection against \"same-host different-UID users via `0o600` / `0o700` permissions\" therefore holds only for whoever gets there first. With the other user owning `/tmp/app.lock.rw`, `gen` and `holders` before the lock user first uses `SoftReadWriteLock(\"/tmp/app.lock\")`, the scanner observed the following, with the same two accounts.\n\n**Creates and renames redirected through a symlink.** The other user's tree holds a generation log (the record of who holds the lock, under `gen/`) naming a live writer, so the lock user polls. Once the lock user's record appears in `holders/` (its `prepare()` is over), the other user renames `gen` aside and puts a symlink to `/home/victim/project/.git`, a repository inside a `0700` directory, in its place. The lock user's `write_lock()` returns without error. By then `GenerationLog.commit()` has created `epoch`, `00000000000000000001` and `00000000000000000002` in `.git` and renamed a record over `.git/HEAD`, which now reads `<epoch>\\nfilelock-rw/1\\ngeneration=2\\n`, owned by the lock user; `git status` answers `fatal: not a git repository`. With `holders` swapped instead, `Participant._sweep()` unlinks files with 32-hex-digit names in the target directory once they have stayed unchanged for `stale_threshold`. This needs no race: every later acquisition on an instance that has passed `prepare()` goes through the swapped `gen`.\n\n**Two writers, no `on_compromise`.** `OsFiles.read()` accepts any regular file, so the owner of `gen` writes snapshots itself. While lock user process V1 is inside `write_lock()`, the other user creates the next `gen/<N>` naming no holder, and V2's `write_lock(timeout=2)` succeeds at once. A further forged generation naming both tokens keeps both heartbeats answering `ok`, because `Participant.heartbeat()` tests only `self.token in latest.members`; `compromise` was still `None` in both after the default 30 s heartbeat. The other user reads no lock user record at any point: listing `gen/` and `holders/` gives the generation and the tokens.\n\n**Hangs, memory growth and bare `OSError`s.** The protocol code treats every record in `gen/` and `holders/` as written by a cooperating peer, so the tree's owner can do several things. They can make `Participant.leave()` retry a refused commit without end (`read_lock(timeout=1)` spins at full CPU instead of raising `Timeout`). They can keep `GenerationLog._probe()` walking by appending records ahead of it (3 s of writes kept `read_lock(timeout=1)` busy for about 60 s). They can make every `acquire_write(timeout=1)` and `release()` on a free lock take about 22 s with 200 000 generation names hard-linked into a sticky `gen/` (a hard link is a second name for the same file), which `_list()` and `_compact_through()` walk again on each poll because `OsFiles.unlink()` swallows the `EPERM`.\n\nThey can also pin 1 MiB of the lock user's memory per sparse file planted in `holders/`, because `Ledger.observe()` keeps every swept record whole (2 000 hard links to one 1 MiB file: 2 028 MiB resident when `acquire_write()` returns). Under an address-space limit the sweep raises `MemoryError` inside the heartbeat thread, which nothing handles, so the record is not refreshed again. After `stale_threshold` a second lock user process gets the write lock while the first still has `compromise` `None`. And they can make every acquire of a free lock raise a bare `OSError` with a mode-`000` orphan in `holders/` or a symlink at `gen/HEAD`. None of these is reachable in a tree the lock user created itself: it is `0700` at all three levels and the other user's writes fail with `EACCES`.\n\n## Reproduction\n\nThe scanner's reproducer for `ANT-2026-Q5VK5JEE`, quoted as received. It ran against commit `b831496b0273`.\n\n**`repro`**\n\n```python\n#!/usr/bin/env python3\nimport os\nimport pwd\nimport subprocess\nimport sys\nimport tempfile\n\n# the companions are passed with -c, so victim and attacker need no read access to /tmp/repro.d\nvictim_worker = open(\"/tmp/repro.d/victim_worker.py\").read()\nattacker_plant_directory = open(\"/tmp/repro.d/attacker_plant_directory.py\").read()\nattacker_remove_claims = open(\"/tmp/repro.d/attacker_remove_claims.py\").read()\nas_victim = [\"runuser\", \"-u\", \"victim\", \"--\", sys.executable, \"-c\", victim_worker]\nas_attacker = [\"runuser\", \"-u\", \"attacker\", \"--\", sys.executable, \"-c\"]\n\n\ndef two_victims_contend_while_attacker_removes_claims(lock_path):\n    claims = f\"{lock_path}.filelock/claims\"\n    first = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n    first_lines = [first.stdout.readline().strip()]\n    claims_owner = pwd.getpwuid(os.stat(claims).st_uid).pw_name\n\n    removal = subprocess.run([*as_attacker, attacker_remove_claims, lock_path], check=True, capture_output=True, text=True)\n\n    # the first process has not been sent its line yet: it is still inside its critical section\n    second = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)\n    second_lines = [second.stdout.readline().strip()]\n\n    second_lines += second.communicate(\"\\n\")[0].split()\n    first_lines += first.communicate(\"\\n\")[0].split()\n    print(f\"  {claims} is owned by {claims_owner}\")\n    print(f\"  attacker, while the first victim process holds the lock: {'; '.join(removal.stdout.splitlines())}\")\n    print(f\"  first victim process: {first_lines}\")\n    print(f\"  second victim process, started while the first is inside: {second_lines}\")\n    return first_lines, second_lines\n\n\n# set up: a root-owned, sticky, world-writable directory, like /tmp itself\nshared = tempfile.mkdtemp(prefix=\"shared-\", dir=\"/tmp\")\nos.chmod(shared, 0o1777)\n\n# control: the victim's first acquire creates control.lock.filelock/claims itself\nprint(\"CONTROL  victim creates the protocol directories\")\n_, control_second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/control.lock\")\n\n# attack: the attacker creates work.lock.filelock/claims before the victim first uses work.lock\nprint(\"OBSERVED attacker creates the protocol directories first\")\nsubprocess.run([*as_attacker, attacker_plant_directory, f\"{shared}/work.lock\"], check=True)\nfirst, second = two_victims_contend_while_attacker_removes_claims(f\"{shared}/work.lock\")\n\n# check: the second process must be kept out while the first is inside, as it is in the control round\ninside_and_released_cleanly = [\"acquired\", \"is_locked=True\", \"released\"]\ncontrol_excludes = control_second == [\"Timeout\"]\nboth_inside = first == second == inside_and_released_cleanly\nprint(f\"control_excludes={control_excludes} both_inside={both_inside}\")\nprint(\"BUG\" if control_excludes and both_inside else \"OK\")\n```\n\n**`repro.d/attacker_plant_directory.py`**\n\n```python\nimport os\nimport sys\n\nos.umask(0)  # 0777: the victim must be able to publish its claims inside\nos.makedirs(f\"{sys.argv[1]}.filelock/claims\")\n```\n\n**`repro.d/attacker_remove_claims.py`**\n\n```python\nimport os\nimport sys\n\nclaims = f\"{sys.argv[1]}.filelock/claims\"\nfor name in sorted(os.listdir(claims)):\n    try:\n        os.unlink(f\"{claims}/{name}\")\n        print(f\"removed {name}\")\n    except PermissionError as error:\n        print(f\"cannot remove {name}: {error.strerror}\")\n```\n\n**`repro.d/victim_worker.py`**\n\n```python\nimport sys\n\nfrom filelock import SoftFileLockProtocolError, StrictSoftFileLock, Timeout\n\nlock = StrictSoftFileLock(sys.argv[1], timeout=1)\ntry:\n    lock.acquire()\nexcept (Timeout, SoftFileLockProtocolError) as error:\n    print(type(error).__name__, flush=True)\n    sys.exit()\nprint(\"acquired\", flush=True)\n\nsys.stdin.readline()  # the critical section: it lasts until the parent sends a line\n\nprint(f\"is_locked={lock.is_locked}\", flush=True)\nlock.release()\nprint(\"released\", flush=True)\n```\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant X as other account (uid attacker)\n    participant D as work.lock.filelock/claims (sticky /tmp)\n    participant A as holder A (uid victim)\n    participant B as contender B (uid victim)\n    rect rgb(255, 214, 214)\n        X->>D: mkdir -m 777 -p, before the lock user's first use\n        A->>D: acquire(): directory exists, is a directory, adopted\n        A->>D: publish intent-v1-<token>.claim and held-v1-<token>.claim\n        X->>D: unlink both claims (owner of the directory may)\n        B->>D: acquire(): scan finds no claims, publishes its own, wins\n        Note over A,B: both inside, is_locked True in both\n        A->>D: release(): claims missing, counted as removed, no error\n    end\n    Note over A,D: control: directory created by the lock user is 0755, the other account's unlink gets EACCES, B raises Timeout\n```\n\n## Evidence\n\nThe scanner's reproducer (`victim` uid 1000, `attacker` uid 1001, lock `work.lock` in a root-owned `1777` directory under `/tmp`, `StrictSoftFileLock(path, timeout=1)`, Linux 6.1, CPython 3.14.8, overlayfs) runs the same contention twice. In the control round on `control.lock`, whose directories `victim` creates, it prints `claims is owned by victim`, the unlinks by `attacker` answer `Permission denied`, the first process prints `['acquired', 'is_locked=True', 'released']` and the second `['Timeout']`. In the observed round on `work.lock`, planted by `attacker`, it prints `claims is owned by attacker` and `removed held-v1-….claim; removed intent-v1-….claim`, and both processes print `['acquired', 'is_locked=True', 'released']`, the second having acquired before the first was let out. The verdict line reads `control_excludes=True both_inside=True` and then `BUG` after about 2 s. The scanner ran the variant that plants only the outer `<path>.filelock` (then renames the `claims` of `victim` aside) by hand with the same result, and each `SoftReadWriteLock` effect listed under Mechanism in its own run with the same two accounts.\n\nOur review ran the reproducer against the 4.2.0 release image and the scanned commit (`BUG` on both) and against the head of #839 (`OK`). The review's regression tests for both sites need a sticky `/tmp` of another UID and ran there.\n\n## Impact\n\nAnother local user who can create names in the lock's directory puts two processes of the lock user, root included, into the same `StrictSoftFileLock` critical section at a moment of the other user's choosing, with no exception or other signal to either. Two processes then modify whatever the lock protects at the same time.\n\nAgainst `SoftReadWriteLock` the same position decides when two writers, or a writer and readers, are inside together and keeps `compromise` silent in all of them. It also gets file operations made with the lock user's privileges (root's, for a root service) in any directory the lock user can write: a file named `HEAD` replaced, files named `epoch` and `0000000000000000000N` created, files with 32-hex-digit names deleted. The bytes written are filelock's own records, not content the other user chooses. It can keep the lock user's acquire or release running past any `timeout`, or grow the lock user's memory by 1 MiB per planted file. It is **not** a disclosure of the lock user's data, and it does not reach a path the lock user has already used while that tree still exists.\n\n## Workarounds\n\nKeep strict and soft read-write locks in a directory other users cannot write, rather than `/tmp` or another sticky shared directory. Where a shared directory is unavoidable, create `<path>.filelock/claims` (or `<path>.rw` with `gen` and `holders`) as the service user before any other account can, for example at install or boot, and check `stat -c %U` of an existing tree before the first use. A tmp cleaner, a reboot or the `<path>.rw` recovery removes the tree and reopens the window. The kernel refuses a planted `0755` strict tree (the lock user's acquire fails with `PermissionError`), so an acquire that raises that error on a new path without a known cause is worth investigating.\n\n## Patched version\n\n4.2.1.\n\nFix: https://github.com/tox-dev/filelock/pull/839\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-Q5VK5JEE`, scanned commit b831496b0273)\n- 2026-10-16 00:46 UTC: draft advisory; later narrowed to this finding, with `ANT-2026-AD6SUOON`, `ANT-2026-SVFEYX89`, `ANT-2026-M9OPCLPB` and `ANT-2026-6BC3LKF9` moved to advisories of their own\n- 2026-10-16 01:31 UTC: fix PR #839 opened","published":"2026-10-20T14:21:24.480Z","modified":"2026-10-29T02:30:32.212090842Z","cvss":{"score":6.3,"severity":"MEDIUM","vector":"CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"filelock","fixedVersion":"4.2.1"}],"fix":{"url":"https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03","label":"tox-dev/filelock@bfa066d"},"references":[{"type":"WEB","url":"https://github.com/tox-dev/filelock/releases/tag/4.2.1"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/112xxx/CVE-2026-112911.json"},{"type":"ADVISORY","url":"https://github.com/tox-dev/filelock/security/advisories/GHSA-9c4x-gm8v-wgc4"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112911"},{"type":"FIX","url":"https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03"},{"type":"FIX","url":"https://github.com/tox-dev/filelock/pull/839"},{"type":"PACKAGE","url":"https://github.com/tox-dev/filelock"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-29T02:30:32.212090842Z"}}