かーねるさんとか

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

Linux カーネルハック実践入門

Linuxカーネルハックの実践的な入門方法について書いてみようと思います。

カーネルハックをすると、OS の機能を改変したり、追加したりできます。

OS の機能の例としては、スケジューラ、メモリ管理機能、ネットワークスタック、ファイルシステムデバイスドライバ等があります。

今回の記事では、具体的にどうすればカーネルの機能を改変したり、追加したりできるのかについて書いていきます。

特に今回は、カーネルモジュールを使ったカーネルハックの方法について書いていきます。

カーネルハックに関する事前知識や、カーネルモジュールとして実装ことの利点については過去の記事にまとめてありますので、よろしければそちらもご参照ください。

具体的には、既存のファイルシステムをハックして機能を追加してみようと思います。

必要な準備

1. Linux がインストールされた仮想マシン

Linuxカーネルハックを行うには、Linux がインストールされたコンピューターが必要です。

WindowsMac を利用している場合でも、仮想化ソフトウェアを利用すると、WindowsMac の上で、Linux仮想マシンを実行することができます。

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 に直接アクセスしてダウンロードすることもできます。ホスト側のブラウザを使うと、ホストにファイルがダウンロードされるので、ダウンロード完了後には、仮想マシンへファイルをコピーする必要があります。

cdn.kernel.org

ダウンロードできたら、仮想マシン内で以下のコマンドを実行して、ソースコードを展開します。

$ tar xvf linux-5.4.tar.gz

3. おまじない

カーネルモジュールをコンパイルするには、linux-headers のパッケージが必要になります。既にインストールされている場合もありますが、念のために、仮想マシンで以下のコマンドを実行しておくとよいと思います。(以下は Ubuntu 用です。)

$ sudo apt install linux-headers-$(uname -r)

最初のカーネルモジュール

動作確認も兼ねて、最小構成のカーネルモジュールをコンパイルしてインストールしてみましょう。

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

github.com

今回のカーネルモジュールの 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 カーネルに用意されている関数を適宜呼び出していく必要があります。

Makefile について

カーネルモジュールをビルドするための Makefile は御覧の通り、ユーザー空間で動作する C 言語のプログラム用の Makefile と若干異なります。

とりあえず、今回使っている Makefile を少しずつ変更して適用すると、大抵のモジュールはコンパイルできると思います。

カーネルモジュールを使ったカーネルハックを始める

最小構成のカーネルモジュールをどうやって作るのかはわかりました。次に、カーネルモジュールの仕組みを使うと何ができるのかについて見ていきます。

ここからが実践的な内容になります。

今回は、Linux カーネルソースコード内にある MINIX ファイルシステムを、独立したカーネルモジュールとしてビルドしてハックしてみます。

これを応用すると、他の種類のファイルシステムや、デバイスドライバなどの挙動を簡単に変えることができるようになります。

改変後のソースコードGitHub へ置いておきますので、よろしければご参照ください。コミットのログを見ると、どこが、どのように変更されたかわかりやすいと思います。手元に clone した場合は、git log -p とすると、詳細な変更の履歴が見られます。

github.com

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 システムコールに対応しているので、今回は、対応するメンバの実装に変更を加えています。

その他の方法

今回の実装以外にも、いろいろな実装方法が考えられます。例えば、syscall_write_bytes の値の取り出しには、キャラクタデバイスの ioctl を利用したり、sysfs でなくて procfs を使ったり等様々な方法があります。今回は、一番シンプルそうだったので上のような実装にしてみました。

他のカーネルモジュールを使う

例えば、Ubuntu では、ext4 がデフォルトのファイルシステムとして採用されていますが、ext4カーネルモジュールとしてビルドして実験しようとすると、既にインストールされていますと言われて insmod コマンドが失敗します。(今回、MINIX ファイルシステムを例としたのは、既存のシステムに利用されている可能性が低いと思ったからです。)

そのような場合には、ファイル名と、ファイルの中で利用されている変数名と関数名を変更すると競合を回避できます。

具体的には、ext4 であれば、ext4 と表記されているファイル名と変数名、関数名を全て、例えば、ext40 に変更すると競合することなく、独立したカーネルモジュールとしてコンパイル、インストールできます。

このように、カーネルモジュールとしてビルド、もしくはインストールできない場合は、少し手を加えると解決できることがたくさんあります。

また、どうしてもカーネルモジュールだけでは実装できない機能もありますので、その場合はカーネル自体をコンパイルしなおす必要があります。

まとめ

  • 疑問:どうすればカーネルの機能を改変、追加できるのか?
  • 答え:カーネルソースコードを取得して一部改変もしくは追加する
  • カーネルソースコードを改変する際のポイント:カーネルのソースの中には、Makefile を用意するとカーネルモジュールとしてビルドできるものが多くあるので、それらを独立したモジュールとしてビルドできるようにした上で開発を始めると比較的お手軽になりやすい

大部分を一から作り直すこともできますが、既存のリソースを使うほうが楽に意図した挙動を実現できる場合が多くあると思われます。