
Windows용 패커 작성 방법에 대한 튜토리얼!

부담스러워 보이나요? 이 readme를 요약한 프레젠테이션 버전을 시도해 보세요. YouTube 동영상이 곧 공개됩니다!
패커는 자신의 주소 공간(때로는 다른 프로세스의 주소 공간) 내에서 다른 프로그램을 압축 해제하고 실행하는 프로그램입니다. 디버거나 가상 샌드박스와 같은 분석 환경을 공격하는 벡터로 알려져 있기도 합니다. 주로 다음과 같은 몇 가지 용도로 사용됩니다:
기본적으로 패커는 몇 가지 기본 단계만 수행합니다:
마찬가지로 간단하게, 패커는 몇 가지 구성 요소로만 이루어져 있습니다:
패커를 빌드하는 것은 까다로울 수 있습니다. 스텁을 빌드하여 패커 실행 파일에 어떻게든 포함시켜야 하기 때문입니다. Windows의 경우 단순 컴파일을 넘어 Visual Studio의 빌드 시스템을 배우는 것은 힘든 작업이 될 수 있습니다. 다행히 CMake는 Visual Studio를 지원하는 간단하고 크로스 플랫폼인 빌드 시스템을 제공하며, 커스터마이징도 매우 용이합니다!
이 튜토리얼은 다음을 가르치는 것을 목표로 합니다:
이미 C++와 CMake에 익숙하다면 패킹 섹션으로 바로 건너뛰어도 좋습니다. 그렇지 않다면 계속 읽어 보세요!
먼저, 이 빌드 시스템이 제대로 작동하여 올바르게 패킹된 실행 파일을 생성한다는 것을 어떻게든 증명해 봅시다. CMake와 Visual Studio를 설치한 후, 원하는 터미널로 이 저장소의 루트 디렉터리로 이동하여 다음을 실행하세요:``` $ mkdir build $ cd build $ cmake ../
이렇게 하면 packer 튜토리얼 코드를 빌드하는 데 필요한 프로젝트 파일이 생성됩니다. 그런 다음 실행하세요:```
$ cmake --build ./ --config Release
이렇게 하면 Release 모드로 packer 프로젝트가 빌드됩니다. 그런 다음, 다음 테스트를 실행할 수 있습니다:```
$ ctest -C Release ./
If all goes well, test\_pack and test_unpack should succeed. If you want to see the results of the packing yourself, you should see this:
모든 것이 잘 진행된다면, test\_pack과 test_unpack은 성공해야 합니다. 패킹 결과를 직접 확인하고 싶다면, 다음과 같은 결과를 보게 될 것입니다:```
$ ./packed.exe
I'm just a little guy!
패커를 구성하는 모든 요소에 대해 이야기해 봅시다.
패커의 주요 구성 요소가 무엇인지는 알았지만, 개발 주기 중에 패커를 바로 테스트하려면 어떻게 해야 할까요? 바이너리의 패킹 및 언패킹 과정을 제대로 테스트하려면 전체 프로젝트에 세 번째 바이너리를 추가해야 합니다. 즉, 전체적으로 다음 세 개의 프로젝트가 필요합니다.
또한 바이너리가 스텁 실행 파일로 압축되도록 하려면 압축 라이브러리도 필요합니다. zlib이 여기에 잘 맞을 것입니다.
시작 단계에서 우리 프로젝트 구조는 다음과 같아야 합니다.``` packer/ +---+ CMakeLists.txt + dummy/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + stub/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + src/ | | | +---+ main.cpp | + zlib-1.2.13/ | +---+ CMakeLists.txt + ...
각 폴더의 main.cpp는 지금은 단순히 다음과 같이 보일 수 있습니다:```cpp
#include <iostream>
int main(int argc, char *argv[]) {
std::cout << "I'm just a little guy!" << std::endl;
return 0;
}
Already, we should establish that we have a simple dependency chain to deal with, which CMake will nicely resolve for us with some instrumentation:
CMake 계측을 위해 루트 프로젝트, 즉 packer부터 시작하겠습니다.
CMake는 일반적으로 처리할 최소 버전을 요구합니다. 오랫동안 존재해 왔고 이전 버전의 장기 사용을 지원하기 때문입니다. 그 후에는 packer 프로젝트를 C++ 프로젝트로 선언할 수 있습니다.```cmake
cmake_minimum_required(VERSION 3.24)
project(packer CXX)
또한 패커를 "MultiThreadedDLL"(즉, /MD) 대신 "MultiThreaded"(즉, /MT)로 선언하여 런타임 DLL 종속성에 대해 걱정할 필요가 없도록 하고 싶습니다.```cmake
# this line will mark our packer as MultiThreaded, instead of MultiThreadedDLL
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
변수 설정 문에서 꺾쇠 괄호 안의 문은 generator expression이라고 하며, 필요할 때 구성 시 데이터를 확인하는 데 도움이 됩니다. 이 파일 전체에서 자주 보게 될 것입니다. 이 generator expression은 구성이 Debug임을 감지하면 Debug 문자열을 내보내고, 그렇지 않으면 아무것도 내보내지 않습니다. 따라서 컴파일 프로필로 Debug를 선택하면 MultiThreadedDebug 런타임 라이브러리가 제공되고, Release와 같은 디버그가 아닌 컴파일 프로필을 선택하면 MultiThreaded가 제공됩니다. 무슨 일이 일어나고 있는지 잘 이해하려면 조건부 generator expression을 참조하세요. CMAKE_MSVC_RUNTIME_LIBRARY 변수에 대한 자세한 내용은 CMake 문서를 참조하세요.
CMake를 사용하면 Visual Studio에서 UI로 프로젝트를 만들 때 자동으로 하는 것처럼 소스 코드를 계층 구조로 구성할 수 있으며, 폴더에서 일치하는 파일 이름을 재귀적으로 검색할 수도 있습니다. 우리 구성 파일에서는 헤더(.hpp), 코드(.cpp), 리소스 스크립트(.rc)에 대해 전역 재귀를 설정했습니다. 이 예제에서는 실제로 main.cpp만 필요하지만, 더 큰 프로젝트에서는 알아 두면 유용합니다.```cmake
file(GLOB_RECURSE SRC_FILES ${PROJECT_SOURCE_DIR}/src/.cpp) file(GLOB_RECURSE HDR_FILES ${PROJECT_SOURCE_DIR}/src/.hpp) file(GLOB_RECURSE RC_FILES ${PROJECT_SOURCE_DIR}/src/*.rc)
source_group(TREE "${PROJECT_SOURCE_DIR}" PREFIX "Header Files" FILES ${HDR_FILES}) source_group(TREE "${PROJECT_SOURCE_DIR}" PREFIX "Source Files" FILES ${SRC_FILES}) source_group(TREE "${PROJECT_SOURCE_DIR}" PREFIX "Resource Files" FILES ${RC_FILES})
여기서 CMake가 더 정확히는 **메이크 시스템(make system)**으로 설명된다는 점을 짚고 넘어가야겠습니다. CMake는 주어진 컴파일러를 기반으로 해당 환경에 맞는 적절한 "메이크 시스템"을 생성합니다. Linux에서는 감지된(또는 지정된) 컴파일러용 makefile이 생성됩니다. Windows에서 우리는 이 과정을 통해 우리가 사용하는 Visual Studio 버전과 호환되는 Visual Studio 프로젝트가 생성되며, 즉 MSVC 메이크 시스템을 한 번 만들어 두면 원한다면 이후의 모든 작업을 Visual Studio에서 바로 수행할 수 있습니다! 결국 여러분은 CMake를 사용해 Visual Studio를 구성(configuring)하는 것입니다. 따라서 여기서 하는 작업은 본질적으로 프로젝트를 만들 때 GUI가 자동으로 만들어 주는 Visual Studio 내 프로젝트 파일 트리를 직접 생성하는 것과 같습니다.
다음으로, 우리의 종속 프로젝트들을 추가해야 합니다:```cmake
# this will add zlib as a build target
add_subdirectory(${PROJECT_SOURCE_DIR}/zlib-1.2.13)
# this will add our stub project
add_subdirectory(${PROJECT_SOURCE_DIR}/stub)
# this will add our test dummy project
add_subdirectory(${PROJECT_SOURCE_DIR}/dummy)
앞서 언급했듯이 packer 프로젝트는 stub 프로젝트에 의존합니다. packer는 어떻게든 stub을 유지하고 조작하여 최종적으로 패킹된 실행 파일을 얻어야 합니다. Windows 리소스 파일을 사용하면 빌드 구성과 관계없이 stub 실행 파일을 packer 바이너리에 포함시킬 수 있습니다! 또한 CMake를 사용하여 해당 파일들을 생성할 수 있으므로 CMake 프로젝트 내에서 빌드된 실행 파일에 대한 참조가 정확합니다. 지금은 리소스 파일을 생성하여 프로젝트에 포함시키겠습니다:```cmake
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/$/stub.hpp" CONTENT "#pragma once\n#define IDB_STUB 1000\n") file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/$/stub.rc" CONTENT "#include <winresrc.h>\n#include "stub.hpp"\nIDB_STUB STUB "$<TARGET_FILE:stub>"\n")
`CMAKE_CURRENT_BINARY_DIR`는 현재 빌드 디렉터리가 포함된 문자열입니다. Visual Studio는 구성(configuration)에 따라 이진 파일을 폴더에 덤프하므로, `$<CONFIG>` 생성자 문을 사용하여 빌드의 현재 구성을 가져옵니다. 또한 `$<TARGET_FILE:stub>` 생성자 문을 사용하여 스텁 바이너리가 컴파일된 후의 실행 파일 이름을 출력합니다. 이러한 파일들-- 생성된 RC 파일과 생성된 헤더--을 프로젝트에 포함하면 스텁 바이너리를 패커 프로젝트에 성공적으로 통합할 수 있습니다.
다음으로, 앞서 수집한 파일들로 패커 프로젝트의 실행 파일을 선언합니다:```cmake
# this will create our packer executable
add_executable(packer ${HDR_FILES} ${SRC_FILES})
target_sources(packer PRIVATE ${RC_FILES} "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>/stub.rc")
그런 다음 임포트된 zlib 라이브러리를 링커용으로 링크합니다:```cmake
target_link_libraries(packer zlibstatic)
대신 정말로 zlib의 DLL이 필요하다면 `zlib`만 링크할 수도 있습니다.
생성된 파일(그리고 zlib도 빌드 단계의 일부로 생성)이 있기 때문에 프로젝트의 include 헤더에 동적 디렉터리를 포함해야 합니다. CMake에서 프로젝트가 프로젝트 루트에 있어야 하는 종속성 때문에 스텁 바이너리에도 zlib include 디렉터리를 추가합니다:```cmake
# zlib, as part of its build step, drops a config header in the build directory.
# we do this too, so make sure to include everything for the build!
target_include_directories(packer PUBLIC
"${PROJECT_SOURCE_DIR}/src"
"${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>"
"${PROJECT_SOURCE_DIR}/zlib-1.2.13"
"${CMAKE_CURRENT_BINARY_DIR}/zlib-1.2.13"
)
# also set the includes for the stub from here.
# we can't set this in the stub CMake file because CMake requires includes to be in the same
# directory as the build target. for this file, our build target is packer, so this sets
# up includes relative to the packer executable.
target_include_directories(stub PUBLIC
"${PROJECT_SOURCE_DIR}/zlib-1.2.13"
"${CMAKE_CURRENT_BINARY_DIR}/zlib-1.2.13"
)
마지막으로, CMake가 의존성 관계를 알게 하여 의존성 체인을 매끄럽게 정리합니다: stub을 packer의 의존성으로 표시하고, packer를 dummy의 의존성으로 표시합니다.```cmake
add_dependencies(packer stub) add_dependencies(dummy packer)
이제 [the stub](https://github.com/frank2/packer-tutorial/blob/main/stub/CMakeLists.txt)와 [the dummy](https://github.com/frank2/packer-tutorial/blob/main/dummy/CMakeLists.txt)용 CMake 파일은 꽤 이해하기 쉬울 것입니다!
훌륭한 점은 CMake가 테스트를 관리해 준다는 것입니다! 그리고 이 시점에서 우리 패커에 대한 테스트를 생성하는 것은 간단한 과정입니다. 명령어 하나를 실행하기만 하면 되고, 종료 코드가 0이면 테스트가 통과합니다. 간단하게 하기 위해, 바이너리를 실행 파일의 첫 번째 인자로 전달하여 패킹한다고 가정해 봅시다. 우리가 원하는 것은 다음과 같습니다:```
$ packer.exe dummy.exe
CMake로 그렇게 하는 것은 매우 간단합니다:```cmake
enable_testing() add_test(NAME test_pack COMMAND "$<TARGET_FILE:packer>" "$<TARGET_FILE:dummy>")
마지막으로, 프로그램이 언패킹되는지 여부를 테스트하는 것은 훨씬 더 간단합니다. 출력물을 그냥 실행하면 됩니다! 로더를 에뮬레이션하려고 할 때 단순히 실행만으로 프로그램이 충돌하는 오류가 얼마나 많이 발생하는지 놀라지 마세요! 간단히 다시 말하면, 패킹된 바이너리의 경우 바이너리가 "packed.exe"로 출력되어야 한다고 가정합시다. 그렇다면, 여러분이 해야 할 일은 다음과 같습니다:```cmake
add_test(NAME test_unpack
COMMAND "packed.exe")
이 과정은 패커가 바이너리 출력에 실패하면 자연스럽게 실패합니다. 안타깝게도 Visual Studio용 CMake는 ADDITIONAL_CLEAN_FILES 변수를 무시하므로, 생성된 stub.rc 및 stub.hpp 파일을 포함한 빌드 시스템의 모든 생성 파일을 수동으로 정리해야 합니다.
축하합니다! 패커를 성공적으로 빌드하고 테스트하기 위해 많은 작업이 수행되었습니다. 이제 야채를 먹었으니, 우리 패커는 다음과 같은 일을 할 수 있습니다:
이제 재미있는 부분으로 넘어갈 수 있습니다!
우리는 컴파일러를 설정하여 스텁 실행 파일을 리소스로 패커 바이너리에 컴파일하는 데 성공했습니다. 하지만 어떻게 바이너리를 패킹된 상태로 스텁에 넣을 수 있을까요? 리소스로 추가할 수는 없습니다. 패커의 최종 사용자가 컴파일러를 사용할 것이라고 기대할 수 없기 때문입니다. 패커 실행 파일은 독립 실행형 솔루션이어야 합니다.
제가 많이 사용해 온 한 가지 기법(분석가에게는 명백할 수 있지만)은 스텁 바이너리에 새 섹션을 추가하여 런타임에 로드되도록 하는 것입니다. 이는 결국 PE 포맷에 대한 입문서 역할을 겸하게 됩니다. 하지만 먼저, 리소스에서 데이터를 어떻게 꺼낼까요?
런타임에 바이너리에서 리소스 데이터를 획득하는 기본 단계는 세 가지입니다:
다음 함수는 주어진 바이너리에서 리소스를 검색하고 획득하는 방법을 보여줍니다:```cpp std::vectorstd::uint8_t load_resource(LPCSTR name, LPCSTR type) { auto resource = FindResourceA(nullptr, name, type);
if (resource == nullptr) { std::cerr << "Error: couldn't find resource." << std::endl; ExitProcess(6); }
auto rsrc_size = SizeofResource(GetModuleHandleA(nullptr), resource); auto handle = LoadResource(nullptr, resource);
if (handle == nullptr) { std::cerr << "Error: couldn't load resource." << std::endl; ExitProcess(7); }
auto byte_buffer = reinterpret_cast<std::uint8_t *>(LockResource(handle));
return std::vectorstd::uint8_t(&byte_buffer[0], &byte_buffer[rsrc_size]); }
데이터가 쉽게 조작 가능한 벡터에 담겨 있으므로, 이제 스텁 이미지를 파싱하고 여기에 새 데이터를 추가할 수 있습니다.
### PE 파일 파싱
Windows 실행 파일은 가장 기본적인 구성 요소로 나누면 *헤더*와 *섹션 데이터*의 두 부분으로 나뉩니다. 헤더에는 로딩 프로세스에 중요한 많은 메타데이터가 포함되어 있고, 섹션 데이터는 말 그대로 데이터입니다. 즉, 실행 코드(예: `.text` 섹션) 또는 임의의 데이터(예: `.data` 섹션)가 될 수 있습니다. 모든 Windows 실행 파일의 시작은 `IMAGE_DOS_HEADER` 구조로 시작됩니다:```c
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
이 구조는 복잡해 보이지만, 이름에서 알 수 있듯이 이 헤더는 과거 Windows 및 Microsoft DOS 버전의 유물입니다. 여기서 우리는 이 헤더의 두 값인 e_magic과 e_lfanew에만 관심을 둡니다. e_magic은 단순히 이미지 맨 위에 있는 매직 헤더 값, 즉 파일 시작 부분의 "MZ"입니다. e_lfanew는 파일 시작부터 NT 헤더까지의 오프셋으로, 실행 파일에 대한 훨씬 더 많은 메타데이터 정보를 포함하는 PE 헤더입니다. 예를 들어, 우리는 다음과 같이 패커를 위한 간단한 PE 검증기를 구성할 수 있습니다:```cpp void validate_target(const std::vectorstd::uint8_t &target) { auto dos_header = reinterpret_cast<const IMAGE_DOS_HEADER *>(target.data());
// IMAGE_DOS_SIGNATURE is 0x5A4D (for "MZ") if (dos_header->e_magic != IMAGE_DOS_SIGNATURE) { std::cerr << "Error: target image has no valid DOS header." << std::endl; ExitProcess(3); }
auto nt_header = reinterpret_cast<const IMAGE_NT_HEADERS *>(target.data() + dos_header->e_lfanew);
// IMAGE_NT_SIGNATURE is 0x4550 (for "PE") if (nt_header->Signature != IMAGE_NT_SIGNATURE) { std::cerr << "Error: target image has no valid NT header." << std::endl; ExitProcess(4); }
// IMAGE_NT_OPTIONAL_HDR64_MAGIC is 0x020B if (nt_header->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) { std::cerr << "Error: only 64-bit executables are supported for this example!" << std::endl; ExitProcess(5); } }
`IMAGE_NT_HEADERS`는 전반적으로 상당히 큰 구조체이므로, 여기서 전체를 문서화하지는 않겠지만, 알아야 할 모든 것은 [Microsoft의 헤더 문서](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_nt_headers64)에서 찾을 수 있습니다. 어차피 우리는 이 헤더에서 몇 개의 구조체 멤버만 필요로 할 것입니다. 지금은 스텁 데이터에 추가하기 위해 대상 바이너리를 압축해야 합니다.
zlib의 `compress` 및 `decompress` 함수는 사용법이 매우 간단합니다. zlib로 더 고급스러운 작업을 원한다면, 압축 스트림을 한 번에 한 청크씩 처리할 수 있는 `deflate`/`inflate` 함수를 사용하는 것이 좋습니다. [zlib 매뉴얼](https://www.zlib.net/manual.html)의 "Advanced Functions" 섹션을 참조하십시오. 하지만 이 예제에서는 `compress`와 `decompress`로 충분합니다.
우선, 대상 바이너리의 크기에 대해 zlib의 `compressBound` 함수로 크기 값을 얻습니다. 이 크기 값은 데이터 크기가 주어졌을 때 압축된 데이터 스트림을 담는 데 필요한 최대 값에 해당합니다. 그런 다음 이 값을 사용하여 압축된 데이터를 담을 벡터를 할당할 수 있습니다. `compress` 함수는 결국 압축 버퍼의 실제 크기를 반환하며, 그 값으로 벡터를 적절한 크기로 조정할 수 있습니다.```cpp
// get the maximum size of a compressed buffer of the target binary's size.
uLong packed_max = compressBound(target.size());
uLong packed_real = packed_max;
// allocate a vector with that size
std::vector<std::uint8_t> packed(packed_max);
if (compress(packed.data(), &packed_real, target.data(), target.size()) != Z_OK)
{
std::cerr << "Error: zlib failed to compress the buffer." << std::endl;
ExitProcess(8);
}
// resize the buffer to the real compressed size
packed.resize(packed_real);
잠시 데이터 정렬에 대해 이야기해 보겠습니다. 주어진 데이터 스트림은 해당 주소나 크기가 주어진 정렬 경계로 나누어 떨어지면 정렬된 것으로 간주됩니다. 예를 들어, PE 파일에서 디스크의 데이터 섹션은 일반적으로 0x400 경계로 정렬되는 반면, 메모리에서는 0x1000 경계로 정렬됩니다. 주어진 값이 정렬되었는지 여부는 값에 정렬을 모듈러 연산하여 확인할 수 있습니다 (즉, value % alignment == 0). PE 파일은 다른 값으로 임의로 정렬될 수 있으며, 이 값은 PE 로더 전반에 존재하고 중요합니다. 주어진 값과 주어진 경계를 정렬하는 것은 비교적 간단한 연산입니다:```cpp
template
T align(T value, T alignment) {
auto result = value + ((value % alignment == 0) ? 0 : alignment - (value % alignment));
return result;
}
이 함수는 본질적으로 정렬되지 않을 수 있는 값을 지정된 경계에 제대로 정렬하는 데 필요한 나머지 값으로 패딩합니다.
PE 파일에 임의의 데이터를 제대로 추가하려면 특히 *파일 정렬(file alignment)*에 유의해야 합니다. 나중에 PE 파일이 메모리에 로드될 때 올바른 정렬 값을 계산할 수 있지만, 지금 데이터를 추가할 때는 파일을 파일 정렬 경계에 맞춰 정렬해야 합니다. 다음 코드에서는 헤더를 획득한 다음 파일 정렬 및 섹션 정렬 경계를 가져온 후, 스텁 데이터를 파일 경계에 맞춰 정렬하고 새로 패킹된 섹션을 추가합니다.```cpp
// next, load the stub and get some initial information
std::vector<std::uint8_t> stub_data = load_resource(MAKEINTRESOURCE(IDB_STUB), "STUB");
auto dos_header = reinterpret_cast<IMAGE_DOS_HEADER *>(stub_data.data());
auto e_lfanew = dos_header->e_lfanew;
// get the nt header and get the alignment information
auto nt_header = reinterpret_cast<IMAGE_NT_HEADERS64 *>(stub_data.data() + e_lfanew);
auto file_alignment = nt_header->OptionalHeader.FileAlignment;
auto section_alignment = nt_header->OptionalHeader.SectionAlignment;
// align the buffer to the file boundary if it isn't already
if (stub_data.size() % file_alignment != 0)
stub_data.resize(align<std::size_t>(stub_data.size(), file_alignment));
// save the offset to our new section for later for our new PE section
auto raw_offset = static_cast<std::uint32_t>(stub_data.size());
// encode the size of our unpacked data into the stub data
auto unpacked_size = target.size();
stub_data.insert(stub_data.end(),
reinterpret_cast<std::uint8_t *>(&unpacked_size),
reinterpret_cast<std::uint8_t *>(&unpacked_size)+sizeof(std::size_t));
// add our compressed data.
stub_data.insert(stub_data.end(), packed.begin(), packed.end());
이제 파일 섹션 경계에 따라 섹션에 대한 데이터를 추가했지만, 우리의 스텁 실행 파일은 여전히 PE 파일에서 이 섹션을 인식하지 못합니다. PE 파일의 섹션 테이블을 파싱할 뿐만 아니라, 우리 섹션을 가리키는 새 항목을 추가해야 합니다. 이것이 raw_offset 변수가 존재하는 이유입니다.
먼저 NumberOfSections를 업데이트하여 섹션 수를 쉽게 늘릴 수 있습니다. 일반적으로 마지막 섹션 테이블 이후의 데이터는 0으로 채워져 있으므로, 0으로 채워진 데이터를 우리의 새 섹션으로 쉽게 덮어쓸 수 있습니다.```cpp
// increment the number of sections in the file header
auto section_index = nt_header->FileHeader.NumberOfSections;
++nt_header->FileHeader.NumberOfSections;
다음으로, 섹션 테이블 자체에 대한 포인터를 획득해야 합니다. 기술적으로 섹션 테이블은 NT 헤더의 선택 헤더 바로 뒤에 위치하지만, 선택 헤더의 크기는 실제로 NT 파일 헤더의 `SizeOfOptionalHeader` 값에 의해 결정됩니다. 따라서 해당 위치에 도달하려면 `OptionalHeader` 구조체의 시작 지점에서 `SizeOfOptionalHeader` 값이 제공하는 오프셋까지의 포인터를 계산해야 합니다.```cpp
// acquire a pointer to the section table
auto size_of_header = nt_header->FileHeader.SizeOfOptionalHeader;
auto section_table = reinterpret_cast<IMAGE_SECTION_HEADER *>(
reinterpret_cast<std::uint8_t *>(&nt_header->OptionalHeader)+size_of_header
);
마지막으로, 섹션 메타데이터 추가를 시작할 준비가 되었습니다. 이것이 우리의 PE 섹션 헤더입니다:```c typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // IMAGE_SIZEOF_SHORT_NAME is 8 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;
새 섹션 헤더에 대해 우리가 관심을 갖는 특정 변수는 `Name`, `VirtualSize`, `VirtualAddress`, `SizeOfRawData`, `PointerToRawData` 및 `Characteristics`입니다. 이 시점에서 파일 정렬과 메모리 정렬이라는 두 가지 유형의 정렬을 알고 있어야 하는 이유는, 주어진 PE 실행 파일이 로딩 과정의 결과로 *디스크* 상에서 보이는 모습과 *메모리* 상에서 보이는 모습이라는 두 가지 서로 다른 메모리 상태를 갖기 때문입니다. 주어진 PE 파일이 로드 여부와 관계없이 동일한 메모리 레이아웃을 갖도록 구성할 수도 있지만, 이것은 흔한 구성이 아닙니다.
`Name` 변수는 새 섹션에 부여할 수 있는 8바이트 레이블입니다. 저는 `.packed`를 선택했는데, 7바이트 ASCII 문자열이므로 버퍼에 정확히 맞습니다.
`VirtualAddress`는 메모리에서 특정 섹션의 오프셋을 나타냅니다. "상대 가상 주소" 또는 RVA라고도 합니다. `VirtualSize`는 메모리에서 해당 섹션의 크기를 나타냅니다. (참고로 MSVC는 이 값을 섹션의 정렬되지 않은 크기 값으로 컴파일하므로, 우리도 새 섹션에서 이 규칙을 따릅니다.) `PointerToRawData`는 디스크에서 특정 섹션의 오프셋을 나타내고, `SizeOfRawData`는 디스크에서 해당 섹션의 크기를 나타냅니다.
`Characteristics`는 복잡하며, 다른 의미들 외에도 섹션이 읽기 가능, 쓰기 가능 또는 실행 가능한지 여부를 나타낼 수 있습니다. [`IMAGE_SECTION_HEADER` 문서](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_section_header)의 characteristics 섹션을 참조하세요. 지금으로서는 섹션이 읽기 가능하고 초기화된 데이터를 포함하는 것으로 표시되기만 하면 된다는 점만 알아두면 됩니다.
이 모든 것을 염두에 두고, 이제 새 섹션을 만들 수 있습니다!```cpp
// get a pointer to our new section and the previous section
auto section = §ion_table[section_index];
auto prev_section = §ion_table[section_index-1];
// calculate the memory offset, memory size and raw aligned size of our packed section
auto virtual_offset = align(prev_section->VirtualAddress + prev_section->Misc.VirtualSize, section_alignment);
auto virtual_size = section_size;
auto raw_size = align<DWORD>(section_size, file_alignment);
// assign the section metadata
std::memcpy(section->Name, ".packed", 8);
section->Misc.VirtualSize = virtual_size;
section->VirtualAddress = virtual_offset;
section->SizeOfRawData = raw_size;
section->PointerToRawData = raw_offset;
// mark our section as initialized, readable data.
section->Characteristics = IMAGE_SCN_MEM_READ | IMAGE_SCN_CNT_INITIALIZED_DATA;
하지만 이제 작은 문제가 하나 생깁니다. 이미지의 크기가 변경되었기 때문입니다. 문제가 아니라고 생각할 수도 있겠지만, NT 헤더의 옵셔널 헤더에는 로더가 실행 파일에 할당해야 하는 공간의 크기를 결정하는 SizeOfImage라는 변수가 있습니다. 이것도 간단히 해결할 수 있습니다. 이미지에 필요한 크기는 마지막 섹션을 섹션 정렬 경계에 맞춘 크기이기 때문입니다.```cpp
// calculate the new size of the image.
nt_header->OptionalHeader.SizeOfImage = align(virtual_offset + virtual_size, section_alignment);
그리고 그게 전부입니다! 이제 압축된 바이너리를 스텁이 나중에 압축 해제하고 로드할 수 있도록 새 섹션으로 성공적으로 추가했습니다. 이제 수정된 스텁 이미지를 디스크에 저장하기만 하면 됩니다.```cpp
std::ofstream fp("packed.exe", std::ios::binary);
if (!fp.is_open()) {
std::cerr << "Error: couldn't open packed binary for writing." << std::endl;
ExitProcess(9);
}
fp.write(reinterpret_cast<const char *>(stub_data.data()), stub_data.size());
fp.close();
축하합니다! 지금까지 우리는 다음을 달성했습니다:
우리는 패커 작성의 절반을 완료했습니다! 이제 프로세스에서 아마도 가장 어려운 부분인 스텁 바이너리를 구체화하는 작업으로 넘어갈 수 있습니다.
언팩 스텁 작성의 세부 사항은 내부적으로 복잡해질 수 있지만, 기본적으로는 몇 가지 단계로 귀결됩니다:
이 과정은 매우 단순해서, 우리의 메인 루틴은 단 몇 개의 함수로 구성됩니다:```cpp int main(int argc, char *argv[]) { // first, decompress the image from our added section auto image = get_image();
// next, prepare the image to be a virtual image
auto loaded_image = load_image(image);
// resolve the imports from the executable load_imports(loaded_image);
// relocate the executable relocate(loaded_image);
// get the headers from our loaded image auto nt_headers = get_nt_headers(loaded_image);
// acquire and call the entrypoint auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint; reinterpret_cast<void(*)()>(entrypoint)();
return 0; }
### 메모리에서 PE 읽기
먼저 실행 중인 바이너리에서 패커가 만든 섹션의 데이터를 어떻게든 가져와야 합니다. 실행 중인 바이너리의 헤더를 런타임에 얻을 수 있을까요? 네, 물론입니다! [`GetModuleHandleA`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlea)에 null 인수를 전달하면 궁극적으로 실행 중인 PE 헤더에 대한 포인터를 반환합니다! 따라서 런타임에는 메모리에 존재하는 이미지에 쉽게 접근할 수 있습니다. 이것이 우리 바이너리에 새 섹션을 추가하는 것이 매력적인 이유입니다. 바이너리에서 대상 섹션을 매우 쉽게 파싱할 수 있기 때문입니다.
섹션 테이블 파싱에 대해 배운 내용을 바탕으로, 이 코드 섹션은 이해하기 쉬울 것입니다:
``````cpp
// find our packed section
auto base = reinterpret_cast<const std::uint8_t *>(GetModuleHandleA(NULL));
auto nt_header = get_nt_headers(base);
auto section_table = reinterpret_cast<const IMAGE_SECTION_HEADER *>(
reinterpret_cast<const std::uint8_t *>(&nt_header->OptionalHeader)+nt_header->FileHeader.SizeOfOptionalHeader
);
const IMAGE_SECTION_HEADER *packed_section = nullptr;
for (std::uint16_t i=0; i<nt_header->FileHeader.NumberOfSections; ++i)
{
if (std::memcmp(section_table[i].Name, ".packed", 8) == 0)
{
packed_section = §ion_table[i];
break;
}
}
if (packed_section == nullptr) {
std::cerr << "Error: couldn't find packed section in binary." << std::endl;
ExitProcess(1);
}
다음으로, 바이너리에서 스텁 데이터를 압축 해제해야 합니다. zlib은 압축 해제 루틴이 원본 압축 해제 페이로드의 크기를 어떤 방식으로든 접근 가능하도록 인코딩할 것을 권장합니다. 그래서 우리는 패킹된 데이터의 헤더에 압축 해제된 바이너리의 크기를 인코딩한 것입니다. 따라서 압축 해제된 데이터에 대한 포인터를 얻고, 압축 해제된 데이터를 담을 수 있는 새 버퍼를 만든 다음, zlib의 압축 해제 함수를 호출하면 됩니다.```cpp // decompress our packed image auto section_start = base + packed_section->VirtualAddress; auto section_end = section_start + packed_section->Misc.VirtualSize; auto unpacked_size = *reinterpret_cast<const std::size_t *>(section_start); auto packed_data = section_start + sizeof(std::size_t); auto packed_size = packed_section->Misc.VirtualSize - sizeof(std::size_t);
auto decompressed = std::vectorstd::uint8_t(unpacked_size); uLong decompressed_size = static_cast(unpacked_size);
if (uncompress(decompressed.data(), &decompressed_size, packed_data, packed_size) != Z_OK) { std::cerr << "Error: couldn't decompress image data." << std::endl; ExitProcess(2); }
return decompressed;
보시다시피, `get_image`는 결국 비교적 단순한 함수였습니다. 추가된 섹션에서 대상 바이너리를 추출했으니, 이제 이를 로드해야 합니다.
### 실행을 위한 PE 로드
Windows 실행 파일 로더는 내부적으로 다양한 작업을 수행하며, 다양한 실행 파일 구성을 지원합니다. 이 튜토리얼이 생성하는 패커를 예제 바이너리 이상으로 탐색한다면 여러 오류를 만날 가능성이 높습니다. 오늘 구축할 구성이 기술적으로 매우 최소한이기 때문입니다. 그러나 실행을 위한 최소 기준을 마련하기 위해, 최신 Windows 실행 파일을 로드하려면 다음 작업을 수행해야 합니다:
* 실행 파일 이미지의 메모리 표현을 보유하는 이미지를 할당
* 헤더를 포함한 실행 파일 이미지의 섹션들을 할당된 이미지에 매핑
* 바이너리가 필요로 하는 다른 라이브러리에 대한 런타임 임포트를 해석
* 이미지 내 다양한 주소가 의도된 위치를 가리키도록 바이너리 이미지를 다시 매핑
`load_image` 함수부터 시작해 보겠습니다. 우선, 패킹된 바이너리에서 섹션 테이블을 확보해야 합니다. 이는 결국 새로 할당된 이미지에 매핑될 것입니다. 적절한 실행 파일 버퍼를 위해 할당은 매우 간단합니다. 섹션 헤더에서 `SizeOfImage` 값을 가져와 읽기, 쓰기, 실행이 가능한 이미지를 생성하는 [`VirtualAlloc`](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualalloc)으로 새 버퍼를 만드는 것입니다.```cpp
// get the original image section table
auto nt_header = get_nt_headers(image.data());
auto section_table = reinterpret_cast<const IMAGE_SECTION_HEADER *>(
reinterpret_cast<const std::uint8_t *>(&nt_header->OptionalHeader)+nt_header->FileHeader.SizeOfOptionalHeader
);
// create a new VirtualAlloc'd buffer with read, write and execute privileges
// that will fit our image
auto image_size = nt_header->OptionalHeader.SizeOfImage;
auto base = reinterpret_cast<std::uint8_t *>(VirtualAlloc(nullptr,
image_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE));
if (base == nullptr) {
std::cerr << "Error: VirtualAlloc failed: Windows error " << GetLastError() << std::endl;
ExitProcess(3);
}
버퍼를 할당했으니, 이제 헤더와 섹션을 그 버퍼에 복사해야 합니다. 이는 앞서 살펴본 SectionAlignment 변수와 일치해야 합니다. 다행히도 우리가 섹션을 준비한 방식과 다른 섹션들이 준비된 방식은 이미 SectionAlignment 경계에 정렬되어 있습니다. 결국 결과는 간단한 포인터 연산입니다. 대상 이미지를 PointerToRawData 오프셋에서 로드된 이미지의 VirtualAddress 오프셋으로 복사하면 됩니다.
선택적으로 PE 헤더를 이미지의 상단에 복사할 수도 있습니다. 원본 헤더를 유지하면 개발이 수월해지지만, 헤더를 제거하려는 것은 분석에 저항하는 패커를 만드는 좋은 첫걸음입니다. 여기서는 사용 편의를 위해 헤더를 복사합니다.```cpp // copy the headers to our new virtually allocated image std::memcpy(base, image.data(), nt_header->OptionalHeader.SizeOfHeaders);
// copy our sections to their given addresses in the virtual image for (std::uint16_t i=0; i<nt_header->FileHeader.NumberOfSections; ++i) if (section_table[i].SizeOfRawData > 0) std::memcpy(base+section_table[i].VirtualAddress, image.data()+section_table[i].PointerToRawData, section_table[i].SizeOfRawData);
return base;
자, 로딩 프로세스의 쉬운 부분은 끝났습니다. 우리는 메모리에서 바이너리를 가져와 언패킹하고, 해당 섹션들을 실행 가능한 메모리 영역에 다시 매핑했습니다. 이미지를 준비했으니 이제 로딩 프로세스의 복잡한 세부 사항을 파고들 수 있습니다.
### API imports 해석
선택적 헤더 안에는 *데이터 디렉터리*라고 하는 것이 있습니다. 이 디렉터리는 실행 파일에 대한 다양한 정보(예: 이미지가 내보내는 심볼, 아이콘이나 비트맵 같은 리소스)를 포함합니다. 이 튜토리얼에서는 **import 디렉터리**와 **재배치 디렉터리**라는 두 데이터 디렉터리를 파싱할 것입니다. 각 디렉터리는 하드코딩되어 있으며, 해당 인덱스는 [선택적 헤더에 대한 문서](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header32)에서 찾을 수 있습니다(`DataDirectory` 설명까지 아래로 스크롤). 데이터 디렉터리는 `VirtualAddress` 값이 null이 아닌 경우 존재합니다.```c
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
우리는 제공된 RVA를 캐스팅하여 데이터 디렉터리에 대한 포인터를 검색합니다. 예를 들어, 이것이 결국 임포트 데이터 디렉터리에서 임포트 테이블을 얻는 방법입니다:```cpp // get the import table directory entry auto nt_header = get_nt_headers(image); auto directory_entry = nt_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
// if there are no imports, that's fine-- return because there's nothing to do. if (directory_entry.VirtualAddress == 0) { return; }
// get a pointer to the import descriptor array auto import_table = reinterpret_cast<IMAGE_IMPORT_DESCRIPTOR *>(image + directory_entry.VirtualAddress);
API import를 해석하기 위해 로더는 이 디렉터리를 파싱한 다음, 필요한 라이브러리를 로드하고 import된 함수들을 획득합니다. 다행히도 이 디렉터리는 파싱하기 비교적 쉬운 편입니다.
이는 import 디스크립터 구조로 시작합니다:```c
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
우리가 가장 우려하는 것은 이름이 다소 혼동스러운 두 변수, 즉 OriginalFirstThunk와 FirstThunk입니다. OriginalFirstThunk에는 Name RVA가 가리키는 DLL과 관련하여 이 실행 파일이 필요로 하는 임포트에 대한 정보가 들어 있습니다. 혼동을 더하자면, FirstThunk도 마찬가지입니다. 둘을 구분하는 것은 무엇일까요? FirstThunk에는 해석(resolved)된 후의 임포트가 들어 있습니다. 이 해석은 악명 높은 GetProcAddress 함수에 의해 수행됩니다.
우리의 썽크(thunk)는 처리해야 할 추가 데이터 구조입니다:```c typedef struct _IMAGE_THUNK_DATA64 { union { ULONGLONG ForwarderString; // PBYTE ULONGLONG Function; // PDWORD ULONGLONG Ordinal; ULONGLONG AddressOfData; // PIMAGE_IMPORT_BY_NAME } u1; } IMAGE_THUNK_DATA64;
이 데이터 구조는 import 및 export thunk를 모두 포함하므로 `ForwarderString`은 무시해도 된다. import thunk는 `Ordinal`이거나 다른 구조체인 `IMAGE_IMPORT_BY_NAME`에 대한 RVA일 수 있다. *ordinal*은 단순히 해당 DLL의 export 테이블에 대한 오프셋이다. `IMAGE_IMPORT_BY_NAME` 구조체는 다음과 같이 생겼다:```c
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
이것은 가변 구조의 예시입니다. 크기가 가변적인 구조를 만들기 위해 배열 접근 시 경계 검사가 없다는 점을 활용합니다. 이 경우 Name은 0으로 종료되는 C 문자열이어야 합니다.
이진 데이터에는 타입이 포함되지 않으므로, ordinal과 import는 thunk 항목의 최상위 비트로 구분됩니다. ordinal은 32비트와 64비트 구현 모두에서 정수의 하위 절반에 포함됩니다.
임포트 테이블은 C 문자열처럼 동작합니다. 마지막 항목은 잠재적인 임포트의 끝을 나타내는 널 종료 OriginalFirstThunk입니다. thunk 데이터도 동일한 방식으로, thunk 배열의 널 항목으로 종료됩니다.
모든 것을 종합하면, 의사 코드에서 임포트 테이블을 파싱하는 방식은 다음과 같습니다:``` for every import descriptor: load the dll parse the original and first thunk
for every thunk:
if ordinal bit set:
import by ordinal
else:
import by name
store import in first thunk
흥미롭게도, `GetProcAddress`는 함수 인자로 C 문자열을 받도록 타입이 지정되어 있음에도 불구하고, Windows는 서수 값을 C 문자열로 캐스팅하기만 하여 서수(ordinal)로 가져오도록 요구합니다. 참 이상하죠.
이러한 설명을 염두에 두면, 다음 while 루프가 이제 이해될 것입니다:```cpp
// when we reach an OriginalFirstThunk value that is zero, that marks the end of our array.
// typically all values in the import descriptor are zero, but we do this
// to be shorter about it.
while (import_table->OriginalFirstThunk != 0)
{
// get a string pointer to the DLL to load.
auto dll_name = reinterpret_cast<char *>(image + import_table->Name);
// load the DLL with our import.
auto dll_import = LoadLibraryA(dll_name);
if (dll_import == nullptr) {
std::cerr << "Error: failed to load DLL from import table: " << dll_name << std::endl;
ExitProcess(4);
}
// load the array which contains our import entries
auto lookup_table = reinterpret_cast<IMAGE_THUNK_DATA64 *>(image + import_table->OriginalFirstThunk);
// load the array which will contain our resolved imports
auto address_table = reinterpret_cast<IMAGE_THUNK_DATA64 *>(image + import_table->FirstThunk);
// an import can be one of two things: an "import by name," or an "import ordinal," which is
// an index into the export table of a given DLL.
while (lookup_table->u1.AddressOfData != 0)
{
FARPROC function = nullptr;
auto lookup_address = lookup_table->u1.AddressOfData;
// if the top-most bit is set, this is a function ordinal.
// otherwise, it's an import by name.
if (lookup_address & IMAGE_ORDINAL_FLAG64 != 0)
{
// get the function ordinal by masking the lower 32-bits of the lookup address.
function = GetProcAddress(dll_import,
reinterpret_cast<LPSTR>(lookup_address & 0xFFFFFFFF));
if (function == nullptr) {
std::cerr << "Error: failed ordinal lookup for " << dll_name << ": " << (lookup_address & 0xFFFFFFFF) << std::endl;
ExitProcess(5);
}
}
else {
// in an import by name, the lookup address is an offset to
// an IMAGE_IMPORT_BY_NAME structure, which contains our function name
// to import
auto import_name = reinterpret_cast<IMAGE_IMPORT_BY_NAME *>(image + lookup_address);
function = GetProcAddress(dll_import, import_name->Name);
if (function == nullptr) {
std::cerr << "Error: failed named lookup: " << dll_name << "!" << import_name->Name << std::endl;
ExitProcess(6);
}
}
// store either the ordinal function or named function
// in our address table.
address_table->u1.Function = reinterpret_cast<std::uint64_t>(function);
// advance to the next entries in the address table and lookup table
++lookup_table;
++address_table;
}
// advance to the next entry in our import table
++import_table;
}
임포트가 해결되었으니, 이제 마지막 단계인 재배치 디렉터리(relocation directory)를 다룰 차례입니다!
다음으로 처리할 데이터 디렉터리는 재배치 디렉터리라고 불리는 것입니다. 이 디렉터리는 코드 내의 절대 주소를 새로운 베이스 값으로 변환하는 역할을 합니다. 이 과정은 본질적으로 주소 공간 레이아웃 무작위화라고 들어보셨을 법한 기능을 구현하지만, DLL 주소 공간이 서로 충돌하지 않도록 하는 역할도 담당합니다.
먼저, 우리의 바이너리가 실제로 주소 베이스를 이동할 수 있는지 확인해야 합니다. 특히 오래된 바이너리의 경우 때때로 이 기능이 활성화되어 있지 않습니다. 주어진 Windows 실행 파일의 특성(characteristics)에는 혼란스럽게도 선택적 헤더(optional header)에서 DllCharacteristics라고 불리는 특성들이 포함되어 있습니다. 우리가 관심을 두는 것은 "동적 베이스(dynamic base)" 특성입니다. ASLR이 적용되지 않은 바이너리를 푸는 특별한 방법이 있으며, 이에 대해서는 다루지 않으므로, 우리 바이너리가 이 기능을 지원하지 않으면 오류로 처리합니다. (이것은 스텁(stub)이 아니라 패커(packer)에서 오류로 처리하는 것이 더 나은 위치이지만, PE 헤더 교육의 흐름을 쉽게 하기 위해 여기에 배치했습니다.)```cpp
// first, check if we can even relocate the image. if the dynamic base flag isn't set,
// then this image probably isn't prepared for relocating.
auto nt_header = get_nt_headers(image);
if (nt_header->OptionalHeader.DllCharacteristics & IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE == 0) { std::cerr << "Error: image cannot be relocated." << std::endl; ExitProcess(7); }
// once we know we can relocate the image, make sure a relocation directory is present auto directory_entry = nt_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
if (directory_entry.VirtualAddress == 0) { std::cerr << "Error: image can be relocated, but contains no relocation directory." << std::endl; ExitProcess(8); }
다음으로, *주소 델타*를 계산해야 합니다. 이는 단순히 이미지의 `ImageBase` 변수와 가상 이미지의 기준 주소 간의 차이입니다. 바이너리에서 하드코딩된 주소 값을 빠르게 조정하는 데 사용됩니다.```cpp
// calculate the difference between the image base in the compiled image
// and the current virtually allocated image. this will be added to our
// relocations later.
std::uintptr_t delta = reinterpret_cast<std::uintptr_t>(image) - nt_header->OptionalHeader.ImageBase;
이제 재배치 테이블을 처리할 준비가 되었습니다.```c typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; DWORD SizeOfBlock; // WORD TypeOffset[1]; } IMAGE_BASE_RELOCATION;
주석 처리된 `TypeOffset` 배열에 주목하세요. 사실 여기서 관련이 있습니다! 재배치 테이블은 조정할 주소를 포함하는 오프셋 블록들로 구성되며, 이러한 블록은 `VirtualAddress` RVA로 식별됩니다. `TypeOffset` 배열에는 재배치 유형과 `VirtualAddress`로부터 조정할 오프셋을 모두 담고 있는 인코딩된 워드 값들이 들어 있습니다. 재배치 유형에 관해서는 64비트 바이너리의 경우, 우리가 관심을 갖는 재배치 유형은 하나뿐입니다. 안타깝게도 이 구조체는 실제로 가변 길이 구조체가 아니므로 `TypeOffset` 배열을 얻으려면 포인터 연산을 좀 해야 합니다.
앞서 언급했듯이 `TypeOffset` 배열에는 인코딩된 워드가 들어 있습니다. 상위 4비트(`0xF000` 마스크)에는 재배치 유형이 포함되어 있으며, 이 유형은 PE 형식 문서의 ["base relocation types" 섹션](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)에서 확인할 수 있습니다. 하위 12비트(`0x0FFF` 마스크)에는 조정할 `VirtualAddress` 인자로부터의 오프셋이 포함됩니다.
이것을 설명하는 일은 꽤나 번거롭고 결국에는 매우 혼란스럽게 느껴집니다. 재배치할 주소에 대한 포인터를 얻는 방법은 다음과 같습니다.```cpp
auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);
그리고 해당 주소를 조정하면 다음과 같습니다:```cpp *ptr += delta;
재배치 디렉터리를 설명하는 것은 복잡하지만, 실제 코드에서의 동작은 이해하기 매우 간단합니다.
`SizeOfBlock` 변수를 사용하면 다음 재배치 데이터 블록으로 쉽게 이동할 수 있습니다. 이 블록에는 헤더의 크기*뿐만 아니라* `TypeOffset` 배열의 크기도 포함되어 있습니다. 만약 `TypeOffset`이 정적 크기 배열이었다면, 재배치 헤더에 대한 `sizeof` 호출을 더하는 것만으로 다음 재배치 항목으로 이동했을 것입니다.
이 모든 설명을 통해, 여러분은 이 재배치 코드를 이해할 수 있을 것입니다:```cpp
// get the relocation table.
auto relocation_table = reinterpret_cast<IMAGE_BASE_RELOCATION *>(image + directory_entry.VirtualAddress);
// when the virtual address for our relocation header is null,
// we've reached the end of the relocation table.
while (relocation_table->VirtualAddress != 0)
{
// since the SizeOfBlock value also contains the size of the relocation table header,
// we can calculate the size of the relocation array by subtracting the size of
// the header from the SizeOfBlock value and dividing it by its base type: a 16-bit integer.
std::size_t relocations = (relocation_table->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(std::uint16_t);
// additionally, the relocation array for this table entry is directly after
// the relocation header
auto relocation_data = reinterpret_cast<std::uint16_t *>(&relocation_table[1]);
for (std::size_t i=0; i<relocations; ++i)
{
// a relocation is an encoded 16-bit value:
// * the upper 4 bits are its relocation type
// (https://learn.microsoft.com/en-us/windows/win32/debug/pe-format see "base relocation types")
// * the lower 12 bits contain the offset into the relocation entry's address base into the image
//
auto relocation = relocation_data[i];
std::uint16_t type = relocation >> 12;
std::uint16_t offset = relocation & 0xFFF;
auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);
// there are typically only two types of relocations for a 64-bit binary:
// * IMAGE_REL_BASED_DIR64: a 64-bit delta calculation
// * IMAGE_REL_BASED_ABSOLUTE: a no-op
//
if (type == IMAGE_REL_BASED_DIR64)
*ptr += delta;
}
// the next relocation entry is at SizeOfBlock bytes after the current entry
relocation_table = reinterpret_cast<IMAGE_BASE_RELOCATION *>(
reinterpret_cast<std::uint8_t *>(relocation_table) + relocation_table->SizeOfBlock
);
}
Congratulations! 이제 우리 이미지를 실행할 준비가 완료되었습니다! 지금까지 많은 작업을 해냈습니다:
이제 언패킹된 바이너리를 실행할 준비가 되었습니다!
이제 메인 루프로 돌아가 보겠습니다:```cpp int main(int argc, char *argv[]) { // first, decompress the image from our added section auto image = get_image();
// next, prepare the image to be a virtual image
auto loaded_image = load_image(image);
// resolve the imports from the executable load_imports(loaded_image);
// relocate the executable relocate(loaded_image);
// get the headers from our loaded image auto nt_headers = get_nt_headers(loaded_image);
// acquire and call the entrypoint auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint; reinterpret_cast<void(*)()>(entrypoint)();
return 0; }
보시다시피, 실행을 전송하는 것은 매우 간단하지만 [함수 포인터](https://en.wikipedia.org/wiki/Function_pointer)에 대한 지식이 필요합니다. 다음은 우리와 관련된 부분입니다:```cpp
// acquire and call the entrypoint
auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint;
reinterpret_cast<void(*)()>(entrypoint)();
AddressOfEntryPoint는 짐작할 수 있듯이 로드된 이미지의 코드 진입점에 대한 RVA입니다. main 진입점에 대해 알고 있는 것과는 달리, 특정 바이너리의 원시 진입점은 타입이 없는 상태입니다-- C++ 컴파일러가 주로 main 또는 WinMain 같은 예상되는 main 함수에 인자를 전달하기 위한 코드 환경을 설정하는 역할을 담당합니다.
기본적으로, 함수 포인터는 다음과 같이 선언됩니다:```c return_type (*variable_name)(int arg1, int arg2, ...)
따라서, 우리의 진입점을 호출하기 위한 함수 포인터는-- 연관된 타입이 없으며-- 다음과 같이 보입니다:```c
void (*entrypoint)()
캐스트로서, 이는 다음과 같이 축약됩니다:```c void(*)()
모든 것을 종합하면, 엔트리포인트를 함수 포인터로 캐스팅하고 같은 줄에서 호출하기만 하면 됩니다. 다음과 같이:```cpp
reinterpret_cast<void(*)()>(entrypoint)();
모든 것이 올바르게 로드되면 패킹된 프로그램이 실행되는 것을 볼 수 있습니다. 우리의 경우 더미 실행 파일을 패킹했기 때문에 단순히 메시지를 출력합니다:``` $ ./packed.exe I'm just a little guy!
축하합니다! 끝났습니다! 방금 Windows 패커를 작성했습니다!
## 추가 연습
* **분석가 공격**: [일부 안티 디버깅 기법](https://anti-reversing.com/Downloads/Anti-Reversing/The_Ultimate_Anti-Reversing_Reference.pdf)을 구현하는 방법을 배우고 패커를 강화하세요.
* **지원 범위 확장**: 재배치 불가능한 바이너리를 구현하는 방법을 배우거나, 스레드 로컬 스토리지 디렉터리(`IMAGE_TLS_DIRECTORY`) 및 리소스 디렉터리(`IMAGE_RESOURCE_DIRECTORY`)와 같은 추가 디렉터리를 구현하는 방법을 배우세요. 이 수준에 대한 문서가 부족하므로, [exe-rs](https://github.com/exe-rs)에서 제 구현을 확인할 수 있습니다.
* **스텁 난독화**: 분석가가 바이너리를 어떻게 언패킹할지 파악하고, [헤더 지우기](https://github.com/frank2/packer-tutorial/blob/main/stub/src/main.cpp#L84)와 같은 방법으로 언패킹을 쉽게 하지 못하도록 방지해 보세요.