読み取り権限がなく実行権限だけのファイルが実行できるのはなぜ? - カーネルのソースを読む -

きっかけはこのツイート。

実際にやってみるとわかるけど、実行権限だけついてるファイルは実行可能です。でも、「読み込めないのに実行できる」というのは直感に反するような気もしますね。だって、実行するためにはプログラムをメモリに読み込む必要がありますから!ではなぜ実行権限だけのファイルが実行できるのか、その仕組みを解説します。

実行とはなにか、どういう仕組みなのか

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_flagsacc_modeMAY_EXECを立ててるのにMAY_READとかを立ててないのがどう考えてもクサい。これはもうopen_flagsdo_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_openatopen_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);

opdo_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_modemay_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システムコールのときには実行権限しかチェックされない」ということになってることが確認できました。めでたしめでたし!

追記

id:ozumaちなみにシェルスクリプトは読み込み権限も無いとダメ(Shenbangが読めないから)

ブコメにあるとおり、インタプリタが実行するやつは実行フラグだけでは実行できません。

たとえば、以下のような Ruby スクリプトに実行権限だけを与えて実行しても、

#!/usr/bin/env ruby
p "nyan"

ruby: Permission denied -- ./nyan.rb (LoadError) となります。

正確にはShebangを読んでインタプリタを実行するところまではできるんだけど、そのインタプリタスクリプトを読み込もうとするタイミングでread権限が必要になるので実行できない、という流れになります。

さらに追記

id:unsoluble_sugar 仕組みはわかったけど何故この仕様にしたのかが理解できない

id:tmurakam 実装がどうなってるかじゃなくて、どうしてそういう仕様にしたのかのほうが重要だと思うのだけど

ざっとmanを洗ってみましたが、公式の理由はみつけられませんでした。けど、読み取り権限さえあれば、プログラムをコピーしてきて実行権限つけて実行することは可能なわけで、そうなると仮に実行権限に読み取り権限が必須という仕様であったら、実行権限って無意味なフラグになりますよね。それを考えたら合理的な仕様であると私は感じました。あくまで私の見解ですが。

と思ったけど、今でも r-- の場合は実行しようと思えば可能ですね。ここで --x の場合のことを考えると、やはり「リバースエンジニアリング防止」くらいの意味でありそうですね。