Secure User Image Upload Capabilities in PHP

Secure User Image Upload Capabilities in PHP

Regarding file names, random names are definitely a good idea and take away a lot of headaches.

If you want to make totally sure the content is clean, consider using GD or ImageMagick to copy the incoming image 1:1 into a new, empty one.

That will slightly diminish image quality because content gets compressed twice, but it will remove any EXIF information present in the original image. Users are often not even aware how much info gets put into the Metadata section of JPG files! Camera info, position, times, software used... It's good policy for sites that host images to remove that info for the user.

Also, copying the image will probably get rid of most exploits that use faulty image data to cause overflows in the viewer software, and inject malicious code. Such manipulated images will probably simply turn out unreadable for GD.

Full Secure Image Upload Script

When you start working on a secure image upload script, there are many things to consider. Now I'm no where near an expert on this, but I've been asked to develop this once in the past. I'm gonna walk through the entire process I've been through here so you can follow along. For this I'm gonna start with a very basic html form and php script that handles the files.

HTML form:

<form name="upload" action="upload.php" method="POST" enctype="multipart/form-data">
Select image to upload: <input type="file" name="image">
<input type="submit" name="upload" value="upload">
</form>

PHP file:

<?php
$uploaddir = 'uploads/';

$uploadfile = $uploaddir . basename($_FILES['image']['name']);

if (move_uploaded_file($_FILES['image']['tmp_name'], $uploadfile)) {
echo "Image succesfully uploaded.";
} else {
echo "Image uploading failed.";
}
?>

First problem: File types

Attackers don't have to use the form on your website to upload files to your server. POST requests can be intercepted in a number of ways. Think about browser addons, proxies, Perl scripts. No matter how hard we try, we can't prevent an attacker from trying to upload something they're not supposed to. So all of our security has to be done serverside.

The first problem is file types. In the script above an attacker could upload anything they want, like a php script for example, and follow a direct link to execute it. So to prevent this, we implement Content-type verification:

<?php
if($_FILES['image']['type'] != "image/png") {
echo "Only PNG images are allowed!";
exit;
}

$uploaddir = 'uploads/';

$uploadfile = $uploaddir . basename($_FILES['image']['name']);

if (move_uploaded_file($_FILES['image']['tmp_name'], $uploadfile)) {
echo "Image succesfully uploaded.";
} else {
echo "Image uploading failed.";
}
?>

Unfortunately this isn't enough. As I mentioned before, the attacker has full control over the request. Nothing will prevent him/her from modifying the request headers and simply change the Content type to "image/png". So instead of just relying on the Content-type header, it would be better to also validate the content of the uploaded file. Here's where the php GD library comes in handy. Using getimagesize(), we'll be processing the image with the GD library. If it isn't an image, this will fail and therefor the entire upload will fail:

<?php
$verifyimg = getimagesize($_FILES['image']['tmp_name']);

if($verifyimg['mime'] != 'image/png') {
echo "Only PNG images are allowed!";
exit;
}

$uploaddir = 'uploads/';

$uploadfile = $uploaddir . basename($_FILES['image']['name']);

if (move_uploaded_file($_FILES['image']['tmp_name'], $uploadfile)) {
echo "Image succesfully uploaded.";
} else {
echo "Image uploading failed.";
}
?>

We're still not there yet though. Most image file types allow text comments added to them. Again, nothing prevents the attacker from adding some php code as a comment. The GD library will evaluate this as a perfectly valid image. The PHP interpreter would completely ignore the image and run the php code in the comment. It's true that it depends on the php configuration which file extensions are processed by the php interpreter and which not, but since there are many developers out there that have no control over this configuration due to the use of a VPS, we can't assume the php interpreter won't process the image. This is why adding a file extension white list isn't safe enough either.

The solution to this would be to store the images in a location where an attacker can't access the file directly. This could be outside of the document root or in a directory protected by a .htaccess file:

order deny,allow
deny from all
allow from 127.0.0.1

