/ «2006-08-23 (Wed) ^ 2006-09-01 (Fri)» ?
   西田 亙の本:GNU 開発ツール -- hello.c から a.out が誕生するまで --

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


2006-08-26 (Sat)

[UNIX] root のアクセス制御

今回は出版の話から離れ、先日神戸(かんべ)さんからご指摘頂いた、「ルートの場合、システムコールレベルで write パーミッションは無視される」というお話を peti-hacking を通じて確認してみましょう。

ただし、通常のプログラミングではプログラマーがシステムコールを直接呼び出したつもりでも、実際にはCライブラリ内の wrapper function が呼び出されてしまいます。途中で自分があずかり知らないコードが関与すると、カーネルの仕様にもとづく動作なのか、それともCライブラリ関数による介入が影響しているのか、判断がつきません。

そこで、今回は勉強がてら glibc に頼ることなく、Linux システムコールを直接呼び出す実験プログラムに挑戦してみましょう。

return.c による実験

まず手始めに、次の return.c を用意します。

int main() {
  return 123;
 }

123 を "返す" だけの極めて単純なプログラムですが、実はこのコードが意味するところはとても深いのです。

$ gcc -Wall -o return return.c 
$ ./return ; echo $?
123
$ ls -l return
-rwxr-xr-x  1 wataru wataru 7072 2006-08-27 23:46 return

gcc ドライバを用いて実行可能ファイル return を生成し、実行後 $? 変数を用いて return プロセスが親プロセス(この場合はシェル)に返したステータスコードを表示しています。

123 が表示されていることから、問題なく動作しています。デフォルトのビルド方法で作成されたプログラムファイルは動的リンクに基づいていますが、この時のファイルサイズは7Kバイトです。

次に、-static オプションを指定して、静的リンクで return_static プログラムファイルを作成してみましょう。

$ gcc -Wall -o return_static -static return.c 
$ ./return_static ; echo $?
123
$ ls -l return_static 
-rwxr-xr-x  1 wataru wataru 508334 2006-08-27 23:48  return_static

同じ動作をするプログラムファイルにもかかわらず、今度は508Kバイトにもなっています。リンク方法の違いにより、なぜここまでプログラムファイルが肥大化するのか?この謎は、GNU開発ツールを紐解いて頂ければ、氷解するはずです(少し宣伝でした)。

_start エントリ

実は、GNU C コンパイラで作成した ELF プログラムは main ではなく、_start から実行が始まるように定められています。

これまで main 関数の return 文を見た際に、「最初に実行されるはずの main は、一体どこへ戻るというの?」という疑問を持たれていた方もいらっしゃるかと思いますが、この疑問は極めて正しいのです。残念なことに、この疑問に応えてくれる書籍はほとんどないのですが、種明かしをすると「main は crt1.o 内部の _start 手続きから呼び出されます」。証拠は次の通り。

$ nm -u `gcc -print-file-name=crt1.o`
        U __libc_csu_fini
        U __libc_csu_init
        U __libc_start_main
        U main

このコマンドが意味するところについては、GNU開発ツールを参照して頂くとして、重要なのは nm コマンドで表示された crt1.o オブジェクトファイル中の "未定義シンボル" 中に main が顔を覗かせている点にあります。main が未定義ということは、裏を返せば crt1.o の内部で main 関数を呼び出しているコードが存在することを意味しているのです。

_start の成りすまし

それでは、いっそのこと main 関数の名前を _start にしてみてはどうでしょうか?この推論に基づき、_start.c を用意します。

int _start() {
  return 123;
 }

mian が _start に変わっているだけです。

$ gcc -Wall -c _start.c 
$ ls -l _start.o
-rw-r--r--  1 wataru wataru 697 2006-08-27 23:49 _start.o

今度は gcc ドライバに -c (Compile) オプションを渡し、オブジェクトファイルのみを生成しています。出来上がった _start.o のファイルサイズは697バイトでした。

オブジェジェクトファイルを最終的な実行可能ファイルに変身させるためには、リンクが必要となりますが、この作業を担当しているツールがリンカローダ(通常リンカ) ld です。

$ ld -o _start _start.o
$ ls -l _start
-rwxr-xr-x  1 wataru wataru 717 2006-08-27 23:50 _start
$ file _start
_start: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),  statically linked, not stripped

ld に入力ファイルとして _start.o を指定し、-o (Output) オプションにより出力ファイル名を _start に指定しています。出来上がった _start は717バイトとオブジェクトファイル並のコンパクトさであり、file コマンドでその内容を確認すると「静的にリンクされた ELF 実行可能ファイルである」と表示されています。それでは、実行してみましょう。

$ ./_start ; echo $?
Segmentation fault
139

以前、malloc failure で紹介した segmentation fault が発生しています。x86 の保護機構により気楽に実験できていますが、MMU (Memory Management Unit) を持たないプロセッサの場合は、下手するとハングアップ、システム再起動です。

