Unpatched SQL Injection Vulnerability in TI WooCommerce Wishlist Plugin

Published 25 September 2024
Table of Contents

This blog post is about an unauthenticated SQL injection vulnerability on the TI WooCommerce Wishlist plugin. If you're a TI WooCommerce Wishlist user, deactivate and delete the plugin since there is no patched version available.

✌️ Our users are protected from this vulnerability. Are yours?

Web developers

Automatically mitigate vulnerabilities in real-time without changing code.

See pricing
Plugin developers

Identify vulnerabilities in your plugins and get recommendations for fixes.

Request audit
Hosting companies

Protect your users, improve server health and earn additional revenue.

Patchstack for hosts

About TI WooCommerce Wishlist plugin

The plugin TI WooCommerce Wishlist, which has over 100,000 active installations, is one of the most popular free plugins for quickly setting up the wishlist functionality with WooCommerce on a WordPress site.

The security vulnerability

In the latest version (2.8.2 as of writing the article) and below, the plugin is vulnerable to a SQL injection vulnerability that allows any users to execute arbitrary SQL queries in the database of the WordPress site. No privileges are required to exploit the issue. The vulnerability is unpatched on the latest version and is tracked as the CVE-2024-43917.

Unauthenticated SQL injection: Case One

The underlying vulnerable code exists in the get() function:

function get( $data = array(), $count = false ) {
	global $wpdb;

	$default = array(
		'count'    => 10,
		'field'    => null,
		'offset'   => 0,
		'order'    => 'DESC',
		'order_by' => 'date',
		'external' => true,
		'sql'      => '',
	);

	foreach ( $default as $_k => $_v ) {
		if ( array_key_exists( $_k, $data ) ) {
			$default[ $_k ] = $data[ $_k ];
			unset( $data[ $_k ] );
		}
	}
	// TRIMMED
	$default['offset'] = absint( $default['offset'] );
	$default['count']  = absint( $default['count'] );
	if ( is_array( $default['field'] ) ) {
		$default['field'] = '`' . implode( '`,`', $default['field'] ) . '`';
	} elseif ( is_string( $default['field'] ) ) {
		$default['field'] = array( 'ID', $default['field'] );
		$default['field'] = '`' . implode( '`,`', $default['field'] ) . '`';
	} else {
		$default['field'] = '*';
	}
	if ( $count ) {
		$default['field'] = 'COUNT(`ID`) as `count`';
	}
	// TRIMMED
	$sql   = "SELECT {$default[ 'field' ]} FROM `{$this->table}`";
	$where = '1';
	if ( ! empty( $data ) && is_array( $data ) ) {
		if ( array_key_exists( 'meta', $data ) ) {
			unset( $data['meta'] );
		}
		foreach ( $data as $f => $v ) {
			$s = is_array( $v ) ? ' IN ' : '=';
			if ( is_array( $v ) ) {
				foreach ( $v as $_f => $_v ) {
					$v[ $_f ] = $wpdb->prepare( '%s', $_v );
				}
				$v = implode( ',', $v );
				$v = "($v)";
			} else {
				$v = $wpdb->prepare( '%s', $v );
			}
			$data[ $f ] = sprintf( '`%s`%s%s', $f, $s, $v );
		}
		$where = implode( ' AND ', $data );
		$sql   .= ' WHERE ' . $where;
	}
	// TRIMMED
	$sql .= sprintf( ' ORDER BY `%s` %s LIMIT %d,%d;', $default['order_by'], $default['order'], $default['offset'], $default['count'] ); //  [1]

	$products = $wpdb->get_results( $sql, ARRAY_A ); // WPCS: db call ok; no-cache ok; unprepared SQL ok.  [2]
	// TRIMMED
	if ( empty( $products ) || is_wp_error( $products ) ) {
		return array();
		$products[ $k ] = apply_filters( 'tinvwl_wishlist_product_get', $product );
	}

	$_products = wc_get_products( $args );
	// Filter wishlist products
	$products = apply_filters( 'tinvwl_wishlist_get_products', $products, $this );


	return $products;
}

The above function is using string concatenation to form up a SQL query with the user-input as seen in the $sql variable [1]. The $sql variable is then getting passed to $wpdb->get_results() parameter directly [2]. Tracing this function upwards to the source where we can inject the malicious SQL queries, we found that the above function is getting called by the wishlist_get_products() function:

	public function wishlist_get_products( WP_REST_Request $request ) {
		$wishlist = $this->get_wishlist_by_share_key( $request );

		if ( is_wp_error( $wishlist ) ) {
			return $wishlist;
		}

		$wlp  = new TInvWL_Product();
		$args = [
			'wishlist_id' => $wishlist['wishlist']['ID'],
			'external'    => false
		];

		if ( null !== ( $count = $request->get_param( 'count' ) ) ) {
			$args['count'] = $count;
		}
		if ( null !== ( $offset = $request->get_param( 'offset' ) ) ) {
			$args['offset'] = $offset;
		}
		if ( null !== ( $order = $request->get_param( 'order' ) ) ) { // [3]
			$args['order'] = $order;
		}


		$products = $wlp->get( $args ); // [4]

		$response = array_map( function ( $product ) use ( $request ) {
			return $this->prepare_product_data( $product, 'get_products', $request->get_params() );
		}, $products );

		return rest_ensure_response( apply_filters( 'tinvwl_api_wishlist_get_products_response', $response ) );
	}

