Linuxカーネルをgdbでデバッグ(またはディストリビューションのカーネルを使うときは当たってるパッチにも注意しよう)

この記事はLinux Advent Calendar 2018の1日目ですΣ(゚∀゚ノ)ノキャー

イントロ

ほんとは別の内容にしようと思ってたのですが、進めてる途中でカーネルデバッグをするハメになったのでカーネルデバッグをネタにしてみました。カーネルデバッグと言っても普通のデバッグと変わらないよね〜というところがわかると思います。(`・ω・´)<コワクナイヨー

デバッグの環境としてはlibvirt(qemu)で動いてるゲスト環境にホスト側からgdbデバッグする感じです。ディストリビューションFedora 29です。デバッグするカーネルFedoraカーネルで4.19.2-300.fc29.x86_64です。

テストコード

テストコードは↓です。これはdebugfsのディレクトリ(大概は/sys/kernel/debug/だと思います)にopen-testってファイルを作って、そのファイルを読むとhello, world!\nって読めるだけの単純なものです。

#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/debugfs.h>

MODULE_DESCRIPTION("debugfs open test module");
MODULE_AUTHOR("masami266");
MODULE_LICENSE("GPL");

#define OPEN_TEST_FILE "open-test"

static struct dentry *open_test_file;

static char open_test_file_buf[] = "hello, world!\n";

static ssize_t open_test_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
        pr_info("%s\n", __func__);
        return simple_read_from_buffer(buf, len, ppos, open_test_file_buf, sizeof(open_test_file_buf));
}

static struct file_operations open_test_fops = {
        .owner = THIS_MODULE,
        .read = open_test_read,
};

static int open_test_init(void)
{
        open_test_file = debugfs_create_file(OPEN_TEST_FILE, 0644,
                            NULL, open_test_file_buf,
                            &open_test_fops);

        if (!open_test_file) {
                pr_info("failed to create debugfs file\n");
                return -ENODEV;
        }

        pr_info("open_test module has been initialized\n");
        return 0;
}

static void open_test_exit(void)
{
        if (open_test_file)
                debugfs_remove(open_test_file);

        pr_info("good bye\n");
}

module_init(open_test_init);
module_exit(open_test_exit);

Makefileはこうです。

KERNDIR := /lib/modules/`uname -r`/build
BUILD_DIR := $(shell pwd)
VERBOSE = 0

obj-m := open_test.o
smallmod-objs := open_test.o

all:
        make -C $(KERNDIR) SUBDIRS=$(BUILD_DIR) KBUILD_VERBOSE=$(VERBOSE) modules

clean:
        rm -f *.o
        rm -f *.ko
        rm -f *.mod.c
        rm -f *~

実行してみる

これをメインラインのカーネルで実行するとこうなります。予想通りの挙動ですね〜

masami@kerntest:~/open-test$ uname -a
Linux kerntest 4.20.0-rc3-test+ #8 SMP Fri Nov 23 10:15:41 JST 2018 x86_64 x86_64 x86_64 GNU/Linux
masami@kerntest:~/open-test$ sudo insmod ./open_test.ko
masami@kerntest:~/open-test$ sudo cat /sys/kernel/debug/open-test
hello, world!

だがしかし、fedoraカーネルで実行するとエラーになります。。。これをデバッグするのが今回のネタとなります。

masami@kerntest:~/open-test$ uname -a
Linux kerntest 4.19.2-300.fc29.x86_64 #1 SMP Wed Nov 14 19:05:24 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
masami@kerntest:~/open-test$ sudo insmod ./open_test.ko
masami@kerntest:~/open-test$ sudo cat /sys/kernel/debug/open-test
cat: /sys/kernel/debug/open-test: Operation not permitted

デバッグ

状況確認

まずはdmesgを見てみます。

[  155.906829] open_test: loading out-of-tree module taints kernel.
[  155.906874] open_test: module verification failed: signature and/or required key missing - tainting kernel
[  155.907409] open_test: open_test module has been initialized

open_test_read()のpr_info()の部分は実行されていないのがわかります。そしたらstrace(1)を使ってみます。

openat(AT_FDCWD, "/sys/kernel/debug/open-test", O_RDONLY) = -1 EPERM (Operation not permitted)

straceを使うとopenat(2)でEPERMが返ってきてるのがわかります。ファイルをreadするにはopenする必要が有るから当然な感じですね。ということで、ファイルを開く処理のどこかでEPERMが返ってるわけですが、これをコードを読んでいって調べるのは大変なのでgdbでも使いましょう。

configの内容を合わせてみる

メインラインのカーネルも一旦config-4.19.2-300.fc29.x86_64を使ってビルドしてみましたが、問題ありませんでした。ということでコンフィグ的な差分では無いというのがわかりました。

デバッグの準備

gdbで接続するための準備

今回の環境ではlibvirtを使っているのでlibvirtgdbの接続ができるようにします。設定ファイルの変更ですがこれはvrishコマンドでできます。

masami@saga:~$ sudo virsh edit kerntest

エディタが立ち上がって編集可能になるので、まずは先頭の部分を以下のように変えます。

<domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>

そして、一番下にでも以下の行を追加して上書き保存してエディタを終了しましょう。

  <qemu:commandline>
    <qemu:arg value='-s'/>
  </qemu:commandline>

基本的にはこれだけでOKなんですが、デバッグされるホストのカーネルのほうでKASLRが有効になっていてリモートデバッグするときにはこのままだと適切な場所にブレークポイントを張ったりができないので、これを無効にする必要があります。詳細はこちらを参照してください。

kernhack.hatenablog.com

KASLRを無効にするのはカーネルコマンドラインでオプションを渡せばできます。起動時に毎回設定するのは面倒なのでgrubのほうでカーネルコマンドラインに追記しましょう。/etc/default/grubというファイルがgrub.cfgを作る時の設定ファイルです。fedoraの場合は以下の行がありますので、

GRUB_CMDLINE_LINUX="rhgb quiet"

こんな感じにします。

GRUB_CMDLINE_LINUX="rhgb quiet nokaslr"

そして、sudo grub2-mkconfig -o /boot/grub2/grub.cfgな感じでgrubの設定ファイルを作り直してから再起動しましょう。 これでtcpポート1234を使ってリモートでバッグできるようになりました。

デバッグ用のvmlinuxとソースの準備

デバッグするのにデバッグシンボルがないと不便ですし、ソースもみたいですよね。ということでこれも用意しましょう。これはgdbを実行するホストのほうで必要になります。 デバッグシンボル付きのvmlinuxはkernel-debuginfoパッケージでインストールすることができます。ホストとゲストで同じfedoraを使ってるならホスト側でsudo dnf debuginfo-install -y kernelを実行しても良いですね。もしくはゲスト側でインストールしてvmlinuxをscp等でダウンロードするのも有りです。vmlinuxが有る場所は以下の場所です。

/usr/lib/debug/lib/modules/4.19.2-300.fc29.x86_64/vmlinux

ソールはkernel-debuginfo-commonパッケージにあります。なのでsudo dnf debuginfo-install -y kernelを実行すればvmlinuxとソースの両方がインストールできます。ソースは以下の場所にインストールされます。

/usr/src/debug/kernel-4.19.fc29/linux-4.19.2-300.fc29.x86_64/

自分はゲストのほうでパッケージをインストールしてvmlinuxとソースコードディレクトリはscpでホスト側に持っていきました。

これでgdbを使う準備は完了です。

gdbで動作を追う

gdbを使うにしてもブレークポイントを設定する場所とかは確認したいので、まずはopenat()を見てみます。

SYSCALL_DEFINE4(openat, int, dfd, const char __user *, filename, int, flags,
        umode_t, mode)
{
    if (force_o_largefile())
        flags |= O_LARGEFILE;

    return do_sys_open(dfd, filename, flags, mode);
}

do_sys_open()が呼ばれているのでこちらにブレークポイントを張って追いかけていきましょう。しかし、ファイルのオープンは色んな所で行われるため条件付きのブレークポイントにしないと面倒です。引数のfilenameが開きたいファイル名なのでこれを条件にしましょう。組み込み関数の$_streq()を使うと文字列の比較ができます。

gdbを起動するときのソースコードのパスも一緒に指定しました。

masami@saga:~$ gdb --directory=./linux-4.19.2-300.fc29.x86_64/ ./vmlinux  
Reading symbols from ./vmlinux...done.
gdb-peda$ 

そしてリモート接続します。あ、その前に別のターミナルからssh接続してモジュールのロードまで済ませて置きましょう。

gdb-peda$ target remote :1234
Remote debugging using :1234
Warning: not running or target is remote
0xffffffff819294a2 in native_safe_halt () at ./arch/x86/include/asm/irqflags.h:57
warning: Source file is more recent than executable.
57              asm volatile("sti; hlt": : :"memory");

ブレークポイントをセットしたら実行を再開させましょう。

gdb-peda$ b do_sys_open if $_streq(filename, "/sys/kernel/debug/open-test") == 1
Breakpoint 1 at 0xffffffff812ad830: file fs/open.c, line 1049.
gdb-peda$ c
Continuing.

以下のようにアクセスできない〜って言われた場合は無視してcを押して処理を継続しましょう。

Thread 1 hit Breakpoint 1, do_sys_open (dfd=0xffffff9c, filename=0x7f55d9b808b0 <error: Cannot access memory at address 0x7f55d9b808b0>, flags=0x88000, mode=0x0) at fs/open.c:1049
1049    {

これが設定した条件に引っかった時です。

Thread 1 hit Breakpoint 1, do_sys_open (dfd=0xffffff9c, filename=0x7ffe8f308807 "/sys/kernel/debug/open-test", flags=0x8000, mode=0x0) at fs/open.c:1049
1049    {
gdb-peda$ 

ここからは普通のgdbの使い方と一緒でnextとかstepを使ってどこでEPERMが返ってくるか調べていくとfull_proxy_open()がEPERMを返すことが分かりました。そんなわけでブレークポイントの条件をこちらに変えてもokです。

b full_proxy_open if $_streq(filp->f_path->dentry->d_iname, "open-test") == 1

良いところで止まったら、ゆっくり見ていきましょう。

gdb-peda$ c
Continuing.
[Switching to Thread 1]
Warning: not running or target is remote

Thread 1 hit Breakpoint 1, full_proxy_open (inode=inode@entry=0xffff8802261744b0, filp=filp@entry=0xffff88022fe10100) at fs/debugfs/file.c:288
warning: Source file is more recent than executable.
288     {

debugfs_file_get()は成功したようですね。

Warning: not running or target is remote
294             r = debugfs_file_get(dentry);
gdb-peda$ n
Warning: not running or target is remote
295             if (r)
gdb-peda$ p r
$1 = 0x0

しばらく先に進んでopen()が設定されているか調べていて、テストプログラムではreadしか設定していないのでここはNULLになってますね。

320             if (real_fops->open) {
gdb-peda$ p real_fops->open
$2 = (int (*)(struct inode *, struct file *)) 0x0 <irq_stack_union>

実行を続けるとdentryを片付けてエラーコード-1が返ってます。

gdb-peda$ n
Warning: not running or target is remote
338             debugfs_file_put(dentry);
gdb-peda$ n
Warning: not running or target is remote
339             return r;
gdb-peda$ p r
$3 = 0xffffffff

nで実行を継続していったときはrを最後に確認したのはdebugfs_file_get()の実行時なのでその後はgdb上では出てきてません(´・ω・`) ということでソースを見てみましょう。

