たのしく学ぶLinuxカーネル開発(第一回): `rm -rf /`実行時にカーネルパニックさせる

公開:2020/09/25
更新:2020/09/25
14 min読了の目安(約8600字TECH技術記事

はじめに

Linuxカーネル開発を学ぶためにはやることがたくさんあります。よくあるのは個々の部品について学んで、その後に実際に役立つものを作っていく、というものです。ただし、これはちゃんとやれば身に付くことは身に付くのですが、非常に地味なので、よほどカーネルに興味を持っている人以外には退屈でしょう。そこで、目的をもって特定の機能をカーネルならではの方法で実現する記事を書けば面白いのでは…となったのでここに初回を書くことにしました。

対象読者はCライクなプログラミング言語での開発経験がある人です。Cのポインタがわかればなおよし。もしできればOSカーネルについての基本的な知識も欲しいです。

背景

UNIXが誕生してから現在に至るまでrm -rf /によって全ファイルをぶっ飛ばす事件が後をたちません。GNUのcoreutilsに入っているrmではルートディレクトリ("/")への操作を特別扱いして容易に悲劇を起こさなくするpreserve-rootというデフォルトで有効になるオプションもあります。しかし人間とはこういうときにもこの機能を無効にする--no-preserve-rootをうっかり付けててしまうのです。

そこでユーザがrm -rf /を実行しようとするとカーネルパニックさせる(Windowsでいうところのブルースクリーンを出す)フェイルセーフ機能を作ることにしました。

対象とするカーネル

  • linux v5.3

このカーネルのソースコードは以下コマンドによって取得できます。

$ git clone -b v5.3 --depth 1 https://github.com/torvalds/linux
...                    # 数GBのディスク容量を消費しますので、ご注意ください
$ git checkout v5.3
...
$ 

変更点

この機能の変更点を示すパッチファイル0001-panic-if-user-tries-to-run-rm-rf.patchの中身は次の通りです(内容は後で説明します)。ライセンスはGPL v2です。

From 27a3af9519c8b07c527bd48ef19b4baf9f6d4a9c Mon Sep 17 00:00:00 2001
From: Satoru Takeuchi <satoru.takeuchi@gmail.com>
Date: Sun, 6 Oct 2019 15:53:34 +0000
Subject: [PATCH] panic if user tries to run rm -rf /

---
 fs/exec.c | 37 +++++++++++++++++++++++++++++++++++++
 1 file changed, 37 insertions(+)

diff --git a/fs/exec.c b/fs/exec.c
index f7f6a140856a..8d2c1441b64c 100644
--- a/fs/exec.c
+++ b/fs/exec.c
@@ -1816,6 +1816,43 @@ static int __do_execve_file(int fd, struct filename *filename,
        if (retval < 0)
                goto out;

+       // Panic if user tries to execute `rm -rf /`
+       if (bprm->argc >= 3) {
+               struct page *page;
+               char *kaddr;
+               char rm_rf_root_str[] = "rm\0-rf\0/";
+               char buf[sizeof(rm_rf_root_str)];
+               int bytes_to_copy;
+               unsigned long offset;
+
+               bytes_to_copy = min(sizeof(rm_rf_root_str), bprm->p % PAGE_SIZE);
+               page = get_arg_page(bprm, bprm->p, 0);
+               if (!page) {
+                       retval = -EFAULT;
+                       goto out;
+               }
+               kaddr = kmap(page);
+               offset = bprm->p % PAGE_SIZE;
+               memcpy(buf, kaddr + offset, bytes_to_copy);
+               kunmap(page);
+               put_arg_page(page);
+
+               if (bytes_to_copy < sizeof(rm_rf_root_str)) {
+                       page = get_arg_page(bprm, bprm->p + bytes_to_copy, 0);
+                       if (!page) {
+                               retval = -EFAULT;
+                               goto out;
+                       }
+                       kaddr = kmap(page);
+                       memcpy(buf + bytes_to_copy, kaddr, sizeof(rm_rf_root_str) - bytes_to_copy);
+                       kunmap(page);
+                       put_arg_page(page);
+               }
+
+               if (!memcmp(rm_rf_root_str, buf, sizeof(rm_rf_root_str)))
+                       panic("`rm -rf /` is detected");
+       }
+
        would_dump(bprm, bprm->file);

        retval = exec_binprm(bprm);
--
2.17.1

動かし方

最初に一点注意。この機能は壊してもいいVM上でやりましょう。動作確認テストが失敗すると全ファイルがぶっ飛びかねませんし、成功してもダーティなページキャッシュに乗っているデータは失うことになります。

まずは次のようにパッチファイルを適用した上でビルド、インストール、再起動します。

$ git apply 0001-panic-if-user-tries-to-run-rm-rf.patch
...                                                 # 機能追加に必要なパッチを当てる
$ sudo apt install kernel-package flex bison libssl-dev
...                                                 # カーネルビルドに必要なパッケージをインストール
$ make localmodconfig
...                                                 # ビルドのための設定をする。何か聞かれたらENTERを押しまくる
$ make -j$(grep -c processor /proc/cpuinfo)
...                                                 # ビルドする
$ sudo make modules_install && make install
...                                                 # 新しいカーネルとそのモジュールをインストールする
$ sudo /sbin/reboot # GRUBで現在起動中のものなど特定のカーネルを次回起動するようになっている場合は適宜修正してください

再起動後にカーネルバージョンが変わっていることを確認します。

$ uname -r
5.3.0+
$ 

5.3.0+となっていれば成功です。最後の"+"はカスタムカーネルのときに勝手に付与されます。

最後に例のコマンドを実行します。

$ rm -rf /

これで応答が戻ってこなければ成功です。マシンのコンソールを見ている場合はパニック時のカーネルログが見られます。GUI上の端末エミュレータあるいはssh経由でコマンドを叩いた場合は単に画面が停止したように見えるでしょう。

参考までにわたしの環境での実行結果を載せておきます。

この機能はrm -rf /を誰が実行したかなどというものは考えておらず、ルートディレクトリ以下のファイルを消す権限が無い一般ユーザがこのコマンドを実行しても容赦なくカーネルパニックを起こしますのでご注意ください。

この後はマシンを再起動させて、必要ならば本記事によってインストールしたカーネルを削除したり、GRUBが使うデフォルトカーネルの変更をしたりしてください。

パッチの解説

まずパッチを当てる前のコードについて簡単に説明しておきます。

  1. ユーザプロセスが新規プログラムを実行しようとexecve()システムコールを呼ぶ
  2. カーネル内の上記システムコールを処理するハンドラ関数が動作しはじめて、その過程でパッチの中にもある__do_execve_file()関数を呼ぶ
    1. execve()システムコールに与えられたコマンドライン引数や環境変数についての情報を取り出してカーネルのメモリに読み込む。パッチ内のcopy_strings(bprm->argc, argv, bprm)はコマンドライン引数の内容に相当する
  3. execve()システムコールの実処理をする。現在動作中のプロセスを新しいプログラムで置き換えて、当該プログラムのエントリポイントから実行開始

このパッチは、3と4の間に、ユーザから渡されたexecve()システムコールの引数がrm -rf /であればカーネルパニックさせる処理を追加します。

ではパッチに行番号を振って説明します。

  1 From 27a3af9519c8b07c527bd48ef19b4baf9f6d4a9c Mon Sep 17 00:00:00 2001
  2 From: Satoru Takeuchi <satoru.takeuchi@gmail.com>
  3 Date: Sun, 6 Oct 2019 15:53:34 +0000
  4 Subject: [PATCH] panic if user tries to run rm -rf /
  5
  6 ---
  7  fs/exec.c | 37 +++++++++++++++++++++++++++++++++++++
  8  1 file changed, 37 insertions(+)
  9
 10 diff --git a/fs/exec.c b/fs/exec.c
 11 index f7f6a140856a..8d2c1441b64c 100644
 12 --- a/fs/exec.c
 13 +++ b/fs/exec.c
 14 @@ -1816,6 +1816,43 @@ static int __do_execve_file(int fd, struct filename *filename,
 15         if (retval < 0)
 16                 goto out;
 17
 18 +       // Panic if user tries to execute `rm -rf /`
 19 +       if (bprm->argc >= 3) {
 20 +               struct page *page;
 21 +               char *kaddr;
 22 +               char rm_rf_root_str[] = "rm\0-rf\0/";
 23 +               char buf[sizeof(rm_rf_root_str)];
 24 +               int bytes_to_copy;
 25 +               unsigned long offset;
 26 +
 27 +               bytes_to_copy = min(sizeof(rm_rf_root_str), bprm->p % PAGE_SIZE);
 28 +               page = get_arg_page(bprm, bprm->p, 0);
 29 +               if (!page) {
 30 +                       retval = -EFAULT;
 31 +                       goto out;
 32 +               }
 33 +               kaddr = kmap(page);
 34 +               offset = bprm->p % PAGE_SIZE;
 35 +               memcpy(buf, kaddr + offset, bytes_to_copy);
 36 +               kunmap(page);
 37 +               put_arg_page(page);
 38 +
 39 +               if (bytes_to_copy < sizeof(rm_rf_root_str)) {
 40 +                       page = get_arg_page(bprm, bprm->p + bytes_to_copy, 0);
 41 +                       if (!page) {
 42 +                               retval = -EFAULT;
 43 +                               goto out;
 44 +                       }
 45 +                       kaddr = kmap(page);
 46 +                       memcpy(buf + bytes_to_copy, kaddr, sizeof(rm_rf_root_str) - bytes_to_copy);
 47 +                       kunmap(page);
 48 +                       put_arg_page(page);
 49 +               }
 50 +
 51 +               if (!memcmp(rm_rf_root_str, buf, sizeof(rm_rf_root_str)))
 52 +                       panic("`rm -rf /` is detected");
 53 +       }
 54 +
 55         would_dump(bprm, bprm->file);
 56
 57         retval = exec_binprm(bprm);
 58 --
 59 2.17.1
 60

17行目の時点でexecve()の引数はカーネルのメモリに保存されています。ここからが本パッチの変更がはじまります。

検出したいコマンドはrm -rf /であり、かつ、このときの引数の数は3です。これに加えてbprm->argcにはexecve()に渡された引数の数が入っています。よってbprm->argc >= 3という条件のif文によって関係ないコマンド実行を弾いています。べつに>=ではなく==でもよかったのですが、rm -rf / fooとかやっても(このときの引数の数は4)ひどいことになるのには変わりがないのでこうしました。

コマンドライン引数はカーネルメモリ内の所定領域に、各引数をNULL文字('\0')で区切ったデータとして配置されています。たとえばechoコマンドにhelloという文字列を引数として実行した場合は"echo\0hello"というデータになります。コマンド名も引数の一つということに注意してください。rm -rf /の場合は"rm\0-rf\0/"になっているはずです。前述のif文の中身では22行目に「こうあるべき」なデータを置いて、それと実際の引数の値を51行目において比較して、マッチすれば52行目においてカーネルパニックさせています。

カーネルメモリ内からデータをとってくるのが少々やっかいです。これは27~49行目に対応します。必要とするデータはメモリ上の1ないし2ページ(CPUが仮想記憶という機能によってメモリを管理する単位。x86_64アーキテクチャにおいては4KB)にまたがって存在しています。殆どの場合は1ページに収まります。この場合は27行目から37行目だけで終わりです。2ページにまたがる場合は39行目から49行目を実行します。ここではデータが1ページにおさまっている場合についてのみ書きます。

27~37行目はそれぞれ次のような意味を持ちます。

  • 27行目: カーネルメモリからもってくるデータのサイズ。データが一ページに収まる場合は9バイト
  • 28~32行目: データが入っているページを指すpage構造体と呼ばれるデータを得る、およびそのエラー処理をする。このpage構造体は37行目において解放する
  • 33行目: ページ構造体が示すページのアドレスを得る。kmap()によって得たアドレスは36行目のように対応するkunmap()を呼ぶのがお約束
  • 34,35行目: 必要なデータをbufにコピー

駆け足で説明しましたがとりあえずフィーリングで読んでみてなんとなくわかればいいと思います。

おわりに

第一回と書きましたが、二回目以降があるかどうかは記事への反響と私のやる気次第です。