From 8d843a5f022b9956968ce140074c2972ef7b784c Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 07:38:13 -0400 Subject: [PATCH 01/10] Fix hints and docs for challenges 2, 5, 10 and 12 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 713688f7-85cd-404d-92fe-f94163885359 --- .github/skills/ctf-testing/test_ctf_challenges.sh | 4 ++-- README.md | 6 +++--- verify/src/verify/commands.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/skills/ctf-testing/test_ctf_challenges.sh b/.github/skills/ctf-testing/test_ctf_challenges.sh index a51c22c..1a2169f 100644 --- a/.github/skills/ctf-testing/test_ctf_challenges.sh +++ b/.github/skills/ctf-testing/test_ctf_challenges.sh @@ -267,7 +267,7 @@ else fi # Challenge 2: Basic File Search -# Hint: "Use find to search for files. Try: find ~ -name '*.txt'" +# Hint: "Use 'find' to search by name. Try: find ~ -type f -iname '*secret*'" echo "Challenge 2: Basic File Search" TXT_FILE=$(find /home/ctf_user/documents -name '*.txt' -type f 2>/dev/null | head -1) || true if [[ -n "${TXT_FILE}" ]]; then @@ -318,7 +318,7 @@ else fi # Challenge 5: Permission Analysis -# Hint: "Look for files with unusual permissions. Try: find / -perm 777" +# Hint: "Try: find /opt -type f -perm -o+w" echo "Challenge 5: Permission Analysis" FLAG_5="" for path in /opt /etc /var; do diff --git a/README.md b/README.md index 6b5f42d..cc63834 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Running into problems? See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). | # | Challenge | Description | Difficulty | Skills | |---|-----------|-------------|------------|--------| | 1 | The Hidden File | Find and read a hidden file in `ctf_challenges` | ⭐ | Hidden files, `ls` | -| 2 | The Secret File | Locate a file containing "secret" in its name under your home directory | ⭐ | File searching, `find` | +| 2 | The Secret File | Locate a regular file (not a directory) with "secret" in its name under your home directory | ⭐ | File searching, `find` | | 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 | @@ -32,9 +32,9 @@ Running into problems? See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). | 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 | +| 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag. It is broadcast to your open terminals | ⭐⭐ | File transfer, SCP | | 11 | Web Configuration | The web server is running on a non-standard port. Find and fix it | ⭐⭐ | Nginx, services | -| 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets | ⭐⭐⭐ | Packet inspection, tcpdump | +| 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets on the loopback interface (needs `sudo`) | ⭐⭐⭐ | Packet inspection, tcpdump | | 13 | Cron Job Hunter | A scheduled task contains a hidden flag. Find and read 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 | diff --git a/verify/src/verify/commands.py b/verify/src/verify/commands.py index 7fe8def..209fff7 100644 --- a/verify/src/verify/commands.py +++ b/verify/src/verify/commands.py @@ -48,17 +48,17 @@ CHALLENGE_HINTS = [ "Run: verify 0 CTF{example}", "Hidden files in Linux start with a dot. Try 'ls -la' in the ctf_challenges directory.", - "Use the 'find' command to search for files. Try: find ~ -name '*.txt' 2>/dev/null", + "Use 'find' to search by name. Try: find ~ -type f -iname '*secret*' 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 under /opt for regular files anyone can write to. Try: find /opt -type f -perm -o+w 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.", + "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. The flag is broadcast to your open terminals when the upload lands.", "Web servers serve content from specific directories. Check what ports nginx is listening on.", - "Network traffic can carry hidden messages. Look at ping patterns with tcpdump.", + "Network traffic can carry hidden messages. The pings run on the loopback interface. Capture ICMP payloads with: sudo tcpdump -i lo -X icmp", "Cron jobs run on schedules. Check /etc/cron.d/, /etc/crontab, and user crontabs with 'crontab -l'.", "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.", From e620921a204b97ceaedb7dfef8bf5545e69f11f9 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 08:12:40 -0400 Subject: [PATCH 02/10] Redesign challenges 3-5, 8-9, 11-18 for unique skills and no shortcuts Update setup, hints, README and test solver accordingly; install bzip2/xz-utils for ch15 and restart systemd-resolved for ch9. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 713688f7-85cd-404d-92fe-f94163885359 --- .../skills/ctf-testing/test_ctf_challenges.sh | 173 +++++++++--------- README.md | 20 +- setup/challenges/ch03_log_analysis.py | 28 ++- setup/challenges/ch04_user_investigation.py | 8 +- setup/challenges/ch05_permissions.py | 13 +- setup/challenges/ch08_ssh_secrets.py | 33 +++- setup/challenges/ch09_dns.py | 11 +- setup/challenges/ch11_web_config.py | 2 + setup/challenges/ch12_network_traffic.py | 6 +- setup/challenges/ch13_cron.py | 18 +- setup/challenges/ch14_process_env.py | 9 +- setup/challenges/ch15_archive.py | 8 +- setup/challenges/ch16_symlinks.py | 10 +- setup/challenges/ch17_history.py | 24 +-- setup/challenges/ch18_disk_detective.py | 11 +- setup/system.py | 2 + verify/src/verify/commands.py | 22 +-- 17 files changed, 224 insertions(+), 174 deletions(-) diff --git a/.github/skills/ctf-testing/test_ctf_challenges.sh b/.github/skills/ctf-testing/test_ctf_challenges.sh index 1a2169f..452fdc1 100644 --- a/.github/skills/ctf-testing/test_ctf_challenges.sh +++ b/.github/skills/ctf-testing/test_ctf_challenges.sh @@ -284,11 +284,11 @@ else fi # Challenge 3: Log Analysis -# Hint: "Large log files can hide secrets. Check /var/log and use 'tail'" +# Hint: "Find the biggest file in /var/log (ls -lS), then filter noise with grep -v" echo "Challenge 3: Log Analysis" -LARGE_LOG=$(find /var/log -type f -size +100M 2>/dev/null | head -1) || true +LARGE_LOG=$(ls -S /var/log/*.log 2>/dev/null | head -1) || true if [[ -n "${LARGE_LOG}" ]]; then - FLAG_3=$(tail -1 "${LARGE_LOG}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true + FLAG_3=$(grep -v 'Failed password' "${LARGE_LOG}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true if [[ -n "${FLAG_3}" ]]; then _verify_flag 3 "${FLAG_3}" else @@ -296,42 +296,41 @@ if [[ -n "${LARGE_LOG}" ]]; then FLAGS[3]="" fi else - _fail "Challenge 3: No large log files found" + _fail "Challenge 3: No log files found" FLAGS[3]="" fi # Challenge 4: User Investigation -# Hint: "Investigate other users. Check /etc/passwd or use 'getent passwd'" +# Hint: "Compare the users with 'getent passwd' and look at the fifth field" echo "Challenge 4: User Investigation" -FLAG_4="" -for user in $(getent passwd | awk -F: '$3 >= 1000 && $1 != "ctf_user" && $1 != "nobody" {print $1}'); do - if [[ -r "/home/${user}/.profile" ]]; then - FLAG_4=$(grep -ao 'CTF{[^}]*}' "/home/${user}/.profile" 2>/dev/null | head -1) || true - [[ -n "${FLAG_4}" ]] && break - fi -done +FLAG_4=$(getent passwd | awk -F: '$3 >= 1000 && $1 != "ctf_user" && $1 != "nobody" {print $5}' \ + | grep -ao 'CTF{[^}]*}' | head -1) || true if [[ -n "${FLAG_4}" ]]; then _verify_flag 4 "${FLAG_4}" else - _fail "Challenge 4: Could not find flag in user profiles" + _fail "Challenge 4: Could not find flag in user comment fields" FLAGS[4]="" fi # Challenge 5: Permission Analysis -# Hint: "Try: find /opt -type f -perm -o+w" +# Hint: "Try: find /opt -type f -perm -o+w"; the file points at a locked key owned by ctf_user echo "Challenge 5: Permission Analysis" FLAG_5="" -for path in /opt /etc /var; do - PERM_FILE=$(find "${path}" -type f -perm 777 2>/dev/null | head -1) || true - if [[ -n "${PERM_FILE}" ]]; then - FLAG_5=$(cat "${PERM_FILE}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true - [[ -n "${FLAG_5}" ]] && break +POINTER=$(find /opt -type f -perm -o+w -not -path '/opt/uv/*' 2>/dev/null | head -1) || true +LOCKED=$(grep -ao '/opt/[^ ]*\.key' "${POINTER}" 2>/dev/null | head -1) || true +if [[ -n "${LOCKED}" ]]; then + if cat "${LOCKED}" >/dev/null 2>&1; then + _fail "Challenge 5: Locked key was readable without chmod - SETUP BUG" + else + chmod u+r "${LOCKED}" + FLAG_5=$(grep -ao 'CTF{[^}]*}' "${LOCKED}" 2>/dev/null | head -1) || true + chmod 000 "${LOCKED}" fi -done +fi if [[ -n "${FLAG_5}" ]]; then _verify_flag 5 "${FLAG_5}" else - _fail "Challenge 5: Could not find flag in 777 permission files" + _fail "Challenge 5: Could not read the locked key" FLAGS[5]="" fi @@ -376,35 +375,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 with ssh-keygen, authorize it in ~/.ssh/authorized_keys, ssh vault@localhost" +echo "Challenge 8: SSH Key Authentication" +KEY_DIR=$(mktemp -d) +ssh-keygen -q -t ed25519 -N '' -f "${KEY_DIR}/id" >/dev/null +mkdir -p ~/.ssh +cat "${KEY_DIR}/id.pub" >> ~/.ssh/authorized_keys +FLAG_8=$(ssh -i "${KEY_DIR}/id" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR vault@localhost 2>/dev/null \ + | grep -ao 'CTF{[^}]*}' | head -1) || true +if sshpass -p 'CTFpassword123!' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR vault@localhost true 2>/dev/null; then + _fail "Challenge 8: vault accepted password auth - SETUP BUG" +fi +grep -vF "$(cut -d' ' -f2 "${KEY_DIR}/id.pub")" ~/.ssh/authorized_keys > "${KEY_DIR}/ak" || true +cat "${KEY_DIR}/ak" > ~/.ssh/authorized_keys +rm -rf "${KEY_DIR}" if [[ -n "${FLAG_8}" ]]; then _verify_flag 8 "${FLAG_8}" else - _fail "Challenge 8: Could not find flag in .ssh directory" + _fail "Challenge 8: Key login as vault did not return a flag - SETUP BUG" FLAGS[8]="" fi # Challenge 9: DNS Inspection -# Hint: "Inspect systemd-resolved configuration safely" +# Hint: "Find the search domain (resolvectl status), then 'getent hosts' the intranet host" 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 +SEARCH_DOMAIN=$(resolvectl status 2>/dev/null | awk '/DNS Domain:/ {for (i=3;i<=NF;i++) print $i}' | grep -m1 '^ctf-lab') || true +if [[ -n "${SEARCH_DOMAIN}" ]]; then + FLAG_9=$(getent hosts "intranet.${SEARCH_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: getent hosts did not return a flag - SETUP BUG" FLAGS[9]="" fi else - _fail "Challenge 9: DNS drop-in not readable - SETUP BUG" + _fail "Challenge 9: Custom search domain not shown by resolvectl - SETUP BUG" FLAGS[9]="" fi @@ -434,24 +442,26 @@ else fi # Challenge 11: Web Configuration -# Hint: "Check what ports nginx is listening on" +# Hint: "nginx should serve on port 80. Check ss, curl, error.log, fix config, 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 \ - | 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" - FLAGS[11]="" +FLAG_11="" +if curl -s "localhost:80" 2>/dev/null | grep -aq 'CTF{'; then + _fail "Challenge 11: Flag served on port 80 before any fix - SETUP BUG" +elif curl -s "localhost:8083" 2>/dev/null | grep -aq 'CTF{'; then + _fail "Challenge 11: Flag served on 8083 before any fix - SETUP BUG" +else + SITE=/etc/nginx/sites-available/default + echo 'CTFpassword123!' | sudo -S sed -i -e 's/listen 8083/listen 80/;s/listen \[::\]:8083/listen [::]:80/;s|/var/www/htm;|/var/www/html;|' "${SITE}" 2>/dev/null + if echo 'CTFpassword123!' | sudo -S nginx -t &>/dev/null \ + && echo 'CTFpassword123!' | sudo -S systemctl reload nginx &>/dev/null; then + sleep 1 + FLAG_11=$(curl -s "localhost:80" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true fi +fi +if [[ -n "${FLAG_11}" ]]; then + _verify_flag 11 "${FLAG_11}" else - _fail "Challenge 11: Could not find nginx non-standard port" + _fail "Challenge 11: Could not get flag from nginx after fixing config" FLAGS[11]="" fi @@ -479,18 +489,22 @@ else fi # Challenge 13: Cron Job Hunter -# Hint: "Check /etc/cron.d/, /etc/crontab, and user crontabs" +# Hint: "Check /etc/cron.d, see what the job runs and where it writes, wait for the next run" 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 -rhao '/opt/scripts/[^ ]*' /etc/cron.d 2>/dev/null | head -1) || true +JOB_LOG=$(grep -ao '/var/tmp/[^" ]*' "${CRON_SCRIPT}" 2>/dev/null | head -1) || true +if [[ -n "${JOB_LOG}" ]]; then + for _ in {1..35}; do + FLAG_13=$(grep -ao 'CTF{[^}]*}' "${JOB_LOG}" 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: Cron job did not produce the flag" FLAGS[13]="" fi @@ -498,6 +512,9 @@ fi # Hint: "Process info lives in /proc. Check /proc/PID/environ" echo "Challenge 14: Process Environment" FLAG_14="" +if systemctl show ctf-secret-process -p Environment 2>/dev/null | grep -q 'CTF{'; then + _fail "Challenge 14: Flag leaks through systemctl show - SETUP BUG" +fi for pid in $(pgrep -u ctf_user 2>/dev/null); do [[ -r "/proc/${pid}/environ" ]] || continue FLAG_14=$(tr '\0' '\n' < "/proc/${pid}/environ" 2>/dev/null | grep -ao 'CTF{[^}]*}') || true @@ -511,17 +528,17 @@ else fi # Challenge 15: Archive Archaeologist -# Hint: "Archives can be nested. Use 'tar -xzf' to extract layers" +# Hint: "Each layer may use different compression. 'tar -xf' detects the format" echo "Challenge 15: Archive Archaeologist" ARCHIVE=$(find /home/ctf_user/ctf_challenges -name '*.tar.gz' 2>/dev/null | head -1) || true if [[ -n "${ARCHIVE}" ]]; then TMPDIR=$(mktemp -d) cd "${TMPDIR}" - tar -xzf "${ARCHIVE}" 2>/dev/null || true + tar -xf "${ARCHIVE}" 2>/dev/null || true for _ in {1..5}; do - INNER=$(find . -maxdepth 1 -name '*.tar.gz' 2>/dev/null | head -1) || true + INNER=$(find . -maxdepth 1 -name '*.tar.*' 2>/dev/null | head -1) || true [[ -z "${INNER}" ]] && break - tar -xzf "${INNER}" 2>/dev/null || true + tar -xf "${INNER}" 2>/dev/null || true rm -f "${INNER}" done FLAG_15=$(grep -rh 'CTF{' . 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true @@ -540,15 +557,12 @@ else fi # Challenge 16: Symbolic Sleuth -# Hint: "Use 'readlink -f' to find the final target" +# Hint: "Use 'readlink -f' to find the end of the trail; the flag is the final name" 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 - [[ -n "${FLAG_16}" ]] && break -done < <(find /home/ctf_user/ctf_challenges -type l -print0 2>/dev/null) +FLAG_16=$(readlink -f /home/ctf_user/ctf_challenges/follow_me 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true +if grep -aq 'CTF{' /home/ctf_user/ctf_challenges/follow_me 2>/dev/null; then + _fail "Challenge 16: Flag readable inside the target file - SETUP BUG" +fi if [[ -n "${FLAG_16}" ]]; then _verify_flag 16 "${FLAG_16}" else @@ -557,14 +571,14 @@ else fi # Challenge 17: History Mystery -# Hint: "Bash stores history in ~/.bash_history. Other users may have history too" +# Hint: "Search other users' ~/.bash_history for keywords like 'export'" 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 + FLAG_17=$(grep -a 'export' "${home}/.bash_history" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true [[ -n "${FLAG_17}" ]] && break done if [[ -n "${FLAG_17}" ]]; then @@ -575,23 +589,16 @@ else fi # Challenge 18: Disk Detective -# Hint: "Try mounting disk images with 'sudo mount -o loop'" +# Hint: "Inspect the disk image metadata with blkid, e2label or dumpe2fs -h" echo "Challenge 18: Disk Detective" DISK_IMG=$(find /opt /home -name '*.img' -type f 2>/dev/null | head -1) || true if [[ -n "${DISK_IMG}" ]]; then - MNTDIR=$(mktemp -d) - echo 'CTFpassword123!' | sudo -S mount -o loop "${DISK_IMG}" "${MNTDIR}" 2>/dev/null - FLAG_18=$(find "${MNTDIR}" -type f -print0 2>/dev/null \ - | xargs -0 grep -ah 'CTF{' 2>/dev/null \ - | grep -ao 'CTF{[^}]*}' \ - | head -1) || true - echo 'CTFpassword123!' | sudo -S umount "${MNTDIR}" 2>/dev/null || true - rmdir "${MNTDIR}" 2>/dev/null || true - + FLAG_18=$(echo 'CTFpassword123!' | sudo -S blkid -o value -s LABEL "${DISK_IMG}" 2>/dev/null \ + | grep -ao 'CTF{[^}]*}' | head -1) || true if [[ -n "${FLAG_18}" ]]; then _verify_flag 18 "${FLAG_18}" else - _fail "Challenge 18: Could not find flag in disk image" + _fail "Challenge 18: Could not read flag from filesystem label" FLAGS[18]="" fi else diff --git a/README.md b/README.md index cc63834..77b107a 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,22 @@ Running into problems? See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). |---|-----------|-------------|------------|--------| | 1 | The Hidden File | Find and read a hidden file in `ctf_challenges` | ⭐ | Hidden files, `ls` | | 2 | The Secret File | Locate a regular file (not a directory) with "secret" in its name under your home directory | ⭐ | File searching, `find` | -| 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 | +| 3 | The Odd Log Entry | Thousands of failed logins hide a single successful one in a log under `/var/log` | ⭐⭐ | `grep`, log analysis | +| 4 | The User Detective | Another user's account record carries a flag | ⭐⭐ | Users, `getent passwd` | +| 5 | The Permissive File | Find a suspicious file with wide-open permissions under `/opt`, then follow where it leads | ⭐⭐ | Permissions, `chmod` | | 6 | The Hidden Service | Something is listening on port 8080. 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` | +| 8 | SSH Key Authentication | Set up SSH key authentication to log in as the key-only `vault` user | ⭐⭐⭐ | `ssh-keygen`, `authorized_keys` | +| 9 | DNS Inspection | Find the lab's custom search domain and resolve a host inside it | ⭐⭐ | DNS, `resolvectl`, `getent hosts` | | 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag. It is broadcast to your open terminals | ⭐⭐ | File transfer, SCP | -| 11 | Web Configuration | The web server is running on a non-standard port. Find and fix it | ⭐⭐ | Nginx, services | +| 11 | Web Configuration | nginx should serve the site on port 80 but is misconfigured. Find and fix it | ⭐⭐ | Nginx, `nginx -t`, services | | 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets on the loopback interface (needs `sudo`) | ⭐⭐⭐ | 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 job handles a secret. Find out what it does and catch it in the act | ⭐⭐ | 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 | +| 16 | Symbolic Sleuth | Follow the trail of symbolic links. The flag is where the trail ends | ⭐⭐ | Symlinks, `readlink` | +| 17 | History Mystery | Someone typed a secret into their command line. Search their history | ⭐⭐ | Bash history, `grep` | +| 18 | Disk Detective | A flag is hidden in filesystem metadata. Inspect the disk image | ⭐⭐⭐ | Disk images, `blkid` | **Difficulty:** ⭐ Beginner | ⭐⭐ Intermediate | ⭐⭐⭐ Advanced diff --git a/setup/challenges/ch03_log_analysis.py b/setup/challenges/ch03_log_analysis.py index c00416a..b44c60a 100644 --- a/setup/challenges/ch03_log_analysis.py +++ b/setup/challenges/ch03_log_analysis.py @@ -1,23 +1,33 @@ from __future__ import annotations +import random from pathlib import Path from helpers import run -LOG_SIZE_BYTES = 120 * 1024 * 1024 +FAILED_ATTEMPTS = 50_000 +USERS = ["root", "admin", "test", "oracle", "postgres", "ubuntu", "deploy", "git", "guest", "backup"] def setup(flags: dict[int, str]) -> None: - log_file = Path("/var/log/large_log_file.log") - line = "INFO backup-worker completed routine health check without findings\n" - chunk = line * (1024 * 1024 // len(line)) + rng = random.Random() + success_at = rng.randrange(FAILED_ATTEMPTS // 4, FAILED_ATTEMPTS * 3 // 4) + log_file = Path("/var/log/auth_audit.log") - bytes_written = 0 with log_file.open("w") as file: - while bytes_written < LOG_SIZE_BYTES: - file.write(chunk) - bytes_written += len(chunk) - file.write(f"{flags[3]}\n") + for index in range(FAILED_ATTEMPTS): + minute, second = divmod(index % 3600, 60) + stamp = f"Sep 29 {(index // 3600) % 24:02d}:{minute:02d}:{second:02d}" + ip = f"{rng.randint(11, 223)}.{rng.randint(0, 255)}.{rng.randint(0, 255)}.{rng.randint(1, 254)}" + file.write( + f"{stamp} ctf-vm sshd[{rng.randint(1000, 32000)}]: " + f"Failed password for {rng.choice(USERS)} from {ip} port {rng.randint(1024, 65535)} ssh2\n" + ) + if index == success_at: + file.write( + f"{stamp} ctf-vm sshd[{rng.randint(1000, 32000)}]: " + f"Accepted password for {flags[3]} from 203.0.113.7 port 51022 ssh2\n" + ) run(["chown", "ctf_user:ctf_user", str(log_file)]) diff --git a/setup/challenges/ch04_user_investigation.py b/setup/challenges/ch04_user_investigation.py index d148929..ae92494 100644 --- a/setup/challenges/ch04_user_investigation.py +++ b/setup/challenges/ch04_user_investigation.py @@ -1,12 +1,8 @@ from __future__ import annotations -from pathlib import Path - -from helpers import ensure_user, recursive_chown, write_file +from helpers import ensure_user, run def setup(flags: dict[int, str]) -> None: ensure_user("flag_user") - write_file("/home/flag_user/.profile", f"{flags[4]}\n", mode=0o644) - recursive_chown("/home/flag_user", "flag_user", "flag_user") - Path("/home/flag_user").chmod(0o755) + run(["usermod", "-c", f"Service account {flags[4]}", "flag_user"]) diff --git a/setup/challenges/ch05_permissions.py b/setup/challenges/ch05_permissions.py index b52af93..d538895 100644 --- a/setup/challenges/ch05_permissions.py +++ b/setup/challenges/ch05_permissions.py @@ -1,7 +1,16 @@ from __future__ import annotations -from helpers import write_file +from pathlib import Path + +from helpers import recursive_chown, write_file def setup(flags: dict[int, str]) -> None: - write_file("/opt/systems/config/system.conf", f"{flags[5]}\n", mode=0o777) + write_file( + "/opt/systems/config/system.conf", + "# Access keys were moved to /opt/systems/keys/master.key after the last audit.\n", + mode=0o777, + ) + write_file("/opt/systems/keys/master.key", f"{flags[5]}\n", mode=0o000) + recursive_chown("/opt/systems/keys", "ctf_user", "ctf_user") + Path("/opt/systems/keys").chmod(0o755) diff --git a/setup/challenges/ch08_ssh_secrets.py b/setup/challenges/ch08_ssh_secrets.py index 25b6d7c..bf365d9 100644 --- a/setup/challenges/ch08_ssh_secrets.py +++ b/setup/challenges/ch08_ssh_secrets.py @@ -2,11 +2,34 @@ from pathlib import Path -from helpers import recursive_chown, write_file +from helpers import ensure_user, recursive_chown, restart_service, write_executable, write_file 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) + # The vault user has no password. Its only way in is a public key listed in ctf_user's authorized_keys. + ensure_user("vault") + write_file("/etc/ctf/flag_8", f"{flags[8]}\n", mode=0o400, owner="vault", group="vault") + write_executable("/usr/local/bin/ctf_vault_keys", "#!/bin/sh\ncat /home/ctf_user/.ssh/authorized_keys\n") + write_executable( + "/usr/local/bin/ctf_vault_login", + "#!/bin/sh\necho \"Welcome to the vault. Your key was accepted. Flag: $(cat /etc/ctf/flag_8)\"\n", + ) + write_file( + "/etc/ssh/sshd_config.d/98-ctf-vault.conf", + """Match User vault + PasswordAuthentication no + KbdInteractiveAuthentication no + AuthorizedKeysCommand /usr/local/bin/ctf_vault_keys + AuthorizedKeysCommandUser root + ForceCommand /usr/local/bin/ctf_vault_login +""", + mode=0o644, + ) + + ssh_dir = Path("/home/ctf_user/.ssh") + ssh_dir.mkdir(exist_ok=True) + (ssh_dir / "authorized_keys").touch() + recursive_chown(ssh_dir, "ctf_user", "ctf_user") + ssh_dir.chmod(0o700) + (ssh_dir / "authorized_keys").chmod(0o600) + restart_service("ssh") diff --git a/setup/challenges/ch09_dns.py b/setup/challenges/ch09_dns.py index 7caf2be..80ef189 100644 --- a/setup/challenges/ch09_dns.py +++ b/setup/challenges/ch09_dns.py @@ -1,17 +1,16 @@ from __future__ import annotations -from helpers import write_file +from helpers import append_line_once, restart_service, write_file def setup(flags: dict[int, str]) -> None: 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]} - + """# Internal search domain for the lab intranet. [Resolve] -# This drop-in is harmless. It exists so learners can inspect systemd-resolved config safely. +Domains=ctf-lab.internal """, mode=0o644, ) + append_line_once("/etc/hosts", f"10.10.10.10 intranet.ctf-lab.internal {flags[9]}") + restart_service("systemd-resolved") diff --git a/setup/challenges/ch11_web_config.py b/setup/challenges/ch11_web_config.py index 2ced587..6f79f63 100644 --- a/setup/challenges/ch11_web_config.py +++ b/setup/challenges/ch11_web_config.py @@ -14,5 +14,7 @@ def setup(flags: dict[int, str]) -> None: 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;") + # Wrong port and a typo in the document root: both must be fixed to serve the flag on port 80. + content = content.replace("root /var/www/html;", "root /var/www/htm;") 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..f5165f6 100644 --- a/setup/challenges/ch12_network_traffic.py +++ b/setup/challenges/ch12_network_traffic.py @@ -9,7 +9,7 @@ def setup(flags: dict[int, str]) -> None: "/usr/local/bin/ping_message.sh", f"""#!/bin/bash while true; do - ping -p {flag_hex} -c 1 127.0.0.1 + ping -p {flag_hex} -c 1 127.0.0.1 >/dev/null sleep 1 done """, @@ -25,8 +25,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..68ff799 100644 --- a/setup/challenges/ch13_cron.py +++ b/setup/challenges/ch13_cron.py @@ -1,15 +1,21 @@ from __future__ import annotations -from helpers import write_file +from helpers import write_executable, write_file def setup(flags: dict[int, str]) -> None: + write_file("/etc/ctf/flag_13", f"{flags[13]}\n", mode=0o600) + write_executable( + "/opt/scripts/nightly_backup.sh", + """#!/bin/bash +umask 022 +echo "$(date -Is) backup ok, audit token: $(cat /etc/ctf/flag_13)" > /var/tmp/backup_status.log +""", + ) 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/nightly_backup", + """# Nightly backup (runs every minute in the lab) +* * * * * root /opt/scripts/nightly_backup.sh """, mode=0o644, ) diff --git a/setup/challenges/ch14_process_env.py b/setup/challenges/ch14_process_env.py index 4c1add9..0c20c33 100644 --- a/setup/challenges/ch14_process_env.py +++ b/setup/challenges/ch14_process_env.py @@ -4,13 +4,10 @@ def setup(flags: dict[int, str]) -> None: - write_file("/etc/ctf/flag_14", f"{flags[14]}\n", mode=0o600) + 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 +15,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 +23,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/ch15_archive.py b/setup/challenges/ch15_archive.py index 7d89636..fd81eac 100644 --- a/setup/challenges/ch15_archive.py +++ b/setup/challenges/ch15_archive.py @@ -9,9 +9,9 @@ def setup(flags: dict[int, str]) -> None: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) (temp_path / "flag.txt").write_text(f"{flags[15]}\n") - with tarfile.open(temp_path / "inner.tar.gz", "w:gz") as archive: + with tarfile.open(temp_path / "inner.tar.xz", "w:xz") as archive: 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(temp_path / "middle.tar.bz2", "w:bz2") as archive: + archive.add(temp_path / "inner.tar.xz", arcname="inner.tar.xz") with tarfile.open("/home/ctf_user/ctf_challenges/mystery_archive.tar.gz", "w:gz") as archive: - archive.add(temp_path / "middle.tar.gz", arcname="middle.tar.gz") + archive.add(temp_path / "middle.tar.bz2", arcname="middle.tar.bz2") diff --git a/setup/challenges/ch16_symlinks.py b/setup/challenges/ch16_symlinks.py index 4b1fbdc..ee0dcd2 100644 --- a/setup/challenges/ch16_symlinks.py +++ b/setup/challenges/ch16_symlinks.py @@ -6,10 +6,12 @@ def setup(flags: dict[int, str]) -> None: - write_file("/var/lib/ctf/secrets/deep/hidden/final_flag.txt", f"{flags[16]}\n", mode=0o644) + hidden = Path("/var/lib/ctf/secrets/deep/hidden") + final_target = hidden / flags[16] + write_file(final_target, "You reached the end of the trail. The flag is the name of this file.\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")), + (final_target, Path("/var/lib/ctf/secrets/deep/link3")), + (Path("deep/link3"), Path("/var/lib/ctf/secrets/link2")), (Path("/var/lib/ctf/secrets/link2"), Path("/home/ctf_user/ctf_challenges/follow_me")), ] for target, link in links: @@ -19,6 +21,6 @@ def setup(flags: dict[int, str]) -> None: Path("/var/lib/ctf"), Path("/var/lib/ctf/secrets"), Path("/var/lib/ctf/secrets/deep"), - Path("/var/lib/ctf/secrets/deep/hidden"), ): directory.chmod(0o755) + hidden.chmod(0o711) # traversable but not listable, so the name only shows via readlink diff --git a/setup/challenges/ch17_history.py b/setup/challenges/ch17_history.py index d87faf3..e1dedef 100644 --- a/setup/challenges/ch17_history.py +++ b/setup/challenges/ch17_history.py @@ -1,22 +1,24 @@ from __future__ import annotations from pathlib import Path +import random from helpers import ensure_user, recursive_chown, write_file +COMMANDS = [ + "ls -la", "cd /var/log", "tail -f syslog", "df -h", "free -m", "top", "sudo apt update", + "sudo apt upgrade -y", "systemctl status nginx", "cd /etc/nginx", "vim nginx.conf", + "sudo systemctl restart nginx", "git pull", "docker ps", "cat /etc/hosts", "ps aux", + "netstat -tulpn", "journalctl -u ssh", "crontab -l", "whoami", "uptime", "history", +] + + 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]} -sudo systemctl restart nginx -exit -""", - mode=0o644, - ) + rng = random.Random() + lines = [rng.choice(COMMANDS) for _ in range(400)] + lines.insert(rng.randrange(100, 300), f"export DEPLOY_KEY={flags[17]}") + write_file("/home/old_admin/.bash_history", "\n".join(lines) + "\nexit\n", mode=0o644) 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..e2723cc 100644 --- a/setup/challenges/ch18_disk_detective.py +++ b/setup/challenges/ch18_disk_detective.py @@ -1,15 +1,10 @@ from __future__ import annotations -from helpers import recursive_chown, run, write_file +from helpers import recursive_chown, run 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"]) - run(["mkdir", "-p", "/mnt/ctf_disk"]) - run(["mount", "-o", "loop", "/opt/ctf_disk.img", "/mnt/ctf_disk"]) - try: - write_file("/mnt/ctf_disk/.flag", f"{flags[18]}\n") - finally: - run(["umount", "/mnt/ctf_disk"]) + # ext4 labels hold 16 bytes, exactly the length of a flag. + run(["mkfs.ext4", "-F", "-L", flags[18], "/opt/ctf_disk.img"]) recursive_chown("/home/ctf_user/ctf_challenges", "ctf_user", "ctf_user") diff --git a/setup/system.py b/setup/system.py index d5f3464..ee6f9e3 100644 --- a/setup/system.py +++ b/setup/system.py @@ -29,6 +29,8 @@ def install_packages() -> None: "inotify-tools", "netcat-openbsd", "tcpdump", + "bzip2", + "xz-utils", ] apt_get("update") apt_get("install", "-y", *packages) diff --git a/verify/src/verify/commands.py b/verify/src/verify/commands.py index 209fff7..0c3f52e 100644 --- a/verify/src/verify/commands.py +++ b/verify/src/verify/commands.py @@ -49,22 +49,22 @@ "Run: verify 0 CTF{example}", "Hidden files in Linux start with a dot. Try 'ls -la' in the ctf_challenges directory.", "Use 'find' to search by name. Try: find ~ -type f -iname '*secret*' 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 under /opt for regular files anyone can write to. Try: find /opt -type f -perm -o+w 2>/dev/null", + "Every log line looks alike except one. Find the biggest file in /var/log (ls -lS), then filter out the noise with grep (try grep -v).", + "Every account has a comment (GECOS) field in /etc/passwd. Compare the users with 'getent passwd' and look at the fifth field.", + "Look under /opt for regular files anyone can write to. Try: find /opt -type f -perm -o+w 2>/dev/null. If a file says 'Permission denied', check 'ls -l': who owns it, and what can you change?", "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/.", + "The 'vault' user has no password and only accepts an SSH key. Create a key pair with 'ssh-keygen', authorize the public key in your own ~/.ssh/authorized_keys, then run 'ssh vault@localhost'.", + "Find the custom search domain with 'resolvectl status' or /etc/systemd/resolved.conf.d/. The lab's 'intranet' host lives in that domain: resolve its full name with 'getent hosts'.", "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. The flag is broadcast to your open terminals when the upload lands.", - "Web servers serve content from specific directories. Check what ports nginx is listening on.", + "nginx should serve the site on port 80 but doesn't. Check 'ss -tlnp', 'curl -i localhost:', and /var/log/nginx/error.log. Fix the config, then 'sudo nginx -t && sudo systemctl reload nginx'.", "Network traffic can carry hidden messages. The pings run on the loopback interface. Capture ICMP payloads with: sudo tcpdump -i lo -X icmp", - "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/ and /etc/crontab, see what the job runs and where it writes, then wait for the next run.", "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.", + "Archives can be nested, and each layer may use a different compression. Check every layer with 'file'; 'tar -xf' detects the format for you.", + "Follow the chain with 'ls -l' one hop at a time, or jump to the end with 'readlink -f'. The flag is where the trail ends, not what is inside it.", + "Bash stores command history in ~/.bash_history. Other users have history files too. Search them for keywords like 'export' or 'key'.", + "Filesystems carry metadata besides files. Inspect the disk image in /opt with 'blkid', 'e2label', or 'sudo dumpe2fs -h'.", ] From 461106882efa4aefad8360955f51444ed7327f15 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 08:42:56 -0400 Subject: [PATCH 03/10] Fix AWS post-reboot IP lookup in deploy_and_test.sh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 713688f7-85cd-404d-92fe-f94163885359 --- .github/skills/ctf-testing/deploy_and_test.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/skills/ctf-testing/deploy_and_test.sh b/.github/skills/ctf-testing/deploy_and_test.sh index cfc5e1f..43dd203 100755 --- a/.github/skills/ctf-testing/deploy_and_test.sh +++ b/.github/skills/ctf-testing/deploy_and_test.sh @@ -423,9 +423,13 @@ _reboot_vm() { echo " Starting instance ${instance_id}..." >&2 aws ec2 start-instances --instance-ids "${instance_id}" > /dev/null aws ec2 wait instance-running --instance-ids "${instance_id}" - # IP may change, get new one - sleep 10 - ip=$(_get_public_ip "${provider}") + # The public IP changes after stop/start and terraform state is stale, so ask EC2 directly + ip=$(aws ec2 describe-instances --instance-ids "${instance_id}" \ + --query 'Reservations[0].Instances[0].PublicIpAddress' --output text) + if [[ ! "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + _log ERROR "Invalid IP address retrieved after restart: '${ip}'" >&2 + return 1 + fi ;; azure) echo " Restarting Azure VM..." >&2 From 17a7c42a400807ab408981e6e96e1ae9f87a9c84 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 09:12:41 -0400 Subject: [PATCH 04/10] Sync state to disk before reboot in test script GCP resets the VM without a clean shutdown, which lost the reboot marker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 713688f7-85cd-404d-92fe-f94163885359 --- .github/skills/ctf-testing/test_ctf_challenges.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/skills/ctf-testing/test_ctf_challenges.sh b/.github/skills/ctf-testing/test_ctf_challenges.sh index 452fdc1..b3f1a1e 100644 --- a/.github/skills/ctf-testing/test_ctf_challenges.sh +++ b/.github/skills/ctf-testing/test_ctf_challenges.sh @@ -673,6 +673,8 @@ if [ "$WITH_REBOOT" = true ] && [ $FAILED -eq 0 ]; then mkdir -p "${TEST_STATE_DIR}" sort -u /var/ctf/completed_challenges 2>/dev/null | wc -l > "$PROGRESS_SNAPSHOT" touch "$REBOOT_MARKER" + # GCP resets the VM without a clean shutdown, so flush state to disk first + sync echo "Reboot marker created. After reboot, re-run with --post-reboot to verify services." exit 100 fi From b7e396ee8a09e7c81f67d3e24ad2d830459ebcb2 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 10:19:03 -0400 Subject: [PATCH 05/10] Remove unused port 8083 firewall rules Nginx's misconfigured listener on 8083 is only reached from the VM in ch11, so no cloud needs to expose it. Full test with reboot passes on AWS, Azure and GCP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 713688f7-85cd-404d-92fe-f94163885359 --- aws/main.tf | 7 ------- azure/main.tf | 12 ------------ gcp/main.tf | 2 +- 3 files changed, 1 insertion(+), 20 deletions(-) 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"] From 1788ced2a7743a9f3af4522b8fa2e2f5d46f8a72 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 10:52:51 -0400 Subject: [PATCH 06/10] docs: extract shared lab guide and slim provider READMEs Move connect, capture flags, verify commands and finish steps into GUIDE.md, add a quick start to the root README, and simplify the AWS region steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7ca812f-6f5f-4846-b188-475430b77721 --- GUIDE.md | 80 +++++++++++++++++++++++ README.md | 20 ++++-- aws/README.md | 165 ++++++++++-------------------------------------- azure/README.md | 135 +++++++-------------------------------- gcp/README.md | 121 ++++++----------------------------- 5 files changed, 172 insertions(+), 349 deletions(-) create mode 100644 GUIDE.md diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..4a56de3 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,80 @@ +# Playing the Lab + +This guide covers everything you do once your lab VM is running, and it's the same on AWS, Azure, and GCP. Deploy the lab first with your provider's guide: [AWS](./aws/README.md), [Azure](./azure/README.md), or [GCP](./gcp/README.md). + +## Contents + +- [Connect to the Lab](#connect-to-the-lab) +- [Capture Flags](#capture-flags) +- [Verify Commands](#verify-commands) +- [Finish the CTF](#finish-the-ctf) + +## Connect to the Lab + +1. Connect via SSH using the `public_ip_address` output from Terraform: + + ```sh + ssh ctf_user@ + ``` + +2. On first login you'll be asked whether to add the host fingerprint to your known hosts file. Type `yes` and press Enter. + +3. Enter the password when prompted: `CTFpassword123!` + + > [!NOTE] + > This password is intentionally public. The lab uses password authentication for simplicity. In production, use key-based authentication. + +You'll see a welcome message when you're in. If SSH works but the lab doesn't seem ready yet, see [Lab not ready after SSH login](./TROUBLESHOOTING.md#lab-not-ready-after-ssh-login). + +## Capture Flags + +Each challenge hides a flag somewhere on the VM. Find it using the command line, then submit it with `verify`. + +- **What a flag looks like:** Flags are wrapped in `CTF{...}`. Every lab instance generates its own flags, so a flag from someone else's lab won't work in yours. +- **Start with the example:** Run `verify 0 CTF{example}` to confirm the `verify` command works. `verify progress` should then show `1/19`. +- **Pick a challenge:** Read the [challenge list](./README.md#challenges). They're roughly ordered from easiest to hardest, but you can solve them in any order. +- **Submit what you find:** Run `verify `. For example, a flag for challenge 3 is submitted with `verify 3 CTF{...}`. +- **Challenge 10 starts on your computer:** Run it from a terminal on your own machine, not from inside the SSH session. + +Tips: + +- Use `man` pages to learn commands (e.g., `man find`). +- Combine commands with pipes (`|`) to process output. +- Use `verify hint ` when you're stuck. +- Experiment freely. You can't break anything permanently, and you can always redeploy. + +## Verify Commands + +Run these on the lab VM. Running `verify` with no arguments prints the usage summary. + +| Command | What it does | +|---------|--------------| +| `verify ` | Submits a flag for challenge `0`-`18`. Tells you whether it's correct and shows your updated progress. Your first submission starts the timer. | +| `verify progress` | Shows how many flags you've found out of 19: the example flag (challenge 0) plus the 18 real challenges. | +| `verify list` | Lists every challenge by name, with `[✓]` next to the ones you've solved. | +| `verify hint ` | Shows a hint for challenge `0`-`18`. Hints nudge you toward the right tool or location; they don't give you the answer. | +| `verify time` | Shows wall clock time elapsed since your first submission, not active keyboard time. | +| `verify export ` | Available after you solve all 18 real challenges. Prints your completion certificate and completion token, and saves the certificate to `~/ctf_certificate_.txt`. | + +How the timer works: + +- It starts the first time you run `verify `. +- It keeps running while the VM is stopped, so paused time still counts. +- It freezes on your first successful `verify export` after you've solved all 18 real challenges. + +## Finish the CTF + +Once you've solved all 18 challenges, export your completion certificate: + +```sh +verify export +``` + +> [!IMPORTANT] +> Enter your GitHub username **exactly** as it appears on GitHub: no `@` symbol, no extra spaces, no special characters. For example: `verify export octocat`, not `verify export @octocat`. + +Use the same GitHub account that owns your fork of this repository. Verification checks for the fork. + +Save the token it prints. You'll need it to record your progress at [learntocloud.guide/phase1](https://learntocloud.guide/phase1). The full token is around **300+ characters**. If it isn't accepted, see [Completion token not accepted](./TROUBLESHOOTING.md#completion-token-not-accepted). + +**Save your token before cleaning up.** Destroying the lab deletes the VM and everything on it. diff --git a/README.md b/README.md index 77b107a..f40f0d1 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,19 @@ Test your Linux command line skills with 18 progressive Capture The Flag challen ## Get Started -Start by [forking this repository](https://github.com/learntocloud/linux-ctfs/fork) to your GitHub account—completion verification checks that you have a fork. Then pick a cloud provider and follow its guide. Each guide covers deploying the lab, connecting, capturing flags, using the `verify` command, exporting your completion token, and cleaning up. +**You'll need:** a cloud account (AWS, Azure, or GCP), [Terraform](https://developer.hashicorp.com/terraform/install), your provider's CLI, and about 3-4 hours. -| Provider | Cost for ~4 hours | Guide | -|----------|-------------------|-------| -| AWS | ~$0.01 (Free Tier eligible) | [AWS Guide](./aws/README.md) | -| Azure | ~$0.05 | [Azure Guide](./azure/README.md) | -| GCP | ~$0.03 | [GCP Guide](./gcp/README.md) | +1. **Fork** this repository to your GitHub account. Completion verification checks that you have a fork. +2. **Deploy** the lab with your provider's guide: + + | Provider | Cost for ~4 hours | Guide | + |----------|-------------------|-------| + | AWS | ~$0.01 (Free Tier eligible) | [AWS Guide](./aws/README.md) | + | Azure | ~$0.05 | [Azure Guide](./azure/README.md) | + | GCP | ~$0.03 | [GCP Guide](./gcp/README.md) | + +3. **Play** by connecting over SSH and solving challenges with the [Playing the Lab guide](./GUIDE.md). It covers the `verify` command and exporting your completion token. +4. **Clean up** with `terraform destroy` when you're done, after saving your token, so you aren't billed for a VM you've finished with. Running into problems? See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). @@ -44,6 +50,8 @@ Running into problems? See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). **Difficulty:** ⭐ Beginner | ⭐⭐ Intermediate | ⭐⭐⭐ Advanced +There are 18 challenges. `verify progress` reports `/19` because it also counts the practice flag (challenge 0) that checks `verify` works. + ## About Your Completion Certificate The certificate and token from `verify export` work on the honor system. They record that you finished the lab, but they can't prove it. You control the VM and have root on it, and the token's signing key is in this public repository, so anyone determined to fake a token can. diff --git a/aws/README.md b/aws/README.md index c9a6781..f90ce4b 100644 --- a/aws/README.md +++ b/aws/README.md @@ -1,31 +1,30 @@ # Linux Command Line CTF Lab - AWS -> [!IMPORTANT] +> [!IMPORTANT] > Please complete [Phase 1 Guide](https://learntocloud.guide/phase/1) before attempting these challenges. Do not share solutions publicly - focus on sharing your learning journey instead. +Deploy the lab here, then follow the [Playing the Lab guide](../GUIDE.md) to connect, capture flags, and export your certificate. + ## Contents - [Prerequisites](#prerequisites) - [Deploy the Lab](#deploy-the-lab) -- [Connect to the Lab](#connect-to-the-lab) -- [Capture Flags](#capture-flags) -- [Verify Commands](#verify-commands) -- [Finish the CTF](#finish-the-ctf) +- [Play the Lab](#play-the-lab) - [Pause the Lab](#pause-the-lab) - [Clean Up](#clean-up) - [Troubleshooting](#troubleshooting) -- [Security Note](#security-note) ## Prerequisites 1. [Terraform](https://www.terraform.io/downloads.html) (v1.9.0 or later) 2. [AWS CLI](https://aws.amazon.com/cli/) configured with your credentials +3. A GitHub fork of this repository ### Windows Run the AWS Terraform deployment from [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/windows/wsl/install). AWS release-mode readiness uses a local `/bin/sh` script to wait for Systems Manager Run Command, so native Windows PowerShell and Command Prompt are not currently supported. -Install Terraform and the AWS CLI inside your WSL distribution, then configure AWS credentials there before following the steps below: +Install Terraform and the AWS CLI inside your WSL distribution, then configure AWS credentials there before continuing: ```sh aws configure @@ -36,181 +35,85 @@ Credentials configured only in Windows PowerShell are not automatically availabl ## Deploy the Lab -1. Fork this repository. - -2. Clone your fork: +1. Clone your fork: ```sh git clone https://github.com//linux-ctfs cd linux-ctfs/aws ``` -3. Check which AWS regions are enabled for your account: - - ```sh - aws ec2 describe-regions \ - --region us-east-1 \ - --all-regions \ - --query "Regions[?OptInStatus=='opt-in-not-required' || OptInStatus=='opted-in'].{Name:RegionName,Status:OptInStatus}" \ - --output table - ``` - -4. Export one of the enabled regions before running Terraform: - - ```sh - export AWS_REGION=us-east-1 - ``` - -5. (Optional) Set the AWS region in a `terraform.tfvars` file using one of the enabled regions: - - ```sh - aws_region = "us-east-1" - ``` - - If you prefer not to use a `terraform.tfvars` file, you can pass the exported value directly to Terraform in the next step. - -6. Initialize and apply Terraform: +2. Initialize and apply Terraform with an AWS region enabled for your account (for example `us-east-1`): ```sh terraform init - - # If you set aws_region in terraform.tfvars - terraform apply - - # Or, if you want to pass the exported region directly - terraform apply -var="aws_region=$AWS_REGION" + terraform apply -var="aws_region=us-east-1" ``` - Type `yes` when prompted. - - If you run into errors when deploying, see [TROUBLESHOOTING.md](../TROUBLESHOOTING.md#aws) for common issues and fixes. - -7. Note the `public_ip_address` output—you'll use this to connect. - -## Connect to the Lab + Type `yes` when prompted. You can also set `aws_region = "us-east-1"` in a `terraform.tfvars` file and run `terraform apply` without `-var`. -1. Connect via SSH: +
+ Not sure which regions are enabled for your account? ```sh - ssh ctf_user@ + aws ec2 describe-regions \ + --region us-east-1 \ + --all-regions \ + --query "Regions[?OptInStatus=='opt-in-not-required' || OptInStatus=='opted-in'].{Name:RegionName,Status:OptInStatus}" \ + --output table ``` -1. On first login you will be asked if you want to add fingerprints to the known hosts file; type `yes` and press Enter. - -1. When prompted, enter the password: `CTFpassword123!` - -You'll see a welcome message when you're in. If SSH works but the lab doesn't seem ready yet, see [Lab not ready after SSH login](../TROUBLESHOOTING.md#lab-not-ready-after-ssh-login). - -## Capture Flags - -Each challenge hides a flag somewhere on the VM. Your job is to find it using the command line, then submit it with `verify`. - -- **What a flag looks like:** Flags are wrapped in `CTF{...}`. Every lab instance generates its own flags, so a flag from someone else's lab won't work in yours. -- **Start with the example:** Run `verify 0 CTF{example}` to confirm the `verify` command works. `verify progress` should then show `1/19`. -- **Pick a challenge:** Read the challenge descriptions in the [challenge list](../README.md#challenges). They're roughly ordered from easiest to hardest, but you can solve them in any order. -- **Submit what you find:** When you find a flag, run `verify `. For example, a flag for challenge 3 is submitted with `verify 3 CTF{...}`. -- **Challenge 10 starts on your computer:** Run it from a terminal on your own machine, not from inside the SSH session. - -Tips: - -- Use `man` pages to learn commands (e.g., `man find`). -- Combine commands with pipes (`|`) to process output. -- Use `verify hint ` when you're stuck. -- Experiment freely—you can't break anything permanently, and you can always redeploy. - -## Verify Commands +
-Run these on the lab VM. Running `verify` with no arguments prints the usage summary. +3. Note the `public_ip_address` output. You'll use it to connect. -| Command | What it does | -|---------|--------------| -| `verify ` | Submits a flag for challenge `0`-`18`. Tells you whether it's correct and shows your updated progress. Your first submission starts the timer. | -| `verify progress` | Shows how many flags you've found out of 19: the example flag (challenge 0) plus the 18 real challenges. | -| `verify list` | Lists every challenge by name, with `[✓]` next to the ones you've solved. | -| `verify hint ` | Shows a hint for challenge `0`-`18`. Hints nudge you toward the right tool or location; they don't give you the answer. | -| `verify time` | Shows wall clock time elapsed since your first submission, not active keyboard time. | -| `verify export ` | Available after you solve all 18 real challenges. Prints your completion certificate and completion token, and saves the certificate to `~/ctf_certificate_.txt`. | +If deployment fails, see [TROUBLESHOOTING.md](../TROUBLESHOOTING.md#aws). -How the timer works: +## Play the Lab -- The timer starts the first time you run `verify `. -- It keeps running while the VM is stopped, so paused time still counts. -- It freezes on your first successful `verify export` after you've solved all 18 real challenges. +Continue with the [Playing the Lab guide](../GUIDE.md): connect over SSH, capture flags, and export your completion token. -## Finish the CTF +## Pause the Lab -Once you've solved all 18 challenges, export your completion certificate: +To reduce cost while you're away, stop the VM: ```sh -verify export +terraform apply -var="aws_region=" -var ctf_instance_state="stopped" -auto-approve ``` -> [!IMPORTANT] -> Enter your GitHub username **exactly** as it appears on GitHub—no `@` symbol, no extra spaces, no special characters. For example: `verify export octocat` not `verify export @octocat`. - -Use the same GitHub account that owns your fork of this repository—verification checks for the fork. - -Save the token it prints—you'll need it to record your progress at [learntocloud.guide/phase1](https://learntocloud.guide/phase1). The full token is around **300+ characters**. If it isn't accepted, see [Completion token not accepted](../TROUBLESHOOTING.md#completion-token-not-accepted). - -Save your token before cleaning up—destroying the lab deletes the VM and everything on it. - -## Pause the Lab - -If you'd like to pause the lab, you can utilize the following commands to start or stop the VM and reduce lab cost: +Start it again: ```sh -# power off (stop instance) -terraform apply \ - -var ctf_instance_state="stopped" \ - -auto-approve - -# power on (start instance) -terraform apply \ - -var ctf_instance_state="running" \ - -auto-approve +terraform apply -var="aws_region=" -var ctf_instance_state="running" -auto-approve ``` -Note: This module uses an ephemeral public IP. After stopping/starting the instance, `public_ip_address` may change. - -After a restart, check for a new IP: +This module uses an ephemeral public IP, so it may change after a restart. Look up the new one: ```sh terraform output public_ip_address ``` -If you see a "Remote host identification has changed" warning after a restart, remove the old key, then reconnect: +If you see a "Remote host identification has changed" warning, remove the old key and reconnect: ```sh -# 1) Remove the old host key for that IP ssh-keygen -R - -# 2) Reconnect and accept the new key ssh ctf_user@ ``` > [!NOTE] -> `verify time` uses wall clock elapsed time. If the lab is stopped before you complete and export, stopped time still counts in elapsed time. +> `verify time` uses wall clock time, so stopped time still counts. ## Clean Up -Destroy the resources when you're done to avoid charges: +Save your completion token first: destroying the lab deletes the VM and everything on it. Then destroy the resources to avoid charges: ```sh -terraform destroy +terraform destroy -var="aws_region=" ``` Type `yes` when prompted. ## Troubleshooting -1. Ensure your AWS CLI is configured with valid credentials -2. Check that you're using Terraform v1.9.0 or later -3. Verify you have permissions to create EC2, VPC, Security Group, IAM role/profile, and SSM Run Command resources - -AWS release mode uses Systems Manager to wait for setup readiness. Terraform creates an EC2 instance profile with `AmazonSSMManagedInstanceCore`, lets the VM run setup through `user_data`, then sends an SSM Run Command to confirm the setup marker files. If `terraform apply` fails while waiting for readiness, see [AWS: SSM setup readiness errors](../TROUBLESHOOTING.md#aws-ssm-setup-readiness-errors). - -For region, quota, and organization policy errors, see the [AWS section of TROUBLESHOOTING.md](../TROUBLESHOOTING.md#aws). If problems persist, [open an issue](../TROUBLESHOOTING.md#getting-help--reporting-issues). - -## Security Note +Most `terraform apply` failures come from missing credentials (`aws sts get-caller-identity` should succeed), an old Terraform version, or missing permissions for EC2, VPC, Security Groups, IAM role/profile, and SSM Run Command. -This lab uses password authentication for simplicity. In production, use key-based authentication. +Release mode uses Systems Manager to wait for setup readiness, so a failure while waiting is covered in [AWS: SSM setup readiness errors](../TROUBLESHOOTING.md#aws-ssm-setup-readiness-errors). For region, quota, and organization policy errors, see the [AWS section of TROUBLESHOOTING.md](../TROUBLESHOOTING.md#aws). If problems persist, [open an issue](../TROUBLESHOOTING.md#getting-help--reporting-issues). diff --git a/azure/README.md b/azure/README.md index 57bc811..4aed476 100644 --- a/azure/README.md +++ b/azure/README.md @@ -1,48 +1,45 @@ # Linux Command Line CTF Lab - Azure -> [!IMPORTANT] +> [!IMPORTANT] > Please complete [Phase 1 Guide](https://learntocloud.guide/phase/1) before attempting these challenges. Do not share solutions publicly - focus on sharing your learning journey instead. +Deploy the lab here, then follow the [Playing the Lab guide](../GUIDE.md) to connect, capture flags, and export your certificate. + ## Contents - [Prerequisites](#prerequisites) - [Deploy the Lab](#deploy-the-lab) -- [Connect to the Lab](#connect-to-the-lab) -- [Capture Flags](#capture-flags) -- [Verify Commands](#verify-commands) -- [Finish the CTF](#finish-the-ctf) +- [Play the Lab](#play-the-lab) - [Pause the Lab](#pause-the-lab) - [Clean Up](#clean-up) - [Troubleshooting](#troubleshooting) -- [Security Note](#security-note) ## Prerequisites 1. [Terraform](https://developer.hashicorp.com/terraform/install) (v1.14.0 or later) 2. [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) 3. An Azure account with an active subscription +4. A GitHub fork of this repository -> [!NOTE] +> [!NOTE] > If you have an Azure Student account, you may encounter errors. See [this workaround](https://github.com/g-now-zero/l2c-guides/blob/main/posts/ctf-azure-spot-instances-guide.md). ## Deploy the Lab -1. Fork this repository. - -2. Clone your fork: +1. Clone your fork: ```sh git clone https://github.com//linux-ctfs cd linux-ctfs/azure ``` -3. Log in to Azure: +2. Log in to Azure: ```sh az login ``` -4. Initialize and apply Terraform: +3. Initialize and apply Terraform: ```sh terraform init @@ -51,125 +48,45 @@ -var az_region="YOUR_AZURE_REGION" ``` - Replace the values with your subscription ID and preferred region (defaults to East US). - - Type `yes` when prompted. - - If you run into errors when deploying, see [TROUBLESHOOTING.md](../TROUBLESHOOTING.md#azure) for common issues and fixes. + Replace the values with your subscription ID and preferred region (defaults to East US). Type `yes` when prompted. -5. Note the `public_ip_address` output—you'll use this to connect. +4. Note the `public_ip_address` output. You'll use it to connect. -### VM Size / Capacity Errors +If deployment fails, see [TROUBLESHOOTING.md](../TROUBLESHOOTING.md#azure). -If `terraform apply` fails with `SkuNotAvailable` or quota/capacity errors, the fastest fix is usually switching region and/or VM size. +### VM size / capacity errors -Defaults for this lab: -- Region: `East US` (`az_region`) -- VM size: `Standard_B1s` (`azure_vm_size`) +If `terraform apply` fails with `SkuNotAvailable` or quota/capacity errors, switching region and/or VM size (`azure_vm_size`, default `Standard_B1s`) is usually the fastest fix. See: -Use the full Azure troubleshooting steps here: - [Azure: SkuNotAvailable / Capacity errors](../TROUBLESHOOTING.md#azure-skunotavailable--capacity-errors) - [Azure: Quota limit errors](../TROUBLESHOOTING.md#azure-quota-limit-errors) -## Connect to the Lab - -1. Connect via SSH: - - ```sh - ssh ctf_user@ - ``` - -1. On first login you will be asked if you want to add fingerprints to the known hosts file; type `yes` and press Enter. - -1. When prompted, enter the password: `CTFpassword123!` - -You'll see a welcome message when you're in. If SSH works but the lab doesn't seem ready yet, see [Lab not ready after SSH login](../TROUBLESHOOTING.md#lab-not-ready-after-ssh-login). - -## Capture Flags - -Each challenge hides a flag somewhere on the VM. Your job is to find it using the command line, then submit it with `verify`. - -- **What a flag looks like:** Flags are wrapped in `CTF{...}`. Every lab instance generates its own flags, so a flag from someone else's lab won't work in yours. -- **Start with the example:** Run `verify 0 CTF{example}` to confirm the `verify` command works. `verify progress` should then show `1/19`. -- **Pick a challenge:** Read the challenge descriptions in the [challenge list](../README.md#challenges). They're roughly ordered from easiest to hardest, but you can solve them in any order. -- **Submit what you find:** When you find a flag, run `verify `. For example, a flag for challenge 3 is submitted with `verify 3 CTF{...}`. -- **Challenge 10 starts on your computer:** Run it from a terminal on your own machine, not from inside the SSH session. - -Tips: - -- Use `man` pages to learn commands (e.g., `man find`). -- Combine commands with pipes (`|`) to process output. -- Use `verify hint ` when you're stuck. -- Experiment freely—you can't break anything permanently, and you can always redeploy. - -## Verify Commands - -Run these on the lab VM. Running `verify` with no arguments prints the usage summary. - -| Command | What it does | -|---------|--------------| -| `verify ` | Submits a flag for challenge `0`-`18`. Tells you whether it's correct and shows your updated progress. Your first submission starts the timer. | -| `verify progress` | Shows how many flags you've found out of 19: the example flag (challenge 0) plus the 18 real challenges. | -| `verify list` | Lists every challenge by name, with `[✓]` next to the ones you've solved. | -| `verify hint ` | Shows a hint for challenge `0`-`18`. Hints nudge you toward the right tool or location; they don't give you the answer. | -| `verify time` | Shows wall clock time elapsed since your first submission, not active keyboard time. | -| `verify export ` | Available after you solve all 18 real challenges. Prints your completion certificate and completion token, and saves the certificate to `~/ctf_certificate_.txt`. | +## Play the Lab -How the timer works: - -- The timer starts the first time you run `verify `. -- It keeps running while the VM is stopped, so paused time still counts. -- It freezes on your first successful `verify export` after you've solved all 18 real challenges. - -## Finish the CTF - -Once you've solved all 18 challenges, export your completion certificate: - -```sh -verify export -``` - -> [!IMPORTANT] -> Enter your GitHub username **exactly** as it appears on GitHub—no `@` symbol, no extra spaces, no special characters. For example: `verify export octocat` not `verify export @octocat`. - -Use the same GitHub account that owns your fork of this repository—verification checks for the fork. - -Save the token it prints—you'll need it to record your progress at [learntocloud.guide/phase1](https://learntocloud.guide/phase1). The full token is around **300+ characters**. If it isn't accepted, see [Completion token not accepted](../TROUBLESHOOTING.md#completion-token-not-accepted). - -Save your token before cleaning up—destroying the lab deletes the VM and everything on it. +Continue with the [Playing the Lab guide](../GUIDE.md): connect over SSH, capture flags, and export your completion token. ## Pause the Lab -If you want to pause the lab and reduce cost, use these commands. - -Power off the VM: +To reduce cost while you're away, power off the VM: ```sh terraform apply -invoke=action.azurerm_virtual_machine_power.ctf_power_off ``` -Type `yes` when prompted. - -Power on the VM: +Power it back on: ```sh terraform apply -invoke=action.azurerm_virtual_machine_power.ctf_power_on ``` -Type `yes` when prompted. - -Connect again: - -```sh -ssh ctf_user@ -``` +Type `yes` when prompted for each. Then reconnect with `ssh ctf_user@`. > [!NOTE] -> `verify time` uses wall clock elapsed time. If the lab is powered off before you complete and export, powered-off time still counts in elapsed time. +> `verify time` uses wall clock time, so powered-off time still counts. ## Clean Up -Destroy the resources when you're done to avoid charges: +Save your completion token first: destroying the lab deletes the VM and everything on it. Then destroy the resources to avoid charges: ```sh terraform destroy @@ -179,11 +96,9 @@ Type `yes` when prompted. ## Troubleshooting -1. Ensure your Azure CLI is logged in with valid credentials -2. Check that you're using Terraform v1.14.0 or later -3. Verify you have permissions to create VMs, VNets, and Network Security Groups +Most `terraform apply` failures come from an expired `az login`, an old Terraform version, or missing permissions to create VMs, VNets, and Network Security Groups. -If release setup fails during `terraform apply`, Azure reports the failure through the VM Custom Script Extension. Useful VM-side logs are: +If release setup fails, Azure reports it through the VM Custom Script Extension. Useful VM-side logs: ```text /var/log/ctf_setup.log @@ -192,7 +107,3 @@ If release setup fails during `terraform apply`, Azure reports the failure throu ``` For SKU, capacity, and quota errors, see the [Azure section of TROUBLESHOOTING.md](../TROUBLESHOOTING.md#azure). If problems persist, [open an issue](../TROUBLESHOOTING.md#getting-help--reporting-issues). - -## Security Note - -This lab uses password authentication for simplicity. In production, use key-based authentication. diff --git a/gcp/README.md b/gcp/README.md index 29c42b1..de247e1 100644 --- a/gcp/README.md +++ b/gcp/README.md @@ -1,46 +1,43 @@ # Linux Command Line CTF Lab - GCP -> [!IMPORTANT] +> [!IMPORTANT] > Please complete [Phase 1 Guide](https://learntocloud.guide/phase/1) before attempting these challenges. Do not share solutions publicly - focus on sharing your learning journey instead. +Deploy the lab here, then follow the [Playing the Lab guide](../GUIDE.md) to connect, capture flags, and export your certificate. + ## Contents - [Prerequisites](#prerequisites) - [Deploy the Lab](#deploy-the-lab) -- [Connect to the Lab](#connect-to-the-lab) -- [Capture Flags](#capture-flags) -- [Verify Commands](#verify-commands) -- [Finish the CTF](#finish-the-ctf) +- [Play the Lab](#play-the-lab) - [Pause the Lab](#pause-the-lab) - [Clean Up](#clean-up) - [Troubleshooting](#troubleshooting) -- [Security Note](#security-note) ## Prerequisites 1. [Terraform](https://developer.hashicorp.com/terraform/install) (v1.9.0 or later) 2. [gcloud CLI](https://cloud.google.com/sdk/docs/install) 3. A Google Cloud account with a project and billing enabled +4. A GitHub fork of this repository ## Deploy the Lab -1. Fork this repository. - -2. Clone your fork: +1. Clone your fork: ```sh git clone https://github.com//linux-ctfs cd linux-ctfs/gcp ``` -3. Log in to Google Cloud: +2. Log in to Google Cloud: ```sh gcloud auth login gcloud auth application-default login ``` -4. Initialize and apply Terraform: +3. Initialize and apply Terraform: ```sh terraform init @@ -50,98 +47,31 @@ -var gcp_zone="YOUR_GCP_ZONE" ``` - Replace the values with your project ID and preferred region/zone (defaults to us-central1/us-central1-a). - - Type `yes` when prompted. - - If you run into errors when deploying, see [TROUBLESHOOTING.md](../TROUBLESHOOTING.md#gcp) for common issues and fixes. - -5. Note the `public_ip_address` output—you'll use this to connect. - -## Connect to the Lab - -1. Connect via SSH: - - ```sh - ssh ctf_user@ - ``` - -1. On first login you will be asked if you want to add fingerprints to the known hosts file; type `yes` and press Enter. - -1. When prompted, enter the password: `CTFpassword123!` - -You'll see a welcome message when you're in. If SSH works but the lab doesn't seem ready yet, see [Lab not ready after SSH login](../TROUBLESHOOTING.md#lab-not-ready-after-ssh-login). - -## Capture Flags - -Each challenge hides a flag somewhere on the VM. Your job is to find it using the command line, then submit it with `verify`. - -- **What a flag looks like:** Flags are wrapped in `CTF{...}`. Every lab instance generates its own flags, so a flag from someone else's lab won't work in yours. -- **Start with the example:** Run `verify 0 CTF{example}` to confirm the `verify` command works. `verify progress` should then show `1/19`. -- **Pick a challenge:** Read the challenge descriptions in the [challenge list](../README.md#challenges). They're roughly ordered from easiest to hardest, but you can solve them in any order. -- **Submit what you find:** When you find a flag, run `verify `. For example, a flag for challenge 3 is submitted with `verify 3 CTF{...}`. -- **Challenge 10 starts on your computer:** Run it from a terminal on your own machine, not from inside the SSH session. + Replace the values with your project ID and preferred region/zone (defaults to us-central1/us-central1-a). Type `yes` when prompted. -Tips: +4. Note the `public_ip_address` output. You'll use it to connect. -- Use `man` pages to learn commands (e.g., `man find`). -- Combine commands with pipes (`|`) to process output. -- Use `verify hint ` when you're stuck. -- Experiment freely—you can't break anything permanently, and you can always redeploy. +If deployment fails, see [TROUBLESHOOTING.md](../TROUBLESHOOTING.md#gcp). -## Verify Commands +## Play the Lab -Run these on the lab VM. Running `verify` with no arguments prints the usage summary. - -| Command | What it does | -|---------|--------------| -| `verify ` | Submits a flag for challenge `0`-`18`. Tells you whether it's correct and shows your updated progress. Your first submission starts the timer. | -| `verify progress` | Shows how many flags you've found out of 19: the example flag (challenge 0) plus the 18 real challenges. | -| `verify list` | Lists every challenge by name, with `[✓]` next to the ones you've solved. | -| `verify hint ` | Shows a hint for challenge `0`-`18`. Hints nudge you toward the right tool or location; they don't give you the answer. | -| `verify time` | Shows wall clock time elapsed since your first submission, not active keyboard time. | -| `verify export ` | Available after you solve all 18 real challenges. Prints your completion certificate and completion token, and saves the certificate to `~/ctf_certificate_.txt`. | - -How the timer works: - -- The timer starts the first time you run `verify `. -- It keeps running while the VM is stopped, so paused time still counts. -- It freezes on your first successful `verify export` after you've solved all 18 real challenges. - -## Finish the CTF - -Once you've solved all 18 challenges, export your completion certificate: - -```sh -verify export -``` - -> [!IMPORTANT] -> Enter your GitHub username **exactly** as it appears on GitHub—no `@` symbol, no extra spaces, no special characters. For example: `verify export octocat` not `verify export @octocat`. - -Use the same GitHub account that owns your fork of this repository—verification checks for the fork. - -Save the token it prints—you'll need it to record your progress at [learntocloud.guide/phase1](https://learntocloud.guide/phase1). The full token is around **300+ characters**. If it isn't accepted, see [Completion token not accepted](../TROUBLESHOOTING.md#completion-token-not-accepted). - -Save your token before cleaning up—destroying the lab deletes the VM and everything on it. +Continue with the [Playing the Lab guide](../GUIDE.md): connect over SSH, capture flags, and export your completion token. ## Pause the Lab -If you want to pause the lab and reduce cost, stop the VM with the gcloud CLI. Use the same zone you deployed to (default `us-central1-a`). - -Power off the VM: +To reduce cost while you're away, stop the VM. Use the same zone you deployed to (default `us-central1-a`). ```sh gcloud compute instances stop ctf-instance --zone=YOUR_GCP_ZONE ``` -Power on the VM: +Start it again: ```sh gcloud compute instances start ctf-instance --zone=YOUR_GCP_ZONE ``` -This lab uses an ephemeral public IP, so the IP may change after a restart. Look up the current IP: +This lab uses an ephemeral public IP, so it may change after a restart. Look up the new one: ```sh gcloud compute instances describe ctf-instance \ @@ -149,22 +79,19 @@ gcloud compute instances describe ctf-instance \ --format="value(networkInterfaces[0].accessConfigs[0].natIP)" ``` -If you see a "Remote host identification has changed" warning after a restart, remove the old key, then reconnect: +If you see a "Remote host identification has changed" warning, remove the old key and reconnect: ```sh -# 1) Remove the old host key for that IP ssh-keygen -R - -# 2) Reconnect and accept the new key ssh ctf_user@ ``` > [!NOTE] -> `verify time` uses wall clock elapsed time. If the lab is stopped before you complete and export, stopped time still counts in elapsed time. +> `verify time` uses wall clock time, so stopped time still counts. ## Clean Up -Destroy the resources when you're done to avoid charges: +Save your completion token first: destroying the lab deletes the VM and everything on it. Then destroy the resources to avoid charges: ```sh terraform destroy @@ -174,12 +101,6 @@ Type `yes` when prompted. ## Troubleshooting -1. Ensure your gcloud CLI is authenticated -2. Check that you're using Terraform v1.9.0 or later -3. Verify you have permissions to create Compute Engine instances and firewall rules - -See [TROUBLESHOOTING.md](../TROUBLESHOOTING.md) for common issues. If problems persist, [open an issue](../TROUBLESHOOTING.md#getting-help--reporting-issues). - -## Security Note +Most `terraform apply` failures come from missing gcloud authentication (both `auth login` steps), an old Terraform version, a project without billing enabled, or missing permissions to create Compute Engine instances and firewall rules. -This lab uses password authentication for simplicity. In production, use key-based authentication. +See the [GCP section of TROUBLESHOOTING.md](../TROUBLESHOOTING.md#gcp). If problems persist, [open an issue](../TROUBLESHOOTING.md#getting-help--reporting-issues). From 9d31875e64f4d206124cea2edc8f70dbf0e326a9 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 10:55:32 -0400 Subject: [PATCH 07/10] chore: enforce Terraform >= 1.9.0 for AWS and GCP Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7ca812f-6f5f-4846-b188-475430b77721 --- .github/skills/ctf-testing/deploy_and_test.sh | 2 +- CONTRIBUTING.md | 2 +- aws/main.tf | 2 ++ gcp/main.tf | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/skills/ctf-testing/deploy_and_test.sh b/.github/skills/ctf-testing/deploy_and_test.sh index 43dd203..234be09 100755 --- a/.github/skills/ctf-testing/deploy_and_test.sh +++ b/.github/skills/ctf-testing/deploy_and_test.sh @@ -15,7 +15,7 @@ # reboot and progress persists # # Prerequisites: -# - terraform (>= 1.0; Azure requires >= 1.14.0) +# - terraform (>= 1.9.0; Azure requires >= 1.14.0) # - jq (for AWS terraform config) # - sshpass (macOS: brew install hudochenkov/sshpass/sshpass) # - aws CLI (for AWS, must be logged in) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ddf779..40e8901 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ All PRs that change setup, challenges, verify behavior, or Terraform should be t Install: -1. `terraform` 1.0 or newer; Azure requires Terraform 1.14.0 or newer +1. `terraform` 1.9.0 or newer; Azure requires Terraform 1.14.0 or newer 2. `jq` 3. `sshpass` 4. The cloud CLI for the provider you want to test diff --git a/aws/main.tf b/aws/main.tf index 4c45ea2..58e9969 100644 --- a/aws/main.tf +++ b/aws/main.tf @@ -1,5 +1,7 @@ terraform { + required_version = ">= 1.9.0" + required_providers { aws = { source = "hashicorp/aws" diff --git a/gcp/main.tf b/gcp/main.tf index 295a04f..778059b 100644 --- a/gcp/main.tf +++ b/gcp/main.tf @@ -1,4 +1,6 @@ terraform { + required_version = ">= 1.9.0" + required_providers { google = { source = "hashicorp/google" From 9ea3e6536098f344418237eaa00d75a9fdad369f Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 12:45:54 -0400 Subject: [PATCH 08/10] Harden challenge setup behavior Make nginx directive edits fail clearly on unexpected templates, centralize learner artifact ownership, scope history-file ownership, and clarify the cron challenge description. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80c0c0ee-bd2b-41fb-804d-9b865e59da2c --- README.md | 2 +- setup/challenges/__init__.py | 3 +++ setup/challenges/ch11_web_config.py | 20 ++++++++++++++++---- setup/challenges/ch17_history.py | 11 ++++++++--- setup/challenges/ch18_disk_detective.py | 3 +-- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f40f0d1..b6bfb3a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Running into problems? See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). | 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag. It is broadcast to your open terminals | ⭐⭐ | File transfer, SCP | | 11 | Web Configuration | nginx should serve the site on port 80 but is misconfigured. Find and fix it | ⭐⭐ | Nginx, `nginx -t`, services | | 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets on the loopback interface (needs `sudo`) | ⭐⭐⭐ | Packet inspection, tcpdump | -| 13 | Cron Job Hunter | A scheduled job handles a secret. Find out what it does and catch it in the act | ⭐⭐ | Cron, scheduling | +| 13 | Cron Job Hunter | A scheduled job handles a secret. Find out what it runs and inspect the result | ⭐⭐ | 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. The flag is where the trail ends | ⭐⭐ | Symlinks, `readlink` | diff --git a/setup/challenges/__init__.py b/setup/challenges/__init__.py index fca8c6f..fd5aa5d 100644 --- a/setup/challenges/__init__.py +++ b/setup/challenges/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +from helpers import recursive_chown + from . import ( ch01_hidden_file, ch02_file_search, @@ -44,3 +46,4 @@ def setup_all_challenges(flags: dict[int, str]) -> None: ch18_disk_detective, ): module.setup(flags) + recursive_chown("/home/ctf_user/ctf_challenges", "ctf_user", "ctf_user") diff --git a/setup/challenges/ch11_web_config.py b/setup/challenges/ch11_web_config.py index 6f79f63..94dcb57 100644 --- a/setup/challenges/ch11_web_config.py +++ b/setup/challenges/ch11_web_config.py @@ -5,6 +5,19 @@ from helpers import restart_service, write_file +def replace_once(content: str, old: str, new: str) -> str: + old_count = content.count(old) + new_count = content.count(new) + if old_count == 1 and new_count == 0: + return content.replace(old, new, 1) + if old_count == 0 and new_count == 1: + return content + raise RuntimeError( + f"Expected exactly one original or modified nginx directive, " + f"found {old_count} original and {new_count} modified: {old!r}" + ) + + def setup(flags: dict[int, str]) -> None: write_file( "/var/www/html/index.html", @@ -12,9 +25,8 @@ def setup(flags: dict[int, str]) -> None: ) 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;") - # Wrong port and a typo in the document root: both must be fixed to serve the flag on port 80. - content = content.replace("root /var/www/html;", "root /var/www/htm;") + content = replace_once(content, "listen 80 default_server;", "listen 8083 default_server;") + content = replace_once(content, "listen [::]:80 default_server;", "listen [::]:8083 default_server;") + content = replace_once(content, "root /var/www/html;", "root /var/www/htm;") nginx_default.write_text(content) restart_service("nginx") diff --git a/setup/challenges/ch17_history.py b/setup/challenges/ch17_history.py index e1dedef..ea3174d 100644 --- a/setup/challenges/ch17_history.py +++ b/setup/challenges/ch17_history.py @@ -3,7 +3,7 @@ from pathlib import Path import random -from helpers import ensure_user, recursive_chown, write_file +from helpers import ensure_user, write_file COMMANDS = [ @@ -19,6 +19,11 @@ def setup(flags: dict[int, str]) -> None: rng = random.Random() lines = [rng.choice(COMMANDS) for _ in range(400)] lines.insert(rng.randrange(100, 300), f"export DEPLOY_KEY={flags[17]}") - write_file("/home/old_admin/.bash_history", "\n".join(lines) + "\nexit\n", mode=0o644) - recursive_chown("/home/old_admin", "old_admin", "old_admin") + write_file( + "/home/old_admin/.bash_history", + "\n".join(lines) + "\nexit\n", + mode=0o644, + owner="old_admin", + group="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 e2723cc..81523a0 100644 --- a/setup/challenges/ch18_disk_detective.py +++ b/setup/challenges/ch18_disk_detective.py @@ -1,10 +1,9 @@ from __future__ import annotations -from helpers import recursive_chown, run +from helpers import run def setup(flags: dict[int, str]) -> None: run(["dd", "if=/dev/zero", "of=/opt/ctf_disk.img", "bs=1M", "count=10"]) # ext4 labels hold 16 bytes, exactly the length of a flag. run(["mkfs.ext4", "-F", "-L", flags[18], "/opt/ctf_disk.img"]) - recursive_chown("/home/ctf_user/ctf_challenges", "ctf_user", "ctf_user") From 53bd40875acecc5f08d7a2d8767637ba08bac560 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 17:23:01 -0400 Subject: [PATCH 09/10] Standardize titles and hints; harden challenges 11, 16, 17 - Align challenge titles across README, verify names, certificate and test script; README table is the source of truth. - Add a module docstring to every setup/challenges/chNN_*.py. - Rewrite all 18 hints as nudges that point at concepts, not the exact tool, field or flag. README Skills column is concept-only. - ch11: write the broken nginx config directly instead of patching the default one with replace_once. - ch16: five-link chain with a relative ../ hop and decoy links to a fake flag; README says to start at follow_me. - ch17: three users' histories with decoy secrets; the flag is in a curl header, not an export line. - ch18: test now reads the label without sudo, matching the learner path. - AGENTS.md: document the title, docstring and hint standards. Tested: basic run passes 28/28 on GCP and Azure. AWS and reboot not run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4703c39f-f11c-4c63-b095-09708ed6ff8f --- .../skills/ctf-testing/test_ctf_challenges.sh | 34 ++++----- AGENTS.md | 5 +- README.md | 32 ++++---- setup/challenges/ch01_hidden_file.py | 7 ++ setup/challenges/ch02_file_search.py | 7 ++ setup/challenges/ch03_log_analysis.py | 7 ++ setup/challenges/ch04_user_investigation.py | 7 ++ setup/challenges/ch05_permissions.py | 7 ++ setup/challenges/ch06_service_discovery.py | 7 ++ setup/challenges/ch07_encoding.py | 7 ++ setup/challenges/ch08_ssh_secrets.py | 7 ++ setup/challenges/ch09_dns.py | 7 ++ setup/challenges/ch10_remote_upload.py | 7 ++ setup/challenges/ch11_web_config.py | 45 +++++------ setup/challenges/ch12_network_traffic.py | 7 ++ setup/challenges/ch13_cron.py | 7 ++ setup/challenges/ch14_process_env.py | 7 ++ setup/challenges/ch15_archive.py | 7 ++ setup/challenges/ch16_symlinks.py | 22 +++++- setup/challenges/ch17_history.py | 51 ++++++++++--- setup/challenges/ch18_disk_detective.py | 7 ++ verify/src/verify/commands.py | 74 +++++++++---------- 22 files changed, 263 insertions(+), 105 deletions(-) diff --git a/.github/skills/ctf-testing/test_ctf_challenges.sh b/.github/skills/ctf-testing/test_ctf_challenges.sh index b3f1a1e..263096e 100644 --- a/.github/skills/ctf-testing/test_ctf_challenges.sh +++ b/.github/skills/ctf-testing/test_ctf_challenges.sh @@ -244,9 +244,9 @@ echo "" # Store discovered flags declare -A FLAGS -# Challenge 1: Hidden File Discovery +# Challenge 1: The Hidden File # Hint: "Hidden files in Linux start with a dot. Try 'ls -la'" -echo "Challenge 1: Hidden File Discovery" +echo "Challenge 1: The Hidden File" HIDDEN_FILE=$(ls -la /home/ctf_user/ctf_challenges/ 2>/dev/null \ | awk '/^-.*\./ {print $NF}' \ | grep '^\.' \ @@ -266,9 +266,9 @@ else FLAGS[1]="" fi -# Challenge 2: Basic File Search +# Challenge 2: The Secret File # Hint: "Use 'find' to search by name. Try: find ~ -type f -iname '*secret*'" -echo "Challenge 2: Basic File Search" +echo "Challenge 2: The Secret File" TXT_FILE=$(find /home/ctf_user/documents -name '*.txt' -type f 2>/dev/null | head -1) || true if [[ -n "${TXT_FILE}" ]]; then FLAG_2=$(cat "${TXT_FILE}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true @@ -283,9 +283,9 @@ else FLAGS[2]="" fi -# Challenge 3: Log Analysis +# Challenge 3: The Odd Log Entry # Hint: "Find the biggest file in /var/log (ls -lS), then filter noise with grep -v" -echo "Challenge 3: Log Analysis" +echo "Challenge 3: The Odd Log Entry" LARGE_LOG=$(ls -S /var/log/*.log 2>/dev/null | head -1) || true if [[ -n "${LARGE_LOG}" ]]; then FLAG_3=$(grep -v 'Failed password' "${LARGE_LOG}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true @@ -300,9 +300,9 @@ else FLAGS[3]="" fi -# Challenge 4: User Investigation +# Challenge 4: The User Detective # Hint: "Compare the users with 'getent passwd' and look at the fifth field" -echo "Challenge 4: User Investigation" +echo "Challenge 4: The User Detective" FLAG_4=$(getent passwd | awk -F: '$3 >= 1000 && $1 != "ctf_user" && $1 != "nobody" {print $5}' \ | grep -ao 'CTF{[^}]*}' | head -1) || true if [[ -n "${FLAG_4}" ]]; then @@ -312,9 +312,9 @@ else FLAGS[4]="" fi -# Challenge 5: Permission Analysis +# Challenge 5: The Permissive File # Hint: "Try: find /opt -type f -perm -o+w"; the file points at a locked key owned by ctf_user -echo "Challenge 5: Permission Analysis" +echo "Challenge 5: The Permissive File" FLAG_5="" POINTER=$(find /opt -type f -perm -o+w -not -path '/opt/uv/*' 2>/dev/null | head -1) || true LOCKED=$(grep -ao '/opt/[^ ]*\.key' "${POINTER}" 2>/dev/null | head -1) || true @@ -334,9 +334,9 @@ else FLAGS[5]="" fi -# Challenge 6: Service Discovery +# Challenge 6: The Hidden Service # Hint: "What services are running? Use 'ss -tulpn' to find listening ports" -echo "Challenge 6: Service Discovery" +echo "Challenge 6: The Hidden Service" FLAG_6="" for port in $(ss -tulpn 2>/dev/null \ | awk '/LISTEN/ {split($5,a,":"); print a[length(a)]}' \ @@ -354,9 +354,9 @@ else FLAGS[6]="" fi -# Challenge 7: Encoding Challenge +# Challenge 7: The Encoded Secret # Hint: "The flag is encoded. Use 'base64 -d' to decode" -echo "Challenge 7: Encoding Challenge" +echo "Challenge 7: The Encoded Secret" ENCODED_FILE=$(find /home/ctf_user/ctf_challenges -name '*.txt' -type f 2>/dev/null | head -1) || true if [[ -n "${ENCODED_FILE}" ]]; then FLAG_7=$(cat "${ENCODED_FILE}" 2>/dev/null \ @@ -571,14 +571,14 @@ else fi # Challenge 17: History Mystery -# Hint: "Search other users' ~/.bash_history for keywords like 'export'" +# Hint: "Search every user's ~/.bash_history; the flag is not in an export line" 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 -a 'export' "${home}/.bash_history" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true + FLAG_17=$(grep -ao 'CTF{[^}]*}' "${home}/.bash_history" 2>/dev/null | head -1) || true [[ -n "${FLAG_17}" ]] && break done if [[ -n "${FLAG_17}" ]]; then @@ -593,7 +593,7 @@ fi echo "Challenge 18: Disk Detective" DISK_IMG=$(find /opt /home -name '*.img' -type f 2>/dev/null | head -1) || true if [[ -n "${DISK_IMG}" ]]; then - FLAG_18=$(echo 'CTFpassword123!' | sudo -S blkid -o value -s LABEL "${DISK_IMG}" 2>/dev/null \ + FLAG_18=$(blkid -o value -s LABEL "${DISK_IMG}" 2>/dev/null \ | grep -ao 'CTF{[^}]*}' | head -1) || true if [[ -n "${FLAG_18}" ]]; then _verify_flag 18 "${FLAG_18}" diff --git a/AGENTS.md b/AGENTS.md index 622d3b3..970584a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,10 @@ The VM has a built-in hint system. `verify hint ` is the sanctioned nudg You're working on the lab infrastructure, and the normal rules apply. See `.github/copilot-instructions.md` for project structure, challenge authoring, and testing workflow. -Two things specific to this repo: +Things specific to this repo: - Flags are derived per instance in `setup/flags.py`; they are never checked into source. Keep it that way. - Solution commands belong in `.github/skills/` only. Don't let them leak into `README.md`, challenge text, or setup code. +- Challenge titles come from the `README.md` table. Keep them identical in `CHALLENGE_NAMES` and the certificate list in `verify/src/verify/commands.py`, and in the labels in `.github/skills/ctf-testing/test_ctf_challenges.sh`. +- Every `setup/challenges/chNN_*.py` starts with a module docstring: title, learner goal, skills tested, and a one-line "Plants:" note. Say what the challenge tests, never the command that solves it. +- Hints in `CHALLENGE_HINTS` are nudges. Point at the concept or a `man` page, not the exact tool, field, or flag. The README "Skills" column follows the same rule: name concepts, not the commands that solve the challenge. diff --git a/README.md b/README.md index b6bfb3a..86f3a8b 100644 --- a/README.md +++ b/README.md @@ -29,24 +29,24 @@ Running into problems? See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). | # | Challenge | Description | Difficulty | Skills | |---|-----------|-------------|------------|--------| -| 1 | The Hidden File | Find and read a hidden file in `ctf_challenges` | ⭐ | Hidden files, `ls` | -| 2 | The Secret File | Locate a regular file (not a directory) with "secret" in its name under your home directory | ⭐ | File searching, `find` | -| 3 | The Odd Log Entry | Thousands of failed logins hide a single successful one in a log under `/var/log` | ⭐⭐ | `grep`, log analysis | -| 4 | The User Detective | Another user's account record carries a flag | ⭐⭐ | Users, `getent passwd` | -| 5 | The Permissive File | Find a suspicious file with wide-open permissions under `/opt`, then follow where it leads | ⭐⭐ | Permissions, `chmod` | +| 1 | The Hidden File | Find and read a hidden file in `ctf_challenges` | ⭐ | Hidden files, directory listing | +| 2 | The Secret File | Locate a regular file (not a directory) with "secret" in its name under your home directory | ⭐ | File searching | +| 3 | The Odd Log Entry | Thousands of failed logins hide a single successful one in a log under `/var/log` | ⭐⭐ | Log analysis, text filtering | +| 4 | The User Detective | Another user's account record carries a flag | ⭐⭐ | Users, account records | +| 5 | The Permissive File | Find a suspicious file with wide-open permissions under `/opt`, then follow where it leads | ⭐⭐ | Permissions, file ownership | | 6 | The Hidden Service | Something is listening on port 8080. Connect to it | ⭐⭐ | Networking, ports | -| 7 | The Encoded Secret | Find and decode an encoded flag in `ctf_challenges` | ⭐⭐ | Base64, encoding | -| 8 | SSH Key Authentication | Set up SSH key authentication to log in as the key-only `vault` user | ⭐⭐⭐ | `ssh-keygen`, `authorized_keys` | -| 9 | DNS Inspection | Find the lab's custom search domain and resolve a host inside it | ⭐⭐ | DNS, `resolvectl`, `getent hosts` | -| 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag. It is broadcast to your open terminals | ⭐⭐ | File transfer, SCP | -| 11 | Web Configuration | nginx should serve the site on port 80 but is misconfigured. Find and fix it | ⭐⭐ | Nginx, `nginx -t`, services | -| 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets on the loopback interface (needs `sudo`) | ⭐⭐⭐ | Packet inspection, tcpdump | +| 7 | The Encoded Secret | Find and decode an encoded flag in `ctf_challenges` | ⭐⭐ | Encoding, decoding | +| 8 | SSH Key Authentication | Set up SSH key authentication to log in as the key-only `vault` user | ⭐⭐⭐ | SSH key authentication, users | +| 9 | DNS Inspection | Find the lab's custom search domain and resolve a host inside it | ⭐⭐ | DNS, name resolution | +| 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag. It is broadcast to your open terminals | ⭐⭐ | File transfer | +| 11 | Web Configuration | nginx should serve the site on port 80 but is misconfigured. Find and fix it | ⭐⭐ | Web servers, services, config debugging | +| 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets on the loopback interface (needs `sudo`) | ⭐⭐⭐ | Packet inspection | | 13 | Cron Job Hunter | A scheduled job handles a secret. Find out what it runs and inspect the result | ⭐⭐ | 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. The flag is where the trail ends | ⭐⭐ | Symlinks, `readlink` | -| 17 | History Mystery | Someone typed a secret into their command line. Search their history | ⭐⭐ | Bash history, `grep` | -| 18 | Disk Detective | A flag is hidden in filesystem metadata. Inspect the disk image | ⭐⭐⭐ | Disk images, `blkid` | +| 14 | Process Environment | A running process has a secret in its environment. Extract it | ⭐⭐⭐ | Processes, environment variables | +| 15 | Archive Archaeologist | A flag is buried inside nested archives. Dig it out | ⭐⭐ | Archives, compression | +| 16 | Symbolic Sleuth | Start at `follow_me` in `~/ctf_challenges` and follow the trail of symbolic links. The flag is where the trail ends | ⭐⭐ | Symbolic links | +| 17 | History Mystery | Someone typed a secret into their command line. Find it. Not every secret is the real one | ⭐⭐ | Shell history, text search | +| 18 | Disk Detective | A flag is hidden in filesystem metadata. Inspect the disk image | ⭐⭐⭐ | Disk images, filesystem metadata | **Difficulty:** ⭐ Beginner | ⭐⭐ Intermediate | ⭐⭐⭐ Advanced diff --git a/setup/challenges/ch01_hidden_file.py b/setup/challenges/ch01_hidden_file.py index 0d41cd2..ecdfb6d 100644 --- a/setup/challenges/ch01_hidden_file.py +++ b/setup/challenges/ch01_hidden_file.py @@ -1,3 +1,10 @@ +"""Challenge 1: The Hidden File. + +Learner goal: find and read a hidden file in ctf_challenges. +Skills tested: hidden files, directory listing. +Plants: a dotfile in /home/ctf_user/ctf_challenges containing flags[1]. +""" + from __future__ import annotations from helpers import write_file diff --git a/setup/challenges/ch02_file_search.py b/setup/challenges/ch02_file_search.py index 49d95c4..10aec9b 100644 --- a/setup/challenges/ch02_file_search.py +++ b/setup/challenges/ch02_file_search.py @@ -1,3 +1,10 @@ +"""Challenge 2: The Secret File. + +Learner goal: locate a regular file with "secret" in its name under the home directory. +Skills tested: file searching. +Plants: a nested file under /home/ctf_user/documents containing flags[2]. +""" + from __future__ import annotations from helpers import write_file diff --git a/setup/challenges/ch03_log_analysis.py b/setup/challenges/ch03_log_analysis.py index b44c60a..d0ee5c8 100644 --- a/setup/challenges/ch03_log_analysis.py +++ b/setup/challenges/ch03_log_analysis.py @@ -1,3 +1,10 @@ +"""Challenge 3: The Odd Log Entry. + +Learner goal: find the single successful login among thousands of failed ones in a log under /var/log. +Skills tested: log analysis, text filtering. +Plants: /var/log/auth_audit.log with flags[3] on the one "Accepted" line. +""" + from __future__ import annotations import random diff --git a/setup/challenges/ch04_user_investigation.py b/setup/challenges/ch04_user_investigation.py index ae92494..7744f6a 100644 --- a/setup/challenges/ch04_user_investigation.py +++ b/setup/challenges/ch04_user_investigation.py @@ -1,3 +1,10 @@ +"""Challenge 4: The User Detective. + +Learner goal: find the flag in another user's account record. +Skills tested: users, account records. +Plants: flag_user with flags[4] in its GECOS (comment) field. +""" + from __future__ import annotations from helpers import ensure_user, run diff --git a/setup/challenges/ch05_permissions.py b/setup/challenges/ch05_permissions.py index d538895..92966c5 100644 --- a/setup/challenges/ch05_permissions.py +++ b/setup/challenges/ch05_permissions.py @@ -1,3 +1,10 @@ +"""Challenge 5: The Permissive File. + +Learner goal: find a suspicious wide-open file under /opt, then follow where it leads. +Skills tested: permissions, file ownership. +Plants: /opt/systems config that points to /opt/systems/keys/master.key (mode 000) holding flags[5]. +""" + from __future__ import annotations from pathlib import Path diff --git a/setup/challenges/ch06_service_discovery.py b/setup/challenges/ch06_service_discovery.py index bbe89ac..33008c7 100644 --- a/setup/challenges/ch06_service_discovery.py +++ b/setup/challenges/ch06_service_discovery.py @@ -1,3 +1,10 @@ +"""Challenge 6: The Hidden Service. + +Learner goal: discover what is listening on port 8080 and connect to it. +Skills tested: networking, ports. +Plants: a systemd service that serves flags[6] over HTTP on port 8080. +""" + from __future__ import annotations from helpers import enable_service, write_executable, write_file, write_service diff --git a/setup/challenges/ch07_encoding.py b/setup/challenges/ch07_encoding.py index a49bb55..831a125 100644 --- a/setup/challenges/ch07_encoding.py +++ b/setup/challenges/ch07_encoding.py @@ -1,3 +1,10 @@ +"""Challenge 7: The Encoded Secret. + +Learner goal: find and decode an encoded flag in ctf_challenges. +Skills tested: encoding, decoding. +Plants: /home/ctf_user/ctf_challenges/encoded_flag.txt with an encoded flags[7]. +""" + from __future__ import annotations import base64 diff --git a/setup/challenges/ch08_ssh_secrets.py b/setup/challenges/ch08_ssh_secrets.py index bf365d9..b11bb6d 100644 --- a/setup/challenges/ch08_ssh_secrets.py +++ b/setup/challenges/ch08_ssh_secrets.py @@ -1,3 +1,10 @@ +"""Challenge 8: SSH Key Authentication. + +Learner goal: set up key authentication to log in as the key-only vault user. +Skills tested: SSH key authentication, users. +Plants: vault user, sshd config, and login helpers that reveal flags[8] once key auth works. +""" + from __future__ import annotations from pathlib import Path diff --git a/setup/challenges/ch09_dns.py b/setup/challenges/ch09_dns.py index 80ef189..f64a6bc 100644 --- a/setup/challenges/ch09_dns.py +++ b/setup/challenges/ch09_dns.py @@ -1,3 +1,10 @@ +"""Challenge 9: DNS Inspection. + +Learner goal: find the lab's custom search domain and resolve a host inside it. +Skills tested: DNS, name resolution. +Plants: a resolved.conf.d search domain and an /etc/hosts entry carrying flags[9]. +""" + from __future__ import annotations from helpers import append_line_once, restart_service, write_file diff --git a/setup/challenges/ch10_remote_upload.py b/setup/challenges/ch10_remote_upload.py index f10f141..c5e3f6e 100644 --- a/setup/challenges/ch10_remote_upload.py +++ b/setup/challenges/ch10_remote_upload.py @@ -1,3 +1,10 @@ +"""Challenge 10: Remote Upload. + +Learner goal: upload a new file into ~/ctf_challenges from their own computer to trigger the flag. +Skills tested: file transfer. +Plants: a directory-monitor service that broadcasts flags[10] to open terminals on new files. +""" + from __future__ import annotations from helpers import enable_service, write_executable, write_file, write_service diff --git a/setup/challenges/ch11_web_config.py b/setup/challenges/ch11_web_config.py index 94dcb57..1af74d9 100644 --- a/setup/challenges/ch11_web_config.py +++ b/setup/challenges/ch11_web_config.py @@ -1,21 +1,13 @@ -from __future__ import annotations - -from pathlib import Path +"""Challenge 11: Web Configuration. -from helpers import restart_service, write_file +Learner goal: nginx should serve the site on port 80 but is misconfigured; find and fix it. +Skills tested: web servers, services, config debugging. +Plants: a deliberately broken nginx default site and a web root that reveals flags[11] once fixed. +""" +from __future__ import annotations -def replace_once(content: str, old: str, new: str) -> str: - old_count = content.count(old) - new_count = content.count(new) - if old_count == 1 and new_count == 0: - return content.replace(old, new, 1) - if old_count == 0 and new_count == 1: - return content - raise RuntimeError( - f"Expected exactly one original or modified nginx directive, " - f"found {old_count} original and {new_count} modified: {old!r}" - ) +from helpers import restart_service, write_file def setup(flags: dict[int, str]) -> None: @@ -23,10 +15,21 @@ def setup(flags: dict[int, str]) -> None: "/var/www/html/index.html", f'

