██████╗ ███████╗ █████╗ ████████╗██╗ ██╗███████╗██╗ ███████╗███████╗██████╗
██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔══██╗
██║ ██║█████╗ ███████║ ██║ ███████║███████╗██║ █████╗ █████╗ ██████╔╝
██║ ██║██╔══╝ ██╔══██║ ██║ ██╔══██║╚════██║██║ ██╔══╝ ██╔══╝ ██╔═══╝
██████╔╝███████╗██║ ██║ ██║ ██║ ██║███████║███████╗███████╗███████╗██║
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝╚═╝
現在のスレッドを終了し、実行再開前に復元し、さらに実行中でないときにページ保護の変更を実装する、回避技術のPoC実装です。

スリープおよび難読化手法は、maldevコミュニティでよく知られており、さまざまな実装があります。目的は、スリープ中にメモリスキャナーから隠れることで、通常はページ保護を変更し、シェルコードの暗号化などの優れた機能を追加することもあります。しかし、シェルコードを隠すためのもう一つの重要なポイントがあります。それは、現在の実行スレッドを隠すことです。 スタックのスプーフィングはクールですが、少し考えた後、スタックがなければスタックをスプーフィングする必要はないのではないかと思いました :)
このテクニックの有用性は読者の評価に委ねますが、いずれにせよ、いくつかのトピックを復習し、私のようにこの世界に足を踏み入れたばかりの人にとっては、maldevを学ぶためのクールな方法だと思います。
ここで示す主な実装では、スタックから取り出す必要があるすべてのものをデータセクションにグローバル変数として保持しますが、すべてをヒープに移動する実装も間もなく公開されます。このコードを PIC かつインジェクタブルにするために必要な主要な変更を示すことを目的としています。
このリポジトリは GitHub と GitLab 間でミラーリングされています。
ここに記載されていることはすべて、読み物または開発中の経験から得た、さまざまなトピックに対する私の理解に基づいています。私は専門家ではないこと、そして最も避けたいのは誤った情報を広めることであることを自覚しています。そのため、何か正しくないと思われる点があれば、Twitter で連絡していただくか、このリポジトリで issue を開いていただければ幸いです。ご理解いただきありがとうございます。
このテクニックの主な目的は明確です。現在のスレッドを終了し、実行再開前に復元することです。しかし、これは具体的に何を意味し、どのような新しい制約が生じるのでしょうか? 実行を復元するには、スレッドを終了する前に2つのことを保存する必要があります。1つ目は CPU の状態、2つ目はスタックです。そして、新しいスレッドが起動された後に、それらを効果的に再設定する必要があります。
このテクニックで生じる新しい制約について述べましたが、主なものは2つあります。1つ目は、スレッドが終了してからスタックが復元されるまでの間に必要なものはすべてスタックの外に保存する必要があることです。これにより、いくつかの新しい課題が生じることが後でわかります。 2つ目は、プロセス内に常に少なくとももう1つのスレッドが実行されている必要があることです。なぜなら、自スレッドを終了しているため、他のスレッドがなければプロセスは終了するからです。これは大きな問題ではないと思います。ほとんどのエージェントは他のプロセスにインジェクションされるため、そのプロセスには少なくとも1つのスレッドが実行され続けると想定できます。
この POC には4つのコア関数があります:
スタックを保存しようとするとき、疑問が生じます: スタックのどの程度を保存する必要があるのでしょうか?
DeathSleep 関数(コンテキスト、スタックを保存し、難読化と復元のためのすべてを準備する関数)を呼び出した後のスタックの内容を最初に確認しましょう。

見てわかるように、すべての関数には3つの部分があります:
明らかに保存する必要があるスタックの最小部分は、メインプログラム内のすべて、つまりそのシャドウスペース、リターンアドレス、および DeathSleep 関数までのすべてです。 それ以前のものは実際には必要ありません(エントリ関数で使用されたスタックを保存することには利点がありますが、これについては後で説明します)。なぜなら、それは新しいスレッドを起動するための Windows ルーチンによって使用されるスタックだからです。 これに加えて、DeathSleep 関数のシャドウスペースも保存することにしました(実際には必要ありませんが、起床時の Rsp の計算が容易になります)。
したがって、最終的には以下を保存しています:

