2021GoogleCTF部分赛题writeup及赛后复现
这次比赛时做出两道题,分别是
- filestore
- cpp
最终排名193位做出题就算成功。
因为前一段时间比较忙还有自己瞎安排时间,导致屯着几场比赛的wp写了一半,这几天会整理好陆续放出。
Misc
filestore
这题的解题关键是理解程序中的“去冗余”算法得到爆破flag的思路。
附件给了源代码文件和nc地址,先nc过去看下程序的交互逻辑
1 | [root@centos ~]# nc filestore.2021.ctfcompetition.com 1337 |
再看源代码,源代码内容如下
1 | # filestore.py |
脚本开了一块64kb的存储空间,flag被预先写入这块空间中。我们与这块存储空间进行交互的方式有三种:
- 向里面写入一段数据,写入时执行了一定的策略(下详述)。
- 使用写入数据时生成的标识
fid
,读出相应的数据。 - 查看当前空间占用情况。
一种考虑是直接从存储空间中直接读出flag。flag内容也是通过脚本设计的load
接口写入的,写入时生成了一个对应的fid
用于读取flag。如果走这条路,我们需要拿到这个fid
,其是由python中的secrets
模块生成的,这里摘取python官方文档中对该模块的描述
The
secrets
module is used for generating cryptographically strong random numbers suitable for managing data such as passwords, account authentication, security tokens, and related secrets.
总之随机性是非常强的,所以不考虑从伪随机角度突破。fid
的格式是10位长的数字加字母组合,爆破显然也不可取,故直接读出flag这条路应该是堵死了。
接下来具体分析store
方法具体内容
1 | # Use deduplication to save space. |
传入数据后,先从中取出最多前16字节的数据。
1 | prefix = data[:MINIMUM_BLOCK] |
接下来方法尝试从整个数据块blob
中匹配出所有与这前16字节数据相同的数据片段,如果有,则逐个计算剩余的传入数据(因为传入的数据是每16个字节逐块处理的,当前一个块被处理完后,对下一轮循环来说,它的传入数据指的是去掉已经处理过的数据块的数据,所以说是剩余的传入数据。这里看代码可能比文字描述更好理解。)与这些匹配的数据片段的重合部分的长度,得到这些长度中的最大值bestlen
。
1 | ind = -1 |
接下来是实际存储的部分,判断如果找到有重合部分,则标记已经存储了重合部分,而不把这部分重合数据再存一遍,也就是deduplication;如果没有重合部分,则存储16个字节并标记。
1 | if bestind != -1: |
剩余的传入数据将被交给下一轮循环处理。
也就是说,既然flag已经储存在blob
中,当我们传入的内容和flag片段完全重合时,是不会多占用任何空间的。故我们可以逐位爆破flag,当爆破到正确的字符时,占用的空间会比其他字符更少。
我们可以简单验证一下。
1 | [root@centos ~]# nc filestore.2021.ctfcompetition.com 1337 |
思路有效,接下来写脚本爆破,这里我套用了以前做一道类似的题(这道题也很有意思,不妨看一下)写的脚本,爆这题会比较慢,可以自行优化下
1 | from pwn import * |
爆到一半assert
报错了,看一下输出信息
1 | ... |
此时flag恰好爆出16位,反应过来问题在于爆破第17个字符时,如果当前测试的是错误的字符,那么程序会先以“去冗余”的方法存入前面16个字节,然后单独处理第17个字符。只要这个字符在flag中出现过,就不会占用新的存储空间,相应地如果没有出现,则增加空间占用。所以这里我们要选定一个字符作为基准开始爆破,运气好的话这个字符就是第17个字符,可以一路爆完整个flag。
我在这个地方绕了点弯路,去google翻译看了下ded
开头的单词后面大多跟i
,所以从i
开始爆了,爆出来是ic4ti0n}
,拼起来正好是CTF{CR1M3_0f_d3dic4ti0n}
~~嫌疑人的献身?~~还挺顺,交上去提示不是正确的flag。
根据前面的分析我们可以得到flag中都有些什么字符,整理出来如下
1 | 0134CFMRTcdfinptu_{} |
发现还有u
和p
没出现,原来flag是CTF{CR1M3_0f_d3dup1ic4ti0n}
,确实和题意比较相符。
推荐题解
来自队伍Jump2Flag 这篇文章用了一个小技巧,即每次测试时只发送当前测试的字符和之前的3个字符,这样就可以规避笔者爆破第17个字符时遇到的问题了。
来自队伍Social Engineering Experts 这篇文章思路非常巧妙,先爆出flag中有哪些字符后,依次爆出flag中有哪些2个字符的组合、4个字符的组合……直到16个字符的组合,然后就可以拼凑出flag了。
Reverse
cpp
把代码都分析懂,还原出程序逻辑后发现可以逐位爆破flag,主要是费体力,化整为零,没什么技巧。分析,就嗯分析,不就是6k行代码吗
给了c源码附件,看过去全是宏,头大。
先直接运行看看
报错,删掉报错信息对应的语句就可以正常编译并运行程序。
先简单看看程序大概干了些什么。
- 16-42行,让你输入flag,需要按照规定的格式(例如
CHAR_C
)设置字符。 - 45-109行,将规定格式的字符转成数值,就是字符对应的ascii值。
- 112-839行,存放
ROM
常量。 - 840-1919行,将用户设置的flag值写入到
ROM
。 - 1920-1925行,定义了一些操作。
- 1926-6215行,当满足一定的
include
层数后,一大堆#define
、#undef
等操作,这里应该是涉及到验证flag正确性的核心逻辑。 - 6216-6223行,套娃,
include
层数不够时include
本身。 - 6224-end行,如果满足条件
S == -1
,则输出拿到有效key的提示,否则编译时报异常。
这里需要意识到题目的想法应该是要你输入一个flag作为key,程序用宏实现了一套DRM(Digital rights management,数字版权管理)方案,当你输对了flag时就能满足上面说的正常运行程序提示key有效的条件。
代码中多次出现__INCLUDE_LEVEL__
,查资料得知这是一个预定义的宏,这里援引文档
This macro expands to a decimal integer constant that represents the depth of nesting in include files. The value of this macro is incremented on every ‘#include’ directive and decremented at the end of every included file. It starts out at 0, its value within the base file specified on the command line.
再结合一些简单的例子,大致理解这个宏的用途。当预处理器预处理源码文本时,对于一开始处理的这个cpp.c
(本题中源码的名称),其__INCLUDE_LEVEL__
为0
,当处理到1926行的判断条件时
1 |
此时__INCLUDE_LEVEL__
过小,故跳过核心验证逻辑部分,而转到6216行开始include
自身的部分
1 |
如此反复,直到当前include
的cpp.c
文件的__INCLUDE_LEVEL__
层数达到13才进入核心验证逻辑,而不再include
自身。
这里为什么要反复include
源码文件本身这么多次?~~可能是为了拖时间。~~一种可能的情况是核心逻辑的重复执行会造成一种“累加效应”,以实现校验作用。具体是不是这样需要我们进一步分析下去。
首先考虑到程序有大量嵌套的条件判断,我们最好把代码格式化成可读性更强的形式。这里我写了一个python脚本对源码作缩进处理:
1 | import re |
随便节选一部分代码看看效果,这玩意没缩进看起来还是蛮难受的。。。
1 | ... |
继续分析程序逻辑,我们得到几点基本认识:
- 核心逻辑都被包裹在
#if S == number
形式的语句块中, - 我们可以认为,代码用宏定义的方式,实现了为变量赋值的效果。
字母 + 0-7范围内的数字
,例如A6
,可以认为表示一个8位宽数值变量从LSB到MSB顺序的第7位(即第二高位)。某个“变量”的某一“位”处于被宏定义(例如#define A6
)或者未被宏定义(例如#undef A6
)的状态,便可以认为是这个变量的第几位是0还是1。 - 每
include
一次这个文件,都只能执行一个语句块里的内容,所以需要include
多次,累加起来实现程序功能。
先尝试分析得到从某个语句块可以跳转到另外某几个语句块的跳转关系,以使我们对程序有一个整体感受。这里我用了脚本提取+手撸的方法,因为写的脚本判断跳转是根据某个语句块里出现#define S number
的情况,但实际上有一些这种语句是没有意义的。
1 | # ... |
手动处理完后结果如下,元组中第一个元素表示S
等于多少时的语句块,第二个元素对应这个语句块可能跳转到的语句块
1 | (0, [24]) |
接下来开始逐个分析每个语句块的功能,从结果来说,根据代码总结可以得到语句块实际上实现的若干种功能:
A = 01010101
、A = B
(赋值,例子随便举的)A ^= B
(异或等于)A &= B
(与等于)A |= B
(或等于)A ~= A
(取反等于)A += B
(加等于)if A == value then #define S = number1 else #define S = number2
(条件跳转)- ``(取ROM)
这里先放张图,是程序完整逻辑的草稿,有一些细节是有问题的,主要是提供一个对程序逻辑的直观印象。
这里选几种功能来分析对应的代码“模式”
赋值
1 | // 赋值为常量,下面一组语句等效于 B = 00100000 |
异或等于
我们可以基于程序对每一位的操作画一张真值表,对于在代码中直接体现出来的情况,记录操作后的结果;如果某种情况没有出现,则用"keep"表示值不变。
C0 | A0 | final A0 |
---|---|---|
1 | 1 | 0 |
1 | 0 | 1 |
0 | 0 | keep |
0 | 1 | keep |
整理发现,这不就是异或运算嘛,所以下面这一段代码就是变量的异或等于运算。
1 | // A ^= C |
与等于、或等于、取反等于三种操作也可以类似用真值表去判断,这里不作分析。但是提一下加等于,因为这不是简单一位和一位进行位运算,而要涉及到进位规则。最开始我错把其分析成了异或等于运算,重新分析发现是个不知道什么运算,用几个例子推了一下,惊觉怎么和加法一样,然后对了一遍全加器的真值表确定这是加法运算。
加等于
加法是需要进位规则的,代码在加等于部分的代码中引入了变量c
,不带数字表示第几位而仅仅是一个字母"c",用来标志进位(carry)。不同于这道题中出现的其他运算,加等于是唯一在运算过程中不同位之间会互相影响的。
依旧是列出真值表,列出表之后很容易看出这套运算是加法,有一点数字逻辑知识可能会反应更快。虽然画真值表来判断位运算感觉有些繁琐,但确实很可靠。
I0 | A0 | c | final I0 | next c |
---|---|---|---|---|
0 | 0 | 1 | 1 | 0 |
0 | 1 | 0 | 1 | 0 |
1 | 0 | 1 | 0 | 1 |
1 | 1 | 0 | 0 | 1 |
0 | 0 | 0 | keep | keep |
0 | 1 | 1 | keep | keep |
1 | 0 | 0 | keep | keep |
1 | 1 | 1 | keep | keep |
非常巧妙,代码里只是列出了计算后I0
或c
可能变化的4种情况,剩下4种就不用列了。
分析到就加法我们心里就知道,这道题有可能以比较容易理解的算法设计了循环,而我们逆向的时候理解起来也可以舒服点了。
1 | // I += A |
条件跳转
很容易理解,看看就好
1 | // if R == 0 then S = 59 else S = 8 |
取ROM
这里要结合几个宏定义的“函数”来看,程序利用这几个宏定义实现了从ROM中取值的功能。
1 |
举一段例子来分析
1 | /* |
一组完整的例子如下
1 | // l = I |
到这里就整理出了各个语句块的具体功能,接下来我们还要对这些功能进行整理合并,还原得到算法,这里我还原后的C实现如下
1 |
|
其中,ROM的值是拿脚本提取出来的
1 | import re |
在逆向算法的过程中,我们应当注意到的非常重要的一点是,只要key的任何一位不满足条件,用于判断key正确与否的关键变量Q
都会在这一位对应的一轮循环中不可逆地标记key为错误的(具体是因为Q |= A
这个运算),这意味着key是可以逐位爆破的。这里我基于上面的c代码小改了一下用于爆破:
1 |
|
爆破结果为CTF{pr3pr0cess0r_pr0fe5sor
,加上}
得到正确flag。