システムコールを速く漏れなくフックする方法
2021年12月22日 水曜日
CONTENTS
【IIJ 2021 TECHアドベントカレンダー 12/22(水)の記事です】
まえがき
この記事では、システムコールをフックする新しい手法を紹介します。
必要な前提知識について
この記事は CPU レジスタ、簡単なアセンブリ言語プログラミング、また、システムコールについての前提知識を想定した記述になっておりますが、それぞれについて、記事の末尾で簡単な説明をしていますので、これらの内容にあまり詳しくないかもしれないと思われた方は、是非、末尾の前提知識の項を先に見てみてください。
概要
- 背景:システムコールのフックは様々な用途で利用されています。
- 問題:システムコールをフックする仕組みには5つ重要な要件がありますが、既存の仕組みではそれらを同時に達成できないため、結果として、これまでのシステムコールフックを利用した仕組みは性能もしくは汎用性が制限されてきました。
- 提案手法:今回紹介する Zpoline という手法では、新しいバイナリ書き換えの方法とトランポリンコードの用意の仕方を工夫することで、その問題を解決します。
- 性能:getpid という単純なシステムコールをエミュレーションする場合、Zpoline を用いてシステムコールフックを実装すると、既存の ptrace を利用した場合と比較して 100 倍以上になるという結果になりました。
想定環境
今回の記事は、x86-64 CPU 上で動作する Linux を想定して記述されています。
ソースコード
記事で紹介する仕組みは GitHub 上のリポジトリで公開しておりますので、よろしければ是非お試しください。
背景
システムコールは、ユーザ空間プログラムがカーネルとコミュニケーションを行うために利用されるインタフェースです。ユーザ空間プログラムは、カーネルの機能を利用するほとんどの場面でシステムコールを発行するため、システムコールフックは、トレーシングやユーザ空間プログラムの挙動を変更する仕組みを実装する上で重要な役割を果たします。具体的にシステムコールフックは、トレーシングツール(strace や ltrace 等)、サンドボックス(gVisor 等)、OS エミュレーション層(User-mode Linux (UML) や Wine)等で利用されています。
問題
しかし、既存のシステムコールフックの仕組みは、以下の5つの要件を同時に満たすことができず、結果として、現在システムコールフックを利用して実装されているシステムは性能や汎用性が制限されています。
5つの要件
- (要件1) 元のユーザ空間プログラムの性能劣化が小さい:システムコールフックは、OS エミュレーション等で利用されるため、フック自体がプログラムの性能を劣化させるべきではありません。
- (要件2) システムコールをフックし損ねることがない:システムコールをフックし損ねると、フックを利用したプログラムが正しく動作できないため、全てのシステムコールをフックできる必要があります。
- (要件3)ユーザ空間プログラムのソースコードと再コンパイルを必要としない:ユーザは利用しているプログラムのソースコードを必ずしも保有していないため、ソースコード、またソースコードの再コンパイルを必要とするべきではありません。
- (要件4)カーネルの変更が不要で、かつ、カーネルモジュールも必要としない:一般的な利用が著しく困難になるため、システムコールフックは公式のカーネルへマージされていない独自機能に依存するべきではありません。また、カーネルの変更を必要としないカーネルモジュールでも、カーネルのバージョンごとに細かい実装の変更が必要となり、全てのカーネルのバージョンで利用可能な状態を保つのは非常に困難であるため、システムコールフックは独自のカーネルモジュールに依存すべきではありません。
- (要件5) システムコールのエミュレーションに利用できる:システムコールフックは、OS エミュレーション層のような機能の根幹としても使われるため、システムコールエミュレーションに利用可能であるべきです。
既存手法
既存のシステムコールフックの仕組み、特にユーザ空間のプログラムと再コンパイルを必要とせず、独自のカーネル内の機能に依存しないもの(要件3と4を満たすもの)、を以下のテーブルにまとめてみました。
システムコールフックの仕組み | 要件1 | 要件2 | 要件3 | 要件4 | 要件5 |
ptrace, Syscall User Dispatch | ✔ | ✔ | ✔ | ✔ | |
eBPF | ✔ | ✔ | ✔ | ✔ | |
ライブラリ関数の置き換え | ✔ | ✔ | ✔ | ✔ | |
既存のバイナリ書き換えツール | ✔ | ✔ | ✔ | ✔ |
- ptrace、Syscall User Dispatch:カーネルが提供している ptrace や Syscall User Dispatch のような機能は、ユーザ空間でシステムコールのフックを実装するために利用できます。ですが、これらを利用すると、元のユーザ空間プログラム内部でのシステムコール呼び出しのコストが大きくなり、結果として、性能が大きく劣化してしまいます。(要件1を満たせない)
- eBPF :eBPF のようなカーネル内の関数へフックを適用できる仕組みもありますが、eBPF は XDP のような場合を除くと、基本的にカーネルの挙動を変更するためには利用できないため、カーネル機能をユーザ空間でエミュレートする、といった用途には適していません。(要件5を満たせない)
- ライブラリ関数の置き換え:標準ライブラリ(libc 等)は、沢山のシステムコールのラッパーライブラリ関数を実装しており、多くの場合、プログラムはそれらを利用してシステムコールを発行するため、そのようなライブラリ関数を置き換えることで、システムコールをフックできます。具体的には、LD_PRELOAD という環境変数へ独自のライブラリ関数実装を含んだ共有ライブラリを指定すると、通常利用されるはずのライブラリ関数を、独自の実装と置き換えることができます。このようなライブラリ関数の置き換えは、カーネルの機能を利用せず、ユーザ空間プログラムの性能を劣化させない点で、ptrace や Syscall User Dispatch より優れています。一方で、システムコールを発行する CPU 命令は必ずしもライブラリ関数と紐づいていないため、結果として、ライブラリ関数を置き換えるだけでは、システムコールをフックし損ねる場合があります。(要件2を満たせない)
- 既存のバイナリ書き換えツール:他に、既存のバイナリ書き換えツールを使ってフックを実装する方法も考えられます。バイナリ書き換えツールでは、プログラム中のシステムコールを発行する CPU 命令を別の命令へ置き換えて、任意のフック実装へ処理を移行できるようにします。こちらもライブラリ関数の置き換えと同じく、カーネルの機能を利用しないため、ユーザ空間プログラムの性能を低下させにくいという利点があります。しかし、既存のバイナリ書き換え手法には、(後述の)問題があり、確実な置き換えを保証するのが難しいというのが欠点です(要件2を満たせない)
提案手法
今回の提案手法である Zpoline はバイナリを書き換えることでシステムコールをフックします。
既存手法の項で述べられている通り、既存のバイナリ書き換えは、システムコールをフックし損ねる可能性がある(要件2を満たせない)という点以外は理想的です。Zpoline は、なんとかその欠点を克服して、5つ全ての要件を満たすことを目指します。
具体的に、バイナリ書き換えでシステムコールのフックを実現するには、プログラム内の syscall / sysenter 命令を別の命令に置き換えて、任意の処理へ移行させられるようにする必要があります。
x86-64 CPU で処理を特定の箇所へ移行させるためには、通常、jmp もしくは call 命令を使います。なので、syscall / sysenter 命令を jmp もしくは call 命令と置き換えるとシステムコールのフックが実現できそうです。
システムコールをバイナリ書き換えでフックする場合の問題
ですが、syscall / sysenter 命令と jmp / call 命令の置き換えには問題があり、この問題のために、既存のバイナリ書き換えの仕組みでは、必ずしも置き換えを成功させられなくなっています。
具体的には、syscall と sysenter 命令が、それぞれ 0x0f 0x05 と 0x0f 0x34 という2バイトのオペコードで表される一方、jmp と call 命令で任意の処理を移行するためには、その処理の場所(アドレス)を指定する必要があり、結果として、2バイトでは置き換えることが難しい、というのが問題です。
Zpoline のアイデア
Zpoline では、システムコールの呼出規約をうまく使い、かつ、トランポリンコード(処理を任意の箇所へ飛ばすためのプログラム)を適切に用意することで、上記の問題を解決します。
Zpoline でのバイナリ書き換え
具体的に、Zpoline では、syscall / sysenter 命令を callq *%rax という、0xff 0xd0 の2バイトで表される命令へ置き換えます。この場合、callq *%rax は syscall / sysenter と同じく2バイトの命令なので、単純な置き換えが可能であり、syscall / sysenter 以外の命令を上書きして壊してしまう心配はありません。
syscall / sysenter の代わりに、callq *%rax が実行されると何が起こるかというと、rax レジスタの入っているアドレスへ処理をジャンプさせます。ここで、システムコールの呼出規約を利用します。x86-64 CPU 上の Linux は、rax レジスタへ、リクエストしたいシステムコールの番号を入れることになっています。Linux では、システムコール番号は 0 から始まる 400 から 500 前後の数なので、callq *%rax は、アドレス 0 ~ 500 程度までへのジャンプになります。
Zpoline では、そのアドレス 0 ~ 500 を含む領域にトランポリンコードを用意して、任意のフック関数へジャンプさせます。
Zpoline の名前は、アドレス 0 (Zero) にトランポリン (tranPOLINE) コードを用意することから来ています。
トランポリンコード
Zpoline では、トランポリンコードを用意するために、mmap システムコール を使って、メモリをアドレス 0 に確保します。Linux では、デフォルトでは、アドレス 0 は mmap できないようになっていますが、/proc/sys/vm/mmap_min_addr に 0 を設定すると、通常ユーザでもアドレス 0 にメモリをマップできるようになります。
次に、アドレス 0 から最大のシステムコール番号までを nop 命令 ( 0x90 ) で埋めます。その後、最後の nop 命令の次に、任意のシステムコールフック関数へジャンプするためのコードを埋め込みます。
その結果、syscall / sysenter を置き換えた callq *%rax は、トランポリンコードの上の nop 命令のどれかへのジャンプになります。nop 命令に飛んだ後は、システムコール フックへのジャンプのコードへ行き着くまで、続く nop 命令を実行します。これで、任意のフック関数へのジャンプが実装できました。
全体的な挙動
以下の図は、Zpoline の概観を示しています。
全体的な挙動の流れは以下のようになります。
- ユーザ空間プログラムの main() 開始前に syscall / sysenter 命令を callq *%rax へ置き換える。
- ユーザ空間プログラムの main() が開始する。
- ユーザ空間プログラムは、通常通り、システムコールを実行しようとする。
- これまで syscall / sysenter 命令があった箇所は callq *%rax へ置き換えられているため、システムコールは発行されず、トランポリンコードへジャンプする。
- トランポリンコードは、任意のフック関数へ処理をジャンプさせる。
- フック関数内で、任意の処理を行い、元のユーザ空間プログラムへリターンする。
実装
GitHub 上のリポジトリでは、Zpoline を LD_PRELOAD でロードされることを想定した共有ライブラリとして実装しています。このライブラリは、プログラムの main 関数が実行され始める前に、トランポリンコードの用意と、バイナリの書き換えを行います。また、バイナリ書き換えは、メモリ上にロードされたプログラムに対して行うため、元のプログラムファイル自体を変更することはありません。
フックをプログラミングする際の問題点と dlmopen を使った解決策
フック実装に関する問題点
上記の仕組みで、システムコールをフックできるようにはなりましたが、そのままでは使い勝手に問題があります。具体的な問題点は以下のようになります。
例えば、元のユーザ空間プログラムが標準ライブラリの printf を呼び出すとします。printf は様々な処理を行った末、通常、write システムコールで標準出力へ文字列を出力します。ですが、Zpoline を適用した後には、その write システムコールを発行する syscall 命令は callq *%rax へ置き換えられて、フック関数へのジャンプになります。さて、ここでフック関数が文字を出力するために printf を再度呼び出すと問題が発生します。フック関数が printf を呼び出すと、フック関数へたどり着くようになっているので、結果としてループが発生します。
つまり、フック関数の実装に、元のユーザ空間プログラムが利用するリソース、特にライブラリ等を利用するべきではなく、結果として、そのままでは、標準ライブラリ等の機能、例えば printf をフック関数内で使えないため、フック関数のプログラミングがしにくくなってしまう、というのが問題です。
解決策:dlmopen を使う
上記の問題を解決するには、ライブラリ等のリソースを、フック関数の実装用と元のユーザ空間プログラム用とで、それぞれ別々に用意した方が良さそうです。そのためには、一つのライブラリを、同一のユーザ空間プログラム空間の、別々の場所に複数展開した上で、ライブラリ関数の呼び出し元がフック関数か元のユーザ空間プログラムであるかに応じて、適切に呼び出し先を接続できる必要があります。
このような操作はかなり複雑ですが、Linux では、dlmopen という仕組みが提供されており、これを使うことで実現できます。
dlmopen は、dlopen という、特定のライブラリをユーザ空間プログラム内でロードすることができる機能の拡張で、そのライブラリのロードに対して、名前空間を設定できるようになっています。複数の名前空間が設定された環境でプログラムがライブラリ関数を呼び出した場合には、同一の名前空間のライブラリの実装が使われるようになります。
具体的な Zpoline での dlmopen の使い方
まず、フック関数の実装を、Zpoline の LD_PRELOAD 用の共有ライブラリとは別の共有ライブラリとしてコンパイルします。この時に、フック関数は他の共有ライブラリ、例えば printf を含む標準ライブラリに含まれる関数を利用して問題ありません。
次に、Zpoline の LD_PRELOAD 用の共有ライブラリの実装の中で、dlmopen を利用して、先ほどのフック関数実装を含む共有ライブラリを、新しい名前空間を指定してロードします。dlmopen はフック関数実装を含むライブラリだけでなく、フック関数が利用する他の共有ライブラリも同時に、同一の名前空間にロードしてくれます。この名前空間の分離によって、フック関数と元のユーザ空間プログラムが別々にロードされたライブラリ関数を利用するようになり、ループが発生することもなくなります。
性能
簡単に、Zpoline を利用して作ったシステムコールフックの仕組みを ptrace と Syscall User Dispatch と比較してみました。
ベンチマーク内容
とても単純な、プロセスの pid を取得する getpid をトラップして、代わりにシステムコールを実行するために必要な CPU サイクルを計測しました。さらに、pid をキャッシュして、実際には getpid システムコールを実行しないで、キャッシュした値を返すエミュレーションも実装して、同様に CPU サイクルを計測しました。
実験環境
- CPU:Intel Xeon E5-2640 v3 CPU 2.60 GHz
- オペレーティングシステム:Linux-5.11 ( Ubuntu 20.04 )
結果
結果を以下のテーブルに示します。数字の単位は CPU サイクル数です。
システムコールフックの仕組み | pid キャッシュなし | pid キャッシュあり |
ptrace | 17820 | 16403 |
Syscall User Dispatch | 5957 | 4563 |
Zpoline | 1459 | 138 |
Zpoline は ptrace と Syscall User Dispatch よりも遥かに少ない CPU サイクルでシステムコールをフックできることがわかりました。
特に、システムコールフック自体のオーバーヘッドは、getpid システムコールのオーバーヘッドが含まれない「pid キャッシュあり」のケースに見ることができます。今回の環境では、Zpoline は ptrace より 118 倍高速であるという結果になりました。
まとめ
Zpoline という、バイナリを書き換えてシステムコールをフックする仕組みを紹介しました。詳細については、GitHub 上のリポジトリにあるコードとコード内のコメントを見ていただけましたら理解の助けになるかもしれません。また、Zpoline のコード自体は大きくなく、比較的簡単に色々な仕組みに組み込めると思うので、よろしければ、是非使ってみてください。
前提知識
システムコールとは?
広く一般的に利用されているコンピュータ、例えば、ノートパソコン、スマートフォン、サーバ用コンピュータ、では、オペレーティングのカーネル(中心部分)が CPU やメモリ、I/O デバイス等の物理的なリソースへアクセスする機能を実装し、オフィスソフトウェアや Web ブラウザ等のアプリケーション(本記事ではユーザ空間プログラムと呼びます)は、それらカーネル機能を適宜呼び出し利用することで、アプリケーションとしての機能を実装します。
システムコールは、ユーザ空間プログラムが、カーネル機能を呼び出して利用するためのインターフェースです。
このような説明だけだと、イメージが掴みにくいかもしれないので、以下では具体例を用意しました。
hello world とシステムコール
「hello world」と出力するプログラムは、プログラミングの参考書等によく書いてある、一番最初のシンプルなサンプルプログラムとして有名だと思います。ここでは、hello world を通してシステムコールがどのように機能しているか見ていきます。
printf を使った hello world
具体的に、C 言語の hello world は、以下のようなものが多いと思います。
#include <stdio.h> int main(void) { printf("hello world\n"); return 0; }
この例では、printf という標準ライブラリに含まれるライブラリ関数を使って hello world と出力しています。
printf 自体はライブラリ関数と呼ばれる、ライブラリ内に実装されている関数で、カーネル内に実装されているシステムコールとは違うものです。ですが、「コンソールに出力を行う」という作業は実はカーネル機能を使わなければできないようになっており、printf は内部で、write システムコールというシステムコールを呼び出しています。
標準ライブラリに含まれるシステムコールのラッパー関数を使った hello world
printf の代わりに、以下のようにすると、write システムコールを呼び出して、同じく hello world と出力することができます。
#include <unistd.h> int main(void) { write(1, "hello world\n", 12); return 0; }
このプログラム中の write は、標準ライブラリに含まれる write システムコールのために用意されたラッパー関数を呼び出しています。ですので、こちらも厳密には直接システムコールを呼び出しているとは言えないかもしれません。
標準ライブラリは、他にもたくさんのシステムコールのラッパー関数を実装しており、C 言語でシステムコールを使ったプログラミングをする場合には、これらのラッパー関数を利用することが多いと思われます。
システムコールをアセンブリ言語で呼び出す
標準ライブラリに実装されているシステムコールのラッパー関数に頼らずに write システムコールを呼び出して、hello world と出力するには以下のようになります。
extern long exec_write_syscall(int, const void *, unsigned long); void ___exec_write_syscall(void) { asm volatile ( ".globl exec_write_syscall \n\t" "exec_write_syscall: \n\t" "movq $1, %rax \n\t" "syscall \n\t" "ret \n\t" ); } int main(void) { exec_write_syscall(1, "hello world\n", 12); return 0; }
今回の例では、アセンブリ言語を使って、直接、システムコールを発行する CPU 命令である syscall 命令を実行する処理を書いています。
標準ライブラリに含まれるシステムコールのラッパー関数も、結局のところ、上の exec_write_syscall に含まれる処理を実行することで、システムコールを発行しています。
CPU レジスタ
上記のアセンブリプログラムが何をしているのかという話の前に、簡単に CPU のレジスタについて説明します。
CPU はレジスタと呼ばれる、容量が非常に小さい一方で高速な記憶装置を持っており、C 言語等では隠蔽されていますが、内部的には主にそのレジスタを使って演算を行なっています。
レジスタはそれぞれ名前がつけられており、具体的に、Intel や AMD 製が有名な x86-64 と呼ばれるアーキテクチャを採用している CPU では、例えば、rax、rbx、rcx、rdx、rsi、rdi、r8、r9、r10 のような名前のレジスタがあります。
アセンブリ言語では、これらの中から、ある特定のレジスタを指定して利用することができます。今回、システムコールの呼び出しには、特定のレジスタへの操作が必要なため、アセンブリ言語で exec_write_syscall を実装しています。
システムコールの呼出規約
上記の例に含まれる exec_write_syscall では、標準ライブラリに実装されているラッパー関数が内部で行なっているものと同じ処理を実行します。
この操作は、システムコールの呼出規約と呼ばれる決まりに従ったものになっています。
具体的に x86-64 CPU 上の Linux では呼出規約は、
- 呼び出したいシステムコールに対応する番号を rax レジスタに入れて、
- 引数を、rdi、rsi、rdx、r10、r8、r9 レジスタの順番で入れた後、
- syscall もしくは sysenter 命令を実行する。
という取り決めになっています。
(今回の例では syscall 命令を利用していますが、sysenter という別の命令を使ってもシステムコールを発行できます。)
上で「システムコールに対応する番号を rax レジスタに入れて」と表現しましたが、システムコールに対応する番号は、予めカーネルが定義している値で、x86-64 CPU 上の Linux では以下のようになっています。
read 0 write 1 open 2 close 3 ... 他にもたくさん
なので、exec_write_syscall 内に見られる以下の実装では、
movq $1, %rax syscall
各行それぞれ、
- write システムコールを呼び出したいので、対応している1を rax レジスタへ入れる。
- 次に、syscall という CPU 命令を実行する。
となっています。
exec_write_syscall の引数は、通常の関数呼び出しと同じなので、上記の例では rdi、rsi、rdx レジスタは触りません。(システムコールと通常の関数呼び出しの呼出規約は第4引数の取り扱いが違うので、引数が4つ以上あるシステムコールを利用する場合は、変更が必要です。)
システムコールを発行する命令の機械語
exec_write_syscall を含んでいるプログラムをコンパイルして、a.out というバイナリを生成した後、objdump というコマンドを使って以下のようにすると、バイナリに含まれているアセンブリ言語の実装と、対応する機械語を見ることができます。
$ objdump -D ./a.out
exec_write_syscall の箇所では、以下のような出力になっています。
0000000000001131 <exec_write_syscall>: 1131: 48 c7 c0 01 00 00 00 mov $0x1,%rax 1138: 0f 05 syscall
上記の出力によると、mov $0x1, %rax は、0x48 0xc7 0xc0 0x01 0x00 0x00 0x00 というバイト列(機械語の命令)に対応し、syscall 命令は、0x0f 0x05 と表現されています。(ちなみに、sysenter 命令は 0x0f 0x34 です。)
今回のポイントは、CPU の処理が 0x0f 0x05 というバイト列に達すると、それを syscall 命令と解釈して、システムコールの発行へつながる、という点です。
バイナリを書き換えてシステムコールをフックするとは?
今回の記事では、バイナリという単語は、上記の objdump で出力されたような、プログラムのバイト列を指しています。
上で確認した通り、syscall / sysenter 命令を実行するプログラムのバイナリは 0x0f 0x05 / 0x0f 0x34 のバイト列を含んでいます。
バイナリを書き換えてシステムコールをフックする、というのは、これら0x0f 0x05 / 0x0f 0x34 を別の何かと置き換えることで任意の処理を実行する、ということを意味しています。
前提知識のまとめ
- システムコールは、ユーザ空間プログラムがカーネルへ機能の利用をリクエストするためのインターフェースである。
- 実際にシステムコールを発行するためには、syscall / sysenter 命令を実行する。
- x86-64 CPU 上の Linux の呼出規約では、rax レジスタへシステムコール番号を入れて、syscall / sysenter 命令を実行することが決められている。
- syscall / sysenter 命令のオペコードは、0x0f 0x05 それぞれ 0x0f 0x34 である。
- バイナリ書き換えでシステムコールをフックする、とはプログラム中の 0x0f 0x05 / 0x0f 0x34 を別の何かと置き換えて、任意の処理を実行できるようにすることである。