The Exploit
An unauthenticated attacker on a WordPress Multisite installation can read and delete arbitrary files by calling the plugin's backup endpoints without proper authorization checks.
GET /?wp_db_backup=1&fragment=1 HTTP/1.1
Host: target.example.com
Connection: close
When this request lands, the plugin processes the fragment parameter, calls can_user_backup('frame'), but then ignores its return value and proceeds to execute backup initialization code. No authentication token or capability check blocks the attacker. The response will begin executing database backup operations and may expose file paths or error messages revealing the backup directory structure. A follow-up request can then traverse to sensitive locations using the unvalidated wp_db_temp_dir parameter:
GET /?wp_db_backup=1&wp_db_temp_dir=../../../etc/&fragment=1 HTTP/1.1
Host: target.example.com
Connection: close
The attacker observes that the plugin accepts this directory path without validation, allowing traversal out of the intended backup directory. Subsequent requests can enumerate or delete files in arbitrary locations where the web server process has write permissions, potentially including /wp-config.php or other sensitive files.
What the Patch Did
Before:
} elseif ( isset( $_GET['fragment'] ) ) {
$this->can_user_backup( 'frame' );
add_action( 'init', array( &$this, 'init' ) );
} elseif ( isset( $_GET['backup'] ) ) {
$this->can_user_backup();
add_action( 'init', array( &$this, 'init' ) );
if ( isset( $_GET['wp_db_temp_dir'] ) ) {
$requested_dir = sanitize_text_field( $_GET['wp_db_temp_dir'] );
if ( is_writeable( $requested_dir ) ) {
$tmp_dir = $requested_dir;
}
}
After:
} elseif ( isset( $_GET['fragment'] ) ) {
if ( ! $this->can_user_backup( 'frame' ) ) {
return;
}
add_action( 'init', array( &$this, 'init' ) );
} elseif ( isset( $_GET['backup'] ) ) {
if ( ! $this->can_user_backup() ) {
return;
}
add_action( 'init', array( &$this, 'init' ) );
[Parameter removed entirely; only get_temp_dir() and filtered wp_db_b_backup_dir used]
The patch added two critical controls. First, it wraps each call to can_user_backup() in a conditional that checks its boolean return value; if authorization fails (returns false), execution halts via return. This is the WordPress pattern for gating admin operations — the method likely checks current_user_can() or relies on the deprecated is_site_admin() in Multisite. Second, the patch removes the user-supplied wp_db_temp_dir GET parameter entirely, eliminating the directory traversal sink. The replacement uses only get_temp_dir() (safe system default) and a filtered hook wp_db_b_backup_dir that developers can override programmatically, not via untrusted request parameters.
Root Cause
CWE-862: Missing Authorization Check and CWE-22: Improper Limitation of a Pathname to a Restricted Directory.
The vulnerability flows from two unchecked trust boundaries. First, the fragment and backup GET parameters are read from $_GET with no authentication guard; the code calls can_user_backup() but discards its return value, so authorization failure does not halt execution. The function likely checks is_site_admin() on Multisite or a capability check, but this check is advisory—the plugin trusts the function call to be sufficient and does not act on failure. Second, the wp_db_temp_dir GET parameter is passed directly into sanitize_text_field(), which strips HTML tags but does not prevent path traversal sequences like ../. The value is then used as $tmp_dir without canonicalization or confinement, allowing an attacker to write backup files (or exploit subsequent file operations) in directories far outside the plugin's intended backup location.
Why It Works
The load-bearing line is the conditional check: if ( ! $this->can_user_backup() ) { return; }. Without this, the authorization function is a no-op—it executes, but its result is ignored. Removing the return statement would leave the vulnerability open; the attacker would still be blocked from proceeding if the method raises an exception or uses wp_die(), but the patch assumes the return value is the primary signal. The engineer also removed the wp_db_temp_dir parameter entirely rather than "fixing" it with realpath() or wp_safe_remote_get() because even sophisticated path validation can be fragile; the safest approach is defense-in-depth: strip the user-controllable parameter, use only system defaults and programmatic hooks, and let administrators customize via code filters (which run at initialization time, not on each request). This layered approach prevents both the forgotten return-value check and future regressions from path validation bypasses.
Hardening Checklist
-
Always check the return value of authorization functions. Use
if ( ! $function() ) { return; }orif ( ! $function() ) { wp_die(); }immediately after callingcurrent_user_can(), capability checks, or custom authorization methods. Do not call them for side effects. -
Never accept filesystem paths from user input. Remove or deprecate GET/POST parameters like
wp_db_temp_dirthat allow callers to specify directories. Usewp_safe_remote_post()options orapply_filters()hooks instead, which run during plugin initialization, not per-request. -
Use
wp_verify_nonce()for state-changing operations. Even if the user passes authorization, add a nonce check to prevent CSRF attacks on authenticated backup operations:check_admin_referer( 'wp_db_backup_nonce' ). -
Validate directory paths with
realpath()and confinement checks. If you must accept a path, userealpath()to resolve symlinks, then assert that the resolved path is within an allowed parent directory usingstrpos()orstr_starts_with(). -
Test authorization failures in isolation. Write unit tests where
can_user_backup()returns false and verify that no backup operation occurs. Use mocking to simulate both authenticated and unauthenticated states.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-4030