読者です 読者をやめる 読者になる 読者になる

Log.i53

Themidaのアンパックを目指すブログ改め使い物になるえんじにゃを目指すブログ

Nt vs. Zw

メモ 翻訳

割と有名な記事らしいので超適当に翻訳してみた.

簡単に纏めると

  • Zwは開発者が完全にランダムに選んだもので特に意味は持たない
  • ユーザモードアプリケーションからのネイティブAPIコールはZw系でもNt系でも全く同じルーチンを呼び出す
  • カーネルモードアプリケーションからNtXxx系のネイティブAPIをコールするとシステムサービスを実装する関数を直接呼び出す
  • カーネルモードアプリケーションからZwXxx系のネイティブAPIをコールするとPrevious Modeがカーネルモードに設定してから実際の関数を呼び出す.
  • Previous Modeが重要でネイティブAPIがユーザモードかカーネルモードのどちらから呼び出されたのかを把握するために設定される
  • Previous Modeがユーザに設定されている場合にはパラメータの検証を行う必要があるが、カーネルに設定されている場合にはパラメータが暗黙的に信頼され検証は行われない
  • カーネルモードでNtXxxルーチンを使用するとPrevious Modeが設定されずリクエストが失敗する恐れがあるので、ZwXxxルーチンを使用してPrevious Modeをカーネルモードに正しく設定すること

翻訳元:The NT Insider:Nt vs. Zw - Clearing Confusion On The Native API

Nt vs. Zw - ネイティブAPIの混乱を解く

NTのネイティブAPIは新しいものではない. 非常に多くのユーティリティに悪用されているし、一部のAPIは完全に文書化されていてDDKでもサポートされている. しかしながら人々はまだネイティブAPIの特定の側面について混乱している. 例えば次のような一般的な質問がよくあげられる.

  • NtXxxとZwXxxの2種類のAPIが存在するのは何故か
  • ZwXxxの呼び出しが失敗したり成功したりするのは何故か
  • Zwとはどういう意味か

3つ目の問いだが、NTの開発者の一人の名前がZimbanza Woobie氏であると言った冗談もあるが、実はZwは完全にランダムに開発者が選んだもので特別な意味は持たない. この記事は、読者が既にネイティブAPIが存在することと、ネイティブAPIWindowsの他のサブシステムにどのように関係しているのかを理解していることが前提となっている.

バニラorチョコレート

ユーザモードおよびカーネルモードから呼び出せるAPIにはNtXxxとZwXxxの2種類がある. これは4通りの呼び出し方があることを意味している. 例として、XxReadFileを用いる:

  • ユーザモードのアプリケーションがNtReadFileを呼び出す
  • ユーザモードのアプリケーションがZwReadFileを呼び出す
  • カーネルモードのアプリケーションがNtReadFileを呼び出す
  • カーネルモードのアプリケーションがZwReadFileを呼び出す

この4通りのルーチンコールの正確な違いは何か. まずはユーザモードの話から始めよう.

ユーザモードからの呼び出し

あなたは(おそらく)ご存知であろうが、ユーザモードのアプリケーションはNTDLL.LIBにリンクしている. XxReadFileを例に挙げて、NTDLL内のNtReadFileとZwReadFileルーチンの逆アセンブルした結果を比較しよう.

0: kd> u ntdll!NtReadFile
ntdll!NtReadFile:
77f761e8 b8b7000000       mov     eax,0xb7
77f761ed ba0003fe7f       mov     edx,0x7ffe0300
77f761f2 ffd2             call    edx
77f761f4 c22400           ret     0x24

新たなルーチンを呼び出すスタブとリターン命令が見られる. とりあえず続けてZwReadFileもチェックしてみよう.

0: kd> u ntdll!ZwReadFile
ntdll!NtReadFile:
77f761e8 b8b7000000       mov     eax,0xb7
77f761ed ba0003fe7f       mov     edx,0x7ffe0300
77f761f2 ffd2             call    edx
77f761f4 c22400           ret     0x24

