Skip to content

Commit 74a6261

Browse files
committed
completion of the long awaited msf rpc control classes. This approach
reduces exploit error, speeds up exploit completion, and provides more accurate exploit validation
1 parent 7ad37d3 commit 74a6261

10 files changed

Lines changed: 605 additions & 16 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Each config is a JSON array of exploit entries. SETC supports two target modes:
9292
| `target_image` | One of these | Docker image for single-container targets |
9393
| `yml_file` + `target_name` | One of these | Docker Compose file + target container name |
9494
| `exploit_options` | No | Additional MSF console commands (semicolon-separated) |
95+
| `exploit_mode` | No | `"cli"` (default) or `"rpc"` for Metasploit RPC-based exploitation |
9596
| `exploit_success_pattern` | No | Regex pattern to detect success (default: checks for port 4444) |
9697
| `target_delay` | No | Seconds to wait after starting the target (default: 0) |
9798
| `exploit_retries` | No | Retry count before giving up (default: 4) |
@@ -165,6 +166,30 @@ usage: setc [-h] [-v] [-p PASSWORD] [--volume VOLUME] [--network NETWORK]
165166
| `--cleanup_postgres` | Remove PostgreSQL container after completion |
166167
| `--cleanup_elk` | Remove Elasticsearch and Kibana containers after completion |
167168

169+
### RPC exploit mode
170+
171+
By default SETC drives Metasploit via `msfconsole -x` (CLI mode). Setting `"exploit_mode": "rpc"` in a config entry switches to the MSGRPC API via `pymetasploit3`, which provides:
172+
173+
- **Structured exploit execution**`module.execute()` returns a job ID
174+
- **Reliable success detection** — checks `client.sessions.list` for actual Meterpreter/shell sessions instead of grepping `netstat`
175+
- **Job tracking** — active jobs are visible via `client.jobs.list`
176+
177+
```json
178+
[
179+
{
180+
"name": "CVE-2018-11776",
181+
"settings": {
182+
"description": "Struts2 OGNL injection RCE",
183+
"target_image": "vulhub/struts2:2.5.25",
184+
"exploit": "multi/http/struts2_multi_eval_ognl",
185+
"exploit_mode": "rpc"
186+
}
187+
}
188+
]
189+
```
190+
191+
RPC mode uses the same Metasploit Docker image — it starts `msfrpcd` instead of `msfconsole`. The `pymetasploit3` dependency is installed via `pip install -r requirements.txt`.
192+
168193
### Manual exploit mode
169194

170195
When `exploit` is omitted (or set to `""`), SETC starts the target and tcpdump capture, then pauses and waits for you to manually exploit the target from a separate terminal. Press Enter when done and SETC proceeds with cleanup, PCAP parsing, and log conversion as usual.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[
2+
{"name":"CVE-2014-6271",
3+
"settings": {
4+
"description":"Apache CGI shellshock using user agent",
5+
"yml_file":"$VULN_PATH/bash/CVE-2014-6271/docker-compose.yml",
6+
"target_name":"setc-web-1",
7+
"exploit": "multi/http/apache_mod_cgi_bash_env_exec",
8+
"exploit_options":"set TARGETURI /victim.cgi;",
9+
"exploit_mode": "rpc"
10+
}
11+
}
12+
]
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
[
2+
{"name":"CVE-2018-11776",
3+
"settings": {
4+
"description":"Struts2 OGNL injection RCE",
5+
"target_image":"vulhub/struts2:2.5.25",
6+
"exploit": "multi/http/struts2_multi_eval_ognl",
7+
"exploit_mode": "rpc"
8+
}
9+
},
10+
{"name":"CVE-2022-0543",
11+
"settings": {
12+
"description":"Redis Lua RCE",
13+
"target_image":"vulhub/redis:5.0.7",
14+
"exploit": "exploit/linux/redis/redis_debian_sandbox_escape",
15+
"exploit_mode": "rpc"
16+
}
17+
},
18+
{"name":"CVE-2021-3129",
19+
"settings": {
20+
"description":"Laravel debug RCE",
21+
"target_image":"vulhub/laravel:8.4.2",
22+
"exploit": "exploit/multi/php/ignition_laravel_debug_rce",
23+
"exploit_mode": "rpc"
24+
}
25+
},
26+
{"name":"CVE-2014-6271",
27+
"settings": {
28+
"description":"Apache CGI shellshock using user agent",
29+
"yml_file":"$VULN_PATH/bash/CVE-2014-6271/docker-compose.yml",
30+
"target_name":"setc-web-1",
31+
"exploit": "multi/http/apache_mod_cgi_bash_env_exec",
32+
"exploit_options":"set TARGETURI /victim.cgi;",
33+
"exploit_mode": "rpc"
34+
}
35+
},
36+
{"name":"CVE-2012-1823",
37+
"settings": {
38+
"description":"PHP CGI Argument Injection",
39+
"yml_file":"$VULN_PATH/php/CVE-2012-1823/docker-compose.yml",
40+
"target_name":"setc-php-1",
41+
"exploit": "multi/http/php_cgi_arg_injection",
42+
"exploit_mode": "rpc"
43+
}
44+
},
45+
{"name":"CVE-2019-17558",
46+
"settings": {
47+
"description":"Apache Solr Remote Code Execution via Velocity Template",
48+
"yml_file":"$VULN_PATH/solr/CVE-2019-17558/docker-compose.yml",
49+
"target_name":"setc-solr-1",
50+
"exploit": "multi/http/solr_velocity_rce",
51+
"exploit_mode": "rpc"
52+
}
53+
}
54+
]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ psycopg2-binary
33
python-on-whales==0.74.0
44
rich>=13.0
55
elasticsearch>=9.0
6+
pymetasploit3>=1.0.6

