PHP Image Upload Security: How Not to Do It

June 29th, 2012 | Posted by Darwish in Programming

Let’s take a break from talking about games for a brief journey into the world of web development. I’ve done a fair bit of work with PHP and I want to address the issue of secure file uploads.

File uploading is a scary thing for web developers. You’re allowing complete strangers to put whatever they want onto your precious web server. In this article I’ll be dealing entirely with the uploading of images, and how to ensure that what a user is giving you is actually an image.

Part I: The Evil $_FILES["file"]["type"]

Many times, I’ve seen (and—back in my youth—written) code that resembles the following:

$valid_mime_types = array(
    "image/gif",
    "image/png",
    "image/jpeg",
    "image/pjpeg",
);

// Check that the uploaded file is actually an image
// and move it to the right folder if is.
if (in_array($_FILES["file"]["type"], $valid_mime_types)) {
    $destination = "uploads/" . $_FILES["file"]["name"];
    move_uploaded_file($_FILES["file"]["tmp_name"], $destination);
}

The above snippet checks the uploaded file’s MIME type to validate it as an image, then moves the file to the appropriate location if it passes. So what’s the problem? Well, if you read the documentation page on handling file uploads, pay attention to what it has to say about $_FILES["file"]["type"]:

[T]his value is completely under the control of the client and not checked on the PHP side.

The first rule of web security is to never trust user-submitted data. Allowing a file onto your server because the client says it’s an image is like giving a stranger the keys to your house because he says he’s won’t steal anything. Here’s a quick example of a script that could be used to exploit such a vulnerability:

// The destination for our attack:
$host = "127.0.0.1";
$port = 8887;
$page = "/server.php";

// Here we have the file we're uploading (note the content-type):
$payload =
"------ThisIsABoundary
Content-Disposition: form-data; name="file"; filename="evil.php"
Content-Type: image/jpeg

<?php phpinfo();
------ThisIsABoundary--";

// Finally, craft the request and send it.
$content_length = strlen($payload);
$headers = array(
    "POST {$page} HTTP/1.1",
    "Host: {$host}:{$port}",
    "Connection: close",
    "Content-Length: {$content_length}",
    "User-Agent: Evil Robot",
    "Content-Type: multipart/form-data; boundary=----ThisIsABoundary",
);

$request = implode("rn", $headers) . "rnrn" . $payload . "rn";

$fp = fsockopen($host, $port, $errno, $errstr)
      or die("ERROR: $errno - $errstr");
fwrite($fp, $request);

The above script crafts a standard HTTP request that uploads a php file named evil.php. If the server relies on $_FILES["file"]["type"]to validate uploads, then it’ll be under the mistaken impression that we’re sending it an image.

Part II: The mod_mime Apache Module and Multiple File Extensions

So what’s the solution, then? Some people use file extension checks, since servers will determine appropriate handlers and content types base on the extension of the file. Something like this will work most of the time:

$valid_file_extensions = array(".jpg", ".jpeg", ".gif", ".png");

$file_extension = strrchr($_FILES["file"]["name"], ".");

// Check that the uploaded file is actually an image
// and move it to the right folder if is.
if (in_array($file_extension, $valid_file_extensions)) {
    $destination = "uploads/" . $_FILES["file"]["name"];
    move_uploaded_file($_FILES["file"]["tmp_name"], $destination);
}

You might be safe with this, depending on your server settings. See, Apache can be configured to interpret multiple file extensions for the same file. While it might be useful for allowing a filename to determine both language and content type at once, it also presents a security vulnerability to developers who are unaware of this feature.

Exploiting the multiple file extension vulnerability doesn’t take much skill. Grab any PHP file, add .jpg to the end of its name, then upload it to the vulnerable server. Then visit the file in your web browser. This will cause Apache to run the script and output the results. Piece of cake.

Part III: The Script Disguised as an Image

People who are wary of  falsified MIME types and extra file extensions often advocate the use of something like getimagesize() to ensure that the uploaded file is actually an image.

if (@getimagesize($_FILES["file"]["tmp_name"]) !== false) {
    $destination = "uploads/" . $_FILES["file"]["name"];
    move_uploaded_file($_FILES["file"]["tmp_name"], $destination);
}

Surely, an image can’t be harmful? I mean, look at this kitten: That kitten could never hurt anyone, right? Just count yourself lucky that it’s a white hat kitten.

Now click on the kitten-link  (it opens in a new tab) and see what happens. What you should see is the exact same kitten, but this time, I’m running it as a PHP script. To accomplish this, I took the wonderful jhead tool, and I embedded a comment inside the original kitten image. My comment looked something like this:

<?php blahblahblah(); __halt_compiler();

The __halt_compiler()function call is there so that the image data doesn’t accidentally get interpreted as PHP and throw a parse error. This is why the output stops before outputting the actual image data. If you want to see exactly what I wrote, you can download the original kitten image (right-click, Save Image As…) and open it in your favorite text editor.

Part IV: The End

The above security checks certainly aren’t useless. If you’re expecting an image to be uploaded, then it’s nice to check and see if it’s a valid image. Having many layers of security is always a good thing. But what do we do about the PHP scripts that seem to keep sneaking by our protection?

Our goal here is not only to ensure that the file uploaded is an image, but also to ensure that the server doesn’t run any script handlers. My favorite way to do this is using Apache’s ForceType directive:

ForceType application/octet-stream
<FilesMatch "(?i).jpe?g$">
    ForceType image/jpeg
</FilesMatch>
<FilesMatch "(?i).gif$">
    ForceType image/gif
</FilesMatch>
<FilesMatch "(?i).png$">
    ForceType image/png
</FilesMatch>

This code, placed in the .htaccess file in your upload directory will only allow images to be associated with their default handlers. Everything else will be served as a plain byte stream and no handlers will be run.

I like this more that the “turn PHP off” solution (php_flag engine off), because it turns all script handlers off at once, in case your server also serves perl, python or whatever. Of course, you can always do both just to be on the safe side.

An even better solution is to place the files outside of the web directory, so that they will never be served at all. Then you need to write a script that takes a request for the file, retrieves the appropriate file from the filesystem, and outputs it with the correct headers. Of course, outputting files based on user input comes with its own set of security vulnerabilities, but that’s a story for another day.

Last but not least, always be sure to rename uploaded files. Choosing a random name makes it that much harder for an attacker to fool you, and it ensures that nobody overwrites your .htaccess or .user.ini files, neither of which be a good thing.

There are many resources out there on the web that have a lot to say about security. If you’re interested in reading more, check out OWASP and more specifically the OWASP Cheat Sheet Page.

You can follow any responses to this entry through the RSS 2.0 You can leave a response, or trackback.

20 Responses



Leave a Reply

Your email address will not be published. Required fields are marked *