The Exploit
Unauthenticated attacker. An AJAX POST to the add_listing_action endpoint with a crafted $image parameter containing path traversal sequences will move an arbitrary file on the server to a web-accessible location, or overwrite it entirely.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
action=add_listing_action&image=../../../wp-config.php
The server responds with HTTP 200 and silently moves wp-config.php from the WordPress root into the temp directory used by the plugin. An attacker observing the filesystem or making a subsequent request can confirm the file has been relocated. If the temp directory is world-readable or web-accessible, the attacker can then download the file and extract database credentials, auth salts, and other secrets needed for account takeover or remote code execution.
What the Patch Did
Before:
$filepath = $temp_dir . $image;
if ( is_dir( $filepath ) || ! file_exists( $filepath ) ) {
After:
$image = sanitize_file_name( $image );
$filepath = $temp_dir . $image;
if ( is_dir( $filepath ) || ! file_exists( $filepath ) ) {
The patch adds a call to WordPress's sanitize_file_name() function before the $image variable is concatenated into the filepath. This function strips directory traversal sequences (../, ..\\) and other dangerous characters from the input, ensuring the resulting filename cannot reference parent directories or absolute paths. It transforms ../../../wp-config.php into wp-config.php — a safe, relative filename within the intended scope.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal").
The $image parameter arrives from the AJAX request unsanitized. It flows directly into the $filepath variable via string concatenation at the vulnerable line, then is used in filesystem operations (implied by the context: if ( is_dir( $filepath ) || ! file_exists( $filepath ) )). Because no validation occurs between the request boundary and the filesystem sink, an attacker can inject path traversal sequences that escape the intended $temp_dir scope and reference any file on the system readable or writable by the web server user. The lack of a capability check (current_user_can()) means this sink is reachable without authentication.
Why It Works
The single load-bearing line is $image = sanitize_file_name( $image );. Removing it restores the vulnerability immediately — an attacker can again pass ../../../wp-config.php and have it treated as a valid path segment.
The engineer added this line, not a whitelist or regex filter, because sanitize_file_name() is WordPress's idiomatic, battle-tested API for this exact problem: it handles Unicode normalization, strips null bytes, removes OS-specific dangerous characters (:, ", <, > on Windows; null bytes everywhere), and crucially, removes forward and backward slashes used in traversal attacks. It returns a string safe for use as a filename within a single directory — not across multiple levels.
Why not also add a capability check or nonce verification at the AJAX endpoint? The patch doesn't; it focuses on the input vector. A belt-and-suspenders approach would add both, but this plugin's threat model may be "a listing upload endpoint should accept files from anonymous users" (e.g., public classifieds submissions). The patch trusts that decision and makes the input handling bulletproof instead. This is a reasonable choice provided the temp directory itself has correct permissions and the file operations that follow do not introduce new risks.
Hardening Checklist
-
Use
sanitize_file_name()on all user-supplied filenames before filesystem operations. This is non-negotiable for any plugin accepting file uploads or names. Test with inputs like../../etc/passwdto confirm traversal is blocked. -
Validate file operation inputs with
wp_safe_remote_fopen()orwp_remote_get()if fetching remote files, and userealpath()post-operation to confirm the resolved path remains within the intended base directory — catching symlink attacks and race conditions the sanitizer alone cannot prevent. -
Add
current_user_can( 'upload_files' )or equivalent capability checks to AJAX actions that modify the filesystem, even if the plugin's business logic permits public uploads. This creates an audit trail and raises the bar for anonymous exploitation. -
Apply
wp_verify_nonce()to all AJAX actions, regardless of authentication level. It does not require login but does bind the request to a specific user session, preventing CSRF and making exploitation require active user interaction or session hijacking. -
Log all filesystem operations (move, copy, delete, rename) with
error_log()or a proper audit plugin. When exploitation occurs, forensics depend on visibility.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-10488