Who runs the interpreter for files that are execute-only?

If the user has no read permission on an executable script, then trying to run it will fail, unless she has the CAP_DAC_OVERRIDE capability (eg. she's root):

$ cat > yup; chmod 100 yup
#! /bin/sh
echo yup
^D
$ ./yup
/bin/sh: 0: Can't open ./yup

The interpreter (whether failing or successful) will always run as the current user, ignoring any setuid bits or setcap extended attributes of the script.

Executable scripts are different from binaries in the fact that the interpreter should be able to open and read in order to run them. However, notice that they're simply passed as an argument to the interpreter, which may not try to read them at all, but do something completely different:

$ cat > interp; chmod 755 interp
#! /bin/sh
printf 'you said %s\n' "$1"
^D
$ cat > script; chmod 100 script
#! ./interp
nothing to see here
^D
$ ./script
you said ./script

Of course, the interpreter itself may be a setuid or cap_dac_override=ep-setcap binary (or pass down the script's path as an argument to such a binary), in which case it will run with elevated privileges and could ignore any file permissions.

Unreadable setuid scripts on Linux via binfmt_misc

On Linux you can bypass all the restrictions on executable scripts (and wreck your system ;-)) by using the binfmt_misc module:

As root:

# echo ':interp-test:M::#! ./interp::./interp:C' \
    > /proc/sys/fs/binfmt_misc/register

# cat > /tmp/script <<'EOT'; chmod 4001 /tmp/script # just exec + setuid
#! ./interp
id -u
EOT

As an ordinary user:

$ echo 'int main(void){ dup2(getauxval(AT_EXECFD), 0); execl("/bin/sh", "sh", "-p", (void*)0); }' |
    cc -include sys/auxv.h -include unistd.h -x c - -o ./interp
$ /tmp/script
0

Yuppie!

More information in Documentation/admin-guide/binfmt-misc.rst in the kernel source.

The -p option may cause an error with some shells (where it could be simply dropped), but is needed with newer versions of dash and bash in order to prevent them from dropping privileges even if not asked for.


Executing a script works in two phases. First the kernel reads the beginning of the file and sees that it starts with #!, so it reads the shebang line and determines what interpreter to call. The kernel then transforms the original command line into the path to the interpreter, the option(s)¹ on the shebang line if any, the path to the file and the original options. It then executes this command line, mostly as if this had been the command line all along, but without doing any further shebang processing.

So far, the kernel has checked that the caller has execute permission over the script file. Read permission has not come into play. The kernel reads the beginning of the file as part of executing it, so this is controlled by the execute permission, not by the read permission.

In the second phase, the interpreter is executed. Since it sees the script file name on its command line, it will presumably try to open it. This is the point at which read permission is necessary. If the interpreter doesn't have the permission to read the file, its open call will fail, and then presumably the interpreter will print an error message and give up. I say “presumably” because this is what every sensible interpreter does, but there is no technical obligation that things happen this way. If you use a program that isn't an interpreter on the shebang line, it doesn't make any difference in the first phase, but the program will do whatever it does in the second phase. For example, a “script” starting with #!/bin/echo will just print the script file name and any additional command line argument, then exit, and the script only needs to be executable for that.

¹ Many kernels, including Linux, only allow one option.


In general, scripts can not be executed without "r" permission, even if you own the file

$ ls -l tst
---x--x--x 1 sweh sweh 24 May  4 21:22 tst*

$ ./tst
/bin/bash: ./tst: Permission denied

$ sudo cat tst
#!/bin/bash

echo hello

With your edited question.

The #! part of the program is interpreted by the kernel as part of the exec() system call. So to get that far the script doesn't need to be readable.

Effectively what happens, in my example, is that the kernel converts my ./tst into a /bin/bash ./tst call.

This conversion explains why scripts need to have r access to be processed, but the kernel just needs x to determine the interpreter to be used.