프로그램 구동 시 segment 동작 방식

옥영진·2021년 5월 2일
0

BOF

목록 보기
2/4

프로그램 구동 시 segment 동작 방식

// simple.c
void function(int a, int b, int c){
    char buffer1[15];
    char buffer2[10];
}

void main(){
    function(1, 2, 3);
}

예시로 위의 간단한 프로그램이 실행되었을 때 프로세스가 메모리에 적재된 후 메모리와 레지스터가 어떻게 동작하는지 알아보자.

일단 위 C 프로그램을 gcc를 통해 어셈블리 코드로 변경하면 아래와 같이 변환된다.

    .file	"simple.c"
    .text
.globl function
    .type	function,@function
function:
    pushl	%ebp
    movl	%esp, %ebp
    subl	$40, %esp
    leave
    ret
.Lfe1:
    .size	function,.Lfe1-function
.globl main
    .type	main,@function
main:
    pushl	%ebp
    movl	%esp, %ebp
    subl	$8, %esp
    andl	$-16, %esp
    movl	$0, %eax
    subl	%eax, %esp
    subl	$4, %esp
    pushl	$3
    pushl	$2
    pushl	$1
    call	function
    addl	$16, %esp
    leave
    ret
.Lfe2:
    .size	main,.Lfe2-main
    .ident	"GCC: (GNU) 3.2.3 20030422 (Hancom Linux 3.2.3)"

프로그램이 컴파일 되어 실제 메모리 상에 어느 위치에 존재하게 될지 알아보기 위해서 컴파일을 한 후에 gdb를 이용하여 어셈블리 코드와 메모리에 적재될 logical address를 살펴보자.

GNU gdb Red Hat Linux (5.2.1-4)
Copyright 2002 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for details.
This GDB was configured as "i386-redhat-linux"...
(gdb) disas main
Dump of assembler code for function main:
0x80482fc <main>:	push	%ebp
0x80482fd <main+1>:	mov	%esp,%ebp
0x80482ff <main+3>:	sub	$0x8,%esp
0x8048302 <main+6>:	and	$0xfffffff0,%esp
0x8048305 <main+9>:	mov	$0x0,%eax
0x804830a <main+14>:	sub	%eax,%esp
0x804830c <main+16>:	sub	$0x4,%esp
0x804830f <main+19>:	push	$0x3
0x8048311 <main+21>:	push	$0x2
0x8048313 <main+23>:	push	$0x1
0x8048315 <main+25>:	call	0x80482f4 <function>
0x804831a <main+30>:	add	$0x10,%esp
0x804831d <main+33>:	leave
0x804831e <main+34>:	ret
0x804831f <main+35>:	nop
End of assembler dump.
(gdb) disas function Dump of assembler code for function function:
0x80482f4 <function>:	push	%ebp
0x80482f5 <function+1>:	mov	%esp,%ebp
0x80482f7 <function+3>:	sub	$0x28,%esp
0x80482fa <function+6>: leave
0x80482fb <function+7>: ret
End of assembler dump.
(gdb)

앞에 붙어 있는 주소가 바로 logical address이고, function() 함수가 아래에 위치하고 main() 함수는 위에 자리잡고 있음을 알 수 있다. 따라서 이 프로그램이 실행될 때의 segment의 구조는 아래 그림과 같이 될 것임을 예상할 수 있다.

simple.c 프로그램에서는 전역변수를 선언하지 않았기 대문에 data segment에는 링크된 라이브러리의 전역변수 값만 들어 있을 것이다.
프로그램이 시작되면 CPU가 수행할 명령이 있는 레지스터인 EIP 레지스터가 main() 함수가 시작되는 코드를 가리키고 있을 것이다. main() 함수의 시작점은 0x80482fc가 된다.

step 1)


EIP는 main() 함수 시작점을 가리키고 있고, ESP는 스택의 맨 꼭대기를 가키고 있다. 프로그램이 수행되면서 수많은 PUSH와 POP 명령을 할 것인데, ESP가 가리키는 지점에 PUSH와 POP 명령을 수행하라는 의미이다.
main 함수 시작 시 EBP를 저장하는(push %ebp) 이유는 이전에 수행하던 함수의 데이터를 보존하기 위해서인데, 이를 base pointer라고 한다. 함수가 시작될 때에는 stack pointer와 base pointer를 새로 지정하는데 이러한 과정을 함수 프롤로그 과정이라고 한다.

