The Exploit
An authenticated attacker with contributor-level access to WordPress can include and execute arbitrary PHP files on the server by manipulating the ekit_testimonial_style parameter in a request that triggers the vulnerable render_raw function.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<valid_session>
action=ekit_testimonial_render&ekit_testimonial_style=../../../../wp-config
When this request is processed, the server will include and execute the file at wp-config.php (or any other file accessible to the web server process) because the $style parameter is directly concatenated into a require statement without path restriction. The attacker observes database credentials, authentication keys, and other sensitive constants printed in the response or reflected in error logs. If the attacker uploads a malicious PHP file to the uploads directory first, they can then use a traversal path like ../../../../wp-content/uploads/malicious to execute arbitrary code on the server.
What the Patch Did
Before
$style = isset($ekit_testimonial_style) ? sanitize_text_field($ekit_testimonial_style) : 'default';
if (is_array($testimonials) && !empty($testimonials)) {
require Handler::get_dir() . 'style/' . $style . '.php';
}
After
$style = isset($ekit_testimonial_style) ? sanitize_text_field($ekit_testimonial_style) : 'default';
$styles = [
'style1',
'style2',
'style3',
'style4',
'style5',
'style6',
];
if (in_array($style, $styles) && is_array($testimonials) && !empty($testimonials)) {
require Handler::get_dir() . 'style/' . $style . '.php';
}
The patch implements strict whitelist validation using in_array($style, $styles) before the require statement. The original code relied on sanitize_text_field(), which removes HTML tags and encodes special characters but does not prevent path traversal sequences like ../. The fix creates a hardcoded array of permitted style names and verifies that the user-supplied input matches one of those values — a capability-check pattern analogous to WordPress's in_array() checks for allowed post types or taxonomies. Only pre-defined style identifiers are now accepted; any attempt to include files outside the intended style directory is blocked.
Root Cause
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
The ekit_testimonial_style parameter, supplied by an authenticated POST request to the AJAX handler, is received and passed through sanitize_text_field() — a function designed to prevent stored XSS and HTML injection, not path traversal. The sanitized value flows directly into a string concatenation inside a require statement:
require Handler::get_dir() . 'style/' . $style . '.php';
Because sanitize_text_field() does not filter directory traversal characters (../, ..\\), an attacker can inject relative path sequences that escape the intended style/ subdirectory. The trust boundary is crossed at the AJAX action handler: the plugin trusts that any authenticated contributor will supply only legitimate style names, but makes no cryptographic or whitelist-based check to enforce that assumption. The require language construct then executes whatever PHP file the attacker points it toward.
Why It Works
The load-bearing line is the in_array($style, $styles) check. Removing it returns the code to its vulnerable state. This single conditional gates the require, ensuring that only exact string matches to the whitelist array will proceed; a path like ../../../../wp-config will fail the in_array test and the file will not be included.
The engineer added the $styles array definition to serve as the source of truth — a hard-coded enumeration of legitimate styles that cannot be tampered with via request parameters. The additional check is_array($testimonials) && !empty($testimonials) was already present, but placing in_array first in the condition chain ensures it is evaluated before any file I/O occurs, providing early rejection. This is defense-in-depth: the original code performed zero validation on the path component, so a single whitelist check here blocks the entire class of traversal attacks. A secondary layer would be realpath() normalization, but the engineer chose the simpler and safer whitelist approach — a pattern that scales well when the set of allowed values is small and known in advance.
Hardening Checklist
-
Use
in_array($var, $allowed_array, true)(strict mode) for any user input that selects between a finite set of values, such as style names, page templates, or sorting orders. Never concatenate unsanitized user input into filesystem paths. -
Replace
sanitize_text_field()with context-specific validation functions: usesanitize_key()for identifiers,sanitize_file_name()for file names, and whitelist checks for options that must match predefined sets.sanitize_text_field()is not a security boundary for path traversal. -
Audit all
require,include,require_once, andinclude_oncestatements in AJAX and REST handlers to confirm that file paths are either static strings, come from a whitelist, or are validated withrealpath()to ensure they do not escape an intended directory viais_dir()checks. -
Implement contributor-level capability checks at the AJAX entry point itself using
current_user_can('edit_posts')or equivalent, and document why that role has access to file inclusion logic. If a contributor should not be able to dynamically load PHP, move the AJAX handler to require'manage_options'. -
Log all file inclusion attempts (parameter value, resolved path, inclusion result) to catch exploitation attempts during incident response. WordPress does not do this by default; use a custom filter on
wp_ajax_*actions to record the$_POSTpayload before the handler runs.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-2047