The Exploit
An unauthenticated attacker can move arbitrary files on the server if the form contains a file upload field and "Saving inquiry data in database" is enabled.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=mwf_form_submit&form_key=<valid_form_key>&mwf_upload_files[../../wp-config.php]=wp-config.php
The server accepts the request and moves wp-config.php into the uploads directory. If the attacker replaces wp-config.php with a malicious version before the move occurs, or if the file's new location is web-accessible, code execution follows. The response contains no obvious error — the form processes normally while the file operation executes in the background during regenerate_upload_file_keys() iteration.
What the Patch Did
Before
$form_id = MWF_Functions::get_form_id_from_form_key( $this->Data->get_form_key() );
$filepath = MW_WP_Form_Directory::generate_user_filepath( $form_id, $key, $upload_filename );
if ( ! file_exists( $filepath ) ) {
continue;
}
After
$form_id = MWF_Functions::get_form_id_from_form_key( $this->Data->get_form_key() );
try {
$filepath = MW_WP_Form_Directory::generate_user_filepath( $form_id, $key, $upload_filename );
} catch ( \Exception $e ) {
error_log( $e->getMessage() );
continue;
}
if ( ! $filepath || ! file_exists( $filepath ) ) {
continue;
}
The patch added exception handling and explicit null-checking of the $filepath output before use. However, the real load-bearing fix lives in class.directory.php: two new validation methods, _is_valid_path_segment() and _is_within_expected_dir_candidate(), now reject path traversal sequences (.., .), absolute paths, and any filepath that escapes the intended user directory tree. The $key parameter is now validated by _is_valid_path_segment() before path_join() receives it, preventing the root cause: the WordPress path_join() function returns absolute paths unchanged, and the old code trusted that the supplied $key was safe to join.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The attacker-controlled mwf_upload_files[] POST parameter array keys flow into the plugin's Data model via _set_request_valiables(), where they are stored as field keys without validation. During form processing, regenerate_upload_file_keys() iterates over these keys and passes them as the $name argument to generate_user_filepath(). The vulnerable code in class.directory.php checks only that wp_basename($filename) === $filename — ensuring the filename itself contains no slashes — but does not validate the $name parameter. When $name is set to ../../wp-config.php, the function calls path_join($user_file_dir, '../../wp-config.php'). Because path_join() returns absolute paths unchanged and ../../wp-config.php resolves to an absolute path, the function returns the absolute path to wp-config.php rather than a path confined to the upload directory. The file_exists() check then passes because wp-config.php genuinely exists at the root level, and move_temp_file_to_upload_dir() proceeds to rename() the file into the uploads folder.
Why It Works
The load-bearing fix is the new _is_valid_path_segment() method, which rejects inputs where path_is_absolute() returns true or where the value equals . or ... Remove this check, and an attacker can still craft relative traversal sequences such as ../../../wp-config.php, or supply an absolute path like /var/www/wp-config.php, both of which path_join() will honor. The _is_within_expected_dir_candidate() method serves as defence-in-depth: even if a creative input bypasses the first check, the second method canonicalizes both the resolved filepath and the expected user directory, then confirms via strpos() that the filepath is either equal to or a child of the expected directory. The try-catch block and null-check in class.main.php are hygiene: they prevent a crash if path generation fails, but without the two new validation methods in class.directory.php, attackers would still succeed because generate_user_filepath() would not throw an exception — it would return the attacker's target path unmolested.
Hardening Checklist
-
Whitelist path segment components: Use
_is_valid_path_segment()logic (or equivalent) to reject path traversal sequences (.,..), null bytes, absolute paths, and directory separators before passing user input topath_join()or any filesystem operation. WordPress providespath_is_absolute()andwp_basename()as primitives. -
Post-resolution confinement check: After calling
path_join()and normalizing withwp_normalize_path(), always verify the final resolved path lies within the intended parent directory usingstrpos()or equivalent prefix matching on the canonical (realpath'd) directory boundary. -
Reject empty / invalid path segments: Check that path inputs are non-empty strings and normalize them before validation. Use
wp_normalize_path()on user input before testing against path-safety rules. -
Wrap path generation in exception handling: Call filesystem operations inside try-catch blocks and log exceptions without exposing details in user-facing responses. This prevents both crashes and information leakage if path validation fails.
-
Test path traversal inputs in your test suite: Add unit tests covering
.,.., absolute paths, and null bytes as inputs to any function that constructs file paths from user data. Run these tests on every form field that touches the filesystem.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-5436