2021/system

시스템 스터디 1주차

그냥 사람 2021. 1. 17. 20:33

1. 프로그램 구동시 Segment에서 일어나는 일

 

simple.c

위의 c 프로그램을 어셈블리 코드로 변환하기 위해

$ gcc -S -o simple.asm simple.c

를 사용하여 컴파일하고 실행 파일을 만들었다.

(gcc : C 프로그램을 컴파일하는 명령어 / -S : 전처리된 파일을 어셈블리 파일로 컴파일까지만 수행하고 멈춘다. / -o : 실행파일 생성)

그리고 cat 명령어를 사용하여 simple.asm에 저장된 그 결과를 출력한다.

 

simple.c 프로그램을 수행하기 위한 segment가 실제 메모리 상에 어느 위치에 존재하게 될지 알아보기 위해 각 명령들의 logical adress를 살펴보려고 한다.

 

$ gcc -o simple simple.c

를 사용하면 gcc 명령어를 통해 simple.c에 대한 실행 파일인 simple이 저장된다.

$ gdb simple 

를 사용하면 해당 실행 파일을 디버깅 모드로 실행할 수 있다.

박스 쳐져 있는 부분에 있는 주소가 해당 명렁어에 대한 logical address이다.

 이 주소를 보면 function() 함수의 주솟값이 main() 함수의 주솟값이 작음을 알 수 있다. 따라서 code segment에서 function() 함수가 아래에 자리 잡고 main() 함수는 위에 자리잡을 것이다. 이 메모리 주소를 바탕으로 생성될 이 프로그램의 segment 모양은 다음과 같이 될 것임을 유추할 수 있다.

 

 

 

 

 이 segment의 logical address는 0x0400000부터 시작하지만 실제 프로그램이 컴파일과 링크되는 과정에서 다른 라이브러리들을 필요로 하게 된다. 따라서 코딩한 코드가 시작되는 지점은 이 시작점과 일치하지는 않는다. 뿐만 아니라 stack segment 역시 0xBFFFFFFF까지 할당되지만 필요한 환경 변수나 실행 옵션으로 주어진 변수 등에 의해서 가용한 영역은 그보다 조금 더 아래에 자리잡고 있다.

 

 

<step 1>

 프로그램이 시작되면 EIP 레지스터, 즉 CPU가 수행할 명령이 있는 레지스터는 main() 함수가 시작되는 코드를 가리키고 있을 것이다. 위의 실행파일에 따르면 그 시작점은 0x40057d이다. 이때 ESP(stack segment)의 맨 꼭대기를 가리키는 포인터)가 정확히 어느 지점을 가리키는지 알아보기 위하여 gdb를 이용하여                                              레지스터의 값을 알아보았다.

 gdb의 break 명령어를 통해 main() 함수의 시작점인 0x40057d에 정지점을 설정한 후 r 명령어를 통해 프로그램을 실행하였다. 정지점(breakpoint)은 디버깅의 목적으로 실행중인 프로그램을 의도적으로 잠시 또는 아예 멈추게 하는 장소를 가리킨다. 따라서 이렇게 하면 main() 함수의 시작점인 0x40057d에서 프로그램이 실행을 멈추게 된다. 이때 info register 명령어를 사용하여 현재 ESP의 주소(0x7fffffffde58)를 출력하였다.

 PUSH 명령어를 통해 ESP가 가리키는 지점에 데이터를 넣을 것인지, 아니면 그 아래 지점에 데이터를 넣을 것인지는 system architecture에 따라 다르다. POP 명령어를 통해 ESP가 가리키는 지점에 있는 데이터를 가져갈 것인지, 아니면 그 위의 데이터를 가져갈 것인지도 마찬가지이다.

 

 이전에 수행하던 함수의 데이터를 보존하기 위한 포인터를 base pointer라고 한다. 새로운 함수를 호출할 때마다 stack pointer와 base pointer을 재설정하며, 함수의 수행이 끝나면 함수 호출 이전 상태로 복구된다. 이러한 과정을 함수 프롤로그 과정이라고 한다.

 

<step 2>

1. push %esp

