From 6ec1f6f4babab95353b20051600e56674dfc97af Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Fri, 25 Sep 2026 11:42:47 -0700 Subject: [PATCH 1/5] Close challenge shortcuts and make each challenge test its skill - Give every flag its own random suffix so one flag can't predict the rest - Rework ch8 (real SSH key login), ch9 (private DNS zone), ch10 (only scp/sftp uploads count), ch11 (flag only on port 80), ch13 (trace a cron job's short-lived output) - Close leaks in ch12, ch14, ch16, ch17, ch18 - Close port 8083 in AWS/Azure/GCP firewall rules - Update README descriptions and verify hints - Add shortcut regression checks and new services to the test suite Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 93529270-84bf-485c-99ca-0109bc533d3d --- .github/skills/ctf-testing/SKILL.md | 4 +- .github/skills/ctf-testing/deploy_and_test.sh | 26 ++- .../skills/ctf-testing/test_ctf_challenges.sh | 154 +++++++++++++----- README.md | 16 +- aws/main.tf | 7 - azure/main.tf | 12 -- gcp/main.tf | 2 +- setup/challenges/ch08_ssh_secrets.py | 40 ++++- setup/challenges/ch09_dns.py | 51 +++++- setup/challenges/ch10_remote_upload.py | 52 ++++-- setup/challenges/ch11_web_config.py | 22 ++- setup/challenges/ch12_network_traffic.py | 15 +- setup/challenges/ch13_cron.py | 27 ++- setup/challenges/ch14_process_env.py | 11 +- setup/challenges/ch16_symlinks.py | 25 +-- setup/challenges/ch17_history.py | 20 ++- setup/challenges/ch18_disk_detective.py | 11 +- setup/flags.py | 9 +- setup/system.py | 37 +++++ verify/src/verify/commands.py | 24 +-- 20 files changed, 409 insertions(+), 156 deletions(-) diff --git a/.github/skills/ctf-testing/SKILL.md b/.github/skills/ctf-testing/SKILL.md index b6858ed..a9077d1 100644 --- a/.github/skills/ctf-testing/SKILL.md +++ b/.github/skills/ctf-testing/SKILL.md @@ -67,8 +67,8 @@ Use this skill when the user asks things like: - If cleanup still fails, report the remaining resources clearly. 5. **Report the result plainly.** - Include provider, mode, pass/fail result, cleanup status, and blocker if any. - - A successful basic run shows about 27 tests passing. - - A successful full run includes a second pass after reboot with 5 service checks and 1 progress persistence check. + - A successful basic run shows about 41 tests passing, including shortcut regression checks. + - A successful full run includes a second pass after reboot with 8 service checks and 1 progress persistence check. - Summary line: `RESULT: PASS ()` or `RESULT: FAIL ()`. ## Failure Handling diff --git a/.github/skills/ctf-testing/deploy_and_test.sh b/.github/skills/ctf-testing/deploy_and_test.sh index cfc5e1f..0447ba9 100755 --- a/.github/skills/ctf-testing/deploy_and_test.sh +++ b/.github/skills/ctf-testing/deploy_and_test.sh @@ -488,7 +488,7 @@ _upload_challenge_10_file() { # shellcheck disable=SC2086 _sshpass_cmd ssh ${SSH_OPTS} "${SSH_USER}@${ip}" \ 'for _ in $(seq 1 30); do pgrep -x inotifywait >/dev/null && break; sleep 2; done - : > /tmp/.ctf_upload_triggered; rm -f ~/ctf_challenges/scp_upload_test' || true + rm -f ~/ctf_challenges/scp_upload_test' || true upload_file=$(mktemp) echo "challenge 10 scp upload test" > "${upload_file}" @@ -498,6 +498,29 @@ _upload_challenge_10_file() { rm -f "${upload_file}" } +# Set up SSH key authentication from the local machine and log in with the key +# (challenge 8). The key login is what the VM rewards. +# Arguments: +# $1 - IP address of the VM +_setup_challenge_8_key_login() { + local ip="$1" + local key_dir + + _log INFO "Setting up SSH key authentication for challenge 8..." + key_dir=$(mktemp -d) + ssh-keygen -q -t ed25519 -N '' -f "${key_dir}/id_ed25519" + # Same steps as ssh-copy-id, without needing a local ~/.ssh directory + # shellcheck disable=SC2086 + _sshpass_cmd ssh ${SSH_OPTS} "${SSH_USER}@${ip}" \ + 'umask 077; mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys' < "${key_dir}/id_ed25519.pub" \ + || _log WARN "installing the public key for challenge 8 failed" + # shellcheck disable=SC2086 + ssh ${SSH_OPTS} -i "${key_dir}/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o PasswordAuthentication=no \ + "${SSH_USER}@${ip}" true \ + || _log WARN "key-based login for challenge 8 failed" + rm -rf "${key_dir}" +} + # Copy test script to VM and execute it # Arguments: # $1 - Cloud provider name @@ -514,6 +537,7 @@ _run_tests() { fi _copy_test_script "${provider}" "${ip}" + _setup_challenge_8_key_login "${ip}" _upload_challenge_10_file "${ip}" _log INFO "Running tests on ${provider} VM (${ip})..." diff --git a/.github/skills/ctf-testing/test_ctf_challenges.sh b/.github/skills/ctf-testing/test_ctf_challenges.sh index a51c22c..b3086bb 100644 --- a/.github/skills/ctf-testing/test_ctf_challenges.sh +++ b/.github/skills/ctf-testing/test_ctf_challenges.sh @@ -163,7 +163,7 @@ if [[ "${POST_REBOOT}" == true ]]; then echo "Verifying services survived reboot..." - for service in ctf-secret-service ctf-monitor-directory ctf-ping-message ctf-secret-process nginx; do + for service in ctf-secret-service ctf-monitor-directory ctf-ping-message ctf-secret-process ctf-dns ctf-ssh-key-watch auditd nginx; do if systemctl is-active "${service}" &>/dev/null; then _pass "${service} is running after reboot" else @@ -233,6 +233,50 @@ else _fail "Timer did not start after first numeric verify command" fi +# ============================================================================ +# SHORTCUT REGRESSION CHECKS +# ============================================================================ +# Each check guards against a way to grab a flag without the intended skill. +_section "SHORTCUT REGRESSION CHECKS" + +_no_flag() { + local description="${1}" + local content="${2}" + if echo "${content}" | grep -q 'CTF{'; then + _fail "Shortcut open: ${description}" + else + _pass "Shortcut closed: ${description}" + fi +} + +_no_flag "ch8 flag not planted in ~/.ssh" "$(grep -rah 'CTF{' /home/ctf_user/.ssh 2>/dev/null || true)" +_no_flag "ch9 flag not in resolved config" "$(cat /etc/systemd/resolved.conf.d/* 2>/dev/null || true)" +touch /home/ctf_user/ctf_challenges/local_touch_test +LOCAL_IGNORED=false +for _ in {1..10}; do + grep -q 'ignored local_touch_test' /var/log/monitor_directory.log 2>/dev/null && { LOCAL_IGNORED=true; break; } + sleep 1 +done +rm -f /home/ctf_user/ctf_challenges/local_touch_test +if [[ "${LOCAL_IGNORED}" == true ]]; then + _pass "Shortcut closed: ch10 local file creation is ignored" +else + _fail "Shortcut open: ch10 local file creation was not rejected" +fi +_no_flag "ch11 flag not served on port 8083" "$(curl -s --connect-timeout 3 localhost:8083 2>/dev/null || true)" +_no_flag "ch11 flag page not readable by ctf_user" "$(cat /var/www/ctf/index.html 2>/dev/null || true)" +_no_flag "ch12 flag not in ping script or logs" "$(cat /usr/local/bin/ping_message.sh /var/log/ping_message.log 2>/dev/null; grep -o 'PATTERN: 0x[0-9a-f]*' /var/log/ping_message.log 2>/dev/null | cut -c12- | xxd -r -p 2>/dev/null || true)" +_no_flag "ch13 flag not in cron files" "$(cat /etc/cron.d/* /usr/local/bin/ctf_status_report.sh 2>/dev/null || true)" +_no_flag "ch14 flag not exposed by systemctl" "$(systemctl cat ctf-secret-process.service 2>/dev/null; systemctl show ctf-secret-process.service 2>/dev/null || true)" +_no_flag "ch16 cat follow_me does not print the flag" "$(cat /home/ctf_user/ctf_challenges/follow_me 2>/dev/null || true)" +_no_flag "ch17 history unreadable without sudo" "$(cat /home/old_admin/.bash_history 2>/dev/null || true)" +_no_flag "ch18 disk image unreadable without sudo" "$(grep -ao 'CTF{[^}]*}' /opt/ctf_disk.img 2>/dev/null || true)" +if getent hosts ubuntu.com >/dev/null 2>&1; then + _pass "Normal DNS resolution still works with the ch9 resolver" +else + _fail "Normal DNS resolution broken by the ch9 resolver - SETUP BUG" +fi + # ============================================================================ # CHALLENGE DISCOVERY AND SOLVING # ============================================================================ @@ -341,8 +385,8 @@ echo "Challenge 6: Service Discovery" FLAG_6="" for port in $(ss -tulpn 2>/dev/null \ | awk '/LISTEN/ {split($5,a,":"); print a[length(a)]}' \ - | grep -vE '^(22|80|443|8083)$' \ - | head -3); do + | grep -vE '^(22|53|54|80|443|8083)$' \ + | sort -u); do FLAG_6=$(curl -s --connect-timeout 3 "localhost:${port}" 2>/dev/null \ | grep -ao 'CTF{[^}]*}' \ | head -1) || true @@ -376,42 +420,44 @@ else FLAGS[7]="" fi -# Challenge 8: SSH Secrets -# Hint: "SSH configurations often hide secrets. Explore ~/.ssh thoroughly" -echo "Challenge 8: SSH Secrets" -FLAG_8="" -while IFS= read -r -d '' f; do - FLAG_8=$(grep -ao 'CTF{[^}]*}' "${f}" 2>/dev/null | head -1) || true - [[ -n "${FLAG_8}" ]] && break -done < <(find /home/ctf_user/.ssh -type f -print0 2>/dev/null) +# Challenge 8: SSH Key Authentication +# Hint: "Create a key pair, ssh-copy-id it, log in with the key, watch the banner" +# deploy_and_test.sh performs the key setup and key login from the local machine +# before this script runs; the login banner should now show the flag. +echo "Challenge 8: SSH Key Authentication" +FLAG_8=$(bash -lc true 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true if [[ -n "${FLAG_8}" ]]; then - _verify_flag 8 "${FLAG_8}" + _verify_flag 8 "${FLAG_8}" "Solved challenge 8" "Challenge 8: Found flag but verify rejected it - SETUP BUG" else - _fail "Challenge 8: Could not find flag in .ssh directory" + _fail "Challenge 8: key login did not reveal the flag in the login banner - SETUP BUG" FLAGS[8]="" fi # Challenge 9: DNS Inspection -# Hint: "Inspect systemd-resolved configuration safely" +# Hint: "resolvectl status shows which server handles which domain; query its TXT record" echo "Challenge 9: DNS Inspection" -DNS_DROP_IN="/etc/systemd/resolved.conf.d/ctf-dns.conf" -if [[ -r "${DNS_DROP_IN}" ]]; then - FLAG_9=$(grep -ao 'CTF{[^}]*}' "${DNS_DROP_IN}" 2>/dev/null | head -1) || true +DNS_DOMAIN=$(resolvectl status 2>/dev/null \ + | grep -oE '~[a-z0-9.-]+' \ + | grep -v '^~\.$' \ + | tr -d '~' \ + | head -1) || true +if [[ -n "${DNS_DOMAIN}" ]]; then + FLAG_9=$(dig +short TXT "${DNS_DOMAIN}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true if [[ -n "${FLAG_9}" ]]; then _verify_flag 9 "${FLAG_9}" "Solved challenge 9" "Challenge 9: Found flag but verify rejected it - SETUP BUG" else - _fail "Challenge 9: DNS drop-in has no CTF flag - SETUP BUG" + _fail "Challenge 9: TXT lookup for ${DNS_DOMAIN} returned no flag - SETUP BUG" FLAGS[9]="" fi else - _fail "Challenge 9: DNS drop-in not readable - SETUP BUG" + _fail "Challenge 9: No routing domain found in resolvectl status - SETUP BUG" FLAGS[9]="" fi # Challenge 10: Remote Upload # Hint: "Run scp from your own computer into ~/ctf_challenges/" # deploy_and_test.sh uploads a file with scp from the local machine before this -# script runs, so the flag should already be in the trigger file. +# script runs, so the login banner should now show the flag. echo "Challenge 10: Remote Upload" if ! systemctl is-active ctf-monitor-directory.service &>/dev/null; then _fail "Challenge 10: Monitor service not running - SETUP BUG" @@ -419,12 +465,12 @@ if ! systemctl is-active ctf-monitor-directory.service &>/dev/null; then else FLAG_10="" for _ in {1..10}; do - FLAG_10=$(grep -ao 'CTF{[^}]*}' /tmp/.ctf_upload_triggered 2>/dev/null | head -1) || true + FLAG_10=$(bash -lc true 2>/dev/null | grep 'Challenge 10' | grep -ao 'CTF{[^}]*}' | head -1) || true [[ -n "${FLAG_10}" ]] && break sleep 2 done rm -f /home/ctf_user/ctf_challenges/scp_upload_test - + if [[ -n "${FLAG_10}" ]]; then _verify_flag 10 "${FLAG_10}" "Solved challenge 10" "Challenge 10: Found flag but verify rejected it - SETUP BUG" else @@ -434,24 +480,25 @@ else fi # Challenge 11: Web Configuration -# Hint: "Check what ports nginx is listening on" +# Hint: "Check nginx's port with ss, move it to the standard port, reload" echo "Challenge 11: Web Configuration" -NGINX_PORT=$(grep -r 'listen' /etc/nginx/ 2>/dev/null \ - | grep -oP 'listen\s+\K[0-9]+' \ - | grep -v '^80$' \ - | head -1) || true -if [[ -n "${NGINX_PORT}" ]]; then - FLAG_11=$(curl -s "localhost:${NGINX_PORT}" 2>/dev/null \ +NGINX_SITE=$(grep -RlE 'listen\s+[0-9]+' /etc/nginx/sites-enabled/ 2>/dev/null | head -1) || true +if [[ -n "${NGINX_SITE}" ]]; then + NGINX_SITE=$(readlink -f "${NGINX_SITE}") + echo 'CTFpassword123!' | sudo -S sed -i -E 's/listen(\s+)(\[::\]:)?8083/listen\1\280/' "${NGINX_SITE}" 2>/dev/null + echo 'CTFpassword123!' | sudo -S systemctl reload nginx 2>/dev/null || true + sleep 2 + FLAG_11=$(curl -s "localhost:80" 2>/dev/null \ | grep -ao 'CTF{[^}]*}' \ | head -1) || true if [[ -n "${FLAG_11}" ]]; then _verify_flag 11 "${FLAG_11}" else - _fail "Challenge 11: Could not get flag from nginx" + _fail "Challenge 11: nginx on port 80 did not serve the flag" FLAGS[11]="" fi else - _fail "Challenge 11: Could not find nginx non-standard port" + _fail "Challenge 11: Could not find nginx site config" FLAGS[11]="" fi @@ -479,18 +526,25 @@ else fi # Challenge 13: Cron Job Hunter -# Hint: "Check /etc/cron.d/, /etc/crontab, and user crontabs" +# Hint: "Check /etc/cron.d/; read the script a job runs and work out when its output exists" echo "Challenge 13: Cron Job Hunter" FLAG_13="" -for dir in /etc/cron.d /etc/cron.daily /etc/cron.hourly; do - [[ -d "${dir}" ]] || continue - FLAG_13=$(grep -rh 'CTF{' "${dir}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true - [[ -n "${FLAG_13}" ]] && break -done +CRON_SCRIPT=$(grep -hvE '^\s*(#|$)' /etc/cron.d/* 2>/dev/null \ + | awk 'NF >= 7 {print $7}' \ + | grep '^/usr/local/' \ + | head -1) || true +if [[ -n "${CRON_SCRIPT}" && -r "${CRON_SCRIPT}" ]]; then + REPORT=$(grep -oP '^REPORT=\K\S+' "${CRON_SCRIPT}" | head -1) || true + for _ in {1..40}; do + FLAG_13=$(grep -ao 'CTF{[^}]*}' "${REPORT}" 2>/dev/null | head -1) || true + [[ -n "${FLAG_13}" ]] && break + sleep 2 + done +fi if [[ -n "${FLAG_13}" ]]; then _verify_flag 13 "${FLAG_13}" else - _fail "Challenge 13: Could not find flag in cron directories" + _fail "Challenge 13: Could not catch the cron job's output" FLAGS[13]="" fi @@ -540,13 +594,11 @@ else fi # Challenge 16: Symbolic Sleuth -# Hint: "Use 'readlink -f' to find the final target" +# Hint: "Use 'readlink -f' to find the final target; the path can matter" echo "Challenge 16: Symbolic Sleuth" FLAG_16="" while IFS= read -r -d '' link; do - TARGET=$(readlink -f "${link}" 2>/dev/null) || true - [[ -r "${TARGET}" ]] || continue - FLAG_16=$(grep -ao 'CTF{[^}]*}' "${TARGET}" 2>/dev/null | head -1) || true + FLAG_16=$(readlink -f "${link}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true [[ -n "${FLAG_16}" ]] && break done < <(find /home/ctf_user/ctf_challenges -type l -print0 2>/dev/null) if [[ -n "${FLAG_16}" ]]; then @@ -557,14 +609,16 @@ else fi # Challenge 17: History Mystery -# Hint: "Bash stores history in ~/.bash_history. Other users may have history too" +# Hint: "Other users' history files are private, but you have sudo" echo "Challenge 17: History Mystery" FLAG_17="" for home in /home/*; do user=$(basename "${home}") [[ "${user}" == "ctf_user" ]] && continue - [[ -r "${home}/.bash_history" ]] || continue - FLAG_17=$(grep -ao 'CTF{[^}]*}' "${home}/.bash_history" 2>/dev/null | head -1) || true + [[ -e "${home}/.bash_history" ]] || continue + FLAG_17=$(echo 'CTFpassword123!' | sudo -S cat "${home}/.bash_history" 2>/dev/null \ + | grep -ao 'CTF{[^}]*}' \ + | head -1) || true [[ -n "${FLAG_17}" ]] && break done if [[ -n "${FLAG_17}" ]]; then @@ -599,6 +653,16 @@ else FLAGS[18]="" fi +SUFFIXES=$(for n in "${!FLAGS[@]}"; do + [[ "${n}" == "0" || -z "${FLAGS[${n}]}" ]] && continue + echo "${FLAGS[${n}]}" | grep -oE '_[0-9a-f]+\}$' +done | sort) +if [[ -n "${SUFFIXES}" && "$(echo "${SUFFIXES}" | wc -l)" == "$(echo "${SUFFIXES}" | sort -u | wc -l)" ]]; then + _pass "Every flag has its own random suffix" +else + _fail "Flags share suffixes - one flag predicts the others" +fi + # ============================================================================ # VERIFICATION TOKEN TEST # ============================================================================ diff --git a/README.md b/README.md index f2a87a4..abee451 100644 --- a/README.md +++ b/README.md @@ -16,19 +16,19 @@ Test your Linux command line skills with 18 progressive Capture The Flag challen | 3 | The Largest Log | Find and read an unusually large file in `/var/log` | ⭐⭐ | File sizes, log navigation | | 4 | The User Detective | Another user has a flag in their login configuration | ⭐⭐ | User management, UIDs | | 5 | The Permissive File | Find a suspicious file with wide-open permissions under `/opt` | ⭐⭐ | Permissions | -| 6 | The Hidden Service | Something is listening on port 8080. Connect to it | ⭐⭐ | Networking, ports | +| 6 | The Hidden Service | Something is listening on a network port. Find it and connect to it | ⭐⭐ | Networking, ports | | 7 | The Encoded Secret | Find and decode an encoded flag in `ctf_challenges` | ⭐⭐ | Base64, encoding | -| 8 | SSH Key Authentication | Configure SSH key authentication and find a hidden flag | ⭐⭐ | SSH configuration | -| 9 | DNS Inspection | Inspect the system DNS configuration without changing live resolver files | ⭐⭐ | DNS, `systemd-resolved` | -| 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag | ⭐⭐ | File transfer, SCP | -| 11 | Web Configuration | The web server is running on a non-standard port. Find and fix it | ⭐⭐ | Nginx, services | +| 8 | SSH Key Authentication | From your own computer, set up SSH key authentication and log in with your key to reveal the flag | ⭐⭐ | SSH keys, `ssh-keygen` | +| 9 | DNS Inspection | The system sends one internal domain to a private DNS server. Find the domain and query its records | ⭐⭐ | DNS, `resolvectl`, `dig` | +| 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag. Files created on the VM don't count | ⭐⭐ | File transfer, SCP | +| 11 | Web Configuration | The web server is running on a non-standard port. Move it back to the standard HTTP port to see the flag | ⭐⭐ | Nginx, services | | 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets | ⭐⭐⭐ | Packet inspection, tcpdump | -| 13 | Cron Job Hunter | A scheduled task contains a hidden flag. Find and read it | ⭐⭐ | Cron, scheduling | +| 13 | Cron Job Hunter | A scheduled task briefly publishes a secret. Find the job, work out what it does, and catch it | ⭐⭐ | Cron, scheduling | | 14 | Process Environment | A running process has a secret in its environment. Extract it | ⭐⭐⭐ | `/proc`, environment vars | | 15 | Archive Archaeologist | A flag is buried inside nested archives. Dig it out | ⭐⭐ | tar, gzip, archives | | 16 | Symbolic Sleuth | Follow the trail of symbolic links to find the flag | ⭐⭐ | Symlinks, `readlink` | -| 17 | History Mystery | Someone typed a flag in their command history. Find it | ⭐⭐ | Bash history | -| 18 | Disk Detective | A flag is hidden in filesystem metadata. Investigate mounted filesystems | ⭐⭐⭐ | Disk images, mounting | +| 17 | History Mystery | A former admin typed a password on the command line. Find it in their shell history | ⭐⭐ | Bash history, `sudo` | +| 18 | Disk Detective | A flag is hidden inside a disk image. Mount it and investigate | ⭐⭐⭐ | Disk images, mounting | **Difficulty:** ⭐ Beginner | ⭐⭐ Intermediate | ⭐⭐⭐ Advanced diff --git a/aws/main.tf b/aws/main.tf index 6a4aab4..4c45ea2 100644 --- a/aws/main.tf +++ b/aws/main.tf @@ -220,13 +220,6 @@ resource "aws_security_group" "ctf_sg" { cidr_blocks = ["0.0.0.0/0"] } - ingress { - from_port = 8083 - to_port = 8083 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - egress { from_port = 0 to_port = 0 diff --git a/azure/main.tf b/azure/main.tf index e5c5723..481049a 100644 --- a/azure/main.tf +++ b/azure/main.tf @@ -222,18 +222,6 @@ resource "azurerm_network_security_group" "ctf_nsg" { source_address_prefix = "*" destination_address_prefix = "*" } - - security_rule { - name = "CTF-Nginx" - priority = 1004 - direction = "Inbound" - access = "Allow" - protocol = "Tcp" - source_port_range = "*" - destination_port_range = "8083" - source_address_prefix = "*" - destination_address_prefix = "*" - } } # Create a network interface diff --git a/gcp/main.tf b/gcp/main.tf index d9b3fc7..295a04f 100644 --- a/gcp/main.tf +++ b/gcp/main.tf @@ -185,7 +185,7 @@ resource "google_compute_firewall" "ctf_firewall_http" { allow { protocol = "tcp" - ports = ["80", "8080", "8083"] + ports = ["80", "8080"] } source_ranges = ["0.0.0.0/0"] diff --git a/setup/challenges/ch08_ssh_secrets.py b/setup/challenges/ch08_ssh_secrets.py index 25b6d7c..7381a04 100644 --- a/setup/challenges/ch08_ssh_secrets.py +++ b/setup/challenges/ch08_ssh_secrets.py @@ -1,12 +1,38 @@ from __future__ import annotations -from pathlib import Path - -from helpers import recursive_chown, write_file +from helpers import enable_service, write_executable, write_file, write_service def setup(flags: dict[int, str]) -> None: - flag_path = "/home/ctf_user/.ssh/secrets/backup/.authorized_keys" - write_file(flag_path, f"{flags[8]}\n", mode=0o600) - recursive_chown("/home/ctf_user/.ssh", "ctf_user", "ctf_user") - Path("/home/ctf_user/.ssh").chmod(0o700) + write_file("/etc/ctf/flag_8", f"{flags[8]}\n", mode=0o600) + # Watch sshd's own journal entries (matched on the trusted _COMM field, so + # `logger` can't fake them) and award the flag on the first key-based login. + write_executable( + "/usr/local/bin/ctf_ssh_key_watch.sh", + """#!/bin/bash +journalctl -f -n 0 -o cat _COMM=sshd _COMM=sshd-session | while read -r LINE; do + case "$LINE" in + "Accepted publickey for ctf_user "*) + install -o ctf_user -g ctf_user -m 600 /etc/ctf/flag_8 /var/lib/ctf-rewards/flag_8 + ;; + esac +done +""", + ) + write_service( + "ctf-ssh-key-watch.service", + """[Unit] +Description=CTF SSH Key Authentication Challenge +After=systemd-journald.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/ctf_ssh_key_watch.sh +Restart=always +RestartSec=1 + +[Install] +WantedBy=multi-user.target +""", + ) + enable_service("ctf-ssh-key-watch.service") diff --git a/setup/challenges/ch09_dns.py b/setup/challenges/ch09_dns.py index 7caf2be..5e17d20 100644 --- a/setup/challenges/ch09_dns.py +++ b/setup/challenges/ch09_dns.py @@ -1,17 +1,56 @@ from __future__ import annotations -from helpers import write_file +from helpers import enable_service, restart_service, write_file, write_service + + +DNS_ADDRESS = "127.0.0.2" +ZONE = "ctf.internal" def setup(flags: dict[int, str]) -> None: + # A private resolver answers for the lab zone. Its config (and the flag) is + # root-only; learners find the zone through systemd-resolved and query it. write_file( - "/etc/systemd/resolved.conf.d/ctf-dns.conf", - f"""# CTF Challenge 9: DNS inspection -# The live resolver file is intentionally left untouched. -# FLAG: {flags[9]} + "/etc/ctf/dns.conf", + f"""port=53 +listen-address={DNS_ADDRESS} +bind-interfaces +no-resolv +no-hosts +user=nobody +group=nogroup +local=/{ZONE}/ +txt-record={ZONE},"{flags[9]}" +""", + mode=0o600, + ) + write_service( + "ctf-dns.service", + """[Unit] +Description=CTF Internal DNS Challenge +After=network.target +Before=systemd-resolved.service + +[Service] +Type=simple +ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/etc/ctf/dns.conf --pid-file +Restart=always +RestartSec=1 +[Install] +WantedBy=multi-user.target +""", + ) + enable_service("ctf-dns.service") + + # A route-only domain keeps every other lookup on the normal upstream resolver. + write_file( + "/etc/systemd/resolved.conf.d/ctf-dns.conf", + f"""# Lab internal DNS: only names under {ZONE} are sent to this server. [Resolve] -# This drop-in is harmless. It exists so learners can inspect systemd-resolved config safely. +DNS={DNS_ADDRESS} +Domains=~{ZONE} """, mode=0o644, ) + restart_service("systemd-resolved") diff --git a/setup/challenges/ch10_remote_upload.py b/setup/challenges/ch10_remote_upload.py index f10f141..5b87786 100644 --- a/setup/challenges/ch10_remote_upload.py +++ b/setup/challenges/ch10_remote_upload.py @@ -1,21 +1,44 @@ from __future__ import annotations -from helpers import enable_service, write_executable, write_file, write_service +from helpers import enable_service, run, write_executable, write_file, write_service + + +DIRECTORY = "/home/ctf_user/ctf_challenges" def setup(flags: dict[int, str]) -> None: write_file("/etc/ctf/flag_10", f"{flags[10]}\n", mode=0o600) + + # auditd records which program created each file, so a file made on the VM + # itself (touch, editors, cp) doesn't count - only scp/sftp from outside. + write_file( + "/etc/audit/rules.d/ctf-upload.rules", + f"""-a always,exit -F dir={DIRECTORY} -F perm=wa -F exe=/usr/lib/openssh/sftp-server -k ctf_upload +-a always,exit -F dir={DIRECTORY} -F perm=wa -F exe=/usr/bin/scp -k ctf_upload +""", + mode=0o640, + ) + run(["augenrules", "--load"]) + write_executable( "/usr/local/bin/monitor_directory.sh", - """#!/bin/bash -DIRECTORY="/home/ctf_user/ctf_challenges" -FLAG=$(cat /etc/ctf/flag_10) + f"""#!/bin/bash +DIRECTORY="{DIRECTORY}" while [ ! -f /var/lib/cloud/instance/ctf-setup.done ]; do sleep 5 done -sleep 10 -touch /tmp/.ctf_upload_triggered 2>/dev/null || true -chmod 666 /tmp/.ctf_upload_triggered 2>/dev/null || true + +uploaded_remotely() {{ + for _ in $(seq 1 10); do + # --input-logs: ausearch otherwise reads stdin, which is the inotify stream here + if ausearch --input-logs -k ctf_upload -ts recent -i /dev/null | grep -qF "$1"; then + return 0 + fi + sleep 0.5 + done + return 1 +}} + LAST_TRIGGER=0 inotifywait -m -e create --format '%f' "$DIRECTORY" | while read -r FILE do @@ -23,20 +46,23 @@ def setup(flags: dict[int, str]) -> None: case "$FILE" in .*.sw? | *~ | 4913) continue ;; esac + if ! uploaded_remotely "$FILE"; then + echo "$(date -Is) ignored $FILE: not created by scp/sftp" + continue + fi # Show one banner per upload burst (e.g. scp of several files) NOW=$(date +%s) if [ $((NOW - LAST_TRIGGER)) -lt 5 ]; then continue fi LAST_TRIGGER=$NOW - { + install -o ctf_user -g ctf_user -m 600 /etc/ctf/flag_10 /var/lib/ctf-rewards/flag_10 + {{ printf '\\n========== CHALLENGE 10: REMOTE UPLOAD ==========' printf '\\nA new file was uploaded to %s.' "$DIRECTORY" - printf '\\nHere is your flag: %s' "$FLAG" + printf '\\nHere is your flag: %s' "$(cat /etc/ctf/flag_10)" printf '\\n==================================================\\n' - } | wall - echo "$FLAG" > /tmp/.ctf_upload_triggered - sync + }} | wall done """, ) @@ -44,7 +70,7 @@ def setup(flags: dict[int, str]) -> None: "ctf-monitor-directory.service", """[Unit] Description=CTF Directory Monitor Challenge -After=local-fs.target +After=local-fs.target auditd.service [Service] Type=simple diff --git a/setup/challenges/ch11_web_config.py b/setup/challenges/ch11_web_config.py index 2ced587..94afe87 100644 --- a/setup/challenges/ch11_web_config.py +++ b/setup/challenges/ch11_web_config.py @@ -2,17 +2,37 @@ from pathlib import Path -from helpers import restart_service, write_file +from helpers import recursive_chown, restart_service, write_file def setup(flags: dict[int, str]) -> None: + # Port 8083 serves a decoy; the flag page is only served once nginx is + # moved back to port 80. The flag page is readable only by www-data. write_file( "/var/www/html/index.html", + '

