Path Traversal Vulnerabilities: Exploitation Patterns and Secure File Handling

Path traversal (directory traversal) vulnerabilities allow attackers to read and write files outside the intended directory. This guide covers attack patterns, encoding bypasses, and secure file handling in Python, JavaScript, Java, and Go.

Path traversal — also called directory traversal or ../ attacks — is a class of vulnerability that lets an attacker read, overwrite, or execute files outside the directory the application intended to expose. The root cause is almost always the same: user-supplied input is incorporated into a file path without sufficient validation or canonicalization.

Despite being one of the oldest web vulnerability classes, path traversal consistently appears in real-world incidents. Unrestricted file read vulnerabilities have been central to numerous major breaches, providing attackers access to configuration files, private keys, database credentials, and application source code.

How Path Traversal Works

The canonical attack sequence:

  1. An application accepts user input to determine which file to serve: GET /download?file=report.pdf
  2. The application constructs a path: /app/uploads/report.pdf
  3. The attacker substitutes: GET /download?file=../../../etc/passwd
  4. The application constructs: /app/uploads/../../../etc/passwd → resolves to /etc/passwd

The payload ../ moves one directory up. Three levels brings an attacker from /app/uploads/ to the filesystem root on most Linux systems.

Encoding Bypasses

Naïve defenses that block ../ in raw input fail against encoding variants:

EncodingPayload
URL encoding%2e%2e%2f
Double URL encoding%252e%252e%252f
Unicode/UTF-8..%c0%af (overlong encoding)
Null byte../../../etc/passwd%00.jpg
Mixed separators..\../ (Windows)
Absolute path/etc/passwd (if no base prepended)

Blacklist approaches that filter specific strings are bypassed by any of these. The only reliable approach is canonicalization followed by prefix checking.

Vulnerable Code Patterns

Python (Flask) — Vulnerable

from flask import Flask, request, send_file
import os

app = Flask(__name__)
UPLOAD_DIR = "/app/uploads"

@app.route("/download")
def download():
    filename = request.args.get("file", "")
    # VULNERABLE: directly concatenates user input
    file_path = os.path.join(UPLOAD_DIR, filename)
    return send_file(file_path)

os.path.join has a subtle behavior: if filename is an absolute path (e.g., /etc/passwd), os.path.join discards the base directory entirely and returns the absolute path. A ../ sequence still works via relative traversal.

Python — Secure

from flask import Flask, request, send_file, abort
import os

app = Flask(__name__)
UPLOAD_DIR = os.path.realpath("/app/uploads")

@app.route("/download")
def download():
    filename = request.args.get("file", "")
    
    # Reject null bytes
    if "\x00" in filename:
        abort(400)
    
    # Construct and canonicalize the path
    requested_path = os.path.realpath(os.path.join(UPLOAD_DIR, filename))
    
    # Verify the resolved path is within the allowed directory
    if not requested_path.startswith(UPLOAD_DIR + os.sep):
        abort(403)
    
    # Verify the file exists and is a regular file (not a symlink to outside)
    if not os.path.isfile(requested_path):
        abort(404)
    
    return send_file(requested_path)

os.path.realpath resolves all .. sequences, symlinks, and redundant separators before the prefix check. The + os.sep ensures that a directory named /app/uploads-evil does not pass the check.

Node.js — Vulnerable

const express = require('express');
const path = require('path');
const fs = require('fs');

const app = express();
const UPLOAD_DIR = '/app/uploads';

app.get('/download', (req, res) => {
  const filename = req.query.file;
  // VULNERABLE: path.join does not prevent traversal
  const filePath = path.join(UPLOAD_DIR, filename);
  res.sendFile(filePath);
});

Node.js — Secure

const express = require('express');
const path = require('path');
const fs = require('fs');

const app = express();
const UPLOAD_DIR = path.resolve('/app/uploads');

app.get('/download', (req, res) => {
  const filename = req.query.file;
  
  if (!filename || filename.includes('\x00')) {
    return res.status(400).send('Invalid filename');
  }
  
  // Resolve the full path and normalize
  const requestedPath = path.resolve(path.join(UPLOAD_DIR, filename));
  
  // Ensure the resolved path starts with the upload directory
  if (!requestedPath.startsWith(UPLOAD_DIR + path.sep)) {
    return res.status(403).send('Access denied');
  }
  
  // Use fs.stat to confirm it's a regular file
  fs.stat(requestedPath, (err, stat) => {
    if (err || !stat.isFile()) {
      return res.status(404).send('Not found');
    }
    res.sendFile(requestedPath);
  });
});

