Skip to content

Lateral Movement Emulation

Lateral movement emulation is the controlled exercise of the remote-execution and credential-reuse techniques adversaries use to move between hosts — WinRM/PowerShell remoting (T1021.006), PsExec-style service execution (T1021.002), WMI (T1047), scheduled tasks over IPC (T1053/T1021), RDP (T1021.001), DCOM/COM remote calls (T1175 [verify mapping]) — against lab-domain hosts with authorized accounts, so that the east-west telemetry contract of each technique can be validated: which logon types, which process lineages, which network flows, which service events each shape produces. Lateral movement is where the estate’s identity and segmentation controls meet its detection engineering — and it is the surface where “we would see it” claims most often fail, because east-west admin traffic is voluminous, legitimate, and only distinguishable from attack traffic by pairing (which account, from where, to where, doing what). The module’s deliverable is that pairing table, measured, not asserted.

An intrusion’s damage concentrates after lateral movement — the attacker who has moved from one workstation to a file server to a DC has converted a user incident into a domain incident — and the defensive coverage of east-west behavior is structurally weak for two reasons: legitimate admin traffic looks like attack traffic, and the telemetry is scattered across five event sources that most estates never join. Emulating each lateral shape validates the joins: does your SIEM correlate 4624 type 3 logons with the 4688 remote process and the Sysmon 3 flow that caused them? Does the PsExec service event (7045) carry the lineage to the parent host? Does the WinRM remoting session appear in both the source’s PowerShell operational log and the target’s WinRM log, joined by activity ID? The exercise also tests the controls lateral movement depends on defeating: local-admin password uniqueness (does LAPS hold?), tiering boundaries (can a workstation admin reach servers?), and segmentation (does the network isolate admin-plane traffic?) — each emulated path that succeeds is a control finding with a name and an owner. For the SOC, lateral telemetry is the highest-value alerting real estate in the estate (few legitimate reasons for most host-to-host admin patterns), and calibrated emulation is how its rules get built against ground truth instead of guesswork. And for the program, lateral drills quantify BT13’s segmentation promises: the flow matrix before and after an exercise is the honest segmentation picture.

Each lateral technique has a telemetry fingerprint composed of events on both hosts plus the network path:

LATERAL MOVEMENT TELEMETRY CONTRACTS (source -> target)
[WinRM/PS Remoting T1021.006]
source: 4688 (winrm/powershell), PowerShell/Operational
target: 4624 type 3 + 4672 (admin), WinRM/Operational,
4688 remote powershell (parent = wsmprovhost)
network: Sysmon 3 to 5985/5986
[PsExec-class T1021.002]
source: 4688 (psexec), Sysmon 3 (SMB to 445)
target: 4624 type 3, 7045 (PSEXESVC install),
4688 (PSEXESVC child), Sysmon 11 (svc exe drop)
[WMI T1047]
source: 4688 (wmic or API), Sysmon 3 (DCOM 135 + dynamic)
target: 4624 type 3 (Kerberos), 4688 (WmiPrvSE parent!)
Sysmon 1: parent WmiPrvSE = the tell
[Scheduled task over IPC]
source: 4688 (schtasks /s), Sysmon 3 (445)
target: 4624 type 3, 4698 (task created), 4688 task run
[RDP T1021.001]
source: 4648 (explicit creds) or mstsc lineage
target: 4624 type 10, 4778/4779 (session reconn),
Sysmon 3 to 3389
[Credential reuse (PtH shape) T1550.002]
target: 4624 type 3 with NTLM + LogonType 9/NewCredentials
variants, 4648 explicit-credential use
detection anchor: NTLM from workstations = finding

