티스토리 뷰



안녕하세요. Hackability 입니다.


다들 ISCTF 는 잘 하고 계신가요~ 하루 조금 넘는 대회긴 하지만 뭔가 배울수 있는 해킹 대회를 만들고 싶었는데 능력이 부족해서 많이 부족한것 같습니다 -_ㅠ


아래는 mpwn2300, mpwn2500, mpwn2800에 대한 힌트 입니다.


특히 mpwn2500의 경우, 아직 푼 팀이 없는데 아래 글과 제 블로그에 2015 HITCON pwnable 200점 문제인 nanana에 대한 라이트업을 참고 하시면 도움이 될 것 같습니다.


아래 내용은 phrack에서 발간했던 Scraps of notes or remote stack overflow exploitation 글을 번역한 내용이며 중간에 Canary 부분을 참고 하시면 될 것 같습니다.


 ==Phrack Inc.==

Volume 0x0e, Issue 0x43, Phile #0x0d of 0x10

|=-----------------------------------------------------------------------=|
|=------=[ Scraps of notes on remote stack overflow exploitation ]=------=|
|=-----------------------------------------------------------------------=|
|=-------------=[ Adam 'pi3' Zabrocki - pi3 (at) itsec pl ]=-------------=|
|=-----------------------------------------------------------------------=|

