由于工作需要,最近在为客户搭建了很多次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动态靶机的制作、异地组网、钓鱼邮件演练。

此作者没有提供个人介绍。
最后更新于 2026-10-22