The emulation design pivots on the authorized account matrix: each drill runs a named lab account through a named shape to a named target — and the account choice is itself part of the measurement, because the detections that matter most distinguish which account did the lateraling (a workstation user account remoting to a server is a finding; an admin-tier account from a jump host is baseline). The pairing discipline is the exercise’s analytical core: every shape’s contract lists events on both endpoints plus the flow, and the after-action joins them — if your SIEM cannot join the source’s 4688 with the target’s 4624 and the Sysmon 3 flow in one view, the gap is recorded with the exact fields needed. The control checks ride the same drills: an account whose credentials should not reach a target (tiering violation test) either fails at authentication (control works — 4625 recorded) or succeeds (segmentation/LAPS finding). The cleanliness discipline inherits from T3/T4: EMU- naming for every artifact (remote tasks, temporary services), manifest-driven cleanup, and lab-only scope — because remote execution artifacts on a shared lab domain can compound (a task left on a target outlives the exercise window). The honest complication to plan for: lateral telemetry is noisy in production (SCCM, RMM tools, backup agents all do host-to-host admin), so every rule validated here ships with a baseline-observation requirement — the T4 lesson applied to east-west.

TechniqueSource shapeTarget telemetryDetection anchorLab tool
WinRM / PS remotingwinrs/Invoke-Command4624 T3 + wsmprovhost 4688Parent chain + both-end logswinrs / pwsh
PsExec-classpsexec or hand svc7045 + 4624 T3 + svc child 4688Service name + drop pathpsexec [verify]
WMI execwmic /node4624 T3 + WmiPrvSE parent 4688WmiPrvSE parentagewmic
Remote scheduled taskschtasks /s4698 + 4624 T3Task name + remote creationschtasks
RDPmstsc4624 T10 + 4778/4779Type 10 from workstationmstsc
DCOM (MMC20)COM remote activation4624 T3 + mmc child 4688Unexpected COM activationPowerShell COM
SMB share stagingcopy \\host\share4624 T3 + Sysmon 11 writesAdmin-share writescopy/xcopy
PtH shapePtH tool or explicit creds4624 NTLM + 4648NTLM from workstationlab tooling [verify]
Remote service execsc \\host create7045 + 4624 T3Service install patternssc
Drive mapping stagingnet use + exec4624 T3 + 4688Admin share mount+execnet use

latmove.py — a lateral-movement drill harness for a lab domain: executes authorized remote-execution shapes (WinRM via winrs, WMI via wmic, remote scheduled task via schtasks /s, admin-share write via copy) against declared lab targets with declared lab accounts, records each drill with its full telemetry contract (both endpoints’ expected events), verifies execution success from the target’s OS view, and writes the drill manifest with EMU--named artifacts and their cleanup commands. Windows-lab only, stdlib only.

