システムコールをフックしたくなった理由

yasukata氏(以下、yasukata):yasukataといいます。発表を始めます。

今回は、「Zpoline」という、バイナリを書き換えることでシステムコールをフックする仕組みを紹介します。ここではx84-64のCPUで動作するLinuxを想定しています。(スライドを示して)ソースコードはこちらにURLがあるので、よろしければ見てみてください。あとでスライドも公開するので、そちらも併せてご覧ください。

まず、なぜシステムコールをフックしたくなったのかですが、個人的にカーネルに実装されている機能を、アプリケーションを変えないでユーザー空間で置き換えたいと思ったからです。

ネットワークスタックをユーザー空間に持っていきたいと思った時、システムコールをフックして、適宜ユーザー空間実装を実行すればよさそうだと考えて、作り始めました。その時に「もしかすると、この用途に合ったシステムコールをフックする仕組みはないのでは?」と気づきました。

システムコールをフックするには具体的に4つの要件があります。1つ目はフックの適用後にアプリケーションの性能劣化が小さいこと。2つ目は、フック適用の確度が高い、つまりフックし損ねないこと。3つ目は、ユーザー空間プログラムの再コンパイルが不要であること。4つ目は、カーネルを変える必要がなく、カーネルモジュールもいらないことです。

調べてみた結果、代表的なところを3つ上げると、ptraceのような既存のカーネル機能や、LD_PRELOADを使ったライブラリ関数の置き換え、既存のバイナリ書き換え手法などがありました。これらを一見したところ、既存の仕組みでは、「性能」と「フックをし損ねないこと」の両立が難しそうでした。

「性能」と「フック適用の確度の高さ」を両立する難しさ

今回のモチベーションは、それらを両立する仕組みを作りたいということです。ここで紹介するZpolineという仕組みは、バイナリ書き換えでシステムコールをフックします。バイナリ書き換えの特性上、性能の劣化は抑えやすいのですが、一方で、既存の仕組みでも起こるように、フックをし損ねてしまうことがあります。

なので、どうすればバイナリ書き換えでフックし損ねないようにできるかが今回のチャレンジで、なぜそれが起こるのかと、何が難しいのかをお話しします。

x86-64のCPUでシステムコールを発行しようとすると、基本的にsyscallもしくはsysenterというCPU命令が使われます。これらはそれぞれ2byteですが、具体的にやりたいのは、syscall、sysenter命令を置き換えて、任意のフック関数のアドレスへジャンプすることです。

この時難しいのは、任意のアドレスを指定するのに2byteでは小さくて、2byteを超えてしまうと、ほかの命令を壊してしまうということです。

このため、既存のバイナリ書き換えの仕組みでは、確実な置き換えの保証が難しいという問題があります。

Zpolineのアイデアとバイナリを書き換える方法

今回のZpolineの考え方ですが、2byteでジャンプ先のアドレスを指定するのは難しそうなので、代わりにシステムコールの呼出規約を利用した書き換えを行って、かつ、適切にトランポリンコードを用意する方向性でやっていきます。

では、どのようにバイナリを書き換えるのかですが、Zpolineでは、syscall、sysenter命令を「callq *%rax」へ置き換えます。読みにくいので「コールアールエーエックス」と呼びます。

ポイントは、callq *%raxはオペコードが「0xff 0xd0」の2byteなので、syscall、sysenterをそのまま置き換えられることです。「置き換えた後はどうなるの?」と思われるかもしれませんが、callq *%raxを実行すると、raxレジスタに入っている値を宛先アドレスと解釈してジャンプします。

さらに「それどうなるの?」と思われることについては、ここでシステムコールの呼出規約が利いてきます。x86-64のCPUで動いているLinux上では、ユーザー空間プログラムは利用したいシステムコールの番号をraxレジスタに入れた後に、syscall、sysenter命令を実行することが決められています。

システムコール番号は、カーネルが中でシステムコールを識別するために定義している番号で、例えばreadシステムコールだったら0、writeシステムコールだったら1、と決まっています。システムコールは合計4〜500個あるので、システムコール番号は400から500ぐらいまでとなっています。

syscall、sysenter命令が実行される時には、raxレジスタにシステムコール番号が入っています。syscall、sysenterをcallq *%raxで置き換えると、アドレス0から400、500程度までジャンプするところがポイントです。

なのでここでは、callq *%raxでジャンプしてくるアドレス0から500程度までを含む領域に、トランポリンコードを用意していきます。(スライドを示して)Linuxでは、こちらのコマンドにあるように、procfsから設定するとmmapでアドレス0にメモリを確保できるようになります。

ちなみに、Zpolineの名前は、アドレス0に置かれるトランポリンコードというところから来ています。

(スライドを示して)具体的にトランポリンコードの中身はこのようになっています。まず、システムコール番号の数だけ先頭をnopで埋めます。その直後に任意のフックへのジャンプのコードを置きます。

これによって、callq *%raxを実行すると、最初に置いたnopのどれかに着地して、その後は、下に続いているnopを辿ってフックへジャンプする処理まで行きます。そして、任意のフックに飛んでくれます。これで任意のフックへジャンプする処理が書けました。

getpidが100倍以上高速にエミュレートできた

今回は、初期化する部分をLD_PRELOADで最初にロードされることを想定した共有ライブラリとして実装して、この例のように実行します。すると、トランポリンコードの用意とバイナリの書き換えを、a.out内のmain関数が開始する前に実行します。

バイナリ書き換え自体は、メモリにロードされたプログラムに対して行うので、プログラムファイル自体の変更は必要ありません。

フックのオーバーヘッドがどのようなものかを知るために、実際にシステムコールのフックを適用した後に、getpidという軽いシステムコールを1回実行するために必要なCPUサイクルを計測しました。

2パターン試しました。フック関数の中でgetpidシステムコールを実際に発行して結果を返すパターンと、メモリ上にキャッシュしたpidの値を返すパターンです。テーブルには、pidキャッシュなし、あり、という区別で書いてあります。

今回は、ユーザー空間でシステムコールを置き換えて自分で実装したかったので、pidキャッシュありのほうが、今回のケースでのオーバーヘッドがより見えるようになっていると思っています。

この環境で計った感じでは、Zpolineでシステムコールをフックした場合、ptraceで実装する場合に比べて、getpidが100倍以上高速にエミュレートできる結果になりました。

まとめと質疑応答

まとめです。今回はZpolineという、バイナリを書き換えることでシステムコールをフックする仕組みを紹介しました。ソースコードは(スライドを示して)こちらのURLにあるので、よろしければ試してみてください。

それから、今回は時間の関係で省いたのですが、具体的なフック関数のプログラミングの方法については、新しく記事を用意したので、よろしければこちらもご覧ください。以上です。ご清聴ありがとうございました。

司会者:ありがとうございます。けっこう質問が出ますね。「純粋なフックなしに比べれば、逆にどれくらいのオーバーヘッドなんだろう?」「ほかより速いのはわかったけど、オーバーヘッドはどれくらい?」という質問があります。

yasukata:(スライドを示して)pidキャッシュありのこの数字は、メモリ上にキャッシュしたpidの値を返しているだけなので、これはかなり純粋なオーバーヘッドに近いと思っています。