Tiny Core LinuxでLinuxのinitプロセスが実行されるあたりを調べる

この記事はLinux Advent Calendar 2020 - Qiitaの1日目の記事です。

Tiny Core Linux(以下tcl)を使ってLinuxのブートプロセスを見てましょう。Tiny Core Linuxは軽量ディストリビューションで最小のisoイメージだと11MBほどです😃 ブートプロセスを見ると言っても電源onからの流れではなくてinitプロセスの実行に関する部分です。

この記事ではバージョン11.1のCore-current.iso を利用しています。

www.tinycorelinux.net

isoファイルの構成

まずはtclのisoがどんな感じで構成されていて、Linuxを起動させるのか確認しましょう。 isoファイルの構成はこのようになっています。

$ sudo mount -o loop Core-current.iso ./mnt
$ tree ./mnt/
./mnt/
└── boot
    ├── core.gz
    ├── isolinux
    │   ├── boot.cat
    │   ├── boot.msg
    │   ├── f2
    │   ├── f3
    │   ├── f4
    │   ├── isolinux.bin
    │   └── isolinux.cfg
    └── vmlinuz

2 directories, 9 files

vmlinuzはカーネル、core.gzはinitramfsのファイル、そして起動にはisolinuxを使っているのでそのファイル類が含まれています。

起動の設定はisolinux.cfgに書かれています。

display boot.msg
default microcore
label microcore
        kernel /boot/vmlinuz
        initrd /boot/core.gz
        append loglevel=3

label mc
        kernel /boot/vmlinuz
        append initrd=/boot/core.gz loglevel=3
implicit 0
prompt 1
timeout 300
F1 boot.msg
F2 f2
F3 f3
F4 f4

Tiny Core Linuxnのinitramfsの中身

今回はisoファイルの内容を作業用のディレクトリを作ってそこでファイルを読んだりしていきます。

$ mkdir tcl-work
$ cp -a mnt/boot/ tcl-work/.

core.gzの中も展開します。core.gzはcpioファイルをgzipで圧縮したものなので展開してからcpioコマンドで中身を取り出します。

$ cd tcl-work
$ mkdir core-work
$ cd core-work
$ zcat ../../mnt/boot/core.gz | sudo cpio -i -H newc -dv

展開するとこのようになります。

$ ls -la
total 72
drwxrwxr-x. 17 masami masami 4096 Nov 22 10:30 ./
drwxrwxr-x.  4 masami masami 4096 Nov 22 10:30 ../
drwxr-xr-x.  2 masami masami 4096 Nov 22 10:30 bin/
drwxrwxr-x.  7 masami masami 4096 Nov 22 10:30 dev/
drwxr-xr-x.  8 masami masami 4096 Nov 22 10:30 etc/
drwxrwxr-x.  2 masami masami 4096 Nov 22 10:30 home/
-rwxr-xr-x.  1 masami masami  496 Nov 22 10:30 init*
drwxr-xr-x.  4 masami masami 4096 Nov 22 10:30 lib/
lrwxrwxrwx.  1 masami masami   11 Nov 22 10:30 linuxrc -> bin/busybox*
drwxrwxr-x.  2 masami masami 4096 Nov 22 10:30 mnt/
drwxrwsr-x.  2 masami masami 4096 Nov 22 10:30 opt/
drwxrwxr-x.  2 masami masami 4096 Nov 22 10:30 proc/
drwxrwxr-x.  2 masami masami 4096 Nov 22 10:30 root/
drwxrwxr-x.  3 masami masami 4096 Nov 22 10:30 run/
drwxr-xr-x.  2 masami masami 4096 Nov 22 10:30 sbin/
drwxrwxr-x.  2 masami masami 4096 Nov 22 10:30 sys/
drwxrwxrwt.  2 masami masami 4096 Nov 22 10:30 tmp/
drwxr-xr-x.  7 masami masami 4096 Nov 22 10:30 usr/
drwxrwxr-x.  8 masami masami 4096 Nov 22 10:30 var/

システムの起動に関連しそうなファイルとして /linuxrc/initがありますね。linuxrcはbusyboxコマンドへのリンクで/initはシェルスクリプトになっています。このファイルの内容は後で見てみます。

ちなみにfedora 33のinitramfsはこのようになっていて/linuxrcはありません。

$ /usr/lib/dracut/skipcpio ./initramfs-5.9.9-200.fc33.x86_64.img | zcat | cpio -idv
$ ls -la
total 32680
drwxrwxr-x. 12 masami masami     4096 Nov 22 02:07 ./
drwxrwxr-x.  5 masami masami     4096 Nov 22 12:45 ../
lrwxrwxrwx.  1 masami masami        7 Nov 22 02:07 bin -> usr/bin/
drwxr-xr-x.  2 masami masami     4096 Nov 22 02:07 dev/
drwxr-xr-x. 11 masami masami     4096 Nov 22 02:07 etc/
lrwxrwxrwx.  1 masami masami       23 Nov 22 02:07 init -> usr/lib/systemd/systemd*
lrwxrwxrwx.  1 masami masami        7 Nov 22 02:07 lib -> usr/lib/
lrwxrwxrwx.  1 masami masami        9 Nov 22 02:07 lib64 -> usr/lib64/
drwxr-xr-x.  2 masami masami     4096 Nov 22 02:07 proc/
drwxr-xr-x.  2 masami masami     4096 Nov 22 02:07 root/
drwxr-xr-x.  2 masami masami     4096 Nov 22 02:07 run/
lrwxrwxrwx.  1 masami masami        8 Nov 22 02:07 sbin -> usr/sbin/
-rwxr-xr-x.  1 masami masami     3418 Nov 22 02:07 shutdown*
drwxr-xr-x.  2 masami masami     4096 Nov 22 02:07 sys/
drwxr-xr-x.  2 masami masami     4096 Nov 22 02:07 sysroot/
drwxr-xr-x.  2 masami masami     4096 Nov 22 02:07 tmp/
drwxr-xr-x.  8 masami masami     4096 Nov 22 02:07 usr/
drwxr-xr-x.  3 masami masami     4096 Nov 22 02:07 var/

では/linuxrcと/initはどのように使われるのでしょうか。/initや/linuxrcが利用される仕組みはEarly userspace support — The Linux Kernel documentationに説明があります。tclではinitramfsが利用されてます。この場合、以下の部分が該当します。/initが必須ということですね。

using initramfs. The call to prepare_namespace() must be skipped. This means that a binary must do all the work. 
Said binary can be stored into initramfs either via modifying usr/gen_init_cpio.c or via the new initrd format, an cpio archive. It must be called “/init”. 
This binary is responsible to do all the things prepare_namespace() would do.

起動時のログからもinitramfsが利用されていることがわかります。

tc@box:~$ dmesg | grep initramfs
Trying to unpack rootfs image as initramfs...

Tiny Linux Coreで起動シーケンスの確認

ではこれらの動作を確認してみましょう。 と、その前にqemuGUIを立ち上げるとコンソール出力が見にくいのでシリアルコンソールを有効にしてしまいます。 編集するのはisolinux.confです。このファイルにラベルは2つありますがデフォルトはmicrocoreのほうなのでこちらに設定しましょう。console=ttyS0,115200をappendの行に追加します。 ついでなのでログレベルも最大にしてしまいます。そうするとこのような感じになります。

append loglevel=7 console=ttyS0,115200

つぎにinitramfsのファイルを弄りましょう。シリアルコンソールの設定をetc/inittabに追加します。

ttyS0::respawn:/sbin/getty -nl /sbin/autologin 115200 ttyS0 vt102

そうしたら本題の/iniitと/linuxrcをrenameしてみます。

-rwxr-xr-x.  1 root   root    496 Nov 22 12:14 init.bk*
drwxr-xr-x.  4 root   root   4096 Nov 22 12:14 lib/
lrwxrwxrwx.  1 root   root     11 Nov 22 12:14 linuxrc.bk -> bin/busybox*

core.gzを作成しましょう。

$ sudo find | sudo cpio -o -H newc | gzip -2 > ../boot/core.gz

これで新しいintramfsのイメージもできたのでisoイメージを作成します。 実行はtcl-workディレクトリの1つ上の場所で行います。

$ sudo mkisofs -l -J -R -r -V TC-custom -no-emul-boot -boot-load-size 4 \
 -boot-info-table -b boot/isolinux/isolinux.bin \
 -c boot/isolinux/boot.cat -o test.iso tcl-work/

これでisoファイルが出来たのでqemuで起動します。

$ qemu-system-x86_64 -boot d -cdrom test.iso -m 512 -nographic

見事にカーネルパニックします😃

Floppy drive(s): fd0 is 2.88M AMI BIOS
FDC 0 is a S82078B
blk_update_request: I/O error, dev fd0, sector 0 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
floppy: error 10 while reading block 0
VFS: Cannot open root device "(null)" or unknown-block(2,0): error -6
Please append a correct "root=" boot option; here are the available partitions:
0100            8192 ram0 
 (driver?)
0101            8192 ram1 
 (driver?)
0102            8192 ram2 
 (driver?)
0103            8192 ram3 
 (driver?)
0104            8192 ram4 
 (driver?)
0105            8192 ram5 
 (driver?)
0106            8192 ram6 
 (driver?)
0107            8192 ram7 
 (driver?)
0b00         1048575 sr0 
 driver: sr
0200               4 fd0 
 driver: floppy
Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(2,0)
Kernel Offset: disabled
Rebooting in 60 seconds..

まずは/linuxrcの名前だけ戻して起動させます。

VFS: Cannot open root device "(null)" or unknown-block(2,0): error -6
Please append a correct "root=" boot option; here are the available partitions:
0100            8192 ram0 
 (driver?)
0101            8192 ram1 
 (driver?)
0102            8192 ram2 
 (driver?)
0103            8192 ram3 
 (driver?)
0104            8192 ram4 
 (driver?)
0105            8192 ram5 
 (driver?)
0106            8192 ram6 
 (driver?)
0107            8192 ram7 
 (driver?)
0b00         1048575 sr0 
 driver: sr
0200               4 fd0 
 driver: floppy
Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(2,0)
Kernel Offset: disabled

