Files
attack-defense-platform/README.md
T
Cyrene 2da6305626 docs: full operator manual in README (setup, spec, API, troubleshooting)
Replaces the 3-line upstream stub with a manual that documents the platform
as it actually runs. Every claim is derived from the live code and registry
rather than from memory.

Challenge spec:
- 28-challenge tables (6 XVIII / 10 XVI / 12 XVII, 16 active) generated from
  teams/challenge_registry.json, with per-challenge org_port, chall/ssh
  offsets, and the real team-1 runtime ports read from state.json.
- Port formula corrected to the real one:
  port = 30000 + idx*1000 + chall_offset. org_port is the native graveyard
  port and is NOT used for runtime allocation, so two challenges sharing an
  org_port (carbeat offset 1 vs anti-alchemy offset 30) never collide.
- Per-challenge ssh_user documented: only the 6 native XVIII images provision
  ctfuser; all imported XVI/XVII images chpasswd root, so hardcoding ctfuser
  breaks 10 of the 16 active challenges.
- Scoring: 100 per flag awarded to the ATTACKER (first solve only), +50 SLA
  bonus at most once per 5-minute window, runtime threshold documented as
  len(enabled_challenges()) rather than the hardcoded constant 6.

Setup and operations:
- Setup from clone: required /opt path, Docker, venv, panel credentials, both
  systemd units verbatim, team creation, verification step.
- Full HTTP API split into public / admin / team, including why challenge
  toggle and bulk team delete are async jobs.
- Troubleshooting and operational traps as declarative rules: the bare domain
  is the receiver and not the panel, EOL base images, UFW default-deny
  silently blackholing ports, the mandatory compose -p teamN project name,
  and why docker image prune -af destroys services-* images that are in use.
- Image sizes measured from the host (189MB-903MB, ~8GB for 16 active)
  instead of the incorrect "~3GB per challenge" figure.
- Topology section: PixiJS v8, on-demand rendering, and the parent-to-child
  drag hierarchy derived from the edge list.

The flag example is redacted to a placeholder. No live credential, token, or
flag is committed. README.md is the only file touched.
2026-09-28 19:13:51 +08:00

23 KiB
Raw Blame History

Attack Defense Platform

Platform attack-defense (serang–serang) untuk GEMASTIK Final Round, menyatukan 28 challenge dari tiga set (GEMASTIK XVIII, XVI, XVII) di bawah satu panel admin, satu flag store, satu SLA checker, dan satu skorboard.

Multi-team: N tim, masing-masing memiliki copy semua challenge + flag sendiri, dengan receiver terisolasi per tim, checker SLA otomatis, dan visualisasi topologi serangan real-time.

 Browser (admin/team)
        |  HTTP + WebSocket  (proxy server-side)
        v
 Panel :18081 (FastAPI)  ----  systemd: gemastik-panel
        |
        +--> Receiver global :18080        ---- systemd: gemastik-receiver
        +--> Receiver tim N  :31080+1000*(N-1)  ---- systemd: gemastik-receiver-teamN
        |         (menjalankan checker SLA untuk tim N)
        +--> N x <challenge>_container_teamN  ---- docker compose
        |
        +--> flags + leaderboard + points (teams/leaderboard.json, teams/points.json)

Daftar Isi


Arsitektur

Tiga proses inti, semuanya dikelola systemd:

Proses Port Unit systemd Peran
Panel admin 18081 gemastik-panel Web UI admin + seluruh API
Receiver global 18080 gemastik-receiver Flag store untuk mode single-node
Receiver tim N 31080 + 1000×(N−1) gemastik-receiver-teamN Checker SLA tim N

Mengapa receiver per tim harus unit terpisah. Kalau receiver dijalankan sebagai child process dari panel, systemctl restart gemastik-panel akan membunuh seluruh cgroup — termasuk semua receiver — dan SLA semua tim ikut turun ke 0. Unit terpisah membuat restart panel tidak menyentuh receiver. Generatornya: panel/gen_receiver_services.py.

Kredensial tidak pernah masuk browser. Panel melakukan proxy ke receiver secara server-side, sehingga password admin hanya ada di panel/.env pada host.

Sumber data tunggal. teams/challenge_registry.json dibaca oleh panel, generator compose, dan generator receiver. Menambah challenge = menambah satu entri di registry, bukan menyunting tiga tempat.

