# RCA Report — CVE-2026-54849

## Summary

Unauthenticated SQL injection exists in the **Premmerce Wishlist for WooCommerce**
WordPress plugin (versions `<= 1.1.11`). The plugin persists a visitor's wishlist
"keys" in a browser cookie named `premmerce_wishlist` (a JSON array). On every
unauthenticated request to the wishlist functionality, the plugin reads that
cookie, decodes it, and feeds the attacker-controlled keys directly into a SQL
`IN (...)` query built by **string concatenation** (`implode('", "', $keys)`).
Because the keys are never validated or escaped before being interpolated, an
unauthenticated attacker can break out of the double-quoted literal and inject
arbitrary SQL. The injection is reachable through an unauthenticated REST API
endpoint (`/?rest_route=/premmerce/wishlist/add/popup`, registered with
`permission_callback => __return_true`), so no WordPress login is required.

## Impact

- **Package/component affected:** `premmerce-woocommerce-wishlist` (Premmerce Wishlist
  for WooCommerce). Vulnerable component: `src/WishlistStorage.php::cookieGet()` +
  `src/Models/WishlistModel.php::getWishlistsByKeys()` / `getDefaultWishlistByKeys()`.
- **Affected versions:** `<= 1.1.11`. Patched in `1.1.12`
  (changelog: *"Security: SQL injection fix in wishlist cookie handling"*).
- **Risk level:** Critical (Patchstack CVSS 9.3, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:L).
- **Consequences:** An unauthenticated remote attacker can interact with the
  WordPress database via SQL injection — e.g. exfiltrate data from any table
  (demonstrated below by extracting the admin `user_login` from `wp_users` via a
  `UNION SELECT`) and perform time-based blind inference. This can lead to full
  disclosure of the WordPress database (users, password hashes, sessions, etc.).

## Impact Parity

- **Disclosed/claimed maximum impact:** Unauthenticated SQL injection allowing
  interaction with the database, including data extraction (CVSS 9.3, C:H).
- **Reproduced impact from this run:** Unauthenticated SQL injection proven on the
  real product through two independent techniques:
  1. **Time-based blind SQLi** — a `SLEEP(5)` payload delayed the unauthenticated
     REST response by ~5.2–6.1s on the vulnerable build (vs ~0.1s on the fixed build).
  2. **UNION-based data extraction** — a `UNION SELECT` payload leaked the WordPress
     admin `user_login` (`sqliadmin`) into the unauthenticated REST response body on
     the vulnerable build (1 occurrence in the response), with no leak on the fixed build.
- **Parity:** `full`. The claimed unauthenticated SQL injection (data extraction) was
  reproduced end-to-end against the real product on the claimed `api_remote` surface.
- **Not demonstrated:** This proof stops at database read/extraction (consistent with
  the disclosed C:H impact). It does not demonstrate writing to the database or code
  execution, which are not claimed by the advisory.

## Root Cause

The cookie value is trusted all the way into the SQL query without validation or
parameterization.

Vulnerable `src/WishlistStorage.php::cookieGet()` (1.1.11):
```php
public function cookieGet()
{
    if ($this->cookieIsSet()) {
        $data = json_decode(stripslashes($_COOKIE[self::COOKIE_NAME]));
        return $data ? $data : array();   // <-- no validation of $data entries
    }
    return array();
}
```
(`COOKIE_NAME = 'premmerce_wishlist'`. `stripslashes` undoes the `addslashes` that
WordPress applies to `$_COOKIE` via `wp_magic_quotes()` on every request, so
`json_decode` effectively receives the raw, URL-decoded cookie value unchanged.)

Vulnerable `src/Models/WishlistModel.php::getWishlistsByKeys()` (1.1.11):
```php
public function getWishlistsByKeys($keys)
{
    if ($keys && is_array($keys)) {
        $sql = vsprintf(
            "SELECT * FROM `%s` WHERE `wishlist_key` IN (%s);",
            array(
                $this->tblWishlist,
                '"' . implode('", "', $keys) . '"'   // <-- direct concatenation
            )
        );
        return $this->getWishlistsBySql($sql);
    }
}
```
The attacker-controlled `$keys` are placed inside the `IN ("...")` list with only a
double-quote wrapper. A key containing `x") UNION SELECT ...` closes the quote and
injects SQL. The same concatenation pattern exists in `getDefaultWishlistByKeys()`,
`setUserToWishlistsByKeys()`, `deleteWishlists()`, `getWishlistsByProductId()`, and
the `where_in` branch of `getWishlists()`.