---[ Contents

1 - Introduction
2 - Anti Exploitation Techniques
3 - The stack cookies problem 
  3.1 - A story of cookie protection
  3.2 - The canary security
  3.3 - Exploiting canaries remotely
4 - A few words about the other protections
5 - Hacking the PoC  (이 부분은 제외)
6 - Conclusion
7 - References
8 - Appendix - PoC  (이 부분은 제외)
  8.1 - The server (s.c)
  8.2 - The exploit (moj.c)

---[ 1. - 소개

본론으로 들어 가기 전에 POSIX 표준과 관련된 몇 가지 기술들에 대해 얘기하려 한다. 이 내용은 앞으로 우리가 최신 보안 매커니즘 시스템을 우회하기 위한 작은 발판이 될 것이다.

최근 스택 오버플로우 에러는 시스템을 공격하는데 성공적이지 못하다. 또한, 더욱더 어려워지고 원격 공격이 불가능에 가까워지고 있다. 그 이유는 새로운 보안 패치들이 익스플로잇 버그들을 더욱 어렵게 하고 있기 때문이다. 우리는 이러한 다양한 종류의 패치에 의해 다양한 관점, 그리고 다양한 아이디어로 공격에 방어하는 것에 대해 깊은 감명을 받았다. 최신 *NIX 시스템에서 일반적으로 사용하고 있는 기술들에 대해 알아보자.

---[ 2. - 안티 익스플로잇 기술들

*) AAAS (ASCII Armored Address Space)

AAAS는 굉장히 흥미로운 아이디어다. 아이디어는 라이브러리들을 (일반적으로는 어떤 ET_DYN 오브젝트) 최초 16 MB 주소 공간에 로드하는 것이다. 결과적으로 이 모든 공유라이브러리의 코드와 데이터는 주소의 시작이 NULL로 시작되는 위치에 존재하게 된다. 이것은 자연스럽게 strcpy와 같이 NULL 문자열을 받지 못하는 함수들에 의한 오버플로우 버그들에 대항하게 된다. 이런 방어들은 본질적으로 NULL 바이트 이슈가 없는 (예를들어 Linux/*BSD x86 시스템의 PLT와 같은) 것에 대해서는 효과가 없다. 보통 이런 방어기법들은 Fedora 배포버전에서 사용되고 있다.

*) ESP (Executable Space Protection)

이 방어 매커니즘의 아이디어는 매우 오래되었고 간단하다. 전통적으로 오버플로우들은 쉘 코드를 이용하여 익스플로잇을 하게 되는데 이는 데이터 영역에서 코드가 실행되는 경우이다. 이러한 일반적이지 않은 상황은 간단히 데이터 영역 (stack, heap, data, etc) 또는 더욱 일반적으로 (만약 가능하다면) 모든 쓰기 가능한 프로세스 메모리 공간에서 실행을 제제함으로써 막아진다. 하지만 이 방어 기법은 이미 라이브러리나 프로그램 함수들에 의해 로드된 코드의 호출을 막진 못한다. 이는 고전적인 return-into-libc 관련 공격을 가능케 한다. 최근 모든 PAE 또는 64bit x86 linux 커널에서는 ESP가 기본적으로 적용이 되어 있다.

*) ASLR (Address Space Layout Randomization)

ASLR의 기본 아이디어는 프로그램의 스택이나 힙 또는 라이브러리들의 주소를 랜덤하게 로드하는 것이다. 결과적으로 공격자가 공격에 성공하여 프로그램의 흐름을 바꾸더라도 공격자는 다음 코드가 (shellcode, library functions) 어디에 위치하는지 모르게 된다. 이 아이디어는 간단하지만 매우 효과적이다. ASLR은 리눅스 커널 2.6.12 부터 기본적으로 설정되어 있다.

*) Stack Canaries (Canaries of the death)

이는 컴파일러 매커니즘으로 기존의 커널 기반의 기술들과는 차이가 있다. 함수가 호출될 때, 컴파일러에 의해 특정 코드가 삽입되는데 이는 함수 프롤로그 위치에서 metadata가 들어가기전에 특정 값 (이를 쿠키라고 함)을 넣게 된다. 에필로그 시 해당 쿠키는 원본과 비교를 하게 되어 만약 이 특정 값이 다르다면 충돌이 일어나게 된다. 그러면 프로그램은 종료되게 되고 시스템 로그에 보고된다. 기술에 대한 자세한 내용은 뒤에 설명하도록 한다.

---[ 3 - 스택 쿠키의 문제점

---[ 3.1 - 쿠키 방어의 스토리

스택 쿠키에 대한 많은 구현들이 존재한다. 어떤건 괜찮은 편이지만 또 어떤건 그렇지가 않다. 분명한건 가장 훌륭한 구현은 SSP (Stack Smashing Protector)로써 gcc 버전 4.x 부터 포함되어 ProPolice 라고 알려진 것이다.

이러한 카나리는 어떻게 동작하는 것일까? 스택 프레임이 생성될 때, 카나리가 추가된다. 이 특정값은 랜덤 값이다. 해커가 스택 오버플로우 버그를 일으키면 스택에 존재하는 리턴 주소 덮어 쓰게 되는데 리턴 주소를 덮어 쓰게 하려면 카나리 역시 덮어 써져야 한다. (*hackability: 카나리가 리턴 주소보다 먼저 스택에 푸쉬되기 때문) 만약 에필로그가 호출 될 시 (스택 프레임이 제거 될 때) 원본 카나리 값과 (x86의 gs segment selector를 참조하여 TLS 위치에 저장되어 있는) 스택에 있는 값과 비교를 하여 만약 값이 다르다면 시스템 로그에 공격 메시지 로그를 남기고 종료하게 된다.

만약 프로그램이 SSP로 컴파일 되었다면 스택은 다음과 같이 구성된다.

            |             ...             |
            -------------------------------
            | N - Argument for function   |
            -------------------------------
            | N-1 - Argument for function |
            -------------------------------
            |             ...             |
            -------------------------------
            | 2 - Argument for function   |
            -------------------------------
            | 1 - Argument for function   |
            -------------------------------
            |        Return Address       |
            -------------------------------
            |         Frame Pointer       |
            -------------------------------
            |             xxx             |
            -------------------------------
            |           Canary            |
            -------------------------------
            |       Local Variables       |
            -------------------------------
            |             ...             |

카나리 위에 있는 xxx 값은 gcc에 의해 추가된 패딩이다. 컴파일러를 3.3.x 와 3.4.x 사이의 버전을 사용하게 된다면 보통 20 bytes로 존재한다. 이는 off-by-one 버그를 막기 위해 존재한다. 본문에서는 이에 대한 내용이 아니긴 하지만 이 부분에 대해서도 알아야 한다.

* 재정렬 이슈
-------------

Bulba 와 Kil3r 가 phrack [1]에 출간한 내용중 마약 다음과 같이 지역변수가 설정되었을 때 어떻게 우회를 하는지에 대한 내용이다.

---------------------------------------------------------------------------
                    int func(char *arg) {

                       char *ptr;
                       char buf[MAX];

                       ...

                       memcpy(buf,arg,strlen(arg));

                       ...

                       strcpy(ptr,arg);

                       ...

                    }
---------------------------------------------------------------------------

이 경우, 카나리 값을 조작할 필요가 없다. 우리는 간단히 ptr 포인터를 return 주소로 변경하면 된다. 이는 해당 포인터가 메모리 카피의 목적 포인터로 사용이 된다면 우리가 쓰기를 원하는 위치로 설정하여 카나리를 건드리지 않고 리턴 주소를 변경할 수 있게 된다.

                |             ...             |
                -------------------------------
                | arg - Argument for function |
                -------------------------------
          ----> |        Return Address       |
          |     -------------------------------
          |     |         Frame Pointer       |
          |     -------------------------------
          |     |             xxx             |
          |     -------------------------------
          |     |           Canary            |
          |     -------------------------------
          ----  |           char *ptr         |
                -------------------------------
                |        char buf[MAX-1]      |
                -------------------------------
                |        char buf[MAX-2]      |
                -------------------------------
                |             ...             |
                -------------------------------
                |          char buf[0]        |
                -------------------------------
                |             ...             |

이런 경우, 공격자는 직접적으로 포인터를 수정하여 카나리가 실패하게 된다.

사실 SSP는 다른 카나리 구현들 (예를들어 StackGuard)보다 더욱 복잡하고 발전되어 있는 형태로 구현이 되어 있다. SSP는 몇 가지 경험적인 내용을 통해 스택에 있는 지역 변수들을 정렬한다.

예를들어, 다음과 같은 함수가 있다고 가정하면

---------------------------------------------------------------------------
            int func(char *arg1, char *arg2) {

               int a;
               int *b;
               char c[10];
               char d[3];

               memcpy(c,arg1,strlen(arg1));
               *b = 5;
               memcpy(d,arg2,strlen(arg2));
               return *b;
            }
---------------------------------------------------------------------------

이론적으로 스택은 다음과 같을 것이다.

               (d[..]) (c[..]) (*b) (a) (...) (FP) (IP)

하지만 SSP는 이러한 지역변수의 순서를 바꾸게 되어 다음과 같이 되게 된다.

               (*b) (a) (d[..]) (c[..]) (...) (FP) (IP)

물론 SSP 역시 카나리를 추가 하게 된다. 결과적으로 공격자 입장에서 스택이 그전에 비해 굉장히 비참해졌다.


                |             ...             |
                -------------------------------
                |arg1 - Argument for function |
                -------------------------------
                |arg2 - Argument for function |
                -------------------------------
                |        Return Address       |
                -------------------------------
                |         Frame Pointer       |
                -------------------------------
                |             xxx             |
                -------------------------------
                |           Canary            |
                -------------------------------
                |           char c[..]        |
                -------------------------------
                |           char d[..]        |
                -------------------------------
                |            int a            |
                -------------------------------
                |            int *b           |
                -------------------------------
                |         Copy of arg1        |
                -------------------------------
                |         Copy of arg2        |
                -------------------------------
                |             ...             |

SSP는 항상 모든 버퍼들을 카나리 근처에 위치시키고 포인터들을 버퍼에서 최대한 멀게끔 하려고 노력한다. 함수의 인자들 역시 스택의 특수한 곳에 복사를 하게 되어 원본 인자들을 절대 사용할수 없게 한다.

이런 재정렬과 같이 포인터를 통해 프로그램의 흐름을 변경하는 경우는 적어 보인다. 이는 공격자가 무작위 대입을 이용하여 스택 오버플로우 버그를 하는 것 외에는 대안이 없어 보인다. 정말 그럴까? :)

* SSP의 한계
------------

SSP는 완벽하지 않다. 몇가지 특별한 경우들이 SSP가 안전한 프레임을 만드는것을 허용하지 않는다. 아래들이 알려진 몇 가지 경우들이다.

*) SSP는 각각의 버퍼들에 대해 따로 방어하지 않는다. 만약 스택에 있는 데이터가 안전한 방법으로 재정렬이 되어도 공격자는 아직도 다른 버퍼를 이용하여 특정 버퍼를 덮어 쓸수는 있다. 만약 많은 버퍼들이 있고 모든 버퍼가 근접하게 존재한다고 가정하면 버퍼들간은 덮어쓰기가 가능하다. 만약 어플리케이션에서 이런 버퍼를 프로그램의 흐름에 영향을 주는 값으로 사용하게 된다면 취약하게 된다.

*) 만약 우리가 구조체나 클래스들을 가지고 있다면 SSP는 데이터 영역에 인자들을 재정렬하지 않는다.

*) 만약 함수의 인자 수가 동적이라면 (예를들어 *printf()) SSP는 얼마나 많은 인자가 들어 오는지 알 수 없기 때문에 이 역시 인자들에 대해 안전한 위치로의 복사가 이루어지지 않는다.

*) 만약 프로그램이 alloc 함수를 사용하거나 확장 버전의 standard C를 사용하여 동적 배열을 생성하면 (char tab[size+5]) SSP는 이러한 데이터를 프레임 상단에 위치하게 된다. Dynamic 배열에 관련된 내용은 andrewg의 프렉 문서[13]를 참고하길 바란다.

*) 대부분의 경우, 프로그램이 C++에서 가상 함수를 다루는 경우 안전한 프레임을 만들기가 매우 어렵다. 이 역시 자세한 내용은 [2] 를 참고하기 바란다.

**) 몇몇 배포판에서 (Ubuntu 10.04 와 같은) 카나리가 0x00FFFFFF에 의해 마스크가 되어 NULL 바이트가 항상 존재하게 된다.

**) StackGuard v2.0.1에서는 0x000AFF0D라는 정적 카나리를 사용한다. 이 값은 랜덤하게 선택된것이 아니다. 0x00은 문자열 인자의 카피를 막기 위함이고 0x0A는 띄어 쓰기로써 gets와 같은 함수들의 읽기를 막기 위함 이고 0xFF나 0x0D역시 때때로 문자열 복사를 막는다. 만약 system-V가 아닌 곳에서 SSP를 사용하여 terminator 카나리를 만들게 된다면 대부분 비슷함을 알수 있게 될 것이다. StackGuard는 항상 '\r'(0x0D)를 추가하지만 SSP는 그렇지 않다.

---[ 3.2 - 카나리 보안

gcc 버전 4.1 stage 2 [6], [7]에서부터 SSP가 기본적으로 설정이 되어 있다. Gcc 개발자들은 IBM Pro Police Stack Detector를 재구현 하였다. 구현된 내용을 자세히 살펴보자. 우리는 먼저 다음을 확인해야 한다.

*) 카나리가 정말 랜덤한가?
*) 카나리의 주소가 노출이 가능한가?

실시간 방어
-----------

카나리에 의해 보호되고 있는 함수들을 보게 되면 다음과 같은 코드가 SSP에 의해 에필로그에 추가되었음을 알 수 있다.

---------------------------------------------------------------------------
0x0804841c <main+40>:   mov    -0x8(%ebp),%edx
0x0804841f <main+43>:   xor    %gs:0x14,%edx
0x08048426 <main+50>:   je     0x804842d <main+57>
0x08048428 <main+52>:   call   0x8048330 <__stack_chk_fail@plt>
---------------------------------------------------------------------------

이 코드는 스택에 있는 지역 카나리 값과 TLS에 존재하는 원본 카나리 값을 비교 하게 된다. 만약 두 값이 같지 않다면 함수는 __stack_chk_fail을 호출하게 된다.

구현은 "debug/stack_chk_fail.c"에 있는 GNU C Library 에서 찾아 볼 수 있다.

---------------------------------------------------------------------------
#include <stdio.h>
#include <stdlib.h>
 
extern char **__libc_argv attribute_hidden;
 
void
__attribute__ ((noreturn))
__stack_chk_fail (void)
{
  __fortify_fail ("stack smashing detected");
}
---------------------------------------------------------------------------

중요한 것은 함수가 "noreturn" 속성을 갖는다는 점이다. 이 뜻은 어떤 것도 리턴하지 않겠다는 의미이다. 이 부분에 대해 좀 더 살펴보도록 하자. __forify_fail 함수는 "debug/fortify_fail.c"에서 찾을 수 있다.

---------------------------------------------------------------------------
#include <stdio.h>
#include <stdlib.h>
 
extern char **__libc_argv attribute_hidden;
 
void
__attribute__ ((noreturn))
__fortify_fail (msg)
     const char *msg;
{
  /* The loop is added only to keep gcc happy.  */
  while (1)
    __libc_message (2, "*** %s ***: %s terminated\n",
                    msg, __libc_argv[0] ?: "<unknown>");
}
libc_hidden_def (__fortify_fail)
---------------------------------------------------------------------------

__fortify_fail 함수는 __libc_message 함수를 덮고 있고 이는 abort()를 호출한다. 이는 어떤 방법으로도 회피할 수가 없다.
(*hackability: 꽤 많은 시스템 구현에서 while(1) 이런식으로 무한 뤂을 돌려 특정 상황을 강제 하는 경우들이 있음)


초기화
------

"etc/rtld.c"에 있는 Run-Time Dynamic Linker를 보도록 하자. 카나리는 security_init() 함수에 의해 초기화가 되는데 이는 RTLD가 로드 될 때 호출되게 된다. (TLS는 init_tls() 함수에 의해 초기화가 된다)

---------------------------------------------------------------------------
static void
security_init (void)
{
  /* Set up the stack checker's canary.  */
  uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard ();
#ifdef THREAD_SET_STACK_GUARD
  THREAD_SET_STACK_GUARD (stack_chk_guard);
#else
  __stack_chk_guard = stack_chk_guard;
#endif
 
        [...] // pointer guard stuff
}
---------------------------------------------------------------------------

카나리 값은 _dl_setup_stack_chk_guard()에 의해 생성되게 된다. 최초의 구현은 IBM에서 구현한 것으로 __guard_setup 이라는 함수였다.

시스템에 따라 다르긴 하지만 _dl_setup_stack_chk_guard()는 "sysdeps/unix/sysv/linux/dl-osinfo.h" 또는 "sysdeps/generic/dl-osinfo.h"에 존재하게 된다.

만약 UNIX System V 를 사용하면 다음 함수를 찾을 수 있다.

---------------------------------------------------------------------------
static inline uintptr_t __attribute__ ((always_inline))
_dl_setup_stack_chk_guard (void)
{
  uintptr_t ret;
#ifdef ENABLE_STACKGUARD_RANDOMIZE
  int fd = __open ("/dev/urandom", O_RDONLY);
  if (fd >= 0)
    {
      ssize_t reslen = __read (fd, &ret, sizeof (ret));
      __close (fd);
      if (reslen == (ssize_t) sizeof (ret))
        return ret;
    }
#endif
  ret = 0;
  unsigned char *p = (unsigned char *) &ret;
  p[sizeof (ret) - 1] = 255;
  p[sizeof (ret) - 2] = '\n';
  return ret;
}
---------------------------------------------------------------------------

ENABLE_STACKGUARD_RANDOMIZE 매크로가 설정이 되어 있다면 함수는 "/dev/urandom" 장치를 열어 uintptr_t 바이트 만큼의 사이즈를 읽고 리턴한다. 그 외에는 terminator 카나리가 생성되게 된다. 첫 번째로 ret 변수에 0x00 를 넣고 다음 두 바이트를 0xFF 와 0xA로 넣는다. 최종적으로 terminator 카나리는 항상 0x00000aff 가 된다.

다른 OS에서의 _dl_setup_stack_chk_guard()의 구현은 다음과 같이 되어 있다.

---------------------------------------------------------------------------
#include <stdint.h>
 
static inline uintptr_t __attribute__ ((always_inline))
_dl_setup_stack_chk_guard (void)
{
  uintptr_t ret = 0;
  unsigned char *p = (unsigned char *) &ret;
  p[sizeof (ret) - 1] = 255;
  p[sizeof (ret) - 2] = '\n';
  p[0] = 0;
  return ret;
}
---------------------------------------------------------------------------

이 함수 역시 항상 terminator 카나리 값을 생성하게 된다.


결론
----

카나리가 완벽히 랜덤하고 예측 불가능 하거나 (/dev/urandom의 entropy가 충분하다고 가정) 그에 비해 약한 카나리가 나와도 몇몇 경우에 문제가 발생하게 된다.

카나리는 TLS 에 의존적이며 TLS는 고정된 주소가 아니기 때문에 (가상 주소는 code 레벨에서 노출 시킬수 없기 때문 by segment selector trick) 이를 노출 시키는 것은 어렵다.

3.3. 원격 카나리 익스플로잇

일반적으로 네트워크 데몬들은 clone 또는 fork를 통해 새로운 접속에 대해 스레드를 생성한다. fork의 경우 child process가 execve를 호출 하느냐 안하느냐 2가지 경우로 구분이 된다.

1. without execve()

                                [mother]
                      --------> accept()
                      |            |
                      |            | <- new connection
                      |            |
                      |          fork()
                      |          |    |
                      |   mother |    | child
                      -----------|    |
                                      |
                                    read()
                                      |
                                     ...
                                     ...

2. with execve()
                                [mother]
                      --------> accept()
                      |            |
                      |            | <- new connection
                      |            |
                      |          fork()
                      |          |    |
                      |   mother |    | child
                      -----------|    |
                                      |
                                   execve()
                                      |
                                      |
                                    read()
                                      |
                                     ...
                                     ...

Note 1: OpenSSH는 2번째 경우 좋은 예가 됨
Note 2: 물론 서버에서 accept 대신 select를 이용할 수 있지만 이 경우는 fork가 아님

Man page 에 의하면,

*) fork 시스템 호출은 프로세스를 복제하는데 이 뜻은 카나리가 함수 단위로 생성되는 것이 아니라 프로세스 단위로 생성이 되기 때문에 부모와 자식이 동일한 카나리를 공유한다는 뜻이다. 이는 굉장히 흥미로운 속성인데 몇번의 시도로 카나리를 추측할 수 있다면 카나리를 찾을 수 있다는 뜻이다. (*hackability: 공격 방식에 따라 카나리 하위 한바이트 한바이트씩 추측해서 카나리를 예측 할 수 있음. 카나리가 총 4바이트이고, terminator 카나리라면 하위 바이트는 0x00로 고정하고 나머지 3바이트를 추측하는 것이기 때문에 최대 255 * 3의 시도로 카나리를 찾을 수 있음)

*) execve가 호출되면 "호출 프로세스의 text, data, bss, stack가 로드된 프로그램에 의해 덮어 써지게 된다." 이는 각각의 자식 프로세스들이 각각의 카나리를 갖음을 암시한다. 결과적으로 fork와 같이 추측하여 카나리를 찾는 방식은 효용이 없게 된다.

