Path Traversal & LFI Attacks: Exploitation Techniques and Secure Coding Defenses

Security tutorial - IT technology blog
Security tutorial - IT technology blog

After six months running a file-serving micro-service in production — and watching two distinct Path Traversal probe campaigns light up the access logs — I rebuilt the input validation layer from scratch. This writeup covers the attack mechanics, the bypass tricks attackers actually use, and the defensive patterns that have held up since.

Quick Start (5 Minutes)

Path Traversal and Local File Inclusion (LFI) have the same root cause. An application takes a filename from user input and feeds it directly to a filesystem call, without sanitising it first. The attacker injects ../ sequences to escape the intended directory and reach arbitrary files.

Spotting live probing in your logs takes one command:

grep -E '(\.\./|%2e%2e%2f|%252e%252e|\.\.\ %2f|%c0%ae)' /var/log/nginx/access.log | tail -20

Typical targets an attacker reaches first:

  • /etc/passwd — user enumeration
  • /etc/shadow — password hashes (requires root read access)
  • /proc/self/environ — environment variables, often includes secrets
  • /var/log/nginx/access.log — log poisoning for LFI-to-RCE escalation
  • Application config files: .env, config.php, database.yml

If your app serves files or includes templates based on a user-supplied filename parameter, you have a potential attack surface. Audit those endpoints first.

Deep Dive: How the Attack Actually Works

Basic Path Traversal

Consider this Python Flask snippet — representative of the kind of shortcut that ends up in production:

from flask import Flask, request, send_file

app = Flask(__name__)

@app.route('/download')
def download():
    filename = request.args.get('file')
    return send_file(f'/var/www/uploads/{filename}')

A request to /download?file=../../etc/passwd resolves to /var/www/uploads/../../etc/passwd, which the OS normalises to /etc/passwd. That is the entire attack — no exotic tooling required.

LFI: When Include Becomes an Attack Vector

Local File Inclusion is Path Traversal’s close cousin, but instead of serving file content directly, the application executes or includes it. This is classic PHP territory:

<?php
$page = $_GET['page'];
include("templates/" . $page . ".php");
?>

Requesting ?page=../../../etc/passwd%00 (null byte injection) truncates the .php extension and includes the passwd file — this worked in PHP before version 5.3.4. PHP patched null byte handling in filesystem functions back in 2010. The unsafe include pattern, though, still turns up in legacy codebases running older stacks.

Encoding and Filter Bypass Techniques

Most naive defences just block the literal string ../. Attackers have a full toolkit of encodings to get around that:

  • URL encoding: %2e%2e%2f decodes to ../
  • Double URL encoding: %252e%252e%252f — the server decodes once to %2e%2e%2f, the app decodes again to ../
  • Unicode overlong encoding: %c0%ae%c0%ae/ (older IIS and Java containers)
  • Mixed slashes: ..\ — Windows path normalisation
  • Redundant separators: ....// — when the app strips ../ in a single pass, ....// collapses to ../ after removal
# Common bypass attempts you'll see in logs
curl "https://example.com/view?file=%2e%2e%2f%2e%2e%2fetc%2fpasswd"
curl "https://example.com/view?file=....//....//etc/passwd"
curl "https://example.com/view?file=%252e%252e%252fetc%252fpasswd"

LFI to Remote Code Execution via Log Poisoning

Log poisoning is the scariest escalation. If the attacker can include a log file they’ve already written PHP code into, they get remote code execution. Here’s the sequence:

  1. Send a request with a PHP payload in the User-Agent header: User-Agent: <?php system($_GET['cmd']); ?>
  2. That string gets written to /var/log/nginx/access.log
  3. Exploit the LFI to include the access log: ?page=../../var/log/nginx/access.log
  4. The PHP interpreter executes the poisoned log entry

Three other escalation vectors are worth flagging. /proc/self/environ — inject a PHP payload in an HTTP header, then include that file. PHP session files at /var/lib/php/sessions/sess_SESSIONID — exploitable if the session stores any user-controlled data. And uploaded images with PHP payloads hidden in EXIF metadata — a common trick when file upload validation only checks the extension.

Advanced: Defensive Programming Patterns That Actually Hold

The Correct Fix: realpath() + Allowlist Prefix Check

