0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Windowsで動くx64のシェルコードを書く【1】

Last updated at Posted at 2025-02-16

はじめに

こんにちは、今回はWindowsで動くx64のシェルコードを作ります。
具体的には、WinExec関数から電卓を起動するプログラムをシェルコードとして作成します。

下記の手順で作成します。

  1. C言語で電卓を起動するプログラムを作成する
  2. 作成したプログラムをシェルコードへ改変する

シェルコードの作成は独特なテクニックを要するため、手順を追ってなるべく丁寧に解説していきます。
本記事では手順1を解説し、手順2は次の記事で解説します。

手順1:Cプログラムの作成

電卓を起動するCプログラムを作成します。手順2でシェルコードへ改変するので、開発環境は何でも構いません。先にソースコードを示します。

shellcode_x64_windows.c
shellcode_x64_windows.c
#include<Windows.h>
#include<winternl.h>

/* ROR13アルゴリズム */
unsigned int ror13(unsigned int value)
{
    return (value >> 13) | (value << (32 - 13));
}

/* DLL名をハッシュ化する関数 */
//   KERNEL32.DLL    0x6E2BCA17
unsigned int whash( const PWSTR buffer )
{
    if (buffer == NULL)
    {
        return 0x00;
    }
    unsigned int hash = 0;
    size_t len = wcslen(buffer);
    for (size_t i = 0; i < len; i++) {
        hash = ror13(hash);
        hash += buffer[i];
    }
    return hash;
}
/* DLLのエクスポート関数名をハッシュ化する関数 */
//  WinExec     0x0E8AFE98
unsigned int hash(const PSTR buffer)
{
    if (buffer == NULL)
    {
        return 0x00;
    }
    unsigned int hash = 0;
    size_t len = strlen(buffer);
    for (size_t i = 0; i < len; i++) {
        hash = ror13(hash);
        hash += buffer[i];
    }
    return hash;
}

