GHSA-9c4x-gm8v-wgc4: Medium severity pip/filelock vulnerability

Published Oct 28, 2026
·
Updated

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 oncompromise 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 readlock(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.protectedregular, fs.protectedsymlinks, fs.protectedhardlinks and fs.protectedfifos 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.protectedsymlinks=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: ensureprotocoldirectory 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. attemptdoorway decides from the claim scan alone, and unlinkownerpathresult 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 stuid nor the permission bits. It runs once per instance, and every later OsFiles method takes a full path, with ONOFOLLOW 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. attemptdoorway starts with if readexistingclaims(...): 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 unlinkownerpathresult 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 breaklock() nor forcebreak(). 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 SISDIR and SISLNK. 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 writelock() 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 stalethreshold. This needs no race: every later acquisition on an instance that has passed prepare() goes through the swapped gen.

Two writers, no oncompromise. OsFiles.read() accepts any regular file, so the owner of gen writes snapshots itself. While lock user process V1 is inside writelock(), the other user creates the next gen/<N> naming no holder, and V2's writelock(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 (readlock(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 readlock(timeout=1) busy for about 60 s). They can make every acquirewrite(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 compactthrough() 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 acquirewrite() 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 stalethreshold 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

python #!/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 victimworker = open("/tmp/repro.d/victimworker.py").read() attackerplantdirectory = open("/tmp/repro.d/attackerplantdirectory.py").read() attackerremoveclaims = open("/tmp/repro.d/attackerremoveclaims.py").read() asvictim = ["runuser", "-u", "victim", "--", sys.executable, "-c", victimworker] asattacker = ["runuser", "-u", "attacker", "--", sys.executable, "-c"]

def twovictimscontendwhileattackerremovesclaims(lockpath): claims = f"{lockpath}.filelock/claims" first = subprocess.Popen([asvictim, lockpath], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) firstlines = [first.stdout.readline().strip()] claimsowner = pwd.getpwuid(os.stat(claims).stuid).pwname

removal = subprocess.run([asattacker, attackerremoveclaims, lockpath], check=True, captureoutput=True, text=True)

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

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

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") , controlsecond = twovictimscontendwhileattackerremovesclaims(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([asattacker, attackerplantdirectory, f"{shared}/work.lock"], check=True) first, second = twovictimscontendwhileattackerremovesclaims(f"{shared}/work.lock")

check: the second process must be kept out while the first is inside, as it is in the control round insideandreleasedcleanly = ["acquired", "islocked=True", "released"] controlexcludes = controlsecond == ["Timeout"] bothinside = first == second == insideandreleasedcleanly print(f"controlexcludes={controlexcludes} bothinside={bothinside}") print("BUG" if controlexcludes and bothinside else "OK")

repro.d/attackerplantdirectory.py

python 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/attackerremoveclaims.py

python 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/victimworker.py

python 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"islocked={lock.islocked}", flush=True) lock.release() print("released", flush=True)

Attack sequence

mermaid 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, islocked 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', 'islocked=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', 'islocked=True', 'released'], the second having acquired before the first was let out. The verdict line reads controlexcludes=True bothinside=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 Software

1 affected componentFixes available
pip/filelock>=3.30.0<=4.2.0
4.2.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/filelock to a version that resolves this vulnerability.

    Fixed in 4.2.1
  2. Upgrade

    Upgrade filelock to a version that resolves this vulnerability.

    Fixed in 4.2.1
  3. Compensating control

    Keep StrictSoftFileLock, AsyncStrictSoftFileLock, SoftReadWriteLock, and AsyncSoftReadWriteLock protocol directories in locations other users cannot write, rather than in /tmp or another sticky shared directory. Where a shared directory is unavoidable, pre-create <path>.filelock/claims or <path>.rw/{gen,holders} as the service user before any other account can, and verify the existing tree owner with stat -c %U before first use.

Event History

Oct 28, 2026
Advisory Published
via GitHub·09:08 PM
Data Sourced
via GitHub·09:08 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who is realistically able to exploit this issue?

A local user who can create names in the directory containing the lock path can exploit it. Shared sticky directories such as /tmp and /var/tmp are specifically exposed because the sticky bit does not prevent another user from creating the lock-state directory first.

2

What must an attacker do before they can interfere with a lock?

They must create the lock's state tree before the intended lock user first acquires that lock path. The affected lock implementations then adopt the existing directory after only confirming that it is a real directory, without verifying its ownership.

3

How can I determine whether a use of these locks is exposed?

Review uses of StrictSoftFileLock, AsyncStrictSoftFileLock, SoftReadWriteLock, and AsyncSoftReadWriteLock whose lock paths are in directories where untrusted local users can create entries. Exposure exists if an attacker could pre-create the derived state directories, such as <path>.filelock or <path>.rw, before the lock is first acquired.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203