Alur satu flag

tim penyerang submit flag
  -> panel POST /api/flag/submit
  -> baca flag tim target dari receiver
  -> cocok?
       ya -> catat di leaderboard + skor untuk PENYERANG
       tidak -> tolak

Flag di-mint per (tim, challenge), bukan satu flag global.


Spesifikasi Challenge

28 challenge terdaftar, 16 aktif secara default.

Kolom Port team 1 / SSH team 1 diisi - untuk challenge nonaktif karena port-nya baru dialokasikan saat challenge diaktifkan.

GEMASTIK XVIII — 6 challenge, 6 aktif

User SSH: ctfuser.

# Challenge Kategori org_port Offset chall/SSH Port team 1 SSH team 1 Status
1 blogpost web 10000 0/22 31000 31022 aktif
2 carbeat pwn 11000 1/23 31001 31023 aktif
3 cdn web 12000 2/24 31002 31024 aktif
4 phew crypto 13000 3/25 31003 31025 aktif
5 sheesh crypto 14000 4/26 31004 31026 aktif
6 warmup warmup 15000 5/27 31005 31027 aktif

GEMASTIK XVI — 10 challenge, 3 aktif

User SSH: root.

# Challenge Kategori org_port Offset chall/SSH Port team 1 SSH team 1 Status
7 art web 10000 10/110 31010 31110 aktif
8 xl web 11000 11/111 31011 31111 aktif
9 gemas-notes web 12000 12/112 - - nonaktif
10 pasta web 13000 13/113 - - nonaktif
11 burvesigner crypto 14000 14/114 - - nonaktif
12 hirnfick pwn 15000 15/115 - - nonaktif
13 gemas-fetcher web 16000 16/116 - - nonaktif
14 s3 web 20000 20/120 31020 31120 aktif
15 crawlback web 21000 21/121 - - nonaktif
16 back-to-basic warmup 22000 22/122 - - nonaktif

GEMASTIK XVII — 12 challenge, 7 aktif

User SSH: root.

# Challenge Kategori org_port Offset chall/SSH Port team 1 SSH team 1 Status
17 anti-alchemy web 11000 30/130 31030 31130 aktif
18 asmr pwn 15000 31/131 - - nonaktif
19 bit-canvas pwn 20000 32/132 31032 31132 aktif
20 fjb web-pwn 17000 33/133 - - nonaktif
21 gift-card crypto 21000 34/134 31034 31134 aktif
22 gift-voucher crypto 16000 35/135 31035 31135 aktif
23 gleam-drive web-crypto 12000 36/136 31036 31136 aktif
24 go-green rev 20000 37/137 - - nonaktif
25 kode-viewer web 10000 38/138 - - nonaktif
26 more-less web 22000 39/139 31039 31139 aktif
27 tempest-poc web 14080 40/140 - - nonaktif
28 ticketer crypto 14000 41/141 31041 31141 aktif

Kategori: 13 web, 6 crypto, 4 pwn, 2 warmup, dan masing-masing satu web-pwn, web-crypto, rev. gleam-drive dilayani lewat HTTPS (field scheme di registry), 27 challenge lainnya HTTP.

Format flag

GEMASTIK18{TEAM<idx>_<CHALLENGE>_<12 hex>}

contoh: GEMASTIK18{TEAM1_BLOGPOST_<12 hex acak>}

User SSH per challenge

Hanya 6 challenge native GEMASTIK XVIII yang membuat user ctfuser. Semua challenge impor XVI/XVII menjalankan echo root:${PASSWORD} | chpasswd di Dockerfile, sehingga login sebagai ctfuser ditolak walaupun password benar.

Field ssh_user di teams/challenge_registry.json yang menentukan ini, dibaca panel/teams.py saat set_ssh_passwords(). Men-hardcode ctfuser membuat 10 dari 16 challenge gagal login padahal state.json terlihat benar.


Spesifikasi Port

Setiap tim mendapat blok port sendiri, dengan basis 30000 dan langkah 1000:

port_challenge(tim i, challenge c) = 30000 + 1000 x i + chall_offset(c)
port_ssh(tim i, challenge c)      = 30000 + 1000 x i + ssh_offset(c)
port_receiver(tim i)              = 30000 + 1000 x i + 80

