组织一场正经的CTF比赛,最让人头疼的往往不是出题,而是怎么把题目安全、稳定、公平地发给选手。早几年常见的做法是给附件、给靶机IP,选手连上去各玩各的,听起来没什么问题,但真到了几十上百人同时在线的时候,场景就失控了:有人把题目环境打崩,所有人跟着遭殃;有人拿别人解题的痕迹直接“借鉴”,反作弊全靠裁判肉眼盯;更有题目本身需要一个独立环境,根本没法让所有人共享。动态靶机这个思路就是为了解决这些问题——每个选手、每支队伍进到的都是自己的独立容器实例,flag单独生成,环境互不干扰,平台按需拉起、超时回收。GZCTF就是一套专门干这个事的开源竞赛平台,基于Docker动态生成靶机,部署起来不算复杂,但里面值得讲清楚的门道不少。
这篇内容我打算从部署、配置、镜像制作到运维排障,完整拆一遍基于GZCTF的动态靶机环境搭建过程。适合正在筹备校内赛、训练赛的个人或小团队,也适合第一次接触动态靶机、想搞清楚这套机制到底怎么运作的同学。我会把每一步为什么这么做、哪些参数会影响选手体验、哪些坑是真踩过才知道的,一并写清楚。
1. 先想清楚:动态靶机到底是什么,为什么绕不开 Docker
很多人第一次接触GZCTF的时候,容易把它理解成“一个能挂题的网站”。这么说也没错,但动态靶机这个核心功能,才是它和其他简单题库系统最本质的区别。理解了动态靶机的价值,你才会知道部署时那些看似多余的组件,到底在解决什么问题。
1.1 传统静态附件赛的尴尬,动态靶机是怎么解的
传统CTF题目有两类典型形态。一类是静态题,题目就是一个压缩包,里面放着源码、加密流量、图片等等,选手下载下来本地分析,最后找到flag提交。另一类是固靶,题目给一个固定IP和端口,所有人都连同一个环境。静态题吃亏在环境依赖上,一道带特定版本的题目,选手本地跑不起来就只能干瞪眼;固靶吃亏在公平性和稳定性上,环境被某个人打坏了,后面的人全没法做题,甚至有人直接在靶机上搞破坏,影响别人解题。
动态靶机的思路是把题目做成镜像,平台收到选手的请求后,用Docker实时创建一个独立的容器实例。每个人看到的flag不一样,连进去的环境也不一样,提交flag也只认自己实例里的那一个。这样一来,选手之间互不干扰,题目环境也不会被共享弹栈的人污染,裁判要做的反作弊工作一下子少了很多。Docker在这里承担的就是“批量生成隔离环境”的角色,没有容器技术,这套机制根本玩不起来。
1.2 同类平台那么多,为什么挑GZCTF来实战
目前社区里开源的CTF平台并不少,有的是老牌项目,功能全面但界面和代码风格偏年代感;有的是新出的轻量方案,部署简单但动态靶机能力比较弱。GZCTF在我的实际体验里,最突出的三点是:第一,动态靶机的产品化做得完整,支持镜像、端口、资源限制、实例上限、自动销毁等配置,不需要自己写一堆管理脚本;第二,选手端体验好,启动容器、倒计时、访问URL都是自动化的,不用给选手发一大段操作说明;第三,部署形态足够轻,单机Docker加几个辅助服务就能跑起来,适合小团队快速落地。
更关键的是它对私有镜像仓库、对象存储、邮件服务等周边组件的支持都比较标准,没有搞什么私有协议,换环境迁移数据相对容易。对于想长期维护一套训练平台的组织来说,这一点非常重要——今天在单机上搭好了,明天想把容器调度迁到集群上,底层思路也是通用的,不会白学。
1.3 一套GZCTF平台的完整组成,远不止“一个容器”
很多人以为部署GZCTF就是跑一个容器,其实官方推荐的部署方式至少包含五个角色:GZCTF主程序、PostgreSQL数据库、Redis缓存、Docker Registry私有镜像仓库,以及节点上的Docker引擎。它们各有分工:主程序负责Web界面、业务逻辑和容器编排;数据库存比赛、题目、用户、提交记录;Redis处理缓存和临时数据;Registry用来存动态靶机的镜像,选手启动容器时节点从这里拉镜像;Docker引擎则是所有动态实例的真正宿主。
这个架构里,Registry的存在往往是最容易被新人忽略的。你可能觉得镜像直接build在服务器上给平台用就行了,但GZCTF的动态靶机调度并不是“在你当前这台机器上docker run”,而是通过Docker API拉起容器,镜像需要有一个明确的来源地址。如果题目镜像只存在于本地,没有推到Registry,平台调度时会直接报“找不到镜像”。后面我在部署章节里会专门演示怎么搭Registry并正确推镜像。
2. 从零部署:选一台机器,把 GZCTF 完整跑起来
部署GZCTF本身不复杂,难的是把环境一次配对。这一节我从服务器选型开始,逐步带你搭一套可用的单机环境。整个过程如果你是照着做的,大概半小时内能把平台跑起来。
2.1 硬件和系统选型,别在第一步就埋雷
GZCTF主程序本身不占多少资源,内存512MB到1GB就能转,但动态靶机才是资源消耗的大头。你需要根据参赛人数和题目并行上限来倒推内存需求:每个动态容器限制多少内存,最多同时存活多少个实例,两者相乘就是内存下限。比如一个容器限制256MB,同时最多50个实例,那就至少需要12.8GB可用内存,再加上系统、平台、数据库、镜像仓库的开销,推荐16GB起步。
CPU方面,动态靶机一般不会跑重负载,但容器创建、销毁的调度操作对CPU突发有要求,4核心以上比较稳妥。磁盘则是另一个容易低估的点,一个基础镜像两三百MB,几十个题目镜像堆起来就是几十GB,再加上容器日志和数据库增长,建议预留至少100GB可用空间,最好用SSD。系统我用的是Debian系,Ubuntu Server 22.04或Debian 12都行,安装软件最省事。
2.2 安装 Docker 和 Compose,确认权限和工作模式
GZCTF需要操作Docker来创建和管理容器,这意味着主程序容器要挂载宿主机的Docker socket,也就是/var/run/docker.sock。这一步省不了,但也意味着主程序容器拥有宿主机的Docker控制权,相当于root级别的操作能力。所以我建议只在可信的内网环境里用这套方案,不要随手把平台暴露到公网。
安装Docker我一般直接走官方脚本或系统包管理器。装完之后确认两件事:docker version能正常输出,docker compose version能看到版本号。如果系统里装的是老式docker-compose,也能用,但下面的配置我按新版compose插件写法来。还有一点,确认当前用户能执行Docker命令,或者干脆用root操作,避免权限问题干扰判断。
2.3 核心配置文件:docker-compose.yml 完整示例
实际部署时,我习惯用一个专门的目录放所有配置文件,比如/opt/gzctf。下面是一套我在小规模比赛中验证过可用、也保留了解释说明的compose配置。
version: "3.9" services: gzctf: image: gzctf/gzctf:latest container_name: gzctf restart: always depends_on: - db - cache - registry ports: - "8080:8080" environment: - GZCTF_HTTP_PORT=8080 - ConnectionStrings__Postgres=Host=db;Port=5432;Database=gzctf;Username=gzctf;Password=change_me - ConnectionStrings__Redis=redis:6379,password=change_me - Storage__Type=Local - Storage__Location=/app/cloud - Registry__Type=Basic - Registry__User=gzctf - Registry__Password=change_me - Registry__Url=http://registry:5000 - Email__Confirmation=false - Account__NewSignup=true - Smtp__Host=mail.example.com - Smtp__Port=465 - Smtp__Username=noreply@example.com - Smtp__Password=change_me volumes: - ./appsettings.json:/app/appsettings.json:ro - ./data:/app/cloud - /var/run/docker.sock:/var/run/docker.sock networks: - internal db: image: postgres:15-alpine container_name: gzctf-db restart: always environment: POSTGRES_USER: gzctf POSTGRES_PASSWORD: change_me POSTGRES_DB: gzctf volumes: - pgdata:/var/lib/postgresql/data networks: - internal cache: image: redis:7-alpine container_name: gzctf-cache restart: always command: redis-server --requirepass change_me volumes: - redisdata:/data networks: - internal registry: image: registry:2 container_name: gzctf-registry restart: always environment: REGISTRY_AUTH: htpasswd REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd REGISTRY_HTTP_SECRET: some_random_secret REGISTRY_STORAGE_DELETE_ENABLED: "true" volumes: - ./auth:/auth - registrydata:/var/lib/registry networks: - internal volumes: pgdata: redisdata: registrydata: networks: internal:这套配置我用了多个环境变量来控制GZCTF的行为,这里有几个值得解释的点。ConnectionStrings__Postgres和ConnectionStrings__Redis是主程序连数据库和缓存的连接串,注意环境变量里的双下划线,在 .NET 配置系统里代表层级分隔,不能漏写。Storage__Type=Local表示附件和头像存在本地,Storage__Location指定了存储目录,如果要换对象存储,把类型改成对应方案,补上endpoint和密钥即可。Registry__Type=Basic开启镜像仓库的账号密码认证,防止任何人都能往里推镜像,这个在生产环境必须开。
2.4 镜像仓库的密码文件,必须先手工生成
上面的compose引用了/auth/htpasswd这个文件,但GZCTF的Registry容器不会自动创建它。你需要先创建auth目录,然后用Registry官方镜像里的htpasswd工具生成账号密码。命令是这样:
mkdir -p /opt/gzctf/auth cd /opt/gzctf docker run --rm --entrypoint htpasswd registry:2 -Bbn gzctf your_strong_password > auth/htpasswd-B表示用bcrypt算法加密密码,-b表示命令行直接给密码,-n表示输出到标准输出而不是写文件。生成后检查一下auth/htpasswd内容,应该是一行gzctf:...的密文。这个文件里的账号密码,要和compose里Registry__User、Registry__Password保持一致,主程序才能用这组凭证从Registry拉取镜像。
2.5 启动平台,第一次访问要做什么
配置都就位后,在/opt/gzctf目录下执行:
docker compose up -d等所有容器起来,用docker compose ps看一下状态,正常应该是四个服务都是running。然后浏览器访问http://服务器IP:8080。首次访问时,GZCTF会引导你创建管理员账号,这是平台里唯一的初始入口,务必记住这个账号,后面创建比赛、管理题目都靠它。
进入管理后台后,第一件事不是急着建比赛,而是先把基础配置过一遍。重点看三块:站点设置里的地址和端口是否正确,注册开关和邮箱验证是否按需调整;存储配置确认附件上传能正常写入;最后在管理面板里确认能看到Docker节点的在线状态。Docker节点连不上,后面动态靶机一道题都跑不起来,所以这一步一定要确认到位。
3. 动态靶机核心机制与题目配置逐项解析
平台跑起来只是开始,真正影响比赛质量的是题目如何配置。GZCTF的动态靶机把Docker容器调度抽象成了几个关键的配置项,搞清楚每一项的实际含义,你才能既不把资源耗尽,也不让选手卡在环境上。
3.1 题目类型怎么选:Static、Dynamic 与 Testable
GZCTF里的题目类型,我建议先用一句话记住:Static就是传统静态题,不弹容器;Dynamic是纯动态靶机题,选手每题一个独立容器;Testable则是动态靶机加静态附件的混合形态,选手既下载附件分析,又需要启动容器验证和获取flag。
实际比赛中,纯分析类的题目用Static即可,比如流量包、古典密码、隐写;需要真实运行环境的题,比如Web漏洞利用、PWN、逆向中的交互题,用Dynamic或Testable。Testable最典型的场景是:附件里给出了题目源码或二进制,选手本地逆向出利用方式,然后到动态靶机里打一下拿flag。这种形式既能减少选手环境依赖,又能保留题目分析的深度,是我个人最喜欢的题型组合。
3.2 动态靶机镜像的制作规范,决定后面的所有体验
平台本身不会帮你“出题”,你得先把题目造成Docker镜像。这里面最核心的一件事:镜像必须支持动态flag注入。GZCTF约定,平台启动容器时会把每个实例独有的flag通过环境变量GZCTF_FLAG传给容器。如果镜像里的服务不读取这个变量,就会所有人拿到同一个flag,动态靶机直接失去意义。
所以标准做法是镜像里放一个启动脚本,启动时把环境变量里的flag写入容器内固定位置,然后再把真正的服务拉起来。看一下我常用的模板:
FROM alpine:latest RUN apk add --no-cache python3 WORKDIR /app COPY start.sh /app/start.sh COPY server.py /app/server.py RUN chmod +x /app/start.sh EXPOSE 8000 ENTRYPOINT ["/app/start.sh"]#!/bin/sh if [ -n "$GZCTF_FLAG" ]; then echo "$GZCTF_FLAG" > /flag chmod 444 /flag fi python3 /app/server.py注意几点。写入flag后要把权限设为只读,防止选手在容器里改掉flag影响其他人。容器启动脚本必须放到ENTRYPOINT,而不是CMD,确保环境变量注入发生在脚本执行之前。还有,千万不要在Dockerfile里写死一个flag,那会让所有实例都一模一样。
3.3 平台侧题目参数全解析
镜像准备好之后,在管理后台创建动态题,会看到一长串参数。很多人第一次看到这些框就懵了,其实它们都有明确作用。我把关键参数整理成了表格,方便对照。
| 参数 | 作用 | 我的建议 |
|---|---|---|
| Image | 题目镜像在Registry中的完整名称 | 必须与推送到Registry的镜像名完全一致 |
| Port Mapping | 容器内哪个端口对外暴露 | 按容器实际监听端口填,多端口需要仔细配置 |
| CPU Count | 每个容器允许使用的CPU核数 | 一般0.2到1即可,别给满 |
| Memory Limit | 每个容器的内存上限 | 256MB到512MB起步,PWN题适当调高 |
| Instance Limit | 单道题最多同时存在的容器数 | 根据资源预算设定,防止被单队刷爆 |
| Auto Destroy | 容器无活动多久后自动销毁 | 推荐30到60分钟,避免资源无人释放 |
| Is Trial | 是否允许选手重复申请新实例 | 建议开启,但配好总实例上限 |
这里尤其要解释Instance Limit和Auto Destroy的关系。Instance Limit是这道题在全局范围内允许同时存在的实例上限,注意不是每队一个,是所有人加起来只能有这么多。如果队伍多、题目热门,很容易就被占满,后启动的选手会一直排队。Auto Destroy是无人访问多久后回收实例,可以在一定程度上缓解资源压力。实操上,我一般先把单题实例上限设为队伍数的三分之二,然后在比赛前用几个模拟账号实测一下,再按选手反馈微调。
3.4 动态分值曲线怎么算,别让分变成一道算术题
动态靶机赛制还有一个配套机制:解题人数越多,这道题剩下的分越低,越早解出分越高。这在一定程度上平衡了“巨人题”和“签到题”的分值差距。GZCTF里这道题的基础分、衰减系数、最低分和每道题的权重有关,典型曲线会呈现“指数衰减”的趋势。
不同版本对动态分值的参数命名不完全一样,但原理是通的。假如某题初始分500分,最低分100分,前5个解出来的人分别拿500、400、320、256、205这样的递减分值,第20个解出时已经贴到最低分附近。一血还会有额外的Blood Bonus加成,这个是平台默认支持的。设置分值时,我建议不要把衰减系数设得太狠,尤其题目总数不多的小比赛,分差过大会让后半程的解题彻底失去动力。
3.5 网络隔离与反作弊,平台是怎么做到相对公平的
动态靶机的公平性并不只靠flag不同来保证,网络隔离同样关键。GZCTF在调度动态容器时,会为每个实例分配独立的内部网络标识,队伍之间默认不能互相访问对方的容器。这样就算有人拿到了同队队友的实例地址,也无法从别的队伍容器里读取数据。配合容器启动时生成的随机token,选手访问自己靶机时用的是带token的URL,其他人无法猜测和接管。
当然,这并不能完全防止选手互相交换答案,比如把自己容器里的flag直接发给别人。平台层面的反作弊手段主要是IP绑定、提交频率限制、封禁账号等,再往下就要靠赛制设计和人工复核了。对于小规模比赛,动态靶机加独立网络已经足够挡住大多数简单作弊方式,不用过度设计。
4. 完整实战:从 Dockerfile 到第一道动态题
前面讲了一堆原理,这一节我带你把一道简单的Web动态靶机题完整走一遍。从写Dockerfile开始,到推到Registry,再到选手端启动容器、访问、拿flag、提交,大家照着这个流程走,后面学会做其他类型的题就只是换个镜像而已。
4.1 一道带命令注入漏洞的迷你靶机
为了演示,我写一个极简的Python HTTP服务,它接收一个cmd参数,直接拼到系统命令里执行。这个设计在真实比赛里不算出彩,但用来展示动态靶机的完整链路非常合适。
import os from http.server import BaseHTTPRequestHandler, HTTPServer FLAG = "" flag_path = "/flag" if os.path.exists(flag_path): with open(flag_path) as f: FLAG = f.read().strip() class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path.startswith("/cmd"): cmd = self.path.split("=", 1)[1] if "=" in self.path else "id" result = os.popen(cmd).read() self.send_response(200) self.end_headers() self.wfile.write(result.encode()) else: self.send_response(200) self.end_headers() self.wfile.write(b"hello ctfer") def log_message(self, fmt, *args): pass HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()这个服务读取/flag文件,但并没有直接暴露它,选手需要利用命令注入绕过限制才能拿到flag。Dockerfile可以用前面给的那个Alpine模板,把Python代码拷贝进去,暴露8000端口。这里刻意选了Alpine加Python的标准库,不用Flask,镜像体积能控制在100MB上下,动态靶机拉起会快很多。
4.2 构建镜像并推到私有 Registry
镜像构建好之后,接下来是把镜像推到Registry里。假设平台compose里Registry的地址是registry:5000,而镜像名字必须让平台能用同样的逻辑访问到。实际操作中,因为Docker客户端和Registry容器都在同一个宿主机上,推镜像时可以用宿主机上的映射端口或直接访问容器网络。
我建议在构建时就把镜像tag成完整Registry地址的格式:
docker build -t localhost:5000/web/cmd-exec:1.0 . docker push localhost:5000/web/cmd-exec:1.0推成功后,用docker pull localhost:5000/web/cmd-exec:1.0验证一下能否正常拉取。这里要用到2.4节里生成的Registry账号密码,Docker会提示登录。如果登录失败,先检查auth/htpasswd文件内容和compose里的Registry__Password是否一致,这是自建Registry最常见的坑。
4.3 在管理后台创建题目并走通全流程
镜像推好以后,回到GZCTF管理后台,创建一个题目。题目类型选Dynamic,镜像名填localhost:5000/web/cmd-exec:1.0。但这里有个细节:平台主程序解析镜像名时可能不会自动补全Registry地址,你要么在平台里配置Registry地址后,镜像名直接用仓库内路径加标签的完整格式,要么提前在测试环境确认平台能正确解析。我的习惯是,在配置里把Registry__Url设置为可访问的Registry地址,镜像名就填完整的registry地址/项目名/镜像名:标签,避免歧义。
端口配置填8000,内存限制给256MB,实例上限按队伍数预估。保存后,用选手身份进入比赛,点击“启动实例”,等待几秒钟,平台会返回一个访问URL。浏览器打开这个URL,访问/cmd?cmd=cat /flag,就能看到自己这个实例独有的flag。复制提交,分数立刻到账。整个链路走通,你的动态靶机环境就算真正能用了。
5. 运维实录:比赛中最容易踩的坑与排查方法
就算部署顺利,比赛进行中也一定会冒出各种状况。下面这些坑我基本都踩过,有的是比赛现场临时救火才解决掉的。我把常见问题和排查思路整理成一张速查表,再分别展开说。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 容器一直Pending或创建失败 | 镜像不存在、资源不足、Docker socket权限问题 | 看平台日志和docker ps -a确认容器状态 |
| 选手访问不了靶机URL | 端口配置错误、反代未传递WebSocket/头部 | 对比容器内端口与平台配置 |
| flag不一致或提交失败 | 镜像没读GZCTF_FLAG、直接内置了固定flag | 检查启动脚本和Dockerfile |
| 高并发下平台响应慢 | 内存不足、容器数量超预算 | 查看内存占用,临时调低实例上限 |
| 比赛后迁移数据丢失 | 只备份了数据库没备份Registry和对象存储 | 必须同时备份三类数据 |
5.1 动态容器一直创建失败,问题到底出在哪
只要动态靶机起不来,比赛就基本没法进行,所以这是优先级最高的问题。第一步先看GZCTF主程序日志,一般会给出失败原因,比如“image not found”或“no space left on device”。如果是镜像找不到,回Registry页面确认镜像是否真的推成功了,镜像名是否和题目配置一字不差。如果是磁盘满了,清理无用的镜像和日志,扩容磁盘。还有一个隐蔽问题:如果你给GZCTF主程序挂载的Docker socket路径不对,它根本连不上Docker,所有容器都会卡在待创建状态。
5.2 选手连不上靶机,端口和反代这里最容易绕晕
直连模式下,GZCTF会给每个实例暴露宿主机的随机端口,选手访问http://服务器IP:随机端口/token。如果你在服务器前面还有一层Nginx或Caddy反向代理,事情就变得复杂起来。你得保证动态容器的那一段端口范围也被代理转发,而且需要把原始Host和协议头正确传给后端。很多人只配了平台主程序的站点代理,忘了靶机端口范围,结果比赛一开始,页面能打开、容器能启动,就是访问URL死活连不通。
配置反代时,还需要留意HTTP和HTTPS协议不一致的问题。平台页面是HTTPS,动态靶机访问URL如果还是HTTP,浏览器会直接拦截混合内容,选手一脸懵。这类问题排查起来不难,但要在赛前就让几个模拟账号实际走一遍全流程,别等到比赛已经开始才暴露。
5.3 flag不一致或者拿不到,镜像规范一旦出错全盘皆输
flag相关的问题,十有八九出在镜像启动脚本上。常见三种情况:脚本压根没读取GZCTF_FLAG环境变量,所有实例共用一个写死在镜像里的flag;脚本把flag写进/flag了,但应用读的不是这个位置;以及脚本执行顺序不对,应用启动之后flag才生成。排查时手动run一个容器,用-e GZCTF_FLAG=test_flag方式跑起来,进去看看flag文件是否存在、内容是否等于传入的环境变量,基本就能定位问题。我强烈建议在出题阶段就做这个手动验证,别等题目上线了再试。
5.4 并发一高平台就卡,资源预算必须提前算
动态靶机的资源消耗不是线性的。平时没人启动容器,平台闲得很;比赛开赛前30分钟,所有人同时点启动,瞬间可能创建上百个容器,这时候CPU、内存、磁盘IO一起飙升。我在一次实验中没控制好,某道题设了很大内存限制,结果50个实例直接把16G内存吃满,平台和数据库一起卡死,比赛被迫中断了十来分钟。
经验教训是:赛前一定要做并发压测,至少用模拟账号同时启动十几个容器观察资源占用。动态题的实例上限宁可保守一点,也不要一次给太宽,容器满了选手可以排队等待,平台崩了那是所有人一起等着。另外可以给Docker配置日志文件上限,防止某个容器的stdout把磁盘打满。
5.5 比赛数据备份与快速迁移,守住平台的三样家底
比赛结束以后,数据备份和迁移同样重要。GZCTF的数据分布在三个地方:PostgreSQL数据库里的用户、比赛、题目、提交记录;对象存储或本地目录里的附件和头像;Registry里的所有题目镜像。只备份其中一个都不够,任何一块缺失,恢复出来的平台都是残废的。
我的备份习惯是,停掉容器后直接备份整个项目目录的data、auth,再用pg_dump导出数据库,Registry的数据用volume备份或直接打包/var/lib/registry。恢复时先按同样的compose启动空平台,再把备份导回去,重启容器即可。这个过程最好选在比赛后马上演练一次,真到要迁移服务器的时候才不会手忙脚乱。
最后再分享一点个人习惯:无论规模多大的比赛,正式开赛前24小时我一定会做一次全链路模拟——用一个普通选手账号走一遍注册、开容器、做题、交flag的完整流程,顺便检查服务器负载。动态靶机的坑大多不是出现在配置文档里,而是出现在并发瞬间和版本差异里。先把这套流程跑顺了,比赛时你才有底气坐在那里喝茶,而不是蹲在服务器前看日志。