第一次给社团搭CTF动态靶场的时候,我天真地以为把题目做成Docker镜像、放到公网服务器上就完事了。结果比赛一开始,几十个人同时访问同一个容器,flag互相都能看得到,整个比赛变成“抄答案大赛”,场面一度非常尴尬。后来我才明白,CTF比赛和普通网站托管完全是两码事——动态靶场的核心不是“把题跑起来”,而是“每个人拿到自己那一份独立的flag实例”。
这篇内容我想把整套思路完整讲透,从零开始手把手带你搭出一个可以支撑校级、社团级、甚至区域性线上赛的动态靶场。包含架构选型、组件部署、题目镜像封装、动态flag下发、资源限额、日志清理、常见故障排查,全部基于我实际踩过的坑和经验,照着做就能跑通。适合想搭靶场的社团运维、准备办CTF赛事的技术负责人,以及想搞清楚“动态靶场到底怎么运作”的安全爱好者。
1. 方案设计:动态靶场到底在“动态”什么
1.1 静态靶场和动态靶场最本质的区别
先说一个最直观的场景。如果你把题目直接部署在一台服务器上,比如一个PHP站点,所有人访问的都是同一个实例。三人一组做题,第一个人找到flag之后发到群里,剩下两个人复制粘贴提交就完事,排名完全失真。这也是为什么稍微正式一点的CTF比赛都要求“动态靶场”。
动态靶场背后的机制,简单说就是:每支队伍/每个选手打开一道题时,系统自动为你单独启动一个隔离容器,并灌入一个专属的随机flag。这个flag只在这一道题的这个实例里有效,提交到平台后由平台进行校验。别人就算拿到你的flag也没用,因为同一个题目不同人的flag是不同的。
用生活化的类比来说:静态靶场像公共饮水机,所有人共用一个水龙头;动态靶场像每个房间独立的小冰箱,前台登记后给你房卡,你打开自己的冰箱,里面的饮料只属于你。
1.2 架构选型:为什么是CTFd + Docker + frp
CTF比赛平台的方案其实不少,我见过有人用PHP自己写提交页面,有人用现成的类Hackergame全家桶,也有人直接拿WordPress改。但如果你要办的是正经的个人赛或者队伍赛,最省力、插件生态最全的还是CTFd。它天生支持动态容器题目的计分和flag校验,配合Docker引擎做资源隔离,再叠加frp做端口映射,就能组成一套完整可落地的动态靶场。
其中每个组件的职责是这样的:
- CTFd:前端比赛平台,负责展示题目、注册登录、提交flag、动态计分、排行榜。
- Docker:题目实例的载体,负责隔离环境、分配CPU/内存/网络。
- frp:内网穿透/反向代理工具,负责把Docker容器内部的端口对外映射成可访问的地址。
- ctfd-whale插件:CTFd和Docker之间的“调度员”,当选手点击开始容器时,由它调用Docker API创建新容器、注入动态flag、设定过期时间。
很多人会问,既然CTFd和Docker都在同一台机器上,为什么还需要frp?因为Docker容器默认在虚拟网桥里,外部选手无法直接访问容器的IP和端口。frp把容器端口“反向隧道”到宿主机的一个公网端口上,选手访问的是类似 http://101.xxx.xxx.xxx:28080 或者 nc 101.xxx.xxx.xxx 28333 这样的地址,才能连进题目的真实环境。
如果你条件有限,用一台4核8G的云服务器也能撑起小型赛事。如果平台要承载几十上百人的并发,建议CTFd与题目容器分成两台机器,题目容器所在的机器只专注跑Docker,不跑别的业务。
1.3 动态flag的完整链路
搞清楚整个链路,后面排错才能有的放矢。一次完整的动态题目访问流程是这样的:
- 选手在CTFd平台点击“启动容器”按钮。
- CTFd调用whale插件的API,whale插件读取题目配置中的镜像名、端口规则、flag注入方式。
- whale向Docker daemon发送创建容器的请求,传入一个随机生成的flag字符串,通常通过环境变量或启动命令注入。
- 容器启动后,whale通过frp客户端为容器建立隧道,分配一个宿主机端口。
- 平台返回给选手一组访问地址(HTTP链接或TCP地址)。
- 选手访问该地址,在题目环境里找flag,提交到CTFd。
- CTFd校验flag是否匹配该选手实例中的flag值,匹配则得分。
这里有个非常关键的细节:动态flag并不是密文,而是由平台生成的一串随机明文,通常格式是 flag{...}。它在下发到容器的同时,也会写进CTFd的数据库中。选手提交后,CTFd把提交值和数据库里的值比对,一致就得分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础部署:CTFd、Docker与frp的安装配置
2.1 服务器环境准备
我建议直接用Ubuntu 22.04 LTS,内存至少4G,磁盘至少50G,带宽按参赛人数估算,100人同时在线的话5M带宽会比较吃紧,能上10M更好。本文所有命令均在Ubuntu 22.04下验证过。
先更新系统,装基础工具:
bash复制apt update && apt upgrade -y
apt install -y git curl vim net-tools telnet
然后安装Docker和Compose插件:
bash复制curl -fsSL https://get.docker.com | sh
systemctl enable docker && systemctl start docker
装完验证一下:
bash复制docker version
docker compose version
有输出就说明环境OK了。这里补充一个我踩过的坑:国内云服务器有些预装了旧版docker-compose(Python版),跟compose文件语法不兼容。如果你执行 docker compose up 报错找不到命令,先检查是不是用了 docker-compose 这个旧命令,两个不一样。
2.2 用docker-compose部署CTFd
CTFd官方仓库自带docker-compose配置,克隆下来直接用:
bash复制git clone https://github.com/CTFd/CTFd.git
cd CTFd
默认的docker-compose.yml里已经包含了CTFd、MySQL、Redis三个服务,直接启动:
bash复制docker compose up -d
首次启动需要拉取镜像,耐心等几分钟。起来之后浏览器访问 http://服务器IP:8000,就能看到CTFd的初始化安装页面,设置管理员账号、比赛名称等。
这里我要多说一句初始化的细节。CTFd的默认端口是8000,如果你有域名并且想用80端口,可以修改docker-compose.yml里的ports映射,改成 "80:8000"。另外,默认的数据库是MySQL,数据都持久化在容器volume里,备份的时候一定要备份volume,否则比赛数据恢复不了。CTFd支持从备份文件导入,路径在管理后台的“Backups”菜单。
2.3 frp的部署与配置
frp分服务端frps和客户端frpc,我们的场景中frps通常跟CTFd在同一台机器,frpc运行在每一台题目容器宿主机上。如果你只有一台机器,frps和frpc都装在同一台就行。
先去GitHub Releases页下载对应版本的frp,我这里用0.48.0为例:
bash复制wget https://github.com/fatedier/frp/releases/download/v0.48.0/frp_0.48.0_linux_amd64.tar.gz
tar -zxvf frp_0.48.0_linux_amd64.tar.gz
mv frp_0.48.0_linux_amd64 /opt/frp
frps服务端配置 /opt/frp/frps.toml:
toml复制bindPort = 7000
auth.method = "token"
auth.token = "你的自定义token"
# 用于HTTP题目的反向代理端口
vhostHTTPPort = 8001
# 用于TCP题目的映射端口范围
启动frps:
bash复制/opt/frp/frps -c /opt/frp/frps.toml &
frpc客户端配置 /opt/frp/frpc.toml,这个文件实际上不用我们完全手动写,因为ctfd-whale插件会自动生成并推给frpc。但我们至少要在宿主机上装好frpc并提供给whale插件调用。
注意:frp的token要设置一个足够复杂的值,因为frps的7000端口默认暴露在公网,弱token容易被扫描工具爆破,导致别人随意映射端口。
2.4 安装ctfd-whale插件
ctfd-whale是目前配合CTFd做动态容器最主流的插件之一。把插件克隆到CTFd的plugins目录:
bash复制cd CTFd/CTFd/plugins
git clone https://github.com/sabersalv/ctfd-whale.git whale
然后回到CTFd根目录,重启服务:
bash复制docker compose restart
重启后,用管理员账号登录CTFd,在管理面板的“Plugins”里能看到Whale的配置页。需要填的关键配置项有:
Docker API URL:默认unix:///var/run/docker.sock,如果CTFd容器内无法访问宿主机docker socket,可以用tcp://宿主机IP:2375,但必须给2375端口加TLS或者防火墙限制,否则等于把Docker控制权暴露给公网。FRP API IP:frps服务端的IP地址。FRP API Port:frps的api端口,默认7000。FRP Token:和frps.toml里的一致。FRP Http Port:vhostHTTPPort,上面设的8001。FRP Direct IP Address:即题目容器宿主机的公网IP,用于拼接访问地址。FRP Direct Port Min/Max:分配TCP端口时的范围,比如20000-30000。Max Container Count:全局允许同时运行的容器总数,防止资源耗尽。Container Timeout:容器无活动多久后自动销毁,我用的是600秒。Docker Subnet:容器网络的子网,默认172.17.0.0/16即可。
保存配置后,whale插件会在后台自动为每个用户创建独立的docker网络和资源配额。到这里,整个平台的骨架算是搭起来了。
3. 题目镜像封装:把一道Web题变成动态容器
3.1 为什么题目必须做成镜像
CTFd平台本身只负责展示题目和计分,真正跑题目的环境是Docker容器。所以每道题,无论是Web、Pwn还是Misc,都需要先打成Docker镜像。选手点“启动容器”的时候,whale插件拿这个镜像去创建实例。
打镜像的过程,本质上就是“把题目环境源码、依赖、启动脚本、flag占位符全部装进一个Linux环境”。这样做的好处有几个:环境隔离、秒级启动、资源可控、赛后一键清理。坏处也很明显:镜像太大、基础依赖兼容性差、启动慢。所以写Dockerfile的时候,尽量用精简的基础镜像。
安全提示:不要把敏感信息(比如数据库密码、调试信息、后端接口)写死在镜像里。动态flag是通过环境变量注入的,不是写死在Dockerfile里的。
3.2 一个Web题的完整镜像示例
我们以一道简单的PHP命令执行题为例,题目逻辑是:页面里有一个输入框,用户传入参数后,后端用 passthru() 函数执行系统命令,但用正则做了部分过滤。选手需要想办法绕过过滤,执行 cat /flag 拿到flag。
项目结构如下:
text复制web-passthru/
├── Dockerfile
├── flag.sh
└── src/
├── index.php
└── start.sh
src/index.php 简化的核心逻辑:
php复制<?php
error_reporting(0);
$cmd = $_GET['cmd'] ?? '';
if (preg_match('/flag|cat|nl|more|less/i', $cmd)) {
die('no no no');
}
if ($cmd !== '') {
system($cmd . ' 2>&1');
}
?>
<form method="get">
<input type="text" name="cmd" />
<button type="submit">执行</button>
</form>
这里的过滤是很常见的“弱过滤”,选手可以用 tac /fl*、strings /f*、php -r 'system(...)' 等方式绕过,属于入门级别的命令执行题目。
src/start.sh 负责启动Apache并动态写入flag:
bash复制#!/bin/bash
echo "flag{$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)}" > /flag
chmod 444 /flag
apache2-foreground
Dockerfile:
dockerfile复制FROM php:7.4-apache
COPY src/ /var/www/html/
COPY flag.sh /flag.sh
RUN chmod +x /flag.sh && \
chown -R www-data:www-data /var/www/html
EXPOSE 80
ENTRYPOINT ["/flag.sh"]
这里有个特别注意点:不能把flag作为环境变量直接写进镜像,否则所有实例的flag都一样,或者任何人都能从镜像层里翻出来。正确的做法是让容器在启动时从 /dev/urandom 生成随机字符串写入 /flag 文件,然后Apache启动。
镜像构建:
bash复制docker build -t ctf-web-passthru:latest .
然后在CTFd后台创建题目,题目类型选择“Dynamic Docker Challenge”,镜像名填 ctf-web-passthru:latest,端口填容器内的80端口,flag填写方式选“动态flag”。这样选手每次点启动,都会拿到一个随机生成的独立flag。
3.3 Pwn题和Node沙箱题的镜像姿势
Pwn题跟Web题不太一样,因为需要提供TCP连接,选手用nc连接,而不是浏览器访问HTTP页面。Pwn题通常用xinetd去包装二进制程序,容器内暴露一个TCP端口。Dockerfile示例:
dockerfile复制FROM ubuntu:22.04
RUN apt-get update && apt-get install -y xinetd libc6-dev
COPY pwn /pwn
COPY pwn.xinetd /etc/xinetd.d/pwn
RUN chmod 755 /pwn && chmod 644 /etc/xinetd.d/pwn
ENTRYPOINT ["/usr/sbin/xinetd", "-dontfork"]
对应的 pwn.xinetd 配置文件里会指定服务端口,比如31337,server = /pwn,flags = REUSE。容器启动后,whale插件通过frp把这个容器的31337端口映射成宿主机公网端口,选手用 nc 101.xxx.xxx.xxx 28333 连接。
Node.js沙箱题也是这些年热度很高的类型。有题目会把选手的JS代码扔进一个VM模块里执行,模拟“沙箱逃逸”场景。这种题的Dockerfile其实就是Node官方镜像加一段启动脚本,暴露一个HTTP接口接收代码,然后在 vm.createContext() 里执行。如果选手能逃逸出沙箱拿到环境变量里的flag,就算解题。
3.4 新兴场景:AI安全类动态题目怎么落地
热搜词里有“含AI安全题目”的个人赛,这个趋势这两年确实明显。常见的形式是:AI模型(比如一个聊天机器人)内置了一个“秘密token”作为flag,选手通过提示注入、角色扮演等方式诱导模型泄露这个token。这种题目同样可以做动态化:容器内启动一个封装好的模型服务,将随机生成的秘密token注入环境变量,选手提交该token即得分。
做法上,跟普通Web题差别不大,只是把传统的php/apache换成python/ollama或者OpenAI兼容服务。Docker镜像会大很多,需要有GPU的机器才跑得动大模型。如果只是想办一场轻量的AI安全赛,建议用现成的小模型,或者直接把模型API抽象成一道“提示注入”题,后台用一个固定密钥做校验,这样镜像体积可以控制在几百MB以内。
4. 动态靶场的运营维护与常见问题排查
4.1 资源限额:防止一题拖垮全场
这是办赛过程中最容易出问题的一环。Docker容器默认是不限制CPU和内存的,如果一个选手故意写一个死循环脚本(或者题目本身有漏洞被恶意利用),容器瞬间占满所有CPU,整个平台所有题目都会卡死。
ctfd-whale插件在创建容器时可以通过环境变量或Docker资源限制来控制。实操中我会在容器启动命令里加入资源限制参数。具体做法是修改whale插件的源码配置或者在CTFd的docker-compose.yml里设置默认的容器限额:
yaml复制limits:
cpus: 0.5
memory: 256M
对于Web题,256M内存足够跑PHP/Apache;对于Pwn题,如果题目要开glibc版本,有时需要512M;Node沙箱题建议512M以上。CPU给0.5核,既保证运行速度,又不会因为个别容器异常拖垮宿主机。
经验值:4核8G的服务器,动态题容器建议同时在线不超过20个;如果比赛人数多,一定要把选手分为多个批次,或者给每个容器加更严格的配额。
4.2 日志与镜像管理:存储爆炸的元凶
办赛过程中最容易被忽视的就是日志。容器里的Apache/Nginx访问日志默认输出到stdout,Docker会把这些日志收在宿主机的 /var/lib/docker/containers/<id>/*.log 里。如果没有限制,一个活跃的Web容器一天能产生几个GB的日志。
解决方法是给Docker daemon配日志轮转。在 /etc/docker/daemon.json 里加入:
json复制{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
然后重启Docker:
bash复制systemctl restart docker
这会让每个容器的日志最多保留3个10MB的文件,超大日志自动切割。这个配置对办赛稳定性的提升非常明显。
镜像管理方面,开赛前要挨个把题目镜像 build 好,比赛过程中不要现场改镜像。Docker镜像如果反复构建会产生大量的悬空镜像(dangling images),占据几GB存储,赛后及时清理:
bash复制docker image prune -f
4.3 常见问题排查速查表
这里整理一份我实际排障中遇到频率最高的场景,直接对照操作:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 点击启动容器后一直转圈 | CTFd容器访问不了Docker socket,或frpc未运行 | 检查CTFd容器内能否访问 /var/run/docker.sock,确认frpc进程存活,查看whale插件日志 |
| 容器启动了,但访问不了 | frp端口映射失败,或直接端口范围被防火墙拦截 | 检查frps日志、frpc状态,测试 telnet 公网IP 端口 是否通 |
| 提交flag提示错误 | flag格式不匹配,或数据库里的flag与容器内的不一致 | 确认题目创建时点选了动态flag,检查容器内 /flag 文件内容与数据库是否一致 |
| 所有选手看到的flag都一样 | 镜像里写死了flag,没有走动态注入 | 重新封装镜像,flag必须通过启动脚本动态生成 |
| 平台整体卡顿,容器创建极慢 | 资源不足,或容器数量超过限制 | 用 docker stats 查看资源占用,提高配额或删除空闲容器 |
| 容器运行一段时间后自动消失 | 超时策略生效,这是正常现象 | 可在比赛设置里延长container timeout,或者让选手做题中途不要长时间挂机 |
| TCP题连不上 | Pwn题的frp映射走了TCP模式但未正确配置 | 确认whale插件中FRP Direct Port范围与frps配置一致 |
4.4 赛前压力测试与应急预案
比赛前一天一定要做一次全流程压测。我的建议流程是:注册5个测试账号,每个账号启动所有动态靶场题目的容器,模拟同时抢答的操作。观察项目容器创建时间是否超过5秒、frp端口是否能快速分配、CTFd后台是否出现异常日志。
还有一个容易被忽略的细节:比赛前一定要备份CTFd数据库。动态题目的flag是实时写入数据库的,一旦数据库崩溃,所有进行中的提交记录会丢失,复盘都无从谈起。实操中我用一个简单的crontab每小时自动备份MySQL:
bash复制0 * * * * docker exec ctfd-mysql mysqldump -u root -p你的密码 ctfd > /backup/ctfd_$(date +\%Y\%m\%d_\%H).sql
如果你用的是CTFd自带的Backups功能,改在后台手动导出也可以。但自动备份更省心,避免打完比赛才发现数据没存下来。
5. 题目类型与工具链的小结
动态靶场搭好之后,往里面塞什么题,直接决定了比赛的口碑。从参赛者的反馈来看,Web题最容易上手的包括SQL注入绕过登录、文件读取、命令执行、Node沙箱逃逸、session固定攻击等,这些我都在上面的镜像示例里覆盖到了。Misc题里隐写类非常多,有个很常见的坑是“多密码嵌套自动解密”,这类题往往不需要动态容器,直接作为静态题挂在CTFd上就行,但题目描述里要写清楚下载附件解压、解密工具用什么。
工具链方面,Web方向选手几乎人手一份目录扫描工具,像“御剑”这类老牌工具在CTF圈还有不少人在用。对出题人来说,了解这些工具的存在也有好处——出题人在设计路由和目录结构时,要刻意考验选手对路径发现、参数爆破的掌握,而不是把所有入口都摆在明面上。
我见过不少出题人把题目越出越偏,变成长难句阅读理解和脑洞题,最后选手体验很差。个人建议,题目难度曲线要像阶梯:前两题送分,中间题考验基本功,最后一道压轴题允许选手用非常规思路。动态靶场的技术保障是一回事,出题人的内容设计才是比赛好看不好看的关键。CTF的最终目的是让参与者学到东西,而不是被出题人刁难到怀疑人生。
我个人在实际运营中最大的体会是,动态靶场作为一套基础设施,一旦跑通,后续每一场比赛的边际成本会直线下降。第一次搭可能要折腾一整个周末,但当你把标准镜像模板、常用题目类型、异常处理预案都沉淀下来之后,下一场只需要把题目源码替换掉就能开赛。最后再分享一个小建议:赛前准备一个“一键禁用所有动态容器”的后台脚本,万一比赛过程中出现严重安全事故或题目故障,可以第一时间冻结所有实例,保留现场以便复盘,而不是慌乱地一台台去杀容器。CTF比赛的魅力在于公平对抗,而运维的职责就是守住这条底线。
