今回はハードウェアレベルでメモリ保護を実現する Intel MPK (Memory Protection Keys) のユーザ空間向けの機能、Intel PKU (Protection Keys for Userspace) で遊んでみたという内容になります。
Intel PKU を使うと、ユーザ空間内に複数個のサンドボックスを作成でき、なおかつハードウェア制御により高速なアクセス制御が可能になります。Intel PKU は登場してから10年たった今でも論文などで様々な利用方法が提案されている非常に奥の深い機能なんですが(あまり実用例は聞かないけど)、今回は導入編として、ゆるく触ってみる程度にしたいと思います。
背景
C言語をはじめ、非メモリ安全な言語でプログラミングをやっていると付き物なのが、メモリリークやスタックオーバーフローをはじめとするメモリ脆弱性です。コンピュータが登場してから数十年たった今もなお頻発しているバグであり、Linux カーネルや各種ライブラリをはじめ、多くのソフトウェアで脆弱性の第一要因になっています。
近年は Rust をはじめとするコンパイラレベルのメモリ安全な言語が出て、メモリ脆弱性を引き起こすリスクは大幅に減りました。しかし、Rust で書かれたソフトウェアも結局はC言語などといったレガシーな言語で書かれたライブラリを FFI としてインポートしていたり、FFI や低レイヤプログラミングにおいて unsafe code を書かなければならなかったりして、メモリ脆弱性から完全に逃れられるわけではありません。
メモリ隔離技術
そこで大事になってくるのが、間違ったメモリにアクセスしてしまったら即座に動作を停止させることでメモリ脆弱性を未然に防止する策、いわば「フェイルセーフな設計」です。あらかじめプログラムがアクセスできるメモリ領域を限定(アクセス制御)しておき、領域外にアクセスしたらアクセスをトラップさせます。いわゆる「メモリ分離技術」などと呼ばれる手法です。
メモリ分離技術の方式はいくつかあり、有名なものだと「SFI (Software Fault Isolation)」「コンテナ」「VM」があります。コンテナと VM は説明不要と思います。SFI は一つのアドレス空間内でソフトウェアレベルでアクセス制御をする手法です。
対して、今回紹介する Intel MPK はハードウェアレベルでアクセス制御を行います。ハードウェア、すなわち CPU や MMU によるメモリ保護機能を利用して、一つのアドレス空間内に複数のサンドボックスを作成します。主な特長は2つあり、1つはユーザ空間内に複数個のサンドボックスを作成できるので高速なアクセス制御が可能であること、もう1つはハードウェアレベルでの強力なアクセス制御が利用できることが挙げられます。
Intel MPK
ではハードウェアメモリ保護を利用するにはどうすれば良いのでしょうか?
ハードウェアメモリ保護機能は基本的にハードウェア依存です。Intel アーキテクチャには Intel アーキテクチャ用のものが、Arm アーキテクチャには Arm アーキテクチャ用のものがあります。
今回は Intel 向けのハードウェアメモリ保護である「Intel MPK (Memory Protection Keys)」を扱います。正確には、ユーザ空間向けの「Intel PKU (Protection Keys for Userspace)」です。近年の Intel CPU(2015年発売の Skylake 世代移行)であれば基本的に搭載されている機能ですので、実は Intel CPU であれば気軽に遊べます。
Protection Key
Intel PKU では「Protection Key (PKEY)」と呼ばれるメモリドメインが16個提供されています。メモリドメインとはアクセス制御の単位です。
例えば、あるメモリ領域 0x10000000~0x11000000 に対して Protection key(仮に PKEY 1)を割り当てたとき、そのメモリ領域を一括で read-only にしたり、read/write を許可したり、read/write のいずれも禁止したりすることができます。もし read/write 禁止にしたうえで 0x10000000 のメモリオブジェクトへのアクセスしようとした場合、メモリアクセス命令が実行される段階でハードウェアが実行をトラップし、不正なメモリアクセスを未然に防止することができるわけです。もちろん、バッファオーバーフローや out-of-bounds access などにも有効ですし、それらのメモリ脆弱性を突いた攻撃による影響を緩和できます(コードを書き換える攻撃など、完全に防げるわけではないことに注意)。
Intel PKU の良いところは、メモリドメイン1つ1つが連続領域である必要がないという点です。上の図のように、離れたメモリ領域同士を同じ Protection Key に設定してメモリドメインを生成できます。また、仮にメモリ領域が離れていたとしても、それによってアクセス権限変更のためのオーバーヘッドが増加することはありません。
Protection Key の設定方法
Protection Key を任意のメモリ領域に設定するには、Linux であれば pkey_mprotect() システムコールで簡単に設定できます。
pkey_mprotect(pointer, size, PROT_READ | PROT_WRITE, pkey)
pkey_mprotect() システムコールは、PTE のアクセス権フラグを設定する mprotect() システムコールに引数 pkey を加えたものです。ちょっと紛らわしいんですが、 PROT_READ | PROT_WRITE のところは mprotect() 由来の PTE フラグの設定で、Intel MPK とは関係ありません。Intel MPK に関連のあるところで説明すると、上記の例ではポインタ pointer が指しているアドレスから size 分のメモリ領域に pkey 番目の Protection Key を紐づけています。
アクセス権の変更
Intel PKU ではアクセス権の変更は Protection Key ごとに行います。Preotection Key に紐づけられた各メモリ領域は、自身の PTE フラグのアクセス権 & Protection Key のアクセス権といった論理積で最終的なアクセス権限が決まります。
Intel PKU のアクセス権を操作するフラグとして、各 Protection Key に対して AD (Access-Disabled) と WD (Write-Disabled) の2つのフラグがあり、AD を 1 にすると read/write アクセスが禁止、WD を 1 にすると write アクセスが禁止になります。AD も WD も 0 とすると、read/write アクセスの両方が許可される仕組みです。
| AD | WD | アクセス権 |
|---|---|---|
| 0 | 0 | r/w |
| 0 | 1 | r/- |
| 1 | 0 | -/- |
| 1 | 1 | -/- |
ちなみに実行権限は Intel PKU では操作できません。実行権限を変更したいなら PTE flag を触る必要があります。
各 Protection Key に紐づけられたページのアクセス権を変更するには、WRPKRU 命令を使用しますが、glibc では WRPKRU が pkey_set() としてラッパ関数が提供されており、これを使うと各 Protection Key に対して簡単にアクセス権を変更できます。
// 読み書き許可 (AD=0, WD=0) -> r/w
pkey_set(pkey, 0);
// 書き込み禁止 (AD=0, WD=1) -> r/-
pkey_set(pkey, PKEY_DISABLE_WRITE);
// アクセス許可 (AD=1, WD=0) -> -/-
pkey_set(pkey, PKEY_DISABLE_ACCESS);
Intel PKU の特徴の一つとして、非特権モード(user mode)のままアクセス権を変更できるという点があります。要は、システムコール呼び出しやカーネルへのコンテキストスイッチを行わずに、ユーザ空間内のアクセス権を設定できるということです。アクセス権の変更にカーネルが介入すると特権スイッチやコンテキストスイッチといったランタイムオーバーヘッドが発生しますので、ユーザモード内でアクセス権の切り替えが完結するのは非常に嬉しい点です。
Intel PKU を試してみる
お使いの PC の CPU が Intel PKU に対応していて、OS が Intel PKU に向けたページテーブル更新をサポートしており、かつ CPU で Intel PKU が有効化されているのであれば、ネイティブ環境で動かすことが可能です。
ですが、基本的には QEMU で Linux 仮想環境を用意したうえで試すのが良いでしょう。理由は3つあります。
- Windows では Intel MPK はサポートされていません。現状、Windows で利用するための API が用意されていませんし、CPU で有効化されているかどうかを確認する術もありません。
- Mac は ARM アーキテクチャなので利用不可です。
- Linux では Intel MPK がサポートされています。上記で示したように、
pkey_mprotect()といった Intel PKU に向けられたシステムコールが提供されていますし、Intel PKU の有効化・無効化設定が可能です。ただ、WSL2 や Hyper-V といった VM では利用できない場合がほとんどです。一方、QEMU は Intel PKU および Intel PKS の仮想化機能を提供しています。
…というわけで、実用環境においては、ほぼ Linux ネイティブ環境専用になってしまっているのが現状です。個人的には Windows でも利用できるようになってもっと実用化されてほしいのですが、ハードウェア依存だったりしてなかなか普及していないのでしょう。
ここでは、QEMU で Linux 仮想環境を作って Intel PKU を利用する方法について書きます。
実行環境
- Ubuntu 24.04.3
- Linux 6.x (QEMU 仮想環境; ネイティブ環境で動かせる場合は必須ではない)
- Intel PKU が有効化された環境
QEMU 仮想環境の用意
※ご利用の環境で直に Intel PKU が利用できる場合は不要です
Intel PKU が動く環境を用意するために、QEMU 仮想環境を用意しましょう。Intel PKU がサポートされている x86_64 の Linux 4.9 以降なら何でも良いです。以前、Ubuntu の CUI 環境を QEMU で用意するための手順を別の記事にまとめていますので、必要に応じてご参照ください。
また、Intel PKU を仮想環境で利用するには、qemu-system-x86_64 のオプションで -cpu max を指定する必要があります。サンプルを下記に示します(イメージファイルが vm.qcow2 の場合)。
#!/bin/sh
VM_IMG=vm.qcow2
qemu-system-x86_64 \
-cpu max \
-m 4G -smp 4 \
-hda $VM_IMG \
-net nic -net user \
-nographic
Intel PKU が有効かどうかのチェック
ご利用の Linux 環境で Intel PKU が利用可能かどうかをチェックします。/proc/cpuinfo に pku フラグがあれば有効化されており利用可能な状態です。
$ grep -o pku /proc/cpuinfo
pku
pku
pku
pku
もし無効ならば、pku フラグは現れず何もヒットしません。
$ grep pku /proc/cpuinfo
# ちなみに筆者の Ubuntu on Hyper-V 環境 (x86_64) ではサポートしていませんでした…。なので今回は QEMU on Ubuntu on Hyper-V で実行しています。
簡単なアクセス制御の実装
Intel PKU が有効であることを確認できたら、下記のプログラムをコンパイルして実行します。
ここでは、① 空き PKEY を取得し、② メモリを動的確保し、③ PKEY とメモリを紐づけ、④ アクセス権を Write Disable (read-only) に変更、その後 ⑤ read、⑥ write アクセスをしています。
#define _GNU_SOURCE
#include <err.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
int main() {
unsigned int pkru_value = 0;
// ① PKEY を割り当てる
const int pkey = pkey_alloc(0, 0);
if (pkey == -1) {
perror("pkey_alloc failed");
return 1;
}
printf("Allocated PKEY: %d\n", pkey);
// ② メモリを動的確保する
const int page_size = getpagesize();
char *mem_obj = (char *)mmap(NULL, page_size, PROT_READ | PROT_WRITE,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
if (!mem_obj) {
perror("Memory allocation failed");
return 1;
}
printf("Memory object allocated\n");
// ③ メモリオブジェクトに PKEY を紐づけ
if (pkey_mprotect(mem_obj, page_size, PROT_READ | PROT_WRITE, pkey) == -1) {
perror("pkey_mprotect failed");
if (munmap(mem_obj, page_size) == -1) {
perror("munmap failed");
}
return 1;
}
printf("The memory object associated with PKEY %d\n", pkey);
// ④ PKEY の write アクセスを無効化 (AD=0, WD=1)
pkey_set(pkey, PKEY_DISABLE_WRITE);
printf("PKEY %d has been set to Write-Disabled (WD)\n", pkey);
// ⑤ 値を読み込む
volatile char value = mem_obj[0]; // 読み込みは成功する
printf("Read value: %d\n", value);
// ⑥ 書き込みを試みる (ここでセグメンテーションフォルトが発生するはず)
printf("Attempting to write to memory object with PKEY %d...\n", pkey);
mem_obj[0] = 123; // ここでアクセス違反が発生するはず
// PKU が有効の限り、ここに到達することはないはず..
printf("Write succeeded unexpectedly!\n");
if (munmap(mem_obj, page_size) == -1) {
perror("munmap failed");
return 1;
}
return 1;
}
Intel PKU が正しく動作していれば、⑤ は成功し、一方で ⑥ は失敗するはずです。
実行してみる
このプログラムを実行してみます。こんな画面になっていれば成功です。

順を追って見ていきましょう。
- カーネルから割り当てられた PKEY 番号は 1
- メモリオブジェクト用のメモリが割り当てられた
- メモリオブジェクトは PKEY 1 と紐づけられた(= PTE に PKEY 1 が設定された)
- PKEY 1 は Write Disabled (WD) になった
- メモリオブジェクトから read した。値は 0 だった
- メモリオブジェクトに書き込もうとした
- Segmentation fault (core dumped) 発生
この Segmentation fault の発生が重要で、このメモリオブジェクトは事前に PKEY 1 に紐づけられ、かつ、PKEY 1 が Write Disabled、すなわち read アクセスだけが許可される状態になったため、書き込もうとした時にアクセス違反が発生し、例外エラーによって実行が中断されたのです。
アクセス権を変更してみる
では、アクセス権を変更したらどうなるでしょうか?
まずは read アクセスも禁止にしてみましょう。pkey_set() のアクセス権を PKEY_DISABLE_ACCESS に変更してみます。
// ④' PKEY の read/write アクセスを禁止 (AD=1, WD=0)
pkey_set(pkey, PKEY_DISABLE_ACCESS); // 変更
printf("PKEY %d has been set to Access-Disabled (AD)\n", pkey);
すると、read アクセスの時点で Segmentation fault が発生しました。そう、Access Disabled では read/write アクセスの両方が禁止されるからですね。

今度は read/write アクセスを許可するために、pkey_set() のアクセス権を 0 に置き換えてみます。
// ④'' PKEY の read/write アクセスを許可 (AD=0, WD=0)
pkey_set(pkey, 0); // 変更
printf("PKEY %d has been set to Write-Disabled (WD)\n", pkey);
...
// 書き込みに成功し、ここまで到達する
printf("Write succeeded: %d\n", mem_obj[0]);
if (munmap(mem_obj, page_size) == -1) {
perror("munmap failed");
return 1;
}
return 0;
}
今度は書き込みに成功しました。Intel MPK によって書き込みアクセスが許可された様子です。

