这几天,笔者把 2023 年开发的 Blueberry CTF 竞赛平台开源在了 Github 上。
  项目页面:https://github.com/Ruanxingzhi/Blueberry-CTF

  这是笔者第一次在紧迫的现实需求下开发应用,它在实践中也圆满地完成了任务。核心代码编写于 2023 年 4 月、5 月,后来添加了一些小功能。Blueberry 从出生到现在已经接近一年了,支撑过大大小小十几次比赛,现在很适合对它的开发过程进行一次回顾。如果未来有人开发新的 CTF 竞赛平台,本文也能提供一些经验。

0x00 前言:为何需要一个新的 CTF 竞赛平台

  在很长一段时间内,我们 Lilac 战队使用 ctfd 平台。例如,在 2020 年,我们用它办了五一欢乐赛,当时的博客文章记录了部署和维护 ctfd 的流程。

  ctfd 不支持私有靶机,但总有些题目需要隔离各个用户(否则,RCE 了的选手可以把环境破坏掉,使别人做不了题)。我们当时的解决方案也很直接:每道题开 4 个靶机,这样,一个靶机没法用了,至少其他靶机还存活着。如果 4 个靶机全部被破坏,选手可以联系管理员进行手动重置。docker 容器的启动指令如下:

docker-compose --compatibility up -d --build --scale app=4

  docker-compose.yml 文件写法:

version: "3.1"

services:
  app:
    build: ./deploy
    restart: always
    ports:
      - "10111-10114:8000"
    deploy:
      resources:
        limits:
          cpus: '0.20'
          memory: 200M

  这套方案虽然原始,但确实解决了问题。因此,我们没有太大的动力去换平台,直到 2023 年。

  2023 年春季学期,我们开始大搞新队员培训,私有靶机的问题重新提上日程。培训的时间长达几个月,管理员不可能再如同五一欢乐赛一样随时帮选手重置靶机;且有些题目的做题过程就是要破坏初始环境(典型例子是存储型 xss、sql 二次注入),上文的方法不可能支持这类题目。因此,我们必须让训练平台支持私有靶机。

  我们调研过基于 ctfd 的方案,即赵今开发的 CTFd-Whale 插件。然而,CTFd-Whale 是针对 buuoj 开发的,与 buuoj 的网络结构强耦合,引入了 frp 之类的组件;我们的需求并没有如此复杂。另外,CTFd-Whale 毕竟年久失修(最近一次提交是 2020 年 4 月),与现在版本的 ctfd 的兼容性有待考证。

  既然如此,我们不如从零开始,开发一套新的 CTF 竞赛平台出来。不仅可以实现私有靶机,还能实现许多其他功能(例如一道题目里面支持多个子任务、私有 flag 和烽火台等)。

0x01 需求分析和设计

  略加分析,我们的系统大约分为三个组件:

  1. 用户系统。例如用户注册、登录、个人信息修改等。基本所有的 web 服务都有这个系统。
  2. 题目和记分系统。这包括题目的展示和修改、积分榜展示等。我们需要支持单题多任务、静态积分和动态积分。
  3. 靶机系统。这是我们特有的功能,需要允许用户申请靶机、释放靶机;支持管理员在后台修改靶机参数(例如内存限制、CPU 限制)。

  接下来,我们分别讨论这几个子系统。


用户、题目和记分系统

  用户管理方面无需赘述。用户提供自己的用户名、邮箱和密码以注册。为简单起见,我们的平台不支持用户上传头像,而是使用 Gravatar

  题目系统。系统中可以存在很多道题目,每道题有自己的标题、描述和 tags。一个题目可以拥有多个子任务(这是很现实的需求,例如选手达成任意文件读即可读出 flag 1、达成 RCE 即可获得 flag 2)。每个任务会单独记分,支持静态积分(即分值始终不变)和动态积分(分值随着解出人数增多而下降)。

