메모리 구조
프로그램의 실행은 먼저 그 프로그램이 메인 메모리에 로드되는 과정으로 시작된다.
이후 운영체제는 코드, 데이터, 스택, 힙 등 역할에 따라 구분된 메모리 영역을 구성하고, 각 영역은 상호작용을 통해 전체 프로그램의 실행 흐름을 담당한다.
메모리 공간(RAM)은 프로그램 실행 시 운영체제에 의해 다음과 같이 구분된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌─────────────────────────────┐ ← 높은 주소 (High Address)
│ Stack │
│ - 지역 변수 │
│ - 함수 호출 정보 │
│ - 매개변수 │
│ ↓ 아래로 성장 │
└─────────────────────────────┘
┌─────────────────────────────┐
│ Heap │
│ - malloc(), new 등 │
│ - 동적 메모리 │
│ ↑ 위로 성장 │
└─────────────────────────────┘
┌─────────────────────────────┐
│ Data │
│ [.data] 초기값 있는 전역변수 │
│ [.bss] 초기값 없는 전역변수 │
└─────────────────────────────┘
┌────────────────────────────┐
│ Code (.text) │
│ - 함수, 명령어 │
│ - 실행할 기계어 코드 │
└────────────────────────────┘ ← 낮은 주소 (Low Address)
1. 코드 영역 (Code Segment)
실행 코드, 즉 프로그램의 기계어 명령어가 저장되는 영역이다.
함수 정의, 루프 조건문 등 실제 실행되는 명령이 이곳에 들어간다.
읽기 전용(READ-ONRY) 이므로 코드를 덮어씌울 수 없다.
공유 라이브러리(.so 파일 등)도 이 영역에 로드된다.
1
2
3
4
5
6
7
// code.c
#include <stdio.h>
int main() {
printf("Hello"); // prnitf는 .text에 저장, HELLO는 .rodata에 저장
return 0;
}
위와 같은 예시 파일을 생성 후 gdb로 확인해보자.
1
pwndbg> info files
gdb 실행 후 위의 명령어를 입력해보면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Entry point: 0x1060
0x0000000000000318 - 0x0000000000000334 is .interp
0x0000000000000338 - 0x0000000000000368 is .note.gnu.property
0x0000000000000368 - 0x000000000000038c is .note.gnu.build-id
0x000000000000038c - 0x00000000000003ac is .note.ABI-tag
0x00000000000003b0 - 0x00000000000003d4 is .gnu.hash
0x00000000000003d8 - 0x0000000000000480 is .dynsym
0x0000000000000480 - 0x000000000000050f is .dynstr
0x0000000000000510 - 0x000000000000051e is .gnu.version
0x0000000000000520 - 0x0000000000000550 is .gnu.version_r
0x0000000000000550 - 0x0000000000000610 is .rela.dyn
0x0000000000000610 - 0x0000000000000628 is .rela.plt
0x0000000000001000 - 0x000000000000101b is .init
0x0000000000001020 - 0x0000000000001040 is .plt
0x0000000000001040 - 0x0000000000001050 is .plt.got
0x0000000000001050 - 0x0000000000001060 is .plt.sec
0x0000000000001060 - 0x000000000000116c is .text
0x000000000000116c - 0x0000000000001179 is .fini
0x0000000000002000 - 0x000000000000200a is .rodata
0x000000000000200c - 0x0000000000002040 is .eh_frame_hdr
0x0000000000002040 - 0x00000000000020ec is .eh_frame
0x0000000000003db8 - 0x0000000000003dc0 is .init_array
0x0000000000003dc0 - 0x0000000000003dc8 is .fini_array
0x0000000000003dc8 - 0x0000000000003fb8 is .dynamic
0x0000000000003fb8 - 0x0000000000004000 is .got
0x0000000000004000 - 0x0000000000004010 is .data
0x0000000000004010 - 0x0000000000004018 is .bss
.text 섹션이 0x1060 ~ 0x116c 범위인 것을 알 수 있다.
1
pwndbg> x/20i 0x1060
0x1060 영역부터 20줄의 명령어를 출력해보면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pwndbg> x/20i 0x1060
0x1060 <_start>: endbr64
0x1064 <_start+4>: xor ebp,ebp
0x1066 <_start+6>: mov r9,rdx
0x1069 <_start+9>: pop rsi
0x106a <_start+10>: mov rdx,rsp
0x106d <_start+13>: and rsp,0xfffffffffffffff0
0x1071 <_start+17>: push rax
0x1072 <_start+18>: push rsp
0x1073 <_start+19>: xor r8d,r8d
0x1076 <_start+22>: xor ecx,ecx
0x1078 <_start+24>: lea rdi,[rip+0xca] # 0x1149 <main>
0x107f <_start+31>: call QWORD PTR [rip+0x2f53] # 0x3fd8
0x1085 <_start+37>: hlt
0x1086: cs nop WORD PTR [rax+rax*1+0x0]
0x1090 <deregister_tm_clones>: lea rdi,[rip+0x2f79] # 0x4010 <completed.0>
0x1097 <deregister_tm_clones+7>: lea rax,[rip+0x2f72] # 0x4010 <completed.0>
0x109e <deregister_tm_clones+14>: cmp rax,rdi
0x10a1 <deregister_tm_clones+17>: je 0x10b8 <deregister_tm_clones+40>
0x10a3 <deregister_tm_clones+19>: mov rax,QWORD PTR [rip+0x2f36] # 0x3fe0
0x10aa <deregister_tm_clones+26>: test rax,rax
위와 같이 나오는데, 0x1078을 보면 _start 함수에서 rip+0xca를 통해 main 함수의 주소인 0x1149로 점프하려고 rdi에 넣는 것을 확인할 수 있다.
따라서 main() 함수의 진입점이 0x1149인 것을 알 수 있다.
0x1149부터 명령어 30줄을 출력해보면,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pwndbg> x/30i 0x1149
0x1149 <main>: endbr64
0x114d <main+4>: push rbp
0x114e <main+5>: mov rbp,rsp
0x1151 <main+8>: lea rax,[rip+0xeac] # 0x2004
0x1158 <main+15>: mov rdi,rax
0x115b <main+18>: mov eax,0x0
0x1160 <main+23>: call 0x1050 <printf@plt>
0x1165 <main+28>: mov eax,0x0
0x116a <main+33>: pop rbp
0x116b <main+34>: ret
0x116c <_fini>: endbr64
0x1170 <_fini+4>: sub rsp,0x8
0x1174 <_fini+8>: add rsp,0x8
0x1178 <_fini+12>: ret
0x1179: Cannot access memory at address 0x1179
pwndbg>
1
2
pwndbg> x/s 0x2004
0x2004: "Hello"
0x1151에서 rip(lea 명령어의 다음 주소, 여기선 0x1158)+0xeac (0x2004) (.rodata에 저장된 “Hello” 문자열 주소)를 eax에 넣고,
0x1158에서 mov rdi(첫 번째 함수 인자), rax를 통해 hello를 rdi에 전달한다.
+) ELF (Executable and Linkable Format)는 위치 독립 코드 (Position Independent Code, PIC) 를 위해 RIP-relative addressing을 사용한다. 실행 위치가 어딘지 모르더라도, 상대 주소 (rip + offset)로 데이터 위치를 찾아낼 수 있게 만드는 구조
결론적으로, .text 섹션은 프로그램의 실행 명령어들이 저장되는 공간이며, main, _start 같은 함수들의 실제 어셈블리 명령어가 여기에 위치한다.
우리가 pwndbg에서 x/i 명령어를 통해 살펴본 영역은 .text 섹션의 일부로,
ELF 파일에서 프로그램이 어떤 순서로 실행되는지를 직접 눈으로 확인할 수 있는 영역이다.
++)
1
2
Entry point: 0x1060
0x0000000000001060 - 0x000000000000116c is .text
Entry Point는 처음 실행될 함수의 주소 (보통 _start)
해당 코드에서는 Entry point와 .text의 시작 주소가 같지만 다른 경우도 존재한다.
.text 앞부분에 _start가 없고 다른 함수들이 먼저 들어간다면
1
2
.text: 0x1000 - 0x2000
Entry point: 0x1100 ← .text의 중간 위치
.text 시작주소(0x1000)는 다른 함수가 차지하고, Entry Point는 _start가 있는 중간 주소 (0x1100)이다.
링커가 함수들을 .text 섹션 내에서 정렬 순서에 따라 배치하기 때문에 _start가 항상 앞에 배치된다는 보장이 없다.
또는 커스텀 Entry를 .init 같은 섹션으로 옮긴 경우
1
2
3
.text: 0x1000 - 0x2000
.init: 0x0800 - 0x0a00
Entry point: 0x0800 ← .init에 진입점 설정됨
.init은 프로그램 초기화 코드를 저장하는 섹션인데, 커스텀 링커 스크립트를 사용하거나 특정 환경(부트로더, 셸코드)에서는 .init에 진입점이 들어갈 수도 있다.
이를 이용해 Entry Point를 .text가 아닌 임의 영역으로 설정해 분석 방해, 리버싱 우회 등에 사용할 수 있다.
1. 데이터 영역 (Data Segment)
데이터 영역은 주로 전역변수, 정적변수 같은 프로그램 실행 중 유지되는 데이터들을 저장한다.
크게 3가지 섹션으로 나뉜다.
- .data 섹션 (초기값이 있는 전역/정적 변수)
1
int a = 10; // -> .data에 저장
- .bss 섹션 (초기값이 없는 전역/정적 변수)
1
int b; // -> .bss에 저장
- .rodata 섹션 (Read-Only data)
변경 불가능한 데이터 저장 (ex: 문자열 리터럴)
const로 선언된 상수나 “Hello” 같은 문자열이 여기 들어감
1
2
printf("Hello"); // -> "Hello"는 .rodata에 저장
const int x = 100; // -> .rodata
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
int a = 10; // .data
int b; // .bss
const char *msg = "Hello"; // .rodata
int main() {
static int s = 42; // .data
printf("%s\n", msg);
return 0;
}
위의 예제 코드를 통해 gdb로 살펴보자
1
2
3
4
5
6
7
pwndbg> info files
~~
0x0000000000002000 - 0x000000000000200a is .rodata
~~
0x0000000000004000 - 0x0000000000004020 is .data
0x0000000000004020 - 0x0000000000004028 is .bss
~~
1
2
3
4
5
6
pwndbg> print &a
$1 = (int *) 0x4010 <a>
pwndbg> print &b
$2 = (int *) 0x4024 <b>
pwndbg> print msg
$3 = 0x2004 "Hello"
확인한 것처럼 전역/정적 변수와 문자열 리터럴은 컴파일 시점에 세그먼트별로 나뉘어 메모리에 저장되며, 실행 중에도 각자의 섹션에서 유지된다.
3. 힙 영역 (Heap Segment)
동적 메모리 할당에 사용되는 영역이다.
malloc, calloc, realloc, new 등으로 실행중(runtime)에 메모리를 할당받을 때 쓰인다.
프로그램 실행 중 메모리 크기가 가변적인 데이터를 저장할 때 사용된다.
heap의 구조는 아래와 같다. (glibc 기준)
1
2
3
4
5
6
[ Heap Layout ]
+--------------------------+
| Chunk Header (size, flag)|
+--------------------------+
| User Data |
+--------------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *name = malloc(32);
strcpy(name, "Hello from heap!");
printf("%s\n", name);
free(name);
return 0;
}
위의 예제코드를 gdb로 살펴보자.
1
2
pwndbg> b main
pwndbg> r
1
2
3
4
5
6
7
8
9
10
11
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <string.h>
4
5 int main() {
► 6 char *name = malloc(32);
7 strcpy(name, "Hello from heap!");
8
9 printf("%s\n", name);
10
11 free(name);
1
pwndbg> next 2
1
2
3
4
5
6
7
8
9
10
4
5 int main() {
6 char *name = malloc(32);
7 strcpy(name, "Hello from heap!");
8
► 9 printf("%s\n", name);
10
11 free(name);
12 return 0;
13 }
1
2
3
4
pwndbg> print name
$1 = 0x5555555592a0 "Hello from heap!"
pwndbg> x/s name
0x5555555592a0: "Hello from heap!"
해당 주소가 heap 영역에 속하는지 확인하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pwndbg> info proc mappings
process 5796
Mapped address spaces:
Start Addr End Addr Size Offset Perms objfile
0x555555554000 0x555555555000 0x1000 0x0 r--p /mnt/c/Users/726ks/whs/pwn/memory/heap
0x555555555000 0x555555556000 0x1000 0x1000 r-xp /mnt/c/Users/726ks/whs/pwn/memory/heap
0x555555556000 0x555555557000 0x1000 0x2000 r--p /mnt/c/Users/726ks/whs/pwn/memory/heap
0x555555557000 0x555555558000 0x1000 0x2000 r--p /mnt/c/Users/726ks/whs/pwn/memory/heap
0x555555558000 0x555555559000 0x1000 0x3000 rw-p /mnt/c/Users/726ks/whs/pwn/memory/heap
0x555555559000 0x55555557a000 0x21000 0x0 rw-p [heap]
0x7ffff7d85000 0x7ffff7d88000 0x3000 0x0 rw-p
0x7ffff7d88000 0x7ffff7db0000 0x28000 0x0 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7db0000 0x7ffff7f45000 0x195000 0x28000 r-xp /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f45000 0x7ffff7f9d000 0x58000 0x1bd000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f9d000 0x7ffff7f9e000 0x1000 0x215000 ---p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f9e000 0x7ffff7fa2000 0x4000 0x215000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa2000 0x7ffff7fa4000 0x2000 0x219000 rw-p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa4000 0x7ffff7fb1000 0xd000 0x0 rw-p
0x7ffff7fbb000 0x7ffff7fbd000 0x2000 0x0 rw-p
0x7ffff7fbd000 0x7ffff7fc1000 0x4000 0x0 r--p [vvar]
0x7ffff7fc1000 0x7ffff7fc3000 0x2000 0x0 r-xp [vdso]
0x7ffff7fc3000 0x7ffff7fc5000 0x2000 0x0 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fc5000 0x7ffff7fef000 0x2a000 0x2000 r-xp /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fef000 0x7ffff7ffa000 0xb000 0x2c000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffb000 0x7ffff7ffd000 0x2000 0x37000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x39000 rw-p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffffffde000 0x7ffffffff000 0x21000 0x0 rw-p [stack]
heap 영역이 0x555555559000 ~ 0x55555557a000 이므로 확인되었다.
1
pwndbg> n
1
2
3
4
5
6
7
7 strcpy(name, "Hello from heap!");
8
9 printf("%s\n", name);
10
11 free(name);
► 12 return 0;
13 }
free() 이후에 x/s name을 다시 출력해보면
1
2
3
4
pwndbg> x/s name
0x5555555592a0: "YUUU\005"
pwndbg> print name
$2 = 0x5555555592a0 "YUUU\005"
문자열 “Hello from heap!” 이 깨지고 해당 주소에는 glibc malloc 내부 정보로 덮어씌워진 것을 확인할 수 있다.
++) glibc는 GNU C Library의 줄임말
- 리눅스에서 사용하는 표준 C 라이브러리
- printf, malloc, free, exit, strcpy, read, write 등 기본 함수들 대부분이 glibc 소속
glibc 버전이 다르면 익스플로잇이 달라지는데, 그 이유는 glibc의 힙 구현 방식과 함수 내부 로직이 버전에 따라 바뀌기 때문이다.
익스플로잇은 그 내부 구조를 정확히 조작하는 기술이므로 버전이 다르면 작동 방식이 완전히 달라질 수 있다.
ex)
2.23 버전은 fastbin 위주이고, 2.27 버전은 tcache가 도입되었다.
예전 버전은 double free 방어가 불가능하고, 최신 버전은 보안 기능이 강화되었다.
익스플로잇에서 system() 주소를 계산할 때 아래와 같이 하는데,
1
system_addr = libc_base + offset_of_system
glibc 버전이 바뀌면 offset_of_system이 달라진다.
4. 스택 영역 (Stack Segment)
스택은 함수 호출 시 자동으로 생성되는 메모리 공간이고, 임시 데이터를 저장하는 데 사용된다.
LIFO(Last In, First Out) 구조로, 가장 나중에 들어온 데디터가 가장 먼저 나간다.
스택에 저장되는 것들에는 지역 변수, 함수 매개변수, 리턴 주소, 저장된 RBP, 임시 데이터 등이 있다.
스택 공간은 함수 호출 시 자동으로 자동으로 할당되고, 함수가 끝나면 자동 해제된다.
연속된 주소 공간을 사용하고 메모리 주소가 낮은 주소 방향으로 성장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
High Address
┌──────────────┐
│ 함수 A 프레임 │
│ 리턴 주소 │
│ 저장된 RBP │
│ 매개변수, 지역변수 │
├──────────────┤
│ 함수 B 프레임 │
│ 리턴 주소 │
│ 저장된 RBP │
│ 매개변수, 지역변수 │
└──────────────┘
Low Address
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
void func(int x) {
int a = 1234;
printf("x: %d, a: %d\n", x, a);
}
int main() {
int y = 5678;
func(y);
return 0;
}
위의 예제코드를 gdb로 살펴보자.
1
2
3
4
5
8 int main() {
9 int y = 5678;
10 func(y);
► 11 return 0;
12 }
func(y)까지 실행한 후에 info registers 명령어로 레지스터들의 값을 확인해보면
1
pwndbg> info registers
1
2
3
rbp 0x7fffffffdd70 0x7fffffffdd70
rsp 0x7fffffffdd60 0x7fffffffdd60
rip 0x55555555519b 0x55555555519b <main+29>
rsp(스택 포인터) , rbp(베이스 포인터), rip(현재 실행 중인 명령어 주소) 등을 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pwndbg> x/32gx $rsp
0x7fffffffdd60: 0x0000000000000000 0x0000162e00000000
0x7fffffffdd70: 0x0000000000000001 0x00007ffff7db1d90
0x7fffffffdd80: 0x0000000000000000 0x000055555555517e
0x7fffffffdd90: 0x0000000100000000 0x00007fffffffde88
0x7fffffffdda0: 0x0000000000000000 0x4fae9de9372ba9e4
0x7fffffffddb0: 0x00007fffffffde88 0x000055555555517e
0x7fffffffddc0: 0x0000555555557dc0 0x00007ffff7ffd040
0x7fffffffddd0: 0xb05162168c29a9e4 0xb051725f0da1a9e4
0x7fffffffdde0: 0x0000000000000000 0x0000000000000000
0x7fffffffddf0: 0x0000000000000000 0x0000000000000000
0x7fffffffde00: 0x0000000000000000 0x89e2afec7bac9800
0x7fffffffde10: 0x0000000000000000 0x00007ffff7db1e40
0x7fffffffde20: 0x00007fffffffde98 0x0000555555557dc0
0x7fffffffde30: 0x00007ffff7ffe2e0 0x0000000000000000
0x7fffffffde40: 0x0000000000000000 0x0000555555555060
0x7fffffffde50: 0x00007fffffffde80 0x0000000000000000
x/32gx $rsp 출력은 현재 스택의 32개의 8바이트 값을 16진수로 본 것이다.
이걸 분석하면 함수 호출 상황과 로컬 변수, 리턴 주소, RBP 등을 파악할 수 있다.
info registers로 확인한 주소들을 확인해보면
0x7fffffffdd70: 0x0000000000000001 ← rbp 값 (현재는 더미 값이 세팅된듯)
0x7fffffffdd78: 0x00007ffff7db1d90 ← return address
1
2
pwndbg> info symbol 0x7ffff7db1d90
__libc_start_call_main + 128 in section .text of /lib/x86_64-linux-gnu/libc.so.6
이때, 0x00007ffff7db1d90는 ___libc_start_call_main + 128 이므로 함수가 종료된 후 C 런타임의 시작 함수로 돌아간다는 것(glibc 함수로 복귀)을 알 수 있다.
지역 변수들은 rbp 이전 주소들에 할당이 되기 때문에 rbp - 4부터 살펴보아야 한다.
main()에서 int y = 5678 이후에 rbp-4를 확인하면
1
2
pwndbg> x/wx $rbp-4
0x7fffffffdd6c: 0x0000162e
위와 같이 “5678”이 저장되어 있는 것을 확인할 수 있고,
1
2
pwndbg> x/wx $rbp-4
0x7fffffffdd4c: 0x000004d2
func() 내부에서 rbp-4 를 확인하면 “1234”가 저장되어 있는 것을 확인할 수 있다.