32bit 아키텍처를 생각해보면, 가능한 카나리의 수는 2^32 (우분투 에서는 2^24)의 경우의 수가 나오는데 이는 40억가지 수 (우분투는 1600만)가 나오게 되는데 이는 원격에서는 거의 불가능 하며 로컬에서는 몇 시간이 걸리게 된다.

 그러면 뭘 할 수 있을까? Ben Hawkes [9]는 흥미로운 방법을 제안했다. 무작위 대입을 한 바이트 한 바이트씩 하는 방식이다. 언제 우리가 이를 사용할 수 있을까? 위에서 언급했듯이 카나리는 fork 시 변하지 않게 되어 가능하다.

 아래는 취약한 함수의 스택이다.

 | ..P.. | ..P.. | ..P.. | ..P.. | ..C.. | ..C.. | ..C.. | ..C.. |

P - 1 byte of buffer
C - 1 byte of canary

먼저, 카나리의 첫 번째 바이트를 덮어 쓰고 프로그램이 종료 되는지 안되는지를 확인한다. 이는 여러가지 방법으로도 가능한다. Hawkes는 프로그램의 응답 시간을 추정하였는데, 만약 카나리가 틀리다면 프로그램은 바로 종료가 되게 되고 카나리가 맞다면 계속 프로그램이 동작하게 되어 첫 번재 경우보다 더 긴 시간동안 동작하게 된다는 것이다. 우리는 이 것을 사용하기 보단 서버의 응답을 통해 결과를 알 수 있다. 우리는 단지 소켓에 의해 원하는 결과가 나오는지만 확인하여 우리가 원하는 값이 나왔다면 정확한 카나리를 넣은 것이고 그 다음 카나리를 예측하면 된다.