結局、何が嬉しいのか?
これで Intel PKU によって PKEY ごとにアクセス権限を制御できることが確認できました。では、Intel PKU を使ってアクセス制御するのと、これまでの別の方法(mprotect など)で制御するのとでは何が違うのでしょうか?
嬉しい点 1. アクセス権限の変更にはシステムコールいらず
これは冒頭でも書きましたが、Intel PKU の一つの特長は、アクセス権限の変更のためのシステムコール呼び出しが必要ないという点です。
Intel PKU では、事前処理として PTE と Protection Key の紐づけが必要ですが、一度紐づけてしまえば、以降はアクセス権変更のためにシステムコールを呼び出すことはありません。アクセス権限変更に使う pkey_set() 関数は glib で提供されているライブラリ関数で、ユーザ側で処理が完結します。なぜならアクセス権限を設定する WRPKRU 命令はユーザ権限で実行可能だからです。
このように、カーネルの介入を必要とせず、ユーザアプリケーション内でアクセス権限の制御が完結するので、システムコール発行に伴うランタイムオーバーヘッド(特権モードスイッチ、コンテキストスイッチなど)を回避できるというのも Intel PKU の嬉しい点です。
嬉しい点 2. TLB flush が発生しない
OS に詳しい人であれば、mprotect() などを使ってメモリ領域のアクセス権限を変更したとき、TLB flush が行われることをご存知だと思います。TLB (Translation Lookaside Buffer) は物理アドレスと仮想アドレスのマッピング状態を一時的に記憶しておくキャッシュです。仮想アドレスから物理アドレスへの変換作業を行うために Page Walk と呼ばれるページテーブルを順に辿っていく操作が実行されるのですが、これを毎回実施するのはメモリアクセスのたびに多大なオーバーヘッドを発生させます。そこで、あらかじめよく使うメモリマップ関係をキャッシュとして記録しておき、Page Walk を回避して高速化するという手法が取られているのです。
しかし、TLB は PTE (Page Table Entry) の状態を記憶しておきますので、mprotect() などで PTE のアクセス権限フラグを変更した場合、それを反映させるために TLB のキャッシュをクリアしなければなりません。これが頻繁に起きてしまうと、TLB による高速化が利用できず低速化してしまうのです。
一方で、Intel MPK (PKU) では TLB flush は発生しません。なぜならアクセス権限は PTE ではなく PKRU といった専用のレジスタに記録されており、MMU が PKRU を直接参照するからです。よって、PKU 側で pkey_set() でアクセス権限を変更しても、TLB flush の必要がなく、引き続き TLB のキャッシュが利用可能なのです。
※ ただし、PKEY 番号の変更(pkey_alloc())には PTE の更新が必要ですので TLB flush を伴います。あくまでも PKEY を変更せずにアクセス権限を変更する場合に TLB flush が発生しない、という話です。
活用例・改善例
今回は PKEY 1つしか利用していませんが、最大16個まで利用できますので、ユーザ空間内にサンドボックスのような形で複数のメモリオブジェクトを保護することが可能です。ここでは活用例としていくつか紹介しておきます。
- PKRU-safe: automatically locking down the heap between safe and unsafe languages
- Rust はメモリ安全言語ですが、unsafe なコードの記述も可能です。現に低レイヤのコードやレガシーなライブラリはポインタを使った操作が必要になったりし、ここがセキュリティホールとなりかねません。そこで、PKRU-safe では Rust アプリケーションを untrusted なコードから守るために、Intel PKU を使って trusted 領域と untrusted 領域を隔離しています。
- Harmonizing performance and isolation in microkernels with efficient intra-kernel isolation and communication
- マイクロカーネルは OS コンポーネントごとに仮想アドレス空間によって隔離されるので、Linux のようなモノリシックカーネルに比べて安全です。しかし、仮想アドレス空間の間でのスイッチや、コンポーネント同士の IPC(プロセス間通信)が大量に発生し、大幅なオーバーヘッドを伴うことが長年の課題となっています。そこで UnderBridge では、頻繁に使う OS コンポーネントを特別にカーネル空間で実行し、各コンポーネント同士を Intel PKU を使って隔離するというアプローチをとっています。Intel PKU によるアクセス制御は仮想アドレス空間のそれと比べて高速ですので、この方法によってマイクロカーネルを高速化できることが示されています。
- libmpk: Software Abstraction for Intel Memory Protection Keys (Intel MPK)
- Intel PKU は PKEY が16個までだが、それを無数個用意できるようにしたという話。具体的には
mprotect()などを組み合わせていて、うまい具合に常時16個までの PKEY に収まるようにし、溢れた分はmproct()などでアクセス制御します。
- Intel PKU は PKEY が16個までだが、それを無数個用意できるようにしたという話。具体的には
- VDom: Fast and Unlimited Virtual Domains on Multiple Architectures
- 課題設定は libmpk と同じですが、こちらは複数個の仮想アドレス空間を用意しておき、それらと組み合わせて無数個の PKEY を用意するというアプローチです。
- ERIM: Secure, Efficient In-process Isolation with Protection Keys (MPK)
- Intel PKU は強制力のあるアクセス権限制御を提供してくれますが、攻撃者によってアクセス権限設定のコードまで変更されてしまうと、アクセス制御の回避ができてしまいます。ERIM ではこれを防ぐための方法を提案しています。
おわりに
Intel PKU はユーザ空間内で高速なアクセス権限制御を実現する仕組みです。まだアカデミックな分野での活用例しか見られないので、もっと多くの分野で使われればいいのになぁなんて思っています。少しでも多くの方に知っていただければ幸いです。
補足情報
Intel PKS
今回紹介したのはユーザ空間向けの Intel PKU (Protection Keys for Userspace) ですが、Intel MPK にはもう一つ、カーネル空間向けの Intel PKS (Protection Keys for Supervisor) の2つがあります。どちらも同様の機能を提供する物ですが、ユーザアプリケーションで利用できるのは前者の Intel PKU のみです(Intel PKS を利用するには、カーネル特権が必要です)。今回は Intel PKU にフォーカスを絞っている点、ご承知おきください。
自分でカーネルに Intel MPK を実装したい場合(上級者向け)
Linux では Intel MPK に対する操作がシステムコールによって抽象化されているので簡単に操作できるんですが、自作 OS や xv6 などで Intel MPK を使いたい、あるいは Linux カーネルを改造して自分で実装したいという変態の方々に向けて詳細な仕様についてもメモしておきます。
Intel MPK を有効化する
Intel MPK はデフォルトでは無効化されています。有効化するには CR4 レジスタの PKE フラグを1にする必要があります。この PKE フラグは CR4 レジスタの22ビット目です。
アセンブリコードでの実装例:
mov %cr4, %eax
bts $22, %eax
mov %eax, %cr4
Protection Key の設定方法
Protection Key はページごとに設定する必要があります。具体的には、Page Table Entry の Protection Key field に PKEY 番号(0~15)を 4bit で設定します。この PKEY field は Page Table Entry の 59~62 bit 目と定められています。
アクセス権の変更・読み取り
直接 WRPKRU 命令を呼び出すには、アセンブリでこんな感じで書きます。
EAX レジスタには更新後の PKRU の値を代入しておき、ECX, EDX は 0 としておきます。
# EAX レジスタに更新後の値を代入する
movl $[PKUに書き込む値], %eax
# ecx, edx レジスタの初期化
movl $0x0, %ecx
movl $0x0, %edx
# wrpkru の実行
wrpkru
ちなみに PKRU から値を読み出す RDPKRU 命令を実行すると、EAX レジスタに PKRU の内容が代入され、EDX レジスタは 0 にセットされます。
実行前に ECX レジスタは 0 にセットしておく必要があります。
# レジスタのクリア
movl $0x0, %ecx
# rspkru の実行
rdpkru
# EAX <- PKRU
# EDX <- 0
Intel PKS を利用したい場合
WRPKRU/RDPKRU 命令の操作はユーザ空間に対してのみ有効です。
カーネル空間に対して Protection Key を割り当てた場合、カーネル空間向けの Intel PKS の Protection Key とみなされ、WRPKRU/RDPKRU では操作できなくなります。カーネル空間で利用したい場合、Intel PKS の有効化(CR4.PKS=1)、および WRMSR 命令を使ってアクセス権の変更を行う必要があります。 WRMSR を呼び出すときは、ECX レジスタに 0x6e1 を代入して PKS 用の MSR レジスタをターゲットに指定します。
# EAX レジスタに更新後の値を代入する
movl $[PKSに書き込む値], %eax
# ecx, edx レジスタの初期化
movl $0x6E1, %ecx
movl $0x0, %edx
# wrmsr 命令の実行
wrmsr