由于工作需要,最近在为客户搭建了很多次CTF和AWD训练环境,分别用的GZCTF和Cardinal,这里刚好发一篇文章记录一下常见的配置。
GZCTF
概述
CTF平台需求主要有附件题目、动态靶机题目,这里主流平台有GZCTF、CTFd、ACTF等,这里选用GZCTF平台。
平台架构
官方给了三种部署模式:纯K8S、Docker+K8S、纯Docker,这里推荐使用Docker+K3S,也就是平台组件在Docker运行,选手动态靶机容器运行在K3S集群中,方便管理。
我这里在前面加了一个Nginx反向代理,上了一个自签名证书来保证传输安全。由于平台搭建在内网环境,通过端口映射进行访问,所以启用了平台的代理模式,只开放一个Web端口,通过ReflectX客户端连接平台WS协议,从而把靶机转发到本地端口(这里最早客户端不支持自签名证书,后来提了issue很快就添加了,这里感谢XDSEC)
快速部署
配置文件
首先准备一台Linux服务器/虚拟机,第一步是安装Docker,我这里直接使用linuxmirror的一键脚本安装
bash <(curl -sSL https://linuxmirrors.cn/docker.sh)
随后根据向导进行选择,公有云尽量选择相同云服务商的镜像源,速度更快并且流量免费,这里我最早选择了教科网新出的MirrorZ,但是发现由于MirrorZ会302到最近的镜像站,但是会出现安装同一个程序302到两个镜像站的情况导致出错,所以我最后使用的是USTC的镜像源,Docker源可以使用毫秒镜像1ms.run,如果自己出题的话推荐搭建一个Harbor托管镜像。
例如我这里的运行目录在/opt/ctfplatfom下,首先创建gzctf配置文件appsettings.json
{
"AllowedHosts": "*",
"ConnectionStrings": {
"Database": "Host=db:5432;Database=gzctf;Username=postgres;Password=xxxxxxxx",
"RedisCache": "cache:6379,abortConnect=false,password=xxxxxxxx"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"EmailConfig": {
"SenderAddress": "xxx@mail.com",
"SenderName": "XXX-CTF",
"UserName": "xxx@mail.com",
"Password": "xxxxxxxx",
"Smtp": {
"Host": "smtp.xmail.xx.com",
"Port": 465,
"BypassCertVerify": false
}
},
"XorKey": "xxxxxxxx",
"ContainerProvider": {
"Type": "Kubernetes",
"PortMappingType": "PlatformProxy",
"EnableTrafficCapture": true,
"PublicEntry": "ctf.xxxx.cn",
"KubernetesConfig": {
"Namespace": "gzctf-challenges",
"ConfigPath": "kube-config.yaml",
"AllowCIDR": [
"192.165.13.0/24"
],
"DNS": [
"114.114.114.114",
"223.5.5.5"
]
}
},
"RequestLogging": false,
"DisableRateLimit": false,
"CaptchaConfig": {
"Provider": "HashPow",
"HashPow": {
"Difficulty": 18
}
},
"ForwardedOptions": {
"ForwardedHeaders": 7,
"ForwardLimit": 1,
"ForwardedForHeaderName": "X-Forwarded-For",
"KnownIPNetworks": ["192.165.13.0/24"]
}
}
安装docker及docker-compose
// 一键安装docker,可自动换国内源
bash <(curl -sSL https://linuxmirrors.cn/docker.sh)
// 下载docker-compose
sudo curl -L "https://github.com/docker/compose/releases/download/v2.2.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
// 赋予执行权限
sudo chmod +x /usr/local/bin/docker-compose
// 创建软连接
sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
安装k3s
如果需要修改容器数量上限,请先阅读FAQ,安装完成后不支持修改
// 安装k3s
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="..." sh -
// 国内镜像安装
curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | INSTALL_K3S_MIRROR=cn sh -
// 安装完成后导出连接文件
sudo cat /etc/rancher/k3s/k3s.yaml
将上述输出的内容保存为 kube-config.yaml,并更改 server 字段为 k3s control-panel 所在机器的 IP
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: # ...
server: https://127.0.0.1:6443 # change this to your k3s control-panel's IP or domain
name: default
# ...
编写docker-compose
将appsettings.json和kube-config.yaml保存在ctfplatform目录下,随后编写docker-compose.yml
services:
gzctf:
image: registry.cn-shanghai.aliyuncs.com/gztime/gzctf:develop
restart: always
environment:
- "GZCTF_ADMIN_PASSWORD=xxxxxxxx"
- "LC_ALL=zh_CN.UTF-8"
expose:
- "8080"
volumes:
- "./data/files:/app/files"
- "./appsettings.json:/app/appsettings.json:ro"
- "./kube-config.yaml:/app/kube-config.yaml:ro"
depends_on:
- db
networks:
default:
ipv4_address: 192.165.13.10
db:
image: postgres:alpine
restart: always
environment:
- "POSTGRES_PASSWORD=xxxxxxxx"
volumes:
- "./data/db:/var/lib/postgresql"
cache:
image: redis:alpine
command: ["--bind", "0.0.0.0"]
restart: always
networks:
default:
driver: bridge
ipam:
config:
- subnet: 192.165.13.0/24
启动平台
确保当前在ctfplatform目录下,执行命令启动容器集群
docker-compose up -d
启用后可以查看状况
docker ps -a
若无法启动可查看日志错误内容
docker logs <容器id>

FAQ
修改容器数量上限
如果需要在一台 k3s 实例上运行超过 255 个题目容器,需要在安装 k3s 时 指定 INSTALL_K3S_EXEC,并更改 node-cidr-mask-size 为所需的子网大小。
INSTALL_K3S_EXEC="--kube-controller-manager-arg=node-cidr-mask-size=16"
上述配置项在安装后无法更改, CIDR可修改为 /16 ,安装完成后还需要修改k3s的pod上限
// 在 k3s control-panel 所在的机器上执行以下命令:
sudo nano /etc/rancher/k3s/kubelet.config
// 编辑如下设置中的 maxPods
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
maxPods: 500
sudo nano /etc/systemd/system/k3s.service
// 编辑如下设置中的 ExecStart,指定kubelet-arg
ExecStart=/usr/local/bin/k3s \
server \
--kubelet-arg=config=/etc/rancher/k3s/kubelet.config
// 重启k3s服务
sudo systemctl daemon-reload
sudo systemctl restart k3s
安装K3S后主机80和443端口被占用
k3s安装后会自动安装Traefik,类似于K8S的Ingress负载均衡器,会监听主机的80和443端口,并且用lsof命令看不到进程,这里如果不使用Traefik的话,可以在K3S的Systemd配置文件的ExecStart中添加 "--disable=traefik"来关闭Traefik。
修改靶机容器端口范围
k3s 默认的 NodePort 端口范围为 30000-32767,这可能会与需求不太符合,因此可以根据需要更改 k3s 的 NodePort 端口范围。
在 k3s control-panel 所在的机器上执行以下命令:
sudo nano /etc/systemd/system/k3s.service
//编辑如下设置中的 `ExecStart`,指定`service-node-port-range`
ExecStart=/usr/local/bin/k3s \
server \
--kube-apiserver-arg=service-node-port-range=18001-18500
sudo systemctl daemon-reload
sudo systemctl restart k3s
AWD
概述
AWD平台主要需求是后台可以管理所有选手靶机,选手测可以提交flag,提供靶机checkdown接口,提供大屏软件,这里使用的是开源的Cardinal平台,平台仅提供flag下发,分数计算,具体容器镜像需要自己制作以及启动。
平台架构
Cardinal使用Vue+Go的架构实现,分为前台后台,后台可以添加队伍、添加靶机、生成下发flag,前台队伍可以看到自己队伍靶机、提交flag,同时Cardinal配套的有大屏Asteroid,可以实时展示比赛状态。

平台部署
到github官方仓库下载Release,随后运行以下命令启动平台
# 解压程序包
tar -zxvf Cardinal_VERSION_OS_ARCH.tar.gz
# 赋予执行权限
chmod +x ./Cardinal
# 运行
./Cardinal
随后根据cli提示输入比赛开始时间、结束时间、休息时间、每轮时间、攻击得分等配置。
需要注意的是该平台和ctf平台不同,ctf平台可以支撑多个比赛,但awd平台仅能同时为一场赛事提供服务。
运行完成之后
选手端 http://localhost:19999/
后台管理 http://localhost:19999/manager

靶机部署
这里的队伍靶机需要有以下要求,首先root用户需要能远程登录,平台需要使用root用户下发flag;其次不能给选手root用户,防止选手恶意防护,仅能给选手/tmp和/var/www下的权限,所以需要创建低权限用户。
这里整理了docker和虚拟机版本的批量部署脚本,仅供参考。
docker靶机
dockerfile,使用ubuntu16.04、apache、php7,源码放在./src文件夹下
# Ubuntu 16.04
FROM ubuntu:16.04
RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list
RUN apt-get update && \
apt-get install -y openssh-server apache2 php7.0 libapache2-mod-php7.0 uuid-runtime pwpen&& \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /var/run/sshd && \
echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config && \
echo 'PasswordAuthentication yes'>> /etc/ssh/sshd_config
COPY src/ /var/www/html
RUN rm -rf /var/www/html/index.html
RUN chown -R www-data:www-data /var/www/html
RUN chmod -R 777 /var/www/html /tmp
# 创建 team 用户
RUN useradd -m -s /bin/bash team && \
usermod -a -G www-data team && \
chmod g+rwx /var/www/html /tmp
# 入口脚本:读环境变量设密码、生成 flag、启动服务
RUN printf '#!/bin/bash\n\
# 生成 测试flag\n\
echo "flag{$(uuidgen)}" > /flag\n\
\n\
# 设置 root 密码\n\
echo "root:${ROOT_PASS}" | chpasswd\n\
\n\
# 设置 team 密码\n\
echo "team:${TEAM_PASS}" | chpasswd\n\
\n\
# 启动 ssh & apache\n\
/usr/sbin/sshd -D &\nexec /usr/sbin/apache2ctl -D FOREGROUND\n' > /start.sh && chmod +x /start.sh
CMD ["/start.sh"]
EXPOSE 22 80
随后build镜像
docker build -t chal01 .
批量启动镜像,这里脚本默认是10个靶机,web端口8001-8010,ssh端口7001-7010,root密码固定,team用户生成随机密码并在运行结束后输出到teams.txt中,随后按照格式录入到Cardinal平台即可。
#!/bin/bash
# 保存文件
PASS_FILE="$(pwd)/teams.txt"
> "$PASS_FILE" # 清空旧记录
for i in $(seq 1 10); do
web_port=$((8000+i))
ssh_port=$((7000+i))
# 生成随机密码
TEAM_PASS=$(openssl rand -hex 8)
ROOT_PASS='Team@2025' # 固定
# 记录到宿主机
echo "awd-$i web: http://38.147.162.130:${web_port} ssh: ssh://38.147.162.130:${ssh_port} team : ${TEAM_PASS}" >> "$PASS_FILE"
# 启动容器
docker run -d --rm \
--name awd-$i \
-p ${web_port}:80 \
-p ${ssh_port}:22 \
-e TEAM_PASS="${TEAM_PASS}" \
-e ROOT_PASS="${ROOT_PASS}" \
awd-ubuntu14-php
done
echo "==================================="
echo "All passwords saved to -> $PASS_FILE"
cat "$PASS_FILE"
批量停止靶机
#!/bin/bash
for i in $(seq 1 10); do
docker stop awd-$i
done
虚拟机靶机
虚拟机这里我使用的平台为Proxmox,相较于ESXi功能更丰富,但占用资源比安装VCenter要少,且提供了完善了restful api。

首先需要使用Proxmox平台创建靶机模板虚拟机,具体步骤为:
创建虚拟机 ->安装系统和guesttool -> 安装cloudinit -> 安装基础环境 -> 安装题目环境
随后转为模板,修改以下脚本来实现批量克隆,这里需要修改的是前面PVE的配置部分,需要去生成API Token,随后修改下方的模板虚拟机id,克隆完成后会自动以Cardinal的批量导入json格式输出。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import random, string, json, sys, time, requests
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
# ========== 配置 ==========
PVE_HOST = "https://192.168.1.10:8006" # 去掉尾部空格
NODE = "ctf" # 节点名
TOKEN = "root@pam!operator=<输入secret>"
SOURCE_VMID = 105
TEMPLATE_VMID= 130
TARGET_POOL = "local-lvm"
GW = "192.168.1.1"
DNS = "192.168.1.1"
CIDR= "24"
ChallengeID = 1
# ==========================
session = requests.Session()
session.verify = False
session.headers.update({"Authorization": f"PVEAPIToken={TOKEN}"})
def rand_pass(n=12):
return ''.join(random.choices(string.ascii_letters + string.digits, k=n))
def api(path, method="get", **kwargs):
"""统一发请求 + 抛异常"""
url = f"{PVE_HOST}/api2/json{path}"
r = session.request(method, url, **kwargs)
if r.status_code >= 400:
raise RuntimeError(f"{method.upper()} {path} -> {r.status_code} {r.text[:200]}")
return r.json()["data"] if r.text else None
def wait_task(upid):
while True:
st = api(f"/nodes/{NODE}/tasks/{upid}/status")
if st["status"] == "stopped":
if st.get("exitstatus") != "OK":
raise RuntimeError(f"Task {upid} failed: {st['exitstatus']}")
return
time.sleep(2)
def main():
# 1. 确保源 VM 关机
vm_status = api(f"/nodes/{NODE}/qemu/{SOURCE_VMID}/status/current")
if vm_status["status"] != "stopped":
print(f"[+] 关闭源 VM {SOURCE_VMID}")
upid = api(f"/nodes/{NODE}/qemu/{SOURCE_VMID}/status/stop", method="post")
wait_task(upid)
# 2. 转模板
status = api(f"/nodes/{NODE}/qemu/{SOURCE_VMID}/status/current")
if status.get("template", 0): # 1 表示模板
print(f"[-] 源 VM {SOURCE_VMID} 已是模板,跳过转换")
else:
upid = api(f"/nodes/{NODE}/qemu/{SOURCE_VMID}/template", method="post")
wait_task(upid)
print(f"[+] 源 VM {SOURCE_VMID} 已转为模板")
# 3. 批量克隆 + cloud-init
results = []
for i in range(1, 11):
cloneid = TEMPLATE_VMID + i
hostname = f"team{i:02d}"
ip = f"192.168.1.{cloneid-100}"
pwd = rand_pass()
print(f"[+] 克隆 {cloneid} hostname={hostname} ip={ip}")
# 使用默认存储
#upid = api(f"/nodes/{NODE}/qemu/{SOURCE_VMID}/clone",
# method="post",
# data={"newid": cloneid, "name": hostname, "pool": TARGET_POOL, "full": "1"})
upid = api(f"/nodes/{NODE}/qemu/{SOURCE_VMID}/clone",
method="post", data={"newid": cloneid,
"name": hostname, "full": "1"})
wait_task(upid)
# cloud-init
api(f"/nodes/{NODE}/qemu/{cloneid}/config",
method="post",
data={
"ciuser": "team",
"cipassword": pwd,
"ipconfig0": f"ip={ip}/{CIDR},gw={GW}",
"nameserver": DNS,
"searchdomain": "local",
"ciupgrade": "0",
"citype": "nocloud"
})
# 开机
api(f"/nodes/{NODE}/qemu/{cloneid}/status/start", method="post")
print(f"[+] VM {cloneid} 已启动")
results.append({
"ChallengeID": ChallengeID,
"TeamID": i,
"IP": ip,
"Port": "80",
"SSHPort": "22",
"SSHUser": "root",
"SSHPassword": "Team@2025",
"Description": f"ssh端口:22, 用户名: team, 密码: {pwd}"
})
# 4. 输出 JSON
with open("team-instances.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print("\n============ 全部完成 ============\n")
print(json.dumps(results, indent=2, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except Exception as e:
print("!!! 错误:", e, file=sys.stderr)
sys.exit(1)
FAQ
部分程序静态资源访问失败
运行以下ssh批量脚本去修改数据库的字段
#!/usr/bin/env python3
import paramiko
import sys
USER = 'root'
PASSWORD = 'Team@2025'
DB_USER = 'root'
DB_PASSWORD = 'Team@2025'
DB_NAME = 'fish'
START_IP = 31
END_IP = 40
TIMEOUT = 5
def do_one(ip: str):
sql = f"UPDATE cms_options SET option_value='http://{ip}/' WHERE option_name='domain';"
cmd = f'/xp/server/mysql/bin/mysql -u{DB_USER} -p\'{DB_PASSWORD}\' {DB_NAME} -e "{sql}" && rm -rf /var/www/html/runtime/cache'
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username=USER, password=PASSWORD, timeout=TIMEOUT)
stdin, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode()
err = stderr.read().decode()
if err and 'ERROR' in err:
print(f'[!] {ip} 失败:{err.strip()}')
else:
print(f'[+] {ip} 成功:domain -> http://{ip}/')
except Exception as e:
print(f'[-] {ip} 连接失败:{e}')
finally:
ssh.close()
def main():
for last in range(START_IP, END_IP + 1):
ip = f'192.168.1.{last}'
do_one(ip)
if __name__ == '__main__':
main()
虚拟机cloudinit后root用户无法登录且需要使用证书
cloudinit执行后会在/etc/sshd/sshd_config.d下生成50-cloudinit.yaml,里面写了PermitRootLogin no以及不允许使用密码登录,这里删除此文件并重启ssh服务即可
由于现在有了VibeCoding,其实我一直在自己搞一个支持CTF+AWD+AWDP的竞赛平台,不过由于时间不够,暂时只实现了AWD和CTF的部分功能,希望我早日实现并开源!
太久没有更新过了,打算最近多记录点常用的内容,先做个小记:CTF动态靶机的制作、异地组网、钓鱼邮件演练。