Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ This is an **educational Capture The Flag (CTF)** project designed to teach Linu

### Adding a New Challenge

1. Edit `ctf_setup.sh` to add the challenge setup logic
1. Add a `setup/challenges/chNN_<name>.py` module with a `setup(flags)` function and register it in `CHALLENGES` in `setup/challenges/__init__.py`
- List any apt packages the challenge's own setup needs in a module-level `PACKAGES = [...]`; `setup/system.py` only installs learner tools
- Use `CHALLENGE_DIR` and `DONE_MARKER` from `setup/helpers.py` instead of hardcoding paths
- Challenges solved from outside the VM deliver flags at login via `setup/external_rewards.py`
2. Update `README.md` with the challenge description
3. Add test commands to `.github/skills/ctf-testing/test_ctf_challenges.sh`

Expand Down
4 changes: 2 additions & 2 deletions .github/skills/ctf-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ Use this skill when the user asks things like:
- If cleanup still fails, report the remaining resources clearly.
5. **Report the result plainly.**
- Include provider, mode, pass/fail result, cleanup status, and blocker if any.
- A successful basic run shows about 27 tests passing.
- A successful full run includes a second pass after reboot with 5 service checks and 1 progress persistence check.
- A successful basic run shows about 41 tests passing, including shortcut regression checks.
- A successful full run includes a second pass after reboot with 8 service checks, 2 boot-health checks (no systemd ordering cycles, `systemd-resolved` running), and 1 progress persistence check.
- Summary line: `RESULT: PASS (<providers>)` or `RESULT: FAIL (<providers>)`.

