Linux カーネルハック実践入門
Linux のカーネルハックの実践的な入門方法について書いてみようと思います。
カーネルハックをすると、OS の機能を改変したり、追加したりできます。
OS の機能の例としては、スケジューラ、メモリ管理機能、ネットワークスタック、ファイルシステム、デバイスドライバ等があります。
今回の記事では、具体的にどうすればカーネルの機能を改変したり、追加したりできるのかについて書いていきます。
特に今回は、カーネルモジュールを使ったカーネルハックの方法について書いていきます。
カーネルハックに関する事前知識や、カーネルモジュールとして実装ことの利点については過去の記事にまとめてありますので、よろしければそちらもご参照ください。
具体的には、既存のファイルシステムをハックして機能を追加してみようと思います。
必要な準備
1. Linux がインストールされた仮想マシン
Linux のカーネルハックを行うには、Linux がインストールされたコンピューターが必要です。
Windows や Mac を利用している場合でも、仮想化ソフトウェアを利用すると、Windows や Mac の上で、Linux の仮想マシンを実行することができます。
Linux のデスクトップ機を利用されている場合でも、カーネルハックを行っている際に、追加した内容にバグがあるとシステムが破損する恐れがあるので、安全のために仮想マシンを利用されることをおすすめします。
この記事を書いている時点で、デスクトップ環境で利用可能な仮想化ソフトウェアには以下のようなものがあります。お使いの環境に合わせて選んでください。
- VirtualBox ( Windows、Mac、Linux で無償で利用可能 )
- VMware Workstation Player ( Windows と Linux 用 無償ライセンスあり)
- VMware Fusion ( Mac 用 有償 )
- Parallels Desktop ( Mac 用 有償 )
- virt-manager ( Linux 用 無償 )
Linux を仮想マシンにインストールする場合には、ディストリビューションごとに配布されているインストーラを利用する必要があります。
Linux カーネルは OS の基本的な部分であって、Graphical User Interface (GUI) などはカーネル自体には含まれておらず、一般的に人が使うためには多くの付属ソフトウェアが必要になります。
ディストリビューションは、Linux を汎用的に利用できるように付属ソフトウェアをまとめて簡単に利用可能なようにしてくれているものです。
ディストリビューションの種類はたくさんありますが、有名なものとして以下のようなものがあります。
今回の記事では、Ubuntu 20.04 を利用しています。ディストリビューションが異なると、パッケージのインストールのコマンドが大きく異なりますが、根本的な部分については同じです。
具体的な Linux を仮想マシンへインストールする方法については、仮想化ソフトウェアとディストリビューションに依存するのと、インターネット上にたくさん情報があるので割愛します。
例えば、”Ubuntu VirtualBox インストール” のようなフレーズで検索すると情報が出て来やすいと思います。
2. Linux のソースコード
カーネルハックでは、カーネルの機能を変更するので、カーネルのソースコードが必要になります。
Linux カーネルにはバージョンがあり、バージョンごとにソースコードの内容が異なります。
今回は、仮想マシンに既にインストールされている Linux と同じバージョンのソースコードを入手します。インストールされている Linux カーネルのバージョンと、ソースコードのバージョンが異なると、実験がうまくいかないことがあるので、注意してください。
Linux カーネルのソースコードは The Linux Kernel Archives というホームページで配布されていますが、今回はホームページからではなく、特定のバージョンのソースコードを取得するために、CDN の URL からダウンロードします。
仮想マシンにインストールされている Linux のバージョンは以下のコマンドを仮想マシン内で実行すると得られます。
$ uname -r 5.4.0-40-generic
上記のコマンド出力の結果、カーネルのバージョンが 5.4.0 であることがわかりました。
仮想マシンで、以下のコマンドを実行すると、Linux のバージョン 5.4 のソースコードが取得できます。実験の際には、手元の仮想マシンにインストールされている Linux カーネルと同じバージョンを取得できるように、コマンド内のバージョン番号を変更してください。
インストールされている Linux カーネルのバージョンが 3.~ や、4.~ の場合は、URL の最後から一つ前の項目を v5.x から、v3.x や v4.x に適宜変更してください。
$ wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.4.tar.gz
また、ブラウザで以下の URL に直接アクセスしてダウンロードすることもできます。ホスト側のブラウザを使うと、ホストにファイルがダウンロードされるので、ダウンロード完了後には、仮想マシンへファイルをコピーする必要があります。
ダウンロードできたら、仮想マシン内で以下のコマンドを実行して、ソースコードを展開します。
$ tar xvf linux-5.4.tar.gz
最初のカーネルモジュール
動作確認も兼ねて、最小構成のカーネルモジュールをコンパイルしてインストールしてみましょう。
ソースコードは GitHub に置いてありますので、よかったら試してみてください。
今回のカーネルモジュールの C プログラムは以下のようになります。kmod.c という名前をつけて保存します。
#include <linux/kernel.h> #include <linux/module.h> static int __init kmod_init(void) { printk(KERN_INFO "kernel module is loaded\n"); return 0; } static void __exit kmod_exit(void) { printk(KERN_INFO "kernel module is unloaded\n"); } module_init(kmod_init); module_exit(kmod_exit); MODULE_LICENSE("GPL v2");
以下が、kmod.c をカーネルモジュールとしてビルドするための Makefile です。
PWD := $(shell pwd) KDIR := /lib/modules/$(shell uname -r)/build obj-m += kmod.o EXTRA_CFLAGS=-I$(PWD)/include SUBDIRS := $(PWD) COMMON_OPS = -C $(KDIR) M='$(SUBDIRS)' EXTRA_CFLAGS='$(EXTRA_CFLAGS)' deafult: $(MAKE) $(COMMON_OPS) modules clean: rm -rf *.o *.ko *.cmd *.mod.c *.mod .*.cmd .tmp_versions modules.order Module.symvers *~
上の kmod.c と Makefile を置いてあるディレクトリの中で以下のように make コマンドを実行すると kmod.ko というカーネルモジュールのファイルがビルドされます。
$ make
動作確認のために、インストールしてみます。カーネルモジュールのインストールには、以下のように insmod コマンドを利用します。
$ sudo insmod kmod.ko
insmod を実行した後に、dmesg コマンドを実行してみてください。一番下の方に以下のようなメッセージが表示されていれば成功です。
$ dmesg ... [ 305.841891] kernel module is loaded
カーネルモジュールをアンインストールするには、rmmod コマンドを実行します。
$ sudo rmmod kmod
rmmod コマンドを実行した後に、再度 dmesg を実行してみてください。以下のようにメッセージが追加されているはずです。
$ dmesg ... [ 305.841891] kernel module is loaded [ 326.462975] kernel module is unloaded
kmod.c についての簡単な説明
kmod.c はカーネル空間で動作するプログラムです。プログラムの書き方自体は、Linux カーネルそのものと同じです。
Linux カーネルのプログラミングは、基本的に C 言語ですが、ユーザー空間で動作するプログラムと異なる点が多くあります。ポイントは、標準ライブラリは利用できず、Linux カーネル内部で用意されている関数を利用する必要があることです。
まず、printf が使えません。代わりに、ログを出力する printk という関数が用意されており、デバッグの際には概ね代用できます。
module_init と module_exit は、Linux カーネルが内部的に用意している関数で、それぞれ、カーネルモジュールをインストールしたときと、アンインストールしたときに実行される関数を登録できます。
カーネルモジュールを使ったカーネルハックを始める
最小構成のカーネルモジュールをどうやって作るのかはわかりました。次に、カーネルモジュールの仕組みを使うと何ができるのかについて見ていきます。
ここからが実践的な内容になります。
今回は、Linux カーネルのソースコード内にある MINIX ファイルシステムを、独立したカーネルモジュールとしてビルドしてハックしてみます。
これを応用すると、他の種類のファイルシステムや、デバイスドライバなどの挙動を簡単に変えることができるようになります。
改変後のソースコードを GitHub へ置いておきますので、よろしければご参照ください。コミットのログを見ると、どこが、どのように変更されたかわかりやすいと思います。手元に clone した場合は、git log -p とすると、詳細な変更の履歴が見られます。
1. MINIX ファイルシステムをカーネルモジュールとしてビルドする
まずは、作業用に MINIX ファイルシステムのソースコードをすべてコピーしましょう。これはどこのディレクトリにコピーしてもかまいません。
$ cp -r linux-5.4/fs/minix ./
次に、コピーした MINIX ファイルシステムのディレクトリの中に入って、make コマンドを実行してみます。
$ cd minix $ make: *** No targets. Stop.
そのままでは、上記のように、No targets と言われて何も起こりません。
MINIX ファイルシステムの Makefile の中身は以下のようになっています。
# SPDX-License-Identifier: GPL-2.0-only # # Makefile for the Linux minix filesystem routines. # obj-$(CONFIG_MINIX_FS) += minix.o minix-objs := bitmap.o itree_v1.o itree_v2.o namei.o inode.o file.o dir.o
ここで、最初に作った最小のカーネルモジュール用の Makefile と比べてみると、MINIX ファイルシステムの Makefile には不足している部分が多くあるのがわかります。
その不足部分が MINIX ファイルシステムをカーネルモジュールとして直接コンパイルできない理由なので、足りない部分を手で追加しましょう。
ポイントは、obj-$(CONFIG_...) の個所を obj-m に変更することと、default: 以下に make コマンド実行時に何をすべきかを書くことです。
以下が変更後の Makefile です。
# SPDX-License-Identifier: GPL-2.0-only # # Makefile for the Linux minix filesystem routines. # PWD := $(shell pwd) KDIR := /lib/modules/$(shell uname -r)/build obj-m += minix.o minix-objs := bitmap.o itree_v1.o itree_v2.o namei.o inode.o file.o dir.o EXTRA_CFLAGS=-I$(PWD)/include SUBDIRS := $(PWD) COMMON_OPS = -C $(KDIR) M='$(SUBDIRS)' EXTRA_CFLAGS='$(EXTRA_CFLAGS)' deafult: $(MAKE) $(COMMON_OPS) modules clean: rm -rf *.o *.ko *.cmd *.mod.c *.mod .*.cmd .tmp_versions modules.order Module.symvers *~
Makefile を上のように書き換えた後に、再度 make コマンドを実行してみると、今度はコンパイルが成功して、minix.ko というファイルが生成されるはずです。
実際に使えるか試してみましょう。以下のコマンドで、コンパイルされたカーネルモジュールをインストールできます。
$ sudo insmod minix.ko
次に、実験のため、MINIX ファイルシステム用にディスクを用意しましょう。今回は、RAM ディスクを使って実験してみましょう。RAM ディスクは、brd というカーネルモジュールを使うと作ることができます。以下のコマンドで、brd のカーネルモジュールをロードしてください。
$ sudo modprobe brd
上記のコマンドを実行すると、/dev/ram0 というパスに、RAM ディスクができます。今度は、以下のコマンドを実行して、/dev/ram0 を MINIX ファイルシステム用に初期化(フォーマット)しましょう。
mkfs コマンドで他のディスクを初期化しないように注意してください。間違って操作すると、重要なデータが消えてしまう可能性があります。
$ sudo mkfs.minix /dev/ram0
以上で、ディスクの準備が完了したので、MINIX ファイルシステムをマウントしていきましょう。今回は、~/testmnt というディレクトリに MINIX ファイルシステム用に初期化した /dev/ram0 をマウントします。
$ mkdir ~/testmnt $ sudo mount /dev/ram0 ~/testmnt
これで、マウントが完了し、~/testmnt 以下のファイルは、さきほどビルドした MINIX ファイルシステムのカーネルモジュールによって管理されるようになりました。
最後に、簡単のため ~/testmnt ディレクトリ以下のファイルの所有権をユーザーのものに変えておきましょう。
$ sudo chown -R $USER:$USER ~/testmnt
マウントを解除する場合には、以下のコマンドを実行します。
$ sudo umount ~/testmnt
MINIX ファイルシステムのカーネルモジュールをアンインストールするためには、以下のコマンドを実行します。以下のコマンドは、マウントを解除した後で実行する必要があります。
$ sudo rmmod minix
ここまでで、MINIX ファイルシステムが、独立したカーネルモジュールとして利用できるようになりました。
ポイントは、Makefile しか既存のソースコードに変更を加えていない点です。Makefile を少し変更するだけで、特定の機能を切り出して改変することができるようになります。
2. MINIX ファイルシステムをハックする
これまでのステップで、MINIX ファイルシステムがカーネルモジュールとしてビルドして利用できるようになりました。
やっとここからカーネルハックを始めていきます。
今回は、ファイルシステム全体で、write システムコールを使って書き込まれたバイト数を保持するとともに、ユーザー空間側から確認できるようにしてみます。
そのような機能は、file.c を以下のように変更すると実装できます。(簡単のため並列での書き込みは想定しておりません。)
diff --git a/file.c b/file.c index c50b0a2..1c3525d 100644 --- a/file.c +++ b/file.c @@ -9,6 +9,20 @@ #include "minix.h" +#include <linux/uio.h> +#include <linux/moduleparam.h> + +static long syscall_write_bytes; +module_param(syscall_write_bytes, long, 0444); + +static ssize_t minix_file_write_iter(struct kiocb *iocb, struct iov_iter *from) +{ + syscall_write_bytes += from->count; + printk(KERN_INFO "write %ld bytes, current total %ld bytes\n", + from->count, syscall_write_bytes); + return generic_file_write_iter(iocb, from); +} + /* * We have mostly NULLs here: the current defaults are OK for * the minix filesystem. @@ -16,7 +30,7 @@ const struct file_operations minix_file_operations = { .llseek = generic_file_llseek, .read_iter = generic_file_read_iter, - .write_iter = generic_file_write_iter, + .write_iter = minix_file_write_iter, .mmap = generic_file_mmap, .fsync = generic_file_fsync, .splice_read = generic_file_splice_read,
まずは動作確認から
上記の変更を加えた後、コンパイルしなおして、再度カーネルモジュールをインストールしなおします。
以下のコマンドを、先ほど Makefile に変更を加えた MINIX ファイルシステムのソースコードの置いてあるディレクトリで実行すると、モジュールをリロードしたのち、ファイルシステムを ~/testmnt にマウントしなおします。
$ make clean $ make $ sudo umount ~/testmnt $ sudo rmmod minix $ sudo insmod minix.ko $ sudo mount /dev/ram0 ~/testmnt
動作確認は、ユーザー空間で動作するプログラムを使って行います。
以下のプログラムをコピペして writer.c として保存してください。
このプログラムは、-l で指定された長さのデータを -f で指定されたファイルへ write システムコールを使って書き込みます。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <getopt.h> int main(int argc, char* const* argv) { int ch, fd; const char *filename = NULL; long length = 0, l; ssize_t written; char buf[1024] = { 0 }; while ((ch = getopt(argc, argv, "f:l:")) != -1) { switch (ch) { case 'f': filename = optarg; break; case 'l': length = atol(optarg); break; default: printf("unknown option\n"); exit(1); } } if (!filename) { printf("please specify a file name\n"); exit(1); } if (length <= 0) { printf("please specify length\n"); exit(1); } if (sizeof(buf) < length) { printf("please specify a smaller value than %ld\n", sizeof(buf)); exit(1); } printf("write %ld bytes to %s\n", length, filename); fd = open(filename, O_CREAT | O_RDWR, 0644); if (fd < 0) { perror("open"); exit(1); } written = write(fd, buf, length); if (written < 0) { perror("write"); exit(1); } close(fd); return 0; }
次のコマンドで、上記のプログラム(writer.c)をコンパイルして、writer というバイナリを生成します。
$ gcc writer.c -o writer
やっと動作確認です。
以下のようなコマンドを実行してみてください。以下のコマンドを3つ実行すると、896 ( 128 + 256 + 512 ) バイト分 write システムコールを呼びだします。
$ ./writer -f ~/testmnt/file1.txt -l 128 $ ./writer -f ~/testmnt/file1.txt -l 256 $ ./writer -f ~/testmnt/file2.txt -l 512
このコマンドを実行した後に、dmesg を実行してみてください。以下のようなメッセージが表示されていれば、カーネル空間でバイト数がカウントされていることが確認できます。上の3つのコマンドで 896 バイト分 write システムコールを呼んでいるので、total 896 bytes と表示されているのは意図した挙動です。
他にも vim 等のエディタで書き込みをしてみると、エディタは実は手でタイプしたよりたくさん書き込んでいる様子がわかったりするかもしれません。
$ dmesg ... [ 7531.295107] write 128 bytes, current total 128 bytes [ 7534.903116] write 256 bytes, current total 384 bytes [ 7683.015261] write 512 bytes, current total 896 bytes
次に、以下のコマンドを実行してみてください。896 という数字が表示されていれば成功です。ここでは、sysfs というインターフェースを通して、ユーザー空間から、カーネル空間に保存されている値を取り出しています。
$ cat /sys/module/minix/parameters/syscall_write_bytes 896
プログラムの説明
file.c に加えた変更は主に以下の3点です。
1. カーネル空間にバイト数をカウントするための変数(syscall_write_bytes)を用意する
2. ユーザー空間から syscall_write_bytes の値を読み取れるように、module_param という仕組みを使って、sysfs に登録する
3. カーネル空間の write システムコールの入り口で、書き込みバイト数を取得して、syscall_write_bytes に加算する ( minix_file_write_iter )
sysfs について
sysfs はカーネル空間内部の情報をユーザー空間側からアクセスできるようにする手段の一つとして利用できます。
カーネルモジュールのソースコード内で、module_param という仕組みを使うと、/sys/module/モジュール名/parameters/変数名 のような特殊ファイルが生成され、変数の値にアクセスできるようになります。
file_operations 構造体について
file_operations 構造体は、カーネルの中で、ファイルに関するオブジェクトに紐づけられます。
上にあるように、メンバは、lseek や read、write システムコールのようなファイルに関連するシステムコールの実際の実装が登録されます。
この、file_operations 構造体に登録する関数によって、ファイル操作による挙動を変更できます。
例えば、Ubuntu 等でデフォルトで利用されている ext4 ファイルシステムは、ext4 ファイルシステムの独自の file_operations 構造体を実装することで、独自の挙動を実装します。別のファイルシステムである XFS は、XFS 独自の file_operations 構造体を持っています。
また、通信に用いられる socket も socket 特有の file_operations 構造体を実装しており、そのおかげでファイルデスクリプタを通して read や write ができます。
今回、変更したのは、MINIX ファイルシステムの file_operations 構造体です。
中でも、write_iter が write システムコールに対応しているので、今回は、対応するメンバの実装に変更を加えています。
他のカーネルモジュールを使う
例えば、Ubuntu では、ext4 がデフォルトのファイルシステムとして採用されていますが、ext4 をカーネルモジュールとしてビルドして実験しようとすると、既にインストールされていますと言われて insmod コマンドが失敗します。(今回、MINIX ファイルシステムを例としたのは、既存のシステムに利用されている可能性が低いと思ったからです。)
そのような場合には、ファイル名と、ファイルの中で利用されている変数名と関数名を変更すると競合を回避できます。
具体的には、ext4 であれば、ext4 と表記されているファイル名と変数名、関数名を全て、例えば、ext40 に変更すると競合することなく、独立したカーネルモジュールとしてコンパイル、インストールできます。
このように、カーネルモジュールとしてビルド、もしくはインストールできない場合は、少し手を加えると解決できることがたくさんあります。
また、どうしてもカーネルモジュールだけでは実装できない機能もありますので、その場合はカーネル自体をコンパイルしなおす必要があります。