#!/usr/bin/env python3
"""latmove.py — lateral-movement drill harness (lab domain only).
Authorized accounts, EMU-named artifacts, manifest cleanup.
Usage:
python latmove.py --shape winrm --target 10.0.0.21 \
--user labadmin --domain lab.local [--all]
python latmove.py --cleanup --manifest latmove_manifest.json
Shapes: winrm | wmi | schtask | sharecopy. Windows only.
stdlib only.
"""
import argparse, json, os, subprocess, sys, datetime
MANIFEST = "latmove_manifest.json"
EMU_TAG = "EMU-Lat"
def run(cmd, timeout=60):
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, shell=False)
return r.returncode == 0, (r.stdout + r.stderr).strip()
def manifest():
if os.path.exists(MANIFEST):
return json.load(open(MANIFEST, encoding="utf-8"))
return {"created": [], "drills": []}
def save(m):
m["when"] = datetime.datetime.now(
datetime.timezone.utc).isoformat()
json.dump(m, open(MANIFEST, "w"), indent=2)
CONTRACTS = {
"winrm": {"source": ["4688 winrs", "Sysmon 3 -> 5985"],
"target": ["4624 T3 + 4672", "4688 wsmprovhost",
"WinRM/Operational"]},
"wmi": {"source": ["4688 wmic", "Sysmon 3 -> 135"],
"target": ["4624 T3 Kerberos",
"4688 parent=WmiPrvSE"]},
"schtask": {"source": ["4688 schtasks /s",
"Sysmon 3 -> 445"],
"target": ["4624 T3", "4698 task created",
"4688 task execution"]},
"sharecopy": {"source": ["4688 copy", "Sysmon 3 -> 445"],
"target": ["4624 T3",
"Sysmon 11 file write"]},
}
# ---------------- shapes ----------------
def shape_winrm(target, user, domain, m):
"""Remote echo via winrs; artifact-free."""
cmd = (f'winrs -r:{target} -u:{domain}\\{user} '
f'cmd /c echo {EMU_TAG}-winrm-ok')
ok, out = run(cmd.split())
m["drills"].append({"shape": "winrm", "target": target,
"user": user, "ok": ok,
"contract": CONTRACTS["winrm"]})
return ok, out[:100]
def shape_wmi(target, user, domain, m):
"""Remote process via wmic; logs EMU marker to target temp."""
cmd = (f'wmic /node:{target} /user:{domain}\\{user} '
f'process call create "cmd /c echo {EMU_TAG}-wmi '
f'> %TEMP%\\{EMU_TAG}.log"')
ok, out = run(cmd.split())
m["drills"].append({"shape": "wmi", "target": target,
"user": user, "ok": ok,
"contract": CONTRACTS["wmi"]})
return ok, out[:100]
def shape_schtask(target, user, domain, m):
"""Remote scheduled task EMU-Lat (created + run + removed)."""
task = f"{EMU_TAG}-Task"
mk = (f'schtasks /s {target} /u {domain}\\{user} /create '
f'/tn {task} /sc once /st 23:59 /tr '
f'"cmd /c echo {EMU_TAG} >> %TEMP%\\{EMU_TAG}.log" /f')
ok, out = run(mk.split())
if ok:
run(f'schtasks /s {target} /u {domain}\\{user} /run '
f'/tn {task}'.split())
m["created"].append({
"type": "remote-task", "target": target,
"name": task,
"remove": ["schtasks", "/s", target,
"/u", f"{domain}\\{user}",
"/delete", "/tn", task, "/f"]})
m["drills"].append({"shape": "schtask", "target": target,
"user": user, "ok": ok,
"contract": CONTRACTS["schtask"]})
save(m)
return ok, out[:100]
def shape_sharecopy(target, user, domain, m):
"""Write EMU marker file to target admin share."""
src = os.path.join(os.environ["TEMP"],
f"{EMU_TAG}-payload.txt")
with open(src, "w") as fh:
fh.write(f"{EMU_TAG} share-write marker\n")
dst = f"\\\\{target}\\Admin$\\{EMU_TAG}-payload.txt"
# uses current credentials for the admin-share write
ok, out = run(["cmd", "/c", f'copy /y "{src}" "{dst}"'])
if ok:
m["created"].append({
"type": "remote-file", "target": target,
"path": dst,
"remove": ["cmd", "/c", f'del "{dst}"']})
os.remove(src) if os.path.exists(src) else None
m["drills"].append({"shape": "sharecopy", "target": target,
"user": user, "ok": ok,
"contract": CONTRACTS["sharecopy"]})
save(m)
return ok, out[:100]
def cleanup(m):
results = []
for item in reversed(m["created"]):
ok, out = run(item["remove"])
results.append((item["type"], item.get("name",
item.get("path")),
"removed" if ok else "FAILED"))
return results
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--shape", choices=list(CONTRACTS),
required=True)
ap.add_argument("--target", required=True)
ap.add_argument("--user", required=True)
ap.add_argument("--domain", required=True)
ap.add_argument("--cleanup", action="store_true")
args = ap.parse_args()
m = manifest()
if args.cleanup:
print("[*] cleaning up by manifest:")
for t, name, status in cleanup(m):
print(f" [{status:>8}] {t:<12} {name}")
m["created"] = []
save(m)
return
shapes = {"winrm": shape_winrm, "wmi": shape_wmi,
"schtask": shape_schtask,
"sharecopy": shape_sharecopy}
ok, out = shapes[args.shape](args.target, args.user,
args.domain, m)
print(f"[{'OK' if ok else 'FAIL'}] {args.shape} -> "
f"{args.target}: {out}")
print("[*] telemetry contract to verify (both endpoints):")
for e in CONTRACTS[args.shape]["source"]:
print(f" source: {e}")
for e in CONTRACTS[args.shape]["target"]:
print(f" target: {e}")
save(m)
if __name__ == "__main__":
main()
  • The contracts table (lines 24-38) is the module’s knowledge encoded for the blue role: each shape’s expected events on source and target, named precisely (4624 type 3 + 4672, WmiPrvSE parentage, 7045-class service events). The harness prints the contract after every drill (lines 155-160) so the blue role’s live verification needs no separate document.
  • shape_winrm() (lines 41-50) runs winrs with an echo — the artifact-free shape whose entire value is the both-endpoint logon/execution events; nothing to clean up, nothing to verify beyond the contract.
  • shape_schtask() (lines 63-79) creates the remote task EMU-Lat-Task (once-triggered, echo action), triggers it, and — critically — records the remote deletion command in the manifest at creation time (lines 70-75); remote artifacts are the easiest to forget and the manifest model from T3 carries here unchanged.
  • shape_sharecopy() (lines 82-98) writes a marker file to the target’s Admin$ share using current credentials (the credential-reuse shape), records the remote del for cleanup, and removes its local staging file immediately — the drill’s network-side events (SMB flow + remote file write) are the measurement.
  • shape_wmi() (lines 52-60) drives the WmiPrvSE-parented remote process — the shape whose detection anchor is parentage on the target, one of the most reliable lateral signals in the entire event stream.

