The Exploit
An unauthenticated attacker with access to a form containing a file upload field can move arbitrary files on the server by manipulating the filename parameter during form submission.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
action=mwform_upload&post_id=1&field_key=upload_field&filename=../../../../wp-config.php
When this request is processed by the vulnerable move_temp_file_to_upload_dir() function, the attacker observes a successful file move operation (HTTP 200 response with success: true) and wp-config.php is relocated to the upload directory. The attacker can then request the moved file via a direct HTTP GET to retrieve database credentials, API keys, and salts.
What the Patch Did
Before
$filepath = path_join( $user_file_dir, $filename );
if ( str_contains( $filepath, '../' ) || str_contains( $filepath, '..' . DIRECTORY_SEPARATOR ) ) {
throw new \RuntimeException( '[MW WP Form] Invalid file reference requested.' );
}
After
$normalized_filename = wp_normalize_path( $filename );
if (
wp_basename( $normalized_filename ) !== $normalized_filename ||
strstr( $normalized_filename, "\0" )
) {
throw new \RuntimeException( '[MW WP Form] Invalid file reference requested.' );
}
$filepath = path_join( $user_file_dir, $filename );
$filepath = wp_normalize_path( $filepath );
$user_file_dir = trailingslashit( wp_normalize_path( $user_file_dir ) );
if ( 0 !== strpos( $filepath, $user_file_dir ) ) {
throw new \RuntimeException( '[MW WP Form] Invalid file reference requested.' );
}
if ( str_contains( $filepath, '../' ) || str_contains( $filepath, '..' . DIRECTORY_SEPARATOR ) ) {
throw new \RuntimeException( '[MW WP Form] Invalid file reference requested.' );
}
The patch adds three distinct security controls layered together: filename canonicalization via wp_normalize_path() and wp_basename() verification to ensure the filename contains no directory separators; null byte detection via strstr() to block truncation attacks; and path confinement via strpos() prefix check to verify the resolved filepath remains within the intended upload directory. The final string-matching check is retained as defense-in-depth.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
The filename parameter, supplied by an attacker in the form submission, flows directly into path_join() without validation that it contains no directory separators. The original code attempted to detect ../ sequences via string matching alone, which is insufficient because: (1) wp_normalize_path() was never called to resolve symbolic links or mixed slash representations into a canonical form, (2) null bytes were not rejected, allowing truncation of the validated path, and (3) the constructed $filepath was never verified to actually reside within the intended $user_file_dir boundary. An attacker passing filename=../../../../wp-config.php bypasses the substring check because the traversal happens after path_join() combines the base directory with the malicious filename—the final path contains ../ but the check can be evaded by encoding, mixed separators, or symlink resolution.
Why It Works
The load-bearing line is:
if ( 0 !== strpos( $filepath, $user_file_dir ) ) {
throw new \RuntimeException( '[MW WP Form] Invalid file reference requested.' );
}
This check ensures that after all path resolution and joining, the final filepath begins with the intended upload directory. Without this line, an attacker passes ../../../../wp-config.php, the paths are joined, and the substring check for ../ becomes insufficient because symlinks or alternative representations may have already resolved the traversal. The wp_basename() check prevents traversal in the filename itself; the null byte check prevents truncation-based bypasses; and wp_normalize_path() resolves all path representations to a canonical form before the prefix check is applied. Together, they form defense-in-depth: if an attacker bypasses the basename check (unlikely), the confinement check catches them; if encoding evades normalization (impossible with wp_normalize_path()), the subsequent string match remains. The engineer added all three because each layer defends against a distinct bypass technique.
Hardening Checklist
- Always normalize paths before validation. Call
wp_normalize_path()on both the user-supplied filename and the final constructed filepath; never rely on string matching against unnormalized input. - Validate that filenames are basenames only. Use
wp_basename()and verify it equals the input, rejecting any filename containing/,\, or.at the directory boundary. - Implement path confinement with a prefix check. After joining paths, verify the final path with
strpos($filepath, $safe_dir) === 0(orrealpath()in non-WordPress contexts) to ensure the file cannot escape the intended directory. - Detect and reject null bytes. Use
strstr($input, "\0")to block null byte injection, which can truncate or redirect path validation. - Retain legacy string matching as defense-in-depth. Keep simple substring checks for
../and..+DIRECTORY_SEPARATOReven after implementing the above; they cost nothing and catch alternative representations.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-4347