FreeBSD-12.2ではディレクトリに対するread(2)がエラーとして扱われるようになった話

遅くなってしまいましたが、[FreeBSD Advent Calendar 2020](https://qiita.com/advent-calendar/2020/freebsd) 11日目の記事です。
今日は[FreeBSD-12.1 Release](https://www.freebsd.org/releases/12.2R/relnotes.html)ではディレクトリに対する[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&sektion=2&manpath=freebsd-release-ports)がエラーとして扱われる挙動に変更されたという話をしようと思います。

## 背景

[FreeBSD-12.1のリリースノート](https://www.freebsd.org/releases/12.2R/relnotes.html)を眺めていると、以下のような記述を見つけました。どうやらデフォルトでは[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&sektion=2&manpath=freebsd-release-ports)によるディレクトリの読み取りが禁止となる挙動に変更されたようです。

> The read(2) system call has been changed to disable read() calls on directories by default. A new sysctl(8) has been added, security.bsd.allow_read_dir, which when set to 1 will restore the previous behavior.

まずは試してみましょう。FreeBSD-12.1とFreeBSD-12.2のそれぞれの環境でディレクトリに対して[cat(1)](https://www.freebsd.org/cgi/man.cgi?query=cat&apropos=0&sektion=1&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)してみましょう。

FreeBSD-12.1の場合は、ディレクトリを[cat(1)](https://www.freebsd.org/cgi/man.cgi?query=cat&apropos=0&sektion=1&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)すると何やらデータが表示されます。

```sh
$ # FreeBSD-12.1の場合。
$ freebsd-version -ku
12.1-RELEASE
12.1-RELEASE
$
$ # ディレクトリがcatできる。
$ # (制御コードも表示されるのでstringsでフィルタしています)
$ cat /tmp | strings
        .X11-unix
        .XIM-unix
        .ICE-unix
.font-unix
```

これに対し、FreeBSD-12.2ではディレクトリを[cat(1)](https://www.freebsd.org/cgi/man.cgi?query=cat&apropos=0&sektion=1&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)してもエラーとして処理されるという挙動になっています。

```sh
$ # FreeBSD-12.2の場合。
$ freebsd-version -ku
12.2-RELEASE
12.2-RELEASE
$
$ # エラーとして処理されています。
$ cat /tmp
cat: /tmp: Is a directory
```

ちなみに、Linuxの場合もディレクトリに対する[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&sektion=2&manpath=freebsd-release-ports)はエラーとして処理されます。

```sh
$ cat /etc/redhat-release
CentOS Linux release 7.8.2003 (Core)
$
$ cat /tmp
cat: /tmp: ディレクトリです
```

## もう少し踏み込んで調べてみる

コマンドでの振る舞いについては把握できましたが、どのような挙動でエラーになっているのか気になります。[ktrace(1)](https://www.freebsd.org/cgi/man.cgi?query=ktrace&apropos=0&sektion=1&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)を使用して、ディレクトリを[cat(1)](https://www.freebsd.org/cgi/man.cgi?query=cat&apropos=0&sektion=1&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)した時の内部的な振る舞いを見てみましょう。

```sh
$ ktrace cat /tmp
cat: /tmp: Is a directory
$
$ kdump -f ktrace.out
...中略...
  1755 cat      CALL  openat(AT_FDCWD,0x7fffffffee04,0<O_RDONLY>)
  1755 cat      NAMI  "/tmp"
  1755 cat      RET   openat 3
...中略...
  1755 cat      CALL  read(0x3,0x80064c000,0x1000)
  1755 cat      RET   read -1 errno 21 Is a directory
  1755 cat      CALL  write(0x2,0x7fffffffde60,0x5)
```

なるほど、[openat(2)](https://www.freebsd.org/cgi/man.cgi?query=openat&apropos=0&sektion=2&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)で指定したディレクトリをオープンしてファイルディスクリプタを取得、その後に[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&apropos=0&sektion=2&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)でデータを読みだそうとした時に `errno 21` が返されるという挙動になっています。

`errno 21` が示すエラー内容を確認すると `EISDIR` のマクロ定数となっていました。

```sh
$ find /usr/include/ -type f -name errno.h | xargs grep -w 21 | grep define
/usr/include/sys/errno.h:#define        EISDIR          21              /* Is a directory */
```

つまりはFreeBSD-12.2では、ディレクトリに対する[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&apropos=0&sektion=2&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)は `EISDIR` を返すという挙動に変更されているという話のようです。

## sysctlでディレクトリに対するread(2)の挙動を変更する

もう一度[FreeBSD-12.1のリリースノート](https://www.freebsd.org/releases/12.2R/relnotes.html)を見てみると以下の記述があります。どうやら[sysctl(8)](https://www.freebsd.org/cgi/man.cgi?query=sysctl&apropos=0&sektion=8&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)で `security.bsd.allow_read_dir` というsysctl変数を `1` に設定することで、FreeBSD-12.1以前の挙動に戻すことができるようです。

> A new sysctl(8) has been added, security.bsd.allow_read_dir, which when set to 1 will restore the previous behavior.

さっそく試してみましょう。まずは `security.bsd.allow_read_dir` の説明を確認してみます。これまで確認してきたように、[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&apropos=0&sektion=2&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)によるディレクトリの読み込みを有効・無効化するためのsysctl変数となっています。

```sh
$ sysctl -d security.bsd.allow_read_dir
security.bsd.allow_read_dir: Enable read(2) of directory for filesystems that support it
```

実際に `security.bsd.allow_read_dir=1` を設定すると、FreeBSD-12.1以前の挙動(＝ディレクトリが[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&apropos=0&sektion=2&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)できるようになる)に戻ります。

```sh
$ sysctl security.bsd.allow_read_dir
security.bsd.allow_read_dir: 0
$ cat /tmp
cat: /tmp: Is a directory
$ sudo sysctl -w security.bsd.allow_read_dir=1
security.bsd.allow_read_dir: 0 -> 1
$ sysctl security.bsd.allow_read_dir
security.bsd.allow_read_dir: 1
$ cat /tmp | strings
        .X11-unix
        .XIM-unix
        .ICE-unix
.font-unix
```

## ソースコードの変更箇所を見てみる

より深く調べてみましょう。これら一連の変更は[リビジョン 363016](https://svnweb.freebsd.org/base/stable/12/sys/kern/vfs_vnops.c?r1=363017&r2=363016&pathrev=363017)で行われたようです。

変更箇所は[sys/kern/vfs_vnops.c](https://github.com/freebsd/freebsd/blob/master/sys/kern/vfs_vnops.c)となっています。一連の変更箇所の中から、今回の挙動に関連する部分をピックアップして見てみます。

`struct fileops.fo_read` に[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&apropos=0&sektion=2&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)に対応する `vn_io_fault()` という関数が設定されています。

```c
 106 struct  fileops vnops = {
 107     .fo_read = vn_io_fault,
 108     .fo_write = vn_io_fault,
 ...
 122 };
```

`vfs_allow_read_dir` という変数が[sysctl(8)](https://www.freebsd.org/cgi/man.cgi?query=sysctl&apropos=0&sektion=8&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)で設定した `security.bsd.allow_read_dir` に対応しています。

```c
 135 static int vfs_allow_read_dir = 0;
 136 SYSCTL_INT(_security_bsd, OID_AUTO, allow_read_dir, CTLFLAG_RW,
 137     &vfs_allow_read_dir, 0,
 138     "Enable read(2) of directory for filesystems that support it");
```

`vn_io_fault()` においては、1162行～1173行が[リビジョン 363017](https://svnweb.freebsd.org/base/stable/12/sys/kern/vfs_vnops.c?r1=363017&r2=363016&pathrev=363017)で追加された内容となっています。修正内容としてはシンプルで、 `vp->v_type == VDIR` ならディレクトリに対する読み込み処理であるため、変数 `vfs_allow_read_dir` が `0` の場合に `EISDIR` を返すという処理になっています。

```c
1149 static int
1150 vn_io_fault(struct file *fp, struct uio *uio, struct ucred *active_cred,
1151     int flags, struct thread *td)
1152 {
 ...
1162     /*
1163      * The ability to read(2) on a directory has historically been
1164      * allowed for all users, but this can and has been the source of
1165      * at least one security issue in the past.  As such, it is now hidden
1166      * away behind a sysctl for those that actually need it to use it.
1167      */
1168     if (vp->v_type == VDIR) {
1169         KASSERT(uio->uio_rw == UIO_READ,
1170             ("illegal write attempted on a directory"));
1171         if (!vfs_allow_read_dir)
1172             return (EISDIR);
1173     }
```

## その他：コミットログに歴史的な経緯が記載されている

BSD系ではディレクトリが[cat(1)](https://www.freebsd.org/cgi/man.cgi?query=cat&apropos=0&sektion=1&manpath=FreeBSD+12.2-RELEASE+and+Ports&arch=default&format=html)できるがLinuxではそうではない、というのはある意味トリビア(？)的な情報となっていました。
(ちなみに[Solaris10](https://ja.wikipedia.org/wiki/Solaris)では `cat /tmp` はエラーになるようです...)

このあたりの歴史的な経緯は[リビジョン 363017](https://svnweb.freebsd.org/base?view=revision&revision=363017)のコミットログに詳しく記載されています。

## まとめ

[FreeBSD-12.1 Release](https://www.freebsd.org/releases/12.2R/relnotes.html)において[read(2)](https://www.freebsd.org/cgi/man.cgi?query=read&sektion=2&manpath=freebsd-release-ports)によるディレクトリの読み込みがデフォルトでは禁止となったという話をしました。修正内容的には大きな修正ではありませんが、歴史的な経緯を踏まえると挙動を以前のものに戻せる仕組みを入れるといった、影響範囲を考慮した設計・実装が必要となるようです。