GitHub Advisoryadvisory · PyPI
GHSA-fj95-fr6c-h845 filelock: SoftReadWriteLock holder refresh writes through a hard link and hangs on a named pipe that another local user renames over its record
MEDIUM
Official page ↗
- Aliases
- CVE-2026-112934
- Ecosystem
- PyPI
- Packages
- filelock
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H
- Weaknesses
- CWE-62
- Reviewed
- 2026-10-28
Affected packages
• PyPI filelock: >= 4.0.0, < 4.2.1 → fixed in 4.2.1
Description
`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) refreshes its holder record (the file under `.rw/holders/` that says a process holds or wants the lock) on every poll of a waiting acquire and on every heartbeat (the periodic refresh a holder runs to keep its record fresh). It does so by reopening `.rw/holders/` by name with a plain blocking `O_WRONLY | O_NOFOLLOW` open and writing at offset 0, without checking what it opened. `O_NOFOLLOW` makes the open fail only when the final name is a symbolic link (a file that redirects to another path).
A different local user (the other user below) who owns the lock's `.rw` tree reads the token of the user running the lock (the lock user below) from the `holders/` listing and renames another node over the record. The other user owns the tree because it created it first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), or because it can write a lock directory without the sticky bit.
A FIFO (a named pipe: a file whose open for writing blocks until some process opens it for reading) makes the lock user's `write_lock()` / `read_lock()` hang regardless of `timeout`. It also stops a lock holder's heartbeat thread without `on_compromise` (the callback that tells a holder it lost the lock) ever running, so the lock grants a second process while the first still believes it holds it. This works on a default Linux.
Where the kernel lets a user hard-link a file it does not own (a hard link is a second name for the same file; Linux with `fs.protected_hardlinks=0`; macOS and the BSDs by their defaults, not executed), a hard link makes the lock user overwrite the first ~135 bytes of one of its own private files with its holder record. Affected versions: filelock >= 4.0.0 and .rw` (with `gen/` and `holders/`) before the lock's first use in a sticky directory such as `/tmp`, where `prepare()` adopts an existing tree whoever owns it and whatever its mode. Or the lock lives in a directory without the sticky bit that the account can write (the scanner checked a root-created `0777` directory). There being first is not needed, because the other user renames the lock user's own `0700` tree aside and puts its own in place; `prepare()` does not run again for a live lock object, and a waiting lock user recreates its record in the replacement. A tree the lock user already created in a sticky directory (mode `0700`) is out of reach.
• FIFO variant: needs only that boundary. It works on a default Linux. `fs.protected_hardlinks` plays no part, and `fs.protected_fifos=1` (a Linux setting that refuses some opens of FIFOs in sticky world-writable directories) does not help, since it covers `O_CREAT` opens in sticky directories only, and the other user renames the FIFO into place inside its own `holders/`.
• Hard-link variant: also needs a kernel that permits a hard link to a file the caller does not own. That is Linux with `fs.protected_hardlinks=0` (the kernel's built-in value; observed), while most distributions and the scanner's test image boot with `1`, under which the other user's `link()` fails with `EPERM` (operation not permitted) and the file stays untouched. macOS and the BSDs have no such restriction by default; that follows from their `link(2)` semantics, and the scanner did not execute it. The target must be a regular file the lock user may write, on the same filesystem as the lock, in a directory the other user can search. A `0600` file in a `0755` home directory qualifies; a file under a `0700` directory does not.
• Platform: observed on Linux 6.1. `AsyncSoftReadWriteLock` runs the same code through an executor thread and loses that thread (from the code, not run). On Windows named pipes cannot live in a regular directory, so there is no FIFO case there.
• Ancestor boundary: protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock user or root and is either not writable by others or sticky.
Affected code
At the scanned commit `b831496b0273`, `OsFiles.overwrite` opens by name with `O_WRONLY | O_NOFOLLOW` and writes without `fstat` (`fstat` would report the type of the file behind the open descriptor), while `read` just above it uses `O_NONBLOCK` and a regular-file check. The blocking open is `_attempt_open` via `_open`. `prepare` adopts an existing tree whoever owns it. The single caller is `Participant._refresh_nonce`, reached from `_keep_claim_fresh` on every poll of a waiting acquire and from `heartbeat`, which knows only `lost` and `transient`, not a call that does not return. The acquire loop checks its deadline and the `_closed` flag only after `advance()` returns (`_sync.py` lines 584–592). The code maps the heartbeat outcome at lines 603–613, and release joins the heartbeat thread for `heartbeat_interval + 1` at line 446.
Introduc…
Related
Alias · CVE (1)
- CVE-2026-112934 filelock is a platform-independent file lock for Python. vulnerability
Weakness · CWE (1)
- CWE-62 UNIX Hard Link weakness
References
- https://github.com/tox-dev/filelock/security/advisories/GHSA-fj95-fr6c-h845 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-112934 advisory
- https://github.com/tox-dev/filelock/pull/842 web
- https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03 web
- https://github.com/tox-dev/filelock package
- https://github.com/tox-dev/filelock/releases/tag/4.2.1 web