이전 함수의 base pointer을 스택에 저장한다. 그렇게 되면 stack pointer가 4바이트 아래인 0x7fffffffde54를 가리키게 될 것이다.

 

2. mov %esp, %ebp

ESP의 값을 EBP에 복사한다. 이렇게 하면 함수의 base pointer와 stack pointer가 같은 지점을 가리키게 된다.

 

3. sub $0x8, %esp

ESP에서 8을 뺀다. 따라서 ESP는 8바이트 아래 지점을 가리키게 되고 스택에 8바이트의 공간이 생기게 된다. 이것을 스텍이 8바이트 확장되었다고 말한다. 이 명령이 수행되고 나면 ESP에는 0xbffffa70이 들어가게 된다.

 

4. and $0xfffffff0, $esp

ES{의 주솟값의 맨 뒤 4bit를 0으로 만들기 위해 ESP와 0xfffffff0과 AND 연산을 한다.

 

5. mov $0x0 %eax

EAX 레지스터에 0을 넣는다.

 

6. sub %eax, %esp

ESP에 들어있는 값에서 EAX에 들어있는 값만큼을 뺀다.

 

7. sub $0x4, %esp

스택을 4바이트 확장한다. 따라서 ESP에 들어있는 값은 0xbffffa6c가 된다.

 

여기까지의 명령을 수행한 모습은 다음과 같다.(22쪽)

 

<step 3>

1. push $0x03

   push $0x02

   push $0x01

function(1, 2, 3)을 수행하기 위해 인자값 1, 2, 3을 차례로 넣는다. 스택에서 값을 꺼낼 때는 가장 마지막에 저장된 값이 먼저 나오므로 1, 2, 3이 아닌 3, 2, 1 순으로 넣어준다.

 

2. call 0x80482f4

0x80482f4에 있는 명령을 수행한다. 이 주소는 function 함수가 있는 곳이다.

call 명령은 함수를 호출할 때 사용하는 명령이다.

그리고 함수 실행이 끝난 다음에는 다시 이후의 명령을 계속 수행할 수 있도록 이후의 명령이 있는 주소(return address)를 스택에 넣는다.

그 다음 EIP에 function()함수의 시작 지점의 주소를 넣는다.

이렇게 함으로써 함수 수행이 끝나고 나면 스택을 POP하여 다음에 실행해야 할 명령이 어디에 있는지 알 수 있는 것이다.

 

여기까지의 명령을 수행한 모습은 다음과 같다.(23쪽) EIP는 function() 함수가 시작되는 지점을 가리키고 있고 스택에는 main() 함수에서 넣었던 값들이 쌓여 있다.

 

<step 4>

1. push $ebp

   mov %esp, %ebp

이전 main() 함수의 base pointer를 스택에 저장한다. 그리고 stack pointer를 function() 함수의 base pointer로 삼기 위해  ESP의 값을 EBP에 복사하여 ESP와 EBP가 같아지도록 한다.

 

여기까지의 명령을 수행한 모습은 다음과 같다.(23쪽)

 

<step 5>

1. sub $0x28, %esp

스택을 40바이트 확장한다.

40바이트를 확장한 이유는 simple.c의 function()함수에서 지역 변수로 buffer1[15]와 buffer2[10]을 선언했기 때문이다.

 

buffer1[15]는 총 15byte, buffer2[10]는 총 10byte가 필요하지만 스택은 word인 4byte 단위로 자라므로 각각 16byte와 1byte를 할당한다. 따라서 확장되어야 할 스택의 크기는 28byte이다. 그런데 gcc 2.96 이후의 버전에서는 스택이 16배수로 할당되고 8byte의 dummy 값이 들어간다. 그래서 buffer1[15]를 위해서 16byte가 할당되고 buffer2[10]를 위해서 16byte가 할당된 후 8byte의 dummy가 추가되어 총 40byte의 스택이 확장된 것이다.

 

여기까지의 명령을 수행한 모습은 다음과 같다.(25쪽)

 

<step 6>

1. leave

함수 프롤로그 작업을 되돌린다. <step 4>에서 나타난 함수 프롤로그는

push $ebp

mov %esp, %ebp

였다. 따라서 이를 되돌리는 작업은

mov $ebp, %esp

pop $ebp

이다.

즉, function() 함수에서 확장한 스택과 main() 함수에서 확장한 스택 사이의 경계인 현재의 base pointer의 값을 stack pointer에 복사하여 base pointer와 stack pointer가 모두 그 경계에 위치하도록 한다. 그렇게 하면 function() 함수에서 확장했던 스택 공간은 없어진다. 여기에 POP을 하면 이전에 PUSH해서 저장해 두었던 main() 함수의 base pointer가 복원된다.

 

POP을 했으므로 stack pointer는 1 word 위로 올라간다. 그러면 이제 stack pointer는 return address가 있는 지점을 가리키고 있을 것이다. 

 

2. ret

ret instruction은 이전 함수로 return 하라는 의미이다. EIP 레지스터에 return address를 POP하여 집어 넣는 역할을 한다. 따라서 stack pointer는 1 word 위로 올라간다.

 

여기까지의 명령이 수행된 모습은 다음과 같다.(27쪽)

 

<step 7>

1. add $0x10, %esp

스택을 16byte 줄인다. 따라서 stack pointer는 0x804830c에 있는 명령을 수행하기 이전의 위치로 돌아가게 된다.

 

2.

leave

ret

각 레지스터의 값은 main() 함수 프롤로그 작업을 되돌리고 main() 함수 이전으로 돌아가게 된다.

 

5. Buffer overflow의 이해

 1) 버퍼(buffer)

  시스템이 연산 작업을 하는데 있어 필요한 데이터를 일시적으로 메모리 상의 어디엔가 저장하는데, 그 저장공간을 버퍼라고 한다.

  대부분의 프로그램에서는 이 버퍼를 스택에 생성한다. 스택에는 함수 내에서 선언한 지역 변수가 저장되고, 함수가 끝나면 반환된다.

 

 2) buffer overflow 공격의 원리

  return address는 현재 함수의 base pointer 바로 위에 있으므로 그 위치는 변하지 않는다.

  따라서 buffer overflow 공격은 공격자가 메모리상의 임의의 위치에 원하는 코드를 저장시켜 두고 return address가 저장되어 있는 지점에 그 코드의 주소를 집어넣음으로써 EIP에 공격자의 코드가 있는 곳의 주소를 들어가게 해 공격한다.

  이때 return address를 조작하고 그 위치에 공격 코드를 넣어주어야 하는데, little endian 시스템에 retrun address 값을 넣을 때에는 바이트 순서를 뒤집어서 넣어 주어야 한다.

 

 3) buffer overflow 공격의 문제점

  return address 위의 버퍼 공간이 쉘 코드를 넣을 만큼 충분하지 않다면 다른 공간을 활용해야 한다.

  이를 위해서는 return address가 EIP에 들어간 후 다른 공간에 있는 명령을 수행할 수 있게 해주어야 한다. 이때 그 공간의 주소를 정확하게 알아내는 것이 어려우므로 return address의 값을 그 공간의 주소로 직접 변경해주는 것이 쉽지 않다. 따라서 다음 그림과 같이 간접적인 방법을 이용한다.(33쪽)

 

 ESP가 가리키는 지점을 쉘 코드가 있는 위치를 가리키도록 48byte를 빼주고 jmp %esp instruction을 수행하여 EIP에 ESP가 가리키는 지점의 주소를 넣도록 한다. ESP 레지스터는 사용자가 직접 수정할 수 있는 레지스터이기 때문이다.