Edit: After talking with some other PHP programmers, I highly suggest using a folder outside of the document root, because htaccess isn't always reliable.

We still need the user or any other visitor to be able to view the image though. So we'll use php to retrieve the image for them:

<?php
$uploaddir = 'uploads/';
$name = $_GET['name']; // Assuming the file name is in the URL for this example
readfile($uploaddir.$name);
?>

Second problem: Local file inclusion attacks

Although our script is reasonably secure by now, we can't assume the server doesn't suffer from other vulnerabilities. A common security vulnerability is known as Local file inclusion. To explain this I need to add an example code:

<?php
if(isset($_COOKIE['lang'])) {
$lang = $_COOKIE['lang'];
} elseif (isset($_GET['lang'])) {
$lang = $_GET['lang'];
} else {
$lang = 'english';
}

include("language/$lang.php");
?>

In this example we're talking about a multi language website. The sites language is not something considered to be "high risk" information. We try to get the visitors preferred language through a cookie or a GET request and include the required file based on it. Now consider what will happen when the attacker enters the following url:

www.example.com/index.php?lang=../uploads/my_evil_image.jpg

PHP will include the file uploaded by the attacker bypassing the fact that they can't access the file directly and we're back at square one.

The solution to this problem is to make sure the user doesn't know the filename on the server. Instead, we'll change the file name and even the extension using a database to keep track of it:

CREATE TABLE `uploads` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`original_name` VARCHAR(64) NOT NULL,
`mime_type` VARCHAR(20) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
<?php

if(!empty($_POST['upload']) && !empty($_FILES['image']) && $_FILES['image']['error'] == 0)) {

$uploaddir = 'uploads/';

/* Generates random filename and extension */
function tempnam_sfx($path, $suffix){
do {
$file = $path."/".mt_rand().$suffix;
$fp = @fopen($file, 'x');
}
while(!$fp);

fclose($fp);
return $file;
}

/* Process image with GD library */
$verifyimg = getimagesize($_FILES['image']['tmp_name']);

/* Make sure the MIME type is an image */
$pattern = "#^(image/)[^\s\n<]+$#i";

if(!preg_match($pattern, $verifyimg['mime']){
die("Only image files are allowed!");
}

/* Rename both the image and the extension */
$uploadfile = tempnam_sfx($uploaddir, ".tmp");

/* Upload the file to a secure directory with the new name and extension */
if (move_uploaded_file($_FILES['image']['tmp_name'], $uploadfile)) {

/* Setup a database connection with PDO */
$dbhost = "localhost";
$dbuser = "";
$dbpass = "";
$dbname = "";

// Set DSN
$dsn = 'mysql:host='.$dbhost.';dbname='.$dbname;

// Set options
$options = array(
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
);

try {
$db = new PDO($dsn, $dbuser, $dbpass, $options);
}
catch(PDOException $e){
die("Error!: " . $e->getMessage());
}

/* Setup query */
$query = 'INSERT INTO uploads (name, original_name, mime_type) VALUES (:name, :oriname, :mime)';

/* Prepare query */
$db->prepare($query);

/* Bind parameters */
$db->bindParam(':name', basename($uploadfile));
$db->bindParam(':oriname', basename($_FILES['image']['name']));
$db->bindParam(':mime', $_FILES['image']['type']);

/* Execute query */
try {
$db->execute();
}
catch(PDOException $e){
// Remove the uploaded file
unlink($uploadfile);

die("Error!: " . $e->getMessage());
}
} else {
die("Image upload failed!");
}
}
?>

So now we've done the following:

  • We've created a secure place to save the images
  • We've processed the image with the GD library
  • We've checked the image MIME type
  • We've renamed the file name and changed the extension
  • We've saved both the new and original filename in our database
  • We've also saved the MIME type in our database

We still need to be able to display the image to visitors. We simply use the id column of our database to do this:

<?php

$uploaddir = 'uploads/';
$id = 1;

/* Setup a database connection with PDO */
$dbhost = "localhost";
$dbuser = "";
$dbpass = "";
$dbname = "";

// Set DSN
$dsn = 'mysql:host='.$dbhost.';dbname='.$dbname;

// Set options
$options = array(
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
);

try {
$db = new PDO($dsn, $dbuser, $dbpass, $options);
}
catch(PDOException $e){
die("Error!: " . $e->getMessage());
}

/* Setup query */
$query = 'SELECT name, original_name, mime_type FROM uploads WHERE id=:id';

/* Prepare query */
$db->prepare($query);

/* Bind parameters */
$db->bindParam(':id', $id);

/* Execute query */
try {
$db->execute();
$result = $db->fetch(PDO::FETCH_ASSOC);
}
catch(PDOException $e){
die("Error!: " . $e->getMessage());
}

/* Get the original filename */
$newfile = $result['original_name'];

/* Send headers and file to visitor */
header('Content-Description: File Transfer');
header('Content-Disposition: attachment; filename='.basename($newfile));
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . filesize($uploaddir.$result['name']));
header("Content-Type: " . $result['mime_type']);
readfile($uploaddir.$result['name']);
?>