chall_offset dan ssh_offset dibaca dari teams/challenge_registry.json (panel/teams.py, create_team()). Field org_port di registry adalah port native challenge di graveyard asalnya dan tidak dipakai untuk menghitung port runtime.

Enam challenge native GEMASTIK XVIII memakai offset challenge 0–5 dan SSH 22–27, sehingga untuk team 1 berada di 31000–31005 dan 31022–31027:

Tim Port challenge Port SSH Receiver
1 31000–31005 31022–31027 31080
2 32000–32005 32022–32027 32080
3 33000–33005 33022–33027 33080
4 34000–34005 34022–34027 34080

Challenge impor memakai offset sendiri, jadi portnya tidak selalu berakhiran 0000–0005. Angka nyata team 1: art 31010/31110, xl 31011/31111, s3 31020/31120, anti-alchemy 31030/31130, bit-canvas 31032/31132, gift-card 31034/31134, gift-voucher 31035/31135, gleam-drive 31036/31136, more-less 31039/31139, ticketer 31041/31141.

Dua challenge dengan org_port sama tidak bentrok, karena yang dipakai adalah chall_offset. anti-alchemy (XVII, offset 30) dan carbeat (XVIII, offset 1) sama-sama punya org_port 11000, tetapi memakai port 31030 dan 31001.


Skor dan Penilaian

POINTS_PER_FLAG     = 100   # ke tim PENYERANG, hanya solve pertama
SLA_BONUS_POINTS    = 50    # bonus bila semua challenge aktif UP
SLA_BONUS_MIN_ALIVE = 6     # konstanta; threshold runtime = len(enabled_challenges())

Attack points. Submit flag benar milik tim lain memberi +100 ke tim penyerang. Duplikat (flag + penyerang + target sama) tidak dihitung dua kali.

SLA bonus. Diberi bila semua challenge yang aktif UP, maksimal sekali per jendela 5 menit. Threshold runtime bukan angka tetap 6 melainkan len(enabled_challenges()) — mengaktifkan challenge ke-17 membuat syaratnya "semua 17 UP".

Badge. Juara, runner-up, dan tempat ketiga dihitung dari total poin.


Kebutuhan Sistem

Host reference: Ubuntu 24.04 (noble), x86_64, Docker + Compose v2, systemd.

Sumber daya Minimum Recommended
CPU 2 vCPU 4 vCPU
RAM 8 GB 16 GB
Disk 60 GB 100 GB+
Docker Compose v2 (docker compose) —

Compose v1 (docker-compose) tidak didukung — seluruh generator memakai docker compose.

Disk adalah pembatas utama. Tiap challenge yang aktif menjadi satu image services-<name>; ukurannya bervariasi dari ~190 MB (gift-card) sampai ~900 MB (warmup), dan pada host ini 16 image aktif menempati sekitar 8 GB. Membangun banyak challenge sekaligus akan mengisi disk sebelum selesai — implementasi terbaik adalah membangun challenge secara berurutan dan menjalankan docker builder prune -af di antaranya.

phew menjalankan generator kunci Paillier saat start (±12 detik) sehingga butuh RAM ekstra dan checker-nya memakai _CRYPTO_TIMEOUT, bukan timeout prompt bawaan 5 detik.

Prasyarat jaringan: setiap compose template sudah memuat extra_hosts: host.docker.internal:host-gateway, dan UFW host harus mengizinkan port challenge (lihat Jebakan Operasional).

Dependency checker ada di receiver/requirements.txt: fastapi, uvicorn, pwntools, pyelftools, pycryptodome, fastecdsa, ecdsa, Pillow, pandas, openpyxl, PyPDF2.


Setup

1. Clone

git clone <repo-url> attack-defense-platform
cd attack-defense-platform

Semua path di dalam kode memakai /opt/gemastik18-final sebagai BASE, jadi letakkan repo di sana:

sudo mkdir -p /opt
sudo mv attack-defense-platform /opt/gemastik18-final
cd /opt/gemastik18-final

2. Docker

sudo bash node.sh

node.sh memasang Docker CE dari repo resmi lalu menjalankan starter.py. Instalasi manual:

sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
    docker-buildx-plugin docker-compose-plugin

3. Kredensial panel