static int full_proxy_open(struct inode *inode, struct file *filp)
{
    struct dentry *dentry = F_DENTRY(filp);
    const struct file_operations *real_fops = NULL;
    struct file_operations *proxy_fops = NULL;
    int r;

    r = debugfs_file_get(dentry);
    if (r)
        return r == -EIO ? -ENOENT : r;

    real_fops = debugfs_real_fops(filp);
    r = -EPERM;
    if (debugfs_is_locked_down(inode, filp, real_fops))
        goto out;

    real_fops = fops_get(real_fops);
    if (!real_fops) {
        /* Huh? Module did not cleanup after itself at exit? */
        WARN(1, "debugfs file owner did not clean up at exit: %pd",
            dentry);
        r = -ENXIO;
        goto out;
    }

    proxy_fops = kzalloc(sizeof(*proxy_fops), GFP_KERNEL);
    if (!proxy_fops) {
        r = -ENOMEM;
        goto free_proxy;
    }
    __full_proxy_fops_init(proxy_fops, real_fops);
    replace_fops(filp, proxy_fops);

    if (real_fops->open) {
        r = real_fops->open(inode, filp);
        if (r) {
            replace_fops(filp, d_inode(dentry)->i_fop);
            goto free_proxy;
        } else if (filp->f_op != proxy_fops) {
            /* No protection against file removal anymore. */
            WARN(1, "debugfs file owner replaced proxy fops: %pd",
                dentry);
            goto free_proxy;
        }
    }

    goto out;
free_proxy:
    kfree(proxy_fops);
    fops_put(real_fops);
out:
    debugfs_file_put(dentry);
    return r;
}

