background
k
6
r
|
g
H
1
1
+
E
}
M
O
b
>
O
i
a
&
A
V
d
m
K
C
T
y
K
j
6
s
E

favicon
favicon
Reverier 的博客
 

Cyber Terminal 开发小记

Reverier-Xu at 2022-10-26 02:08:19 Development CC-BY-NC-SA 4.0

记录一些狗血历程,供大家嘲笑。

技术选型

在开发新平台之前,我仔细思考了一下我们的需求:

能够一次部署多次办比赛,同时常驻练习平台,降低运维压力。

考虑到协会每年要举办的比赛至少有四场:MoeCTF、Mini LCTF、D3CTF/LCTF、中学生比赛,每次比赛结束之后都要辛苦运维把所有题目都搬迁到 archive 平台上。对于 MoeCTF 和 Mini LCTF 来说,可能还需要在 archive 平台上直接举办比赛,之前一直使用的 CTFd 就出现了一些问题:

为了让以后办比赛不再这么折腾,还是换一个平台比较好。

前言

从前年我运维 moeCTF 2020 开始,我就一直试图寻找一个能解决 CTFd 痛点的新平台。具体了解过的平台与插件有:

然后从 20 年磨蹭到 22 年上半年,期间经历了各种各样的麻烦事,也没有抽出来整块整块的时间写较大的工程项目,于是就一直搁置了。

然后就到了 22 年的暑假前,大概五月的时候。

miniL 前一个月 👴: 今年 miniL 必上新平台

miniL 赛前两天 👴: 写不完了,寄,CTFd 救救

miniL 结束之后 👴: 今年 MoeCTF 必上新平台

然后就开始这场苦逼旅途了。

语言?框架?

先后考察了

于是最后入了 go 的坑,Web 请求框架用了 gin, 数据库用了 PostgreSQL + GORM.

在前端选型上纠结了一会儿 vue 和 react, 最终选择了 vue3, 感觉前端框架用起来都大差不差,vue3 的组织方式更符合我的直觉一些。

前端组件库用的这个:Naive UI.

SSR?CSR?

其实我是比较偏向服务端渲染的,这样用户点开页面就能看见信息,前端编写难度也会降低一些,不用为了一些异步请求搞占位符、skeleton 啥的。不过考察了一圈子 vue 的库发现情况并没有我想的那么好,大部分方案都是针对 NodeJS 作为后端的,而且并没有一个能跟 go 后端配合很好还特性稳定的方案。基本上搜起来 go+vue 都是前后端分离式项目。

后来决定使用客户端渲染,因为前后端都在同一台服务器上,倒也不太担心会对用户体验造成什么太大的影响。

最后决定下来就拿 Go 和 Vue 3 写一个前后端分离式的平台了。

数据库设计

技术栈确认下来之后,接下来简单搭建了一下脚手架,下一步就是进行数据库设计。

Cyber Terminal 与 CTFd 最大的区别就是拥有举办甚至同时举办多场比赛的能力,同时能够自动进行题目归档等工作。所以数据库设计上除了参考 CTFd 的现有设计之外,还要多出一些考虑。

具体的数据库设计如下:

数据库 E-R

整体网站架构的设计如下:

网站架构

其中将 game 和 category 分开属于设计失误,打算在下一个版本更新中迁移合并。

当时设计的时候我想着,如果要给平台添加练习场的话,应当有一个“分类”的概念,而比赛归档时刚好就可以作为同一个“分类”而归档。而对于题目本身的性质而言,一道题目只能归类于一个“分类”有些生硬,于是我将类似于“web”、“pwn”这一类概念从“category”改为了“tag”, 这样一道题目就可以有多个标签,也起到了分类的作用。而原有的分类,就作为组织题目来源的一种方式来使用。但是后来在实现上发现 category 与 game 本身就是一对一的关系,但是由于拆分开了,为数据库操作增添了许多不便,属于完全多余的设计,还为此写了很多屎山代码。

CTF 的题目类型比较多样,有静态附件题目,动态容器题目,还有之前为了反作弊实现的动态附件题目,甚至还包括一些需要评测的 ACM 题,总体来说需求各不相同。于是我设计了“checker”来表示不同的题目类型,题目可以通过多态关联到某个特定类型的 checker 上,然后与 checker 相关的数据就直接存在对应 checker 实现中。而 checker 在具体使用中也要有针对 challenge 的数据存储与针对 user 的数据存储,我在这里设计了 checkerConfig 和 checkerSession . 前者用来与 challenge 一对一关联,存储该类型题目的相关设定,例如静态 flag、动态 flag 模板、静态附件、容器题目的 docker 镜像与配置等等等,而 session 则是与 challenge+user 进行唯一关联,当 user 尝试访问 challenge 的时候就会自动建立此模型,里面可以存放一次性生成的动态 flag、已启动容器的状态与访问方式等等等。

checker 的组织方式与 CTFd 稍有不同,CTFd 将 题目、flag 类型 等都通过暴露内部 API 的方式提供给插件实现,我觉得似乎没有什么必要,索性就组合成为一个 checker. 同时 CTFd 是不具备 session 管理能力的,需要在插件中自行管理数据库,既然这样不如直接把这个概念一起做进去。