1바이트는 최대 256 개의 값을 갖는데 이는 현실적인 숫자 이다. 우리가 첫 번째 바이트를 알게 된다면 다음 바이트 역시 256 개의 확률을 갖기 때문에 쿠키를 추측하기 위해서는 4 * 256 = 1024 조합으로만으로도 가능하다.

아래는 4 단계로 추측하는 것을 나타낸 것이다.

First byte:
| ..P.. | ..P.. | ..P.. | ..P.. | ..X.. | ..C.. | ..C.. | ..C.. |

Second byte:
| ..P.. | ..P.. | ..P.. | ..P.. | ..X.. | ..Y.. | ..C.. | ..C.. |

Third byte:
| ..P.. | ..P.. | ..P.. | ..P.. | ..X.. | ..Y.. | ..Z.. | ..C.. |

Fourth byte:
| ..P.. | ..P.. | ..P.. | ..P.. | ..X.. | ..Y.. | ..Z.. | ..A.. |

공격자가 카나리를 모두 찾아 카나리 값이 XYZA라는 것을 안다면 어플리케이션에 대한 공격이 가능해진다. 데이터를 덮어 쓰고 카나리 위치에 우리가 찾은 카나리로 덮어 쓰게 된다면 메모리 오염 탐지는 발생하지 않을 것이다.

