이 글은 ELF 파일 로딩, main() 함수의 스택 프레임 설정, 스택 확장 시 발생하는 페이지 폴트, 그리고 GDB를 이용해 이를 확인하는 방법을 정리한 것이다.
ELF 헤더(Program Header) 분석
.text, .rodata, .data, .bss 등의 세그먼트가 있습니다. 세그먼트 매핑 예시
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를 매핑한다.매핑 권한
.text: 읽기(Read) + 실행(Execute) .rodata: 읽기(Read) .data: 읽기(Read) + 쓰기(Write) .bss: 읽기(Read) + 쓰기(Write) (제로 초기화)문자열 리터럴("helloworld") 위치
.rodata 섹션에 저장된다. objdump -s --section .rodata hello | grep -A2 "helloworld"
출력 예시:
0000000000000e00 68656c6c 6f776f72 6c6400 helloworld.
#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;
}
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 ; 복귀
(높은 주소)
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_obj와 buffer를 위한 메모리를 동시에 확보한다.memset(stack_obj, 0, 4096)이 실행될 때 스택 하위 주소(rbp-0x1010)부터 실제 쓰기가 이루어진다.스택 공간 예약
sub $0x1060, %rsp
%rsp 레지스터 값을 내릴 뿐, 물리 페이지를 할당하지 않는다.첫 번째 쓰기(store)에서 페이지 폴트 발생
memset@plt로 들어가서 실제로 첫 바이트를 쓰는 명령(rep stosb 또는 movb)이 실행되는 순간에 커널 페이지 폴트 핸들러
rbp-0x1010부터)를 매핑 memset 루프는 다음 바이트들을 같은 페이지 내에서 계속 쓰게 된다.다음 페이지 경계 넘어가면 또 폴트
rbp-0x1010 + 4096)에서 다시 페이지 폴트가 발생하여 두 번째 페이지가 매핑된다.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_init의 memcpy는 이미 매핑된 스택 영역에 문자열을 복사한다.
추후 GDB를 사용하여 스택 확장 후 공간에 접근했을 때 rss 값이 늘어나는 걸 관찰하는 실험을 할 계획이다.