在做 BUUCTF 上的 [2019 红帽杯] easyRE 时,出题人将我引导进了一篇看雪的文章:[原创] 看雪 CTF 从入门到存活(六)主动防御。由于时过境迁,评论区已经给出了正确的 flag,但在 2019 年的评论中,我们依然能体会到当时参赛者们满头问号的感受。这样一篇高视角的理论文章,到底对解题有多大作用呢?

实际上,出题人就是以该文章的理念设计了这么一道题目。

什么是 “主动防御”

在初期我们做的 CTF 题目中,出题人都是将 flag 获取逻辑放在一个函数中,然后设置障碍,我们称为 “被动防御”。虽然这些障碍可能难以跨越,但参赛者心中都有一条明确的思路:只要越过这些障碍,就能得到 flag。

与之相对的 “主动防御”,是指将参赛者引导到一条无法获取正确 flag 的路径。这条路径同样有一些障碍,参赛者往往会错判预期,认为只需要跨过这些障碍,就可以得到 flag,结果却折服于出题人的小巧思。

主动防御基于过程激励理论,该理论又有三大理论:期望理论、公平理论和强化理论。总结起来就是让参赛者认为 “此路可通” 并不断强化这种信念。

这本身需要出题人有一定水平,可以合理安排真假线索。

示例

我们先从头分析开头的题目。下面的伪代码已经经过我的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
__int64 sub_4009C6()
{
__int64 result;
int i;
__int64 v2;
__int64 v3;
__int64 v4;
__int64 v5;
__int64 v6;
__int64 v7;
__int64 v8;
__int64 v9;
__int64 v10;
__int64 v11;
_BYTE key[13];
_BYTE key1[4];
_BYTE key2[19];
_QWORD buf[5];
_QWORD buf_1[8];
char v17;
unsigned __int64 v18;

v18 = __readfsqword(0x28u);
qmemcpy(key, "Iodl>Qnb(ocy", 12);
key[12] = 127;
qmemcpy(key1, "y.i", 3);
key1[3] = 127;
qmemcpy(key2, "d`3w}wek9{iy=~yL@EC", sizeof(key2));
memset(buf, 0, 37);
bufRead(0, buf, 37);
BYTE4(buf[4]) = 0;
if ( bufLength(buf) == 36 )
{
for ( i = 0; i < bufLength(buf); ++i )
{
if ( (*(buf + i) ^ i) != key[i] )
{
result = 4294967294LL;
goto LABEL_13;
}
}
puts("continue!");
memset(buf_1, 0, sizeof(buf_1));
v17 = 0;
bufRead(0, buf_1, 64);
HIBYTE(buf_1[4]) = 0;
if ( bufLength(buf_1) == 39 )
{
v2 = base64Encode(buf_1);
v3 = base64Encode(v2);
v4 = base64Encode(v3);
v5 = base64Encode(v4);
v6 = base64Encode(v5);
v7 = base64Encode(v6);
v8 = base64Encode(v7);
v9 = base64Encode(v8);
v10 = base64Encode(v9);
v11 = base64Encode(v10);
if ( !flagCmp(v11, fakeFlag) )



{
puts("You found me!!!");
puts("bye bye~");
}
result = 0;
}
else
{
result = 4294967293LL;
}
}
else
{
result = 0xFFFFFFFFLL;
}
LABEL_13:
if ( __readfsqword(0x28u) != v18 )
StackErrorexit();
return result;
}

对 fakeFlag 进行十次 Base64 解码会得到我们所说的网址。但不是我们想要的 flag。

然而,在 fakeFlag 的下方,还有一串没有被使用的常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.data:00000000006CC0A0     
.data:00000000006CC0A0 byte_6CC0A0 db 40h
.data:00000000006CC0A0
.data:00000000006CC0A1 db 35h
.data:00000000006CC0A2 db 20h
.data:00000000006CC0A3 byte_6CC0A3 db 56h
.data:00000000006CC0A4 db 5Dh
.data:00000000006CC0A5 db 18h
.data:00000000006CC0A6 db 22h
.data:00000000006CC0A7 db 45h
.data:00000000006CC0A8 db 17h
.data:00000000006CC0A9 db 2Fh
.data:00000000006CC0AA db 24h
.data:00000000006CC0AB db 6Eh
.data:00000000006CC0AC db 62h
.data:00000000006CC0AD db 3Ch
.data:00000000006CC0AE db 27h
.data:00000000006CC0AF db 54h
.data:00000000006CC0B0 db 48h
.data:00000000006CC0B1 db 6Ch
.data:00000000006CC0B2 db 24h
.data:00000000006CC0B3 db 6Eh
.data:00000000006CC0B4 db 72h
.data:00000000006CC0B5 db 3Ch
.data:00000000006CC0B6 db 32h
.data:00000000006CC0B7 db 45h
.data:00000000006CC0B8 db 5Bh
.data:00000000006CC0B9 db 0

通过查询 byte_6CC0A0 的交叉引用,我们发现它被函数 sub_400D35() 使用了。分析该函数会发现 byte_6CC0A3 其实也属于 byte_6CC0A0,于是进行修复,得到最终的函数:

sub_400D35()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
unsigned __int64 __fastcall sub_400D35(__int64 a1, __int64 a2)
{
unsigned __int64 result;
unsigned int v3;
int i;
int j;
unsigned int v6;
unsigned __int64 v7;

v7 = __readfsqword(0x28u);
v3 = sub_43FD20(0) - qword_6CEE38;
for ( i = 0; i <= 1233; ++i )
{
sub_40F790(v3);
sub_40FE60();
sub_40FE60();
v3 = sub_40FE60() ^ 0x98765432;
}
v6 = v3;
if ( (v3 ^ realFlag[0]) == 'f' && (HIBYTE(v6) ^ realFlag[3]) == 'g' )
{
for ( j = 0; j <= 24; ++j )
sub_410E90(realFlag[j] ^ *(&v6 + j % 4));
}
result = __readfsqword(0x28u) ^ v7;
if ( result )
StackErrorexit();
return result;
}

循环 1 生成随机值 v3,然后赋值给 v6。接下来是一个判断,v3(取低位的 1 字节)和 realFlag 的第一个字符进行异或后等于 fv6 取高位的 1 字节和 realFlag 的第四个字符进行异或后得到 g,则进入第 2 个循环,将 realFlagv6 异或得到 flag

因此我们需要得到符合条件的 v6。根据进入循环 2 的条件,我们可以将 flagrealFlag 的前四个元素进行异或,得到正确的 v6,然后用 v6realFlag 异或得到 flag。

1
2
3
4
5
6
7
cipher = [0x40, 0x35, 0x20, 0x56, 0x5D, 0x18, 0x22, 0x45, 0x17, 0x2F, 0x24, 0x6E, 0x62, 0x3C, 0x27, 0x54, 0x48, 0x6C, 0x24, 0x6E, 0x72, 0x3C, 0x32, 0x45, 0x5B]
key = [0x40^ord('f'),0x35^ord('l'),0x20^ord('a'),0x56^ord('g')]
flag = ""
for i in range(len(cipher)):
flag += chr(cipher[i]^(key[i%4]))
print(flag)

题做完了,不过我们还想看一下出题人是怎么把这个函数埋入程序执行流程中的。

查询 sub_400D35() 的交叉引用,会发现这个函数是存放于 .fini_array 中的。在程序启动流程中,main 函数返回之后便会执行 __libc_csu_fini() 函数,静态编译下该函数会按降序取出 .fini_array 中的函数指针并执行。

Note

_fini_array 是一个特殊的 ELF 符号数组,用于存储在程序或共享对象的终止(清理)阶段将要执行的终止函数的地址。运行时的链接器 / 加载器会在程序或共享对象终止时依次调用这些函数,以完成清理工作。_fini_array 是 ELF 文件中的一部分,属于特殊的节 (section) 之一。这些特殊节在程序执行过程中具有特定的目的和执行顺序。

在 C 程序中,可以通过 __attribute__((destructor)) 指定一个函数在程序退出时执行,这些函数的地址会被放入 .fini_array 中。例如:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

void cleanup() __attribute__((destructor));

void cleanup() {
printf("Program is exiting. Cleaning up resources...\n");
}

int main() {
printf("Hello, World!\n");
return 0;
}

这个题目属于 “可执行代码段中没有被显式 CALL 的函数”,是准确反汇编的主要障碍之一。[1] 很显然,大量选手(包括我哈)都缺乏应对这类题的经验。在我耻辱下播查阅 WP 时,有相当数量的作者说自己没有想到这一点,只能参考别人的 WP。


  1. Disassembly Challenges ↩︎