카나리의 위치를 쉽고 간단히 찾을 수 있는 방법은 테스팅 외에는 별 다른 방법이 없다. 만약 우리가 100 바이트 버퍼를 덮어 쓰고 101 바이트의 가짜 패킷을 전송하여 결과를 확인한 후 충돌이 낫는지 안낫는지 확인하는 것이다. 확률적으로 프로그램이 크래쉬가 나지 않는다면 카나리를 덮어 쓰지 않았을 확률이 높다. 지속적으로 카나리를 추측하고 바이트를 증가시키면서 카나리 값을 찾는다.

제한점
------

만약 이 기술이 동작하지 않는다면? 모든 경우 우리가 바이트들을 조작할 수 있는 것은 아니다. 예를들어 NULL과 같은 제한된 문자열에 대해 필터링이 되어 있는 경우들이다. 

좋은 예제로 TJ Saunders가 발견한 pre-auth ProFTPd 버그 (CVE-2010-3867)을 보면, 이 버그는 TELNET_IAC 문자열들을 파싱할 때 잘못 계산하면서 발생된다. 이 버그를 좀더 자세히 살펴 보자.

문제는 "src/netio.c"에 있는 pr_netio_telnet_gets 함수에서 발생한다.

---------------------------------------------------------------------------
char *pr_netio_telnet_gets(char *buf, size_t buflen,
    pr_netio_stream_t *in_nstrm, pr_netio_stream_t *out_nstrm) {
  char *bp = buf;

...

  [L1]  while (buflen) {

  ...

      toread = pr_netio_read(in_nstrm, pbuf->buf,
      (buflen < pbuf->buflen ?  buflen : pbuf->buflen), 1);
  ...

  [L2]    while (buflen && toread > 0 && *pbuf->current != '\n' 
          && toread--) {
  ...
          if (handle_iac == TRUE) {
            switch (telnet_mode) {
              case TELNET_IAC:
                 switch (cp) {

  ...
  ...

                   default:

  ...

                     *bp++ = TELNET_IAC;
  [L3]                  buflen--;

                     telnet_mode = 0;
                     break;
                 }
  ...
            }
          }
          
              *bp++ = cp;
  [L4]        buflen--;
        }
  ...
  ...
        *bp = '\0';
        return buf;
        }
    }
