Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍
🐍 PyPI
Not in CISA KEV
MEDIUM severity

CVE-2026-112911 — filelock

MEDIUMFix: tox-dev/filelock@bfa066d

CVE-2026-112911 is a medium-severity (CVSS 6.3) CWE-283 vulnerability in filelock. A fix is available for filelock — see the affected versions and patch details below.

filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion

Also known asGHSA-9c4x-gm8v-wgc4
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 28, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for CVE-2026-112911.

EPSS Exploitation Probability

via FIRST.org ↗
0.1%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs4th percentile — riskier than 4% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

CVE-2026-112911 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 396,966 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐍filelock

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.

Description

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.

Against 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).

Severity

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).

Classification 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.

Scope and preconditions

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

These 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.

Affected code

At the scanned commit b831496: _ensure_protocol_directory returns for any existing real directory, whatever its owner or mode. StrictSoftFileLock._acquire calls it for both levels and does not look at directory ownership again. _attempt_doorway decides from the claim scan alone, and _unlink_owner_path_result counts a missing claim as removed on release. The sibling site is OsFiles.prepare. 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.

Introduced

The 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.

Mechanism

The 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.

Lock 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.

A 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.

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.

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.

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.

Hangs, memory growth and bare OSErrors. 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.

They 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.

Reproduction

The scanner's reproducer for ANT-2026-Q5VK5JEE, quoted as received. It ran against commit b831496b0273.

repro

#!/usr/bin/env python3
import os
import pwd
import subprocess
import sys
import tempfile

# the companions are passed with -c, so victim and attacker need no read access to /tmp/repro.d
victim_worker = open("/tmp/repro.d/victim_worker.py").read()
attacker_plant_directory = open("/tmp/repro.d/attacker_plant_directory.py").read()
attacker_remove_claims = open("/tmp/repro.d/attacker_remove_claims.py").read()
as_victim = ["runuser", "-u", "victim", "--", sys.executable, "-c", victim_worker]
as_attacker = ["runuser", "-u", "attacker", "--", sys.executable, "-c"]


def two_victims_contend_while_attacker_removes_claims(lock_path):
    claims = f"{lock_path}.filelock/claims"
    first = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
    first_lines = [first.stdout.readline().strip()]
    claims_owner = pwd.getpwuid(os.stat(claims).st_uid).pw_name

    removal = subprocess.run([*as_attacker, attacker_remove_claims, lock_path], check=True, capture_output=True, text=True)

    # the first process has not been sent its line yet: it is still inside its critical section
    second = subprocess.Popen([*as_victim, lock_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
    second_lines = [second.stdout.readline().strip()]

    second_lines += second.communicate("\n")[0].split()
    first_lines += first.communicate("\n")[0].split()
    print(f"  {claims} is owned by {claims_owner}")
    print(f"  attacker, while the first victim process holds the lock: {'; '.join(removal.stdout.splitlines())}")
    print(f"  first victim process: {first_lines}")
    print(f"  second victim process, started while the first is inside: {second_lines}")
    return first_lines, second_lines


# set up: a root-owned, sticky, world-writable directory, like /tmp itself
shared = tempfile.mkdtemp(prefix="shared-", dir="/tmp")
os.chmod(shared, 0o1777)

# control: the victim's first acquire creates control.lock.filelock/claims itself
print("CONTROL  victim creates the protocol directories")
_, control_second = two_victims_contend_while_attacker_removes_claims(f"{shared}/control.lock")

# attack: the attacker creates work.lock.filelock/claims before the victim first uses work.lock
print("OBSERVED attacker creates the protocol directories first")
subprocess.run([*as_attacker, attacker_plant_directory, f"{shared}/work.lock"], check=True)
first, second = two_victims_contend_while_attacker_removes_claims(f"{shared}/work.lock")

# check: the second process must be kept out while the first is inside, as it is in the control round
inside_and_released_cleanly = ["acquired", "is_locked=True", "released"]
control_excludes = control_second == ["Timeout"]
both_inside = first == second == inside_and_released_cleanly
print(f"control_excludes={control_excludes} both_inside={both_inside}")
print("BUG" if control_excludes and both_inside else "OK")

repro.d/attacker_plant_directory.py

import os
import sys

os.umask(0)  # 0777: the victim must be able to publish its claims inside
os.makedirs(f"{sys.argv[1]}.filelock/claims")

repro.d/attacker_remove_claims.py

import os
import sys

claims = f"{sys.argv[1]}.filelock/claims"
for name in sorted(os.listdir(claims)):
    try:
        os.unlink(f"{claims}/{name}")
        print(f"removed {name}")
    except PermissionError as error:
        print(f"cannot remove {name}: {error.strerror}")

repro.d/victim_worker.py

import sys

from filelock import SoftFileLockProtocolError, StrictSoftFileLock, Timeout

lock = StrictSoftFileLock(sys.argv[1], timeout=1)
try:
    lock.acquire()
except (Timeout, SoftFileLockProtocolError) as error:
    print(type(error).__name__, flush=True)
    sys.exit()
print("acquired", flush=True)

sys.stdin.readline()  # the critical section: it lasts until the parent sends a line

print(f"is_locked={lock.is_locked}", flush=True)
lock.release()
print("released", flush=True)

Attack sequence

sequenceDiagram
    participant X as other account (uid attacker)
    participant D as work.lock.filelock/claims (sticky /tmp)
    participant A as holder A (uid victim)
    participant B as contender B (uid victim)
    rect rgb(255, 214, 214)
        X->>D: mkdir -m 777 -p, before the lock user's first use
        A->>D: acquire(): directory exists, is a directory, adopted
        A->>D: publish intent-v1-<token>.claim and held-v1-<token>.claim
        X->>D: unlink both claims (owner of the directory may)
        B->>D: acquire(): scan finds no claims, publishes its own, wins
        Note over A,B: both inside, is_locked True in both
        A->>D: release(): claims missing, counted as removed, no error
    end
    Note over A,D: control: directory created by the lock user is 0755, the other account's unlink gets EACCES, B raises Timeout

Evidence

The 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.

Our 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.

Impact

Another 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.

Against 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.

Workarounds

Keep 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.

Patched version

4.2.1.

Fix: https://github.com/tox-dev/filelock/pull/839

Credits

Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.

  • 2026-10-15 23:08 UTC: report received (finding ANT-2026-Q5VK5JEE, scanned commit b831496b0273)
  • 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
  • 2026-10-16 01:31 UTC: fix PR #839 opened

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐍PyPIfilelock≥ 3.30.0&&< 4.2.14.2.1pip install --upgrade 'filelock==4.2.1'

Affected Products

1 product · 1 configurations
Application
filelocktox-dev
≥ 3.30.0 && < 4.2.1
range

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for filelock, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update filelock to 4.2.1 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-112911 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

Frequently Asked Questions

`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
O3 Security · Impact-Aware SCA

Is CVE-2026-112911 in your dependencies?

Find it across PyPI, including transitive dependencies.

CVE-2026-112911: filelock — Fixed in 4.2.1