From a1cf6c088c467ce76e688ba30b838059170e7263 Mon Sep 17 00:00:00 2001 From: Gwyneth Pena-Siguenza Date: Wed, 27 May 2026 15:36:53 -0400 Subject: [PATCH] Refactor setup to Python Move CTF setup and verify logic into Python packages while keeping ctf_setup.sh as a thin bootstrap. Update Terraform contributor and release setup paths, add release packaging, and refresh CTF testing guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/ctf-testing/SKILL.md | 184 ++-- .github/skills/ctf-testing/deploy_and_test.sh | 44 + .../skills/ctf-testing/test_ctf_challenges.sh | 17 +- .github/workflows/release-setup.yml | 56 ++ .gitignore | 5 + CONTRIBUTING.md | 169 ++-- README.md | 3 +- aws/README.md | 21 + aws/main.tf | 152 +++- azure/README.md | 18 + azure/main.tf | 143 +++- ctf_setup.sh | 785 +----------------- gcp/README.md | 19 + gcp/main.tf | 140 +++- setup/challenges/__init__.py | 46 + setup/challenges/ch01_hidden_file.py | 7 + setup/challenges/ch02_file_search.py | 7 + setup/challenges/ch03_log_analysis.py | 23 + setup/challenges/ch04_user_investigation.py | 12 + setup/challenges/ch05_permissions.py | 7 + setup/challenges/ch06_service_discovery.py | 34 + setup/challenges/ch07_encoding.py | 11 + setup/challenges/ch08_ssh_secrets.py | 12 + setup/challenges/ch09_dns.py | 17 + setup/challenges/ch10_remote_upload.py | 50 ++ setup/challenges/ch11_web_config.py | 18 + setup/challenges/ch12_network_traffic.py | 35 + setup/challenges/ch13_cron.py | 15 + setup/challenges/ch14_process_env.py | 38 + setup/challenges/ch15_archive.py | 17 + setup/challenges/ch16_symlinks.py | 24 + setup/challenges/ch17_history.py | 22 + setup/challenges/ch18_disk_detective.py | 15 + setup/flags.py | 59 ++ setup/helpers.py | 88 ++ setup/main.py | 20 + setup/pyproject.toml | 9 + setup/state.py | 26 + setup/system.py | 153 ++++ verify/pyproject.toml | 19 + verify/src/verify/__init__.py | 1 + verify/src/verify/__main__.py | 50 ++ verify/src/verify/commands.py | 295 +++++++ verify/src/verify/state.py | 49 ++ 44 files changed, 1924 insertions(+), 1011 deletions(-) create mode 100644 .github/workflows/release-setup.yml create mode 100644 setup/challenges/__init__.py create mode 100644 setup/challenges/ch01_hidden_file.py create mode 100644 setup/challenges/ch02_file_search.py create mode 100644 setup/challenges/ch03_log_analysis.py create mode 100644 setup/challenges/ch04_user_investigation.py create mode 100644 setup/challenges/ch05_permissions.py create mode 100644 setup/challenges/ch06_service_discovery.py create mode 100644 setup/challenges/ch07_encoding.py create mode 100644 setup/challenges/ch08_ssh_secrets.py create mode 100644 setup/challenges/ch09_dns.py create mode 100644 setup/challenges/ch10_remote_upload.py create mode 100644 setup/challenges/ch11_web_config.py create mode 100644 setup/challenges/ch12_network_traffic.py create mode 100644 setup/challenges/ch13_cron.py create mode 100644 setup/challenges/ch14_process_env.py create mode 100644 setup/challenges/ch15_archive.py create mode 100644 setup/challenges/ch16_symlinks.py create mode 100644 setup/challenges/ch17_history.py create mode 100644 setup/challenges/ch18_disk_detective.py create mode 100644 setup/flags.py create mode 100644 setup/helpers.py create mode 100644 setup/main.py create mode 100644 setup/pyproject.toml create mode 100644 setup/state.py create mode 100644 setup/system.py create mode 100644 verify/pyproject.toml create mode 100644 verify/src/verify/__init__.py create mode 100644 verify/src/verify/__main__.py create mode 100644 verify/src/verify/commands.py create mode 100644 verify/src/verify/state.py diff --git a/.github/skills/ctf-testing/SKILL.md b/.github/skills/ctf-testing/SKILL.md index 983dd64..b6858ed 100644 --- a/.github/skills/ctf-testing/SKILL.md +++ b/.github/skills/ctf-testing/SKILL.md @@ -1,30 +1,32 @@ --- name: ctf-testing -description: Deploy and test Linux CTF challenges across cloud providers (AWS, Azure, GCP). Use when testing CTF setup, validating challenges work correctly, running the full test suite, verifying services survive VM reboots, or after creating new challenges. +description: Deploy and test Linux CTF challenges across cloud providers (AWS, Azure, GCP). Use when running basic tests without reboot or full tests with reboot for CTF setup, challenge validation, and release confidence. --- # CTF Challenge Testing -This skill deploys CTF infrastructure to cloud providers and validates all challenges work correctly. - -## Decision Tree: Which Provider? - -``` -What to test? → Testing new challenge locally first? -├─ Yes → Use Azure (fastest deploy, ~3 min) -│ -└─ No → Full validation before release? - ├─ Quick check → Pick one: aws | azure | gcp - └─ Full release validation → Use "all" + --with-reboot -``` +This skill deploys CTF infrastructure to cloud providers and validates that learners can complete the lab. ## When to Use -- Testing changes to `ctf_setup.sh` +- Testing changes to `ctf_setup.sh`, `setup/`, or `verify/` - Validating challenge setup across AWS, Azure, or GCP -- Running the full test suite before releases +- Running the full contributor-mode test suite before releases - Verifying services survive VM reboots +## Trigger Examples + +Use this skill when the user asks things like: + +- "Run a basic test on Azure" +- "Run a basic test on GCP" +- "Run a basic test on AWS" +- "Run a full test on Azure" +- "Run a full test on GCP" +- "Run a full test on AWS" +- "Run a basic test on all providers" +- "Run a full test on all providers" + ## Prerequisites 1. **terraform** (>= 1.0) @@ -35,107 +37,59 @@ What to test? → Testing new challenge locally first? - Azure: `az account show` - GCP: `gcloud auth list --filter=status:ACTIVE` -## Running Tests - -The scripts are black boxes - run with `--help` or use these commands: - -```bash -./.github/skills/ctf-testing/deploy_and_test.sh [--with-reboot] -``` - -| Command | Description | -|---------|-------------| -| `deploy_and_test.sh aws` | Test AWS only (~15 min) | -| `deploy_and_test.sh azure` | Test Azure only (~15 min) | -| `deploy_and_test.sh gcp` | Test GCP only (~15 min) | -| `deploy_and_test.sh all` | Test all providers (~45 min) | -| `deploy_and_test.sh aws --with-reboot` | Test with reboot verification (~20 min) | - -## What Gets Tested - -The test script simulates a real user journey: - -1. **Verify command sanity check** - Confirms `verify` command works -2. **Challenge solving** - All 18 challenges discovered and solved using hints -3. **Verification token** - Token generation and format validation -4. **Export certificate** - Certificate generation with correct metadata - -**With `--with-reboot`:** -5. **Service resilience** - All systemd services restart after reboot -6. **Progress persistence** - Completed challenges survive reboot - -## Common Pitfalls - -❌ **Don't** run tests without checking cloud CLI authentication first -✅ **Do** verify with `aws sts get-caller-identity` / `az account show` / `gcloud auth list` - -❌ **Don't** forget to check for leftover resources after a failed run -✅ **Do** run the cleanup verification commands in "Post-Test Cleanup" section - -❌ **Don't** run `all` for quick iteration - it takes 45+ minutes -✅ **Do** pick one provider (Azure is fastest) for development, `all` for releases - -## Features - -- **Timestamped logging** - All output includes `[HH:MM:SS]` timestamps -- **Graceful interrupt handling** - Ctrl+C triggers cleanup of deployed infrastructure -- **Proper VM wait logic** - Uses cloud-native waits instead of arbitrary sleeps -- **IP validation** - Verifies retrieved IPs are valid before attempting SSH +## Agent Workflow -## Expected Results - -A successful run shows **~25 tests passing**, followed by a summary: -- 1 verify sanity check -- 18 challenge solutions -- 4 verification token tests -- 2 export certificate tests - -**With `--with-reboot`:** Additional 6 service checks + 1 progress persistence check. - -Summary line: `RESULT: PASS ()` or `RESULT: FAIL ()` - -## Troubleshooting - -### Setup not completing -- Check `/var/log/setup_complete` exists on VM -- Review cloud-init logs: `/var/log/cloud-init-output.log` - -### Service not running -- Check status: `systemctl status ` -- Check logs: `journalctl -u ` - -### Port not accessible externally -- Verify firewall rules in Terraform allow the port -- Check security group/NSG in cloud console - -### SSH connection fails -- Wait longer for VM setup (~3-5 minutes after IP available) -- Verify security group allows port 22 - -## Post-Test Cleanup - -Always verify resources were destroyed to avoid unexpected charges: - -**AWS:** -```bash -aws ec2 describe-instances --filters "Name=tag:Name,Values=CTF*" "Name=instance-state-name,Values=running,pending,stopping,stopped" --query 'Reservations[*].Instances[*].[InstanceId,State.Name]' --output table -aws ec2 describe-vpcs --filters "Name=tag:Name,Values=CTF*" --query 'Vpcs[*].VpcId' --output table -``` - -**Azure:** -```bash -az group list --query "[?starts_with(name, 'ctf')].name" --output table -``` - -**GCP:** -```bash -gcloud compute instances list --filter="name~'ctf'" --format="table(name,zone,status)" -``` - -If resources remain, manually destroy: -```bash -cd && terraform destroy -auto-approve -``` +1. **Determine the mode and provider.** + - Basic test means no reboot. Use this when the user asks for a basic test or does not mention reboot. + - Full test means reboot. Use this when the user asks for a full test or asks to verify reboot behavior. + - Provider must be `aws`, `azure`, `gcp`, or `all`. Azure is usually the fastest single-provider test. +2. **Check provider authentication before deploying.** + - AWS: `aws sts get-caller-identity` + - Azure: `az account show` + - GCP: `gcloud auth list --filter=status:ACTIVE` +3. **Run the requested test from the repository root.** + + | User request | Command | What gets tested | + | --- | --- | --- | + | Basic test on AWS | `./.github/skills/ctf-testing/deploy_and_test.sh aws` | Verify command sanity checks, all 18 challenges, export certificate, token format, username, challenge count, and frozen completion time | + | Basic test on Azure | `./.github/skills/ctf-testing/deploy_and_test.sh azure` | Verify command sanity checks, all 18 challenges, export certificate, token format, username, challenge count, and frozen completion time | + | Basic test on GCP | `./.github/skills/ctf-testing/deploy_and_test.sh gcp` | Verify command sanity checks, all 18 challenges, export certificate, token format, username, challenge count, and frozen completion time | + | Basic test on all providers | `./.github/skills/ctf-testing/deploy_and_test.sh all` | Same basic checks across AWS, Azure, and GCP | + | Full test on AWS | `./.github/skills/ctf-testing/deploy_and_test.sh aws --with-reboot` | Basic checks plus reboot, required service checks, and progress persistence | + | Full test on Azure | `./.github/skills/ctf-testing/deploy_and_test.sh azure --with-reboot` | Basic checks plus reboot, required service checks, and progress persistence | + | Full test on GCP | `./.github/skills/ctf-testing/deploy_and_test.sh gcp --with-reboot` | Basic checks plus reboot, required service checks, and progress persistence | + | Full test on all providers | `./.github/skills/ctf-testing/deploy_and_test.sh all --with-reboot` | Same full checks across AWS, Azure, and GCP | + + The deployment script uses contributor mode, so Terraform uploads the local setup package and runs the code from your working tree. It does not test GitHub Release assets. +4. **Treat cleanup as part of the test, not an optional follow-up.** + - Verify no test resources remain after the script exits. + - If resources remain, run provider-specific cleanup or `terraform destroy` from the provider directory. + - 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. + - Summary line: `RESULT: PASS ()` or `RESULT: FAIL ()`. + +## Failure Handling + +When a run fails, identify the first failing layer: + +| Failure point | What to collect | +| --- | --- | +| Terraform failed | Provider name and Terraform error | +| SSH failed | VM IP and SSH wait output | +| Setup failed | `/var/lib/linux-ctfs/setup.failed`, `/var/log/ctf_setup.log`, and `/var/log/cloud-init-output.log` | +| Challenge failed | Challenge number and failing test output | +| Reboot failed | Failed service name and `journalctl -u ` output | +| Cleanup failed | Remaining cloud resources | + +## CTF Safety Rules + +- Do not create committed solution files. +- Keep solution commands only in `.github/skills/ctf-testing/test_ctf_challenges.sh`. +- Do not add flags or challenge solutions to learner-facing docs. +- Do not make challenges easier while fixing tests. ## Scripts diff --git a/.github/skills/ctf-testing/deploy_and_test.sh b/.github/skills/ctf-testing/deploy_and_test.sh index 73af503..7182faa 100755 --- a/.github/skills/ctf-testing/deploy_and_test.sh +++ b/.github/skills/ctf-testing/deploy_and_test.sh @@ -353,6 +353,41 @@ _wait_for_ssh() { return 1 } +# Wait for setup markers before running challenge tests +# Arguments: +# $1 - IP address of the VM +# Returns: +# 0 on success, 1 on timeout +_wait_for_setup() { + local ip="$1" + local attempt=1 + + _log INFO "Waiting for CTF setup to finish..." + + 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 + _log OK "CTF setup is complete" + return 0 + fi + + # shellcheck disable=SC2086 + if _sshpass_cmd ssh ${SSH_OPTS} "${SSH_USER}@${ip}" \ + "test -f /var/lib/linux-ctfs/setup.failed" &>/dev/null; then + _log ERROR "CTF setup reported failure. Check /var/log/ctf_setup.log on the VM." + return 1 + fi + + echo " Attempt ${attempt}/${MAX_SSH_ATTEMPTS} - waiting..." + sleep "${SSH_RETRY_INTERVAL}" + ((attempt++)) + done + + _log ERROR "CTF setup timed out" + return 1 +} + # Reboot/restart a VM and return the new IP address # Arguments: # $1 - Cloud provider name (aws, azure, gcp) @@ -538,6 +573,15 @@ _test_provider() { return 1 fi + # Wait for setup markers + if ! _wait_for_setup "${ip}"; then + _log ERROR "Setup did not complete for ${provider}" + CLEANUP_ON_EXIT=false + _terraform_destroy "${provider}" + CURRENT_PROVIDER="" + return 1 + fi + # Run tests local test_exit_code=0 _run_tests "${provider}" "${ip}" || test_exit_code=$? diff --git a/.github/skills/ctf-testing/test_ctf_challenges.sh b/.github/skills/ctf-testing/test_ctf_challenges.sh index de0ea32..9a43c52 100644 --- a/.github/skills/ctf-testing/test_ctf_challenges.sh +++ b/.github/skills/ctf-testing/test_ctf_challenges.sh @@ -147,7 +147,7 @@ if [[ -f "${REBOOT_MARKER}" ]]; then EXPECTED=$(cat "$PROGRESS_SNAPSHOT") ACTUAL=$(sort -u /var/ctf/completed_challenges 2>/dev/null | wc -l) if [ "$ACTUAL" -ge "$EXPECTED" ]; then - pass "Progress persisted after reboot ($ACTUAL challenges)" + _pass "Progress persisted after reboot ($ACTUAL challenges)" else _fail "Progress lost after reboot (expected ${EXPECTED}, got ${ACTUAL})" fi @@ -356,19 +356,20 @@ else FLAGS[8]="" fi -# Challenge 9: DNS Configuration -# Hint: "DNS settings are stored in /etc/resolv.conf" -echo "Challenge 9: DNS Configuration" -if [[ -r /etc/resolv.conf ]]; then - FLAG_9=$(grep -ao 'CTF{[^}]*}' /etc/resolv.conf 2>/dev/null | head -1) || true +# Challenge 9: DNS Inspection +# Hint: "Inspect systemd-resolved configuration safely" +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 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: resolv.conf has no CTF flag - SETUP BUG" + _fail "Challenge 9: DNS drop-in has no CTF flag - SETUP BUG" FLAGS[9]="" fi else - _fail "Challenge 9: /etc/resolv.conf not readable - SETUP BUG" + _fail "Challenge 9: DNS drop-in not readable - SETUP BUG" FLAGS[9]="" fi diff --git a/.github/workflows/release-setup.yml b/.github/workflows/release-setup.yml new file mode 100644 index 0000000..49bc1af --- /dev/null +++ b/.github/workflows/release-setup.yml @@ -0,0 +1,56 @@ +name: Release setup package + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to upload assets to" + required: true + +permissions: + contents: write + +jobs: + package-setup: + runs-on: ubuntu-latest + + steps: + - name: Resolve release tag + id: release + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Check out release code + uses: actions/checkout@v4 + with: + ref: ${{ steps.release.outputs.tag }} + + - name: Build deterministic setup archive + run: | + set -euo pipefail + asset="linux-ctfs-setup.tar.gz" + tar \ + --sort=name \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --mtime="UTC 1970-01-01" \ + -cf - ctf_setup.sh setup verify | gzip -n > "$asset" + sha256sum "$asset" > "$asset.sha256" + + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + gh release upload "$TAG" \ + "linux-ctfs-setup.tar.gz" \ + "linux-ctfs-setup.tar.gz.sha256" \ + --clobber diff --git a/.gitignore b/.gitignore index 988e9c5..3cd4bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ *.swp *.swo +# Python +__pycache__/ +*.py[cod] +.venv/ + # Terraform *.tfstate *.tfstate.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d93e8db..973a6fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,106 +1,145 @@ # Contributing to Linux CTF -Thank you for your interest in contributing! This guide will help you get started. +Thanks for helping improve Linux CTF. This guide covers what contributors need today to make, test, and submit changes. ## Before You Start -**Please open an issue before submitting a PR.** This lets us discuss the proposed changes and ensure they align with the project goals. This applies to: +Open an issue before submitting a PR. This gives maintainers a chance to discuss the change before you spend time on it. + +Open an issue for: - New challenges - Bug fixes in existing challenges - Infrastructure changes -- Documentation improvements +- Documentation updates ## What We Accept -✅ Bug fixes for challenge infrastructure -✅ Improvements to setup reliability -✅ Cloud provider optimizations -✅ Documentation clarifications +Good contributions include: + +- Challenge setup fixes +- Setup reliability improvements +- Cloud provider fixes or optimizations +- Clear documentation improvements + +Do not contribute: + +- Challenge solutions in learner-facing docs +- Extra hints beyond what `verify hint` provides +- Files that copy flags, answers, or solution commands +- Changes that make a challenge much easier unless the issue asks for that + +Keep solution commands only in `.github/skills/ctf-testing/test_ctf_challenges.sh`. + +## Pull Request Checklist + +Before opening a PR: + +1. Create a feature branch. +2. Make focused changes tied to an issue. +3. Run the relevant local checks. +4. Run a basic CTF test on at least one cloud provider. +5. Include the issue number, what you tested, and the result in the PR description. + +## Local Checks + +Run the checks that match the files you changed. + +For setup or verify changes: + +```bash +python3 -m compileall -q setup verify +bash -n ctf_setup.sh +shellcheck -S warning -e SC1091 -e SC2086 ctf_setup.sh .github/skills/ctf-testing/test_ctf_challenges.sh .github/skills/ctf-testing/deploy_and_test.sh +``` + +For Terraform changes: -❌ Challenge solutions or hints beyond what `verify hint` provides -❌ Changes that make challenges significantly easier +```bash +terraform -chdir=aws fmt -check && terraform -chdir=aws validate +terraform -chdir=azure fmt -check && terraform -chdir=azure validate +terraform -chdir=gcp fmt -check && terraform -chdir=gcp validate +``` -## Testing Requirements +If you only changed one provider, you only need to run that provider's Terraform checks. -**All tests must pass before submitting a PR.** You must verify your changes work on at least one cloud provider. +## Cloud Testing + +All PRs that change setup, challenges, verify behavior, or Terraform should be tested on at least one cloud provider. ### Prerequisites -1. **Terraform** (>= 1.0) -2. **sshpass** - - macOS: `brew install hudochenkov/sshpass/sshpass` - - Linux: `apt install sshpass` or `yum install sshpass` - - Windows: Use WSL (Windows Subsystem for Linux) -3. **Cloud CLI** authenticated for your chosen provider: +Install: + +1. `terraform` 1.0 or newer +2. `jq` +3. `sshpass` +4. The cloud CLI for the provider you want to test -| Provider | Verify Authentication | -|----------|----------------------| +Check cloud authentication before running a test: + +| Provider | Authentication check | +| --- | --- | | AWS | `aws sts get-caller-identity` | | Azure | `az account show` | | GCP | `gcloud auth list --filter=status:ACTIVE` | -### Running Tests +### Basic and Full Tests -#### Option 1: Using an AI Coding Assistant (Recommended) +The testing script uses contributor mode automatically. It uploads your local `ctf_setup.sh`, `setup/`, and `verify/` files to the VM, so it tests your working tree. It does not test GitHub Release assets. -If you use an AI coding assistant with agent/tool capabilities: +| Test type | Command | Use when | +| --- | --- | --- | +| Basic AWS | `./.github/skills/ctf-testing/deploy_and_test.sh aws` | Testing normal challenge behavior on AWS | +| Basic Azure | `./.github/skills/ctf-testing/deploy_and_test.sh azure` | Testing normal challenge behavior on Azure | +| Basic GCP | `./.github/skills/ctf-testing/deploy_and_test.sh gcp` | Testing normal challenge behavior on GCP | +| Basic all providers | `./.github/skills/ctf-testing/deploy_and_test.sh all` | Checking all providers without reboot | +| Full AWS | `./.github/skills/ctf-testing/deploy_and_test.sh aws --with-reboot` | Testing AWS plus reboot behavior | +| Full Azure | `./.github/skills/ctf-testing/deploy_and_test.sh azure --with-reboot` | Testing Azure plus reboot behavior | +| Full GCP | `./.github/skills/ctf-testing/deploy_and_test.sh gcp --with-reboot` | Testing GCP plus reboot behavior | +| Full all providers | `./.github/skills/ctf-testing/deploy_and_test.sh all --with-reboot` | Release confidence across all providers | -| Tool | How to Test | -|------|-------------| -| GitHub Copilot (VS Code) | Open Chat in agent mode, prompt: `Test the AWS lab` | -| Claude Code | Prompt: `Test the AWS lab` | -| Cursor, Windsurf, etc. | Use agent mode and prompt: `Test the AWS lab` | +Basic tests validate the `verify` command, all 18 challenges, export certificates, and verification tokens. -Replace `AWS` with `Azure` or `GCP` as needed. The AI will use the CTF testing skill to deploy, test, and clean up. +Full tests do everything in a basic test, then reboot the VM and check required services plus progress persistence. -#### Option 2: Manual +If you use an AI coding assistant with skills, use prompts like: -Run from the repository root: +- `Run a basic test on Azure` +- `Run a basic test on GCP` +- `Run a full test on AWS` +- `Run a full test on all providers` -```bash -./.github/skills/ctf-testing/deploy_and_test.sh -``` +See `.github/skills/ctf-testing/SKILL.md` for the agent workflow. -Examples: -```bash -./.github/skills/ctf-testing/deploy_and_test.sh aws -./.github/skills/ctf-testing/deploy_and_test.sh azure -./.github/skills/ctf-testing/deploy_and_test.sh gcp -``` +## Deployment Modes -For thorough testing (includes reboot verification): -```bash -./.github/skills/ctf-testing/deploy_and_test.sh aws --with-reboot -``` +Most contributors only need to know this: -### What Gets Tested +- Normal learner deployments use release mode. +- Contributor testing uses local files. +- `deploy_and_test.sh` handles contributor mode for you. -- All 18 challenges are properly set up -- Services are running and accessible -- Flags can be discovered and submitted -- Progress tracking works -- Verification token generation and format -- (With `--with-reboot`) Services survive VM restart +If you manually run Terraform to test local setup changes, pass: -See [.github/skills/ctf-testing/SKILL.md](.github/skills/ctf-testing/SKILL.md) for detailed documentation. +```bash +terraform apply -var use_local_setup=true +``` + +Release mode downloads a setup package from GitHub Releases. Testing release mode requires published release assets, so it is usually maintainer work. -## Pull Request Process +## Troubleshooting -1. **Open an issue first** to discuss your proposed changes -2. **Fork the repository** and create a feature branch -3. **Make your changes** with clear, descriptive commits -4. **Run tests** on at least one cloud provider -5. **Submit your PR** referencing the issue number -6. **Respond to feedback** from maintainers +If setup fails on a VM, check: -## Code Style +```text +/var/lib/linux-ctfs/setup.failed +/var/log/ctf_setup.log +/var/log/cloud-init-output.log +``` -- Shell scripts should pass `shellcheck` -- Terraform should be formatted with `terraform fmt` -- Use descriptive variable and function names -- Add comments for non-obvious logic +If cloud cleanup fails, run `terraform destroy` from the provider directory and report any resources that remain. -## Questions? +## Questions -If you're unsure about anything, open an issue and ask. We're happy to help! +If you are unsure what to test or how to scope a change, open an issue and ask. diff --git a/README.md b/README.md index 02d81a5..0084325 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Test your Linux command line skills with 18 progressive Capture The Flag challen | 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 Troubleshooting | Someone modified a critical DNS config file. Fix it | ⭐⭐ | DNS, `/etc/resolv.conf` | +| 9 | DNS Inspection | Inspect the system DNS configuration without changing live resolver files | ⭐⭐ | DNS, `systemd-resolved` | | 10 | Remote Upload | Transfer any file to `ctf_challenges` 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 | | 12 | Network Traffic Analysis | Someone is sending secret messages via ping packets | ⭐⭐⭐ | Packet inspection, tcpdump | @@ -106,6 +106,7 @@ To help us troubleshoot quickly, include: - CLI status/output confirming you're authenticated (`aws sts get-caller-identity`, `az account show`, or `gcloud auth list --filter=status:ACTIVE`) - The command you ran and the **exact error output** (redact any secrets) - Whether the failure is during `terraform apply`, SSH connection, or inside the VM (e.g., `verify progress`) +- If SSH works but the lab is not ready, check `/var/log/ctf_setup.log`, `/var/lib/cloud/instance/ctf-setup.done`, `/var/lib/linux-ctfs/setup.done`, and `/var/lib/linux-ctfs/setup.failed` ## License diff --git a/aws/README.md b/aws/README.md index 521e0c5..827d7c6 100644 --- a/aws/README.md +++ b/aws/README.md @@ -29,6 +29,22 @@ Type `yes` when prompted. + By default, Terraform uses release mode. The VM downloads the setup package from the latest GitHub Release, verifies its SHA-256 checksum, and runs setup during first boot. + + Maintainers can choose a specific setup release if they need to pin or roll back the setup package: + + ```sh + terraform apply -var setup_release_tag="v0.1.0" + ``` + + If you are testing unmerged local setup changes, run: + + ```sh + terraform apply -var use_local_setup=true + ``` + + Contributor mode uploads your local setup files and uses SSH to run them on the VM. + 4. Note the `public_ip_address` output—you'll use this to connect. ## Accessing the Lab @@ -105,6 +121,11 @@ Include: - `aws sts get-caller-identity` output (no secrets) - The exact `terraform apply` error output (redact any secrets) - Whether SSH fails or the issue happens after login (e.g., `verify progress`) +- If SSH works but the lab is not ready, check `/var/log/ctf_setup.log`, `/var/lib/cloud/instance/ctf-setup.done`, `/var/lib/linux-ctfs/setup.done`, and `/var/lib/linux-ctfs/setup.failed` + +### Changing Setup Versions + +The setup script runs during first boot. If you change `setup_release_tag` after a VM already exists, or if the default `latest` release changes, recreate the VM so first-boot setup runs with the new package. ## Security Note diff --git a/aws/main.tf b/aws/main.tf index a744401..ad54583 100644 --- a/aws/main.tf +++ b/aws/main.tf @@ -12,24 +12,99 @@ terraform { variable "aws_region" { description = "The AWS region to deploy the CTF lab" type = string - default = "us-east-1" # Default region if not specified + default = "us-east-1" # Default region if not specified } variable "use_local_setup" { - description = "Use local ctf_setup.sh instead of fetching from GitHub (for testing)" + description = "Upload and run the local setup package instead of using a pinned release asset (for contributor testing)" type = bool default = false } +variable "setup_release_tag" { + description = "GitHub release tag that contains the setup package assets, or latest for the newest release" + type = string + default = "latest" +} + # Configure the AWS Provider with the variable region provider "aws" { region = var.aws_region } -# Compress the setup script to fit within AWS user_data limit (16KB limit for base64) -data "external" "compressed_setup" { - count = var.use_local_setup ? 1 : 0 - program = ["bash", "-c", "jq -n --arg data \"$(tr -d '\\r' < ${path.module}/../ctf_setup.sh | gzip -c | base64)\" '{compressed: $data}'"] +locals { + setup_asset_name = "linux-ctfs-setup.tar.gz" + setup_release_base = var.setup_release_tag == "latest" ? "https://github.com/learntocloud/linux-ctfs/releases/latest/download" : "https://github.com/learntocloud/linux-ctfs/releases/download/${var.setup_release_tag}" + setup_release_url = "${local.setup_release_base}/${local.setup_asset_name}" + setup_checksum_url = "${local.setup_release_url}.sha256" + + local_bootstrap_script = <<-EOF + #!/bin/bash + set -e + useradd -m -s /bin/bash ctf_user 2>/dev/null || true + echo 'ctf_user:CTFpassword123!' | chpasswd + usermod -aG sudo ctf_user + echo 'ctf_user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/90-ctf-user + chmod 440 /etc/sudoers.d/90-ctf-user + mkdir -p /etc/ssh/sshd_config.d + printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication yes\n' > /etc/ssh/sshd_config.d/99-ctf-local-bootstrap.conf + systemctl restart ssh || systemctl restart sshd || true + EOF + + release_setup_script = <<-EOF + #!/bin/bash + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + STATE_DIR="/var/lib/linux-ctfs" + FAILED_MARKER="$${STATE_DIR}/setup.failed" + DONE_MARKER="$${STATE_DIR}/setup.done" + INSTALL_DIR="/opt/linux-ctfs-setup" + WORK_DIR="/opt/linux-ctfs-download" + ASSET_NAME="${local.setup_asset_name}" + SETUP_URL="${local.setup_release_url}" + CHECKSUM_URL="${local.setup_checksum_url}" + + mkdir -p "$${STATE_DIR}" "$${WORK_DIR}" + rm -f "$${FAILED_MARKER}" + + fail_setup() { + echo "CTF setup failed. Check /var/log/cloud-init-output.log and /var/log/ctf_setup.log." >&2 + touch "$${FAILED_MARKER}" + } + trap fail_setup ERR + + download_with_retry() { + local url="$1" + local output="$2" + local attempt + for attempt in 1 2 3 4 5; do + if curl -fL --retry 3 --retry-delay 5 --connect-timeout 20 "$${url}" -o "$${output}"; then + return 0 + fi + echo "Download failed for $${url}. Attempt $${attempt}/5." + sleep 10 + done + return 1 + } + + apt-get update + apt-get install -y ca-certificates curl tar gzip coreutils + + cd "$${WORK_DIR}" + download_with_retry "$${SETUP_URL}" "$${ASSET_NAME}" + download_with_retry "$${CHECKSUM_URL}" "$${ASSET_NAME}.sha256" + sha256sum -c "$${ASSET_NAME}.sha256" + + rm -rf "$${INSTALL_DIR}" + mkdir -p "$${INSTALL_DIR}" + tar -xzf "$${ASSET_NAME}" -C "$${INSTALL_DIR}" + chmod +x "$${INSTALL_DIR}/ctf_setup.sh" + "$${INSTALL_DIR}/ctf_setup.sh" + + touch "$${DONE_MARKER}" + rm -f "$${FAILED_MARKER}" + trap - ERR + EOF } # Fetch availability zones @@ -40,7 +115,7 @@ data "aws_availability_zones" "available" { # Create a VPC resource "aws_vpc" "ctf_vpc" { cidr_block = "10.0.0.0/16" - + tags = { Name = "CTF Lab VPC" } @@ -57,8 +132,8 @@ resource "aws_internet_gateway" "ctf_igw" { # Create a Subnet resource "aws_subnet" "ctf_subnet" { - vpc_id = aws_vpc.ctf_vpc.id - cidr_block = "10.0.1.0/24" + vpc_id = aws_vpc.ctf_vpc.id + cidr_block = "10.0.1.0/24" availability_zone = data.aws_availability_zones.available.names[0] tags = { @@ -98,7 +173,7 @@ resource "aws_security_group" "ctf_sg" { protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } - + ingress { from_port = 80 to_port = 80 @@ -136,7 +211,7 @@ resource "aws_security_group" "ctf_sg" { # Create an EC2 Instance data "aws_ami" "ubuntu" { most_recent = true - + filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] @@ -150,7 +225,7 @@ data "aws_ami" "ubuntu" { } resource "aws_instance" "ctf_instance" { - ami = data.aws_ami.ubuntu.id + ami = data.aws_ami.ubuntu.id instance_type = "t3.micro" vpc_security_group_ids = [aws_security_group.ctf_sg.id] @@ -158,33 +233,47 @@ resource "aws_instance" "ctf_instance" { associate_public_ip_address = true - # Use local file for testing, GitHub for production - # AWS supports gzip-compressed user_data (cloud-init auto-decompresses) - user_data_base64 = var.use_local_setup ? data.external.compressed_setup[0].result.compressed : base64encode(<<-EOF - #!/bin/bash - curl -fsSL https://raw.githubusercontent.com/learntocloud/linux-ctfs/main/ctf_setup.sh | bash - EOF - ) + user_data = var.use_local_setup ? local.local_bootstrap_script : local.release_setup_script + user_data_replace_on_change = true tags = { Name = "CTF Lab Instance" } } -resource "null_resource" "wait_for_setup" { +resource "null_resource" "local_setup" { + count = var.use_local_setup ? 1 : 0 depends_on = [aws_instance.ctf_instance] - + + connection { + type = "ssh" + host = aws_instance.ctf_instance.public_ip + user = "ctf_user" + password = "CTFpassword123!" + timeout = "10m" + } + + provisioner "remote-exec" { + inline = [ + "rm -rf /tmp/linux-ctfs-local-setup", + "mkdir -p /tmp/linux-ctfs-local-setup" + ] + } + + provisioner "local-exec" { + command = "mkdir -p ${path.module}/.terraform && tar --exclude='__pycache__' --exclude='*.pyc' --exclude='.venv' -czf ${path.module}/.terraform/linux-ctfs-local-setup.tar.gz -C ${path.module}/.. ctf_setup.sh setup verify" + } + + provisioner "file" { + source = "${path.module}/.terraform/linux-ctfs-local-setup.tar.gz" + destination = "/tmp/linux-ctfs-local-setup.tar.gz" + } + provisioner "remote-exec" { - connection { - type = "ssh" - host = aws_instance.ctf_instance.public_ip - user = "ctf_user" - password = "CTFpassword123!" - timeout = "10m" - } - inline = [ - "while [ ! -f /var/log/setup_complete ]; do sleep 10; done" + "cloud-init status --wait", + "tar -xzf /tmp/linux-ctfs-local-setup.tar.gz -C /tmp/linux-ctfs-local-setup", + "sudo chmod +x /tmp/linux-ctfs-local-setup/ctf_setup.sh && sudo /tmp/linux-ctfs-local-setup/ctf_setup.sh" ] } } @@ -210,6 +299,5 @@ resource "aws_ec2_instance_state" "ctf_instance_state" { instance_id = aws_instance.ctf_instance.id state = var.ctf_instance_state - # Ensure the instance is fully set up before changing its power state - depends_on = [null_resource.wait_for_setup] + depends_on = [null_resource.local_setup] } diff --git a/azure/README.md b/azure/README.md index b9bf16f..2c224de 100644 --- a/azure/README.md +++ b/azure/README.md @@ -37,6 +37,19 @@ Type `yes` when prompted. + By default, Terraform uses release mode. The VM downloads the setup package from the latest GitHub Release, verifies its SHA-256 checksum, and runs setup during first boot. + + Maintainers can choose a specific setup release if they need to pin or roll back the setup package: + + ```sh + terraform apply \ + -var subscription_id="YOUR_AZURE_SUBSCRIPTION_ID" \ + -var az_region="YOUR_AZURE_REGION" \ + -var setup_release_tag="v0.1.0" + ``` + + If you are testing unmerged local setup changes, add `-var use_local_setup=true` to the `terraform apply` command. Contributor mode uploads your local setup files and uses SSH to run them on the VM. + 4. Note the `public_ip_address` output—you'll use this to connect. ## Accessing the Lab @@ -111,6 +124,11 @@ Include: - `az account show` output (no secrets) - The exact `terraform apply` error output (redact any secrets) - Whether SSH fails or the issue happens after login (e.g., `verify progress`) +- If SSH works but the lab is not ready, check `/var/log/ctf_setup.log`, `/var/lib/cloud/instance/ctf-setup.done`, `/var/lib/linux-ctfs/setup.done`, and `/var/lib/linux-ctfs/setup.failed` + +### Changing Setup Versions + +The setup script runs during first boot. If you change `setup_release_tag` after a VM already exists, or if the default `latest` release changes, recreate the VM so first-boot setup runs with the new package. ### VM Size / Capacity Errors diff --git a/azure/main.tf b/azure/main.tf index 4f10510..4c0bf9a 100644 --- a/azure/main.tf +++ b/azure/main.tf @@ -1,11 +1,11 @@ # main.tf terraform { required_version = ">= 1.14.0" - + required_providers { azurerm = { source = "hashicorp/azurerm" - version = ">= 4.55.0" # Minimum version that supports azurerm_virtual_machine_power action + version = ">= 4.55.0" # Minimum version that supports azurerm_virtual_machine_power action } null = { source = "hashicorp/null" @@ -22,15 +22,96 @@ variable "az_region" { variable "subscription_id" { description = "Your Azure Subscription ID" - type = string + type = string } variable "use_local_setup" { - description = "Use local ctf_setup.sh instead of fetching from GitHub (for testing)" + description = "Upload and run the local setup package instead of using a pinned release asset (for contributor testing)" type = bool default = false } +variable "setup_release_tag" { + description = "GitHub release tag that contains the setup package assets, or latest for the newest release" + type = string + default = "latest" +} + +locals { + setup_asset_name = "linux-ctfs-setup.tar.gz" + setup_release_base = var.setup_release_tag == "latest" ? "https://github.com/learntocloud/linux-ctfs/releases/latest/download" : "https://github.com/learntocloud/linux-ctfs/releases/download/${var.setup_release_tag}" + setup_release_url = "${local.setup_release_base}/${local.setup_asset_name}" + setup_checksum_url = "${local.setup_release_url}.sha256" + + local_bootstrap_script = <<-EOF + #!/bin/bash + set -e + useradd -m -s /bin/bash ctf_user 2>/dev/null || true + echo 'ctf_user:CTFpassword123!' | chpasswd + usermod -aG sudo ctf_user + echo 'ctf_user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/90-ctf-user + chmod 440 /etc/sudoers.d/90-ctf-user + mkdir -p /etc/ssh/sshd_config.d + printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication yes\n' > /etc/ssh/sshd_config.d/99-ctf-local-bootstrap.conf + systemctl restart ssh || systemctl restart sshd || true + EOF + + release_setup_script = <<-EOF + #!/bin/bash + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + STATE_DIR="/var/lib/linux-ctfs" + FAILED_MARKER="$${STATE_DIR}/setup.failed" + DONE_MARKER="$${STATE_DIR}/setup.done" + INSTALL_DIR="/opt/linux-ctfs-setup" + WORK_DIR="/opt/linux-ctfs-download" + ASSET_NAME="${local.setup_asset_name}" + SETUP_URL="${local.setup_release_url}" + CHECKSUM_URL="${local.setup_checksum_url}" + + mkdir -p "$${STATE_DIR}" "$${WORK_DIR}" + rm -f "$${FAILED_MARKER}" + + fail_setup() { + echo "CTF setup failed. Check /var/log/cloud-init-output.log and /var/log/ctf_setup.log." >&2 + touch "$${FAILED_MARKER}" + } + trap fail_setup ERR + + download_with_retry() { + local url="$1" + local output="$2" + local attempt + for attempt in 1 2 3 4 5; do + if curl -fL --retry 3 --retry-delay 5 --connect-timeout 20 "$${url}" -o "$${output}"; then + return 0 + fi + echo "Download failed for $${url}. Attempt $${attempt}/5." + sleep 10 + done + return 1 + } + + apt-get update + apt-get install -y ca-certificates curl tar gzip coreutils + + cd "$${WORK_DIR}" + download_with_retry "$${SETUP_URL}" "$${ASSET_NAME}" + download_with_retry "$${CHECKSUM_URL}" "$${ASSET_NAME}.sha256" + sha256sum -c "$${ASSET_NAME}.sha256" + + rm -rf "$${INSTALL_DIR}" + mkdir -p "$${INSTALL_DIR}" + tar -xzf "$${ASSET_NAME}" -C "$${INSTALL_DIR}" + chmod +x "$${INSTALL_DIR}/ctf_setup.sh" + "$${INSTALL_DIR}/ctf_setup.sh" + + touch "$${DONE_MARKER}" + rm -f "$${FAILED_MARKER}" + trap - ERR + EOF +} + provider "azurerm" { features {} subscription_id = var.subscription_id @@ -64,7 +145,7 @@ resource "azurerm_public_ip" "ctf_public_ip" { location = azurerm_resource_group.ctf_rg.location resource_group_name = azurerm_resource_group.ctf_rg.name allocation_method = "Static" - sku = "Standard" + sku = "Standard" } # Create a network security group @@ -84,7 +165,7 @@ resource "azurerm_network_security_group" "ctf_nsg" { source_address_prefix = "*" destination_address_prefix = "*" } - security_rule { + security_rule { name = "HTTP" priority = 1002 direction = "Inbound" @@ -167,12 +248,7 @@ resource "azurerm_linux_virtual_machine" "ctf_vm" { version = "latest" } - # Use local file for testing, GitHub for production - custom_data = var.use_local_setup ? base64encode(replace(file("${path.module}/../ctf_setup.sh"), "\r\n", "\n")) : base64encode(<<-EOF - #!/bin/bash - curl -fsSL https://raw.githubusercontent.com/learntocloud/linux-ctfs/main/ctf_setup.sh | bash - EOF - ) + custom_data = base64encode(var.use_local_setup ? local.local_bootstrap_script : local.release_setup_script) } action "azurerm_virtual_machine_power" "ctf_power_off" { @@ -189,25 +265,44 @@ action "azurerm_virtual_machine_power" "ctf_power_on" { } } -resource "null_resource" "wait_for_setup" { +resource "null_resource" "local_setup" { + count = var.use_local_setup ? 1 : 0 depends_on = [azurerm_linux_virtual_machine.ctf_vm] - + + connection { + host = azurerm_linux_virtual_machine.ctf_vm.public_ip_address + user = "ctf_user" + password = "CTFpassword123!" + timeout = "10m" + } + + provisioner "remote-exec" { + inline = [ + "rm -rf /tmp/linux-ctfs-local-setup", + "mkdir -p /tmp/linux-ctfs-local-setup" + ] + } + + provisioner "local-exec" { + command = "mkdir -p ${path.module}/.terraform && tar --exclude='__pycache__' --exclude='*.pyc' --exclude='.venv' -czf ${path.module}/.terraform/linux-ctfs-local-setup.tar.gz -C ${path.module}/.. ctf_setup.sh setup verify" + } + + provisioner "file" { + source = "${path.module}/.terraform/linux-ctfs-local-setup.tar.gz" + destination = "/tmp/linux-ctfs-local-setup.tar.gz" + } + provisioner "remote-exec" { - connection { - host = azurerm_linux_virtual_machine.ctf_vm.public_ip_address - user = "ctf_user" - password = "CTFpassword123!" - timeout = "10m" - } - inline = [ - "while [ ! -f /var/log/setup_complete ]; do sleep 10; done" + "cloud-init status --wait", + "tar -xzf /tmp/linux-ctfs-local-setup.tar.gz -C /tmp/linux-ctfs-local-setup", + "sudo chmod +x /tmp/linux-ctfs-local-setup/ctf_setup.sh && sudo /tmp/linux-ctfs-local-setup/ctf_setup.sh" ] } } # Output the public IP address output "public_ip_address" { - value = azurerm_linux_virtual_machine.ctf_vm.public_ip_address - depends_on = [null_resource.wait_for_setup] + value = azurerm_linux_virtual_machine.ctf_vm.public_ip_address + depends_on = [null_resource.local_setup] } \ No newline at end of file diff --git a/ctf_setup.sh b/ctf_setup.sh index a1b2772..bb09ed4 100644 --- a/ctf_setup.sh +++ b/ctf_setup.sh @@ -1,760 +1,63 @@ #!/bin/bash # # CTF Environment Setup Script -# Sets up 18 Linux command-line challenges for learning +# Thin cloud-init bootstrap. The actual setup logic lives in setup/. # set -euo pipefail -# ============================================================================= -# DYNAMIC FLAG GENERATION -# ============================================================================= - -generate_flag_suffix() { - head -c 4 /dev/urandom | xxd -p -} - -INSTANCE_SUFFIX=$(generate_flag_suffix) - -declare -A FLAG_BASES=( - [0]="example" - [1]="hidden_files" - [2]="file_search" - [3]="log_analysis" - [4]="user_enum" - [5]="perm_sleuth" - [6]="net_detective" - [7]="decode_master" - [8]="ssh_secrets" - [9]="dns_name" - [10]="net_copy" - [11]="web_config" - [12]="icmp" - [13]="cron_master" - [14]="proc_env" - [15]="archive_dig" - [16]="link_follow" - [17]="history_sleuth" - [18]="disk_sleuth" -) - -declare -A FLAGS -for i in {0..18}; do - if [ "$i" -eq 0 ]; then - FLAGS[$i]="CTF{example}" - elif [ "$i" -eq 12 ]; then - # Flag 12 must be <=16 chars for ping -p (max 32 hex chars) - SHORT_SUFFIX=$(echo "$INSTANCE_SUFFIX" | cut -c1-4) - FLAGS[$i]="CTF{${FLAG_BASES[$i]}_${SHORT_SUFFIX}}" - else - FLAGS[$i]="CTF{${FLAG_BASES[$i]}_${INSTANCE_SUFFIX}}" - fi -done - -declare -A FLAG_HASHES -for i in {0..18}; do - FLAG_HASHES[$i]=$(echo -n "${FLAGS[$i]}" | sha256sum | cut -d' ' -f1) -done - -# ============================================================================= -# VERIFICATION TOKEN SECRET -# ============================================================================= - -INSTANCE_ID=$(head -c 16 /dev/urandom | xxd -p) -MASTER_SECRET="L2C_CTF_MASTER_2024" -VERIFICATION_SECRET=$(echo -n "${MASTER_SECRET}:${INSTANCE_ID}" | sha256sum | cut -d' ' -f1) - -# ============================================================================= -# SYSTEM SETUP -# ============================================================================= - -sudo apt-get update -sudo apt-get install -y net-tools nmap tree nginx inotify-tools figlet lolcat - -for f in /etc/update-motd.d/*; do - sudo chmod -x "$f" 2>/dev/null || true -done - -if ! id "ctf_user" &>/dev/null; then - sudo useradd -m -s /bin/bash ctf_user - echo 'ctf_user:CTFpassword123!' | sudo chpasswd - sudo usermod -aG sudo ctf_user -fi - -# shellcheck disable=SC2016 -echo 'case "$TERM" in *-ghostty) export TERM=xterm-256color;; esac' | sudo tee /etc/profile.d/fix-term.sh > /dev/null -sudo chmod 644 /etc/profile.d/fix-term.sh - -sudo sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config -sudo sed -i 's/ChallengeResponseAuthentication no/ChallengeResponseAuthentication yes/' /etc/ssh/sshd_config -sudo sed -i 's/KbdInteractiveAuthentication no/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config -sudo systemctl restart ssh - -sudo -u ctf_user mkdir -p /home/ctf_user/ctf_challenges -cd /home/ctf_user/ctf_challenges || { echo "Failed to change directory"; exit 1; } - -# ============================================================================= -# WRITE FLAG HASHES FILE -# ============================================================================= - -sudo mkdir -p /etc/ctf -sudo chmod 711 /etc/ctf -cat > /tmp/ctf_hashes << HASHEOF -$(for i in {0..18}; do echo "${FLAG_HASHES[$i]}"; done) -HASHEOF -sudo mv /tmp/ctf_hashes /etc/ctf/flag_hashes -sudo chmod 644 /etc/ctf/flag_hashes - -echo "$INSTANCE_ID" | sudo tee /etc/ctf/instance_id > /dev/null -echo "$VERIFICATION_SECRET" | sudo tee /etc/ctf/verification_secret > /dev/null -sudo chmod 644 /etc/ctf/instance_id -sudo chmod 600 /etc/ctf/verification_secret - -# Shared progress directory (so root and non-root users share the same progress) -sudo mkdir -p /var/ctf -sudo chmod 777 /var/ctf - -sudo tee /usr/local/bin/verify > /dev/null << 'EOFVERIFY' -#!/bin/bash - -HASH_FILE="/etc/ctf/flag_hashes" -if [ ! -f "$HASH_FILE" ]; then - echo "Error: CTF not properly initialized. Hash file missing." - exit 1 +exec > >(tee /var/log/ctf_setup.log) 2>&1 + +DONE_MARKER="/var/lib/cloud/instance/ctf-setup.done" +LEGACY_DONE_MARKER="/var/log/setup_complete" +PROJECT_STATE_DIR="/var/lib/linux-ctfs" +PROJECT_DONE_MARKER="$PROJECT_STATE_DIR/setup.done" +PROJECT_FAILED_MARKER="$PROJECT_STATE_DIR/setup.failed" +UV_ROOT="/opt/uv" + +if [ -f "$DONE_MARKER" ]; then + echo "CTF setup already completed. Skipping." + mkdir -p "$PROJECT_STATE_DIR" + touch "$PROJECT_DONE_MARKER" + touch "$LEGACY_DONE_MARKER" + exit 0 fi -mapfile -t ANSWER_HASHES < "$HASH_FILE" - -INSTANCE_ID=$(cat /etc/ctf/instance_id 2>/dev/null || echo "") -VERIFICATION_SECRET=$(sudo cat /etc/ctf/verification_secret 2>/dev/null || echo "") - -CHALLENGE_NAMES=( - "Example Challenge" - "Hidden File Discovery" - "Basic File Search" - "Log Analysis" - "User Investigation" - "Permission Analysis" - "Service Discovery" - "Encoding Challenge" - "SSH Secrets" - "DNS Troubleshooting" - "Remote Upload Detection" - "Web Configuration" - "Network Traffic Analysis" - "Cron Job Hunter" - "Process Environment" - "Archive Archaeologist" - "Symbolic Sleuth" - "History Mystery" - "Disk Detective" -) - -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" - "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" - "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." - "DNS settings are stored in /etc/resolv.conf. Examine it carefully." - "Monitor file creation with tools like inotifywait, or try creating a file in ctf_challenges." - "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." - "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." - "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." -) - -PROGRESS_FILE=/var/ctf/completed_challenges -START_TIME_FILE=/var/ctf/ctf_start_time -END_TIME_FILE=/var/ctf/ctf_end_time - -check_flag() { - local challenge_num=$1 - local submitted_flag=$2 - - if ! [[ "$challenge_num" =~ ^[0-9]+$ ]] || [ "$challenge_num" -gt 18 ]; then - echo "✗ Invalid challenge number. Use 0-18." - return 1 - fi - - local submitted_hash - submitted_hash=$(echo -n "$submitted_flag" | sha256sum | cut -d' ' -f1) - - if [ "$submitted_hash" = "${ANSWER_HASHES[$challenge_num]}" ]; then - if [ "$challenge_num" -eq 0 ]; then - echo "✓ Example flag verified! Now try finding real flags." - else - echo "✓ Correct flag for Challenge $challenge_num!" - fi - echo "$challenge_num" >> "$PROGRESS_FILE" - sort -u "$PROGRESS_FILE" > "${PROGRESS_FILE}.tmp" - mv "${PROGRESS_FILE}.tmp" "$PROGRESS_FILE" - else - echo "✗ Incorrect flag. Try again!" - fi - show_progress -} - -show_progress() { - local completed=0 - if [ -f "$PROGRESS_FILE" ]; then - completed=$(sort -u "$PROGRESS_FILE" | wc -l) - completed=$((completed-1)) # Subtract example challenge - fi - echo "Flags Found: $completed/18" - if [ "$completed" -eq 18 ]; then - echo "Congratulations! You've completed all challenges!" - fi -} - -init_timer() { - if [ ! -f "$START_TIME_FILE" ]; then - date +%s > "$START_TIME_FILE" - fi -} - -get_elapsed_seconds() { - if [ ! -f "$START_TIME_FILE" ]; then - return 1 - fi - - local start_time end_time - start_time=$(cat "$START_TIME_FILE") - if [ -f "$END_TIME_FILE" ]; then - end_time=$(cat "$END_TIME_FILE") - else - end_time=$(date +%s) - fi - - echo $((end_time - start_time)) -} - -freeze_end_time_on_export() { - if [ -f "$END_TIME_FILE" ]; then - return - fi - - local completed=0 - if [ -f "$PROGRESS_FILE" ]; then - completed=$(sort -u "$PROGRESS_FILE" | wc -l) - completed=$((completed-1)) - fi - - if [ "$completed" -ge 18 ]; then - date +%s > "$END_TIME_FILE" - fi -} - -show_time() { - if [ ! -f "$START_TIME_FILE" ]; then - echo "Timer not started. Complete your first challenge to start the timer." - return - fi - local elapsed - elapsed=$(get_elapsed_seconds) - local hours=$((elapsed / 3600)) - local minutes=$(((elapsed % 3600) / 60)) - local seconds=$((elapsed % 60)) - printf "Elapsed Time: %02d:%02d:%02d\n" $hours $minutes $seconds -} - -show_list() { - echo "======================================" - echo " CTF Challenge Status" - echo "======================================" - for i in {0..18}; do - local status="[ ]" - if [ -f "$PROGRESS_FILE" ] && grep -q "^${i}$" "$PROGRESS_FILE"; then - status="[✓]" - fi - if [ $i -eq 0 ]; then - printf "%s %2d. %s (Example)\n" "$status" "$i" "${CHALLENGE_NAMES[$i]}" - else - printf "%s %2d. %s\n" "$status" "$i" "${CHALLENGE_NAMES[$i]}" - fi - done - echo "======================================" - show_progress -} - -show_hint() { - local num="${1:-}" - if [[ -z "$num" ]] || ! [[ "$num" =~ ^[0-9]+$ ]] || [[ "$num" -gt 18 ]]; then - echo "Usage: verify hint [0-18]" - return 1 - fi - echo "======================================" - echo "Hint for Challenge $num: ${CHALLENGE_NAMES[$num]}" - echo "======================================" - echo "${CHALLENGE_HINTS[$num]}" - echo "======================================" -} - -export_certificate() { - local completed=0 - if [ -f "$PROGRESS_FILE" ]; then - completed=$(sort -u "$PROGRESS_FILE" | wc -l) - completed=$((completed-1)) - fi - - if [ "$completed" -lt 18 ]; then - echo "Complete all 18 challenges to earn your certificate!" - echo "Current progress: $completed/18" - return 1 - fi - - if [ -z "$1" ]; then - echo "Usage: verify export " - echo "Example: verify export octocat" - echo "" - echo "⚠️ Use your GitHub username! This will be verified when you" - echo " submit your token at https://learntocloud.guide" - return 1 - fi - local github_username="$1" - - freeze_end_time_on_export - - local completion_time="Unknown" - if [ -f "$START_TIME_FILE" ]; then - local elapsed - elapsed=$(get_elapsed_seconds) - local hours=$((elapsed / 3600)) - local minutes=$(((elapsed % 3600) / 60)) - completion_time=$(printf "%02d:%02d" $hours $minutes) - fi - - local cert_file=~/ctf_certificate_$(date +%Y%m%d_%H%M%S).txt - - echo "" - echo "============================================================" | lolcat - echo " LEARN TO CLOUD - CTF COMPLETION CERTIFICATE " | lolcat - echo "============================================================" | lolcat - echo "" - echo " This certifies that GitHub user" - echo "" - figlet -c "$github_username" | lolcat - echo "" - echo " has successfully completed all 18 Linux CTF challenges" - echo "" - echo " Completion Time: $completion_time" - echo " Date: $(date +%Y-%m-%d)" - echo "" - echo " Challenges Completed:" - echo " * Hidden File Discovery * Service Discovery" - echo " * Basic File Search * Encoding Challenge" - echo " * Log Analysis * SSH Secrets" - echo " * User Investigation * DNS Troubleshooting" - echo " * Permission Analysis * Remote Upload Detection" - echo " * Web Configuration * Network Traffic Analysis" - echo " * Cron Job Hunter * Process Environment" - echo " * Archive Archaeologist * Symbolic Sleuth" - echo " * History Mystery * Disk Detective" - echo "" - echo "============================================================" | lolcat - echo " 🎉 Congratulations! 🎉 " | lolcat - echo "============================================================" | lolcat - - cat > "$cert_file" << CERTEOF -============================================================ - LEARN TO CLOUD - CTF COMPLETION CERTIFICATE -============================================================ - - This certifies that GitHub user - - $github_username - - has successfully completed all 18 Linux CTF challenges - - Completion Time: $completion_time - Date: $(date +%Y-%m-%d) - - Challenges Completed: - * Hidden File Discovery * Service Discovery - * Basic File Search * Encoding Challenge - * Log Analysis * SSH Secrets - * User Investigation * DNS Troubleshooting - * Permission Analysis * Remote Upload Detection - * Web Configuration * Network Traffic Analysis - * Cron Job Hunter * Process Environment - * Archive Archaeologist * Symbolic Sleuth - * History Mystery * Disk Detective - -============================================================ - Congratulations! -============================================================ -CERTEOF - echo "" - echo "Certificate saved to: $cert_file" - - local timestamp=$(date +%s) - local date_str=$(date +%Y-%m-%d) - - local payload=$(cat << JSONEOF -{"github_username":"$github_username","date":"$date_str","time":"$completion_time","challenges":18,"timestamp":$timestamp,"instance_id":"$INSTANCE_ID"} -JSONEOF -) - - local signature=$(echo -n "$payload" | openssl dgst -sha256 -hmac "$VERIFICATION_SECRET" | cut -d' ' -f2) - local token_data=$(cat << TOKENEOF -{"payload":$payload,"signature":"$signature"} -TOKENEOF -) - local token=$(echo -n "$token_data" | base64 -w 0) - - echo "" - echo "============================================================" | lolcat - echo " 🎫 COMPLETION TOKEN " | lolcat - echo "============================================================" | lolcat - echo "" - echo "⚠️ Save this token! You'll need it to verify your progress" - echo " at https://learntocloud.guide" - echo "" - echo " 1. Go to https://learntocloud.guide" - echo " 2. Sign in with GitHub (as: $github_username)" - echo " 3. Paste the token below" - echo "" - echo "--- BEGIN L2C CTF TOKEN ---" - echo "$token" - echo "--- END L2C CTF TOKEN ---" - echo "" - echo "" +on_error() { + local exit_code=$? + echo "ERROR: ctf_setup.sh failed on line $1" >&2 + mkdir -p "$PROJECT_STATE_DIR" + touch "$PROJECT_FAILED_MARKER" + exit "$exit_code" } +trap 'on_error "$LINENO"' ERR -case "$1" in - "progress") - show_progress - ;; - "list") - show_list - ;; - "hint") - show_hint "${2:?Usage: verify hint [0-18]}" - ;; - "time") - show_time - ;; - "export") - shift - export_certificate "$*" - ;; - [0-9]|1[0-8]) - init_timer - check_flag "$1" "${2:?Usage: verify [challenge_number] [flag]}" - ;; - *) - echo "Usage:" - echo " verify [challenge_number] [flag] - Check a flag" - echo " verify progress - Show progress" - echo " verify list - List all challenges with status" - echo " verify hint [n] - Show hint for challenge n" - echo " verify time - Show elapsed time" - echo " verify export - Export certificate with your GitHub username" - echo - echo "Example: verify 0 CTF{example}" - echo " verify export octocat" - ;; -esac -EOFVERIFY +mkdir -p "$(dirname "$DONE_MARKER")" "$PROJECT_STATE_DIR" "$UV_ROOT"/{cache,python,tools} +rm -f "$PROJECT_FAILED_MARKER" -sudo chmod +x /usr/local/bin/verify - -cat > /usr/local/bin/check_setup << 'EOF' -#!/bin/bash -if [ ! -f /var/log/setup_complete ]; then - echo "System is still being configured. Please wait..." - exit 1 +if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh fi -EOF - -chmod +x /usr/local/bin/check_setup -echo "/usr/local/bin/check_setup" >> /home/ctf_user/.profile - -cat > /etc/motd << 'EOFMOTD' -+==============================================+ -| Learn To Cloud - Linux Command Line CTF | -+==============================================+ - -Welcome! Here are 18 Progressive Linux Challenges. -Refer to the readme for information on each challenge. - -Once you find a flag, use our verify tool to check your answer -and review your progress. - -Usage: - verify [challenge number] [flag] - Submit flag for verification - verify 0 CTF{example} - Example flag (required) - verify progress - Shows your progress - verify time - Shows elapsed wall clock time - - Run this first to initialize progress: verify 0 CTF{example} - Note: Timer starts on your first challenge submission. - It freezes on your first successful verify export after 18/18. - -When you complete all challenges, run: verify export -Save the token it generates — you'll need it to verify your -progress at https://learntocloud.guide - -Good luck! -Team L2C - -+==============================================+ -EOFMOTD - -# Challenge 1: Hidden file -echo "${FLAGS[1]}" > /home/ctf_user/ctf_challenges/.hidden_flag - -# Challenge 2: File search -mkdir -p /home/ctf_user/documents/projects/backup -echo "${FLAGS[2]}" > /home/ctf_user/documents/projects/backup/secret_notes.txt - -# Challenge 3: Log analysis -sudo dd if=/dev/urandom of=/var/log/large_log_file.log bs=1M count=500 -echo "${FLAGS[3]}" | sudo tee -a /var/log/large_log_file.log -sudo chown ctf_user:ctf_user /var/log/large_log_file.log - -# Challenge 4: User investigation -id flag_user >/dev/null 2>&1 || sudo useradd -m flag_user -sudo mkdir -p /home/flag_user -echo "${FLAGS[4]}" | sudo tee /home/flag_user/.profile > /dev/null -sudo chown -R flag_user:flag_user /home/flag_user -sudo chmod 755 /home/flag_user -sudo chmod 644 /home/flag_user/.profile - -# Challenge 5: Permission analysis -sudo mkdir -p /opt/systems/config -echo "${FLAGS[5]}" | sudo tee /opt/systems/config/system.conf -sudo chmod 777 /opt/systems/config/system.conf - -# Challenge 6: Service discovery -echo "${FLAGS[6]}" | sudo tee /etc/ctf/flag_6 > /dev/null -sudo chmod 600 /etc/ctf/flag_6 -cat > /usr/local/bin/secret_service.sh << 'EOF' -#!/bin/bash -FLAG=$(cat /etc/ctf/flag_6) -FLAG_LEN=${#FLAG} -while true; do - echo -e "HTTP/1.1 200 OK\r\nContent-Length: ${FLAG_LEN}\r\nConnection: close\r\n\r\n${FLAG}" | nc -l -q 1 8080 -done -EOF -sudo chmod +x /usr/local/bin/secret_service.sh - -# Create systemd service for Challenge 6 -cat > /etc/systemd/system/ctf-secret-service.service << 'EOF' -[Unit] -Description=CTF Secret Service Challenge -After=network.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/secret_service.sh -Restart=always -RestartSec=1 - -[Install] -WantedBy=multi-user.target -EOF -sudo systemctl daemon-reload -sudo systemctl enable --now ctf-secret-service - -# Challenge 7: Encoding challenge -echo "${FLAGS[7]}" | base64 | base64 > /home/ctf_user/ctf_challenges/encoded_flag.txt - -# Challenge 8: Advanced SSH setup -sudo mkdir -p /home/ctf_user/.ssh/secrets/backup -echo "${FLAGS[8]}" | sudo tee /home/ctf_user/.ssh/secrets/backup/.authorized_keys -sudo chown -R ctf_user:ctf_user /home/ctf_user/.ssh -sudo chmod 700 /home/ctf_user/.ssh -sudo chmod 600 /home/ctf_user/.ssh/secrets/backup/.authorized_keys - -# Challenge 9: DNS troubleshooting -sudo cp /etc/resolv.conf /etc/resolv.conf.bak -sudo sed -i "/^nameserver/s/$/${FLAGS[9]}/" /etc/resolv.conf - -# Challenge 10: Remote upload -echo "${FLAGS[10]}" | sudo tee /etc/ctf/flag_10 > /dev/null -sudo chmod 600 /etc/ctf/flag_10 -cat > /usr/local/bin/monitor_directory.sh << 'EOF' -#!/bin/bash -DIRECTORY="/home/ctf_user/ctf_challenges" -FLAG=$(cat /etc/ctf/flag_10) -while [ ! -f /var/log/setup_complete ]; do - sleep 5 -done -sleep 10 -touch /tmp/.ctf_upload_triggered 2>/dev/null || true -chmod 666 /tmp/.ctf_upload_triggered 2>/dev/null || true -inotifywait -m -e create --format '%f' "$DIRECTORY" | while read FILE -do - printf '\n========== CHALLENGE 10: REMOTE UPLOAD ==========\nA new file was uploaded to %s.\nHere is your flag: %s\n==================================================\n' "$DIRECTORY" "$FLAG" | wall - echo "$FLAG" > /tmp/.ctf_upload_triggered - sync -done -EOF - -sudo chmod +x /usr/local/bin/monitor_directory.sh - -# Create systemd service for Challenge 10 -cat > /etc/systemd/system/ctf-monitor-directory.service << 'EOF' -[Unit] -Description=CTF Directory Monitor Challenge -After=local-fs.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/monitor_directory.sh -Restart=always -RestartSec=1 -StandardOutput=append:/var/log/monitor_directory.log -StandardError=append:/var/log/monitor_directory.log - -[Install] -WantedBy=multi-user.target -EOF -sudo systemctl daemon-reload -sudo systemctl enable --now ctf-monitor-directory - -# Challenge 11: Web Configuration -sudo mkdir -p /var/www/html -echo "