cat > panel/.env <<'EOF'
PANEL_ADMIN_USER=admin
PANEL_ADMIN_PASS=ganti-dengan-password-kuat
EOF
chmod 600 panel/.env

panel/.env sudah masuk .gitignore dan tidak pernah ter-commit.

4. Python environment

cd /opt/gemastik18-final/receiver
sudo python3 -m venv .venv
sudo .venv/bin/pip install -r requirements.txt

5. Unit systemd

Panel (:18081):

# /etc/systemd/system/gemastik-panel.service
[Unit]
Description=Gemastik A/D Panel (web UI for receiver)
After=gemastik-receiver.service network-online.target
Wants=gemastik-receiver.service

[Service]
Type=simple
WorkingDirectory=/opt/gemastik18-final/panel
EnvironmentFile=-/opt/gemastik18-final/panel/.env
ExecStart=/opt/gemastik18-final/receiver/.venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 18081
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

Receiver global (:18080):

# /etc/systemd/system/gemastik-receiver.service
[Unit]
Description=Gemastik18 Receiver Service (CTF flag/control API)
After=docker.service network-online.target
Wants=docker.service
Requires=docker.service

[Service]
Type=simple
WorkingDirectory=/opt/gemastik18-final/receiver
ExecStart=/opt/gemastik18-final/receiver/.venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 18080
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now gemastik-receiver gemastik-panel
systemctl is-active gemastik-panel gemastik-receiver

6. Buat tim

Lewat UI (Teams tab, admin login) atau API:

curl -X POST http://127.0.0.1:18081/api/teams/set \
  -H 'Content-Type: application/json' \
  -b cookies.txt -c cookies.txt \
  -d '{"count":2,"labels":{"1":"Tim Satu","2":"Tim Dua"}}'

Endpoint ini idempoten (membuat yang hilang, mempertahankan yang ada) dan otomatis menjalankan sync_team_ufw() untuk setiap tim baru — tanpa itu port tim akan di-blackhole UFW.

7. Verifikasi

bash panel/verify_platform_health.py

Operasional Harian

Aksi Perintah
Lihat status semua service systemctl is-active gemastik-panel gemastik-receiver gemastik-receiver-team*
Restart panel systemctl restart gemastik-panel
Sinkronkan container tim dengan registry bash panel/apply_registry.sh
Health check python3 panel/verify_platform_health.py
SSH round-trip ke semua challenge python3 panel/verify_ssh_e2e.py
Cek user SSH per challenge bash panel/audit_ssh_users.sh
Reset penuh (tim, flag, kredensial) bash panel/reset_runtime.sh
Health suite topologi bash panel/verify_topo_full.sh
Beban host bash panel/watch_load.sh

Reverse proxy. Domain challenge dan panel dilayani Traefik lewat file dynamic di /data/coolify/proxy/dynamic/attackdefense.yaml. Pola service:

services:
  gemastik-panel-service:
    loadBalancer:
      servers:
        - url: "http://host.docker.internal:18081"

Cert TLS terbit otomatis lewat certResolver: letsencrypt selama DNS terresolve dan port 80 terbuka.

Domain yang dipakai di host ini: panel.attackdefense.imrnes.team (panel, :18081) dan attackdefense.imrnes.team (receiver global, :18080), plus subdomain per challenge aktif. Perhatikan domain bare menunjuk ke receiver, bukan panel — /login di sana akan 404 dan terlihat seperti panel mati.


Topologi

Tab Topology merender graf serangan antar tim dengan PixiJS v8 (panel/static/topo_pixi.js, engine di panel/static/vendor/pixi.mjs).

  • Pan, zoom, dan drag berjalan lewat satu funnel applyView().
  • Drag node tim menyeret seluruh challenge-nya. Indeks parent→child dibangun dari edge list — sumber yang sama untuk menggambar garis — sehingga hierarki drag tidak mungkin berbeda dari gambar.
  • Ticker Pixi didaftarkan tapi tidak dinyalakan: render berlangsung on demand (hanya saat ada pulse serangan atau sedang drag), lalu berhenti saat sunyi. Pada host tanpa GPU, repaint 60fps atas scene statis membuat halaman tidak merespons (rAF turun ke 2 FPS, lag setTimeout(0) 1353 ms).
  • Posisi drag kembali ke layout otomatis saat data di-refresh tiap 10 detik. Untuk merender ulang objek, .text hanya di-set bila string benar-benar berubah — setiap Text baru meng-upload texture GPU (~1,6 detik per siklus bila di-rebuild terus-menerus).

Suite tes: bash panel/run_topo_tests.sh (test_topo_pixels, test_topo_browser, test_topo_viewports, test_topo_race, test_topo_drag).


HTTP API

Semua endpoint di /api kecuali yang ditandai publik.

Publik

Method Path Keterangan
GET /submit UI submit flag publik
POST /api/flag/submit Submit flag
GET /api/public/scoreboard Skorboard tanpa login
GET /api/public/teams Daftar tim tanpa login

Admin (butuh login)

Method Path Keterangan
POST /api/login Login admin
POST /api/logout Logout
GET /api/challenges Daftar challenge + status
PATCH /api/challenges/{challenge} Toggle enable/disable (body {"enabled":bool})
GET /api/challenges/jobs/{job_id} Progress job toggle
GET /api/status Status runtime
GET /api/topology Data graf topologi
GET /api/teams Daftar tim
POST /api/teams/set Buat N tim (idempoten)
PUT /api/teams/{idx} Ubah label/domain tim
DELETE /api/teams/{idx} Hapus satu tim (body {"purge_scores":true})
POST /api/teams/bulk-delete Hapus beberapa tim (job)
GET /api/teams/bulk-delete/{job_id} Progress job hapus massal
POST /api/teams/{idx}/ufw Sinkronkan aturan UFW tim
POST /api/teams/start Start semua tim
POST /api/teams/stop Stop semua tim
POST /api/teams/{idx}/randomize Acak flag tim
GET /api/teams/{idx}/logs Log tim
GET /api/teams/{idx}/creds Kredensial tim
GET /api/credential/{challenge} Kredensial satu challenge
GET /api/targets Target serangan
GET /api/attacks Log serangan
GET /api/leaderboard Leaderboard
GET /api/scoreboard Skorboard internal
GET /api/history Riwayat
POST /api/restart/{challenge} Restart challenge
POST /api/rollback/{challenge} Rollback challenge
POST /api/activate/{challenge} Aktifkan challenge
POST /api/deactivate/{challenge} Nonaktifkan challenge
POST /api/reset/scores Reset skor
POST /api/reset/environment Reset environment (hapus tim + flag)

Toggle challenge jalan asinkron: build per tim bisa memakan waktu menit, jadi kerjaan dijalankan di background thread dan klien melakukan polling ke /api/challenges/jobs/{job_id}. Hapus tim massal juga berupa job karena satu tim butuh sekitar 100 detik (compose down 16 service) — empat tim inline akan menahan request sekitar 7 menit dan memicu timeout di semua proxy.

Tim (login tim)

Method Path Keterangan
GET /team/{idx} Portal tim
GET /team/{idx}/guide Panduan tim
POST /api/team/{idx}/login Login tim
POST /api/team/logout Logout tim
GET /api/team/{idx}/session Status sesi
GET /api/team/{idx}/own-challenges Challenge milik tim
GET /api/team/{idx}/targets Target untuk diserang
GET /api/team/{idx}/info Info tim
GET /api/team/{idx}/status Status challenge tim
GET /api/team/{idx}/activity Aktivitas tim
WS /api/team/{idx}/ssh/ws Terminal web ke container tim

Troubleshooting

/login di domain attackdefense.imrnes.team mengembalikan 404. Domain bare diarahkan ke receiver global (:18080), bukan panel. Panel ada di panel.attackdefense.imrnes.team (:18081). Dua router berbeda melayani kedua subdomain di attackdefense.yaml.

SLA turun ke 0 padahal container hidup. Bisa jadi receiver-nya mati, atau sering: restart panel mematikan receiver karena keduanya satu cgroup. Cek systemctl is-active gemastik-receiver-team*.

SSH ditolak padahal password di state.json benar. Cek ssh_user untuk challenge tersebut di registry. 10 dari 16 challenge impor login sebagai root, bukan ctfuser.

Flag expired / tidak cocok. reset_environment() menghapus flag lama. Cek teams/team<N>/receiver/flags/<challenge>.txt.

Waktu toggle sangat lama. Normal — satu toggle membangun image per tim. Pantau lewat /api/challenges/jobs/{job_id}, bukan dengan kill prosesnya.

pull access denied for services-<name>. Image belum ada sehingga compose mencoba build dengan context yang salah. compose_gen hanya menukar build menjadi image bila image-nya benar-benar ada di docker images.

Disk penuh (/ 0 byte). Lihat Jebakan Operasional. Yang benar: docker builder prune -af dan journalctl --vacuum-size=50M. docker image prune -af menghapus image services-* yang sedang dipakai.


Jebakan Operasional

Yang sudah ketahuan dan sudah diperbaiki. Semua masih berlaku sebagai alasan mengapa kode sekarang berbentuk seperti sekarang.

Base image EOL. debian:buster, ubuntu:20.04, dan node:14 gagal apt-get update karena GPG kedaluwarsa atau mirror 404. Pakai bookworm/noble, node:20.

UFW default deny. Host ini punya UFW aktif default deny. Port baru harus dibuka (ufw allow <port>/tcp) atau traffic di-blackhole diam-diam — termasuk dari container lewat docker bridge. POST /api/teams/set sudah menjalankan sync_team_ufw() karena alasan ini.

Project name compose wajib. Per-team compose harus dijalankan dengan -p teamN. Tanpa itu docker compose memakai nama direktori induk (services) untuk semua tim, sehingga container team2 tertimpa team1.

docker image prune -af menghapus image yang sedang dipakai. Image services-* menjadi dangling dan terhapus meski container masih jalan. Container tetap hidup tetapi image hilang dan tidak bisa di-recreate. Untuk membersihkan ruang: docker builder prune -af + journalctl --vacuum-size=100M + hapus /root/.cache.

Container orphan. Sweep dengan:

docker ps --format '{{.Names}}' | grep -E '_container$' | grep -vE '_team[0-9]+$'

Beban checker. Host 2-CPU/8GB tidak boleh meng-probe 4 receiver sekaligus (Flask sinkron + CPU bersama = SLA timeout palsu), dan tidak boleh menjalankan banyak generator kunci Paillier/RSA bersamaan. Cek uptime, free -m, vmstat 1 3 sebelum menyalahkan checker.

subprocess.run(['docker','exec',...]) tanpa timeout. Container yang jenuh memblokir selamanya dan menahan seluruh loop SLA.


Struktur Direktori

/opt/gemastik18-final/
├── README.md
├── node.sh                      # installer Docker
├── starter.py                   # bootstrap single-node (upstream)
├── teams/
│   ├── challenge_registry.json  # sumber data tunggal 28 challenge
│   ├── points.json              # skor + event
│   ├── leaderboard.json         # solve
│   ├── attacks.json             # log serangan (visualisasi topologi)
│   └── teamN/
│       ├── state.json           # port, flag, kredensial, label, domain
│       ├── services/docker-compose.yml   # hasil generate per tim
│       └── receiver/            # receiver terisolasi tim N
├── services/<challenge>/        # Dockerfile + compose template (28 challenge)
├── panel/
│   ├── main.py                  # FastAPI: seluruh route
│   ├── teams.py                 # orkestrasi tim, port, flag, skor, SLA
│   ├── compose_gen.py           # render compose per tim dari registry
│   ├── gen_receiver_services.py # generate unit systemd per tim
│   ├── gen_receiver_main.py     # generate main.py receiver per tim
│   ├── apply_registry.sh        # sinkronkan container dengan registry
│   ├── reset_runtime.sh         # reset penuh
│   ├── verify_platform_health.py
│   ├── verify_ssh_e2e.py
│   ├── audit_ssh_users.sh
│   ├── run_topo_tests.sh
│   ├── verify_topo_full.sh
│   └── static/
│       ├── index.html           # dashboard admin
│       ├── team.html            # portal tim
│       ├── topo_pixi.js         # renderer topologi PixiJS v8
│       └── vendor/pixi.mjs
└── receiver/
    ├── main.py                  # API receiver (flag store + checker)
    ├── config.py
    ├── requirements.txt
    ├── challenges/              # checker: Blogpost, Phew, xvi/, xvii/
    ├── flags/                   # flag default
    └── .venv/

Credit

Challenge berasal dari tiga repositori: gemastik18-final, gemastik-xvi-final, gemastik-xvii-final.