Closes the next batch of issues from the post-mortem. The previous
"each run uses a different vulnerability" commit shipped 5 modules
but 3 of them couldn't actually fire under SLIRP+restrict=on:
their reverse-shell payloads needed a callback channel the launcher
didn't provide, AND their LHOST options were set to {{ target_ip }}
(the target's IP, not the attacker's — copy-paste from RHOSTS).
Same time, the workloads.py shell commands used bash-only /dev/tcp
redirects that silently no-op'd in the busybox shell sessions
Metasploitable2 returns. Net effect: episodes that selected those
modules would have produced session_open_timeout + dead workloads.
Module configs (the three callback ones):
exploits/modules/distccd_command_exec.toml
exploits/modules/php_cgi_arg_injection.toml
exploits/modules/unreal_ircd_3281_backdoor.toml
- Switch payload from cmd/unix/reverse* to cmd/unix/bind_perl
so the target listens on a known port; msfrpcd connects to it
via the host's hostfwd (no callback path required).
- Drop the bogus LHOST = "{{ target_ip }}" — bind shells don't
use LHOST.
- Add [runtime] table:
requires_bridge = true
extra_target_ports = [<bind_lport>]
Both fields are honored by the loader (ModuleConfig.requires_bridge)
and the launcher (TARGET_PORTS gets the extra port hostfwd'd
when BRIDGE mode is active).
orchestrator/fleet.py
When BRIDGE is unset in env, _run_slot filters the module catalog
down to modules where requires_bridge=False before calling
select_module. Two same-socket-shell modules (vsftpd_234_backdoor +
samba_usermap_script) survive — fleet still has variety; just
doesn't pick modules whose payloads can't land. With BRIDGE set,
the full catalog rotates as before, AND BRIDGE is propagated to
the per-slot subprocess env so launch_target.sh enters tap+bridge
mode.
exploits/workloads.py
Replaced bash-only constructs in three profiles:
scan-and-dial /dev/tcp/HOST/PORT redirects → nc -z -w 1
bursty-c2 same fix
shell-resident exec 3<>/dev/tcp/... → piping into nc -w
All three now run cleanly in busybox / dash / Metasploitable2's
default shell. The remaining three profiles (cpu-saturate, io-walk,
low-and-slow) were already busybox-portable.
scripts/install-lab-host.sh
- lab-host.env now defaults BRIDGE=br-malware (was commented out).
Operator opt-out is to comment the line back in.
- New step 6b: provisions br-malware via vm/setup_bridge.sh AND
pre-creates a per-slot tap pool (cis490tap0..7 for Tier-2 demo,
cis490target0..7 for Tier-3 target) all attached to br-malware
and brought up. Launchers reference these by SLOT — no sudo
needed at episode time.
- On bridge-setup failure, the script auto-comments BRIDGE in the
env file with a "auto-disabled: bridge setup failed" note so
the fleet falls back to same-socket modules + Tier-2 cleanly.
tools/cis490_doctor.py
Two new checks for the lab-host role:
bridge: br-malware exists / up
tier3: msfrpcd listening on 127.0.0.1:55553
tier3: module catalog parses (counts same-socket vs requires_bridge)
All three are warn-level — they don't fail an otherwise-healthy
Tier-2-only setup; they tell the operator what's missing for full
Tier-3 + source 4 coverage.
Tests: 132 (was 129). New cases:
test_fleet.py +3
- fleet skips requires_bridge modules when BRIDGE unset (asserted
across 20 episodes; never picks a callback module)
- fleet uses the full catalog when BRIDGE is set
- BRIDGE env propagates to per-slot subprocess
What's still untested live: the bind_perl payloads against a real
Metasploitable2 in the bridge-enabled launcher path. That's a
deployment validation, not a code change. The unit tests confirm
the dispatch / filter logic; the live test is the next operator
action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""Per-sample-profile post-exploit workloads (driver v2).
|
|
|
|
The Tier-3 driver lands a session and then needs to drive *something*
|
|
in that session for the ``infected_running`` phase. Driver v1 ran
|
|
``yes > /dev/null`` for every sample, which is fine for proving the
|
|
pipe but is the wrong shape for ML — every Tier-3 episode produces
|
|
the same envelope regardless of which malware family we said it was.
|
|
|
|
Driver v2 maps ``sample.profile`` from the manifest to a distinct
|
|
in-session workload so each profile's envelope is observably
|
|
different on every collector:
|
|
|
|
cpu-saturate → 1-vCPU saturation, very low IO/net (XMRig shape)
|
|
scan-and-dial → SYN scans across the bridge IP space + periodic
|
|
dial-home (Mirai shape)
|
|
io-walk → fs traversal + random write spikes (ransomware shape)
|
|
bursty-c2 → long idle, periodic short TCP egress bursts (Dridex)
|
|
low-and-slow → minimal CPU, periodic memory churn (Kovter)
|
|
shell-resident → one long-lived TCP socket pinned to a bridge IP,
|
|
occasional small command bursts (RAT)
|
|
|
|
Each profile returns a small shell command that backgrounds a loop
|
|
inside the session. The driver can stop them by killing the loop's
|
|
PID file or via a profile-specific kill command.
|
|
|
|
This module is intentionally *behaviorally diverse but harmless* —
|
|
it does NOT execute real malware. Real binaries land via the Tier-4
|
|
fetch+run path (separate work). What this gives us today is six
|
|
distinguishable in-guest envelopes the ML model can learn to
|
|
discriminate between *and* fall back to when a real sample isn't yet
|
|
staged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
from samples.manifest import Sample
|
|
|
|
|
|
log = logging.getLogger("cis490.exploits.workloads")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Workload:
|
|
"""A pair of shell commands executable in a Metasploit shell session.
|
|
|
|
``start_cmd`` backgrounds a loop and writes its PID to ``pid_path``.
|
|
``stop_cmd`` kills the loop using that PID file. Both commands are
|
|
expected to be POSIX-shell compatible and to leave the session in
|
|
a usable state on completion (return code 0 on the prompt)."""
|
|
profile: str
|
|
start_cmd: str
|
|
stop_cmd: str
|
|
description: str
|
|
|
|
@property
|
|
def pid_path(self) -> str:
|
|
return f"/tmp/.cis490-workload-{self.profile}.pid"
|
|
|
|
|
|
def _wrap_loop(name: str, body: str) -> Workload:
|
|
"""Common pattern: write a small wrapper script that loops ``body``,
|
|
background it, and stash the wrapper's PID. Stop kills that PID +
|
|
its child group."""
|
|
pid_path = f"/tmp/.cis490-workload-{name}.pid"
|
|
script_path = f"/tmp/.cis490-workload-{name}.sh"
|
|
# Triple-quote the body into a heredoc so single-quotes inside the
|
|
# body don't conflict with our outer single-quoting.
|
|
start = (
|
|
f"cat > {script_path} <<'CIS490_EOF'\n"
|
|
f"#!/bin/sh\n"
|
|
f"trap 'exit 0' TERM INT\n"
|
|
f"while :; do\n"
|
|
f"{body}\n"
|
|
f"done\n"
|
|
f"CIS490_EOF\n"
|
|
f"chmod +x {script_path}; "
|
|
f"nohup sh {script_path} </dev/null >/dev/null 2>&1 &\n"
|
|
f"echo $! > {pid_path}\n"
|
|
f"disown\n"
|
|
)
|
|
stop = (
|
|
f"if [ -f {pid_path} ]; then "
|
|
f" kill -- -$(cat {pid_path}) 2>/dev/null; "
|
|
f" kill $(cat {pid_path}) 2>/dev/null; "
|
|
f" rm -f {pid_path} {script_path}; "
|
|
f"fi; true\n"
|
|
)
|
|
return Workload(profile=name, start_cmd=start, stop_cmd=stop,
|
|
description="(generated)")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Profile factories — each returns a Workload tuned to that family
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _cpu_saturate() -> Workload:
|
|
"""XMRig-class — sustained single-vCPU saturation, no IO, no net."""
|
|
body = " yes > /dev/null 2>&1 &\n wait $!\n"
|
|
w = _wrap_loop("cpu-saturate", body)
|
|
return Workload(
|
|
profile="cpu-saturate",
|
|
start_cmd=w.start_cmd,
|
|
stop_cmd=w.stop_cmd,
|
|
description="100% CPU on 1 vCPU; no IO, no net",
|
|
)
|
|
|
|
|
|
def _scan_and_dial() -> Workload:
|
|
"""Mirai-class — TCP SYN-style probe of bridge subnet + occasional
|
|
"dial home" to the gateway. Heavy net, moderate CPU.
|
|
|
|
Uses ``nc`` (netcat) instead of bash's /dev/tcp redirects — the
|
|
latter is bash-only and silently no-ops on busybox / dash, which
|
|
is what Metasploitable2 and Alpine guest sessions actually run.
|
|
Falls back to a TCP-via-python one-liner if nc isn't available."""
|
|
body = (
|
|
" for i in 1 2 3 4 5 6 7 8 9 10; do\n"
|
|
" nc -z -w 1 10.200.0.$((i+1)) 23 >/dev/null 2>&1 &\n"
|
|
" nc -z -w 1 10.200.0.$((i+1)) 2323 >/dev/null 2>&1 &\n"
|
|
" done\n"
|
|
" wait\n"
|
|
" echo dial-home | nc -w 1 10.200.0.1 4444 >/dev/null 2>&1\n"
|
|
" sleep 2\n"
|
|
)
|
|
w = _wrap_loop("scan-and-dial", body)
|
|
return Workload(
|
|
profile="scan-and-dial",
|
|
start_cmd=w.start_cmd,
|
|
stop_cmd=w.stop_cmd,
|
|
description="Periodic SYN-style scan across bridge IPs + dial-home",
|
|
)
|
|
|
|
|
|
def _io_walk() -> Workload:
|
|
"""Cryptolocker-class — fs traversal + write spikes. Heavy disk."""
|
|
body = (
|
|
" mkdir -p /tmp/.cis490-victim\n"
|
|
" for n in 1 2 3 4 5 6 7 8; do\n"
|
|
" dd if=/dev/urandom of=/tmp/.cis490-victim/f$n bs=4k count=64 2>/dev/null\n"
|
|
" done\n"
|
|
" for f in /tmp/.cis490-victim/*; do cat $f > /dev/null; done\n"
|
|
" sleep 1\n"
|
|
)
|
|
w = _wrap_loop("io-walk", body)
|
|
return Workload(
|
|
profile="io-walk",
|
|
start_cmd=w.start_cmd,
|
|
stop_cmd=w.stop_cmd,
|
|
description="FS traversal + random-data writes, periodic re-read",
|
|
)
|
|
|
|
|
|
def _bursty_c2() -> Workload:
|
|
"""Dridex-class — long idle, periodic small TCP burst to a fixed
|
|
peer (the bridge gateway). nc-based for busybox compatibility."""
|
|
body = (
|
|
" sleep 25\n"
|
|
" for i in 1 2 3; do\n"
|
|
" echo c2-beacon-$$-$i | nc -w 1 10.200.0.1 4445 >/dev/null 2>&1\n"
|
|
" sleep 1\n"
|
|
" done\n"
|
|
)
|
|
w = _wrap_loop("bursty-c2", body)
|
|
return Workload(
|
|
profile="bursty-c2",
|
|
start_cmd=w.start_cmd,
|
|
stop_cmd=w.stop_cmd,
|
|
description="Long idle + periodic 3-packet egress burst to gateway",
|
|
)
|
|
|
|
|
|
def _low_and_slow() -> Workload:
|
|
"""Kovter-class — low CPU, periodic memory churn, no on-disk
|
|
artifact. The hardest envelope to label from /proc alone."""
|
|
body = (
|
|
" sleep 8\n"
|
|
" awk 'BEGIN { for(i=0;i<200000;i++) a[i]=i*i; }' >/dev/null 2>&1\n"
|
|
" sleep 4\n"
|
|
)
|
|
w = _wrap_loop("low-and-slow", body)
|
|
return Workload(
|
|
profile="low-and-slow",
|
|
start_cmd=w.start_cmd,
|
|
stop_cmd=w.stop_cmd,
|
|
description="Periodic memory churn (~200k array allocs) on a slow cycle",
|
|
)
|
|
|
|
|
|
def _shell_resident() -> Workload:
|
|
"""RAT-style — keep a single TCP connection open to the gateway
|
|
with occasional command bursts. Long-lived flow, small bytes.
|
|
|
|
Uses ``nc -w`` on the busybox-compatible path. We pipe a slow
|
|
feed into nc so the connection stays open for ~30 s before the
|
|
-w idle timeout closes it, matching the long-lived-flow shape.
|
|
Then we sleep + reconnect, producing the periodic-tick pattern."""
|
|
body = (
|
|
" ( for i in 1 2 3 4 5 6; do\n"
|
|
" echo cmd-tick-$i\n"
|
|
" sleep 5\n"
|
|
" done ) | nc -w 30 10.200.0.1 4446 >/dev/null 2>&1\n"
|
|
" sleep 5\n"
|
|
)
|
|
w = _wrap_loop("shell-resident", body)
|
|
return Workload(
|
|
profile="shell-resident",
|
|
start_cmd=w.start_cmd,
|
|
stop_cmd=w.stop_cmd,
|
|
description="Resident TCP connection to gateway with periodic ticks",
|
|
)
|
|
|
|
|
|
_FACTORIES = {
|
|
"cpu-saturate": _cpu_saturate,
|
|
"scan-and-dial": _scan_and_dial,
|
|
"io-walk": _io_walk,
|
|
"bursty-c2": _bursty_c2,
|
|
"low-and-slow": _low_and_slow,
|
|
"shell-resident": _shell_resident,
|
|
}
|
|
|
|
|
|
def workload_for(sample: Sample | None) -> Workload | None:
|
|
"""Return the Workload matching ``sample.profile``, or None when
|
|
no sample is supplied (driver v1 fallback path)."""
|
|
if sample is None:
|
|
return None
|
|
factory = _FACTORIES.get(sample.profile)
|
|
if factory is None:
|
|
log.warning("no workload profile for %r; falling back to cpu-saturate", sample.profile)
|
|
return _cpu_saturate()
|
|
return factory()
|
|
|
|
|
|
def all_profiles() -> list[str]:
|
|
return sorted(_FACTORIES.keys())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tier-4 path: real-binary upload + execute inside the shell session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChunkedUpload:
|
|
"""Multi-step upload plan. Each chunk is one ``shell_write`` call;
|
|
the driver issues them in order, then a final integrity check, then
|
|
the exec command. The last command runs the binary and writes its
|
|
PID to ``pid_path``."""
|
|
profile: str
|
|
chunks: tuple[str, ...] # each is a complete shell command
|
|
finalize_cmd: str # decode + verify sha256 + chmod
|
|
exec_cmd: str # actually launch the binary
|
|
stop_cmd: str
|
|
bin_path: str
|
|
pid_path: str
|
|
expected_sha256: str
|
|
n_chunks: int
|
|
|
|
|
|
# Conservative chunk size: msfrpc shell_write payloads are reliable
|
|
# under ~16 KiB (single TCP write inside the framework). Use 8 KiB of
|
|
# *base64* (which is 6 KiB of binary) per chunk so we leave room for
|
|
# the wrapper and stay well under the limit.
|
|
_CHUNK_B64_BYTES = 8 * 1024
|
|
|
|
|
|
def chunked_real_binary_upload(
|
|
binary_bytes: bytes,
|
|
sample: Sample | None = None,
|
|
) -> ChunkedUpload:
|
|
"""Plan a chunked upload of ``binary_bytes`` into a shell session.
|
|
|
|
First chunk creates an empty file; subsequent chunks append a
|
|
base64 segment. ``finalize_cmd`` decodes + sha256-verifies the
|
|
result; ``exec_cmd`` launches the binary and stashes its PID.
|
|
The driver issues these as separate shell_writes so we never
|
|
push more than ~10 KiB through msfrpc in a single call."""
|
|
import base64 as _b64
|
|
import hashlib as _hashlib
|
|
|
|
profile = (sample.profile if sample else "real-binary")
|
|
pid_path = f"/tmp/.cis490-real-{profile}.pid"
|
|
bin_path = f"/tmp/.cis490-real-{profile}.bin"
|
|
b64_path = f"/tmp/.cis490-real-{profile}.b64"
|
|
sha = _hashlib.sha256(binary_bytes).hexdigest()
|
|
encoded = _b64.b64encode(binary_bytes).decode("ascii")
|
|
|
|
chunks: list[str] = []
|
|
chunks.append(f"mkdir -p /tmp; : > {b64_path}; echo upload-begin")
|
|
for i in range(0, len(encoded), _CHUNK_B64_BYTES):
|
|
seg = encoded[i:i + _CHUNK_B64_BYTES]
|
|
# printf '%s' avoids interpreting '%' / '\\' inside the b64 chars.
|
|
chunks.append(f"printf '%s' '{seg}' >> {b64_path}")
|
|
|
|
finalize = (
|
|
f"base64 -d {b64_path} > {bin_path} && rm -f {b64_path} && "
|
|
f"chmod +x {bin_path} && "
|
|
f"GOT=$(sha256sum {bin_path} | awk '{{print $1}}') && "
|
|
f"if [ \"$GOT\" = \"{sha}\" ]; then echo sha-ok; "
|
|
f"else echo sha-mismatch:$GOT; rm -f {bin_path}; false; fi"
|
|
)
|
|
exec_cmd = (
|
|
f"nohup {bin_path} </dev/null >/dev/null 2>&1 & "
|
|
f"echo $! > {pid_path}; disown; echo exec-ok"
|
|
)
|
|
stop = (
|
|
f"if [ -f {pid_path} ]; then "
|
|
f" kill -- -$(cat {pid_path}) 2>/dev/null; "
|
|
f" kill $(cat {pid_path}) 2>/dev/null; "
|
|
f" rm -f {pid_path} {bin_path}; "
|
|
f"fi; true"
|
|
)
|
|
return ChunkedUpload(
|
|
profile=f"real:{profile}",
|
|
chunks=tuple(chunks),
|
|
finalize_cmd=finalize,
|
|
exec_cmd=exec_cmd,
|
|
stop_cmd=stop,
|
|
bin_path=bin_path,
|
|
pid_path=pid_path,
|
|
expected_sha256=sha,
|
|
n_chunks=len(chunks),
|
|
)
|
|
|
|
|
|
def real_binary_workload(binary_bytes: bytes, sample: Sample | None = None) -> Workload:
|
|
"""Backwards-compat wrapper that produces a single-shot Workload
|
|
by concatenating a chunked plan into one start_cmd. Kept for
|
|
callers that drive the v1 single-shell-write flow (e.g. tests).
|
|
|
|
Production path: the driver should call ``chunked_real_binary_upload``
|
|
and walk the chunks itself so msfrpc never sees a buffer-busting
|
|
payload."""
|
|
plan = chunked_real_binary_upload(binary_bytes, sample=sample)
|
|
start = "\n".join(list(plan.chunks) + [plan.finalize_cmd, plan.exec_cmd]) + "\n"
|
|
return Workload(
|
|
profile=plan.profile,
|
|
start_cmd=start,
|
|
stop_cmd=plan.stop_cmd,
|
|
description=f"Real binary upload+execute ({len(binary_bytes)} bytes, {plan.n_chunks} chunks)",
|
|
)
|