Flag value: ${FLAGS[11]}

" | sudo tee /var/www/html/index.html -sudo sed -i 's/listen 80 default_server;/listen 8083 default_server;/' /etc/nginx/sites-available/default -sudo sed -i 's/listen \[::\]:80 default_server;/listen \[::\]:8083 default_server;/' /etc/nginx/sites-available/default - -sudo systemctl restart nginx - -# Challenge 12: Network traffic analysis -FLAG_12_HEX=$(echo -n "${FLAGS[12]}" | xxd -p | tr -d '\n') -cat > /usr/local/bin/ping_message.sh << EOF -#!/bin/bash -while true; do - ping -p ${FLAG_12_HEX} -c 1 127.0.0.1 - sleep 1 -done -EOF - -sudo chmod +x /usr/local/bin/ping_message.sh - -# Create systemd service for Challenge 12 -cat > /etc/systemd/system/ctf-ping-message.service << 'EOF' -[Unit] -Description=CTF Ping Message Challenge -After=network.target -[Service] -Type=simple -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 - -[Install] -WantedBy=multi-user.target -EOF -sudo systemctl daemon-reload -sudo systemctl enable --now ctf-ping-message - -# Challenge 13: Cron Job Hunter -cat > /etc/cron.d/ctf_secret_task << EOF -# CTF Challenge - Secret scheduled task -# This task runs every minute but the flag is hidden here -# FLAG: ${FLAGS[13]} -* * * * * root /bin/true -EOF -sudo chmod 644 /etc/cron.d/ctf_secret_task - -# Challenge 14: Process Environment -echo "${FLAGS[14]}" | sudo tee /etc/ctf/flag_14 > /dev/null -sudo chmod 600 /etc/ctf/flag_14 -cat > /usr/local/bin/ctf_secret_process.sh << 'EOF' -#!/bin/bash -export CTF_SECRET_FLAG=$(cat /etc/ctf/flag_14) -while true; do - sleep 3600 -done -EOF -sudo chmod +x /usr/local/bin/ctf_secret_process.sh - -cat > /etc/systemd/system/ctf-secret-process.service << EOF -[Unit] -Description=CTF Secret Process Challenge -After=network.target - -[Service] -Type=simple -User=ctf_user -Group=ctf_user -Environment="CTF_SECRET_FLAG=${FLAGS[14]}" -ExecStart=/usr/local/bin/ctf_secret_process.sh -Restart=always -RestartSec=1 - -[Install] -WantedBy=multi-user.target +cat > /etc/profile.d/uv.sh <<'EOF' +export PATH="/usr/local/bin:$PATH" EOF -sudo systemctl daemon-reload -sudo systemctl enable --now ctf-secret-process - -# Challenge 15: Archive Archaeologist -CTF_ARCHIVE_TMPDIR=$(mktemp -d) -echo "${FLAGS[15]}" > "$CTF_ARCHIVE_TMPDIR/flag.txt" -( - cd "$CTF_ARCHIVE_TMPDIR" || exit 1 - tar -czf inner.tar.gz flag.txt - tar -czf middle.tar.gz inner.tar.gz - tar -czf /home/ctf_user/ctf_challenges/mystery_archive.tar.gz middle.tar.gz -) -rm -rf "$CTF_ARCHIVE_TMPDIR" +chmod 644 /etc/profile.d/uv.sh -# Challenge 16: Symbolic Sleuth -sudo mkdir -p /var/lib/ctf/secrets/deep/hidden -echo "${FLAGS[16]}" | sudo tee /var/lib/ctf/secrets/deep/hidden/final_flag.txt -sudo ln -s /var/lib/ctf/secrets/deep/hidden/final_flag.txt /var/lib/ctf/secrets/deep/link3 -sudo ln -s /var/lib/ctf/secrets/deep/link3 /var/lib/ctf/secrets/link2 -sudo ln -s /var/lib/ctf/secrets/link2 /home/ctf_user/ctf_challenges/follow_me -sudo chmod 755 /var/lib/ctf /var/lib/ctf/secrets /var/lib/ctf/secrets/deep /var/lib/ctf/secrets/deep/hidden -sudo chmod 644 /var/lib/ctf/secrets/deep/hidden/final_flag.txt +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Challenge 17: History Mystery -sudo useradd -m -s /bin/bash old_admin 2>/dev/null || true -sudo mkdir -p /home/old_admin -cat << HISTEOF | sudo tee /home/old_admin/.bash_history > /dev/null -# Old admin command history -ls -la -cd /var/log -# Note to self: the secret flag is ${FLAGS[17]} -sudo systemctl restart nginx -exit -HISTEOF -sudo chown -R old_admin:old_admin /home/old_admin -sudo chmod 755 /home/old_admin -sudo chmod 644 /home/old_admin/.bash_history +export UV_CACHE_DIR="$UV_ROOT/cache" +export UV_PYTHON_INSTALL_DIR="$UV_ROOT/python" +export UV_TOOL_DIR="$UV_ROOT/tools" +export UV_TOOL_BIN_DIR="/usr/local/bin" -# Challenge 18: Disk Detective -sudo dd if=/dev/zero of=/opt/ctf_disk.img bs=1M count=10 -sudo mkfs.ext4 -L "ctf_disk" /opt/ctf_disk.img -sudo mkdir -p /mnt/ctf_disk -sudo mount -o loop /opt/ctf_disk.img /mnt/ctf_disk -echo "${FLAGS[18]}" | sudo tee /mnt/ctf_disk/.flag > /dev/null -sudo umount /mnt/ctf_disk -sudo chown -R ctf_user:ctf_user /home/ctf_user/ctf_challenges - -sudo sed -i 's/#session optional pam_motd.so/session optional pam_motd.so/' /etc/pam.d/login -sudo sed -i 's/#session optional pam_motd.so/session optional pam_motd.so/' /etc/pam.d/sshd -sudo systemctl restart ssh - -HOSTNAME=$(hostname) -if ! grep -qF "$HOSTNAME" /etc/hosts; then - echo "127.0.0.1 $HOSTNAME" | sudo tee -a /etc/hosts > /dev/null -fi +uv python install 3.13 +uv run --python 3.13 --project "$SCRIPT_DIR/setup" "$SCRIPT_DIR/setup/main.py" +uv tool install --python 3.13 --force "$SCRIPT_DIR/verify" -touch /var/log/setup_complete +chmod -R a+rX "$UV_ROOT" +uv cache clean -echo "CTF environment setup complete!" \ No newline at end of file +touch "$DONE_MARKER" +touch "$LEGACY_DONE_MARKER" +touch "$PROJECT_DONE_MARKER" +echo "CTF environment setup complete!" diff --git a/gcp/README.md b/gcp/README.md index 18969ce..9962ec4 100644 --- a/gcp/README.md +++ b/gcp/README.md @@ -36,6 +36,20 @@ Type `yes` when prompted. + By default, Terraform uses release mode. The VM downloads the setup package from the latest GitHub Release, verifies its SHA-256 checksum, and runs setup during first boot. + + Maintainers can choose a specific setup release if they need to pin or roll back the setup package: + + ```sh + terraform apply \ + -var gcp_project="YOUR_GCP_PROJECT_ID" \ + -var gcp_region="YOUR_GCP_REGION" \ + -var gcp_zone="YOUR_GCP_ZONE" \ + -var setup_release_tag="v0.1.0" + ``` + + If you are testing unmerged local setup changes, add `-var use_local_setup=true` to the `terraform apply` command. Contributor mode uploads your local setup files and uses SSH to run them on the VM. + 4. Note the `public_ip_address` output—you'll use this to connect. @@ -82,6 +96,11 @@ Include: - `gcloud auth list --filter=status:ACTIVE` output (no secrets) - The exact `terraform apply` error output (redact any secrets) - Whether SSH fails or the issue happens after login (e.g., `verify progress`) +- If SSH works but the lab is not ready, check `/var/log/ctf_setup.log`, `/var/lib/cloud/instance/ctf-setup.done`, `/var/lib/linux-ctfs/setup.done`, and `/var/lib/linux-ctfs/setup.failed` + +### Changing Setup Versions + +The setup script runs during first boot. If you change `setup_release_tag` after a VM already exists, or if the default `latest` release changes, recreate the VM so first-boot setup runs with the new package. ## Security Note diff --git a/gcp/main.tf b/gcp/main.tf index 49ae29f..f7a1969 100644 --- a/gcp/main.tf +++ b/gcp/main.tf @@ -17,11 +17,17 @@ variable "gcp_zone" { } variable "use_local_setup" { - description = "Use local ctf_setup.sh instead of fetching from GitHub (for testing)" + description = "Upload and run the local setup package instead of using a pinned release asset (for contributor testing)" type = bool default = false } +variable "setup_release_tag" { + description = "GitHub release tag that contains the setup package assets, or latest for the newest release" + type = string + default = "latest" +} + # Configure the Google Cloud Provider provider "google" { project = var.gcp_project @@ -30,12 +36,78 @@ provider "google" { } locals { - local_setup_script = replace(replace(file("${path.module}/../ctf_setup.sh"), "\r\n", "\n"), "\r", "\n") - remote_setup_script = join("\n", [ - "#!/bin/bash", - "curl -fsSL https://raw.githubusercontent.com/learntocloud/linux-ctfs/main/ctf_setup.sh | bash", - "" - ]) + setup_asset_name = "linux-ctfs-setup.tar.gz" + setup_release_base = var.setup_release_tag == "latest" ? "https://github.com/learntocloud/linux-ctfs/releases/latest/download" : "https://github.com/learntocloud/linux-ctfs/releases/download/${var.setup_release_tag}" + setup_release_url = "${local.setup_release_base}/${local.setup_asset_name}" + setup_checksum_url = "${local.setup_release_url}.sha256" + + local_bootstrap_script = <<-EOF + #!/bin/bash + set -e + useradd -m -s /bin/bash ctf_user 2>/dev/null || true + echo 'ctf_user:CTFpassword123!' | chpasswd + usermod -aG sudo ctf_user + echo 'ctf_user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/90-ctf-user + chmod 440 /etc/sudoers.d/90-ctf-user + mkdir -p /etc/ssh/sshd_config.d + printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication yes\n' > /etc/ssh/sshd_config.d/99-ctf-local-bootstrap.conf + systemctl restart ssh || systemctl restart sshd || true + EOF + + release_setup_script = <<-EOF + #!/bin/bash + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + STATE_DIR="/var/lib/linux-ctfs" + FAILED_MARKER="$${STATE_DIR}/setup.failed" + DONE_MARKER="$${STATE_DIR}/setup.done" + INSTALL_DIR="/opt/linux-ctfs-setup" + WORK_DIR="/opt/linux-ctfs-download" + ASSET_NAME="${local.setup_asset_name}" + SETUP_URL="${local.setup_release_url}" + CHECKSUM_URL="${local.setup_checksum_url}" + + mkdir -p "$${STATE_DIR}" "$${WORK_DIR}" + rm -f "$${FAILED_MARKER}" + + fail_setup() { + echo "CTF setup failed. Check /var/log/cloud-init-output.log and /var/log/ctf_setup.log." >&2 + touch "$${FAILED_MARKER}" + } + trap fail_setup ERR + + download_with_retry() { + local url="$1" + local output="$2" + local attempt + for attempt in 1 2 3 4 5; do + if curl -fL --retry 3 --retry-delay 5 --connect-timeout 20 "$${url}" -o "$${output}"; then + return 0 + fi + echo "Download failed for $${url}. Attempt $${attempt}/5." + sleep 10 + done + return 1 + } + + apt-get update + apt-get install -y ca-certificates curl tar gzip coreutils + + cd "$${WORK_DIR}" + download_with_retry "$${SETUP_URL}" "$${ASSET_NAME}" + download_with_retry "$${CHECKSUM_URL}" "$${ASSET_NAME}.sha256" + sha256sum -c "$${ASSET_NAME}.sha256" + + rm -rf "$${INSTALL_DIR}" + mkdir -p "$${INSTALL_DIR}" + tar -xzf "$${ASSET_NAME}" -C "$${INSTALL_DIR}" + chmod +x "$${INSTALL_DIR}/ctf_setup.sh" + "$${INSTALL_DIR}/ctf_setup.sh" + + touch "$${DONE_MARKER}" + rm -f "$${FAILED_MARKER}" + trap - ERR + EOF } # Create a VPC network @@ -82,7 +154,7 @@ resource "google_compute_firewall" "ctf_firewall_http" { # Create a compute instance resource "google_compute_instance" "ctf_instance" { name = "ctf-instance" - machine_type = "e2-micro" + machine_type = "e2-micro" zone = var.gcp_zone tags = ["ctf-instance"] @@ -98,17 +170,16 @@ resource "google_compute_instance" "ctf_instance" { network_interface { network = google_compute_network.ctf_network.id subnetwork = google_compute_subnetwork.ctf_subnet.id - + access_config { # This gives the instance a public IP } } - # Metadata for the instance - # Use local file for testing, GitHub for production + # Metadata for the instance metadata = { enable-oslogin = "FALSE" - startup-script = var.use_local_setup ? local.local_setup_script : local.remote_setup_script + startup-script = var.use_local_setup ? local.local_bootstrap_script : local.release_setup_script } # Service account for the instance @@ -119,26 +190,45 @@ resource "google_compute_instance" "ctf_instance" { } # Wait for setup completion -resource "null_resource" "wait_for_setup" { +resource "null_resource" "local_setup" { + count = var.use_local_setup ? 1 : 0 depends_on = [google_compute_instance.ctf_instance] - + + connection { + type = "ssh" + host = google_compute_instance.ctf_instance.network_interface[0].access_config[0].nat_ip + user = "ctf_user" + password = "CTFpassword123!" + timeout = "10m" + } + + provisioner "remote-exec" { + inline = [ + "rm -rf /tmp/linux-ctfs-local-setup", + "mkdir -p /tmp/linux-ctfs-local-setup" + ] + } + + provisioner "local-exec" { + command = "mkdir -p ${path.module}/.terraform && tar --exclude='__pycache__' --exclude='*.pyc' --exclude='.venv' -czf ${path.module}/.terraform/linux-ctfs-local-setup.tar.gz -C ${path.module}/.. ctf_setup.sh setup verify" + } + + provisioner "file" { + source = "${path.module}/.terraform/linux-ctfs-local-setup.tar.gz" + destination = "/tmp/linux-ctfs-local-setup.tar.gz" + } + provisioner "remote-exec" { - connection { - type = "ssh" - host = google_compute_instance.ctf_instance.network_interface[0].access_config[0].nat_ip - user = "ctf_user" - password = "CTFpassword123!" - timeout = "10m" - } - inline = [ - "while [ ! -f /var/log/setup_complete ]; do sleep 10; done" + "cloud-init status --wait", + "tar -xzf /tmp/linux-ctfs-local-setup.tar.gz -C /tmp/linux-ctfs-local-setup", + "sudo chmod +x /tmp/linux-ctfs-local-setup/ctf_setup.sh && sudo /tmp/linux-ctfs-local-setup/ctf_setup.sh" ] } } # Output the public IP address output "public_ip_address" { - value = google_compute_instance.ctf_instance.network_interface[0].access_config[0].nat_ip - depends_on = [null_resource.wait_for_setup] + value = google_compute_instance.ctf_instance.network_interface[0].access_config[0].nat_ip + depends_on = [null_resource.local_setup] } \ No newline at end of file diff --git a/setup/challenges/__init__.py b/setup/challenges/__init__.py new file mode 100644 index 0000000..fca8c6f --- /dev/null +++ b/setup/challenges/__init__.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from . import ( + ch01_hidden_file, + ch02_file_search, + ch03_log_analysis, + ch04_user_investigation, + ch05_permissions, + ch06_service_discovery, + ch07_encoding, + ch08_ssh_secrets, + ch09_dns, + ch10_remote_upload, + ch11_web_config, + ch12_network_traffic, + ch13_cron, + ch14_process_env, + ch15_archive, + ch16_symlinks, + ch17_history, + ch18_disk_detective, +) + + +def setup_all_challenges(flags: dict[int, str]) -> None: + for module in ( + ch01_hidden_file, + ch02_file_search, + ch03_log_analysis, + ch04_user_investigation, + ch05_permissions, + ch06_service_discovery, + ch07_encoding, + ch08_ssh_secrets, + ch09_dns, + ch10_remote_upload, + ch11_web_config, + ch12_network_traffic, + ch13_cron, + ch14_process_env, + ch15_archive, + ch16_symlinks, + ch17_history, + ch18_disk_detective, + ): + module.setup(flags) diff --git a/setup/challenges/ch01_hidden_file.py b/setup/challenges/ch01_hidden_file.py new file mode 100644 index 0000000..0d41cd2 --- /dev/null +++ b/setup/challenges/ch01_hidden_file.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from helpers import write_file + + +def setup(flags: dict[int, str]) -> None: + write_file("/home/ctf_user/ctf_challenges/.hidden_flag", f"{flags[1]}\n") diff --git a/setup/challenges/ch02_file_search.py b/setup/challenges/ch02_file_search.py new file mode 100644 index 0000000..49d95c4 --- /dev/null +++ b/setup/challenges/ch02_file_search.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from helpers import write_file + + +def setup(flags: dict[int, str]) -> None: + write_file("/home/ctf_user/documents/projects/backup/secret_notes.txt", f"{flags[2]}\n") diff --git a/setup/challenges/ch03_log_analysis.py b/setup/challenges/ch03_log_analysis.py new file mode 100644 index 0000000..c00416a --- /dev/null +++ b/setup/challenges/ch03_log_analysis.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import run + + +LOG_SIZE_BYTES = 120 * 1024 * 1024 + + +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)) + + 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") + + 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 new file mode 100644 index 0000000..d148929 --- /dev/null +++ b/setup/challenges/ch04_user_investigation.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import ensure_user, recursive_chown, write_file + + +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) diff --git a/setup/challenges/ch05_permissions.py b/setup/challenges/ch05_permissions.py new file mode 100644 index 0000000..b52af93 --- /dev/null +++ b/setup/challenges/ch05_permissions.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from helpers import write_file + + +def setup(flags: dict[int, str]) -> None: + write_file("/opt/systems/config/system.conf", f"{flags[5]}\n", mode=0o777) diff --git a/setup/challenges/ch06_service_discovery.py b/setup/challenges/ch06_service_discovery.py new file mode 100644 index 0000000..bbe89ac --- /dev/null +++ b/setup/challenges/ch06_service_discovery.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from helpers import enable_service, write_executable, write_file, write_service + + +def setup(flags: dict[int, str]) -> None: + write_file("/etc/ctf/flag_6", f"{flags[6]}\n", mode=0o600) + write_executable( + "/usr/local/bin/secret_service.sh", + """#!/bin/bash +FLAG=$(cat /etc/ctf/flag_6) +FLAG_LEN=${#FLAG} +while true; do + echo -e "HTTP/1.1 200 OK\\r\\nContent-Length: ${FLAG_LEN}\\r\\nConnection: close\\r\\n\\r\\n${FLAG}" | nc -l -q 1 8080 +done +""", + ) + write_service( + "ctf-secret-service.service", + """[Unit] +Description=CTF Secret Service Challenge +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/secret_service.sh +Restart=always +RestartSec=1 + +[Install] +WantedBy=multi-user.target +""", + ) + enable_service("ctf-secret-service.service") diff --git a/setup/challenges/ch07_encoding.py b/setup/challenges/ch07_encoding.py new file mode 100644 index 0000000..a49bb55 --- /dev/null +++ b/setup/challenges/ch07_encoding.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import base64 + +from helpers import write_file + + +def setup(flags: dict[int, str]) -> None: + first = base64.b64encode(flags[7].encode()) + second = base64.b64encode(first).decode() + write_file("/home/ctf_user/ctf_challenges/encoded_flag.txt", f"{second}\n") diff --git a/setup/challenges/ch08_ssh_secrets.py b/setup/challenges/ch08_ssh_secrets.py new file mode 100644 index 0000000..25b6d7c --- /dev/null +++ b/setup/challenges/ch08_ssh_secrets.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import recursive_chown, 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) diff --git a/setup/challenges/ch09_dns.py b/setup/challenges/ch09_dns.py new file mode 100644 index 0000000..7caf2be --- /dev/null +++ b/setup/challenges/ch09_dns.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from helpers import 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]} + +[Resolve] +# This drop-in is harmless. It exists so learners can inspect systemd-resolved config safely. +""", + mode=0o644, + ) diff --git a/setup/challenges/ch10_remote_upload.py b/setup/challenges/ch10_remote_upload.py new file mode 100644 index 0000000..6f17d00 --- /dev/null +++ b/setup/challenges/ch10_remote_upload.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from helpers import enable_service, write_executable, write_file, write_service + + +def setup(flags: dict[int, str]) -> None: + write_file("/etc/ctf/flag_10", f"{flags[10]}\n", mode=0o600) + write_executable( + "/usr/local/bin/monitor_directory.sh", + """#!/bin/bash +DIRECTORY="/home/ctf_user/ctf_challenges" +FLAG=$(cat /etc/ctf/flag_10) +while [ ! -f /var/lib/cloud/instance/ctf-setup.done ]; do + sleep 5 +done +sleep 10 +touch /tmp/.ctf_upload_triggered 2>/dev/null || true +chmod 666 /tmp/.ctf_upload_triggered 2>/dev/null || true +inotifywait -m -e create --format '%f' "$DIRECTORY" | while read FILE +do + { + printf '\\n========== CHALLENGE 10: REMOTE UPLOAD ==========' + printf '\\nA new file was uploaded to %s.' "$DIRECTORY" + printf '\\nHere is your flag: %s' "$FLAG" + printf '\\n==================================================\\n' + } | wall + echo "$FLAG" > /tmp/.ctf_upload_triggered + sync +done +""", + ) + write_service( + "ctf-monitor-directory.service", + """[Unit] +Description=CTF Directory Monitor Challenge +After=local-fs.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/monitor_directory.sh +Restart=always +RestartSec=1 +StandardOutput=append:/var/log/monitor_directory.log +StandardError=append:/var/log/monitor_directory.log + +[Install] +WantedBy=multi-user.target +""", + ) + enable_service("ctf-monitor-directory.service") diff --git a/setup/challenges/ch11_web_config.py b/setup/challenges/ch11_web_config.py new file mode 100644 index 0000000..2ced587 --- /dev/null +++ b/setup/challenges/ch11_web_config.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import restart_service, write_file + + +def setup(flags: dict[int, str]) -> None: + write_file( + "/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 = content.replace("listen 80 default_server;", "listen 8083 default_server;") + content = content.replace("listen [::]:80 default_server;", "listen [::]:8083 default_server;") + nginx_default.write_text(content) + restart_service("nginx") diff --git a/setup/challenges/ch12_network_traffic.py b/setup/challenges/ch12_network_traffic.py new file mode 100644 index 0000000..26f9df1 --- /dev/null +++ b/setup/challenges/ch12_network_traffic.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from helpers import enable_service, write_executable, write_service + + +def setup(flags: dict[int, str]) -> None: + flag_hex = flags[12].encode().hex() + write_executable( + "/usr/local/bin/ping_message.sh", + f"""#!/bin/bash +while true; do + ping -p {flag_hex} -c 1 127.0.0.1 + sleep 1 +done +""", + ) + write_service( + "ctf-ping-message.service", + """[Unit] +Description=CTF Ping Message Challenge +After=network.target + +[Service] +Type=simple +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 + +[Install] +WantedBy=multi-user.target +""", + ) + enable_service("ctf-ping-message.service") diff --git a/setup/challenges/ch13_cron.py b/setup/challenges/ch13_cron.py new file mode 100644 index 0000000..9535c54 --- /dev/null +++ b/setup/challenges/ch13_cron.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from helpers import write_file + + +def setup(flags: dict[int, str]) -> None: + 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 +""", + mode=0o644, + ) diff --git a/setup/challenges/ch14_process_env.py b/setup/challenges/ch14_process_env.py new file mode 100644 index 0000000..4c1add9 --- /dev/null +++ b/setup/challenges/ch14_process_env.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from helpers import enable_service, write_executable, write_file, write_service + + +def setup(flags: dict[int, str]) -> None: + write_file("/etc/ctf/flag_14", f"{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 +""", + ) + write_service( + "ctf-secret-process.service", + f"""[Unit] +Description=CTF Secret Process Challenge +After=network.target + +[Service] +Type=simple +User=ctf_user +Group=ctf_user +Environment="CTF_SECRET_FLAG={flags[14]}" +ExecStart=/usr/local/bin/ctf_secret_process.sh +Restart=always +RestartSec=1 + +[Install] +WantedBy=multi-user.target +""", + ) + enable_service("ctf-secret-process.service") diff --git a/setup/challenges/ch15_archive.py b/setup/challenges/ch15_archive.py new file mode 100644 index 0000000..7d89636 --- /dev/null +++ b/setup/challenges/ch15_archive.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import tarfile +import tempfile +from pathlib import Path + + +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: + archive.add(temp_path / "flag.txt", arcname="flag.txt") + with tarfile.open(temp_path / "middle.tar.gz", "w:gz") as archive: + archive.add(temp_path / "inner.tar.gz", arcname="inner.tar.gz") + with tarfile.open("/home/ctf_user/ctf_challenges/mystery_archive.tar.gz", "w:gz") as archive: + archive.add(temp_path / "middle.tar.gz", arcname="middle.tar.gz") diff --git a/setup/challenges/ch16_symlinks.py b/setup/challenges/ch16_symlinks.py new file mode 100644 index 0000000..4b1fbdc --- /dev/null +++ b/setup/challenges/ch16_symlinks.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import write_file + + +def setup(flags: dict[int, str]) -> None: + write_file("/var/lib/ctf/secrets/deep/hidden/final_flag.txt", f"{flags[16]}\n", mode=0o644) + links = [ + (Path("/var/lib/ctf/secrets/deep/hidden/final_flag.txt"), Path("/var/lib/ctf/secrets/deep/link3")), + (Path("/var/lib/ctf/secrets/deep/link3"), Path("/var/lib/ctf/secrets/link2")), + (Path("/var/lib/ctf/secrets/link2"), Path("/home/ctf_user/ctf_challenges/follow_me")), + ] + for target, link in links: + link.unlink(missing_ok=True) + link.symlink_to(target) + for directory in ( + Path("/var/lib/ctf"), + Path("/var/lib/ctf/secrets"), + Path("/var/lib/ctf/secrets/deep"), + Path("/var/lib/ctf/secrets/deep/hidden"), + ): + directory.chmod(0o755) diff --git a/setup/challenges/ch17_history.py b/setup/challenges/ch17_history.py new file mode 100644 index 0000000..d87faf3 --- /dev/null +++ b/setup/challenges/ch17_history.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import ensure_user, recursive_chown, write_file + + +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, + ) + 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 new file mode 100644 index 0000000..2f5e8e5 --- /dev/null +++ b/setup/challenges/ch18_disk_detective.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from helpers import recursive_chown, run, write_file + + +def setup(flags: dict[int, str]) -> None: + run(["dd", "if=/dev/zero", "of=/opt/ctf_disk.img", "bs=1M", "count=10"]) + run(["mkfs.ext4", "-F", "-L", "ctf_disk", "/opt/ctf_disk.img"]) + 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"]) + recursive_chown("/home/ctf_user/ctf_challenges", "ctf_user", "ctf_user") diff --git a/setup/flags.py b/setup/flags.py new file mode 100644 index 0000000..23ba977 --- /dev/null +++ b/setup/flags.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import hashlib +import secrets + + +MASTER_SECRET = "L2C_CTF_MASTER_2024" + +FLAG_BASES: dict[int, str] = { + 0: "example", + 1: "hidden_files", + 2: "file_search", + 3: "log_analysis", + 4: "user_enum", + 5: "perm_sleuth", + 6: "net_detective", + 7: "decode_master", + 8: "ssh_secrets", + 9: "dns_name", + 10: "net_copy", + 11: "web_config", + 12: "icmp", + 13: "cron_master", + 14: "proc_env", + 15: "archive_dig", + 16: "link_follow", + 17: "history_sleuth", + 18: "disk_sleuth", +} + + +def generate_flags() -> dict[int, str]: + instance_suffix = secrets.token_hex(4) + short_suffix = instance_suffix[:4] + + flags: dict[int, str] = {} + for challenge_num, flag_base in FLAG_BASES.items(): + if challenge_num == 0: + flags[challenge_num] = "CTF{example}" + elif challenge_num == 12: + flags[challenge_num] = f"CTF{{{flag_base}_{short_suffix}}}" + else: + flags[challenge_num] = f"CTF{{{flag_base}_{instance_suffix}}}" + return flags + + +def hash_flags(flags: dict[int, str]) -> list[str]: + return [ + hashlib.sha256(flags[challenge_num].encode()).hexdigest() + for challenge_num in range(19) + ] + + +def generate_instance_id() -> str: + return secrets.token_hex(16) + + +def derive_verification_secret(instance_id: str) -> str: + return hashlib.sha256(f"{MASTER_SECRET}:{instance_id}".encode()).hexdigest() diff --git a/setup/helpers.py b/setup/helpers.py new file mode 100644 index 0000000..f3b1b05 --- /dev/null +++ b/setup/helpers.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import os +import pwd +import grp +import shutil +import subprocess +from pathlib import Path +from typing import Iterable + + +def run(command: Iterable[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + list(command), + input=input_text, + text=True, + check=True, + ) + + +def write_file( + path: str | Path, + content: str | bytes, + *, + mode: int | None = None, + owner: str | None = None, + group: str | None = None, +) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + target.write_bytes(content) + else: + target.write_text(content) + if mode is not None: + target.chmod(mode) + if owner is not None or group is not None: + shutil.chown(target, user=owner, group=group) + + +def append_line_once(path: str | Path, line: str) -> None: + target = Path(path) + current = target.read_text() if target.exists() else "" + if line not in current.splitlines(): + with target.open("a") as file: + file.write(f"{line}\n") + + +def ensure_user(username: str, *, shell: str = "/bin/bash", sudo: bool = False) -> None: + try: + pwd.getpwnam(username) + except KeyError: + run(["useradd", "-m", "-s", shell, username]) + + if sudo: + run(["usermod", "-aG", "sudo", username]) + + +def set_password(username: str, password: str) -> None: + run(["chpasswd"], input_text=f"{username}:{password}\n") + + +def recursive_chown(path: str | Path, user: str, group: str, *, follow_symlinks: bool = False) -> None: + root = Path(path) + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(group).gr_gid + + os.chown(root, uid, gid, follow_symlinks=follow_symlinks) + for current_root, directories, files in os.walk(root, followlinks=follow_symlinks): + for name in directories + files: + os.chown(Path(current_root) / name, uid, gid, follow_symlinks=follow_symlinks) + + +def write_executable(path: str | Path, content: str) -> None: + write_file(path, content, mode=0o755) + + +def write_service(unit_name: str, content: str) -> None: + write_file(Path("/etc/systemd/system") / unit_name, content, mode=0o644) + run(["systemctl", "daemon-reload"]) + + +def enable_service(unit_name: str) -> None: + run(["systemctl", "enable", "--now", unit_name]) + + +def restart_service(unit_name: str) -> None: + run(["systemctl", "restart", unit_name]) diff --git a/setup/main.py b/setup/main.py new file mode 100644 index 0000000..411a206 --- /dev/null +++ b/setup/main.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from flags import derive_verification_secret, generate_flags, generate_instance_id, hash_flags +from state import write_ctf_state +from system import configure_system +from challenges import setup_all_challenges + + +def main() -> None: + flags = generate_flags() + instance_id = generate_instance_id() + verification_secret = derive_verification_secret(instance_id) + + configure_system() + write_ctf_state(hash_flags(flags), instance_id, verification_secret) + setup_all_challenges(flags) + + +if __name__ == "__main__": + main() diff --git a/setup/pyproject.toml b/setup/pyproject.toml new file mode 100644 index 0000000..6db997a --- /dev/null +++ b/setup/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "linux-ctf-setup" +version = "0.1.0" +description = "Provision the Learn to Cloud Linux CTF VM." +requires-python = ">=3.13" +dependencies = [] + +[tool.uv] +package = false diff --git a/setup/state.py b/setup/state.py new file mode 100644 index 0000000..cc6b764 --- /dev/null +++ b/setup/state.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + +from helpers import write_file + + +def write_ctf_state(flag_hashes: list[str], instance_id: str, verification_secret: str) -> None: + etc_ctf = Path("/etc/ctf") + var_ctf = Path("/var/ctf") + + etc_ctf.mkdir(parents=True, exist_ok=True) + etc_ctf.chmod(0o711) + + write_file(etc_ctf / "flag_hashes", "\n".join(flag_hashes) + "\n", mode=0o644) + write_file(etc_ctf / "instance_id", f"{instance_id}\n", mode=0o644) + write_file( + etc_ctf / "verification_secret", + f"{verification_secret}\n", + mode=0o640, + owner="root", + group="ctf_user", + ) + + var_ctf.mkdir(parents=True, exist_ok=True) + var_ctf.chmod(0o777) diff --git a/setup/system.py b/setup/system.py new file mode 100644 index 0000000..6e73d9c --- /dev/null +++ b/setup/system.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import socket +from pathlib import Path + +from helpers import append_line_once, ensure_user, restart_service, run, set_password, write_file + + +CTF_PASSWORD = "CTFpassword123!" +DONE_MARKER = "/var/lib/cloud/instance/ctf-setup.done" +APT_OPTIONS = [ + "-o", + "DPkg::Lock::Timeout=120", + "-o", + "Acquire::Retries=3", +] + + +def apt_get(*arguments: str) -> None: + run(["apt-get", *APT_OPTIONS, *arguments]) + + +def install_packages() -> None: + packages = [ + "net-tools", + "nmap", + "tree", + "nginx", + "inotify-tools", + "netcat-openbsd", + "tcpdump", + ] + apt_get("update") + apt_get("install", "-y", *packages) + + +def configure_motd_support() -> None: + for file_path in Path("/etc/update-motd.d").glob("*"): + try: + file_path.chmod(file_path.stat().st_mode & ~0o111) + except FileNotFoundError: + continue + + write_file( + "/etc/motd", + """+==============================================+ +| Learn To Cloud - Linux Command Line CTF | ++==============================================+ + +Welcome! Here are 18 Progressive Linux Challenges. +Refer to the readme for information on each challenge. + +Once you find a flag, use our verify tool to check your answer +and review your progress. + +Usage: + verify [challenge number] [flag] - Submit flag for verification + verify 0 CTF{example} - Example flag (required) + verify progress - Shows your progress + verify time - Shows elapsed wall clock time + + Run this first to initialize progress: verify 0 CTF{example} + Note: Timer starts on your first challenge submission. + It freezes on your first successful verify export after 18/18. + +When you complete all challenges, run: verify export +Save the token it generates. You'll need it to verify your +progress at https://learntocloud.guide + +Good luck! +Team L2C + ++==============================================+ +""", + mode=0o644, + ) + + for pam_file in (Path("/etc/pam.d/login"), Path("/etc/pam.d/sshd")): + if not pam_file.exists(): + continue + content = pam_file.read_text() + content = content.replace( + "#session optional pam_motd.so", + "session optional pam_motd.so", + ) + pam_file.write_text(content) + + +def configure_users() -> None: + ensure_user("ctf_user", sudo=True) + set_password("ctf_user", CTF_PASSWORD) + Path("/home/ctf_user/ctf_challenges").mkdir(parents=True, exist_ok=True) + + +def configure_shell_profile() -> None: + write_file( + "/etc/profile.d/fix-term.sh", + 'case "$TERM" in *-ghostty) export TERM=xterm-256color;; esac\n', + mode=0o644, + ) + + write_file( + "/usr/local/bin/check_setup", + f"""#!/bin/bash +if [ ! -f {DONE_MARKER} ]; then + echo "System is still being configured. Please wait..." + exit 1 +fi +""", + mode=0o755, + ) + append_line_once("/home/ctf_user/.profile", "/usr/local/bin/check_setup") + + +def configure_ssh() -> None: + write_file( + "/etc/ssh/sshd_config.d/99-ctf-password-auth.conf", + """PasswordAuthentication yes +KbdInteractiveAuthentication yes +ChallengeResponseAuthentication yes +""", + mode=0o644, + ) + + sshd_config = Path("/etc/ssh/sshd_config") + content = sshd_config.read_text() + replacements = { + "PasswordAuthentication no": "PasswordAuthentication yes", + "ChallengeResponseAuthentication no": "ChallengeResponseAuthentication yes", + "KbdInteractiveAuthentication no": "KbdInteractiveAuthentication yes", + } + for old, new in replacements.items(): + content = content.replace(old, new) + sshd_config.write_text(content) + restart_service("ssh") + + +def configure_hostname() -> None: + hostname = socket.gethostname() + hosts = Path("/etc/hosts") + content = hosts.read_text() + if hostname not in content: + with hosts.open("a") as file: + file.write(f"127.0.0.1 {hostname}\n") + + +def configure_system() -> None: + install_packages() + configure_users() + configure_shell_profile() + configure_ssh() + configure_motd_support() + configure_hostname() diff --git a/verify/pyproject.toml b/verify/pyproject.toml new file mode 100644 index 0000000..fb6665b --- /dev/null +++ b/verify/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "linux-ctf-verify" +version = "0.1.0" +description = "Learner verification CLI for the Learn to Cloud Linux CTF." +requires-python = ">=3.13" +dependencies = [ + "pyfiglet>=1.0.2", + "rich>=13.9.0", +] + +[project.scripts] +verify = "verify.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/verify"] diff --git a/verify/src/verify/__init__.py b/verify/src/verify/__init__.py new file mode 100644 index 0000000..0b6a533 --- /dev/null +++ b/verify/src/verify/__init__.py @@ -0,0 +1 @@ +"""Learn to Cloud Linux CTF verification CLI.""" diff --git a/verify/src/verify/__main__.py b/verify/src/verify/__main__.py new file mode 100644 index 0000000..368c3eb --- /dev/null +++ b/verify/src/verify/__main__.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import sys + +from .commands import check_flag, export_certificate, show_hint, show_list, show_progress, show_time +from .state import load_state + + +USAGE = """Usage: + verify [challenge_number] [flag] - Check a flag + verify progress - Show progress + verify list - List all challenges with status + verify hint [n] - Show hint for challenge n + verify time - Show elapsed time + verify export - Export certificate with your GitHub username + +Example: verify 0 CTF{example} + verify export octocat +""" + + +def main() -> int: + args = sys.argv[1:] + if not args: + print(USAGE) + return 0 + + command = args[0] + state = load_state() + + if command == "progress": + show_progress() + return 0 + if command == "list": + return show_list() + if command == "hint": + return show_hint(args[1] if len(args) > 1 else None) + if command == "time": + return show_time() + if command == "export": + return export_certificate(state, args[1] if len(args) > 1 else None) + if command.isdigit() and len(args) > 1: + return check_flag(state, command, args[1]) + + print(USAGE) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/verify/src/verify/commands.py b/verify/src/verify/commands.py new file mode 100644 index 0000000..a25bc1a --- /dev/null +++ b/verify/src/verify/commands.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +from datetime import datetime +from pathlib import Path + +from pyfiglet import Figlet +from rich.console import Console + +from .state import ( + END_TIME_FILE, + PROGRESS_FILE, + START_TIME_FILE, + CtfState, + read_completed, + write_completed, +) + + +console = Console() + +CHALLENGE_NAMES = [ + "Example Challenge", + "Hidden File Discovery", + "Basic File Search", + "Log Analysis", + "User Investigation", + "Permission Analysis", + "Service Discovery", + "Encoding Challenge", + "SSH Secrets", + "DNS Inspection", + "Remote Upload Detection", + "Web Configuration", + "Network Traffic Analysis", + "Cron Job Hunter", + "Process Environment", + "Archive Archaeologist", + "Symbolic Sleuth", + "History Mystery", + "Disk Detective", +] + +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", + "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", + "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/.", + "Monitor file creation with tools like inotifywait, or try creating a file in ctf_challenges.", + "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.", + "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.", + "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.", +] + + +def completed_count() -> int: + completed = read_completed() + return max(len(completed - {0}), 0) + + +def show_progress() -> None: + count = completed_count() + console.print(f"Flags Found: {count}/18") + if count == 18: + console.print("Congratulations! You've completed all challenges!") + + +def init_timer() -> None: + if not START_TIME_FILE.exists(): + START_TIME_FILE.parent.mkdir(parents=True, exist_ok=True) + START_TIME_FILE.write_text(f"{int(time.time())}\n") + START_TIME_FILE.chmod(0o666) + + +def elapsed_seconds() -> int | None: + if not START_TIME_FILE.exists(): + return None + start_time = int(START_TIME_FILE.read_text().strip()) + end_time = int(END_TIME_FILE.read_text().strip()) if END_TIME_FILE.exists() else int(time.time()) + return end_time - start_time + + +def freeze_end_time_on_export() -> None: + if END_TIME_FILE.exists(): + return + if completed_count() >= 18: + END_TIME_FILE.write_text(f"{int(time.time())}\n") + END_TIME_FILE.chmod(0o666) + + +def format_elapsed(seconds: int, *, include_seconds: bool) -> str: + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + if include_seconds: + remaining_seconds = seconds % 60 + return f"{hours:02d}:{minutes:02d}:{remaining_seconds:02d}" + return f"{hours:02d}:{minutes:02d}" + + +def check_flag(state: CtfState, challenge_num: str, submitted_flag: str) -> int: + init_timer() + if not challenge_num.isdigit() or not 0 <= int(challenge_num) <= 18: + console.print("✗ Invalid challenge number. Use 0-18.") + return 1 + + num = int(challenge_num) + submitted_hash = hashlib.sha256(submitted_flag.encode()).hexdigest() + if submitted_hash == state.answer_hashes[num]: + if num == 0: + console.print("✓ Example flag verified! Now try finding real flags.") + else: + console.print(f"✓ Correct flag for Challenge {num}!") + completed = read_completed() + completed.add(num) + write_completed(completed) + else: + console.print("✗ Incorrect flag. Try again!") + show_progress() + return 0 + + +def show_time() -> int: + elapsed = elapsed_seconds() + if elapsed is None: + console.print("Timer not started. Complete your first challenge to start the timer.") + return 0 + console.print(f"Elapsed Time: {format_elapsed(elapsed, include_seconds=True)}") + return 0 + + +def show_list() -> int: + completed = read_completed() + console.print("======================================") + console.print(" CTF Challenge Status") + console.print("======================================") + for index, name in enumerate(CHALLENGE_NAMES): + status = "[✓]" if index in completed else "[ ]" + suffix = " (Example)" if index == 0 else "" + console.print(f"{status} {index:2d}. {name}{suffix}") + console.print("======================================") + show_progress() + return 0 + + +def show_hint(num_text: str | None) -> int: + if num_text is None or not num_text.isdigit() or int(num_text) > 18: + console.print("Usage: verify hint [0-18]") + return 1 + num = int(num_text) + console.print("======================================") + console.print(f"Hint for Challenge {num}: {CHALLENGE_NAMES[num]}") + console.print("======================================") + console.print(CHALLENGE_HINTS[num]) + console.print("======================================") + return 0 + + +def export_certificate(state: CtfState, github_username: str | None) -> int: + count = completed_count() + if count < 18: + console.print("Complete all 18 challenges to earn your certificate!") + console.print(f"Current progress: {count}/18") + return 1 + + if not github_username: + console.print("Usage: verify export ") + console.print("Example: verify export octocat") + console.print("") + console.print("⚠️ Use your GitHub username! This will be verified when you") + console.print(" submit your token at https://learntocloud.guide") + return 1 + + freeze_end_time_on_export() + elapsed = elapsed_seconds() + completion_time = format_elapsed(elapsed, include_seconds=False) if elapsed is not None else "Unknown" + date_str = datetime.now().strftime("%Y-%m-%d") + cert_file = Path.home() / f"ctf_certificate_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + + figlet_text = Figlet(justify="center").renderText(github_username).rstrip() + + console.print("") + console.print("============================================================", style="bold cyan") + console.print(" LEARN TO CLOUD - CTF COMPLETION CERTIFICATE ", style="bold cyan") + console.print("============================================================", style="bold cyan") + console.print("") + console.print(" This certifies that GitHub user") + console.print("") + console.print(figlet_text, style="bold green") + console.print("") + console.print(" has successfully completed all 18 Linux CTF challenges") + console.print("") + console.print(f" Completion Time: {completion_time}") + 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(" * Web Configuration * Network Traffic Analysis") + console.print(" * Cron Job Hunter * Process Environment") + console.print(" * Archive Archaeologist * Symbolic Sleuth") + console.print(" * History Mystery * Disk Detective") + console.print("") + console.print("============================================================", style="bold cyan") + console.print(" 🎉 Congratulations! 🎉 ", style="bold magenta") + console.print("============================================================", style="bold cyan") + + cert_file.write_text( + f"""============================================================ + LEARN TO CLOUD - CTF COMPLETION CERTIFICATE +============================================================ + + This certifies that GitHub user + + {github_username} + + has successfully completed all 18 Linux CTF challenges + + Completion Time: {completion_time} + 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 + * Web Configuration * Network Traffic Analysis + * Cron Job Hunter * Process Environment + * Archive Archaeologist * Symbolic Sleuth + * History Mystery * Disk Detective + +============================================================ + Congratulations! +============================================================ +""" + ) + console.print("") + console.print(f"Certificate saved to: {cert_file}") + + timestamp = int(time.time()) + payload = { + "github_username": github_username, + "date": date_str, + "time": completion_time, + "challenges": 18, + "timestamp": timestamp, + "instance_id": state.instance_id, + } + payload_json = json.dumps(payload, separators=(",", ":")) + signature = hmac.new( + state.verification_secret.encode(), + payload_json.encode(), + hashlib.sha256, + ).hexdigest() + token_json = json.dumps( + {"payload": payload, "signature": signature}, + separators=(",", ":"), + ) + token = base64.b64encode(token_json.encode()).decode() + + console.print("") + console.print("============================================================", style="bold cyan") + console.print(" 🎫 COMPLETION TOKEN ", style="bold cyan") + console.print("============================================================", style="bold cyan") + console.print("") + console.print("⚠️ Save this token! You'll need it to verify your progress") + console.print(" at https://learntocloud.guide") + console.print("") + console.print(f" 1. Go to https://learntocloud.guide") + console.print(f" 2. Sign in with GitHub (as: {github_username})") + console.print(" 3. Paste the token below") + console.print("") + console.print("--- BEGIN L2C CTF TOKEN ---") + console.print(token) + console.print("--- END L2C CTF TOKEN ---") + console.print("") + return 0 diff --git a/verify/src/verify/state.py b/verify/src/verify/state.py new file mode 100644 index 0000000..d9a2e06 --- /dev/null +++ b/verify/src/verify/state.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +HASH_FILE = Path("/etc/ctf/flag_hashes") +INSTANCE_ID_FILE = Path("/etc/ctf/instance_id") +VERIFICATION_SECRET_FILE = Path("/etc/ctf/verification_secret") +PROGRESS_FILE = Path("/var/ctf/completed_challenges") +START_TIME_FILE = Path("/var/ctf/ctf_start_time") +END_TIME_FILE = Path("/var/ctf/ctf_end_time") + + +@dataclass(frozen=True) +class CtfState: + answer_hashes: list[str] + instance_id: str + verification_secret: str + + +def load_state() -> CtfState: + if not HASH_FILE.exists(): + raise SystemExit("Error: CTF not properly initialized. Hash file missing.") + + answer_hashes = HASH_FILE.read_text().splitlines() + instance_id = INSTANCE_ID_FILE.read_text().strip() if INSTANCE_ID_FILE.exists() else "" + verification_secret = ( + VERIFICATION_SECRET_FILE.read_text().strip() + if VERIFICATION_SECRET_FILE.exists() + else "" + ) + return CtfState(answer_hashes, instance_id, verification_secret) + + +def read_completed() -> set[int]: + if not PROGRESS_FILE.exists(): + return set() + completed: set[int] = set() + for line in PROGRESS_FILE.read_text().splitlines(): + if line.isdigit(): + completed.add(int(line)) + return completed + + +def write_completed(completed: set[int]) -> None: + PROGRESS_FILE.parent.mkdir(parents=True, exist_ok=True) + PROGRESS_FILE.write_text("".join(f"{num}\n" for num in sorted(completed))) + PROGRESS_FILE.chmod(0o666)