Wordpress Plugin Vulnerabilities: From a Developer’s Point of View

1. Introduction

We all know the prevalence of the WordPress blogging system and its share of vulnerabilities in the core system alone over the years. If not, we can take a look at the cvedetails web page that presents all the vulnerabilities from 2004 to the present.

We can see that there is a total of 140 known vulnerabilities in the core WordPress system. Most of them are classified as XSS, code execution, gain information, and SQL injection vulnerabilities. But this is only showing for the core WordPress system, not including the WordPress plugins, which also have quite a big number of vulnerabilities. This is why this article will talk about those. We can look at the exploit-db and search for a string "wordpress plugins" to find the latest vulnerabilities found in WordPress plugins. The picture below presents the latest vulnerabilities found in WordPress plugins:

image0

The majority of those vulnerabilities are classified as arbitrary code execution, SQL injection or remote file inclusion vulnerabilities. In the next section, we'll take a look at the following vulnerabilities more closely:

SQL Injection:

Plugin-Version Vulnerable File Vulnerable Parameter
upm-polls-1.0.4 functions.php PID
wp-glossary ajax.php id

Remote File Inclusion:

Plugin-Version Vulnerable File Vulnerable Parameter
backWPup-2.1.4 job/wp_export_generate.php BackWPupJobTemp, nonce, type
relocate-upload-0.14 relocate-upload.php ru_folder, abspath
mini-mail-dashboard-1.36 wp-mini-mail.php abspath
zingiri-web-shop-2.2.0 fwkfor/ajax/init.inc.php fws/ajax/init.inc.php wpabspath wpabspath

2. Analyzing WordPress Plugins

In this section, we'll download all the plugins from SVN directory located at http://plugins.svn.wordpress.org into the WordPress directory wp-content/plugins/. Afterwards we'll analyze the vulnerable files for vulnerabilities. ** **

2.1. Plugin upm-polls

First we need to download and install the plugin into wp-content/plugins/ directory. Then we need to enable the plugin in the WordPress admin preferences. After that we can enter the following URL into the web browser, which uses the vulnerable PID parameter:

<a
href="http://127.0.0.1/wordpress/wp-admin/admin-ajax.php?action=upm_ayax_polls_result&amp;do=result&amp;post=1&amp;type=general&amp;PID=1">http://127.0.0.1/wordpress/wp-admin/admin-ajax.php?action=upm_ayax_polls_result&amp;do=result&amp;post=1&amp;type=general&amp;PID=1</a>

Upon entering this URL, the "Do You Like My Site?" text will appear, which is part of the upm-tools plugin. We can see that in the picture below:

image1

Hm, now we need to prove that the PID parameter is vulnerable. The vulnerability description on exploit-db doesn't say anything about which file is vulnerable, only that the PID parameter accepts an arbitrary SQL sentence. If we manually search for the string PID in the plugin directory, we will find that the file functions.php contains the only occurrences of the PID parameter. After careful examination we can conclude that it's the function upm_ayax_polls_result that uses the PID parameter. Let's take a look at the code of the function upm_ayax_polls_result:

