えびちゃんの日記

えびちゃん(競プロ)の日記です。

/dev/clairvoyance について

最近ツイートしたこの子です。

clairvoyance は、通常は知覚するのが不可能と考えられている情報を得る能力を指します。千里眼や透視などと訳されることもあります。 読まれるファイル側が読む側のコマンドを知覚するのは通常はあり得ないと考えられていそうなので、そういう名前をつけました。

身近な例

読むたびに内容が変わるファイルの例として、/dev/random というのがあります。ランダムなバイト列を返してくれます。

% xxd -l64 /dev/random
#> 00000000: 28c7 49f4 3e9c d693 753c 7b15 b146 5d47  (.I.>...u<{..F]G
#> 00000010: c0a0 2ba1 f4b1 aa32 8793 13da 8353 dd7b  ..+....2.....S.{
#> 00000020: b56e 03dc 6d85 6d78 8c93 f86f c3ad 21e6  .n..m.mx...o..!.
#> 00000030: 2743 a6f4 b8e6 c88d f42e 1680 9d7b ffda  'C...........{..

% xxd -l64 /dev/random
#> 00000000: e596 4416 c4ac 79c2 a48b f61f fffc 7938  ..D...y.......y8
#> 00000010: e74f 51a6 0a34 6d36 d143 0a59 c9e7 e1cd  .OQ..4m6.C.Y....
#> 00000020: b524 ffc6 ede4 1e1c e07d 349f eb39 70a6  .$.......}4..9p.
#> 00000030: c9d5 56ae 71b7 3a05 67e2 e2ab d785 fa69  ..V.q.:.g......i

これは実際には、(当然ながら)通常のファイルとは異なる機構で実装されています。 character special file とか character device と呼ばれるものです。

character device を実装する際には、struct file_operations の持つ read などのメンバ変数(関数ポインタ)として所望の処理を書きます。read は、ファイルに対して read の操作をしたときに呼ばれるものです。

第一歩

ファイル作成

ということで、自作したくなるのが世の常というものです。

まずは複雑な処理をせず、該当の機構を使いつつも固定の文字列を返すようなものを実装してみましょう。

hello.c

#include <linux/device.h>

#include <asm/errno.h>

static int device_open(struct inode*, struct file*);
static int device_release(struct inode*, struct file*);
static ssize_t device_read(struct file*, char __user*, size_t, loff_t*);
static ssize_t device_write(struct file*, char const __user*, size_t, loff_t*);

#define DEVICE_NAME "hello"

static int major;
static int minor = 0;

static char* devnode(const struct device* dev, umode_t* mode) {
  if (mode) {
    *mode = 0666;
  }
  return NULL;
}

static struct class cls = {
    .name = DEVICE_NAME,
    .devnode = devnode,
};

static struct file_operations fops = {
    .read = device_read,
    .write = device_write,
    .open = device_open,
    .release = device_release,
};

static int __init kinit(void) {
  major = register_chrdev(0, DEVICE_NAME, &fops);

  if (major < 0) {
    pr_alert("Registering char device failed with %d\n", major);
    return major;
  }

  int e;
  if ((e = class_register(&cls))) {
    pr_warn("class_register() returns %d\n", e);
    return e;
  }
  struct device* p =
      device_create(&cls, NULL, MKDEV(major, minor), NULL, DEVICE_NAME);
  pr_info("Device %s is created (%p)\n", DEVICE_NAME, p);
  return IS_ERR(p) ? PTR_ERR(p) : 0;
}

static void __exit kexit(void) {
  device_destroy(&cls, MKDEV(major, minor));
  class_unregister(&cls);
  unregister_chrdev(major, DEVICE_NAME);
}

static int device_open(struct inode* inode, struct file* file) {
  try_module_get(THIS_MODULE);
  return 0;
}

static int device_release(struct inode* inode, struct file* file) {
  module_put(THIS_MODULE);
  return 0;
}

static char message[] = "Hello.\n";
static size_t message_length = 7;

static ssize_t
device_read(struct file* file, char __user* buf, size_t count, loff_t* ppos) {
  if ((size_t)*ppos == message_length) {
    return 0;
  }

  if (count > message_length - (size_t)*ppos) {
    count = message_length - (size_t)*ppos;
  }
  unsigned long e = copy_to_user(buf, &message[*ppos], count);
  if (e) {
    pr_warn("copy_to_user() returns %lu\n", e);
    return -EFAULT;
  } else {
    *ppos += count;
    return count;
  }
}

static ssize_t device_write(struct file*, char const __user*, size_t, loff_t*) {
  return -EBADF;
}

module_init(kinit);
module_exit(kexit);

MODULE_LICENSE("GPL");

Makefile

KDIR = /lib/modules/`uname -r`/build