---------------------------------------------------------------------------


반복문 [L2]는 바이트들을 읽고 파싱하고 buflen 을 줄이게 된다. 문제는 TELNET_IAC이 0xFF 문자열을 읽으면서 발생한다. 만약 이 문자열이 발생하게 된다면 [L3]에서 buflen이 줄어 들게 되어 결과적으로 buflen이 총 2번 줄어 들어 [L1]에서 잘못된 결과를 발생시킨다. 만약 buflen이 1이고 TELNET_IAC을 파싱 하게 되면 buflen = 1 - 2 = -1 이 되게 되어 결과적으로 \n이 발생될 때 까지 지속적으로 복사를 하게 된다. 

"src/main.c"의 pr_cmd_read()함수에 의해 pr_netio_telnet_gets가 호출된다.


---------------------------------------------------------------------------
int pr_cmd_read(cmd_rec **res) {
  static long cmd_bufsz = -1;
  char buf[PR_DEFAULT_CMD_BUFSZ+1] = {'\0'};

...

  while (TRUE) {

...

    if (pr_netio_telnet_gets(buf, sizeof(buf)-1, session.c->instrm,
        session.c->outstrm) == NULL) {

...

  }

...
...

  return 0;
}
---------------------------------------------------------------------------

이 경우, 취약한 함수의 인자는 스택의 지역변수 이다. 따라서 스택 버퍼 오버플로우 버그가 발생한다. 이론적으로 보면 모든 조건들은 pro-police 카나리를 byte-by-byte 기술로 우회할 수 있게끔 보인다. 하지만 좀더 자세히 보게 되면 취약한 함수에 다음과 같은 코드가 있음을 알 수 있다.

*bp = '\0';

이는 byte-by-byte 공격에 대한 아이디어를 막는다. 왜 그럴까? 왜냐하면 마지막 바이트가 0x00이 될 수 없고 오로지 마지막에서 2번째 바이트만 0x00 으로 만들 수 있기 때문이다.

추가적으로 byte-by-byte 방식은 모든 자식들이 같은 카나리를 갖아야 한다. 만약 자식 프로세스가 execve를 호출한다면 이 방법은 사용할 수가 없다. 이 경우, 무작위 대입을 통해 공격을 해야 한다. 물론 우리가 시간이 아주 많다면 3바이트를 추측해 볼 수도 있지만 아주 많은 시간이 걸린다.

결과적으로 grsecurity는 흥미로운 방식으로 이러한 공격을 방어했다. 고려해 볼만한 사실은 무작위 대입은 자식 프로세스의 충돌이 필수 적이며 이는 자식 프로세스가 SIGILL (예를들어 PaX가 kill signal을 보냄)에 의해 죽는것은 매우 의심스러운 상황이다. 결과적으로 do_coredump 중 커널은 gr_handle_brute_attach()함수를 이용하여 플래그를 설정한다. 이를 통해 다음 fork 시도에 대해 부모 프로세스는 딜레이를 주게 된다. 실제로는 do_fork는 TASK_UNINTERRUPTIBLE 상태에서 이를 설정하는데 최소 30초를 설정하게 된다.

---------------------------------------------------------------------------
+void gr_handle_brute_attach(struct task_struct *p)
+{
+#ifdef CONFIG_GRKERNSEC_BRUTE
+ read_lock(&tasklist_lock);
+ read_lock(&grsec_exec_file_lock);
+ if (p->p_pptr && p->p_pptr->exec_file == p->exec_file)
+ p->p_pptr->brute = 1;
+ read_unlock(&grsec_exec_file_lock);
+ read_unlock(&tasklist_lock);
+#endif
+ return;
+}
+
+void gr_handle_brute_check(void)
+{
+#ifdef CONFIG_GRKERNSEC_BRUTE
+ if (current->brute) {
+ set_current_state(TASK_UNINTERRUPTIBLE);
+ schedule_timeout(30 * HZ);
+ }
+#endif
+ return;
+}
---------------------------------------------------------------------------

이 메커니즘을 통해 공격자의 공격을 효과적으로 제제할 수 있다.

--[ 4 - 다른 방어 기법들에 대한 내용

데몬이 execve 를 쓰지 않고 fork가 된다면 카나리를 byte-to-byte로 찾음으로써 SSP를 우회할 수 있다. 하지만 아직까지도 non executable memory 또는 ASLR과의 싸움이 남아 있다.

실행 영역 보호
--------------

1997년 8월 10일, 솔라 디자이너는 bugtrag mailling list에 ret-into-libc라는 공격 기법을 이용하여 비 실행 메모리 영역을 우회하는 기법에 대해 포스팅 했다 [11]. 이 기술은 추후에 보강이 되었으며 아직도 다음과 같은 경우에 사용되고 있다.

*) 체이닝 (연결). 연속적인 함수 호출. [10] 에서는 x86과 같은 스택 레이아웃에서 동작을 설명했고 이 컨셉은 후에 다른 아키텍처로 확장되었으며 "gadgets" (ROP)라는 기술로 소개되게 된다.

*) mprotect 함수의 사용은 PaX에 대항하는 대책으로 소개 되었으며 아직도 몇몇 시스템에서는 효과적으로 사용된다. 

