かーねるさんとか

発言は個人の見解であり、所属する組織の公式見解ではありません。

ptrace より 100 倍速いシステムコールフック作った

新しい高性能で汎用的なシステムコールフックの仕組みを作ってみました。

モチベーションとして、システムコールをフックしてユーザー空間でエミュレートしたくなったのですが、現状、性能と汎用性を両立する仕組みがなさそうだったので、新しい方法を考えました。

今回のシステムコールフックの仕組みは以下のような特徴があります。

eBPF でトレーシングをしているけれど、できれば制約が少ないユーザー空間でトレーシングツールを作りたい。もしくは、gVisor のようなサンドボックスを作りたいけれど、ptrace による性能劣化が大きいので、他の高速なシステムコールフックの仕組みが使いたい、というような場合に利用できると思います。

今回は、Linuxx86-64 アーキテクチャを想定して実装してみました。

ソースコードGitHub へ置いてあるので、よかったら試してみてください。

github.com

以下に、新しい仕組みの詳細を書いていきます。

現在、考えられるシステムコールフックの作り方

まず、システムコールフックを実装する方法について、色々検索してみた結果、以下のような5つの候補を見つけました。

  1. 既存のカーネル機能を使う ( e.g., ptrace, Syscall User Dispatch (SUD) )
  2. カーネルを変更する、もしくはカーネルモジュールでシステムコールハンドラを書き換える*1
  3. フックしたいプログラムのソースコードを独自のシステムコール命令を含まないライブラリとコンパイルし直す *2
  4. LD_PRELOAD でライブラリ関数を書き換える*3
  5. バイナリ書き換えツールを使う*4

上記の方法の問題点

ですが、上記の5つの方法は、性能もしくは汎用性についての問題が見受けられました。

  1. 既存のカーネル機能:ptrace、SUD はオーバーヘッドが大きい(性能)
  2. カーネルの変更、カーネルモジュール:通常の環境で使えない(汎用性)
  3. プログラムの再コンパイルソースコードが必ずしも手に入らない(汎用性)
  4. LD_PRELOAD:ライブラリ関数でラップされていないシステムコールはフックできない(汎用性)
  5. バイナリ書き換えツール:100% の書き換え成功を保証できない(汎用性)

このことから、現状では、性能と汎用性を両立できる、ユーザー空間でシステムコールフックを実装可能な仕組みはなさそうでした。

今回考えたシステムコールフックの仕組み:Zpoline

今回考えた、Zpoline という仕組みは、プログラムのバイナリが実行前にメモリに読み込まれた段階で、バイナリ書き換えを行います。ですので、Zpoline は、バイナリ書き換えにカテゴライズされますが、プログラムのバイナリ"ファイル"自体は上書きしません。

前提知識:x86-64 でのシステムコール

システムコールは、ユーザー空間のプログラムが、カーネル機能へアクセスするためのインターフェースとして利用されます。

実装として、ユーザー空間プログラムは syscall もしくは sysenter という CPU 命令を利用することで、システムコールを発行できます。

ユーザー空間プログラムが syscall/sysenter 命令を実行すると、実行コンテキストがカーネル空間へ切り替わり、カーネルが予め設定したシステムコール ハンドラへ処理が以降します。

呼出規約 ( Calling Convention )

システムコールや普通の関数コールには、呼出規約と呼ばれる、呼び出しの際の決まりが設定されています。

システムコールにおいては、ユーザー空間のプログラムが、

についての取り決めとなっています。

Linuxx86-64 CPU 環境でのシステムコールでは、

となっています。

解決する問題:バイナリ書き換え固有の問題

Zpoline はバイナリ書き換えによって、システムコールのフックを実装しますが、バイナリ書き換えの仕組みには、100% の書き換え成功を担保することが難しいという問題があります。

具体的な難しさは、ある CPU 命令を、それよりも大きな CPU 命令と置き換える、という点にあります。

まず、syscall と sysenter CPU 命令は、それぞれ、0x0f 0x05 と 0x0f 0x34 という 2 byte のオペコードで表されます。

今回は、それらを任意のユーザー空間にあるシステムコールフック関数へ処理を飛ばすための、jmp もしくは call 命令と置き換えることを目指します。

問題点は、jmp と call 命令は、2 byte だけでは、任意のフック関数へのジャンプを実装することが難しいということにあります。なぜかというと、それらの命令は、ジャンプの宛先のアドレス(今回ならフック関数のアドレス)を指定する必要があり、それには 2 byte 以上が必要になるからです。

プログラムのバイナリの中では、syscall / sysenter 命令以降には、次の命令が書いてあるので、jmp / call 命令がはみ出ると、それらを上書きして、プログラムを壊してしまうことになります。結果として、既存のバイナリ書き換えツールでは、100% の書き換え成功を担保することが難しくなっています。

Zpoline のアイデア

Zpoline のアイデアは、システムコールの呼出規約をうまく使って、それに合わせてバイナリ書き換えを行い、かつ適切にトランポリンコードを用意することです。

以下の図に、外観を示します。

https://raw.githubusercontent.com/yasukata/zpoline/master/Documentation/img/zpoline.png

バイナリ書き換え

