PHP Image Upload Security Check List

PHP image upload security check list

Re-process the image using GD (or Imagick) and save the processed image. All others are just fun boring for hackers.

Edit: And as rr pointed out, use move_uploaded_file() for any upload.

Late Edit: By the way, you'd want to be very restrictive about your upload folder. Those places are one of the dark corners where many exploits happen. This is valid for any type of upload and any programming language/server. Check https://www.owasp.org/index.php/Unrestricted_File_Upload

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.

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}");
}
}

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.

antivirus check on PHP image upload necessary or not?

Why using an antivirus could be useful:

It is technically possible for an image to exploit a vulnerability in a system, not just as metadata, but in different ways, depending on the software that opens the image.

For instance, one of the first PSP vulnerabilities was due to the way the image viewer parsed a TIFF image (http://www.makeuseof.com/tag/how-to-downgrade-your-psp-and-upgrade-to-a-custom-firmware-part-one/#2.00).

Although probably unlikely, there could be a bug/vulnerability/backdoor in the library that resizes the image, so if you want to go the extremely cautious route, the order would be:

  1. Hand the image to the antivirus
  2. Process/store the image in any way.

You may use a library that offers PHP - antivirus integration, such as: http://sourceforge.net/projects/php-clamav/ (but keep in mind that DoS might also occur if requests queue up for a long time, when handing files to the AV; security often involves trade offs!).

NB: Having your library packages kept up to date goes a longer way than an AV, since after modification of the original image, any vulnerability exploitation will be probably corrupted/lost.

PHP Image Upload Security - gd / imagick / move_upload_file

For Nginx, To disable the execution of scripts, your way is correct. To disallow more script types,

location ~* ^/(upload|images|more_dirs)/.*\.(php|php5|sh|more_types)$ 
{
deny all;
}


Related Topics



Leave a reply



Submit