*) dl-resolve는 공유 라이브러리에 있는 함수 호출을 담당하는데 PLT 엔트리에 없는 함수들 역시 호출이 된다.


여기까지 실행 영역 보호 기법을 우회하는 몇가지 기술들에 대해 알아 봤다. 하지만 아직도 문제들이 남아 있다. 우리는 우리가 호출하고 싶어 하는 함수 (ex system)의 주소가 어디에 있는지 모르고 함수들에 필요한 인자들 역시 어디에 존재하는지 모른다.

이 경우 3가지 정도 해법이 존재한다.

*) bruteforce를 통해 얻는다. 명백히 시간이 걸리긴 하지만 필요한 경우 (offset을 찾는다던지)에는 할 수 있다. [12] 에서는 이와 관련된 흥미로운 내용이 있다.

*) 정보 노출을 이용하여 찾기. 상황에 따라 다르긴 하지만 (특히 PIE 로 컴파일된 바이너리) 최신 우분투의 경우 대부분의 데몬들이 PIE로 컴파일 되어 code/data segment 들의 주소가 고정적이지 않다.

*) 메모리 레이아웃을 익스플로잇 하여 인자들을 추측.

중요한 점은 익스플로잇은 프로그램 상황에 따라 매우 의존적이기 때문에 일반적인 익스플로잇 기술은 없다는 것이다. 특히 최신 메모리 보호 기법에 대해서는 더욱 그렇다.

