第五届XCTF总决赛的赛制以及新型攻防赛题之探索

今年,我们r3kapig战队接下了蓝莲花的接力棒,作为亚洲最大的CTF联赛XCTF联赛总决赛的命题方。这次命题的总负责和协调是由我们战队的@Anciety完成。XCTF作为一个老牌联赛赛事已经有了5年的历史,积累了不少口碑,这次承担命题工作对于我们来说也是颇有压力,为此我们也在内部开过几次会议讨论这次比赛的赛题和赛制设计。讨论的结果将在这个文章中呈现。

赛制

首先我们谈一谈赛制,一般来说,常见的CTF赛制有三种,分别是解题赛(Jeopardy),攻防赛(Attack&Defence)以及KoH(King of Hill)。三种赛制各有优劣。 解题赛可以让选手按照出题人的引导不断学习出题人想要分享的知识和意图,其优势是在于,只要题目设计没有问题,选手在解题过程是能够不断学习到新知识。但是其缺点也很明显,选手做解题有点像打人机,缺少了一些对抗的乐趣。对抗性是网络攻防的一个重要属性,一场纯解题在刺激肾上腺素分泌的方面是比不上Koh和攻防。

攻防赛的优势是人与人之间对抗,打来打去,比赛较为刺激。但是,出好攻防赛题对于出题人来说是一件非常有难度的事情。一方面要考虑攻和防的难度的平衡,另外还要考虑如何让选手学到知识。现有的攻防赛或多或少都有1.题目易守难攻2.题目全靠拼手速,学不到东西等问题等问题。

KoH比赛相当于赋予解题赛以对抗性,选手不仅要解出题,还要互相比谁的解答方式更优。有点像攻防和解题之间的一种平衡。不过由于时间紧张,平台方没有办法完成KOH平台的开发,所以我们没有考虑采用这种赛制。

最终,综合考虑,我们还是选择了攻防和解题的混合赛制,解题的主要作用是保证选手在这个比赛中通过做题学习到新知识,而攻防的作用则是增加比赛的对抗性。

攻防题命题意图以及相关考虑

首先我们讨论了攻守平衡的问题,如果一个题目易守难攻,攻击者就辛辛苦苦写出的攻击代码不如防御者一个字节的patch有效,这样会极大的降低攻防赛的趣味性,也会促使选手放弃解题。同理,题目易攻难守也会产生类似后果。

一种平衡攻防的方法是增加逆向的难度,这样防守方和攻击方都需要完成逆向工程才能完成攻击/防御,假设逆向难度为x,攻击难度为10y,防御难度为y,那增加逆向难度可以有效平衡攻击和防御的难度比(x+10y)/(x+y)。这样做是可以的,事实上很多重量级攻防比赛也是这么做的。不过,这样做的主要缺点就是容易让比赛变成逆向大赛。

接着,我们讨论了如何让选手学到东西。在这方面,攻防要比解题难得多。解题可以出的让选手花费十几个小时才能解出来题目且无需考虑攻守平衡的问题。但是攻防不可以,无论题目太难还是攻守不平衡,都会让选手会失去做这题目的兴趣。

为此,我们讨论了一些想法,最终决定设计两道不难,又能持续引导选手学习新东西的题目(TNJ以及pointer_guard)。同时,为了防止这两道比较异类的题目翻车,我们还加了两道传统的攻防题兜底。接下来,我们将介绍TNJ以及pointer_guard的设计。

TNJ赛题设计 By Anciety

本意是猫和老鼠。

题目构思

这题我(Anciety)其实构思了很久,可惜最后因为种种原因效果不佳,其中也有我的一些原因吧,所以也表示抱歉,我的经验不足导致题目没有达到我的预期效果。

这道题的思路来源是曾经 Atum 给我提过在 SECCON 玩过的一个游戏,由双方轮流执行汇编指令来玩,我本身对游戏挺感兴趣的,后来经过一些查找,搜到了这个游戏,叫做 Core War。其实这个游戏在国内知名度并不高,本来我是打算直接使用的,但是这个游戏非常不妙,他有一个设定是平局。因为我们没有 KOH 的条件,所以一定存在攻防双方,平局判攻击方胜利或者防守方胜利都会导致大家更愿意平局(更加简单),于是我舍弃了这个方案,但是保留了这种执行汇编的思路。

之后我又思考了一些变体,经过一些搜索,找到了另一款游戏,叫做 Darwin,双方通过复制自身来玩,谁活下来谁赢。但是这个游戏也有一些问题,主要是攻击方和防守方的高度一致,因为在攻防环境中,要求攻击方和防守方非对称,否则攻击同时也是防御,不然就是分数乘2了,所以在此基础上我设计了这个游戏。

