End-to-end now drives a real KVM guest through the full XMRig-shaped
phase schedule with the workload running INSIDE the guest. Telemetry is
host-side /proc/<qemu_pid>; the load is busybox `yes` (sustained CPU
saturation) and `dd if=/dev/urandom` (disk burst on infecting), driven
over the serial console at every phase transition. The plotted envelope
shows clean idle → armed → infecting (disk spike) → infected_running
(100% CPU plateau) → dormant → re-entry → final clean.
Components:
vm/launch_demo.sh now boots Alpine 3.21 nocloud-cloudinit
(Cirros 0.6.x's cirros-init blocks on the
EC2 metadata service for ~17 min before
falling through to NoCloud — abandoned).
Mounts a cidata ISO as a second drive.
tools/build_cidata.py pure-Python NoCloud ISO builder (pycdlib).
Sets root password and ssh_pwauth via
runcmd so we don't depend on a specific
cloud-init version's plain_text_passwd
handling.
tools/vm_serial.py serial-console client (stdlib socket).
Idempotent login (detects already-in-shell
state), sentinel-bracketed run() that
distinguishes shell output from the TTY
echo of input by requiring a leading
\r\n boundary on the marker.
tools/vm_load_controller.py in-guest load controller. set_phase()
dispatches the per-phase shell command
over the serial connection.
tools/run_real_vm_demo.py ties it all together: boot VM, wait for
cloud-init runcmd, log in, run the
EpisodeRunner with on_phase=controller,
shut down VM.
Deps: paramiko, pycdlib added.
docs/sources.md updated with Alpine cloud image (sha512 pinned), and
the new Python deps.
README leads with the tier-2 plot now (real VM, real workload). The
previous synthetic plot is moved below with explicit "host-side mimic,
not a VM" labelling. Tier-2 status flipped to ✅ in the tier table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
2.6 KiB
Python
72 lines
2.6 KiB
Python
"""In-guest load controller for tier-2 episodes.
|
|
|
|
Drives a real Alpine VM through the same phase schedule the orchestrator
|
|
follows, but the load this time is generated *inside* the guest by busybox
|
|
``yes`` / ``dd`` / a small marker file. The host /proc collector still
|
|
samples the qemu-system process from outside — what's "real" here is the
|
|
workload itself, not the orchestrator's view of it.
|
|
|
|
Phase commands (all run via the SerialClient):
|
|
|
|
clean — kill any running load, idle.
|
|
armed — small disk write (handshake-shape).
|
|
infecting — disk burst: 512 KiB urandom write to /tmp/payload.
|
|
infected_running — background ``yes > /dev/null`` for sustained CPU.
|
|
dormant — kill background load (back to idle).
|
|
|
|
Designed to mimic the envelope of an XMRig-class compromise without
|
|
running real malware. Tier-3 will replace this with msf-driven exploit
|
|
fire and a real sample.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from vm_serial import SerialClient
|
|
|
|
|
|
log = logging.getLogger("cis490.vm_load_controller")
|
|
|
|
|
|
class VMLoadController:
|
|
def __init__(self, serial: SerialClient) -> None:
|
|
self.s = serial
|
|
|
|
def setup(self) -> None:
|
|
# Kill any pre-existing load and clear scratch space.
|
|
self._kill_load()
|
|
self.s.run("rm -f /tmp/payload /tmp/armed.log; echo setup-ok")
|
|
|
|
def teardown(self) -> None:
|
|
self._kill_load()
|
|
|
|
# ---- phases ---------------------------------------------------------
|
|
|
|
def set_phase(self, phase: str) -> None:
|
|
log.info("vm phase -> %s", phase)
|
|
if phase == "clean":
|
|
self._kill_load()
|
|
elif phase == "armed":
|
|
self.s.run("echo armed-handshake-$(date +%s) > /tmp/armed.log")
|
|
elif phase == "infecting":
|
|
self.s.run(
|
|
"dd if=/dev/urandom of=/tmp/payload bs=4k count=128 2>/dev/null && "
|
|
"chmod +x /tmp/payload"
|
|
)
|
|
elif phase == "infected_running":
|
|
self._kill_load()
|
|
# Background CPU burner. `nohup` + `&` + redirects to detach.
|
|
self.s.run(
|
|
"nohup sh -c 'yes > /dev/null' </dev/null >/dev/null 2>&1 & disown"
|
|
)
|
|
elif phase == "dormant":
|
|
self._kill_load()
|
|
else:
|
|
log.warning("unknown phase: %s", phase)
|
|
|
|
# ---- internals ------------------------------------------------------
|
|
|
|
def _kill_load(self) -> None:
|
|
# `true` at the end so the run() exit status is always 0.
|
|
self.s.run("pkill yes 2>/dev/null; pkill stress-ng 2>/dev/null; true")
|