function upm_ayax_polls_result(){
global $wpdb;
if( $_POST['upm_poll_id'] &amp;&amp; $_POST['upm_action'] ==
'polling' ){
#########################################################
$user = wp_get_current_user();
$logging = get_option('pppm_poll_logging');
if( $logging == 1 || $logging == 4 ){
setcookie("_upm-polls-".$_POST['upm_poll_id'], '1', time()+
intval(upm_get_seconds()), '/');
}
if( intval($_POST['upm_answer']) ){
$wpdb-&gt;query( "INSERT INTO
`".$wpdb-&gt;prefix."pppm_polls_votes` VALUES( NULL,
".intval($_POST['upm_poll_id']).",
".intval($_POST['upm_answer']).",
".intval($user-&gt;ID).",
'".$_SERVER['REMOTE_ADDR']."',
".time().", '')" );
}
//upm_polls_result($_POST['upm_poll_id'], $_POST['type']);
exit();
}
elseif( $_GET['do'] == 'result' &amp;&amp; $_GET['PID'] ){
if( $_GET['type'] == 'specific'){global $post; $post = get_post(
$_GET['post'] );}
$POLL = pppm_get_polls( $_GET['type'],
get_option('pppm_poll_first_poll') );
if( $POLL['id'] !='' ){ $button = true; } else { $button = false; }
upm_polls_result($_GET['PID'], $_GET['type'], $button);
exit();
}
elseif( $_GET['do'] == 'next' ){
if( $_GET['type'] == 'specific'){global $post; $post = get_post(
$_GET['post'] );}
upm_polls( 'next' , $_GET['type'] );
exit();
}
}

First, let's print the "Start upm_ayax_polls_result" and "End upm_ayax_polls_result" at the beginning and ending of that function just to make sure that function is really called when processing the user input. After that the following will appear in our browser if we resend the requests:

image2

Cool, we can see that the function upm_ayax_polls_result is indeed being called. But where is the "End upm_ayax_polls_result"? It isn't present, because the function is calling exit(); so the execution never reaches the end of the function.

The next thing to do is to add new PHP code into each of the if-elseif code part to determine which of those if sentences is getting called. We can add the following lines in each of the if-elseif code block (the first line goes into the first if block, the second line goes into the first elseif block and the third line goes into the second elseif block):

print " IFA";
print " IFB";
print " IFC";

Upon resending the request we receive the response shown in the picture below:

image3

So the first elseif block is being called, which is excellent because that code block actually uses the vulnerable PID parameter. Let's print the parameter we're using in our request again:

?action=upm_ayax_polls_result&amp;do=result&amp;post=1&amp;type=general&amp;PID=1

But let's check the if statements of the first if block:

if( $_POST['upm_poll_id'] &amp;&amp; $_POST['upm_action'] == 'polling' ) { ... }

We must not fall into the above if code block, because we will not enter the first elseif block then, thus won't use the vulnerable PID parameter. This is why we can't define the upm_poll_id parameter or use the value polling in upm_action parameter. If we look at the parameter we're using we can see that we're not supplying any input parameter that could force the program to enter into the first if code section. This is ok.

Let's look at the first elseif conditions of the current code block now:

elseif( $_GET['do'] == 'result' &amp;&amp; $_GET['PID'] ) { … }

We can see that we need to define a parameter do with a value result and define whatever value in parameter PID (the parameter PID only needs to be defined). From our request above we can see that we're doing exactly that, the parameter do contains the value result and the parameter PID contains the value 1, thus the execution of the program will enter into the first elseif code block.

After that we need to check where we're using the vulnerable PID parameter so the vulnerability can happen. The only time we're using PID parameter is when we're calling the upm_polls_result function:

upm_polls_result($_GET['PID'], $_GET['type'], $button);

After that we need to check what the function upm_polls_result looks like. Let's present the whole function here:

function upm_polls_result($poll_id, $type = 'general',
$next_button = true, $full_access = false ){
error_reporting(0);
global $wpdb;
$at = false;
if( $type == 'admin' ){ $at = true; $type = 'general'; }
if( $type == 'adminS' ){ $at = true; $type = 'specific'; }

if( $poll_id != '' ){
$POLL = pppm_get_polls( $type, 'default', $poll_id, true,
$full_access );
}
else{

if( $type == 'general' ){
if( is_numeric(get_option('pppm_poll_first_poll'))){
$POLL = pppm_get_polls( 'general', 'default',
get_option('pppm_poll_first_poll'), true , $full_access);
}
else{
$POLL = pppm_get_polls( 'general', 'random', 0, true, $full_access
);
}
}
else{
if( is_numeric(get_option('pppm_poll_first_poll'))){
$POLL = pppm_get_polls( 'specific', 'random',
get_option('pppm_poll_first_poll'), true, $full_access );
}
else{
$POLL = pppm_get_polls( 'specific',
get_option('pppm_poll_first_poll'), 0, true, $full_access );
}
}
}

$poll_id = $POLL['id'];

if( $at ){
$TMP = '&lt;div class="upm_polls"&gt;
&lt;ol class="upm_poll_ul"&gt;
[ANSWERS-START]
&lt;li class="upm_poll_form_list"&gt;
&lt;span class="upm_poll_result_title"&gt;[ANSWER]&lt;/span&gt;
&lt;span class="upm_poll_result_text"&gt;([V%], [V#]
Votes)&lt;/span&gt;
&lt;div class="upm_pollbar" style="background:[POLLBAR-BG];
width:[POLLBAR-WIDTH]; height:8px"&gt;&lt;/div&gt;
&lt;/li&gt;
[ANSWERS-END]
&lt;/ol&gt;
&lt;div class="upm_poll_footer"&gt; &lt;strong&gt;Total Votes:
[TOTAL-VOTERS]&lt;/strong&gt; &lt;/div&gt;
&lt;/div&gt;';
}
else{
$TMP = stripslashes(get_option('pppm_poll_results_template'));
}
$t1 = explode('[ANSWERS-START]', $TMP);
$t2 = explode('[ANSWERS-END]', $t1[1]);
$TMP_HEADER = $t1[0];
$TMP_LOOP = $t2[0];
$TMP_FOOTER = $t2[1];
$TMP_HEADER = str_replace( '[QUESTION]',
stripslashes($POLL['question']), $TMP_HEADER );
echo $TMP_HEADER;
$pnum = mysql_num_rows(mysql_query("SELECT `id` FROM
`".$wpdb-&gt;prefix."pppm_polls_votes` WHERE `qid` =
".intval($poll_id) ));
foreach( $POLL['answers'] as $item ){
$num = mysql_num_rows(mysql_query("SELECT `id` FROM
`".$wpdb-&gt;prefix."pppm_polls_votes` WHERE `qid` =
".intval($poll_id)." AND `item_id` = ".$item['id']));
$W = ceil((((int)$num/(int)$pnum)*100));
$N = (((int)$num/(int)$pnum)*100);
$find = array('[ANSWER]', '[ANSWER-ID]', '[POLLBAR-BG]',
'[POLLBAR-WIDTH]', '[POLLBAR-HEIGHT]', '[TOTAL-VOTERS]', '[V%]',
'[V#]');
if( $W == 0 ) $W = 1;

///////////////////////////////////////////////////////////////////////////////////////////////////
if( get_option('pppm_poll_bgtype') ){
$bg = "url('".get_option('pppm_poll_bg_url')."');";
}
else{
$bg = "#".get_option('pppm_poll_bg_color').";";
}
$H = trim(get_option('pppm_poll_height'));
$replace = array( stripslashes($item['answer']), $item['id'], $bg,
$W.'%', $H.'px', $pnum, round($N,2).'%', $num);

///////////////////////////////////////////////////////////////////////////////////////////////////
echo str_replace( $find, $replace, $TMP_LOOP );
}

if( get_option('pppm_poll_onoff_next') &amp;&amp; $next_button )
{
$_next = ' | &lt;a href="javascript:UPM_next()"
class="upm_next_poll"&gt;Next Poll&lt;/a&gt;' ;
}
else{
$_next = '';
}
$_find = array('[TOTAL-VOTERS]','[POLL-ID]','[NEXT-POLL]');
$_replace = array($pnum, $poll_id, $_next);
if( $POLL == false ){
return false;
}
else{
echo str_replace( $_find, $_replace, $TMP_FOOTER );
}

}

We can see that we're first declaring the $POLL variable by executing the code below (this is because the $poll_id that presents the value in the PID parameter is not empty):

$POLL = pppm_get_polls( $type, 'default', $poll_id, true, $full_access );

So the value of the PID parameter is being passed as the third parameter into the function pppm_get_polls. Let's check the pppm_get_polls function now (only the relevant part of the function is shown):

function pppm_get_polls( $type = 'general', $mode = 'default', $poll_id = 0, $extra = false, $full_access = false ){

global $wpdb;
$g = 0; $s = 0;
global $post;

if( $poll_id ) {
$q_sql = " WHERE `id` = $poll_id ";
}
else{
$q_sql = "";
}
}

In that function we're initializing the variable q_sql which holds the WHERE part of the SQL sentence, so now we have a chance to construct an arbitrary SQL statement that will get executed. This is because the variable $poll_id holds the value of the PID parameter which is appended to the WHERE part of the SQL statement that is later executed. The following code takes the value of q_sql, appends it to the SQL statement and executes it:

$upm_polls = $wpdb-&gt;get_results("SELECT * FROM
`".$wpdb-&gt;prefix."pppm_polls` $q_sql ORDER BY `id` ".$ad);

We can see that we're actually executing the following SQL statement:

SELECT * FROM wp_ppm_polls WHERE id = &lt;PID&gt; ORDER BY id ASC;

If we enter the value <strong>1 </strong>into the PID parameter, we're be executing the SQL below:

SELECT * FROM wp_ppm_polls WHERE id = 1 ORDER BY id ASC;

Now we can control the execution of the SQL sentence, we can end the previous SQL sentence with ';' and add another sentence. There are limitless possibilities regarding insertion/deletion and modification of the SQL database - the only thing that can stop us know is the fact that WordPress is running as a user that has limited permissions over the database, which is usually not the case.

In order to correct this vulnerability we would need to encode the inputted string, so we wouldn't be able to break out of the string bounds when constructing the SQL query.

2.2. Plugin mini-mail-dashboard

The exploit on the exploit-db says the following:

---
PoC
---

http://SERVER/WP_PATH/wp-content/plugins/mini-mail-dashboard-widget/wp-mini-mail.php?abspath=RFI
(requires POSTing a file with ID wpmm-upload for this to work)

---
Vulnerable Code
---
if (isset($_FILES['wpmm-upload'])) {
// Create WordPress environmnt
require_once(urldecode($_REQUEST['abspath']) . 'wp-load.php');

// Handle attachment
WPMiniMail::wpmm_upload();
}

From the above vulnerability explanation, we can gather that the vulnerable code lies somewhere inside the wp-mini-mail.php file that is presented below (the comments are stripped for clarity):

&lt;?php
#error_reporting(E_ALL);

// Check PHP version
if (version_compare(PHP_VERSION, '5.2.4', '&lt;'))
die('Mini Mail requires at least PHP 5.2.4, installed version is ' .
PHP_VERSION);

// Include mini mail class
if (!class_exists('WPMiniMail'))
require_once('wp-mini-mail-class.php');

if (isset($_FILES['wpmm-upload'])) {
// Create WordPress environmnt
require_once(urldecode($_REQUEST['abspath']) . 'wp-load.php');

// Handle attachment
WPMiniMail::wpmm_upload();
}
else {
// Check pre-requisites
WPMiniMail::wpmm_check_prerequisites();

// Start plugin
global $wp_mini_mail;
$wp_mini_mail = new WPMiniMail();

// Schedule cron if needed
if (!wp_next_scheduled('wpmm_cron')) {
$hour = intval(time() / 3600) + 1;
wp_schedule_event($hour * 3600, 'wpmm_schedule', 'wpmm_cron');
}

add_action('wpmm_cron', 'wpmm_cron');
}

function wpmm_cron() {
global $wp_mini_mail;
$wp_mini_mail-&gt;wpmm_cron();
}

// That's it!

?&gt;

We can see that we first check if PHP version at least 5.2.4 is installed after which we include the wp-mini-mail-class.php for processing. After that we're checking if wpmm-upload variable exists. If that variable exists, we're entering into the if block and executing the wpmm_upload function that uploads the file to the server.

We can use the request below to actually upload the file to the server. In that example we're trying to upload a file with a filename "hello.txt" that contains the text "Hello World".

POST
/wordpress/wp-content/plugins/1.36/wp-mini-mail.php?abspath=/var/www/localhost/htdocs/wordpress/
HTTP/1.1

Host: 192.168.1.2
Content-Type: multipart/form-data;
boundary=----------oWYTShxbl0FT7PA26lIb0g

Content-Length: 286

------------oWYTShxbl0FT7PA26lIb0g

Content-Disposition: form-data; name="wpmm-upload"; filename="hello.txt"

Content-Type: text/plain

Hello World

------------oWYTShxbl0FT7PA26lIb0g

Content-Disposition: form-data; name="submit"

Submit

------------oWYTShxbl0FT7PA26lIb0g--

Upon sending this request, we'll get a response as seen on the picture below:

image4

But where does this "Unauthorized" message come from? If we take a look at the PHP code that is called when we try to upload the file, we can see that the function wpmm_upload is called. That function resides in the file and contains:

function wpmm_upload() {
header('Content-Type: text/html; charset=' . $_REQUEST['charset']);

// Security check
if (!wp_verify_nonce($_REQUEST['nonce'], c_wpmm_nonce_upload))
die('Unauthorized');

$folder = $upload_dir['basedir'] . '/mini-mail/';
$file = $_FILES['wpmm-upload']['name'];
$tmp_file = $_FILES['wpmm-upload']['tmp_name'];
$status = $_FILES['wpmm-upload']['error'];

if ($status == UPLOAD_ERR_OK) {
// Create &amp; secure attachment folder
if (!file_exists($folder))
mkdir($folder, 0751, true);

// Create index.php to prevent listing
if (!file_exists($folder . 'index.php'))
fclose(fopen($folder . 'index.php', 'w'));

// Create .htaccess to prevent access
if (!file_exists($folder . '.htaccess')) {
$fh = fopen($folder . '.htaccess', 'w');
if ($fh) {
fwrite($fh, 'order deny,allow' . PHP_EOL);
fwrite($fh, 'deny from all' . PHP_EOL . PHP_EOL);
fclose($fh);
}
}

// Move attachment
if (move_uploaded_file($tmp_file, $folder . '_' . $file . '_'))
echo htmlspecialchars($file);
else
die('Error moving ' . $tmp_file);
}
else
die('Upload error ' . $status);
}

The function wp_verify_nonce verifies that the current user is authorized to upload a file. For now let's comment out this code. If we resent the above request now, we'll receive the following response (as seen in the picture below):

image5

The file was successfully uploaded to the server. The file is located under wp-content/uploads/ which contains the files listed below:

# find .
./mini-mail
./mini-mail/_hello.txt_
./mini-mail/.htaccess
./mini-mail/index.php
./2012
./2012/09

Those files contain a content as follows:

# cat mini-mail/_hello.txt_
Hello World

# cat mini-mail/.htaccess
order deny,allow
deny from all

# cat mini-mail/index.php
#

The hello.txt we uploaded is renamed to "_hello.txt_", but contains the same contents. There's also index.php to prevent listing the current directory contents. And the .htaccess prevents anyone from executing the uploaded files. I guess that the author of this plugin was thinking with security in mind, because first we must bypass the token, then the file actually is uploaded, but the same directory also contains the index.php and .htaccess, which prevent any execution of the code contained in the uploaded file. This is why this vulnerability could be flagged as a false positive, because the valid security checks are in place.

I guess quite a big security vulnerability would be present if the plugin didn't install the .htaccess file in the same directory as our file was uploaded to.

3. Conclusion

We've seen that when writing WordPress plugins, there are numerous things that could go wrong. We need to encapsulate each parameter we're appending to an SQL query as well as securing the uploaded files from execution (or even better, uploading only the non-malicious files).

We also analyzed just the two vulnerabilities specified on the first page of the exploit-db website. We could have just as easily analyzed the rest of the plugins to determine what harm can they do to the server they are installed on.

Comments