elf 파일과 스택의 관계

공부용·2025년 6월 4일
post-thumbnail

스택 확장과 페이지 폴트 관찰

이 글은 ELF 파일 로딩, main() 함수의 스택 프레임 설정, 스택 확장 시 발생하는 페이지 폴트, 그리고 GDB를 이용해 이를 확인하는 방법을 정리한 것이다.


1. ELF 세그먼트가 가상주소(VA)에 매핑되는 과정

  1. ELF 헤더(Program Header) 분석

    • ELF 실행 파일에는 .text, .rodata, .data, .bss 등의 세그먼트가 있습니다.
    • 커널은 실행 시 이 Program Header를 읽어서, 각 세그먼트를 메모리에 매핑할 위치와 크기를 결정한다.
  2. 세그먼트 매핑 예시

    readelf -l hello

    출력 예시:

    Program Headers:
      Type           Offset   VirtAddr           PhysAddr
                     FileSiz  MemSiz              Flags  Align
      LOAD           0x000000 0x0000000000400000 0x0000000000400000
                     0x001000 0x001000             R E    200000
      LOAD           0x002000 0x0000000000600000 0x0000000000600000
                     0x000200 0x000200             RW     200000
    • 첫 번째 LOAD.text.rodata를,
    • 두 번째 LOAD.data.bss를 매핑한다.
  3. 매핑 권한

    • .text: 읽기(Read) + 실행(Execute)
    • .rodata: 읽기(Read)
    • .data: 읽기(Read) + 쓰기(Write)
    • .bss: 읽기(Read) + 쓰기(Write) (제로 초기화)
  4. 문자열 리터럴("helloworld") 위치

    • 컴파일 시 문자열 리터럴은 .rodata 섹션에 저장된다.
    objdump -s --section .rodata hello | grep -A2 "helloworld"

    출력 예시:

    0000000000000e00 68656c6c 6f776f72 6c6400       helloworld.
    • 이 가상주소는 ELF 로더에 의해 이미 페이지 단위로 매핑되어 있으므로, 런타임에 페이지 폴트가 발생하지 않는다.

2. main() 함수의 스택 프레임 설정

2.1 실험용 c코드

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

static char stack_obj[4096];
static int buffer[20];

void buffer_init(int *buffer, const char *str) {
    memcpy((char *)buffer, str, strlen(str) + 1);
}

int main() {
    memset(stack_obj, 0, sizeof(stack_obj));
    buffer_init(buffer, "helloworld");
    printf("buffer에 저장된 문자열: %s\n", (char *)buffer);
    return 0;
}

2.2 어셈블리 변환 결과

00000000000007e0 <main>:
 7e0: push   %rbp
 7e1: mov    %rsp, %rbp
 7e4: sub    $0x1060, %rsp         ; 스택 공간(4096B + 80B + 8B canary + 정렬)을 확보
 7eb: mov    %fs:0x28, %rax         ; stack canary 읽기
 7f4: mov    %rax, -0x8(%rbp)       ; canary 저장
 7fa: lea    -0x1010(%rbp), %rax     ; stack_obj 주소 계산
 801: mov    $0x1000, %edx           ; 크기 = 4096
 806: mov    $0x0, %esi              ; 값 = 0
 80b: mov    %rax, %rdi              ; 인자: stack_obj
 80e: call   memset@plt              ; stack_obj를 0으로 초기화 → 페이지 폴트 발생 지점
 813: lea    -0x1060(%rbp), %rax     ; buffer 주소 계산
 81a: lea    <helloworld 리터럴>, %rsi ; 인자: 문자열 리터럴 주소
 821: mov    %rax, %rdi              ; 인자: buffer
 824: call   buffer_init             ; buffer에 "helloworld" 복사
 829: lea    -0x1060(%rbp), %rax     ; buffer 주소
 830: mov    %rax, %rsi              ; 인자: buffer
 833: lea    <포맷 문자열>, %rdi     ; 인자: 포맷 문자열
 83a: mov    $0x0, %eax              ; variadic 함수 준비
 83f: call   printf@plt              ; 출력
 844: mov    $0x0, %eax              ; return 0
 849: mov    -0x8(%rbp), %rcx        ; canary 재검사
 84d: xor    %fs:0x28, %rcx
 856: je     85d <main+0x7d>
 858: call   __stack_chk_fail@plt     ; canary 위조 시 종료
 85d: leave                           ; mov %rbp, %rsp + pop %rbp
 85e: ret                             ; 복귀

2.3 스택 메모리 배치

(높은 주소)
 rbp + 0x00    ← 이전 프레임 포인터
 rbp - 0x08    ← stack canary
 rbp - 0x10    ← (정렬 여유)
 rbp - 0x1060  ← buffer[20] (80바이트)
 rbp - 0x1010  ← stack_obj[4096] (4096바이트)
 rbp - 0x0000  ← (%rbp 위치)
(낮은 주소)
  • sub $0x1060, %rsp 명령 한 번으로 stack_objbuffer를 위한 메모리를 동시에 확보한다.
  • 이후 memset(stack_obj, 0, 4096)이 실행될 때 스택 하위 주소(rbp-0x1010)부터 실제 쓰기가 이루어진다.

3. 스택 확장 시 페이지 폴트 발생 지점

  1. 스택 공간 예약

    sub    $0x1060, %rsp
    • 이 명령은 단순히 %rsp 레지스터 값을 내릴 뿐, 물리 페이지를 할당하지 않는다.
    • 따라서 스택은 논리적으로 확장되었지만, 아직 물리 메모리는 매핑되지 않은 상태이다.
  2. 첫 번째 쓰기(store)에서 페이지 폴트 발생

    • memset@plt로 들어가서 실제로 첫 바이트를 쓰는 명령(rep stosb 또는 movb)이 실행되는 순간에
    • 그 가상주소가 물리 메모리와 연결되어 있지 않으므로 CPU가 페이지 폴트(#PF)를 발생한다.
  3. 커널 페이지 폴트 핸들러

    • “이 접근이 스택 확장에 의한 것”임을 확인
    • 새 물리 페이지를 할당하고, 해당 가상주소(예: rbp-0x1010부터)를 매핑
    • memset 루프는 다음 바이트들을 같은 페이지 내에서 계속 쓰게 된다.
  4. 다음 페이지 경계 넘어가면 또 폴트

    • 4096바이트를 다 쓰려면 페이지 경계를 넘어야 하므로, 두 번째 페이지 경계(예: rbp-0x1010 + 4096)에서 다시 페이지 폴트가 발생하여 두 번째 페이지가 매핑된다.

4. 결론

  • ELF 로더.text, .rodata, .data, .bss 등을 각 권한에 맞춰 가상주소에 매핑한다.

  • main 함수의 프롤로그(push rbp, mov rbp, rsp, sub rsp, 0x1060)는 스택 상에 로컬 변수 영역(stack_obj + buffer + canary) 을 한 번에 예약한다.

  • 페이지 폴트는 “가상주소에 실제 첫 쓰기가 발생하는 순간”에 커널이 물리 페이지를 할당해 연결하면서 일어난다.

  • memset(stack_obj, 0, 4096) 내부의 첫 번째 바이트 쓰기 명령(rep stosb 등)에서 스택 페이지가 실제 매핑되고,
    이후 buffer_initmemcpy는 이미 매핑된 스택 영역에 문자열을 복사한다.

    추후 GDB를 사용하여 스택 확장 후 공간에 접근했을 때 rss 값이 늘어나는 걸 관찰하는 실험을 할 계획이다.


profile
공부 내용을 가볍게 적어놓는 블로그.

0개의 댓글