/ «2006-07-11 (Tue) ^ 2006-07-18 (Tue)» ?
   西田 亙の本:GNU 開発ツール -- hello.c から a.out が誕生するまで --

Categories Books | Hard | Hardware | Linux | MCU | Misc | Publish | Radio | Repository | Thoughts | Time | UNIX | Writing | プロフィール


2006-07-15 (Sat)

[UNIX] malloc failure (その1)

failmalloc と危機管理

奥地氏の enbug diary で、とても刺激的なお題を見つけました。failmalloc と呼ばれる共有ライブラリパッケージを使ったお話ですが、要は "意図的にメモリ確保に失敗する malloc 共有ライブラリ" を使い、メモリ管理を内部で正しく行っているかどうか、"外部から" 簡単に検証してみようというものです。

failmalloc は英文の紹介ページですが、その内容はさすがです。なぜ、氏がこのような事を思いついたのかは、次の一言に集約されています。

 経験不足な人が書いたコードはエラーチェックが無茶苦茶である。
 要するに、失敗することを考えていない。

後で述べますが、私は Linux 環境もまた「失敗することを考えておらず、危機意識に欠けている」と常々感じていましたので、failmalloc とは波長がピッタリと一致。折しも、共有ライブラリに関する解説を書き終えたところでしたので、今日は久しぶりにテクニカルライターの観点から、この問題を捉え直してみたいと思います(ハードネタが続いていたことですし)。

failmalloc が教えるもの

既に、failmalloc はネット上のあちこちで話題に上がっているようですが、この問題は後半で紹介されている "スクリプト言語の不甲斐なさ" として済ませるべきものではありません。failmalloc が提起している問題はとても深く、その意味を汲み取るためには、まず failmalloc 自身の仕組みを理解する必要があります。

残念ながら、failmalloc は glibc に依存しているため、BSD や Mac OS X では再現できません。また、GNU 特有のパッケージ構成になっているため、ソースツリー内部は "雑音" が多く、コードの仕組みを読み取ることが難しくなっています。

そこで、今回は10〜30行の小さなプログラムを自分の手で組みながら、failmalloc が提示している問題を理解してみましょう。

man 2 malloc

最初に、malloc ライブラリ関数の仕様を再確認しておきます。


  SYNOPSIS
      #include <stdlib.h>

      void *malloc(size_t size);

  DESCRIPTION
      malloc()  allocates  size  bytes  and returns a pointer to the allocated
      memory.  The memory is not cleared.

  RETURN VALUE
      For  malloc(), the value returned is a pointer to the allocated
      memory, which is suitably aligned for any  kind  of  variable,  or
      NULL if the request fails.

最終行に書かれている "メモリー確保に失敗した時、malloc は NULL を返す" という点が、今回のポイントです。すべてのプログラマーが、malloc のインターフェースに従い、メモリー確保失敗を念頭に置いたコードを書いていれば、問題は発生するはずがありません。ところが、奥地氏が確かめてみると、さにあらず・・。

Segmentation fault 検証

さて、malloc から NULL が返された際、エラーとして対処しなければ、一体何が起きるのでしょうか?実際にコードで実験してみましょう。手始めに、malloc の動作テストプログラム malloc_success.c を用意します。

#include <stdio.h>     // printf()
#include <stdlib.h>    // malloc(), free()

#define BUFSIZE        16

int main() {
  int i;
  unsigned char* buf;
  unsigned char* ptr;

  ptr = buf = malloc(BUFSIZE);
  if (buf == 0) return 1;
  printf("buf = %p\n", buf);

  for (i = 0; i < BUFSIZE; i++)
    *(ptr++) = i % 256;

  ptr = buf;
  for (i = 0; i < BUFSIZE; i++)
    printf("%02X\n", *(ptr++));

  free(buf);
  return 0;
 }

16バイトのメモリ領域を確保し、その開始アドレスを buf ポインタ変数に格納した上で、領域全体を初期化するだけの単純なプログラムです。

$ gcc -Wall -o malloc_success malloc_success.c 
$ ./malloc_success ; echo $?
buf = 0x804a008
00
01
02
03
04
05
06
07
08
09
0A
0B
0C
0D
0E
0F
0

この実行例では、malloc により 0x804a008 番地からのメモリ領域が確保され、初期化は正常に終了しています。次に、強制的に NULL (0) を返すオリジナル malloc を実装し、NULL のチェックを削除した、malloc_fail.c を用意します。

#include <stdio.h>     // printf()

#define BUFSIZE        16

void* malloc(size_t size) {
  return (void*) 0;
 }

int main() {
  int i;
  unsigned char* buf;
  unsigned char* ptr;

  ptr = buf = malloc(BUFSIZE);
  printf("buf = %p\n", buf);

  for (i = 0; i < BUFSIZE; i++)
    *(ptr++) = i % 256;

  ptr = buf;
  for (i = 0; i < BUFSIZE; i++)
    printf("%02X\n", *(ptr++));

  return 0;
 }

ポインタ変数 buf にはゼロが設定されますから、ゼロ番地からの初期化が行われることになります。

$ gcc -Wall -o malloc_fail malloc_fail.c 
$ ./malloc_fail ; echo $?
buf = (nil)
Segmentation fault
139

最初のループで Segmentation fault が発生し、プロセスは異常終了してしまいました。failmalloc のページで、python, perl, ruby が軒並み Segmentation fault を起こしていたのは、malloc_fail と同じように、戻り値の確認を怠り、ゼロ番地に対するアクセスを許してしまったことが原因です。

メモリ保護機構

なぜ Segmentation fault が発生するのかについては、軽く一冊の本が書けてしまいますので、一般的には馴染みの薄いメモリ保護機構を簡単に実体験してみることにしましょう。

まず、プログラム(正確にはプロセス)中におけるデータや実行コードのアドレスを address.c により観察してみます。

#include <stdio.h>     // printf()
#include <unistd.h>    // getpid()

int data = 123;
const int rodata = 456;

int main(int argc, char** argv) {
  int block;

  printf("my pid  = %d\n", getpid());
  printf("&data   = %p\n", &data);
  printf("&rodata = %p\n", &rodata);
  printf("main    = %p\n", main);
  printf("&argc   = %p\n", &argc);
  printf("&argv   = %p\n", &argv);
  printf("&block  = %p\n", &block);

  while (1) ;

  return 0;
 }

address.c 中には、実行コードはもとより、.text, .data, .rodata の3セクション、およびスタック領域に存在するデータが定義されています。先頭で getpid を用いて自身のプロセスID(PID)を表示し、各種変数や関数の開始アドレスを表示した後、最後は while loop でブロックします。

$ gcc -Wall -o address address.c 
$ ./address 
my pid  = 9349
&data   = 0x804970c
&rodata = 0x8048598
main    = 0x80483b4
&argc   = 0xbfee0b70
&argv   = 0xbfee0b74
&block  = 0xbfee0b64

block 変数のアドレスを表示したところで、ハングアップしますので、コントロールZを入力し、address プロセスを一旦バックグラウンドに移行させます。

[1]+  Stopped                 ./address
$

実行例のPIDは 9349 でした。Linux の場合、/proc ディレクトリ中には、各プロセスのPIDをファイル名としたディレクトリが存在し、その内部には様々なプロセス情報が格納されています。

$ ls -F /proc
1/     2534/  3958/  5/     9138/      diskstats    kcore       self@
1044/  3/     3960/  6/     9349/      dma          key-users   slabinfo
1045/  3288/  3962/  7/     9383/      driver/      kmsg        stat
12/    3833/  3963/  710/   94/        execdomains  loadavg     swaps
121/   3839/  3965/  8/     95/        fb           locks       sys/
122/   3882/  3966/  8160/  acpi/      filesystems  meminfo     sysrq-trigger
123/   3888/  3979/  8163/  asound/    fs/          misc        sysvipc/
124/   3892/  3980/  8164/  buddyinfo  ide/         modules     tty/
125/   3901/  3981/  8240/  bus/       interrupts   mounts@     uptime
1398/  3926/  3982/  8243/  cmdline    iomem        mtrr        version
1646/  3932/  3983/  8244/  cpuinfo    ioports      net/        vmstat
1804/  3939/  3984/  9/     crypto     irq/         partitions
2/     3945/  4/     9132/  devices    kallsyms     scsi/
$ ls -F /proc/9349
attr/  cmdline  environ  fd/   mem     oom_adj    root@    stat   status  wchan
auxv   cwd@     exe@     maps  mounts  oom_score  seccomp  statm  task/

この中の maps ファイルを参照すると、テキスト形式でプロセスの mmap (Memory MAP)状況を得ることができます。

$ cat /proc/9349/maps
08048000-08049000 r-xp 00000000 03:04 713410     /usr/src/work/evil_malloc/address
08049000-0804a000 rw-p 00000000 03:04 713410     /usr/src/work/evil_malloc/address
b7e8b000-b7e8c000 rw-p b7e8b000 00:00 0 
b7e8c000-b7fba000 r-xp 00000000 03:04 1609       /lib/tls/libc-2.3.6.so
b7fba000-b7fbf000 r--p 0012e000 03:04 1609       /lib/tls/libc-2.3.6.so
b7fbf000-b7fc2000 rw-p 00133000 03:04 1609       /lib/tls/libc-2.3.6.so
b7fc2000-b7fc4000 rw-p b7fc2000 00:00 0 
b7fca000-b7fcd000 rw-p b7fca000 00:00 0 
b7fcd000-b7fe2000 r-xp 00000000 03:04 801        /lib/ld-2.3.6.so
b7fe2000-b7fe4000 rw-p 00015000 03:04 801        /lib/ld-2.3.6.so
bfecd000-bfee2000 rw-p bfecd000 00:00 0          [stack]
ffffe000-fffff000 ---p 00000000 00:00 0          [vdso]

