Windows環境で低レイヤー開発をしてきたエンジニアがLinuxのメモリ管理に触れると、いくつかの挙動に強烈な違和感を覚えます。
Windowsの VirtualAlloc(NULL, ...) は基本的に低位アドレスから高位アドレスへ(Bottom-up)空き領域を探索しますが、Linuxの mmap(NULL, ...) は標準的な配置戦略として高位アドレスから低位アドレスへ(Top-down)探索を行います。さらに、特定のアドレスを強制指定するフラグの挙動や、VMA(Virtual Memory Area)の動的な結合・分断のメカニズムも、OS間で設計思想が大きく異なります。
本稿(前編)では、Linuxにおける仮想メモリの動的割り当てのクセと、カーネルがVMAをどのようにマージ・分割して管理しているか、bpftraceによるトレースログを交えて深掘りします。
※なお、本記事は前後編の2部構成となっております。後編では、今回紐解くVMAの性質を逆手に取り、システムを完全にパニックへ陥れる極限実験を展開します。
イントロダクション:Windowsエンジニアを襲う「違和感」
まずは、アドレスを指定せずに mmap を連続で呼び出した際の挙動を確認します。
連続割り当て時のアドレス遷移
プログラムを実行し、連続して2つの仮想メモリ領域を確保します。
Starting Linux Memory Experiment...
PID: 3048
Press Enter to Start...
Allocated at: 0x7ff4cca90000
この時点の /proc/3048/maps は以下の通りです。
7ff4cca26000-7ff4cca29000 rw-p 00000000 00:00 0
7ff4cca90000-7ff4ccaa0000 ---p 00000000 00:00 0 <- 1つ目の確保 (16KB)
7ff4ccaa0000-7ff4ccaa5000 rw-p 00000000 00:00 0
ここで、2つ目の仮想メモリを確保します。
Allocated at: 0x7ff4cca80000
maps の状態は以下のように変化します。
7ff4cca26000-7ff4cca29000 rw-p 00000000 00:00 0
7ff4cca80000-7ff4ccaa0000 ---p 00000000 00:00 0 <- 64KB低位に変移してマージ
7ff4ccaa0000-7ff4ccaa5000 rw-p 00000000 00:00 0
考察:なぜ64KBずつ低位にズレるのか?
自動的に割り当てられる仮想メモリのアドレスが、毎回正確に 64KB(0x10000) ずつ低位にズレていきます。
これはLinuxカーネルのデフォルトの配置戦略である Top-down Allocation が動作しているためです。カーネルはマッピング可能な空き領域(タスクのスタック領域の下方)から順に、下位アドレスに向かってVMA(Virtual Memory Area)を割り当てていきます。
大規模なメモリブロックが存在する場合のジャンプ挙動
では、意図的に先方にアドレスを確保し、Top-downの進路を塞いだ場合はどうなるでしょうか。bpftrace等で mmap のシステムコールをトレース(Attach 2 probes)しながら挙動を追います。
PID: 3134
Press Enter to Start...
Allocated at: 0x7f980d64a000
Allocated at: 0x7f980d3f0000
Memory Released. Press Enter to Exit...
システムコールのトレースログは以下の通りです。
PID 3134: mmap(addr: 0, flags: 0x22) -> ret: 0x7f980d64a000
PID 3134: mmap(addr: 0x7f980d63a000, flags: 0x100022) -> ret: 0x7f980d63a000
PID 3134: mmap(addr: 0x7f980d62a000, flags: 0x100022) -> ret: 0x7f980d62a000
PID 3134: mmap(addr: 0x7f980d64a000, flags: 0x22) -> ret: 0x7f980d3f0000
4回目の mmap で、アドレスが 0x7f980d64a000 から一気に 0x7f980d3f0000 まで大きくジャンプしました。
この時の /proc/3134/maps を確認すると、原因が判明します。
7f980d400000-7f980d499000 r--p 00000000 fd:00 50534058 /usr/lib64/libstdc++.so.6.0.29
7f980d499000-7f980d5a5000 r-xp 00099000 fd:00 50534058 /usr/lib64/libstdc++.so.6.0.29
7f980d5a5000-7f980d618000 r--p 001a5000 fd:00 50534058 /usr/lib64/libstdc++.so.6.0.29
7f980d618000-7f980d625000 r--p 00217000 fd:00 50534058 /usr/lib64/libstdc++.so.6.0.29
7f980d625000-7f980d626000 rw-p 00224000 fd:00 50534058 /usr/lib64/libstdc++.so.6.0.29
7f980d62a000-7f980d64a000 ---p 00000000 00:00 0
考察:カーネルの探索アルゴリズム
Top-downによる連続割り当ての途中に、共有ライブラリ(ここでは libstdc++.so)などの巨大なメモリブロックが既に存在している場合、カーネルはそのブロックを避けて、さらに低位にある広大な未割り当て領域へと一気にジャンプします。Linuxカーネル内の vm_unmapped_area() が空き領域を探索する際の、合理的かつ決定論的な挙動です。
MAP_FIXED による libc 破壊実験
Windowsの VirtualAlloc で MEM_RESERVE 済みの領域に対してアドレスを被せようとすると、エラー(ERROR_INVALID_ADDRESS など)が返ります。しかし、Linuxの mmap で MAP_FIXED フラグを指定すると、既に確保されているメモリ領域(他ライブラリを含む)を容赦なく破壊(上書き)します。
実際に、プロセスの心臓部である libc.so.6 のマッピングを書き換える実験を行います。
size_t size = 0x10000;
void* p1 = mmap(NULL, size, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p1 == MAP_FAILED) { perror("mmap p1"); return 1; }
std::cout << "Allocated p1 at: " << p1 << std::endl;
std::cout << "2. Check maps again. Then, enter target hex address to OVERWRITE: ";
std::string addr_str;
std::cin >> addr_str;
void* target_addr = (void*)std::stoul(addr_str, nullptr, 16);
std::cout << "Executing MAP_FIXED on " << target_addr << "..." << std::endl;
// ここで破壊!
void* result = mmap(target_addr, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
if (result == MAP_FAILED) {
perror("mmap FIXED");
std::cout << "Error code: " << errno << std::endl;
} else {
std::cout << "Successfully overwritten at: " << result << std::endl;
std::cout << "Now, let's try to call a libc function (printf)..." << std::endl;
// 運命の分岐点:libcのコードを破壊していれば、ここでプロセスが消滅します
printf("If you see this, the process is still alive.\n");
}
--- Linux Memory Destruction Experiment ---
PID: 3532
Open another terminal and check: cat /proc/3532/maps
Press Enter to allocate p1...
ターゲットとする /proc/3532/maps 内の libc の配置は以下の通りです。
7fb625400000-7fb625429000 r--p 00000000 fd:00 50534031 /usr/lib64/libc.so.6
7fb625429000-7fb62559e000 r-xp 00029000 fd:00 50534031 /usr/lib64/libc.so.6
7fb62559e000-7fb6255f6000 r--p 0019e000 fd:00 50534031 /usr/lib64/libc.so.6
7fb6255f6000-7fb6255fa000 r--p 001f6000 fd:00 50534031 /usr/lib64/libc.so.6 <- ここを狙う
7fb6255fa000-7fb6255fc000 rw-p 001fa000 fd:00 50534031 /usr/lib64/libc.so.6
ターゲットアドレス 0x7fb6255f6000 を入力し、MAP_FIXED を実行します。
Allocated p1 at: 0x7fb625ab2000
Check maps again. Then, enter target hex address to OVERWRITE: 0x7fb6255f6000
Executing MAP_FIXED on 0x7fb6255f6000...
Successfully overwritten at: 0x7fb6255f6000
カーネル内でのVMAの動的分割
上書き成功後の maps を確認すると、極めて興味深い現象が起きています。
7fb625400000-7fb625429000 r--p 00000000 fd:00 50534031 /usr/lib64/libc.so.6
7fb625429000-7fb62559e000 r-xp 00029000 fd:00 50534031 /usr/lib64/libc.so.6
7fb62559e000-7fb6255f6000 r--p 0019e000 fd:00 50534031 /usr/lib64/libc.so.6
7fb6255f6000-7fb6255f7000 rw-p 00000000 00:00 0 <- ★上書きされたアノニマスメモリ
7fb6255f7000-7fb6255fa000 r--p 001f7000 fd:00 50534031 /usr/lib64/libc.so.6 <- ★オフセットが自動調整されて残存
7fb6255fa000-7fb6255fc000 rw-p 001fa000 fd:00 50534031 /usr/lib64/libc.so.6
既存の libc.so のVMAが前・中・後の3つに分割され、真ん中の領域(1ページ分:0x7fb6255f6000-0x7fb6255f7000)がアンマップされた上で、新しいアノニマスメモリ(rw-p)に置き換わっています。
驚くべきは、カーネルが「ファイル(libc.so.6)との対応関係およびファイルオフセット(001f7000)」を適切に維持・計算し直したまま、VMAを切り出している点です。
破壊後のプロセスの挙動:なぜ即死しないのか?
この状態で、プログラム内で printf を呼び出してみます。
Now, let's try to call a libc function (printf)...
If you see this, the process is still alive.
Press Enter to exit...
なんとプロセスはクラッシュせず、printf の実行に成功します。今回破壊した領域は libc の .rodata の一部、あるいは直接実行制御に関わらないデータセグメントであったため、関数の実行自体は問題なかったと考えられます。
プロセス終了時の死
しかし、Enterを押してプログラムを終了(exit)させようとした瞬間、
Segmentation fault (コアダンプ)
GDBでコアダンプ(gdb バックトレース)を確認します。
(gdb) bt
#0 0x0000000000000000 in ?? ()
#1 0x00007fe77c441e32 in run_exit_handlers (status=0, listp=, run_list_atexit=run_list_atexit@entry=true, run_dtors=run_dtors@entry=true) at exit.c:147
#2 0x00007fe77c441fd0 in GI_exit (status=) at exit.c:156
#3 0x00007fe77c42a617 in libc_start_call_main (...) at ../sysdeps/nptl/libc_start_call_main.h:74
bpftraceによるカーネルトレースの出力:
Core Dumped: SIGSEGV received at address 0x7fb6255f6000
exit.c 内の run_exit_handlers(atexit 登録関数の呼び出し処理)の実行中、ポインタが 0x0 (ヌルポインタ)へ一直線にジャンプしてクラッシュしています。上書きした 0x7fb6255f6000 の中に、終了時に呼び出すべき内部関数のハンドラ(関数ポインタ配列など)が含まれていたことが原因で確定です。
Windowsであれば、このような重要システムDLLのメモリ領域をユーザー空間から意図的に上書きしようとする挙動は、最悪の場合OSのカーネルガードやサブシステムを巻き込んでBug Check(BSOD)を引き起こしかねない危険な行為です。これをプロセス空間内だけの閉じたクラッシュ(SIGSEGV)に留めるLinuxのサンドボックス構造の堅牢性と、同時に MAP_FIXED の容赦ない破壊力が浮き彫りになりました。
防衛策としての MAP_FIXED_NOREPLACE
MAP_FIXED の「既存のマッピングを警告なしに消し去る」という挙動は、アドレス固定配置を行いたい動的リンカ等には便利ですが、一般的なアプリケーションの実装においてはバグやセキュリティホールの温床になります。
そこで Linux 4.17 以降で導入されたのが MAP_FIXED_NOREPLACE フラグです。
MAP_FIXED_NOREPLACE による衝突検知
同様に libc の領域をターゲットにして、今度は MAP_FIXED_NOREPLACE を指定して mmap を実行します。
PID: 3485
Allocated p1 at: 0x7f87dca35000
2. Check maps again. Then, enter target hex address to OVERWRITE: 0x7f87dc5fa000
Executing MAP_FIXED on 0x7f87dc5fa000...
mmap FIXED: File exists
Error code: 17 (EEXIST)
Press Enter to exit...
bpftraceによるシステムコール確認:
PID 3485: mmap(addr: 0x7f87dc5fa000, flags: 0x100022) -> ret: 0xffffffffffffffef (-EEXIST)
指定したアドレスに既にマッピング(libc)が存在しているため、カーネルは既存のVMAを破壊せず、即座に EEXIST (File exists / エラーコード17) を返して処理を安全に拒否しました。
まとめ:エンジニアとしてのフラグの使い分け
実験から得られた mmap のアドレス指定フラグに関する知見のまとめです。
| フラグ | 挙動 | 主なユースケース |
|---|---|---|
flags: 0 (指定なし) |
カーネルがTop-down(またはBottom-up)で空き領域を決定論的に探索。 | 通常のあらゆるメモリ確保 |
MAP_FIXED |
指定アドレスに何かあれば強制的にVMAを分割・削除して上書き。 | 動的リンカ(ld.so)、カスタムメモリマネージャの実装 |
MAP_FIXED_NOREPLACE |
指定アドレスに割り当て試行。既に存在すれば EEXIST で安全にエラーを返す。 |
特定アドレスへの決定論的配置、JITコンパイラのコード領域確保 |
2.VMAの性質:動的な「合体」と「分断」
気になったと思いますが、最初に仮想メモリを取得したタイミングのこの現象。
7ff4cca90000-7ff4ccaa0000 ---p 00000000 00:00 0
7ff4cca80000-7ff4ccaa0000 ---p 00000000 00:00 0
結論から言うと、これは属性が完全に一致し、かつ隣接していればカーネルは即座にVMAを統合するというデフォルトの動作であり、実行している最大の理由は 「Page Fault発生時の探索コスト(レイテンシ)の削減」 と 「カーネルメモリ(Slab)の節約」 です。
マージの基本条件
- 空間的な隣接性:アドレス空間上で隙間なく連続していること。
-
属性の一致:権限(
rwx)、マッピングフラグ(shared/private)、バッキングファイル(同一ファイル・同一オフセット連続性、または共に匿名メモリであること)が完全に一致していること。
mprotect によるVMAの変遷
実際に mprotect(または mmap(MAP_FIXED))を用いて領域の一部の権限を変更した際、VMAがどのように分断され、そして元に戻る(マージされる)かを /proc/[pid]/maps の出力から追ってみます。
void* mid = (char*)p1 + 0x10000;
mprotect(mid, 0x10000, PROT_READ);
void* back = (char*)mid + 0x10000;
mprotect(back, 0x10000, PROT_WRITE);
mprotect(back, 0x10000, PROT_NONE);
mprotect(mid, 0x10000, PROT_NONE);
① 初期状態(メモリ確保前)
対象領域の周辺は、別用途の匿名メモリとライブラリ(libgcc_s)のテキストセクションに挟まれた空き領域です。
7f1afabfc000-7f1afac09000 rw-p 00000000 00:00 0
<--- [空き領域: 256KB] --->
7f1afade3000-7f1afade6000 r--p 00000000 fd:00 50523079 /usr/lib64/libgcc_s-11-20240719.so.1
② 256KBの匿名メモリ確保(PROT_NONE)
0x7f1afada3000 から256KB(0x40000 バイト)の領域を PROT_NONE(---p)で確保します。これにより、単一の新しいVMAが生成されます。
7f1afabfc000-7f1afac09000 rw-p 00000000 00:00 0
7f1afada3000-7f1afade3000 ---p 00000000 00:00 0 # ★新規VMA (256KB)
7f1afade3000-7f1afade6000 r--p 00000000 fd:00 50523079 /usr/lib64/libgcc_s-11-20240719.so.1
③ 1回目の権限変更:VMAの分断(Split)
確保した領域の中間部(オフセット+64KBから64KB分:0x7f1afadb3000-0x7f1afadc3000)を PROT_READ(r--p)に変更します。
前後で属性が異なるためマージされず、1つだったVMAが3つに分断されます。
7f1afabfc000-7f1afac09000 rw-p 00000000 00:00 0
7f1afada3000-7f1afadb3000 ---p 00000000 00:00 0 # 1: 前方(PROT_NONE)
7f1afadb3000-7f1afadc3000 r--p 00000000 00:00 0 # 2: 中間(PROT_READへ変更)
7f1afadc3000-7f1afade3000 ---p 00000000 00:00 0 # 3: 後方(PROT_NONE)
7f1afade3000-7f1afade6000 r--p 00000000 fd:00 50523079 /usr/lib64/libgcc_s-11-20240719.so.1
④ 2回目の権限変更:さらなる分断
さらに後ろのブロック(オフセット+128KBから64KB分:0x7f1afadc3000-0x7f1afadd3000)を PROT_WRITE(-w-p)に変更します。
ここでも属性不一致が起きるため、VMAはさらに細分化され、元々1つだった領域が5つにまで分断されます。
7f1afabfc000-7f1afac09000 rw-p 00000000 00:00 0
7f1afada3000-7f1afadb3000 ---p 00000000 00:00 0 # 1: ---p
7f1afadb3000-7f1afadc3000 r--p 00000000 00:00 0 # 2: r--p
7f1afadc3000-7f1afadd3000 -w-p 00000000 00:00 0 # 3: -w-p(新規変更箇所)
7f1afadd3000-7f1afade3000 ---p 00000000 00:00 0 # 4: ---p
7f1afade3000-7f1afade6000 r--p 00000000 fd:00 50523079 /usr/lib64/libgcc_s-11-20240719.so.1
⑤ 属性の巻き戻しによる後方マージ(Merge)
2回目に変更した箇所(-w-p)を、元の PROT_NONE(---p)に戻します。
このとき、カーネルは「変更された領域」と「その後方の領域(0x7f1afadd3000-0x7f1afade3000)」が共に ---p で隣接していることを検知し、後方マージを行います(5つのVMAが4つに減少)。
7f1afabfc000-7f1afac09000 rw-p 00000000 00:00 0
7f1afada3000-7f1afadb3000 ---p 00000000 00:00 0
7f1afadb3000-7f1afadc3000 r--p 00000000 00:00 0
7f1afadc3000-7f1afade3000 ---p 00000000 00:00 0 # ★後方とマージされて1つに拡張
7f1afade3000-7f1afade6000 r--p 00000000 fd:00 50523079 /usr/lib64/libgcc_s-11-20240719.so.1
⑥ 全領域の復元:前後マージによる完全な統合
最後に、1回目に変更した箇所(r--p)も PROT_NONE(---p)に戻します。
これにより、当該領域は「前方の ---p 領域」と「後方の ---p 領域」の双方と属性が一致します。カーネル内部の vma_merge() はこの両側マージを処理し、最終的に完全に初期の単一VMA(256KB)の状態へと巻き戻ります。
7f1afabfc000-7f1afac09000 rw-p 00000000 00:00 0
7f1afada3000-7f1afade3000 ---p 00000000 00:00 0 # ★完全に元の1つのVMAにマージ
7f1afade3000-7f1afade6000 r--p 00000000 fd:00 50523079 /usr/lib64/libgcc_s-11-20240719.so.1
「プログラマの意図の抹消」とその対策
ここで注目すべきは、ステップ⑥でマージされた瞬間、「この領域が、いつ、どういう意図で細かく分けて確保/変更されたか」というプログラマ側のコンテキスト(メタデータ)がカーネル内から完全に抹消されるという点です。
これはパフォーマンス上必要な最適化ですが、デバッグ時やセキュリティ監査(JITコンパイラが生成したコード領域の追跡など)において、「どのVMAが何のために確保されたか」が判別不能になる課題を孕んでいます。
代替アプローチ・対抗策
この「意図の抹消」に対抗、あるいはこれを逆手に取るアプローチとして以下の仕組みが挙げられます。
prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, ...) の活用(Linux 5.17+)
Linux 5.17以降では、prctl を用いて匿名メモリ領域(VMA)に任意の「名前」を付与できます。
名前が付与されたVMAは、/proc/[pid]/maps 上で [anon:my_jit_buffer] のように可視化されます。重要な性質として、名前(属性)が異なるVMA同士は隣接していてもマージされません。 これにより、意図的な分断の維持とメタデータの保持が可能になります。
3.LinuxカーネルにおけるVMAの多重権限構造と分割・マージの真実
先ほどの検証で、権限を PROT_NONE に戻すとVMAが綺麗に合体する様子が見えました。
ここで疑問なのがこちらが設定している権限はrw-のような形ですがカーネルでもそう管理されているのか?ということで、先ほどの動作をカーネル内部の挙動から追います
検証:mprotect による VMA の分割(Split)とマージ(Merge)
① NONE ➔ READ(0x7f9108894000, len: 0x10000, prot: 1)
[VMA_MODIFY SCOPE]
Target VMA: 0xffff8beaffbe2000
VMA Range: 0x7f9108884000 - 0x7f91088c4000
Req Range: 0x7f9108894000 - 0x7f91088a4000
Flags: 0x8000070 -> 0x8000071
[VMA_SPLIT DETECTED]
Addr: 0x7f9108894000 | New Flags: 0x8000071
単一の巨大な VMA の中間に異なる権限(PROT_READ)が設定されたため、カーネルは新しい vm_area_struct を切り出し、フラグが 0x8000070 から 0x8000071 へと遷移します。
② READ ➔ WRITE(0x7f91088a4000, len: 0x10000, prot: 2)
[VMA_MODIFY SCOPE]
Target VMA: 0xffff8beaffbe2000
VMA Range: 0x7f9108894000 - 0x7f91088a4000
Req Range: 0x7f91088a4000 - 0x7f91088b4000
Flags: 0x8000071 -> 0x8100072
[VMA_SPLIT DETECTED]
Addr: 0x7f91088a4000 | New Flags: 0x8100072
さらに隣接区間を PROT_WRITE に変更すると、再びフラグが変化し、VMA が分断されます。
③ WRITE ➔ NONE(元に戻す)
[VMA_MODIFY SCOPE]
Target VMA: 0xffff8beaffbe2000
VMA Range: 0x7f9108894000 - 0x7f91088a4000
Req Range: 0x7f91088a4000 - 0x7f91088b4000
Flags: 0x8000071 -> 0x8000070
[VMA_MERGE DETECTED]
Addr: 0x7f91088a4000 | New Flags: 0x8000070
権限を PROT_NONE に戻すと、隣接する VMA と属性(Flags)が同一になるため、カーネルはオブジェクトの肥大化を防ぐべく自動的にマージ(結合)を試みます。
④ READ ➔ NONE(すべてを NONE に集約)
[VMA_MODIFY SCOPE]
Target VMA: 0xffff8beaffaf8f30
VMA Range: 0x7f9108884000 - 0x7f9108894000
Req Range: 0x7f9108894000 - 0x7f91088a4000
Flags: 0x8000070 -> 0x8000070
[VMA_MERGE DETECTED]
Addr: 0x7f9108894000 | New Flags: 0x8000070
最終的にすべての区間が PROT_NONE(0x8000070)に揃ったことで、綺麗に1つの VMA へと再結合されました。
ユーザー空間の「rw-」とカーネル空間の「vm_flags」の乖離
ユーザー空間から見れば、パーミッションは単なる r, w, x のビットマスク(PROT_READ(1)、PROT_WRITE(2))に過ぎません。しかし、カーネル内部(vma->vm_flags)の動きを見ると、ログには 0x8000071 や 0x8100072 といった複雑な値が現れます。
Dirty 追跡と管理ビットの介入
例えば、PROT_WRITE(2)を付与した際、内部フラグは単に末尾のビットが変わるだけでなく、上位ビットが 0x80xxxxx から 0x81xxxxx へと変化しています。
これは、ユーザーが要求した「書き込み許可」に連動し、カーネルが内部的に「Dirty追跡(書き込み履歴の管理)ビット」などの独自のアクションフラグを自動的に起立させたためです。
-
0x8000071 と 0x8100071 の違い:
ユーザーから見ればどちらも「読み取り専用(r--p)」に見える状態であっても、カーネルにとっては「Dirty追跡の有無」や「過去の書き換え履歴に伴う内部状態」が異なる、全く別の管理オブジェクトとして扱われます。
ログの怪:なぜ遷移前のフラグがズレるのか?
ここで新たな疑問が。bpftraceでのフック時、NONE(0x8000070)から WRITE(2)へ変更した際、本来であれば 0x8000070 -> 0x8100072 となるはずが、ログ上は以下のように遷移しています。
Flags: 0x8000071 -> 0x8100072(遷移前が0x8000070ではなく、直前で操作した READ 用の0x8000071になっている)
原因の考察:VMA オブジェクト主導の「END側フラグ保持」
この謎を解く鍵は、要求されたアドレスレンジ(Req Range)の関係性にあります。
-
NONE ➔ READ の要求:
0x7f9108894000 - 0x7f91088a4000 -
NONE ➔ WRITE の要求:
0x7f91088a4000 - 0x7f91088b4000
READの終了アドレス(END)と、WRITEの開始アドレス(START)が 0x7f91088a4000 で完全に一致しています。
ユーザー空間では「アドレス(線形な空間)」が主役ですが、カーネル内部では struct vm_area_struct という実体を持ったオブジェクトが主役です。
vma_modify やその周辺ロジックが走る際、カーネルは探索対象となったターゲットVMA(この場合は直前に分割されたVMA、あるいは隣接するVMAの境界)のメタデータを参照します。そのため、フックしたタイミングによっては、変更対象セグメントの「直前の状態」ではなく、「隣接する、あるいはマージ/分割対象となるVMAのEND側のフラグ(0x8000071)」をコンテキストとして保持・出力してしまっている可能性が高いと考えられます。
実際に /proc/PID/maps を確認すると、カーネル内部の過渡期的なログのズレとは裏腹に、ユーザー空間に対しては正確に要求通りのセグメントに分断されて見えています。
7f9108884000-7f9108894000 ---p 00000000 00:00 0 # NONE
7f9108894000-7f91088a4000 r--p 00000000 00:00 0 # READ
7f91088a4000-7f91088b4000 -w-p 00000000 00:00 0 # WRITE
7f91088b4000-7f91088c4000 ---p 00000000 00:00 0 # NONE
比較表:Windows vs Linux メモリ管理
| 特徴 | Windows (VAD/PTE) | Linux (VMA/PTE) |
|---|---|---|
| 管理単位 | 予約(Reserve)単位の独立性を重視。 | 属性が同じなら即座に統合(Merge)。 |
| 境界線 | VADノードとして比較的維持されやすい。 | 属性が一致した瞬間に消失する「地続き」。 |
| 権限変更 |
VirtualProtect で細かく制御。 |
mprotect でVMAが分断・再統合。 |
| 外部介入 | API(OpenProcess等)を通じた厳重な管理。 |
/proc 以下のファイルシステムによる「透明性」。 |
| 限界点 | コミット制限やVAD上限でのエラー停止。 | 管理構造体増殖による kmalloc 枯渇とハング。 |
前編では、ユーザー空間の見え方とカーネル内部(vm_flags)の乖離、そして属性が一致した瞬間に境界線が消滅するVMAの「地続き」の性質を解き明かしました。Linuxのメモリ管理は、合理的かつ極めて柔軟に設計されていることがお分かりいただけたかと思います。
しかし、この柔軟性と「ファイル(VFS)を介した契約構造」は、時にエンジニアの直感を裏切る罠となり、また悪意ある(あるいはバグを孕んだ)プロセスによってOS全体を物理的に沈黙させる武器へと変貌します。
続く「後編」では、以下のテーマでLinuxメモリ管理のさらに深い闇へと踏み込みます。
本検証に関するフィードバックについて
本記事で扱っている挙動は、OSの内部仕様や未定義動作に深く依存しています。環境による挙動の差異や、客観的なログを伴う反証・追考をお持ちの方は、以下のGitHubリポジトリ(Issue)までお寄せください。
※ 技術的整合性を保つため、コメントの投稿には「再現コード」「実行環境の情報」「WinDbg等の出力ログ」が必須となります。客観的なデータのないテキストのみの指摘や、再現性のない感情的なコメントは、予告なくクローズまたは削除いたしますので予めご了承ください。