戸田 洋三(JPCERTコーディネーションセンター) [著]
本連載では、脆弱性を含むサンプルコードを題材に、修正方法の例を解説していきます。今回はジョブを定期実行するための仕組み「Cron」をとりあげます。
はじめに
今回は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()が普及していなかったために自前でこのような関数を用意したのかもしれませんね。
■cronie-1.4.3 の edit_cmd() から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)ファイルの置き換えを元のユーザ権限で行うことにより、他のユーザのファイルを間違っていじれないようにしてあるのです。
ところが、一か所、その仕組みをきちんと使っていないところがあります。さて、どこでしょう?
プロセスの権限を落として処理すべきなのに、一か所だけそれをやっていないところはどこだか分かりましたか?
答えはutime()関数を使って一時ファイルのタイムスタンプを0にセットするところです。もう一度コードを追ってみましょう。
- swap_uids()で一般ユーザ権限に落とす
- mkstemp()で一時ファイルを生成
- swap_uids_back()でroot権限に戻る
- fdopen()で一時ファイルのFILEポインタを取得
- FILEポインタを通じて一時ファイルに既存ファイルの内容をコピー
- 元のcrontab(5)ファイルをfclose()
- 一時ファイルのタイムスタンプを0(epoch time)にセット(utime())
一時ファイルにcrontab(5)ファイルの内容をコピーしている間に、一時ファイルを削除し、同じ名前でどこか他のファイルへのシンボリックリンクを作ることができるとどうなるでしょうか。
ファイルシステムでそのような操作が行なわれたとしても、プログラム上はとくにチェックをしていないので、そのままutime()関数によるタイムスタンプの変更を行なってしまいます。しかも、このときのプロセスの権限はrootに戻っていますから、どんなファイルのタイムスタンプも操作できるのです。これにより、Cronで設定されているジョブを実行させないようにしたり、ログのローテーション処理に影響を与えたりできる可能性があります。
このように、シンボリックリンクを使うことで、意図していないファイルを操作させるような攻撃手法をsymlink攻撃と呼びます。今回のCronの場合は、タイムスタンプの操作を悪用されるので、ファイルのタイムスタンプに依存した処理をする部分への攻撃に悪用される危険があります。
今回の問題にはCVE-2010-0424という番号が振られており、以下のように説明されています。
The edit_cmd function in crontab.c in (1) cronie before 1.4.4 and (2) Vixie cron (vixie-cron) allows local users to change the modification times of arbitrary files, and consequently cause a denial of service, via a symlink attack on a temporary file in the /tmp directory.
(cronieの1.4.4より前のバージョンおよびVixie cron(vixie-cron)のcrontab.cで定義されているedit_cmd関数には、ローカルユーザが任意のファイルの変更時刻をいじることが可能になる問題がある。これは、/tmpディレクトリのファイルへのsymlink攻撃を通じてDoS攻撃につながる危険がある)
修正コード
Fedoraのリポジトリで実際に行われた修正を見てみましょう。
■修正された edit_cmd()static void edit_cmd(void) { ......省略...... fclose(f); if (fflush(NewCrontab) < OK) { perror(Filename); exit(ERROR_EXIT); } + if (swap_uids() == -1) { + perror("swapping uids"); + exit(ERROR_EXIT); + } /* Set it to 1970 */ utimebuf.actime = 0; utimebuf.modtime = 0; utime(Filename, &utimebuf); + if (swap_uids_back() == -1) { + perror("swapping uids"); + exit(ERROR_EXIT); + } 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); } ......省略...... done: }
行頭に「+」をつけた部分が修正によって追加された部分です。プロセスの権限を一般ユーザ権限に落としてからutime()を実行しています。プロセスの実行権限を一般ユーザ権限に落しておけば、symlink攻撃を受けたとしても、システムファイルまでいじられる危険がなくなるというわけです。もちろん、当該ユーザの権限でいじれるものについては、これでは保護できていないことに注意が必要です。この修正は、symlink攻撃に対処するための最小限の修正だけを行なったものと理解すべきでしょう。
そもそもこのコードには、以下のような問題点があります。
- 一時ファイルのタイムスタンプをわざわざ0に設定している タイムスタンプは編集作業によって更新されるので、ファイルが更新されたかどうかをチェックするには、生成時点のタイムスタンプを保存しておくだけでいいはずです。
- utime()によるファイル名文字列を使ったファイル操作 ファイルの中身をコピーするためにFILEポインタを使い、タイムスタンプを設定するためにファイル名文字列を使っています。ファイル名文字列を使ったファイル操作を行う場合には、今回のような競合状態が発生する危険があることに注意する必要があります。ファイル名文字列を使った操作は最小限にすること、また、ファイルをopenしたら、そのときに得られたFILEポインタやファイル記述子だけを使って操作を行うように心がけましょう。ファイルのタイムスタンプを設定するにはファイル名文字列を使ってutime()関数を使うしかありませんが、幸いにも今回のコードではタイムスタンプを設定する必要はありません。編集前のタイムスタンプを保存し、編集後のタイムスタンプと比較するような形にしたほうがより安全になります。
- root権限の使用(setuid root) 個々のユーザのcrontab(5)ファイルを保護するために、パーミション設定によるディレクトリの保護と、crontab(1)コマンドにおける権限管理を行なっていました。しかし、root権限はそのシステム上でなんでもできてしまうので、できるだけ使わないほうが望ましいのです。このような場合は、専用のユーザアカウントをひとつ設けて権限管理を行うことで、Cronに脆弱性があった場合でもシステムの他の部分への侵害を最小限に止めることが期待できます。
よりセキュアなcrontab(1)コマンド
Vixie Cronバージョン4、あるいはDebian/GNU Linuxや*BSDなどに取り込まれているCronのコードを調べてみると、上記に説明したような形で、よりセキュアなコードになっていることが分かります。また、権限管理もroot権限を使う代わりに、crontabという専用グループを設ける形で行っています。2種類のコードを比較してみることをお勧めします。
今回紹介した、ファイルの扱いやプロセスの権限管理に関連したガイドラインとして、セキュアコーディングスタンダードには、POS02-CやFIO01-Cといったレコメンデーションがあります。こちらもぜひ参考にしてみてください。