Each drill reaches the target host as a real remote administration event — a real logon, a real process, a real task — and the telemetry contract reaches the detection layer as the exercise’s paired measurement: source events, target events, and the flow between, which your SIEM either joins into one alert or doesn’t (the gap, named with fields). The account matrix reaches the identity controls: each drill’s account choice tests tiering (workstation account to server = should fail or alert), local-admin uniqueness (the same local credential on two hosts = LAPS finding), and NTLM posture (any NTLM-based success from a workstation is a finding on a Kerberos estate). The flow matrix reaches BT13’s segmentation program: before/after drill runs produce the east-west admin-port picture that proves whether segmentation isolates the admin plane or merely documents it. The manifest and cleanup reach the lab domain’s hygiene: every remote task and file is EMU--named, removal-commands ride the manifest, and the cleanup pass re-verifies absence — because leftover remote artifacts on a shared domain are the exercise’s own persistence incident. At the program level, the lateral coverage matrix reaches the quarterly review as the metric that matters most: for each shape × account-tier pair, did detection fire — and the honest gaps (usually the WmiPrvSE parentage rule and the 4648 explicit-credential join) become backlog tickets with the exact event pairs attached.

  1. Plan — account matrix: labadmin (tier-0), svc-acct (tier-1), user1 (workstation); targets: 2 member servers + 1 workstation
  2. Baseline — eastwest snapshot: current host-to-host admin flows recorded
  3. WinRM — labadmin -> server A via winrs: 4624 T3 + wsmprovhost 4688 + Sysmon 3 verified live
  4. WMI — svc-acct -> server B: 4624 + WmiPrvSE-parent 4688; detection fires (parentage rule)
  5. Task — user1 -> workstation B remote task: control check — tiering boundary SHOULD alert
  6. Finding — task created + executed, no alert: tiering-violation gap ticketed with event pair
  7. Cleanup — manifest pass removes EMU task + file; absence verified on both targets
  8. Matrix — post-drill flow diff: 6 new admin flows, all EMU-attributed; coverage matrix updated

eastwest.py — the east-west flow analyzer: parses zeek conn.log for internal-to-internal flows on admin-service ports (445 SMB, 5985/5986 WinRM, 3389 RDP, 135 RPC), builds the host-to-host admin matrix, and diffs against a baseline snapshot — simultaneously the lateral drill’s verification view and BT13’s segmentation-drift detector. stdlib only.

  • Admin-port focus. The matrix tracks the four port families that carry lateral movement — everything else is bulk traffic a different tool should own; focus keeps the matrix readable and the diffs meaningful.
  • Baseline diff as first-class output. New host-pairs on admin ports are the signal (new lateral relationships), so the tool’s diff mode — not just the live matrix — is the primary interface.
  • Bidirectional awareness. SMB flows are directional (client→server), but RDP sessions look bidirectional; the tool normalizes by (source, target, port-role) so the matrix doesn’t double-count.
  • Reservation for identity joins. The report leaves a user column empty by design — the next improvement wires host-pairs to 4624 events; pretending to know the user from flow logs alone would be fabrication.
