Introduction
Imagine handing a stranger the keys to your house and saying, "You can only go in the kitchen." Now imagine they discover the keys also open every other room — and you never knew. That's essentially what a path traversal vulnerability does to your application's filesystem.
A path traversal vulnerability (also known as a directory traversal attack) allows an attacker to manipulate file path references in your application to access files and directories stored outside the intended folder. In the worst cases, this means reading sensitive configuration files, credentials, or even overwriting critical system files.
This post breaks down a recently patched high-severity path traversal vulnerability found in skills/hatch-pet/scripts/generate_pet_images.py, explains how it could have been exploited, and walks through the best practices you should adopt to prevent similar issues in your own code.
The Vulnerability Explained
What Happened?
The vulnerability resided in how the hatch-pet script constructed file paths. Specifically, the code joined a run_dir variable with a hardcoded filename (pet_request.json) to build a full file path — something like this:
# Vulnerable pattern
import os
run_dir = get_run_dir() # Could come from CLI args, API params, or env vars
request_file = os.path.join(run_dir, 'pet_request.json')
with open(request_file, 'r') as f:
data = f.read()
This looks innocent at first glance. The problem? run_dir was derived from user-controlled input without any validation.
The Anatomy of a Path Traversal Attack
The ../ sequence (or ..\ on Windows) is the key weapon in a path traversal attack. When you join an unvalidated user input with a base path, an attacker can supply sequences like:
../../etc/passwd
../../../var/secrets/.env
../../../../home/user/.ssh/id_rsa
So if an attacker passes run_dir as ../../etc/, the resulting path becomes:
os.path.join("../../etc/", "pet_request.json")
# Resolves to: ../../etc/pet_request.json
This effectively points the file operation outside the intended working directory, potentially exposing or corrupting sensitive system files.
Real-World Impact
Depending on how the vulnerable code is used, an attacker could:
- Read sensitive files:
/etc/passwd,.envfiles containing API keys, database credentials, private SSH keys - Overwrite critical files: Configuration files, log files, or even executable scripts
- Achieve privilege escalation: By overwriting scripts run by higher-privileged processes
- Exfiltrate application secrets: Internal configuration, tokens, or user data
Attack Scenario: Step by Step
Let's walk through a concrete attack scenario:
Setup: The hatch-pet script accepts a --run-dir CLI argument or reads RUN_DIR from an environment variable.
Step 1 — Attacker identifies the input vector:
python generate_pet_images.py --run-dir /tmp/myrun
Step 2 — Attacker crafts a malicious path:
python generate_pet_images.py --run-dir "../../etc/"
Step 3 — Script attempts to read:
# Internally resolves to:
open("../../etc/pet_request.json", 'r')
# Or in a write scenario:
open("../../etc/pet_request.json", 'w')
Step 4 — Data exfiltration or corruption occurs silently, with no error raised by the application.
This is classified as CWE-22: Improper Limitation of a Pathname to a Restricted Directory and is consistently listed in the OWASP Top 10 under A01:2021 – Broken Access Control.
The Fix
What Changed?
The fix introduces proper path validation before the run_dir variable is used to construct any file path. The core of the solution is to:
- Resolve the absolute, canonical path of the constructed file path
- Verify it falls within the expected base directory
- Reject any path that escapes the intended directory
Here's what a secure implementation looks like:
# BEFORE (Vulnerable)
import os
def get_request_file(run_dir):
return os.path.join(run_dir, 'pet_request.json')
run_dir = args.run_dir # Unvalidated user input
request_file = get_request_file(run_dir)
with open(request_file, 'r') as f:
data = f.read()
# AFTER (Secure)
import os
# Define a strict base directory for all run operations
BASE_RUNS_DIR = os.path.abspath('/app/runs')
def get_safe_request_file(run_dir: str) -> str:
"""
Safely construct a file path, ensuring it stays within BASE_RUNS_DIR.
Raises ValueError if the resolved path escapes the base directory.
"""
# Resolve the absolute path (resolves all ../ sequences)
abs_run_dir = os.path.realpath(os.path.abspath(run_dir))
# Ensure the resolved path starts with our allowed base directory
if not abs_run_dir.startswith(BASE_RUNS_DIR + os.sep):
raise ValueError(
f"Invalid run_dir: path '{run_dir}' escapes the allowed directory."
)
return os.path.join(abs_run_dir, 'pet_request.json')
# Usage
try:
request_file = get_safe_request_file(args.run_dir)
with open(request_file, 'r') as f:
data = f.read()
except ValueError as e:
print(f"Security error: {e}")
sys.exit(1)
Why This Fix Works
The critical line is:
abs_run_dir = os.path.realpath(os.path.abspath(run_dir))
os.path.abspath()converts the path to an absolute path based on the current working directoryos.path.realpath()resolves all symbolic links and normalizes../sequences
After this resolution, ../../etc/ becomes /etc/ — and the subsequent check:
if not abs_run_dir.startswith(BASE_RUNS_DIR + os.sep):
...immediately catches that /etc/ is not within /app/runs/ and raises an error before any file operation occurs.
Note: Always append
os.sepwhen checkingstartswith()to avoid false positives where a directory like/app/runs-extramight incorrectly match a check for/app/runs.
Conclusion
Path traversal vulnerabilities are a classic yet persistently dangerous class of security flaw. They're often introduced through seemingly harmless code — a simple os.path.join() call that doesn't account for what a malicious actor might supply as input.
The key lessons from this vulnerability and its fix are:
- Never trust user input — validate and canonicalize all file paths before use
- Use
os.path.realpath()+ boundary checks to enforce directory restrictions in Python - Allowlists beat blocklists — define what's permitted, reject everything else
- Least privilege matters — limit what your process can access at the OS level
- Automate detection — integrate SAST tools and write security-specific tests
Security vulnerabilities like this one are fixed one patch at a time, but the real win comes from building a culture where developers understand why these patterns are dangerous and proactively avoid them. The next time you write code that touches the filesystem with user-supplied input, take a moment to ask: "What happens if someone passes ../../etc/ here?"
That moment of reflection might just save your application — and your users.
This vulnerability was identified and patched as part of an automated security scanning process. Automated tools are a valuable layer of defense, but they work best when paired with developer education and secure-by-default coding practices.