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](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-5120)). ### 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. /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](https://paragonie.com/blog/2015/05/preventing-sql-injection-in-php-applications-easy-and-definitive-guide)) and use a [random filename](https://paragonie.com/blog/2015/07/how-safely-generate-random-strings-and-integers-in-php) 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`: