はじめに
2025年6月、Google Cloudが大規模な障害を起こしたことは記憶に新しいと思います。 それも原因がNULLポインタということで、SNSは大きく盛り上がっていましたね。
「え、そんなことで、Google Cloudが落ちるの?!」 と驚かれた方、「俺ならNULLとか絶対参照しないけどなあ笑」 と笑っている方もいらっしゃるのではないでしょうか?
しかし、実際のところNULLポインタがなんなのか、どうしてアクセスを試みるとクラッシュするのかをちゃんと理解している方はとても少ないと思います。
NULLポインタをちゃんと理解するために、深掘りして見ていきましょう。
NULLポインタとは?
Cに少しでも触れた方であれば、NULL
という単語を目にしたことがあるでしょう。
ではNULL
とはなんでしょうか?
「NULL
は0でしょ?」と思う方も一定数いらっしゃると思います。間違いではないのですが、正確にはちょっと違うんですね。
定義を見てみましょう。NULL
は大体マクロとして定義されています。
この定義が示すように、NULL
の実体はアドレス0を表す無効なポインタ値なんですね。
0だとか空っぽといったイメージとは裏腹に、NULL
ポインタは「仮想アドレス空間の0番地」を明確に指し示しているのですね。
単に「0を指しているだけ」と考えると、それがなぜ危険なのかピンとこないかもしれません。
しかし、実はこの0番地へのアクセスが、触れてはいけない特別な領域へのアクセスを意味するのです。
なぜNULLを参照するとクラッシュするのか?
仮想アドレス空間
少しアドレスの話をしましょう。
OSはプログラムに対して独立した仮想アドレス空間を与えています。これにより、他のプログラムに影響を与えず自由にメモリを利用できます。
でも、この仮想アドレス空間の一番低い部分、0番地を含む最初の一部分に関しては、OSがわざと「アクセス禁止」にしています。
これはNULLポインタを参照しちゃったときに、OSがすぐに異常を検知し、プログラムを強制終了させるためです。
もしここが使えていたら、うっかり触ってぶっ壊れちゃうことがありそうですよね。
OSは仮想アドレスを実際のメモリに変換して、「読み書きOK」などの許可を設定しています。
MMUと協力してガッチリと制御することで、許可されていない0番地へのアクセスは、即座に察知し、「セグフォだよ!」と知らせてくれるんですね。
NULLにアクセスしてみる
実際にNULLポインタへのアクセスを試みてみます。
#include
int main(void)
{
int *p = NULL;
*p = 1;
printf("reached?\n"); // もちろん到達しません
return (0);
}
実行すると、セグフォで強制終了します。
アクセスが許可されていないメモリ領域にアクセスしようとしたため、プロセスを強制終了したということです。先ほど説明したセグフォのお知らせがきたんですね。
アドレス空間を覗いてみる
アドレス空間を見てみましょう。
#include
#include
int global_var = 23;
void print_layout(void)
{
int local_var = 0;
int *heap_var = malloc(sizeof(int));
if (heap_var == NULL)
return;
printf("print_layout: %p\n", (void*)print_layout);
printf("global_var : %p\n", (void*)&global_var);
printf("heap_var : %p\n", (void*)heap_var);
printf("local_var : %p\n", (void*)&local_var);
free(heap_var);
}
int main(void)
{
print_layout();
return (0);
}
出力されるアドレスは毎回高い位置にありますね。
何度か繰り返すと、メモリの最初の方にはマッピングされていないことに気がつくかもしれません。毎回アドレスがランダムなのはASLRによるものです。
もう一つの例を見てみましょう。
Linuxでは低い値へのアクセスを拒否するためにmmap()
が可能なアドレスの最小値が決められています。
cat /proc/sys/vm/mmap_min_addr
環境によって数値は異なりますが、非0の値が出力されると思います。
これ未満のアドレスにはユーザーがmmap()
できないということです。
これは低いアドレスにアクセスすることによるセキュリティリスクを低減するためのものです。
古いバージョンのディストリビューションでは制限の値が小さい、あるいは0であったために脆弱性がありましたが、最近のものでは大抵ガードされているkと思います。
色々な視点から見てみましたが、やはり0番地付近はOSが保護している領域であることが理解できると思います。
mmapで侵入しようとしてみる
では、無理やりメモリを割り当てようとしたらどうなるでしょうか?
あえて侵入を試みてみましょう。
#include
#include
#include
#include
int main(void)
{
void *addr = mmap((void*)0x0, 4096,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE,
-1,0);
if (addr == MAP_FAILED)
printf("failed: %s\n", strerror(errno));
else
{
printf("??: %p\n", addr);
munmap(addr, 4096);
}
return (0);
}
大体の環境ではこのコードは失敗し、Operation not permitted
になります。
つまりはOSから明確にアクセスを拒否されているわけです。
やはり0番地付近はOSが頑なにガードしている特別な領域ということです。
SIGSEGVで確認してみる
SIGSEGVにシグナルハンドラを設定して改めてアクセスを拒絶されていることを確認します。
#include
#include
#include
#include
void handler(int sig, siginfo_t *si, void *ctx)
{
printf("Caught SIGSEGV, address: %p\n", si->si_addr);
_exit(1);
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sa, NULL);
int *p = NULL;
*p = 1;
return (0);
}
アドレスのどこでセグフォが起きたのかが出力されたと思います。
OSがNULLアクセスを危険な行為として明示的に止めていることが分かりますね。
おわりに
色々な角度からNULLポインタを分析しました。NULLが単なる空っぽや0ではないことがお分かりいただけたかと思います。
- NULLはただの0番地ではなく、OSがアクセスを厳しく禁止している特別な領域
- 「NULLは来ないと思ってた」なんて油断は禁物です。NULLを参照するコードを書いてしまえば、OSに「セグフォだ!」と怒られて、プログラムは落ちる
NULLポインタを安全に扱うためには、「そもそもNULLが来ない設計」 が望ましいです。
もし設計上、NULLを受け入れる必要がある場合は、 「参照する前に必ずNULLかどうかを確認する」 ことが必要ということです。
Views: 0