In [3], we can see that the order parameter is getting taken from the user through $request->get_param( 'order' ). Then, the vulnerable function is getting called at [4] with the user-input. If we go back to the above get() function, this value is being directly concatenated and executed in the SQL statement confirming the SQLi. Tracing the wishlist_get_products() function itself, we found that it is called by a REST API endpoint where one can inject their malicious SQL injection queries.

register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<share_key>[A-Fa-f0-9]{6})/get_products', [
	'methods'             => WP_REST_Server::READABLE,
	'callback'            => [ $this, 'wishlist_get_products' ],
	'permission_callback' => '__return_true',
] );

In short, the whole flow of the request towards the vulnerability looks like:

REST API -> wishlist_get_products() -> get()

Unauthenticated SQL injection: Case Two

The underlying vulnerable code exists in the get_wishlists_data() function:

function get_wishlists_data( $share_key ) {

global $wpdb;

$table              = sprintf( '%s%s', $wpdb->prefix, 'tinvwl_items' );
$table_lists        = sprintf( '%s%s', $wpdb->prefix, 'tinvwl_lists' );
$table_stats        = sprintf( '%s%s', $wpdb->prefix, 'tinvwl_analytics' );
$table_translations = sprintf( '%s%s', $wpdb->prefix, 'icl_translations' );
$table_languages    = sprintf( '%s%s', $wpdb->prefix, 'icl_languages' );
$lang               = filter_input( INPUT_POST, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); // [1]
$lang_default       = filter_input( INPUT_POST, 'lang_default', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$stats              = filter_input( INPUT_POST, 'stats', FILTER_SANITIZE_FULL_SPECIAL_CHARS );

	$sql = "SELECT {$default[ 'field' ]} FROM `{$table}` INNER JOIN `{$table_lists}` ON `{$table}`.`wishlist_id` = `{$table_lists}`.`ID` AND `{$table_lists}`.`type` = 'default'";

	if ( $lang ) {
		if ( $lang_default ) {
			$languages = sprintf( "'%s'", implode( "', '", array( $lang, $lang_default ) ) );
		} else {
			$languages = "'" . $lang . "'";
		}

		$sql .= "LEFT JOIN {$table_translations} tr ON
{$table}.product_id = tr.element_id AND tr.element_type = 'post_product'
LEFT JOIN {$table_translations} tr2 ON
{$table}.variation_id != 0 AND {$table}.variation_id = tr2.element_id AND tr2.element_type = 'post_product_variation'
LEFT JOIN {$table_translations} t ON
tr.trid = t.trid AND t.element_type = 'post_product' AND t.language_code IN ({$languages})
LEFT JOIN {$table_translations} t2 ON
{$table}.variation_id != 0 AND tr2.trid = t2.trid AND t2.element_type = 'post_product_variation' AND t2.language_code IN ({$languages})
JOIN {$table_languages} l ON
(
t.language_code = l.code OR t2.language_code = l.code
) AND l.active = 1";
	}

	$results = $wpdb->get_results( $sql, ARRAY_A );

}

The lang and lang_default parameters [1] are being taken from the POST request and passed into the SQL query using concatenation making it vulnerable to SQLi. Tracing this back to the source, it is called in the ajax_action() function.

public function ajax_action(): void {

// TRIMMED
	if ( defined( 'DOING_AJAX' ) && DOING_AJAX && isset( $post['tinvwl-security'] ) && wp_verify_nonce( $post['tinvwl-security'], 'wp_rest' ) ) {
		$this->wishlist_ajax_actions( $wishlist, $post, $guest_wishlist );
	} else {
		$response = [
			'status' => false,
			'msg'    => [ __( 'Something went wrong', 'ti-woocommerce-wishlist' ) ],
			'icon'   => 'icon_big_times',
		];

		$response['msg'] = array_unique( $response['msg'] );
		$response['msg'] = implode( '<br>', $response['msg'] );

// TRIMMED
		wp_send_json( $response );
	}
}

Tracing that function back yet again, it is hooked in the wc_ajax hook.

private function define_hooks(): void {
	add_action( 'wc_ajax_tinvwl', [ $this, 'ajax_action' ] );
}

In short, the whole flow of the request towards the vulnerability looks like:

wc_ajax_tinvwl -> ajax_action() -> get_wishlists_data()

The patch

As of writing this article, there is no patched version for the plugin. If the vulnerability gets patched in the near future, we will update the article with the patch information and patched version.

Conclusion

For the SQL query process, always do a safe escape and format for the user’s input before performing a query. The best practice is to use a prepared statement and also cast each of the used variables to its intended usage. For instance, it is always better to cast a variable to an integer if the intended value of the variable should be an integer value.

Want to learn more about finding and fixing vulnerabilities?

Explore our Academy to master the art of finding and patching vulnerabilities within the WordPress ecosystem. Dive deep into detailed guides on various vulnerability types, from discovery tactics for researchers to robust fixes for developers. Join us and contribute to our growing knowledge base.

Timeline

18 July, 2024We found the vulnerability and notified the vendor.
22 August, 2024Published the vulnerabilities to the Patchstack vulnerability database (No reply from vendor).
September 12, 2024Plugin closed by the WP plugin review team
25 September, 2024Security advisory article publicly released.

🤝 You can help us make the Internet a safer place

Plugin developer?

Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.

Get started for free
Hosting company?

Protect your users too! Improve server health and earn added revenue with proactive security.

Patchstack for hosts
Security researcher?

Report vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.

Learn more

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu