
qemuとgdbで手動でx86-64ページテーブルをウォークする。仮想アドレスを分解し、cr3を辿って物理メモリの全レベルを通過し、生のバイトからフラグを抽出する。
あなたはページングについて読んだことがあります。図表は理解できる。4レベル、各9ビット、ページフレーム、オフセット。確かに。しかし、実際にページテーブルをウォークする必要があるチャレンジに直面すると、自分はそれを_知っている_わけではないことに気づきます。_について知っている_だけです。大きな違いです。
私にとって効果的だったのは、QEMUとgdbの前に座り、自分でウォークを行うことでした。すべてのインデックスを計算し、物理メモリからすべてのエントリを読み取り、すべてのポインタを手動で追跡します。そのような半日の作業は、何時間もの講義よりも多くのことを教えてくれます。
これはそのプロセスからの私のノートのコレクションです。まだ概念的な側面が不足している場合は、まずZardusのカーネルメモリ管理に関する講義をご覧ください。それが理論です。これがラボです。
目標:仮想アドレスを取得し、生の物理メモリ内を追跡してデータを見つけるまで追跡します。カーネルヘルパーなし。抽象化なし。単なるQEMU VM、gdb、そして生の物理メモリです。
最終的には、ページングは読んだだけのものではなく、自分で手を動かして行ったことで知るものになるでしょう。
プリビルドされたカーネルとinitramfsが含まれています。私はFedoraで実行しましたが、QEMUとgdbを実行できるOSならどれでも動作するはずです。パッケージマネージャでインストールしてください:```
sudo apt install qemu-system-x86 gdb
sudo dnf install qemu-system-x86 gdb
brew install qemu gdb
### チャレンジのバイナリ
ターゲットは、フラグをメモリに保存し、その仮想アドレスを表示する簡単なCプログラムです。```c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
char secret[] = "FLAG{p4g3_t4bl3_w4lk3r}";
printf("secret @ %p\n", (void *)secret);
printf("pid = %d\n", getpid());
printf("Spinning. Walk the page tables to find the flag.\n");
while (1)
{
}
}
ビジーループは意図的なものです。当初は pause() を使用していましたが、それではプロセスが syscall 内でスリープ状態になります。gdb が VM を停止すると、CPU はおそらく別の CR3 でアイドルタスクを実行しています。スピンループはプロセスを CPU 上に留めるため、停止することで正しいページテーブルを持つコンテキストにいることが保証されます。
このバイナリを含むビルド済み initramfs は既に initramfs.cpio.gz に含まれています。それを再ビルドする必要がある場合(Linux のみ、busybox と glibc-static が必要)、このディレクトリで make を実行してください。
./start.sh
このスクリプトは、バンドルされたカーネルとinitramfsをQEMU上で起動し、`-s`(gdbサーバーを`localhost:1234`で待機)と`nokaslr`オプションを指定することで、実行間でカーネルアドレスが固定されるようにします。
VMは即座に起動し、チャレンジバイナリが実行されます。コンソールにフラグの仮想アドレスが表示されます。```
secret @ 0x7ffe08985c90
pid = 1
Spinning. Walk the page tables to find the flag.
Write down that virtual address. That's your target.

デフォルトのQEMUエスケープキーは
Ctrl-aですが、それは私のtmuxプレフィックスと衝突するため、スクリプトでは-echr 0x11を使用してCtrl-qに再マッピングしています。Ctrl-qを別の用途で使用している場合は、start.shの16進数値を自分の設定に合わせて変更してください。
gdb -ex "target remote :1234"

---
## 仮想アドレスの分解
仮想アドレスはあります。しかし、データは _実際には_ どこにあるのでしょうか?
仮想アドレスは、オペレーティングシステムが提供する丁寧なフィクションです。
各プロセスは、ゼロから始まる自分専用のメモリがあると考えます。
実際には、データは物理RAMのまったく無関係な場所に存在します。
ページテーブルは、両者を結びつけるマップです。CPUがメモリアクセスを行うたびに(またはTLBキャッシュから参照するたびに)辿るツリー構造です。
それでは、CPUが行うことをやってみましょう。手動で。そのアドレスを変換するには、CPUが各レベルで使用するインデックスに分解する必要があります。
x86-64の仮想アドレスは48ビット幅です。その48ビットは5つのフィールドに分割されます。```
63 48 47 39 38 30 29 21 20 12 11 0
┌────────┬────────┬────────┬────────┬────────┬──────────┐
│ sign │ PGD │ PUD │ PMD │ PT │ Offset │
│ extend │ index │ index │ index │ index │ │
│ (16b) │ (9b) │ (9b) │ (9b) │ (9b) │ (12b) │
└────────┴────────┴────────┴────────┴────────┴──────────┘
各9ビットのインデックスは、そのレベルのページテーブル内の512エントリのうちの1つを選択します。12ビットのオフセットは、最終的な4KB(0x1000)ページ内のバイトを選択します。
インデックスを抽出するには、シフトとマスクを行います:``` PGD index = (VA >> 39) & 0x1FF PUD index = (VA >> 30) & 0x1FF PMD index = (VA >> 21) & 0x1FF PT index = (VA >> 12) & 0x1FF Offset = VA & 0xFFF
gdb では、これらを直接計算できます:```
(gdb) p/x (0x7ffe08985c90 >> 39) & 0x1ff
$1 = 0xff
(gdb) p/x (0x7ffe08985c90 >> 30) & 0x1ff
$2 = 0x1f8
(gdb) p/x (0x7ffe08985c90 >> 21) & 0x1ff
$3 = 0x44
(gdb) p/x (0x7ffe08985c90 >> 12) & 0x1ff
$4 = 0x185
(gdb) p/x 0x7ffe08985c90 & 0xfff
$5 = 0xc90
Write these down. You'll use each one at its corresponding level.
Your values will differ. The address
0x7ffe08985c90is just an example. Use whatever address your challenge binary printed.
A note on 5-level paging. Recent CPUs and kernels support LA57, which adds a fifth level (PML5) above the PGD and extends virtual addresses to 57 bits. The walk is the same pattern: one more 9-bit index, one more table lookup. Most systems still run 4-level paging. You can check yours:
cat /proc/cpuinfo | grep la57. Everything in this article assumes 4-level.
Every tree has a root. For page tables, that root is in the CR3 register: it holds the physical address of the top-level table, the PGD. Every process gets its own CR3 value, the kernel swaps it on context switch.
This is our entry point into the walk. Read it from gdb:``` (gdb) info registers cr3 cr3 0x66c7000 [ PDBR=26311 PCID=0 ]
ページテーブルのベースは `0x66c7000` です。下位12ビットはPCID/フラグ(ここではゼロ)なので、ベースアドレスはそのままの値です。
ここからウォークが始まります。
---
## ウォーク
ここがポイントです。各レベルは同じパターンに従います。フラグはレベルによって若干異なりますが、プロセスは変わりません。そのパターンは次の通りです:
1. **エントリアドレスの計算:** `base + index * 8` (各エントリは8バイト)
2. **物理メモリからのエントリの読み取り** QEMUモニターの `xp` コマンドを使用
3. **フラグのデコード** (以下のリファレンスを参照)。Present (ビット0) が0の場合、ページはマッピングされておらず、ウォークは停止します
4. **次のテーブルのベースを抽出:** エントリを `& 0x000FFFFFFFFFF000` でマスク
5. **次のレベルに移動**
各エントリは64ビットです。一般的なフラグビット:```
Bit Name Meaning when set
0 Present Page/table is mapped
1 Read/Write Writable
2 User/Supervisor Accessible from userspace
3 Write-Through Write-through caching
4 Cache Disable Caching disabled
5 Accessed CPU has read this entry
6 Dirty CPU has written to the page (final level only)
7 Page Size 1 GB page (PUD) or 2 MB page (PMD)
63 NX No-execute
ビット[51:12]は次のテーブル(または最終レベルのページフレーム)の物理アドレスを保持します。ビット9-11はハードウェアによって無視され、OSが使用可能です。Linuxはそれらをブックキーピング(例えばソフトダーティ追跡)に使用します。ビット52-62は予約されています。エクスプロイトの書き込みでPTEを読む際に両方に遭遇します。
進む際にこのフラグテーブルを手元に置いておいてください。
それでは始めましょう。
CR3からPGDベースがあります: 0x66c7000。
PGDインデックスは 0xff です。
エントリアドレスを計算します:``` entry = 0x66c7000 + 0xff * 8 = 0x66c77f8
QEMUの物理メモリ参照コマンドを使用してgdbから読み取ります:```
(gdb) monitor xp/1gx 0x66c77f8
000000066c77f8: 0x0000000006713067
エントリ: 0x6713067 [Present RW User Accessed Dirty].
次のベース: 0x6713067 & 0x000FFFFFFFFFF000 = 0x6713000.
PGDエントリから抽出したベース (0x6713000) はPUDを指します。同じプロセスで、次のインデックス: 0x1f8.```
entry = 0x6713000 + 0x1f8 * 8 = 0x6713fc0
No content provided.```
(gdb) monitor xp/1gx 0x6713fc0
00000006713fc0: 0x00000000066ac067
エントリ: 0x66ac067 [Present RW User Accessed Dirty]。ページサイズ (ビット7) = 0、1GB 巨大ページではありません。
次のベース: 0x66ac067 & 0x000FFFFFFFFFF000 = 0x66ac000。
ベース: 0x66ac000。PMD インデックス: 0x44。```
entry = 0x66ac000 + 0x44 * 8 = 0x66ac220
- [unlicense](https://github.com/PurpleBooth/functional-login-system-benhammondmusic) - Node.js、Express、Pug、MongoDB、Passport で書かれたログインシステム。```
(gdb) monitor xp/1gx 0x66ac220
000000066ac220: 0x00000000066c4067
エントリ: 0x66c4067 [存在 読み書き ユーザ アクセス済み ダーティ]. ページサイズ(ビット7) = 0、2MBの巨大ページではありません。
次のベース: 0x66c4067 & 0x000FFFFFFFFFF000 = 0x66c4000.
ベース: 0x66c4000. PTインデックス: 0x185.```
entry = 0x66c4000 + 0x185 * 8 = 0x66c4c28
バッチリビジョンモードでは、スキャナーのリビジョンエンドポイントをフォアグラウンドで実行して生成テキストを分析し、`block` または `flag` の判定を返すことで、リアルタイムの API ガードレールを適用できます。これまでに見てきたすべてのクライアントコードサンプルはこのスキャン方式を使用しており、コンテンツがエンドユーザーに届く前にレビューする必要がある場合に最も役立ちます。```
(gdb) monitor xp/1gx 0x66c4c28
000000066c4c28: 0x80000000037fd867
エントリ: 0x80000000037fd867 [Present RW User Accessed Dirty NX]。これが
最終的なPTEです。
物理ページフレーム: 0x80000000037fd867 & 0x000FFFFFFFFFF000 =
0x37fd000。