游戏的大致思路是结合两者的优点,我很喜欢 Core War 轮流执行汇编的场景,带来了很多变数,然后 Darwin 提供的游戏机制保证了游戏可以在规定时间内结束。所以,游戏被设计为双方轮流执行汇编,攻击方尝试驻留在程序中,防守方需要去破坏攻击方。

事实上这个过程还存在问题,比如攻击方如果一开始位置被防守方知道了,防守方可以直接破坏该位置,所以我决定让防守方无法获取攻击方加载位置,但是这样我又担心攻击方过强,所以加入了防守方可以看到攻击方输入代码的设定,这里也提供了代码分析的可能,也就是可以通过分析攻击方代码来应对。

在最开始的版本中,我打算使用动态参数调整,通过攻击方防守方的分数比例来决定一些参数,从而实现弱者保护,但是最终由于和平台方匹配上存在困难,只好作罢。

这其实表达了我对 AD 模式的一个理解, AD 模式其实并不像真正的安全环境的攻防,而更像是我们平时玩的游戏,在一道题目上体现 AD 的环境,要想激烈,有看点,其实最好的就是游戏。

但是同时这种游戏也不能纯粹是游戏,我本来是打算考大家的游戏策略来设计游戏,但是考虑到 CTF 需要的一些主题,最终没有这么去做。

运行过程

这道题目最终还是有点失败,有几个原因吧。

第一个是我的提示不够,大家一开始没有理解我的意图,例如在打补丁的时候都是在我原来程序上去直接修改,而非自己编译,因为一开始的防守程序就有问题,所以直接能拿到 flag 也让选手表示难以理解,甚至没有动力再去理解了。

另外就是中途先因为和平台兼容时 checker 的 timeout 的问题,导致 checker 误判死循环,然后是因为缺少 alarm 导致两次资源耗尽,甚至不得不下线处理,这是实现上的问题,我对此感到很抱歉,也是我这次最大的遗憾,想了很多去设计,然后实现却做得不太好。

另一方面,可能也是由于我的提示不够强,到了第二天,我发现大家的 patch 都没有明显改进,我以为第一天由于攻击收益不如防守收益,大家都会多去考虑如何防守,但是第二天效果确实不佳,全场无法防守也会导致选手失去动力去维护这个题目,于是在一个小时之后选择了下线,辛苦了第一天晚上想到了新 payload 的同学哈哈。

攻守设计

这是我一开始预计的攻守设计:

  1. 由于防守方为空,攻击方占优,可以很快出现 exp (我预计是半个小时,时间差不多在 40 多分钟出现一血,还算是达到预期)
  2. 大家都看完题了,防守方知道修补方法,发现防守位置需要改一下(默认 patch 的问题是 jmp target 的问题,改改就好),通过 patch 快速修复,可以暂时避免无 shellcode 情况下的攻击。
  3. 这个时候可以写攻击了,实现最基本的死循环,防守方还处于死循环阶段,于是攻击方占优,再次难以防守。
  4. 防守方进化,循环覆盖整个 arena ,可以限制死循环攻击方。
  5. 攻击方进化,通过各类复制自身方法攻击。
  6. 防守方进化,通过分析攻击方程序进行阻止。
  7. ...

在运行过程中,最终只有 1,2,3,4 部分完成了,5 和 6 都没有出现,第二天的攻击方进化最好的是我看到的一个没有预料到的 payload ,介于 3 和 4 之间,就是通过相对跳转完成死循环,但是事实上是无法攻破4阶段的。

5 阶段由于 m68k 架构存在复制连续寄存器到内存的指令,所以复制自身是比较靠谱的,但是最终都会有直接跳转,所以最终 6 可以防住这样的情况。

6 阶段示例

在比赛过程中,我也编写了 6 阶段的防守,可以供大家参考,我采用的方法是覆盖程序中出现的跳转目标,这里有一个问题,就是如果攻击方等一段时间再复制,那可能无法起到效果,不过这就很方便修改了,直接也等一段时间就好。

在第二天的测试中,我尝试了多个 payload ,都无法攻击这个防御,其中最接近攻破的是相对跳转的 payload,因为我只判断了 JMP 的目标,但是那个相对跳转死在了 tail 部分的覆盖中。

#include <stdio.h>
#include <unistd.h>

struct shellcode {
    unsigned int size;
    unsigned int loc;
    char *code;
};

char clear_at[] = "\x20\x7c\x00\x00\x00\x00\x20\xfc\x00\x00\x00\x00";
char defender_shellcode[0x200];

char tail_shellcode[] = "\x20\x7c\x00\x00\x00\x00\x20\xfc\x00\x00\x00\x00\x4e\xf9\x00\x80\x00\x06";

void gen_defender(int *pipe, struct shellcode* _attacker) {
    int size = 0;
    for (unsigned int i = 0; i < _attacker->size - 3; i++) {
        if (_attacker->code[i] == (char)0x4e && _attacker->code[i + 1] == (char)0xf8) {
            // jmp xxxx
            clear_at[4] = _attacker->code[i + 2];
            clear_at[5] = _attacker->code[i + 3];
            for (int j = 0; j < 12; ++j) {
                defender_shellcode[size++] = clear_at[j];
            }
        }
    }
    for (int i = 0; i < 18; ++i) {
        defender_shellcode[size++] = tail_shellcode[i];
    }
    write(pipe[1], &size, sizeof(int));

    write(pipe[1], defender_shellcode, size);
}

TNJ赛题总结

这次比赛我也学到了很多,一方面是对题目的把控,我的引导性不够强,导致发展一度没有往我所预期的方向,另外一个忽略的部分就是这种交互提升的设计,应该需要加入明牌设计,双方能够部分获取到对方的信息,例如防守方的信息可以公开,这样加速针对性进化。

其余就是实现上的一些细节了,这次由于测试不充分出了很多问题,这个就是经验了,下次应该就不会有了(不过或许下次我就不再写 AD 了。。。太容易出问题了)。

最后希望各位高抬贵手吧,为了大家能够有玩的感觉,我花了好长时间去搜索资料寻找思路来设计,虽然最后表现不佳。。我确实是比较菜,导致了这些问题,可能下次我这种菜鸡就只负责给个思路比较好了哈哈哈。


Pointer Guard赛题设计By Atum

这道题其实在第三届XCTF总决赛我(Atum)出过一次,只不过那一次题目没有平衡好攻守平衡的事情,所以导致到比赛后期所有的队都能够打全场,却没有人防御得住,我觉得这个题目本身还是很好玩的,于是打算吸取上一次的经验教训,重新出一次新的。

题目构思

  1. 读入blacklist,向选手leak出所有的地址。
  2. write_cnt_max次任意地址读写,在写之前会根据stack_enable,binary_enable,libc_enable判断指定区域是否允许写,若允许写,则会过相应的blacklist。
  3. func_max次任意函数调用,每次调用最多有argc_max个参数。调用前会对函数名过一个func_blacklist

根据这个流程,防御者需要通过建设blacklist来防止攻击者攻击成功,blacklist中的地址/函数将不会被允许写或者调用。而攻击者则需要通过绕过blacklist来拿shell。 而运维方可以根据具体形式调整以下参数

  1. write_cnt_max 任意地址写的次数
  2. func_max 任意函数调用的次数
  3. argc_max 每次函数调用允许的最多参数个数
  4. stack_enable 是否允许写stack
  5. binary_enable 是否允许写binary本身
  6. libc_enable 是否允许写libc

攻守设计

我设计这样的题目的主要意图是引导选手去寻找一些能够做劫持控制流乃至getshell的函数指针以及对应的程序执行路径。攻击方可以选择一些广为流传的路径进行攻击,比如free_hook+free+one_gadget,但是这些熟悉的路径对于防守方来说也很熟悉。所以防守方可以轻而易举的ban掉。如果攻击方能够花力气寻找到一些不被大多数人所熟悉的路径,那防守方则很难进行防御。

到目前为止,这个题目有点易攻难守。为了进一步平衡,题目允许防守方在攻击成功之后去查看被攻击方的blacklist,防守方可以通过这样来参考其他战队的防御手段,进而推断出他们的攻击手段。 另外,为了能够增加题目活跃度,刺激攻守双方积极的探索新的攻击路径,运维方可以通过调整参数来增加选手的做题难度,比如可以让攻击者做任意写的次数变少任意函数调用的次数变少等。

事实上,在比赛结束之后,这道题的已经被调整为攻击者只能进行一次任意写以及0次任意函数调用,而且在这个情况下,依然有战队可以攻击成功。

Pointer Guard赛题总结

相比传统的攻防题,我觉得这道题的攻防更有活力,也更能刺激选手去探索新的东西。不过在这次比赛,这道题目在上线时机,以及参数调整时机方面还有待改进。首先是上线太晚,导致选手没有足够的时间去探索新路径。其次是参数调整的时机太过于随意,没有规范化。以至于题目没有完全达到预期的效果。

编辑于 2019-10-27