LoginSignup
10
7

More than 5 years have passed since last update.

open+fork+exitで無限ループすることがあるという話

Last updated at Posted at 2019-01-30

背景

OSSECというOSSのHIDSでメールが無限に送信されてしまう問題があり、その調査をしたときに見つけた話。
ファイル記述子を持った状態でforkして、子プロセスでは何もせずにexitしていたとしてもfgetsなどで無限ループになることがあるということを知りました(環境等にもよります)。

環境

  • OS
    • Ubuntu 16.04.3 LTS
  • libc
    • 2.23-0ubuntu10

PoC

口で説明するよりコードを見たほうが早いと思うので貼ります。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(){
    FILE* fp = fopen("test.txt", "r");
    char s[10+1];

    while (fgets(s, 10, fp) != NULL) {
        pid_t pid = fork();
        int status;
        if (pid == 0) {
            exit(0);
        } else {
            waitpid(pid, &status, 0);
        }
        printf("%s\n", s);
    }
}

このコードを見てダメな点に気づけるでしょうか?達人なら気づけるかもしれませんが、自分のような弱者には無理でした。。
大まかな動作としてはtest.txtを開いてfgetsして表示しているだけです。test.txtは適当なサイズの文字列が入ったテキストファイルです。
あとは途中でforkして、子プロセスでは何もせずにexitしています。
特に何も問題ないように見えますが、実はこのコードが無限ループします。
環境や使っているlibcのバージョンにもよるので必ずではありませんが、上に書いた環境ではほぼ必ず無限ループします。

一応Gistも貼っておきます。
https://gist.github.com/knqyf263/ac2367c07ace91d6cb9155915fd8ed96

詳細

forkするとファイル記述子(file descriptor)が複製されますが、参照するファイルテーブルエントリは共有されます。その中にはファイルをどこまで読んだか、というposition等のデータが含まれています。そのため、子プロセスの方からもそのファイル記述子を使ってアクセスすると壊れてしまうというのはUNIXプログラミングをやっている人であれば普通に知っていることかと思います。
ですが、今回はforkされた側では一切触っていないにも関わらずfgetsが無限ループします。

挙動としては、fgetsした後に再度fgetsすると、先程まで読み込んでいたファイルの場所と違う場所を起点として読み込まれます。これがファイルの先頭になってしまったりするため、ずっとファイルの読み込みが可能になります。fgetposやftellを使うとpositionが巻き戻っているのが分かるかと思うので興味がある人は試してみてください。

この辺りについて詳しく書かれたドキュメントがあります。
http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_05_01

詳細は読んで欲しいですが少しだけ抜粋しておきます。

The result of function calls involving any one handle (the "active handle") is defined elsewhere in this volume of POSIX.1-2017, but if two or more handles are used, and any one of them is a stream, the application shall ensure that their actions are coordinated as described below. If this is not done, the result is undefined.

ハンドル(ファイル記述子とストリームをあわせてそう呼ぶらしい)が1つの場合の関数の結果は定義されているが、複数ハンドルがある場合はアプリケーション側できちんと処理する必要があり、それをしない場合の結果は未定義であるということです。

For a handle to become the active handle, the application shall ensure that the actions below are performed between the last use of the handle (the current active handle) and the first use of the second handle (the future active handle).

2つ以上ハンドルがある場合は、利用するハンドルを切り替える前に処理をする必要がある。

というところまで読んでも、いやforkしたあとストリームにアクセスしてないから関係ないじゃん、と思っていたのですが、forkについて以下に書いてありました。

Note that after a fork(), two handles exist where one existed before. The application shall ensure that, if both handles can ever be accessed, they are both in a state where the other could become the active handle first. The application shall prepare for a fork() exactly as if it were a change of active handle. (If the only action performed by one of the processes is one of the exec functions or _exit() (not exit()), the handle is never accessed in that process.)

forkの場合もハンドルの切り替えのように処理する必要があるとのことです。ただし、exec 系や _exit()であればハンドルに全くアクセスしないと書いてあり、つまり切り替えのための処理がなくても良いと言っているように見えます(自信なし)。
逆に言うと、exit() の場合はハンドルへのアクセスが発生するためfork時に適切に処理する必要があるということです。

If the active handle ceases to be accessible before the requirements on the first handle, above, have been met, the state of the open file description becomes undefined. This might occur during functions such as a fork() or _exit().

正しい処理をせずにファイル記述子にアクセスできなくなった場合、状態が未定義になってしまっておかしくなるようです。ご丁寧にforkの場合とかに起きるよ、と書いてくれています。

一応straceでどのようなシステムコールが実行されているか見てみます。

