The Exploit
An authenticated WordPress user with Subscriber role or higher can delete arbitrary directories on the server by calling the cache-clearing endpoint with a crafted permalink parameter containing directory traversal sequences.
GET /wp-admin/admin-ajax.php?action=two_clear_page_cache&permalink=../../&_wpnonce=<valid_nonce> HTTP/1.1
Host: target.example.com
Cookie: wordpress_logged_in=<subscriber_session>
The attacker observes a successful cache deletion response (HTTP 200) or silence. Within seconds, the server's non-cache directories begin vanishing — WordPress configuration files, plugin directories, or application data outside wp-content/cache cease to exist. A recursive deletion operation consumes the filesystem traversal, and by the time logs surface anomalies, critical application state is gone.
What the Patch Did
Before:
public static function delete_page_cache($dir, $is_home_url)
{
if (is_dir($dir)) {
foreach (scandir($dir) as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$path = $dir . $file;
if (is_file($path)) {
unlink($path); // phpcs:ignore
} else {
After:
public static function delete_page_cache($dir, $is_home_url)
{
// Validate directory path before deletion
if (empty($dir)) {
return;
}
// Ensure the directory is within the allowed cache directory
$real_dir = realpath($dir);
$real_allowed_dir = realpath(TENWEB_SO_PAGE_CACHE_DIR);
if ($real_dir === false || $real_allowed_dir === false) {
return;
}
// Prevent directory traversal - ensure path is within cache directory
if (strpos($real_dir, $real_allowed_dir) !== 0) {
return;
}
if (is_dir($dir)) {
foreach (scandir($dir) as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$path = $dir . $file;
// Additional safety check for each path
$real_path = realpath($path);
if ($real_path === false || strpos($real_path, $real_allowed_dir) !== 0) {
continue;
}
if (is_file($path)) {
unlink($path); // phpcs:ignore
The patch introduces realpath() normalization and prefix validation using strpos(). The load-bearing security control is the comparison strpos($real_dir, $real_allowed_dir) !== 0 — this enforces that the resolved canonical path begins with the allowed cache directory constant TENWEB_SO_PAGE_CACHE_DIR. Without this check, ../../sensitive_directory resolves to an absolute path outside the cache root, and deletion proceeds unrestricted.
The patch also hardens the AJAX handler in OptimizerWebPageCacheWP.php by adding explicit capability checks (current_user_can('manage_options')), correct nonce verification with proper parameter passing to wp_verify_nonce(), and a new validate_cache_path() function that re-enforces the prefix check before any deletion is triggered.
Root Cause
CWE-22 (Path Traversal) and CWE-20 (Improper Input Validation) converge here. The attacker controls the permalink GET parameter in the AJAX request. This value flows into OptimizerWebPageCache::delete_cache_by_url($permalink), which calls get_cache_dir_for_page_from_url($url) without validating that the returned $dir stays within the cache directory. The function wp_parse_url() extracts the host and path from the URL, but hostile paths like ../../wp-config.php are never checked for directory traversal patterns. The $dir value lands directly in delete_page_cache($dir, ...), where scandir() and unlink() operate on it recursively without boundary enforcement.
The missing trust boundary is between user input ($_GET['permalink']) and the filesystem operation (unlink($path)). Early versions of the code assumed that URL parsing would implicitly constrain the output, but URL parsing only extracts components — it does not reject ../ sequences.
Why It Works
The line if (strpos($real_dir, $real_allowed_dir) !== 0) { return; } is load-bearing. If removed, an attacker's ../../ path still resolves via realpath() to an absolute path (e.g., /var/www/html), and without the prefix check, that path is accepted and deleted. The surrounding lines exist for defense-in-depth: realpath() resolves symlinks and normalizes . and .. sequences into their true targets, preventing obfuscation tricks. The === false guards on realpath() catch non-existent or inaccessible paths early, reducing the surface for edge cases. The per-file validation strpos($real_path, $real_allowed_dir) !== 0 inside the loop catches any files an attacker might have created outside the cache directory via symlinks or race conditions. Together, these layers ensure that no deletion escapes the intended root.
Hardening Checklist
-
Use
realpath()+ prefix matching for all path-based operations. Normalize any user-influenced filesystem path withrealpath(), then enforce that it begins with an allowed root directory usingstrpos()orstr_starts_with()(PHP 8+). Apply this rule at every recursion level or function boundary that touches the filesystem. -
Verify capabilities before AJAX actions that modify state. Call
current_user_can('manage_options')or a custom capability early in any AJAX handler that deletes, creates, or modifies files. Do not rely on role names; usecurrent_user_can()with explicit capability checks. -
Validate nonce parameters correctly. Extract the nonce from
$_GET,$_POST, or$_REQUESTwithsanitize_text_field(), then pass it towp_verify_nonce($nonce, 'action_name')and check the return value strictly (if (!wp_verify_nonce(...)) { wp_die(...); }). The old pattern!== nullis insufficient and masks failures. -
Reject dangerous URL patterns before passing to filesystem logic. Before a user-supplied URL reaches
get_cache_dir_for_page_from_url(), validate that it does not contain encoded traversal sequences (%2e%2e,%252f) or literal ones (..,..\\). Implement a dedicated allowlist or denylist function likeis_url_safe_for_cache_clear()that rejects these patterns outright. -
Validate component extraction results. After
wp_parse_url()or similar parsing functions, check that expected keys exist (e.g.,isset($parsed_url['host'], $parsed_url['path'])) before using them. An empty or missing component can lead to unexpected paths or default fallbacks.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13377