#!/usr/bin/env python3
"""eastwest.py — east-west admin-flow matrix + baseline diff.
Usage:
python eastwest.py snap baseline.json --conn conn.log
python eastwest.py diff baseline.json --conn today.log
Deps: none (zeek conn.log TSV parsing, stdlib).
"""
import argparse, json, sys
from collections import defaultdict
FIELDS = None
ADMIN_PORTS = {"445": "smb", "5985": "winrm", "5986": "winrm-ssl",
"3389": "rdp", "135": "rpc-epm"}
def private(ip):
return (ip.startswith("10.") or
ip.startswith("192.168.") or
(ip.startswith("172.") and
16 <= int(ip.split(".")[1]) <= 31))
def parse_conn(path):
"""Yield (src, dst, port, proto) for internal flows."""
global FIELDS
for line in open(path, encoding="utf-8",
errors="replace"):
if line.startswith("#fields"):
FIELDS = line.rstrip("\n").split("\t")[1:]
continue
if line.startswith("#") or not line.strip():
continue
p = line.rstrip("\n").split("\t")
if not FIELDS:
continue
try:
src = p[FIELDS.index("id.orig_h")]
dst = p[FIELDS.index("id.resp_h")]
port = p[FIELDS.index("id.resp_p")]
proto = p[FIELDS.index("proto")]
except (ValueError, IndexError):
continue
yield src, dst, port, proto
def build_matrix(path):
"""Set of (src, dst, svc) internal admin relationships."""
matrix = set()
counts = defaultdict(int)
for src, dst, port, proto in parse_conn(path):
svc = ADMIN_PORTS.get(port)
if not svc or proto != "tcp":
continue
if not (private(src) and private(dst)):
continue
if svc == "rdp":
# normalize RDP as (client -> server) on 3389
key = (src, dst, svc)
else:
key = (src, dst, svc)
matrix.add(key)
counts[key] += 1
return matrix, counts
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mode", choices=["snap", "diff"])
ap.add_argument("file")
ap.add_argument("--conn", required=True)
ap.add_argument("--json")
args = ap.parse_args()
matrix, counts = build_matrix(args.conn)
if args.mode == "snap":
json.dump({"pairs": sorted("|".join(k) for k in matrix)},
open(args.file, "w"), indent=2)
print(f"[+] {len(matrix)} admin relationships -> "
f"{args.file}")
return
old = json.load(open(args.file, encoding="utf-8"))
old_pairs = set(tuple(p.split("|")) for p in old["pairs"])
new_pairs = matrix - old_pairs
gone = old_pairs - matrix
if not new_pairs:
print("[+] no NEW admin relationships vs baseline")
else:
by_svc = defaultdict(list)
for s, d, svc in new_pairs:
by_svc[svc].append((s, d))
print(f"[!] {len(new_pairs)} NEW admin relationships:")
for svc, pairs in sorted(by_svc.items()):
print(f" [{svc}] {len(pairs)} pairs")
for s, d in sorted(pairs)[:12]:
c = counts.get((s, d, svc), 0)
print(f" {s:<16} -> {d:<16} "
f"({c} sessions)")
if gone:
print(f"[*] {len(gone)} relationships retired "
f"(not seen this window)")
if args.json:
json.dump({"new": sorted("|".join(k) for k in
new_pairs),
"gone": sorted("|".join(k) for k in gone)},
open(args.json, "w"), indent=2)
if __name__ == "__main__":
main()
  • build_matrix() (lines 31-51) filters zeek’s conn.log to internal-to-internal TCP on the four admin-port families and builds the relationship set with per-pair session counts — the flow-side ground truth for both lateral drills and segmentation checks.
  • The diff (lines 74-93) is the tool’s primary value: new admin relationships vs baseline. A lateral drill’s expected artifacts should appear here as EMU-attributable new pairs; unexpected new pairs during the window are the exercise’s incidental findings — and in production operation, the same diff is the segmentation-drift alarm.
  • The retired-relationships note (lines 94-95) keeps the baseline honest — admin relationships do churn (decommissioned servers, moved services), and the report distinguishing “new” from “retired” prevents baseline rot from becoming invisible.
  • The user column decision (design notes + absent from output) is the honesty line: flow logs don’t know accounts, and the tool refuses to guess; the identity join (4624 by host-pair) is improvement #1 precisely because it requires the SIEM side.

