この記事は「Linux Advent Calendar 2022」の21日目です!空いていたのでいれさせていただきました!!:waiwai:
概要
タイトルが全てですがある一般ユーザで実行されているプロセスがwrite権限を持ってい状態でオープンしたファイルへは別ターミナルから権限を落とすようにchownされても書き込みを続けることができるという話です。
実験
$ id uid=1000(ryu) gid=1000(ryu) groups=1000(ryu)
というユーザーが
ls -l test -rw------- 1 ryu ryu 5 Dec 20 23:14 test
というファイルに対して
package main import ( "fmt" "os" "time" ) func main() { filename := "./test" f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) if err != nil { panic(err) } defer f.Close() fmt.Printf("pid=%d, fd=%d", os.Getpid(), f.Fd()) for { i, err := f.WriteString("text\n") if err != nil { panic(err) } if i == 5 { fmt.Println("ok") continue } break } }
を実行しているときにファイルのパーミッションを変えてみる。OKが出続けてwriteが失敗している様子は確認できなかった。同じようなコードでread版も試してみたが同じく失敗はしなかった
vfsあたりの実装を見てみる
VFS 層の手続きであるvfs_readあたりを眺めてみる。chownが実行されればinodeの情報が更新されるはずだが確かにinode自体を見に行っているわけでは無さそうというのがわかる。vfs_writeの方でも同様の実装となっていてopen(2)で行われた権限チェックで成功していれば以降はreopenしない限りは最初の権限のままファイルオペレーションを実施できる。もちろんその先のオペレーションでEPERMを返す事もできる(ssize_tはsignedなので)。
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { ssize_t ret; if (!(file->f_mode & FMODE_READ)) // ファイルがREADモードであるかを見ている return -EBADF; if (!(file->f_mode & FMODE_CAN_READ)) // return -EINVAL; if (unlikely(!access_ok(buf, count))) return -EFAULT; ret = rw_verify_area(READ, file, pos, count); if (ret) return ret; if (count > MAX_RW_COUNT) count = MAX_RW_COUNT; if (file->f_op->read) ret = file->f_op->read(file, buf, count, pos); // fs固有の処理を呼び出すまでにpermissionは見ていない else if (file->f_op->read_iter) ret = new_sync_read(file, buf, count, pos); else ret = -EINVAL; if (ret > 0) { fsnotify_access(file); add_rchar(current, ret); } inc_syscr(current); return ret; }
ext2の実装を追っている記事を見てもこの先でpermissionを見ていることは無さそうであった。このへんまで頑張るなら拡張属性とかSELinuxの出番になるのだろうか?
truncateは権限チェックをする
inode自体を書き換えるような操作は当然行うことができない。chownとかchmodができないのは感覚的にもあっていそうだがvfs_truncateでもEPERMが返ってくるケースがあるようなので少し???となった。先程のコードの中でtruncate(2)をする処理を追記するとchownしたタイミングから失敗するようになる。
truncate(2)ではチェックしてるのにread/writeではしてないのは何故だろうと考えてみたがよくわからなかった。truncate(2)はinodeを更新することで実現しているからチェックが必要なのはわかるがwriteでチェックしてないのは何故だろう?(mmapしてメモリで操作して〜みたいなことをやればvfs_writeのチェックは回避できるのでそこでやる意味が少ないとかでしょうか...セキュリティよくわからない...)
long vfs_truncate(const struct path *path, loff_t length) { struct user_namespace *mnt_userns; struct inode *inode; long error; inode = path->dentry->d_inode; /* For directories it's -EISDIR, for other non-regulars - -EINVAL */ if (S_ISDIR(inode->i_mode)) return -EISDIR; if (!S_ISREG(inode->i_mode)) return -EINVAL; error = mnt_want_write(path->mnt); if (error) goto out; mnt_userns = mnt_user_ns(path->mnt); error = inode_permission(mnt_userns, inode, MAY_WRITE); // inode自体のパーミッションを見に行っている if (error) goto mnt_drop_write_and_out; error = -EPERM; // 別ターミナルで操作されていれば権限エラーで返す if (IS_APPEND(inode)) goto mnt_drop_write_and_out; error = get_write_access(inode); if (error) goto mnt_drop_write_and_out; /* * Make sure that there are no leases. get_write_access() protects * against the truncate racing with a lease-granting setlease(). */ error = break_lease(inode, O_WRONLY); if (error) goto put_write_and_out; error = security_path_truncate(path); if (!error) error = do_truncate(mnt_userns, path->dentry, length, 0, NULL); put_write_and_out: put_write_access(inode); mnt_drop_write_and_out: mnt_drop_write(path->mnt); out: return error; }
POSIX
POSIX 1003.1 では、権限が不十分な場合に [EACCES] エラーを返す必要があるとのこと。読み込みや書き込み時の場合は特に言及が無いらしい。(ちゃんと読めてないので後で読む)
nfsはopen成功してもreadとかwriteで権限エラーになる
manに書いてあるがUIDマッピング使ってるファイルシステムの場合はそうなるという話。
UID マッピングを使用している NFS ファイルシステムでは、 open() がファイルディスクリプターを返した場合でも read(2) が EACCES で拒否される場合がある。 これはクライアントがアクセス許可のチェックを行って open() を実行するが、読み込みや書き込みの際には サーバーで UID マッピングが行われるためである。