exit システムコール

前置きが長くなりましたが、ここからが本番です。ELF プログラムファイルがメモリ上に読み込まれプロセスとして起動し、処理を終了する際には、「単純に return するだけではダメ」なのです。プロセスを終了させるためには、カーネルに対して exit システムコールを発行する必要があります。

ちなみに exit という名前は、Cライブラリの関数名(man 3 exit)と衝突するため、exit システムコールを呼び出す wrapper function の名前は _exit に変更されています(man 2 _exit)。大変紛らわしいのですが、「exit ライブラリ関数、exit システムコール、_exit ライブラリ関数」の三者は厳密に区別する必要がありますので、注意しましょう。

先ほど登場した crt1.o は crt (C Run Time startup) と呼ばれるオブジェクトファイルのひとつであり、Cプログラムファイルが起動する際の初期化処理(ライブラリ・環境変数・argc/argv の設定)および終了処理(atexit などのライブラリ処理・_exit ライブラリ関数の呼び出し) を担当しています。今回は、ld に対して _start.o しか指定していないため、_exit が呼び出されず、プロセスが "暴走" してしまったのです。

exit システムコールの仕様は次のようになっています。

SYNOPSIS
      #include <unistd.h>

      void _exit(int status);

引数として int 型のステータスコードを取り、当然のことながら値は返しません。このシステムコールを具体的にどのような方法でコードに展開するかが問題となりますが、Linux の場合は <asm/unistd.h> に参考になる情報が記載されています。カーネルヘッダーファイルがインストールされていれば、/usr/include/asm/unistd.h で参照できるはずです。

#ifndef _ASM_I386_UNISTD_H_
#define _ASM_I386_UNISTD_H_ 

/*
 * This file contains the system call numbers.
 */ 

#define __NR_restart_syscall      0
#define __NR_exit                 1
#define __NR_fork                 2
#define __NR_read                 3
#define __NR_write                4
#define __NR_open                 5
#define __NR_close                6
#define __NR_waitpid              7
#define __NR_creat                8
#define __NR_link                 9
#define __NR_unlink              10
#define __NR_execve              11
...以下省略...

システムコールには連番が割り当てられており、exit は1番となっています(__NR_exit マクロとして定義)。

このファイルの後半には、システムコールを直接呼び出すための関数マクロ群が定義されています。

#define _syscall1(type,name,type1,arg1) \
type name(type1 arg1) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
        : "=a" (__res) \
        : "0" (__NR_##name),"b" ((long)(arg1))); \
__syscall_return(type,__res); \
}

_syscall1 は、ひとつの引数を取るシステムコールを展開するためのマクロであり、第一引数にはシステムコールの戻り値の型、第二引数にはシステムコール名、第三引数にはシステムコール引数の型、第三引数にはシステムコール引数の名前を指定します。

マクロの中身は GNU C コンパイラ得意のインラインアセンブラで記述されていますが、どのようなコードが展開されるのか、次のソースリスト _syscall1.c でチェックしてみましょう。

#include <asm/unistd.h>

_syscall1(void, exit, int, status);

3行目の記述により、exit システムコールを直接呼び出すコードが展開されます。マクロ展開はCプリプロセッサ(cpp)の担当ですから、プリプロセスを実行してみましょう。

$ cpp _syscall1.c 
# 1 "_syscall1.c"
# 1 "<built-in>"
# 1 "<command line>"
# 1 "_syscall1.c"
# 1 "/usr/include/asm/unistd.h" 1 3 4
# 2 "_syscall1.c" 2

void exit(int status) { long __res; __asm__ volatile ("int $0x80" : "=a" (__res) : "0" (1),"b" ((long)(status)) : "memory"); do { if ((unsigned long)(__res) >= (unsigned long)(-(128 + 1))) { errno = -(__res); __res = -1; } return (void) (__res); } while (0); };

非常に長いマクロ展開ですが、中身ではソフトウェア割り込み 0x80 を使い、システムコールが呼び出されています。分かりやすいように書き直すと、次のようになります。

void exit(int status) {
  long __res;

  __asm__ volatile ("int $0x80" :  \
                    "=a" (__res) : \
                    "0" (1), "b" ((long)(status)) : \
                    "memory");
  do {
    if ((unsigned long) (__res) >= (unsigned long)(-(128 + 1))) {
      errno = -(__res); __res = -1;
     }
    return (void) (__res);
   } while (0);
 };

glibc の _exit 関数とはことなり、このコードはカーネルと直接対話を行いますので、邪魔者が介在する余地は全くありません。コード中に存在する errno はCライブラリ内部で定義されているグローバル変数ですが、今回はライブラリのリンクは行わないため、ソース中で errno を定義しておきます。

それではこのテクニックを使い、先ほどの _start.c を書き直した _start_exit.c を作成します。