**Reachability (unauthenticated):** `src/RestApi/RestApi.php` registers
`register_rest_route('premmerce/wishlist', '/add/popup', ... 'permission_callback' => '__return_true')`.
Its callback `wishlistAddPopupRest()` → `wishlistAddPopup()` → `getWishlistsAll()`:
```php
public function getWishlistsAll() {
    if (is_user_logged_in()) { ... }
    else { if ($this->storage->cookieIsSet()) {
        $wishlistAll = $this->model->getWishlistsByKeys($this->storage->cookieGet()); // <-- SQLi
    } }
    return $wishlistAll;
}
```
So an unauthenticated visitor supplying a crafted `premmerce_wishlist` cookie reaches
the vulnerable query. (WooCommerce must be active for the plugin's REST/frontend to
register, via `WishlistPlugin::run()` / `validateRequiredPlugins()`.)

**Fix (1.1.12):** `cookieGet()` now validates each key with
`preg_match('/^[a-zA-Z0-9]{1,13}$/', $key)` (rejecting anything containing quotes,
spaces, SQL metacharacters) and the SQL builders were converted to `$wpdb->prepare()`
with `%s` placeholders and an `allowed_columns` allow-list for `where_in`. The fix
diff (`src/WishlistStorage.php` + `src/Models/WishlistModel.php`) is the authoritative
patch.

### Injected SQL (this reproduction)

The crafted cookie URL-decodes to the JSON value
`["x\") UNION SELECT SLEEP(5),2,3,4,5,6,7,8-- "]` (the embedded `"` is JSON-escaped
as `\"`). After `json_decode`, the key is `x") UNION SELECT SLEEP(5),2,3,4,5,6,7,8-- `,
which is concatenated into:
```sql
SELECT * FROM `wp_premmerce_wishlist` WHERE `wishlist_key` IN ("x") UNION SELECT SLEEP(5),2,3,4,5,6,7,8-- ");
```
The `wp_premmerce_wishlist` table has 8 columns (`ID, user_id, name, wishlist_key,
products, date_created, date_modified, default`), so the `UNION SELECT` supplies 8
columns. The data-extraction variant
`["x\") UNION SELECT 1,2,user_login,4,5,6,7,8 FROM wp_users-- "]` places
`wp_users.user_login` into the `name` column, which the wishlist popup template
renders — leaking the admin username into the unauthenticated response.

## Reproduction Steps

1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained; run twice,
   both pass with exit 0).
2. **What it does:**
   - Deploys a real stack in Docker: `mariadb:11.4` + `wordpress:6.7.2-php8.2-apache`,
     installs WordPress (admin `sqliadmin`), activates WooCommerce 8.9.3, and
     installs+activates Premmerce Wishlist **1.1.11 (vulnerable)** from the local zip.
   - Sends an **unauthenticated** HTTP GET (from a separate client container on the
     Docker network, hitting the WordPress service by container name) to
     `/?rest_route=/premmerce/wishlist/add/popup` with the crafted `premmerce_wishlist`
     cookie. Two techniques, two attempts each:
     - **SLEEP payload** — measures response time (expect ~5s on vulnerable).
     - **UNION payload** — greps the response body for the admin `user_login`
       (expect a leak on vulnerable).
   - Swaps to the **fixed 1.1.12** build and repeats the same two techniques as a
     negative control (expect no sleep, no leak).
   - Writes `bundle/repro/runtime_manifest.json` and exits 0 only if all four
     criteria hold (vuln sleeps + leaks; fixed does neither).