Flag value: {flags[11]}

\n', ) - nginx_default = Path("/etc/nginx/sites-available/default") - content = nginx_default.read_text() - content = replace_once(content, "listen 80 default_server;", "listen 8083 default_server;") - content = replace_once(content, "listen [::]:80 default_server;", "listen [::]:8083 default_server;") - content = replace_once(content, "root /var/www/html;", "root /var/www/htm;") - nginx_default.write_text(content) + write_file( + "/etc/nginx/sites-available/default", + """server { + listen 8083 default_server; + listen [::]:8083 default_server; + + root /var/www/htm; + index index.html; + server_name _; + + location / { + try_files $uri $uri/ =404; + } +} +""", + mode=0o644, + ) restart_service("nginx") diff --git a/setup/challenges/ch12_network_traffic.py b/setup/challenges/ch12_network_traffic.py index f5165f6..553538d 100644 --- a/setup/challenges/ch12_network_traffic.py +++ b/setup/challenges/ch12_network_traffic.py @@ -1,3 +1,10 @@ +"""Challenge 12: Network Traffic Analysis. + +Learner goal: read secret messages sent via ping packets on the loopback interface (needs sudo). +Skills tested: packet inspection. +Plants: a service that pings 127.0.0.1 with flags[12] (hex-encoded) as the packet payload. +""" + from __future__ import annotations from helpers import enable_service, write_executable, write_service diff --git a/setup/challenges/ch13_cron.py b/setup/challenges/ch13_cron.py index 68ff799..af9f3d8 100644 --- a/setup/challenges/ch13_cron.py +++ b/setup/challenges/ch13_cron.py @@ -1,3 +1,10 @@ +"""Challenge 13: Cron Job Hunter. + +Learner goal: find out what a scheduled job runs and inspect the result. +Skills tested: cron, scheduling. +Plants: /etc/cron.d/nightly_backup running a script that handles flags[13]. +""" + from __future__ import annotations from helpers import write_executable, write_file diff --git a/setup/challenges/ch14_process_env.py b/setup/challenges/ch14_process_env.py index 0c20c33..5389f7f 100644 --- a/setup/challenges/ch14_process_env.py +++ b/setup/challenges/ch14_process_env.py @@ -1,3 +1,10 @@ +"""Challenge 14: Process Environment. + +Learner goal: extract a secret from a running process's environment. +Skills tested: processes, environment variables. +Plants: a service whose environment contains CTF_SECRET_FLAG=flags[14]. +""" + from __future__ import annotations from helpers import enable_service, write_executable, write_file, write_service diff --git a/setup/challenges/ch15_archive.py b/setup/challenges/ch15_archive.py index fd81eac..2d63ebb 100644 --- a/setup/challenges/ch15_archive.py +++ b/setup/challenges/ch15_archive.py @@ -1,3 +1,10 @@ +"""Challenge 15: Archive Archaeologist. + +Learner goal: dig a flag out of nested archives. +Skills tested: archives, compression. +Plants: /home/ctf_user/ctf_challenges/mystery_archive.tar.gz with flags[15] buried inside. +""" + from __future__ import annotations import tarfile diff --git a/setup/challenges/ch16_symlinks.py b/setup/challenges/ch16_symlinks.py index ee0dcd2..4b4e60c 100644 --- a/setup/challenges/ch16_symlinks.py +++ b/setup/challenges/ch16_symlinks.py @@ -1,3 +1,10 @@ +"""Challenge 16: Symbolic Sleuth. + +Learner goal: follow a trail of symbolic links; the flag is where the trail ends. +Skills tested: symbolic links. +Plants: a five-link chain from ctf_challenges/follow_me to a final file named flags[16], plus decoy links to a fake flag. +""" + from __future__ import annotations from pathlib import Path @@ -9,18 +16,29 @@ def setup(flags: dict[int, str]) -> None: hidden = Path("/var/lib/ctf/secrets/deep/hidden") final_target = hidden / flags[16] write_file(final_target, "You reached the end of the trail. The flag is the name of this file.\n", mode=0o644) + + decoy_target = Path("/var/lib/ctf/decoy/CTF{decoy_trail}") + write_file(decoy_target, "Nice try. This trail was a decoy.\n", mode=0o644) + links = [ - (final_target, Path("/var/lib/ctf/secrets/deep/link3")), + (final_target, Path("/var/lib/ctf/secrets/vault/link5")), + (Path("link5"), Path("/var/lib/ctf/secrets/vault/link4")), + (Path("../vault/link4"), Path("/var/lib/ctf/secrets/deep/link3")), (Path("deep/link3"), Path("/var/lib/ctf/secrets/link2")), (Path("/var/lib/ctf/secrets/link2"), Path("/home/ctf_user/ctf_challenges/follow_me")), + (decoy_target, Path("/var/lib/ctf/secrets/deep/link3_old")), + (Path("deep/link3_old"), Path("/var/lib/ctf/secrets/link2_old")), ] for target, link in links: + link.parent.mkdir(parents=True, exist_ok=True) 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/vault"), ): directory.chmod(0o755) - hidden.chmod(0o711) # traversable but not listable, so the name only shows via readlink + hidden.chmod(0o711) + decoy_target.parent.chmod(0o711) diff --git a/setup/challenges/ch17_history.py b/setup/challenges/ch17_history.py index ea3174d..f101a83 100644 --- a/setup/challenges/ch17_history.py +++ b/setup/challenges/ch17_history.py @@ -1,3 +1,10 @@ +"""Challenge 17: History Mystery. + +Learner goal: find a secret someone typed into their command line. +Skills tested: shell history, text search. +Plants: .bash_history files for three users, each with decoy secrets; one holds flags[17] in a command line. +""" + from __future__ import annotations from pathlib import Path @@ -14,16 +21,38 @@ ] +DECOY_SECRETS = [ + "export API_KEY=sk_live_51Hq7xT2eZvKYlo2C", + "export DB_PASSWORD=hunter2", + "export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCY", + "export SLACK_TOKEN=xoxb-0000-0000-placeholder", + "mysql -u root -pchangeme123 -h db01", + "curl -H 'Authorization: Bearer expired-token-do-not-use' https://api.example.internal/v1/status", + "sshpass -p 'Summer2019!' ssh backup@10.0.0.5", + "git remote add origin https://deploy:notarealtoken@git.example.internal/app.git", +] + +USERS = ("old_admin", "deploy", "intern") + + def setup(flags: dict[int, str]) -> None: - ensure_user("old_admin") rng = random.Random() - lines = [rng.choice(COMMANDS) for _ in range(400)] - lines.insert(rng.randrange(100, 300), f"export DEPLOY_KEY={flags[17]}") - write_file( - "/home/old_admin/.bash_history", - "\n".join(lines) + "\nexit\n", - mode=0o644, - owner="old_admin", - group="old_admin", - ) - Path("/home/old_admin").chmod(0o755) + flag_owner = rng.choice(USERS) + for user in USERS: + ensure_user(user) + lines = [rng.choice(COMMANDS) for _ in range(400)] + for secret in rng.sample(DECOY_SECRETS, 4): + lines.insert(rng.randrange(0, len(lines)), secret) + if user == flag_owner: + lines.insert( + rng.randrange(100, 300), + f"curl -H 'X-Deploy-Token: {flags[17]}' https://deploy.example.internal/release", + ) + write_file( + f"/home/{user}/.bash_history", + "\n".join(lines) + "\nexit\n", + mode=0o644, + owner=user, + group=user, + ) + Path(f"/home/{user}").chmod(0o755) diff --git a/setup/challenges/ch18_disk_detective.py b/setup/challenges/ch18_disk_detective.py index 81523a0..dc84bf7 100644 --- a/setup/challenges/ch18_disk_detective.py +++ b/setup/challenges/ch18_disk_detective.py @@ -1,3 +1,10 @@ +"""Challenge 18: Disk Detective. + +Learner goal: find a flag hidden in filesystem metadata by inspecting a disk image. +Skills tested: disk images, filesystem metadata. +Plants: /opt/ctf_disk.img, an ext4 image whose label is flags[18]. +""" + from __future__ import annotations from helpers import run diff --git a/verify/src/verify/commands.py b/verify/src/verify/commands.py index 0c3f52e..35be085 100644 --- a/verify/src/verify/commands.py +++ b/verify/src/verify/commands.py @@ -25,16 +25,16 @@ CHALLENGE_NAMES = [ "Example Challenge", - "Hidden File Discovery", - "Basic File Search", - "Log Analysis", - "User Investigation", - "Permission Analysis", - "Service Discovery", - "Encoding Challenge", - "SSH Secrets", + "The Hidden File", + "The Secret File", + "The Odd Log Entry", + "The User Detective", + "The Permissive File", + "The Hidden Service", + "The Encoded Secret", + "SSH Key Authentication", "DNS Inspection", - "Remote Upload Detection", + "Remote Upload", "Web Configuration", "Network Traffic Analysis", "Cron Job Hunter", @@ -47,24 +47,24 @@ CHALLENGE_HINTS = [ "Run: verify 0 CTF{example}", - "Hidden files in Linux start with a dot. Try 'ls -la' in the ctf_challenges directory.", - "Use 'find' to search by name. Try: find ~ -type f -iname '*secret*' 2>/dev/null", - "Every log line looks alike except one. Find the biggest file in /var/log (ls -lS), then filter out the noise with grep (try grep -v).", - "Every account has a comment (GECOS) field in /etc/passwd. Compare the users with 'getent passwd' and look at the fifth field.", - "Look under /opt for regular files anyone can write to. Try: find /opt -type f -perm -o+w 2>/dev/null. If a file says 'Permission denied', check 'ls -l': who owns it, and what can you change?", - "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.", - "The 'vault' user has no password and only accepts an SSH key. Create a key pair with 'ssh-keygen', authorize the public key in your own ~/.ssh/authorized_keys, then run 'ssh vault@localhost'.", - "Find the custom search domain with 'resolvectl status' or /etc/systemd/resolved.conf.d/. The lab's 'intranet' host lives in that domain: resolve its full name with 'getent hosts'.", - "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. The flag is broadcast to your open terminals when the upload lands.", - "nginx should serve the site on port 80 but doesn't. Check 'ss -tlnp', 'curl -i localhost:', and /var/log/nginx/error.log. Fix the config, then 'sudo nginx -t && sudo systemctl reload nginx'.", - "Network traffic can carry hidden messages. The pings run on the loopback interface. Capture ICMP payloads with: sudo tcpdump -i lo -X icmp", - "Cron jobs run on schedules. Check /etc/cron.d/ and /etc/crontab, see what the job runs and where it writes, then wait for the next run.", - "Process info lives in /proc. Each process has a directory with its environment in /proc/PID/environ.", - "Archives can be nested, and each layer may use a different compression. Check every layer with 'file'; 'tar -xf' detects the format for you.", - "Follow the chain with 'ls -l' one hop at a time, or jump to the end with 'readlink -f'. The flag is where the trail ends, not what is inside it.", - "Bash stores command history in ~/.bash_history. Other users have history files too. Search them for keywords like 'export' or 'key'.", - "Filesystems carry metadata besides files. Inspect the disk image in /opt with 'blkid', 'e2label', or 'sudo dumpe2fs -h'.", + "Some files aren't shown by default. 'man ls' explains how to list everything in the ctf_challenges directory.", + "Search your home directory by name. 'man find' covers matching names and file types. You want a regular file, not a directory.", + "Every log line looks alike except one. Sorting files by size helps you spot the big log in /var/log. Then filter out the noise: a search tool can exclude lines as well as match them.", + "Accounts store more than a name and ID. Find the other users on this system and read everything their records contain. 'man 5 passwd' explains the fields.", + "Look under /opt for regular files anyone can write to; 'man find' covers matching by permission. If a file says 'Permission denied', check 'ls -l': who owns it, and what can you change?", + "Something on this machine is listening on a port. 'man ss' shows how to list listening sockets. Once you know the port, connect to it: the service speaks HTTP.", + "Look for a file in ctf_challenges that isn't readable as-is. Its look (letters, digits, maybe '=' padding) hints at the encoding, and 'man' pages for that encoding cover reversing it. Check whether the result is readable yet.", + "The 'vault' user has no password and only accepts a key. 'man ssh-keygen' covers making one. The sshd settings in /etc/ssh/sshd_config.d/ show where the vault login looks for authorized keys. You can connect from the VM itself.", + "The resolver's settings ('man resolvectl') reveal a custom search domain, and the lab's 'intranet' host lives in it. Build its full name, then look it up with a tool that uses the system's own name resolution, not just DNS servers.", + "Do this from your own computer, not the VM, with a tool that copies files over SSH. The destination is the ctf_challenges directory on the VM, and the file must be new - overwriting doesn't count. The flag is broadcast to your open terminals when the upload lands.", + "nginx should serve the site on port 80 but doesn't. Compare where it is listening with where it should be, and read its error log under /var/log/nginx/. nginx can test a config before you reload it ('man nginx'); then reload the service.", + "Capture on the loopback interface with a tool that can print packet contents as hex and ASCII, and filter down to ping traffic so you can see the payload clearly. It needs sudo.", + "Find where scheduled jobs are defined on this system ('man 5 crontab' is a start), read what the job runs and where its output goes, then wait for the next run.", + "Every running process exposes information about itself in the /proc filesystem ('man proc'). Find the right process, then look at what it was started with.", + "Archives can be nested, and each layer may use a different compression. Identify each layer before extracting it; 'man file' and 'man tar' help.", + "A long directory listing shows where each link points. Follow the chain hop by hop, or look for a tool that resolves it all at once. Some links may lead nowhere useful, so make sure you start from the right one. The flag is where the trail ends, not what is inside it.", + "Shells keep a record of the commands typed, and users other than you have one. Secrets show up in more than variables. Not every secret you find is the flag, so look at what the real one is shaped like.", + "A filesystem is more than the files inside it. The image is a file on this machine, but not a normal document. Something that describes a filesystem may not need it mounted. Check the man pages of ext4 tools.", ] @@ -218,11 +218,11 @@ def export_certificate(state: CtfState, github_username: str | None) -> int: console.print(f" Date: {date_str}") console.print("") 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(" * User Investigation * DNS Inspection") - console.print(" * Permission Analysis * Remote Upload Detection") + console.print(" * The Hidden File * The Hidden Service") + console.print(" * The Secret File * The Encoded Secret") + console.print(" * The Odd Log Entry * SSH Key Authentication") + console.print(" * The User Detective * DNS Inspection") + console.print(" * The Permissive File * Remote Upload") console.print(" * Web Configuration * Network Traffic Analysis") console.print(" * Cron Job Hunter * Process Environment") console.print(" * Archive Archaeologist * Symbolic Sleuth") @@ -247,11 +247,11 @@ def export_certificate(state: CtfState, github_username: str | None) -> int: Date: {date_str} Challenges Completed: - * Hidden File Discovery * Service Discovery - * Basic File Search * Encoding Challenge - * Log Analysis * SSH Secrets - * User Investigation * DNS Inspection - * Permission Analysis * Remote Upload Detection + * The Hidden File * The Hidden Service + * The Secret File * The Encoded Secret + * The Odd Log Entry * SSH Key Authentication + * The User Detective * DNS Inspection + * The Permissive File * Remote Upload * Web Configuration * Network Traffic Analysis * Cron Job Hunter * Process Environment * Archive Archaeologist * Symbolic Sleuth From c26692f58596370f9302c1aa1be089e4bda8c2b4 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Tue, 29 Sep 2026 17:40:33 -0400 Subject: [PATCH 10/10] Print per-phase timing in deploy_and_test.sh Track deploy, ready (SSH + setup wait), tests and destroy time for each provider and print a summary table after the run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4703c39f-f11c-4c63-b095-09708ed6ff8f --- .github/skills/ctf-testing/deploy_and_test.sh | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/skills/ctf-testing/deploy_and_test.sh b/.github/skills/ctf-testing/deploy_and_test.sh index 234be09..e6afe59 100755 --- a/.github/skills/ctf-testing/deploy_and_test.sh +++ b/.github/skills/ctf-testing/deploy_and_test.sh @@ -69,6 +69,9 @@ readonly NC='\033[0m' # No Color CURRENT_PROVIDER="" CLEANUP_ON_EXIT=false +# One row per provider: "provider deploy ready tests destroy total" in seconds +TIMING_ROWS=() + # ============================================================================= # UTILITY FUNCTIONS # ============================================================================= @@ -93,6 +96,30 @@ _log() { esac } +# Format a number of seconds as "XmYYs" +# Arguments: +# $1 - Duration in seconds +_format_duration() { + printf '%dm%02ds' $(( $1 / 60 )) $(( $1 % 60 )) +} + +# Print the per-provider timing table collected in TIMING_ROWS +_print_timing_summary() { + [[ ${#TIMING_ROWS[@]} -eq 0 ]] && return 0 + + echo "" + echo "Timing (deploy = terraform apply, ready = SSH + setup wait, tests include any reboot cycle)" + printf ' %-8s %8s %8s %8s %8s %8s\n' "Cloud" "Deploy" "Ready" "Tests" "Destroy" "Total" + local row provider deploy ready tests destroy total + for row in "${TIMING_ROWS[@]}"; do + read -r provider deploy ready tests destroy total <<< "${row}" + printf ' %-8s %8s %8s %8s %8s %8s\n' "${provider}" \ + "$(_format_duration "${deploy}")" "$(_format_duration "${ready}")" \ + "$(_format_duration "${tests}")" "$(_format_duration "${destroy}")" \ + "$(_format_duration "${total}")" + done +} + # Signal handler for cleanup on interrupt (SIGINT/SIGTERM) # Destroys any in-progress infrastructure before exiting _cleanup_handler() { @@ -567,6 +594,7 @@ _run_post_reboot_tests() { _test_provider() { local provider="$1" local result=0 + local t_start=${SECONDS} t_mark # Enable cleanup on interrupt for this provider CURRENT_PROVIDER="${provider}" @@ -582,6 +610,7 @@ _test_provider() { _check_prerequisites "${provider}" # Deploy + t_mark=${SECONDS} if ! _terraform_apply "${provider}"; then _log ERROR "Terraform apply failed for ${provider}" CLEANUP_ON_EXIT=false @@ -590,6 +619,8 @@ _test_provider() { return 1 fi + local deploy_secs=$(( SECONDS - t_mark )) + # Get IP local ip if ! ip=$(_get_public_ip "${provider}"); then @@ -602,6 +633,7 @@ _test_provider() { _log OK "VM deployed at: ${ip}" # Wait for SSH + t_mark=${SECONDS} if ! _wait_for_ssh "${ip}"; then _log ERROR "SSH connection failed for ${provider}" CLEANUP_ON_EXIT=false @@ -619,7 +651,10 @@ _test_provider() { return 1 fi + local ready_secs=$(( SECONDS - t_mark )) + # Run tests + t_mark=${SECONDS} local test_exit_code=0 _run_tests "${provider}" "${ip}" || test_exit_code=$? @@ -642,12 +677,18 @@ _test_provider() { result=1 fi + local tests_secs=$(( SECONDS - t_mark )) + # Cleanup echo "" + t_mark=${SECONDS} CLEANUP_ON_EXIT=false _terraform_destroy "${provider}" CURRENT_PROVIDER="" + local destroy_secs=$(( SECONDS - t_mark )) + TIMING_ROWS+=("${provider} ${deploy_secs} ${ready_secs} ${tests_secs} ${destroy_secs} $(( SECONDS - t_start ))") + return "${result}" } @@ -674,6 +715,7 @@ _main() { done # Final summary (short pass/fail) + _print_timing_summary echo "" if [[ ${#failed_providers[@]} -gt 0 ]]; then _log ERROR "RESULT: FAIL (${failed_providers[*]})"