$ strace -f ./a.out
execve("./a.out", ["./a.out"], [/* 21 vars */]) = 0
...(中略)...
open("test.txt", O_RDONLY)              = 3
fstat(3, {st_mode=S_IFREG|0664, st_size=39, ...}) = 0
read(3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4096) = 39
clone(strace: Process 18815 attached
child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fcebb4ed9d0) = 18815
[pid 18814] wait4(18815,  <unfinished ...>
[pid 18815] lseek(3, -30, SEEK_CUR)     = 9
[pid 18815] exit_group(0)               = ?
[pid 18815] +++ exited with 0 +++
<... wait4 resumed> [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 18815
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=18815, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
write(1, "aaaaaaaaa\n", 10aaaaaaaaa
)             = 10
clone(strace: Process 18816 attached
child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fcebb4ed9d0) = 18816
[pid 18814] wait4(18816,  <unfinished ...>
[pid 18816] lseek(3, -21, SEEK_CUR)     = -1 EINVAL (Invalid argument)
[pid 18816] exit_group(0)               = ?
[pid 18816] +++ exited with 0 +++

結果を見てみると分かるのですが、子プロセスでlseekが発生しています。
しかもファイルディスクリプタが3なので、openされたtest.txtに対する処理です。
つまり、子プロセスでは何もせずにexit()しているだけなのにlseekが実行されているということです。

この事象に関してはBugzillaにglibcのバグとして起票されています。
https://sourceware.org/bugzilla/show_bug.cgi?id=23151

ここに書いてあるように子プロセスがlseekでファイルの先頭に位置をずらしてしまうため無限ループしうります。タイミングがシビアなのかと思いきや割と普通に無限ループします。

ですが上のリンクが貼られ、動作は未定義なので問題ない(バグではない)という旨の回答がされています。
つまりアプリケーション側できちんと処理してねということになります。

そして上のドキュメントではハンドルを切り替える前にfflush()をするかストリームを閉じるように書いてあります(細かい条件が色々ありますが詳細はドキュメント参照)。

If the stream is open with a mode that allows reading and the underlying open file description refers to a device that is capable of seeking, the application shall either perform an fflush(), or the stream shall be closed.

そのため、fflush()をすれば今回の無限ループが解消されそうであると分かります。
また、_exit()を使っても今回の無限ループは解消しますが、色々な処理が省略されるので本番のプログラムでは使うべきではないと言われているようです(詳しくは知らない)。

fflushを挟んだのが以下です。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(){
    FILE* fp = fopen("test.txt", "r");
    char s[10+1];

    while (fgets(s, 10, fp) != NULL) {
        fflush(fp);
        pid_t pid = fork();
        int status;
        if (pid == 0) {
            exit(0);
        } else {
            waitpid(pid, &status, 0);
        }
        printf("%s\n", s);
    }
}

forkの前にfflush()を挟んだだけですが、これで無限ループは発生しません。
再度straceしてみます。

$ strace -f ./a.out
execve("./a.out", ["./a.out"], [/* 21 vars */]) = 0
...(中略)...
open("test.txt", O_RDONLY)              = 3
fstat(3, {st_mode=S_IFREG|0664, st_size=39, ...}) = 0
read(3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4096) = 39
lseek(3, -30, SEEK_CUR)                 = 9
clone(strace: Process 18892 attached
child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fd13e08e9d0) = 18892
[pid 18891] wait4(18892,  <unfinished ...>
[pid 18892] exit_group(0)               = ?
[pid 18892] +++ exited with 0 +++

今度は子プロセスではlseekが走らず、親プロセスでlseekが実行されています。
なので、親プロセスでfflushしなかった場合に子プロセスでfflushが走るのかな、と憶測しています(根拠なし)。
ただ、このlseekに渡されてる数字が負の数値になっているのがよく分かっていません。
39バイト読んで30バイト戻して結果的に9バイトみたいな感じに見えて、fgetsの内部挙動について理解が浅いな...と思っております。

また、子プロセス側でfclose(fp)した場合でも解消できます。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(){
    FILE* fp = fopen("test.txt", "r");
    char s[10];
    int fd;

    while (fgets(s, 10, fp) != NULL) {
        pid_t pid = fork();
        int status;
        if (pid == 0) {
            fclose(fp);
            exit(0);
        } else {
            waitpid(pid, &status, 0);
        }
        printf("%s\n", s);
    }
}

straceもみてみます。

$ strace -f ./a.out
execve("./a.out", ["./a.out"], [/* 21 vars */]) = 0
...(中略)...
open("test.txt", O_RDONLY)              = 3
fstat(3, {st_mode=S_IFREG|0664, st_size=39, ...}) = 0
read(3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4096) = 39
clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f14f66669d0) = 19521
strace: Process 19521 attached
[pid 19520] wait4(19521,  <unfinished ...>
[pid 19521] close(3)                    = 0
[pid 19521] exit_group(0)               = ?
[pid 19521] +++ exited with 0 +++
<... wait4 resumed> [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 19521
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=19521, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
write(1, "aaaaaaaaa\n", 10aaaaaaaaa
)             = 10
clone(strace: Process 19522 attached
child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f14f66669d0) = 19522

今度は親プロセスでも子プロセスでも全くlseekが呼ばれなくなっています。
よ、呼ばれなくても良いのか...?
「バッファにデータがある時にexitしたらオープンなハンドルをfflushする」みたいな処理なんでしょうか。
先にcloseしておけばfflush走らないからOK的なことなのかな。
詳しい人からのご指摘を待っております。

上記事象によってOSSECで起きていた問題

OSSECでのアラートメール無限再送は、上の問題によってアラートが書かれたファイルをreadする処理が無限に走ってしまい、ずっとメールを送ってしまうというものでした。fflushによって無事に直ったのでPRをマージしてもらいました。

まとめ

オープンなファイル記述子やストリームを持った状態でforkして、子プロセスでは一切触らずにexitしただけでも無限ループが起きることがある、というお話でした。
exit時に謎のlseekが走ってpositionがずれてしまうことがあるというのが原因です。
親プロセスでだけ使いたい場合はforkする前にfflushするか、子プロセスでfcloseしておけば回避可能です。
結構難しかったのでメモとして残しておきます。

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7