3. **Expected evidence of reproduction:**
   - Vulnerable `SLEEP` response times ≈ 5.2–6.1s; fixed ≈ 0.07–0.13s.
   - Vulnerable `UNION` response bodies contain `sqliadmin`; fixed bodies do not.
   - `bundle/repro/runtime_manifest.json` with `confirmation_status: "confirmed"`.

## Evidence

- **Main log:** `bundle/logs/reproduction_steps.log` (full script output, both runs).
- **Runtime manifest:** `bundle/repro/runtime_manifest.json` (valid JSON).
- **Response bodies:** `bundle/repro/artifacts/vuln_sleep_{1,2}.body`,
  `vuln_union_{1,2}.body`, `fixed_sleep_{1,2}.body`, `fixed_union_{1,2}.body`.
- **Plugin source (vulnerable + fixed, for diff):** `bundle/artifacts/premmerce-1.1.11.zip`,
  `bundle/artifacts/premmerce-1.1.12.zip`.

Key excerpt — **UNION data extraction on vulnerable 1.1.11** (admin username leaked
into the unauthenticated REST response, inside the wishlist-name span):
```
$ grep -o '.\{0,40\}sqliadmin.\{0,40\}' bundle/repro/artifacts/vuln_union_1.body
x-title\">\\n                                                sqliadmin                                            <\/span>
$ grep -c sqliadmin bundle/repro/artifacts/vuln_union_1.body   -> 1
$ grep -c sqliadmin bundle/repro/artifacts/fixed_union_1.body  -> 0
```

Key excerpt — **time-based blind SQLi divergence** (from `runtime_manifest.json`):
```
vulnerable_sleep_seconds:  [5.197395, 6.098308]   # SLEEP(5) fired
fixed_sleep_seconds:       [0.121983, 0.076013]   # no sleep (regex filtered the key)
vulnerable_union_leak_counts: [1, 1]              # admin user_login leaked
fixed_union_leak_counts:      [0, 0]              # no leak
```

**Environment:** Docker `mariadb:11.4` + `wordpress:6.7.2-php8.2-apache` (PHP 8.2.28),
WooCommerce 8.9.3, Premmerce Wishlist 1.1.11 (vulnerable) / 1.1.12 (fixed),
WordPress table prefix `wp_`, admin `sqliadmin`. The unauthenticated HTTP requests
are issued by a separate container on the Docker bridge network to the WordPress
service by container name (`http://premmerce-wp/...`); response bodies are retrieved
via `docker create` + `docker cp` (host bind-mounts and published ports are not
visible from this shell because the Docker daemon runs in a separate namespace).

## Recommendations / Next Steps

- **Upgrade** Premmerce Wishlist for WooCommerce to **>= 1.1.12** immediately.
- **Long-term:** the plugin should use `$wpdb->prepare()` with placeholders for *all*
  dynamic SQL (several other builders in `WishlistModel.php` were also concatenating
  user-influenced values and were patched in 1.1.12) and treat cookie data as
  untrusted, validating structure/types before use.
- **Testing:** add automated tests that feed malicious cookie values
  (quotes, UNION, SLEEP, semicolons) and assert no SQL injection / no error / no
  data leak. Consider enabling a WAF rule for the `premmerce_wishlist` cookie.
- **Detection:** review access logs for `premmerce_wishlist` cookie values containing
  `UNION`, `SLEEP`, `--`, `"`, or `)` as indicators of attempted exploitation.

## Additional Notes

- **Idempotency:** the script tears down and rebuilds the whole stack on every run,
  so it can be executed repeatedly with deterministic results. Verified by two
  consecutive runs (both exit 0, both `confirmed`).
- **Surface match:** the claimed surface is `api_remote` (unauthenticated HTTP request
  to the plugin endpoint); the proof uses a real unauthenticated HTTP request to the
  plugin's REST endpoint — `validated_surface = api_remote`.
- **Scope/impact:** this is a data-extraction SQL injection (read). The proof does not
  demonstrate DB writes or RCE, which are outside the disclosed impact.
- **Sanitizer:** no sanitizer is used; the proof is a plain non-sanitized product run
  (real Apache + WordPress + MariaDB), and the oracle is response timing + response
  content, not ASAN/UBSAN.
