The Exploit
An unauthenticated attacker can execute arbitrary PHP code by sending a specially crafted HTTP request with malicious values in the content-abs and content-dir header fields, which are then passed unsanitized to define() and require_once() statements.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=bmi_heart_beat&content-abs=php://filter/convert.base64-encode/resource=/etc/passwd&content-dir=/var/www&content-configdir=/var/www&content-content=/var/www&content-backups=/var/www
The server parses these fields directly from the POST body into the $fields array without validation, then uses them to redefine ABSPATH and include critical plugin files. An attacker observing the response will see error messages leaking filesystem paths, or in optimized attacks, will see the PHP filter chain executed — for example, base64-encoded copies of sensitive files reflected in error logs or debug output. When require_once is called with a filter-wrapped path, the injected filter runs before the file inclusion logic, allowing the attacker to read arbitrary files or, in chained variants, execute code.
What the Patch Did
Before
define('ABSPATH', $fields['content-abs']);
define('BMI_ROOT_DIR', $fields['content-dir']);
require_once BMI_INCLUDES . '/bypasser.php';
$request = new BMI_Backup_Heart(true,
$fields['content-configdir'],
$fields['content-content'],
$fields['content-backups'],
$fields['content-abs'],
$fields['content-dir'],
After
define('ABSPATH', filterChainFix($fields['content-abs']));
define('BMI_ROOT_DIR', filterChainFix($fields['content-dir']));
require_once filterChainFix(BMI_INCLUDES) . '/bypasser.php';
$request = new BMI_Backup_Heart(true,
$fields['content-configdir'],
$fields['content-content'],
$fields['content-backups'],
filterChainFix($fields['content-abs']),
filterChainFix($fields['content-dir']),
The patch introduced a custom validation function filterChainFix() that wraps every user-controlled input before it reaches a security-critical operation. This function implements multi-layered input filtering: it validates that the input is a string, enforces a maximum length of 256 characters (blocking PHP filter chains which are verbose), rejects any path containing the php: protocol prefix or pipe character (|, used to chain filters), and finally verifies that the resulting path actually exists on the filesystem using is_dir() or file_exists(). Together, these checks prevent PHP stream filter injection by making it impossible to pass filter URLs to define() or require_once().
Root Cause
CWE-74 (Improper Neutralization of Special Elements in Output Used by a Downstream Component—'Injection') combined with CWE-22 (Improper Limitation of a Pathname to a Restricted Directory).
The backup-heart.php file processes the AJAX action bmi_heart_beat, extracting user input from POST parameters (content-abs, content-dir, content-configdir, content-content, content-backups) into an associative array $fields without any prior sanitization. This input then flows directly into two dangerous sinks: the define() function (which sets PHP constants to arbitrary string values) and the require_once() statement (which loads PHP files). PHP's stream wrapper functionality means that a string like php://filter/convert.base64-encode/resource=/etc/passwd is a valid file path in these contexts, and the PHP engine will execute the filter chain before attempting file operations. The vulnerability exists because there is no whitelist, type check, or protocol validation between the request boundary and the sink — the attacker's controlled value travels the entire dataflow unchecked.
Why It Works
The load-bearing line is the blocklist check for php: and | characters inside filterChainFix(). Without it, an attacker bypasses all other checks: the length limit can be circumvented by chaining multiple smaller payloads, and filesystem existence checks fail when the attacker hasn't created the malicious file locally. The pipe character is essential because PHP filters are chained with the syntax php://filter/convert.X|convert.Y/resource=..., so rejecting | alone breaks the injection syntax. The length limit (256 characters) and type check (is_string()) are defense-in-depth: they catch obvious attacks early and prevent type juggling bypass tricks. The filesystem existence validation (is_dir() / file_exists()) ensures that only paths to real resources can be dereferenced, preventing attackers from inventing arbitrary paths wholesale. If the engineer had omitted only the php: and | blocklist, the exploit would still work; if they had omitted only the length limit, an attacker could still build a working filter chain; but together, these layers force an attacker to provide a real filesystem path with no protocol prefix and no filter syntax — turning the injection primitive into a harmless path disclosure.
Hardening Checklist
- Use
wp_safe_remote_post()with strictsslverifyand timeout parameters for any inter-plugin or cross-origin communication; avoid custom header parsing without using WordPress HTTP APIs that apply baseline validation. - Apply
sanitize_file_name()orsanitize_text_field()to any user input before passing it todefine(),require(),include(), orfile_get_contents(); never pass raw$_POSTor$_GETvalues directly to these functions. - Validate file paths against a whitelist of allowed directories using
realpath()and checking that the resolved path is within an expected prefix; for example,if (strpos(realpath($user_path), realpath(WP_CONTENT_DIR)) !== 0) { wp_die('Invalid path'); }. - Reject any input containing protocol schemes (
://) or dangerous characters (|,;,$, backticks) using a regex or blocklist before file operations; PHP stream wrappers are a common attack surface in file-handling plugins. - Test file inclusion paths with
wp_cache_get_multiple()or similar caching to avoid repeated filesystem access, and log all file operations to catch injection attempts in the audit trail.
References
- https://nvd.nist.gov/vuln/detail/CVE-2023-6553