GHSA-m462-7rxw-wvpg CVE-2026-112938
filelock: StrictSoftFileLock follows a symbolic link swapped in at <path>.filelock and creates claim files in a directory another local user chooses
- Severity
- 5.8 / 10
- Medium · CVSS 3.1
- Exploit chance (EPSS)
- —
- No score published
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-363 Race Condition Enabling Link Following
- Fix
4.2.1- Upgrade filelock to this version or later
Description
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 (a0777or 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>.filelockbefore 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>.filelockcan 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
SoftFileLockProtocolErrororFileNotFoundError. 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 (
claimsandforce_break()on a resting symbolic link) depend on the lock user inspecting or recovering the lock. The planted link invites this by makingacquire()fail with a protocol error. Theforce_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 indocs/tutorials.rstdoes.docs/how-to.rsttells 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 createsT/claimswith the lock user's credentials. Themkdirsucceeds, so_ensure_protocol_directoryreturns without looking at anything. _attempt_doorwaythen works inT/claimsfor as long as the name still points there._read_existing_claimsusesdirectory.exists()andos.scandir(directory)._publish_recordopens the claim directory by its full path and creates.intent-v1-<token>.claim.private-v1-<hex>.tmpandintent-v1-<token>.claim(the intent claim, which announces a pending acquire) in it. When its intent wins the scan,_link_no_replaceaddsheld-v1-<token>.claim(the held claim, which marks the lock as taken)._open_directorydoes passO_DIRECTORY | O_NOFOLLOW, but on"<path>.filelock/claims", so the flag coversclaimsonly.- Cleanup resolves the name again.
_unlink_in_directoryopens<path>.filelock/claimsby path. When the name is back at the real directory by then, theunlinkgetsENOENT(the "no such file" error), which_unlink_owner_path_resultcounts as removed. The claim file stays inT/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
#!/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
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
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
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:…
Affected versions
PyPI
| Package | Vulnerable | Fixed in |
|---|---|---|
| filelock | >= 3.30.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 5.8
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H
- Attack vector
- Local
- Attack complexity
- High
- Privileges required
- Low
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- Low
- Integrity
- Low
- Availability
- High
Weakness
CWE-363: Race Condition Enabling Link Following
The product checks the status of a file or directory before accessing it, which produces a race condition in which the file can be replaced with a link before the access is performed, causing the product to access the wrong file.
References
Packages
Other links
Timeline
- Published
- Last updated by the source
Related advisories
| Advisory | Severity | Why 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-rfpc-qcw5-3pc4 filelock: SoftFileLock removes a live holder's marker when another user forges the PID in a writable marker | 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 |
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