Thanks to this script the visitor will be able to view the image or download it with its original filename. However, they can't access the file on your server directly nor will they be able to fool your server to access the file for him/her as they has no way of knowing which file it is. They can't brute force your upload directory either as it simply doesn't allow anyone to access the directory except the server itself.

And that concludes my secure image upload script.

I'd like to add that I didn't include a maximum file size into this script, but you should easily be able to do that yourself.

ImageUpload Class

Due to the high demand of this script, I've written an ImageUpload class that should make it a lot easier for all of you to securely handle images uploaded by your website visitors. The class can handle both single and multiple files at once, and provides you with additional features like displaying, downloading and deleting images.

Since the code is simply to large to post here, you can download the class from MEGA here:

Download ImageUpload Class

Just read the README.txt and follow the instructions.

Going Open Source

The Image Secure class project is now also available on my Github profile. This so that others (you?) can contribute towards the project and make this a great library for everyone.

How do I secure private photos that a user uploads on my site?

You are correct in your original assumption. Store your files outside of the public directory and use a PHP script to check authorization and display the image.

To get around the sharing problem you can give them an area where they can say "Share this photo" and it will display a URL like

http://www.yoursite.com/image/12390123?v=XA21IW

XA21IW would be some unique hash stored in a table and they can specify a lifetime or you can code one yourself. When the page loads and v is passed in you can lookup a table to determine if it is a valid hash for that image id.

You have some options here. Every time they click "Share this photo" you can:

  1. Destroy all old hashes
  2. Add on to the stack
  3. Allow them to configure an expiration etc...

Or simply allow images to be public/private.

Secure image upload with PHP?

You need to use php move_upload_file function and also I have made changes to your if statement here is the working and tested example:

<?php

if (isset($_REQUEST["submit"])) {

$allowedExts = array("jpg", "jpeg", "gif", "png");
$extension = end(explode(".", $_FILES["file"]["name"]));

if ($_FILES["file"]["type"] == "image/gif" || $_FILES["file"]["type"] == "image/jpg" || $_FILES["file"]["type"] == "image/jpeg" || $_FILES["file"]["type"] == "image/png" && $_FILES["file"]["size"] < 2500000 && in_array($extension, $allowedExts)) {

if ($_FILES["file"]["error"] > 0) {

echo "Error: " . $_FILES["file"]["error"] . "<br />";

}
else {

$fname = $_FILES["file"]["name"];
move_uploaded_file($_FILES["file"]["tmp_name"], $fname);

echo "Upload: " . $_FILES["file"]["name"] . "<br />";
echo "Type: " . $_FILES["file"]["type"] . "<br />";
echo "Size: " . ($_FILES["file"]["size"] / 1024) . " Kb<br />";
echo "Stored in: " . $fname;

}

}
else {

echo "Invalid file type";

}

}
?>
<form action="" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" name="submit" value="submit" />
</form>