setc/runners/base.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,61 @@ def exploit_until_success(self, status_delay: int = 3, status_checks: int = 7, t
180180
def _get_target_container(self) -> docker.models.containers.Container:
181181
"""Return the Docker container object for the vulnerable target."""
182182

183+
@staticmethod
184+
def _parse_msf_options(options_str: str) -> dict:
185+
"""Parse 'set KEY value;set KEY2 value2;' into {KEY: value, KEY2: value2}."""
186+
opts = {}
187+
for part in options_str.split(";"):
188+
part = part.strip()
189+
if part.lower().startswith("set "):
190+
tokens = part.split(None, 2) # "set", KEY, VALUE
191+
if len(tokens) == 3:
192+
opts[tokens[1]] = tokens[2]
193+
return opts
194+
195+
def _wait_for_msfrpc(self, container, password, port=55552, timeout=60):
196+
"""Poll the MSGRPC port inside a container until MsfRpcClient connects.
197+
198+
Returns an MsfRpcClient instance.
199+
Raises TimeoutError if msfrpcd doesn't respond within timeout.
200+
"""
201+
from pymetasploit3.msfrpc import MsfRpcClient
202+
203+
container.reload()
204+
container_ip = container.attrs['NetworkSettings']['Networks'][self.network]['IPAddress']
205+
206+
deadline = time.time() + timeout
207+
last_err = None
208+
# Suppress noisy retry/urllib3 warnings while polling for msfrpcd
209+
retry_logger = logging.getLogger("retry.api")
210+
urllib3_logger = logging.getLogger("urllib3")
211+
old_retry_level = retry_logger.level
212+
old_urllib3_level = urllib3_logger.level
213+
retry_logger.setLevel(logging.CRITICAL)
214+
urllib3_logger.setLevel(logging.CRITICAL)
215+
try:
216+
with console.status(f"[bold]Connecting to msfrpcd ({container_ip}:{port})...[/bold]"):
217+
while time.time() < deadline:
218+
try:
219+
client = MsfRpcClient(password, server=container_ip, port=port, ssl=False)
220+
except Exception as e:
221+
last_err = e
222+
time.sleep(2)
223+
continue
224+
# Connected — break out so the spinner clears before we log
225+
break
226+
else:
227+
client = None
228+
finally:
229+
retry_logger.setLevel(old_retry_level)
230+
urllib3_logger.setLevel(old_urllib3_level)
231+
if client is not None:
232+
logger.info("Connected to msfrpcd")
233+
return client
234+
raise TimeoutError(
235+
f"msfrpcd not ready after {timeout}s at {container_ip}:{port}: {last_err}"
236+
)
237+
183238
def ready_to_exploit(self, ready_delay: int = 5) -> bool:
184239
"""Return True when the target's log output has stabilized (container is ready)."""
185240
if self.target_logs == None:
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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

Comments
 (0)