ふと openSUSE 13.1 を install してみた。lsb_release の Description と /etc/os-release の PRETTY_NAME は一致しているようだ。
% lsb_release -a LSB Version: n/a Distributor ID: openSUSE project Description: openSUSE 13.1 (Bottle) (x86_64) Release: 13.1 Codename: Bottle % cat /etc/os-release NAME=openSUSE VERSION="13.1 (Bottle)" VERSION_ID="13.1" PRETTY_NAME="openSUSE 13.1 (Bottle) (x86_64)" ID=opensuse ANSI_COLOR="0;32" CPE_NAME="cpe:/o:opensuse:opensuse:13.1" BUG_REPORT_URL="https://bugs.opensuse.org" HOME_URL="https://opensuse.org/" ID_LIKE="suse"
なお、サーバ (テキスト環境) をインストールしたのだが、lsb_release はなかったので、zypper install lsb-release として lsb-release パッケージをインストールした。
また、/etc/SuSE-release と /etc/SUSE-brand というものもあった。
% cat /etc/SuSE-release openSUSE 13.1 (x86_64) VERSION = 13.1 CODENAME = Bottle # /etc/SuSE-release is deprecated and will be removed in the future, use /etc/os-release instead % cat /etc/SUSE-brand openSUSE VERSION = 13.1
Ruby で system, spawn メソッドなどコマンドを起動するところに vfork システムコールを使ってみた。 (現在の trunk に入っており、消されなければ Ruby 2.2 に入る。)
vfork というのは危険だけれど速い fork システムコールである。
Unix におけるコマンドの起動は、まず親プロセスが fork でプロセスを複製し、そうやってできた子プロセスが execve でプロセスを別のプログラムに入れ替える。
ここで、たいていは fork してすぐに execve するので、時間をかけてプロセスのメモリをコピーしたあげくにすぐに捨てる、という動作が無駄で遅い、というのが古代の Unix では問題だったそうな。
そこで BSD のひとが、子プロセスが execve するまでは親のメモリをそのまま使えばいいじゃない、と考えてそういう動作を行う vfork を作った。 これは無駄な動作がなくなるので実際に速くなった。 なお、親子が同じメモリで同時に動作するとまともに動かないのが明らかなので、子プロセスが execve する (あるいは _exit などで終了する) まで親プロセスは停止する。
とはいえ、子プロセスがメモリを書き換えた結果が親プロセスから見えるとか、ちょっとありえないと言いたくなるような動作で、vfork がよろしくないことは初めから分かっていた。 だから仮想メモリで copy-on-write を実現して、あたかもコピーしたかのように見えるけれども実際にはコピーしていないので速い、という動作を fork で実現したら vfork を捨てようという話ではあったようだ。
そして時が経ち、現代では fork が copy-on-write にするのは普通で、速くなった。 だから vfork は忘れましょう、というのが普通の認識だろう。
しかし、仮想メモリで copy-on-write とはいえ、子プロセスのメモリをそういうふうに設定しないといけないので、親プロセスがメモリを使えば使うほど fork が遅くなるという傾向は変わっていない。 vfork にその傾向はない (あるいは低い) ので、親プロセスが大きくなればそのうち vfork のほうが明確に速くなるだろう。
追記: 「子プロセスのメモリをそういうふうに設定」というのは、もっとちゃんと述べると、 copy-on-write な fork では、親プロセスと子プロセスがメモリをread onlyで共有するようにページテーブルを設定するが、これにはページの数に (つまり使用メモリ量に) 比例した時間がかかる、という意味である。 なお、この状態では、どちらかのプロセスで書き込みがおこるたびにページフォルトが起こり、必要なら新しいページを確保してコピーして書き込み可能にして、書き込みを続行する (copy-on-write)。 しかし、子プロセスが exec した後でも、親プロセスのページテーブルがそのままになっていると、親プロセスがread onlyなページに書き込むたびに (コピーは起きないものの) ページフォルトは起こるかもしれない。 これは、親プロセスを遅くするような気がする。 この動作については「NetBSD ドキュメンテーション: なぜ伝統的な vfork()を実装したのか」に説明がある。
もうひとつの問題はメモリのオーバーコミット (利用できるよりも多くのメモリをプロセスに割り当てること) を許していない場合に、 巨大なプロセスの fork が失敗しがちになるということである。 fork すると親プロセスと同じ量のメモリが子プロセスに割り当てられるので、必要なメモリは 2倍になる。 極端な場合として、利用できるメモリの半分よりも大きなプロセスは fork できないことになる。 vfork であれば、子プロセスは親プロセスのメモリをそのまま利用するので、この問題は発生しない。
というわけで、vfork を使ってみたわけだが、まず現実的なメモリ量で速くなるのか、という疑問を解決するために測定してみた。現実的なメモリ量で速くならないなら、あまり魅力はない。
以下のようにして測定してみた。
% uname -mrsv
Linux 3.14-2-amd64 #1 SMP Debian 3.14.15-2 (2014-08-09) x86_64
% ./miniruby -Ilib -rbenchmark -e '
str = "a" * 1000;
23.times {
mem = File.read("/proc/self/status")[/^VmSize:\s*(\S+)/, 1]
str << str
time = Benchmark.realtime { system("true") }
puts "#{mem} #{time}"
}'
で、プロットしてみた。

