かーねるさんとか

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

dlmopen を使ってシステムコールのフックをいい感じにプログラミングする

前回の記事で紹介しました Zpoline というシステムコールフックの仕組みだと、バイナリ書き換え後の関数を、システムコールフックから呼び出してしまうと、デッドロックもしくは無限ループが発生することがあるという問題がありました。

結果として、システムコールフックの実装に標準ライブラリ内の機能、例えば、printf 等が使えなくてプログラミングがしにくくなっていました。

この問題は、dlmopen という機能を使うと解決できると教えて頂いて、実際に大分簡単にフック関数を実装できるようになったので、今回の記事では、具体的な Zpoline での利用方法について説明します。

以下の GitHub 上のリポジトリに置いてあるソースコードは dlmopen を利用できるように変更してありますので、よかったら是非試してみてください。

github.com

dlmopen の前に dlopen とは?

dlmopen は dlopen という機能の拡張です。dlmopen の前に、簡単に dlopen について説明します。

dlopen を利用すると、プログラムの中から、任意の共有ライブラリファイルをロードすることができます。

具体例として、以下のようなサンプルプログラムを用意してみました。

dlopentest.c

#include <dlfcn.h>

int main(void)
{
	void (*example_fn)(void);
	void *handle = dlopen("./libdlopentest.so", RTLD_NOW);
	example_fn = dlsym(handle, "example_function");
	example_fn();
	dlclose(handle);
	return 0;
}

libdlopentest.c

#include <stdio.h>  

void example_function(void)
{
	printf("I'm example_function\n");
}

上のプログラムはそれぞれ以下のコマンドでコンパイルできます。

$ gcc dlopentest.c -ldl
$ gcc -shared -fPIC -o libdlopentest.so libdlopentest.c

生成された a.out を実行すると以下のような出力になります。

$ ./a.out
I'm example_function

上のサンプルでは、a.out (dlopentest.c) の中から、libdlopentest.so (libdlopentest.c) という共有ライブラリファイルを dlopen を使ってロードし、その中に実装されている、example_function という名前の関数を実行しています。

多くの場合、共有ライブラリをプログラムに組み込むには、例えば今回の例では、以下のように -l[ライブラリ名] のようにコンパイラに対して指定すると思いますが、

$ gcc dlopentest.c -ldlopentest # これは例で実際には動きません

dlopen を利用すると、コンパイラによる紐付けとは別にプログラムの中から共有ライブラリをロードすることができます。

dlmopen

dlmopen は dlopen と、基本的に共有ライブラリをプログラム内へロードできるという点で同じですが、共有ライブラリをロードする名前空間を指定できるという拡張を実装している点で異なります。

ライブラリのロードされる名前空間を分けることで、ある特定の実装が、デフォルトでロードされているライブラリ実装、例えば libc を利用しないようにできます。

具体的にどういうことか、わかりにくいと思いましたので、以下にサンプルプログラムを用意してみました。以下のプログラムでは、dlopen もしくは dlmopen を使って、共有ライブラリをデフォルトとは別の名前空間にロードした後で、main() と example_function() が利用している printf のアドレスを出力しています。dlopen と dlmopen のどちらを利用するかは、プログラム (a.out ) の引数で指定できます。(0 => dlopen, 0 以外 => dlmopoen)

dlmopentest.c

#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int main(int argc, char const* argv[])
{
	void (*example_fn)(void);
	void *handle;
	if (atoi(argv[1]) == 0) {
		printf("use dlopen: printf used by main and example_function will be the same\n");
		handle = dlopen("./libdlmopentest.so", RTLD_NOW | RTLD_LOCAL);
	} else {
		printf("use dlmopen: printf used by main and example_function will be different\n");
		handle = dlmopen(LM_ID_NEWLM, "./libdlmopentest.so", RTLD_NOW | RTLD_LOCAL);
	}
	example_fn = dlsym(handle, "example_function");
	printf("I'm main (printf is at %p)\n", printf);
	example_fn();
	dlclose(handle);
	return 0;
}

libdlmopentest.c

#include <stdio.h>  

void example_function(void)
{
	printf("I'm example_function (printf is at %p)\n", printf);
}

上のプログラムは以下のコマンドでコンパイルできます。

$ gcc dlmopentest.c -ldl
$ gcc -shared -fPIC -o libdlmopentest.so libdlmopentest.c

以下はプログラムの出力の例です。

$ ./a.out 0
use dlopen: printf used by main and example_function will be the same
I'm main (printf is at 0x7f26b8dfce10)
I'm example_function (printf is at 0x7f26b8dfce10)

$ ./a.out 1
use dlmopen: printf used by main and example_function will be different
I'm main (printf is at 0x7fb852ce0e10)
I'm example_function (printf is at 0x7fb852ad2e10)

dlopen を利用した場合(上の出力では $ ./a.out 0 の場合)は、main と example_function が表示する printf のアドレスが同じ(0x7f26b8dfce10)である一方、dlmopen を利用すると(上の出力で $ ./a.out 1 の場合)、それぞれが異なるアドレス(main: 0x7fb852ce0e10 と example_function: 0x7fb852ad2e10)を表示しています。

これは、dlmopen が libdlmopentest.so と libdlmopentest.so が依存しているライブラリ(今回は libc)を、デフォルトとは異なる新しい名前空間にロードし、libdlmopentest.so 内部の実装が、新しい名前空間にロードされた libc を利用するように設定してくれたからです。

今回は、libdlmopentest.so に実装されている example_function がデフォルトの libc を利用しなくなった、というところがポイントです。

Zpoline で dlmopen を利用する