具体的には、syscall / sysenter 命令を callq *%rax という 0xff 0xd0 で表される 2 byte の命令で書き換えます。ここで、大事なポイントは、callq *%rax は syscall / sysenter 命令と同じ 2 byte なので、他の箇所に影響を与えずに、単純に置き換えることができます。

さて、callq *%rax が何をするのかというと、%rax CPU レジスタへ入った値を宛先アドレスとして、ジャンプします。また、callq は call 系列の命令なので、ジャンプ元のアドレスはスタックへ push します。

ここで、システムコールの呼出規約を使います。前述の通り、Linux の呼出規約では、%rax へは、システムコール番号が入っています。システムコール番号は、カーネルの定義によって、0 から始まり 400~500 程度までに収まる数なので、callq *%rax が飛ぶ宛先アドレスは必ず 0 ~ 500 程度になります。

Zpoline では、そのアドレス 0 ~ 500 の含まれる領域にトランポリンコードを用意し、任意のシステムコールフック関数へのジャンプを実装します。Zpoline の名前は、tramPOLINE code をアドレス 0 ( Zero ) に設定することから来ています。

トランポリンコード

トランポリンコードの用意は、mmap システムコール を使って、メモリをアドレス 0 に確保することから始まります。Linux では、デフォルトでは、アドレス 0 は mmap できないようになっていますが、/proc/sys/vm/mmap_min_addr に 0 を設定すると、通常ユーザーでもアドレス 0 にメモリをマップできるようになります。

次に、アドレス 0 から最大のシステムコール番号までを nop 命令 ( 0x90 ) で埋めます。その後、最後の nop 命令の次に、任意のシステムコールフック関数へジャンプするためのコードを埋め込みます。

その結果、syscall / sysenter を置き換えた callq *%rax は、トランポリンコードの上の nop 命令のどれかへのジャンプになります。nop 命令に飛んだ後は、システムコール フックへのジャンプのコードへ行き着くまで、続く nop 命令を実行します。

これにより、任意のフック関数へのジャンプが実装できました。

また、callq *%rax の呼び出し元のアドレスは、callq 命令のおかげでスタックに保存されているので、フック関数の return は callq *%rax の呼び出し元への return になります。

実装

Zpoline で LD_PRELOAD でロードされることを想定したライブラリとして実装されており、プログラムの main 関数が実行され始める前に、トランポリンコードとバイナリ書き換えを行います。これにより、Zpoline はどんなプログラムに対してもシステムコールフックを適用できます。LD_PREALOD を使っていますが、既存の仕組みのように、ライブラリ関数の書き換えは行いません。

独自のシステムコールフックは、LD_PRELOAD でロードされるライブラリの中に実装することができます。

1点、既存のバイナリ書き換えの仕組みや、Syscall User Dispatch と同様に、フックを適用する対象のプログラムが呼び出す可能性のある関数を、フックから呼び出す場合には注意が必要です。例えば、function_A という関数があり、内部の実装が、ロックを確保し、あるシステムコールを実行、その後、ロックを開放する、とします。仮に、フック適用対象のプログラムが function_A を呼び出すと、その中のシステムコールは、Zpoline によってフックされます。問題は、フックが function_A を呼び出すと、デッドロックが発生します。なぜなら、最初に呼び出された function_A の中で、ロックがリリースされていないからです。

このような問題については、フック関数が利用するリソースを、フック適用対象のプログラムと分けることで、回避できます。今後、フック関数の実装に役に立ちそうな実装を追加する予定でいます。

制約

Zpoline の適用には、以下の二つの制約があります。

  1. システムコールの呼出規約が、CPU レジスタのどれかに、決まった範囲のシステムコール番号を設定するものであること
  2. メモリアドレス 0 を利用可能であること。通常、この領域は利用されておらず、競合することは少ないかと思っています。

性能

簡単に、Zpoline を利用して作ったシステムコールフックの仕組みを ptrace と Syscall User Dispatch と比較してみました。

とても単純な、プロセスの pid を取得する getpid をトラップして、代わりにシステムコールを実行するために必要な CPU サイクルを計測しました。さらに、pid をキャッシュして、実際には getpid システムコールを実行しないで、キャッシュした値を返すエミュレーションも実装して、同様に CPU サイクルを計測しました。計測には、Intel Xeon E5-2640 v3 CPU 2.60 GHz と Linux-5.11 ( Ubuntu 20.04 ) を利用しました。

以下が計測結果です。

Hook Mechanism without pid cache with pid cache
ptrace 17820 16403
Syscall User Dispatch 5957 4563
Zpoline 1459 138

Zpoline は ptrace と Syscall User Dispatch よりも遥かに少ない CPU サイクルでシステムコールをフックできることがわかりました。

特に、システムコールフック自体のオーバーヘッドは、getpid システムコールのオーバーヘッドが含まれない with pid cache のケースに見ることができます。今回の環境では、Zpoline は ptrace より 118 倍高速であるという結果になりました。

まとめ

ptrace よりも 100 倍以上高速で、LD_PRELOAD や既存のバイナリ書き換えツールより確実、かつ、カーネルへの変更や、プログラムのソースコードも必要ない Zpoline というシステムコールフックの仕組みを考えました。

実装の詳細は、GitHub 上のソースコードを見てみてください。

その他の考えられる方法

システムコールフックの仕組みを探しているときに、他にもいくつか候補がありましたが、以下のような理由で、今回のような仕組みになりました。