結果としては、vfork のほうがあからさまに速い。ruby (miniruby) が起動したあたりのメモリ量 (21MB 強) ですでに数倍の速度が出ている。メモリが大きくなるにつれて差は広がり、プロセスが 4GB くらいになるまで測っているが、そこでは 200倍以上になっている。
というわけで、速度を考えるとぜひ vfork を使いたい。
しかし、vfork は危険である。わかりやすいのは CERT の Secure Coding で、 POS33-C. Do not use vfork() と明確に「使うな」と書かれていることだろう。(JPCERT による和訳)
この危険性を確信を持って避けられるか、というのが問題である。
fork に対する vfork の違いは以下の 2点である。
メモリの共有で問題が起きないように、メモリの使い方を以下のように制限する。
親プロセスで制限を守るのは難しくない。しかし、子プロセスについては簡単ではない。
いずれにせよ制限を守れなかった場合には、子プロセスが親プロセスに影響を及ぼす、あるいはその逆が起こり得る。このとき、子プロセスと親プロセスの権限が異なるとセキュリティ問題 (権限昇格) に発展するかもしれない。vfork した直後は親子の権限は同じなので、その後でどちらかが setuid などで権限を変化させる場合が問題である。このため、setuid などが可能なプロセス (root のプロセスや、setuid/setgid されたコマンドから起動されたプロセス) では vfork を使わないことにする。
具体的に、子プロセスでスタック以外のメモリを変更するコードが動くことを防ぐことについては以下のように考えてみた。
他に、意図せざるコードが動く可能性はあるだろうか。RTLD とか? async-signal-safe な関数をスレッドライブラリが wrap しているかもしれないのも心配。
調べると、vfork を実際に使う話はいくつか見つかる。 libc で posix_spawn を実装する時に使う、というのが多い。
glibc の posix_spawn の実装 sysdeps/posix/spawni.c vfork が (POSIX_SPAWN_USEVFORK を指定しなくても) 有効になるのは以下の条件が成り立ったとき
(flags & (POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF
| POSIX_SPAWN_SETSCHEDPARAM | POSIX_SPAWN_SETSCHEDULER
| POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_RESETIDS)) == 0
&& file_actions == NULL追記
せっかくなのでもっと細かくデータをとってみた。
% ./miniruby -Ilib -rbenchmark -e '
str = "a" * 1000;
while str.length < 5_000_000_000
mem = File.read("/proc/self/status")[/^VmSize:\s*(\S+)/, 1]
str << str[0, str.length/100]
time = Benchmark.realtime { system("true") }
puts "#{mem},#{time}"
end'
Mac OS X の posix_spawn には POSIX_SPAWN_CLOEXEC_DEFAULT というフラグが使えて、指定しなかった fd をぜんぶ close することができるようだ。これは良い方向だなぁ。
せっかくなので posix-spawn gem も測って比較してみよう。
#!/bin/sh
echo func,mem,time
./ruby.fork -rbenchmark -e '
str = "a" * 1000;
while str.length < 5_000_000_000
mem = File.read("/proc/self/status")[/^VmSize:\s*(\S+)/, 1]
str << str[0, str.length/100]
time = Benchmark.realtime { system("true") }
puts "fork,#{mem},#{time}"
end'
./ruby.vfork -rbenchmark -e '
str = "a" * 1000;
while str.length < 5_000_000_000
mem = File.read("/proc/self/status")[/^VmSize:\s*(\S+)/, 1]
str << str[0, str.length/100]
time = Benchmark.realtime { system("true") }
puts "vfork,#{mem},#{time}"
end'
./ruby.vfork -rposix-spawn -rbenchmark -e '
str = "a" * 1000;
while str.length < 5_000_000_000
mem = File.read("/proc/self/status")[/^VmSize:\s*(\S+)/, 1]
str << str[0, str.length/100]
time = Benchmark.realtime { POSIX::Spawn.system("true") }
puts "posix-spawn,#{mem},#{time}"
end'
うぅむ。posix-spawn に比べて vfork はちょっと遅いなぁ。posix_spawn の中身は vfork なので余計な作業をしているということなんだけど、なんだろう。signal の設定とか、fd の close-on-exec の設定とか、そのあたりだろうか。
vfork した状態で setuid すると、本当に危険だろうか。ちょっと試してみよう。
% t.c
#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <wait.h>
int main(int argc, char *argv[])
{
pid_t pid, pid2;
uid_t ruid, euid, suid;
int ret;
int status;
fprintf(stderr, "parent pid=%ld\n", (long)getpid());
ret = getresuid(&ruid, &euid, &suid);
if (ret == -1) {
perror("getresuid");
exit(EXIT_FAILURE);
}
fprintf(stderr, "parent ruid=%ld euid=%ld suid=%ld\n", (long)ruid, (long)euid, (long)suid);
pid = vfork();
if (pid == -1) {
perror("vfork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* child */
fprintf(stderr, "child pid=%ld\n", (long)getpid());
ret = setuid(1000);
if (ret == -1) {
perror("setuid");
exit(EXIT_FAILURE);
}
ret = getresuid(&ruid, &euid, &suid);
if (ret == -1) {
perror("getresuid");
exit(EXIT_FAILURE);
}
fprintf(stderr, "child ruid=%ld euid=%ld suid=%ld\n", (long)ruid, (long)euid, (long)suid);
sleep(10);
_exit(EXIT_SUCCESS);
}
/* parent */
fprintf(stderr, "parent\n");
pid2 = waitpid(pid, &status, WUNTRACED|WCONTINUED);
if (pid2 == -1) {
perror("waitpid");
exit(EXIT_FAILURE);
}
if (WIFEXITED(status))
fprintf(stderr, "pid %ld exit %ld\n", (long)pid2, WEXITSTATUS(status));
else if (WIFSIGNALED(status))
fprintf(stderr, "pid %ld signal %ld\n", (long)pid2, WTERMSIG(status));
else
fprintf(stderr, "pid %ld unexpected %ld\n", (long)pid2, (long)status);
return EXIT_SUCCESS;
}
% gcc t.c
% sudo ./a.out
parent pid=8623
parent ruid=0 euid=0 suid=0
child pid=8624
child ruid=1000 euid=1000 suid=1000
で、他の端末から gdb で attach してみる。
(gdb) attach 8624 Attaching to program: /tmp/a/a.out, process 8624 ptrace: Operation not permitted.
お、カーネルが禁止してくれているようだ。
ptrace のマニュアルの EPERM の項に書いてある該当しそうな条件は set-user-ID/set-group-ID programs というところかなぁ。ruid=euid=suid にしてあるので、これら3つ以外になにか記録してあるのだろうか。
kill でシグナルを送ることはできる。
また、vfork でなく、fork でも同じ挙動になる。まぁ、メモリを覗かれるだけでも危ないか。
vfork したとき、親プロセスの他のスレッド (vfork を呼び出した以外のスレッド) は止まるのだろうか。(あと、そもそもメモリは共有されるだろうか)
% cat t.c
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <pthread.h>
static volatile char *global = "vfork doesn't share memory.";
void printn(int prefix, int n)
{
char buf[4];
buf[0] = prefix;
buf[1] = '0' + n;
buf[2] = '\n';
buf[3] = '\0';
write(1, buf, sizeof(buf));
}
void *thread_func(void *arg)
{
int i;
for (i = 0; i < 6; i++) {
printn('t', i);
sleep(1);
}
return NULL;
}
int main(int argc, char *argv[])
{
int ret;
pthread_t th;
pid_t pid;
int status;
int j;
ret = pthread_create(&th, NULL, thread_func, NULL);
if (ret != 0) { errno = ret; perror("pthread_create"); exit(EXIT_FAILURE); }
pid = vfork();
if (pid == -1) { perror("vfork"); exit(EXIT_FAILURE); }
if (pid == 0) {
/* child */
int i;
global = "vfork share memory.";
for (i = 0; i < 3; i++) {
sleep(1);
printn('c', i);
}
_exit(0);
}
for (j = 0; j < 3; j++) {
printn('p', j);
sleep(1);
}
pid = wait(&status);
if (pid == -1) { perror("wait"); exit(EXIT_FAILURE); }
ret = pthread_join(th, NULL);
if (ret != 0) { errno = ret; perror("pthread_join"); exit(EXIT_FAILURE); }
printf("%s\n", global);
return EXIT_SUCCESS;
}
% gcc -Wall t.c -lpthread
% ./a.out
t0
c0
t1
t2
c1
t3
c2
p0
t4
p1
t5
p2
vfork share memory.
% uname -srvm
Linux 3.14-2-amd64 #1 SMP Debian 3.14.15-2 (2014-08-09) x86_64
子プロセスで書き換えたグローバル変数が親プロセスに反映されているので親プロセスと子プロセスでメモリは共有されている。共有しているので親プロセスの vfork を呼び出したスレッド p[0-2] と子プロセス c[0-2] は当然同時には実行されなくて、子プロセスが終わった後に親プロセスが動いている。親プロセスで動いている他のスレッド t[0-5] は vfork 中にも動いている。
この調子でいくつか調べてみた。
メモリを共有し、親プロセスの他のスレッドが動作しつづける:
メモリを共有し、親プロセス全体が停止する:
メモリを共有せず、親プロセス全体が停止する:
メモリを共有せず、親プロセスは動作し続ける (fork 相当の動作):
しかし、いろいろ考えると、やはり方向としては vfork より posix_spawn のほうがいいなぁ、と思う。自分で vfork を使うと、子プロセスでのメモリの書き変えをスタックだけに完璧に制限できるかどうか確信が持てない。確信を持てないから危ない感じの場合 (私の判断としては setuid 可能な場合) は使わないようになっちゃうし。
本日のまつもとさんの発表 (基調講演) をネタになるせさんと議論した結果、非常に簡単化した静的解析なら簡単に実装できることがわかったので実装してみた。
% cat static-check.rb
class C
def m
xxxx
end
end
def_methods = {}
call_methods = {}
ObjectSpace.each_object {|o|
methods = []
methods.concat o.instance_methods.map {|msym| o.instance_method(msym) } if Module === o
methods.concat o.methods.map {|msym| o.method(msym) }
methods.each {|m|
def_methods[m.name] = true
asm = RubyVM::InstructionSequence.disasm(m)
next if !asm
asm.scan(/mid:([a-zA-Z0-9]+)/) {
call_methods[$1] = true
}
}
}
a = call_methods.keys - def_methods.keys
if !a.empty?
p a.sort
end
% ruby --disable-gems static-check.rb
["synchronize", "xxxx"]
これにより、定義されていないけれど呼び出しが存在する、xxxx というメソッドを発見できている。いままでは実行が xxxx というメソッド呼び出しに到達しないかぎりこのようなものを発見することはできなかったのだが、ここではそこを実行せずに発見している。
話は簡単で、すべてのメソッド定義のメソッド名をリストアップし、すべてのメソッド呼び出しのメソッド名をリストアップし、後者だけに存在するメソッド名を表示しているだけである。(実装が不完全であるのはわかっているので気にしていない)
飲み会の中で実装したのだが、ちょうどそこにいたまつもとさんやささださんなどに見せていろいろな話が出た。
単純な typo の発見ならこれでもかなり効くだろうとか、定期的に実行して名前の増減を監視すればどうかとか。いつやるかが問題だけど、Rails みたいなのはリクエストを受付始める前でやればいいとか、require の終わりでやればいいとか。あと、InstructionSequence#to_a が使えるだろうとか。
shellshock で有名になった、シェル関数を子孫のプロセスに export するという bash の機能は、どうも ksh88 に由来するような気がする。
ksh88 のマニュアル には typeset で、-f (関数) と -x (export) を組み合わせられると記述されている。
ksh88 はあまり生き残っていないのだが、AIX 7.1 にまだあったので試してみた。試行錯誤の結果、以下のようにすると動くことが確認できた。
-bash-4.2$ ksh
$ echo bar > bin/foo # foo という名前のファイルで shell procedure を作る。中身は bar を呼び出す
$ chmod 755 bin/foo # shell procedure は実行可能でないといけないので実行可能にする
$ which foo # which で foo が見つかる
/home/akr/bin/foo
$ foo # ここで foo を呼び出すと bar が見つからないというエラー
foo: bar: not found .
$ bar () { echo baz; } # bar を関数として定義する
$ foo # ここで foo を呼び出しても bar は見つからない
foo: bar: not found .
$ typeset -fx bar # bar を export するように設定する
$ foo # foo を呼び出すと bar が呼び出される
baz
$
試してわかったのだが、これは bash のように環境変数を介する機能ではないようだ。例えば、ksh -c bar としたり、foo の先頭に #!/bin/ksh をつけると bar の定義は伝わらない。
truss すると以下のようになる。
-bash-4.2$ truss -f ksh -c 'bar () { echo baz; }; typeset -fx bar; foo'
...
12124234: 51511337: execve("/home/akr/bin/foo", 0x200119B8, 0x20011EB8) Err#8 ENOEXEC
12124234: 51511337: kopen("/home/akr/bin/foo", O_RDONLY) = 3
...
baz
12124234: 51511337: kwrite(1, " b a z\n", 4) = 4
...
つまり、ksh は foo を execve しようとして失敗し、しょうがないので foo を自分で読んで処理して (bar を呼んで) baz を出力する、という流れである。
execve してないので呼び出し元の情報が利用可能で、typeset により export とマークされている bar を見つけて使うのであろう。
というわけで、もし ksh88 が使われていても、(環境変数は関係ないので) shellshock のような話にはならないだろう。
ちなみに、ksh93 では動かない。(ksh93 のマニュアルでも、typeset で -f と -x を組み合わせた時の動作の記述は消えている。)
-bash-4.2$ ksh93 -c 'bar () { echo baz; }; typeset -fx bar; foo'
foo: line 1: bar: not found
なお、pdksh のマニュアル には typeset -fx は効果がないことが書いてある。
[latest]