靶机系统

  假如有一个 24h 待命的管理员,那么他完全可以用 shell 完成靶机管理。假如选手要开新的靶机,他就执行 docker run;如果选手要释放靶机,则执行 docker rm -f。我们的靶机系统只是把这个过程自动化。

  出题人需要把题目打包成 docker 镜像并传到平台上。这件事有很多种实现方法:可以在平台上进行 build;也可以本地打包,导出为 tar 文件,上传到平台;还可以发布到镜像仓库,由平台 pull 下来。总之,现在比赛平台上有了各个题目的 docker 镜像。当用户申请靶机时,我们就启动一个容器,并定于 1h 之后销毁;当销毁时间到了,或者用户主动要求释放靶机,我们就把这个容器销毁掉。


  以上,我们分析完了系统中的所有组件。不难发现,所有的工作都是 CRUD,只不过用户系统的 CRUD 笔者经常写,靶机系统的 CRUD 笔者没写过。花半个小时学一下 docker SDK 也应该能写了。

  接下来,我们进行技术选型:用什么语言编写平台和靶机管理器;用什么中间件等。在选型之前,有一个非常现实的问题要考虑:笔者迟早是要毕业的,这个系统就要传给学弟们用。那么,笔者一定得选一个他们都擅长的语言,并尽量少用各种中间件,以方便后续维护和二次开发。笔者最后选择的是:

  • 全部使用 Python 开发。平台使用 Flask,靶机管理器使用 docker 的 Python SDK。
  • 用 PostgreSQL 进行数据存储,web 服务和靶机管理器都应当是无状态应用,以便扩容和开发。

  这里选择 PostgreSQL,是因为它可以承担我们所有的需求。可能有读者好奇「为什么不引入 redis 来缓存排行榜」「为什么不引入消息队列以便在平台与靶机管理器之间传输消息」「为什么不使用 websocket 来尽快通知选手容器启动成功」,对这些问题的回答都是:没必要。

  随着用户数量的上升,最先达到瓶颈的一定是靶机服务器的内存,而不是其他的什么东西。每道题目的内存需求以 100MB 算(事实上 pwn 题的需求普遍更小,而 SQL 注入题、XSS 题可能需要几百 MB 内存),那么一台 16G 内存的服务器只能支持 160 个容器。如果每个用户同时持有 2 个靶机,那就只能支持 80 位参赛者。如此少的参赛者数量,PostgreSQL 完全不会有性能压力;每位参赛者就算每秒轮询 web 平台一次,也才不到 100 QPS,对 Flask 来说轻轻松松。因此我们在选型上可以尽量简化,以避免开发和维护难度上升。

💡
顺带一提,PostgreSQL 的优化器十分强大,不是几百几千个用户就能击垮的。就算排行榜计算真的十分耗时,也可以使用 PostgreSQL 的「物化视图」来缓存,定期刷新。因此,就算用户规模扩大到数千人,也不必引入 redis。

  web 服务可以利用 Flask 轻松实现。至于靶机管理,也非常简单。现在的问题是如何把这两者连接起来。常规设计是利用消息队列:web 服务收到开靶机的请求后,往消息队列里面生产一条消息,而靶机管理器消费这条消息,把容器开起来,并回复 ACK。但我们并没有引入 RabbitMQ 这样的消息队列服务。

  PostgreSQL 提供了消息订阅服务(LISTEN/NOTIFY),可以当成简易的消息队列用,但没有 ACK 机制,无法确认请求被执行。所以我们使用最朴素的办法:

  • web 服务收到开靶机的请求时,向数据库中写一行 (user_id, problem_id, start_time, destroy_time, 'pending')
  • 靶机管理器每秒轮询数据库,取出 pending 状态的请求,启动容器,并把状态改为 running
  • 靶机管理器每秒轮询数据库,选择 destroy_time 比当前时间早的那些行,销毁容器,并把状态改为 destroyed

  这样,我们以非常简单的方式实现了可靠的通讯。不必担心性能问题——当它出现性能问题的时候,内存早就被成千上万个靶机挤爆了。

0x02 一些实现细节

  用户 session 维护。由于没有引入 redis,我们采用了 Flask 自带的 cookie + 签名方式。

  web 平台部署。采用 Gunicorn + gevent,压力测试下支持数千 QPS。必须使用 nginx 反代,否则 worker 会被缓慢的网络传输阻塞在那里。由于 web 平台是无状态应用,可以直接使用 -w 8 参数启动多个 worker 进程。

  动态积分方式。采用以下公式:$$p = \max\begin{cases}{0.5^{\max(n-1, 0)/15} \cdot p_{\max}}\\{0.25\cdot p_{\max}}\end{cases}$$

💡
设计思路:当解出人数为 0 或 1 时,该任务的分值为基础分值;每多 15 人解出题目,分值减半;一个任务的最低分值是它基础分值的四分之一。

  可编程的 flag 校验机制。出题人可以写一段 Python 代码来检验选手提交的 flag 是否正确。它支持了私有 flag 和一些奇怪的校验方法。

  私有 flag。某选手在某道题目的 flag 为:hmac(uid || pid)。靶机启动时,会把 flag 写入环境变量,选手可以通过读取 /proc/self/environ 或执行 env 指令获得 flag。靶机管理器可以与 web 平台部署在不同的机器上,由于它们共享 hmac key,算出来的 flag 是一致的。

💡
如果希望选手在 RCE 之后才能获得 flag,也有办法实现。这需要精细地编写 entrypoint.sh。

0x03 实践

  Blueberry 平台实际上是边开发边部署,很多比赛是用半成品 Blueberry 支持的。笔者回想起第一次比赛,当时后台功能都未开发完,添加题目得去写 SQL 修改数据库,十分搞笑。

  首次使用是作为某本科生课程的实验平台,暴露出了一些问题。例如:

