PE 포맷이란
PE 포맷은 Portable Excutable의 약자로 윈도우 운영체제에서 실행 가능한 파일을 위한 포맷 형식이다. 시그니처는 MZ. (MS-DOS의 개발자인 마크 즈비코프스키의 이름을 땄다.)
파일 종류
- 실행:
.exe, .scr - 드라이버:
.sys, .vxd - 라이브러리:
.dll, .ocx, .cpl, .drv - 오브젝트:
.obj
.scr 확장자는 생소해서 찾아봤더니 스크린 보호기 파일이라고 한다. 실행 가능하게 만들어지고, 생소해서 악성 코드로 많이 사용된다고 한다.
PE 포맷 파일 구조
윈도우는 NT 3.1 이후로 PE 포맷으로 확장적으로 옮겼다고 한다. 그래서 PE 파일은 명시적으로 DOS 환경을 지원한다. 다만, 실제로 빌드된 파일을 DOS 환경에서 실행하면 This program cannot be run in DOS mode 라는 메시지를 보여준다. 단순히 호환성만 유지..
위 사진을 바탕으로, 크게 구조를 헤더와 바디로 나눌 수 있다. 헤더에는 DOS Header와 DOS Stub이 있고, 바디에는 이 헤더에 정의된 섹션들이 있다.
이렇게 구조화된 이유는, 프로세스 동적 링커에게 파일을 어떻게 메모리로 매핑할 지 설명할 수 있게 하기 위해서다.
각 세션별로 OS에게 요구하는 권한이 다르다. 예를 들어, 코드 섹션(대표적으로 .text)은 읽기 권한이 필요하고, 데이터 섹션(대표적으로 .data)은 읽기/쓰기 권한이 필요하다.
세션 정렬
디스크와 메모리에서 요구하는 정렬 방식이 다른데, 이는 목적성이 다르기 때문이다.
메모리에서 섹션은 페이지 단위(일반적으로 4kb)로 정렬되어야 한다. 이러한 이유는 메모리 효율성에서 찾아볼 수 있다. 페이지 섹션의 크기가 작으면 페이지 테이블이 커지고, (여러 페이지의 권한 정보를 모두 리스트업 해야하니) 페이지 섹션의 크기가 크면 낭비되는 공간이 커진다.
즉, 디스크에서 사용하는 FlieAlignment와 SectionAlignment가 다른 이유가 이것이다. 디스크에 있는 그대로 정렬하면 메모리 효율성이 떨어지기 때문에.
반대로도 똑같다. 메모리에 4kb로 저장하던 걸 디스크에 그대로 빼다박으면 낭비되는 공간이 커진다.
DOS Header
앞서 설명한 내용이 도스 헤더에 반영되어 있기에 길게 설명할 건 없고, 구조체로 직접 살펴보자.
#[repr(C)]
struct DOSHeader {
e_magic: u16, // 매직 넘버: 항상 'MZ'(0x5A4D) 값을 가집니다. DOS 실행 파일임을 나타냅니다.
e_cblp: u16, // 파일의 마지막 페이지에 있는 바이트 수
e_cp: u16, // 파일에 있는 페이지 수
e_crlc: u16, // 재배치(Relocation) 수
e_cparhdr: u16, // 단락(paragraph) 단위의 헤더 크기 (1 단락 = 16 바이트)
e_minalloc: u16, // 프로그램을 실행하기 위해 필요한 최소 추가 단락 수
e_maxalloc: u16, // 프로그램을 실행하기 위해 필요한 최대 추가 단락 수
e_ss: u16, // 초기 스택 세그먼트(SS) 레지스터 값
e_sp: u16, // 초기 스택 포인터(SP) 레지스터 값
e_csum: u16, // 체크섬
e_ip: u16, // 초기 명령어 포인터(IP) 레지스터 값
e_cs: u16, // 초기 코드 세그먼트(CS) 레지스터 값
e_lfarlc: u16, // 재배치 테이블의 파일 주소
e_ovno: u16, // 오버레이 번호
e_res: [u16; 4], // 예약된 공간
e_oemid: u16, // OEM 식별자
e_oeminfo: u16, // OEM 정보
e_res2: [u16; 10], // 예약된 공간
e_lfanew: u32, // PE 헤더(NT 헤더)의 파일 오프셋. 가장 중요한 필드입니다.
}
일반적으로 현대 NT에서는 쓸모없는 정보들이다. 과거 호환성만 남겨놓으려고 만들어진 것이기 때문에 훑고 넘어가도 괜찮다. e_lfanew 필드가 중요한데, 이는 PE 헤더(NT 헤더)의 파일 오프셋을 가리킨다.
DOS Stub
현대 NT 헤더 다음에 있는 영역인데, 따로 중요한 부분은 아니다. DOS 환경에서 실행되는 파일이라면 이 영역에 있는 코드가 실행된다.
NT Header
NT 헤더 구조체는 1개의 시그니처 필드와 2개의 하위 구조체로 이루어져 있다. 시그니처 필드는 항상 PE\0\0 값을 가진다.
말로만 해서는 뭐가 뭔지 모르니, 하나씩 톺아보자.
NT Header 구조체
#[repr(C)]
struct NTHeader {
signature: u32, // 시그니처: 항상 'PE\0\0' (0x50450000) 값을 가집니다.
file_header: FileHeader, // 파일의 물리적 레이아웃과 속성에 대한 정보
optional_header: OptionalHeader, // 파일의 논리적 실행에 필요한 정보 (이름과 달리 필수)
}
signature
항상 PE\0\0 값을 가진다.
file_header
파일의 물리적 레이아웃과 속성에 대한 정보 (아래 FileHeader 구조체)
optional_header
파일의 논리적 실행에 필요한 정보 (아래 OptionalHeader 구조체)
FileHeader 구조체
#[repr(C)]
struct FileHeader {
machine: u16, // 대상 CPU 아키텍처 (e.g., 0x8664 for x64, 0x14c for x86), WinNT.h에 정의된 값
number_of_sections: u16, // 섹션 헤더 테이블에 있는 섹션의 수
time_date_stamp: u32, // 파일이 생성된 시간 (Unix timestamp)
pointer_to_symbol_table: u32,// COFF 심볼 테이블의 파일 오프셋 (디버깅용, 보통 0)
number_of_symbols: u32, // COFF 심볼 테이블의 심볼 수
size_of_optional_header: u16,// 바로 뒤에 오는 OptionalHeader의 크기
characteristics: u16, // 파일의 속성을 나타내는 플래그 (e.g., 실행 가능한지, DLL인지, 커널인지 ...)
}
machine
대상 CPU 아키텍처 (e.g., 0x8664 for x64, 0x14c for x86)
상수는 WinNT.h에 정의되어 있다.
number_of_sections
현재 PE파일의 섹션 헤더 테이블에 있는 섹션의 수
time_date_stamp
파일이 생성된 시간 (Unix timestamp)
변조가 가능하다.
size_of_optional_header
OptionalHeader 구조체의 크기
characteristics
파일의 속성을 나타내는 플래그 (e.g., 실행 가능한지, DLL인지, 커널인지 ...)
이것도 동일하게 WinNT.h에 정의되어 있다.
예를 들어보자:
0001: IMAGE_FILE_RELOCS_STRIPPED - 해당 파일에서 재배치 정보가 삭제됨0002: IMAGE_FILE_EXECUTABLE_IMAGE - 해당 파일은 실행가능한 EXE 파일임0004: IMAGE_FILE_LINE_NUMS_STRIPPED - 해당 파일은 라인 넘버가 제거됨0008: IMAGE_FILE_LOCAL_SYMS_STRIPPED - 해당 파일은 로컬 심볼 정보가 제거됨0100: IMAGE_FILE_32BIT_MACHINE - 해당 파일은 32비트 머신을 필요로 함
위 플래그들이 비트 or 연산으로 조합되어 있다.
OptionalHeader 구조체
// 32비트 PE 파일(PE32) 기준의 OptionalHeader
#[repr(C)]
struct OptionalHeader {
// --- Standard COFF Fields ---
magic: u16, // 매직 넘버: 0x10b (32-bit), 0x20b (64-bit)
major_linker_version: u8, // 링커의 메이저 버전
minor_linker_version: u8, // 링커의 마이너 버전
size_of_code: u32, // 모든 코드 섹션(.text)의 총 크기
size_of_initialized_data: u32, // 초기화된 데이터 섹션들의 총 크기
size_of_uninitialized_data: u32, // 초기화되지 않은 데이터 섹션(.bss)들의 총 크기
address_of_entry_point: u32, // 프로그램 실행 시작 주소 (RVA: Relative Virtual Address)
base_of_code: u32, // 첫번째 코드 섹션의 시작 RVA
base_of_data: u32, // 첫번째 데이터 섹션의 시작 RVA (64-bit에서는 이 필드가 없음)
// --- Windows-specific Fields ---
image_base: u32, // 메모리에 로드될 때의 권장 시작 가상 주소
section_alignment: u32, // 메모리에서의 섹션 정렬 단위 (보통 4KB)
file_alignment: u32, // 파일에서의 섹션 정렬 단위 (보통 512 바이트)
major_operating_system_version: u16, // 필요한 운영체제의 메이저 버전
minor_operating_system_version: u16, // 필요한 운영체제의 마이너 버전
major_image_version: u16, // 이미지의 메이저 버전
minor_image_version: u16, // 이미지의 마이너 버전
major_subsystem_version: u16, // 서브시스템의 메이저 버전
minor_subsystem_version: u16, // 서브시스템의 마이너 버전
win32_version_value: u32, // 예약된 필드, 항상 0
size_of_image: u32, // 메모리에 로드된 전체 이미지의 크기
size_of_headers: u32, // 모든 헤더(DOS, PE, Section)를 합친 크기
check_sum: u32, // 이미지 파일 체크섬 (유효성 검사)
subsystem: u16, // 실행에 필요한 서브시스템 (e.g., GUI, Console)
dll_characteristics: u16, // DLL 관련 보안 플래그 (e.g., ASLR, DEP)
size_of_stack_reserve: u32, // 처음에 예약할 스택의 크기
size_of_stack_commit: u32, // 처음에 실제로 할당(커밋)할 스택의 크기
size_of_heap_reserve: u32, // 처음에 예약할 힙의 크기
size_of_heap_commit: u32, // 처음에 실제로 할당(커밋)할 힙의 크기
loader_flags: u32, // 예약된 필드, 사용되지 않음
number_of_rva_and_sizes: u32, // 아래 DataDirectory 배열의 유효한 항목 수 (보통 16)
data_directory: [ImageDataDirectory; 16], // 데이터 디렉터리 배열
}
// DataDirectory 배열의 각 항목을 위한 구조체
#[repr(C)]
struct ImageDataDirectory {
virtual_address: u32, // 데이터의 시작 가상 주소 (RVA)
size: u32, // 해당 데이터의 크기 (바이트)
}
magic
매직 넘버: 0x10b (32비트), 0x20b (64비트)
major_linker_version
링커의 메이저 버전
minor_linker_version
링커의 마이너 버전
size_of_code
모든 코드 섹션(.text)의 총 크기
size_of_initialized_data
초기화된 데이터 섹션들의 총 크기
즉, 코드 섹션을 제외한 데이터 섹션들의 총 크기
size_of_uninitialized_data
초기화되지 않은 데이터 섹션(.bss)들의 총 크기
즉, 초기화되지 않은 데이터 섹션들의 총 크기
address_of_entry_point
프로그램 실행 시작 주소 (RVA: Relative Virtual Address)
실제로 프로그램 실행 시작 주소는 파일 오프셋이 아니라 RVA로 저장된다.
(image_base + address_of_entry_point)
base_of_code
첫번째 코드 섹션의 시작 RVA (64비트에서는 이 필드가 없음)
(image_base + base_of_code)
base_of_data
첫번째 데이터 섹션의 시작 RVA (64비트에서는 이 필드가 없음)
(image_base + base_of_data)
image_base
메모리에 로드될 때의 권장 시작 가상 주소
보통 실행 파일은 0x400000 에서, 라이브러리(dll)은 0x10000000 에서 시작한다. 다만, 라이브러리의 경우 이미 점유된 상태라면 재배치가 된다.
재배치가 발생하면, DLL 내부의 주소 참조들을 실제 로드된 주소에 맞게 수정하는 작업이 추가로 필요하기 때문에 약간의 성능 저하가 발생할 수 있다.
실행 파일(.exe)은 가상 메모리 공간에서 가장 먼저 자리를 잡기 때문에 일반적으로 재배치되지 않는다.
section_alignment
메모리에서의 섹션 정렬 단위 (보통 4kb)
file_alignment
파일에서의 섹션 정렬 단위 (보통 512 바이트)
size_of_image
메모리에 로드된 전체 이미지의 크기
보통 다른 경우가 더 많다.
size_of_headers
모든 헤더(DOS, PE, Section)를 합친 크기
image_base에서 size_of_headers 만큼 떨어진 곳에 첫번째 섹션이 위치한다.
data_directory
데이터 디렉터리 배열
각 항목은 가상 주소와 size를 가지게 된다. 이 개념이 특히 중요한데, 데이터 디렉토리가 없다면, PE파일은 그냥 껍데기일 뿐이기 때문이다.
인덱스 정보가 담긴 헤더 파일을 까보자.
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor
여기서 중요하게 봐야할 디렉토리는 EXPORT, IMPORT, RESOURCE, TLS, LOAD_CONFIG, IAT 이다.
우선 TLS(Thread-Local-Storage)와 LOAD_CONFIG가 중요한데, 이는 런타임에 추가적인 정보를 제공하기 위해 사용된다.
EXPORT 디렉토리는 다른 프로그램에서 사용할 수 있도록 외부에 제공하는 함수(내보내는 함수)들의 목록이다.
보통 DLL에서 많이 사용한다. (ffi)
IMPORT 디렉토리는 프로그램이 실행되기 위해 다른 DLL로부터 가져와야 하는 함수들의 목록이다.
여기 메모리에 접근하면 IMAGE_IMPORT_DESCRIPTOR 배열이 시작되는 곳으로 이동한다.
안에는 가져와야 할 DLL 별로 하나씩 IMAGE_IMPORT_DESCRIPTOR 구조체 배열이 있다.
이 배열들의 마지막엔 NULL Padding이 들어가있다. 또한, 구조체 안에 IAT 내부의 포인터 주소가 있다.
그래서 대충 동작을 살펴보면, 로더가 IMPORT 디렉토리로 가서, 모두 읽고 CreateFileW 같은걸로 로드한다. 그리고 그 주소를 IAT(Import Address Table)에 등록한다.
바로 여기서 로드된 IAT 주소를 IAT 디렉토리에서 볼 수 있다.
또한, 일반적으로 우리가 역공학 시 섹션 자체를 분석하려고 하는데, 이는 잘못됐다. 기본적으로 로더도 데이터 디렉토리를 읽지 않는다.
섹션은 속이려면 얼마든지 속일 수 있다.
섹션 헤더
struct ImageSectionHeader {
name: [u8; 8],
virtual_size: u32,
virtual_address: u32,
size_of_raw_data: u32,
pointer_to_raw_data: u32,
pointer_to_relocations: u32,
pointer_to_line_numbers: u32,
number_of_relocations: u16,
number_of_line_numbers: u16,
characteristics: u32,
}
위 내용에 섹션들이 저장된다. .text.든 .data든 모두 이 구조체에 저장된다.
name
섹션 이름
아래는 일반적인 섹션 리스트다.
.text :
코드, 실행, 읽기 속성을 지니며 컴파일 후의 결과가 이곳에 저장된다. 즉, 이 섹션은 실행되는 코드들이 들어가는 섹션이다.
.data :
초기화, 읽기, 쓰기 속성을 지니며 초기화된 전역 변수를 가진다.
.rdata :
초기화, 읽기 속성을 지니며 문자열 상수나 const로 선언된 변수처럼 읽기만 가능한 읽기 전용 데이터 섹션이다.
.bss :
비초기화, 읽기, 쓰기 속성을 지니며 초기화되지 않은 전역 변수의 섹션이다.
.edata :
초기화, 읽기 속성을 지니며 EAT와 관련된 정보가 들어가 있는 섹션이다.
.idata :
초기화, 읽기, 쓰기 속성을 지니며 IAT와 관련된 정보가 들어가 있는 섹션이다.
.rsrc
초기화, 읽기 속성을 지니며 리소스가 저장되는 섹션이다.
virtual_size
섹션의 가상 크기
virtual_address
섹션의 가상 주소
size_of_raw_data
섹션의 실제 크기
pointer_to_raw_data
섹션의 실제 데이터가 저장된 파일 오프셋
pointer_to_relocations
섹션의 재배치 정보가 저장된 파일 오프셋
pointer_to_line_numbers
섹션의 라인 넘버가 저장된 파일 오프셋
number_of_relocations
섹션의 재배치 정보의 수
number_of_line_numbers
섹션의 라인 넘버의 수
characteristics
섹션의 속성을 나타내는 플래그 (e.g., 실행 가능한지.. 등등)
헤더 파일을 까보자.
// IMAGE_SCN_TYPE_REG 0x00000000 // Reserved.
// IMAGE_SCN_TYPE_DSECT 0x00000001 // Reserved.
// IMAGE_SCN_TYPE_NOLOAD 0x00000002 // Reserved.
// IMAGE_SCN_TYPE_GROUP 0x00000004 // Reserved.
#define IMAGE_SCN_TYPE_NO_PAD 0x00000008 // Reserved.
// IMAGE_SCN_TYPE_COPY 0x00000010 // Reserved.
#define IMAGE_SCN_CNT_CODE 0x00000020 // Section contains code.
#define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 // Section contains initialized data.
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 // Section contains uninitialized data.
#define IMAGE_SCN_LNK_OTHER 0x00000100 // Reserved.
#define IMAGE_SCN_LNK_INFO 0x00000200 // Section contains comments or some other type of information.
// IMAGE_SCN_TYPE_OVER 0x00000400 // Reserved.
#define IMAGE_SCN_LNK_REMOVE 0x00000800 // Section contents will not become part of image.
#define IMAGE_SCN_LNK_COMDAT 0x00001000 // Section contents comdat.
// 0x00002000 // Reserved.
// IMAGE_SCN_MEM_PROTECTED - Obsolete 0x00004000
#define IMAGE_SCN_NO_DEFER_SPEC_EXC 0x00004000 // Reset speculative exceptions handling bits in the TLB entries for this section.
#define IMAGE_SCN_GPREL 0x00008000 // Section content can be accessed relative to GP
#define IMAGE_SCN_MEM_FARDATA 0x00008000
// IMAGE_SCN_MEM_SYSHEAP - Obsolete 0x00010000
#define IMAGE_SCN_MEM_PURGEABLE 0x00020000
#define IMAGE_SCN_MEM_16BIT 0x00020000
#define IMAGE_SCN_MEM_LOCKED 0x00040000
#define IMAGE_SCN_MEM_PRELOAD 0x00080000
#define IMAGE_SCN_ALIGN_1BYTES 0x00100000 //
#define IMAGE_SCN_ALIGN_2BYTES 0x00200000 //
#define IMAGE_SCN_ALIGN_4BYTES 0x00300000 //
#define IMAGE_SCN_ALIGN_8BYTES 0x00400000 //
#define IMAGE_SCN_ALIGN_16BYTES 0x00500000 // Default alignment if no others are specified.
#define IMAGE_SCN_ALIGN_32BYTES 0x00600000 //
#define IMAGE_SCN_ALIGN_64BYTES 0x00700000 //
#define IMAGE_SCN_ALIGN_128BYTES 0x00800000 //
#define IMAGE_SCN_ALIGN_256BYTES 0x00900000 //
#define IMAGE_SCN_ALIGN_512BYTES 0x00A00000 //
#define IMAGE_SCN_ALIGN_1024BYTES 0x00B00000 //
#define IMAGE_SCN_ALIGN_2048BYTES 0x00C00000 //
#define IMAGE_SCN_ALIGN_4096BYTES 0x00D00000 //
#define IMAGE_SCN_ALIGN_8192BYTES 0x00E00000 //
// Unused 0x00F00000
#define IMAGE_SCN_ALIGN_MASK 0x00F00000
#define IMAGE_SCN_LNK_NRELOC_OVFL 0x01000000 // Section contains extended relocations.
#define IMAGE_SCN_MEM_DISCARDABLE 0x02000000 // Section can be discarded.
#define IMAGE_SCN_MEM_NOT_CACHED 0x04000000 // Section is not cachable.
#define IMAGE_SCN_MEM_NOT_PAGED 0x08000000 // Section is not pageable.
#define IMAGE_SCN_MEM_SHARED 0x10000000 // Section is shareable.
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // Section is executable.
#define IMAGE_SCN_MEM_READ 0x40000000 // Section is readable.
#define IMAGE_SCN_MEM_WRITE 0x80000000 // Section is writeable.
여기에 권한 정보들이 있다. 그러니까, 섹션 이름이 뭐든 간에 이 플래그들로 대충 무슨 역할을 하는지 분석할 수 있다는 의미다.
이것도 동일하게 비트 or 연산자로 권한 조합이 가능하다.