0xfbad/ctfd-challenge-container-plugin

GitHub: 0xfbad/ctfd-challenge-container-plugin

CTFd 插件,为每个参赛者动态创建独立的 Docker 容器,支持多主机负载均衡和自动生命周期管理。

Stars: 0 | Forks: 0

# 个性化容器插件 CTFd 插件,用于在 Docker 主机池中为用户创建独立的 Docker 容器,每个参与者都能获得自己隔离的环境,具有自动端口分配、过期计时器和生命周期管理功能。 ## 工作原理 当用户启动挑战时,插件会选择负载最低的健康 Docker 上下文,获取每个上下文的创建信号量以防止并发请求压垮守护进程,通过 SDK 的 SSH 隧道访问 Docker API,使用动态端口映射和安全加固创建容器,回读映射的端口,向数据库写入包含容器 ID 和过期时间戳的 `ContainerInfoModel` 行,并将连接详情返回给用户。每个挑战可以固定到特定的 Docker 上下文或保持未分配状态由负载均衡器自动选择。 用户直接连接到运行器主机上容器的映射端口。插件支持 TCP、SSH 和 Web 连接类型,每个挑战都配置有前端显示的凭据和连接信息。 ## 安装 ### 安装插件 将此仓库克隆到 CTFd 的插件目录中,文件夹名称可以任意,但需要直接放在 `CTFd/CTFd/plugins/` 下。 ``` cd CTFd/CTFd/plugins git clone ``` CTFd 在启动时检测插件,因此克隆后需要重启。 ### Docker 访问 CTFd 容器需要访问 Docker,包括本地 socket 和通过 SSH 访问远程主机。在 `docker-compose.yml` 中为 CTFd 服务添加这些卷。 ``` services: ctfd: volumes: - /var/run/docker.sock:/var/run/docker.sock - ~/.ssh:/root/.ssh:ro - ~/.docker:/root/.docker:ro ``` Docker socket 让 SDK 与本地守护进程通信,SSH 密钥让它能隧道连接到远程主机,docker 配置目录包含插件读取以解析端点的上下文元数据文件。 如果您只使用远程上下文且不需要本地守护进程,可以跳过 socket 挂载,但仍需要 SSH 和 docker 配置挂载。 为了让远程上下文在 CTFd 容器内正常工作,您还需要 `network_mode: host` 或等效的网络访问,以便 SSH 连接能够到达您的 Docker 主机。 ### Docker 上下文 在运行 CTFd 的机器上设置上下文(如果您挂载了配置,也可以在容器内设置)。 ``` docker context create server1 --docker "host=ssh://user@server1.example.com" docker context create server2 --docker "host=ssh://user@server2.example.com" ``` 然后从管理配置页面的 `/admin/config` 中的 Challenge Containers 部分导入它们,点击"Import Contexts",插件会扫描主机上的上下文,显示可达性,并让您设置公共主机名后再导入。权重和启用状态可以在之后更改。 对于单服务器部署,您无需配置任何内容。首次启动时如果上下文表为空,插件会检查本地 Docker socket `/var/run/docker.sock` 是否可达,如果可达,会自动创建一个 `local` 上下文,使用机器的主机名作为公共地址。如果您删除它并重启 CTFd,它会重新出现。 ### 预拉取镜像 镜像需要在 Docker 主机上存在,挑战才能使用它们。配置页面有一个 Image Availability 部分,显示哪些挑战镜像存在于哪些上下文上,并为缺失的镜像提供拉取按钮。 您也可以通过 API 批量拉取。 ``` curl -X POST /containers/api/pull \ -H "Content-Type: application/json" \ -d '{"image": "your-challenge:latest"}' ``` 在请求体中传递 `context_name` 以仅拉取到特定上下文。 ### 数据库 插件在首次加载时自动创建其表,无需手动迁移。它创建 `docker_contexts` 用于上下文池,`container_challenges` 用于挑战定义,`container_info` 用于活动容器元数据,`container_settings` 用于插件配置,以及 `container_history` 用于永久的生命周期记录。 ## 容器生命周期 ### 创建 1. 用户在挑战页面上点击"Start Instance" 2. 插件获取每个挑战+团队(或每个挑战+用户)的创建锁,使重复请求串行化 3. 检查此挑战是否已为此用户/团队存在容器,如果存在则返回连接信息 4. 选择一个 Docker 上下文,要么是固定到挑战的,要么是通过加权评分选择负载最低的健康上下文 5. 获取每个上下文的创建信号量(限制每台主机并发创建数,默认 2) 6. 通过 Docker SDK 创建容器,使用动态端口映射、安全加固(`cap_drop=ALL`、`no-new-privileges`、pids 限制 256)、挑战配置中的资源限制,以及环境变量(`CHALLENGE_ID`、`TEAM_ID`、`USER_ID`) 7. 从 Docker API 回读映射的主机端口 8. 写入包含容器 ID、端口、过期时间和上下文名称的 `ContainerInfoModel` 行 9. 返回连接详情(主机名、端口、连接类型、凭据) 如果 Docker 创建容器后数据库提交失败,插件会回滚事务并终止孤立的容器,这样就不会出现插件不知道的幽灵容器。 ### 销毁 用户点击"Stop Instance"或管理员从仪表板强制终止。插件通过 Docker API 终止容器,记录事件,并删除数据库行。容器创建时带有 `auto_remove=True`,因此 Docker 会在它们停止时清理文件系统。 ### 过期 每个挑战都有可配置的过期时间(以分钟为单位,默认 30,0 表示永不过期)。APScheduler 作业按可配置的间隔(默认 5 秒)运行,查询数据库中超过过期时间戳的容器并终止它们。用户可以从 UI 续订会话,将计时器重置为挑战配置的持续时间。 ### 解题后自动过期 当玩家提交正确答案时,插件会将他们运行中的容器的过期时间缩短为从现在起的 90 秒。这会在解题后快速释放资源,而不会立即终止容器,让玩家有时间查看结果。可以通过 `post_solve_expiry_seconds` 设置配置延迟,设置为 0 可禁用。没有过期时间(`expires = 0`)的容器不受影响。 ### 容器历史 `ContainerInfoModel` 行在容器停止时被删除,因此无法再查看使用模式。`ContainerHistoryModel` 表永久保留生命周期数据。每次创建容器时都会插入一行,包含容器 ID、挑战、用户/团队、docker 上下文和创建时间戳。当容器结束时,该行会更新带有 `stopped_at` 时间戳和原因。 原因跟踪容器的结束方式:`stopped`(用户或管理员终止)、`expired`(过期作业)、`purged`(管理员清除所有)、`reconciled`(启动时清理的过时记录)或 `solved`(解题后自动过期)。solved 原因在玩家提交正确答案时设置,实际的 `stopped_at` 时间戳在过期作业终止容器时稍后填充。 挑战、用户和团队的外键使用 `ON DELETE SET NULL`,以便在删除这些记录时历史记录仍然保留。 ## 负载均衡 插件使用最少连接数评分在 Docker 上下文之间分配容器。对于每个健康的上下文,它计算 `weight / (active_count + 1)`,其中 active_count 通过内存中的 `select_and_reserve` / `release_slot` 调用跟踪,而不是在每次调度决策时查询数据库。得分最高者获胜,平局按字母顺序打破。权重为 2 且零容器的上下文得分为 2.0,而权重为 1 且零容器的得分为 1.0,因此较重的上下文先被选中,但随着它积累容器,得分下降,较轻的上下文开始接收流量。 上下文选择和槽位预留是在锁下原子进行的,因此两个并发请求不能竞争同一个槽位。槽位在容器被终止时(由用户、管理员或过期作业)释放,并在启动时通过协调恢复。 这比轮询更好,因为它考虑了实际负载,如果容器的生命周期不同或一些容器提前被终止,负载均衡器自然会路由到有容量的地方,而不是盲目地循环遍历上下文。 ## 容器安全 每个容器都会获得硬化的默认值,无论挑战配置如何。 - `cap_drop=["ALL"]` 丢弃所有 Linux 能力 - `security_opt=["no-new-privileges:true"]` 防止 setuid/setgid 提权 - `pids_limit=256` 限制进程数量以防止 fork 炸弹 - `auto_remove=True` 以便 Docker 在容器停止时清理文件系统 ## 新鲜度令牌 具有静态旗帜的挑战容易受到参与者之间旗帜共享的影响。插件可以将确定性的每用户令牌作为 `FRESHNESS_TOKEN` 环境变量注入每个容器,挑战作者使用它来构建独特的旗帜,自定义 CTFd 旗帜类型通过重新计算提交者的预期旗帜来验证。 令牌是 4 个小写字母数字字符(`[a-z0-9]`),由 HMAC-SHA256 派生。相同的输入总是产生相同的输出,因此重启容器不会改变旗帜。`freshness_secret` 密钥在首次启动时自动生成并存储在插件设置中,无需手动配置。 要使用它,您需要在 CTFd 中设置"freshness"类型的旗帜,并在您希望令牌替换的位置放置 `%TOKEN%`,例如 `ctf{this_is_a_flag_%TOKEN%}`。在挑战端,您可以在入口脚本中从环境变量构建旗帜。 ``` FROM python:3.12-slim COPY server.py /app/server.py WORKDIR /app CMD FLAG="ctf{this_is_a_flag_${FRESHNESS_TOKEN}}" python server.py ``` 服务器代码像往常一样从环境变量中读取 `FLAG`,那里不需要更改。 ``` import os flag = os.environ.get('FLAG') ``` 当用户提交的旗帜匹配模板结构但包含其他参与者的令牌时,提交拒绝,并告诉他们旗帜属于其他人。事件以 `flag_sharing` 记录为警告级别,以便管理员可以在仪表板中看到它。昂贵的全用户检查仅在提交匹配旗帜模式但令牌错误时运行,正常的错误猜测会跳过它。 在管理设置中清除 `freshness_secret` 会完全禁用该功能,不会向容器注入令牌,`attempt()` 覆盖会回退到正常的旗帜检查。 ## 竞态条件保护 容器创建按挑战+团队(或用户模式下的挑战+用户)使用每键锁串行化,因此如果有人狂点开始按钮或发送并发请求,只会创建一个容器,第二个请求会等待锁。这防止了重复容器问题,即两个请求都通过存在性检查并各自启动一个容器。 每个上下文的创建信号量(默认限制 2)是一个单独的问题,它限制了在单个 Docker 主机上可以同时创建多少容器,这样不同挑战的用户同时点击开始不会用并行 SSH 连接压垮守护进程。 ## 架构 Docker 集成拆分为三个文件,布局与远程桌面插件相同。 - `DockerHostManager` 拥有 Docker 操作:线程本地客户端缓存、端点解析、容器运行/终止/检查、镜像列表、每上下文信号量 - `Orchestrator` 拥有调度:加权评分、健康布尔值、槽位计数。引用主机管理器进行 ping 但从不直接创建容器 - `ContainerManager` 组合两者并添加多上下文回退循环(尝试固定上下文或遍历健康上下文)、过期调度器,以及路由调用的公共 API ## 线程安全 线程本地 Docker 客户端使用代计数器,每当上下文配置更改时就会递增,因此陈旧连接会被透明地丢弃和重新创建。主机管理器和编排器各有自己的锁,两者仅在最终状态交换期间持有,而不是在 I/O 期间(如 Docker ping)。 直接使用 `threading.BoundedSemaphore` 和 `threading.Lock`,gunicorn 的 gevent 工作线程会猴子补丁 threading 模块,因此这些可以与事件循环协作,无需显式导入 gevent。 ## 调度 插件使用 APScheduler 的 `BackgroundScheduler` 运行后台作业。两个独立作业运行: - 过期检查:每 `expiration_check_interval` 秒(默认 5),查询数据库中超过过期时间的容器,终止它们,并释放其负载均衡器槽位 - 健康检查:每 30 秒,ping 每个 Docker 上下文并切换健康布尔值,不健康的上下文保留在跟踪字典中,并在再次响应时自动重新启用 两个作业都使用 `misfire_grace_time=30` 和 `coalesce=True`,因此如果调度器落后,它会赶上而不会触发重复运行。 ## 上下文健康 上下文即使不可达也会保留在健康跟踪字典中,它们的健康布尔值切换为 false 而不是从池中完全移除。负载均衡器在选择调度新容器的位置时会跳过不健康的上下文。在下一次健康检查通过时(每 30 秒),插件会 ping 每个上下文,如果它响应则将布尔值切换回 true,自动重新启用它进行调度,无需任何手动干预或配置重新加载。 健康转换记录为 `host_healthy` / `host_unhealthy` 事件,因此它们会显示在管理事件流和 CTFd 日志中。 端点解析有一个回退链:扫描 `~/.docker/contexts/meta/` 通过每个 `meta.json` 中的 `Name` 字段匹配(Docker 按 SHA256 哈希命名这些目录,而不是上下文名称,所以您必须扫描),然后尝试数据库中的 `ssh://{hostname}`,然后是本地 socket。没有 SSH 主机名的上下文仍然可以工作,前提是有匹配的 Docker 上下文文件或本地 socket。 如果用户在主机不可达时检查其容器状态,他们会看到"主机暂时不可达"消息,而不是删除其容器记录,这样如果主机恢复,他们不会失去会话。 ## 启动协调 在启动时,插件会协调数据库与 Docker,查询所有 `ContainerInfoModel` 行,并通过 Docker API 检查每个容器是否仍在运行。仍在运行的容器会保留其负载均衡器槽位,以便调度器从一开始就有准确的计数。不再存在的容器的记录会被删除,这样在 CTFd 重启或崩溃后您不会积累过时的行。 ## 事件日志 事件日志器为管理仪表板提供线程安全的事件流。每个事件都有类型、消息、级别(info/warning/error)、时间戳、可读的日期时间、可选的用户信息,以及用于领域特定字段的元数据字典,如 container_id、challenge_id 和团队信息。事件也会写入 Python 的 logging 模块,因此它们会显示在 CTFd 日志中。 管理仪表板获得由有界队列(每个监听器 100 个事件)支持的实时 SSE 流,新连接会立即接收最后 200 个事件,以便仪表板在加载时有历史记录。如果浏览器不优雅地断开连接且事件堆积,最旧的事件会被丢弃,而不是永远增长内存。流使用 SSE 注释保活(`": keepalive\n\n"`)每 30 秒检测死连接。事件缓冲区最多保存 2000 个事件用于最近事件 API。 ## 容器日志查看器 管理仪表板在每个运行中的容器行上有一个日志按钮(终端图标)。点击它会打开一个模态框,通过 Docker API 获取容器最后 200 行的 stdout/stderr。尾部计数可通过 API 端点上的 `?tail=N` 查询参数配置(最大 1000)。这对于调试挑战镜像非常有用,无需登录 Docker 主机。 ## 分析仪表板 Container Stats 页面(`/containers/admin/stats`,从管理插件菜单链接)提供四个基于 `ContainerHistoryModel` 数据构建的 ECharts 可视化。所有图表都接受时间范围选择器(24h、7d、30d、全部时间)。 活动图表显示容器随时间的创建和停止,24 小时视图按小时分组,更长时间范围按天分组。顶级用户图表按总容器时间对用户进行排名,工具提示中包含容器数量和独特挑战数量。挑战统计图表显示每个挑战的容器以及重启率覆盖层(每个独特用户的容器,用于发现经常崩溃的挑战)。解题时间图表将 CTFd 的 `Solves` 表与容器历史交叉引用,以计算每个玩家从容器创建到旗帜提交所花费的时间,显示为平均条形图,个别解题时间作为散点。 ## 配置 ### 挑战设置 创建挑战时选择"Container"挑战类型,表单默认为 `web` 连接类型、端口 `80` 和"Auto (load-balanced)"上下文,因此典型的 Web 挑战只需要设置分值并从搜索框中选择一个镜像。 镜像字段是模糊搜索输入而不是平面下拉框,它从选定的上下文加载可用镜像(选择 Auto 时从所有上下文加载),您可以输入几个字符按名称或标签过滤,结果按评分排序,前缀匹配比子字符串命中排名更高,您可以使用箭头键导航。 核心字段位于顶部,运行器字段如上下文和过期时间位于接下来,命令、卷和资源限制隐藏在可折叠的"Advanced options"部分中,如果这些字段已有值,更新表单会自动展开。 - Docker Context:哪个主机运行此挑战的容器,默认为 Auto,由负载均衡器选择 - Image:Docker 镜像,可按名称或标签模糊搜索(必须存在于选定的上下文或 Auto 时的任何上下文) - Port:内部容器端口,主机端口由 Docker 自动分配(默认 80) - Command:可选的命令覆盖 - Volumes:用于卷挂载的 JSON 对象 - Connection Type:`tcp`、`ssh` 或 `web`(默认 web) - SSH Credentials:ssh 类型的用户名/密码 - Expiration:自动终止前的分钟数(0 表示永不过期,默认 30) - Max Memory:每个容器的 MB 限制 - Max CPU:核心限制为小数(1.5 表示 1.5 核心) ### 插件设置 通过管理设置页面管理,无需配置文件。 | 键 | 默认值 | 描述 | |-----|---------|-------------| | max_containers_per_user | 4 | 每个用户的并发容器限制 | | thread_pool_size | 4 | Docker 操作的 worker 线程数 | | max_concurrent_creates | 2 | 每台主机并行容器创建限制 | | expiration_check_interval | 5 | 过期扫描之间的秒数 | | rate_limit_requests | 500 | 每个速率限制间隔的最大请求数 | | rate_limit_interval | 10 | 速率限制窗口秒数 | | freshness_secret | (自动生成) | 新鲜度令牌的 HMAC 密钥,清除以禁用 | | post_solve_expiry_seconds | 90 | 正确答案后容器过期的秒数,0 禁用 | 速率限制更改需要重启 CTFd,因为装饰器值在导入时评估。内存、CPU 和过期限制在挑战设置中按挑战配置。 Docker 上下文 通过 `/admin/config` 的管理配置页面管理。上下文通过导入按钮从主机上的 Docker 上下文元数据导入,每个上下文都有与 Docker 上下文匹配的名称、可选的 SSH 主机名、公共主机名(用户看到的,必需)、权重和启用标志。首次启动时当 Docker socket 可用时会自动填充一个 `local` 上下文。表格显示每台主机的状态(UP/DOWN/DEGRADED/DISABLED)和活动容器计数。重新加载无需重启 CTFd 即可重新连接所有上下文。 ## API 端点 ### 用户 - `POST /containers/api/request` 请求容器实例 - `POST /containers/api/view_info` 检查容器状态和连接信息 - `POST /containers/api/stop` 停止您的容器 - `POST /containers/api/renew` 延长过期时间 - `GET /containers/api/get_connect_type/` 获取挑战的连接类型 ### 管理员 - `GET /containers/dashboard` 查看所有运行中的容器 - `GET /containers/api/running_containers` 运行中的容器 JSON - `POST /containers/api/kill` 终止特定容器 - `POST /containers/api/purge` 终止所有容器 - `POST /containers/api/pull` 预拉取镜像到上下文 - `GET /containers/api/images` 列出所有上下文的镜像 - `GET /containers/api/images/` 列出特定上下文的镜像 - `GET /containers/api/logs/` 获取容器 stdout/stderr(可选 `?tail=N`) ### 分析 - `GET /containers/admin/stats` 分析仪表板页面 - `GET /containers/api/analytics/activity` 容器随时间的创建/停止 - `GET /containers/api/analytics/top_users` 按总容器时间排名前 20 的用户 - `GET /containers/api/analytics/challenges` 每个挑战的容器统计 - `GET /containers/api/analytics/solve_times` 每个挑战的解题时间 所有分析端点都接受 `?range=24h|7d|30d|all`(默认 `7d`) ### 上下文 - `GET /containers/api/contexts` 列出活动上下文名称 - `GET /containers/api/contexts/list` 所有上下文及其健康状态、容器计数、socket 状态 - `GET /containers/api/contexts/discover` 扫描主机上可导入的 Docker 上下文 - `POST /containers/api/contexts/add` 导入上下文 - `PUT /containers/api/contexts/update/` 更新上下文设置 - `DELETE /containers/api/contexts/delete/` 删除上下文 - `GET /containers/api/contexts/test/` 测试上下文连接 - `POST /containers/api/contexts/reload` 重新连接所有上下文 - `GET /containers/api/images/matrix` 哪些挑战镜像存在于哪些上下文上 ### 事件 - `GET /containers/api/events/stream` 容器事件的 SSE 流 - `GET /containers/api/events/recent` 最近 50 个事件 JSON ### 设置 - `GET /containers/api/settings` 所有设置 JSON - `PUT /containers/api/settings` 批量 upsert ## 故障排除 **容器未启动**:检查 Docker 上下文是否已配置且镜像已在相关主机上拉取,使用管理上下文 UI 中的 Test 按钮验证连接,检查上下文是否有足够容量(信号量限制) **容器在错误的主机上**:每个挑战可以在挑战设置中固定到特定的 Docker 上下文,如果未设置,负载均衡器会根据最少连接数评分自动选择 **找不到镜像**:镜像必须在分配给挑战的上下文上存在,然后用户才能启动实例,使用配置页面上的 Image Availability 扫描查看各处的镜像情况并拉取缺失的。对于私有镜像,您需要 `docker save` / `docker load` 到每个主机,或推送到主机可以访问的私有注册表 **容器未过期**:检查挑战的 `expiration_minutes` 是否设置为非零,`expires = 0` 意味着永不过期,这是故意的,通过检查 CTFd 日志中的过期作业消息验证调度器是否正在运行 **容器堆积在一台主机上**:负载均衡器使用加权最少连接数评分,检查管理 UI 中的上下文权重是否设置适当,权重为 2 的上下文比权重为 1 获得两倍的分数加成 **CTF 期间主机宕机**:健康检查每 30 秒运行一次并将不健康的上下文从调度池中切换出去,当主机恢复时,下一次健康检查会重新启用它,检查 CTFd 日志中的 `host_unhealthy` 和 `host_healthy` 事件或查看管理事件流
标签:CTFd, CTF平台, Docker, Docker SDK, GitHub Advanced Security, Python, SSH隧道, 动态端口映射, 多主机, 安全加固, 安全竞赛, 安全防御评估, 容器即服务, 容器管理, 容器编排, 挑战环境, 插件开发, 无后门, 生命周期管理, 自动过期, 请求拦截, 负载均衡, 逆向工具, 隔离环境