ASLR: fork에 의한 이점
----------------------

전에도 설명했듯이 자식 프로세스의 주소 공간은 부모로부터 복사된다. 하지만 execve 로 로드된 자식들은 모든 것이 재로드 되고 ASLR 때문에 예측 불가능한 주소 공간을 갖는다.

수학적 관점으로 보면 주소를 예측 하는 것은
- 변환을 고려하지 않은 샘플링 (fork 의 상황)
- 변환을 고려한 샘플링 (fork-execve 상황)

PIE 로 컴파일된 네트워크 데몬의 경우, 최소 2개의 서로 다른 엔트로피 소스들을 갖는다.
*) 쿠키: 24 bits or 32 bits on 32 bit OS
*) ASLR: 16 bits for mmap() randomization with PaX (in PAGEEXEC case) on 32 bit OS


---------------------------------------------------------------------------
+#ifdef CONFIG_PAX_ASLR
+ if (current->mm->pax_flags & MF_PAX_RANDMMAP) {
+ current->mm->delta_mmap = (pax_get_random_long() 
                & ((1UL << PAX_DELTA_MMAP_LEN)-1)) << PAGE_SHIFT;
+ current->mm->delta_stack = (pax_get_random_long() 
                & ((1UL << PAX_DELTA_STACK_LEN)-1)) << PAGE_SHIFT;
+ }
+#endif

+#define PAX_DELTA_MMAP_LEN (current->mm->pax_flags 
                                & MF_PAX_SEGMEXEC ? 15 : 16)
+#define PAX_DELTA_STACK_LEN (current->mm->pax_flags 
                                & MF_PAX_SEGMEXEC ? 15 : 16)
---------------------------------------------------------------------------

예제:
Ubuntu 10.04 + PaX 에서 동작하는 proftpd 버그에 대해서
- no byte-by-byte
- no execve
- cookie has null byte
- PIE 로 컴파일된 바이너리

이런 상황에서는 평균적으로 2^24 + 2^16 번의 시도가 필요하다. 복잡도 측면에서 보면 쿠키를 추측하는 난이도로 어려움을 알 수 있다.

---[ 6 - 결론

최신 보호 기법에 의해 기존 익스플로잇 방식들은 스택 오버플로우를 통한 원격 공격이 힘들어졌음을 알 수 있다. 우리는 fork 만 사용하는 데몬의 경우 몇 가지 조건이 만족한다면 충분히 공격 가능함도 보았다.


---[ 7 - References

 [2] The Shellcoder's Handbook - Chris Anley, John Heasman, 
 [3] Felix "FX" Linder, Gerardo Richarte


댓글