이렇게 하면 return address 위의 버퍼 공간을 최소로 사용하면서 낭비되는 다른 공간들도 활용할 수 있다.

 

 4) 쉘 코드 만들기

  - 쉘 코드 : 쉘(shell)을 실행시키는 코드. 바이너리 형태의 기계어 코드이다.

   -> 버퍼 오버플로우 공격을 위해서는 실행중인 프로세스에 어떤 동작을 하도록 코드를 넣어 그 실행 흐름을 조작해야 한다. 따라서 실행 가능한 상태의 명령어를 만들어야 하므로 실행파일을 실행시킬 수 있는 쉘 코드를 만들어야 한다.

 

기계어 코드로 쉘 코드를 작성하는 것은 매우 어려우므로 C를 이용해 쉘 실행 프로그램을 작성한 후 컴파일러를 통해 이를 어셈블리어로 변환하고, 수정을 통해 기계어 형태의 쉘 코드를 생성하는 방식을 취할 수 있다.

 

 5) 쉘 실행 프로그램

  쉘 상에서 쉘을 실행시키기 위해서는 '/bin/sh'라는 명령을 내려야 하므로 쉘 실행 프로그램 또한 이 명령을 내리도록 해주어야 한다.

(34페이지)

 

 

***execve() 함수

쉘을 실행시키기 위해서는 execve() 함수가 필요하다.

