ELF (Executable and Linkable Format)
ELF(Executable and Linkable Format)는 System V ABI에서 정의한 바이너리 형식으로, Linux에서 실행 파일, 공유 라이브러리, 오브젝트 파일, 코어 덤프의 표준 형식입니다. 이 문서는 ELF 포맷의 전체 구조(헤더, 세그먼트, 섹션, 심볼 테이블, 재배치, 동적 링킹, GOT/PLT), exec() 실행 흐름, Auxiliary Vector, ASLR/PIE, vDSO, TLS(Thread-Local Storage), 코어 덤프, 커널 모듈(.ko) ELF 구조, binfmt 핸들러, 심볼 버저닝, 스택 언와인딩(.eh_frame/DWARF/ORC), 링커 스크립트, ELF 보안 강화 기법을 다룹니다.
핵심 요약
- 단계 분리 — 펌웨어, 부트로더, 커널 초기화 경계를 구분합니다.
- 하드웨어 기술 — ACPI/DT 등 기술 정보가 어디서 소비되는지 확인합니다.
- 신뢰 체인 — Secure Boot 등 검증 체인을 흐름으로 이해합니다.
- 실패 지점 — 부팅 로그에서 단계별 실패 단서를 빠르게 찾습니다.
- 호환성 관점 — 플랫폼 차이에 따른 초기화 분기를 함께 점검합니다.
단계별 이해
- 부팅 단계 식별
현재 이슈가 어느 단계에서 발생하는지 먼저 고정합니다. - 입력 데이터 확인
펌웨어/테이블/이미지 메타데이터를 점검합니다. - 전환 경계 검증
단계 간 인자 전달과 상태 인계를 추적합니다. - 플랫폼별 재검증
다른 하드웨어 조건에서도 동일하게 동작하는지 확인합니다.
ELF 포맷 개요 (ELF Format Overview)
ELF는 System V ABI에서 정의한 바이너리 형식으로, 실행 파일, 공유 라이브러리, 오브젝트 파일, 코어 덤프를 모두 표현합니다. ELF 파일은 크게 세 부분으로 구성됩니다:
ELF 파일의 유형은 e_type 필드로 구분됩니다:
| 타입 | 값 | 설명 | 용도 |
|---|---|---|---|
ET_REL | 1 | 재배치 가능 파일 (.o) | 링커 입력, 커널 모듈(.ko) |
ET_EXEC | 2 | 실행 파일 (고정 주소) | 비-PIE 바이너리, 정적 링크 |
ET_DYN | 3 | 공유 오브젝트 | 공유 라이브러리(.so), PIE 실행 파일 |
ET_CORE | 4 | 코어 덤프 | 프로세스 크래시 분석 |
PIE와 ET_DYN: 현대 배포판에서 기본 컴파일 옵션(-fPIE -pie)으로 빌드된 실행 파일은 ET_DYN 타입입니다. 커널은 load_elf_binary()에서 ET_DYN이면서 인터프리터가 있으면 ASLR 적용 대상으로 판단하여 랜덤 로드 주소를 배정합니다. ET_EXEC 바이너리는 ELF 헤더에 지정된 고정 가상 주소에 로드됩니다.
ELF 헤더 (ELF Header)
모든 ELF 파일은 파일 오프셋 0에서 시작하는 ELF 헤더를 가집니다. 이 헤더는 파일의 전체적인 속성과 나머지 헤더 테이블의 위치를 지정합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_hdr {
unsigned char e_ident[EI_NIDENT]; /* ELF 식별자 (16바이트) */
Elf64_Half e_type; /* 파일 타입: ET_EXEC, ET_DYN 등 */
Elf64_Half e_machine; /* 아키텍처: EM_X86_64, EM_AARCH64 등 */
Elf64_Word e_version; /* ELF 버전 (항상 EV_CURRENT = 1) */
Elf64_Addr e_entry; /* 엔트리 포인트 가상 주소 */
Elf64_Off e_phoff; /* 프로그램 헤더 테이블의 파일 오프셋 */
Elf64_Off e_shoff; /* 섹션 헤더 테이블의 파일 오프셋 */
Elf64_Word e_flags; /* 프로세서별 플래그 */
Elf64_Half e_ehsize; /* ELF 헤더 크기 (64바이트) */
Elf64_Half e_phentsize; /* 프로그램 헤더 엔트리 크기 */
Elf64_Half e_phnum; /* 프로그램 헤더 엔트리 수 */
Elf64_Half e_shentsize; /* 섹션 헤더 엔트리 크기 */
Elf64_Half e_shnum; /* 섹션 헤더 엔트리 수 */
Elf64_Half e_shstrndx; /* 섹션 이름 문자열 테이블 인덱스 */
} Elf64_Ehdr;
e_ident 배열(16바이트)은 ELF 파일을 식별하는 매직 넘버와 기본 속성을 담고 있습니다:
| 인덱스 | 이름 | 값 | 설명 |
|---|---|---|---|
| 0~3 | EI_MAG0~3 | 0x7f 'E' 'L' 'F' | ELF 매직 넘버 |
| 4 | EI_CLASS | 1=32bit, 2=64bit | 주소 크기 클래스 |
| 5 | EI_DATA | 1=LE, 2=BE | 바이트 순서(엔디안) |
| 6 | EI_VERSION | 1 (EV_CURRENT) | ELF 규격 버전 |
| 7 | EI_OSABI | 0=ELFOSABI_NONE | OS/ABI 식별 (Linux는 보통 0) |
| 8~15 | EI_ABIVERSION~ | 0 | 패딩 (예약) |
# readelf로 ELF 헤더 확인
$ readelf -h /bin/ls
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x6b10
Start of program headers: 64 (bytes into file)
Start of section headers: 140224 (bytes into file)
Number of program headers: 13
Number of section headers: 31
프로그램 헤더 — 세그먼트 (Program Headers)
프로그램 헤더 테이블은 커널이 프로세스 이미지를 생성할 때 사용하는 세그먼트(segment) 정보를 담고 있습니다. 커널의 load_elf_binary()는 이 테이블을 순회하면서 각 세그먼트를 처리합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_phdr {
Elf64_Word p_type; /* 세그먼트 타입 */
Elf64_Word p_flags; /* 세그먼트 권한: PF_R|PF_W|PF_X */
Elf64_Off p_offset; /* 파일 내 세그먼트 시작 오프셋 */
Elf64_Addr p_vaddr; /* 메모리에 매핑될 가상 주소 */
Elf64_Addr p_paddr; /* 물리 주소 (사용되지 않음) */
Elf64_Xword p_filesz; /* 파일 내 세그먼트 크기 */
Elf64_Xword p_memsz; /* 메모리 내 세그먼트 크기 (≥ p_filesz) */
Elf64_Xword p_align; /* 정렬 요구사항 */
} Elf64_Phdr;
커널이 처리하는 주요 세그먼트 타입:
| 타입 | 값 | 커널 처리 | 설명 |
|---|---|---|---|
PT_LOAD | 1 | elf_map()으로 mmap | 메모리에 매핑되는 로드 가능 세그먼트. 코드(.text, R-X)와 데이터(.data/.bss, RW-) 영역 |
PT_INTERP | 3 | 인터프리터 경로 추출 | 동적 링커 경로 (예: /lib64/ld-linux-x86-64.so.2) |
PT_NOTE | 4 | ABI 태그 검증 | 빌드 ID, GNU ABI 태그 등 메타데이터 |
PT_DYNAMIC | 2 | 동적 링커가 사용 | 동적 링킹 정보 (.dynamic 섹션) |
PT_PHDR | 6 | 프로그램 헤더 자체 | 프로그램 헤더 테이블의 위치와 크기 |
PT_GNU_STACK | 0x6474e551 | 스택 실행 권한 결정 | PF_X 없으면 NX 스택 (기본). 커널이 스택 VMA 권한 설정에 사용 |
PT_GNU_RELRO | 0x6474e552 | 동적 링커가 처리 | 재배치 후 읽기 전용으로 전환될 영역 (.got, .init_array 등) |
p_memsz > p_filesz: .bss 세그먼트(초기화되지 않은 전역 변수)는 파일에 데이터를 저장하지 않으므로 p_filesz가 p_memsz보다 작습니다. 커널은 p_filesz만큼 파일에서 매핑하고, 나머지(p_memsz - p_filesz) 영역은 0으로 초기화된 익명 매핑(anonymous mapping)으로 채웁니다. 이 처리는 load_elf_binary() 내 set_brk()와 padzero()에서 이루어집니다.
섹션 헤더 — 섹션 (Section Headers)
섹션 헤더 테이블은 링커가 오브젝트 파일을 조합할 때 사용하는 정보입니다. 실행 시에는 프로그램 헤더(세그먼트)만 필수이며, 섹션 헤더는 선택 사항입니다. 하지만 디버깅과 심볼 분석에 중요합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_shdr {
Elf64_Word sh_name; /* 섹션 이름 (.shstrtab 내 오프셋) */
Elf64_Word sh_type; /* 섹션 타입: SHT_PROGBITS, SHT_SYMTAB 등 */
Elf64_Xword sh_flags; /* 플래그: SHF_WRITE, SHF_ALLOC, SHF_EXECINSTR */
Elf64_Addr sh_addr; /* 메모리 내 주소 (로드 가능 시) */
Elf64_Off sh_offset; /* 파일 내 섹션 시작 오프셋 */
Elf64_Xword sh_size; /* 섹션 크기 */
Elf64_Word sh_link; /* 연관된 섹션 인덱스 */
Elf64_Word sh_info; /* 추가 정보 */
Elf64_Xword sh_addralign; /* 정렬 제약 */
Elf64_Xword sh_entsize; /* 테이블 엔트리 크기 (고정 크기 테이블인 경우) */
} Elf64_Shdr;
주요 섹션과 그 역할:
| 섹션 | 타입 | 플래그 | 설명 |
|---|---|---|---|
.text | PROGBITS | AX | 실행 가능 코드 |
.rodata | PROGBITS | A | 읽기 전용 데이터 (문자열 리터럴, const 변수) |
.data | PROGBITS | WA | 초기화된 전역/정적 변수 |
.bss | NOBITS | WA | 0 초기화 전역/정적 변수 (파일 공간 차지 안 함) |
.plt | PROGBITS | AX | Procedure Linkage Table — 지연 바인딩 트램펄린 |
.got | PROGBITS | WA | Global Offset Table — 전역 심볼의 런타임 주소 |
.got.plt | PROGBITS | WA | PLT 전용 GOT 엔트리 |
.dynamic | DYNAMIC | WA | 동적 링킹 메타데이터 (DT_NEEDED, DT_SYMTAB 등) |
.symtab | SYMTAB | - | 심볼 테이블 (strip 시 제거됨) |
.dynsym | DYNSYM | A | 동적 심볼 테이블 (런타임 필수, strip 불가) |
.strtab | STRTAB | - | 심볼 이름 문자열 테이블 |
.rela.plt | RELA | A | PLT 엔트리 재배치 정보 |
.rela.dyn | RELA | A | 동적 재배치 정보 |
.init_array | INIT_ARRAY | WA | 초기화 함수 포인터 배열 (main() 전 실행) |
.fini_array | FINI_ARRAY | WA | 종료 함수 포인터 배열 (main() 후 실행) |
.note.gnu.build-id | NOTE | A | 빌드 ID 해시 (디버그 심볼 매칭용) |
세그먼트 vs 섹션: 하나의 세그먼트(PT_LOAD)는 여러 섹션을 포함할 수 있습니다. 예를 들어, 코드 세그먼트(R-X)에는 .text, .rodata, .plt 등이, 데이터 세그먼트(RW-)에는 .data, .bss, .got 등이 포함됩니다. 커널은 세그먼트 단위로 mmap()하므로, 같은 세그먼트에 속한 섹션은 동일한 메모리 보호 속성을 갖습니다.
심볼 테이블 (Symbol Table)
심볼 테이블은 프로그램에서 사용되는 함수, 변수, 섹션 등의 이름과 속성을 기록합니다. ELF에는 두 종류의 심볼 테이블이 있습니다: .symtab(전체 심볼, strip 시 제거 가능)과 .dynsym(동적 심볼, 런타임 필수이므로 제거 불가).
/* include/uapi/linux/elf.h */
typedef struct elf64_sym {
Elf64_Word st_name; /* 심볼 이름 (.strtab/.dynstr 내 오프셋) */
unsigned char st_info; /* 바인딩(상위 4비트) + 타입(하위 4비트) */
unsigned char st_other; /* 가시성 (하위 2비트) */
Elf64_Half st_shndx; /* 심볼이 속한 섹션 인덱스 */
Elf64_Addr st_value; /* 심볼 값 (주소 또는 오프셋) */
Elf64_Xword st_size; /* 심볼 크기 (바이트) */
} Elf64_Sym;
/* st_info 매크로 */
#define ELF64_ST_BIND(info) ((info) >> 4)
#define ELF64_ST_TYPE(info) ((info) & 0xf)
#define ELF64_ST_INFO(bind, type) (((bind) << 4) + ((type) & 0xf))
/* st_other 매크로 */
#define ELF64_ST_VISIBILITY(other) ((other) & 0x3)
심볼 바인딩(Binding) — 심볼의 링킹 범위를 결정합니다:
| 바인딩 | 값 | 설명 |
|---|---|---|
STB_LOCAL | 0 | 파일 내부에서만 참조 가능. static 함수/변수. 다른 오브젝트 파일의 동일 이름 심볼과 충돌하지 않음 |
STB_GLOBAL | 1 | 모든 오브젝트 파일에서 참조 가능. 정의가 하나만 존재해야 함 (다중 정의 시 링커 에러) |
STB_WEAK | 2 | 전역 심볼과 유사하나 우선순위가 낮음. 같은 이름의 STB_GLOBAL 심볼이 있으면 대체됨. 미해석 시 에러 아님 (0으로 처리) |
심볼 타입(Type) — 심볼이 참조하는 엔티티의 종류:
| 타입 | 값 | 설명 |
|---|---|---|
STT_NOTYPE | 0 | 타입 미지정 |
STT_OBJECT | 1 | 데이터 오브젝트 (변수, 배열 등) |
STT_FUNC | 2 | 함수 또는 실행 가능 코드 |
STT_SECTION | 3 | 섹션 자체를 나타내는 심볼 |
STT_FILE | 4 | 소스 파일 이름 |
STT_COMMON | 5 | 미초기화 공통 블록 (Fortran COMMON, C의 tentative definition) |
STT_TLS | 6 | Thread-Local Storage 변수 |
STT_GNU_IFUNC | 10 | GNU 간접 함수 — 런타임에 CPU 기능에 따라 최적 구현 선택 (예: memcpy의 AVX/SSE 분기) |
심볼 가시성(Visibility) — 동적 링킹에서의 심볼 노출 범위를 제어합니다:
| 가시성 | 값 | 설명 |
|---|---|---|
STV_DEFAULT | 0 | 기본 가시성. 바인딩 규칙에 따라 동적 심볼로 내보내짐 |
STV_INTERNAL | 1 | 프로세서별 숨김 규칙 적용 (거의 사용되지 않음) |
STV_HIDDEN | 2 | 동적 심볼 테이블에 포함되지 않음. -fvisibility=hidden과 동일 효과 |
STV_PROTECTED | 3 | 외부에서 참조 가능하나, 같은 공유 라이브러리 내에서는 심볼 인터포지션 불가 |
특수 섹션 인덱스 (st_shndx):
| 상수 | 값 | 설명 |
|---|---|---|
SHN_UNDEF | 0 | 미정의 심볼 — 다른 오브젝트/라이브러리에서 정의 필요 |
SHN_ABS | 0xfff1 | 절대 주소 — 재배치에 영향받지 않음 |
SHN_COMMON | 0xfff2 | COMMON 블록 — 링커가 .bss에 할당 |
# 심볼 테이블 확인 예시
$ readelf -s /bin/ls | head -20
Symbol table '.dynsym' contains 127 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@GLIBC_2.2.5
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __errno_location@GLIBC_2.2.5
...
60: 000000000001f060 920 FUNC GLOBAL DEFAULT 16 main
61: 0000000000023400 8 OBJECT GLOBAL DEFAULT 26 stdout
# Weak 심볼 예시 — 정의 없어도 링킹 성공
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep ' W '
0000000000044f10 W pthread_cond_signal@@GLIBC_2.3.2
0000000000044a00 W pthread_mutex_lock@@GLIBC_2.2.5
# IFUNC 심볼 — 런타임 최적 구현 선택
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep IFUNC | head -5
132: 00000000001a3700 0 IFUNC GLOBAL DEFAULT 16 memcpy@@GLIBC_2.14
156: 00000000001a37a0 0 IFUNC GLOBAL DEFAULT 16 memset@@GLIBC_2.2.5
173: 00000000001a37f0 0 IFUNC GLOBAL DEFAULT 16 strcmp@@GLIBC_2.2.5
.symtab vs .dynsym: .symtab은 모든 심볼(로컬 포함)을 담는 완전한 심볼 테이블로, 디버깅과 분석에 사용됩니다. strip 명령으로 제거할 수 있습니다. .dynsym은 동적 링킹에 필요한 심볼만 포함하며, SHF_ALLOC 플래그가 설정되어 런타임에 메모리에 로드됩니다. 실행 파일에서 strip해도 .dynsym은 유지됩니다.
문자열 테이블 (String Tables)
ELF의 문자열 테이블은 NUL(\0)로 종료되는 문자열의 연속 배열입니다. 심볼 이름, 섹션 이름 등은 문자열을 직접 저장하지 않고, 해당 문자열 테이블 내의 바이트 오프셋을 참조합니다.
| 문자열 테이블 | 용도 | 참조 위치 |
|---|---|---|
.strtab | 정적 심볼 이름 | .symtab의 st_name 필드 |
.dynstr | 동적 심볼 이름, 라이브러리 이름 | .dynsym의 st_name, .dynamic의 DT_NEEDED 등 |
.shstrtab | 섹션 이름 | 섹션 헤더의 sh_name 필드. ELF 헤더의 e_shstrndx가 이 섹션의 인덱스 |
오프셋: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
내용: \0 m a i n \0 p r i n t f \0 _ s t a r t \0
st_name = 1 → "main"
st_name = 6 → "printf"
st_name = 13 → "_start"
st_name = 0 → "" (빈 문자열, 이름 없는 심볼)
재배치 (Relocation)
재배치(Relocation)는 링커(정적) 또는 동적 링커(런타임)가 심볼 참조를 최종 주소로 해석하는 과정입니다. 오브젝트 파일에서 아직 결정되지 않은 주소(외부 함수, 전역 변수 등)는 재배치 엔트리로 기록되며, 링킹 시 실제 주소로 패치됩니다.
/* include/uapi/linux/elf.h */
/* Rel: 암시적 addend (코드 내 기존 값을 addend로 사용) */
typedef struct elf64_rel {
Elf64_Addr r_offset; /* 패치할 위치 (섹션 내 오프셋 또는 가상 주소) */
Elf64_Xword r_info; /* 심볼 인덱스(상위 32비트) + 재배치 타입(하위 32비트) */
} Elf64_Rel;
/* Rela: 명시적 addend 포함 (x86_64에서 표준) */
typedef struct elf64_rela {
Elf64_Addr r_offset; /* 패치할 위치 */
Elf64_Xword r_info; /* 심볼 인덱스 + 재배치 타입 */
Elf64_Sxword r_addend; /* 주소 계산에 더해지는 상수 */
} Elf64_Rela;
#define ELF64_R_SYM(info) ((info) >> 32)
#define ELF64_R_TYPE(info) ((info) & 0xffffffff)
x86_64 주요 재배치 타입:
| 타입 | 값 | 계산식 | 용도 |
|---|---|---|---|
R_X86_64_64 | 1 | S + A | 절대 64비트 주소 (데이터 포인터) |
R_X86_64_PC32 | 2 | S + A - P | PC 상대 32비트 (근거리 call/jmp) |
R_X86_64_PLT32 | 4 | L + A - P | PLT를 통한 함수 호출 |
R_X86_64_COPY | 5 | — | 실행 파일의 .bss에 공유 라이브러리 데이터 심볼 복사 |
R_X86_64_GLOB_DAT | 6 | S | GOT 엔트리에 심볼 절대 주소 저장 |
R_X86_64_JUMP_SLOT | 7 | S | PLT용 GOT 엔트리 (지연 바인딩 대상) |
R_X86_64_RELATIVE | 8 | B + A | PIE/공유 라이브러리의 내부 참조 (base address 보정) |
R_X86_64_TPOFF32 | 23 | S + A - TP | TLS 변수의 TP(Thread Pointer) 상대 오프셋 |
계산식에서: S = 심볼 값, A = addend, P = 패치 위치 주소, B = base address, L = PLT 엔트리 주소, TP = Thread Pointer.
커널 모듈의 재배치: 커널 모듈(.ko)은 ET_REL 타입이므로, 커널의 load_module()이 직접 재배치를 수행합니다. 아키텍처별 apply_relocate_add() 함수가 각 .rela.* 섹션을 처리하여 모듈 코드 내 심볼 참조를 커널 심볼의 실제 주소로 패치합니다. 이 과정에서 __ksymtab에 등록된 커널 심볼 테이블을 검색합니다.
동적 섹션 (.dynamic)
.dynamic 섹션은 동적 링커가 사용하는 메타데이터 테이블입니다. PT_DYNAMIC 세그먼트가 이 섹션을 가리키며, 런타임에 메모리에 로드되어 동적 링커가 라이브러리 로딩, 심볼 해석, 재배치를 수행하는 데 필요한 모든 정보를 제공합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_dyn {
Elf64_Sxword d_tag; /* 엔트리 타입 (DT_*) */
union {
Elf64_Xword d_val; /* 정수 값 */
Elf64_Addr d_ptr; /* 주소 값 */
} d_un;
} Elf64_Dyn;
주요 동적 태그:
| 태그 | 값 | d_un | 설명 |
|---|---|---|---|
DT_NEEDED | 1 | d_val | 필요한 공유 라이브러리 이름 (.dynstr 오프셋). 동적 링커가 재귀적으로 로드 |
DT_PLTRELSZ | 2 | d_val | PLT 재배치 (.rela.plt) 전체 크기 |
DT_PLTGOT | 3 | d_ptr | .got.plt 테이블 주소 |
DT_STRTAB | 5 | d_ptr | 동적 문자열 테이블 (.dynstr) 주소 |
DT_SYMTAB | 6 | d_ptr | 동적 심볼 테이블 (.dynsym) 주소 |
DT_RELA | 7 | d_ptr | .rela.dyn 재배치 테이블 주소 |
DT_RELASZ | 8 | d_val | .rela.dyn 테이블 전체 크기 (바이트) |
DT_INIT | 12 | d_ptr | 초기화 함수 주소 (_init) |
DT_FINI | 13 | d_ptr | 종료 함수 주소 (_fini) |
DT_SONAME | 14 | d_val | 공유 라이브러리의 SONAME (.dynstr 오프셋) |
DT_JMPREL | 23 | d_ptr | PLT 재배치 테이블 (.rela.plt) 주소 |
DT_INIT_ARRAY | 25 | d_ptr | 초기화 함수 포인터 배열 (.init_array) 주소 |
DT_FINI_ARRAY | 26 | d_ptr | 종료 함수 포인터 배열 (.fini_array) 주소 |
DT_RUNPATH | 29 | d_val | 라이브러리 검색 경로 (-Wl,-rpath로 설정) |
DT_FLAGS | 30 | d_val | 플래그: DF_ORIGIN, DF_SYMBOLIC, DF_BIND_NOW, DF_STATIC_TLS |
DT_FLAGS_1 | 0x6ffffffb | d_val | 확장 플래그: DF_1_NOW(즉시 바인딩), DF_1_PIE(PIE 표시) |
DT_GNU_HASH | 0x6ffffef5 | d_ptr | GNU 해시 테이블 주소 (심볼 검색 가속) |
DT_VERNEED | 0x6ffffffe | d_ptr | 심볼 버전 필요(version needed) 테이블. 예: GLIBC_2.17 |
DT_NULL | 0 | — | 동적 섹션 종료 표시 |
# 동적 섹션 확인
$ readelf -d /bin/ls
Dynamic section at offset 0x22df0 contains 28 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libselinux.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x4000
0x000000000000000d (FINI) 0x172e4
0x0000000000000019 (INIT_ARRAY) 0x23b90
0x000000006ffffef5 (GNU_HASH) 0x3a0
0x0000000000000005 (STRTAB) 0xec0
0x0000000000000006 (SYMTAB) 0x3d8
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
0x0000000000000000 (NULL) 0x0
GNU 해시 테이블: DT_GNU_HASH는 기존 SYSV 해시(DT_HASH)를 대체하는 고속 심볼 검색 메커니즘입니다. Bloom filter와 해시 버킷을 사용하여 평균 검색 시간을 크게 단축합니다. 현대 Linux 바이너리는 거의 모두 GNU 해시를 사용하며, 동적 링커 시작 시간이 약 50% 감소하는 효과가 있습니다.
ELF 노트 섹션 (Note Sections)
ELF 노트는 벤더별 메타데이터를 구조화된 형태로 저장하는 섹션입니다. .note.* 섹션과 PT_NOTE 세그먼트에 포함되며, 각 노트는 이름-타입-설명의 삼중 구조를 가집니다.
/* ELF 노트 헤더 — 각 노트 앞에 위치 */
typedef struct elf64_note {
Elf64_Word n_namesz; /* 이름 문자열 길이 (NUL 포함) */
Elf64_Word n_descsz; /* 설명(descriptor) 데이터 크기 */
Elf64_Word n_type; /* 노트 타입 (이름별 의미가 다름) */
/* 이후: 이름(4바이트 정렬) + 설명(4바이트 정렬) */
} Elf64_Nhdr;
주요 GNU 노트 타입:
| 섹션 | 이름 | n_type | 설명 |
|---|---|---|---|
.note.gnu.build-id | "GNU" | NT_GNU_BUILD_ID (3) | 바이너리의 고유 식별 해시 (SHA1 등). 디버그 심볼 매칭, debuginfod에서 사용 |
.note.ABI-tag | "GNU" | NT_GNU_ABI_TAG (1) | 최소 커널 버전 요구사항 (예: Linux 3.2.0). load_elf_binary()에서 검증 |
.note.gnu.property | "GNU" | NT_GNU_PROPERTY_TYPE_0 (5) | CET(IBT/SHSTK), BTI 등 보안 속성 표시. 커널이 arch_setup_elf_property()에서 처리 |
# 노트 섹션 확인
$ readelf -n /bin/ls
Displaying notes found in: .note.gnu.property
Owner Data size Description
GNU 0x00000020 NT_GNU_PROPERTY_TYPE_0
Properties: x86 feature: IBT, SHSTK
x86 ISA needed: x86-64-baseline
Displaying notes found in: .note.gnu.build-id
Owner Data size Description
GNU 0x00000014 NT_GNU_BUILD_ID (unique build ID bitstring)
Build ID: 2b0e4e5b0c7c0a4f3e6d...
Displaying notes found in: .note.ABI-tag
Owner Data size Description
GNU 0x00000010 NT_GNU_ABI_TAG (ABI version tag)
OS: Linux, ABI: 3.2.0
CET과 NT_GNU_PROPERTY: Intel CET(Control-flow Enforcement Technology)의 IBT(Indirect Branch Tracking)와 SHSTK(Shadow Stack)이 활성화된 바이너리는 .note.gnu.property에 해당 속성이 기록됩니다. 커널은 arch_setup_elf_property()에서 이 노트를 파싱하여 CR4.CET과 MSR을 설정합니다. 모든 공유 라이브러리에도 CET 속성이 있어야 전체적으로 활성화됩니다.
exec() 처리 흐름 (Execution Flow)
execve() 시스템 콜이 호출되면 커널은 다음 단계를 거쳐 프로세스 이미지를 교체합니다:
- 바이너리 열기:
open_exec()으로 실행 파일을 열고,struct file을 얻습니다. - bprm 구조체 준비:
struct linux_binprm에 파일 첫 256바이트(매직 넘버 검사용), credentials, 파일 정보를 채웁니다. - 포맷 핸들러 탐색:
search_binary_handler()가 등록된 바이너리 포맷 핸들러(struct linux_binfmt)를 순회하며, 매직 넘버를 통해 ELF, 스크립트(#!), misc 등 적절한 핸들러를 찾습니다. - ELF 헤더 검증:
load_elf_binary()가 매직 넘버(0x7f ELF), 클래스(32/64bit), 엔디안, 아키텍처(e_machine)를 검증합니다. - 인터프리터 로딩:
PT_INTERP세그먼트가 있으면 동적 링커(예:/lib64/ld-linux-x86-64.so.2)의 ELF 파일을 별도로 로드합니다. - 기존 주소 공간 해제:
begin_new_exec()에서 point-of-no-return을 넘기고,exec_mmap()으로 기존mm_struct를 해제합니다. - PT_LOAD 세그먼트 매핑:
elf_map()으로 각PT_LOAD세그먼트를do_mmap()하여 파일 내용을 가상 주소 공간에 매핑합니다. - BSS 설정:
set_brk()로.bss영역(p_memsz > p_filesz 차이분)을 익명 매핑으로 할당합니다. - 스택 설정: 새 스택에 argv, envp, auxiliary vector(AT_*)를 배치합니다.
- 실행 시작:
start_thread()로 레지스터를 설정하고, 동적 링크 바이너리이면 인터프리터의 엔트리 포인트에서, 아니면 ELF의e_entry에서 실행을 시작합니다.
/* ELF 바이너리 로더 - fs/binfmt_elf.c (간략화) */
static int load_elf_binary(struct linux_binprm *bprm)
{
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;
struct elf_phdr *elf_ppnt, *elf_phdata;
struct elfhdr *interp_elf_ex = NULL;
unsigned long load_addr = 0, load_bias = 0;
unsigned long elf_entry;
int executable_stack = EXSTACK_DEFAULT;
/* 1. ELF 매직 넘버 및 타입 검증 */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
return -ENOEXEC;
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
return -ENOEXEC;
/* 2. 프로그램 헤더 테이블 전체 읽기 */
elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
/* 3. 프로그램 헤더 1차 순회: PT_INTERP, PT_GNU_STACK 처리 */
for (i = 0; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_INTERP) {
/* 동적 링커 경로 읽기 (예: /lib64/ld-linux-x86-64.so.2) */
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
elf_read(bprm->file, elf_interpreter,
elf_ppnt->p_filesz, elf_ppnt->p_offset);
/* 인터프리터 ELF 파일도 별도로 열어서 헤더 로드 */
interp_elf_ex = ...;
}
if (elf_ppnt->p_type == PT_GNU_STACK) {
/* 스택 실행 권한 결정 */
executable_stack = (elf_ppnt->p_flags & PF_X)
? EXSTACK_ENABLE_X : EXSTACK_DISABLE_X;
}
}
/* 4. Point of no return — 기존 주소 공간 해제 */
begin_new_exec(bprm);
setup_new_exec(bprm);
/* 5. ET_DYN(PIE)이면 ASLR용 load_bias 계산 */
if (elf_ex->e_type == ET_DYN) {
load_bias = ELF_ET_DYN_BASE; /* ASLR randomization 적용 */
if (current->flags & PF_RANDOMIZE)
load_bias += arch_mmap_rnd();
}
/* 6. PT_LOAD 세그먼트 매핑 */
for (i = 0; i < elf_ex->e_phnum; i++) {
if (elf_ppnt->p_type != PT_LOAD)
continue;
/* elf_map() → do_mmap(): 파일을 가상 주소에 매핑 */
error = elf_map(bprm->file,
load_bias + vaddr, /* 매핑 주소 */
elf_ppnt, /* 세그먼트 정보 */
elf_prot, elf_flags, total_size);
}
/* 7. BSS 영역 설정 (p_memsz > p_filesz 부분) */
set_brk(elf_bss, elf_brk, bss_prot);
/* 8. 인터프리터가 있으면 로드 */
if (interp_elf_ex)
elf_entry = load_elf_interp(interp_elf_ex, ...);
else
elf_entry = elf_ex->e_entry + load_bias;
/* 9. auxiliary vector를 스택에 배치 */
create_elf_tables(bprm, elf_ex, interp_load_addr,
e_entry, phdr_addr);
/* 10. 엔트리 포인트에서 실행 시작 */
start_thread(regs, elf_entry, bprm->p);
return 0;
}
Auxiliary Vector (보조 벡터)
커널은 exec() 시 사용자 공간 스택에 auxiliary vector(보조 벡터)를 배치합니다. 이 벡터는 동적 링커(ld-linux.so)와 C 라이브러리가 프로세스 환경을 파악하는 데 사용하는 키-값 쌍입니다.
스택 레이아웃 (낮은 주소 → 높은 주소):
주요 auxiliary vector 엔트리 (create_elf_tables()에서 생성):
| 키 | 값 | 설명 |
|---|---|---|
AT_PHDR | 3 | 프로그램 헤더 테이블의 메모리 주소 |
AT_PHENT | 4 | 프로그램 헤더 엔트리 크기 |
AT_PHNUM | 5 | 프로그램 헤더 엔트리 수 |
AT_PAGESZ | 6 | 시스템 페이지 크기 (보통 4096) |
AT_BASE | 7 | 인터프리터(ld.so)의 로드 기본 주소 |
AT_FLAGS | 8 | 프로세서별 플래그 |
AT_ENTRY | 9 | 프로그램 엔트리 포인트 (e_entry) |
AT_UID/GID | 11/12 | 실제 사용자/그룹 ID |
AT_EUID/EGID | 13/14 | 유효 사용자/그룹 ID |
AT_PLATFORM | 15 | 플랫폼 문자열 (예: "x86_64") |
AT_HWCAP | 16 | 하드웨어 능력 비트마스크 (SSE, AVX 등) |
AT_CLKTCK | 17 | sysconf(_SC_CLK_TCK) 값 (보통 100) |
AT_RANDOM | 25 | 16바이트 랜덤 데이터 주소 (스택 canary 시드 등) |
AT_HWCAP2 | 26 | 확장 하드웨어 능력 (CET, TME 등) |
AT_EXECFN | 31 | 실행 파일명 문자열 주소 |
AT_SYSINFO_EHDR | 33 | vDSO ELF 이미지 주소 |
# auxiliary vector 확인 방법
$ LD_SHOW_AUXV=1 /bin/true
AT_SYSINFO_EHDR: 0x7ffd5c9fe000 # vDSO 주소
AT_HWCAP: 178bfbff # CPU 기능 비트맵
AT_PAGESZ: 4096 # 페이지 크기
AT_CLKTCK: 100 # clock ticks/sec
AT_PHDR: 0x55a3c4200040 # 프로그램 헤더 주소
AT_PHENT: 56 # phdr 엔트리 크기
AT_PHNUM: 13 # phdr 엔트리 수
AT_BASE: 0x7f2e8c400000 # ld.so 기본 주소
AT_ENTRY: 0x55a3c4201a90 # _start 주소
AT_RANDOM: 0x7ffd5c9d4a09 # 16바이트 난수 위치
AT_EXECFN: /bin/true
AT_PLATFORM: x86_64
ASLR과 PIE (Address Space Layout Randomization)
ASLR은 프로세스의 주요 메모리 영역(스택, 힙, mmap, 실행 코드)의 기본 주소를 매 실행마다 랜덤화하여 공격자가 코드/데이터 위치를 예측하기 어렵게 만듭니다.
| 영역 | 랜덤화 소스 | 커널 코드 |
|---|---|---|
| 스택 | arch_align_stack() | setup_arg_pages()에서 스택 top에 랜덤 오프셋 추가 |
| mmap base | arch_mmap_rnd() | 공유 라이브러리, vDSO 로드 주소 랜덤화 |
| 힙 (brk) | arch_randomize_brk() | BSS 끝 이후 brk 시작점 랜덤화 |
| PIE 바이너리 | ELF_ET_DYN_BASE + rnd | load_elf_binary()에서 ET_DYN 바이너리에 적용 |
/* load_elf_binary()에서 PIE(ET_DYN) ASLR 처리 */
if (elf_ex->e_type == ET_DYN) {
/*
* ET_DYN 바이너리는 상대 주소로 컴파일되어 임의 위치에 로드 가능.
* ELF_ET_DYN_BASE는 아키텍처별 기본 주소:
* x86_64: (1UL << 47) * 2 / 3 ≈ 0x555555555000 부근
*/
load_bias = ELF_ET_DYN_BASE;
if (current->flags & PF_RANDOMIZE)
load_bias += arch_mmap_rnd();
load_bias = ELF_PAGESTART(load_bias);
}
/* ET_EXEC는 ELF에 지정된 고정 가상 주소에 로드 (ASLR 불가) */
if (elf_ex->e_type == ET_EXEC)
load_bias = 0; /* p_vaddr를 그대로 사용 */
# ASLR 수준 확인 및 설정
$ cat /proc/sys/kernel/randomize_va_space
2 # 0=비활성, 1=스택+mmap+vDSO, 2=1+brk(힙)
# 같은 PIE 바이너리를 두 번 실행하면 주소가 다름
$ cat /proc/self/maps | head -1
55a3c4200000-55a3c4205000 r--p ... # 첫 번째 실행
$ cat /proc/self/maps | head -1
564e8b400000-564e8b405000 r--p ... # 두 번째 실행 (주소 변경됨)
동적 링킹 (Dynamic Linking)
동적 링크 바이너리의 경우, 커널은 실행 파일과 함께 동적 링커(인터프리터)를 메모리에 로드하고, 인터프리터의 엔트리 포인트에서 실행을 시작합니다. 동적 링커(ld-linux.so)는 실제 프로그램의 main()이 실행되기 전에 공유 라이브러리 로드와 심볼 재배치를 수행합니다.
- 동적 링킹 처리 흐름 (커널 + 사용자 공간) */
- [커널] load_elf_binary() */
- PT_INTERP 세그먼트에서 인터프리터 경로 획득 */
- 예: "/lib64/ld-linux-x86-64.so.2" */
- 인터프리터 ELF 파일을 로드 (load_elf_interp) */
- 엔트리 포인트를 인터프리터의 e_entry로 설정 */
- [사용자 공간] ld-linux.so 시작 */
- _dl_start() → 자기 자신(ld.so)을 재배치 */
- _dl_main() → .dynamic 섹션 파싱 */
- → DT_NEEDED 라이브러리를 재귀적으로 로드 */
- → 심볼 해석 및 GOT/PLT 재배치 */
- 초기화 함수 실행 (.init_array) */
- 프로그램 엔트리 포인트(AT_ENTRY)로 점프 */
- → _start → __libc_start_main → main() */
GOT/PLT와 지연 바인딩(Lazy Binding): 외부 함수 호출은 PLT(Procedure Linkage Table)를 거칩니다. PLT 엔트리는 GOT(Global Offset Table)에서 실제 주소를 읽어 점프합니다. 최초 호출 시 GOT 엔트리는 PLT의 리졸버 코드를 가리키며, 리졸버가 심볼을 해석하여 GOT를 갱신합니다. 이후 호출은 GOT에서 직접 점프하여 오버헤드가 없습니다.
; PLT를 통한 printf() 호출 예시 (x86_64)
; 사용자 코드에서 printf 호출
call printf@plt ; → PLT 엔트리로 점프
; .plt 섹션의 printf 엔트리
printf@plt:
jmp *printf@GOTPLT(%rip) ; GOT에서 주소 읽어 간접 점프
; 최초 호출 시 GOT는 아래 push를 가리킴 (리졸버로 이동)
push $0x3 ; 재배치 인덱스
jmp .plt ; _dl_runtime_resolve 호출
; _dl_runtime_resolve()가 printf의 실제 주소를 찾아
; GOT 엔트리를 갱신 → 이후 호출은 직접 printf로 점프
Full RELRO: -Wl,-z,relro,-z,now로 빌드하면 동적 링커가 프로그램 시작 시 모든 GOT 엔트리를 즉시 바인딩(eager binding)한 뒤, PT_GNU_RELRO 영역을 mprotect(PROT_READ)로 읽기 전용으로 전환합니다. 이를 통해 GOT overwrite 공격을 방지할 수 있습니다. 현대 배포판에서는 보안 강화를 위해 기본 활성화되는 추세입니다.
vDSO (virtual Dynamic Shared Object)
vDSO는 커널이 모든 사용자 공간 프로세스의 주소 공간에 자동으로 매핑하는 특수한 공유 라이브러리입니다. gettimeofday(), clock_gettime(), getcpu() 등 빈번하게 호출되지만 실제 특권이 불필요한 시스템 콜을 커널 진입 없이 사용자 공간에서 처리할 수 있게 합니다.
/* vDSO 매핑 — arch/x86/entry/vdso/ */
/* 커널이 exec 시 vDSO를 매핑하는 과정 */
/* load_elf_binary() → arch_setup_additional_pages() */
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
/* vDSO 이미지를 프로세스 주소 공간에 매핑 */
return map_vdso(&vdso_image_64, 0);
/* AT_SYSINFO_EHDR auxv 엔트리가 이 주소를 가리킴 */
}
/* vDSO 내 함수 예시: 시스템 콜 없이 시간 획득 */
int __vdso_clock_gettime(clockid_t clock, struct timespec *ts)
{
/* 커널이 공유 메모리(vvar)에 갱신해둔 시간 데이터를 직접 읽음 */
/* → syscall 오버헤드(~100ns) 제거 */
...
}
# vDSO 확인
$ cat /proc/self/maps | grep vdso
7ffd5c9fe000-7ffd5ca00000 r-xp 00000000 00:00 0 [vdso]
# vDSO가 export하는 심볼 확인
$ objdump -T /proc/self/root/usr/lib/vdso/vdso64.so 2>/dev/null || \
dd if=/proc/self/mem bs=1 skip=$((0x7ffd5c9fe000)) count=8192 2>/dev/null | \
readelf -Ws /dev/stdin 2>/dev/null
# __vdso_clock_gettime, __vdso_gettimeofday, __vdso_time, __vdso_getcpu
ELF 분석 도구 (Analysis Tools)
# readelf — ELF 구조 분석의 표준 도구
$ readelf -h /bin/ls # ELF 헤더
$ readelf -l /bin/ls # 프로그램 헤더 (세그먼트)
$ readelf -S /bin/ls # 섹션 헤더
$ readelf -s /bin/ls # 심볼 테이블
$ readelf -d /bin/ls # 동적 섹션 (DT_NEEDED 등)
$ readelf -r /bin/ls # 재배치 엔트리
$ readelf -n /bin/ls # 노트 섹션 (빌드 ID 등)
# objdump — 디스어셈블리와 섹션 분석
$ objdump -d /bin/ls # 코드 섹션 디스어셈블리
$ objdump -j .plt -d /bin/ls # PLT 엔트리만 디스어셈블
$ objdump -R /bin/ls # 동적 재배치 테이블
# nm — 심볼 목록
$ nm -D /bin/ls # 동적 심볼만 표시
# ldd — 동적 라이브러리 의존성
$ ldd /bin/ls # 의존 공유 라이브러리와 로드 주소
# file — 파일 타입 식별
$ file /bin/ls
/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=..., for GNU/Linux 3.2.0, stripped
TLS — Thread-Local Storage (스레드 로컬 저장소)
TLS(Thread-Local Storage)는 각 스레드가 독립적인 복사본을 가지는 변수를 구현하는 메커니즘입니다. C/C++에서 __thread 또는 _Thread_local 키워드로 선언하며, ELF에서는 PT_TLS 세그먼트와 TLS 관련 재배치로 구현됩니다.
/* TLS 변수 선언 예시 */
__thread int tls_var = 42; /* 초기화됨 → .tdata 섹션 */
__thread int tls_bss_var; /* 미초기화 → .tbss 섹션 */
_Thread_local char buf[256]; /* C11 표준 키워드 (동일) */
/* 커널 내 per-CPU 변수도 유사한 개념 */
DEFINE_PER_CPU(int, my_percpu_var); /* 커널에서는 __percpu로 구현 */
TLS 관련 ELF 섹션과 세그먼트:
| 요소 | 타입 | 설명 |
|---|---|---|
.tdata | PROGBITS | 초기화된 TLS 변수. SHF_TLS | SHF_ALLOC | SHF_WRITE 플래그 |
.tbss | NOBITS | 미초기화 TLS 변수 (0 초기화). .bss와 유사하게 파일 공간 불필요 |
PT_TLS | 세그먼트 (7) | TLS 초기화 이미지. 새 스레드 생성 시 이 이미지를 복사하여 스레드별 TLS 영역 생성 |
STT_TLS | 심볼 타입 (6) | TLS 변수 심볼. st_value는 TLS 블록 내 오프셋 |
TLS 접근 모델: 컴파일러는 TLS 변수의 가시성과 링킹 상황에 따라 4가지 접근 모델을 사용합니다:
| 모델 | 플래그 | 적용 상황 | 성능 |
|---|---|---|---|
| Local Exec (LE) | -ftls-model=local-exec | 실행 파일 내부에서 자체 TLS 접근. TP에서 고정 오프셋으로 직접 접근 | 최고 (단일 명령어) |
| Initial Exec (IE) | -ftls-model=initial-exec | 실행 파일에서 공유 라이브러리의 TLS 접근. GOT에서 TP 오프셋을 읽음 | 빠름 (GOT 1회 접근) |
| General Dynamic (GD) | -ftls-model=global-dynamic | dlopen()으로 로드된 라이브러리의 TLS. __tls_get_addr() 호출 필요 | 느림 (함수 호출) |
| Local Dynamic (LD) | -ftls-model=local-dynamic | 같은 DSO 내 여러 TLS 변수 접근 시 GD 최적화. 모듈 base를 1회만 조회 | GD보다 약간 빠름 |
; Local Exec 모델 (x86_64) — 가장 효율적
; 실행 파일 자체의 TLS 변수 접근
mov eax, dword ptr fs:[tls_var@TPOFF] ; FS + 고정 오프셋
; Initial Exec 모델 — GOT에서 오프셋 로드
mov rax, qword ptr [rip + tls_var@GOTTPOFF] ; GOT에서 TP 오프셋
mov eax, dword ptr fs:[rax] ; FS + 오프셋
; General Dynamic 모델 — __tls_get_addr() 호출
lea rdi, [rip + tls_var@TLSGD] ; TLS descriptor 주소
call __tls_get_addr@PLT ; 동적 링커가 주소 반환
mov eax, [rax] ; 반환된 주소로 접근
# TLS 세그먼트 확인
$ readelf -l /usr/bin/python3 | grep TLS
TLS 0x000000000029a890 0x000000000049a890 0x000000000049a890
0x0000000000000010 0x0000000000000088 R 0x8
# TLS 심볼 확인
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep TLS
16: 0000000000000000 4 TLS GLOBAL DEFAULT 32 errno@@GLIBC_PRIVATE
18: 0000000000000008 8 TLS GLOBAL DEFAULT 32 __resp@@GLIBC_PRIVATE
# TLS 관련 재배치
$ readelf -r /usr/bin/python3 | grep TPOFF
00000049c038 001b00000012 R_X86_64_TPOFF64 0000000000000000 _Py_tss_tstate + 0
x86_64 TLS와 FS 레지스터: x86_64에서 FS 세그먼트 레지스터는 TCB(Thread Control Block)를 가리킵니다. 실행 파일의 TLS 블록은 TCB 아래(음수 오프셋)에, dlopen()으로 로드된 모듈의 TLS는 DTV(Dynamic Thread Vector)를 통해 동적으로 할당됩니다. 커널은 arch_prctl(ARCH_SET_FS, addr) 또는 clone() 시 CLONE_SETTLS 플래그로 MSR_FS_BASE를 설정합니다.
코어 덤프 (Core Dump)
프로세스가 치명적 시그널(SIGSEGV, SIGABRT 등)을 받으면 커널은 프로세스의 메모리 상태를 ELF 코어 덤프 파일로 저장합니다. 코어 파일은 ET_CORE 타입의 ELF이며, 크래시 분석에 필수적인 정보를 포함합니다.
코어 덤프 생성 경로:
코어 파일 구조:
/* fs/binfmt_elf.c — 코어 덤프 핵심 함수 (간략화) */
static int elf_core_dump(struct coredump_params *cprm)
{
struct elfhdr elf;
struct elf_phdr *phdr;
/* 1. ELF 헤더 작성 (ET_CORE) */
fill_elf_header(&elf, segs + 1, ELF_ARCH, 0);
elf.e_type = ET_CORE;
/* 2. PT_NOTE 세그먼트 작성 — 레지스터, 시그널 정보 */
fill_note(&psinfo_note, "CORE", NT_PRPSINFO, ...);
fill_note(&siginfo_note, "CORE", NT_SIGINFO, ...);
/* 각 스레드의 레지스터 상태 */
for_each_thread(p, t) {
fill_note(&t->prstatus_note, "CORE", NT_PRSTATUS,
sizeof(struct elf_prstatus), &t->prstatus);
}
/* 3. VMA 순회 — 각 메모리 영역을 PT_LOAD로 기록 */
for_each_vma(cprm->mm, vma) {
/* 코어 덤프 필터에 따라 포함 여부 결정 */
if (!vma_dump_size(vma, cprm->mm_flags))
continue;
phdr->p_type = PT_LOAD;
phdr->p_vaddr = vma->vm_start;
phdr->p_memsz = vma->vm_end - vma->vm_start;
/* 메모리 내용을 파일에 기록 */
dump_user_range(cprm, vma->vm_start, size);
}
}
# 코어 덤프 설정
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
# 코어 덤프 필터 (비트마스크로 포함할 VMA 유형 제어)
$ cat /proc/self/coredump_filter
00000033 # bit 0: 익명 private, bit 1: 익명 shared
# bit 4: ELF headers, bit 5: DAX private
# 코어 파일 분석
$ readelf -h core | grep Type
Type: CORE (Core file)
$ readelf -n core | head -20
Displaying notes found at file offset 0x... with length 0x...:
Owner Data size Description
CORE 0x00000150 NT_PRSTATUS (prstatus structure)
CORE 0x00000088 NT_PRPSINFO (prpsinfo structure)
CORE 0x00000080 NT_SIGINFO (siginfo_t data)
CORE 0x00000130 NT_AUXV (auxiliary vector)
CORE 0x00000563 NT_FILE (mapped files)
# GDB로 코어 파일 분석
$ gdb /path/to/binary core
(gdb) bt # 크래시 시점 backtrace
(gdb) info registers # 레지스터 상태
(gdb) x/10i $rip # 크래시 위치 디스어셈블리
# systemd 환경에서 코어 덤프 조회
$ coredumpctl list
$ coredumpctl debug # 최근 코어 덤프로 GDB 실행
커널 모듈 ELF (.ko)
커널 모듈(.ko 파일)은 ET_REL(재배치 가능) 타입의 ELF 오브젝트입니다. 일반 사용자 공간 실행 파일과 달리 프로그램 헤더가 없으며, 커널의 load_module()이 링커 역할을 수행하여 섹션 기반으로 로딩과 재배치를 처리합니다.
모듈 전용 ELF 섹션:
| 섹션 | 설명 |
|---|---|
.modinfo | 모듈 메타데이터: license=, description=, author=, alias=, depends=, vermagic=. modinfo 명령이 읽는 데이터 |
__versions | CRC 기반 심볼 버전 체크섬. 커널-모듈 ABI 호환성 검증에 사용 (CONFIG_MODVERSIONS) |
.init.text | 모듈 초기화 코드 (module_init()). 로딩 후 해제 가능한 영역으로 분류 |
.exit.text | 모듈 제거 코드 (module_exit()). 내장 빌드 시 제거됨 |
__ksymtab | EXPORT_SYMBOL()로 내보낸 심볼 테이블 |
__ksymtab_gpl | EXPORT_SYMBOL_GPL()로 내보낸 GPL 전용 심볼 테이블 |
__kcrctab | 내보낸 심볼의 CRC 체크섬 |
.gnu.linkonce.this_module | struct module 인스턴스. 모듈 이름, init/exit 함수 포인터 포함 |
__param | module_param()으로 정의한 모듈 파라미터 기술자 |
.altinstructions | CPU 기능에 따른 대체 명령어 (alternative instructions) 테이블 |
__bug_table | BUG() / WARN() 매크로의 위치 정보 |
__ex_table | 예외 테이블 — 사용자 공간 접근(copy_to_user 등)에서 페이지 폴트 시 복구 주소 |
/* kernel/module/main.c — 모듈 로딩 핵심 흐름 (간략화) */
static int load_module(struct load_info *info, const char *uargs, int flags)
{
/* 1. ELF 헤더 검증 (ET_REL, 아키텍처, 버전) */
err = elf_validity_check(info);
/* 2. 섹션 헤더 테이블 파싱 — 모든 섹션 정보 수집 */
err = setup_load_info(info, flags);
/* 3. .modinfo에서 vermagic 검증 */
err = check_modinfo(info->mod, info, flags);
/* vermagic = "6.1.0 SMP preempt mod_unload" */
/* 4. SHF_ALLOC 섹션을 core/init 영역으로 분류 */
module_frob_arch_sections(info);
err = move_module(info->mod, info);
/* vmalloc으로 메모리 할당, 섹션 데이터 복사 */
/* 5. 심볼 해석 — 커널 심볼 테이블에서 외부 참조 검색 */
err = simplify_symbols(info);
/* find_symbol() → __ksymtab 검색 → 심볼 주소 resolve */
/* 6. 재배치 수행 */
err = apply_relocations(info);
/* 각 .rela.* 섹션에 대해 apply_relocate_add() 호출 */
/* 모듈 코드 내 주소 참조를 실제 커널 심볼 주소로 패치 */
/* 7. __ex_table, __bug_table 등 특수 섹션 등록 */
post_relocation(info->mod, info);
/* 8. 모듈 초기화 함수 실행 */
return do_init_module(info->mod);
/* mod->init() 호출 후 .init.* 섹션 메모리 해제 */
}
# 커널 모듈의 ELF 구조 확인
$ readelf -h drivers/net/ethernet/intel/e1000e/e1000e.ko
ELF Header:
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
# 모듈 전용 섹션 확인
$ readelf -S e1000e.ko | grep -E 'modinfo|ksymtab|init\.text|versions'
[11] .modinfo PROGBITS ...
[13] __versions PROGBITS ...
[21] .init.text PROGBITS ...
[35] __ksymtab PROGBITS ...
[36] __ksymtab_gpl PROGBITS ...
# .modinfo 내용 확인
$ modinfo e1000e.ko
filename: e1000e.ko
license: GPL v2
description: Intel(R) PRO/1000 Network Driver
author: Intel Corporation
alias: pci:v00008086d00001533...
depends:
vermagic: 6.1.0 SMP preempt mod_unload
# 모듈의 재배치 엔트리 — 커널 심볼 참조
$ readelf -r e1000e.ko | head -10
Relocation section '.rela.text' at offset ... contains 1234 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000054 002c00000002 R_X86_64_PC32 0000000000000000 printk - 4
00000000009e 003400000002 R_X86_64_PC32 0000000000000000 __kmalloc - 4
EXPORT_SYMBOL과 심볼 네임스페이스: 커널 5.4부터 EXPORT_SYMBOL_NS(sym, ns)로 심볼 네임스페이스를 지정할 수 있습니다. 모듈이 특정 네임스페이스의 심볼을 사용하려면 MODULE_IMPORT_NS(ns)를 선언해야 합니다. __ksymtab 섹션에 네임스페이스 정보가 포함되며, 커널은 로딩 시 이를 검증합니다. 이는 커널 내부 API의 무분별한 사용을 제한하고 서브시스템 간 경계를 명확히 합니다.
binfmt 핸들러 프레임워크 (Binary Format Handlers)
Linux 커널은 다양한 실행 파일 형식을 플러그인 방식으로 지원합니다. exec() 시스템 콜이 호출되면 커널은 등록된 바이너리 포맷 핸들러를 순회하며, 파일의 매직 넘버를 검사하여 적절한 핸들러를 찾습니다. 이 프레임워크의 핵심은 struct linux_binfmt입니다.
/* include/linux/binfmts.h */
struct linux_binfmt {
struct list_head lh; /* 전역 리스트 연결 */
struct module *module; /* 소유 모듈 (참조 카운트) */
int (*load_binary)(struct linux_binprm *); /* 바이너리 로드 */
int (*load_shlib)(struct file *); /* uselib() 지원 (레거시) */
int (*core_dump)(struct coredump_params *cprm); /* 코어 덤프 생성 */
unsigned long min_coredump; /* 최소 코어 덤프 크기 */
};
커널에 내장된 주요 바이너리 포맷 핸들러:
| 핸들러 | 소스 | 매직 / 판별 | 처리 대상 |
|---|---|---|---|
binfmt_elf | fs/binfmt_elf.c | \x7fELF (4바이트) | ELF 실행 파일, 공유 라이브러리 |
binfmt_script | fs/binfmt_script.c | #! (2바이트) | 셸 스크립트, 인터프리터 스크립트 |
binfmt_misc | fs/binfmt_misc.c | 사용자 정의 매직/확장자 | QEMU, Wine, Java, .NET 등 |
binfmt_flat | fs/binfmt_flat.c | BFLT 헤더 | MMU-less 시스템용 플랫 바이너리 |
binfmt_elf_fdpic | fs/binfmt_elf_fdpic.c | \x7fELF + FDPIC ABI | MMU-less ELF (공유 텍스트 세그먼트) |
/* fs/exec.c — 바이너리 포맷 핸들러 탐색 (간략화) */
static int search_binary_handler(struct linux_binprm *bprm)
{
struct linux_binfmt *fmt;
int retval;
/* 등록된 모든 binfmt 핸들러를 순회 */
list_for_each_entry(fmt, &formats, lh) {
if (!try_module_get(fmt->module))
continue;
/* 각 핸들러의 load_binary() 호출 */
retval = fmt->load_binary(bprm);
module_put(fmt->module);
if (retval == -ENOEXEC)
continue; /* 이 포맷이 아님 → 다음 핸들러 시도 */
return retval; /* 성공(0) 또는 에러 → 탐색 종료 */
}
return retval; /* -ENOEXEC: 어떤 핸들러도 인식하지 못함 */
}
/* fs/binfmt_elf.c — ELF 핸들러 등록 */
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary, /* ELF 실행 파일 로드 */
.load_shlib = load_elf_library, /* 레거시 uselib() */
.core_dump = elf_core_dump, /* ET_CORE 코어 덤프 */
.min_coredump = ELF_EXEC_PAGESIZE,
};
/* fs/binfmt_script.c — #! 스크립트 핸들러 */
static int load_script(struct linux_binprm *bprm)
{
/* 첫 2바이트가 '#!' 인지 확인 */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
return -ENOEXEC;
/* 인터프리터 경로 파싱 (예: /usr/bin/python3) */
i_name = bprm->buf + 2; /* '#!' 이후 */
...
/* bprm의 파일을 인터프리터로 교체 후 재귀 호출 */
bprm->interpreter = open_exec(i_name);
return search_binary_handler(bprm); /* 재귀! */
}
binfmt_misc — 사용자 정의 바이너리 핸들러: binfmt_misc는 사용자 공간에서 /proc/sys/fs/binfmt_misc를 통해 임의의 바이너리 포맷을 등록할 수 있는 강력한 확장 메커니즘입니다. 컨테이너 환경에서 다른 아키텍처의 바이너리를 투명하게 실행하는 데 특히 유용합니다.
# binfmt_misc 마운트 확인
$ mount | grep binfmt_misc
systemd-1 on /proc/sys/fs/binfmt_misc type binfmt_misc
# QEMU user-mode emulation 등록 (ARM64 바이너리를 x86_64에서 실행)
$ echo ':qemu-aarch64:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/bin/qemu-aarch64-static:FPC' \
> /proc/sys/fs/binfmt_misc/register
# 등록된 핸들러 확인
$ cat /proc/sys/fs/binfmt_misc/qemu-aarch64
enabled
interpreter /usr/bin/qemu-aarch64-static
flags: FPC
offset 0
magic 7f454c460201010000000000000000000200b700
mask ffffffffffffff00fffffffffffffffffeffffff
# Java JAR 파일 직접 실행 등록
$ echo ':Java:M::\xca\xfe\xba\xbe::/usr/bin/java:' \
> /proc/sys/fs/binfmt_misc/register
# Wine으로 PE (.exe) 바이너리 실행 등록 (매직: MZ)
$ echo ':DOSWin:M::MZ::/usr/bin/wine:' \
> /proc/sys/fs/binfmt_misc/register
# 현재 등록된 모든 핸들러 확인
$ ls /proc/sys/fs/binfmt_misc/
qemu-aarch64 qemu-arm Java DOSWin register status
F(fix-binary) 플래그와 컨테이너: binfmt_misc 등록 시 F 플래그를 사용하면, 등록 시점에 인터프리터 바이너리의 struct file을 미리 열어 둡니다. 이는 Docker/Podman에서 멀티 아키텍처 이미지를 빌드할 때 핵심적인데, 컨테이너 내부에 QEMU 바이너리가 없어도 호스트의 QEMU를 사용할 수 있기 때문입니다. 커널 4.8에서 도입된 이 기능이 없으면, 컨테이너 내에서 인터프리터 경로를 찾을 수 없어 -ENOENT 에러가 발생합니다.
재귀 깊이 제한: #! 스크립트가 또 다른 #! 스크립트를 인터프리터로 지정하는 재귀적 구조를 방지하기 위해, 커널은 linux_binprm.recursion_depth로 최대 재귀 깊이를 4로 제한합니다 (BINPRM_MAX_RECURSION). 이를 초과하면 -ENOEXEC를 반환합니다. 예를 들어, 셸 스크립트(#!/bin/bash) → bash(ELF) 은 깊이 2이므로 정상 동작합니다.
심볼 버저닝 (Symbol Versioning)
심볼 버저닝은 공유 라이브러리가 동일한 심볼의 여러 버전을 동시에 제공할 수 있게 하는 ELF 확장입니다. glibc는 이 메커니즘을 활용하여 하위 호환성을 유지하면서 ABI를 발전시킵니다. 버저닝 정보는 .gnu.version, .gnu.version_r, .gnu.version_d 섹션에 저장됩니다.
/* 심볼 버저닝 관련 ELF 구조체 */
/* .gnu.version — 심볼 버전 인덱스 테이블
* .dynsym의 각 심볼에 1:1 대응하는 16비트 버전 인덱스 */
typedef struct {
Elf64_Half vd_ndx; /* 버전 인덱스 (0=local, 1=global, 2+=정의된 버전) */
} Elf64_Versym;
/* .gnu.version_d — 라이브러리가 정의(제공)하는 버전 */
typedef struct {
Elf64_Half vd_version; /* 구조체 버전 (VER_DEF_CURRENT = 1) */
Elf64_Half vd_flags; /* VER_FLG_BASE: 기본 버전 */
Elf64_Half vd_ndx; /* 버전 인덱스 */
Elf64_Half vd_cnt; /* Verdaux 엔트리 수 */
Elf64_Word vd_hash; /* 버전 이름 ELF 해시 */
Elf64_Word vd_aux; /* Verdaux 오프셋 */
Elf64_Word vd_next; /* 다음 Verdef 오프셋 (0=마지막) */
} Elf64_Verdef;
/* .gnu.version_r — 바이너리가 요구(필요)하는 버전 */
typedef struct {
Elf64_Half vn_version; /* 구조체 버전 (VER_NEED_CURRENT = 1) */
Elf64_Half vn_cnt; /* Vernaux 엔트리 수 */
Elf64_Word vn_file; /* 라이브러리 이름 오프셋 (.dynstr) */
Elf64_Word vn_aux; /* Vernaux 오프셋 */
Elf64_Word vn_next; /* 다음 Verneed 오프셋 (0=마지막) */
} Elf64_Verneed;
/* Vernaux — 요구 버전의 상세 항목 */
typedef struct {
Elf64_Word vna_hash; /* 버전 이름 ELF 해시 */
Elf64_Half vna_flags; /* VER_FLG_WEAK: 약한 버전 참조 */
Elf64_Half vna_other; /* .gnu.version에서 사용하는 인덱스 */
Elf64_Word vna_name; /* 버전 문자열 오프셋 (예: "GLIBC_2.17") */
Elf64_Word vna_next; /* 다음 Vernaux 오프셋 (0=마지막) */
} Elf64_Vernaux;
glibc에서 심볼 버저닝이 동작하는 방식:
# .gnu.version 섹션 — 각 동적 심볼의 버전 인덱스
$ readelf -V /bin/ls
Version symbols section '.gnu.version' contains 120 entries:
Addr: 0x0000000000004ab8 Offset: 0x004ab8 Link: 6 (.dynsym)
000: 0 (*local*) 2 (GLIBC_2.3) 3 (GLIBC_2.14) 1 (*global*)
004: 4 (GLIBC_2.3.4) 2 (GLIBC_2.3) 2 (GLIBC_2.3) 0 (*local*)
# .gnu.version_r — 필요한 버전 목록
Version needs section '.gnu.version_r' contains 1 entry:
Addr: 0x0000000000004c48 Offset: 0x004c48 Link: 7 (.dynstr)
000000: Version: 1 File: libc.so.6 Cnt: 7
0x0010: Name: GLIBC_2.14 Flags: none Version: 3
0x0020: Name: GLIBC_2.3 Flags: none Version: 2
0x0030: Name: GLIBC_2.3.4 Flags: none Version: 4
0x0040: Name: GLIBC_2.17 Flags: none Version: 5
0x0050: Name: GLIBC_2.28 Flags: none Version: 6
0x0060: Name: GLIBC_2.34 Flags: none Version: 7
0x0070: Name: GLIBC_2.2.5 Flags: none Version: 8
# 특정 심볼의 버전 확인
$ objdump -T /bin/ls | grep memcpy
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.14 memcpy
# glibc가 제공하는 memcpy 버전들 확인
$ objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep ' memcpy'
00000000000a7910 g DF .text 0000000000000039 GLIBC_2.2.5 memcpy
0000000000098fc0 g DF .text 0000000000000244 (GLIBC_2.14) memcpy
# 바이너리가 요구하는 최소 glibc 버전 확인
$ objdump -T /bin/ls | grep -oP 'GLIBC_\d+\.\d+(\.\d+)?' | sort -Vu | tail -1
GLIBC_2.34
ld.so의 버전 해석 과정: 동적 링커(ld-linux.so)는 심볼 해석 시 .gnu.version과 .gnu.version_r을 교차 참조합니다. 바이너리의 .gnu.version에 기록된 인덱스를 .gnu.version_r에서 찾아 버전 문자열(예: GLIBC_2.14)을 얻고, 라이브러리의 .gnu.version_d에서 해당 버전이 제공되는지 확인합니다. 버전이 일치하지 않으면 "version `GLIBC_2.34' not found" 에러를 출력합니다. 이 과정은 _dl_check_map_versions() (elf/dl-version.c)에서 수행됩니다.
커널 모듈의 심볼 버저닝 (CONFIG_MODVERSIONS): 커널 모듈은 glibc와 다른 자체 버저닝 메커니즘을 사용합니다. EXPORT_SYMBOL()로 내보낸 각 심볼에 대해 CRC32 체크섬을 계산하여 __kcrctab 섹션에 저장합니다. 모듈 로딩 시 check_version()이 모듈의 __versions 섹션에 기록된 CRC와 커널의 CRC를 비교하여, 함수 시그니처 변경으로 인한 ABI 불일치를 감지합니다. 이것이 "disagrees about version of symbol"/"Invalid module format" 에러의 원인입니다.
스택 언와인딩과 .eh_frame (Stack Unwinding)
스택 언와인딩(unwinding)은 현재 실행 지점에서 호출 스택을 역추적하는 과정으로, 예외 처리(C++ throw/catch), 디버거 백트레이스, 시그널 처리, 성능 프로파일링에 필수적입니다. ELF에서는 .eh_frame과 .eh_frame_hdr 섹션에 DWARF CFI(Call Frame Information) 형식으로 언와인딩 정보를 저장합니다.
| 섹션 | 타입 | 설명 |
|---|---|---|
.eh_frame | SHT_PROGBITS | CIE + FDE 레코드: 각 함수의 프레임 복원 규칙 |
.eh_frame_hdr | SHT_PROGBITS | FDE 이진 검색 테이블 — PT_GNU_EH_FRAME 세그먼트로 매핑 |
.debug_frame | SHT_PROGBITS | DWARF 디버그용 CFI (비할당, 스트립 시 제거) |
.gcc_except_table | SHT_PROGBITS | C++ 예외 처리 LSDA (Language Specific Data Area) |
/* DWARF CFI 핵심 구조: CIE (Common Information Entry) + FDE (Frame Description Entry) */
/* CIE — 공통 정보 엔트리: 함수군에 대한 공통 언와인딩 규칙 */
struct cie_record {
uint32_t length; /* CIE 크기 (0xffffffff이면 64비트 확장) */
uint32_t cie_id; /* 항상 0 (CIE 식별자) */
uint8_t version; /* CFI 버전 (1 또는 3) */
char augmentation[]; /* "zR", "zPLR" 등 확장 문자열 */
/* ULEB128 code_alignment_factor; 텍스트 정렬 단위 (x86_64: 1) */
/* SLEB128 data_alignment_factor; 데이터 정렬 단위 (x86_64: -8) */
/* ULEB128 return_address_register; RA 레지스터 번호 (x86_64: 16=RIP) */
/* initial_instructions[]; 초기 CFA 규칙 바이트코드 */
};
/* FDE — 프레임 기술 엔트리: 개별 함수의 언와인딩 규칙 */
struct fde_record {
uint32_t length; /* FDE 크기 */
uint32_t cie_pointer; /* 소속 CIE로의 오프셋 (≠0이면 FDE) */
/* pc_begin; 함수 시작 주소 (인코딩은 CIE augmentation에 따름) */
/* pc_range; 함수 크기 */
/* instructions[]; DW_CFA_* 바이트코드: 주소별 프레임 규칙 변화 */
};
CFI 바이트코드의 핵심 개념: 각 명령어 주소에서 CFA(Canonical Frame Address)와 레지스터 복원 규칙을 정의합니다. CFA는 일반적으로 이전 프레임의 스택 포인터 값입니다.
| 명령어 | 의미 |
|---|---|
| DW_CFA_def_cfa reg, off | CFA = reg + off (프레임 기준 주소 정의) |
| DW_CFA_def_cfa_offset n | CFA의 오프셋을 n으로 변경 |
| DW_CFA_offset reg, off | reg은 CFA+off 위치에 저장됨 |
| DW_CFA_advance_loc n | 코드 위치를 n × code_align만큼 전진 |
| DW_CFA_restore reg | reg 복원 규칙을 CIE 초기 상태로 복원 |
| DW_CFA_remember_state | 현재 규칙 집합을 스택에 push |
| DW_CFA_restore_state | 스택에서 규칙 집합을 pop |
# .eh_frame의 CIE/FDE 레코드 확인
$ readelf --debug-dump=frames /bin/ls | head -40
Contents of the .eh_frame section:
00000000 0000000000000014 00000000 CIE
Version: 1
Augmentation: "zR"
Code alignment factor: 1
Data alignment factor: -8
Return address column: 16
Augmentation data: 1b # DW_EH_PE_pcrel|DW_EH_PE_sdata4
DW_CFA_def_cfa: r7 (rsp) ofs 8 # 초기: CFA = RSP + 8
DW_CFA_offset: r16 (rip) at cfa-8 # RIP는 CFA-8에 저장됨
00000018 0000000000000024 0000001c FDE cie=00000000 pc=0000c040..0000c0a2
DW_CFA_advance_loc: 4
DW_CFA_def_cfa_offset: 16 # push 이후: CFA = RSP + 16
DW_CFA_offset: r6 (rbp) at cfa-16 # RBP가 CFA-16에 저장됨
DW_CFA_advance_loc: 4
DW_CFA_def_cfa_register: r6 (rbp) # mov rbp,rsp 이후: CFA = RBP + 16
# .eh_frame_hdr — FDE 이진 검색 테이블
$ readelf --debug-dump=frames-interp /bin/ls 2>/dev/null | head -5
# PT_GNU_EH_FRAME 세그먼트 확인
$ readelf -l /bin/ls | grep EH_FRAME
GNU_EH_FRAME 0x000000000000e8e4 0x000000000000e8e4 ... R 0x4
# C++ 예외 테이블 확인
$ readelf -S /usr/bin/c++filt | grep except
[15] .gcc_except_table PROGBITS ...
커널 스택 언와인딩 — ORC vs DWARF:
| 특성 | DWARF CFI (.eh_frame) | ORC (Oops Rewind Capability) |
|---|---|---|
| 사용 환경 | 사용자 공간 (glibc, libunwind) | 커널 전용 (x86_64, 커널 4.14+) |
| 데이터 형식 | 가변 길이 바이트코드 (인터프리터 필요) | 고정 크기 6바이트 엔트리 (직접 조회) |
| 정확도 | 높음 (임의 레지스터 복원 가능) | 높음 (SP/BP/IP만 복원) |
| 크기 | 상대적으로 큼 | 작음 (~2-3% 커널 텍스트 크기) |
| 속도 | 상대적으로 느림 (바이트코드 해석) | 빠름 (테이블 직접 조회) |
| 생성 도구 | GCC/Clang (-fasynchronous-unwind-tables) | objtool (컴파일 후 오브젝트 분석) |
| 커널 설정 | CONFIG_UNWINDER_FRAME_POINTER (레거시) | CONFIG_UNWINDER_ORC (기본값) |
/* arch/x86/include/asm/orc_types.h — ORC 엔트리 구조 (6바이트) */
struct orc_entry {
s16 sp_offset; /* SP 복원 오프셋 */
s16 bp_offset; /* BP 복원 오프셋 */
unsigned sp_reg:4; /* SP 기준 레지스터 (SP, BP, SP_INDIRECT 등) */
unsigned bp_type:4; /* BP 상태 (undefined, prev_sp, regs 등) */
unsigned type:2; /* 프레임 타입 (CALL, REGS, REGS_PARTIAL) */
unsigned signal:1; /* 시그널 프레임 여부 */
unsigned end:1; /* 언와인딩 종료 지점 */
} __packed;
/* .orc_unwind + .orc_unwind_ip 섹션에 저장
* objtool이 정적 분석으로 각 명령어 주소의 프레임 상태를 사전 계산 */
/* arch/x86/kernel/unwind_orc.c — ORC 언와인더 핵심 루프 */
void __unwind_start(struct unwind_state *state,
struct task_struct *task, ...)
{
/* IP → ORC 엔트리 테이블 이진 검색 */
orc = orc_find(state->ip);
...
}
bool unwind_next_frame(struct unwind_state *state)
{
struct orc_entry *orc = orc_find(state->ip);
/* ORC 엔트리의 sp_reg/sp_offset으로 이전 SP 계산 */
switch (orc->sp_reg) {
case ORC_REG_SP:
sp = state->sp + orc->sp_offset; break;
case ORC_REG_BP:
sp = state->bp + orc->sp_offset; break;
...
}
/* IP = *(sp - 8): 리턴 주소로 이전 프레임의 IP 복원 */
state->ip = *((unsigned long *)sp - 1);
state->sp = sp;
return true;
}
-fomit-frame-pointer와 언와인딩: 현대 컴파일러는 기본적으로 -fomit-frame-pointer로 RBP 프레임 포인터를 최적화합니다(범용 레지스터로 활용). 이 경우 전통적인 RBP 체이닝 방식의 백트레이스가 불가능하므로, .eh_frame(사용자 공간) 또는 ORC(커널)의 테이블 기반 언와인딩이 필수적입니다. perf record --call-graph dwarf는 .eh_frame을 사용하고, perf record --call-graph fp는 프레임 포인터를 사용합니다. 프레임 포인터가 없는 바이너리에서 fp 모드는 잘못된 백트레이스를 생성합니다.
DWARF 디버그 정보 (Debug Information)
DWARF(Debugging With Attributed Record Formats)는 ELF 바이너리에 포함되는 디버그 정보의 표준 형식입니다. 소스 수준 디버깅(GDB, LLDB), 심볼 해석, 소스-라인 매핑에 사용되며, 컴파일러가 -g 옵션으로 생성합니다. 현재 널리 사용되는 버전은 DWARF 4(-gdwarf-4, GCC 기본)와 DWARF 5(-gdwarf-5, Clang 기본)입니다.
| 섹션 | DWARF 버전 | 설명 |
|---|---|---|
.debug_info | 2+ | 핵심 디버그 정보: DIE(Debugging Information Entry) 트리 — 함수, 변수, 타입, 스코프 |
.debug_abbrev | 2+ | DIE 태그/속성 약어 테이블 — .debug_info의 압축 인코딩을 해석하는 스키마 |
.debug_line | 2+ | 소스 파일명/줄 번호 ↔ 기계어 주소 매핑 (라인 넘버 프로그램) |
.debug_str | 2+ | 중복 제거된 문자열 풀 (변수명, 파일 경로 등) |
.debug_loc | 2-4 | 변수 위치 목록: PC 범위별로 변수가 레지스터/메모리 어디에 있는지 기술 |
.debug_loclists | 5 | .debug_loc의 DWARF 5 후속 — 더 컴팩트한 인코딩 |
.debug_ranges | 3-4 | 비연속 주소 범위 목록 (인라인 함수, 최적화된 코드용) |
.debug_rnglists | 5 | .debug_ranges의 DWARF 5 후속 |
.debug_aranges | 2+ | 주소 → 컴파일 유닛 빠른 조회 테이블 |
.debug_types | 4 | 타입 정보 전용 섹션 (DWARF 5에서 .debug_info에 통합) |
.debug_str_offsets | 5 | 문자열 오프셋 인덱싱 — 문자열 참조를 인덱스로 압축 |
.debug_line_str | 5 | 라인 테이블 전용 문자열 풀 |
/* DWARF DIE (Debugging Information Entry) 구조 개념
* 각 DIE는 태그(종류)와 속성(키-값) 목록으로 구성됨
* DIE는 트리 구조로 스코프를 표현 */
/* 예: 다음 C 코드의 DWARF DIE 표현 */
int add(int a, int b) { return a + b; }
/*
* DIE 트리:
* DW_TAG_compile_unit ← 컴파일 유닛 (소스 파일)
* DW_AT_name: "math.c"
* DW_AT_comp_dir: "/home/dev/project"
* DW_AT_language: DW_LANG_C11
* DW_AT_producer: "GCC 13.2.0"
* DW_AT_stmt_list: (→ .debug_line 오프셋)
*
* DW_TAG_base_type ← 기본 타입
* DW_AT_name: "int"
* DW_AT_byte_size: 4
* DW_AT_encoding: DW_ATE_signed
*
* DW_TAG_subprogram ← 함수
* DW_AT_name: "add"
* DW_AT_low_pc: 0x401000 ← 함수 시작 주소
* DW_AT_high_pc: 0x401020 ← 함수 끝 주소
* DW_AT_type: → (int) ← 반환 타입 참조
* DW_AT_frame_base: DW_OP_reg6(rbp)
*
* DW_TAG_formal_parameter ← 매개변수
* DW_AT_name: "a"
* DW_AT_type: → (int)
* DW_AT_location: DW_OP_fbreg(-20) ← RBP-20에 저장
*
* DW_TAG_formal_parameter
* DW_AT_name: "b"
* DW_AT_type: → (int)
* DW_AT_location: DW_OP_fbreg(-24)
*/
# DWARF 디버그 섹션 크기 확인 — 디버그 정보가 바이너리보다 큰 경우가 흔함
$ readelf -S /usr/lib/debug/usr/bin/ls.debug | grep debug
[28] .debug_aranges PROGBITS ... 000002d0 ...
[29] .debug_info PROGBITS ... 00032a41 ... # 200KB+
[30] .debug_abbrev PROGBITS ... 00001e25 ...
[31] .debug_line PROGBITS ... 0000d8a2 ...
[32] .debug_str PROGBITS ... 00005e12 ...
# DIE 트리 덤프 — 함수와 변수의 디버그 정보 확인
$ readelf --debug-dump=info a.out | head -50
Contents of the .debug_info section:
Compilation Unit @ offset 0x0:
Length: 0x120
Version: 5 # DWARF 5
Unit Type: DW_UT_compile
Abbrev Offset: 0x0
Pointer Size: 8
<0><c>: Abbrev Number: 1 (DW_TAG_compile_unit)
<d> DW_AT_producer : GNU C17 13.2.0
<22> DW_AT_language : 29 (C11)
<23> DW_AT_name : math.c
<2a> DW_AT_comp_dir : /home/dev/project
<1><3e>: Abbrev Number: 2 (DW_TAG_subprogram)
<3f> DW_AT_name : add
<43> DW_AT_low_pc : 0x401000
<4b> DW_AT_high_pc : 32
<4f> DW_AT_type : <0x70>
# 소스 줄 ↔ 기계어 주소 매핑
$ readelf --debug-dump=decodedline a.out
Decoded dump of debug contents of section .debug_line:
CU: math.c:
File name Line number Starting address View Stmt
math.c 1 0x401000 x
math.c 2 0x401004 x
math.c 2 0x40100a
math.c 2 0x401013 x
# addr2line — 주소를 소스 위치로 변환 (내부적으로 .debug_line 사용)
$ addr2line -e a.out -f 0x401004
add
/home/dev/project/math.c:2
# dwarfdump (libdwarf) — 더 상세한 DWARF 분석
$ dwarfdump --print-all a.out 2>/dev/null | head -20
Split DWARF와 debuginfod — 대규모 프로젝트의 디버그 정보 관리:
| 기술 | 설명 | 장점 |
|---|---|---|
Split DWARF (-gsplit-dwarf) | 디버그 정보를 .dwo 파일로 분리. 바이너리에는 .debug_addr과 스켈레톤 CU만 남김 | 링크 시간 단축(디버그 정보 병합 불필요), 릴리스 바이너리 크기 감소 |
DWARF 패키지 (dwp) | 여러 .dwo 파일을 단일 .dwp 패키지로 병합 | 배포/관리 편의성, GDB가 자동 탐색 |
| debuginfod | HTTP 서버로 .debug 파일을 온디맨드 제공. DEBUGINFOD_URLS 환경 변수 | 로컬 디버그 심볼 설치 불필요, GDB/perf 자동 다운로드 |
| GNU Build ID | .note.gnu.build-id — 바이너리의 고유 SHA1 식별자 | debuginfod에서 바이너리↔디버그 파일 매칭 키로 사용 |
.gnu_debuglink | 별도 디버그 파일(*.debug)의 경로와 CRC32 | 스트립된 바이너리에서 디버그 파일 위치 힌트 |
# Split DWARF 컴파일 — .dwo 파일 생성
$ gcc -g -gsplit-dwarf -c math.c -o math.o
$ ls math.*
math.c math.dwo math.o
# .dwo 파일 병합 (대규모 프로젝트)
$ dwp -e myapp -o myapp.dwp
# GNU Build ID 확인
$ readelf -n /bin/ls | grep 'Build ID'
Build ID: 2f31f68c55e02a7e7a72ad2758e04bd50c4e1e3a
# debuginfod 설정 (Fedora/Ubuntu/Arch 기본 제공)
$ export DEBUGINFOD_URLS="https://debuginfod.elfutils.org/"
$ gdb /bin/ls
Reading symbols from /bin/ls...
Downloading separate debug info for /bin/ls...
# 수동으로 별도 디버그 파일 설치 (Debian/Ubuntu)
$ sudo apt install coreutils-dbgsym
# → /usr/lib/debug/.build-id/2f/31f68c...debug
# 스트립 + debuglink 생성
$ objcopy --only-keep-debug myapp myapp.debug
$ strip --strip-debug myapp
$ objcopy --add-gnu-debuglink=myapp.debug myapp
커널의 디버그 정보 (vmlinux): 커널은 CONFIG_DEBUG_INFO로 DWARF 디버그 정보를 생성합니다. vmlinux에 포함된 디버그 정보는 수백 MB에 달할 수 있으며, crash dump 분석(crash 유틸리티), SystemTap, BPF CO-RE(BTF)의 기반이 됩니다. 커널 5.2+에서는 CONFIG_DEBUG_INFO_BTF로 DWARF에서 추출한 경량 타입 정보인 BTF(BPF Type Format)를 생성하여, BPF 프로그램이 커널 데이터 구조에 안전하게 접근할 수 있게 합니다. BTF는 DWARF의 1/100 이하 크기로 커널 이미지에 포함됩니다 (.BTF, .BTF.ext 섹션).
링커 스크립트 (Linker Script)
링커 스크립트는 링커(ld)에게 입력 섹션을 출력 세그먼트에 어떻게 배치할지 지시하는 설정 파일입니다. 사용자 공간 프로그램은 시스템 기본 링커 스크립트(ld --verbose)를 사용하지만, 커널(vmlinux.lds.S)과 부트로더, 펌웨어, 베어메탈 프로그램은 맞춤 링커 스크립트가 필수적입니다.
/* 기본 링커 스크립트 구조 — 섹션에서 세그먼트로의 매핑 */
/* ENTRY: 프로그램 진입점 (ELF e_entry에 설정) */
ENTRY(_start)
/* PHDRS: 출력 세그먼트(프로그램 헤더) 정의 */
PHDRS
{
headers PT_PHDR PHDRS; /* 프로그램 헤더 자체 */
interp PT_INTERP; /* 인터프리터 경로 */
text PT_LOAD FLAGS(5); /* R-X: 코드 세그먼트 */
rodata PT_LOAD FLAGS(4); /* R--: 읽기 전용 데이터 */
data PT_LOAD FLAGS(6); /* RW-: 데이터 세그먼트 */
dynamic PT_DYNAMIC FLAGS(6); /* 동적 링킹 정보 */
note PT_NOTE FLAGS(4); /* 빌드 ID 등 메타데이터 */
eh_frame PT_GNU_EH_FRAME FLAGS(4); /* .eh_frame_hdr */
stack PT_GNU_STACK FLAGS(6); /* 스택 속성 (NX) */
relro PT_GNU_RELRO FLAGS(4); /* RELRO 영역 */
}
/* SECTIONS: 입력 섹션을 출력 섹션에 배치하고, 세그먼트에 할당 */
SECTIONS
{
/* 코드 영역 — text 세그먼트 */
.text : {
*(.text .text.*) /* 모든 입력의 .text 섹션 */
*(.text.hot .text.hot.*) /* 핫 코드 (PGO) */
} :text /* → text 세그먼트에 배치 */
/* 읽기 전용 데이터 — rodata 세그먼트 */
.rodata : {
*(.rodata .rodata.*)
} :rodata
/* 동적 링킹 — data 세그먼트 */
.dynamic : { *(.dynamic) } :data :dynamic
/* GOT/PLT — RELRO 보호 대상 */
.got : { *(.got) } :data :relro
.got.plt : { *(.got.plt) }
/* 초기화된 데이터 */
.data : { *(.data .data.*) } :data
/* BSS — 파일에 저장되지 않음 (p_memsz > p_filesz) */
.bss : { *(.bss .bss.*) *(COMMON) } :data
/* PROVIDE: 심볼이 정의되지 않았을 때만 제공 */
PROVIDE(_end = .);
PROVIDE(__bss_start = ADDR(.bss));
}
리눅스 커널의 링커 스크립트 (vmlinux.lds.S): 커널은 아키텍처별 링커 스크립트 템플릿(arch/x86/kernel/vmlinux.lds.S)을 C 전처리기로 처리하여 최종 vmlinux.lds를 생성합니다. 커널만의 특수한 섹션 배치가 정의됩니다.
/* arch/x86/kernel/vmlinux.lds.S — 커널 링커 스크립트 (핵심 발췌) */
#include <asm-generic/vmlinux.lds.h>
SECTIONS
{
. = __START_KERNEL; /* x86_64: 0xffffffff81000000 */
.text : AT(ADDR(.text) - LOAD_OFFSET) {
_text = .;
_stext = .;
/* HEAD_TEXT: startup_64 등 부팅 초기 코드 */
HEAD_TEXT
/* TEXT_TEXT: 일반 커널 코드 */
TEXT_TEXT
...
_etext = .;
}
/* .init 영역: 부팅 후 해제되는 코드/데이터 */
.init.text : AT(ADDR(.init.text) - LOAD_OFFSET) {
_sinittext = .;
INIT_TEXT /* __init 매크로 함수들 */
_einittext = .;
}
.init.data : AT(ADDR(.init.data) - LOAD_OFFSET) {
INIT_DATA /* __initdata 변수들 */
}
/* 커널 특수 섹션 */
.altinstructions : {
__alt_instructions = .;
*(.altinstructions) /* CPU 기능별 대체 명령어 */
__alt_instructions_end = .;
}
__ex_table : {
__start___ex_table = .;
*(__ex_table) /* 예외 테이블 */
__stop___ex_table = .;
}
/* ORC 언와인더 데이터 */
.orc_unwind_ip : {
__start_orc_unwind_ip = .;
*(.orc_unwind_ip)
__stop_orc_unwind_ip = .;
}
.orc_unwind : {
__start_orc_unwind = .;
*(.orc_unwind)
__stop_orc_unwind = .;
}
/* __start/__stop 심볼: 링커가 자동 생성하는 섹션 경계 포인터 */
/* 커널 코드에서 for_each 매크로로 섹션 내용을 순회할 때 사용 */
}
# 시스템 기본 링커 스크립트 확인
$ ld --verbose 2>/dev/null | grep -A3 'SECTIONS'
SECTIONS
{
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000));
. = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
# 커널 빌드에서 생성된 실제 링커 스크립트
$ head -20 /usr/src/linux/vmlinux.lds
# 또는 빌드 디렉토리에서:
$ head -20 /lib/modules/$(uname -r)/build/vmlinux.lds
# 커스텀 링커 스크립트 사용 (임베디드/베어메탈)
$ gcc -T custom.ld -nostdlib -o firmware startup.o main.o
# 섹션→세그먼트 매핑 확인
$ readelf -l /bin/ls | head -30
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x003a58 0x003a58 R 0x1000
LOAD 0x004000 0x0000000000004000 0x0000000000004000 0x013581 0x013581 R E 0x1000
LOAD 0x018000 0x0000000000018000 0x0000000000018000 0x007b10 0x007b10 R 0x1000
LOAD 0x020000 0x0000000000020000 0x0000000000020000 0x001258 0x002560 RW 0x1000
...
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
__start/__stop 매직 심볼과 커널 섹션 순회: GNU ld는 __start_SECTION과 __stop_SECTION 심볼을 자동 생성합니다(섹션 이름이 C 식별자 규칙을 만족하는 경우). 커널은 이 메커니즘을 광범위하게 활용합니다. 예를 들어 __start___ex_table부터 __stop___ex_table까지 순회하면 모든 예외 테이블 엔트리에 접근할 수 있습니다. module_init()/module_exit() 매크로가 함수 포인터를 .initcall*.init 섹션에 배치하고, 커널이 부팅 시 __initcall_start부터 순회하며 드라이버 초기화를 실행하는 것도 같은 원리입니다.
ELF 보안 강화 기법 (Security Hardening)
현대 Linux 시스템은 ELF 바이너리에 다양한 보안 메커니즘을 적용하여 메모리 손상 취약점의 악용을 방어합니다. 이러한 기법들은 컴파일러, 링커, 커널이 협력하여 구현합니다.
| 기법 | 보호 대상 | 구현 위치 | 컴파일러 플래그 |
|---|---|---|---|
| Stack Canary (SSP) | 스택 버퍼 오버플로 | 컴파일러 (함수 프롤로그/에필로그에 카나리 삽입) | -fstack-protector-strong (기본값) |
| NX (No-Execute) | 코드 주입 | 커널 (PTE NX 비트), 링커 (PT_GNU_STACK) | -z noexecstack (기본값) |
| RELRO | GOT 오버라이트 | 링커 (PT_GNU_RELRO 세그먼트) | -z relro -z now (Full RELRO) |
| PIE/ASLR | ROP/JOP 공격 | 컴파일러 (PIC 코드), 커널 (무작위 주소 배치) | -fPIE -pie (기본값) |
| FORTIFY_SOURCE | 버퍼 오버플로 (libc 함수) | 컴파일러 (strcpy→__strcpy_chk 등 치환) | -D_FORTIFY_SOURCE=2 -O2 |
| CET (Control-flow Enforcement) | ROP/JOP 공격 (하드웨어) | CPU (Shadow Stack, IBT), 컴파일러, 커널 | -fcf-protection=full |
| CFI (Control Flow Integrity) | 간접 호출 하이재킹 | 컴파일러 (간접 호출 대상 검증) | -fsanitize=cfi (Clang) |
# 바이너리의 보안 속성 종합 점검 — checksec 스크립트
$ checksec --file=/bin/ls
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Full RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH No Symbols Yes 5 15 /bin/ls
# 개별 보안 속성 수동 확인
# 1. NX (No-Execute Stack) — PT_GNU_STACK 세그먼트의 플래그 확인
$ readelf -l /bin/ls | grep GNU_STACK
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
# ^^ RW = NX 활성 (E 없음)
# 2. RELRO — PT_GNU_RELRO 세그먼트 존재 + BIND_NOW 확인
$ readelf -l /bin/ls | grep GNU_RELRO
GNU_RELRO 0x01f9c8 0x000000000021f9c8 0x000000000021f9c8 0x000638 0x000638 R 0x1
$ readelf -d /bin/ls | grep BIND_NOW
0x000000000000001e (FLAGS) BIND_NOW
# Full RELRO = PT_GNU_RELRO + BIND_NOW (즉시 바인딩으로 GOT가 읽기 전용)
# 3. PIE — ELF 타입이 DYN (공유 오브젝트)이면 PIE
$ readelf -h /bin/ls | grep Type
Type: DYN (Position-Independent Executable)
# 4. Stack Canary — __stack_chk_fail 심볼 참조 존재
$ readelf -s /bin/ls | grep stack_chk
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __stack_chk_fail@GLIBC_2.4
# 5. FORTIFY — __*_chk 함수 참조 확인
$ readelf -s /bin/ls | grep '_chk@'
6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __printf_chk@GLIBC_2.3.4
12: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __memcpy_chk@GLIBC_2.3.4
Partial RELRO vs Full RELRO:
| 속성 | Partial RELRO (-z relro) | Full RELRO (-z relro -z now) |
|---|---|---|
| 링커 플래그 | -Wl,-z,relro | -Wl,-z,relro,-z,now |
| PT_GNU_RELRO | 존재 | 존재 |
| GOT (.got) | 읽기 전용 | 읽기 전용 |
| GOT PLT (.got.plt) | 쓰기 가능 (Lazy Binding) | 읽기 전용 (Immediate Binding) |
| DT_BIND_NOW | 없음 | 있음 |
| 시작 시간 | 빠름 (lazy) | 느림 (모든 심볼 즉시 해석) |
| 보안 수준 | GOT PLT 오버라이트 가능 | GOT 전체 보호 |
/* 커널의 ELF 보안 검사 — fs/binfmt_elf.c */
/* NX 스택 처리: PT_GNU_STACK 세그먼트의 PF_X 플래그로 결정 */
static int load_elf_binary(struct linux_binprm *bprm)
{
...
for (i = 0; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_GNU_STACK) {
if (elf_ppnt->p_flags & PF_X)
executable_stack = EXSTACK_ENABLE_X; /* 실행 가능 스택 */
else
executable_stack = EXSTACK_DISABLE_X; /* NX 스택 (기본) */
}
}
...
/* ASLR: 인터프리터와 바이너리의 로드 주소 무작위화 */
if (elf_ex->e_type == ET_DYN) { /* PIE 바이너리 */
load_bias = ELF_ET_DYN_BASE; /* 2/3 * TASK_SIZE */
if (current->flags & PF_RANDOMIZE)
load_bias += arch_mmap_rnd(); /* ASLR 오프셋 */
load_bias = ELF_PAGESTART(load_bias);
}
...
}
/* Stack Canary (커널 측): 프로세스 생성 시 랜덤 카나리 값 설정 */
/* include/linux/sched.h */
struct task_struct {
...
unsigned long stack_canary; /* copy_process()에서 get_random_canary()로 초기화 */
...
};
/* arch/x86에서는 per-cpu __stack_chk_guard 또는 gs:[0x28]에 저장 */
CET (Control-flow Enforcement Technology): Intel CET는 두 가지 메커니즘을 제공합니다. Shadow Stack은 하드웨어가 관리하는 별도의 리턴 주소 스택으로, CALL/RET 시 자동으로 검증합니다(ROP 방어). IBT(Indirect Branch Tracking)는 간접 점프/호출의 대상이 ENDBR64 명령어로 시작해야 함을 강제합니다(JOP 방어). 커널 6.2+에서는 CONFIG_X86_USER_SHADOW_STACK과 CONFIG_X86_KERNEL_IBT로 사용자/커널 공간 CET를 지원합니다. arch_prctl(ARCH_SHSTK_ENABLE)으로 사용자 공간 Shadow Stack을 활성화할 수 있습니다.
__start/__stop 매직 심볼과 커널 섹션 순회: GNU ld는 __start_SECTION과 __stop_SECTION 심볼을 자동 생성합니다(섹션 이름이 C 식별자 규칙을 만족하는 경우). 커널은 이 메커니즘을 광범위하게 활용합니다. 예를 들어 __start___ex_table부터 __stop___ex_table까지 순회하면 모든 예외 테이블 엔트리에 접근할 수 있습니다. module_init()/module_exit() 매크로가 함수 포인터를 .initcall*.init 섹션에 배치하고, 커널이 부팅 시 __initcall_start부터 순회하며 드라이버 초기화를 실행하는 것도 같은 원리입니다.
ELF 분석 플레이북
ELF 이슈는 링크 단계, 로더 단계, 런타임 단계로 나눠서 보면 원인 축소가 빠릅니다. 커널/모듈/유저 바이너리를 같은 도구로 교차 확인하면 문제를 빠르게 재현할 수 있습니다.
- 헤더 확인: 클래스(ELF32/64), 엔디안, 머신 타입 점검
- 섹션/세그먼트 확인: 로딩 가능한 PT_*와 디버그/심볼 섹션 분리 확인
- 심볼/재배치 확인: undefined symbol, relocation 유형 점검
- 보안 속성 확인: PIE, RELRO, NX, canary 등 정책 확인
# ELF 기본 헤더/세그먼트
readelf -h vmlinux
readelf -l vmlinux
# 심볼/재배치
nm -n vmlinux | head
readelf -r mymodule.ko | head
# 바이너리 타입 확인
file arch/x86/boot/bzImage
objdump -h mymodule.ko | head
| 증상 | 원인 후보 | 대응 |
|---|---|---|
| Exec format error | 아키텍처/ABI 불일치 | readelf -h로 머신 타입/클래스 확인 |
| Unknown symbol in module | 심볼 export 누락/버전 불일치 | nm, modinfo, MODVERSIONS 상태 점검 |
| 디버깅 심볼 누락 | stripped 바이너리 사용 | CONFIG_DEBUG_INFO 활성 후 재빌드 |
관련 문서
ELF와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.