lab domain, admin workstation, zeek tapping the lab segment

Section titled “lab domain, admin workstation, zeek tapping the lab segment”

$ python eastwest.py snap baseline.json —conn week1.log [+] 38 admin relationships -> baseline.json

run the lateral drills (each shape, declared account+target)

Section titled “run the lateral drills (each shape, declared account+target)”

$ python latmove.py —shape winrm —target 10.0.0.21
—user labadmin —domain lab.local [OK] winrm -> 10.0.0.21: EMU-Lat-winrm-ok [*] telemetry contract to verify (both endpoints): source: 4688 winrs | Sysmon 3 -> 5985 target: 4624 T3 + 4672 | 4688 wsmprovhost | WinRM/Operational

$ python latmove.py —shape wmi —target 10.0.0.22
—user svc-lab —domain lab.local [OK] wmi -> 10.0.0.22: … $ python latmove.py —cleanup —manifest latmove_manifest.json

next window: diff must show exactly the drill pairs

Section titled “next window: diff must show exactly the drill pairs”

$ python eastwest.py diff baseline.json —conn drillday.log [!] 3 NEW admin relationships: [winrm] 1 pairs 10.0.0.5 -> 10.0.0.21 (12 sessions) [wmi] 1 pairs [smb] 1 pairs

Lateral detections are pair and parentage detections: 4624 type 3 joined with the 4688 whose parent is WmiPrvSE or wsmprovhost; 7045 joined with the source’s 4688; 4648 explicit-credential events joined across hosts. The exercise validates each join exists in your SIEM — and the common finding is that the events exist but the joins don’t, which is a correlation-rule gap rather than a telemetry gap (cheaper to fix, easier to demo). Your own tooling hygiene: latmove.py creates only EMU--named artifacts with manifest cleanup; eastwest.py reads logs only — both quiet on endpoints. The account-matrix discipline extends to alerting: rules should distinguish account tiers (an alert on tier-0 lateral is a different severity than workstation-to-workstation), and the exercise’s account matrix gives each tier a validated example. Wazuh/SIEM mapping of the cross-host joins depends on your forwarding and field normalization (activity IDs, target-user fields) [verify]; Suricata sees the flows (445/5985/3389 patterns) and pairs naturally with the eastwest matrix for NDR-side validation. The standing metric: new-admin-relationship count per week from the diff — near-zero on a well-segmented estate, and the lateral drill is the controlled injection that proves the metric’s plumbing works before you trust it for real.

  1. Identity join: correlate host-pairs with 4624/4648 events from Security logs to fill the user column (this is the join the exercise validates in the SIEM).
  2. Tier tagging: map accounts to tiers from a simple CSV, so the matrix rows read tier-0 -> tier-1 and violations highlight themselves.
  3. RDP session depth: parse 4778/4779 to count reconnects per pair (interactive-lateral signal).
  4. Segmentation-policy check: accept a policy CSV (who may talk to whom) and diff reality vs policy — BT13’s promise, mechanically checked.
  5. Drill-mode: accept a latmove manifest and verify every drill’s flow appears in the matrix — closing the harness-to-analyzer loop automatically.
SignalSourceIDNotes
Type-3 logon from workstationWindows Security4624 (T3) + 4625 failuresJoin with 4688 target-side
WmiPrvSE-parented processSysmonEID 1 (parent image)Highest-fidelity WMI anchor
wsmprovhost / WinRM sessionsSecurity + PowerShell4624, 4104, WinRM op logBoth-endpoint join
Remote service installWindows Security7045PSEXESVC-class naming
Explicit-credential useWindows Security4648Credential-reuse pivot
Admin-share writesSysmonEID 11 (Admin$ paths)Staging signature
Admin-port flow matrixzeek/NetFlow (eastwest)diff vs baselineSegmentation drift alarm
RDP type-10 from workstationWindows Security4624 T10 + 4778/4779Interactive lateral
NTLM where Kerberos expectedWindows Security4624 NTLM fieldsProtocol-posture finding
Wazuh lateral correlationHIDS/SIEM[verify rule IDs per deployment]Cross-host joins per estate