標準的なコンパイルでは、すべての関数は3つの部分、つまりプロローグ、関数コード、エピローグで構成されるべきです。
Rsp(スタックポインタ)は、関数のプロローグとエピローグでのみ変更されるべきです。プロローグでは、レジスタを保存し、すべてのローカル変数を保持し、さらにシャドウスペースを保持するためにスタックポインタを増加させ(スタックを増加させることはアドレスを減少させることを意味します)、エピローグではその逆を行います。
つまり、関数コード内のスタックポインタは常にシャドウスペースの終端(上の画像の紫色の部分)を指し、関数のスタックサイズとシャドウスペースの合計は、プロローグがスタックポインタをどれだけ増加させるかを計算することで求められます。この値は、アンワインドテーブルに保持されている情報を使用して簡単に計算できます。これらのテーブルの使用方法についてはここでは説明しませんが、要約すると、これらのテーブルは他のスレッドやプロセスがスタックを正しく移動してその内容を表示したり、例外を処理したり、分析したりできるようにするために使用されます。
コンテキストのキャプチャはおそらく最も簡単な作業の1つです。DeathSleep の最初の行で、不揮発性レジスタが変更される前に RtlCaptureContext() を呼び出すだけです。 ただし、実行を復元するコンテキストには2つの変更を加える必要があります。
1つ目は Rip の変更です。Rip は次に実行する命令を保持するレジスタであり、そのままにしておくと実行は DeathSleep 関数内で再開されます。Rip を DeathSleep のリターンアドレスを保持するように変更します。これは、エピローグで増加されたサイズ(上の画像の緑色 + 紫色の領域)だけ変位した現在の Rsp が指すアドレスです。
2つ目の変更はスレッドが復元されたときに行われ、Rsp を復元したスタックの先頭を指すように設定することです。これは復元フェーズ中に行われます。新しいスタックがどこに配置されるか事前にわからないためです。この値は単に復元されたスタックの終了アドレスになります。後述するように、DeathSleep の呼び出し元によって予約されたシャドウスペースもコピーしているからです。これが DeathSleep 呼び出し前の RSP の値と正確に一致します。
起床のポイントに到達したら、実行を再開する直前に、保存したスタックを所定の位置に配置する必要があります。 保存したスタックは Awake 関数によってキャプチャされたアドレスから始まることがわかっているため、新しくキャプチャしたアドレスが、保存したスタックを配置する開始点になります。しかし、これには問題があります。古いスタックを配置した後に関数を呼び出すと、そのスタックが変更されて壊れてしまうからです。また、ここでクリーンアップを行うと非常に便利です。特に、スタックのバックアップに使用したヒープを解放する場合などです。 つまり、現在の Rsp と現在使用しているスタックの一部を、復元するスタックを配置する場所の外側に移動する必要があります。 問題を明確にするために、以下に図を示します:

そして、私の解決策は次のとおりです。すべてを移動するだけです:

すべての大変な作業を終えた後、最後に行うことは NtContinue を使用することです。この関数を使用すると、以前にキャプチャして変更したコンテキストで現在のコンテキストを変更できます。これにより、DeathSleep 呼び出し直後の RIP が設定され、すべてのレジスタは DeathSleep 呼び出し時と同じ値を持ち、RSP はスタックの先頭を指すようになります。
さて、現在のスレッドを保存および復元するために必要な基本はわかりましたが、スレッドがまったくない状態でもこれらすべてを実行できるようにする必要があります。 ここで、私たちの頼りになる Windows の Thread Pool API に出会います。これは、タスク(最大1つの引数を持つ関数)を、オペレーティングシステムによって完全に管理されるスレッドグループ(プール)にキューイングできるようにするツールです。 Ekko をご存知なら、この API を使用していることがわかります。ですから…同じ方法で実装しましょう。
すべてがうまくいきましたが、1つ問題がありました。キューイングされたタスクの実行が終了した後も、ワーカーが起動したままでした。これは、プログラムが生成する可能性のあるすべてのスレッドを破棄したいと考えていたため、問題でした。したがって、これは適切な方法ではありませんでした。
少し調べた結果、Ekko で使用されていたスレッドプール API は古いバージョンであり、より多くの機能を備えた新しいバージョンがあることがわかりました。その中には、問題を効果的に解決する関数 CloseThreadPool() があります。この新しい API を使用すると、独自のプールを作成し、使用後にそれらを破棄して、使用されたすべてのワーカーを終了できます。さらに2つの利点があります。スレッドの最大数を設定できることと、クリーングループを使用できることです。 スレッドの最大数を設定すると、時間差なくタスクがキューイングされる限り、すべてのタスクを順次実行できます。 クリーングループは、すべてが完了した後のクリーンアップを容易にするのに役立ちます。
これで完了でしょうか?さて、この時点でスレッドは終了しており、Awake をエントリポイントとする新しいスレッドを作成し、以前の状態を復元してプールを閉じる Rebirth 関数をキューイングしています。今のところ順調です!
前述のすべてが完了したとき、難しい部分は解決されたと思いました。この部分は以前のテクニックによってすでに解決されていたからです。しかし、なんと、思ってもみないことが起こりました。
主な問題は、コードの外部に処理をオフロードする必要があることです。メモリ保護を RW(読み取り/書き込み)に変更するため、VirtualProtect() を呼び出すと、関数が戻るときにプロセスがクラッシュします(RW ページでは命令を実行できません)。そのため、どこか別の場所からこれを実行し、RX(読み取り/実行)ページに戻る方法を見つける必要があります(戻るときも同様です)。 もちろん、このためにもスレッドプール API を使用しますが、問題があります。タスクに渡せる引数は1つだけですが、VirtualProtect() は4つの引数を取ります。
このために、再び NtContinue() を使用します。この関数のこの用途を最初に見たのは Foliage でしたが、Ekko でも使用されています。 NtContinue() は、前述のように、それを呼び出したスレッドにコンテキストを設定することができます。巧妙に調整することで、1つの引数(スレッドプール API に非常に便利)のみを使用して、複数の引数を持つ関数を「呼び出す」ことができます。 基本的な考え方は、RIP を関数の開始アドレスに設定し、Windows x64 呼び出し規約が最初の4つの引数をレジスタ(rcx、rdx、r8、r9 の順)で渡すため、NtContinue に渡すコンテキスト構造体に引数を配置するだけです。これにより、事実上、関数の呼び出しがシミュレートされます。 NtContinue を使用する際に最後に注意すべきことは Rsp です。前述のように、このアドレスは関数が呼び出されたときにリターンアドレスを保持する必要があります。
したがって、NtContinue が機能するために最初に必要なのはコンテキストを取得することです。手動で作成することもできますが、問題が生じます。関数に渡されたときに RET が戻るために使用するアドレスを指す Rsp の値を見つけることです。 タスクは別のスレッドで動作するため、そのスタックがどこに配置されるかはわかりません。 解決策(Ekko から慎重に借用しました。ありがとうございます :P)は、ワーカー内で RtlCaptureContext() を使用してコンテキストのコピーを取得し、取得したコンテキストのスタックポインタを8増加させることです。これにより、CALL RtlCaptureContext() によってスタックに導入されたアドレス、つまりこの関数のリターンアドレスを指すようになり、すべての関数のリターンアドレスとして使用できます。
さて、これは問題ありませんが、この Rsp の変更ができない場合はどうなるでしょうか? 復号化(deobfuscate)するときにこれが発生します。新しいスレッドにいるため、古いコンテキストの Rsp は役に立ちません。新しいスレッドから取得した新しいコンテキストが必要ですが、Rsp を変更して正しいアドレスを指すようにする古いトリックは使用できません。
取得したコンテキストを変更することはできませんが、それが役に立たないというわけではありません。実際には使用しますが、別の方法で使用します。そのコンテキストを NtContinue() で Rip を変更せずに復元すると、実行は RtlCaptureContext() の呼び出し後の次の命令にリダイレクトされ、Rsp も正しいため、変更されたコンテキストで NtContinue() を呼び出した後に使用して、タスクの実行を正しく終了できます。 これを行うために、ROP チェーンを使用します。最初のコンテキストの Rsp を手動で作成したスタックを指すように設定します。このスタックには、2回目の NtContinue() 呼び出し(終了するための正しいコンテキストを設定する呼び出し)まで実行をリダイレクトするために必要なすべてが保持されます。
手動で作成したスタックは次のようになります:

2つの ROP ガジェットを使用しています。1つは、関数のシャドウスペースを修正または「ジャンプオーバー」するためのものです。2つ目は、NtContinue の引数を rcx に配置し、その後 NtContinue に戻る役割を担います。
これら2つの ROP ガジェットを見つけるのは非常に簡単です。シャドウスペースを修正するためのものは、ほとんどすべての関数のエピローグです(Ntdll だけで500以上のヒットが見つかりました)。前述のように、エピローグは主に Rsp を減少させるように設計されているからです。2つ目は pop rcx; ret; で、2バイトであり、Ntdll と Kernel32 DLL の間でもいくつか見つかりました。
見てきたように、NtContinue を使用するには、最初の引数が入力されていれば機能します。これは古いスレッドプール API に最適ですが、新しいスレッドプール API では、引数は2番目の位置で渡されます。そのため、これだけでは機能しません。
この最後の問題をどう解決するかわからず数時間が経ちましたが、両方の API が同じ関数をいくつかのケースで使用していることに気づきました。これにより、見た目よりも似ているかもしれないと考え、両者の関係を調査することにしました。
古い API では、タスクをキューイングするために CreateTimerQueueTimer() を使用しています。新しい API では、同じことを行うために2つの関数が必要です: CreateThreadpoolTimer() は、コールバック関数とそれに渡す引数を受け取り、タスクを記述する TP_TIMER 構造体へのポインタを返します。タスクをキューイングする2番目の関数は SetThreadpoolTimer() で、前のポインタと、タスクがいつ実行されるかを記述する FILETIME 構造体へのポインタを受け取ります。
これらの関数をリバースすると、次のことがわかります:

ご覧のとおり、CreateThreadpoolTimer() は TpAllocTimer() の単なる便利なラッパーであり、SetThreadpoolTimer() は TpSetTimer() への単なるフォワーダーです。
次に、CreateTimerQueueTimer() の内部を確認してみましょう。 まず、これは Ntdll の関数 RtlCreateTimer() への単なる別の便利なラッパーです。ここで魔法が起こります。これはより大きな関数ですが、ここに探していた Gold があります:

ご覧のとおり、この関数内には TpAllocTimer() と TpSetTimer() への呼び出しが含まれています。つまり、内部で CreateThreadpoolTimer() と SetThreadpoolTimer() を呼び出しているのと似ています。キューイングしている関数は、関数に与えたコールバックそのものではなく、RtlpTpTimerCallback() をコールバックとして設定していることがわかります。 これが何を意味するのかまだ気づいていないのであれば、それは、引数を2番目の位置で受け取る関数 RtlpTpTimerCallback() をキューイングするために CreateThreadpoolTimer() を使用しているということです。RtlpTpTimerCallback() は、引数を最初の位置で受け取る別の関数を実行します。
したがって、まだ理解する必要があるのは、コールバック情報がどのように RtlpTpTimerCallback() に渡されるかということです。少しリバースした結果、次の構造にたどり着きました。驚いたことに、これで動作します!

これで、引数を最初の位置で受け取る関数を呼び出せるようになり、同時にプールを閉じてスレッドを残さないようにできます。Win-Win です。 この関数は Ntdll でエクスポートされていないことに注意してください。そのため、DLL 内でバイト形式で見つけることにしました。
これで終わりです。すべてを確認したところで、この POC の開発中に頭に浮かんだ核となるアイデアと、なぜすべてがこのように行われたのかについて説明できたと思います。
このコードは MSCV コンパイラとリンカでのみテストされています。この POC はコンパイル方法に大きく依存しているため、同じツールを使用することをお勧めします。また、他のコンパイラではそのまま動作することを保証しません。