おや、どちらも同じ場所を呼び出している. これでユーザモードのプログラムからは同じ場所が呼び出されることが分かった. 異なるシステムサービスコールを選択してもそれらは正しい形式を持っていることが分かる. 今度は正確に、これらの呼び出しが起こったときにどこにジャンプするのか、(前述のとおり、ユーザモードからのAPI呼び出しの時にどこにジャンプするのか)アドレス0x7FFE0300を確認してみよう.

0: kd> ln 0x7ffe0300
(7ffe0300)   SharedUserData!SystemCallStub   
Exact matches:
    SharedUserData!SystemCallStub

0: kd> u SharedUserData!SystemCallStub
SharedUserData!SystemCallStub:
7ffe0300 8bd4             mov     edx,esp
7ffe0302 0f34             sysenter
7ffe0304 c3               ret

呼び出し元によってEAXに(どのシステムサービスが呼び出されたかを表すコードが判明する)何かが指定され、その後、このルーチンはEDX内へユーザモードスタックの最上位を示すポインタを代入する. インテルのドキュメントを確認するとSYSENTER命令は、現在のスレッドをカーネルモードに切り替え、MSR+0x176にあるSYSENTER_EIP_MSRによって指されるルーチンを実行するとある.

これはINT 2Eのフッキングが何故悪い考えなのか、古いINT 2Eのフックが何故働かないのかを知るいい機会だ. システムでSYSENTER命令がサポートされるようになってINT 2Eが単に使用されなくなったからだ. INT 2Eが呼び出されることが無い場合フックが利用できないというわけだ.

さて、WinDBGに戻ってrdmsrコマンドを実行し、SYSENTER_EIP_MSRが何かを確認してみよう.

0: kd> rdmsr 176
msr[176] = 00000000:8053a270

非常に興味深い. このアドレスが何か確認しよう.

0: kd> ln 8053a270
(8053a270)   nt!KiFastCallEntry   |  (8053a2fb)   
nt!KiSystemService
Exact matches:
    nt!KiFastCallEntry

KiFastEntryを読み解くのは面白いが、とりあえずここでは関数の最後の行を見てみよう.

053a2f9 eb5c jmp     nt!KiSystemService+0x5c (8053a357)

KiFastCallEntryは実際には返されない(retではなくjmpである)ことを確認することができる. これは、KiSystemService内にいくつかのオフセットで無条件ジャンプを行っている. コードの概要をおさらいすると、KiSystemService内のコードは、最終的にXxReadFileへの呼び出すの最初の行でEAXに代入したサービスナンバーを元に、システムサービステーブルであるKiServiceTable内のエントリを検索する. このテーブル内の各エントリはネイティブAPIのポインタであり、"system service"ルーチンとしても知られている. "system service"ルーチンが呼び出される前に、システムサービスは、ユーザスタックの最上位からカーネルスタックの最上位へ渡されたシステムサービスのパラメータをコピーするコードをディスパッチする. SYSENTERが実行される前にスタックの最上位のポインタがEDXに保持される理由がこれである.

カーネルバージョンのNtReadFileは0xB7のインデックスを示していることが分かる.

0: kd> !osrexts.sst
0: 0x805912c2  (nt!NtAcceptConnectPort)
1: 0x805d87b0  (nt!NtAccessCheck)
2: 0x805dc3e4  (nt!NtAccessCheckAndAuditAlarm)
...
b7: 0x8056b2ec  (nt!NtReadFile)
...

nt!NtReadFile のアドレス(0x8056b2ec)を確認してみよう.

0: kd> u nt!NtReadFile
nt!NtReadFile:
8056b2ec 6a58             push    0x58
8056b2ee 6858044e80       push    0x804e0458
8056b2f3 e8e09ffcff       call    nt!_SEH_prolog (805352d8)
8056b2f8 33ff             xor     edi,edi
8056b2fa 897de4           mov     [ebp-0x1c],edi
8056b2fd 897de0           mov     [ebp-0x20],edi
8056b300 897dd8           mov     [ebp-0x28],edi
8056b303 897ddc           mov     [ebp-0x24],edi
8056b306 64a124010000     mov     eax,fs:[00000124]
8056b30c 8945d4           mov     [ebp-0x2c],eax
8056b30f 8a8040010000     mov     al,[eax 0x140]
8056b315 8845d0           mov     [ebp-0x30],al
8056b318 57               push    edi
8056b319 8d45cc           lea     eax,[ebp-0x34]
8056b31c 50               push    eax
8056b31d ff75d0           push    dword ptr [ebp-0x30]

これが実際にリードファイルシステムサービスを実装する関数のようだ.

ユーザモードからのネイティブAPIコールの流れを要約しよう.

ユーザモードプログラムはNtXxxかZwXxxのどちらかを呼び出すが、そのどちらも同じ場所を指している. 全てのユーザモードからのネイティブAPIコールは、EAXに代入されたインデックスを単に読み込むことによって体を成し、システムコールスタブが実行されてから元の実行に戻る.

システムコールスタブはEDX内にユーザモードスタックの最上位を指すポインタを保持してSYSENTER命令を実行する. SYSENTER命令は割り込みを無効にし、そのスレッドをカーネルモードに切り替え、SYSENTER_EIP_MSR (XP SP1 では KiFastCallEntry)内に位置する命令を実行する. KiFastCallEntryは、ユーザモードに戻る時に、どこに行くか知るためのトラップフレームを立ち上げ、割り込むを有効にし、KiSystemService内にジャンプする. KiSystemServiceは、ほかの事をしている間に、(EDXによって示される)ユーザスタックからパラメータをコピーして、EAX内に以前格納された値を取得し、KiServiceTable[EAX]に位置する関数を実行する. ネイティブAPIは、Previous Modeがユーザモードに設定されたスレッドによりカーネルモードで実行される. これは呼び出し元がユーザモードから来たことを示している.

カーネルモードからの呼び出し

ご存知(なはず)のように、カーネルモードのコンポーネントはNTOSKRNL.LIBにリンクしている. XxReadFileを再び使用して、カーネルサイドからの2通りの呼び出しをのぞいてみる. まずはNtReadFileを試してみよう.

0: kd> u nt!NtReadFile
nt!NtReadFile:
8056b2ec 6a58             push    0x58
8056b2ee 6858044e80       push    0x804e0458
8056b2f3 e8e09ffcff       call    nt!_SEH_prolog (805352d8)
8056b2f8 33ff             xor     edi,edi
...

これには見覚えがある! これはユーザモードから最終的に呼び出されたNtReadFileを実装する関数(システムサービステーブルが指していたもの)である. したがって、ドライバからNtReadFileを呼び出した場合、エントリーポイントのいずれかの一般的なシステムサービスディスパッチャのタイプをバイパスして、単にその関数を実行することに気づくだろう.

ユーザモードではNtXxxとZwXxxが同一であることは確認してきたが、カーネルモードではどうだろうか. ZwReadFileを確認してみよう.

0: kd> u nt!ZwReadFile
nt!ZwReadFile:
80504d4c b8b7000000       mov     eax,0xb7
80504d51 8d542404         lea     edx,[esp 0x4]
80504d55 9c               pushfd
80504d56 6a08             push    0x8
80504d58 e89e550300       call    nt!KiSystemService (8053a2fb)
80504d5d c22400           ret     0x24

予想外にNtReadFileの呼び出しの時とは異なる. 先頭にEAXに0xB7を代入する身近な命令が見られる. その後でカーネルスタックにあるパラメータを指すポインタをEDXに格納して、スタックに定数値とEFLAGSをプッシュしている. 最後にKiSystemServiceを呼び出している. これは、ユーザモードからSYSENTERを実行したときにKiFastCallEntryから呼び出している関数であった.

何故ここではSYSENTERを実行していないのかと言うと、ここは既にカーネルモードになっているからだ. カーネルモードからネイティブAPIを呼び出した時にこのルートを通ったとき、起こりうる最も重要なことはカーネルモードで実行され、KiSystemServiceを通じてPrevious Modeはカーネルモードに設定される. カーネルモードからNtXxx系のルーチンを単に呼び出す場合、これは必ずしもそうでないことに注意することだ. このケースでは、Previous Modeを変更する必要がなかったために、その関数に移動して実行を開始できた.

カーネルモードからのネイティブAPIコールの流れを要約しよう.

ケース A:
  • カーネルモードコンポーネントはNtXxxを呼び出す.
  • これはシステムサービスを実装する関数を直接呼び出すものである.
  • この呼び出しではPrevious Modeは変更されない.
ケース B:
  • カーネルモードコンポーネントはZwXxxを呼び出す.
  • これはシステムサービスコード(インデックス値)をEAXレジスタに格納して、既に(カーネルモードの)スタックにプッシュされていた引数へのポインタをEDXレジスタに格納する.
  • その後、他のことを行う間にKiSystemServiceが呼び出され、EDXレジスタによって示された位置からパラメータをコピーし、以前EAXレジスタに格納された値を取得し、KiServiceTable[EAX]に位置する関数を実行する.
  • Previous Modeがカーネルモードに設定されて、ネイティブAPIが(カーネルモード内で)実行される. これは、呼び出し元がカーネルモードから来たことを示している.

このため、NtXxxで直接呼出すことでオーバーヘッドが少なくなっていることは明らかだ. しかし、ZwXxxの呼び出しはPrevious Modeを変更している. これはどういった影響を与えるものなのか. Previous Modeは時より重要になるように思える.

Previous Mode

これが何を意味するかを把握しよう. デフォルトのカーネルモードコンポーネントは他の全てのカーネルモードコンポーネントを信頼するということが知るべき重要な事実である. なぜなら、システムサービスは常にカーネルモードで処理され、呼び出し元が暗黙的に信頼されている場合は、Windowsは呼び出し元がユーザモードまたはカーネルモードのどちらであったかを追跡するからである.

システムは、システムサービスコールがどこから行われたかによってモードを決定するためにPrevious Mode Indicatorを使用する. 呼び出しがユーザモードから行われたときは、Previous Modeはユーザに設定される. システムサービスの処理ルーチンが呼び出し元を信頼するかどうかを決定する必要がある場合、Previous Modeの値をチェックする. Previous Modeがユーザに設定されていた場合、システムサービス処理ルーチンは呼び出しがユーザモードから行われたことを知り、関数に渡されたいくつかのパラメータはそれらが使用される前に値の検証をする必要がある.

これがPrevious Modeが何故設定されるかを最も重要な部分として話してきた理由である. システムがユーザモードから来ているユーザの要求としてそのシステムサービスリクエストを処理したり、その要求を検証しないとユーザモードアプリケーションが何か問題を引き起こすかもしれない. 全てのバッファは検証の対象とされ、全てのアクセスチェックが実行され、要求のない部分については暗黙的に信頼される.

カーネルコンポーネントがZwXxx系のネイティブAPIを呼び出した場合、全てが順調に動作する. Previous Modeはカーネルに設定され、カーネルの証明書が使用される. 呼び出されたシステムサービス処理ルーチンは、カーネルモードコンポーネントから来たリクエストであるため、渡されたいくつかのパラメータが有効であることを前提とする(またカーネルモードコンポーネントはお互いを暗黙的に信頼する).

NtXxx系のネイティブシステムサービスはそれ自身が関数名である. 従って、カーネルモードコンポーネントがNtXxx系のシステムサービスを呼び出した場合、いずれ設定されるであろうPrevious Modeは変更されない. これにより、要求先がユーザに設定されていても、カーネルコンポーネントは任意のユーザスタックでの実行を可能にする. システムサービスは、要求パラメータの確認の試行・任意のユーザモードスレッドの証明書を使用する可能性・これによる要求が失敗する可能性を知ることはない.

ユーザモードリクエストのためのプロセス検証の手順の一つに、渡された全てのバッファはバッファの使用状況に応じてProbeForReadまたはProbeForWriteのどちらかが実行されることがあるが、これがまた新たな問題となっている. これらのルーチンはカーネルモードアドレス上で実行された場合例外を引き起こす. 従って、リクエストモードがユーザに設定されている時にカーネルモードバッファを渡した場合、呼び出したネイティブAPIはSTATUS_ACCESS_VIOLATIONの例外を引き起こす.

この教訓から、カーネルモードではZwXxxルーチンを使用して、Previous Modeをカーネルモードに正しく設定する必要があるだろう.

実際に処理する

全てのネイティブAPIコールはハンドルテーブルの2つのタイプのうちの1つをインデックスとするハンドル値を持って働く. ハンドルは、EPROCESS構造体の一部であるテーブル内の有効な(特定のプロセスコンテキストに固有のオブジェクトを記述することを意味する)エントリーを記述するか、または、グローバルハンドルテーブル内の(全てのプロセスコンテキストを表示するオブジェクトを記述する)エントリーを記述する. これがいくつかの興味深いシナリオを生む.

既存のドライバを持って、必要に応じてファイルのログを取る便利な機能を作るとする. まずは、2つのIOCTLをセットアップする. 1つはログの取得を有効にしもう1つはログの取得を無効にするものだ. IOCTLのハンドラでは、ログの取得を有効にするためにドライバがファイルを書き込むのに使用するハンドルを返すZwCreateFile(Zw系のAPIを使用することを覚えておくこと)を呼び出す. ここまでのところは良いだろう.

InitializeObjectAttributes(&oa, &logFileName, OBJ_CASE_INSENSITIVE,NULL, NULL);
code = ZwCreateFile(&devExt->LogFileHandle, GENERIC_WRITE,
     &oa, &iosb, NULL, FILE_ATTRIBUTE_NORMAL,
     0, FILE_OVERWRITE_IF,
     FILE_NON_DIRECTORY_FILE | FILE_SYNCHRONOUS_IO_NONALERT,
     NULL, 0);

ここから、全てのディスパッチエントリーポイントにZwWriteFileの呼び出しの追加を開始し、ファイルロギングを行うデバイスの拡張機能のためのフラグを設定する.

if (devExt->LoggingEnabled) {
    code = ZwWriteFile(devExt->LogFileHandle, NULL, NULL, NULL,
    &iosb, (PVOID)logMessage,
    logMessageLen),
    NULL, NULL);
}

ZwWriteFileはPASSIVE_LEVELでしか呼び出すことができない制限があることに注意して、DPCとDpcFprlsrタイマを記録するワークアイテムを設定する. その後、デバイス上でログの取得を有効すると、ディスパッチエントリーポイント内の全てのZwWriteFileの呼び出しは成功するが、ワークアイテムの1つがSTATUS_INVALID_HANDLEを返す. これは何故か.

ディスパッチエントリーポイント内でハンドルを生成したことを思い出そう. したがって、ハンドルが生成されるとアプリケーションを呼び出すプロセスコンテキスト内で、実行することができる. この場合、ハンドルはそのEPROCESS構造体に位置するユーザモードアプリケーションのハンドルテーブル内のオブジェクトを参照する. ワークアイテムはSYSTEMプロセスコンテキスト内で動作するため、ZwWriteFileの呼び出しはSTATUS_INVALID_HANDLEで正しく失敗する. これはSYSTEMプロセスコンテキスト内に無意味なハンドルが渡されたからである.

この問題には既に解決策がある. オブジェクトの属性の1つとしてOBJ_KERNEL_HANDLEを指定し、すべてのプロセスコンテキスト内でハンドルが動作するようにすればよいのだ. このフラグは、ハンドルがグローバルハンドルテーブルへ移動するようオブジェクトマネージャをキューに追加し、全てのプロセスコンテキスト内で見えるようにする.

InitializeObjectAttributes(&oa, &logFileName,
    OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
    NULL, NULL);