## Failure Handling
Expand Down
28 changes: 26 additions & 2 deletions .github/skills/ctf-testing/deploy_and_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ _wait_for_setup() {
while [[ ${attempt} -le ${MAX_SSH_ATTEMPTS} ]]; do
# shellcheck disable=SC2086
if _sshpass_cmd ssh ${SSH_OPTS} "${SSH_USER}@${ip}" \
"test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done || test -f /var/log/setup_complete" &>/dev/null; then
"test -f /var/lib/linux-ctfs/setup.done || test -f /var/lib/cloud/instance/ctf-setup.done" &>/dev/null; then
_log OK "CTF setup is complete"
return 0
fi
Expand Down Expand Up @@ -488,7 +488,7 @@ _upload_challenge_10_file() {
# shellcheck disable=SC2086
_sshpass_cmd ssh ${SSH_OPTS} "${SSH_USER}@${ip}" \
'for _ in $(seq 1 30); do pgrep -x inotifywait >/dev/null && break; sleep 2; done
: > /tmp/.ctf_upload_triggered; rm -f ~/ctf_challenges/scp_upload_test' || true
rm -f ~/ctf_challenges/scp_upload_test' || true

upload_file=$(mktemp)
echo "challenge 10 scp upload test" > "${upload_file}"
Expand All @@ -498,6 +498,29 @@ _upload_challenge_10_file() {
rm -f "${upload_file}"
}

# Set up SSH key authentication from the local machine and log in with the key
# (challenge 8). The key login is what the VM rewards.
# Arguments:
# $1 - IP address of the VM
_setup_challenge_8_key_login() {
local ip="$1"
local key_dir

_log INFO "Setting up SSH key authentication for challenge 8..."
key_dir=$(mktemp -d)
ssh-keygen -q -t ed25519 -N '' -f "${key_dir}/id_ed25519"
# Same steps as ssh-copy-id, without needing a local ~/.ssh directory
# shellcheck disable=SC2086
_sshpass_cmd ssh ${SSH_OPTS} "${SSH_USER}@${ip}" \
'umask 077; mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys' < "${key_dir}/id_ed25519.pub" \
|| _log WARN "installing the public key for challenge 8 failed"
# shellcheck disable=SC2086
ssh ${SSH_OPTS} -i "${key_dir}/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o PasswordAuthentication=no \
"${SSH_USER}@${ip}" true \
|| _log WARN "key-based login for challenge 8 failed"
rm -rf "${key_dir}"
}

# Copy test script to VM and execute it
# Arguments:
# $1 - Cloud provider name
Expand All @@ -514,6 +537,7 @@ _run_tests() {
fi

_copy_test_script "${provider}" "${ip}"
_setup_challenge_8_key_login "${ip}"
_upload_challenge_10_file "${ip}"

_log INFO "Running tests on ${provider} VM (${ip})..."
Expand Down
167 changes: 122 additions & 45 deletions .github/skills/ctf-testing/test_ctf_challenges.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,27 @@ if [[ "${POST_REBOOT}" == true ]]; then

echo "Verifying services survived reboot..."

for service in ctf-secret-service ctf-monitor-directory ctf-ping-message ctf-secret-process nginx; do
for service in ctf-secret-service ctf-monitor-directory ctf-ping-message ctf-secret-process ctf-dns ctf-ssh-key-watch auditd nginx; do
if systemctl is-active "${service}" &>/dev/null; then
_pass "${service} is running after reboot"
else
_fail "${service} failed to start after reboot - SETUP BUG"
fi
done

# systemd silently drops jobs (resolved, network, even ssh) to break
# ordering cycles, so a cycle is a setup bug even if the loop above passed.
if journalctl -b --no-pager 2>/dev/null | grep -q "ordering cycle"; then
_fail "systemd ordering cycle at boot - SETUP BUG"
else
_pass "No systemd ordering cycles at boot"
fi
if systemctl is-active systemd-resolved &>/dev/null; then
_pass "systemd-resolved is running after reboot"
else
_fail "systemd-resolved failed to start after reboot - SETUP BUG"
fi

if [ -f "$PROGRESS_SNAPSHOT" ]; then
EXPECTED=$(cat "$PROGRESS_SNAPSHOT")
ACTUAL=$( { sort -u /var/ctf/completed_challenges 2>/dev/null || true; } | wc -l )
Expand Down Expand Up @@ -233,6 +246,50 @@ else
_fail "Timer did not start after first numeric verify command"
fi

# ============================================================================
# SHORTCUT REGRESSION CHECKS
# ============================================================================
# Each check guards against a way to grab a flag without the intended skill.
_section "SHORTCUT REGRESSION CHECKS"

_no_flag() {
local description="${1}"
local content="${2}"
if echo "${content}" | grep -q 'CTF{'; then
_fail "Shortcut open: ${description}"
else
_pass "Shortcut closed: ${description}"
fi
}

_no_flag "ch8 flag not planted in ~/.ssh" "$(grep -rah 'CTF{' /home/ctf_user/.ssh 2>/dev/null || true)"
_no_flag "ch9 flag not in resolved config" "$(cat /etc/systemd/resolved.conf.d/* 2>/dev/null || true)"
touch /home/ctf_user/ctf_challenges/local_touch_test
LOCAL_IGNORED=false
for _ in {1..10}; do
grep -q 'ignored local_touch_test' /var/log/monitor_directory.log 2>/dev/null && { LOCAL_IGNORED=true; break; }
sleep 1
done
rm -f /home/ctf_user/ctf_challenges/local_touch_test
if [[ "${LOCAL_IGNORED}" == true ]]; then
_pass "Shortcut closed: ch10 local file creation is ignored"
else
_fail "Shortcut open: ch10 local file creation was not rejected"
fi
_no_flag "ch11 flag not served on port 8083" "$(curl -s --connect-timeout 3 localhost:8083 2>/dev/null || true)"
_no_flag "ch11 flag page not readable by ctf_user" "$(cat /var/www/ctf/index.html 2>/dev/null || true)"
_no_flag "ch12 flag not in ping script or logs" "$(cat /usr/local/bin/ping_message.sh /var/log/ping_message.log 2>/dev/null; grep -o 'PATTERN: 0x[0-9a-f]*' /var/log/ping_message.log 2>/dev/null | cut -c12- | xxd -r -p 2>/dev/null || true)"
_no_flag "ch13 flag not in cron files" "$(cat /etc/cron.d/* /usr/local/bin/ctf_status_report.sh 2>/dev/null || true)"
_no_flag "ch14 flag not exposed by systemctl" "$(systemctl cat ctf-secret-process.service 2>/dev/null; systemctl show ctf-secret-process.service 2>/dev/null || true)"
_no_flag "ch16 cat follow_me does not print the flag" "$(cat /home/ctf_user/ctf_challenges/follow_me 2>/dev/null || true)"
_no_flag "ch17 history unreadable without sudo" "$(cat /home/old_admin/.bash_history 2>/dev/null || true)"
_no_flag "ch18 disk image unreadable without sudo" "$(grep -ao 'CTF{[^}]*}' /opt/ctf_disk.img 2>/dev/null || true)"
if getent hosts ubuntu.com >/dev/null 2>&1; then
_pass "Normal DNS resolution still works with the ch9 resolver"
else
_fail "Normal DNS resolution broken by the ch9 resolver - SETUP BUG"
fi

# ============================================================================
# CHALLENGE DISCOVERY AND SOLVING
# ============================================================================
Expand Down Expand Up @@ -341,8 +398,8 @@ echo "Challenge 6: Service Discovery"
FLAG_6=""
for port in $(ss -tulpn 2>/dev/null \
| awk '/LISTEN/ {split($5,a,":"); print a[length(a)]}' \
| grep -vE '^(22|80|443|8083)$' \
| head -3); do
| grep -vE '^(22|53|54|80|443|8083)$' \
| sort -u); do
FLAG_6=$(curl -s --connect-timeout 3 "localhost:${port}" 2>/dev/null \
| grep -ao 'CTF{[^}]*}' \
| head -1) || true
Expand Down Expand Up @@ -376,55 +433,57 @@ else
FLAGS[7]=""
fi

# Challenge 8: SSH Secrets
# Hint: "SSH configurations often hide secrets. Explore ~/.ssh thoroughly"
echo "Challenge 8: SSH Secrets"
FLAG_8=""
while IFS= read -r -d '' f; do
FLAG_8=$(grep -ao 'CTF{[^}]*}' "${f}" 2>/dev/null | head -1) || true
[[ -n "${FLAG_8}" ]] && break
done < <(find /home/ctf_user/.ssh -type f -print0 2>/dev/null)
# Challenge 8: SSH Key Authentication
# Hint: "Create a key pair, ssh-copy-id it, log in with the key, watch the banner"
# deploy_and_test.sh performs the key setup and key login from the local machine
# before this script runs; the login banner should now show the flag.
echo "Challenge 8: SSH Key Authentication"
FLAG_8=$(bash -lc true 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true
if [[ -n "${FLAG_8}" ]]; then
_verify_flag 8 "${FLAG_8}"
_verify_flag 8 "${FLAG_8}" "Solved challenge 8" "Challenge 8: Found flag but verify rejected it - SETUP BUG"
else
_fail "Challenge 8: Could not find flag in .ssh directory"
_fail "Challenge 8: key login did not reveal the flag in the login banner - SETUP BUG"
FLAGS[8]=""
fi

# Challenge 9: DNS Inspection
# Hint: "Inspect systemd-resolved configuration safely"
# Hint: "resolvectl status shows which server handles which domain; query its TXT record"
echo "Challenge 9: DNS Inspection"
DNS_DROP_IN="/etc/systemd/resolved.conf.d/ctf-dns.conf"
if [[ -r "${DNS_DROP_IN}" ]]; then
FLAG_9=$(grep -ao 'CTF{[^}]*}' "${DNS_DROP_IN}" 2>/dev/null | head -1) || true
DNS_DOMAIN=$(resolvectl status 2>/dev/null \
| grep -oE '~[a-z0-9.-]+' \
| grep -v '^~\.$' \
| tr -d '~' \
| head -1) || true
if [[ -n "${DNS_DOMAIN}" ]]; then
FLAG_9=$(dig +short TXT "${DNS_DOMAIN}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true
if [[ -n "${FLAG_9}" ]]; then
_verify_flag 9 "${FLAG_9}" "Solved challenge 9" "Challenge 9: Found flag but verify rejected it - SETUP BUG"
else
_fail "Challenge 9: DNS drop-in has no CTF flag - SETUP BUG"
_fail "Challenge 9: TXT lookup for ${DNS_DOMAIN} returned no flag - SETUP BUG"
FLAGS[9]=""
fi
else
_fail "Challenge 9: DNS drop-in not readable - SETUP BUG"
_fail "Challenge 9: No routing domain found in resolvectl status - SETUP BUG"
FLAGS[9]=""
fi

# Challenge 10: Remote Upload
# Hint: "Run scp from your own computer into ~/ctf_challenges/"
# deploy_and_test.sh uploads a file with scp from the local machine before this
# script runs, so the flag should already be in the trigger file.
# script runs, so the login banner should now show the flag.
echo "Challenge 10: Remote Upload"
if ! systemctl is-active ctf-monitor-directory.service &>/dev/null; then
_fail "Challenge 10: Monitor service not running - SETUP BUG"
FLAGS[10]=""
else
FLAG_10=""
for _ in {1..10}; do
FLAG_10=$(grep -ao 'CTF{[^}]*}' /tmp/.ctf_upload_triggered 2>/dev/null | head -1) || true
FLAG_10=$(bash -lc true 2>/dev/null | grep 'Challenge 10' | grep -ao 'CTF{[^}]*}' | head -1) || true
[[ -n "${FLAG_10}" ]] && break
sleep 2
done
rm -f /home/ctf_user/ctf_challenges/scp_upload_test

if [[ -n "${FLAG_10}" ]]; then
_verify_flag 10 "${FLAG_10}" "Solved challenge 10" "Challenge 10: Found flag but verify rejected it - SETUP BUG"
else
Expand All @@ -434,24 +493,25 @@ else
fi

# Challenge 11: Web Configuration
# Hint: "Check what ports nginx is listening on"
# Hint: "Check nginx's port with ss, move it to the standard port, reload"
echo "Challenge 11: Web Configuration"
NGINX_PORT=$(grep -r 'listen' /etc/nginx/ 2>/dev/null \
| grep -oP 'listen\s+\K[0-9]+' \
| grep -v '^80$' \
| head -1) || true
if [[ -n "${NGINX_PORT}" ]]; then
FLAG_11=$(curl -s "localhost:${NGINX_PORT}" 2>/dev/null \
NGINX_SITE=$(grep -RlE 'listen\s+[0-9]+' /etc/nginx/sites-enabled/ 2>/dev/null | head -1) || true
if [[ -n "${NGINX_SITE}" ]]; then
NGINX_SITE=$(readlink -f "${NGINX_SITE}")
echo 'CTFpassword123!' | sudo -S sed -i -E 's/listen(\s+)(\[::\]:)?8083/listen\1\280/' "${NGINX_SITE}" 2>/dev/null
echo 'CTFpassword123!' | sudo -S systemctl reload nginx 2>/dev/null || true
sleep 2
FLAG_11=$(curl -s "localhost:80" 2>/dev/null \
| grep -ao 'CTF{[^}]*}' \
| head -1) || true
if [[ -n "${FLAG_11}" ]]; then
_verify_flag 11 "${FLAG_11}"
else
_fail "Challenge 11: Could not get flag from nginx"
_fail "Challenge 11: nginx on port 80 did not serve the flag"
FLAGS[11]=""
fi
else
_fail "Challenge 11: Could not find nginx non-standard port"
_fail "Challenge 11: Could not find nginx site config"
FLAGS[11]=""
fi

Expand Down Expand Up @@ -479,18 +539,25 @@ else
fi

# Challenge 13: Cron Job Hunter
# Hint: "Check /etc/cron.d/, /etc/crontab, and user crontabs"
# Hint: "Check /etc/cron.d/; read the script a job runs and work out when its output exists"
echo "Challenge 13: Cron Job Hunter"
FLAG_13=""
for dir in /etc/cron.d /etc/cron.daily /etc/cron.hourly; do
[[ -d "${dir}" ]] || continue
FLAG_13=$(grep -rh 'CTF{' "${dir}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true
[[ -n "${FLAG_13}" ]] && break
done
CRON_SCRIPT=$(grep -hvE '^\s*(#|$)' /etc/cron.d/* 2>/dev/null \
| awk 'NF >= 7 {print $7}' \
| grep '^/usr/local/' \
| head -1) || true
if [[ -n "${CRON_SCRIPT}" && -r "${CRON_SCRIPT}" ]]; then
REPORT=$(grep -oP '^REPORT=\K\S+' "${CRON_SCRIPT}" | head -1) || true
for _ in {1..40}; do
FLAG_13=$(grep -ao 'CTF{[^}]*}' "${REPORT}" 2>/dev/null | head -1) || true
[[ -n "${FLAG_13}" ]] && break
sleep 2
done
fi
if [[ -n "${FLAG_13}" ]]; then
_verify_flag 13 "${FLAG_13}"
else
_fail "Challenge 13: Could not find flag in cron directories"
_fail "Challenge 13: Could not catch the cron job's output"
FLAGS[13]=""
fi

Expand Down Expand Up @@ -540,13 +607,11 @@ else
fi

# Challenge 16: Symbolic Sleuth
# Hint: "Use 'readlink -f' to find the final target"
# Hint: "Use 'readlink -f' to find the final target; the path can matter"
echo "Challenge 16: Symbolic Sleuth"
FLAG_16=""
while IFS= read -r -d '' link; do
TARGET=$(readlink -f "${link}" 2>/dev/null) || true
[[ -r "${TARGET}" ]] || continue
FLAG_16=$(grep -ao 'CTF{[^}]*}' "${TARGET}" 2>/dev/null | head -1) || true
FLAG_16=$(readlink -f "${link}" 2>/dev/null | grep -ao 'CTF{[^}]*}' | head -1) || true
[[ -n "${FLAG_16}" ]] && break
done < <(find /home/ctf_user/ctf_challenges -type l -print0 2>/dev/null)
if [[ -n "${FLAG_16}" ]]; then
Expand All @@ -557,14 +622,16 @@ else
fi

# Challenge 17: History Mystery
# Hint: "Bash stores history in ~/.bash_history. Other users may have history too"
# Hint: "Other users' history files are private, but you have sudo"
echo "Challenge 17: History Mystery"
FLAG_17=""
for home in /home/*; do
user=$(basename "${home}")
[[ "${user}" == "ctf_user" ]] && continue
[[ -r "${home}/.bash_history" ]] || continue
FLAG_17=$(grep -ao 'CTF{[^}]*}' "${home}/.bash_history" 2>/dev/null | head -1) || true
[[ -e "${home}/.bash_history" ]] || continue
FLAG_17=$(echo 'CTFpassword123!' | sudo -S cat "${home}/.bash_history" 2>/dev/null \
| grep -ao 'CTF{[^}]*}' \
| head -1) || true
[[ -n "${FLAG_17}" ]] && break
done
if [[ -n "${FLAG_17}" ]]; then
Expand Down Expand Up @@ -599,6 +666,16 @@ else
FLAGS[18]=""
fi

SUFFIXES=$(for n in "${!FLAGS[@]}"; do
[[ "${n}" == "0" || -z "${FLAGS[${n}]}" ]] && continue
echo "${FLAGS[${n}]}" | grep -oE '_[0-9a-f]+\}$'
done | sort)
if [[ -n "${SUFFIXES}" && "$(echo "${SUFFIXES}" | wc -l)" == "$(echo "${SUFFIXES}" | sort -u | wc -l)" ]]; then
_pass "Every flag has its own random suffix"
else
_fail "Flags share suffixes - one flag predicts the others"
fi

# ============================================================================
# VERIFICATION TOKEN TEST
# ============================================================================
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@ Test your Linux command line skills with 18 progressive Capture The Flag challen
| 3 | The Largest Log | Find and read an unusually large file in `/var/log` | ⭐⭐ | File sizes, log navigation |
| 4 | The User Detective | Another user has a flag in their login configuration | ⭐⭐ | User management, UIDs |
| 5 | The Permissive File | Find a suspicious file with wide-open permissions under `/opt` | ⭐⭐ | Permissions |
| 6 | The Hidden Service | Something is listening on port 8080. Connect to it | ⭐⭐ | Networking, ports |
| 6 | The Hidden Service | Something is listening on a network port. Find it and connect to it | ⭐⭐ | Networking, ports |
| 7 | The Encoded Secret | Find and decode an encoded flag in `ctf_challenges` | ⭐⭐ | Base64, encoding |
| 8 | SSH Key Authentication | Configure SSH key authentication and find a hidden flag | ⭐⭐ | SSH configuration |
| 9 | DNS Inspection | Inspect the system DNS configuration without changing live resolver files | ⭐⭐ | DNS, `systemd-resolved` |
| 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag | ⭐⭐ | File transfer, SCP |
| 11 | Web Configuration | The web server is running on a non-standard port. Find and fix it | ⭐⭐ | Nginx, services |
| 8 | SSH Key Authentication | From your own computer, set up SSH key authentication and log in with your key to reveal the flag | ⭐⭐ | SSH keys, `ssh-keygen` |
| 9 | DNS Inspection | The system sends one internal domain to a private DNS server. Find the domain and query its records | ⭐⭐ | DNS, `resolvectl`, `dig` |
| 10 | Remote Upload | From your own computer, upload a new file into `~/ctf_challenges` on the VM to trigger the flag. Files created on the VM don't count | ⭐⭐ | File transfer, SCP |
| 11 | Web Configuration | The web server is running on a non-standard port. Move it back to the standard HTTP port to see the flag | ⭐⭐ | Nginx, services |
| 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets | ⭐⭐⭐ | Packet inspection, tcpdump |
| 13 | Cron Job Hunter | A scheduled task contains a hidden flag. Find and read it | ⭐⭐ | Cron, scheduling |
| 13 | Cron Job Hunter | A scheduled task briefly publishes a secret. Find the job, work out what it does, and catch it | ⭐⭐ | Cron, scheduling |
| 14 | Process Environment | A running process has a secret in its environment. Extract it | ⭐⭐⭐ | `/proc`, environment vars |
| 15 | Archive Archaeologist | A flag is buried inside nested archives. Dig it out | ⭐⭐ | tar, gzip, archives |
| 16 | Symbolic Sleuth | Follow the trail of symbolic links to find the flag | ⭐⭐ | Symlinks, `readlink` |
| 17 | History Mystery | Someone typed a flag in their command history. Find it | ⭐⭐ | Bash history |
| 18 | Disk Detective | A flag is hidden in filesystem metadata. Investigate mounted filesystems | ⭐⭐⭐ | Disk images, mounting |
| 17 | History Mystery | A former admin typed a password on the command line. Find it in their shell history | ⭐⭐ | Bash history, `sudo` |
| 18 | Disk Detective | A flag is hidden inside a disk image. Mount it and investigate | ⭐⭐⭐ | Disk images, mounting |

**Difficulty:** ⭐ Beginner | ⭐⭐ Intermediate | ⭐⭐⭐ Advanced

Expand Down
Loading
Loading