이 함수는 바이너리 형태의 실행파일이나 스크립트 파일을 실행시킨다.

const char *형의 인자 세 개를 요구한다. 첫 번째 인자는 실행시킬 파일의 이름, 두 번째 인자는 함께 넘겨줄 인자들의 포인터, 세 번째 인자는 환경 변수 포인터이다.

여기서 두 번째 인자인 인자들의 포인터는 C 프로그램의 main() 함수에 argv라는 인자를 떠올리면 된다. argv[0]은 해당 프로그램의 실행 파일 이름을 나타내고 argv[1]부터는 실행 시 주어진 인자들을 차례로 나타낸다. 마찬가지로 execve()의 두 번째 인자는 argv[0]부터 들어가는 값을 가리키는 포인터가 되어야 한다.

 

execve() 함수의 실제 코드는 libc에 들어 있으므로 이 프로그램은 컴파일되면서 Linux libc과 링크된다. 따라서 컴파일할 때 static library 옵션을 주어야 한다.

 

 6) Dynamic Link Library vs Static Link Library

  - Dynamic Link Library(동적 링크 라이브러리)

   :  printf()와 같이 자주 사용되는 함수의 기능을 수행하는 기계어 코드들을 각 실행파일들이 중복해서 포함하고 있다면 저장 공간이 낭비된다. 따라서 이와 같은 함수들에 대해서는 운영체제가 그 기계어 코드를 가지고 있고, 다른 프로그램들이 이 기능을 빌려쓰게 해주는 방식이다.

    .so 혹은 .a라는 확장자를 가진 형태로 존재한다.

    리눅스 : libc / windows : DLL(Dynamic Link Library)

 

  - Static Link Library

   : 위와 같이 운영체제의 버전과 libc의 버전에 따라 호출 형태나 링크 형태가 달라질 수 있다.

    이러한 영향을 받지 않기 위해 라이브러리 함수의 기계어 코드를 실행파일이 직접 가지고 있게 하는 방식이다.

    Dynamic Link Library 방식보다 실행파일의 크기가 커진다.

 

따라서 excve() 함수의 내부를 보기 위해서는 Static Link Library 형태로 컴파일한 후 기계어 코드를 보아야 한다.

(36, 37페이지)

 

gcc의 -v 옵션을 사용해 gcc의 컴파일 방식을 출력하였다.

sh.c를 static link library(-static)로 컴파일하여 sh라는 실행파일을 만들었다. 그리고 objdump를 이용하여 기계어 코드가 출력되게 하였다. 이때 필요한 execve() 함수 부분만 보기 위해 grep 명령어를 사용하였다. execve() 부분은 32라인이면 다 보이기 때문에 -A 32 옵션을 주어 32라인만 출력하게 하였다.

 

 

코드는 세 개의 column으로 출력되는데 맨 왼쪽은 address를 나타내고 가운데는 기계어 코드, 맨 오른쪽은 기계어 코드에 대응하는 어셈블리 코드를 나타낸다.

 

execve() 함수 내에서는 함수 프롤로그를 하고 함수 호출 이전에 스택에 쌓인 인자값들을 검사한 후 이상이 없으면 인터럽트를 발생시켜 시스템 콜(system call)을 한다. 시스템 콜은 운영체제와 약속된 행동을 해달라고 요청하는 것이다.

execve() 함수는 인터럽트를 발생시키기 이전에 범용 레지스터에 각 인자들을 집어넣어주어야 한다. 따라서

