Recent Posts
Recent Comments
Link
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
Tags
more
Archives
Today
Total
관리 메뉴

CIDY

[System_Hacking] Linux Exploitation 본문

Hack/DreamHack

[System_Hacking] Linux Exploitation

CIDY 2023. 5. 16. 02:26

포너블에서 이용되는 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