int main()
{
    /* PEBのアドレスを取得する */
    // x64の場合、PEBはGSセグメントの先頭から+0x60に配置される
    // __readgsqwordは組み込み関数であるためシェルコードでも使用できる
    // PEBという組み込み型が用意されているため、そのポインタ型であるPPEBでパースする
    PPEB peb = (PPEB)__readgsqword(0x60);
	/* kernel32.dllのベースアドレスを取得する */
	PVOID kernel32 = NULL;
    PLIST_ENTRY start = peb->Ldr->InMemoryOrderModuleList.Flink;
    for (PLIST_ENTRY entry = start->Flink; entry != start; entry = entry->Flink)
    {
        // InMemoryOrderModuleList.FlinkはLDR_DATA_TABLE_ENTRY構造体のInMemoryOrderLinksメンバのアドレスを指す
        // 他のメンバへは下記の2方法でアクセスできる
        // ・オフセットを計算して(Flink + オフセット)でアクセスする
        // ・CONTAINING_RECORDマクロを使う
        // 今回はオフセットを計算するのが面倒なのでマクロを使用した
        PLDR_DATA_TABLE_ENTRY dllInfo = CONTAINING_RECORD(entry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
        // ドキュメント化されていないが、Reserved4がBaseDllNameである
        // PUNICODE_STRING型であるため型変換して文字列を取り出す
        PUNICODE_STRING BaseDllName = (PUNICODE_STRING)dllInfo->Reserved4;
        if ( whash( BaseDllName->Buffer ) == 0x6E2BCA17)
        {
            kernel32 = dllInfo->DllBase;
            break;
        }
    }

    /* WinExecの関数アドレスを取得する */
    // 配列としてアクセスできるように、取得したアドレスを適切なサイズの型へ変換する
    // ・AddressOfNamesは8バイトのRVAが格納されているのでULONG *でキャストする
    // ・AddressOfNameOrdinalsは2バイトの序数が格納されているのでWORD *でキャストする
    // ・AddressOfFunctionsは8バイトのRVAが格納されているのでULONG *でキャストする
    PVOID winExec = NULL;
    PIMAGE_DOS_HEADER kernel32DosHeader = (PIMAGE_DOS_HEADER)kernel32;
    PIMAGE_NT_HEADERS kernel32NtHeader = (PIMAGE_NT_HEADERS)((size_t)kernel32DosHeader + (size_t)kernel32DosHeader->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY kernel32ExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((size_t)kernel32DosHeader + (size_t)kernel32NtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
    ULONG *rvaOfNames = (size_t)kernel32DosHeader + (size_t)kernel32ExportDirectory->AddressOfNames;
    WORD *rvaOfNameOrdinals = (size_t)kernel32DosHeader + (size_t)kernel32ExportDirectory->AddressOfNameOrdinals;
    ULONG *rvaOfFunctions = (size_t)kernel32DosHeader + (size_t)kernel32ExportDirectory->AddressOfFunctions;

    for (unsigned int i = 0; i < kernel32ExportDirectory->NumberOfNames; i++)
    {
        if ( hash( (PSTR)((size_t)kernel32DosHeader + (size_t)rvaOfNames[i]) ) == 0x0E8AFE98)
        {
            winExec = (size_t)kernel32DosHeader + (size_t)rvaOfFunctions[rvaOfNameOrdinals[i]];
            break;
        }
    }

    /* 電卓起動 */
    typedef UINT(*WINEXEC)(LPCSTR, UINT);
	((WINEXEC)winExec)("calc.exe", 0);
}

ポイントは3つあります。

  1. PEBから関数アドレスを取得すること
  2. 名前の検索をハッシュ値で行うこと
  3. 関数ポインタでアドレスを呼び出すこと

以下、詳細な説明です。

PEBから関数アドレスを取得する

一般に、プログラムがDLLのエクスポート関数を使用するには、関数のアドレスを取得する必要があります。取得した関数アドレスをcallのオペランドに指定することで、関数を呼び出すことができます。実際に、先に示したプログラムも、計算したWinExecの関数アドレスをcallすることで電卓を起動しています。

図1.png

一般的なプログラムの場合、関数アドレスの解決方法は、DLLのリンクの仕方によって異なります。詳しくは、Microsoftのドキュメント「実行可能ファイルとDLLのリンク」に書かれていますが、簡単に概要だけ説明します。

  • プログラムがDLLを暗黙的にリンクしている場合
    暗黙的リンクとは、#includeでヘッダーファイルをインポートし、コンパイル時にlibファイルをリンクすることです。プログラムがDLLを暗黙的にリンクしている場合、関数アドレスはローダーによって解決されます。コンパイル時にリンカーがcallのオペランドをIATエントリで仮置きし、メモリへのロード時にローダーがIATを更新します。

  • プログラムがDLLを明示的にリンクしている場合
    明示的リンクとは、LoadLibraryAなどを使ってDLLをロードすることです。プログラムがDLLを明示的にリンクしている場合、GetProcAddressを使って関数アドレスを取得する必要があります。少なくともLoadLibraryAをエクスポートしているkernel32.dllを暗黙的にリンクしなければ、明示的リンクは成立しません。

シェルコードが関数アドレスを取得する方法を考えます。
シェルコードは、攻撃者がプログラムのインターフェイスからスタック領域やヒープ領域へ流し込むものであり、ローダーによって読み込まれることを想定していません。よって、上記に示したような正規のリンク方法は利用できず、別の方法で関数アドレスを取得する必要があります。

そこで、ロード済みのDLLのベースアドレスをPEBから取得します。DLLのベースアドレスが分かれば、PEヘッダーをパースすることで、エクスポート関数のアドレスを取得できます。PEBはローダーが生成するため、実質ローダーの後追いをしているといえます。

PEBとは、Windowsが実行中のプロセス情報を格納するインメモリーの構造体です。ローダーは、DLLをロードした時に、PEBにDLLの情報を書き込みます。詳しくは、Microsoftのドキュメント「PEB構造体」を参照ください。

Windowsのユーザープログラムを実行すると、暗黙的リンクとは関係なく、必ずntdll.dllkernel32.dllがロードされるという仕様があります。プログラムはそれらがロードされたアドレスを事前に知ることができないため、LoadLibraryAなどを使用して再ロードすることが一般的ですが、関数情報を隠匿したいマルウェアやローダーの影響を受けないシェルコードは、PEBからの情報取得を試みます。

PEBから関数アドレスを取得する具体的な手順は下記の通りです。

  1. PEBのアドレスを取得する
    PEBは、プログラムの読み込み時にローダーが生成するため、事前にアドレスを知ることはできません。しかし、x86の場合はFSセグメントの先頭から+0x30の位置に、x64の場合はGSセグメントの先頭から+0x60の位置に配置するという決まりがあります。FS/GSセグメントからメモリを読み取れる専用の関数があるため、それを利用してPEBにアクセスします。
    具体的なコードは下記の通りです。

    /* PEBのアドレスを取得する */
    // x64の場合、PEBはGSセグメントの先頭から+0x60に配置される
    // __readgsqwordは組み込み関数であるためシェルコードでも使用できる
    // PEBという組み込み型が用意されているため、そのポインタ型であるPPEBでパースする
    PPEB peb = (PPEB)__readgsqword(0x60);
    

    FSセグメント/GSセグメントとは、メモリの特定領域を指す用語です。背景が複雑であるため、解説は他の記事に譲ります。

  2. DLLのベースアドレスを取得する
    PEB構造体を下記に示します。先頭から+0x0CにPEB_LDR_DATA構造体へのポインタが存在することが分かります。

    typedef struct _PEB {
      BYTE                          Reserved1[2];
      BYTE                          BeingDebugged;
      BYTE                          Reserved2[1];
      PVOID                         Reserved3[2];
      PPEB_LDR_DATA                 Ldr;
      PRTL_USER_PROCESS_PARAMETERS  ProcessParameters;
      PVOID                         Reserved4[3];
      PVOID                         AtlThunkSListPtr;
      PVOID                         Reserved5;
      ULONG                         Reserved6;
      PVOID                         Reserved7;
      ULONG                         Reserved8;
      ULONG                         AtlThunkSListPtr32;
      PVOID                         Reserved9[45];
      BYTE                          Reserved10[96];
      PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
      BYTE                          Reserved11[128];
      PVOID                         Reserved12[1];
      ULONG                         SessionId;
    } PEB, *PPEB;
    

    PEB構造体はドキュメント化されていないメンバが多く存在し、OSのバージョンアップによって日々更新され続けています。ドキュメント化されていないメンバも含めたホスト環境の正確な情報は、WinDbgを使用して確認することをお勧めします。
    図2.png

    PEB_LDR_DATA構造体を下記に示します。先頭から+0x20にLIST_ENTRY構造体が存在することが分かります。

    typedef struct _PEB_LDR_DATA {
      BYTE       Reserved1[8];
      PVOID      Reserved2[3];
      LIST_ENTRY InMemoryOrderModuleList;
    } PEB_LDR_DATA, *PPEB_LDR_DATA;
    

    LIST_ENTRY構造体を下記に示します。LIST_ENTRY構造体は、LIST_ENTRYの線形リストになっており、一周すると最初のノードへ戻ってきます。

    typedef struct _LIST_ENTRY {
      struct _LIST_ENTRY *Flink;
      struct _LIST_ENTRY *Blink;
    } LIST_ENTRY, *PLIST_ENTRY, PRLIST_ENTRY;
    

    LIST_ENTRY構造体は、LDR_DATA_TABLE_ENTRY構造体の一部になっています。具体的には、peb->Ldr->InMemoryOrderModuleList.Flinkは、LDR_DATA_TABLE_ENTRY構造体のInMemoryOrderLinksメンバのアドレスを指しています。つまり、Flinkの指すアドレスからLDR_DATA_TABLE_ENTRY構造体の先頭アドレスを計算し、先頭アドレスをLDR_DATA_TABLE_ENTRY型でパースすれば、他メンバへ綺麗にアクセスできます。

    LDR_DATA_TABLE_ENTRY構造体は、下記に示す通り、DLL名やDLLのベースアドレスなどの情報を保持しています。よって、LIST_ENTRY構造体の線形リストを探索すれば、目的のDLLのベースアドレスを取得することが可能です。

    typedef struct _LDR_DATA_TABLE_ENTRY {
      PVOID Reserved1[2];
      LIST_ENTRY InMemoryOrderLinks;
      PVOID Reserved2[2];
      PVOID DllBase;
      PVOID EntryPoint;
      PVOID Reserved3;
      UNICODE_STRING FullDllName;
      BYTE Reserved4[8];
      PVOID Reserved5[3];
      union {
        ULONG CheckSum;
        PVOID Reserved6;
      };
      ULONG TimeDateStamp;
    } LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
    

    LDR_DATA_TABLE_ENTRY構造体はドキュメント化されていません。バージョンアップにより更新されることが少ないとの情報から、下記サイトを参考にしました。
    NirSoft - struct LDR_DATA_TABLE_ENTRY

    具体的なコードを下記に示します。FlinkからLDR_DATA_TABLE_ENTRY構造体をパースする処理は、組み込みのCONTAINING_RECORDマクロを使用しました。CONTAINING_RECORDマクロはWindowsの組み込みマクロであり、ドキュメントが存在します。

    探索の終了条件は「線形リストが一周する or 目的のDLLが見つかる」です。ただし、今回探しているkernel32.dllは必ずロードされているので、前者の条件が使用されることはありません。

    目的のDLLは、DLLのファイル名の一致から判断しました。DLLのファイル名は、ドキュメント化されていないReserved4メンバに格納されています。また、ファイル名の比較にハッシュを使っていますが、その詳細は後で説明します。

    /* kernel32.dllのベースアドレスを取得する */
    PVOID kernel32 = NULL;
    PLIST_ENTRY start = peb->Ldr->InMemoryOrderModuleList.Flink;
    for (PLIST_ENTRY entry = start->Flink; entry != start; entry = entry->Flink)
    {
        // InMemoryOrderModuleList.FlinkはLDR_DATA_TABLE_ENTRY構造体のInMemoryOrderLinksメンバのアドレスを指す
        // 他のメンバへは下記の2方法でアクセスできる
        // ・オフセットを計算して(Flink + オフセット)でアクセスする
        // ・CONTAINING_RECORDマクロを使う
        // 今回はオフセットを計算するのが面倒なのでマクロを使用した
        PLDR_DATA_TABLE_ENTRY dllInfo = CONTAINING_RECORD(entry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
        // ドキュメント化されていないが、Reserved4がBaseDllNameである
        // PUNICODE_STRING型であるため型変換して文字列を取り出す
        PUNICODE_STRING BaseDllName = (PUNICODE_STRING)dllInfo->Reserved4;
        if ( whash( BaseDllName->Buffer ) == 0x6E2BCA17)
        {
            kernel32 = dllInfo->DllBase;
            break;
        }
    }
    

    目的のDLLを見つける方法として、ファイル名でなく序数も使用できます。ntdll.dllkernel32.dllkernelbase.dllのロード順は決まっていることが多く、探索せずに決め打ちしたほうが早いです。今回は、将来的な拡張性を考え、ファイル名の比較としました。

  3. 関数アドレスを取得する
    DLLのベースアドレスには、DLLの実行可能イメージが配置されています。実行可能イメージとは、メモリにロードされた実行可能ファイルのコピーです。よって、DLLのベースアドレスは、DLLファイルのPEヘッダーの先頭であり、DOSヘッダーの先頭でもあります。

    正確には、ロード時にセクションのアライメントやIATの更新などが行われるため、「実行可能イメージ=実行可能ファイルのコピー」ではありません。「イメージ」に対する公式の解釈はここに記載されています。

    PEヘッダーの解説は他の記事に譲り、ここではコードのみを提示します。DLLのベースアドレスから関数アドレスを取得する処理は下記の通りです。

    /* WinExecの関数アドレスを取得する */
    // 配列としてアクセスできるように、取得したアドレスを適切なサイズの型へ変換する
    // ・AddressOfNamesは8バイトのRVAが格納されているのでULONG *でキャストする
    // ・AddressOfNameOrdinalsは2バイトの序数が格納されているのでWORD *でキャストする
    // ・AddressOfFunctionsは8バイトのRVAが格納されているのでULONG *でキャストする
    PVOID winExec = NULL;
    PIMAGE_DOS_HEADER kernel32DosHeader = (PIMAGE_DOS_HEADER)kernel32;
    PIMAGE_NT_HEADERS kernel32NtHeader = (PIMAGE_NT_HEADERS)((size_t)kernel32DosHeader + (size_t)kernel32DosHeader->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY kernel32ExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((size_t)kernel32DosHeader + (size_t)kernel32NtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
    ULONG *rvaOfNames = (size_t)kernel32DosHeader + (size_t)kernel32ExportDirectory->AddressOfNames;
    WORD *rvaOfNameOrdinals = (size_t)kernel32DosHeader + (size_t)kernel32ExportDirectory->AddressOfNameOrdinals;
    ULONG *rvaOfFunctions = (size_t)kernel32DosHeader + (size_t)kernel32ExportDirectory->AddressOfFunctions;
    
    for (unsigned int i = 0; i < kernel32ExportDirectory->NumberOfNames; i++)
    {
        if ( hash( (PSTR)((size_t)kernel32DosHeader + (size_t)rvaOfNames[i]) ) == 0x0E8AFE98)
        {
            winExec = (size_t)kernel32DosHeader + (size_t)rvaOfFunctions[rvaOfNameOrdinals[i]];
            break;
        }
    }
    

    ポイントは、ヘッダーの各要素のサイズを正確に把握し、適切な型でキャストすることです。異なるサイズのポインタでキャストすると、配列参照した場合にオフセットがズレてしまいます。例えば、今回はx64のシェルコードを作成しているため、RVAの横幅は8バイトです。よって、8バイトの大きさをもつ型へのポインタ(上記ではULONG*)でキャストしています。一方、EXPORT_DIRECTORYの序数は2バイトです。よって、2バイトの大きさをもつ型へのポインタ(上記ではWORD*)でキャストしています。
    関数名の比較にハッシュを使っていますが、その詳細はDLL名と同じく、次項で説明します。

名前の検索をハッシュ値で行う

前項で作成したコードでは、目的のDLLやエクスポート関数を見つけるために行った文字列比較において、ハッシュ値を利用しています。これは、文字列を難読化するために行われる「API Hashing」というテクニックです。

単純に文字列を比較する場合、その実装方法によらず、必ず文字列(もしくは、その断片)をソースコードへハードコーディングする必要があります。しかし、ハードコーディングされた文字列は、stringsなどを用いた簡単な表層解析によって抽出され得るため、攻撃者にとって都合が良くありません。そのため、関数情報を隠匿したいマルウェアやシェルコードでは、しばしば文字列の難読化が行われます。

文字列の難読化手法はいくつか存在し、単純な文字列の分解やXOR、Stack Strings、API Hashingなどがあります。今回は、実際の攻撃コードでも採用率の高い、ROR13を使った「API Hashing」を採用しました。アルゴリズムの詳しい解説は、こちらの記事などに詳しく書かれていますので、この記事では実装コードのみを示します。

/* ROR13アルゴリズム */
unsigned int ror13(unsigned int value)
{
    return (value >> 13) | (value << (32 - 13));
}

/* DLL名をハッシュ化する関数 */
//   KERNEL32.DLL    0x6E2BCA17
unsigned int whash( const PWSTR buffer )
{
    if (buffer == NULL)
    {
        return 0x00;
    }
    unsigned int hash = 0;
    size_t len = wcslen(buffer);
    for (size_t i = 0; i < len; i++) {
        hash = ror13(hash);
        hash += buffer[i];
    }
    return hash;
}
/* DLLのエクスポート関数名をハッシュ化する関数 */
//  WinExec     0x0E8AFE98
unsigned int hash(const PSTR buffer)
{
    if (buffer == NULL)
    {
        return 0x00;
    }
    unsigned int hash = 0;
    size_t len = strlen(buffer);
    for (size_t i = 0; i < len; i++) {
        hash = ror13(hash);
        hash += buffer[i];
    }
    return hash;
}

関数ポインタでアドレスを呼び出す

WinExecの関数アドレスを取得したら、そのアドレスをcallすることで電卓を起動できます。アセンブリの場合、引数をスタックに積んだ状態でcallのオペランドにアドレスを指定すれば関数を呼び出せますが、それをC言語で表現する場合は「関数ポインタ」を使用します。

関数ポインタは、関数のアドレスを格納する専用のポインタ変数であり、C言語の仕様で定義されています。通常のポインタは、逆参照演算子(*)を付けた時に何バイトを読み取るべきかを持っておく必要があるため、ポインタの型と共に宣言されます。例えば、2バイトを逆参照可能なポインタは下記のように宣言されます。

// 2バイトを逆参照可能なポインタの宣言
WORD *two_byte_pointer;

一方で、関数ポインタは、逆参照した時のために「戻り値の型は何か」「引数の型と個数は何か」を持っておく必要があり、それらと一緒に宣言される必要があります。関数ポインタへの代入は、通常のポインタと同じくアドレスを格納した変数の値を代入すれば問題ありません。また、関数ポインタを使うときは、ポインタの直後に括弧を付ければ逆参照とみなされるため、演算子(*)は不要です。例を下記に示します。

int function(int arg)
{
    return arg + 1;
}
// int型を一つ引数に取り、int型の戻り値をもつ関数のポインタの宣言
int (*int_func)(int);
// 関数ポインタへの代入(定義された関数の場合、関数名にはアドレスが格納されている)
int_func = function;
// 関数ポインタの逆参照
int ret = int_func(30)

今回のシェルコードでは、WinExecのアドレスが変数winExecに格納されています。よって、MicrosoftのドキュメントなどからWinExecの引数と戻り値を調査し、winExecを正しく逆参照できるポインタを宣言すれば、関数を呼び出すことが可能です。下記コードでは、typedefで関数ポインタの型だけを定義し、winExecをそのままキャストして使うことで実現しています。

/* 電卓起動 */
typedef UINT(*WINEXEC)(LPCSTR, UINT);
((WINEXEC)winExec)("calc.exe", 0);

コンパイルと動作確認

以上で、シェルコードの処理を一通り解説し終えました。実際にコンパイルして、生成されたexeファイルから電卓を起動できることを確認します。

図3.png

次回は、「手順2:シェルコードへの変換」を行います。アセンブリ言語への深い知識を要求されるため、今回と同じくなるべく丁寧に解説できればと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?