The Exploit
An unauthenticated attacker can delete arbitrary files on the server by crafting a POST request to the form handler with a malicious image_url parameter containing path traversal sequences.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=wte_set_user_profile_image&image_url=../../wp-config.php&user_id=1
The server processes the request without validating that image_url stays within the uploads directory. The rename() call executes against the traversal path, moving or deleting the target file. If wp-config.php exists at that traversed location, it is removed; the attacker observes a 200 response with success output, and the WordPress installation immediately becomes non-functional — database credentials are gone.
What the Patch Did
Before:
$img_upload_dir = wp_upload_dir();
$wte_image_dir = trailingslashit( $img_upload_dir[ 'basedir' ] ) . 'wp-travel-engine/images/users';
$img_filetype = wp_check_filetype( $image_url, null );
$img_file_ext = isset( $img_filetype[ 'ext' ] ) ? $img_filetype[ 'ext' ] : '.jpg';
$img_file_name = $wte_image_dir . '/wte_users_' . $user_id . '.' . $img_file_ext;
if ( wp_mkdir_p( $wte_image_dir ) ) :
if ( file_exists( $image_url ) || file_exists( $wte_image_dir . '/wte_users_' . $user_id . '.' . $img_file_ext ) ) :
if ( file_exists( $wte_image_dir . '/wte_users_' . $user_id . '.' . $img_file_ext ) ) :
unlink( $wte_image_dir . '/wte_users_' . $user_id . '.' . $img_file_ext );
endif;
if ( file_exists( $image_url ) ) :
rename( $image_url, $wte_image_dir . '/wte_users_' . $user_id . '.' . $img_file_ext );
endif;
endif;
endif;
After:
$uploads = wp_get_upload_dir();
$base_real = ( $uploads['basedir'] ?? false ) ? realpath( $uploads['basedir'] ) : false;
if ( ! $base_real ) {
return false;
}
$wte_image_dir = trailingslashit( $uploads[ 'basedir' ] ) . 'wp-travel-engine/images/users';
$img_filetype = wp_check_filetype( $image_url, null );
if ( ! ( $img_filetype[ 'ext' ] ?? true ) || ! ( $img_filetype[ 'type' ] ?? true ) ) {
return false;
}
$img_file_ext = $img_filetype[ 'ext' ] ?? '.jpg';
$img_file_name = $wte_image_dir . '/wte_users_' . $user_id . '.' . $img_file_ext;
if ( wp_mkdir_p( $wte_image_dir ) ) :
if ( file_exists( $img_file_name ) ) :
wp_delete_file( $img_file_name );
endif;
if ( file_exists( $image_url ) ) :
$src_real = realpath( $image_url );
if ( $src_real && is_file( $src_real ) && strpos( wp_normalize_path( $src_real ), wp_normalize_path( $base_real ) ) === 0 ) {
@rename( $src_real, $img_file_name );
}
endif;
endif;
The patch added three load-bearing security controls. First, it canonicalizes both the source file and the uploads base directory using realpath(), which resolves all symlinks and .. sequences to their true filesystem paths. Second, it validates that the resolved source file path begins with the resolved base directory path using a substring check (strpos()), ensuring the source is confined to the uploads tree. Third, it added a type guard (is_file()) to confirm the source is a regular file, not a directory or special file. These controls work together to implement path confinement — the attacker cannot escape the intended directory, even with traversal sequences.
Root Cause
The vulnerability is CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') in the set_user_profile_image() function (lines 479–555 of class-wp-travel-engine-form-handler.php). The image_url parameter, received from an unauthenticated AJAX request via $_POST, is passed directly to file_exists() and rename() without canonicalization or boundary validation. An attacker supplies a path like ../../wp-config.php, which the vulnerable code resolves at runtime through PHP's directory traversal logic, crossing the trust boundary into protected areas of the filesystem.
Why It Works
The single load-bearing line is:
if ( $src_real && is_file( $src_real ) && strpos( wp_normalize_path( $src_real ), wp_normalize_path( $base_real ) ) === 0 ) {
Without the strpos() boundary check, an attacker could still pass a canonicalized symlink or hardlink to an arbitrary file outside the uploads directory, and the rename() would execute. The realpath() calls alone are insufficient because they resolve symlinks after the attacker controls the path string — a symlink inside /uploads can point outside it. The substring validation is the critical control: it enforces that the real, resolved path of the source begins with the real, resolved path of the safe base directory. No directory traversal or symlink can bypass this. The is_file() type guard and the early realpath() validation of $base_real are defence-in-depth: they prevent edge cases like directory deletion and ensure the base directory itself is valid before the boundary check runs.
Hardening Checklist
- Always call
realpath()on user-supplied file paths before passing them tofile_exists(),unlink(),rename(), orcopy(), then compare the resolved path against a whitelist or a safe base directory usingstrpos()orstr_starts_with(). - Use
wp_normalize_path()on both the source and base paths before string comparison to handle Windows backslashes and symbolic links consistently across platforms. - Add
is_file()oris_dir()type guards before filesystem operations on user-controlled paths to prevent unintended deletion of unexpected file types. - Validate file MIME types and extensions early using
wp_check_filetype(), then reject files that fail validation rather than defaulting to a safe extension like.jpg. - Use
wp_delete_file()instead ofunlink()— it logs deletions and applies additional WordPress-level safety filters.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-7526