kbuild:
    make -C $(KDIR) M=`pwd`

clean:
    make -C $(KDIR) M=`pwd` clean

obj-m = hello.o

ビルド

ビルドのために必要なヘッダファイルを用意します。

% uname -r
#> 6.8.0-47-generic

これで出てきたものと同じバージョンのヘッダを用意する必要があります。

% curl -O https://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-headers-6.8.0-47_6.8.0-47.47_all.deb
% curl -O https://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-headers-6.8.0-47-generic_6.8.0-47.47_amd64.deb
% dpkg -i linux-headers-6.8.0-47_6.8.0-47.47_all.deb
% dpkg -i linux-headers-6.8.0-47-generic_6.8.0-47.47_amd64.deb

あとは make すればいいでしょう。

% make -C /lib/modules/`uname -r`/build M=`pwd`
#> make[1]: Entering directory '/usr/src/linux-headers-6.8.0-47-generic'
#> warning: the compiler differs from the one used to build the kernel
#>   The kernel was built by: x86_64-linux-gnu-gcc-13 (Ubuntu 13.2.0-23ubuntu4) 13.2.0
#>   You are using:           gcc-13 (Ubuntu 13.3.0-12ubuntu1) 13.3.0
#> make[1]: Leaving directory '/usr/src/linux-headers-6.8.0-47-generic'

カーネルをビルドするのに使われた gcc とバージョンが違うと警告が出ます。マイナーバージョンが違うくらいであれば問題ないんじゃないかなと思っています。

/usr/src/linux-headers-6.8.0-47-generic/.config に CONFIG_CC_VERSION_TEXT というのが記載されていました。

さて、次のようなファイルができているはずです。

.
├── Makefile
├── hello.c
├── hello.ko
├── hello.mod
├── hello.mod.c
├── hello.mod.o
└── hello.o

登録

バイスの登録をするためには、そういう権限が必要です。権限がないと次のようになります。cf. capabilities(7)

% insmod hello.ko
#> insmod: ERROR: could not insert module hello.ko: Operation not permitted

Docker で作業する際には --cap-add=CAP_SYS_MODULE というオプションをつけるとうまくいくと思います。

% insmod hello.ko

% cat /proc/devices | grep hello
238 hello

ここの 238 というのが、デバイスの番号(のうちの major と呼ばれる方)になります。 さて、/dev/hello という名前のファイルを作り、いま作ったデバイスに紐づけます。

% mknod -m 666 /dev/hello c 238 0

読んでみましょう。

% cat /dev/hello
#> cat: /dev/hello: Operation not permitted

読めませんでした。デバイスを読むにあたってもそういう権限が必要みたいです。 Docker で作業する際には、--device-cgroup-rule='c 238:0 rwm' というオプションをつけるとよい気がします。

% cat /dev/hello
#> Hello.

読めました。

自作

調査

さて、そういう処理を入れていきます。

なんとかして、open された際に open してきたプログラムを特定したいです。 /proc/self 配下のファイル*1を見ると下記のようになります。

% cat -A /proc/self/cmdline
#> cat^@-A^@/proc/self/cmdline^@

% cat -A /proc/self/comm   
#> cat$

なので、カーネル側がなにかをしている部分でコマンドライン引数が取得できそうということは推測できます。

current というポインタ(実際には変数ではなくマクロで、get_current() という関数が呼ばれている*2)が指す構造体の中に、それっぽいものがあります。

current->comm が /proc/self/comm で使われるものなのですが、char comm[TASK_COMM_LEN]; と定義されていて、TASK_COMM_LEN は 16 です。長いファイル名(ファイル名の上限は 255 文字)の場合は途中で打ち切られてしまいます。

なので、/proc/self/cmdline のための処理をしている箇所を探しに行きます。

current->mm->arg_start の位置に、該当の文字列がありそうです。pr_info などを使うことで、dmesg にログを残せるのでそうしましょう。

unsigned long arg_start = current->mm->arg_start;
pr_info("current->mm->arg_start: 0x%016lX\n", arg_start);
pr_info("current->mm->arg_start: %p\n", (void*)arg_start);
pr_info("current->mm->arg_start: %s\n", (char*)arg_start);
% /usr/bin/cat /dev/clairvoyance /proc/self/maps
#:
#> 7ffc7f887000-7ffc7f8a8000 rw-p 00000000 00:00 0                          [stack]
#:

% dmesg | tail
#:
#> [...] current->mm->arg_start: 0x00007FFC7F8A7EFD
#> [...] current->mm->arg_start: 000000008e4d4edd
#> [...] current->mm->arg_start: /usr/bin/cat