(38페이지)

 

mov 0x8(%ebp), %ebx

mov 0xc(%ebp), %ecx

mov 0x10(%ebp), %edx

를 하는 것이다. 이는 ebp 레지스터가 가리키는 곳의 +8byte 지점의 값을 ebx 레지스터에 넣고, +12byte 지점의 값을 ecx에 넣고, +16byte 지점의 값을 edx 레지스터에 넣으라는 뜻이다. (ebp+0byte 지점에는 이전 함수의 ebp가, ebp+4byte 지점에는 return address가 들어있다.)

 

그 다음 system call을 한다.

mov $0xb, %eax

int $0x80

eax 레지스터에 11(0xb)을 넣고 int $0x80을 하였다.

int $0x80는 운영체제에 할당된 인터럽트 영역으로 system call을 하라는 뜻이다.

int $0x80을 호출하기 이전에 eax 레지스터에 시스템 콜 벡터(vector)를 지정해 주어야 하는데 execve()에 해당하는 값이 11인 것이다.

즉, 11번 system call을 호출하기 위해 각 범용 레지스터의 값들을 채우고 system call을 위한 인터럽트를 발생시킨 것.

 

 

다음으로 execve() 함수가 호출되기 전 main()에서 한 처리를 살펴보았다.

main()에서는 execve()에 세 개의 인자를 넘겨주기 위해 세 번의 push를 한다.

 

movl $0x8089728, 0xfffffff8(%ebp)

movl $0x0, 0xffffffc(%ebp)

sub $0x4, %esp

push $0x0

lea 0xfffffff8(%ebp), %eax

push %eax

pushl 0xfffffff8(%ebp)

call 804c75c <__exeave>

우선 '/bin/sh'라는 문자열이 들어있는 곳의 주소(0x8089728)를 ebp 레지스터가 가리키는 곳의 -8byte 지점(0xfffffff8)에 넣는다.

그리고 ebp -4byte 지점(0xfffffffc)에는 0을 넣는다.

위는 sh.c에서

shell[0] = "/bin/sh"

shell[1] = NULL;

와 같은 역할을 한다.

 

이제 이 값들을 PUSH 해야 한다.

push $0x0

NULL을 PUSH 하고

lea 0xfffffff8(%ebp), %eax

push %eax

ebp+8의 주소를 eax 레지스터에 넣은 다음 eax 레지스터를 PUSH한다.

 

이상의 수행을 마치고 나면 segment 내의 모습은 다음과 같아진다. (41페이지)

 

위의 코드에서는 'bin/sh'가 data segment에 저장되어 있기 때문에 data segment의 주소를 이용할 수 있었지만 buffer overflow 공격 시점에서는 '/bin/sh'가 어느 지점에 저장되어 있다는 것을 기대하기도 여렵고 또한 저장되어 있다고 하더라도 저장되어 있는 메모리 공간의 주소를 찾기도 어렵다. 따라서 직접 넣어주어야 한다.

이와 같은 역할을 하는 코드를 작성해보면

push $0x0                        // NULL을 넣어준다. 

push '/sh\0'                     // /sh\0 문자열의 끝을 의미하는 \0

push '/bin'                       // /bin 문자열. 위와 합쳐서 /bin/sh\0이 된다.

mov %esp, %ebx               // 현재 스택 포인터는 /bin/sh\0을 넣은 지점이다.

push $0x0                        // NULL을 PUSH

push %ebx                       // /bin/sh\0의 포인터를 PUSH 

mov %esp, %ecx               // esp 레지스터는 /bin/sh\0의 포인터의 포인터이다.

mov $0x0, %edx                // edx 레지스터에 NULL을 넣어준다.

mov $oxb, %eax                // system call vector를 12번으로 지정. eax에 넣는다.

int $0x80                         // system call을 호출하라는 interrupt 발생

와 같은 식으로 작성 가능하다. 여기서 '/sh\0'와 '/bin'를 실제 코드로 만들기 위해서는 이 문자를 16진수 값으로 바꾸어야 한다. 따라서 

push '/sh\0'

push '/bin'

push $0x0068732f

