CIDY
[System_Hacking] Linux Exploitation 본문
포너블에서 이용되는 linux exploitation에는 다양한 기법이 존재하겠지만.. 복습 겸 조금 생소한? 것들만 가볍게 정리하고 넘어가겠다.
.init_array & .fini_array
위와 같이 gdb에서 elfheader명령어를 수행하면 바이너리에 존재하는 여러 섹션들을 볼 수 있다. 이러한 섹션들은 소스코드가 빌드될 때 컴파일러에 의해 만들어지는데, .init_array와 .fini_array는 바이너리가 실행되고 종료될 때 참조하는 함수 포인터들이 저장되어 있는 섹션이다.
void usercall noreturn start(__int64 a1@<rax>, void (*a2)(void)@<rdx>)
{
...
__libc_start_main(main, v2, &_0, _libc_csu_init, _libc_csu_fini, a2, &v3);
위 함수는 바이너리가 처음 실행될 때 호출되는 start라는 함수이다. start에서는 바이너리 실행 과정에서 필요한 여러 요소들을 초기화하기 위해 내부적으로 __libc_start_main이라는 함수를 호출한다. 이 과정에서 .init_array와 .init_array라는 섹션을 참조하는데, 이는 main함수 호출 전에 __libc_start_main내부에서 수행되는 __libc_csu_init함수에 의해 실행된다.
void
__libc_csu_init (int argc, char **argv, char **envp)
{
/* For dynamically linked executables the preinit array is executed by
the dynamic linker (before initializing any shared object). */
#ifndef LIBC_NONSHARED
/* For static executables, preinit happens right before init. */
{
const size_t size = __preinit_array_end - __preinit_array_start;
size_t i;
for (i = 0; i < size; i++)
(*__preinit_array_start [i]) (argc, argv, envp);
}
#endif
#ifndef NO_INITFINI
_init ();
#endif
const size_t size = __init_array_end - __init_array_start;
for (size_t i = 0; i < size; i++)
(*__init_array_start [i]) (argc, argv, envp);
}
.init_array섹션의 크기를 계산해 그 size만큼 .init_array에 저장된 함수 포인터들을 순차적으로 호출하고 있다. (종료 시에도 __libc_start_main -> __GI_exit -> _run_exit_handlers -> _dl_fini에서 이와 유사하게 .fini_array에 저장된 함수 포인터를 호출한다.)
.init_array섹션과 .fini_array섹션을 열어보면 위와 같이 함수 포인터가 존재한다. 함수 종료 과정에서 참조되는 .fini_array를 덮어쓸 수 있다면 실행 흐름을 조작할 수 있게 된다.
void exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true, true);
}
main함수가 종료한 뒤, __libc_start_main에서는 프로그램을 종료를 위해 __GI_exit함수를 호출하는데, 위와 같다.
void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
bool run_list_atexit, bool run_dtors)
{
/* First, call the TLS destructors. */
#ifndef SHARED
if (&__call_tls_dtors != NULL)
#endif
if (run_dtors)
__call_tls_dtors ();
...
const struct exit_function *const f = &cur->fns[--cur->idx];
switch (f->flavor)
{
void (*atfct) (void);
void (*onfct) (int status, void *arg);
void (*cxafct) (void *arg, int status);
case ef_free:
case ef_us:
break;
case ef_on:
onfct = f->func.on.fn;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (onfct);
#endif
onfct (status, f->func.on.arg);
break;
case ef_at:
atfct = f->func.at;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (atfct);
#endif
atfct ();
break;
case ef_cxa:
cxafct = f->func.cxa.fn;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (cxafct);
#endif
cxafct (f->func.cxa.arg, status);
break;
}
}
__GI_exit에서 호출하는 __run_exit_handlers는 보통 _dl_fini함수를 호출하게 된다.
struct exit_function
{
/* `flavour' should be of type of the `enum' above but since we need
this element in an atomic operation we have to use `long int'. */
long int flavor;
union
{
void (*at) (void);
struct
{
void (*fn) (int status, void *arg);
void *arg;
} on;
struct
{
void (*fn) (void *arg, int status);
void *arg;
void *dso_handle;
} cxa;
} func;
};
__run_exit_handelrs함수는 exit_function구조체의 멤버 변수인 flavor값에 따라 함수를 호출하는데, 그게 별 일 없으면 _dl_fini인 것이다.
0x7ffff7de7dc9 <_dl_fini+777>: mov r12,QWORD PTR [rax+0x8]
...
0x7ffff7de7de5 <_dl_fini+805>: je 0x7ffff7de7e00 <_dl_fini+832>
0x7ffff7de7de7 <_dl_fini+807>: nop WORD PTR [rax+rax*1+0x0]
0x7ffff7de7df0 <_dl_fini+816>: mov edx,r13d
=> 0x7ffff7de7df3 <_dl_fini+819>: call QWORD PTR [r12+rdx*8]
그리고 _dl_fini내부에서는 위와 같은 과정으로 .fini_array를 호출하게 된다.
따라서 .fini_array를 덮어쓴다면 프로그램 종료 과정에서 실행 흐름을 조작할 수 있다. 단, full relro일 경우 .init_array및 .fini_array에 write권한이 없기 때문에 overwrite가 불가능하다.
_rtld_global
full relro의 경우 .init_array, .fini_array에도 write권한이 없기 때문에 곤란한 상황이 발생한다. 이 때 hook변수와 더불어 덮을 수 있는 또다른 것이 바로 _rtld_global구조체의 멤버들이다.
void
_dl_fini (void)
{
for (Lmid_t ns = GL(dl_nns) - 1; ns >= 0; --ns)
{
/* Protect against concurrent loads and unloads. */
__rtld_lock_lock_recursive (GL(dl_load_lock));
unsigned int nloaded = GL(dl_ns)[ns]._ns_nloaded;
/* No need to do anything for empty namespaces or those used for
auditing DSOs. */
if (nloaded == 0
__rtld_lock_unlock_recursive (GL(dl_load_lock));
앞서 말한 _dl_fini코드의 일부이다. _dl_fini는 dl_load_lock을 인자로 __rtld_lock_lock_recursive를 호출한다. (둘 다 _rtld_global구조체의 멤버들이다.) _rtld_global구조체가 위치한 영역에는 write권한이 있기 때문에 위 함수 포인터와 인자를 덮어서 system("/bin/sh")를 호출하면 쉘을 얻을 수 있는 것이다.
gdb에서
p &_rtld_global._dl_rtld_lock_recursive
p &_rtld_global._dl_load_lock
을 치면 각각 함수 포인터와 인자이고, 이를 덮을 수 있다면 실행 흐름 조작이 가능하다. (vmmap명령어로 매핑을 보면 write권한이 존재한다.)
단, 위 아이디어로 system("/bin/sh")를 완성하기 위해서는 두 번의 overwrite기회가 필요하다. 만약 바이너리에서 overwrite기회를 한 번만 주었다면, _dl_rtld_lock_recursive를 start함수 주소로 바꾸어 overwrite기회를 여러 번 가져올 수도 있다.
Master Canary
마스터 카나리는 main함수 호출 이전에 랜덤으로 생성된 카나리를 스레드 별 전역 변수로 사용되는 TLS(Thread Local Storage)에 저장한다. (모든 스레드가 하나의 카나리값을 공유하도록 하기 위해)
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
int gscope_flag;
#ifndef __ASSUME_PRIVATE_FUTEX
int private_futex;
#else
int __glibc_reserved1;
#endif
/* Reservation of some values for the TM ABI. */
void *__private_tm[4];
/* GCC split stack support. */
void *__private_ss;
} tcbhead_t;
TLS는 tcbhead_t구조체를 가지고, 이는 위와 같다.
static void
security_init (void)
{
/* Set up the stack checker's canary. */
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);
#ifdef THREAD_SET_STACK_GUARD
THREAD_SET_STACK_GUARD (stack_chk_guard);
#else
__stack_chk_guard = stack_chk_guard;
#endif
security_init함수는 _dl_setup_stack_chk_guard함수에서 반환된 랜덤한 카나리값(stack_chk_guard)을 설정한다.
#define THREAD_SET_STACK_GUARD(value) \
THREAD_SETMEM (THREAD_SELF, header.stack_guard, value)
그리고 THREAD_SET_GUARD 매크로를 통해 TLS의 header.stack_guard에 카나리값을 넣는다.
void *
internal_function
_dl_allocate_tls_storage (void)
{
void *result;
size_t size = GL(dl_tls_static_size);
#if TLS_DTV_AT_TP
/* Memory layout is:
[ TLS_PRE_TCB_SIZE ] [ TLS_TCB_SIZE ] [ TLS blocks ]
^ This should be returned. */
size += (TLS_PRE_TCB_SIZE + GL(dl_tls_static_align) - 1)
& ~(GL(dl_tls_static_align) - 1);
#endif
/* Allocate a correctly aligned chunk of memory. */
result = __libc_memalign (GL(dl_tls_static_align), size);
TLS영역은 _dl_allocate_tls_storage함수에서 __libc_memalign함수를 호출해 할당한다.
SSP보호기법이 걸린 바이너리를 열어보면 fs:0x28이 참조하는 주소가 TLS의 header.stack_guard인 것을 찾아볼 수 있다. 이 마스터 카나리를 공격자가 원하는 값으로 조작하는 게 가능하다면 카나리를 뚫을 수 있을 것이다.
하지만 일반 스택에는 마스터 카나리값이 존재하지 않으므로 마스터 카나리를 덮은 것이 불가능하다.
// gcc -o master2 master2.c -pthread
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void giveshell()
{
execve("/bin/sh",0,0);
}
int thread_routine() {
char buf[256];
int size = 0;
printf("Size: ");
scanf("%d", &size);
printf("Data: ");
read(0, buf, size);
return 0;
}
int main()
{
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
pthread_t thread_t;
if (pthread_create(&thread_t, NULL, thread_routine, NULL) < 0)
{
perror("thread create error:");
exit(0);
}
pthread_join(thread_t, 0);
return 0;
}
하지만 위와 같은 스레드 함수인 thread_routine함수의 스택은 TLS영역과 인접한 영역에 할당된다. 즉 overflow가 발생할 경우 TLS에 있는 마스터 카나리까지 덮을 수 있는 것이다.
#define THREAD_COPY_STACK_GUARD(descr) \
((descr)->header.stack_guard \
= THREAD_GETMEM (THREAD_SELF, header.stack_guard))
int
__pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg)
{
...
#ifdef THREAD_COPY_STACK_GUARD
THREAD_COPY_STACK_GUARD (pd);
#endif
/* Copy the pointer guard value. */
#ifdef THREAD_COPY_POINTER_GUARD
THREAD_COPY_POINTER_GUARD (pd);
#endif
/* Verify the sysinfo bits were copied in allocate_stack if needed. */
#ifdef NEED_DL_SYSINFO
CHECK_THREAD_SYSINFO (pd);
#endif
...
}
위는 pthread_create함수의 코드 일부이다. tcbhead_t구조체의 stack_guart를 복사해서 사용하기 때문에 stack_guard를 덮어쓰면 마스터 카나리를 원하는 값으로 조작할 수 있게 된다.
위에 살짝 보이는 함수는 thread_routine함수이다. bp를 걸고 쭉 실행시키면 위와 같이 실행된다.
rsi를 열어보면 이게 스레드 스택이다.
위치로 보건대 표시한 주소가 master canary인것같다. 가 아니고 그 위에 있는 ~~4728이 마스터 카나리이다. (p $fs_base + 0x28) 따라서 thread_routine에서 발생하는 오버플로우를 통해 마스터 카나리를 조작할 수 있게 된다.
Environ ptr
라이브러리에서 프로그램의 환경 변수를 참조해야 할 일이 있기 때문 이를 위해 libc.so.6에는 environ포인터가 존재하고, 이는 프로그램의 환경 변수를 가리키는 포인터이다. environ포인터는 로더의 초기화 함수에 의해 초기화된다.
한 마디로, environ변수 자체는 라이브러리 내부에 존재하지만 그것이 가리키는 곳은 스택 어딘가의 주소라는 말이다. libc leak이 되고, 그것이 가리키는 값을 알 수 있으면 스택 주소 역시 얻을 수 있게 되는 것이다.
Seccomp
seccomp은 리눅스 커널에서 프로그램의 샌드박싱 매커니즘을 제공하는 컴퓨터 보안 기능이다. seccomp은 호출 가능한 시스템 콜을 제한하는 방식으로 이루어진다. 만약 허용되지 않는 시스템 콜이 들어오면 프로그램을 종료시켜 버린다.
seccomp에는 STRICT_MODE와 FILTER_MODE 두 종류가 있다.
static int mode1_syscalls[] = {
__NR_seccomp_read, __NR_seccomp_write, __NR_seccomp_exit, __NR_seccomp_sigreturn,
0, /* null terminated */
};
#ifdef CONFIG_COMPAT
static int mode1_syscalls_32[] = {
__NR_seccomp_read_32, __NR_seccomp_write_32, __NR_seccomp_exit_32, __NR_seccomp_sigreturn_32,
0, /* null terminated */
};
#endif
int __secure_computing(int this_syscall)
{
int mode = current->seccomp.mode;
int exit_sig = 0;
int *syscall;
u32 ret;
switch (mode) {
case SECCOMP_MODE_STRICT:
syscall = mode1_syscalls;
#ifdef CONFIG_COMPAT
if (is_compat_task())
syscall = mode1_syscalls_32;
#endif
do {
if (*syscall == this_syscall)
return 0;
} while (*++syscall);
exit_sig = SIGKILL;
ret = SECCOMP_RET_KILL;
break;
...
}
이게 STRICT_MODE이고,
int __secure_computing(int this_syscall)
{
int mode = current->seccomp.mode;
int exit_sig = 0;
int *syscall;
u32 ret;
switch (mode) {
case SECCOMP_MODE_FILTER: {
int data;
ret = seccomp_run_filters(this_syscall);
data = ret & SECCOMP_RET_DATA;
ret &= SECCOMP_RET_ACTION;
switch (ret) {
case SECCOMP_RET_ERRNO:
...
case SECCOMP_RET_TRAP:
...
case SECCOMP_RET_TRACE:
...
return 0;
case SECCOMP_RET_ALLOW:
return 0;
case SECCOMP_RET_KILL:
default:
break;
}
이게 FILTER_MODE이다.
스트릭트 모드는 read, write, exit, sigreturn 네 가지만 허용하고 나머지는 SIGKILL한다. 필터 모드는 특정 시스템 콜을 허용 혹은 제한할 수 있다. 필터링을 적용할 때 BPF(Berkeley Packet Filter)라는 네트워크 패킷 필터링에 사용되는 매커니즘이 사용된다.
prctl함수의 인자로 PR_SET_SECCOMP를 전달하면 seccomp을 활성화할 수 있다.
int syscall_filter() {
#define syscall_nr (offsetof(struct seccomp_data, nr))
#define arch_nr (offsetof(struct seccomp_data, arch))
/* architecture x86_64 */
#define REG_SYSCALL REG_RAX
#define ARCH_NR AUDIT_ARCH_X86_64
struct sock_filter filter[] = {
/* Validate architecture. */
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, arch_nr),
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, ARCH_NR, 1, 0),
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL),
/* Get system call number. */
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, syscall_nr),
/* List allowed syscalls. */
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_rt_sigreturn, 0, 5),
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_open, 0, 4),
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_openat, 0, 3),
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_execve, 0, 2),
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_execveat, 0, 1),
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_KILL),
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter)/sizeof(filter[0])),
.filter = filter,
};
if ( prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1 ) {
perror("prctl(PR_SET_NO_NEW_PRIVS)\n");
return -1;
}
if ( prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == -1 ) {
perror("Seccomp filter error\n");
return -1;
}
return 0;
}
이런 식으로 BPF_JEQ를 넣어서 특정 시스템 콜이 아니면 SECCOMP_RET_ALLOW로 분기한다. (deny list)
https://orcinus-orca.tistory.com/63
[System_Hacking] AD: stage2_SECCOMP
*sandbox 샌드박스는 외부의 공격으로부터 시스템을 보호하기 위해 설계된 보호 기법이다. -> Allow List와 Deny list 두 가지를 선택 적용할 수 있다. -> 프로그램의 기능 수행에 있어 꼭 필요한 시스템
orcinus-orca.tistory.com
자세한 건 여기서 보면 좋을 것 같다.
#define __X32_SYSCALL_BIT 0x40000000UL
// common.c
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
struct thread_info *ti;
enter_from_user_mode();
local_irq_enable();
ti = current_thread_info();
if (READ_ONCE(ti->flags) & _TIF_WORK_SYSCALL_ENTRY)
nr = syscall_trace_enter(regs);
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
#ifdef CONFIG_X86_X32_ABI
} else if (likely((nr & __X32_SYSCALL_BIT) &&
(nr & __X32_SYSCALL_BIT) < X32_NR_syscalls)) {
nr = array_index_nospec(nr & ~__X32_SYSCALL_BIT,
X32_NR_syscalls);
regs->ax = x32_sys_call_table[nr](regs);
#endif
}
syscall_return_slowpath(regs);
}
그리고 do_syscall_64함수에서는 시스콜 번호를 나타내는 nr변수가 sys_call_table배열의 인덱스로 사용된다. 이때 nr솨 0x40000000의 결과가 0이 아닐 경우 nr에서 저 4을 없앤다. (nr & ~__X32_SYSCALL_BIT) 따라서 0x40000000과 or연산해서 시스템 콜 번호를 삽입하면 필터링을 우회해서 가져다 쓸 수 있다.
https://orcinus-orca.tistory.com/65
[System_Hacking] AD: stage2_ABI(+예제)
#include #include #include #include #include #include #include #include #include #include #include #define DENY_SYSCALL(name) \ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, __NR_##name, 0, 1), \ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_KILL) #define MAINTAIN_PROCESS B
orcinus-orca.tistory.com
이것도 같이 보면 좋을듯
SROP
https://orcinus-orca.tistory.com/75
[System_Hacking] AD: stage5_SigReturn Oriented Programming
*Signal 예전에 syscall에 대해 설명하면서 (Ck수업) 운영체제는 유저모드와 커널 모드로 나뉜다고 한 적이 있다. 프로그램을 실행하는 과정은 이 두 모드가 상호작용하며 이루어진다. 시그널은 프로
orcinus-orca.tistory.com
일단 이거 참고하면 좋을듯.
리눅스에서는 시그널이 들어오면 커널모드에서 처리한다. 커널모드에서 다시 유저모드로 돌아와야 하는데, 이 때 유저모드의 스택에 레지스터 정보를 모두 저장했다가 돌아올 때 다시 레지스터에 넣는다. rt_sigreturn시스콜(15)은 저장해둔 정보들을 다시 돌려둘 때 사용된다.
rt_sigreturn시스콜을 호출할 수 있다면 모든 레지스터와 세그먼트를 조작할 수 있고, 이를 SROP라고 한다.
pwntools에섯 SigreturnFrame이라는 클래스를 이용하면 딱 위치에 알맞게 값을 세팅해줘서 편하게 SROP공격을 할 수 있다. syscall할 때 스택에 상위부터 위 frame이 들어있으면 되고, 페이로드랑 같이 frame을 바이트로 인코딩해서 보내주면 된다. (bytes(frame))
_IO_FILE
https://orcinus-orca.tistory.com/78
[System_Hacking] AD: stage6__IO_FILE
파일을 열 때는 접근 유형을 명시해야 함 -> 읽기모드, 쓰기모드 등... -> 파일 관련 함수들은 fopen이 반환한 파일 포인터를 토대로 파일 정보를 확인한다. -> 파일 작업 이해를 위해서는 파일 구조
orcinus-orca.tistory.com
일단 이거 참고하면 좋을듯. 위 글은 머리에 든 거 없을때 적은거라 지금 글이 더 매끄러울것 같기는 함.
_IO_FILE은 이래저래 재미있는 부분이 많다. 프로그램 내부에서 파일을 fopen, fread, fwrite등.. 할 일이 있으니 그 열어온 파일을 관리하는 구조체가 필요하다. _IO_FILE이 바로 리눅스 시스템 표준 라이브러리에서 파일 스트림을 나타내는 구조체이다. fopen으로 파일 스트림을 열면 힙에 이 구조체가 할당된다.
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
_IO_FILE구조체는 이렇게 생겼다.
_flags : 4바이트(상위4바이트는 안 쓰이도록 정렬), 파일 권한 표시, 0xfbad0000을 매직값으로 한다.
_IO_read_ptr : 파일 읽기 버퍼 포인터
_IO_read_end : 파일 읽기 버퍼 끝 포인터
_IO_read_base : 파일 읽기 버퍼 시작 포인터
_IO_write_base : 파일 쓰기 버퍼 시작 포인터
_IO_write_ptr : 파일 쓰기 버퍼 포인터
_IO_write_end : 파일 쓰기 버퍼 끝 포인터
_chain : _IO_FILE구조체형 포인터이다. 이 필드로 _IO_FILE구조체 링크드 리스트를 만든다. 링크드 리스트의 헤더는 라이브러리 전역 변수인 _IO_list_all에 저장됨.
_fileno : 4바이트, fd값이다.
#define _IO_MAGIC 0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK 0xFFFF0000
#define _IO_USER_BUF 0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED 0x0002
#define _IO_NO_READS 0x0004 /* Reading not allowed. */
#define _IO_NO_WRITES 0x0008 /* Writing not allowed. */
#define _IO_EOF_SEEN 0x0010
#define _IO_ERR_SEEN 0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close. */
#define _IO_LINKED 0x0080 /* In the list of all open files. */
#define _IO_IN_BACKUP 0x0100
#define _IO_LINE_BUF 0x0200
#define _IO_TIED_PUT_GET 0x0400 /* Put and get pointer move in unison. */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING 0x1000
#define _IO_IS_FILEBUF 0x2000
/* 0x4000 No longer used, reserved for compat. */
#define _IO_USER_LOCK 0x8000
위는 _flag값들이다.
FILE * _IO_new_file_fopen (FILE *fp, const char *filename, const char *mode, int is32not64)
{
int oflags = 0, omode;
int read_write;
int oprot = 0666;
int i;
FILE *result;
const char *cs;
const char *last_recognized;
if (_IO_file_is_open (fp))
return 0;
switch (*mode)
{
case 'r':
omode = O_RDONLY;
read_write = _IO_NO_WRITES;
break;
case 'w':
omode = O_WRONLY;
oflags = O_CREAT|O_TRUNC;
read_write = _IO_NO_READS;
break;
case 'a':
omode = O_WRONLY;
oflags = O_CREAT|O_APPEND;
read_write = _IO_NO_READS|_IO_IS_APPENDING;
break;
...
}
fopen시 _IO_FILE구조체를 생성하면서 플래그도 설정되는데, fopen함수에서 내부적으로 호출하는 _IO_new_file_fopen함수에서 전달받은 mode에 따라 oflags에 or연산으로 플래그값을 추가한다.
// gcc -o file2 file2.c
#include <stdio.h>
int main()
{
char file_data[256];
int ret;
FILE *fp;
strcpy(file_data, "AAAA");
fp = fopen("testfile","r");
fread(file_data, 1, 256, fp);
printf("%s",file_data);
fclose(fp);
}
이 프로그램을 컴파일해서 직접 돌려보자.
fopen이후 힙에 0x1e0짜리 청크가 생성된 것을 볼 수 있다. 근데 왜 초기화되어 있을까?
지금 free돼서 fd랑 bk에 있어야 할 _flags랑 읽기 포인터가 망가지긴 했다. 내가 열어야 할 testfile을 만들지 않고 파일을 실행시켜서 fopen자체가 생성되다 만 것이었다. fclose전까지 free되지는 않을 것이다.
testfile을 생성해주자 정상적으로 _IO_FILE구조체가 생성되었다.
fread하니까 이런저런 버퍼 포인터들이 구조체에 적혔다. 탑청크가 사라진것은 입력 버퍼를 사용하도록 설정했기 때문인 것 같다. 저 포인터가 어디를 가리키고 있나 보니 입력 버퍼의 시작점을 가리키고 있다. 아마 정상적으로 읽어온 다음 버퍼 비우면서 포인터들도 다시 시작점을 가리키도록 한 것 아닐까 생각해본다.
그런데 사실 위에서 보이는 구조체는 _IO_FILE이 아니고 _IO_FILE_plus이다.
struct _IO_FILE_plus
{
_IO_FILE file;
const struct _IO_jump_t *vtable;
};
_IO_FILE_plus는 _IO_FILE구조체 + _IO_jump_t형 포인터이다.
_IO_jump_t는 파일 관련 여러 동작들을 수행하는 함수 포인터들이 저장되어 있는 테이블이다.
const struct _IO_jump_t _IO_file_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_file_finish),
JUMP_INIT(overflow, _IO_file_overflow),
JUMP_INIT(underflow, _IO_file_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_default_pbackfail),
JUMP_INIT(xsputn, _IO_file_xsputn),
JUMP_INIT(xsgetn, _IO_file_xsgetn),
JUMP_INIT(seekoff, _IO_new_file_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_new_file_setbuf),
JUMP_INIT(sync, _IO_new_file_sync),
JUMP_INIT(doallocate, _IO_file_doallocate),
JUMP_INIT(read, _IO_file_read),
JUMP_INIT(write, _IO_new_file_write),
JUMP_INIT(seek, _IO_file_seek),
JUMP_INIT(close, _IO_file_close),
JUMP_INIT(stat, _IO_file_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};
그런 포인터들의 동작 묶음 테이블은 여러 개 있는데 그 중 _IO_jump_t가 _IO_FILE_plus구조체에서 사용되는 친구이다.
그리고 stdin, stdout, stderr는 라이브러리에 의해 프로세스 시작 시 기본적으로 생성되는 파일 스트림이다. (라이브러리 내부에 위치)
_IO_FILE_plus에 있는 vtable이라는 포인터를 참조하고 거기 있는 값에 offset계산해서 위 테이블 중 어떤 함수 포인터를 수행할지 내부적으로 결정하기 때문에 vtable자체를 적당히 계산해서 덮어버리면 실행 흐름을 조작할 수 있다.
그런데 16.04이후로는 vtable포인터가 valid한 범위를 가리키고 있는지 검사하기 때문에 저 포인터 묶음 테이블들의 범위를 벗어나지 못한다. 하지만 달리 보면 그 범위 내에서 조작이 가능한 것이다.
혹은 오버플로우 등으로 인해 파일포인터 변수를 덮을 수 있게 된다면 가짜 파일 구조체를 만들어 그 주소로 파일포인터 변수를 덮을수도 있고, 파일 구조체에 write가 가능하다면 기존 파일 구조체와 동일한 위치에서 그 구조체 내용을 조작할 수도 있다.
아까 본 파일 구조체에서 표시한 위치가 vtable포인터이다. 저 주소가 _IO_file_jumps임.
밑에 _IO_str_jumps는 또 다른 포인터 묶음임. (0 두 칸이 더미)
여기서 표시한 게 xsgetn으로, fread를 호출하면 저 함수 포인터가 수행된다. 저게 vtable + 0x40으로 계산되어 수행되니까 vtable을 조작하고, 그 + 0x40위치에 원하는 함수를 적으면 실행흐름 조작이 가능한 것이다.
그런데 앞서 말했듯 요즘은 vtable을 너무 터무니없는 값으로 조작하면 수행하다가 프로그램을 아예 터트려버린다.
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;
uintptr_t ptr = (uintptr_t) vtable;
uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
// check
if (__glibc_unlikely (offset >= section_length))
_IO_vtable_check ();
return vtable;
}
라이브러리 코드에 이런 검사가 있는데, IO_validate_vtable의 섹션 크기를 계산해서 vtable이 _libc_IO_vtables영역에 들어있는지 검사한다. 즉 vtable을 조작해도 _libc_IO_vtables영역 어딘가의 주소로만 조작할 수 있다는 것이다.
void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
/* Honor the compatibility flag. */
void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (flag);
#endif
if (flag == &_IO_vtable_check)
return;
{
Dl_info di;
struct link_map *l;
if (!rtld_active ()
|| (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
&& l->l_ns != LM_ID_BASE))
return;
}
#else /* !SHARED */
if (__dlopen != NULL)
return;
#endif
__libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}
위 코드에서 unlikely에 걸리면(vtable주소가 _libc_IO_vtables영역에 존재하지 않으면) _IO_vtable_check함수를 호출해서 검사하고 invalid할 경우 터뜨려버린다.
하지만 조작할 수는 있다는 것에 의의가 있다. _libc_IO_vtables영역 안이기만 하면 검사는 통과할 수 있기 때문이다.
이에 대한 방법은 아래 글을 보면 좋을 것 같다. 직접 코드를 보면서 찾은 것이기 때문에..
https://orcinus-orca.tistory.com/162
[System_Hacking] iofile_vtable_check
CIDY [System_Hacking] iofile_vtable_check 본문 Hack../DreamHack [System_Hacking] iofile_vtable_check CIDY 2022. 8. 18. 23:04
orcinus-orca.tistory.com
일단 아까 _IO_file_jumps테이블 아래에 있던 _IO_str_jumps테이블을 이용하는 방법이 있는데, 크게 두 가지가 있다.
int
_IO_str_overflow (_IO_FILE *fp, int c)
{
int flush_only = c == EOF;
_IO_size_t pos;
if (fp->_flags & _IO_NO_WRITES)
return flush_only ? 0 : EOF;
if ((fp->_flags & _IO_TIED_PUT_GET) && !(fp->_flags & _IO_CURRENTLY_PUTTING))
{
fp->_flags |= _IO_CURRENTLY_PUTTING;
fp->_IO_write_ptr = fp->_IO_read_ptr;
fp->_IO_read_ptr = fp->_IO_read_end;
}
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
{
if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
return EOF;
else
{
char *new_buf;
char *old_buf = fp->_IO_buf_base;
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100;
if (new_size < old_blen)
return EOF;
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
일단 첫 번째 방법은 _IO_str_overflow를 이용하는 것이다. 맨 아랫줄을 보면 (_IO_strfile*)fp->_s._allocate_buffer (new_size); 가 수행되는 것을 볼 수 있다.
_IO_strfile은 이렇게 생긴 구조체이고, 얘가 가리키는 _s는 이렇게 생겼다. _s는 _IO_str_fields형으로 정의되어 있는데,
그건 이렇게 생긴 구조체이다. 파일 구조체인 fp를 _IO_strfile형으로 바꿨고, 거기에서 _s위에 있는 _sbf는 _IO_streambuf형으로 정의되어 있다.
_IO_streambuf는 _IO_FILE_plus와 동일하게 정의되어 있다. 따라서 _s는 vtable포인터 바로 다음 위치를 의미하는 것이다. _s 내부에서 _allocate_buffer는 첫 번째이므로 딱 vtable포인터 다음 위치에 실행하고 싶은 함수를 덮어쓴 뒤에, fp의 멤버 변수들을 잘 조작해서 new_size를 /bin/sh등의 값으로 만들고 익스플로잇을 트리거할 함수의 offset에 맞추어 _IO_str_overflow가 실행되도록 vtable포인터를 적절히 덮고 조건문을 통과시키는 방식으로 공격할 수 있다.
애초에 이 정도로 값을 조작할 수 있으면 조건문 통과 정도는 사소하다.
다음으로는 아까 달아둔 링크에서 설명했던 방법인데, _IO_str_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);
}
_free_buffer를 수행한다는 점을 이용할건데, 앞서 분석했듯이 _free_buffer의 위치는 vtable포인터 바로 다음 다음에 위치한다. 거기에 system함수 주 덮고, _IO_buf_base만 /bin/sh로 세팅해주면 쉘을 얻을 수 있다.
그리고 _IO_FILE 잘 조작하면 aaw, aar도 할 수있다. fwrite의 경우 vtable에 있는 애들 중 _IO_new_file_xsputn함수를 호출한다.
_IO_size_t
_IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n)
{
...
if (do_write)
{
count = new_do_write (f, s, do_write);
to_do -= count;
if (count < do_write)
return n - to_do;
}
...
}
그리고 그안에서 위와 같이 new_do_write를 호출한다.
int
_IO_new_do_write (_IO_FILE *fp, const char *data, _IO_size_t to_do)
{
return (to_do == 0
|| (_IO_size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
걔는 이렇게 생겼는데, 파일 포인터 자나 파일 구조체의 변수들을 바꿀 수 있으면 _flags, _IO_write_base, _IO_write_ptr등을 조작해서 원하는 주소에 든 값을 출력할 수 있다.
_flag에 _IO_CURRENTLY_PUTTING인 0x800을 or해주고, _IO_write_base에는 읽고싶은 시작주소를, _IO_write_ptr에는 어디까지 읽고싶은지(끝 주소) 적고, _IO_read_end에 _IO_write_base와 동일한 값을 적고 fwirte를 수행하면 aar을 할 수 있는 것이다.
이와 유사하게 _IO_FILE aaw도 가능하다. fread는 _IO_file_xsgetn을 호출하는데, 그 안에서_IO_new_file_underflow함수를 호출한다.
int _IO_new_file_underflow (FILE *fp)
{
ssize_t count;
if (fp->_flags & _IO_NO_READS)
{
fp->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return EOF;
}
...
count = _IO_SYSREAD (fp, fp->_IO_buf_base,
fp->_IO_buf_end - fp->_IO_buf_base);
}
위 코드에서 if문을 맞추면 _IO_SYSREAD함수를 호출할 수 있는데, 여기서 파일 디스크립터를 stdin(0)으로 해주면 내 입력을 넣을 수 있다. (_fileno변수를 0으로 맞추면 됨.)
_IO_buf_base를 쓰고싶은 시작 주소, _IO_buf_end에 끝 주소, 그리고 _fileno를 0으로 해주면 aaw가 가능하다.
_flag의 경우 aar은 fbad1800 aaw은 fbad2488해주면 보통 되더라~
끝
'Hack > DreamHack' 카테고리의 다른 글
[System_Hacking] mmapped (0) | 2023.07.04 |
---|---|
[System_Hacking] Tasks (0) | 2023.05.20 |
[System_Hacking] Logical Bug (0) | 2023.05.15 |
[System_Hacking] STB-lsExecutor (0) | 2023.04.09 |
[System_Hacking] Chatbot (0) | 2023.04.06 |