These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecurityFile Upload

File Upload Vulnerabilities

Upload bugs aren’t one vulnerability — they’re a family:

  1. Extension validation bypass → uploaded file executes as PHP
  2. Content-Type spoofing → server-side MIME filter waved through
  3. Storage in web root → uploaded files served by the webserver as code
  4. Path traversal in filename → overwrite arbitrary files
  5. SVG / HTML / polyglot payloads → stored XSS on render
  6. Metadata-based deserialization (PHAR)

A safe upload pipeline gets all six right. Most don’t.

Why

The minimum-viable bug is treating the client-supplied filename and MIME type as truthful:

$name = $_FILES['avatar']['name'];                       // attacker controls
$type = $_FILES['avatar']['type'];                       // attacker controls
$ext  = pathinfo($name, PATHINFO_EXTENSION);             // attacker controls
 
if (!in_array($ext, ['jpg', 'png', 'gif'])) abort();     // extension check
if (!str_starts_with($type, 'image/')) abort();          // MIME check (worthless)
 
move_uploaded_file($_FILES['avatar']['tmp_name'], "/var/www/uploads/$name");

The MIME check is decoration — $_FILES[…]['type'] is the client’s claim. The extension check is bypassable by case (shell.Php), variants (.phtml, .phar, .pht), Apache mod_mime rules (shell.php.jpg → executed as PHP), and the filename can contain ../.

Search patterns

rg -n 'move_uploaded_file\(|\$_FILES' --type=php
 
# Specific danger shapes
rg -n '\$_FILES\[[^]]+\]\[[''"]name[''"]\]' --type=php       # trusting client filename
rg -n '\$_FILES\[[^]]+\]\[[''"]type[''"]\]' --type=php       # trusting client MIME
 
# Indirect upload code (frameworks)
rg -n '->store\(|->storeAs\(|->move\(|UploadedFile' --type=php

Test inputs

Filename / extension:

  • shell.php
  • shell.php.jpg (Apache mod_mime)
  • shell.phtml / shell.phar / shell.pht / shell.phps
  • shell.Php / shell.PHP (case-only difference)
  • shell.php%00.jpg (null byte, legacy)
  • shell.php;.jpg (IIS)
  • shell.php/ (trailing slash on some setups)
  • shell.php. (trailing dot)
  • shell .php (trailing space)
  • .htaccess (overrides server config in upload dir)
  • web.config (IIS equivalent)

Path traversal in filename:

  • ../../../var/www/html/shell.php
  • ..\..\..\Windows\System32\drivers\etc\hosts

Content payloads (image-disguised PHP):

  • JPG comment / EXIF holding PHP:
    exiftool -Comment='<?php system($_GET["c"]); ?>' avatar.jpg
  • GIF magic header + PHP: file starts with GIF89a<?php …
  • PNG IDAT chunk with PHP (less common, more elaborate)

Polyglot / scripted:

  • SVG with <script>alert(1)</script> (XSS on render in browser)
  • HTML uploaded as .html or .svg (stored XSS)
  • PDF with embedded JavaScript

PHAR polyglot (see PHAR Deserialization):

  • avatar.jpg that is also a valid PHAR with serialized RCE gadget

MIME spoofing:

  • Send Content-Type: image/jpeg while body is <?php …
  • Content-Type: text/html with a PHP payload (server-side filter that only blocks application/x-php)

Audit focus

For each upload flow:

  1. Extension validation — allowlist (good) or denylist (bad)? Allowlist of ['jpg', 'png', 'gif', 'webp'] only, lowercased after pathinfo.
  2. MIME detection — server-side via finfo / mime_content_type on the actual file content, not the client-supplied type. Cross-checked against the extension.
  3. Filename handling — is the client name discarded and replaced with a server-generated random name? bin2hex(random_bytes(16)) . '.' . $ext is the standard.
  4. Storage location — outside the webroot, or served via a controller that streams bytes with forced Content-Disposition: attachment?
  5. Execution disabled on upload directory.htaccess:
    php_flag engine off
    AddType text/plain .php .phtml .phar .pht .phps
    Or, in Nginx, a location block that strips PHP-FPM handlers.
  6. Content re-encoding — for images, re-render through GD/Imagick. Strips PHP from EXIF/comments, drops <script> from SVG, kills PHAR polyglots. The single best defense in depth.
  7. Function choicemove_uploaded_file(), not copy() / rename(). Only the former validates that the file came from a multipart POST.
  8. Size limits$file['size'] and upload_max_filesize / post_max_size aligned.
  9. Race conditions — does code check the file at one path then move it later (a window for TOCTOU)?

The “uploaded image stored under its original name in /uploads/” pattern is the single most reliable RCE primitive in PHP shops. If you find it during review, the fix is almost certainly: random filename + outside webroot + re-render image content + serve via controller. All four.

Fix

function handle_upload(array $file, string $dest_dir): string {
  if ($file['error'] !== UPLOAD_ERR_OK) abort();
  if ($file['size'] > 5 * 1024 * 1024) abort();
 
  $finfo = new finfo(FILEINFO_MIME_TYPE);
  $mime  = $finfo->file($file['tmp_name']);
 
  $allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
  ];
  if (!isset($allowed[$mime])) abort();
 
  // Re-encode image — neutralises EXIF / comment / PHAR payloads
  $img = imagecreatefromstring(file_get_contents($file['tmp_name']));
  if ($img === false) abort();
 
  $name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
  $path = $dest_dir . '/' . $name;
 
  match ($mime) {
    'image/jpeg' => imagejpeg($img, $path, 85),
    'image/png'  => imagepng($img, $path),
    'image/webp' => imagewebp($img, $path),
  };
  imagedestroy($img);
 
  return $name;
}

$dest_dir is /var/uploads/ — outside the webroot. Serve via a controller that:

  • Authenticates the request
  • Looks up the file by stored ID, not by user-supplied path
  • Sends bytes with a hardcoded Content-Type and Content-Disposition: attachment