How to Securely Allow Users to Upload Files

If, in the course of developing a web application for yourself or for a client, you ever find yourself writing code to allow users to upload files, you've just entered a whole new world of complexity where a simple mistake could result in remotely exploitable security vulnerabilities.

Fortunately, there is one simple design decision you can make that will stop the most common vulnerabilities associated with handling file uploads:

Always Store Uploaded Files Outside of the Document Root

If your website is example.com and when a visitor accesses this website in their browser, the script located at /home/example/public_html/index.php is executed, then you should not be storing the files that users have uploaded in /home/example/public_html/ or any of its subdirectories. A good candidate, instead, would be /home/example/uploaded/ .

With your files safely outside the scope of being directly accessible (and therefore directly executed as code), you are spared the tedium of writing complicated blacklists, whitelists, kludgy attempts at inferring a file's true MIME type (don't trust the one provided in $_FILES ; attackers can change it to whatever they want), and hamfisted attempts at processing untrusted image files with PHP's GD extension (which shouldn't be relied on for security purposes).

But What if I Want Files to be Publicly Accessible?

Just because your files are stored outside of the document root doesn't mean you can't give your users access to them. You could, for example, forward the files to a static content server incapable of executing dynamic content (an Apache server without mod_php) or a third-party service (e.g. Cloudinary). This still satisfies the requirement of not being stored in your web server's document root.

But I only have one server. What can I do?

If you can't store your files separately, store them locally and use a simple proxy script that allows read-only access to uploaded files (while guaranteeing that the file will not ever be executed directly). Compared to having a separate server for serving static user content, this solution entails a performance hit.

For example, this script assumes that you're storing the user-provided file in the user-provided filename (and checking for collisions, of course). It side-steps directory traversal and local file disclosure attacks by checking realpath() and checking each directory name individually and strips NUL bytes.

<?php /** * This is an example of an image proxy script. It assumes an .htaccess or nginx rewrite e.g. * files/.* -> /proxy_script.php?path=$1 */ require "../vendor/autoload.php"; if (empty($_GET['path'])) { header('HTTP/1.1 404 Not Found'); exit; } // We're going to iterate over $dirs $dirs = explode('/', $_GET['path']); // We start with $path set to the basepath $path = BASEPATH; // For the FileInfo functions: $fi = new finfo(FILEINFO_MIME, '/usr/share/file/magic'); // Bad filenames that should trigger an alert and terminate the script $bad_files = [ '..', '.git', '.htaccess', '.svn', 'composer.json', 'composer.lock', 'framework_config.yaml' ]; // Let's iterate through directories while (!empty($dirs)) { // PHP has a bad history of handling NUL bytes. Just strip them. $piece = str_replace("\0", '', array_shift($dirs)); if (empty($piece)) { continue; } if (in_array($piece, $bad_files)) { // We don't want these requests to succeeed. Framework::logger()->alert('File proxy - blacklist violation'); header('HTTP/1.1 404 Not Found'); exit; } if (is_dir($path . DIRECTORY_SEPARATOR . $piece)) { $realpath = realpath($path . DIRECTORY_SEPARATOR . $piece); if (strpos($realpath, $path) !== 0) { Framework::logger()->alert( 'Directory traversal attempt that somehow bypassed ".." blacklist.' ); header('HTTP/1.1 404 Not Found'); exit; } } $path .= DIRECTORY_SEPARATOR . $piece; } // If the file exists and is within BASEPATH (i.e. not a successful LFI) $realpath = realpath($path); if (file_exists($realpath) && strpos($realpath, BASEPATH) === 0) { $type = finfo_file($fi, $file); header("Content-Type: ".$type); readfile($realpath); exit; } // Are you still here? header('HTTP/1.1 404 Not Found');

There are a lot of ways this can be improved, of course. To name two:

Instead of storing the file at /home/example/uploaded/some/directories/user_provided.file , store all relevant metadata in a database record (while taking care to prevent SQL injection vulnerabilities) and use a random filename for the actual filesystem storage.

, store all relevant metadata in a database record (while taking care to prevent SQL injection vulnerabilities) and use a random filename for the actual filesystem storage. Instead of always reading from disk, integrate with Memcache and serve popular files directly from RAM (this usually results in a 90% performance gain).

However, even without these enhancements, you easily add directory-level (or even file-level) access controls.

If you follow this advice, congratulations, you've just avoided most of the attacks that plague applications that accept file uploads from end users. And you did all that without having to delve into the realm of server configuration.

Now let's look at some less effective strategies.

Ineffective Strategies for Securing File Upload Scripts

Blacklisting Bad File Extensions

Consider this snippet:

$block_extensions = ['php', 'pl', 'cgi']; $ext = preg_replace('/.+?\.(.+)$/', '$1', $_FILES['file']['name']); if (in_array($ext, $block_extensions)) { move_uploaded_file( /* ... */ ); }

The problem with this approach is the same problem that plagues any blacklist strategy: It permits anything that isn't known to be bad. Proof of concept: Save the following script as 0day.phtml , upload it with your form, then access upload_dir/0day.phtml?cmd=whoami :

<?php // If you are reading this, the code did not execute: echo shell_exec($_GET['cmd']), PHP_EOL;

If you are running PHP on an Apache web server, then your browser should say something like www-user . Additionally, attackers can be creative and upload their own malicious .htaccess file.

This might seem obvious, but even network security professionals overlook it.

For example, Snort Rule 1-27667 attempts to block attempts to exploit CVE-2013-5576 (which allows attackers to upload arbitrary PHP scripts by appending an extra . to the filename) will only block .php. files upload attempts, but not any of the other malicious file extensions (e.g. php3 or phtml ) that are executable by default on Apache.

(When we asked a Snort rule developer about this trivial bypass, they said, "99.9% of attackers aren't going to think of doing that.")

Checking the MIME Type in $_FILES

Consider this snippet:

$allowed_types = ['image/jpg', 'image/png', 'image/jpeg', 'image/gif']; if (in_array($_FILES['file']['type'], $allowed_types)) { move_uploaded_file( /* ... */ ); }

This seems like a good idea, but if someone wanted to upload a malicious file (for example, a PHP reverse shell script), all they need to do is upload a file and tell the server that its MIME type is image/gif . Game over.

Using getimagesize() to Verify that the File is an Image

When developing web applications that expect a photo, some developers think they can cleverly defeat attackers by using the GD extension and image processing functions to guarantee that the file a user has provided is actually an image. Unfortunately, it's not fool-proof.

As an excellent blog post by Benjamin Watson demonstrates, you can upload a valid JPEG image and still hide a malicious payload in its EXIF comments.

Other Security Considerations

Even if you save all of your user's files outside of the document root, if you have other filesystem-based vulnerabilities in your application, your upload form can still be an attack vector into your application. However, so can your email server, as Keith Makan explained in his blog post, Ordering Remote File Inclusion via Email.

Here's how it works:

Find a local file inclusion vulnerability elsewhere in the target application which isn't already immediately useful for compromising the entire system. Upload a file outside of the web-root that contains malicious shell code. Use the local file inclusion to get the file to execute.

There are two schools of thought for how to handle this:

Fix the vulnerability so this doesn't happen in the first place. Encode or encrypt the uploaded files so that they aren't directly executable in this fashion. (Defense-in-depth.)

Given that many environments would be exploitable through Makan's attack, we'd strongly recommend just fixing the vulnerability. There are academic merits to encoding/encrypting files and even situations where this would be desirable, but generally it would be unnecessary in the scenario we've described.