This site is running on the wrong port.

\n', + ) + write_file( + "/var/www/ctf/index.html", f'

Flag value: {flags[11]}

\n', + mode=0o600, + ) + recursive_chown("/var/www/ctf", "www-data", "www-data") + Path("/var/www/ctf").chmod(0o700) + + write_file( + "/etc/nginx/conf.d/ctf-web.conf", + """map $server_port $ctf_site_root { + 80 /var/www/ctf; + default /var/www/html; +} +""", + mode=0o644, ) nginx_default = Path("/etc/nginx/sites-available/default") content = nginx_default.read_text() content = content.replace("listen 80 default_server;", "listen 8083 default_server;") content = content.replace("listen [::]:80 default_server;", "listen [::]:8083 default_server;") + content = content.replace("root /var/www/html;", "root $ctf_site_root;", 1) nginx_default.write_text(content) restart_service("nginx") diff --git a/setup/challenges/ch12_network_traffic.py b/setup/challenges/ch12_network_traffic.py index 26f9df1..67b54fe 100644 --- a/setup/challenges/ch12_network_traffic.py +++ b/setup/challenges/ch12_network_traffic.py @@ -1,15 +1,18 @@ from __future__ import annotations -from helpers import enable_service, write_executable, write_service +from helpers import enable_service, write_executable, write_file, write_service def setup(flags: dict[int, str]) -> None: - flag_hex = flags[12].encode().hex() + write_file("/etc/ctf/flag_12", flags[12], mode=0o600) + # The pattern is built at runtime from a root-only file and ping's output is + # discarded, so the flag only exists on the wire. write_executable( "/usr/local/bin/ping_message.sh", - f"""#!/bin/bash + """#!/bin/bash +PATTERN=$(od -An -tx1 /etc/ctf/flag_12 | tr -d ' \\n') while true; do - ping -p {flag_hex} -c 1 127.0.0.1 + ping -q -p "$PATTERN" -c 1 127.0.0.1 >/dev/null 2>&1 sleep 1 done """, @@ -25,8 +28,8 @@ def setup(flags: dict[int, str]) -> None: ExecStart=/usr/local/bin/ping_message.sh Restart=always RestartSec=1 -StandardOutput=append:/var/log/ping_message.log -StandardError=append:/var/log/ping_message.log +StandardOutput=null +StandardError=null [Install] WantedBy=multi-user.target diff --git a/setup/challenges/ch13_cron.py b/setup/challenges/ch13_cron.py index 9535c54..1a9afb6 100644 --- a/setup/challenges/ch13_cron.py +++ b/setup/challenges/ch13_cron.py @@ -1,15 +1,30 @@ from __future__ import annotations -from helpers import write_file +from helpers import write_executable, write_file def setup(flags: dict[int, str]) -> None: + # The flag is only published for a short window each minute, so learners + # have to read the job, follow it to its script, and work out the timing. + write_file("/etc/ctf/flag_13", f"{flags[13]}\n", mode=0o600) + write_executable( + "/usr/local/bin/ctf_status_report.sh", + """#!/bin/bash +# Publishes a short-lived status report, then cleans up after itself. +REPORT=/var/tmp/ctf_status_report.txt +{ + echo "Status report generated $(date)" + echo "Access code: $(cat /etc/ctf/flag_13)" +} > "$REPORT" +chmod 644 "$REPORT" +sleep 20 +rm -f "$REPORT" +""", + ) write_file( - "/etc/cron.d/ctf_secret_task", - f"""# CTF Challenge - Secret scheduled task -# This task runs every minute but the flag is hidden here -# FLAG: {flags[13]} -* * * * * root /bin/true + "/etc/cron.d/ctf_status_report", + """# Generates the lab status report +* * * * * root /usr/local/bin/ctf_status_report.sh """, mode=0o644, ) diff --git a/setup/challenges/ch14_process_env.py b/setup/challenges/ch14_process_env.py index 4c1add9..d963666 100644 --- a/setup/challenges/ch14_process_env.py +++ b/setup/challenges/ch14_process_env.py @@ -4,13 +4,12 @@ def setup(flags: dict[int, str]) -> None: - write_file("/etc/ctf/flag_14", f"{flags[14]}\n", mode=0o600) + # systemd reads EnvironmentFile= as root, so the flag never appears in the + # unit file or `systemctl show`; it only lives in the process environment. + write_file("/etc/ctf/flag_14.env", f"CTF_SECRET_FLAG={flags[14]}\n", mode=0o600) write_executable( "/usr/local/bin/ctf_secret_process.sh", """#!/bin/bash -if [ -r /etc/ctf/flag_14 ]; then - export CTF_SECRET_FLAG=$(cat /etc/ctf/flag_14) -fi while true; do sleep 3600 done @@ -18,7 +17,7 @@ def setup(flags: dict[int, str]) -> None: ) write_service( "ctf-secret-process.service", - f"""[Unit] + """[Unit] Description=CTF Secret Process Challenge After=network.target @@ -26,7 +25,7 @@ def setup(flags: dict[int, str]) -> None: Type=simple User=ctf_user Group=ctf_user -Environment="CTF_SECRET_FLAG={flags[14]}" +EnvironmentFile=/etc/ctf/flag_14.env ExecStart=/usr/local/bin/ctf_secret_process.sh Restart=always RestartSec=1 diff --git a/setup/challenges/ch16_symlinks.py b/setup/challenges/ch16_symlinks.py index 4b1fbdc..cc9b240 100644 --- a/setup/challenges/ch16_symlinks.py +++ b/setup/challenges/ch16_symlinks.py @@ -5,20 +5,25 @@ from helpers import write_file +SECRETS = Path("/var/lib/ctf/secrets") + + def setup(flags: dict[int, str]) -> None: - write_file("/var/lib/ctf/secrets/deep/hidden/final_flag.txt", f"{flags[16]}\n", mode=0o644) + # The flag is part of the final target's path, not its contents, so + # `cat follow_me` isn't enough - the learner has to resolve the chain. + final_target = SECRETS / "deep" / flags[16] / "end_of_the_trail.txt" + write_file( + final_target, + "You reached the end of the trail. The flag isn't written in this file - look at where it lives.\n", + mode=0o644, + ) links = [ - (Path("/var/lib/ctf/secrets/deep/hidden/final_flag.txt"), Path("/var/lib/ctf/secrets/deep/link3")), - (Path("/var/lib/ctf/secrets/deep/link3"), Path("/var/lib/ctf/secrets/link2")), - (Path("/var/lib/ctf/secrets/link2"), Path("/home/ctf_user/ctf_challenges/follow_me")), + (final_target, SECRETS / "deep" / "link3"), + (SECRETS / "deep" / "link3", SECRETS / "link2"), + (SECRETS / "link2", Path("/home/ctf_user/ctf_challenges/follow_me")), ] for target, link in links: link.unlink(missing_ok=True) link.symlink_to(target) - for directory in ( - Path("/var/lib/ctf"), - Path("/var/lib/ctf/secrets"), - Path("/var/lib/ctf/secrets/deep"), - Path("/var/lib/ctf/secrets/deep/hidden"), - ): + for directory in (Path("/var/lib/ctf"), SECRETS, SECRETS / "deep", final_target.parent): directory.chmod(0o755) diff --git a/setup/challenges/ch17_history.py b/setup/challenges/ch17_history.py index d87faf3..3c79cd8 100644 --- a/setup/challenges/ch17_history.py +++ b/setup/challenges/ch17_history.py @@ -9,14 +9,24 @@ def setup(flags: dict[int, str]) -> None: ensure_user("old_admin") write_file( "/home/old_admin/.bash_history", - f"""# Old admin command history -ls -la -cd /var/log -# Note to self: the secret flag is {flags[17]} + f"""ls -la +cd /var/www/html +sudo systemctl status nginx +sudo tail -n 50 /var/log/nginx/error.log +df -h +free -m +cd ~ +git clone https://github.com/example/inventory-app.git +cd inventory-app +cp .env.example .env +nano .env +mysql -u inventory -p'{flags[17]}' -h 127.0.0.1 inventory -e 'SHOW TABLES;' sudo systemctl restart nginx +crontab -l +history -c exit """, - mode=0o644, + mode=0o600, ) recursive_chown("/home/old_admin", "old_admin", "old_admin") Path("/home/old_admin").chmod(0o755) diff --git a/setup/challenges/ch18_disk_detective.py b/setup/challenges/ch18_disk_detective.py index 2f5e8e5..8f08061 100644 --- a/setup/challenges/ch18_disk_detective.py +++ b/setup/challenges/ch18_disk_detective.py @@ -1,15 +1,20 @@ from __future__ import annotations +from pathlib import Path + from helpers import recursive_chown, run, write_file def setup(flags: dict[int, str]) -> None: - run(["dd", "if=/dev/zero", "of=/opt/ctf_disk.img", "bs=1M", "count=10"]) - run(["mkfs.ext4", "-F", "-L", "ctf_disk", "/opt/ctf_disk.img"]) + image = Path("/opt/ctf_disk.img") + run(["dd", "if=/dev/zero", f"of={image}", "bs=1M", "count=10"]) + run(["mkfs.ext4", "-F", "-L", "ctf_disk", str(image)]) run(["mkdir", "-p", "/mnt/ctf_disk"]) - run(["mount", "-o", "loop", "/opt/ctf_disk.img", "/mnt/ctf_disk"]) + run(["mount", "-o", "loop", str(image), "/mnt/ctf_disk"]) try: write_file("/mnt/ctf_disk/.flag", f"{flags[18]}\n") finally: run(["umount", "/mnt/ctf_disk"]) + # Root-only so the image has to be mounted (with sudo) instead of grepped. + image.chmod(0o600) recursive_chown("/home/ctf_user/ctf_challenges", "ctf_user", "ctf_user") diff --git a/setup/flags.py b/setup/flags.py index 23ba977..2b58dc0 100644 --- a/setup/flags.py +++ b/setup/flags.py @@ -30,17 +30,16 @@ def generate_flags() -> dict[int, str]: - instance_suffix = secrets.token_hex(4) - short_suffix = instance_suffix[:4] - + # Every flag gets its own suffix so one solved flag can't be used to predict the rest. flags: dict[int, str] = {} for challenge_num, flag_base in FLAG_BASES.items(): if challenge_num == 0: flags[challenge_num] = "CTF{example}" elif challenge_num == 12: - flags[challenge_num] = f"CTF{{{flag_base}_{short_suffix}}}" + # ping -p accepts at most 16 pattern bytes, so this flag stays short. + flags[challenge_num] = f"CTF{{{flag_base}_{secrets.token_hex(2)}}}" else: - flags[challenge_num] = f"CTF{{{flag_base}_{instance_suffix}}}" + flags[challenge_num] = f"CTF{{{flag_base}_{secrets.token_hex(4)}}}" return flags diff --git a/setup/system.py b/setup/system.py index d5f3464..84b44a3 100644 --- a/setup/system.py +++ b/setup/system.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil import socket from pathlib import Path @@ -29,6 +30,9 @@ def install_packages() -> None: "inotify-tools", "netcat-openbsd", "tcpdump", + "auditd", + "dnsmasq-base", + "dnsutils", ] apt_get("update") apt_get("install", "-y", *packages) @@ -104,12 +108,44 @@ def configure_shell_profile() -> None: append_line_once("/home/ctf_user/.profile", "/usr/local/bin/check_setup") +def configure_rewards() -> None: + """Flags earned by actions taken from outside the VM (challenges 8 and 10).""" + rewards = Path("/var/lib/ctf-rewards") + rewards.mkdir(parents=True, exist_ok=True) + shutil.chown(rewards, user="ctf_user", group="ctf_user") + rewards.chmod(0o700) + write_file( + "/etc/profile.d/ctf-rewards.sh", + """if [ "$(id -un)" = "ctf_user" ]; then + _ctf_rewards=/var/lib/ctf-rewards + # A key-based login is rewarded asynchronously, so give it a moment to land. + if [ -n "${SSH_USER_AUTH:-}" ] && grep -q '^publickey ' "$SSH_USER_AUTH" 2>/dev/null; then + _ctf_i=0 + while [ ! -f "$_ctf_rewards/flag_8" ] && [ "$_ctf_i" -lt 10 ]; do + sleep 0.3 + _ctf_i=$((_ctf_i + 1)) + done + fi + if [ -f "$_ctf_rewards/flag_8" ]; then + echo "Challenge 8: SSH key login detected. Your flag: $(cat "$_ctf_rewards/flag_8")" + fi + if [ -f "$_ctf_rewards/flag_10" ]; then + echo "Challenge 10: remote upload detected. Your flag: $(cat "$_ctf_rewards/flag_10")" + fi + unset _ctf_rewards _ctf_i +fi +""", + mode=0o644, + ) + + def configure_ssh() -> None: write_file( "/etc/ssh/sshd_config.d/99-ctf-password-auth.conf", """PasswordAuthentication yes KbdInteractiveAuthentication yes ChallengeResponseAuthentication yes +ExposeAuthInfo yes """, mode=0o644, ) @@ -140,6 +176,7 @@ def configure_system() -> None: install_packages() configure_users() configure_shell_profile() + configure_rewards() configure_ssh() configure_motd_support() configure_hostname() diff --git a/verify/src/verify/commands.py b/verify/src/verify/commands.py index e0c2986..259d0bf 100644 --- a/verify/src/verify/commands.py +++ b/verify/src/verify/commands.py @@ -32,7 +32,7 @@ "Permission Analysis", "Service Discovery", "Encoding Challenge", - "SSH Secrets", + "SSH Key Authentication", "DNS Inspection", "Remote Upload Detection", "Web Configuration", @@ -51,20 +51,20 @@ "Use the 'find' command to search for files. Try: find ~ -name '*.txt' 2>/dev/null", "Large log files can hide secrets. Check /var/log and use 'tail' to see the end of files.", "Investigate other users on the system. Check /etc/passwd or use 'getent passwd'.", - "Look for files with unusual permissions. Try: find / -perm 777 2>/dev/null", + "Look for files with unusual permissions. Try: find / -type f -perm 777 2>/dev/null", "What services are running? Use 'netstat -tulpn' or 'ss -tulpn' to find listening ports.", "The flag is encoded. Look for encoded files and use 'base64 -d' to decode.", - "SSH configurations often hide secrets. Explore ~/.ssh directory thoroughly.", - "Modern Ubuntu DNS is usually managed by systemd-resolved. Inspect /etc/resolv.conf, resolvectl status, and /etc/systemd/resolved.conf.d/.", - "Run scp from your own computer, not the VM. The destination must be the ctf_challenges directory (e.g. user@ip:~/ctf_challenges/), and the file must be new - overwriting doesn't count.", - "Web servers serve content from specific directories. Check what ports nginx is listening on.", + "On your own computer, create a key pair with 'ssh-keygen', install the public key on the VM with 'ssh-copy-id', then log in with the key. Watch the login banner.", + "Ubuntu DNS is managed by systemd-resolved. 'resolvectl status' shows which server handles which domain. Once you know the domain, query its TXT record with 'dig' or 'resolvectl query'.", + "Run scp from your own computer, not the VM - files created on the VM itself don't count. The destination must be the ctf_challenges directory (e.g. user@ip:~/ctf_challenges/), and the file must be new. Missed the message? Log in again.", + "Check what port nginx is listening on with 'ss -tlnp', then find its site config in /etc/nginx/. Move it to the standard HTTP port and reload nginx.", "Network traffic can carry hidden messages. Look at ping patterns with tcpdump.", - "Cron jobs run on schedules. Check /etc/cron.d/, /etc/crontab, and user crontabs with 'crontab -l'.", + "Cron jobs run on schedules. Check /etc/cron.d/, /etc/crontab, and 'crontab -l'. Read the script a job runs and work out when its output exists.", "Process info lives in /proc. Each process has a directory with its environment in /proc/PID/environ.", "Archives can be nested. Use 'tar -xzf' or 'gunzip' to extract layers. Check file types with 'file' command.", - "Symlinks can chain together. Use 'readlink -f' to find the final target, or 'ls -la' to see link targets.", - "Bash stores command history in ~/.bash_history. Other users may have history files too.", - "A disk image file exists on the system. Try mounting it with 'sudo mount -o loop ' to explore its contents.", + "Symlinks can chain together. Use 'readlink -f' to find the final target, or 'ls -la' to see each hop. The path can matter more than the contents.", + "Bash stores command history in ~/.bash_history. Other users' history files are private, but you have sudo. Look for secrets typed on the command line.", + "A disk image file exists on the system. Mount it with 'sudo mount -o loop ' and explore it - including hidden files.", ] @@ -220,7 +220,7 @@ def export_certificate(state: CtfState, github_username: str | None) -> int: console.print(" Challenges Completed:") console.print(" * Hidden File Discovery * Service Discovery") console.print(" * Basic File Search * Encoding Challenge") - console.print(" * Log Analysis * SSH Secrets") + console.print(" * Log Analysis * SSH Key Authentication") console.print(" * User Investigation * DNS Inspection") console.print(" * Permission Analysis * Remote Upload Detection") console.print(" * Web Configuration * Network Traffic Analysis") @@ -249,7 +249,7 @@ def export_certificate(state: CtfState, github_username: str | None) -> int: Challenges Completed: * Hidden File Discovery * Service Discovery * Basic File Search * Encoding Challenge - * Log Analysis * SSH Secrets + * Log Analysis * SSH Key Authentication * User Investigation * DNS Inspection * Permission Analysis * Remote Upload Detection * Web Configuration * Network Traffic Analysis From 03683ac155d2c150a1257f6fd31457dca5e7023d Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Sun, 27 Sep 2026 15:04:08 -0400 Subject: [PATCH 2/5] Split challenge-specific concerns out of setup/system.py - Move the login-banner reward plumbing (challenges 8 and 10) into setup/external_rewards.py, including the ExposeAuthInfo sshd drop-in, and have ch08/ch10 use grant_command() instead of hardcoding the path. - Let challenges declare their own apt packages via PACKAGES; system.py now only lists learner tools. - Add CHALLENGE_DIR and DONE_MARKER to helpers.py and use them instead of hardcoded paths (ch10 previously duplicated the done-marker path). - Keep a single CHALLENGES registry in setup/challenges/__init__.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb6ef7ff-cd36-49b9-8051-47b519236889 --- .github/copilot-instructions.md | 5 +- setup/challenges/__init__.py | 48 +++++++------ setup/challenges/ch01_hidden_file.py | 4 +- setup/challenges/ch06_service_discovery.py | 3 + setup/challenges/ch07_encoding.py | 4 +- setup/challenges/ch08_ssh_secrets.py | 5 +- setup/challenges/ch09_dns.py | 1 + setup/challenges/ch10_remote_upload.py | 10 +-- setup/challenges/ch11_web_config.py | 3 + setup/challenges/ch15_archive.py | 4 +- setup/challenges/ch16_symlinks.py | 4 +- setup/challenges/ch18_disk_detective.py | 4 +- setup/external_rewards.py | 58 ++++++++++++++++ setup/helpers.py | 5 ++ setup/main.py | 4 +- setup/system.py | 81 ++++++++-------------- 16 files changed, 153 insertions(+), 90 deletions(-) create mode 100644 setup/external_rewards.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5c6ed06..0d8ee4d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,7 +20,10 @@ This is an **educational Capture The Flag (CTF)** project designed to teach Linu ### Adding a New Challenge -1. Edit `ctf_setup.sh` to add the challenge setup logic +1. Add a `setup/challenges/chNN_.py` module with a `setup(flags)` function and register it in `CHALLENGES` in `setup/challenges/__init__.py` + - List any apt packages the challenge's own setup needs in a module-level `PACKAGES = [...]`; `setup/system.py` only installs learner tools + - Use `CHALLENGE_DIR` and `DONE_MARKER` from `setup/helpers.py` instead of hardcoding paths + - Challenges solved from outside the VM deliver flags at login via `setup/external_rewards.py` 2. Update `README.md` with the challenge description 3. Add test commands to `.github/skills/ctf-testing/test_ctf_challenges.sh` diff --git a/setup/challenges/__init__.py b/setup/challenges/__init__.py index fca8c6f..9b89565 100644 --- a/setup/challenges/__init__.py +++ b/setup/challenges/__init__.py @@ -22,25 +22,33 @@ ) +CHALLENGES = ( + ch01_hidden_file, + ch02_file_search, + ch03_log_analysis, + ch04_user_investigation, + ch05_permissions, + ch06_service_discovery, + ch07_encoding, + ch08_ssh_secrets, + ch09_dns, + ch10_remote_upload, + ch11_web_config, + ch12_network_traffic, + ch13_cron, + ch14_process_env, + ch15_archive, + ch16_symlinks, + ch17_history, + ch18_disk_detective, +) + + +def required_packages() -> list[str]: + """apt packages declared by challenges via an optional PACKAGES list.""" + return sorted({package for module in CHALLENGES for package in getattr(module, "PACKAGES", [])}) + + def setup_all_challenges(flags: dict[int, str]) -> None: - for module in ( - ch01_hidden_file, - ch02_file_search, - ch03_log_analysis, - ch04_user_investigation, - ch05_permissions, - ch06_service_discovery, - ch07_encoding, - ch08_ssh_secrets, - ch09_dns, - ch10_remote_upload, - ch11_web_config, - ch12_network_traffic, - ch13_cron, - ch14_process_env, - ch15_archive, - ch16_symlinks, - ch17_history, - ch18_disk_detective, - ): + for module in CHALLENGES: module.setup(flags) diff --git a/setup/challenges/ch01_hidden_file.py b/setup/challenges/ch01_hidden_file.py index 0d41cd2..e53aadb 100644 --- a/setup/challenges/ch01_hidden_file.py +++ b/setup/challenges/ch01_hidden_file.py @@ -1,7 +1,7 @@ from __future__ import annotations -from helpers import write_file +from helpers import CHALLENGE_DIR, write_file def setup(flags: dict[int, str]) -> None: - write_file("/home/ctf_user/ctf_challenges/.hidden_flag", f"{flags[1]}\n") + write_file(CHALLENGE_DIR / ".hidden_flag", f"{flags[1]}\n") diff --git a/setup/challenges/ch06_service_discovery.py b/setup/challenges/ch06_service_discovery.py index bbe89ac..ce6db0b 100644 --- a/setup/challenges/ch06_service_discovery.py +++ b/setup/challenges/ch06_service_discovery.py @@ -3,6 +3,9 @@ from helpers import enable_service, write_executable, write_file, write_service +PACKAGES = ["netcat-openbsd"] + + def setup(flags: dict[int, str]) -> None: write_file("/etc/ctf/flag_6", f"{flags[6]}\n", mode=0o600) write_executable( diff --git a/setup/challenges/ch07_encoding.py b/setup/challenges/ch07_encoding.py index a49bb55..2be1564 100644 --- a/setup/challenges/ch07_encoding.py +++ b/setup/challenges/ch07_encoding.py @@ -2,10 +2,10 @@ import base64 -from helpers import write_file +from helpers import CHALLENGE_DIR, write_file def setup(flags: dict[int, str]) -> None: first = base64.b64encode(flags[7].encode()) second = base64.b64encode(first).decode() - write_file("/home/ctf_user/ctf_challenges/encoded_flag.txt", f"{second}\n") + write_file(CHALLENGE_DIR / "encoded_flag.txt", f"{second}\n") diff --git a/setup/challenges/ch08_ssh_secrets.py b/setup/challenges/ch08_ssh_secrets.py index 7381a04..d17c992 100644 --- a/setup/challenges/ch08_ssh_secrets.py +++ b/setup/challenges/ch08_ssh_secrets.py @@ -1,5 +1,6 @@ from __future__ import annotations +from external_rewards import grant_command from helpers import enable_service, write_executable, write_file, write_service @@ -9,11 +10,11 @@ def setup(flags: dict[int, str]) -> None: # `logger` can't fake them) and award the flag on the first key-based login. write_executable( "/usr/local/bin/ctf_ssh_key_watch.sh", - """#!/bin/bash + f"""#!/bin/bash journalctl -f -n 0 -o cat _COMM=sshd _COMM=sshd-session | while read -r LINE; do case "$LINE" in "Accepted publickey for ctf_user "*) - install -o ctf_user -g ctf_user -m 600 /etc/ctf/flag_8 /var/lib/ctf-rewards/flag_8 + {grant_command(8)} ;; esac done diff --git a/setup/challenges/ch09_dns.py b/setup/challenges/ch09_dns.py index 5e17d20..dc6e468 100644 --- a/setup/challenges/ch09_dns.py +++ b/setup/challenges/ch09_dns.py @@ -3,6 +3,7 @@ from helpers import enable_service, restart_service, write_file, write_service +PACKAGES = ["dnsmasq-base"] DNS_ADDRESS = "127.0.0.2" ZONE = "ctf.internal" diff --git a/setup/challenges/ch10_remote_upload.py b/setup/challenges/ch10_remote_upload.py index 5b87786..0289a31 100644 --- a/setup/challenges/ch10_remote_upload.py +++ b/setup/challenges/ch10_remote_upload.py @@ -1,9 +1,11 @@ from __future__ import annotations -from helpers import enable_service, run, write_executable, write_file, write_service +from external_rewards import grant_command +from helpers import CHALLENGE_DIR, DONE_MARKER, enable_service, run, write_executable, write_file, write_service -DIRECTORY = "/home/ctf_user/ctf_challenges" +PACKAGES = ["auditd", "inotify-tools"] +DIRECTORY = CHALLENGE_DIR def setup(flags: dict[int, str]) -> None: @@ -24,7 +26,7 @@ def setup(flags: dict[int, str]) -> None: "/usr/local/bin/monitor_directory.sh", f"""#!/bin/bash DIRECTORY="{DIRECTORY}" -while [ ! -f /var/lib/cloud/instance/ctf-setup.done ]; do +while [ ! -f {DONE_MARKER} ]; do sleep 5 done @@ -56,7 +58,7 @@ def setup(flags: dict[int, str]) -> None: continue fi LAST_TRIGGER=$NOW - install -o ctf_user -g ctf_user -m 600 /etc/ctf/flag_10 /var/lib/ctf-rewards/flag_10 + {grant_command(10)} {{ printf '\\n========== CHALLENGE 10: REMOTE UPLOAD ==========' printf '\\nA new file was uploaded to %s.' "$DIRECTORY" diff --git a/setup/challenges/ch11_web_config.py b/setup/challenges/ch11_web_config.py index 94afe87..ee50d06 100644 --- a/setup/challenges/ch11_web_config.py +++ b/setup/challenges/ch11_web_config.py @@ -5,6 +5,9 @@ from helpers import recursive_chown, restart_service, write_file +PACKAGES = ["nginx"] + + def setup(flags: dict[int, str]) -> None: # Port 8083 serves a decoy; the flag page is only served once nginx is # moved back to port 80. The flag page is readable only by www-data. diff --git a/setup/challenges/ch15_archive.py b/setup/challenges/ch15_archive.py index 7d89636..a9510d9 100644 --- a/setup/challenges/ch15_archive.py +++ b/setup/challenges/ch15_archive.py @@ -4,6 +4,8 @@ import tempfile from pathlib import Path +from helpers import CHALLENGE_DIR + def setup(flags: dict[int, str]) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -13,5 +15,5 @@ def setup(flags: dict[int, str]) -> None: archive.add(temp_path / "flag.txt", arcname="flag.txt") with tarfile.open(temp_path / "middle.tar.gz", "w:gz") as archive: archive.add(temp_path / "inner.tar.gz", arcname="inner.tar.gz") - with tarfile.open("/home/ctf_user/ctf_challenges/mystery_archive.tar.gz", "w:gz") as archive: + with tarfile.open(CHALLENGE_DIR / "mystery_archive.tar.gz", "w:gz") as archive: archive.add(temp_path / "middle.tar.gz", arcname="middle.tar.gz") diff --git a/setup/challenges/ch16_symlinks.py b/setup/challenges/ch16_symlinks.py index cc9b240..9a7cbcf 100644 --- a/setup/challenges/ch16_symlinks.py +++ b/setup/challenges/ch16_symlinks.py @@ -2,7 +2,7 @@ from pathlib import Path -from helpers import write_file +from helpers import CHALLENGE_DIR, write_file SECRETS = Path("/var/lib/ctf/secrets") @@ -20,7 +20,7 @@ def setup(flags: dict[int, str]) -> None: links = [ (final_target, SECRETS / "deep" / "link3"), (SECRETS / "deep" / "link3", SECRETS / "link2"), - (SECRETS / "link2", Path("/home/ctf_user/ctf_challenges/follow_me")), + (SECRETS / "link2", CHALLENGE_DIR / "follow_me"), ] for target, link in links: link.unlink(missing_ok=True) diff --git a/setup/challenges/ch18_disk_detective.py b/setup/challenges/ch18_disk_detective.py index 8f08061..3969d6d 100644 --- a/setup/challenges/ch18_disk_detective.py +++ b/setup/challenges/ch18_disk_detective.py @@ -2,7 +2,7 @@ from pathlib import Path -from helpers import recursive_chown, run, write_file +from helpers import CHALLENGE_DIR, recursive_chown, run, write_file def setup(flags: dict[int, str]) -> None: @@ -17,4 +17,4 @@ def setup(flags: dict[int, str]) -> None: run(["umount", "/mnt/ctf_disk"]) # Root-only so the image has to be mounted (with sudo) instead of grepped. image.chmod(0o600) - recursive_chown("/home/ctf_user/ctf_challenges", "ctf_user", "ctf_user") + recursive_chown(CHALLENGE_DIR, "ctf_user", "ctf_user") diff --git a/setup/external_rewards.py b/setup/external_rewards.py new file mode 100644 index 0000000..87dcfe6 --- /dev/null +++ b/setup/external_rewards.py @@ -0,0 +1,58 @@ +"""Login-banner delivery for flags earned by actions taken outside the VM. + +Only challenges 8 (SSH key login) and 10 (remote upload) use this: the learner +solves them from their own computer, so the flag is shown on their next login. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from helpers import write_file + + +REWARDS_DIR = Path("/var/lib/ctf-rewards") + + +def grant_command(challenge: int) -> str: + """Shell command that publishes /etc/ctf/flag_ to the login banner.""" + return f"install -o ctf_user -g ctf_user -m 600 /etc/ctf/flag_{challenge} {REWARDS_DIR}/flag_{challenge}" + + +def configure_external_rewards() -> None: + REWARDS_DIR.mkdir(parents=True, exist_ok=True) + shutil.chown(REWARDS_DIR, user="ctf_user", group="ctf_user") + REWARDS_DIR.chmod(0o700) + + # Exposes $SSH_USER_AUTH so the banner can tell a key-based login apart. + # Takes effect when configure_ssh() restarts sshd. + write_file( + "/etc/ssh/sshd_config.d/99-ctf-expose-auth-info.conf", + "ExposeAuthInfo yes\n", + mode=0o644, + ) + + write_file( + "/etc/profile.d/ctf-rewards.sh", + f"""if [ "$(id -un)" = "ctf_user" ]; then + _ctf_rewards={REWARDS_DIR} + # A key-based login is rewarded asynchronously, so give it a moment to land. + if [ -n "${{SSH_USER_AUTH:-}}" ] && grep -q '^publickey ' "$SSH_USER_AUTH" 2>/dev/null; then + _ctf_i=0 + while [ ! -f "$_ctf_rewards/flag_8" ] && [ "$_ctf_i" -lt 10 ]; do + sleep 0.3 + _ctf_i=$((_ctf_i + 1)) + done + fi + if [ -f "$_ctf_rewards/flag_8" ]; then + echo "Challenge 8: SSH key login detected. Your flag: $(cat "$_ctf_rewards/flag_8")" + fi + if [ -f "$_ctf_rewards/flag_10" ]; then + echo "Challenge 10: remote upload detected. Your flag: $(cat "$_ctf_rewards/flag_10")" + fi + unset _ctf_rewards _ctf_i +fi +""", + mode=0o644, + ) diff --git a/setup/helpers.py b/setup/helpers.py index f3b1b05..399fea3 100644 --- a/setup/helpers.py +++ b/setup/helpers.py @@ -9,6 +9,11 @@ from typing import Iterable +CHALLENGE_DIR = Path("/home/ctf_user/ctf_challenges") +# Written by ctf_setup.sh once setup finishes; keep the path in sync with it. +DONE_MARKER = "/var/lib/cloud/instance/ctf-setup.done" + + def run(command: Iterable[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]: return subprocess.run( list(command), diff --git a/setup/main.py b/setup/main.py index 411a206..c43b3de 100644 --- a/setup/main.py +++ b/setup/main.py @@ -3,7 +3,7 @@ from flags import derive_verification_secret, generate_flags, generate_instance_id, hash_flags from state import write_ctf_state from system import configure_system -from challenges import setup_all_challenges +from challenges import required_packages, setup_all_challenges def main() -> None: @@ -11,7 +11,7 @@ def main() -> None: instance_id = generate_instance_id() verification_secret = derive_verification_secret(instance_id) - configure_system() + configure_system(required_packages()) write_ctf_state(hash_flags(flags), instance_id, verification_secret) setup_all_challenges(flags) diff --git a/setup/system.py b/setup/system.py index 84b44a3..3e04311 100644 --- a/setup/system.py +++ b/setup/system.py @@ -1,14 +1,22 @@ from __future__ import annotations -import shutil import socket from pathlib import Path -from helpers import append_line_once, ensure_user, restart_service, run, set_password, write_file +from external_rewards import configure_external_rewards +from helpers import ( + CHALLENGE_DIR, + DONE_MARKER, + append_line_once, + ensure_user, + restart_service, + run, + set_password, + write_file, +) CTF_PASSWORD = "CTFpassword123!" -DONE_MARKER = "/var/lib/cloud/instance/ctf-setup.done" APT_OPTIONS = [ "-o", "DPkg::Lock::Timeout=120", @@ -21,19 +29,19 @@ def apt_get(*arguments: str) -> None: run(["apt-get", *APT_OPTIONS, *arguments]) -def install_packages() -> None: - packages = [ - "net-tools", - "nmap", - "tree", - "nginx", - "inotify-tools", - "netcat-openbsd", - "tcpdump", - "auditd", - "dnsmasq-base", - "dnsutils", - ] +# Tools learners are expected to use. Packages a challenge needs to run its +# own setup belong in that challenge module's PACKAGES list. +LEARNER_PACKAGES = [ + "net-tools", + "nmap", + "tree", + "tcpdump", + "dnsutils", +] + + +def install_packages(challenge_packages: list[str]) -> None: + packages = sorted({*LEARNER_PACKAGES, *challenge_packages}) apt_get("update") apt_get("install", "-y", *packages) @@ -85,7 +93,7 @@ def configure_motd_support() -> None: def configure_users() -> None: ensure_user("ctf_user", sudo=True) set_password("ctf_user", CTF_PASSWORD) - Path("/home/ctf_user/ctf_challenges").mkdir(parents=True, exist_ok=True) + CHALLENGE_DIR.mkdir(parents=True, exist_ok=True) def configure_shell_profile() -> None: @@ -108,44 +116,12 @@ def configure_shell_profile() -> None: append_line_once("/home/ctf_user/.profile", "/usr/local/bin/check_setup") -def configure_rewards() -> None: - """Flags earned by actions taken from outside the VM (challenges 8 and 10).""" - rewards = Path("/var/lib/ctf-rewards") - rewards.mkdir(parents=True, exist_ok=True) - shutil.chown(rewards, user="ctf_user", group="ctf_user") - rewards.chmod(0o700) - write_file( - "/etc/profile.d/ctf-rewards.sh", - """if [ "$(id -un)" = "ctf_user" ]; then - _ctf_rewards=/var/lib/ctf-rewards - # A key-based login is rewarded asynchronously, so give it a moment to land. - if [ -n "${SSH_USER_AUTH:-}" ] && grep -q '^publickey ' "$SSH_USER_AUTH" 2>/dev/null; then - _ctf_i=0 - while [ ! -f "$_ctf_rewards/flag_8" ] && [ "$_ctf_i" -lt 10 ]; do - sleep 0.3 - _ctf_i=$((_ctf_i + 1)) - done - fi - if [ -f "$_ctf_rewards/flag_8" ]; then - echo "Challenge 8: SSH key login detected. Your flag: $(cat "$_ctf_rewards/flag_8")" - fi - if [ -f "$_ctf_rewards/flag_10" ]; then - echo "Challenge 10: remote upload detected. Your flag: $(cat "$_ctf_rewards/flag_10")" - fi - unset _ctf_rewards _ctf_i -fi -""", - mode=0o644, - ) - - def configure_ssh() -> None: write_file( "/etc/ssh/sshd_config.d/99-ctf-password-auth.conf", """PasswordAuthentication yes KbdInteractiveAuthentication yes ChallengeResponseAuthentication yes -ExposeAuthInfo yes """, mode=0o644, ) @@ -172,11 +148,12 @@ def configure_hostname() -> None: file.write(f"127.0.0.1 {hostname}\n") -def configure_system() -> None: - install_packages() +def configure_system(challenge_packages: list[str]) -> None: + install_packages(challenge_packages) configure_users() configure_shell_profile() - configure_rewards() + # Before configure_ssh(), whose sshd restart applies the rewards drop-in. + configure_external_rewards() configure_ssh() configure_motd_support() configure_hostname() From 42f5622f72142343c5a5b19ff3f1eb0137199111 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Sun, 27 Sep 2026 15:14:07 -0400 Subject: [PATCH 3/5] Drop unused PROGRESS_FILE import and needless f-string in verify Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb6ef7ff-cd36-49b9-8051-47b519236889 --- verify/src/verify/commands.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/verify/src/verify/commands.py b/verify/src/verify/commands.py index 259d0bf..2c75198 100644 --- a/verify/src/verify/commands.py +++ b/verify/src/verify/commands.py @@ -13,7 +13,6 @@ from .state import ( END_TIME_FILE, - PROGRESS_FILE, START_TIME_FILE, CtfState, read_completed, @@ -294,7 +293,7 @@ def export_certificate(state: CtfState, github_username: str | None) -> int: console.print("⚠️ Save this token! You'll need it to verify your progress") console.print(" at https://learntocloud.guide") console.print("") - console.print(f" 1. Go to https://learntocloud.guide") + console.print(" 1. Go to https://learntocloud.guide") console.print(f" 2. Sign in with GitHub (as: {github_username})") console.print(" 3. Paste the token below") console.print("") From ef1e769aff9ffdd527eed43f1f4d4f5db5674cf8 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Sun, 27 Sep 2026 15:14:07 -0400 Subject: [PATCH 4/5] Remove legacy /var/log/setup_complete marker Nothing depends on it alone: every readiness check also accepts /var/lib/linux-ctfs/setup.done or the cloud-init instance marker, both of which ctf_setup.sh still writes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb6ef7ff-cd36-49b9-8051-47b519236889 --- .github/skills/ctf-testing/deploy_and_test.sh | 2 +- aws/main.tf | 2 +- ctf_setup.sh | 3 --- gcp/main.tf | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/skills/ctf-testing/deploy_and_test.sh b/.github/skills/ctf-testing/deploy_and_test.sh index 0447ba9..7cfd046 100755 --- a/.github/skills/ctf-testing/deploy_and_test.sh +++ b/.github/skills/ctf-testing/deploy_and_test.sh @@ -367,7 +367,7 @@ _wait_for_setup() { while [[ ${attempt} -le ${MAX_SSH_ATTEMPTS} ]]; do # shellcheck disable=SC2086 if _sshpass_cmd ssh ${SSH_OPTS} "${SSH_USER}@${ip}" \ - "test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done || test -f /var/log/setup_complete" &>/dev/null; then + "test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done" &>/dev/null; then _log OK "CTF setup is complete" return 0 fi diff --git a/aws/main.tf b/aws/main.tf index 4c45ea2..d5d5be4 100644 --- a/aws/main.tf +++ b/aws/main.tf @@ -125,7 +125,7 @@ locals { exit 1 fi - if test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done || test -f /var/log/setup_complete; then + if test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done; then echo "CTF setup is complete." exit 0 fi diff --git a/ctf_setup.sh b/ctf_setup.sh index 5ad6a11..273abc0 100644 --- a/ctf_setup.sh +++ b/ctf_setup.sh @@ -10,7 +10,6 @@ perl -pi -e 's/\r\n/\n/g' "$0" exec > >(tee /var/log/ctf_setup.log) 2>&1 DONE_MARKER="/var/lib/cloud/instance/ctf-setup.done" -LEGACY_DONE_MARKER="/var/log/setup_complete" PROJECT_STATE_DIR="/var/lib/linux-ctfs" PROJECT_DONE_MARKER="$PROJECT_STATE_DIR/setup.done" PROJECT_FAILED_MARKER="$PROJECT_STATE_DIR/setup.failed" @@ -20,7 +19,6 @@ if [ -f "$DONE_MARKER" ]; then echo "CTF setup already completed. Skipping." mkdir -p "$PROJECT_STATE_DIR" touch "$PROJECT_DONE_MARKER" - touch "$LEGACY_DONE_MARKER" exit 0 fi @@ -60,6 +58,5 @@ chmod -R a+rX "$UV_ROOT" uv cache clean touch "$DONE_MARKER" -touch "$LEGACY_DONE_MARKER" touch "$PROJECT_DONE_MARKER" echo "CTF environment setup complete!" \ No newline at end of file diff --git a/gcp/main.tf b/gcp/main.tf index 295a04f..7003010 100644 --- a/gcp/main.tf +++ b/gcp/main.tf @@ -137,7 +137,7 @@ locals { exit 1 fi - if test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done || test -f /var/log/setup_complete; then + if test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done; then echo "CTF setup is complete." exit 0 fi From 88b8eab8f4c6ab6e7a67b16dc4877c61289976e4 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Sun, 27 Sep 2026 15:44:37 -0400 Subject: [PATCH 5/5] Fix boot ordering cycle from ctf-dns Before=systemd-resolved systemd-resolved starts before sysinit.target and network.target, while ctf-dns is a regular service after both, so Before=systemd-resolved made two ordering cycles. systemd broke them by dropping jobs at boot (seen: resolved and network.target), leaving DNS down after reboot and, depending on which job was dropped, SSH unreachable. resolved doesn't need dnsmasq up to start; it only forwards ctf.internal queries on demand. Add post-reboot checks for ordering cycles and a running systemd-resolved, which the service is-active loop didn't catch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb6ef7ff-cd36-49b9-8051-47b519236889 --- .github/skills/ctf-testing/SKILL.md | 2 +- .github/skills/ctf-testing/test_ctf_challenges.sh | 13 +++++++++++++ setup/challenges/ch09_dns.py | 1 - 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/skills/ctf-testing/SKILL.md b/.github/skills/ctf-testing/SKILL.md index a9077d1..e9083f4 100644 --- a/.github/skills/ctf-testing/SKILL.md +++ b/.github/skills/ctf-testing/SKILL.md @@ -68,7 +68,7 @@ Use this skill when the user asks things like: 5. **Report the result plainly.** - Include provider, mode, pass/fail result, cleanup status, and blocker if any. - A successful basic run shows about 41 tests passing, including shortcut regression checks. - - A successful full run includes a second pass after reboot with 8 service checks and 1 progress persistence check. + - A successful full run includes a second pass after reboot with 8 service checks, 2 boot-health checks (no systemd ordering cycles, `systemd-resolved` running), and 1 progress persistence check. - Summary line: `RESULT: PASS ()` or `RESULT: FAIL ()`. ## Failure Handling diff --git a/.github/skills/ctf-testing/test_ctf_challenges.sh b/.github/skills/ctf-testing/test_ctf_challenges.sh index b3086bb..4e81b77 100644 --- a/.github/skills/ctf-testing/test_ctf_challenges.sh +++ b/.github/skills/ctf-testing/test_ctf_challenges.sh @@ -171,6 +171,19 @@ if [[ "${POST_REBOOT}" == true ]]; then fi done + # systemd silently drops jobs (resolved, network, even ssh) to break + # ordering cycles, so a cycle is a setup bug even if the loop above passed. + if journalctl -b --no-pager 2>/dev/null | grep -q "ordering cycle"; then + _fail "systemd ordering cycle at boot - SETUP BUG" + else + _pass "No systemd ordering cycles at boot" + fi + if systemctl is-active systemd-resolved &>/dev/null; then + _pass "systemd-resolved is running after reboot" + else + _fail "systemd-resolved failed to start after reboot - SETUP BUG" + fi + if [ -f "$PROGRESS_SNAPSHOT" ]; then EXPECTED=$(cat "$PROGRESS_SNAPSHOT") ACTUAL=$( { sort -u /var/ctf/completed_challenges 2>/dev/null || true; } | wc -l ) diff --git a/setup/challenges/ch09_dns.py b/setup/challenges/ch09_dns.py index dc6e468..937799f 100644 --- a/setup/challenges/ch09_dns.py +++ b/setup/challenges/ch09_dns.py @@ -30,7 +30,6 @@ def setup(flags: dict[int, str]) -> None: """[Unit] Description=CTF Internal DNS Challenge After=network.target -Before=systemd-resolved.service [Service] Type=simple