13.1 소개
PE(Portable Executable) 파일은 Windows 운영체제에서 사용되는 실행 파일 형식이다. PE 파일은 애초에 다른 운영체제에 이식성을 좋게 하려는 의도로 만들어졌지만, 실제로는 Windows 계열의 운영체제에서만 사용되고 있다..
PE 파일은 32비트 형태의 파일을 의미하며, 64비트 형태의 파일은 PE32+(혹은 PE+) 라고 부르며 PE 파일의 확장 형태이다.
13.2 PE File Format
종류 | 주요 확장자 | 종류 | 주요 확장자 |
실행 계열 | EXE, SCR | 드라이버 계열 | SYS, VXD |
라이브러리 계열 | DLL, OCX, CPL, DRV | 오브젝트 파일 계열 | OBJ |
엄밀히 얘기하면 오브젝트 파일을 제외한 모든 것은 실행 가능한 파일이다. (DLL, SYS 파일 등은 셸에서 직접 실행할 수 없지만, 디버거와 같이 다른 형태의 방법을 통해 실행이 가능한 파일이다.)
+) 드라이버 : 운영체제와 디바이스가 서로 통신할 수 있도록 하는 소프트웨어 구성요소
+) 셸(shell, 자령해석프로그램) : 운영 체제 상에서 다양한 운영 체제 기능과 서비스를 구현하는 인터페이스를 제공하는 프로그램이다. Shell은 사용자와 운영 체제의 내부(커널) 사이의 인터페이스를 감싸는 층이기 때문에 그러한 이름이 붙었다.
노트패트 파일을 헥스 에디터 HxD를 이용해 열어본 화면이다. 해당 화면은 notepad.exe 파일의 시작 부분이며, PE 파일의 헤더 부분이다. 바로 이 부분(PE 헤더)에 notepad.exe 파일이 실행되기 위해 필요한 모든 정보가 적혀있다. 어떻게 메모리에 적재되고, 어디서부터 실행되어야 하며, 실행에 필요한 DLL들은 어떤 것이 있고, 필요한 stack/heap 메모리의 크기를 얼마로 할지 등의 수많은 정보가 PE 헤더에 구조체 형식으로 저장되어 있다. 즉 PE File Format을 공부하는 것은 PE 헤더 구조체를 공부한다는 것과 같은 말이다.
1. 기본 구조
notepad.exe는 일반적인 PE 파일의 기본 구조이다. notepad.exe을 통해 PE 파일의 기본 구조에 대해 알아본다.
PE 파일 = PE 헤더(DOS Header ~ Section Header) + PE 바디(Section Header 밑의 Section들)
파일에서는 Offset으로, 메모리에서는 VA(Virtual Address, 절대주소)로 위치를 표현한다. 파일과 메모리에서 위치를 표현하는 방법이 다른 이유는 파일이 메모리에 로딩되면 모양이 달라지기 때문이다.
PE 바디에 해당하는 Section은 개발도구와 빌드 옵션에 따라서 섹션의 이름, 크기, 개수, 저장 내용 등이 달라진다. 중요한 점은 각 용도별로 여러 섹션이 나뉘어서 저장된다는 것이다.
PE 파일의 구조를 그림으로 나타낸 것이다. 여기에서 각 섹션이 끝날 때마다 NULL 영역이 존재하는 것을 볼 수 있다. 이는 NULL Padding이라고 불리는 영역이다. 파일과 메모리에서 섹션의 시작 위치는 각 파일(메모리)의 최소 기본 단위의 배수에 해당하는 위치여야 하고, 빈 공간은 NULL로 채워버려 위와 같은 그림이 만들어진다. (잘 보면 섹션의 시작 위치가 모두 000으로 떨어지는 것을 볼 수 있다.)
2. VA & RVA
VA : 프로세스 가상 메모리의 절대주소를 말한다
RVA : 어느 기준 위치(ImageBase)에서부터의 상대주소를 말한다.
RVA + ImageBase = VA
PE 파일이 로딩될 때 해당 위치에 다른 PE 파일이 이미 로딩되어 있는 경우가 있을 수 있다. 그럴 때 재배치 과정을 통해 비어 있는 다른 위치에 로딩된다. 만약 PE 헤더 정보들이 절대주소(VA)로 되어있다면 정상적인 엑세스가 이루어지지 않는다. 따라서 RVA를 사용해 정상적인 엑세스가 이루어지도록 한다. RVA는 재배치 과정을 거쳐도 기준 위치에 대한 상대주소가 변하지 않기 때문에 정상적인 엑세스가 가능하다.
13.3 PE 헤더
PE 헤더는 DOS Header, DOS Stub, NT Header와 Section Header(Section의 수만큼 존재한다.)로 구성된다. 그리고 이들은 많은 구조체로 이루어져 있다.
1. DOS Header
DOS 헤더는 IMAGE_DOS_HEADER 구조체로 이루어져 있다. 해당 구조체의 크기는 40이다.
IMAGE_DOS_HEADER 구조체에서 꼭 알아둬야 할 중요한 멤버는 e_magic과 e_lfanew이다.
e_magic : DOS signature(4D5A => ASCII 값 "MZ")
e_lfanew : NT header의 옵셋을 표시한다. 이 값이 가리키는 위치에 NT Header 구조체가 존재해야 한다
(파일에 따라가변적을 값을 가짐)
2. DOS Stub
DOS Stub의 존재 여부는 옵션이며 크기도 일정하지 않다. (존재 여부가 옵션이기 때문에 없어도 파일 실행에는 문제가 없다!) 그리고 코드와 데이터의 혼합으로 이루어져 있다. 위 사진의 영역은 16비트 어셈블리 명령어이다. 32bit Windows OS에서는 이쪽 명령어가 실행되지 않는다.(PE 파일로 인식하기 때문에 아예 무시한다.) 만약 Notepad.exe 파일을 DOS 환경에서 실행하거나, DOS용 디버거를 이용해서 실행하면 해당 코드를 실행시킬 수 있다.
3. NT Header
NT header의 구조체는 IMAGE_NT_HEADERS이다. 구조체의 형태는 아래와 같다.
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
//출처 : https://learn.microsoft.com/ko-kr/windows/win32/api/winnt/ns-winnt-image_nt_headers32
해당 구조체는 3개의 멤버로 되어 있고 크기는 F8이다. 각 멤버들에 대해 알아보자면 다음과 같다.
1. Signature : 50450000h("PE"00) 값을 가진다.
2. FileHeader : 구조체
3. Optional Header : 구조체
4. NT Header - File Header
File Header는 IMAGE_FILE_HEADER 구조체 형식이고 해당 구조체는 파일의 개략적인 속성을 나타낸다.
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
//출처 : https://learn.microsoft.com/ko-kr/windows/win32/api/winnt/ns-winnt-image_file_header
위 구조체에서 Machine, NumberOfSections, SizeOfOptionalHeader, Characteristics 멤버가 중요하다. 이 값들이 정확히 세팅되어 있지 않으면 파일은 정상적으로 실행되지 않기 때문이다. 각 멤버에 대해 알아보겠다.
#1. Machine
Machine 넘버는 CPU 별로 고유한 값이며 32bit Intel x86 호환 칩은 위 사진에서도 볼 수 있듯이 14C 값을 가진다.
#2. NumberOfSections
PE 파일은 코드, 데이터, 리소스 등이 각각의 섹션에 나뉘어서 저장된다. NumberOfSections는 그 섹션의 개수를 나타낸다. 그런데 이 값은 반드시 0보다 커야한다. 그리고 여기 정의된 섹션의 개수와 실제 섹션이 다르면 실행 에러가 발생한다.
#3. SizeOfOptionalHeader
IMAGE_NT_HEADER 구조체의 마지막 멤버는 IMAGE_OPTIONAL_HEADER32 구조체이다. SizeOfOptionalHeader 값은 바로 이 IMAGE_OPTIONAL_HEADER32 구조체의 크기를 나타낸다. IMAGE_OPTIONAL_HEADER32 구조체가 C언어의 구조체이기 때문에 이미 크기가 결정되어 있음에도 Windows의 PE 로더는 IMAGE_FILE_HEADER 구조체의 SizeOfOptionalHeader 값을 통해 구조체의 크기를 인식한다. 이는 PE32+ 형태의 파일인 경우엔 IMAGE_OPTIONAL_HEADER64 구조체를 사용해 그 크기가 달라지기 때문에 SizeOfOptionalHeader에 그 크기를 명시한다.
(이런 점을 이용해서 상식적인 PE 파일 형식을 벗어나는 PE 파일을 만들 수 있다 => UPack에서 볼 수 있다.)
#4. Characteristics
파일의 속성을 나타내는 값으로, 실행이 가능한 형태인지 혹은 DLL 파일인지 등의 정보등이 bit OR 형식으로 조합된다. (아래 보이는 값들을 bit OR 형식으로 조합해 최종 Characteristics 값이 완성되는 것이다.)
위 값들 중 빨간 박스에 표시된 값들은 알아두면 좋다!
PE 파일 중에 0x0002값이 없는 경우 (not executable)가 존재한다. 초반 설명에 나왔던 object 파일 및 resource DLL 같은 파일을 예로 들 수 있다.
+) TimeDataStamp : 파일의 실행에 영향을 미치지 않는 값으로, 해당 파일의 빌드 시간을 나타낸 값이다.
5. NT Header - Optional Header
PE 헤더 구조체 중에서 가장 크기가 큰 IMAGE_OPTIONAL_HEADER32이다.
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
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;
//출처:https://learn.microsoft.com/ko-kr/windows/win32/api/winnt/ns-winnt-image_optional_header32
IMAGE_OPTIONAL_HEADER32 구조체에서 파일 실행에 필수적이라서 잘못 세팅되면 파일이 정상 실행되지 않는 값은 다음과 같다.
#1. Magic
Magic 넘버는 IMAGE_OPTIONAL_HEADER32 구조체인 경우 10B, IMAGE_OPTIONAL_HEADER64 구조체인 경우 20B 값을 가진다.
#2. AddressOfEntryPoint
해당 값은 EP(Entry Point)의 RVA(Relative Virtual Address) 값을 가지고 있다. 그리고 이 값이 프로그램에서 최초로 실행되는 코드의 시작 주소로 매우 중요한 값이다.
#3. ImageBase
32비트의 경우 프로세스의 가상 메모리는 0 ~ FFFFFFFF이고 ImageBase는 이러한 메모리에서 PE 파일이 로딩되는 시작 주소를 나타낸다. 아래는 파일별 시작 주소이다.
EXE, DLL | user memory | 0 ~ 7FFFFFFF |
SYS | kernel memory | 80000000 ~ FFFFFFFF |
PE 로더는 PE 파일을 실행시키기 위해 프로세스를 생성하고 파일을 메모리에 로딩한 후 EIP 레지스터 값을 ImageBase + AddressOfEntryPoint 값으로 세팅한다.
#4. SectionAlignment, FileAlignment
FileAlignment : 파일에서 섹션의 최소단위를 나타내는 것
SectionAlignment : 메모리에서 섹션의 최소단위를 나타내는 것
이 둘의 값은 같을 수도 있고 다를 수도 있다. 파일/메모리의 섹션 크기는 반드시 각 FileAlignment/SectionAlignment의 배수가 되어야 한다.
#5. SizeOfImage
PE 파일이 메모리에 로딩되었을 때 가상 메모리에서 PE Image가 차지하는 크기를 나타낸다. 일반적으로 파일의 크기와 메모리에 로딩된 크기는 다르기 때문에 해당 값이 존재한다.
#6. SizeOfHeader
PE 헤더의 전체 크기를 나타낸다. FileAlignment의 배수여야 한다. 파일 시작에서 SizeOfHeader 옵셋만큼 떨어진 위치에 첫 번째 섹션이 위치하게 된다.
#7. Subsystem
해당 파일이 어떤 종류의 파일인지 구분한다. (실제 값은 16까지 존재한다!)
값 | 의미 | 비고 |
1 | Driver file | 시스템 드라이버 (ex. ntfs, sys) |
2 | GUI 파일 | 창 기반 애플리케이션(ex. notepad.exe) |
3 | CUI | 콘솔 기반 애플리케이션(ex. cmd.exe) |
#8. NumberOfRvaAndSizes
IMAGE_OPTIONAL_HEADER32 구조체의 마지막 멤버인 DataDirectory 배열의 개수를 나타낸다. 구조체 정의에 해당 배열의 개수가 명시되어 있지만(IMAGE_NUMBEROF_DIRECTORY(16)), PE 로더는 이 값을 보고 배열의 크기를 인식한다.
#9. DataDirectory
여기서 말하는 Directory란 그냥 어떤 구조체의 배열이라고 생각하면 된다. EXPORT, IMPORT, RESOURCE, TLS Directory를 눈여겨봐야하고 특히, EXPORT와 IMPORT는 PE 헤더에서 매우 중요하기 때문에 잘 알아두자!
6. 섹션 헤더
섹션 헤더는 각 섹션의 속성을 정의한 것이다. 각 섹션은 비슷한 성격의 자료를 모아둔 곳이다. 그리고 해당 섹션의 속성을 기술하는 곳이 섹션 헤더이다. 그리고 각 섹션마다 속성이 다르기 때문에 각각의 특성, 접근 권한 등을 다르게 설정할 필요가 있다.
IMAGE_SECTION_HEADER
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
// 출처 : https://learn.microsoft.com/ko-kr/windows/win32/api/winnt/ns-winnt-image_section_header
해당 구조체에서 알아야 할 중요한 멤버는 5가지가 있다.
1. VirtualSize : 메모리에서 섹션이 차지하는 크기
2. VirtualAddress : 메모리에서 섹션의 시작 주소(RVA)
3. SizeOfRawData : 파일에서 섹션이 차지하는 크기
4. PointerToRawData : 파일에서 섹션의 시작 위치
5. Characteristics : 섹션의 속성(bit OR)
파일은 메모리에 로딩되었을 때 그 크기가 변하기 때문에 VirtualSize와 SizeOfRawData는 일반적으로 다른 값을 가진다. 또한, 파일과 메모리에서 섹션의 최소 단위(SectionAlignment, FileAlignment)에 의해 VirtualAddress와 PointerToRawData는 아무 값이나 가질 수 없다.
추가적으로 Name 항목에 대해 알아보면, Name 항목은 C언어의 문자열처럼 NULL로 끝나지 않는다. 또한 ASCII 값만 와야한다는 제한도 없다. PE 스펙에선 섹션 Name에 대한 명시적인 규칙이 없지 때문에 어떠한 값을 넣어도 된다. 따라서 해당 값은 참고용일 뿐 어떤 정보로써 활용하기에는 100% 장담할 수 없다.(text 속성을 가지고 있지만 code로 이름을 변경해도 상관이 없다....)
+) 이미지(image) : 파일에서의 PE와 메모리에서의 PE는 서로 다른 모양을 가진다. 이를 구별하기 위해서 메모리에 로딩된 상태를 이미지라고 정의해 파일과 구별한다.
출처 : 리버싱 핵심원리 (이승원, 인사이트)
PE 헤더 내의 정보는 RVA 형태로 된 것이 많다. PE 파일이 프로세스 가상 메모리의 특정 위치에 로딩되는 순간 이미 그 위치에 다른 PE 파일이 로딩되어 있다면 재배치 과정을 통해서 비어 있는 다른 위치에 로딩된다. 이때 PE 헤더 정보들이 VA(절대주소)로 되어있다면 정상적인 엑세스가 이루어지지 않을 것이다. RVA(상대주소)로 해두면 재배치가 발생하도 기준위치(ImageBase)에 대한 상대주소가 변하지 않기 때문에 아무런 문제 없이 엑세스할 수 있다.
'리버싱 핵심원리' 카테고리의 다른 글
[리버싱 핵심원리] 25장 - PE 패치를 이용한 DLL 로딩 (0) | 2023.05.14 |
---|---|
[리버싱 핵심원리] 13장 - PE File Format(2) (0) | 2023.05.12 |
[리버싱 핵심원리] 24장 - DLL 이젝션 (0) | 2023.05.11 |
[리버싱 핵심원리] 23장 - DLL 인젝션 (0) | 2023.05.11 |
[리버싱 핵심원리] 22장 - 악의적인 목적으로 사용되는 키로거 (0) | 2023.05.09 |