はじめに
今回はCronをとりあげます。CronはOSの持っている時計に基づき、あらかじめ設定しておいたコマンドを実行するための仕組みで、Unix系システムには必ず備えられているといっていい機能でしょう。ログファイルのローテーションやログインアカウントの利用状況集計など、システム管理上のジョブを定期的に実行するために活用されています。
英語版Wikipediaのページによると、Cronの歴史はVersion7 Unix(1979年リリース)までさかのぼるそうです。Linuxディストリビューションの多くが現在使っているものは、Paul Vixie氏が実装したVixie Cronが元になっています。
サンプルコード
Cronでは、crontabという設定ファイルでいつどのようなジョブを実行するかを指定します。この設定ファイルはユーザごとに用意されており、必要に応じてユーザが自分で編集します。この設定ファイルを編集するためのコマンドもcrontabという名前です。設定ファイルのcrontabとコマンドのcrontabを区別するために、マニュアルページのセクション番号を付けて、設定ファイルはcrontab(5)、コマンドはcrontab(1)などと表記します。
以下に引用したコードは、crontab(1)のソースコードの一部、edit_cmd()という関数です。ユーザが自分の設定ファイルを編集するために「crontab -e」というようにコマンドを起動したときにこの関数が呼び出されます。
edit_cmd()関数が内部から参照している関数や変数で他のファイルに定義されているものも、一緒に展開して並べてあります。実際のコードを確認する場合には注意してください。また、今回のトピックに関係ないシグナル処理の部分などは省略してあります。
ちなみに、文字列の連結操作のためにglue_strings()という関数を定義しています。今ならこの機能はsnprintf()を使うべきところです。Vixie Cronが書かれた当時はまだsnprintf()が普及していなかったために自前でこのような関数を用意したのかもしれませんね。
static uid_t save_euid; static gid_t save_egid; static char Filename[MAX_FNAME]; static FILE *NewCrontab; int swap_uids(void) { save_egid = getegid(); save_euid = geteuid(); return ((setegid(getgid()) || seteuid(getuid()))? -1 : 0); } int swap_uids_back(void) { return ((setegid(save_egid) || seteuid(save_euid)) ? -1 : 0); } /* * glue_strings is the overflow-safe equivalent of * sprintf(buffer, "%s%c%s", a, separator, b); * * returns 1 on success, 0 on failure. 'buffer' MUST NOT be used if * glue_strings fails. */ int glue_strings(char *buffer, size_t buffer_size, const char *a, const char *b, char separator) { char *buf; char *buf_end; if (buffer_size <= 0) return (0); buf_end = buffer + buffer_size; buf = buffer; for ( /* nothing */ ; buf < buf_end && *a != '\0'; buf++, a++) *buf = *a; if (buf == buf_end) return (0); if (separator != '/' || buf == buffer || buf[-1] != '/') *buf++ = separator; if (buf == buf_end) return (0); for ( /* nothing */ ; buf < buf_end && *b != '\0'; buf++, b++) *buf = *b; if (buf == buf_end) return (0); *buf = '\0'; return (1); } static char *tmp_path() { char *tmpdir = NULL; if ((getuid() == geteuid()) && (getgid() == getegid())) { tmpdir = getenv("TMPDIR"); } return tmpdir ? tmpdir : "/tmp"; } static void edit_cmd(void) { char n[MAX_FNAME], q[MAX_TEMPSTR]; FILE *f; int ch = '\0', t; struct stat statbuf; struct utimbuf utimebuf; WAIT_T waiter; PID_T pid, xpid; if (!glue_strings(n, sizeof n, SPOOL_DIR, User, '/')) { fprintf(stderr, "path too long\n"); exit(ERROR_EXIT); } if (!(f = fopen(n, "r"))) { if (errno != ENOENT) { perror(n); exit(ERROR_EXIT); } fprintf(stderr, "no crontab for %s - using an empty one\n", User); if (!(f = fopen(_PATH_DEVNULL, "r"))) { perror(_PATH_DEVNULL); exit(ERROR_EXIT); } } if (!glue_strings(Filename, sizeof Filename, tmp_path(), "crontab.XXXXXXXXXX", '/')) { fprintf(stderr, "path too long\n"); exit(ERROR_EXIT); } if (swap_uids() == -1) { perror("swapping uids"); exit(ERROR_EXIT); } if (-1 == (t = mkstemp(Filename))) { perror(Filename); goto fatal; } if (swap_uids_back() == -1) { perror("swapping uids back"); goto fatal; } if (!(NewCrontab = fdopen(t, "r+"))) { perror("fdopen"); goto fatal; } // copy the rest of the crontab (if any) to the temp file. if (EOF != ch) while (EOF != (ch = get_char(f))) putc(ch, NewCrontab); fclose(f); if (fflush(NewCrontab) < OK) { perror(Filename); exit(ERROR_EXIT); } // Set it to 1970 utimebuf.actime = 0; utimebuf.modtime = 0; utime(Filename, &utimebuf); again: rewind(NewCrontab); if (ferror(NewCrontab)) { fprintf(stderr, "%s: error while writing new crontab to %s\n", ProgramName, Filename); fatal: unlink(Filename); exit(ERROR_EXIT); } // we still have the file open. editors will generally rewrite the // original file rather than renaming/unlinking it and starting a // new one; even backup files are supposed to be made by copying // rather than by renaming. if some editor does not support this, // then don't use it. the security problems are more severe if we // close and reopen the file around the edit. switch (pid = fork()) { // 子プロセスでroot権限を放棄しFilenameを引数にエディタを起動 } // parent for (;;) { xpid = waitpid(pid, &waiter, 0); // エラー状態だったら異常終了する } // lstat doesn't make any harm, because // the file is stat'ed only when crontab is touched if (lstat(Filename, &statbuf) < 0) { perror("lstat"); goto fatal; } if (!S_ISREG(statbuf.st_mode)) { fprintf(stderr, "%s: illegal crontab\n", ProgramName); goto remove; } if (statbuf.st_mtime == 0) { fprintf(stderr, "%s: no changes made to crontab\n", ProgramName); goto remove; } fprintf(stderr, "%s: installing new crontab\n", ProgramName); fclose(NewCrontab); if (swap_uids() < OK) { perror("swapping uids"); goto remove; } if (!(NewCrontab = fopen(Filename, "r+"))) { perror("cannot read new crontab"); goto remove; } if (swap_uids_back() < OK) { perror("swapping uids back"); exit(ERROR_EXIT); } if (NewCrontab == 0L) { perror("fopen"); goto fatal; } // 以下、Filenameをcrontabに入れ換える remove: unlink(Filename); done: }
edit_cmd()の処理は、以下のような流れになります。
- root権限でcrontab(1)実行開始
- ユーザのcrontab(5)ファイルをopenする
- 一般ユーザ権限で(swap_uids())、一時ファイルを生成
- 一時ファイルに既存ファイルの内容をコピー
- 一時ファイルのタイムスタンプを0(epoch time)にセットする(utime())
- 子プロセスがfork()し、一般ユーザ権限でエディタを起動、ユーザは一時ファイルを編集する
- 親プロセスは、子プロセスが終了するのを待つ(waitpid())
- 一般ユーザ権限で、一時ファイルのタイムスタンプを確認、変更されていれば、新たなcrontab(5)ファイルとして入れ換える
Fedora Linuxでは、各ユーザのcrontab(5)ファイルは専用のディレクトリ(/var/spool/cron/)に置いてあります。他ユーザのcrontab(5)ファイルをいたずらに編集できないよう、このディレクトリ以下にはrootしかアクセスできないようにパーミションが設定されており、各ユーザはcrontab(1)コマンドを通じてのみ、自分のcrontab(5)ファイルを編集できます。
crontab(1)コマンドは「setuid root」されているため、一般ユーザが起動したときでもroot権限で動作し、/var/spool/cron/以下のファイルにアクセスすることができます。
そして、crontab(1)を起動したユーザが自分のcrontab(5)ファイルのみ編集できるようにする仕掛けが上記コード中にあるswap_uids()とswap_uids_back()です。
setuidされたプログラムは、起動したユーザのIDをプロセスの属性情報として覚えており、seteuid()などのシステムコールを使うことで、setuidされた権限で動作するか、それとも自分を起動した元のユーザの権限で動作するかを変更できます。edit_cmd()では、一時ファイルの生成やcrontab(5)ファイルの置き換えを元のユーザ権限で行うことにより、他のユーザのファイルを間違っていじれないようにしてあるのです。
ところが、一か所、その仕組みをきちんと使っていないところがあります。さて、どこでしょう?