开发日记 01

👴 脑子真的是抽抽了才会用 GORM

时间回到六月,👴 好不容易忙完手头的 p 事,打算开干。先是花半天时间确定了技术栈、框架之类的东西,然后设计了一下数据库结构,就开始上手写后端。

在写的时候为了图方便,就找了一个看似很好用很 nb 的 GORM. 结果写四行代码能踩俩坑。

为什么是四行代码?有三行是 if err != nil { return nil, err }

首先,GORM 的 API 并不是统一的:

// 返回值是 *Cmd, 可以继续往后链式调用,虽然不知道都写到 First 了后面还能有什么
// 如果想获取到这一行的错误时,要使用 *Cmd.Error, 返回类型为 errors.Error
err := db.Model(&models.User{}).Where("name = ? and verified = ?", name, true).First(&user).Error
 
// 但是在 Association API 中,返回值直接就是 errors.Error, 这个时候如果再往后面接一个 Error(), 结果就是 err 字符串
err := db.Model(&models.Team{}).Where("id = ?", teamID).Association("Users").Find(&users)

其次,他自己文档里的实例跑不动:

GORM: Customize Data Types

// 里面有这么一段用来自定义 JSON 字段的东西
type JSON json.RawMessage
 
// Scan scan value into Jsonb, implements sql.Scanner interface
func (j *JSON) Scan(value interface{}) error {
  // 这里就有问题,实际上 value 是个 str, 于是转换失败直接寄了
  bytes, ok := value.([]byte)
  if !ok {
    return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
  }
 
  result := json.RawMessage{}
  err := json.Unmarshal(bytes, &result)
  *j = JSON(result)
  return err
}
 
func (j JSON) Value() (driver.Value, error) {
  if len(j) == 0 {
    return nil, nil
  }
 
  // 但是很奇怪的是这里吐出去的是个 [] byte, 👴 怀疑是他从数据库里取数据的时候没有保证前后一致
  return json.RawMessage(j).MarshalJSON()
}

再然后,那个 关联模式 (Association Mode) 就是个半成品。

根据 👴 不多的数据库原理分数,👴 想起来数据库的关系模型分为三种:一对一,一对多,多对多。结果打开文档一看有四种:belongs to​, has one​, has many​, many to many​, 吓得我又去搜了一下 what is the difference between has one and belongs to?​出来的链接除了第一个是 ThinkPHP5, 剩下全是 GORM.

👴: 草,我数据库原理白学了

(搜)

👴: …

👴: 什么自创关系模型

然后 👴 想着应该大差不差,凭感觉写一写,在之前的设计中,submission 和 user 与 challenge 都是一对多关系,在使用场景下也需要根据 submission 查 user 或者根据 user 查 submission.

👴: 那 submission belongs to challenge, challenge has many submissions

👴: 没毛病

(写、跑、寄了,草)

👴: ?

仔细读了一遍文档,发现 belongs to 和 has one 都属于一对一关系,看来是 👴 读文档不仔细,挨一锤。

于是 👴 仔细看了看两个一对一关系有啥区别:

// 文档里这个叫 belongs to
type Game struct {
  ID uint
  ...
  Category *Category // <-
  CategoryID uint
}
 
type Category {
  ID uint
  ...
}
 
// 这个叫 has one
type Game struct {
  ID uint
  ...
  CategoryID uint
}
 
type Category {
  ID uint
  ...
  Game *Game // <-
}

建出来的数据表都是 games​ 中有一个 category_id​ 列,和完全相同的关联约束,区别只是一个 Game 结构体里包含 Category, 一个 Category 结构体里包含 Game.

👴: 这有啥区别?奇怪的 feature🐎

👴: 那 👴 只写一个键,在两边都内置一个对面的结构体指针应该没事吧,这里面要加载另一个表需要显式 preload 的,倒也不会循环引用

(写、跑、寄了,草)

👴: ** ** ** ** ****

于是出现了一个尴尬的情况,无论用哪种方式,都只能在一个数据模型上使用 Association Mode, 另一个模型只能手动过滤列:

// 用 Association 通过 game 查 category, 彳亍
db.Model(&models.Game{}).Where("id = ?", gameID).Association(&models.Category{}).First(&category)
// 用 Association 通过 category 查 game, 真不彳亍
db.Model(&models.Category{}).Joins("inner join games on games.category_id = categories.id and categories.id = ?", categoryID).First(&game)
// 那 👴 干嘛不这样写
db.Model(&models.Game{}).Where("category_id = ?", categoryID).First(&category)
// 👴: 这 Association Mode 存在的意义是啥

同理,has many 也有这样的问题。

在后面开发的时候 👴 基本上是开着 Debug 模式一路写过去的,每次写完都要跑一次让 GORM 输出一下他构造好的 SQL 长什么样,符不符合 👴 的预期,然后发现了一大堆各种各样的奇怪问题,这里就不详述了。

👴: 啥啥啥,这写的是个啥,怎么 User 里 创建的时候有个 Languages 一会儿 过滤的时候又变成了 codes, 你们写文档都不带 🧠 放一下原始结构体到底长啥样 🐎