0%

PE 구조 이해하기 - IMAGE_OPTIONAL_HEADER 구조

windows 시스템 실행파일의 구조와 원리 책 내용을 요약 및 정리 하는 포스팅이에요.

PE파일의 PE 시그니처 다음에 이어지는 IMAGE_OPTIONAL_HEADER에 대한 내용을 알아볼 거에요.


IMAGE_OPTIONAL_HEADER의 시작

IMAGE_FILE_HEADER에 이어서 나오는 구조체는 224바이트로 구성된 IMAGE_OPTIONAL_HEADER 이에요.

IMAGE_OPTIONAL_HEADER 구조체는 96바이트를 차지하는 30개의 기본 필드8바이트 크기의 IMAGE_DATA_DIRECTORY 구조체에 대한 엔트리 개수가 16개인 배열 128(=8*16)바이트로 구성되어 있어요.

-> IMAGE_OPTIONAL_HEADER32 구조체를 가지고 설명.

-> 64비트 같은 경우에는 BaseOfData 부분이 없으며, 메모리 영역 부분이 DWORD 형이 아닌 ULONGLONG 형으로 선언이 되어있고, 데이터 디렉토리 구조체의 크기가 고정되어 있지 않음.

구조체는 다음과 같이 “WinNT.h” 헤더 파일에 정의되어 있음.

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
typedef struct _IMAGE_OPTIONAL_HEADER {

/*
Standard fields.
*/

WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;

/*
NT additional fields.
*/

DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

첫 번째 멤버, WORD Magic

IMAGE_OPTIONAL_HEADER를 나타내는 시그니처에요. 이 값은 32비트 PE의 경우 0x010B이고, 64비트 PE의 경우 0x020B에요.
닷넴 프레임워크 .NET PE의 경우 항상 0x010B 라고 해요.

1
2
#define IMAGE_NT_OPTIONAL_HDR32 0x10B
#define IMAGE_NT_OPTIONAL_HDR64_MAGIC 0x20B

두 번째 멤버, BYTE MajorLinkerVersion

세 번째 멤버, BYTE MinorLinkerVersion

PE 파일을 만들어낸 링커의 버전을 나타내요.
예를 드러 메이저 0x09, 마이너 0x0A라면 링커 버전 9.10을 나타내는 거죠.

“Visual Studio.NET Command Prompt”를 실행시켜 Link.exe를 실행시키면 링커의 버전을 확인할 수 있다고 해요.


네 번째 멤버, DWORD SizeOfCode

모든 코드 섹션들의 사이즈를 합한 라운드업된 크기에요.
일반적으로 실행 파일은 하나의 코드 섹션을 가지기 때문에 이 필드는 .text 섹션의 바이트 수와 동일해요.

정확하게 말하면, 섹션 중 “IMAGE_SCN_CNT_CODE” 속성을 가진 섹션들의 전체 크기라고 해요.
이러한 속성들은 IMAGE_SECTION_HEADER의 Characteristics에서 찾아볼 수 있어요.


다섯 번째 멤버, DWORD SizeOfInitializeData

코드 섹션을 제외한 초기화된 데이터 섹션의 전체 크기를 나타내요.
초기화된 데이터 섹션은 “IMAGE_SCN_CNT_INITALIZED_DATA” 속성을 가져요.


여섯 번째 멤버, DWORD SizeOfUninitializedData

초기화되지 않은 데이터 섹션(일반적으로 .bss 섹션 또는 .textbss 섹션)의 바이트수를 나타내요.

초기화되지 않은 데이터 섹션은 “IMAGE_SCN_CNT_UNINITALIZED_DATA” 속성을 가져요.

일반적으로 초기화되지 않은 데이터를 일반 데이터 섹션에 병합시킬 수 있기 때문에 보통 이 필드의 값은 0이 된다.


일곱 번째 멤버, DWORD AddressOfEntryPoint

로더가 실행을 시작할 주소를 나타내요. RVA 형태로 값이 저장되고, 일반적으로 .text 섹션 내의 특정 번지가 되요.
즉, 이 필드의 값은 프로그램이 처음으로 실행될 코드를 담고 있는 주소인 거죠.

좀 더 자세히보면, 프로그램이 로드된 후 이 프로세스의 메인 스레드 문맥(Context)의 EIP/RIP 레지스터가 가질 수 있는 최초의 값이에요.
일반적으로 이 번지가 가리키는 값은 소위 말하는 런타임 시작 루틴(EXE의 경우 WinMainCRTStartup 또는 mainCRTStartup, DLL의 경우 DllMainCRTStartup)의 번지 값이에요.

AddressOfEntryPoint 멤버는 중요한 필드죠!


여덟 번째 멤버, DWORD BaseOfCode

첫 번째 코드 섹션이 시작 되는 RVA값을 가지고 있어요.
코드 섹션은 전형적으로 PE 헤더 다음, 데이터 섹션 바로 직전에 있어요.

MS 링커가 만들어내는 EXE의 RVA는 보통 0x1000인데, 이 값은 설정에 따라 바뀔 수 있어요.


아홉 번째 멤버, DWORD BaseOfData

이론적으로 메모리에 로드될 때 데이터의 첫 번째 바이트의 RVA를 가리켜요.
그리고 이 필드의 값은 MS의 링커 버전에 따라 다르고, 64비트 PE에서는 없는 멤버에요.


열 번째 멤버, ImageBase

해당 PE가 가상 주소 공간에 매핑될 때, 매핑시키고자 하는 메모리 상의 시작 주소에요.

그 주소가 사용되고 있지 않으면 로더는 이 멤버가 가리키는 값으로 매핑시키기를 원해요.

만약에 PE 파일이 ImageBase가 가리키는 주소에 로드되면 로더는 기본 재배치를 수행하는 과정을 건너뛸 수 있어요.

EXE에 대해서 이 값은 기본 이미지 베이스의 값은 0x00400000 이고, DLL의 경우 이 값은 0x10000000 이에요. 이 값들은 링크 시 옵션 /BASE를 지정함으로써 변결될 수 있어요.

추가적으로…

일반적으로 PE 파일이 가상 메모리 공간에 매핑될 때, DLL 파일보다 EXE 파일이 먼저 올라가게 되요. 그래서 EXE 파일이 메모리에 올라갈 때 재배치 과정을 거치지 않아요. 하지만 DLL의 경우에는 그 해당하는 위치가 다른 녀석들이 이미 올라가 있는 경우가 많아서 재배치 과정을 거쳐요.

요즘에는 윈도우의 보호기법 중 하나인 ASLR(Address space layout randomization) 때문에 ImageBase에 있는 주소에 PE 파일들이 매핑되지 않고, 랜덤으로 가상 메모리 공간에 매핑이 되요!


열한 번째 멤버, SectionAlignment

PE 파일이 메모리에 매핑될 때 각 섹션의 시작 주소는 언제나 이 SectionAlignment 필드에서 지정된 값의 배수가 되는 가상 주소가 되도록 보장되요.

이러한 제한이 바로 PE 파일 자체가 메모리 맵 파일임을 말하고 있는 거에요.

일반적인 파일들은 시스템 페이징 파일을 통해서 메모리에 매핑이 되지만, PE 파일 경우에 PE 파일 자체가 페이징 파일이 되기 떄문에 섹션의 시작 주소는 항상 메모리 페이지의 배수이어야 되요.

PE 파일 자체가 메모리 맵 파일이라고 하더라도 메모리에서 PE 파일에 대한 변조가 일어나더라도 실제 파일에는 영향을 끼치지 않아요.

메모리 속성 중 “실행 가능“ 속성을 가지게 되면 이 페이지에 대한 “쓰기“ 동작에 대한 파일로의 반영은 “COW“ 라는 매커니즘을 통해서 페이지에 데이터를 갱신하게 되면 매핑된 파일로의 반영이 아니라 해당 데이터를 그 페이지에 갱신하기 직전에 페이지 파일(PageFile.sys)로 복사되어서 백업된 뒤 해당 페이지가 갱신되요.

따라서 메모리에 매핑된 PE는 “실행 가능”속성을 지니기 때문에 디스크 상의 해당 PE 자체로의 즉각적인 반영이 이루어지지 않는거죠.


열두 번째 멤버, FileAlignment

PE 파일 내에서 섹션들의 정렬 단위를 나타내요.
하드 디스크에 저장된 PE 파일 내의 각각의 섹션을 구성하는 바이너리 데이터들은 FileAlignemnt 필드의 값의 배수로 시작하도록 보장해요.

이 값이 실질적으로 디스크의 섹터 단위가 되는거죠.
NTFS의 경우 한 섹터는 디폴트로 4K이고, 보통 0x200 아니면 0x100의 값을 가지고 있어요.
MS 링커 버전에 따라 기본 값은 변경이 되고, 무조건 이 값은 2의 멱승이 되어야 해요.

만약 SectionAlignment 필드의 값이 CPU 페이지 사이즈보다 작을 경우 이 필드는 SectionAlignment 필드의 값과 같아야 해요.


열세 번째 멤버, MajorOperatingSystemVersion

열네 번째 멤버, MinorOperatingSystemVersion

PE 파일을 실행하는 데 필요한 운영체제의 최소 버전을 말해요.
Windows 10 기반에서 컴파일과 링킹 과정을 거쳤다고 하더라도, 특정 버전 이상에서만 지원되는 기능 같은 것들을 사용하지 않으면 그 이전 버전에서도 돌아갈 수 있기 때문에 운영체제의 최소 버전 값이 정해져요.


열다섯 번째 멤버, MajorImageVersion

열여섯 번째 멤버, MinorImageVersion

유저가 정의 가능한 필드이며, 사용자가 만드는 EXE나 DLL에 유저 나름대로의 버전을 넣을 수 있어요.
링킹 시에 /VERSION 옵션을 사용해서 링킹을 해주면 되요.


열일곱 번째 멤버, MajorSubsystemVersion

열여덟 번째 멤버, MinorSubsystemVersion

PE 파일을 실행하는 데 필요한 서브시스템의 최소 버전을 말해요.
링킹 시에 /SUBSYSTEM 스위치로 변경할 수 있어요.


열아홉 번째 멤버, Win32VersionValue

이 필드는 VC++ 6.0 SDK 까지는 예약 필드였는데, 7.0에 와서 Win32VersionValue 라는 이름을 가진 필드로 바뀌었고, 보통 0으로 결정되요.


스물 번째 멤버, DWORD SizeOfImage

이 멤버는 로더가 해당 PE 파일을 메모리 상에 로드할 때 확보/예약해야 할 해당 PE를 위한 충분한 크기 값을 가져요.
PE 파일의 크기와 같을 수도 있지만 PE 파일 상에서의 섹션의 배치가 메모리에 매핑되면서 달라질 수 있기 때문에 보통은 PE 파일의 크기보다 커요.

이 필드의 값은 반드시 SectionAlignment 필드의 값의 배수가 되어야 해요.


스물 첫 번째 멤버, DWORD SizeOfHeaders

이 필드는 MS-DOS 헤더, PE 헤더, 섹션 테이블들의 크기를 모두 합친 바이트의 수에요.
모든 헤더나 테이블들은 PE 파일 상에서 반드시 코드 또는 데이터 섹션의 앞쪽에 위치에 있어야 해요.

이 필드의 값은 FilaAlignment 필드 값의 배수가 되어야 해요.


스물 두 번째 멤버, DWORD CheckSum

이름 그대로 이미지의 체크섬 값이다. PE 파일의 체크섬 값은 IMAGEHELP.DLL의 CheckSumMAppedFile API를 통해서 얻을 수 있으며, 체크섬 값은 커널 모드 드라이버나 시스템 DLL의 경우 요구 된다.
그 외의 경우라면 보통 0으로 설정이 된다.
/RELEASE 링커 스위치를 통해 이 필드를 설정을 할 수 있다.


스물 세 번째 멤버, WORD Subsystem

Win32 아키텍처는 크게 유저 모드와 커널 모드로 나눌 수 있으며, 유저 모드에는 서브시스템이라는 컴포넌트가 존재해요.

Win32에서 지원하는 기본 서브 시스템은 Win32 서브시스템, 이전 OS/2와의 호환을 위한 OS/2 서브시스템, 이전 UNIX와의 호환을 위해 최소 표준으로 지원되는 POSIX/CUI 서브시스템 이렇게 3가지 정도가 있다고 해요.

어떤 서브시스템을 지원하는지 알고 싶으면, “WinNT.h” 헤더 파일에 정의되어 있으니, 그것을 참고하면 되요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define IMAGE_SUBSYSTEM_UNKNOWN              0   // Unknown subsystem.
#define IMAGE_SUBSYSTEM_NATIVE 1 // Image doesn't require a subsystem.
#define IMAGE_SUBSYSTEM_WINDOWS_GUI 2 // Image runs in the Windows GUI subsystem.
#define IMAGE_SUBSYSTEM_WINDOWS_CUI 3 // Image runs in the Windows character subsystem.
#define IMAGE_SUBSYSTEM_OS2_CUI 5 // image runs in the OS/2 character subsystem.
#define IMAGE_SUBSYSTEM_POSIX_CUI 7 // image runs in the Posix character subsystem.
#define IMAGE_SUBSYSTEM_NATIVE_WINDOWS 8 // image is a native Win9x driver.
#define IMAGE_SUBSYSTEM_WINDOWS_CE_GUI 9 // Image runs in the Windows CE subsystem.

#define IMAGE_SUBSYSTEM_EFI_APPLICATION 10 //
#define IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER 11 //
#define IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER 12 //
#define IMAGE_SUBSYSTEM_EFI_ROM 13
#define IMAGE_SUBSYSTEM_XBOX 14
#define IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION 16
#define IMAGE_SUBSYSTEM_XBOX_CODE_CATALOG 17

대부분의 경우 IMAGE_SUBSYSTEM_WINDOS_GUI 또는 IMAGE_SUBSYSTEM_WINDOWS_CUI 이에요.

WinMain()으로 시작하는 경우 IMAGE_SUBSYSTEM_WINDOWS_GUI 인 0x0002가 될 것이고, main()으로 시작하는 콘솔 응용 애플리케이션의 경우 IMAGE_SUBSYSTEM_WINDOWS_CUI인 0x0003이 될거에요.

그 이외의 다바이스 드라이버 같이 별도로 서브시스템을 사용하지 않는 경우 IMAGE_SUBSYSTEM_NATIVE인 0x00001의 값을 가지게 되요.


스물 네 번째 멤버, WORD DllCharacteristics

이 멤버는 PE가 DLL 이라는 전제 하에 어떤 상황에서 DLL 초기화 함수(DLLMain())을 호출 되어야 하는지를 지시하는 플래그 값이 저장되어 있어요.

“WinNT.h” 헤더 파일에 이 필드에 집어 넣을 수 있는 매크로 정의가 되어 있어요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// DllCharacteristics Entries

// IMAGE_LIBRARY_PROCESS_INIT 0x0001 // Reserved.
// IMAGE_LIBRARY_PROCESS_TERM 0x0002 // Reserved.
// IMAGE_LIBRARY_THREAD_INIT 0x0004 // Reserved.
// IMAGE_LIBRARY_THREAD_TERM 0x0008 // Reserved.


#define IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA 0x0020 // Image can handle a high entropy 64-bit virtual address space.
#define IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE 0x0040 // DLL can move.
#define IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY 0x0080 // Code Integrity Image

#define IMAGE_DLLCHARACTERISTICS_NX_COMPAT 0x0100 // Image is NX compatible
#define IMAGE_DLLCHARACTERISTICS_NO_ISOLATION 0x0200 // Image understands isolation and doesn't want it
#define IMAGE_DLLCHARACTERISTICS_NO_SEH 0x0400 // Image does not use SEH. No SE handler may reside in this image
#define IMAGE_DLLCHARACTERISTICS_NO_BIND 0x0800 // Do not bind this image.

#define IMAGE_DLLCHARACTERISTICS_APPCONTAINER 0x1000 // Image should execute in an AppContainer
#define IMAGE_DLLCHARACTERISTICS_WDM_DRIVER 0x2000 // Driver uses WDM model
#define IMAGE_DLLCHARACTERISTICS_GUARD_CF 0x4000 // Image supports Control Flow Guard.
#define IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE 0x8000

이 중 DllMain() 정의시 아래의 대응 관계처럼 이 진입점 함수의 파라미터로 넘어오는 fdwReason의 가능한 네가지 값과 동일한 위상이라고 볼 수 있어요.

  • IMAGE_LIBRARY_PROCESS_INIT < - > DLL_PROCESS_ATTACH
  • IMAGE_LIBRARY_PROCESS_TERM < - > DLL_PROCESS_DEATCH
  • IMAGE_LIBRARY_THREAD_INIT < - > DLL_THREAD_ATTACH
  • IMAGE_LIBRARY_THREAD_TERM < - > DLL_PROCESS_DETACH

하지만 이 필드의 값은 EXE의 경우에도 0이며, DLL의 경우에도 0이에요.
MS는 위의 예약어에 해당하는 네 가지 플래그를 더이상 사용하지 않고 IMAGE_DLLCHARACTERISTIC_XXX 라는 플래그들을 추가했어요.

  • IMAGE_DLLCHARACTERISTICS_NO_SEH ( 0x0400)

    구조적 예외 핸들링(SEH, Structured Exception Handling)을 사용하지 않는다. 구조적 예외 핸들링은 Win32 시스템에서 제공하는 기능(C++ 에서의 try{…}catch(…){…{ 예외 제어 구문과 비슷한 기능)이다.

    Visual C++ 컴파일러의 경우 이 SEH를 지원하기 위해 try{…}except(…){…} 라는 키워드를 제공한다. 이 플래그의 경우 VC++ 6.0에서는 지원하지 않음.

  • IMAGE_DLLCHARACTERISTICS_NO_BIND ( 0x0800)

    이 플래그는 이 이미지를 바인딩하지 않음을 의미함. VC++ 6.0 에서는 없는 정의

  • IMAGE_DLLCHARACTERISTICS_WDM_DRIVER ( 0x2000)

    드라이버가 WDM 모델을 사용한다는 의미. WDM은 Windows Driver Model의 약자로서 모든 Microsoft Windows 운영체제에 걸쳐서 호환 가능한 소스코드로 구성되는 디바이스 드라이버를 작성할 수 있도록 소개된 모델임.

    WDM 룰을 따르는 커널 모드 드라이버를 WDM 드라이버라고 부름. DDK의 서브셋의 일종임.

  • IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE ( 0x8000)

    이 플래그는 터미널 서버가 터미널 서비스가 인식하지 못하는 애플리케이션을 로드시켰을 경우 실행 가능하도록 하기 위해 호환 가능한 코드를 담고 있는 DLL도 같이 로드시킬 수 있음을 의미함.


스물 다섯 번째 멤버, DWORD SizeOfStackReserver

스물 여섯 번째 멤버, DWORD SizeOfStackCommit

스물 일곱 번째 멤버, DWORD SizeOfHeapReserve

스물 여덟 번째 멤버, DWORD SizeOfHeapCommit

프로세스는 가상 주소 공간에 자신만의 스택과 힙을 별도로 가져요.
프로세스 생성 시 시스템은 언제나 메인 스레드를 위한 디폴트 스택과 프로세스를 위한 디폴트 힙을 해당 프로세스 내에 생성 시켜 주는데, 이 스택과 힘의 크기와 속성에 관계된 설정을 이 필드에서 지정해요.

PE 파일의 경우 로드되면서 하나의 프로세스가 되든지, DLL 이라면 특정 프로세스의 주소 공간 내부에 잠입해 들어가게 되는데요.
이때 스택과 힙의 예약 크기와 커밋 크기를 지정해줄 수 있어요.

PE 파일이 메모리에 로드될 때 시스템은 이 필드들의 값을 참조하여 해당 프로세스에 디폴트 스택과 디폴트 힙을 만들어 줘요.

링커가 만들어낸 디폴트 값은 스택과 힙 모두 예약 크기 1M인 0x00100000 이며 커밋 크기는 1페이지에 해당하는 4K인 0x00001000이에요.


스물 아홉 번째 멤버, DWORD LoaderFlags

이 필드는 0으로 설정이되요. 원래 목적은 디버깅 지원에 관계 되었다고 해요.


서른 번째 멤버, DWORD NumberOfRvaAndSizes

이 필드는 바로 다음에 나올 주요 섹션들과 정보들의 위치와 크기를 나타내는 IMAGE_DATA_DIRECTORY 구조체 배열의 원소 개수를 의미해요.
책 에서는 이 구조체의 개수는 항상 16라서, 이 멤버의 값은 언제나 0x00000010 이라고 해요.

하지만 공식 문서에서 항상 그렇지 않다고 해요. 따라서 IMAGE_DATA_DIRECTORY 구조체 배열의 개수를 알기 위해서는 이 멤버의 값을 확인 해야 되요.


마지막으로…

IMAGE_OPTIONAL_HEADER에서 기억하고 가야할 멤버들은 AddressOfEntryPoint, ImageBase, SectionAlignment, FileAlignment, SizeOfImage, NumberOfRvaAndSizes 라고 생각해요.

AddressOfEntryPoint는 OP의 주소 값이 들어가고,
ImageBase는 가상 주소 공간에 매핑될 때, 매핑시키고자 하는 메모리 상의 주소 값이고,
SectionAlignment는 메모리에 매핑될 때, 섹션의 크기를 확인할 때 사용하는 녀석이고,
FileAlignemt는 파일 내에서, 섹션의 크기를 확인할 때 사용하는 녀석이고,
SizeOfImage는 PE 파일을 메모리 상에 로드할 때 확보해야할 크기 값을 가지고 있는 녀석이고,
NumberOfRvaAndSizes는 PE 파일 안에 있는 섹션들의 위치와 크기를 나타내는 녀석이므로 기억해야 한다고 생각해요!






참고자료


IMAGE_OPTIONAL_HEADER