Cranberry CTF:需求分析与架构设计

0x00 为什么重构 Blueberry

Blueberry CTF 上线至今,已度过两年多的光阴。当初的博客文章称它为“新一代开源 CTF 竞赛平台”,是因为它解决了 CTFd 的几个核心问题:不支持 docker 靶机、不支持单题多任务。与此同时,Blueberry 保持了架构上的简洁,它的代码远比 CTFd 少,二次开发也更方便——例如,笔者曾经用几个小时的时间,让 Blueberry 支持了 docker compose 多容器靶机。

然而,在使用过程中,我们也发现,Blueberry 现在的架构,难以支撑更多更复杂的功能,最重要的原因就是前端代码越来越臃肿(读者可以去看看这个 commit)。笔者当初选择 Flask + jinja 服务端渲染,一方面是为了架构的简洁性,另一方面,自己不擅长前端,很容易写出不佳实践。但今天再看,前后端分离变得可行了:Pid 先生进入了俱乐部,他是个前端大师,且愿意慷慨地捐赠时间,来让 BBNG 成为现实——本项目最初的名字是 Blueberry NG(下一代 Blueberry),但考虑到本项目采用了与 Blueberry 截然不同的设计方式,最终笔者将本项目命名为 Cranberry(蔓越莓)。

既然决定了 Cranberry 采用前后端分离方式,笔者便可以专注于架构和后端。设计新系统之前,我们先来谈谈 Blueberry 的若干缺陷。

0x01 Blueberry 缺失了什么

笔者想分两大块来讨论 Blueberry 的缺陷:功能上的缺失,以及代码中的内在问题。

功能缺失:

  1. 不支持题目导入导出。对于一个成熟的办赛者而言,题目复用是极其常见的情况(例如 PHP 入门题“跑马场”,每年新选手培训都会使用)。Blueberry 可以通过“在同一台服务器上运行多个 backend.py”来让不同的赛事复用同一个靶机,但题目描述无法共用。
  2. 对分布式靶机支持不佳。backend.py 把 PgSQL 当成消息队列用,每次取出一个 pending 状态的任务并创建容器,所以实际上用户可以正常地在多个服务器上运行 backend.py 来分担压力;但这是一种原始的方案,没有考虑到服务器上面有哪些题目镜像、负载如何。如果要支持数百人的竞赛,分布式靶机几乎是必需的。
  3. 端口转发依赖手动实现。目前,平台与靶机分离部署时,一般会手动(用 iptables 或者 benyamin218118/tcpforwarder)把靶机的数千个端口转发到平台,这不是一个优秀设计。
  4. 只能在平台上 check flag。但有些场景在靶机内 check 更合适。
  5. 不支持团队参赛。目前只能以“多人共用一个账号”的方式实现团队赛。

代码实现的缺陷:

  1. 很难支持前三血等高级计分功能。Blueberry 的榜是从解题情况直接计算出来的,本质上是一个巨大的 SQL,见 ranking.py。这也导致 Blueberry 难以做出“pwn 榜”“web 榜”。
  2. tag 功能实际上是 hack 出来的。在数据库中,tag 不是被保存为列表,而是被保存为“web | week1”这样的用竖线分隔的字符串。这是毫无疑问的设计缺陷,以至于后续开发“按 tag 搜索”功能时非常费劲。

Blueberry 是一个没有被过度设计的系统,笔者当年以最少的代码量实现了所需的功能。然而,Cranberry 作为“下一代 CTF 竞赛系统”的愿景,是使那些曾经因平台限制而无法发挥的出题人的脑洞,得以亮相在选手面前。于是,Cranberry 必然要设计得足够灵活,这不是一件简单的事。越灵活的系统,越容易过度设计,架构变得越来越复杂,最终每次添加功能都成为一场灾难。本文要探讨的核心问题,就是:如何以软件工程学手段,让 Cranberry 在极尽灵活的前提下,仍然保持结构简洁、对新手管理员友好、同时 bug 尽量少?

0x02 Cranberry 的设计理念

我们应当尽可能灵活。例如,当我们支持在靶机内跑脚本进行 check,我们便可以实现“选手攻破靶机,在受害者屏幕上弹出计算器,然后点击验证按钮,立即得分”。然而,如果每题都是在靶机服务器上验证,则会造成不必要的延迟和带宽浪费——毕竟绝大多数题目在平台服务器上就可以验证。所以,我们需要同时支持“靶机 check”和“平台 check”。

