ELF 파일
2025년 3월 15일
ref #
https://blog.k3170makan.com/2018/10/introduction-to-elf-format-part-vi.html
https://noise.getoto.net/2021/03/02/how-to-execute-an-object-file-part-1/
ELF (Executable and Linkable Format) #
unix like 운영체제에서 사용되는 표준 파일 포맷으로 실행 파일, 오브젝트파일, 공유라이브러리의 포맷이다.
kernel.elf 파일도 ELF 파일형식이며, 이전 bootloader 문서에서 발견된 버그도 kernel.elf 파일을 ELF 로 읽어야 하지만 메모리에 바로 쓰고 entrypoint로 점프하려 해서 발생했던 버그였다.
ELF Header #
1typedef struct {
2 unsigned char e_ident[16]; // ELF 식별 정보 (매직 넘버, 클래스, 엔디언 등)
3 uint16_t e_type; // 파일 타입 (실행 파일, 공유 라이브러리 등)
4 uint16_t e_machine; // 대상 아키텍처 (x86_64: 0x3E)
5 uint32_t e_version; // ELF 버전 (보통 1)
6 uint64_t e_entry; // 엔트리 포인트 가상 주소 (프로그램 시작 주소)
7 uint64_t e_phoff; // 프로그램 헤더 테이블 오프셋
8 uint64_t e_shoff; // 섹션 헤더 테이블 오프셋
9 uint32_t e_flags; // 프로세서 별 플래그
10 uint16_t e_ehsize; // ELF 헤더 크기 (64-bit에서는 일반적으로 64바이트)
11 uint16_t e_phentsize; // 프로그램 헤더 엔트리 크기
12 uint16_t e_phnum; // 프로그램 헤더 엔트리 개수
13 uint16_t e_shentsize; // 섹션 헤더 엔트리 크기
14 uint16_t e_shnum; // 섹션 헤더 엔트리 개수
15 uint16_t e_shstrndx; // 섹션 헤더 문자열 테이블의 인덱스
16} Elf64_Ehdr;
프로그램 헤더와 섹션 헤더의 파일 오프셋과 크기를 알 수 있다.
kernel.elf 의 구조 #
1+---------------------------+ <- 0x0
2| ELF Header (64byte) |
3+---------------------------+ <- 0x40
4| Program Header 1 (56byte) |
5| ... | (224 byte)
6| Program Header 4 |
7+---------------------------+ <- 0x120
8| section 1 |
9| ... | (3352 byte)
10| section 16 |
11+---------------------------+ <- 0xE38
12| Section Header 1 (64byte) |
13| ... | (1024 byte)
14| Section Header 16 |
15+---------------------------+ <- 0x1238
16total: 4664 byte
Section Header String Index #
.shstrtab 섹션의 인덱스 번호가 저장되어 있으며, .shstrtab 에는 ELF 파일의 각 섹션의 이름이 문자열 테이블 형태로 저장된다.
Program Header #
실행파일이 실행될 때 로더가 어떤 섹션을 어떤 메모리 주소에 로드해야 하는지, 어떤 권한을 줘야하는지 등이 저장되어 있다.
다시말하면 프로그램이 실행되면 Program Header에 담긴 섹션만 메모리에 로드된다는 의미이다.
1typedef struct {
2 uint32_t p_type; // 세그먼트 타입
3 uint32_t p_flags; // 권한(R/W/X)
4 uint64_t p_offset; // 파일 내 오프셋
5 uint64_t p_vaddr; // 가상 메모리 주소
6 uint64_t p_paddr; // 물리 메모리 주소
7 uint64_t p_filesz; // 파일에서 세그먼트 크기
8 uint64_t p_memsz; // 메모리에서 세그먼트 크기
9 uint64_t p_align; // 메모리 정렬
10} Elf64_Phdr;
세그먼트 타입 #
- LOAD (0x1): 실행 시 메모리에 로드될 코드/데이터 영역에 대한 정보를 알려준다. 파일 오프셋과 사이즈만큼 메모리 주소에 로드(mmap)한다.
- 실제로 확인해보면 다른 타입들은 LOAD 타입의 영역 안에 있기 때문에 로더는 LOAD 타입만 확인해서 메모리에 로드해주면 된다.
- 다른 타입들은 특별한 역할을 맡기 때문에 프로그램 헤더의 엔트리로 존재하는 것이다.
- DYNAMIC (0x2): .dynamic 섹션을 메모리에 로드할때 사용되는 정보이며 동적 링킹 정보가 포함된다.
readelf -d로 확인할 수 있는 내용들이 담겨져 있다.tag - val/ptr한 쌍으로 구성되며 필요한 라이브러리(NEEDED tag) 이름 주소(.dynstr 섹션)를 담고 있거나, .got.plt(PLTGOT tag) 섹션의 주소를 담는 등 동적링커가 필요로 하는 정보의 주소 엔트리가 저장되는 곳에 대한 메모리 주소이다.
- INTERP (0x3): ELF 로더 (동적링커) 경로 문자열이 저장된 위치(.interp 섹션)를 의미한다. 이 문자열이 있다면 프로그램 내에서 .so 의 함수를 사용한다는 의미하며, 없다면 단독으로 실행 가능한 프로그램이라는 뜻이다.
- 프로그램 헤더에서 확인한 오프셋 위치를 확인해보면 동적링커의 문자열이 포함되어 있다.
- 동적 링커의 필요 여부는 ldd로도 확인할 수 있다.
1kdh@DESKTOP-MHEA7GE:~/min-os/kernel$ ldd ./a.out 2 linux-vdso.so.1 (0x00007fffa7495000) 3 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8b4e864000) 4 /lib64/ld-linux-x86-64.so.2 (0x00007f8b4ea9b000) - 컴파일 시 사용하는
ld (링커)와 런타임에 GOT에 점프주소를 찾아주는ld*.so (동적링커)는 완전히 다르다.
- 프로그램 헤더에서 확인한 오프셋 위치를 확인해보면 동적링커의 문자열이 포함되어 있다.
- NOTE (0x4): GNU 빌드 ID, ABI 정보 등 메타데이터가 저장된다.
read -n으로 확인할 수 있다.
- PHDR (0x6): 프로그램 헤더 테이블 타입이다. kernel.elf 를 예로 보면 프로그램 헤더 테이블이 파일 오프셋이 0x40 으로 ELF 헤더 다음에 위치한 것을 알 수 있으며 메모리상에는 이미지베이스를 더한 값에 로드된다.
- 레퍼런스 글 에서 바이너리 파일을 수정해서 PHDR 다음 헤더부터 가리키게 오프셋을 수정했는데, 제대로 동작하는 것을 볼 수 있었다.
- 두번째 프로그램 헤더 로드주소와 겹쳐있는걸 볼 수 있는데
ELF Header + Program Header + .rodata영역이기 때문이다.
- TLS (0x7): 스레드별 독립적인 데이터를 저장하는 영역을 말한다. 일부 컴파일 방법의 경우 이 영역이 전역변수처럼 사용되어 data나 bss 처럼 배치될수도 있다.
- GNU_STACK (0x6474e551): 스택을 NX(Non-Executable)로 설정할지 정하는 헤더이다. 이 헤더를 삭제하면 리눅스 버전마다 기본값으로 실행되고, 이 헤더에 RWX 권한을 세팅하면 NX 비트가 해제된다.
- GNU_RELRO (0x6474e552): 이것도 GNU_STACK 처럼 RELRO 설정 영역을 프로그램 헤더로 지정한 것이다.
- 초기에 쓰기 권한으로 시작해서 수정하고 더이상의 수정이 필요 없는 메모리 영역 (.plt.got 등)은 보안을 위해 mprotect로 ReadOnly 가 된다. got overwrite 등을 막기 위해 사용됨
kernel.elf #
내용을 확인해보면 파일의 오프셋과 로드되는 가상메모리 주소가 다른데, 자세히 보면 커널 빌드 시 지정한 image base 의 문제만은 아닌 것을 알 수 있다.
.text 섹션은 file offset이 0x1b0 이고, image base 는 0x100000인데, 메모리 로드 주소는 0x1011b0 으로 0x1000 바이트만큼 차이가 난다.
이 0x1000 바이트는 Align 값이며 메모리에 로드될때 최소 정렬 기준이기 때문에 파일 그대로 메모리에 복사하면 entry_point를 찾지 못하는 문제가 발생했던 것이다.
Section Header #
ELF 헤더에서 섹션헤더의 시작 오프셋, 수, 크기(x64=64byte)를 알 수 있으며, 각 섹션헤더는 섹션에 대한 정보가 저장된다.
1typedef struct {
2 uint32_t sh_name; // shstrtab 섹션에서의 현재 섹션이름 오프셋
3 uint32_t sh_type; // 섹션의 타입
4 uint64_t sh_flags; // 섹션의 RWX 권한
5 uint64_t sh_addr; // 메모리에 로드될 가상주소
6 uint64_t sh_offset; //
7 uint64_t sh_size;
8 uint32_t sh_link;
9 uint32_t sh_info;
10 uint64_t sh_addralign;
11 uint64_t sh_entsize;
12} Elf64_Shdr;
sh_name #
섹션 이름이 저장된 shstrtab 섹션에서의 오프셋이 담겨있다.
sh_type #
- NULL(0): 사용되지 않음. 섹션 헤더의 0번째 인덱스는 무조건 NULL 타입이다.
- PROGBITS(1): 일반적인 데이터나 코드를 담는 섹션이다.
.text,.data,.rodata등의 섹션이 해당된다. - SYMTAB(2): 정적 심볼 테이블 섹션. 심볼에 대한 정보가 담겨있고 이름은 스트링 테이블
.strtab을 참조한다. 필수가 아니기 때문에 strip 할때 제거된다.1struct Elf64_Sym { 2 uint32_t st_name; // 각 심볼 스트링 테이블(STRTAB)의 오프셋이 담겨있다. 3 unsigned char st_info; // 스코프를 지정. LOCAL(static), GLOBAL, WEAK(override). 이 정보로 심볼이 충돌났는지 검사 가능 4 unsigned char st_other; // 심볼 가시성(접근 스코프)에 대한 정보. __attribute__((visibility(...)) 로 임의지정할 수 있다. 5 uint64_t st_shndx; // 해당 심볼이 포함된 섹션의 인덱스 6 uint64_t st_value; // 심볼의 주소. (함수의 시작주소, 변수의 메모리주소, 섹션심볼이면 섹션 시작주소 등) 7 uint64_t st_size; // 심볼의 크기. (함수의 코드 바이트 길이, 변수의 데이터 사이즈 등) 8} - STRTAB(3): 문자열 테이블 섹션 타입.
.shstrtab,.strtab - RELA(4), REL(9): 재배치 오프셋 정보를 담는 섹션.
- NOTE(7): build ID, core dump info, OS, ABI 등 메타데이터 정보
- NOBITS(8): 파일에는 없지만 실행되면 메모리 공간이 할당되는 섹션.
.bss(초기화되지 않은 전역/정적변수) - DYNAMIC(11): 동적 심볼 테이블 섹션.
Elf64_Sym구조체를 동일하게 사용하지만, strip 되지 않는 영역이다. - INIT_ARRAY(14), FINI_ARRAY(15): 프로그램 시작/종료 시 자동으로 호출되는 함수에 대한 포인터배열이 담겨있다.