You can also use getimagesize function as suggested by doing next thing:

$size = getimagesize("http://www.simplestudio.rs/060620121945.jpg");

$file_format = $size['mime'];

$file_format will be represented as for example "image/jpeg" so you can easily check for image types like this:

foreach($allowedExts as $allowed) {

$chk_types = strpos($file_format, $allowed);

if($chk_types > -1) {
$type_is_good = true;
break;
}

}

Secure image upload in php

As long as you don't use the FileInfo (http://www.php.net/manual/en/ref.fileinfo.php) extensions from php to check the mime type, your function is not secure at all (think later you'll want to upload pdf's, excels, etc).

Also, md5 over md5 does nothing than increasing the collision chances.

L.E: Something as simple as the following should do it:

function getExtensionToMimeTypeMapping() {
return array(
'ai'=>'application/postscript',
'aif'=>'audio/x-aiff',
'aifc'=>'audio/x-aiff',
'aiff'=>'audio/x-aiff',
'anx'=>'application/annodex',
'asc'=>'text/plain',
'au'=>'audio/basic',
'avi'=>'video/x-msvideo',
'axa'=>'audio/annodex',
'axv'=>'video/annodex',
'bcpio'=>'application/x-bcpio',
'bin'=>'application/octet-stream',
'bmp'=>'image/bmp',
'c'=>'text/plain',
'cc'=>'text/plain',
'ccad'=>'application/clariscad',
'cdf'=>'application/x-netcdf',
'class'=>'application/octet-stream',
'cpio'=>'application/x-cpio',
'cpt'=>'application/mac-compactpro',
'csh'=>'application/x-csh',
'css'=>'text/css',
'csv'=>'text/csv',
'dcr'=>'application/x-director',
'dir'=>'application/x-director',
'dms'=>'application/octet-stream',
'doc'=>'application/msword',
'drw'=>'application/drafting',
'dvi'=>'application/x-dvi',
'dwg'=>'application/acad',
'dxf'=>'application/dxf',
'dxr'=>'application/x-director',
'eps'=>'application/postscript',
'etx'=>'text/x-setext',
'exe'=>'application/octet-stream',
'ez'=>'application/andrew-inset',
'f'=>'text/plain',
'f90'=>'text/plain',
'flac'=>'audio/flac',
'fli'=>'video/x-fli',
'flv'=>'video/x-flv',
'gif'=>'image/gif',
'gtar'=>'application/x-gtar',
'gz'=>'application/x-gzip',
'h'=>'text/plain',
'hdf'=>'application/x-hdf',
'hh'=>'text/plain',
'hqx'=>'application/mac-binhex40',
'htm'=>'text/html',
'html'=>'text/html',
'ice'=>'x-conference/x-cooltalk',
'ief'=>'image/ief',
'iges'=>'model/iges',
'igs'=>'model/iges',
'ips'=>'application/x-ipscript',
'ipx'=>'application/x-ipix',
'jpe'=>'image/jpeg',
'jpeg'=>'image/jpeg',
'jpg'=>'image/jpeg',
'js'=>'application/x-javascript',
'kar'=>'audio/midi',
'latex'=>'application/x-latex',
'lha'=>'application/octet-stream',
'lsp'=>'application/x-lisp',
'lzh'=>'application/octet-stream',
'm'=>'text/plain',
'man'=>'application/x-troff-man',
'me'=>'application/x-troff-me',
'mesh'=>'model/mesh',
'mid'=>'audio/midi',
'midi'=>'audio/midi',
'mif'=>'application/vnd.mif',
'mime'=>'www/mime',
'mov'=>'video/quicktime',
'movie'=>'video/x-sgi-movie',
'mp2'=>'audio/mpeg',
'mp3'=>'audio/mpeg',
'mpe'=>'video/mpeg',
'mpeg'=>'video/mpeg',
'mpg'=>'video/mpeg',
'mpga'=>'audio/mpeg',
'ms'=>'application/x-troff-ms',
'msh'=>'model/mesh',
'nc'=>'application/x-netcdf',
'oga'=>'audio/ogg',
'ogg'=>'audio/ogg',
'ogv'=>'video/ogg',
'ogx'=>'application/ogg',
'oda'=>'application/oda',
'pbm'=>'image/x-portable-bitmap',
'pdb'=>'chemical/x-pdb',
'pdf'=>'application/pdf',
'pgm'=>'image/x-portable-graymap',
'pgn'=>'application/x-chess-pgn',
'png'=>'image/png',
'pnm'=>'image/x-portable-anymap',
'pot'=>'application/mspowerpoint',
'ppm'=>'image/x-portable-pixmap',
'pps'=>'application/mspowerpoint',
'ppt'=>'application/mspowerpoint',
'ppz'=>'application/mspowerpoint',
'pre'=>'application/x-freelance',
'prt'=>'application/pro_eng',
'ps'=>'application/postscript',
'qt'=>'video/quicktime',
'ra'=>'audio/x-realaudio',
'ram'=>'audio/x-pn-realaudio',
'ras'=>'image/cmu-raster',
'rgb'=>'image/x-rgb',
'rm'=>'audio/x-pn-realaudio',
'roff'=>'application/x-troff',
'rpm'=>'audio/x-pn-realaudio-plugin',
'rtf'=>'text/rtf',
'rtx'=>'text/richtext',
'scm'=>'application/x-lotusscreencam',
'set'=>'application/set',
'sgm'=>'text/sgml',
'sgml'=>'text/sgml',
'sh'=>'application/x-sh',
'shar'=>'application/x-shar',
'silo'=>'model/mesh',
'sit'=>'application/x-stuffit',
'skd'=>'application/x-koan',
'skm'=>'application/x-koan',
'skp'=>'application/x-koan',
'skt'=>'application/x-koan',
'smi'=>'application/smil',
'smil'=>'application/smil',
'snd'=>'audio/basic',
'sol'=>'application/solids',
'spl'=>'application/x-futuresplash',
'spx'=>'audio/ogg',
'src'=>'application/x-wais-source',
'step'=>'application/STEP',
'stl'=>'application/SLA',
'stp'=>'application/STEP',
'sv4cpio'=>'application/x-sv4cpio',
'sv4crc'=>'application/x-sv4crc',
'swf'=>'application/x-shockwave-flash',
't'=>'application/x-troff',
'tar'=>'application/x-tar',
'tcl'=>'application/x-tcl',
'tex'=>'application/x-tex',
'texi'=>'application/x-texinfo',
'texinfo'=>'application/x-texinfo',
'tif'=>'image/tiff',
'tiff'=>'image/tiff',
'tr'=>'application/x-troff',
'tsi'=>'audio/TSP-audio',
'tsp'=>'application/dsptype',
'tsv'=>'text/tab-separated-values',
'txt'=>'text/plain',
'unv'=>'application/i-deas',
'ustar'=>'application/x-ustar',
'vcd'=>'application/x-cdlink',
'vda'=>'application/vda',
'viv'=>'video/vnd.vivo',
'vivo'=>'video/vnd.vivo',
'vrml'=>'model/vrml',
'wav'=>'audio/x-wav',
'wrl'=>'model/vrml',
'xbm'=>'image/x-xbitmap',
'xlc'=>'application/vnd.ms-excel',
'xll'=>'application/vnd.ms-excel',
'xlm'=>'application/vnd.ms-excel',
'xls'=>'application/vnd.ms-excel',
'xlw'=>'application/vnd.ms-excel',
'xml'=>'application/xml',
'xpm'=>'image/x-xpixmap',
'xspf'=>'application/xspf+xml',
'xwd'=>'image/x-xwindowdump',
'xyz'=>'chemical/x-pdb',
'zip'=>'application/zip',
);
}