step 2)

  1. push %ebp : 이전 함수의 base pointer를 저장하면 stack pointer는 4바이트 아래인 0xbffffa78을 가리키게 된다.
  2. mov %esp, %ebp : ESP 값을 EBP에 복사함으로써 함수의 base pointer와 stack pointer가 같은 지점을 가리키게 된다.
  3. sub $0x8, %esp : ESP에서 8을 빼라는 명령으로, ESP는 8바이트 아래 지점을 가리키게 되고, stack에 8바이트의 공간이 생기게 된다. 이 명령 수행 후 ESP에는 0xbffffa70이 들어가게 된다.
  4. and $0xfffffff0, %esp : ESP와 11111111 11111111 11111111 11110000을 AND 연산하여 ESP 주소값의 맨 뒤 4bit를 0으로 만든다.
  5. mov $0x0, %eax : EAX 레지스터에 0을 넣는다.
  6. sub %eax, %esp : ESP에 들어 있는 값에서 EAX에 들어 있는 값만큼 뺀다. stack pointer를 EAX 값만큼 확장시키려하는 것이지만 EAX에는 0이 들어있으므로 의미 없는 명령이다.
  7. sub $0x4, %esp : stack을 4바이트 확장하여서 ESP에 들어있는 값은 0xbffffa6c가 된다.

step 3)

  1. push $0x03 push $0x02 push $0x01 : function(1, 2, 3)을 수행하기 위해 인자값 1, 2, 3을 넣어준다. 수행순서가 거꾸로 되어 있는 이유는 stack은 FILO(First In Last Out)이기 때문이다.
  2. call 0x80482f4 : 0x80482f4에 있는 명령을 수행하라는 것인데, function 함수가 자리잡은 곳이다. call 명령은 함수 호출시 사용하는 명령으로, 함수 종료 후 다시 이 후 명령을 진행할 수 있도록 이 후 명령이 있는 주소를 stack에 넣어 EIP에 함수의 시작 지점의 주소를 넣는다. (add $0x10, %esp 명령이 있는 주소)

함수 종료 후 stack에서 POP하여 어디서부터 명령을 이어서 진행할지 알 수 있게 되는데 이것이 바로 buffer overflow에서 가장 중요한 return address 이다.

step 4)


EIP는 function() 함수 시작 지점을 가리키고, stack에는 main() 함수에서 넣었던 값들이 쌓여있다.

  1. push %ebp mov %esp, %ebp : main() 함수에서 사용하던 base pointer가 저장되고 stack pointer를 function() 함수의 base pointer로 삼는다. (함수 프롤로그 수행)

step 5)

  1. sub $0x28, %esp : stack을 40 바이트 확장한다. 40 바이트인 이유는 function() 함수에서 지역 변수로 buffer1[15], buffer2[10]을 선언했기 때문인데, stack은 word(4 바이트) 단위이어서 buffer1[15]는 15 바이트 대신 16 바이트를, buffer2[10]은 12 바이트를 할당한다. 하지만 여기서 gcc 버전에 따라 또 달라지는데, gcc 2.96 이후 버전에서는 stack은 16배수로 할당된다. 8 바이트 이하의 버퍼는 1 word 단위로 할당되지만 9 바이트 이상의 버퍼는 4 word 단위로 할당이 되고 8 바이트의 dummy 값이 들어간다.
    따라서 buffer1[15]를 위해서 16 바이트 할당, buffer2[10]을 위해서 16 바이트 할당, 마지막으로 dummy 값 8 바이트, 총 40 바이트의 stack이 확장된 것이다.

step 6)


이전 단계에서 만들어진 버퍼를 통해 필요한 데이터를 사용할 수 있게 된다. 보통 mov $0x41, [$esp -4] mov $0x42, [$esp-8] 과 같은 형식으로 ESP를 기준으로 stack의 특정 지점에 데이터를 복사해 넣는 방식으로 동작한다.


stack은 위 그림과 같은 형태를 갖게 된다.

step 7)


leave 명령(함수 프롤로그 작업 되돌리기)을 수행한다. push %ebp mov %esp, %ebp 이것을 되돌리는 작업은 mov %ebp, %esp pop %ebp 인데, leave 명령이 이 두 가지 일을 한꺼번에 하는 것이다. 이전 함수 즉, main() 함수의 base pointer를 복원 시킨다.
POP을 했으므로 stack pointer는 1 word 위로 올라갈 것이고, return address가 있는 지점을 가리키게 될 것이다.

ret 명령은 이전 함수로 return 하라는 의미이다. EIP 레지스터에 return address를 POP하여 집어 넣는 역할을 한다.

step 8)


ret 수행 후 return address는 POP되어 EIP에 저장되고, stack pointer는 1 word 위로 올라간다.

  1. add $0x10, %esp : stack을 16 바이트 줄인다. stack pointer는 0x804830c에 있는 명령을 수행하기 이전의 위치로 돌아가게 된다.

마지막으로 leave, ret을 수행하게 되면 각 레지스터의 값은 main() 함수 프롤로그 작업을 되돌리고 main() 함수 이전으로 돌아가게 된다. 이것은 운영체제가 호출하는 함수인 init_process() 함수로 되돌아가는 것이다.

profile
안녕하세요 함께 공부합시다

0개의 댓글