
Windows向けパッカーの書き方チュートリアル!

難しそうに思えますか? このREADMEを要約したプレゼンテーション版を試してみてください。YouTube動画も近日公開予定です!
パッカーは、自身のアドレス空間(あるいは別プロセスのアドレス空間)内で別のプログラムを展開し起動するプログラムです。デバッガや仮想サンドボックスなどの解析環境を攻撃するベクターとして知られることもあります。主に以下のような用途で使われます:
基本的に、パッカーにはほんのいくつかの基本ステップしかありません:
同様にシンプルに、パッカーはいくつかの構成要素だけで成り立っています:
パッカーの構築は厄介な場合があります。なぜなら、スタブをビルドし、何らかの方法でパッカー実行可能ファイルに組み込む必要があるからです。Windowsでは、単純なコンパイルを超えたVisual Studioのビルドシステムを学ぶのは骨の折れる作業になり得ます。幸い、CMakeはVisual Studioをサポートするシンプルでクロスプラットフォームなビルドシステムを提供し、高度にカスタマイズ可能です!
このチュートリアルでは、以下を教えることを目的としています:
すでにC++とCMakeに精通している方は、パッキングのセクションに直接進んでください。そうでない場合は、読み進めてください!
まず、このビルドシステムが機能し、適切にパックされた実行可能ファイルを作成することを何らかの方法で確認しましょう。CMakeとVisual Studioをインストールしたら、お好みのターミナルでこのリポジトリのルートディレクトリに移動し、次のコマンドを実行してください:``` $ mkdir build $ cd build $ cmake ../
これにより、packerチュートリアルコードをビルドするために必要なプロジェクトファイルが作成されます。次に実行します:```
$ cmake --build ./ --config Release
This will build the packer project in Release mode. Then, you can run the following test:
これを実行すると、Release モードで packer プロジェクトがビルドされます。次に、次のテストを実行できます:```
$ ctest -C Release ./
すべてがうまくいけば、test\_pack と test_unpack は成功するはずです。パッキングの結果を自分で確認したい場合は、次のように表示されるはずです:```
$ ./packed.exe
I'm just a little guy!
これを実現したすべての構成要素について説明しましょう。
パッカーの主要コンポーネントが何であるかはわかりましたが、開発サイクルの一環としてパッカーをすぐにテストするにはどうすればよいでしょうか? バイナリのパックおよびアンパック処理を適切にテストするには、プロジェクト全体に3つ目のバイナリを追加する必要があります。つまり、全体として次の3つのプロジェクトが必要です:
また、バイナリがスタブ実行ファイルに圧縮されるようにするための圧縮ライブラリも必要です。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;
}
すでに、対処すべき単純な依存関係チェーンがあることを確認しておきましょう。これは、CMake がいくつかの設定を追加することでうまく解決してくれます。
では、CMake の設定として、ルートプロジェクト、つまり packer から始めましょう。
CMake を扱うには、通常、最低バージョンの指定が必要です。CMake は長い間存在しており、以前のバージョンの長期的な使用をサポートしているためです。その後、packer プロジェクトを C++ プロジェクトとして宣言できます。```cmake
cmake_minimum_required(VERSION 3.24)
project(packer CXX)
また、パッカーを「MultiThreaded」(すなわち /MT)として宣言し、「MultiThreadedDLL」(すなわち /MD)の代わりにすることで、ランタイムDLLの依存関係を気にする必要がなくなります。```cmake
# this line will mark our packer as MultiThreaded, instead of MultiThreadedDLL
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
変数設定文の山かっこ内の文はジェネレータ式と呼ばれ、必要な場所で構成時のデータを解決するのに役立ちます。このファイル全体で頻繁に目にすることになるでしょう。このジェネレータ式は、構成がDebugであることを検出すると文字列Debugを出力し、それ以外の場合は何も出力しません。これにより、Debugコンパイルプロファイルが選択された場合はMultiThreadedDebugのランタイムライブラリが提供され、Releaseなどの非デバッグコンパイルプロファイルが選択された場合はMultiThreadedが提供されます。そこで何が起こっているのかをよく理解するには、条件付きジェネレータ式を参照してください。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はより正確には**メイクシステム**と表現すべきであることを説明しておきます。CMakeは、指定されたコンパイラに基づいて、与えられた環境に適した正しい「メイクシステム」を作成します。Linuxでは、これは検出された(または指定された)コンパイラ用のmakefileになります。Windows上の私たちにとっては、これは私たちのVisual Studioのバージョンと互換性のあるVisual Studioプロジェクトを生成します。つまり、MSVCメイクシステムを作成したら、必要に応じてVisual Studioだけをすべてに使用できるということです! 結局のところ、あなたはCMakeを使ってVisual Studioを構成しているのです。つまり、ここで行っていることは、プロジェクトをGUIで作成するときにVisual Studioが自動的に行う、プロジェクト内のファイルのツリーを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 は構成に基づいてバイナリをフォルダに出力するため、`$<CONFIG>` ジェネレータステートメントを使用して、ビルドの現在の構成を取得します。また、`$<TARGET_FILE:stub>` ジェネレータステートメントを使用して、stub バイナリがコンパイルされた後の実行可能ファイル名を出力します。これらのファイル(生成された RC ファイルと生成されたヘッダー)をプロジェクトに含めると、stub バイナリをパッカー プロジェクトに正常に統合できます。
次に、先ほど収集したファイルを使用して、パッカー プロジェクトの実行可能ファイルを宣言します。```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 もビルドステップの一部として生成します)、プロジェクトのインクルードヘッダーに動的ディレクトリを含める必要があります。CMake ではプロジェクトがプロジェクトのルートにあることに依存しているため、スタブバイナリにも zlib のインクルードディレクトリを追加します:```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に依存関係の状況を伝えます。スタブをパッカーの依存関係として、パッカーをダミーの依存関係としてマークします。```cmake
add_dependencies(packer stub) add_dependencies(dummy packer)
[スタブ](https://github.com/frank2/packer-tutorial/blob/main/stub/CMakeLists.txt) と [ダミー](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.rsとstub.hppファイルを含む、ビルドシステム内のすべての生成ファイルを手動でクリーンアップする必要があります。
おめでとうございます!パッカーのビルドとテストを成功させるために、多くの作業が行われました。さて、野菜を食べ終えたところで、私たちのパッカーは以下のことができるようになりました:
では、いよいよ本題に移りましょう!
コンパイラをセットアップして、スタブ実行可能ファイルをリソースとしてパッカーバイナリにコンパイルすることには成功しました。しかし、どうやってバイナリをパックされた状態でスタブに取り込むのでしょうか?パッカーのエンドユーザーがコンパイラを使うことを期待できないため、リソースとして追加することはできません。パッカー実行ファイルはスタンドアロンソリューションであることを想定されています。
私がよく使ってきたテクニックの1つ(分析者には明らかかもしれませんが)は、スタブバイナリに新しいセクションを追加し、最終的に実行時にロードするというものです。これは、PE形式のクラッシュコースを兼ねることになります。しかし、まず、リソースからデータを取り出すにはどうすればよいでしょうか?
実行時にバイナリからリソースデータを取得するには、3つの基本的なステップがあります:
次の関数は、特定のバイナリからリソースを検索して取得する方法を示しています:```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実行可能ファイルは、その最も基本的な構成要素として、*ヘッダー* と *セクションデータ* の2つの部分に分かれます。ヘッダーにはローディングプロセスに重要なメタデータが多数含まれており、セクションデータはその名の通りデータであり、実行可能コード(例:`.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の名残です。ここでは、このヘッダーの2つの値、e_magicとe_lfanewだけに注目します。e_magicは、イメージの先頭にあるマジックヘッダー値、つまりファイルの先頭の"MZ"のことです。e_lfanewは、ファイルの先頭からNTヘッダーまでのオフセットで、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ファイルに任意のデータを適切に追加するには、特に*ファイルアライメント*に注意する必要があります——後でメモリ内の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を更新することで、セクション数を簡単に増やすことができます。通常、最後のセクションテーブルの後ろのデータはゼロで埋められているため、そのゼロデータを私たちの新しいセクションで簡単に上書きできます。```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` です。この時点で、ファイルアラインメントとメモリアラインメントという2種類のアラインメントを認識する必要がある理由は、与えられたPE実行可能ファイルには、ロードプロセスの結果として、*ディスク上*での見え方と*メモリ内*での見え方という2つの異なるメモリ状態が存在するためです。特定の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を読み取る
まず、実行中のバイナリからパッカーが作成したセクションのデータを何とかして取得する必要があります。実行中のバイナリのヘッダーを実行時に取得することは可能でしょうか?はい、絶対に可能です!null引数を指定した [`GetModuleHandleA`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlea) は、最終的に実行中の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インポートの解決
オプショナルヘッダー内には、*データディレクトリ*と呼ばれるものがあります。このディレクトリには、イメージがエクスポートするシンボルや、アイコンやビットマップなどのリソースといった、実行可能ファイルに関するさまざまな情報が含まれています。このチュートリアルでは、**インポートディレクトリ**と**リロケーションディレクトリ**という2つのデータディレクトリを解析します。各ディレクトリはハードコードされており、そのインデックスは[オプショナルヘッダーのドキュメント](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インポートを解決するために、ローダーはこのディレクトリを解析し、その後、必要なライブラリをロードして、それらのインポートされた関数を取得します。幸いなことに、このディレクトリは比較的解析が容易です。
それはインポート記述子構造から始まります:```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;
私たちが最も注目しているのは、紛らわしい名前を持つ2つの変数、OriginalFirstThunkとFirstThunkです。OriginalFirstThunkには、この実行可能ファイルがName RVAで指定されたDLLに関連して必要とするインポートに関する情報が含まれています。さらに紛らわしいことに、FirstThunkにも同様の情報が含まれています。この2つの違いは何でしょうか?FirstThunkには、解決後のインポートが含まれています。この解決は、悪名高いGetProcAddress関数によって実行されます。
私たちのサンクは、対処すべき追加のデータ構造です。```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;
このデータ構造はインポートサンクとエクスポートサンクの両方をカバーするため、`ForwarderString` は無視できます。インポートサンクは、`Ordinal` または別の構造体 `IMAGE_IMPORT_BY_NAME` へのRVAのいずれかです。*序数* は、単に指定されたDLLのエクスポートテーブル内のオフセットです。`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 はゼロ終端のC文字列であることが期待されます。
バイナリデータには型が含まれないため、オーディナルとインポートは、サンクエントリの最上位ビットによって区別されます。オーディナルは、32ビットと64ビットの両方の実装で、整数の下半分に含まれます。
インポートテーブルはC文字列のように機能します-- 最後のエントリは、潜在的なインポートの終わりを示すためにNULL終端されたOriginalFirstThunkです。サンクデータも同様に機能し、サンク配列内のNULLエントリによって終了します。
すべてをまとめると、インポートテーブルの解析は次のような擬似コードで行います:``` 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文字列としてキャストするだけで序数によるインポートを期待します。まったく不思議な話です。
これらの説明を踏まえると、この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;
}
インポートが解決されたので、いよいよ最後の部分である、リロケーション ディレクトリ(再配置ディレクトリ)の処理に移ることができます。
次に取り組むべきデータ ディレクトリは、リロケーション ディレクトリと呼ばれるものです。このディレクトリは、コード内の絶対アドレスを新しいベース値に変換する役割を担っています。この処理は、基本的には アドレス空間配置のランダム化(ASLR)と呼ばれるものを実装するものですが、DLL のアドレス空間同士が衝突しないようにする役割も担っています。
まず、対象のバイナリが実際にアドレス ベースを移動できることを確認する必要があります。特に古いバイナリでは、これが有効になっていないことがあります。特定の Windows 実行可能ファイルには、その特性を示す値がオプション ヘッダー内にあり、紛らわしくも DllCharacteristics と呼ばれています。ここで注目するのは "dynamic base" 特性です。非 ASLR バイナリをアンパックする特別な方法もありますが、それについては説明しないため、バイナリがこの特性をサポートしていない場合はエラーとします。(これは、スタブではなくパッカー側でエラーにする方が良いのですが、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ビットバイナリでは、1つの再配置の種類だけを気にすればよいです。残念ながら、この構造体は実際には可変長構造体ではないため、`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! Our image is now ready for execution! We've accomplished a lot so far:
Now we're ready to run our unpacked binary!
Let's go back to our main loop now:```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) などによってアンパックを容易にできないようにしましょう。