String-based filtering is fragile against encoding variations. The correct approach uses the OS itself to resolve the canonical path, then checks it against a known-safe prefix:

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

app = Flask(__name__)
UPLOAD_DIR = os.path.realpath('/var/www/uploads')

@app.route('/download')
def download():
    filename = request.args.get('file', '')
    # realpath() resolves all ../ sequences and symlinks
    requested_path = os.path.realpath(os.path.join(UPLOAD_DIR, filename))

    # Strict prefix check — attacker cannot escape UPLOAD_DIR
    if not requested_path.startswith(UPLOAD_DIR + os.sep):
        abort(403)

    if not os.path.isfile(requested_path):
        abort(404)

    return send_file(requested_path)

os.path.realpath() resolves all symlinks and ../ sequences before the check runs. No blacklisting, no regex — just OS-level ground truth. The os.sep suffix on the prefix prevents a directory named /var/www/uploads-secret from passing the startswith check.

PHP Equivalent

<?php
$base_dir = realpath('/var/www/templates');
$requested = realpath($base_dir . '/' . $_GET['page']);

// realpath() returns false for non-existent paths — always check
if ($requested === false || strpos($requested, $base_dir . DIRECTORY_SEPARATOR) !== 0) {
    http_response_code(403);
    exit('Forbidden');
}

include $requested;
?>

Node.js / Express Pattern

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

const STATIC_DIR = path.resolve('/var/www/files');

app.get('/file', (req, res) => {
  const requested = path.resolve(STATIC_DIR, req.query.name || '');

  if (!requested.startsWith(STATIC_DIR + path.sep)) {
    return res.status(403).send('Forbidden');
  }

  if (!fs.existsSync(requested) || !fs.statSync(requested).isFile()) {
    return res.status(404).send('Not found');
  }

  res.sendFile(requested);
});

Nginx: Block Traversal at the Edge

Application code shouldn’t be your only line of defence. These Nginx rules drop traversal patterns at the edge — before the request hits your app:

server {
    # Block common path traversal patterns at the edge
    location ~* (\.\.\/|\.\.\\|%2e%2e%2f|%252e%252e) {
        return 403;
    }

    location /uploads {
        alias /var/www/uploads/;
        # Deny executable file types in upload directories
        location ~* \.(php|py|sh|exe|pl)$ {
            deny all;
        }
    }
}

Practical Tips from the Trenches

Use an explicit allowlist map instead of constructing paths. If you’re building a template or page system, maintain a hardcoded map of valid names to file paths. User input selects a key from the map — it never touches the path construction:

TEMPLATES = {
    'home': 'templates/home.html',
    'about': 'templates/about.html',
    'contact': 'templates/contact.html',
}

template_name = request.args.get('page', 'home')
template_path = TEMPLATES.get(template_name)
if template_path is None:
    abort(404)

Run as a low-privilege user. Even if traversal succeeds, a process running as www-data with no read access to /etc/shadow or application secrets limits the damage. I keep production secrets in environment variables and restrict config file permissions to 600.

Harden credentials on every service. Credentials reachable via traversal shouldn’t be reused anywhere else — that’s basic blast-radius containment. For provisioning accounts and service passwords, I use the generator at toolcraft.app/en/tools/security/password-generator. It runs entirely in the browser, so the password never crosses the wire. Worth caring about: even the generation step is a leak surface if you’re using an online tool that sends data server-side.

Test your own defences before attackers do. Run a quick LFI probe against staging:

for payload in '../etc/passwd' '../../etc/passwd' '%2e%2e%2fetc%2fpasswd' '....//etc/passwd'; do
    echo -n "Testing [$payload]: "
    curl -s "https://staging.example.com/view?file=$payload" | head -1
done

If any response contains root:, you have an open vulnerability to fix before it ships.

Alert on traversal patterns in production logs. A cron job that counts ../ hits per hour and fires an alert when the rate jumps is enough to catch probing campaigns early. Both campaigns I mentioned at the start showed up as a sudden 10× spike in a single hour — obvious once you have the monitoring in place.

Path Traversal keeps showing up in bug bounty reports for one reason: the vulnerable code is easy to write by accident. send_file(f'/uploads/{filename}') looks harmless. The realpath() + prefix check is just as simple to add. Find every spot in your codebase where user input feeds a filesystem call, apply the pattern, and this whole class of attack stops working.

Share: