This commit is contained in:
Ivan Nikolskiy 2026-07-12 20:32:52 +01:00
parent 211e43c1a4
commit c673ab58a8
12 changed files with 519 additions and 17 deletions

View File

@ -1,8 +0,0 @@
<h3 align="center"><img src="docs/logo.png" alt="logo" width="50%"></h3>
<h3 align="center"><strong>Naughty or Nice CTF</strong></h4>
<p align="center">
<br>Unwrap festive flags and unleash your inner hacker in a
<br>Christmas CTF where being naughty or nice is all part of the game.
</p>

View File

@ -1,9 +0,0 @@
version: 1
defaults:
category: "misc"
points: 100
is_visible: false
complexity: 5
service_webshell: false
service_export_flag: true
service_flag_env: "FLAG"

6
k8s/00-namespace.yaml Normal file
View File

@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: ctf
labels:
app.kubernetes.io/part-of: noc-ctf

13
k8s/01-flags-secret.yaml Normal file
View File

@ -0,0 +1,13 @@
apiVersion: v1
kind: Secret
metadata:
name: ctf-flags
namespace: ctf
type: Opaque
stringData:
# entrypoint.sh in each image writes $FLAG to the flag file the challenge reads.
chal1-flag: "CTF{pyth0n_sst1_1nj3ct10n_rul3z}" # Second Track
chal2-flag: "CTF{sst1_w1th_f1lt3rs_m4d3_1t_s3cur3}" # Second Track Aftermath
chal3-flag: "CTF{sst1_n0_c0mm4nd_3x3cuti0n_just_r34d}" # Second Track Reborn
chal4-flag: "CTF{lf1_php_s3ss10n_p01s0n1ng}" # Big Software Foundation
chal5-flag: "CTF{xss_m4d3_34s13r_w1th_pupp3t33r}" # Elf's Blog

118
k8s/README.md Normal file
View File

@ -0,0 +1,118 @@
# Deploying the Web CTF challenges to k3s (Traefik)
Five web challenges, each a single-container HTTP service on port 80, exposed on
its own subdomain via a Traefik Ingress.
| Host | Challenge | Stack | Flag secret key |
|----------------------|--------------------------|------------------------|-----------------|
| `chal1.entysec.com` | Second Track (Easy) | Flask + gunicorn | `chal1-flag` |
| `chal2.entysec.com` | Second Track Aftermath | Flask + gunicorn | `chal2-flag` |
| `chal3.entysec.com` | Second Track Reborn | Flask + gunicorn | `chal3-flag` |
| `chal4.entysec.com` | Big Software Foundation | PHP 7.2 + Apache | `chal4-flag` |
| `chal5.entysec.com` | Elf's Blog | Node + Chromium + Flask (admin bot) | `chal5-flag` |
Each challenge's `entrypoint.sh` reads the `FLAG` env var and writes it to the
file the app expects — so the flags live only in the `ctf-flags` Secret, never
baked into an image.
## Prerequisites
- A running k3s cluster with Traefik installed (k3s ships it by default).
- `kubectl` configured against the cluster.
- Docker on the k3s node (to build images), **or** a container registry every
node can reach (multi-node).
## 1. Build images and make them available to k3s
k3s uses **containerd**, not the Docker daemon — a `docker build` alone is not
visible to k3s. `build-images.sh` builds each image and imports it into k3s'
containerd store. Run it on the k3s node:
```bash
./build-images.sh
```
Multi-node cluster? Use a registry instead of per-node import:
```bash
REGISTRY=registry.entysec.com/ctf ./build-images.sh
# then prefix the image: names in the chalN-*.yaml manifests with the same registry
```
## 2. Deploy
```bash
./deploy.sh
```
That applies the namespace, the flags Secret, and all five challenges (each a
Deployment + Service + Ingress), then waits for rollout.
Deploy a single challenge:
```bash
kubectl apply -f chal4-big-software-foundation.yaml
```
## 3. Point DNS at the cluster
Create DNS records (A/AAAA, or a `*.entysec.com` wildcard) for each host,
pointing at the IP where Traefik's entrypoint is exposed (the k3s node / load
balancer). Traefik routes by the `Host` header, so the subdomain must resolve to
the cluster.
Quick local test without DNS:
```bash
curl -H 'Host: chal1.entysec.com' http://<NODE_IP>/
```
## 4. (Optional) HTTPS
Two common options:
- **cert-manager + Let's Encrypt** — add a `ClusterIssuer`, then a `tls:` block
referencing a cert Secret on each Ingress and switch the router entrypoint
annotation to `websecure`.
- **Traefik default TLS** — set the annotation
`traefik.ingress.kubernetes.io/router.tls: "true"` and add a `tls:` section.
Example TLS-enabled Ingress (with cert-manager):
```yaml
metadata:
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [chal1.entysec.com]
secretName: chal1-tls
```
## Managing flags
Flags live in `01-flags-secret.yaml`. After editing, re-apply and restart the
affected deployment so the new flag is written on boot:
```bash
kubectl apply -f 01-flags-secret.yaml
kubectl -n ctf rollout restart deploy/chal1-second-track
```
## Tear down
```bash
kubectl delete namespace ctf
```
## Notes
- **Elf's Blog** runs gunicorn + a Puppeteer admin bot in one container. It needs
more memory (limit 1Gi) and gets a tmpfs `/dev/shm`. Chromium already launches
with `--no-sandbox`, so no extra pod privileges are required. Keep it at
`replicas: 1` — it persists comments to the container filesystem.
- The Flask apps read `PORT` (set to 80); the PHP app listens on Apache's
default 80. All Services target container port 80.
- To reset a challenge's state (e.g. Elf's Blog comments):
`kubectl -n ctf rollout restart deploy/chal5-elfs-blog`.

48
k8s/build-images.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Build all web challenge images and make them available to k3s.
#
# k3s uses containerd, not the Docker daemon, so a locally-built Docker image
# is NOT visible to k3s until it is imported into containerd (or pushed to a
# registry). This script builds each image and imports it directly.
#
# Run this ON the k3s node (needs docker + the k3s binary).
#
# For a MULTI-NODE cluster, import-per-node does not scale: set REGISTRY to a
# registry reachable by every node, e.g.
# REGISTRY=registry.entysec.com/ctf ./build-images.sh
# then change imagePullPolicy to IfNotPresent (already set) and prefix the
# image names in the manifests with $REGISTRY/.
set -euo pipefail
# Repo root = parent of this script's directory.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REGISTRY="${REGISTRY:-}" # empty => build + import into local k3s containerd
# image-tag -> build context (relative to repo root)
declare -a CHALLENGES=(
"ctf-second-track:latest|Web/Easy/Second Track"
"ctf-second-track-aftermath:latest|Web/Medium/Second Track Aftermath"
"ctf-second-track-reborn:latest|Web/Hard/Second Track Reborn"
"ctf-big-software-foundation:latest|Web/Easy/Big Software Foundation"
"ctf-elfs-blog:latest|Web/Hard/Elfs' Blog"
)
for entry in "${CHALLENGES[@]}"; do
tag="${entry%%|*}"
ctx="${entry#*|}"
image="${REGISTRY:+$REGISTRY/}$tag"
echo "==> Building $image (context: $ctx)"
docker build -t "$image" "$ROOT/$ctx"
if [ -n "$REGISTRY" ]; then
echo "==> Pushing $image"
docker push "$image"
else
echo "==> Importing $image into k3s containerd"
# Pipe the Docker image straight into k3s' containerd image store.
docker save "$image" | sudo k3s ctr images import -
fi
done
echo "==> Done. Images ready for k3s."

View File

@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: chal1-second-track
namespace: ctf
labels: { app: chal1-second-track }
spec:
replicas: 1
selector:
matchLabels: { app: chal1-second-track }
template:
metadata:
labels: { app: chal1-second-track }
spec:
containers:
- name: app
image: ctf-second-track:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
env:
- name: PORT
value: "80"
- name: FLAG
valueFrom:
secretKeyRef: { name: ctf-flags, key: chal1-flag }
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "500m", memory: "256Mi" }
---
apiVersion: v1
kind: Service
metadata:
name: chal1-second-track
namespace: ctf
spec:
selector: { app: chal1-second-track }
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chal1-second-track
namespace: ctf
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: chal1.entysec.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chal1-second-track
port: { number: 80 }

View File

@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: chal2-aftermath
namespace: ctf
labels: { app: chal2-aftermath }
spec:
replicas: 1
selector:
matchLabels: { app: chal2-aftermath }
template:
metadata:
labels: { app: chal2-aftermath }
spec:
containers:
- name: app
image: ctf-second-track-aftermath:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
env:
- name: PORT
value: "80"
- name: FLAG
valueFrom:
secretKeyRef: { name: ctf-flags, key: chal2-flag }
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "500m", memory: "256Mi" }
---
apiVersion: v1
kind: Service
metadata:
name: chal2-aftermath
namespace: ctf
spec:
selector: { app: chal2-aftermath }
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chal2-aftermath
namespace: ctf
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: chal2.entysec.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chal2-aftermath
port: { number: 80 }

View File

@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: chal3-reborn
namespace: ctf
labels: { app: chal3-reborn }
spec:
replicas: 1
selector:
matchLabels: { app: chal3-reborn }
template:
metadata:
labels: { app: chal3-reborn }
spec:
containers:
- name: app
image: ctf-second-track-reborn:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
env:
- name: PORT
value: "80"
- name: FLAG
valueFrom:
secretKeyRef: { name: ctf-flags, key: chal3-flag }
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "500m", memory: "256Mi" }
---
apiVersion: v1
kind: Service
metadata:
name: chal3-reborn
namespace: ctf
spec:
selector: { app: chal3-reborn }
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chal3-reborn
namespace: ctf
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: chal3.entysec.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chal3-reborn
port: { number: 80 }

View File

@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: chal4-big-software
namespace: ctf
labels: { app: chal4-big-software }
spec:
replicas: 1
selector:
matchLabels: { app: chal4-big-software }
template:
metadata:
labels: { app: chal4-big-software }
spec:
containers:
- name: app
image: ctf-big-software-foundation:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
env:
# PHP/Apache image; entrypoint writes $FLAG to /var/www/html/flag.txt
- name: FLAG
valueFrom:
secretKeyRef: { name: ctf-flags, key: chal4-flag }
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "500m", memory: "256Mi" }
---
apiVersion: v1
kind: Service
metadata:
name: chal4-big-software
namespace: ctf
spec:
selector: { app: chal4-big-software }
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chal4-big-software
namespace: ctf
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: chal4.entysec.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chal4-big-software
port: { number: 80 }

74
k8s/chal5-elfs-blog.yaml Normal file
View File

@ -0,0 +1,74 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: chal5-elfs-blog
namespace: ctf
labels: { app: chal5-elfs-blog }
spec:
replicas: 1 # keep at 1: writes comments.json to local fs
selector:
matchLabels: { app: chal5-elfs-blog }
template:
metadata:
labels: { app: chal5-elfs-blog }
spec:
containers:
- name: app
image: ctf-elfs-blog:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
env:
- name: FLAG
valueFrom:
secretKeyRef: { name: ctf-flags, key: chal5-flag }
- name: ADMIN_BOT_ENABLED
value: "1"
- name: ADMIN_BOT_INTERVAL
value: "15"
- name: BASE_URL
value: "http://127.0.0.1:80"
resources:
requests: { cpu: "100m", memory: "256Mi" }
limits: { cpu: "1", memory: "1Gi" } # headless Chromium is memory-hungry
# Chromium is launched with --disable-dev-shm-usage, so a large /dev/shm
# is not strictly required, but this avoids the default 64Mi shm limit.
volumeMounts:
- name: dshm
mountPath: /dev/shm
volumes:
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: chal5-elfs-blog
namespace: ctf
spec:
selector: { app: chal5-elfs-blog }
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chal5-elfs-blog
namespace: ctf
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: chal5.entysec.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chal5-elfs-blog
port: { number: 80 }

21
k8s/deploy.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Apply all CTF web-challenge manifests to the cluster.
# Assumes kubectl is configured against your k3s cluster and Traefik is installed.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
kubectl apply -f "$DIR/00-namespace.yaml"
kubectl apply -f "$DIR/01-flags-secret.yaml"
kubectl apply -f "$DIR/chal1-second-track.yaml"
kubectl apply -f "$DIR/chal2-second-track-aftermath.yaml"
kubectl apply -f "$DIR/chal3-second-track-reborn.yaml"
kubectl apply -f "$DIR/chal4-big-software-foundation.yaml"
kubectl apply -f "$DIR/chal5-elfs-blog.yaml"
echo "==> Applied. Watching rollout..."
kubectl -n ctf rollout status deploy/chal1-second-track
kubectl -n ctf rollout status deploy/chal2-aftermath
kubectl -n ctf rollout status deploy/chal3-reborn
kubectl -n ctf rollout status deploy/chal4-big-software
kubectl -n ctf rollout status deploy/chal5-elfs-blog
kubectl -n ctf get pods,ingress