function getMimeType($filePath) {

if (!is_file($filePath)) {
return false;
}

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $filePath);
finfo_close($finfo);

return $mime;
}

function upload($filePath, $destinationDir = 'images', array $allowedMimes = array()) {

if (!is_file($filePath) || !is_dir($destinationDir)) {
return false;
}

if (!($mime = getMimeType($filePath))) {
return false;
}

if (!in_array($mime, $allowedMimes)) {
return false;
}

$ext = null;
$extMapping = getExtensionToMimeTypeMapping();
foreach ($extMapping as $extension => $mimeType) {
if ($mimeType == $mime) {
$ext = $extension;
break;
}
}

if (empty($ext)) {
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
}

if (empty($ext)) {
return false;
}

$fileName = md5(uniqid(rand(0, time()), true)) . '.' . $ext;
$newFilePath = $destinationDir.'/'.$fileName;

if(!rename($filePath, $newFilePath)) {
return false;
}

return $fileName;
}

// use it
if (isset($_FILES['something']['tmp_name'])) {
$file = $_FILES['something']['tmp_name'];
$storagePath = 'images'; // this is relative to this script, better use absolute path.
$allowedMimes = array('image/png', 'image/jpg', 'image/gif', 'image/pjpeg');

$fileName = upload($file, $storagePath, $allowedMimes);
if (!$fileName) {
exit ('Your file type is not allowed.');
} else {
// check if file is image, optional, in case you allow multiple types of files.
// $imageInfo = @getimagesize($storagePath.'/'.$fileName);
exit ("Your uploaded file is {$fileName} and can be found at {$storagePath}/{$fileName}");
}
}

