PHP image upload exploits and prevention

Table of contents

Image uploads are supported on practically all websites that support user logins, through avatar images, profile backgrounds or photo galleries. But images can be dangerous if not implemented with a solid understanding of PHP's vulnerabilities around image formats and validation.

Invalid images

In order to understand the core issue with image exploits, let's look at a heavily simplified example. Suppose an administrator was tasked to support multiple file extensions for PHP scripts, like version-specific .php4. .php5 and .php7, or some versioned generated scripts like .php.v1, .php.v2 etc. Without much further thought, they may add this to their apache2/httpd configuration:

AddHandler application/x-httpd-php .php*

While this would solve their issue, it also opens the door for attackers to abuse an insecure image upload that only checks file extensions. Assume this is their upload script:

<?php
  $file = basename($_FILES["image"]["tmp_name"]);
  $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
  if(in_array($extension, ["jpg", "jpeg", "png"])){
    if(move_uploaded_file($_FILES["image"]["tmp_name"], "uploads/".$file)){
      echo "Upload successful";
    }else{
      echo "Upload error";
    }
  }
?>

An attacker could craft an invalid image containing PHP code:

echo "<?php echo 'hacked'; ?>" > script.php.jpg

Since the above code only checks the last file extension, the attacker could now access the file through their browser, like http://example.com/uploads/script.php.jpg and the webserver would execute it as a valid php script, because it contains .php in it's filename.

Invalid images without insecure server config

The first exploit relies on a bad server configuration, which will hopefully become rare in the future. This does not render the attack vector useless however, as an attacker only needs to find a way to feed the file through an include() or require() call.

Let's say the web application using the image upload uses a primitive navigation like this:

<?php
  if(!empty($_GET['page'])){
    require($_GET['page']);
  }else{
    require("home.php");
  }
?>

Intended usage of this navigation are urls like http://example.com/?page=about.php, but attackers could simply craft urls that load uploaded images through the require() function instead, like http://example.com/?page=uploads/script.php.jpg.

Checking mime type

Since extensions are unreliable, many developers fall back to checking mime types instead. Using this approach is based on the assumption that mime types verify the file contents are of a certain type, which is not true. In reality, mime type checking only looks at the first few bytes to guess the file contents.

Given this mime type checking upload:

<?php
  $file = $_FILES["image"]["tmp_name"];
  $mime = mime_content_type($file);
  if(in_array($mime, ["imge/jpeg", "image/png"])){
    if(move_uploaded_file($_FILES["image"]["tmp_name"], "uploads/".basename($file))){
      echo "Upload successful";
    }else{
      echo "Upload error";
    }
  }
?>

Inexperienced developers may expect this check to confirm the file is a valid image, but the check can be fooled simply by prepending valid jpeg headers to an attacker's script:

echo -e '\xFF\xD8\xFF<?php echo "Hacked!"; ?>' > script.jpg

And just like that, the file will return a valid jpeg mime type, but still contain the attacker's shellcode, which they only need to get executed by the server now.

Enforcing valid image size

Now since extensions and mime types can't be trusted, the next resort is trying to check if the file contains something that at least looks like valid pixel data of an image. The function of choice here would be getimagesize(), assuming that "if it has a valid size, it is probably a valid image".

<?php
  $file = $_FILES["image"]["tmp_name"];
  if(getimagesize($file)){
    if(move_uploaded_file($_FILES["image"]["tmp_name"], "uploads/".basename($file))){
      echo "Upload successful";
    }else{
      echo "Upload error";
    }
  }
?>

The getimagesize() function would return false for non-image files, so the check should prevent malformed images from being uploaded (note that getimagesize() can also return no error but garbage data for invalid images, but we ignore this edge case for simplicity and assume it always validates correct image data).

However, many image formats can be appended with foreign data without damaging the file validity. For example, if an attacker has a valid image named image.jpg, they can append their shellcode to the end of it:

echo '<?php echo "hacked"; ?>' >> image.jpg

If they upload this edited file, it will look like a normal image for all intents and purposes, but their PHP code is still maintained at the end of the file and will run if executed by the server.

Stripping superfluous data

The only way to remove data after the contents of an image file is to re-encode it again. Using the populat imagick extension, this can be achieved quickly:

<?php
  $file = $_FILES["image"]["tmp_name"];
  try{
    $image = new Imagick($file);
    $image->writeImage($file);
  }catch(Exception $e){
    die("Invalid image");
  }
  if(move_uploaded_file($_FILES["image"]["tmp_name"], "uploads/".basename($file))){
    echo "Upload successful";
  }else{
    echo "Upload error";
  }
?>

Before running the upload logic, the image file is briefly opened with imagick and saved over the original file. This may seem confusing at first, but imagick reads only the image data, ignoring the shellcode at the end. Only the valid pixel information is written back over the original file, effectively stripping any appended data.

For some image files, this may be enough, but formats supporting EXIF metadata have one last issue that attackers could exploit. The shellcode could simply be written to one of the metadata fields of a valid image:

exiftool -Comment='<?php echo "hacked"; ?>' image.jpg

Since php will ignore anything outside of it's opening/closing tags, an image file with this comment will run just fine if fed into the php interpreter. Don't be fooled to think you can just strip the comment field either; attackers could manipulate any EXIF field's data to include their shellcode.

Safely handling uploaded images

The only way to safely allow image uploaded in PHP environments is to re-encode the file and strip all metadata from it, and saving it with a generated name and file extension, so attackers have no control over the resulting path.

You can either do this using gd:

<?php
  $file = $_FILES["image"]["tmp_name"];
  $outfile = "uploads/".uniqid().".jpg"$image = imagecreatefromjpeg($file);
  if($image){
    imagejpeg($image, $outfile, 100);
    imagedestroy($image);
  }else{
    echo "Invalid image";
  }
?>

Or with Imagick by including the stripImage() method:

<?php
  $file = $_FILES["image"]["tmp_name"];
  $outfile = "uploads/".uniqid().".jpg"
  try{
    $image = new Imagick($file);
    $image->stripImage();
    $image->writeImage($file);
    echo "Upload successful";
  }catch(Exception $e){
    echo "Invalid image";
  }
?>

Both solutions use uniqid() to generate a unique random name for the image, and statically append the .jpg extension to ensure attackers have no control over the resulting file's name or path. They re-encode the image data only, ignoring all appended shellcode and in the case of Imagick specifically strip EXIF data (gd does this implicitly when re-encoding).

This solution is the only way to safely handle uploaded images for PHP, as all other approaches above can be exploited in the right environment.

More articles

Automating web server setups with ansible

Reliable web server setups across multiple hosts

The downsides of source-available software licenses

And how it differs from real open-source licenses

Configure linux debian to boot into a fullscreen application

Running kiosk-mode applications with confidence