The Exploit
An unauthenticated attacker with no valid user account can delete arbitrary files on the WordPress installation by crafting a POST request with a malicious form_id parameter containing path traversal sequences.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: vulnerable-site.local
Content-Type: application/x-www-form-urlencoded
action=snow_monkey_forms_delete_user_dir&form_id=../../../wp-config.php&nonce=<valid_nonce>
The server responds with 200 OK and the targeted file is deleted from the filesystem. If wp-config.php is removed, the WordPress installation becomes non-functional and the attacker can re-upload a backdoored version. Other high-value targets include wp-settings.php (complete site failure) or .htaccess (disable security rules).
What the Patch Did
Before
if ( ! preg_match( '|^[a-z0-9]+$|', $saved_token ) ) {
throw new \RuntimeException(
sprintf(
'[Snow Monkey Forms] Failed to generate user directory path. The directory name is "%1$s"',
$saved_token
)
);
}
$user_dir = path_join( static::get(), $saved_token );
$user_dir = path_join( $user_dir, (string) $form_id );
After
if ( ! Helper::is_valid_token_format( $saved_token ) ) {
throw new \RuntimeException(
sprintf(
'[Snow Monkey Forms] Failed to generate user directory path. The directory name is "%1$s"',
$saved_token
)
);
}
$form_id = Helper::sanitize_form_id( $form_id );
if ( false === $form_id ) {
throw new \RuntimeException( '[Snow Monkey Forms] Invalid form ID.' );
}
$user_dir = path_join( static::get(), $saved_token );
$user_dir = path_join( $user_dir, (string) $form_id );
Additionally, the patch introduced the _is_within_expected_dir() method:
private static function _is_within_expected_dir( string $path ): bool {
$expected_dir = wp_normalize_path( static::get() );
$real_path = wp_normalize_path( realpath( $path ) );
if ( false === $real_path ) {
return false;
}
return 0 === strpos( $real_path, $expected_dir );
}
The patch added explicit input validation on the $form_id parameter using Helper::sanitize_form_id(), rejecting any form ID that fails validation. More critically, it introduced directory boundary confinement via _is_within_expected_dir(), which uses realpath() to resolve symbolic links and relative paths, then validates with strict string prefix matching that the resolved path lies within the plugin's expected upload directory. This method is called before all file and directory deletion operations.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
The $form_id request parameter, passed to the generate_user_dirpath() function via AJAX without sanitization, flows directly into the path_join() call. The original code validated only the CSRF token ($saved_token) against the pattern ^[a-z0-9]+$, but imposed no restrictions on $form_id. An attacker supplies a form_id value like ../../../wp-config.php, which bypasses the alpha-numeric check and becomes part of the constructed file path. The path_join() function does not validate path traversal sequences; it simply concatenates strings. The plugin then performs unlink() or rmdir() operations on the attacker-controlled path, deleting files outside the intended upload directory. The trust boundary crossed is the unauthenticated request handler, which directly accepts user input without boundary validation.
Why It Works
The load-bearing line is the call to _is_within_expected_dir() before deletion operations. Remove it and the bug remains fully exploitable. The engineer added Helper::sanitize_form_id() as a first-pass filter to reject obviously malicious form IDs (blocking numeric IDs with special characters), but this alone is insufficient — a numeric form ID like 1 is valid but can be prefixed with traversal sequences like ../../../1. The real defence is _is_within_expected_dir(), which resolves the final constructed path using realpath() (forcing symbolic link resolution and canonicalization) and then performs a strict string prefix comparison against the normalized expected directory. This approach prevents bypass via encoding tricks, double-slashes, or relative path tricks, because realpath() collapses all of them to their true location. The Helper::sanitize_form_id() check serves as a shallow defence-in-depth layer that catches obvious abuse early; the _is_within_expected_dir() check is the actual security boundary.
Hardening Checklist
- Use
realpath()and strict prefix matching for all path operations: When accepting user input that influences file paths, canonicalize withrealpath()and validate that the result begins with your expected base directory usingstrpos() === 0or equivalent, never string replacement. - Separate validation of form_id from token validation: Never assume that validating one input parameter (the CSRF token) is sufficient for adjacent parameters in the same request. Apply independent
sanitize_*or validation logic to every user-supplied value that influences file operations. - Apply directory boundary checks at the point of file operation, not at path construction: Even if path construction looks safe, add the
_is_within_expected_dir()check immediately beforeunlink(),rmdir(),fopen(), orfile_get_contents(). This catches logical errors in path building. - Use WordPress's
wp_normalize_path()before comparisons: Handles both Unix and Windows path separators, preventing bypass via mixed slashes. - Deny by default for file operations: Reject any path that is not explicitly in the whitelist of expected directories, rather than trying to blacklist dangerous patterns.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-1056