The Exploit
An unauthenticated attacker with the ability to submit a Contact Form 7 form (when Flamingo is active) can craft a malicious file path containing traversal sequences, then trigger deletion of arbitrary files on the server by having an Administrator view and delete the associated Flamingo inbound message.
POST /wp-json/contact-form-7/v1/contact_forms/<form_id>/feedback HTTP/1.1
Host: target.local
Content-Type: multipart/form-data; boundary=----WebKitBoundary
------WebKitBoundary
Content-Disposition: form-data; name="your-file"; filename="../../../../etc/passwd"
Content-Type: application/octet-stream
[file content]
------WebKitBoundary
Content-Disposition: form-data; name="_wpnonce"
[valid nonce]
------WebKitBoundary--
The attacker submits a form with a filename containing path traversal sequences. When an Administrator opens the corresponding Flamingo inbound message in wp-admin and clicks delete, the plugin's dnd_remove_uploaded_files() function extracts file paths from the post content without validating they stay within the uploads directory, then calls wp_delete_file() on the attacker-controlled path. The server deletes the target file (e.g., wp-config.php or a plugin containing a backdoor), and the Administrator receives no warning that a system file was destroyed.
What the Patch Did
Before:
foreach( $matches[0] as $files ) {
$new_file = str_replace( site_url().'/', wp_normalize_path( ABSPATH ), $files );
if( file_exists( $new_file ) ) {
wp_delete_file( $new_file );
}
}
After:
foreach ( $matches[0] as $files ) {
// Convert url to dir
$file = str_replace( site_url() . '/', wp_normalize_path( ABSPATH ), $files );
// Check if it's a regular file.
if ( is_file( $file ) ) {
// Extract and sanitize the filename
$file_path = dirname( $file ) . '/' . sanitize_file_name( wp_basename( $file ) );
// Prevent traversal attack
$real_path = realpath( $file_path );
$wp_dir = wp_get_upload_dir();
$uploads_dir = wp_normalize_path( realpath( $wp_dir['basedir'] ) . '/' . wpcf7_dnd_dir );
// Check if the file exists and is within the uploads directory
if ( $real_path && file_exists( $real_path ) && strpos( $real_path, $uploads_dir ) === 0 ) {
wp_delete_file( $real_path );
}
}
}
The patch added directory confinement via realpath() and boundary validation. The fixed code resolves the canonical filesystem path of the target file using realpath(), obtains the canonical uploads directory path, then verifies with strpos( $real_path, $uploads_dir ) === 0 that the resolved file path is a child of the uploads directory. It also introduced sanitize_file_name() to strip dangerous characters and is_file() to ensure only regular files (not directories) can be deleted. Previously, any string after site_url() was treated as a valid file path.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The post_content field of a Flamingo inbound message is user-controlled: an attacker submits a contact form with a crafted filename. The plugin uses preg_match_all() to extract file paths from that post content, then passes the matched path directly into wp_delete_file() after only a trivial str_replace() substitution. The attacker-controlled path crosses the trust boundary from user input to filesystem operation without validation that it stays within the intended uploads directory. A path like /wp-json/contact-form-7/v1/contact_forms/123/feedback?your-file=../../../../wp-config.php arrives in the form submission, gets stored in the Flamingo message, and is later extracted and deleted without boundary checking.
Why It Works
The load-bearing line is strpos( $real_path, $uploads_dir ) === 0. This check enforces that the resolved file path begins with the canonical uploads directory path; without it, ../../../../wp-config.php would still be deleted. The other additions (.realpath(), is_file(), sanitize_file_name()) are defense-in-depth: realpath() collapses .. sequences so that the boundary check is meaningful (the raw string ../../../../wp-config.php would not match the strpos() test, but after resolution it points to a real location that can be compared); is_file() prevents directory deletion; sanitize_file_name() blocks non-filesystem characters. If you removed the strpos() check alone, the bug is exploitable. If you removed only realpath(), path traversal sequences would persist in the string comparison and the check would fail to catch them.
Hardening Checklist
- Use
realpath()before any file operation on user-influenced paths, not justwp_normalize_path(). Normalization alone does not resolve..to canonical form;realpath()does. - Implement a whitelist-based boundary check with string prefix matching (e.g.,
strpos( $real_path, $safe_dir ) === 0). Never rely on blacklisting dangerous characters; use positive allowance. - Call
is_file()oris_dir()before deletion to confirm the type of filesystem object matches your intent. This prevents accidental deletion of directories or other special files. - Apply
sanitize_file_name()to any user-provided filename before constructing a filesystem path. This strips null bytes, double-dot sequences, and path separators that might survive earlier normalization. - Never extract file paths from post content without re-validating them at deletion time. A path that was safe when stored may be unsafe if validation logic changes; re-check at the sink.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-2328