push $0x6e69622f

로 작성해 주어야 한다.

 

이를 컴파일하기 위한 프로그램을 작성하려고 한다. c 내에 인라인 어셈블(inline assemble)로 코딩할 것이다. main() 함수 내에 들어가기 때문에 컴파일러가 알아서 함수 프롤로그를 만들어주므로 함수 프롤로그를 작성할 필요가 없다.

'/bin/sh'를 16진수 형태로 바꾸고 main() 함수 안에 넣어서 작성한 sh01.c 코드는 아래와 같다.

(43페이지)

 

 7) NULL의 제거

 여기5서 얻은 기계어 쉘코드를 문자열 형태로 전달해야 하기 때문이다. c언어에서는 char형 변수에 바이너리 값을 넣는 방법을 제공하고 있다. char c="\x90"과 같은 형태로 값을 넣어주면 컴파일러는 이를 문자열로 보지 않고 16진수인 90으로 인식하여 1byte의 데이터로 저장하는 것이다.

 push 0x0과 같은 어셈블리 코드는 기계어 코드로 6a 00이므로 이것을 문자열 형태로 전달하려면

char a[] = "\x6a\x00"과 같이 해주어야 한다. 그런데 여기서 문제가 있다. char형 문자열 배열에서는 0의  값을 만나면 그것을 문자열의 끝으로 인식하게 된다. 즉 0x00 뒤에 어떤 값이 있더라도 그 이후는 무시해 버린다. 따라서 \x00인 기계어 코드가 생기지 않도록 고쳐주어야 한다. 이러한 문제점을 해결하여 위의 어셈블리 코드를 다시 작성하면 아래와 같게 만들 수 있다.

xor %eax, %eax                            // 같은 수를 XOR 하면 0이 된다. 즉 NULL 이다.

push %eax                                  // NULL을 PUSH한다.

push $0x68732f2f                         // /bin/sh나 /bin//sh나 둘 다 shell을 띄운다.

push $0x6e69622f                        // /bin 문자열. 위와 합쳐서 /bin//sh가 된다.

mov %esp, %ebx                          // 현재 스택 포인터는 /bin//sh를 넣은 지점이다.

push %eax                                  // NULL을 PUSH

push %ebx                                  // /bin//sh의 포인터를 PUSH

mov %esp, %ecx                          // esp 레지스터는 /bin//sh 포인터의 포인터이다.

mov %eax, %edx                          // edx 레지스터에 NULL을 넣어준다.

mov $0xb, %al                             // system call vuctor를 12번을 지정하고 al에 넣는다.

int $0x80                                    // system call을 호출하라는 interrupt 발생.

(45페이지)

 

이제 이 것을 문자열화 시켜야 한다. 즉 위의 코드를 \x90 형식으로 바꿔주어야 하는 것이다.

위의 코드에 대한 기계어 코드는 아래와 같다.

 

31 c0

50

68 2f 2f 73 68

68 2f 62 69 6e

89 e3

50

53

89 e1

89 c2

b0 0b

cd 80

 

이것을 문자열 배열에 넣기 위해 다시 가공하면

"\x31\xc0"

"\x50"

"\x68\x2f\x2f\x73\x68"

"\x68\x2f\x62\x69\x6e"

"\x89\xe3"

"\x50"

"\x53"

"\x89\xe1"

"\x89\xc2"

"\xb0\x0b"

"\xcd\x80"

이다.

(페이지 47)

이러한 방식으로 쉘 코드를 실행시킬 수 있다. 이 프로그램의 동작 원리를 알아보기 위해 disassemble 해보았다.

(페이지 48)

 

disassmeble 결과를 보면 함수 프롤로그 수행 이후 다음과 같은 코드를 수행하는 것을 알 수 있다.

0x8048304 <main+16>:   lea   0xfffffffc(%ebp), %eax

0x8048307 <main+19>:   add   $0x8, %eax

0x804830a <main+22>:   mov   %eax, 0xfffffffc(%ebp)

0x804830d <main+25>:   mov   0xfffffffc(%ebp), %eax

0x8048310 <main+28>:   movl   $0x804936c, (%eax)