debugfs_is_locked_down()の呼び出し前に以下の処理がありますね。ということは、real_fops->openがNULLの場合はrはそのままなので-1が返るってのがわかります。

r = -EPERM;

しかし、メインラインのカーネルではエラーにならなかったんですが??? まあ、メインラインのコードも見るしか無いですよね。ということで、4.19.2のfs/debugfs/file.cを見てみましょう。

static int full_proxy_open(struct inode *inode, struct file *filp)
{
    struct dentry *dentry = F_DENTRY(filp);
    const struct file_operations *real_fops = NULL;
    struct file_operations *proxy_fops = NULL;
    int r;

    r = debugfs_file_get(dentry);
    if (r)
        return r == -EIO ? -ENOENT : r;

    real_fops = debugfs_real_fops(filp);
    real_fops = fops_get(real_fops);
    if (!real_fops) {
        /* Huh? Module did not cleanup after itself at exit? */
        WARN(1, "debugfs file owner did not clean up at exit: %pd",
            dentry);
        r = -ENXIO;
        goto out;
    }

    proxy_fops = kzalloc(sizeof(*proxy_fops), GFP_KERNEL);
    if (!proxy_fops) {
        r = -ENOMEM;
        goto free_proxy;
    }
    __full_proxy_fops_init(proxy_fops, real_fops);
    replace_fops(filp, proxy_fops);

    if (real_fops->open) {
        r = real_fops->open(inode, filp);
        if (r) {
            replace_fops(filp, d_inode(dentry)->i_fop);
            goto free_proxy;
        } else if (filp->f_op != proxy_fops) {
            /* No protection against file removal anymore. */
            WARN(1, "debugfs file owner replaced proxy fops: %pd",
                dentry);
            goto free_proxy;
        }
    }

    goto out;
free_proxy:
    kfree(proxy_fops);
    fops_put(real_fops);
out:
    debugfs_file_put(dentry);
    return r;
}

