CTF 题目存储结构与类型插件化

注意
本文最后更新于 2024-01-20,文中内容可能已过时。

起因

👴:哎这个 CTF 设计怎么这么抽象啊,又要有静态 flag 又要有动态 flag 还***要上 OJ 搞程序评测

现有方案(?)

这个故事应该要从 CTFd 开始讲。 CTFd 本身设计为一个可以全方位扩展的解题平台,其 Flag 验证、平台功能都是采用插件化的结构来实现的。但是很不巧的是,这个插件化做的有一些问题,太过自由的结构导致给插件开发者带来了很大的心理负担和维护压力,仅仅实现功能还不够,还要附带实现前端,而一些逻辑还耦合在 Controller 中,导致遇到某些特殊的需求还得去改 CTFd 的源码。

原因之一是,CTFd 的文件管理功能是自带的,出题人上传一个题目文件,这个文件计算完hash之后会存入哈希路径,并在数据库中留下一条记录关联到对应的题目,然后按照原样提供给选手。在这个过程中,插件是无法进行侵入的,也就是说,文件管理本身是完全受限制的。而动态容器就不同了,CTFd 从设计之初就没有考虑过容器的问题,就导致整个容器管理都需要插件进行实现。于是,文件管理这个功能与容器管理是完全割裂开的。

再后来,GZ::CTF 逐渐占领了国内 CTF 赛事的市场。GZ::CTF 各方面用户体验做的很好,不过开发者为了整体用户体验舍弃了很多架构上的设计,其中包括自定义题目分类、题目插件化等等等。在题目文件的存储上 GZ::CTF 并没有做和 CTFd 区别很大的设计,加入了外链文件、根据队伍id动态分发题目文件等支持,但本地文件依旧是按照类似逻辑存储并关联到题目的。

这就导致一些需求在这种文件存储设计中实现起来很抽象,逻辑基本是耦合的:

  1. 动态题目文件分发:给每个队伍分发不同的题目文件,并映射到不同的flag,以此实现一些反作弊功能;
  2. 动态环境挂载:无法将题目文件挂载进选手使用的动态容器中。这个需求有一个固定的场景:OJ评测,我们有现成的评测容器,容器中会对选手的程序进行编译,并重定向输入输出文件,最终进行对比。如果仅使用容器机制进行测评的话,意味着我们每一道评测题目都需要将完整的输入输出评测文件打包进容器镜像之中;如果能够在启动容器时将文件动态挂载进去,那绝大部分评测题目只需要一个容器镜像就可以了;
  3. 基于自定义规则的flag验证机制:之前 @koito 提出过一个需求,希望能够自定义flag验证脚本,这样就可以提供一些更加复杂的题目类型和flag验证方式了。emmm虽然在Ruast中实现一个自定义验证脚本可能不是很容易,不过也不是不彳亍。问题在于,在传统模型下,flag验证只能拿到用户信息、题目信息以及用户提交的内容,过少的上下文导致了这个“自定义flag验证脚本”显得很鸡肋,你用吧,其实没比regex提供多少灵活性。

以上这三个需求只是举个例子,他们都反映了一个共同的特点:

选手下载文件、做题、提交flag到完成答案验证这个流程是强耦合的

一旦有一些新的需求,就需要在这个流程中进行修改,而插件系统一定是无法面面具到的,开发体验和使用体验总要扔一个。

那有没有什么二者兼顾而又不那么抽象的方案呢?没准有,看下面。

基于XXXX(竞标一个NB的技术词汇)的挑战应答题目机制

题目存储结构

在创建题目时,平台会自动给题目分配一个专属存储目录,这里称之为 Bucket。在 Bucket 中,按照用途分为4个部分:

  • provided:存放静态题目文件,上传到这里的文件会被直接提供给选手下载,不会进行任何处理;
  • mapped:存放动态题目文件,上传到这里的文件会被动态分发给选手,这里的设计其实是一个妥协,动态分发的特殊性导致这里的文件永远只能给选手分发1个对应文件,如果需要动态分发多个文件,就只能提前打包成tar/zip来解决了;
  • preserved:存放额外的评测文件,这里的文件对选手是不可见的,但是会在选手提交flag时传递给flag验证器作为额外的上下文。在用途上其实可以很多样,例如把真正的flag验证逻辑作为脚本放在这个区域,然后实现一个flag验证器,在验证时启用某个脚本引擎运行这个脚本,配合相关文件和题目信息上下文进行验证,可以给到很高的自由度;
  • mounted:存放需要被挂载进容器中的文件,这里的文件会按照指定路径挂载进选手做题用的环境容器中。这里在某种程度上也许可以减小出题压力。例如制作各个版本带有xinetd的ubuntu镜像,出题人只需要设置好挂载路径并给出题目二进制文件本身即可作为一道完整的pwn题使用,不再需要自己写Dockerfile进行构建,可以很有效的复用现有的镜像资源。

组件化题目验证机制

有了上述题目存储结构,接下来要实现一个能够利用这套存储结构的题目验证机制,同时,这套机制还应该能够很方便的进行扩展,以满足各种各样的需求。

首先,我们分析一下选手做题的流程,这里选了三种常见的题目类型:

  1. 选手打开题目,下载题目附件,解出flag,提交flag并进行静态验证;
  2. 选手打开题目,下载动态附件,解出flag,提交flag并进行动态映射验证;
  3. 选手打开题目,启动题目环境,解出flag,提交flag并与环境中的flag对比验证;

接下来是管理员的操作流程:

  1. 上传静态附件,设置flag;
  2. 上传动态附件,设置flag;
  3. 上传附件,不设置flag,配置题目镜像;

在这套流程中,题目验证机制总共有以下两个侵入点:

  1. flag验证:验证选手提交的flag是否正确;
  2. 动态容器:在选手启动容器时,动态设置容器的环境变量;

这两个侵入点都是可以通过插件进行扩展的,而且这两个侵入点的逻辑都是可以完全自定义的,这样就可以实现各种各样的题目类型了。

pub trait FlagChecker {
  async fn check(&self, user: &User, challenge: &Challenge, flag: &str) -> Result<bool, CheckerError>;
  async fn get(&self, user: &User, challenge: &Challenge) -> Result<String, CheckerError>;
  async fn env_vars(&self, user: &User, challenge: &Challenge) -> Result<HashMap<String, String>, CheckerError>;
}

...

pub struct StaticAttachmentChecker {
}

pub struct DynamicAttachmentChecker {
  cache: &RedisPool,
}

pub struct EnvironmentChecker {
  cache: &RedisPool,
}
0%