This blog post is about WooCommerce Amazon Affiliates (WZone) plugin vulnerabilities. If you’re a WooCommerce Amazon Affiliates (WZone) user, please deactivate and delete the plugin since there is still no known patched version.
All paid Patchstack users are protected from this vulnerability. Sign up for the free Community account first, to scan for vulnerabilities and apply protection for only $5 / site per month with Patchstack. For plugin developers, we have security audit services and Enterprise API for hosting companies.
About the WZone Plugin
The plugin WZone (premium version), which has over 35,000 sales, is one of the more popular premium plugins specifically related to affiliate integration between AWS and WooCommerce sites. This plugin is developed by AA-Team.
This is a premium WordPress plugin designed to help site owners and bloggers monetize their websites and make money online using the Amazon affiliate program.
The security vulnerability
This plugin suffers from multiple vulnerabilities. All of the vulnerabilities were tested on version 14.0.10 of the plugin and there is still no known patched version. These vulnerabilities also could possibly be impacting versions >= 14.0.20 of the plugin.
The first vulnerability is an Authenticated Arbitrary Option Update. This vulnerability allows any authenticated user to update arbitrary WP Options and in the worst case lead to a privilege escalation. The described vulnerability is still not patched and assigned CVE-2024-33549.
The second and third vulnerabilities are Unauthenticated and Authenticated SQL Injection. This vulnerability allows any unauthenticated and also authenticated user to inject a malicious SQL query into a WordPress database query execution. The described vulnerabilities are still not patched and assigned CVE-2024-33544 and CVE-2024-33546 respectively.
Authenticated Arbitrary Option Update
The underlying vulnerable code exists in the install_default_options function:
public function install_default_options ()
{
unset($_REQUEST['action']);
$is_makeinstall = isset($_REQUEST['is_makeinstall']) ? (int) $_REQUEST['is_makeinstall'] : 0;
$serializedData = urldecode($_REQUEST['options']);
$savingOptionsArr = array();
parse_str($serializedData, $savingOptionsArr);
if ( $savingOptionsArr['box_id'] == 'WooZone_setup_box' ) {
$serializedData = preg_replace('/box_id=WooZone_setup_box&box_nonce=[\w]*&install_box=/', '', $serializedData);
$savingOptionsArr['install_box'] = $serializedData;
$savingOptionsArr['install_box'] = str_replace( "\\'", "\\\\'", $savingOptionsArr['install_box']);
}
$save_id = $savingOptionsArr['box_id'];
unset($savingOptionsArr['box_id']);
if( ! wp_verify_nonce( $savingOptionsArr['box_nonce'], $save_id . '-nonce')) die ('Busted!');
unset($savingOptionsArr['box_nonce']);
require_once( $this->cfg['paths']['plugin_dir_path'] . 'modules/setup_backup/default-sql.php');
$savingOptionsArr['install_box'] = str_replace( '\"', '"', $savingOptionsArr['install_box']);
$savingOptionsArr['install_box'] = str_replace('#!#', '&', $savingOptionsArr['install_box']);
$savingOptionsArr['install_box'] = str_replace("'", "\'", $savingOptionsArr['install_box']);
$pullOutArray = json_decode( $savingOptionsArr['install_box'], true );
if(count($pullOutArray) == 0){
die(json_encode( array(
'status' => 'error',
'html' => "Invalid install default json string, can't parse it!"
)));
}else{
foreach ($pullOutArray as $key => $value){
if ( $is_makeinstall && get_option( $key, false ) ) {
continue 1;
}
update_option( $key, $saveIntoDb );
}
update_option( $this->alias . "_is_installed", 'true');
die(json_encode( array(
'status' => 'ok',
'html' => 'Install default successful'
)));
}
}
This function is hooked under the wp_ajax_WooZoneInstallDefaultOptions action and doesn’t have a proper permission check. It does have a nonce check, however, the nonce value can be fetched from any authenticated (Subscriber+) users. The function will store $_REQUEST[‘options’] value inside of $serializedData variable and then it will parse the value to $savingOptionsArr. The function then constructs a $pullOutArray variable from $savingOptionsArr and will do a loop on each of the values to perform an update_option function call.
Unauthenticated SQL Injection
The underlying vulnerable code exists on the product_by_asin function:
public function product_by_asin( $asins=array() ) {
$asins = array_unique( array_filter($asins) );
if (empty($asins)) return array();
$key = '_amzaff_prodid';
$_key = '_amzASIN';
$return = array_fill_keys( $asins, false );
global $wpdb;
$asins_ = implode(',', array_map(array($this, 'prepareForInList'), $asins));
$sql_asin2id = "select pm.meta_value as asin, p.* from " . $wpdb->prefix.'posts' . " as p left join " . $wpdb->prefix.'postmeta' . " as pm on p.ID = pm.post_id where 1=1 and !isnull(p.ID) and pm.meta_key = '$key' and pm.meta_value != '' and pm.meta_value in ($asins_);";
$res_asin2id = $wpdb->get_results( $sql_asin2id, OBJECT_K );
//var_dump('<pre>', $res_asin2id , '</pre>'); echo __FILE__ . ":" . __LINE__;die . PHP_EOL;
if ( !empty($res_asin2id) ) {
foreach ($res_asin2id as $k => $v) {
$asin = $v->asin;
$return["$asin"] = $v;
}
}
// because we have old amazon products which have only '_amzASIN' meta (they don't have this new '_amzaff_prodid' meta)
$sql_asin2id = "select pm.meta_value as asin, p.* from " . $wpdb->prefix.'posts' . " as p left join " . $wpdb->prefix.'postmeta' . " as pm on p.ID = pm.post_id where 1=1 and !isnull(p.ID) and pm.meta_key = '$_key' and pm.meta_value != '' and pm.meta_value in ($asins_);";
$res_asin2id = $wpdb->get_results( $sql_asin2id, OBJECT_K );
--------------- CUT HERE ---------------
The above function can be called from the WooZone_product_by_asin function:
if ( !function_exists('WooZone_product_by_asin') ) {
function WooZone_product_by_asin( $asins=array() ) {
global $WooZone;
return $WooZone->product_by_asin( $asins );
}
}
The above function can be called from the add_product function that has the purpose of handling the product addition process:
public function add_product( $product, $pms=array() ) {
$pms = array_replace_recursive( array(
// !!! true - only when you know what you're doing on this code
'debug' => false,
'where_from' => 'chrome-extension', // chrome-extension | module-noawskeys
// (integer) number of images per variation child for additional variation images woozone plugin
'avi_nbvars' => 1,
// (integer from 0) 0 = use category from amazon (use browse nodes to build a category structure like on amazon)
'idcateg' => 0,
// (integer from 1 or string 'all')
'nbimages' => 'all',
// (integer from 0 or string 'all')
'nbvariations' => 'all',
// (integer 0 | 1)
'spin' => 0,
// (integer 0 | 1)
'attributes' => 1,
), $pms);
extract( $pms );
$ret = array(
'status' => 'invalid',
'msg' => '',
'msg_arr' => array(),
'msg_full' => '',
'msg_summary' => '',
'product_id' => 0,
'duration' => 0,
);
if ( $avi_nbvars < 1 || $avi_nbvars > $this->the_plugin->ss['max_images_per_variation'] ) {
$avi_nbvars = 1;
$pms['avi_nbvars'] = $avi_nbvars;
}
$this->avi_nbvars = $avi_nbvars;
if ( 'php://input' === $product ) {
$product = $this->the_plugin->wp_filesystem->get_contents( 'php://input' );
if ( ! $product ) {
$product = file_get_contents( 'php://input' );
}
$product = json_decode( $product, true );
}
if ( $debug ) {
//require_once( '_test/product.inc.php' );
$product = $this->the_plugin->cfg['paths']['scripts_dir_path'] . '/directimport/_test/B0769XD5YC.json';
$product = file_get_contents( $product );
$product = json_decode( $product, true );
}
//die( var_dump( "<pre>", $product , "<pre>" ) . PHP_EOL . __FILE__ . ":" . __LINE__ );
//:: verify product has an asin?
$opValidProduct = $this->is_valid_product_asin( $product );
if ( ! $opValidProduct ) {
$ret = array_replace_recursive($ret, array(
'msg' => 'Product ASIN is missing!',
));
$ret['msg_summary'] = $ret['msg'];
return $ret;
}
$asin = $product['ASIN'];
//:: verify if product already is imported?
$opAsinExist = WooZone_product_by_asin( array($asin) );
--------------- CUT HERE ---------------
The above function itself can be called from the _product_import function:
private function _product_import( $product, $pms=array() ) {
$pms = array_replace_recursive( array(
'where_from' => 'module-noawskeys',
'avi_nbvars' => 1,
'idcateg' => 0,
'nbimages' => 'all',
'nbvariations' => 5,
'spin' => 0,
'attributes' => 1,
), $pms);
$ret = array(
'status' => 'invalid',
'msg' => '',
'msg_arr' => array(),
'msg_full' => '',
'msg_summary' => '',
'product_id' => 0,
'duration' => 0,
);
$opStatus = WooZoneDirectImport()->add_product( $product, $pms );
$ret['duration_import'] = $ret['duration'];
unset( $ret['duration'] );
return array_replace_recursive( $ret, $opStatus );
}
The above function acts as a function handler to import product data. When traced back, the function actually can be called from the ajax_request function that is hooked to the wp_ajax_nopriv_WooZoneNoAWSImport action.
public function ajax_request() {
$requestData = array(
'action' => isset($_REQUEST['sub_action']) ? (string) $_REQUEST['sub_action'] : '',
'debug_step' => isset($_REQUEST['debug_step']) ? (string) $_REQUEST['debug_step'] : '',
);
extract($requestData);
//var_dump('<pre>', $requestData , '</pre>'); echo __FILE__ . ":" . __LINE__;die . PHP_EOL;
$ret = array(
'status' => 'invalid',
'msg' => 'Invalid action!',
);
if ( empty($action) || !in_array($action, array(
'add_product',
)) ) {
die(json_encode($ret));
}
//:: actions
switch ( $action ) {
case 'add_product':
$duration = array();
// extract product data
$timer_start = $this->timer_start();
if( isset($_REQUEST['from']) && $_REQUEST['from'] == 'extension_amz_page' ){
$request = $this->the_plugin->wp_filesystem->get_contents( 'php://input' );
if ( ! $product ) {
$request = file_get_contents( 'php://input' );
}
$request = json_decode( $request, true );
$_REQUEST = array_merge($_REQUEST, $request);
// chrome.runtime.sendMessage dont' add slashes
$_REQUEST['variations'] = addslashes($_REQUEST['variations']);
}
$pmsGetProduct = array(
'debug_step' => $debug_step,
'page_content' => isset($_REQUEST['page_content']) ? (string) $_REQUEST['page_content'] : null,
'asin' => isset($_REQUEST['asin']) ? (string) $_REQUEST['asin'] : null,
'country' => isset($_REQUEST['country']) ? (string) $_REQUEST['country'] : null,
'variations' => isset($_REQUEST['variations']) ? (string) $_REQUEST['variations'] : null,
);
//die( var_dump( "<pre>", $pmsGetProduct , "<pre>" ) . PHP_EOL . __FILE__ . ":" . __LINE__ );
if ( 'get_params' === $debug_step ) {
//var_dump('<pre>', $pmsGetProduct , '</pre>'); echo __FILE__ . ":" . __LINE__;die . PHP_EOL;
}
$opGetProduct = $this->_product_extract_data( $pmsGetProduct );
//die( var_dump( "<pre>", $opGetProduct , "<pre>" ) . PHP_EOL . __FILE__ . ":" . __LINE__ );
if ( 'extract_data' === $debug_step ) {
//var_dump('<pre>', $opGetProduct , '</pre>'); echo __FILE__ . ":" . __LINE__;die . PHP_EOL;
}
$ret = array_replace_recursive( $ret, $opGetProduct, array( 'msg_full' => '', 'msg_summary' => '', 'msg_arr' => array() ) );
//die( json_encode( $ret ) ); //DEBUG
unset( $ret['data'] );
$timer_end = $this->timer_end();
$duration["_product_extract_data"] = $timer_end;
if ( 'invalid' === $opGetProduct['status'] ) {
break;
}
// import product
$timer_start = $this->timer_start();
$pmsImportProduct = array(
//'avi_nbvars' => isset($_REQUEST['avi_nbvars']) ? (int) $_REQUEST['avi_nbvars'] : 1,
'idcateg' => isset($_REQUEST['idcateg']) ? (int) $_REQUEST['idcateg'] : 0,
'nbimages' => isset($_REQUEST['nbimages']) ? (string) $_REQUEST['nbimages'] : 'all',
'nbvariations' => isset($_REQUEST['nbvariations']) ? (string) $_REQUEST['nbvariations'] : 5,
'spin' => isset($_REQUEST['spin']) ? (int) $_REQUEST['spin'] : 0,
'attributes' => isset($_REQUEST['attributes']) ? (int) $_REQUEST['attributes'] : 1,
);
//var_dump('<pre>', $pmsImportProduct , '</pre>'); echo __FILE__ . ":" . __LINE__;die . PHP_EOL;
//var_dump('<pre>', $opGetProduct['data'] , '</pre>'); echo __FILE__ . ":" . __LINE__;die . PHP_EOL;
$opImportProduct = $this->_product_import( $opGetProduct['data'], $pmsImportProduct );
--------------- CUT HERE ---------------
Notice on the above function, that there is no proper permission and nonce check, meaning that any unauthenticated users are able to trigger the function and perform product import. Let’s analyze the function.
First, users are able to construct $pmsGetProduct variable with arbitrary supplied input on several keys, including the “asin” key. The _product_extract_data function is just simply a function to prepare for the import data process and will put the initial value including the “asin” key value inside of the $opGetProduct[‘data’] object.
Back to the product_by_asin function, from all of the above conditions mentioned, users are able to fully control the $asins value. Before the SQL query process, the function will construct a $asins_ from $asins variable using the prepareForInList function:
public function prepareForInList($v) {
return "'".$v."'";
}
So, our input will be wrapped in single quotes. If you are already familiar with WordPress, you must be aware that WordPress will apply magic quotes to PHP global input, so if users directly supply the SQL Injection payload via the $_REQUEST variable, any quotes will be escaped and SQL Injection is not possible in this case. However, let’s look back to the ajax_request function, and notice that the function will construct a $request variable via the file_get_contents( ‘php://input’ ) and it will treat it as a JSON object. Then, the function will merge the $request variable to the $_REQUEST global variable, so in this case, users are able to inject the SQL Injection payload by using JSON as the input content type to bypass the magic quotes.
Authenticated SQL Injection
The underlying vulnerable code exists on the action_edit_inline function:
public function action_edit_inline()
{
global $wpdb;
$ret = array(
'status' => 'invalid',
'msg' => '',
);
$request = array(
'table' => isset($_REQUEST['table']) ? trim((string)$_REQUEST['table']) : '',
'itemid' => isset($_REQUEST['itemid']) ? (int)$_REQUEST['itemid'] : 0,
'field_name' => isset($_REQUEST['field_name']) ? trim((string)$_REQUEST['field_name']) : '',
'field_value' => isset($_REQUEST['field_value']) ? trim((string)$_REQUEST['field_value']) : '',
);
extract($request);
$status = 'invalid'; $status_msg = '';
if( $request['itemid'] > 0 ) {
$table = $wpdb->prefix . $table;
if ( 1 ) {
// update field
if ( 1 ) {
$wpdb->update(
$table,
array(
$field_name => $field_value
),
array( 'id' => $itemid ),
array(
'%s'
),
array( '%d' )
);
}
--------------- CUT HERE ---------------
The above function can be called from the ajax_request function:
public function ajax_request( $retType='die', $pms=array() ) {
$request = array(
'action' => isset($_REQUEST['sub_action']) ? $_REQUEST['sub_action'] : '',
'ajax_id' => isset($_REQUEST['ajax_id']) ? $_REQUEST['ajax_id'] : '',
);
extract($request);
//var_dump('<pre>', $request, '</pre>'); die('debug...');
$ret = array(
'status' => 'invalid',
'html' => '',
);
if ( in_array($action, array('publish', 'delete', 'bulk_delete')) ) {
// maintain box html
$_SESSION['WooZoneListTable'][$request['ajax_id']]['requestFrom'] = 'ajax';
$this->setup( $_SESSION['WooZoneListTable'][$request['ajax_id']] );
}
$opStatus = array();
if ( 'publish' == $action ) {
$opStatus = $this->action_publish();
}
else if ( 'delete' == $action ) {
$opStatus = $this->action_delete();
}
else if ( 'bulk_delete' == $action ) {
$opStatus = $this->action_bulk_delete();
}
else if ( 'edit_inline' == $action ) {
$opStatus = $this->action_edit_inline();
}
--------------- CUT HERE ---------------
The function is hooked to the wp_ajax_WooZoneAjaxList_actions action. Since there is no proper permission and nonce check, any authenticated users are able to trigger the function.
Let’s go back to the action_edit_inline function, the function itself will construct a $request variable with the following keys “table”, “itemid”, “field_name” and “field_value”. Users in this case are able to control the value of every key of the variable object. The function then performs an extract process and then will perform $wpdb->update() using the arbitrary values that are fully controlled by the users. With this condition, users are able to modify any table data on the database.
Disclosure note
We decided to release this security advisory article since we haven’t received any reply from the vendor. We also noticed that the affected plugin has an old vulnerability entry that hasn’t been patched here.
The patch
There is still no known official patch for the mentioned vulnerabilities. Although there is a vPatch available for all paid Patchstack users.
Conclusion
The most important thing when implementing an action or process is to apply permission or role and nonce validation. Permission or role check could be validated using current_user_can
function and nonce value could be validated using wp_verify_nonce
or check_ajax_referer
.
For the SQL query process, always do a safe escape and format for the user’s input before performing a query, and never give arbitrary access for users to update tables on the database.
Timeline
Help us make the Internet a safer place
Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.
- If you’re a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
- If you’re a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.