そうするとカーネルパニックとなりました。ではlinuxrcの名前は変更した状態で、/initのほうは名前を戻して起動してみましょう。

   ( '>')
  /) TC (\   Core is distributed with ABSOLUTELY NO WARRANTY.
 (/-_--_-\)           www.tinycorelinux.net

tc@box:~$ e1000 0000:00:03.0 eth0: (PCI:33MHz:32-bit) 52:54:00:12:34:56
e1000 0000:00:03.0 eth0: Intel(R) PRO/1000 Network Connection
e1000: eth0 NIC Link is Up 1000 Mbps Full Duplex, Flow Control: RX

tc@box:~$ ls -la /
total 4
drwxrwxr-x   17 1000     1000           380 Nov 22 05:28 ./
drwxrwxr-x   17 1000     1000           380 Nov 22 05:28 ../
drwxr-xr-x    2 root     root          1380 Nov 22 03:14 bin/
drwxrwxr-x   12 root     staff         4260 Nov 22 05:32 dev/
drwxr-xr-x    8 root     root           680 Nov 22 05:32 etc/
drwxrwxr-x    3 root     staff           60 Nov 22 05:32 home/
-rwxr-xr-x    1 root     root           496 Nov 22 03:14 init
drwxr-xr-x    4 root     root           800 Nov 22 03:14 lib/
lrwxrwxrwx    1 root     root            11 Nov 22 03:14 linuxrc.bk -> bin/busybox
drwxrwxr-x    4 root     staff           80 Nov 22 05:32 mnt/
drwxrwsr-x    2 root     staff          160 Nov 22 03:14 opt/
dr-xr-xr-x   64 root     root             0 Nov 22 05:32 proc/
drwxrwxr-x    2 root     staff           80 Nov 22 03:14 root/
drwxrwxr-x    4 root     staff           80 Nov 22 05:32 run/
drwxr-xr-x    2 root     root          1200 Nov 22 03:14 sbin/
dr-xr-xr-x   12 root     root             0 Nov 22 05:32 sys/
drwxrwxrwt    4 root     staff          120 Nov 22 05:32 tmp/
drwxr-xr-x    7 root     root           140 Nov 22 03:14 usr/
drwxrwxr-x    8 root     staff          180 Nov 22 03:14 var/

今度は起動します。ということでinitramfsを利用する環境では/linuxrcなくてもOKというのが実際の動作からわかりました。

/initの処理

/initは次のような短いシェルスクリプトです。

#!/bin/sh
mount proc
grep -qw multivt /proc/cmdline && sed -i s/^#tty/tty/ /etc/inittab
if ! grep -qw noembed /proc/cmdline; then

  inodes=`grep MemFree /proc/meminfo | awk '{print $2/3}' | cut -d. -f1`

  mount / -o remount,size=90%,nr_inodes=$inodes
  umount proc
  exec /sbin/init
fi
umount proc
if mount -t tmpfs -o size=90% tmpfs /mnt; then
  if tar -C / --exclude=mnt -cf - . | tar -C /mnt/ -xf - ; then
    mkdir /mnt/mnt
    exec /sbin/switch_root mnt /sbin/init
  fi
fi
exec /sbin/init

execコマンドの実行箇所が3箇所ありますが、今回の環境では最初のifブロック部分が実行されます。tclの環境でmountコマンドの出力を見るとこんなふうになってます。

tc@box:~$ mount
rootfs on / type rootfs (rw,size=400480k,nr_inodes=141441)
proc on /proc type proc (rw,relatime)
sysfs on /sys type sysfs (rw,relatime)
devpts on /dev/pts type devpts (rw,relatime,mode=600,ptmxmode=000)
tmpfs on /dev/shm type tmpfs (rw,relatime)
fusectl on /sys/fs/fuse/connections type fusectl (rw,relatime)

この環境では搭載されているメモリサイズに応じたinode数やメモリの何%をファイルシステム容量上限とするかを設定した上でルートファイルシステムを再マウントし直しています。そして/sbin/initを実行します。

先に引用したinitramfsnの説明文で次のような一文がありました。

This binary is responsible to do all the things prepare_namespace() would do.

ここでのnamespaceはコンテナの文脈で使うnamepspaceとは別のものです。prepare_namespace()はルートファイルシステムをマウントしてそこにchrootする処理を行います。initramfsを使う場合はその処理は/initで行ってねということです。

カーネルの動作

起動処理において/initが必要というのはわかりましたが、カーネルからはどう動くのか?というのを見ていきます。tclの11.1ではカーネル5.4.3が使われているのでこのバージョンで確認しましょう。/initを実行するのはinit/main.cのrun_init_process()にて行います。このときの関数の呼ばれ方は次のようになります。

  1. start_kernel()
  2. arch_call_rest_init()
  3. rest_init()
  4. kernel_init()
  5. run_init_process()

start_kernel()の一番最後でarch_call_rest_init()を呼び出して流れていきます。arch_call_rest_init()は名前からしアーキテクチャ毎に実装しする感じですが、s390以外は独自の実装はなくてinit/main.cにあるarch_call_rest_init()を利用しています。

run_init_process()は次のように実行します。

 if (ramdisk_execute_command) {
        ret = run_init_process(ramdisk_execute_command);
        if (!ret)
            return 0;
        pr_err("Failed to execute %s (error %d)\n",
               ramdisk_execute_command, ret);
    }

ramdisk_execute_commandはkernel_init_freeable()にて次のように/initを設定します。

 if (!ramdisk_execute_command)
        ramdisk_execute_command = "/init";

ramdisk_execute_commandのデフォルト値はNULLですが、カーネルコマンドラインパラメータでrdinit=で指定することができます。

static int __init rdinit_setup(char *str)
{
    unsigned int i;

    ramdisk_execute_command = str;
    /* See "auto" comment in init_setup */
    for (i = 1; i < MAX_INIT_ARGS; i++)
        argv_init[i] = NULL;
    return 1;
}
__setup("rdinit=", rdinit_setup);

特に指定がなければ/initが使われます。ということでinitramfsに/initがないとエラーになるのはこれってことがわかります。/initの実行に成功したら0を返してkernel_init()の処理は終了です。 /initは/sbin/initを実行するので本当のinit処理は/initから実行される感じです。

kdump: fedora系カーネルとupstreamカーネルの微妙な差

kdumpで利用するcrashkernelパラメータに渡せる値はfedoraカーネルとupstreamカーネルの微妙な差があるので自分でビルドしたカーネルを使う場合は気をつけろという自分へのメモです。

kdumpをfedorarhelなどでdumpを利用する場合、crashkernelに渡す値にautoが利用できます。rhel8のドキュメント(How should the crashkernel parameter be configured for using kdump on Red Hat Enterprise Linux 8 ? - Red Hat Customer Portal)だと通常はautoを指定するのを勧めてますね。autoを指定するとcrashkernelに指定するメモリ量をよしなに設定しれくれます。それってどこでやってんの?ということですが、fedoraの場合は0001-kdump-add-support-for-crashkernel-auto.patchが処理してます。このパッチの中の↓の部分がautoを指定された場合の処理です。

diff --git a/kernel/crash_core.c b/kernel/crash_core.c
index d631d22089ba..c252221b2f4b 100644
--- a/kernel/crash_core.c
+++ b/kernel/crash_core.c
@@ -258,6 +258,20 @@ static int __init __parse_crashkernel(char *cmdline,
    if (suffix)
        return parse_crashkernel_suffix(ck_cmdline, crash_size,
                suffix);
+
+   if (strncmp(ck_cmdline, "auto", 4) == 0) {
+#ifdef CONFIG_X86_64
+       ck_cmdline = "1G-64G:160M,64G-1T:256M,1T-:512M";
+#elif defined(CONFIG_S390)
+       ck_cmdline = "4G-64G:160M,64G-1T:256M,1T-:512M";
+#elif defined(CONFIG_ARM64)
+       ck_cmdline = "2G-:512M";
+#elif defined(CONFIG_PPC64)
+       ck_cmdline = "2G-4G:384M,4G-16G:512M,16G-64G:1G,64G-128G:2G,128G-:4G";
+#endif
+       pr_info("Using crashkernel=auto, the size choosed is a best effort estimation.\n");
+   }
+
    /*
    * if the commandline contains a ':', then that's the extended
    * syntax -- if not, it must be the classic syntax

見ての通りコマンドラインの内容を書き換えてるだけですね。設定内容はドキュメントにも追加されてます。

diff --git a/Documentation/admin-guide/kdump/kdump.rst b/Documentation/admin-guide/kdump/kdump.rst
index 2da65fef2a1c..d53a524f80f0 100644
--- a/Documentation/admin-guide/kdump/kdump.rst
+++ b/Documentation/admin-guide/kdump/kdump.rst
@@ -285,6 +285,17 @@ This would mean:
     2) if the RAM size is between 512M and 2G (exclusive), then reserve 64M
     3) if the RAM size is larger than 2G, then reserve 128M

+Or you can use crashkernel=auto if you have enough memory.  The threshold
+is 2G on x86_64, arm64, ppc64 and ppc64le. The threshold is 4G for s390x.
+If your system memory is less than the threshold crashkernel=auto will not
+reserve memory.
+
+The automatically reserved memory size varies based on architecture.
+The size changes according to system memory size like below:
+    x86_64: 1G-64G:160M,64G-1T:256M,1T-:512M
+    s390x:  4G-64G:160M,64G-1T:256M,1T-:512M
+    arm64:  2G-:512M
+    ppc64:  2G-4G:384M,4G-16G:512M,16G-64G:1G,64G-128G:2G,128G-:4G


 Boot into System Kernel

upstreamカーネルの場合はautoは当然解釈されないので適切にメモリ量を設定できません😨 ということで、利用したい機能がどこからきているか調べないとハマる場合があるということでした。

Linux Advent Calendar 2019で書いた「unameコマンドから始めるデバッグ&カーネルハック入門」を電子書籍化しました

Linux Advent Calendar 2019でunameコマンドから始めるデバッグカーネルハック入門という記事を書きました。

kernhack.hatenablog.com

このときはFedora 31を実験環境として選んで書いたんですが、環境をCentOS 8にして内容を加筆修正して電子書籍にしました。

masami256.booth.pm

目次はこんな感じです。

f:id:masami256:20200311224120p:plain

strace、gdbbcc、bpftrace、カーネルハック、Livepatch、systemtapunameコマンドと戯れる内容です。

BOOTH様便利ですね👍

booth.pm

Linux起動時のカーネルパニック(at ring_buffer_set_clock())修正めも

機種依存ですが、fedoraで5.4系のカーネルで起動時にカーネルパニックするようになり、自分もこのバグに当たったので修正してパッチをlkmlに投稿しました。 このパッチはLinux5.5.に取り込まれました🎉

github.com

ついでなのでどんな感じでデバッグしたかの記録です。10日前くらいの話だし、デバッグ時の思考の時系列は多少違う気もしますがだいたいこんな感じということで。

環境

こんな感じです。

バグの内容

自分がこのバグに当たったときにはfedoraのbugzillaにすでにバグ登録されてました

bugzilla.redhat.com

5.4系のカーネルだと起動時にカーネルパニックする☠という内容です。実際こんな感じでした。

f:id:masami256:20200127191203j:plain
oops

ただ、自分が普段使ってるデスクトップPCでも同じカーネルを使ってますがこちらでは同様の現象は起きてませんでした。ということで機種依存っぽい感じがします。そして、bugzillaを見てると共通点が見えてきました。 それは次の二点です。

  • Ryzenが載ってるThinkPadを使っている
  • Secure Bootが有効

暫定対策

上に貼り付けたoopsはカーネルコマンドラインオプションにquietが付いているので表示されていないですが、quietを外すと↓のprintk()の部分が表示されました。

 /* sched_clock_stable() is determined in late_initcall */
    if (!trace_boot_clock && !sched_clock_stable()) {
        printk(KERN_WARNING
               "Unstable clock detected, switching default tracing clock to \"global\"\n"
               "If you want to keep using the local clock, then add:\n"
               "  \"trace_clock=local\"\n"
               "on the kernel command line\n");
        tracing_set_clock(&global_trace, "global");
    }

ってことで、これを試すと起動成功したのでbugzillaにtrace_clock=locakを付けたら大丈夫だったよとコメントしました。また、他の人はSecure bootを無効にすることで対応したりもしてました。

原因を探す

NULL pointer dereference の原因箇所

ring_buffer_set_clock()はこんな関数です。

void ring_buffer_set_clock(struct ring_buffer *buffer,
               u64 (*clock)(void))
{
    buffer->clock = clock;
}

どこでエラーになったか一目瞭然ですね。

この関数に至る流れはtracing_set_default_clock()から始まり、 tracing_set_clock() -> ring_buffer_set_clock()となります。

初期化されていない状態でring_buffer_set_clock()が呼ばれてる感じですね。

ちなみにここで変更しようとしているclockは/sys/kernel/debug/tracing/trace_clockとして見えます。

masami@moon:~$ sudo cat  /sys/kernel/debug/tracing/trace_clock
[local] global counter uptime perf mono mono_raw boot x86-tsc

Unstable clock detected?

5.3系では問題なかったので5.4系からのバグではあるのですが、まず以下の部分でUnstable clock detectedとなるのは5.3ではどうだったのか?というのを調べます。trace_boot_clockはtrace_clockオプションなので普段は設定してないので気にしません。

 if (!trace_boot_clock && !sched_clock_stable()) {

調べるといっても、5.3系カーネルの起動時にquietオプションを外すだけですが。そして結果はどうだったかと言うと5.3系でも5.4.7でも同じパスを通っていました。このPCはUnstable clock detectedと判断されるようですね。デスクトップPC(cpuはi7-9700K)のほうはこのパスは通ってませんでした。

upstraem / 他のディストリビューションの様子

とくに同じようなエラーが出てる感じはありませんでした。

ここまでのまとめ

以下の条件に当てはまる人がバグに当たっている感じです。

回避策はtrace_clock=localを設定するかSecure Bootを無効にする。

原因特定のためのデバッグ

Secure Boot環境で動くカーネルを作る方法を調べる

Secure Bootを無効にしちゃったら再現しないので自前でビルドしたカーネルへの署名方法を調べます。調べた結果は↓にまとめました。

kernhack.hatenablog.com

upstreamカーネルを試す

まずはバージョン同じものを使って、カーネルのコンフィグはfedoraの5.4.7のコンフィグを使います。ビルドしてこのカーネルから起動してみると無事に起動しました。ということで、次の手順に進みます。

fedora固有のパッチを調べる

調べると言うかパッチを一旦外せるだけ外してから足していくとかそんな感じですね。fedoraカーネルパッケージのソース一式はgitリポジトリにあるのでクローンしてきて、f31ブランチからブランチを切手作業していきます。この時点でf31ブランチは5.4.12カーネルになっていました。

src.fedoraproject.org

手順的にはspecファイルでパッチをコメントアウトして、fedpkgでsrpmを作ります。そして、必要最小限のパッケージだけ作りたかったので出来たsrpmをインストールしてrpmbuildでビルドするという方法を取りました。fedpkgで同じようなことができるんでしょうか? それはともかくrpmbuildはこんなオプションでやりました。

$ rpmbuild -bb --with baseonly --without debuginfo --target=$(uname -m) kernel.spec

arm64-Add-option-of-13-for-FORCE_MAX_ZONEORDER.patchのように外すと他に直さなきゃいけないところが出てくるパッチもあったりするのですが、まあこの辺は今回のバグに関係ないだろってことでこういうパッチは外さずに進めました。そして、efi-secureboot.patchを外せば問題ないというところまで判明しました。

efi-secureboot.patchの内容を調べる

efi-secureboot.patchLinux 5.4から入ったlockdownに関係するパッチです。

lockdownについてはこちらを参照してもらうとして。。

kernelnewbies.org

このパッチの内容は大きく分けると

最後のCONFIG_LOCK_DOWN_IN_EFI_SECURE_BOOTの値に応じて処理してる部分は以下のifdefのところです。

diff --git a/arch/x86/kernel/setup.c b/arch/x86/kernel/setup.c
index 77ea96b794bd..a119e1bc9623 100644
--- a/arch/x86/kernel/setup.c
+++ b/arch/x86/kernel/setup.c
@@ -73,6 +73,7 @@
 #include <linux/jiffies.h>
 #include <linux/mem_encrypt.h>
 #include <linux/sizes.h>
+#include <linux/security.h>
 
 #include <linux/usb/xhci-dbgp.h>
 #include <video/edid.h>
@@ -1027,6 +1028,13 @@ void __init setup_arch(char **cmdline_p)
    if (efi_enabled(EFI_BOOT))
        efi_init();
 
+   efi_set_secure_boot(boot_params.secure_boot);
+
+#ifdef CONFIG_LOCK_DOWN_IN_EFI_SECURE_BOOT
+   if (efi_enabled(EFI_SECURE_BOOT))
+       security_lock_kernel_down("EFI Secure Boot mode", LOCKDOWN_CONFIDENTIALITY_MAX);
+#endif
+
    dmi_setup();
 
    /*

上記のコードはカーネルuefiのsecure boot環境で起動されてたらlockdownしてます。

ここまでくるとCONFIG_LOCK_DOWN_IN_EFI_SECURE_BOOTが疑わしい感じしてきます。そこでこのオプションは選択せずにカーネルをビルドします。オプションはkernel-x86_64-fedora.configにてこのように行われています。

CONFIG_LOCK_DOWN_IN_EFI_SECURE_BOOT=y
# CONFIG_LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY is not set
# CONFIG_LOCK_DOWN_KERNEL_FORCE_INTEGRITY is not set
CONFIG_LOCK_DOWN_KERNEL_FORCE_NONE=y

オプションの設定はkernel-x86_64-fedora.configでCONFIG_LOCK_DOWN_IN_EFI_SECURE_BOOTをコメントアウトしてsrpmを作ってからビルドしました。そしたら予想通り起動できたので、さらなるデバッグに入ります。

upstreamのソースを再確認

5.4.12のコードを見てたらこんなコードが入っていたんですね。これはregister_tracer()のコードです。

 if (security_locked_down(LOCKDOWN_TRACEFS)) {
        pr_warning("Can not register tracer %s due to lockdown\n",
               type->name);
        return -EPERM;
    }

他にも同じようなチェックがいくつかありました。それでlockdownされているとtracerの初期化しないんだなということかってわかり、初期化されていないのにtrace clockを変更しようとしたからヌルポになるということなんだなと理解しました。

upsteamで再現できるか確認

このときは5.4.7ではなくてfedoraカーネルパッケージのgitでは5.4.12だったのでstable treeの5.4.12を使いました。原因はほぼ掴めたというところなんですが、upstreamで再現するかどうか再度検証します。

まずlockdown機能に関するコンフィグを調べます。lockdownではCONFIG_SECURITY_LOCKDOWN_LSMが最重要の設定でY/Nの2択です。これでYを選べばlockdownの機能が使えます。Yを選択すると更にどのレベルでlockするかという選択があります。選択肢は3個でデフォルトはNoneです。fedoraカーネルもCONFIG_LOCK_DOWN_KERNEL_FORCE_NONEを選択していました。

  • CONFIG_LOCK_DOWN_KERNEL_FORCE_NONE
  • CONFIG_LOCK_DOWN_KERNEL_FORCE_INTEGRITY
  • CONFIG_LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY

fedoraの場合はCONFIG_LOCK_DOWN_IN_EFI_SECURE_BOOTが4個目の選択肢ってところでしょうか。

オプションの内容ですがKconfigによるとCONFIG_LOCK_DOWN_KERNEL_FORCE_NONEは何もしません。CONFIG_LOCK_DOWN_KERNEL_FORCE_INTEGRITYは「Features that allow the kernel to be modified at runtime are disabled.」とあり、CONFIG_LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY「Features that allow the kernel to be modified at runtime or that permit userland code to read confidential material held inside the kernel are disabled」で一番制限が厳しくなっています。

最初にfedoraのコンフィグでmainlineカーネルをビルドしたときはCONFIG_LOCK_DOWN_KERNEL_FORCE_NONEが選択されてたので残りの2パターンを試したところCONFIG_LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITYを設定したときに再現しましたキタ━━━━(゚∀゚)━━━━!!

修正する

lockdownが有効になっているときはtrace_clockを変更できないようにするのが良いだろうと思い、このようなパッチを書きました。

@@ -9420,6 +9420,11 @@ __init static int tracing_set_default_clock(void)
 {
        /* sched_clock_stable() is determined in late_initcall */
        if (!trace_boot_clock && !sched_clock_stable()) {
+               if (security_locked_down(LOCKDOWN_TRACEFS)) {
+                       pr_warn("Can not set tracing clock due to lockdown\n");
+                       return -EPERM;
+               }
+
                printk(KERN_WARNING
                       "Unstable clock detected, switching default tracing clock to \"global\"\n"
                       "If you want to keep using the local clock, then add:\n"

patchを送る

upstreamのカーネルでも再現できたのでupstreamにパッチを送ります。lkmlをみてたらtracing系はlkmlにccで良さげだったのでccにはlkml、toはscripts/get_maintainer.plで出てきたメンテナの人にしてgit format-patchでパッチを作りgit send-emailで送りました。

パッチを送ってしばらくしたらメンテナの人からmainlineへのpull rquestに自分のパッチが入っていたのでacceptされということがわかり、良かったって感じでしたね。5.5-rc8が出るのかと思ってたけどrc8は出ずに5.5が2020/01/27にでて、これに修正が含まれました。

lkml.org

mainlineに修正が取り込まれたのでそのうち5.4のstable treeにも修正が取り込まれるんじゃないかと思います。

まとめ

今回のバグは機種依存・カーネルの設定依存な面もありましたが、バグの原因はわかってしまえば簡単だったのと修正も単純な方法で済ますことができて良かったです😊 あとfedora向けの単純なsecure boot用の署名方法がわかったのは良かったかも。

追記

5.5系は5.5から、5.4系は5.4.16からパッチが入った。

5.5リリースメール

lwn.net

5.4.16リリースメール

https://lwn.net/Articles/811027/

secure bootが有効な環境で自前ビルドのカーネルに署名する方法(fedora向け)

fedoraなら多分一番手軽だと思う署名方法です。他のディストリビューションはわかりません😭

テスト環境

ディストリビューションは(もちろん) fedoraです。バージョンは31です。実機ではなくてqemuでテストしました。

準備

まずpesignとnss-toolsパッケージをインストールします。pesignパッケージをインストールするとpesignコマンドなどのバイナリの他に/etc/pki/pesign/と/etc/pki/pesign-rh-testにデータベースがインストールされます。今回は/etc/pki/pesign-rh-testにあるものを使います。

署名前の準備

まずcertificateファイルを取り出します。

$ sudo certutil -d /etc/pki/pesign-rh-test -L -n "Red Hat Test CA" -r > rhca.cer

次に取り出したcertファイルを登録します。このときにパスワードを聞かれるのでお好きなパスワードを入れてください。

$ sudo mokutil --import ./rhca.cer
input password: 
input password again:

インポートに成功すると次のように確認できます。

$ sudo mokutil --list-new | head -n 10
[key 1]
SHA1 Fingerprint: 65:37:ee:ed:f1:ea:fe:d0:a4:cd:d8:91:eb:f2:2b:45:45:41:cd:44
Certificate:
    Data:

そうしたらここで一旦rebootします。

enroll

再起動するとgrubなどは起動せずにuefiのアプリが起動します。

f:id:masami256:20200112150853p:plain

なにか適当にキーを押すとメニューが出るのでEnroll MOKを選択してエンターキーを押します。

f:id:masami256:20200112150941p:plain

View Key 0を選択してエンターキーを押すと鍵の内容が確認できます。

f:id:masami256:20200112151052p:plain

Continueを選ぶと鍵を登録するか聞かれるのでYesを選択してエンターキーを押しましょう。

f:id:masami256:20200112151206p:plain

パスワードを聞かれるので先程入力したパスワードを打ち込みます。

f:id:masami256:20200112151303p:plain

パスワードが正しければメニュー画面に戻ります。Rebootを選択すればシステムが再起動して通常のブートフローになります。

f:id:masami256:20200112151353p:plain

カーネルへの署名

参考文献の手順だとpesignのオプションでエラーになったんですが、fedoraカーネルパッケージをビルドしたときのログを見てたらコマンドラインオプションがわかりました。

↓がログです。

+ '[' -f arch/x86_64/boot/zImage.stub ']'
+ _pesign_nssdir=/etc/pki/pesign
+ '[' 'Red Hat Test Certificate' = 'Red Hat Test Certificate' ']'
+ _pesign_nssdir=/etc/pki/pesign-rh-test
+ '[' -x /usr/bin/pesign ']'
+ '[' x86_64 == x86_64 -o x86_64 == aarch64 ']'
+ '[' 0 -ge 7 -a -f /usr/bin/rpm-sign ']'
++ id -un
++ uname -m
+ '[' '%{vendor}' == 'Fedora Project' -a mockbuild == mockbuild -a x86_64 == x86_64 ']'
+ '[' -S /var/run/pesign/socket ']'
+ /usr/bin/pesign -c 'Red Hat Test Certificate' --certdir /etc/pki/pesign-rh-test -i arch/x86/boot/bzImage -o vmlinuz.signed -s
+ '[' '!' -s -o vmlinuz.signed ']'
+ '[' '!' -s vmlinuz.signed ']'

コマンドはこんな感じになります。-iオプションにbzImageのパスを渡してください。

$ pesign -c 'Red Hat Test Certificate' --certdir /etc/pki/pesign-rh-test -i ./bzImage -o vmlinuz.signed -s

自分の場合はカーネルのmake installまで済ませていて、カーネルはvmlinuz-5.4.8-testとして置いておいたのでvminuz.signedを/boot/vmlinuz-5.4.8-testとしてコピーして完了でした。 あとは再起動してこのカーネルで起動します。

自前のカーネルでブート

起動してカーネルのバージョン、secure bootが有効なことを確認してすべてOKって感じです🎉

$ uname -a
Linux secureboot-test 5.4.8-test #1 SMP Thu Jan 9 00:04:00 JST 2020 x86_64 x86_64 x86_64 GNU/Linux
$ dmesg | grep Secure
[    0.008666] Secure boot enabled

鍵を消したら

deleteオプションで鍵を消せます。再起動すると登録したときと同じようにShimのアプリが起動するのでそこで鍵を削除できます。

$ sudo mokutil --delete ./rhca.cer 
input password: 
input password again:

鍵を消すとその鍵で署名したカーネルでは起動できなくなります。

f:id:masami256:20200112152306p:plain

カーネルモジュール

自作モジュールもロードできます。

$ lsmod | grep test_mod
test_mod               16384  0

dmesgでも

[  104.658715] test_mod: loading out-of-tree module taints kernel.
[  104.658741] test_mod: module verification failed: signature and/or required key missing - tainting kernel
[  104.658958] test_mod: install test_mod : insmod

参考文献

次のブログを参考にしました。

Booting custom kernels in F18 with Secure Boot - pointless pontifications of a back seat driver — LiveJournal

killコマンドのあれこれ

(´-`).。oO(killコマンドを提供するプロジェクト多いなって

killコマンドを提供するプロジェクト

とりあえず気付いた範囲で4つ。

上のリストはmanページへのリンクになっているのでオプションの違いとかはそちらを参照してください。busyboxのkillは最小構成って感じなのでオプションは1つしかないですね。-sオプションはbusybox以外は実装してますね。で、-lオプションは全てに存在。まあ、これはシグナル名を表示するので無いと困りますね。

ディストリビューションのkillコマンド

じゃあ、ディストリビューションはどのkillコマンドを採用してるのか?ということで手元にあるディストリビューションを確認したところこんなふうになってました。

ディストリビューション killコマンドを提供するパッケージ
fedora 31 util-linux
centos 8 util-linux
arch linux(docker image) util-linux
ubuntu 18.04 procps-ng
alpine linux(docker image) busybox

killコマンドの実装

ソースを見てみます。調べるときに実際に動作させてなくてコードを読んだだけなのでもしかしたら間違ってる可能性もありますが。

util-linux

kill_verbose()がkill(2)でsignalをプロセスに投げているところのようです。

static int kill_verbose(const struct kill_control *ctl)
{
    int rc = 0;

    if (ctl->verbose)
        printf(_("sending signal %d to pid %d\n"), ctl->numsig, ctl->pid);
    if (ctl->do_pid) {
        printf("%ld\n", (long) ctl->pid);
        return 0;
    }
#ifdef HAVE_SIGQUEUE
    if (ctl->use_sigval)
        rc = sigqueue(ctl->pid, ctl->numsig, ctl->sigdata);
    else
#endif
        rc = kill(ctl->pid, ctl->numsig);

    if (rc < 0)
        warn(_("sending signal to %s failed"), ctl->arg);
    return rc;
}

if文でctl->do_pidをチェックしているところは-pオプションの処理です。util-linuxのkillはsigqueue(3)が利用できる場合はkill(2)ではなくてsigqueue(3)を使うんですね。sigqueue(3)の場合はデータを付加できるのでできるならこっちを使おうってことですね。これはmanにも書かれていますが-qオプションを使うとプロセスに送るデータを設定できます。

procps-ng

procps-ngの場合はkill_main()がkillを実現する関数っぽいです。 この関数の主要なところはここです。

 for (i = 0; i < argc; i++) {
        pid = strtol_or_err(argv[i], _("failed to parse argument"));
        if (!kill((pid_t) pid, signo))
            continue;
        error(0, errno, "(%ld)", pid);
        exitvalue = EXIT_FAILURE;
        continue;
    }

こちらはこれといって特殊なことはしてないですね。

coreutils

coreutisの場合はsend_signals()という関数がkill(2)を呼んでいます。

static int
send_signals (int signum, char *const *argv)
{
  int status = EXIT_SUCCESS;
  char const *arg = *argv;

  do
    {
      char *endp;
      intmax_t n = (errno = 0, strtoimax (arg, &endp, 10));
      pid_t pid = n;

      if (errno == ERANGE || pid != n || arg == endp || *endp)
        {
          error (0, 0, _("%s: invalid process id"), quote (arg));
          status = EXIT_FAILURE;
        }
      else if (kill (pid, signum) != 0)
        {
          error (0, errno, "%s", quote (arg));
          status = EXIT_FAILURE;
        }
    }
  while ((arg = *++argv));

  return status;
}

これも短い関数ですね。こちらもprocps-ng同様にkill(2)を呼ぶだけです。

busybox

busyboxの場合はkill_main()という関数でkillの処理を行っています。余談ですがbusyboxのkill.cはprocps/にあるし、procps-ngと同じ感じにしてるんですかね?

#if ENABLE_KILL
    /* Looks like they want to do a kill. Do that */
    while (arg) {
# if SH_KILL
        /*
        * We need to support shell's "hack formats" of
        * " -PRGP_ID" (yes, with a leading space)
        * and " PID1 PID2 PID3" (with degenerate case "")
        */
        while (*arg != '\0') {
            char *end;
            if (*arg == ' ')
                arg++;
            pid = bb_strtoi(arg, &end, 10);
            if (errno && (errno != EINVAL || *end != ' ')) {
                bb_error_msg("invalid number '%s'", arg);
                errors++;
                break;
            }
            if (kill(pid, signo) != 0) {
                bb_perror_msg("can't kill pid %d", (int)pid);
                errors++;
            }
            arg = end; /* can only point to ' ' or '\0' now */
        }
# else /* ENABLE_KILL but !SH_KILL */
        pid = bb_strtoi(arg, NULL, 10);
        if (errno) {
            bb_error_msg("invalid number '%s'", arg);
            errors++;
        } else if (kill(pid, signo) != 0) {
            bb_perror_msg("can't kill pid %d", (int)pid);
            errors++;
        }
# endif
        arg = *++argv;
    }
    return errors;
#endif

コードにはSH_KILLは通常定義されてるのかどうかわかりませんが、定義の有無にかかわらずkill(2)を使うだけですね。

実装の差異

util-linuxはsigqueue(3)が利用可能ならそれを使い、利用できなければkill(2)を呼ぶという実装になっていますが、それ以外はkill(2)を呼ぶ形でした。

まとめ

Linuxでは基本的とも言えるkillコマンドですが、このコマンドを提供するパッケージは複数あり、ディストリビューションによってどのパッケージのkillコマンドを使うか選択していました。 また、実装でも微妙な違いがあるということがわかりました( ´ー`)フゥー...

unameコマンドから始めるデバッグ&カーネルハック入門

この記事はLinux Advent Calendar 2019の1日目の記事です。

はじめに

本記事ではLinuxサーバのホスト名、Linuxカーネルのバージョン、cpuアーキテクチャなどのシステム情報を表示するuname(1)を利用してLinux環境でのデバッグカーネルハックについて説明していきます。本記事ではコマンドやツールの使い方の説明ではなくて、それらを使ってどのようにデバッグするのかというところを説明します。

環境

ディストリビューションにはFedora 31(x86_64)を利用します。動作環境はQEMUlibvirtOracle VM VirtualBoxなどの仮想環境でも良いですし、物理マシンにインストールしても構いませんが仮想環境を利用したほうが手軽に環境構築できるのでおすすめです。ユーザーはsudoを使えるようにしておいてください。インストール時もしくはインストール後に以下のグループをインストールすると後で楽かもしれません。

  • C Development Tools and Libraries
  • Development Tools
  • RPM Development Tools
  • System tools
  • Guest Agents(仮想環境にインストールする場合)

パッケージグループのインストールは以下のコマンドで行えます。

[masami@unamebook ~]$ sudo dnf group install -y "C Development Tools and Libraries" "Development Tools" "RPM Development Tools" "System tools" "Guest Agents"

ファイルの編集をするのにエディタが必要です。これは好きなエディタを使ってください。

おことわり

本記事で行うツールの利用方法などはディストリビューションに依存しないことが多いと思いますが、コマンドの出力結果やソースコードについてはディストリビューション固有のpatchが当たっている場合もあり、fedora 31以外のディストリビューション(場合によってはfedora 31でもパッケージのバージョン違い)と違うことがあります。

uname(1)

uname(1)*1を実行したことのある方は多いと思いますが、まずは普通にuname(1)を実行しましょう。このコマンドはcoreutilsパッケージに含まれています。

[masami@unamebook ~]$ uname -a
Linux unamebook 5.3.11-300.fc31.x86_64 #1 SMP Tue Nov 12 19:08:07 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

unameコマンドに渡した-aオプションはすべての情報を表示させるオプションです。上記の出力結果を分解したものが以下の「表1 unameの出力内容」です。

出力内容 該当するunameコマンドのオプション 意味
Linux -s カーネル
unamebook -n ホスト名
5.3.11-300.fc31.x86_64 -r カーネルリリース
#1 SMP Tue Nov 12 19:08:07 UTC 2019 -v カーネルバージョン*2
x86_64 -m マシンのハードウェア名
x86_64 -p プロセッサ名
x86_64 -i ハードウェアプラットフォーム
GNU/Linux -o オペレーティングシステム

表1 unameの出力内容

uname(1)が出力する内容

uname(1)を実行してシステム情報を見ることができましたがこの情報はどこから取得したのでしょうか?この情報はLinuxカーネルが持っていて、uname(1)はシステムコールを通じてカーネルに問い合わせを行い、カーネルから受け取った結果を表示しています。このときに利用するシステムコールuname(2)*3です。uname(2)のプロトタイプは次のようになっています。カーネルは引数で渡されたbuf変数にシステム情報をセットします。

int uname(struct utsname *buf)

uname(2)のインターフェース

uname(2)の詳細はmanページで確認してください。

[masami@unamebook ~]$ sudo dnf install -y man-pages

strace(1)

strace(1)は、uname(1)の実行時にuname(2)が呼ばれていることを確認しましょう。ここではデバッガは使わずにstrace(1)を利用します。strace(1)は簡単に言うと指定したコマンド・プロセスによるシステムコール呼び出しをトレースするコマンドです。まずはstraceパッケージをインストールします。

[masami@unamebook ~]$ sudo dnf install -y strace

straceパッケージをインストールしたら実行してみましょう。オプションの細かい内容は説明しませんが、ここではuname(2)に絞って表示させるように実行しました。

[masami@unamebook ~]$ strace -v -s 1024 -e trace=uname -C  uname -a
uname({sysname="Linux", nodename="unamebook", release="5.3.11-300.fc31.x86_64", version="#1 SMP Tue Nov 12 19:08:07 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
uname({sysname="Linux", nodename="unamebook", release="5.3.11-300.fc31.x86_64", version="#1 SMP Tue Nov 12 19:08:07 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
uname({sysname="Linux", nodename="unamebook", release="5.3.11-300.fc31.x86_64", version="#1 SMP Tue Nov 12 19:08:07 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
Linux unamebook 5.3.11-300.fc31.x86_64 #1 SMP Tue Nov 12 19:08:07 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
+++ exited with 0 +++
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    0.000007           2         3           uname
------ ----------- ----------- --------- --------- ----------------
100.00    0.000007                     3           total

uname(2)の呼び出しが3回ありますね。なぜ3回uname(2)を実行しているのでしょうか?これはソースコードを確認しないとわかりません。unameコマンドを提供しているのはcoreutilsパッケージなのでcoreutilsパッケージのソースコードをダウンロードしましょう。利用しているのはfedoraにインストールされているcoreutilsパッケージなのでupstreamではなくfedoraソースコードを取得します。

[masami@unamebook ~]$ dnf download --source coreutils

ソースパッケージ(.src.rpm)をダウンロードしたらインストールします。

まず展開先のディレクトリをセットアップします。

[masami@unamebook ~]$ rpmdev-setuptree

そしてrpmコマンドでインストールします。

[masami@unamebook ~]$ rpm -i coreutils-8.31-6.fc31.src.rpm

インストール時に次のようなwarningが出ますが無視して大丈夫です。

warning: user mockbuild does not exist - using root
warning: user mockbuild does not exist - using root

これで~/rpmbuildにソースパッケージがインストールされましたが、ソースコードはまだ展開されていないでソースコードの展開&patchを当てましょう。と、その前にいくつか必要なパッケージをインストールします。

[masami@unamebook ~]$ sudo dnf builddep -y coreutils

これで大丈夫です。ではソースコードの準備をしましょう。

[masami@unamebook ~]$ cd rpmbuild/SPECS/
[masami@unamebook SPECS]$ rpmbuild -bp coreutils.spec

ソースコードを展開しpatchが適用されたソースコードは~/rpmbuild/BUILDにあります。

[masami@unamebook SPECS]$ cd ~/rpmbuild/BUILD/coreutils-8.31/
[masami@unamebook coreutils-8.31]$ ls
ABOUT-NLS   autom4te.cache  build-aux  configure     DIR_COLORS               dist-check.mk  GNUmakefile  lib       Makefile.am  NEWS    src     thanks-gen             THANKStt.in
aclocal.m4  bootstrap       cfg.mk     configure.ac  DIR_COLORS.256color      doc            init.cfg     m4        Makefile.in  po      tests   THANKS.in              TODO
AUTHORS     bootstrap.conf  ChangeLog  COPYING       DIR_COLORS.lightbgcolor  gnulib-tests   INSTALL      maint.mk  man          README  THANKS  THANKS-to-translators

uname(1)のソースコードはsrc/uname.cです。grepuname(2)の呼び出し箇所を調べると3箇所あることがわかります。

[masami@unamebook coreutils-8.31]$ grep -n "uname *(" src/uname.c
286:      if (uname (&name) == -1)
313:    uname(&u);
364:    uname(&u);

286行目付近を見るとこのようになっています。toprint変数はuname(1)の引数に応じてビットが立ちます。オプションに-aを渡した場合はこのif文は真になるので286行目のuname(2)が実行されます。

    280   if (toprint
    281        & (PRINT_KERNEL_NAME | PRINT_NODENAME | PRINT_KERNEL_RELEASE
    282           | PRINT_KERNEL_VERSION | PRINT_MACHINE))
    283     {
    284       struct utsname name;
    285 
    286       if (uname (&name) == -1)
    287         die (EXIT_FAILURE, errno, _("cannot get system name"));

313行目はどうかというと、ここは-pオプションの処理部分です。マクロのelseブロックが実行されるようです。

    304 #if HAVE_SYSINFO && defined SI_ARCHITECTURE
    305       {
    306         static char processor[257];
    307         if (0 <= sysinfo (SI_ARCHITECTURE, processor, sizeof processor))
    308           element = processor;
    309       }
    310 #else
    311       {
    312         static struct utsname u;
    313         uname(&u);
    314         element = u.machine;
    315       }

残りの364行目も確認しましょう。こちらは-iオプションのハードウェアプラットフォーム取得部分です。こちらもマクロのelseブロックが呼ばれているようです。

    354 #if HAVE_SYSINFO && defined SI_PLATFORM
    355       {
    356         static char hardware_platform[257];
    357         if (0 <= sysinfo (SI_PLATFORM,
    358                           hardware_platform, sizeof hardware_platform))
    359           element = hardware_platform;
    360       }
    361 #else
    362       {
    363         static struct utsname u;
    364         uname(&u);
    365         element = u.machine;
    366         if(strlen(element)==4 && element[0]=='i' && element[2]=='8' && element[3]=='6')
    367                 element[1]='3';
    368       }
    369 #endif

この3箇所のuname(2)の呼び出しですが、286行目はupstreamのコードにもあります。しかし、残りの2箇所はupstreamのコードには存在しません。この2箇所はfedoracoreutilsパッケージが独自当てているpatchです。このpatchは~/rpmbuild/SOURCES/coreutils-8.2-uname-processortype.patchです。興味のある方は確認してみてください。

gdb(1)

ソースコードを読んでuname(2)の呼び出し箇所が3箇所あることはわかったのですが、一応実際の動作を見てみましょう。ここではgdbを利用します。gdbを使ってデバッグをする場合はデバッグ情報があると便利です。通常のパッケージに含まれるバイナリファイルはデバッグ情報が存在しないため、デバッグが不便です。fedoraの場合はデバッグ情報はdebuginfoパッケージとして存在しているのでこれをインストールします。glibcのdebuginfoパッケージも合わせてインストールします。

と、その前にdebuginfo-installコマンドが必要なのでパッケージをインストールしましょう。

[masami@unamebook ~]$ sudo dnf install -y dnf-utils

dnf-utilsパッケージをインストールしたらdebuginfo-installコマンドが利用できますので、これでdebuginfoのパッケージをインストールします。

[masami@unamebook ~]$ sudo debuginfo-install -y coreutils glibc

debuginfoパッケージをインストールしたらgdbを起動しましょう。gdbは自動的にデバッグ情報を読み込んでくれます。

[masami@unamebook ~]$ gdb -q /usr/bin/uname
Reading symbols from /usr/bin/uname...
Reading symbols from /usr/lib/debug/usr/bin/uname-8.31-6.fc31.x86_64.debug...
(gdb) 

gdbが立ち上がったらとりあえず280行目のif文あたりにブレークポイントを張りましょう。listコマンドで280行目付近を表示します。

(gdb) list 280
275       toprint = decode_switches (argc, argv);
276
277       if (toprint == 0)
278         toprint = PRINT_KERNEL_NAME;
279
280       if (toprint
281            & (PRINT_KERNEL_NAME | PRINT_NODENAME | PRINT_KERNEL_RELEASE
282               | PRINT_KERNEL_VERSION | PRINT_MACHINE))
283         {
284           struct utsname name; 

breakコマンド(略性はb)で280行目にブレークポイントをセットします。

(gdb) b 280
Breakpoint 1 at 0x27b4: /usr/src/debug/coreutils-8.31-6.fc31.x86_64/separate/../src/uname.c:280. (2 locations)

runコマンド(略称はr)でunameコマンドを実行します。uname(1)のオプションには-aを渡しています。unameコマンドを実行すると先程設定したブレークポイントで止まるので、あとはnでステップ実行していけばuname(2)の呼び出しが3回行われるのがわかります。

(gdb) r -a
Starting program: /usr/bin/uname -a

Breakpoint 1, main (argc=2, argv=0x7fffffffe448) at ../src/uname.c:280
280       if (toprint
(gdb) n
286           if (uname (&name) == -1)
(gdb)  n
~略~
301       if (toprint & PRINT_PROCESSOR)
(gdb) 
313             uname(&u);
(gdb) n
~略~
(gdb) n
364             uname(&u);
(gdb) c
Continuing.
Linux unamebook 5.3.11-300.fc31.x86_64 #1 SMP Tue Nov 12 19:08:07 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
[Inferior 1 (process 6522) exited normally]

今回はプログラムが単純で短いのでステップ実行で済ませています。 ここまででstrace(1)、ソースコードリーディング、gdb(1)を使いuname -aの実行時にuname(2)が3回呼ばれる謎を調べることに成功しました。

bpftrace

bpftraceはLinuxのeBPFを使用したトレースツールです。eBFPとはextended Berkeley Packet Filterの略で元来はパケットフィルタの機能ですがLinuxではこのパケットフィルタ機能が進化し、パケットフィルタだけでは収まらなくなった機能です。Linuxではbpf(2)があり、c言語でeBPFの機能を使ったプログラムを作成できますが、これは大変なので普通はBCCを使うことが多いのではないでしょうか。BCCはBPF Compiler Collectionの略でPythonc言語を使ってeBFPを利用するプログラムを作ることが出来ます。そしてbpftraceですがBCCをバックエンドとして使った高レベルのトレース用言語と説明されています。bpftraceではカーネルレベルのトレースを取ることが出来ます。Fedoraにはパッケージがあるのでインストールしましょう。

[masami@unamebook ~]$ sudo dnf install -y bpftrace

インストールが完了したらuname(2)のトレースを行ってみましょう。まずはシステムコールの呼び出しをトレースします。 この他に、カーネルのソースも読みたいのでこちらもインストールしましょう。今回はdebuginfo-installコマンドを使用してデバッグ情報とソースをインストールします。coreutilsのときは単にソースを取得したいだけだったのでsrpmパッケージをインストールしましたが、debuginfoパッケージの場合はデバッグ情報とソースコードをインストールできます。gdb(1)の章でcoreutilsglibcのdebuginfoパッケージをインストールしましたが、このときにソースコードもインストールされいます。じゃあ、この2つの使い分けは?という疑問があると思いますが、ソースを変更して再ビルドする用途にはsrpmパッケージを、デバッグ情報のみが必要な場合はdebuginfoパッケージを使用すれば良いと思います。では、カーネルのdebuginfoパッケージをインストールします。

[masami@unamebook ~]$ sudo debuginfo-install -y kernel-debuginfo-common-x86_64

debuginfoパッケージに含まれるソースコードは/usr/src/debug以下にインストールされます。

[masami@unamebook ~]$ ls /usr/src/debug/
coreutils-8.31-6.fc31.x86_64  glibc-2.30-13-g919af705ee  kernel-5.3.fc31

インストールできたらカーネル側のuname(2)を処理する関数を探します。システムコールはinclude/linux/syscalls.hで探すことが出来ます。カーネルソースコードは以下の場所にあります。

[masami@unamebook ~]$ cd /usr/src/debug/kernel-5.3.fc31/linux-5.3.11-300.fc31.x86_64/

まずはunamegrepをかけましょう。

[masami@unamebook linux-5.3.11-300.fc31.x86_64]$ grep uname include/linux/syscalls.h
asmlinkage long sys_newuname(struct new_utsname __user *name);
asmlinkage long sys_memfd_create(const char __user *uname_ptr, unsigned int flags);
asmlinkage long sys_uname(struct old_utsname __user *);
asmlinkage long sys_olduname(struct oldold_utsname __user *);

いくつか見つかりますね。sys_のprefixはシステムコールを表しています。名前からsys_newuname、sys_unameのどちらかだろうと想像できますね。ファイルを実際に読んで確認しましょう。まずはそのままな名前のsys_unameを見てみます。

/* obsolete: kernel/sys.c */
asmlinkage long sys_gethostname(char __user *name, int len);
asmlinkage long sys_uname(struct old_utsname __user *);
asmlinkage long sys_olduname(struct oldold_utsname __user *);

obsoleteってコメントがありますね。こちらではなさそうです。ではsys_newunameはどうでしょうか。こちらは特にコメントもありません。uname(2)の実行時はsys_newuname()が実行されるようですね。

asmlinkage long sys_setpriority(int which, int who, int niceval);
~略~
asmlinkage long sys_newuname(struct new_utsname __user *name);

コメントにkernel/sys.cとあるので実装はこのファイルにあるようです。では、このファイルも見てみましょう。SYSCALL_DEFINE1というのはシステムコールの関数に使われるマクロです。SYSCALL_DEFINE1の1は引数を一つ受け取るという意味です。実装は比較的単純ですね。

SYSCALL_DEFINE1(newuname, struct new_utsname __user *, name)
{
        struct new_utsname tmp;

        down_read(&uts_sem);
        memcpy(&tmp, utsname(), sizeof(tmp));
        up_read(&uts_sem);
        if (copy_to_user(name, &tmp, sizeof(tmp)))
                return -EFAULT;

        if (override_release(name->release, sizeof(name->release)))
                return -EFAULT;
        if (override_architecture(name))
                return -EFAULT;
        return 0;
}

ここまででユーザー空間でuname(2)を実行するとカーネル空間のsys_newuname()が実行されることがわかりました。前置きが長くなりましたが実際にbfptrace(1)を使って見ましょう。まずは単純にsys_newutsname()の実行をトレースします。この関数が実行されたときに呼び出し元のコマンド名を表示させるのが以下のコマンドラインです。

[masami@unamebook ~]$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_newuname { printf("%s\n", comm); }'

コマンドを入力してちょっとすると次のようなメッセージが表示されます。終了したい場合はCtrl-Cで終了できます。

Attaching 1 probe...

メッセージが表示されたら別の端末からuname -aを実行すると以下のようにunameが3回表示されます。uname -aを実行するとuname(2)が3回実行されるというのはstrace(1)の章で調べましたね。

[masami@unamebook ~]$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_newuname { printf("%s\n", comm); }'
Attaching 1 probe...
uname
uname
uname
^C

systemtap

systemtapはbpftrace同様にカーネルのトレーシングを行うことができるツールです。systemtapを応用するとトレース以上のこともできます。まずはインストールしましょう。

[masami@unamebook ~]$ sudo dnf install -y systemtap

systemtapはstap(1)がコマンドラインのプログラムとなります。systemtapは専用のスクリプト言語があり、c言語のコードを埋め込むこともできます。本章ではsystemtapを使ってsys_newutsname()の挙動を変更してみましょう。uname(1)で表示するノード名などはutsname()から取得しています。utsname()の戻り値をtmp変数に一旦保存し、それをcopy_to_user()でユーザー空間のプログラムから渡されたnameにコピーします。

SYSCALL_DEFINE1(newuname, struct new_utsname __user *, name)
{
        struct new_utsname tmp;

        down_read(&uts_sem);
        memcpy(&tmp, utsname(), sizeof(tmp));
        up_read(&uts_sem);
        if (copy_to_user(name, &tmp, sizeof(tmp)))
                return -EFAULT;

        if (override_release(name->release, sizeof(name->release)))
                return -EFAULT;
        if (override_architecture(name))
                return -EFAULT;
        return 0;
}

では、systemtapを使ってプログラムにnodenameを書き換えてみましょう。systemtapを利用したライブパッチ機能の実装という感じです。systemtapのプログラムは次のようになります。

#!/usr/bin/env stap

%{
#include <linux/string.h>
#include <linux/uaccess.h>
#include <uapi/linux/utsname.h>
%}

function set_dummy_uname:long(name:long)
%{
        struct new_utsname u = { 0 };
           
        if (copy_from_user(&u, (struct new_utsname *) STAP_ARG_name, sizeof(u)))
        STAP_RETURN(-EFAULT);
    
        strcpy(u.nodename, "livepatched");

        if (copy_to_user((struct new_utsname *) STAP_ARG_name, &u, sizeof(u)))
                STAP_RETURN(-EFAULT);
    
        STAP_RETURN(0);

%}

probe begin {
        printf("start livepatch code. Ctrl-C to stop\n")
}

probe kernel.function("__do_sys_newuname").return {
    // Only set dummy nodename to test program
    if (execname() == "uname-test") {
        // Use @entry() to get name parameter in return probe.
        set_dummy_uname(@entry($name))
    }
}

probe end {
        printf("Done\n")
}

このプログラムではシステムコールのreturn時に処理を入れています。まずシステムコールを呼び出したプログラム名を調べ、テストプログラムでなければ特に変更は行わないで終了します。nodenameを設定しているのはset_dummy_uname()で呼び出し時にシステムコールのパラメータとして渡されたname変数をset_dummy_uname()の引数として渡しています。システムコールを呼び出したのがテストプログラムならnodenameをlivepatchedに変更します。systamtapスクリプトの特徴として、自分で実装した関数の引数にアクセスする場合はSTAP_ARG_変数名でアクセスします。このスクリプトではnameという変数名で引数を受け取っているためSTAP_ARG_nameと書いています。

ここで使用するテストプログラムですが以下のような簡単なものです。

#include <stdio.h>
#include <sys/utsname.h>

int main(int argc, char **argv)
{
    struct utsname name = { 0 };
    
    if (uname(&name)) {
        perror("uname");
        return -1;
    }

    printf("nodename: %s\n", name.nodename);
    return 0;
}

このcコードをコンパイルしましょう。

[masami@unamebook ~]$ gcc uname-test.c -o uname-test

では、このsystemtapスクリプトを実行しましょう。ここでも端末を2つ使います。1つはuname-testプログラムを実行します。もう1つは次のようにsystemtapスクリプトを実行します。start livepatch ~と出たら準備完了です。

[masami@unamebook ~]$ sudo stap -g uname_livepatch.stp 
start livepatch code. Ctrl-C to stop

別の端末でテストします。

[masami@unamebook ~]$ uname -n
unamebook
[masami@unamebook ~]$ ./uname-test 
nodename: livepatched
[masami@unamebook ~]$ uname -n
unamebook
[masami@unamebook ~]$ 

次にsystemtapのプログラムをCtrl-Cで止めてuname-testを実行してみます。systemtapスクリプトを終了したことでsys_newuname()の挙動がもとに戻ったことでuname(2)の戻り値がもとに戻りましたね。

[masami@unamebook ~]$ ./uname-test 
nodename: unamebook

Kernel Hack

前章のsystemtapではsystemtapを利用してカーネルの挙動を変えてみました。本章ではカーネルソースコードを変更し、前章と同様の挙動に変えてみます。カーネルのソースパッケージにパッチを当てるにはgit-am(1)で当てることができるパッチを作る必要があります。パッチの作り方としてメインラインのカーネルを変更してパッチをつくるかFedoraカーネルソースを変更するの2パターンがあります。FedoraカーネルにはFedoraプロジェクトが適用したパッチも含まれているため、メインラインのカーネルを変更した場合、変更内容によってはFedoraカーネルソースとコンフリクトが発生する可能性もあります。そこで本章ではFedoraカーネルソースを変更してパッチを作ります。まずはカーネルのソースパッケージをダウンロードしてインストールします。以前coreutilsのソースパッケージをインストールしているので綺麗なrpmbuildディレクトリを作りたいところです。よって、既存のrpmbuildディレクトリはリネームして置いておき、新たにrpmbuildディレクトリを作成します。そしてソースパッケージをダウンロードしてインストールします。

[masami@unamebook ~]$ dnf download --source kernel
[masami@unamebook ~]$ rpm -i kernel-5.3.11-300.fc31.src.rpm 

カーネルパッケージのビルドに必要な依存パッケージのインストールを行います。

[masami@unamebook ~]$ sudo dnf builddep -y kernel
[masami@unamebook ~]$ sudo dnf install -y pesign

これでカーネルソースパッケージをビルドする準備は整ったのでですが、specファイルにあるbuildidを設定してFedorarpmと区別できるようにします。お好みのエディタでkernel.specを開き# define buildid .localの下に%define buildid .unametestを追加します。

# define buildid .local
%define buildid .unametest

この段階ではまだ~/rpmbuild/SOURCESにカーネルのコードやパッチが置かれているだけなのでソースコードを展開します。

[masami@unamebook SPECS]$ rpmbuild -bp kernel.spec

展開に成功すると~/rpmbuild/BUILD/kernel-5.3.fc31/linux-5.3.11-300.unametest.fc31.x86_64/にパッチが当てられたカーネルのコードが置かれます。また通常のカーネルソースツリーとは違いconfigsディレクトリが存在します。このディレクトリにはfedoraの各アーキテクチャ向けのconfigファイルが存在します。rpmbuildではなく、ローカル環境でビルドを試す際はこのディレクトリにあるconfigを使用してmake oldconfigを行うか、使用しているカーネルのコンフィグファイルが/bootにあるのでそれをコピーして使いましょう。 では、ここからはカーネルソースコードを弄っていきます。まずは~/rpmbuild/BUILD/kernel-5.3.fc31に移動し、 linux-5.3.11-300.unametest.fc31.x86_64をコピーして作業ディレクトリを作ります。コピー先の名前は任意です。ここでは linux-5.3.11-300.unametest.fc31.x86_64.hackとしました。

[masami@unamebook kernel-5.3.fc31]$ cp -a linux-5.3.11-300.unametest.fc31.x86_64 linux-5.3.11-300.unametest.fc31.x86_64.hack

一旦ソースをいじっていない状態でビルドができることを確認しましょう。この段階でビルドに成功することを確認しておけば、ソースの変更後にビルドが通らなくなった場合の問題の切り分けに役立ちます。この段階でgitのブランチも作っておきます。 linux-5.3.11-300.unametest.fc31.x86_64には.gitディレクトリがあり、gitがすでに利用できるようになっています。

[masami@unamebook kernel-5.3.fc31]$ cd linux-5.3.11-300.unametest.fc31.x86_64.hack/
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ git checkout -b uname-hack
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ cp configs/kernel-5.3.11-x86_64.config .config
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ make oldconfig
scripts/kconfig/conf  --oldconfig Kconfig
#
# configuration written to .config
#

make oldconfigは特に問題なく完了すると思います。では、カーネルカーネルモジュールをビルドします。bzImageで圧縮されたカーネルのイメージをビルドし、modulesでカーネルモジュールをビルドしています。-j$(nproc)は並列ビルドのオプションです。ログインしている環境で利用可能なcpu数をもとにビルドを並列実行します。カーネルのビルドは最後にarch/x86/boot/bzImage is readyというメッセージが出れば成功です。rpmのソースパッケージを使ってるのでrpmbuildでビルドしても良いのですが、それだと時間がかかるのでまずは手軽にmakeでビルドしています。

[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ make -j$(nproc) bzImage
~略~
BUILD   arch/x86/boot/bzImage
Setup is 17692 bytes (padded to 17920 bytes).
System is 9085 kB
CRC dde74ef3
Kernel: arch/x86/boot/bzImage is ready  (#1)

モジュールのビルドも実行します。モジュールのビルドは成功時にこれといったログはありません。次のようにしれっとプロンプトが表示されていたら成功しています。

[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ make -j$(nproc) modules
~略~
  LD [M]  sound/x86/snd-hdmi-lpe-audio.ko
  LD [M]  virt/lib/irqbypass.ko

さて、カーネルとモジュールのビルドに成功したら準備は完了です。実際にカーネルのソースに手を加えましょう。変更するのはkernel/sys.cです。カレントプロセス名を調べてプロセス名がuname-testならnodenameを変更します。やっていることはsystemtapの場合と同様です。変更内容は次のようになります。

diff --git a/kernel/sys.c b/kernel/sys.c
index 2969304..908ce7f 100644
--- a/kernel/sys.c
+++ b/kernel/sys.c
@@ -1243,6 +1243,10 @@ SYSCALL_DEFINE1(newuname, struct new_utsname __user *, name)
        down_read(&uts_sem);
        memcpy(&tmp, utsname(), sizeof(tmp));
        up_read(&uts_sem);
+
+       if (!strcmp(current->comm, "uname-test"))
+               strcpy(tmp.nodename, "livepatched");
+
        if (copy_to_user(name, &tmp, sizeof(tmp)))
                return -EFAULT;
 

systemtapの場合は関数の途中にコードを入れていないので関数から返るところに処理を入れ込みましたが、今回は直接カーネルのコードを弄っているので素直なコードの変更になってますね。変更したらカーネルがビルドできるか確認しましょう。ビルド方法は先ほどと同じです。

[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ make -j$(nproc) bzImage
~略~
  BUILD   arch/x86/boot/bzImage
Setup is 17692 bytes (padded to 17920 bytes).
System is 9089 kB
CRC 27223b7c
Kernel: arch/x86/boot/bzImage is ready  (#2)

今回も成功したらarch/x86/boot/bzImage is readyというメッセージが出ます。このメッセージの最後にある#2というのはこのカーネルをビルドしたのが2回目という意味です。ビルドに成功したのでパッチを作成しましょう。まず最初にgitのメールアドレスと名前を変更します。これはFedoraカーネルの場合、デフォルトではFedoraカーネルチームのメールアドレスが使われるので上書きして自分のメールアドレスと名前に変更します。そうしたら変更をコミットし、git-format-patch(1)でパッチを作ります。出来たパッチはカレントディレクトリに置かれます。

[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ git config --local --add user.email <your email>
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ git config --local --add user.name "your name"
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ git add kernel/sys.c
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ git commit -m "uname: add test code"
[uname-hack 9ea2b59] uname: add test code
 1 file changed, 4 insertions(+)
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ git format-patch -s master
0001-uname-add-test-code.patch
[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$

ここで出来たパッチは次のようになります。コミットメッセージ、変更内容、メール送信用のサブジェクトなどが設定されています。最後の2.23.0というのはgitのバージョンです。

From 9ea2b595c1c246a7ff94e6c4c721896ffcf9f5a6 Mon Sep 17 00:00:00 2001
From: Your Name <your email>
Date: Mon, 25 Nov 2019 23:26:46 +0900
Subject: [PATCH] uname: add test code

Signed-off-by:Your Name <your email>
---
 kernel/sys.c | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/kernel/sys.c b/kernel/sys.c
index 2969304..908ce7f 100644
--- a/kernel/sys.c
+++ b/kernel/sys.c
@@ -1243,6 +1243,10 @@ SYSCALL_DEFINE1(newuname, struct new_utsname __user *, name)
        down_read(&uts_sem);
        memcpy(&tmp, utsname(), sizeof(tmp));
        up_read(&uts_sem);
+
+       if (!strcmp(current->comm, "uname-test"))
+               strcpy(tmp.nodename, "livepatched");
+
        if (copy_to_user(name, &tmp, sizeof(tmp)))
                return -EFAULT;
 
-- 
2.23.0

では、このパッチをFedoraカーネルソースパッケージに追加してrpmbuild(8)でビルドしましょう。パッチを~/rpmbuild/SOURCESディレクトリにコピーします。

[masami@unamebook linux-5.3.11-300.unametest.fc31.x86_64.hack]$ cp 0001-uname-add-test-code.patch ../../../SOURCES/.

そして~/rpmbuild/SPECSに移動します。kernel.specファイルをエディタで開き、# END OF PATCH DEFINITIONSの行まで移動します。PatchXXX(Xは数字)の行がありますが、この数字は任意です。ここでは999としました。次のように# END OF PATCH DEFINITIONSの上に2行を追加します。

# uname hack patch
Patch999: 0001-uname-add-test-code.patch

# END OF PATCH DEFINITIONS

そうしたらビルドしましょう。ここでは必要最小限のカーネルパッケージだけ作成します。次のようなコマンドライン引数でビルドを行います。

[masami@unamebook SPECS]$ rpmbuild -bb --with baseonly --without debuginfo --without debug --target=$(uname -m) kernel.spec
~略~
Executing(%clean): /bin/sh -e /var/tmp/rpm-tmp.0BA8HX
+ umask 022
+ cd /home/masami/rpmbuild/BUILD
+ cd kernel-5.3.fc31
+ /usr/bin/rm -rf /home/masami/rpmbuild/BUILDROOT/kernel-5.3.11-300.unametest.fc31.x86_64
+ RPM_EC=0
++ jobs -p
+ exit 0

出来上がったrpmファイルは~ rpmbuild/RPMS/x86_64/に置かれます。

[masami@unamebook x86_64]$ ls
kernel-5.3.11-300.unametest.fc31.x86_64.rpm       kernel-devel-5.3.11-300.unametest.fc31.x86_64.rpm    kernel-modules-extra-5.3.11-300.unametest.fc31.x86_64.rpm
kernel-core-5.3.11-300.unametest.fc31.x86_64.rpm  kernel-modules-5.3.11-300.unametest.fc31.x86_64.rpm

出来上がったrpmをインストールします。

[masami@unamebook x86_64]$ sudo dnf localinstall -y ./*.rpm

インストールしたらカーネルの優先順を確認しましょう。通常はインストールしたカーネルで起動できると思いますが、出来なかった場合はここで調べたindexを使ってgrub2-rebootコマンドで起動するカーネルを設定してリブートします。

[masami@unamebook ~]$ sudo grubby --info=ALL
index=0
kernel="/boot/vmlinuz-5.3.11-300.unametest.fc31.x86_64"
args="ro resume=UUID=c98315dc-7144-427b-a6a0-2aad53d8ebc5 rhgb quiet"
root="UUID=0b584087-de7b-42f8-8dea-d3b679d22613"
initrd="/boot/initramfs-5.3.11-300.unametest.fc31.x86_64.img"
title="Fedora (5.3.11-300.unametest.fc31.x86_64) 31 (Thirty One)"
id="24b7d4741c7c4982889ac4d4acb27ae1-5.3.11-300.unametest.fc31.x86_64"

再起動したらuname(1)でカーネルを確認しましょう。unametestが入っているのでビルドしたカーネルで正しく起動したかわかりますね。

[masami@unamebook ~]$ uname -a
Linux unamebook 5.3.11-300.unametest.fc31.x86_64 #1 SMP Mon Nov 25 23:32:51 JST 2019 x86_64 x86_64 x86_64 GNU/Linux

そして、systemtapのときにも使ったuname-testを実行してみましょう。

[masami@unamebook ~]$ ./uname-test 
nodename: livepatched

nodenameが変わりましたね。ここでuname-testをuname-test2としてコピーして実行すると、オリジナルのnodenameがでましたね。ということでカーネルに手を入れてuname(2)の挙動を変更することにも成功しました🎉

[masami@unamebook ~]$ cp uname-test uname-test2
[masami@unamebook ~]$ ./uname-test2
nodename: unamebook

Livepatch

systemtapの章ではsystemtapを利用してカーネルの挙動を変えてみましたが、LinuxカーネルにはLivepatchという機能があり、実行中のカーネルの挙動を再起動なしに変更する(patchを当てる)ことができる機能があります。本章ではこの機能でunameシステムコールの挙動を変更してみます。ただしFedoraカーネルではLivepatch機能は有効になっていないためカーネルのソースパッケージよりLivepatchを有効にしたカーネルを作る必要があります。カーネルのソースは前章でダウンロードしてきてあるのでこれに対してコードを弄っていきましょう。ここで本当はunameの挙動を変更するのがベターなところですが、unameの実装をLivepatchするのはちょっと手間なので単純に弄ることができるgetpid(2)を使います。

まず~/rpmbuild/SOURCESに移動し、Livepatchの機能を有効にする設定を追加します。

[masami@unamebook ~]$ cd rpmbuild/SOURCES/
[masami@unamebook SOURCES]$ echo "CONFIG_LIVEPATCH=y" >> kernel-local
[masami@unamebook SOURCES]$ echo "CONFIG_TEST_LIVEPATCH=m" >> kernel-local

kernel-localファイルはこのようになります。

[masami@unamebook SOURCES]$ cat kernel-local 
# This file is intentionally left empty in the stock kernel. Its a nicety
# added for those wanting to do custom rebuilds with altered config opts.
CONFIG_LIVEPATCH=y
CONFIG_TEST_LIVEPATCH=m

Fedoraではkernel-localファイルにカーネルコンフィギュレーションを設定することでベースとなる設定ファイル(x86_64ならkernel-x86_64.config)に設定を追加できます。 設定を追加したら~/rpmbuild/SPECに移動し依存パッケージのインストールを行います。kernel-localファイルを作ったらあとはビルドするだけです。

[masami@unamebook SOURCES]$ cd ~/rpmbuild/SPECS/

先程はunametestという名称をカーネルパッケージに追加しましたが、今度はgetpidtestという名称を付けましょう。kernel.specを以下のように変更します。

# define buildid .local
%define buildid .getpidtest

specファイルを変更したらカーネルをビルドします。

[masami@unamebook SPECS]$  rpmbuild -bb --with baseonly --without debuginfo --without debug --target=$(uname -m) kernel.spec
~略~
+ cd kernel-5.3.fc31
+ /usr/bin/rm -rf /home/masami/rpmbuild/BUILDROOT/kernel-5.3.11-300.getpidtest.fc31.x86_64
+ RPM_EC=0
++ jobs -p
+ exit 0

ビルドが終了したら~/rpmbuild/RPMS/x86_64に移動してlsするとgetpidtestと名前の付いたrpmが出来ているのが確認できます。

[masami@unamebook x86_64]$ ls
kernel-5.3.11-300.getpidtest.fc31.x86_64.rpm       kernel-core-5.3.11-300.unametest.fc31.x86_64.rpm    kernel-modules-5.3.11-300.getpidtest.fc31.x86_64.rpm        kernel-modules-extra-5.3.11-300.unametest.fc31.x86_64.rpm
kernel-5.3.11-300.unametest.fc31.x86_64.rpm        kernel-devel-5.3.11-300.getpidtest.fc31.x86_64.rpm  kernel-modules-5.3.11-300.unametest.fc31.x86_64.rpm
kernel-core-5.3.11-300.getpidtest.fc31.x86_64.rpm  kernel-devel-5.3.11-300.unametest.fc31.x86_64.rpm   kernel-modules-extra-5.3.11-300.getpidtest.fc31.x86_64.rpm

新しくビルドしたrpmをインストールします。

[masami@unamebook x86_64]$ sudo dnf localinstall -y  kernel-*.getpidtest.*

次にgrubのエントリを確認します。

[masami@unamebook x86_64]$ sudo grubby --info=ALL | grep index -A1
index=0
kernel="/boot/vmlinuz-5.3.11-300.unametest.fc31.x86_64"
--
index=1
kernel="/boot/vmlinuz-5.3.11-300.getpidtest.fc31.x86_64"
--
index=2
kernel="/boot/vmlinuz-5.3.11-300.fc31.x86_64"
--
index=3
kernel="/vmlinuz-0-rescue-24b7d4741c7c4982889ac4d4acb27ae1"

getpidtestのカーネルはindexが1となっているのでこのカーネルで起動するようにします。

[masami@unamebook x86_64]$ sudo grub2-reboot 1

これで準備完了なので再起動します。再起動したらもちろんuname(1)で正しいカーネルで起動したか確認します😄

[masami@unamebook ~]$ uname -a
Linux unamebook 5.3.11-300.getpidtest.fc31.x86_64 #1 SMP Tue Nov 26 23:10:42 JST 2019 x86_64 x86_64 x86_64 GNU/Linux

カーネルビルド時にライブパッチ機能が有効になっていたか確認します。fedoraは/bootにカーネルのコンフィグが置かれるのでこれで確認できます。

[masami@unamebook ~]$ grep CONFIG_LIVEPATCH /boot/config-5.3.11-300.getpidtest.fc31.x86_64 
CONFIG_LIVEPATCH=y

ではライブパッチを作りましょう。カーネルのライブパッチはカーネルモジュールとして作成します。まず作業ディレクトリを作ります。

[masami@unamebook ~]$ mkdir getpid-livepatch-module

そして、getpid-livepatch.cを以下の内容で作成します。your nameのところは適当に変えてください。

#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/livepatch.h>
#include <linux/string.h>
#include <linux/sched.h>

MODULE_DESCRIPTION("getpid livepatch test module");
MODULE_AUTHOR("your name");
MODULE_LICENSE("GPL");
MODULE_INFO(livepatch, "Y");

asmlinkage long livepatch_getpid(void)
{
        pr_info("%s\n", __func__);
        
        if (unlikely(!strcmp(current->comm, "getpid-test")))
                return 1;

        return  task_tgid_vnr(current);
}

static struct klp_func funcs[] = {
        {
                .old_name = "__x64_sys_getpid",
                .new_func = livepatch_getpid,
        }, {}
};

static struct klp_object objs[] = {
        {
                .funcs = funcs,
        }, {}
};

static struct klp_patch patch = {
        .mod = THIS_MODULE,
        .objs = objs,
};

static int getpid_livepatch_init(void)
{
        int ret = 0;

        ret = klp_enable_patch(&patch);
        if (ret) {
                pr_info("failed to enable getpid live patch\n");
                return ret;
        }

        pr_info("getpid livepatch test module is enabled\n");
        return ret;
}

static void getpid_livepatch_cleanup(void)
{
}

module_init(getpid_livepatch_init);
module_exit(getpid_livepatch_cleanup);

このライブパッチではgetpid(2)を実行したプログラム名がgetpid-testの場合に1を返し、それ以外の場合は普通にpidを返しています。このファイルをビルドするのにMakefileが必要ですのでこれも作成します。Makefileのインデントはtabなんで気をつけてください。

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

obj-m := getpid-livepatch.o
smallmod-objs := getpid-livepatch.o

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

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

ビルドします。ビルドに成功するとgetpid-livepatch.koというファイルができます。

[masami@unamebook getpid-livepatch-module]$ make
make -C /lib/modules/`uname -r`/build M=/home/masami/getpid-livepatch-module KBUILD_VERBOSE=0 modules
make[1]: Entering directory '/usr/src/kernels/5.3.11-300.getpidtest.fc31.x86_64'
  CC [M]  /home/masami/getpid-livepatch-module/getpid-livepatch.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC      /home/masami/getpid-livepatch-module/getpid-livepatch.mod.o
  LD [M]  /home/masami/getpid-livepatch-module/getpid-livepatch.ko
make[1]: Leaving directory '/usr/src/kernels/5.3.11-300.getpidtest.fc31.x86_64'

次にテストプログラムを用意しましょう。getpid-test.cという名前でファイルを作り、以下の内容で保存します。

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char **argv)
{
        printf("pid is %d\n", getpid());
        return 0;
}

ビルドします。

[masami@unamebook getpid-livepatch-module]$ gcc getpid-test.c -o getpid-test

実行すると以下のようにpidが表示されます。

[masami@unamebook getpid-livepatch-module]$ ./getpid-test 
pid is 1435

では、ライブパッチのモジュールをロードします。

[masami@unamebook getpid-livepatch-module]$ sudo insmod ./getpid-livepatch.ko

dmesgコマンドでカーネルのログを見ると以下のような行があるのが確認できます。

[  959.659727] getpid_livepatch: loading out-of-tree module taints kernel.                                                                                                                                                                    
[  959.659729] getpid_livepatch: tainting kernel with TAINT_LIVEPATCH                                                                                                                                                                         
[  959.659787] getpid_livepatch: module verification failed: signature and/or required key missing - tainting kernel                                                                                                                          
[  959.668116] livepatch: enabling patch 'getpid_livepatch'                                                                                                                                                                                   
[  959.670655] livepatch: 'getpid_livepatch': starting patching transition                                                                                                                                                                    
[  959.670802] getpid_livepatch: getpid livepatch test module is enabled      

ただ、livepatch_getpid()の最初にpr_info()で関数名を表示させているので以下のようなログが大量に出てると思いますが。

[ 1042.927756] getpid_livepatch: livepatch_getpid

それはさておきgetpid-testを再度実行するとpidとして1が返ってきます。ということでカーネルのライブパッチ機能を使ったライブパッチも成功です🎉

[masami@unamebook getpid-livepatch-module]$ ./getpid-test 
pid is 1

プログラム名が違えばもちろんpidは普通に返ってきます。

[masami@unamebook getpid-livepatch-module]$ cp getpid-test getpid-test2
[masami@unamebook getpid-livepatch-module]$ ./getpid-test2
pid is 1455

おまけ

FedoraをupstreamとしているRHELCentOSuname(1)の挙動も紹介します。

RHEL 8.1

バージョンは8.1です。これでuname(1)を実行するとuname(2)が3回呼ばれているのがわかりますね。

[masami@rhel8 ~]$ cat /etc/os-release  | grep VERSION_ID
VERSION_ID="8.1"
[masami@rhel8 ~]$ strace -v -s 1024 -e trace=uname -C  uname -a
uname({sysname="Linux", nodename="rhel8", release="4.18.0-147.0.3.el8_1.x86_64", version="#1 SMP Mon Nov 11 12:58:36 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
uname({sysname="Linux", nodename="rhel8", release="4.18.0-147.0.3.el8_1.x86_64", version="#1 SMP Mon Nov 11 12:58:36 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
uname({sysname="Linux", nodename="rhel8", release="4.18.0-147.0.3.el8_1.x86_64", version="#1 SMP Mon Nov 11 12:58:36 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
Linux rhel8 4.18.0-147.0.3.el8_1.x86_64 #1 SMP Mon Nov 11 12:58:36 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
+++ exited with 0 +++
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    0.000068          22         3           uname
------ ----------- ----------- --------- --------- ----------------
100.00    0.000068      

coreutilsのバージョンは8.30です。

[masami@rhel8 ~]$ rpm -qi coreutils | grep -e Version -e Release
Version     : 8.30
Release     : 6.el8

CentOS 8

CentOSのバージョンは8です。こちらもuname(2)が3回呼ばれてますね。

masami@centos8 ~]$ cat /etc/os-release  | grep VERSION_ID
VERSION_ID="8"
[masami@centos8 ~]$ strace -v -s 1024 -e trace=uname -C  uname -a
uname({sysname="Linux", nodename="centos8", release="4.18.0-80.11.2.el8_0.x86_64", version="#1 SMP Tue Sep 24 11:32:19 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
uname({sysname="Linux", nodename="centos8", release="4.18.0-80.11.2.el8_0.x86_64", version="#1 SMP Tue Sep 24 11:32:19 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
uname({sysname="Linux", nodename="centos8", release="4.18.0-80.11.2.el8_0.x86_64", version="#1 SMP Tue Sep 24 11:32:19 UTC 2019", machine="x86_64", domainname="(none)"}) = 0
Linux centos8 4.18.0-80.11.2.el8_0.x86_64 #1 SMP Tue Sep 24 11:32:19 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
+++ exited with 0 +++
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    0.000013           4         3           uname
------ ----------- ----------- --------- --------- ----------------
100.00    0.000013                     3           total

coreutilsのバージョンはこちらも8.30です。

[masami@centos8 ~]$ rpm -qi coreutils | grep -e Version -e Release
Version     : 8.30
Release     : 6.el8

まとめ

最後はgetpid(2)を使いましたが、主にuname(1)とuname(2)を使ってユーザーランドでのデバッグからカーネルを弄るところまでを行いました(_´Д`)ノ~~オツカレー

Raspberry Piで学ぶコンピュータアーキテクチャ (Make:PROJECTS)

Raspberry Piで学ぶコンピュータアーキテクチャ (Make:PROJECTS)

*1:1というのはmanセクションの番号です。1は通常のコマンドです。

*2:カーネルバージョンにある#1というのはビルド環境にてビルドした回数です。

*3:システムコールのmanセクションは2です