差が有りますね。。。これが差分です。

+        r = -EPERM;
+        if (debugfs_is_locked_down(inode, filp, real_fops))
+                goto out;
+

fedoraカーネルではreal_fops->openが設定されていないと -EPERMが返る仕様になっています。ということはディストリビューション側でパッチを当ててますよね。ってことで調べてみましょう。fedoraカーネルのgitリポジトリこちらです。カーネルと言うかカーネルパッケージのgitリポジトリです。

ブランチをf29に変えてパッチを調べるとefi-lockdown.patchに該当のコードが見つかります。このパッチファイルの1560行目からが該当する部分です。まさにありますね。

 
@@ -272,6 +296,10 @@ static int full_proxy_open(struct inode *inode, struct file *filp)
        return r == -EIO ? -ENOENT : r;
 
    real_fops = debugfs_real_fops(filp);
+   r = -EPERM;
+   if (debugfs_is_locked_down(inode, filp, real_fops))
+       goto out;
+

ここまで分かったのでテストプログラムを修正しましょう。

修正

file_operations構造体のopen()を設定します。openの処理自体には特別な処理は不要なのでlinux/fs.hで宣言されているsimple_open()を使いましょう。

        .open = simple_open,

これで実行するとちゃんと動きました(∩´∀`)∩ワーイ

masami@kerntest:~/open-test$ sudo insmod ./open_test.ko
masami@kerntest:~/open-test$ uname -a
Linux kerntest 4.19.2-300.fc29.x86_64 #1 SMP Wed Nov 14 19:05:24 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
masami@kerntest:~/open-test$ sudo cat /sys/kernel/debug/open-test
hello, world!
masami@kerntest:~/open-test$ 

もちろんメインラインのカーネルでも動きますヽ(=´▽`=)ノ

masami@kerntest:~/open-test$ sudo insmod ./open_test.ko
masami@kerntest:~/open-test$ uname -a
Linux kerntest 4.20.0-rc3-test+ #8 SMP Fri Nov 23 10:15:41 JST 2018 x86_64 x86_64 x86_64 GNU/Linux
masami@kerntest:~/open-test$ sudo cat /sys/kernel/debug/open-test
hello, world!
masami@kerntest:~/open-test$ 

まとめ

gdbを使ったカーネルデバッグを説明しました。カーネルと言っても普通のcプログラムと変わらずにデバッグできるし、こわくないよーという感じではないでしょうか。 あと、ディストリビューションカーネルを使っているときはもしかしたらメインラインに入っていない機能が有る場合も有るので、使われているパッチにも気をつけないといけないですね。。。

( ´ー`)フゥー...