|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import logging |
| 4 | +import os |
| 5 | +import secrets |
| 6 | +import time |
| 7 | + |
| 8 | +import docker |
| 9 | +import docker.models.containers |
| 10 | +from python_on_whales import DockerClient |
| 11 | +from python_on_whales.exceptions import DockerException |
| 12 | +from rich.console import Console |
| 13 | +from runners.base import BaseRunner |
| 14 | +from utils import safe_stop_remove |
| 15 | + |
| 16 | +logger = logging.getLogger(__name__) |
| 17 | +console = Console(stderr=True) |
| 18 | + |
| 19 | + |
| 20 | +class DockerComposeMsfRpc(BaseRunner): |
| 21 | + """Runner for exploiting multi-container targets via docker-compose using Metasploit RPC.""" |
| 22 | + |
| 23 | + def __init__(self, docker_client: docker.DockerClient, vuln_name: str = "", target_name: str = "target", |
| 24 | + network_name: str = "set_framework_net", volume_name: str = "set_logs", |
| 25 | + target_yml: str = "", msf_exploit: str = "", msf_options: str = "", delay: int = 0, |
| 26 | + msf_image: str = "metasploitframework/metasploit-framework:6.2.33", |
| 27 | + prefix: str = "") -> None: |
| 28 | + """Initialize with compose file path, target service name, and exploit config.""" |
| 29 | + super().__init__(docker_client, network_name, volume_name, prefix=prefix) |
| 30 | + self.vuln_name = vuln_name |
| 31 | + self.target_yml = self._expand_and_validate(target_yml, "yml_file") |
| 32 | + self.target_name = target_name |
| 33 | + self.msf_exploit = msf_exploit |
| 34 | + self.msf_options = msf_options |
| 35 | + self.delay = delay |
| 36 | + self.msf_image = msf_image |
| 37 | + self.compose_project = self.prefix if self.prefix else "setc" |
| 38 | + self.rpc_password = secrets.token_hex(8) |
| 39 | + self.rpc_port = 55552 |
| 40 | + self.rpc_client = None |
| 41 | + |
| 42 | + self.setc_yml = self._expand_and_validate( |
| 43 | + "$SETC_PATH/example_configurations/setc-net_docker-compose.yml", "SETC_PATH") |
| 44 | + self.wdocker = None |
| 45 | + self.tcpdump_instances = [] |
| 46 | + self.attack = None |
| 47 | + |
| 48 | + @staticmethod |
| 49 | + def _expand_and_validate(path: str, label: str) -> str: |
| 50 | + """Expand environment variables in a path and verify it exists. |
| 51 | +
|
| 52 | + Raises: |
| 53 | + EnvironmentError: If any env vars remain unexpanded. |
| 54 | + FileNotFoundError: If the expanded path does not exist. |
| 55 | + """ |
| 56 | + expanded = os.path.expandvars(path) |
| 57 | + if "$" in expanded: |
| 58 | + unset = [tok for tok in expanded.split(os.sep) if tok.startswith("$")] |
| 59 | + raise EnvironmentError( |
| 60 | + f"Environment variable(s) not set for {label}: {', '.join(unset)}. " |
| 61 | + f"Path after expansion: {expanded}" |
| 62 | + ) |
| 63 | + if not os.path.exists(expanded): |
| 64 | + raise FileNotFoundError( |
| 65 | + f"Path does not exist for {label}: {expanded}" |
| 66 | + ) |
| 67 | + return expanded |
| 68 | + |
| 69 | + def target_setup(self) -> None: |
| 70 | + """Build and start the docker-compose services.""" |
| 71 | + wdocker = DockerClient(compose_project_name=self.compose_project, compose_files=[self.target_yml, self.setc_yml]) |
| 72 | + wdocker.compose.build() |
| 73 | + wdocker.compose.up(detach=True) |
| 74 | + self.wdocker = wdocker |
| 75 | + if self.prefix: |
| 76 | + self.target_name = self.target_name.replace("setc-", f"{self.compose_project}-", 1) |
| 77 | + |
| 78 | + def target_cleanup(self) -> None: |
| 79 | + """Stop and remove all compose services and tcpdump sidecars.""" |
| 80 | + if self.tcpdump_instances: |
| 81 | + self.tcpdump_cleanup() |
| 82 | + try: |
| 83 | + self.wdocker.compose.stop() |
| 84 | + self.wdocker.compose.rm() |
| 85 | + except DockerException as e: |
| 86 | + logger.warning("Failed to stop/remove compose services: %s", e) |
| 87 | + |
| 88 | + def tcpdump_setup(self) -> None: |
| 89 | + """Start a tcpdump container for the target compose service.""" |
| 90 | + tcpdump_instances = [] |
| 91 | + for i in self.wdocker.compose.ps(): |
| 92 | + if i.name == self.target_name: |
| 93 | + dk_tcpdump = self._run_tcpdump_container(self.vuln_name, self.target_name) |
| 94 | + tcpdump_instances.append(dk_tcpdump) |
| 95 | + self.tcpdump_instances = tcpdump_instances |
| 96 | + |
| 97 | + def tcpdump_cleanup(self) -> None: |
| 98 | + """Stop and remove all tcpdump sidecar containers.""" |
| 99 | + for instance in self.tcpdump_instances: |
| 100 | + safe_stop_remove(instance, label="tcpdump") |
| 101 | + |
| 102 | + def attack_setup(self) -> None: |
| 103 | + """Start the Metasploit attack container with msfrpcd and connect via RPC.""" |
| 104 | + logger.debug("Starting RPC attack system for %s", self.target_name) |
| 105 | + cmd = ["./msfrpcd", "-P", self.rpc_password, "-S", "-f", "-a", "0.0.0.0", |
| 106 | + "-p", str(self.rpc_port)] |
| 107 | + dk_attack = self.client.containers.run( |
| 108 | + self.msf_image, |
| 109 | + command=cmd, |
| 110 | + detach=True, |
| 111 | + name="%s-attack" % self.target_name, |
| 112 | + network=self.network, |
| 113 | + tty=True, |
| 114 | + ) |
| 115 | + self.attack = dk_attack |
| 116 | + self.rpc_client = self._wait_for_msfrpc(dk_attack, self.rpc_password, port=self.rpc_port) |
| 117 | + |
| 118 | + def attack_cleanup(self) -> None: |
| 119 | + """Stop and remove the Metasploit attack container.""" |
| 120 | + self.rpc_client = None |
| 121 | + safe_stop_remove(self.attack, label="%s-attack" % self.target_name) |
| 122 | + |
| 123 | + @staticmethod |
| 124 | + def _set_module_option(module, key, value): |
| 125 | + """Set an option on a module, falling back to _runopts for payload options. |
| 126 | +
|
| 127 | + The RPC module's __setitem__ raises KeyError for options not defined on |
| 128 | + the exploit module (e.g. LHOST, LPORT which are payload options). These |
| 129 | + still need to land in _runopts so that execute() forwards them in the |
| 130 | + RPC call. |
| 131 | + """ |
| 132 | + if key in module.options: |
| 133 | + module[key] = value |
| 134 | + else: |
| 135 | + module._runopts[key] = value |
| 136 | + |
| 137 | + def exploit(self) -> None: |
| 138 | + """Execute the configured exploit via the Metasploit RPC API.""" |
| 139 | + module = self.rpc_client.modules.use('exploit', self.msf_exploit) |
| 140 | + |
| 141 | + # Core options — use helper to handle exploit vs payload options |
| 142 | + self._set_module_option(module, 'RHOSTS', self.target_name) |
| 143 | + self._set_module_option(module, 'LHOST', "%s-attack" % self.target_name) |
| 144 | + self._set_module_option(module, 'ForceExploit', True) |
| 145 | + self._set_module_option(module, 'AutoCheck', False) |
| 146 | + |
| 147 | + # Parse and apply user-provided options |
| 148 | + parsed = self._parse_msf_options(self.msf_options) |
| 149 | + payload = parsed.pop('PAYLOAD', None) |
| 150 | + for key, value in parsed.items(): |
| 151 | + self._set_module_option(module, key, value) |
| 152 | + |
| 153 | + # A payload MUST be provided to execute(), otherwise pymetasploit3 |
| 154 | + # sets DisablePayloadHandler=True and no handler listens for the |
| 155 | + # reverse connection. Auto-select the first compatible payload |
| 156 | + # when the user hasn't specified one (mirrors msfconsole behaviour). |
| 157 | + if payload is None: |
| 158 | + compatible = module.payloads |
| 159 | + if compatible: |
| 160 | + payload = compatible[0] |
| 161 | + |
| 162 | + self._last_payload = payload |
| 163 | + result = module.execute(payload=payload) |
| 164 | + self._last_job_id = result.get('job_id') |
| 165 | + |
| 166 | + def exploit_success(self, pattern: str = "4444") -> bool: |
| 167 | + """Check if the exploit established a session via RPC session list.""" |
| 168 | + try: |
| 169 | + sessions = self.rpc_client.sessions.list |
| 170 | + return bool(sessions) |
| 171 | + except Exception: |
| 172 | + return False |
| 173 | + |
| 174 | + def exploit_until_success(self, status_delay: int = 3, status_checks: int = 7, tries: int = 4) -> bool: |
| 175 | + """Repeatedly run the exploit until an RPC session is established.""" |
| 176 | + success = False |
| 177 | + with console.status(f"[bold]Exploiting {self.target_name} (RPC)...[/bold]"): |
| 178 | + for i in range(tries): |
| 179 | + self.exploit() |
| 180 | + for j in range(status_checks): |
| 181 | + if self.exploit_success(pattern=self.exploit_success_pattern): |
| 182 | + success = True |
| 183 | + break |
| 184 | + time.sleep(status_delay) |
| 185 | + if success: |
| 186 | + break |
| 187 | + if success: |
| 188 | + logger.info("Exploit of %s success (RPC session established)", self.target_name) |
| 189 | + else: |
| 190 | + logger.warning("Exploit failed or status unknown (RPC)") |
| 191 | + return success |
| 192 | + |
| 193 | + def _get_target_container(self) -> docker.models.containers.Container: |
| 194 | + """Look up and return the target container by name from the Docker API.""" |
| 195 | + return self.client.containers.get(self.target_name) |
0 commit comments