これまでの Zpoline を利用した場合のフック関数実装についての問題は、フック関数が、フック適用対象のプログラムのために用意されたライブラリ等のリソースを利用してしまうことに起因しました。

dlmopen を利用すると、フック関数のためだけにライブラリのようなリソースを新しく用意することができ、また、フック関数から、フック適用対象のプログラムのためのリソースを利用しないようにできる、というのが今回のポイントです。

今回の実装では、Zpoline の初期化用の共有ライブラリと、フック関数実装を、別々の共有ライブラリとしてコンパイルします。

初期化用の共有ライブラリは、LD_PRELOAD でロードされることを想定しており、フック適用対象のプログラムの main() が開始する前に、トランポリンコードの用意とバイナリ書き換えを行います。さらに、その後、dlmopen を使って、フック関数の実装を含む共有ライブラリを、新しい名前空間へロードします。

実装

以下のコードが、Zpoline で dlmopen を利用する初期化用ライブラリ(LD_PRELOAD でロードされる方)の実装です。以下では、フック関数は、LIBZPHOOK という環境変数に指定された共有ライブラリの中で、__hook_fn という名前で実装していることを想定しています。(わかりやすさのために簡略化してあります。詳しくはソースコードをご参照ください。)

static long (*hook_fn)(int64_t a1, int64_t a2, int64_t a3,
		       int64_t a4, int64_t a5, int64_t a6,
		       int64_t a7) = NULL;

/* trampoline code will jump to syscall_hook */
long syscall_hook(int64_t a1, int64_t a2, int64_t a3,
		  int64_t a4, int64_t a5, int64_t a6,
		  int64_t a7)
{
	return hook_fn(a1, a2, a3, a4, a5, a6, a7);
}

static void load_hook_lib(void)
{
	void *handle;
	const char *filename;

	filename = getenv("LIBZPHOOK");

	handle = dlmopen(LM_ID_NEWLM, filename, RTLD_NOW | RTLD_LOCAL);

	hook_fn = dlsym(handle, "__hook_fn");
}

__attribute__((constructor(0xffff))) static void __zpoline_init(void)
{
	setup_trampoline();

	rewrite_code();

	load_hook_lib(); // load hook function library
}

上のコードでは、LD_PRELOAD によってロードされた時に、__zpoline_init が最初に実行されることを想定しています。

__zpoline_init

__zpoline_init は、トランポリンコードを用意して (setup_trampoline)、バイナリ書き換えを行った後 (rewrite_code)、フック実装を含んだ共有ライブラリを dlmopen を使ってロードします (load_hook_lib)。

load_hook_lib

上のコード内の load_hook_lib では、dlmopen を使って、LIBZPHOOK という環境変数に指定された、フック関数の実装を含む共有ライブラリを、新しい名前空間にロードします。

この新しい名前空間にロードされたプログラムに対しては、バイナリ書き換えは行いません。結果として、フック関数内で、通常であればシステムコールを発行するライブラリ関数等を呼び出したとしても、そのシステムコールはフックされず、そのままカーネルに対してシステムコールが発行されます。

フック関数実装を含む共有ライブラリのロード完了後に、dlsym という機能を使って、フック関数である __hook_fn という名前の関数を探し、そのアドレスを hook_fn という名前のポインタへ代入します。

syscall_hook

バイナリ書き換え後は、フック適用後のプログラムが発行しようとしたシステムコールはフックされ、syscall_hook という関数へリダイレクトされるようになります。この中で、先ほどの hook_fn (つまり共有ライブラリ内の __hook_fn)を呼び出します。

__hook_fn

__hook_fn の実装自体は、別の名前空間にロードされており、その中で呼び出される、例えば printf 等は、フック適用対象のプログラムが利用するものとは別のものになります。

これで、フック関数実装(__hook_fn)がフック適用対象のプログラムのリソースを利用することを回避できるようになりました。

dlmopen は、フック関数実装を含む共有ライブラリが依存する別のライブラリも一緒に新しい名前空間にロードしてくれるため、フック関数実装で libc 以外のライブラリを利用することも可能です。

独自のフック関数を実装するには

独自のシステムコールフックを実装するには、今回の例では、__hook_fn という名前のフック関数を実装し、独立した共有ライブラリとしてコンパイルすると良いと思われます。

上の例をそのまま使うと、仮に、__hook_fn を含む共有ライブラリを ./apps/basic/libzphook_basic.so というパスにコンパイルして設置した場合には、以下のようなコマンドで、独自の __hook_fn 実装をフック関数として適用できます。

$ LIBZPHOOK=./apps/basic/libzphook_basic.so LD_PRELOAD=./libzpoline.so [program you wish to run]

以下は、フック関数実装の例です。以下の __hook_fn は a1 にシステムコール番号、a2~a7 へシステムコールの引数が入ってきます。この中で、例えば、実際にシステムコールを発行することも、システムコールをエミュレートすることもできます。(簡略化してありますので、詳細は GitHub 上のサンプルプログラムを見てみてください。)

#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include <syscall.h>

long __hook_fn(int64_t a1, int64_t a2, int64_t a3,
	       int64_t a4, int64_t a5, int64_t a6,
	       int64_t a7)
{
	printf("output from __hook_fn: syscall number %ld\n", a1);
	return syscall(a1, a2, a3, a4, a5, a6, a7);
}

まとめ

dlmopen を利用して、Zpoline でのフック関数実装を簡単にする方法について説明しました。詳細については、Github 上のリポジトリにあるサンプルプログラムをご参照ください。比較的簡単に色々な仕組みへ組み込めるようになったと思いますので、よろしければ、是非使ってみてください。