address プロセスに許可されているメモリー領域の一覧ですが、先頭の2行に注目してください。data 変数の格納アドレスは 0x804970c 番地でしたから、同変数は2番目の 08049000-0804a000 領域に存在することが分かります。

maps 情報中の2桁目のフィールドはメモリーパーミッションのフラグを表していますが、08049000-0804a000 領域は rw-p に設定されていますので、Read/Write が許可されていることが分かります。

一方 const 宣言を受けている rodata 変数の格納アドレスは 0x8048598 番地でしたから、この変数は1番目の 08048000-08049000 領域に存在します。同領域のメモリーパーミッションは r-xp ですから、Read/eXecute が許可されていることになります。

maps 情報中に登録されていないメモリー領域に対するアクセス、登録されていてもメモリーパーミッションフラグに反するアクセス(Write フラグが OFF の領域への書き込みなど)は、直ちに Segmentation fault を引き起こし、プロセスはカーネルから強制終了させられます。

ゼロ番地へのアクセスが Segmentation fault を引き起こした理由は、これでお分かり頂けるかと思います。なお、Linux 上のプロセスの開始アドレスは maps 情報からも明らかな通り、0x08048000 番地という決まりになっています。ゼロ番地でない理由のひとつとして、頻度の高いメモリ先頭領域での保護違反を検出する目的があります。

最後に、バックグラウンドに移行させられている address プロセスを fg (ForeGround)コマンドで復帰させ、コントロールCで終了させておきましょう。

$ jobs
[1]+  Stopped                 ./address
$ fg
./address 

$ 

アドレスによるデータ操作とメモリーパーミッション

これだけでは、今ひとつメモリ保護機構の実感が湧きませんから、アドレスを通じて間接的にデータを操作する cast_read.c で理解を深めてみましょう。

#include <stdio.h>     // printf()

int data = 12345678;
const int rodata = 87654321;

int main() {

  printf("&data   = %p\n", &data);
  printf("&rodata = %p\n", &rodata);
  printf("data    = %d\n", *((int*) 0x0804967C));
  printf("rodata  = %d\n", *((int*) 0x08048538));

  return 0;
 }

内部で data, rodata 変数を定義し、それぞれの格納アドレスを表示した後、これらのアドレスを用いて間接的にふたつの変数内容にアクセスしています。アドレスの値は、環境によってことなりますので、出力された値に書き換えてください。

cast_read.c 中では、キャスト演算子が非常に重要な役割を演じています。C言語ではポインタ変数の使いこなしが大事だと言われますが、実践においては、キャスト演算子の理解と使いこなしが、より重要と言えるでしょう。

$ gcc -Wall -o cast_read cast_read.c 
$ ./cast_read 
&data   = 0x804967c
&rodata = 0x8048538
data    = 12345678
rodata  = 87654321

実行例では、data 変数が 0x804967c 番地、rodata 変数が 0x8048538 番地に格納されていました。ソースリスト中で、それぞれのアドレスへアクセスすると、変数を介することなく、その内容を参照することが出来ています。このように、変数名はアドレスの別名に過ぎないのです。

cast_read.c の知識を応用して、ゼロ番地への Read 参照を行ってみましょう(cast_read_zero.c)。

#include <stdio.h>     // printf()

int main() {

  printf("%d\n", *((int*) 0));
  return 0;
 }

もはや、説明の必要もありません。

$ gcc -Wall -o cast_read_zero cast_read_zero.c 
$ ./cast_read_zero 
Segmentation fault

意図的に Segmentation fault を再現できました。ゼロ番地だけでなく、maps に登録されていないメモリ領域に対する参照を行い、Segmentation fault を発生させてみてください。

実験ついでに、今度はアドレスを通して data 変数の内容を書き換える、cast_write_data.c に挑戦してみましょう。

#include <stdio.h>     // printf()

int data = 12345678;
const int rodata = 87654321;

int main() {

  printf("&data   = %p\n", &data);
  printf("&rodata = %p\n", &rodata);

  printf("data    = %d\n", *((int*) 0x0804969c));
  printf("rodata  = %d\n", *((int*) 0x08048558));

  *((int*) 0x0804969C) = 11223344;
  printf("data    = %d\n", *((int*) 0x0804969c));

  return 0;
 }

cast_read_data.c とほとんど同じですが、*((int*) 0x0804969c) = 11223344; により、data 変数を間接的に書き換えています。

$ gcc -Wall -o cast_write_data cast_write_data.c 
$ ./cast_write_data 
&data   = 0x804969c
&rodata = 0x8048558
data    = 12345678
rodata  = 87654321
data    = 11223344

意図通り、data 変数が 12345678 から 11223344 に書き換えられています。rodata 変数や、Read only 領域のアドレス(0x8048000)などへの書き込みを行い、Segmentation fault を確認してみてください。

つづく

以上で、ようやく基礎部分の終了です。長くなりましたので、楽しい libc-hijack はまた明日。