きっかけはこのツイート。
基礎的なことなんだろうけど理解できてないこと。
読み取り権限のない実行権限だけのファイルってどういう扱いになるんだろう。
— ゑぬぽい改@電探が出(ん)たん? (@NPoi) March 27, 2014
実際にやってみるとわかるけど、実行権限だけついてるファイルは実行可能です。でも、「読み込めないのに実行できる」というのは直感に反するような気もしますね。だって、実行するためにはプログラムをメモリに読み込む必要がありますから!ではなぜ実行権限だけのファイルが実行できるのか、その仕組みを解説します。
実行とはなにか、どういう仕組みなのか
Linux において実行とは「forkしてexecする」です(そのへんの詳しい話は プロセスさん を読もう!)。
fork も exec もシステムコール(正確には execve がシステムコールで exec はそのフロントエンドだけど)ですね。カンが良ければこのへんで「あーなるほどね」となると思います。
結論を先に言うと、execシステムコールはファイルの実行権限だけをチェックして、読み込み権限はチェックしないから。ということになります。
もう少し踏み込んで説明すると、何かをメモリに読み込んだりするためには、システムコールを介する必要があります。例えば、あるテキストファイルをメモリ内に読み込みたかったら、open システムコールでファイルディスクリプタを得たあと、そのファイルディスクリプタを使って read システムコールを呼びます。こうすることで、ファイルの内容をメモリに読み込むことができるわけですね。
さて、open システムコールには、「読み込み用に open したいんだよね」とか「書き込み用に open したいんだよね」というフラグを渡す必要がありますね。すると open システムコールはその中でそのフラグを見て、「そのファイルの読みこみ権限(あるいは書き込み権限)持ってんのお前?」とチェックしてくれます。つまり、読み込み権限や書き込み権限のチェックは open システムコール内で行われているわけですね。
話をexecに戻しましょう。さきほども言ったように、プログラムを実行するためには、その内容をユーザー空間のメモリに読み込まなければなりません。しかし、実行ファイルの内容をメモリに読み込むのに使われるシステムコールは、 open システムコールではなく exec システムコールです。その際に exec システムコールが「そのファイルの実行権限持ってんのお前?」というのをチェックして、実行ファイルをメモリ内に読み込んでくれるわけですね。
これが「読み取り権限がなく実行権限だけのファイルが実行できるのはなぜ?」の答えです。
こっから先は「もっと深入りしたいぜ」ってひと向けです。
manを確認する
exec のmanに以下のような記述があります。
このマニュアルで説明されている関数は execve(2) のフロントエンドである。 (現在のプロセスイメージの置き換えについての詳細は execve(2) のマニュアルを参照)
というわけで、execve がその実態ですね。そこで execveのman を読むと、以下のような記述があります。
Error
EACCES
ファイルやスクリプトや ELF インタプリタに 実行許可 (execute permission) が与えられていない。
というわけで、「実行というのはforkしてexecである」ということを知っていれば、manを見ただけでもexecのときには実行権限だけを見るのか〜なるほど〜と納得できます。
じゃあ実際のカーネルのソースはどうなってるの
でも、せっかくなのでなので execve のカーネルのソースまで追って行ってみましょう。現時点での最新stable であるところの linux-3.13.7 を見ます。execveの実態はfs/exec.c
にあるdo_execve_common
のようですね。1449行目です。必要な部分を抜粋します。
static int do_execve_common(const char *filename, struct user_arg_ptr argv, struct user_arg_ptr envp) { struct linux_binprm *bprm; struct file *file; struct files_struct *displaced; bool clear_in_exec; int retval; /* snip */ file = open_exec(filename); retval = PTR_ERR(file); if (IS_ERR(file)) goto out_unmark;
今回の関心は exec されるファイルの権限についてなので、第一引数の filename
に注目しましょう。すると、1494行目で open_exec()
を呼んでファイルを取得しているのが見つかります。ものすごくあやしいですね。注目しつつ、続きを読みましょう。open_exec
の返り値を見て、エラーだったらgoto out_unmak;
しています。これはアレですね、C言語でよくやる「エラーの場合、残りの処理すっとばしてgoto
で後始末の処理にすっ飛んじゃう」ってやつですね。try catch finaly がない C 言語でよく見るテクニックです(だから話題になったgoto fail;
も、gotoそのものが悪とは言いづらいところがあると思うんですよわたしは。おっと、話がそれた)。やはりopen_exec
がキモっぽいです。ではopen_exec
を探しましょう。
同じfs/exec.c
の中にありました。752行目です。
struct file *open_exec(const char *name) { struct file *file; int err; struct filename tmp = { .name = name }; static const struct open_flags open_exec_flags = { .open_flag = O_LARGEFILE | O_RDONLY | __FMODE_EXEC, .acc_mode = MAY_EXEC | MAY_OPEN, .intent = LOOKUP_OPEN, .lookup_flags = LOOKUP_FOLLOW, }; file = do_filp_open(AT_FDCWD, &tmp, &open_exec_flags); if (IS_ERR(file)) goto out;
あっやっぱりこれっぽいですね。do_filp_open
に渡してるopen_flags
のacc_mode
にMAY_EXEC
を立ててるのにMAY_READ
とかを立ててないのがどう考えてもクサい。これはもうopen_flags
がdo_filp_open
内でどう扱われているかについて調べるしかない。fs/namei.c
の3215行目。
struct file *do_filp_open(int dfd, struct filename *pathname, const struct open_flags *op) { struct nameidata nd; int flags = op->lookup_flags; struct file *filp; filp = path_openat(dfd, pathname, &nd, op, flags | LOOKUP_RCU); if (unlikely(filp == ERR_PTR(-ECHILD))) filp = path_openat(dfd, pathname, &nd, op, flags); if (unlikely(filp == ERR_PTR(-ESTALE))) filp = path_openat(dfd, pathname, &nd, op, flags | LOOKUP_REVAL); return filp; }
path_openat
にopen_flags
であるop
を渡してますね。path_openat
は同じファイルの3144行目。
static struct file *path_openat(int dfd, struct filename *pathname, struct nameidata *nd, const struct open_flags *op, int flags) { struct file *base = NULL; struct file *file; struct path path; int opened = 0; int error; /* (snip) */ error = do_last(nd, &path, file, op, &opened, pathname);
op
はdo_last
に渡されています。do_last
は同じファイルの2852行目。
static int do_last(struct nameidata *nd, struct path *path, struct file *file, const struct open_flags *op, int *opened, struct filename *name) { struct definalize_updatentry *dir = nd->path.dentry; int open_flag = op->open_flag; bool will_truncate = (open_flag & O_TRUNC) != 0; bool got_write = false; int acc_mode = op->acc_mode; struct inode *inode; bool symlink_ok = false; struct path save_parent = { .dentry = NULL, .mnt = NULL }; bool retried = false; int error; /* (snip) */ error = may_open(&nd->path, acc_mode, open_flag);
変数の初期化時にacc_mode
というローカル変数にop->acc_mode
を格納してます。今回のようにopen_exec
から飛んできたならここにMAY_EXEC | MAY_OPEN
が入ってるわけですね。で、このacc_mode
はmay_open
に渡されています。同じファイルの2502行目です。
static int may_open(struct path *path, int acc_mode, int flag) { struct dentry *dentry = path->dentry; struct inode *inode = dentry->d_inode; int error; /* (snip) */ error = inode_permission(inode, acc_mode); if (error) return error; }
inode_permission
、ぜったいこいつだ。ここでパーミッションを見てるに違いない。同じファイルの438行目。
/** * inode_permission - Check for access rights to a given inode * @inode: Inode to check permission on * @mask: Right to check for (%MAY_READ, %MAY_WRITE, %MAY_EXEC) * * Check for read/write/execute permissions on an inode. We use fs[ug]id for * this, letting us set arbitrary permissions for filesystem access without * changing the "normal" UIDs which are used for other things. * * When checking for MAY_APPEND, MAY_WRITE must also be set in @mask. */ int inode_permission(struct inode *inode, int mask) { int retval; retval = sb_permission(inode->i_sb, inode, mask); if (retval) return retval; return __inode_permission(inode, mask); }
コメントみると「そのinodeのread/write/executeパーミッションをチェックするぜ」って書いてありますね。見てきたとおり、execシステムコールの場合MAY_EXEC
のビットは立っているがMAY_READ
は立っていないので、実行権限があるかどうかはチェックされるが、読み込み権限についてはチェックされない、ということになります。
というあたりで、これ以上の深入りは「じゃあinodeのパーミッションチェックの実装ってどうなってるの」って話に突っ込んじゃうので今回はここまで読んで満足しておきましょう。
というわけで、カーネルのソースを読んだらやっぱり「execシステムコールのときには実行権限しかチェックされない」ということになってることが確認できました。めでたしめでたし!
追記
とブコメにあるとおり、インタプリタが実行するやつは実行フラグだけでは実行できません。
たとえば、以下のような Ruby スクリプトに実行権限だけを与えて実行しても、
#!/usr/bin/env ruby p "nyan"
ruby: Permission denied -- ./nyan.rb (LoadError)
となります。
正確にはShebangを読んでインタプリタを実行するところまではできるんだけど、そのインタプリタがスクリプトを読み込もうとするタイミングでread権限が必要になるので実行できない、という流れになります。
さらに追記
id:unsoluble_sugar 仕組みはわかったけど何故この仕様にしたのかが理解できない
id:tmurakam 実装がどうなってるかじゃなくて、どうしてそういう仕様にしたのかのほうが重要だと思うのだけど
ざっとmanを洗ってみましたが、公式の理由はみつけられませんでした。けど、読み取り権限さえあれば、プログラムをコピーしてきて実行権限つけて実行することは可能なわけで、そうなると仮に実行権限に読み取り権限が必須という仕様であったら、実行権限って無意味なフラグになりますよね。それを考えたら合理的な仕様であると私は感じました。あくまで私の見解ですが。
と思ったけど、今でも r-- の場合は実行しようと思えば可能ですね。ここで --x の場合のことを考えると、やはり「リバースエンジニアリング防止」くらいの意味でありそうですね。