Java — Vulnerable

@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam String filename) throws IOException {
    // VULNERABLE: Paths.get does not prevent traversal
    Path filePath = Paths.get("/app/uploads").resolve(filename);
    Resource resource = new FileSystemResource(filePath.toFile());
    return ResponseEntity.ok().body(resource);
}

Java — Secure

@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam String filename) throws IOException {
    Path uploadDir = Paths.get("/app/uploads").toRealPath();
    
    // Normalize and resolve the requested path
    Path requestedPath = uploadDir.resolve(filename).normalize();
    
    // Verify the resolved path is within the upload directory
    if (!requestedPath.startsWith(uploadDir)) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
    }
    
    // Verify the file exists and is a regular file
    if (!Files.exists(requestedPath) || !Files.isRegularFile(requestedPath)) {
        return ResponseEntity.notFound().build();
    }
    
    Resource resource = new FileSystemResource(requestedPath.toFile());
    return ResponseEntity.ok()
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

Go — Secure

package main

import (
    "net/http"
    "path/filepath"
    "strings"
    "os"
)

const uploadDir = "/app/uploads"

func downloadHandler(w http.ResponseWriter, r *http.Request) {
    filename := r.URL.Query().Get("file")
    
    if strings.ContainsRune(filename, 0) {
        http.Error(w, "Invalid filename", http.StatusBadRequest)
        return
    }
    
    // Clean and join the path
    requestedPath := filepath.Join(uploadDir, filepath.Clean("/"+filename))
    
    // Resolve symlinks and get the real path
    realPath, err := filepath.EvalSymlinks(requestedPath)
    if err != nil {
        http.Error(w, "Not found", http.StatusNotFound)
        return
    }
    
    // Ensure the real path is within the upload directory
    uploadReal, _ := filepath.EvalSymlinks(uploadDir)
    if !strings.HasPrefix(realPath, uploadReal+string(os.PathSeparator)) {
        http.Error(w, "Access denied", http.StatusForbidden)
        return
    }
    
    http.ServeFile(w, r, realPath)
}

Note the filepath.Clean("/"+filename) pattern in Go — prepending a / before cleaning ensures that filepath.Clean strips any leading .. sequences that would otherwise escape the join.

File Upload Considerations

Path traversal affects uploads as well as downloads. Storing a user-supplied filename directly exposes the server to overwriting arbitrary files:

# VULNERABLE upload handler
@app.route("/upload", methods=["POST"])
def upload():
    file = request.files["file"]
    # Attacker supplies filename: "../../app/templates/index.html"
    file.save(os.path.join(UPLOAD_DIR, file.filename))

Secure approach:

import uuid
from werkzeug.utils import secure_filename

@app.route("/upload", methods=["POST"])
def upload():
    file = request.files["file"]
    
    # Generate a random filename — discard the user-supplied name entirely
    ext = os.path.splitext(secure_filename(file.filename))[1].lower()
    safe_filename = str(uuid.uuid4()) + ext
    
    save_path = os.path.join(UPLOAD_DIR, safe_filename)
    file.save(save_path)
    
    # Store the mapping between safe_filename and original name in your database
    return jsonify({"id": safe_filename})

Testing for Path Traversal

Basic test vectors to include in security testing:

../../../etc/passwd
..%2f..%2f..%2fetc%2fpasswd
%252e%252e%252f%252e%252e%252f%252e%252e%252fetc%252fpasswd
....//....//....//etc/passwd
/etc/passwd
C:\Windows\System32\drivers\etc\hosts

Automated tools: dotdotpwn, ffuf with a traversal wordlist, or Burp Suite’s intruder with path traversal payloads from SecLists (/Discovery/Web-Content/raft-large-files.txt combined with traversal prefixes).

Summary

The secure pattern is consistent across all languages:

  1. Canonicalize the combined path (resolve .., symlinks, encoding) with the language’s realpath equivalent.
  2. Prefix check the canonicalized path against the allowed base directory — include the trailing separator to prevent prefix collisions.
  3. Never trust user-supplied filenames for storage — generate random identifiers server-side.
  4. Reject null bytes explicitly before canonicalization.

Blocklist approaches that filter ../ strings are bypassed by encoding variants. The canonicalize-then-prefix-check pattern is robust against all encoding bypasses because it operates on the resolved filesystem path, not the raw input string.