Lateral evasion targets the joins that make east-west detection work, because the individual events are hard to suppress without breaking function. Protocol selection: choosing WinRM or WMI over PsExec avoids the 7045 service-install event entirely — the counter is parentage-based rules (WmiPrvSE, wsmprovhost lineages) that don’t depend on service events. Credential shaping: PtH avoids plaintext password events but requires NTLM logons — the counter is protocol-posture enforcement (disable NTLM where possible; alert on workstation-origin NTLM) which converts the technique into its own detection. Existing-relationship abuse: adversaries who first become the legitimate admin (credential theft) move through relationships the baseline already contains — the counter is the account-tier matrix and identity joins (which account, from which host class), because the flow pair alone is pre-baselined but the account is not. Task-based movement: remote scheduled tasks survive reboots and avoid service events; the counter is 4698 correlation with remote-origin (logon type 3 within minutes) — a pair the drills validate. Hop hygiene: adversaries clean remote artifacts (delete tasks, services) promptly — the counter is deletion-event monitoring (4699, service deletes) as its own signal, since rapid create-and-delete on remote hosts is itself anomalous. LOLbin payloads on the target (rundll32, mshta children under remote parents) blur payload-based rules — the counter stays parentage + account tiering, which payload choice cannot change. The program-level insight the drills encode: lateral detection lives in correlation, and every evasion above attacks one specific join — so the exercise’s job is to make each join exist, measured, and baselined before an adversary needs it to fail.

⚠️ What caught me: our lateral exercise validated beautifully — every drill fired its contract — and in the debrief the infra lead asked why the WMI drill targeted the file server’s backup NIC. My target list had used stale DNS: half the drills had crossed the backup VLAN, whose relaxed segmentation was invisible to the estate’s main monitoring tap. The detections were validated against traffic that production users’ lateral movement would never take. Since then, every lateral drill’s target list is resolved and verified against the production routing table during planning, and the flow matrix gets reviewed for which interface each drill actually crossed — the network path is part of the measurement, not just the endpoints.

  1. Build the account×target matrix (3 accounts × 3 targets) and run each latmove.py shape for the authorized pairs; live-verify every contract event on both endpoints — the pairing table is the module’s core deliverable.
  2. Run the tiering violation test deliberately: workstation account to server via schtasks /s; confirm the drill either fails auth (control works) or succeeds silently (finding) — then write the rule that would have caught it.
  3. Snapshot and diff east-west flows around the drills (eastwest.py snap/diff); confirm the drill pairs appear, then run the diff again on a normal week and measure your real noise floor.
  4. Validate the WmiPrvSE rule specifically: run the WMI shape, confirm the parent-image anchor on the target’s Sysmon 1, and check whether your SIEM’s lateral alert keys on parentage or only on the logon — the most common single gap.
  5. Close the loop with the identity join (improvement #1): correlate one drill’s host-pair with its 4624 events to fill the account column, and file the SIEM normalization gap if the join fails — the exercise’s remaining work, made concrete.
  • Technical: Contrast the telemetry contracts of WinRM, WMI, and PsExec-class lateral movement — which events on which hosts make each detectable?
  • Technical: Why is WmiPrvSE parentage one of the most reliable lateral anchors, and what payload freedom does it still allow the attacker?
  • Scenario: Your lateral drill succeeded through the backup VLAN and no detection fired. What does this reveal, and what changes in drill planning?
  • Conceptual: Why are lateral detections correlation-shaped rather than single-event, and which two joins matter most?
  • Conceptual: How does LAPS and tiering design reduce lateral movement before any detection fires, and which drill validates each control?
  • Deep-dive: Design the east-west baseline program: which ports, what cadence, how new-relationship alerts survive legitimate tool churn (RMM, SCCM), and where identity joins attach.
  • Practical: Leadership asks “could an attacker move laterally undetected?” — what does your validated coverage matrix allow you to answer honestly, and what remain as known gaps?

T1 (ATT&CK Mapping & Emulation Planning) · PE4 (Lateral Movement) · PE14 (WMI & PowerShell Remoting) · PE15 (Pivoting & Tunneling) · BT13 (Network Segmentation)