int errno;

void exit(int status) {
  long __res;

  __asm__ volatile ("int $0x80" :  \
                    "=a" (__res) : \
                    "0" (1), "b" ((long)(status)) : \
                    "memory");
  do {
    if ((unsigned long) (__res) >= (unsigned long)(-(128 + 1))) {
      errno = -(__res); __res = -1;
     }
    return (void) (__res);
   } while (0);
 };

void _start() {
  exit(123);
 }

前半に exit システムコールの呼び出しコードを配置し、_start の中では exit(123) を呼び出しています。それでは、ビルドしてみましょう。

$ gcc -Wall -fno-builtin -c _start_exit.c 
$ ld -o _start_exit _start_exit.o
$ ls -l _start_exit
-rwxr-xr-x  1 wataru wataru 889 2006-08-28 01:17 _start_exit
$ file _start_exit
_start_exit: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

-fno-builtin は小賢しい GNU C コンパイラを黙らせるためのテクニックです。_start_exit.o オブジェクトファイルひとつから、889バイトの実行可能ファイルが生成されました。実行はいかに?

$ ./_start_exit ; echo $?
123

今度は segmentation fault を起こさず、無事終了しています。これでreturn.c に相当するコードを crt ファイルやCライブラリに頼ることなく、自力で作成できたことになります。

unlink システムコール

準備が完了したところで、いよいよ今回の本題に入ります。ファイルの削除を担当するシステムコールは unlink です。

SYNOPSIS
      #include <unistd.h>

      int unlink(const char *pathname);

exit システムコールと同じくひとつの引数(パス名)を取りますが、戻り値は int 型です。先ほどと同様な方法で、_syscall1 マクロを用いて unlink システムコールの呼び出しコードを生成し、_start_exit.c を改変した unlink.c を作成します。

int errno;

void exit(int status) {
  long __res;

  __asm__ volatile ("int $0x80" :  \
                    "=a" (__res) : \
                    "0" (1), "b" ((long)(status)) : \
                    "memory");
  do {
    if ((unsigned long) (__res) >= (unsigned long)(-(128 + 1))) {
      errno = -(__res); __res = -1;
     }
    return (void) (__res);
   } while (0);
 };

int unlink(const char* path) {
  long __res;

  __asm__ volatile ("int $0x80" :  \
                    "=a" (__res) : \
                    "0" (10), "b" ((const char*)(path)) : \
                    "memory");
  do {
    if ((unsigned long) (__res) >= (unsigned long)(-(128 + 1))) {
      errno = -(__res); __res = -1;
     }
    return (int) (__res);
   } while (0);
 };

void _start() {
  int ret;

  ret = unlink("./test");
  exit(ret);
 }

本来であれば argc/argv を通じて、削除対象となるファイルのパス名をコマンドから指定できるようにしたいところですが、今回は argc/argv のセットアップを担当する crt ファイルのリンクを行わないため、パスは決め打ちで "./test" としてみました。

ビルドです。

$ gcc -Wall -fno-builtin -c unlink.c
$ ld -o unlink unlink.o
$ ls -l unlink
-rwxr-xr-x  1 wataru wataru 1047 2006-08-28 02:19 unlink

1047バイトの unlink 実行可能ファイルが出来上がりました。

一般ユーザーへの write ディレクトリパーミッションの効果

それでは unlink コマンドを使い、一般ユーザにおける write ディレクトリパーミッションの影響を検討してみましょう。まず、カレントディレクトリ上に test ファイルを touch コマンドで作成します。

$ touch test
$ ls -l test
-rw-r--r--  1 wataru wataru 0 2006-08-28 02:16 test

次に、chmod コマンドを使い、カレントディレクトリの write フラグを OFF にします。

$ ls -dl ./
drwxr-xr-x  2 wataru wataru 552 2006-08-28 02:16 ./
$ chmod -w ./
$ ls -dl ./
dr-xr-xr-x  2 wataru wataru 552 2006-08-28 02:16 ./

この状態で rm コマンドによる削除を試みると・・

$ rm test
rm: cannot remove `test': Permission denied

確かに write パーミッションによる制御が効いています。

$ ./unlink ; echo $?
255
$ ls test
test

unlink コマンドも同様に削除には失敗しています。

root における write ディレクトリ・パーミッションの効果

次に、su コマンドでルートに移行した上で、同じ実験をしてみましょう。

$ su
Password: 
# ./unlink ; echo $?
0
# ls test
ls: test: No such file or directory

ディレクトリの write フラグとは無関係に unlink が成功しています。以上から、スーパーユーザにおいては、ディレクトリの write フラグに関わらず、unlink を実行できてしまうことを確認できました。

「普段は可能な限り一般ユーザとして作業し、必要な時だけ su や sudo コマンドで作業しなさい」という教えは、このように過剰な権限を持つ root の危険性に基づいていたのです。