
简介这套基于Kubernetes容器编排的CTFd动态题目靶场插件源码与设计报告面向计算机相关专业的高校学生、安全竞赛爱好者以及需要搭建动态靶场平台的开发者重点解决CTF比赛中题目容器批量创建、动态下发与自动回收的常见问题。项目完整覆盖插件核心逻辑、K8s管理API、前端交互页面与数据库初始化等模块通过Python脚本驱动容器生命周期内置Frp反向代理与K8s API对接机制HTML与JavaScript协同实现题目容器状态可视化以及创建、删除、动态上线等操作界面另附独立设计报告与项目说明文档帮助理解架构。资源包内含34个文件主要以13个Python源码实现后端调度与API服务、13个HTML页面承载管理界面、5个JS脚本负责前端交互逻辑另有docx格式的设计报告、markdown项目说明以及依赖清单压缩包整体仅195KB目录按功能拆分便于快速定位与二次开发。已有48人浏览学习适合作为课程设计、毕业设计或项目初期立项演示也可在此基础上扩展多容器编排、动态计分联动等更复杂的赛题场景。1. CTFd 动态题目为什么绕不开容器编排和 Kubernetes做过几届 CTF 运维的人都有同一个感受静态 flag 的题目放到中大型比赛里就是一场大型作弊现场。答案一旦被某支队伍提交dump 文件的截图就会在群里流转。把题目容器化、每个用户拉起一个独立实例这个思路很早就有人尝试但真正卡住所有尝试的不是打包镜像而是“谁来管这一堆临时容器”。基于 Kubernetes 容器编排的 CTFd 动态题目靶场插件解决的就是这个管理问题它让 CTFd 在选手点击“开始挑战”时动态创建一个带独立 flag 的容器实例比赛结束或超时后自动销毁回收。这套方案适合两类人一类是想给校内 CTF 或招新赛做反作弊靶场的在校学生另一类是把安全竞赛平台商业化、需要支撑上百人同时在线的大型比赛负责人。读完这篇文章你能搞懂插件的工作方式、Kubernetes 在其中扮演的角色还能照着核心步骤在自己的环境里跑通一个最小实现。2. 插件与 Kubernetes 的分工一套动态靶场是怎么跑起来的2.1 CTFd 的插件机制在哪里挂接管代码CTFd 本身是一个 Flask 应用它的插件系统没有用 Flask Blueprint 之外的特殊框架。插件目录放在CTFd/plugins/下每个插件就是一个 Python 包入口文件是__init__.py。CTFd 会在应用启动时扫描这个目录加载每个插件的load()函数让插件往app上注册路由、往数据库加表、往事件系统挂处理函数。动态靶场插件最需要接管的事件是“用户创建题目实例”和“用户销毁题目实例”。CTFd 的事件钩子在CTFd.utils.helpers里以create_graphql_event之类的形式出现但实际生产环境里大家用的都是更直接的一招给challenge增加一个自定义类型重写它的attempt和before_challenge_start回调。常见做法是让插件在__init__.py里调用CTFd.plugins.CTFdPluginBlueprint注册组件再用app.load_config或数据库记录自定义字段。def load(app): app.db.create_all() register_plugin_blueprint(app) from .core import K8sDynamicChallenge from CTFd.plugins import override_template from CTFd.plugins.challenges import CHALLENGE_CLASSES CHALLENGE_CLASSES[k8s_dynamic] K8sDynamicChallenge override_template(challenge.html, k8s_challenge.html)这段代码把k8s_dynamic这个题目类型注册进了 CTFd 的挑战类型体系里。前端编辑题目时会在类型下拉框里看到它。override_template覆盖的是该题型的挑战页模板模板里会加上“实例状态”和“启动/停止”按钮这部分数据通过自定义路由单独请求。2.2 Kubernetes 在动态靶场里管的是什么Kubernetes 在这里解决的是两个问题调度和生命周期。调度是指选手点击启动后插件把Deployment对象提交给 API Server由 kube-scheduler 决定把 Pod 放到集群里哪个节点上而不是插件自己逐个去 SSH 到宿主机跑docker run。生命周期则是 Kubernetes 的控制器模式在干活Pod 挂了它拉起新的节点故障它会重新调度比赛目录清理时删除 Deployment 就能把管理的 ReplicaSet 和 Pod 一并带走。动态题目更依赖的其实是 Kubernetes 的声明式 API 带来的幂等性。插件向 K8s API 提交一份期望状态镜像是什么、资源限制多少、环境变量里 flag 是什么。剩下的事情由控制循环收敛到期望状态。如果插件自己管理容器异常情况得自己处理走 Kubernetes 之后插件只负责“提交申请”和“核对状态”失败重试逻辑由控制器保证。2.3 一个完整的动态题目请求是怎么走通的以常见赛制为例用户点了“启动环境”后在浏览器里发生的事是这样的前端 AJAX 请求 CTFd 插件注册的/plugins/k8s-dynamic-container/start路由插件收到请求后生成一个唯一实例标识通常把user_id challenge_id拼起来调用 Kubernetes Python 客户端创建 Deployment 和 Service创建成功后返回访问地址前端把页面弹出的 iframe 或链接指向目标地址。用户在靶机里找到 flag提交到 CTFd 的 flag 校验接口通过则得分此时插件再根据比赛配置决定是否销毁实例。这套链路里有几个必须处理的细节用户重复点击启动时不能报重复创建的错误需要先查一次是否已经存在同名 Deployment存在就幂等返回创建实例不能是同步阻塞的镜像拉取到 Pod Ready 可能要几十秒接口得支持轮询状态。3. 写一个能跑通最小闭环的 CTFd 容器插件目录、配置与创建实例3.1 插件目录结构与 plugin.json 注册先搭目录。这是最少文件的骨架再往上加的都是围绕这四个文件的扩展ctfd-k8s-plugin/ ├── __init__.py # CTFd 插件入口 ├── config.py # 读取 K8s 配置和靶场参数 ├── k8s_client.py # 封装 K8s API 调用 ├── api.py # 注册给前端调用的路由 ├── assets/ │ └── k8s_challenge.html ├── plugin.json └── challenges.py # 自定义题型类plugin.json是 CTFd 识别插件身份的清单文件可以直接照抄下面这个最简版{ name: K8s Dynamic Challenge, route: /plugins/k8s-dynamic-container, version: 1.0.0, description: CTFd plugin for Kubernetes-based dynamic challenge instances, author: yourname, license: MIT }route字段影响插件内置入口页面但实际功能路由是插件自己用 Blueprint 注册的不受这个字段限制。name会出现在 CTFd 管理页的插件列表里最好起一个能一眼看出用途的名字免得比赛前在后台找半天。3.2 初始化 Kubernetes 客户端和命名空间策略K8s Python 客户端的坑主要在连接配置上。线上跑在集群内的插件应该用load_incluster_config()本地调试用load_kube_config()但代码里两种都要兼容否则你本地跑通了一部署进集群就报错。# k8s_client.py from kubernetes import client, config def get_api_clients(): try: config.load_incluster_config() # 已在集群内使用 Pod 绑定的 ServiceAccount 身份 except config.ConfigException: config.load_kube_config() # 本地调试读取 ~/.kube/config return client.CoreV1Api(), client.AppsV1Api()load_incluster_config()失败时会抛出ConfigException不要用Exception兜底否则会掩盖其他初始化错误。ServiceAccount 需要预先在 K8s 集群里建好并授予对应的 RBAC 权限比如create deployment、get pod、delete service。插件跑在集群外时用 kubeconfig 的context指向目标集群权限跟着当前用户的 kubeconfig 走。命名空间策略建议一个比赛用一个独立 namespace。你可以在插件 config 里加NAMESPACE ctf-2025这样的字段所有实例统一创建进去。别让插件每次按 user_id 动态创建 namespace那会让 RBAC 权限写起来非常痛苦清理时还得逐个删除。3.3 创建 Pod 实例的核心代码最小闭环里不引入 Service 和 Ingress先把 Deployment 建起来、能拿到状态再说。from kubernetes import client def create_challenge_deployment(user_id, challenge_id, image, flag): apps_v1 get_api_clients()[1] name fchall-{user_id}-{challenge_id} labels {app: ctf-challenge, user: str(user_id), challenge: str(challenge_id)} pod_spec client.V1PodSpec( containers[ client.V1Container( namechallenge, imageimage, image_pull_policyIfNotPresent, env[ client.V1EnvVar(nameFLAG, valueflag), client.V1EnvVar(nameUSER_ID, valuestr(user_id)), ], resourcesclient.V1ResourceRequirements( requests{cpu: 250m, memory: 256Mi}, limits{cpu: 1, memory: 1Gi} ), ports[client.V1ContainerPort(container_port80)] ) ], restart_policyAlways ) deployment client.V1Deployment( metadataclient.V1ObjectMeta(namename, labelslabels), specclient.V1DeploymentSpec( replicas1, selectorclient.V1LabelSelector(match_labelslabels), templateclient.V1PodTemplateSpec( metadataclient.V1ObjectMeta(labelslabels), specpod_spec ) ) ) namespace get_namespace() try: apps_v1.create_namespaced_deployment(namespacenamespace, bodydeployment) except Exception as e: # 409 表示重名说明用户之前已经启动过 if AlreadyExists in str(e): return fchall-{user_id}-{challenge_id}, already_exists raise return name, createdimage_pull_policyIfNotPresent这个参数值得单独解释一下。开发时镜像频繁改动应该用Always比赛环境镜像固定用IfNotPresent能省掉每次创建实例时的仓库拉取检查时间。resources.requests和limits必须写不写 limits 的 Pod 在节点内存紧张时是最先被驱逐的对象。250m表示 0.25 核 CPU内存请求 256Mi这是 Web 类题目的常见起步规格PWN 或逆向题如果涉及跑脚本CPU 可以提高到500m甚至1。环境变量直接注入 FLAG 是最简单的方案但也是后续最容易被盯上的安全短板第 6 章我会专门说怎么加固。创建完 Deployment 后插件还不能直接返回成功因为 Pod 还处在ContainerCreating状态。服务端需要往/plugins/k8s-dynamic-container/status路由暴露一个查询接口让前端每隔 2 秒轮询一次 Pod 的Running状态再决定是否弹出访问窗口。提示不要把create_namespaced_deployment之外的创建逻辑比如创建 Service接到这个函数后面。一个接口只做一件事创建入口和创建网络服务拆成两个接口方便排查问题。4. 题目实例的生命周期flag 注入、访问入口与自动回收4.1 随机 flag 的生成与注入方式动态题目的 flag 必须每次创建实例时重新生成。常见写法是拼一段flag{uuid4hex}但真实比赛里建议带上下文标识方便赛后溯源import uuid def generate_flag(user_id, challenge_id): token uuid.uuid4().hex return fflag{{user{user_id}_chall{challenge_id}_{token}}}这个 flag 同时写入两条链路一是通过环境变量注入到 Pod前面代码里的V1EnvVar二是写入 CTFd 的题目实例表让插件系统知道当前这个用户正在运行哪个 flag。提交答案校验时不能走 CTFd 默认的静态 flag 校验通道插件需要优先查“实例 flag 表”里的动态值。实现上可以给实例表加一个dynamic_flag字段拦截attempt事件时先查这条记录。环境变量注入的坑是用户如果拿到 Pod 的 shell直接env就能看到 FLAG。这在 Web 题里通常不构成问题因为用户本来就在容器里解题但如果你做的是“给出远程环境、禁止直接操作宿主机”的题目模式就需要把 flag 放到权限更严格的地方。第 6 章会讲用 initContainer 加文件挂载的替代方案。4.2 访问入口设计的三种路由方式部署创建了用户怎么访问到容器里的服务从简单到复杂三种方式按比赛规模取舍。第一种是 NodePort 直接暴露。插件在创建 Deployment 时同时创建一个 NodePort Servicespec.ports[0].nodePort指定一个从配置的端口池里取出的端口返回给用户http://节点IP:端口。优点是简单不用配 Ingress缺点是一台节点的可用端口有上限默认 30000-32767而且端口需要手动分配和记录插件里得维护一个端口占用表。第二种是 Ingress 按 Host 分发。插件为每个用户创建一条 Ingress 规则域名用user{id}.chall.example.com这种形式后端指向一个内部 Service。用户访问域名时 Ingress Controller 根据 Host 把流量转发到对应 Pod。这种方案不需要管端口分配但需要域名通配符解析到集群入口DNS 泛解析和 TLS 证书得有人在比赛前准备好。第三种是单 Ingress 加 Path 前缀。所有用户共享一个域名路径用/u/{user_id}/{challenge_id}/区分Ingress 把路径重写后转发到不同 Service。这种方案对 Web 题不友好因为应用内部可能有固定的相对路径跳转改写之后 Web 应用可能变砖适合做题思路不依赖路径的题目。三种方式里我唯一不推荐第三种当主力方案。Path 重写会把所有回调链接都带上前缀要么你在镜像里专门做 base url 适配要么提醒选手刷新页面丢 session。比赛现场排查这种问题很容易磨掉选手的耐心。4.3 超时回收机制防止容器把节点拖垮每道题每次启动都留下一个 Deployment比赛 24 小时、选手 500 人、每人启动 10 次集群里会躺着 5000 个实例节点再大也扛不住。回收机制是动态靶场的刚需。import time from threading import Timer def schedule_recycle(deployment_name, namespace, timeout_seconds): def _recycle(): apps_v1 get_api_clients()[1] try: apps_v1.delete_namespaced_deployment(namedeployment_name, namespacenamespace) except Exception: pass Timer(timeout_seconds, _recycle).start()Timer在单个实例级别做定时回收适合只有几百个实例的小型比赛。超过 timeout 后直接删除 Deployment不看用户是否还在做题。真实比赛建议再加一个“活跃续期”逻辑每当用户向 CTFd 提交一次 flag就把该实例的创建时间戳更新为当前时间相当于把超时时间顺延。还有一个更稳的集群级兜底方案写一个后台上斐济守护协程每隔 5 分钟扫描整个 namespace 下所有 Deployment 的creation_timestamp超过时间的全部删除。这个兜底能处理插件进程崩溃后Timer全部丢失的极端情况。def sweep_stale(namespace, max_age_seconds): apps_v1 get_api_clients()[1] deployments apps_v1.list_namespaced_deployment(namespacenamespace).items now time.time() for dep in deployments: age now - dep.metadata.creation_timestamp.timestamp() if age max_age_seconds: apps_v1.delete_namespaced_deployment(namedep.metadata.name, namespacenamespace)max_age_seconds建议设成题目最大允许时长的 1.5 倍给选手留出续期余量。要防止这个任务和Timer同时删除产生重复删除的告警日志删除命令的返回码非 404 时才记录错误。5. 动态靶场落地避坑5 个会让人通宵排查的翻车现场5.1 页面转圈容器迟迟不进入 Running现象前端轮询 status 一直返回Pending用户刷新页面也只看到“容器启动中”卡着不动。原因镜像没有预先拉取到目标节点。kube-scheduler 把 Pod 调度到一个没有该镜像的节点后节点开始从仓库拉取。如果仓库在国外或被墙拉取时间能拖到几分钟体验等于翻车。解决赛前用 DaemonSet 在所有节点预拉镜像镜像名与版本固定之后提前跑一个kubectl create daemonset pre-pull --image...比赛开始前让它短时间跑完并退出。节点数量多时也可以先做一个 Deployment 副本数等于节点数用nodeSelector逐个节点拉起。5.2 容器启动了环境变量里根本没有 FLAG现象用户进入容器执行echo $FLAG输出空或者提交 flag 提示错误。原因插件的env字段没生效常见是我方代码里把环境变量写在V1PodTemplateSpec的 metadata 上而不是spec.containers[].env。另一个常见原因是镜像里有ENTRYPOINT脚本故意清理环境变量这在公开镜像上很常见。解决先kubectl exec -it pod -- env确认。如果容器里确实没有检查 Deployment 的 YAML 里env层级的缩进是否正确如果 YAML 里没问题说明镜像入口脚本清空了环境把 flag 写入逻辑从环境变量改为通过command注入在镜像入口前拼接一段导出环境变量的命令。5.3 比赛开始三小时后节点上的容器被系统全部杀掉现象选手反应题做到一半环境突然断开查看 Pod 状态为Evicted。原因节点内存压力或磁盘压力达到驱逐阈值kubelet 开始回收低优先级 Pod。没有设置resources.limits的实例首当其冲。解决给所有实例设limits.memory和limits.cpu并给动态题目实例设置priorityClassName: high防止被普通任务抢占。同时看一眼节点上是否残留了上一次比赛没清干净的 DaemonSet它们占用的内存会被算进节点压力。5.4 选手的容器之间能互相访问Flag 被扫走现象某些比赛结束复盘时发现有效提交的 flag 里混着别的队伍的。原因同一 namespace 下所有 Pod 默认互通有选手扫描了同网段的其他 Pod 端口直接访问别人靶机的服务。这是动态靶场独有的作弊面。解决给挑战实例 namespace 加NetworkPolicy只允许外部流量到达本 PodPod 之间的流量全部拒绝。注意 NetworkPolicy 需要 CNI 插件支持Calico 和 Cilium 都是现成可用的。比赛前一定要在样例题目上实测一通有的 CNI 默认不会拦截 Pod 间访问配了也是白配。5.5 比赛结束回收线程把所有人的实例都删了唯独漏了几个现象赛后检查集群发现还有个别 Deployment 挂着直接影响下一场比赛的命名空间复用。原因sweep_stale基于creation_timestamp判断超时但某些 Deployment 中途被用户停掉后又重新创建过新记录的创建时间会被刷新旧记录会被漏掉。解决不要只看创建时间改用labels记录比赛批次。在 Deployment 的 metadata 上加match: round-1标签比赛结束时按标签全量清理。用标签选择器删除是对“漏网之鱼”最强的兜底手段。提示比赛结束后不要只靠插件回收手动执行一次kubectl delete ns 赛事命名空间是最干净的重置方式。插件回收机制是防止比赛过程中资源泄漏用的不是赛后清理的唯一工具。6. 进阶把 flag 注入做深一层顺手验证你整套靶场的完整度环境变量注入 flag 在解题型 CTF 里够用但它有两个隐患任何能执行env或读取/proc/1/environ的人都能拿到 flag容器被镜像里某个服务意外打印日志时 flag 可能跟着进日志。更稳的方案是让主容器里完全没有 flag改用 initContainer 从插件 API 拉取并写入文件。# 在 Deployment spec 中追加 initContainers init_container client.V1Container( nameflag-fetcher, imagecurlimages/curl:latest, command[ sh, -c, curl -s http://plugin-service:8080/getflag?token$FETCH_TOKEN -o /flag/flag.txt chmod 600 /flag/flag.txt ], volume_mounts[client.V1VolumeMount(nameflag-volume, mount_path/flag)] ) volume client.V1Volume( nameflag-volume, empty_dirclient.V1EmptyDirVolumeSource() )主容器把同一个 volume 挂到/flag目录题目入口脚本读取/flag/flag.txt。这样主进程的进程环境里没有 FLAGdocker inspect看不出来进程异常崩溃打印 environ 也泄露不了。代价是镜像的入口脚本要适配本来一行env就能拿到的值现在要读文件。FETCH_TOKEN是插件生成的一次性 token放进 initContainer 的环境变量里API 校验通过才下发 flag。做完这一步你应该用一组自测用例把整套靶场完整过一遍创建实例后确认只有/flag/flag.txt里有值删除 Deployment 后确认端口和 namespace 资源都已释放重复点击“启动”按钮不产生第二个实例。再让一个内部人员冒充选手把容器里能翻的目录都翻一遍确认没有第二处泄密点。我个人的习惯是把这套自测脚本留在插件仓库的tests/目录里每次比赛前跑一遍python tests/smoke.py。动态靶场这个东西平时不炸一炸就是比赛现场炸与其在现场跟选手说“请刷新重试”不如赛前把集群的回收、隔离、注入三条链路各验证三遍。希望这篇笔记能帮你在自己的环境里少踩几个坑把精力留在题本身的设计上。本文还有配套的精品资源点击获取