Image upload security. GD enough? when do I use it?

1st:

That should be good enough. Make sure that the images are only temporarily accessible and are deleted right after everything is done (I would probably also set up a cron job to clean the temporary image directory every so often, but that depends where you store it). As said in the answer you posted, remember to sanitize name and also make sure you have correctly set permissions. Also beware of null byte injection and directory traversal (again just repeating the answer you mentioned).
I would then check whether the image is valid by using getimagesize to ensure it's an actual image and that's about it.

There are also client side html5 solutions, where you evade this problem completely, but of course it means that it won't work with older browsers.
And of course don't trust what the content type it says it is.

2nd:
Yes I would have thought, first try getimagesize, if you get valid then process with GD. Optionally you can also whitelist extensions, if that's something you are not doing.

PHP where to save user uploaded image files

Contrary to what some believe is bad practice, the location itself is not the problem; but you have to take care of a few things:

  • Filter by extensions; accept a few image formats (.jpg, .gif, .png) only and reject the rest; definitely don't accept .php extension

  • Don't trust the mime-type sent by the browsers to determine if an image is valid. Use getimagesize() to do this yourself; this can be fooled by hiding a PHP script inside an image, but that's why we have one more important step.

  • Important - Make sure that images are NOT served with the PHP engine; some images can be crafted in a way that it looks like an image but hides a script inside. Use the web server's configuration for this.

  • Other issues you need to be aware about when you're handling uploads

See also: Secure User Image Upload Capabilities in PHP

Btw, to test if the PHP engine is not being used to serve images, make sure the expose_php is On (you can tell from a phpinfo() page. Then download an image with your browser, inspect the response headers and check whether you see the X-Powered-By header; if it's not there, you should be safe.

Secure image upload PHP

Mime types cannot be trusted, and so, as @yoeriboven said, you can use:

if (!getimagesize($_FILES['profile_image']['tmp_name'])){
die('Not an image');
}

but you can also not allow execution of any files uploaded to your uploads folder. You can do this with .htaccess (assuming you're using Apache) by doing:

AddHandler cgi-script .php .pl .py .jsp .asp .htm .shtml .sh .cgi
Options -ExecCGI

Another little additional thing you can do, just to help users, is to use HTML5's accept attribute:

<input type="file" name="file" accept="image/gif, image/jpeg" />

(obviously the above can be changed client side so it's not to be relied on)



Related Topics



Leave a reply



Submit