The Exploit
An unauthenticated attacker can delete arbitrary files on the server by uploading a crafted email form submission that includes a malicious attachment path.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=mwform_mail&mwform_nonce=&attachments=../../../wp-config.php
When this request lands, the server processes the form submission and executes the _delete_files() method. The attacker observes a 200 OK response with no validation error. The wp-config.php file is deleted from the filesystem, disabling the WordPress installation and creating an immediate path to site takeover or remote code execution via configuration file replacement.
What the Patch Did
Before
if ( file_exists( $file ) ) {
unlink( $file );
}
After
$file = realpath( $file );
if ( false !== $file && is_file( $file ) && 0 === strpos( $file, MW_WP_Form_Directory::get() ) ) {
unlink( $file );
}
The patch introduces three layered security controls. The realpath() function resolves the canonical absolute path and strips path traversal sequences (like ../), eliminating the attacker's ability to escape the intended directory. The is_file() check ensures only files, not directories, can be deleted. Most critically, the strpos() confinement check validates that the resolved path stays within the plugin's attachment directory by comparing the start of the path against MW_WP_Form_Directory::get(). Together, these form a whitelist-by-prefix model: only files whose absolute paths begin with the plugin directory are eligible for deletion.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
The vulnerable code flow begins when a user submits a form with an attachments parameter containing a path like ../../../wp-config.php. This attacker-controlled value enters $this->attachments without sanitization or validation. The _delete_files() method then passes this value directly to the unlink() sink. The file_exists() pre-check provides no security value — it only confirms the file is accessible from the current working directory, and relative paths resolve relative to that directory. The web server process (typically www-data or similar) has permission to traverse to and delete any file in the document root and above, crossing the critical trust boundary from "files the plugin intended to delete" to "arbitrary filesystem paths."
Why It Works
The load-bearing line is the strpos() check: 0 === strpos( $file, MW_WP_Form_Directory::get() ). Remove it, and an attacker can still bypass the vulnerability because realpath() alone is insufficient—an absolute path like /var/www/html/wp-config.php is perfectly valid after realpath() resolution, and is_file() will return true. The engineer added is_file() to prevent directory deletion and subsequent chaos; more importantly, they added it to reinforce the assumption that $file is an ordinary file, not a symlink or device. The false !== $file check is defensive programming: realpath() returns false if the path doesn't exist or can't be resolved, and this guards against logic errors downstream. But strpos() is the actual gate—it's the only line that asks: "Is this file supposed to exist here?" By validating the prefix, the patch shifts from a blacklist model (we can't delete ../) to a whitelist model (we can only delete files in this specific directory). This is the architectural difference that closes the bug.
Hardening Checklist
-
Use
realpath()+ prefix validation on all user-supplied file paths before filesystem operations. Do not rely onfile_exists()oris_file()alone; always resolve the canonical path and validate it stays within an expected directory using string prefix checks orstr_starts_with()(PHP 8+). -
Employ
wp_safe_remote_post()orwp_remote_post()with request validation for any externally-triggered file operations. If forms accept file paths, explicitly whitelist acceptable directories in plugin options and validate against that whitelist on every delete/rename/copy. -
Implement capability checks even for unauthenticated form handlers. Use
wp_verify_nonce()on allaction=AJAX endpoints and require at leastmanage_optionsor a custom role for file modification operations, even if the form is public-facing. -
Log and alert on filesystem operations. Add error_log() or a custom logging function to record all delete attempts with the original and resolved paths. This provides both accountability and an early warning system for exploitation attempts.
-
Use temporary directories for uploaded attachments. Store form attachments in
wp_upload_dir()['basedir']with randomly-generated filenames (e.g.,wp_generate_password()) and immediately delete them after the email is sent, rather than relying on manual cleanup.
References
- https://nvd.nist.gov/vuln/detail/CVE-2023-6559
- https://www.wordfence.com/threat-intel/vulnerabilities/id/CVE-2023-6559