故障记录 20230611-1:io_setup() failed
33 个参赛者。腾讯云服务器公网部署,4 核 32G 实例。
事发时,场上共有 63 个题目容器。
SQL 注入题,启动 mysql 时报错: InnoDB: io_setup() failed with EAGAIN after 5 attempts.
按 InnoDB: io_setup() failed 关键字查阅资料。
Linux 的 async io 有并发数限制。默认限制是 65536,当前 aio 并发数是 63864,确实临近上限。
修改 aio 并发上限: sysctl -w fs.aio-max-nr=1048576
问题解决。
故障记录 20230611-2:容器创建因端口占用失败
选手申请容器,自动分配了 45086 端口,但端口被其他程序占用。
容器卡在 created 状态,并未销毁。选手重新申请,但 blueberry-ctf-prob-8-14 名称冲突。
解决方案:TCP client 默认的随机端口是 ≥ 32768,把 blueberry 端口范围调成 21000-22000 可以避免撞上 tcp client 的端口。

  后来平台就逐步稳定下来了。接下来的使用中都未发生故障。

  值得一提的是,Blueberry 除了为 Lilac 战队提供训练平台、作为本科生课程实验平台之外,还支撑了两场校外比赛。一场是哈尔滨市的职工技能竞赛,另一场是某冬令营。

  在冬令营的正式比赛前一晚,进行了测试赛。其中有道「压力测试」题目如下:

  甚至这道题目也是每人 flag 互异的。checker 写法如下:

def check(s):
    num = int.from_bytes(dynamic_flag.encode(), 'big') % 4999
    num += 2000
    return s == f'flag{num}'

  平台承受住了人均 5000 发提交的压力,在正式赛中也表现十分稳定。由此,笔者认为 Blueberry 平台已经成熟,故在更新几个小功能之后,开源到了 Github 上。读者可以部署 Blueberry 以支撑自己的比赛。

0x04 反思:有哪些地方可以改进

  虽然 Blueberry 的设计比较成功,但实现上仍有一些可以改进的地方。例如「展示题目列表」的相关代码:

@bp.route('/')
def show_problem_list():
    with db_pool.connection() as conn:
        g.problems = list(conn.execute('''
WITH t AS (SELECT problem_id, string_agg(task.id::text, '|' ORDER BY task.id) AS tasks FROM task GROUP BY problem_id),
     p AS (SELECT problem.*, tasks FROM problem LEFT JOIN t ON problem.id = t.problem_id),
     score AS (SELECT problem_id, SUM(point) as total_point FROM view_task_score GROUP BY problem_id),
     r AS (SELECT p.*, score.total_point FROM p LEFT JOIN score ON p.id = score.problem_id),
     solves AS (SELECT problem_id, string_agg(v.cnt::text, ' / ' ORDER BY task_id) as solve_info FROM view_task_solve_cnt AS v GROUP BY problem_id)
SELECT r.*, solve_info FROM r LEFT JOIN solves ON r.id = solves.problem_id WHERE (is_visible or %s) ORDER BY id
''', [g.is_admin]).fetchall())
        g.accepted = list(conn.execute(
            'SELECT task_id FROM accepted_submit WHERE user_id = %s', [g.user['id']]).fetchall())

    g.accepted = [str(x['task_id']) for x in g.accepted]

    for x in g.problems:
        x['tag_list'] = parse_tags(x.pop('tag'))
        x['task_list'] = parse_tags(x.pop('tasks'))

    return render_template('problem/list.html')

  Blueberry 的很多代码都是类似的「大段 SQL 负责把数据聚合起来、少量业务代码负责渲染」思路。当初选择这样的方案,是因为这种方案可以利用到 PostgreSQL 的优化器和索引;但现在回顾这些实现,感到不妥。这段 SQL 语句长达 645 个字节,把这样的代码传给学弟,阅读时会存在困难。不如先把数据分别 SELECT 出来,再在 Python 中进行处理。虽然效率会降低,但是二次开发和维护更加方便了。

  另一个实现上的失误是:系统中有很多「微型的」一对多关系,例如一道题目对应多个 tag。对于这种场景,开一张新表很显得浪费(且增加了 SQL 语句的复杂程度)。Blueberry 采用了非常低级的方案:将其存储为 crypto|math 这样的用 | 分割的字符串,前端渲染时再解包。这使得判断「某题是否有 pwn 标签」变复杂了。事实上,PostgreSQL 支持 JSON 类型的字段,完全可以用 JSON 存储。而当时之所以放弃使用 PostgreSQL JSON 类型,是考虑了兼容 SQLite3 等其他关系型数据库的可能性。然而,随着开发的进行,Blueberry 越发依赖于 PostgreSQL 的特殊语法,已经完全绑定了 PostgreSQL。如果当时放弃兼容其他 RDBMS,这些地方可以做得更好。

💡
笔者很不喜欢在这种小项目中使用 ORM,因为感觉开发者对数据库的掌控度变差了(笑),也不方便使用 PostgreSQL 特有的功能,例如物化视图。

  如果读者在搭建 Blueberry 的过程中出现问题,随时欢迎提 issue 或者通过邮箱交流。