然而,这样的灵活性会造成配置困难。试想一下,一个刚刚接触 Cranberry 的管理员,创建题目时就要选 checker 运行在哪里、靶机内存限制多少……即使他只是想快点尝试一下“跑马场”。要在高度可配置的前提下提供良好的管理员用户体验,可以参考 Spring Boot 的“约定优于配置”思想。我们默认提供最常规的选项,保证大部分题目能运行起来;管理员如果想要玩花样,则再进行特殊配置。靶机服务器上,管理员只需要执行 docker build 把镜像构建出来,便可以使用镜像名来指代特定的环境,无需手动去注册;真有特殊需求,再去注册不迟。

另一个非常重要但经常被人忽略的点,是不可变性(immutable)。试想一下,docker 靶机启动时,暴露了 80 和 3306 端口,予以映射到平台服务器的随机端口。而在选手做题过程中,管理员修改了配置,让这题只暴露 80 端口。选手做题结束,如果按照新配置,则平台只收回 80 端口的映射,3306 端口就被忽略了。为了解决这种隐蔽问题,我们需要保证一些不可变性——选手用哪个模板启动靶机,则 checker 也必须是那个模板的 checker、靶机必须以那个模板的形式被收回,无论这个题目被如何修改。

对 Cranberry 而言,可扩展性是不可动摇的目标。举个例子,虽然 Cranberry 设计时并未考虑 AWD 赛制,但我们必须能让 Cranberry 以最小的改动支持 AWD 赛制。因此,我们不再单纯按 task 计分(在 Blueberry 中,task 是绑定题目的,无法实现给某人凭空加分),而是按“得分项”计分。同时,我们也要为插件提供方便,例如用 OAuth2 替代用户名密码验证,这要求我们保证 auth 模块与其他模块不耦合,以便在特定的竞赛中轻松地切换 auth 的实现。

0x03 亮点功能

以下列出 Cranberry 相对于 Blueberry 的亮点:

  • 题目中心。这是 Cranberry 最重大的架构进化,将在后文详述。简而言之,一道题目放进题目中心后,办赛者可以在任何一场赛事中引用这道题目。
  • 支持多种靶机后端。虽然 Blueberry 在设计上容许 docker 以外的后端(写个 backend.py 即可),但它现在确实只官方支持 docker 靶机。Cranberry 将会支持 docker compose、vm 等后端。
  • 靶机侧 checker。典型使用场景:构建一个 A-B-C 的网络环境,其中 A 在访问 C 的 http 服务,B 担任路由器。给选手提供 B 服务器的 ssh,要求选手登入靶机,实现 MITM 攻击,篡改 A 与 C 之间的通讯报文。选手在实现攻击后,要求平台进行 check,平台将 check 需求发送给题目中心,题目中心安排 A 服务器发送 http 请求,若响应中包含 hacked by Sm1le 字符串,则认为 check 成功。
  • 支持团队赛,而且采用新型的 token 方式。用户可以创建团队,并将团队 token 交给其他参赛者,其他参赛者无需登录自己的账号,即可凭借 token 参赛。

0x04 架构设计

在与 Pid 探讨如何实现题目导入导出时,Pid 提出我们可以建立一个中心式的题库服务,这样就不需要为每场竞赛导入导出了——即使是要导入导出,由于它不与特定的赛事耦合,实现起来也非常干净。笔者研究了一会,认为很有道理。

Cranberry 把“题目中心”与“赛事”做了严格的分离。一场赛事可以依赖多个题目中心,一个题目中心也可以为多个赛事提供服务。题目中心负责启动和销毁靶机,监听随机端口(转发到题目环境);赛事平台服务器必须能直连题目中心(可能需要采用 wireguard 等方式突破 NAT),负责把用户请求转给题目中心,并监听随机端口,转发到题目中心服务器的题目端口。

通讯方式。Cranberry 与题目中心的通讯靠 HTTP API 而不是消息队列完成。原因很简单:消息队列是用来削峰填谷的,不是用来 RPC 的。选手开靶机,要么当场给他开,要么告诉他现在负载过重,开不了。在这件事上,不应该引入消息队列让选手傻等。另外,走 HTTP API 也方便平台随时询问靶机状态、统计题目中心的负载等。

最后,值得一提的是,Cranberry 会提供一个官方 CLI。本来是为了测试用(从而前后端开发节奏可以不同步),但是发现这程序也挺有意思,不如公开送给用户,也算是发扬极客精神了。从本月开始,Cranberry 会进入密集的开发阶段,祝愿 coding 顺利。