Post

메모리 구조

프로그램의 실행은 먼저 그 프로그램이 메인 메모리에 로드되는 과정으로 시작된다.

이후 운영체제는 코드, 데이터, 스택, 힙 등 역할에 따라 구분된 메모리 영역을 구성하고, 각 영역은 상호작용을 통해 전체 프로그램의 실행 흐름을 담당한다.

메모리 공간(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가지 섹션으로 나뉜다.

  1. .data 섹션 (초기값이 있는 전역/정적 변수)
1
int a = 10; // -> .data에 저장
  1. .bss 섹션 (초기값이 없는 전역/정적 변수)
1
int b; // -> .bss에 저장
  1. .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”가 저장되어 있는 것을 확인할 수 있다.

This post is licensed under CC BY 4.0 by the author.