これにより、次のことがわかります。

  • current->mm->arg_start はスタック領域を指している
  • current->mm->arg_start には、いわゆる argv[0] が入っている
    • cat になったりはせず、指定した /usr/bin/cat のまま
    • ここには書いていないが、これの続きに argv[1], ... もある
  • %p でポインタを出力すると難読化されている

難読化に関しては、以下に記述があります。SipHash のハッシュ値を生成しているみたいです。

ハッシュ化を無効化すると下記のログが出るの、wow という感じでした。

**********************************************************
**   NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE   **
**                                                      **
** This system shows unhashed kernel memory addresses   **
** via the console, logs, and other interfaces. This    **
** might reduce the security of your system.            **
**                                                      **
** If you see this message and you are not debugging    **
** the kernel, report this immediately to your system   **
** administrator!                                       **
**                                                      **
**   NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE   **
**********************************************************

ハッシュ化の過程で ptr_key という値を使っており、これはランダムに初期化されたグローバルな値です。 カーネルのシンボルたちのアドレスを取得する方法があるようなので、それを使いつつ値を見ちゃいます。

% cat /proc/kallsyms | grep -w ptr_key
#> ffffffff9ea567b0 d ptr_key
unsigned long* p = (void*)0xffffffff9ea567b0;
pr_info("ptr_key = {0x%016lX, 0x%016lX}", p[0], p[1]);
[...] ptr_key = {0xB1EF2D2DECDE711F, 0xF06A52701443DF17}

こうして手に入れた ptr_key を使って siphash_1u64 の処理をすると、上記と同じハッシュ値が得られることが確かめられます。実際には、上記のハッシュ値は下位 32 bits のみ取り出されていることに注意しましょう。

さて、clairvoyance するにあたって必要な情報はもう得られたので、実装していきます。

ファイル

clairvoyance.c

#include <linux/device.h>

#include <asm/errno.h>

static int device_open(struct inode*, struct file*);
static int device_release(struct inode*, struct file*);
static ssize_t device_read(struct file*, char __user*, size_t, loff_t*);
static ssize_t device_write(struct file*, char const __user*, size_t, loff_t*);

#define DEVICE_NAME "clairvoyance"

static int major;
static int minor = 0;

enum {
  CDEV_NOT_USED,
  CDEV_EXCLUSIVE_OPEN,
};

static atomic_t cdev_status = ATOMIC_INIT(CDEV_NOT_USED);
static char message[266] = "Hello, *{255}.\n";
static size_t message_length = 0;

static char* devnode(const struct device* dev, umode_t* mode) {
  if (mode) {
    *mode = 0666;
  }
  return NULL;
}

static struct class cls = {
    .name = DEVICE_NAME,
    .devnode = devnode,
};

static struct file_operations fops = {
    .read = device_read,
    .write = device_write,
    .open = device_open,
    .release = device_release,
};

static int __init kinit(void) {
  major = register_chrdev(0, DEVICE_NAME, &fops);

  if (major < 0) {
    pr_alert("Registering char device failed with %d\n", major);
    return major;
  }

  int e;
  if ((e = class_register(&cls))) {
    pr_warn("class_register() returns %d\n", e);
    return e;
  }
  struct device* p =
      device_create(&cls, NULL, MKDEV(major, minor), NULL, DEVICE_NAME);
  pr_info("Device %s is created (%p)\n", DEVICE_NAME, p);
  return IS_ERR(p) ? PTR_ERR(p) : 0;
}

static void __exit kexit(void) {
  device_destroy(&cls, MKDEV(major, minor));
  class_unregister(&cls);
  unregister_chrdev(major, DEVICE_NAME);
}

static int device_open(struct inode* inode, struct file* file) {
  if (atomic_cmpxchg(&cdev_status, CDEV_NOT_USED, CDEV_EXCLUSIVE_OPEN)) {
    return -EBUSY;
  }

  char* filename = (char*)current->mm->arg_start;
  for (char* p = filename; *p;) {
    char c = *p++;
    if (c == '/') {
      filename = p;
    }
  }

  snprintf(message, sizeof message, "Hello, %.255s.\n", filename);
  message_length = strlen(message);
  try_module_get(THIS_MODULE);
  return 0;
}

static int device_release(struct inode* inode, struct file* file) {
  atomic_set(&cdev_status, CDEV_NOT_USED);
  module_put(THIS_MODULE);
  memset(message, 0, sizeof message);
  return 0;
}

static ssize_t
device_read(struct file* file, char __user* buf, size_t count, loff_t* ppos) {
  if ((size_t)*ppos == message_length) {
    return 0;
  }

  if (count > message_length - (size_t)*ppos) {
    count = message_length - (size_t)*ppos;
  }
  unsigned long e = copy_to_user(buf, &message[*ppos], count);
  if (e) {
    pr_warn("copy_to_user() returns %lu\n", e);
    return -EFAULT;
  } else {
    *ppos += count;
    return count;
  }
}

static ssize_t device_write(struct file*, char const __user*, size_t, loff_t*) {
  return -EBADF;
}

module_init(kinit);
module_exit(kexit);

MODULE_LICENSE("GPL");

copy_to_user の処理や atomic_t に関しては割愛します。

ビルドなど

Makefile については、obj-m だけ変えておきます。 同じ流れで make して、insmodmknod などをします。

書き換えてビルドし直した場合は rmmod clairvoyance をしてから insmod clairvoyance.ko をする必要があります。 rm /dev/clairvoyance をしたり mknod をし直したりする必要はないみたいです。

なにかしらのミスで変なことになった場合は、dmesg を叩くとログが見れます。権限としては --cap-add=CAP_SYSLOG が必要になると思います。

その他

/dev/grow も同じような機構で実装しています。clairvoyance より簡単なのに grow の方がふぁぼが多くて、世の中そういうもんだよな〜という気持ちになりました。

ギャラリー

cat

grep

less

パイプに通す例

変数展開の例

ランダム生成した長い名前の例

暗号化する例

REPL の例

カラフルだとたのしい

ハッシュ値クイズ

OGP

参考文献

あとがき

興味深さ優先探索型人間なので、気になったことを調べてこういうことになりがちです。どういう経緯でこれを始めたか覚えていません。思い出します。

malloc について調べていて、torvalds/linux に迷い込んで SYSCALL_DEFINE1(brk, unsigned long, brk) とかを調べて、なんやかんやで execve(2) を調べ始めて、ELF について調べて、libc や ld-linux-x86-64.so.2 に依存しないようなバイナリが欲しくなったりして、アセンブリを書き始めて、strace たのしいな〜となり、IntelAMD64機械語のマニュアルを読んだりして、そういえば AVX-512 のエミュレート環境があったらうれしいなとなって colima 上で Intel SDE を動かしてみて、そういえば(少しバックトラックして)そういう本の存在を思い出して ゼロからのOS自作入門 を買ったりしました。 また、フォロヮの影響で [試して理解]Linuxのしくみ を買ったりしました。

上記の過程やそれ以前に目にした概念で、まだよくわかっていないものが諸々あって、signal とか handler とか、vDSO (virtual dynamic shared object) とか vsystable とか、vtable とか、TLB (translation lookahead buffer) とか MMU (memory management unit) とか、どのレイヤーでどういうことが起きているんですか?というので、調べたり調べようとしたりしています。

(お気楽大学生みたい)

たぶんそういう過程で「結局 /dev/random ってどうなってるの?」みたいになって、“character device” “how to create /dev/null” などでググりつつ、下記を見ました。

“kernel object” というそれっぽいキーワードを見つけたので、“kernel object 101” とかでググって解説記事を探したりしました。 一度それっぽいキーワードを見つけると、その後の調査が捗りますね。こういう第一歩のパートには LLM を使うのもありかもしれません*3

で、せっかくならなにか面白そうな挙動をするものが作れたらうれしいよねとなり、grow や clairoyance を書きました。 clairvoyance 自体は(今回のトピックとしては主題っぽさはありつつ)別に副産物でしかなくて、カーネルのコードを読むことに抵抗が減りつつある方が大きいのかなという気もしています。

「どう調べたら知りたいことが知れるのかな?」「得た知識の妥当っぽさをどうしたら確かめられるかな?」みたいなことを考えたり実行したりするのが好きなような気がします。

そういえば、SipHash に関して調べていて Serious Cryptography を買ったりもしました。 crypto もあんまりわかっていない領域なので、詳しくなりたいですよね。 (以前やった)浮動小数点型とかに関してもそうですが、人々があまり知りたがろうとしていなさそうな部分かつまともな教材があまりなさそうな分野に惹かれます。 変な怪しい学問にハマったりしないか心配ですね*4

競プロも衰えない程度には続けていようと思っているものの、ABC で頭の悪いムーブをしたりすると萎えますね。 「えびちゃんってさすがにもっと頭よくなかった? よくあってくれ」のような気持ちになりがちですね。嫌すぎます。

残念ながら使命感駆動とか金銭駆動で勉強できるタイプではないので、これからも興味駆動で、のほほんとやっていけたらうれしいな〜という気持ちです。

おわり

おわりです

*1:これも同じような機構で実装されています。コード的には fs/proc 配下に書かれています。

*2:可読性やばくない?

*3:えびちゃん的には、第一歩よりも後のフェーズで LLM を使っても、いまいちたのしい気持ちになれません。

*4:昨今の LLM を盲信している人々の方がえびちゃん的にはどうかと思いますけどね。