揭秘 Shebang (#!):Kernel 探险之旅
Bruno Croci 我撰写关于编程、游戏开发、黑客项目、创意代码、摄影以及我喜欢的任何想法。
揭秘 Shebang (#!):Kernel 探险之旅
2025年4月7日
从我第一次创建 shell 脚本的经历中,我了解到了 shebang (#!
),这是一个特殊的首行,用于指定执行脚本的解释器:
#! /usr/bin/sh
echo "Hello, World!"
这样你就可以直接使用 ./hello.sh
调用它,它将使用指定的解释器运行,前提是该文件具有执行权限。
当然,shebang 不仅限于 shell 脚本;你可以将其用于任何脚本类型:
#! /usr/bin/python3
print("Hello, World!")
这特别有用,因为许多捆绑的 Linux 实用程序实际上是脚本。 感谢 shebang,你无需显式调用它们的解释器。 例如,有两个(非常令人困惑的)程序可以在 Linux 上创建用户:useradd
和 adduser
。 其中一个是实际的程序,将在系统中创建用户,另一个是实用程序,将创建用户,主目录并为你配置用户。 由于我从未记住哪个是哪个,因此一个好的检查方法是使用实用程序 file
:
$ file $(which useradd)
/usr/sbin/useradd: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2 (...)
$ file $(which adduser)
/usr/sbin/adduser: Perl script text executable
好的,我们知道 addser
是我们要使用的工具,因为它更用户友好,并且通常在添加用户时执行你期望的操作。 是的,如果你检查它的开头:
$ head -n 1 /usr/sbin/adduser
#! /usr/bin/perl
我一直以为 shell 使用 shebang 作为提示,但这是不正确的! 此功能实际上由 Linux Kernel 直接处理。
跟踪 kernel 执行
跟踪 Linux 中任何可执行文件的一个好方法是使用 strace
,它跟踪进程发出的所有系统调用:
$ strace ./test.sh
execve("./test.sh", ["./test.sh"], 0x7ffed15d9828 /* 33 vars */) = 0
brk(NULL) = 0x59aea5a28000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x78ee2be49000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
(...)
有趣的是,对 test.sh
的调用直接进入 execve
,这是从文件开始运行程序的系统调用。 这意味着 kernel 本身 负责查找 正确的解释器并执行它。
如果我们开始深入研究 kernel 代码,我们可以看到 execve
系统调用的入口点是 fs/exec.c
中的 do_execveat_common
函数。 它首先创建一个 struct linux_binprm *bprm;
,意思是“binary program”,然后执行一些检查,最后调用 bprm_execve
:
retval = bprm_execve(bprm);
bprm_execve
然后继续执行 exec_binprm
,然后最终调用 search_binary_handler
。 此函数负责识别文件的可执行格式。 它以 retval = prepare_binprm(bprm)
开头,然后执行该函数,我们意识到它实际上是将文件的部分内容复制到 bprm->buf
中:
/*
* Fill the binprm structure from the inode.
* Read the first BINPRM_BUF_SIZE bytes
*
* This may be called multiple times for binary chains (scripts for example).
*/
static int prepare_binprm(struct linux_binprm *bprm)
{
loff_t pos = 0;
memset(bprm->buf, 0, BINPRM_BUF_SIZE);
return kernel_read(bprm->file, bprm->buf, BINPRM_BUF_SIZE, &pos);
}
BINPRM_BUF_SIZE
在 include/linux/binfmts.h
中是 256。
然后它继续浏览格式列表,并检查当前程序是哪一个:
list_for_each_entry(fmt, &formats, lh) {
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);
retval = fmt->load_binary(bprm);
read_lock(&binfmt_lock);
put_binfmt(fmt);
if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
read_unlock(&binfmt_lock);
return retval;
}
}
这些格式模块是:
- binfmt_elf.c
- binfmt_elf_fdpic.c
- binfmt_flat.c
- binfmt_misc.c
- binfmt_script.c
它们都负责注册自己,以便 search_binary_handler
测试它们中的每一个。 我们知道 ELF 是 Linux 使用的常规二进制格式,ELF FDPIC 是 ELF 的扩展,FLAT 二进制文件只是没有任何特定系统配置的指令(此问题 做了一些解释),SCRIPT 是解释我们的 shebang 的格式,但是真正引起我注意的是 MISC。
此 Kernel 功能允许你通过简单地在 shell 中键入其名称来调用几乎(对于限制,请参见下文)每个程序。 这包括例如编译的 Java(TM),Python 或 Emacs 程序。 为此,你必须告诉 binfmt_misc 哪个解释器必须与哪个二进制文件一起调用。 Binfmt_misc 通过将文件开头的某些字节与你提供的幻数字节序列(屏蔽掉指定的位)匹配来识别二进制文件类型。 Binfmt_misc 还可以识别文件名扩展名,例如
.com
或.exe
。
这是另一种告诉 Kernel 在调用非本地程序 (ELF) 时要运行哪个解释器的方法。 对于脚本(文本文件),我们主要使用 shebang,但对于字节编码的二进制文件(例如 Java 的 JAR 或 Mono EXE 文件),这是首选方式!
回到我们的 shebang 调查,让我们检查 fs/binfmt_script.c
。 检查文件末尾附近的注册机制会发现一些关键信息:
core_initcall(init_script_binfmt);
module_exit(exit_script_binfmt);
MODULE_DESCRIPTION("Kernel support for scripts starting with #!");
MODULE_LICENSE("GPL");
有模块描述(是的,shebang 不是一个正式术语),然后是一个指向 init_script_binfmt
的 core_initcall
调用:
static int __init init_script_binfmt(void)
{
register_binfmt(&script_format);
return 0;
}
它注册了 script_format
对象,该对象定义如下:
static struct linux_binfmt script_format = {
.module = THIS_MODULE,
.load_binary = load_script,
};
当我们检查 load_script
函数时,就找到了:
static int load_script(struct linux_binprm *bprm)
{
const char *i_name, *i_sep, *i_arg, *i_end, *buf_end;
struct file *file;
int retval;
/* Not ours to exec if we don't start with "#!". */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
return -ENOEXEC;
(...)
检查就在那里!
此函数的注释非常好,详细说明了几乎每个步骤,因此我建议在此处阅读源代码 here。 本质上,它读取第一行,解析解释器路径(和任何参数),打开解释器的可执行文件,并将对其的引用分配给 bprm->interpreter
。
回到 exec_binprm
中,它将检查是否找到了解释器(来自脚本或 misc 二进制格式),然后:
(...)
ret = search_binary_handler(bprm);
if (ret < 0)
return ret;
if (!bprm->interpreter)
break;
exec = bprm->file;
bprm->file = bprm->interpreter;
bprm->interpreter = NULL;
exe_file_allow_write_access(exec);
if (unlikely(bprm->have_execfd)) {
if (bprm->executable) {
fput(exec);
return -ENOEXEC;
}
bprm->executable = exec;
} else
fput(exec);
(...)
如果 找到 解释器,则 bprm->file
将更新为指向解释器的文件(替换脚本文件),并且原始脚本文件 (exec
) 的引用计数会通过 fput(exec)
递减。
因此,脚本文件上的单个 execve
系统调用会触发 kernel:打开脚本,检测 #!
,查找并打开指定的解释器,最后加载并执行 解释器,将脚本路径作为参数传递。 kernel 有效地将进程映像替换为解释器的进程映像。
但是我可以在没有 #! 的情况下运行 shell 脚本
这是真的。 你真的不需要 #! 来运行 shell 脚本,但那是 shell 实现的后备机制,而不是 kernel。 例如,如果你尝试 strace 缺少 shebang 的 shell 脚本的执行:
$ cat test.sh
echo "Hello, World!"
$ ./test.sh
Hello, World!
$ strace ./test.sh
execve("./test.sh", ["./test.sh"], 0x7ffd9a1afcf0 /* 33 vars */)= -1 ENOEXEC (Exec format error)
strace: exec: Exec format error
+++ exited with 1 +++
它将失败并显示 ENOEXEC (Exec format error)
,因为没有该文件的格式指示。
为了观察 shell 的后备行为,我们可以跟踪调用脚本的 新 shell 实例。 我们使用 sh -c './test.sh'
来确保子 shell 尝试 execve
,而不是父 shell 直接解释脚本。 我们将使用带有 -f
的 strace
(跟踪子进程)并过滤关键的系统调用:
strace -e trace=execve,openat,read,close -f sh -c "./test.sh"
如果在 test.sh
中有 #!
,它将返回:
$ cat test.sh
#! /usr/bin/sh
echo "Hello, World!"
$ strace -e trace=execve,openat,read,close -f sh -c "./test.sh"
execve("/usr/bin/sh", ["sh", "-c", "./test.sh"], 0x7ffd51f86418 /* 33 vars */) = 0
(...)
strace: Process 2522303 attached
[pid 2522303] execve("./test.sh", ["./test.sh"], 0x5ec40c994540 /* 33 vars */) = 0
(...)
[pid 2522303] openat(AT_FDCWD, "./test.sh", O_RDONLY) = 3
[pid 2522303] close(3) = 0
[pid 2522303] read(10, "#! /usr/bin/sh\necho \"Hello, Worl"..., 8192) = 36
Hello, World!
[pid 2522303] read(10, "", 8192) = 0
[pid 2522303] +++ exited with 0 +++
(...)
如果没有找到 #!
:
$ cat test.sh
echo "Hello, World!"
$ strace -e trace=execve,openat,read,close -f sh -c "./test.sh"
execve("/usr/bin/sh", ["sh", "-c", "./test.sh"], 0x7ffd4de7e798 /* 33 vars */) = 0
(...)
strace: Process 2524967 attached
[pid 2524967] execve("./test.sh", ["./test.sh"], 0x651ce522f540 /* 33 vars */) = -1 ENOEXEC (Exec format error)
[pid 2524967] openat(AT_FDCWD, "./test.sh", O_RDONLY|O_NOCTTY) = 3
[pid 2524967] read(3, "echo \"Hello, World!\"\n", 128) = 21
[pid 2524967] close(3) = 0
[pid 2524967] execve("/bin/sh", ["/bin/sh", "./test.sh"], 0x651ce522f540 /* 33 vars */) = 0
(...)
[pid 2524967] openat(AT_FDCWD, "./test.sh", O_RDONLY) = 3
[pid 2524967] close(3) = 0
[pid 2524967] read(10, "echo \"Hello, World!\"\n", 8192) = 21
Hello, World!
[pid 2524967] read(10, "", 8192) = 0
[pid 2524967] +++ exited with 0 +++
(...)
过滤输出后,很明显,在第一种情况(带有 shebang)下,它正在为我们正在创建的 shell 实例执行初始 execve
,然后为 test.sh
执行另一个 execve
,并执行我们之前描述的所有过程。 在第二种情况(没有 shebang)下,子进程对 ./test.sh
的 execve
失败并显示 ENOEXEC
。 父 shell (sh -c
) 捕获了此错误。 然后,它可能使用 openat
和 read
来检查文件。 检测到它可能是一个 shell 脚本,然后它通过 第二个 execve
调用显式执行 /bin/sh ./test.sh
。
额外内容:处理权限
我们发现 kernel 假设脚本包含 #!
并且 设置了执行权限,从而通过其自身的 execve
系统调用运行脚本。 但是在哪里检查该权限呢?
如果我们尝试调用没有执行权限的脚本,我们将得到:
$ ./test.sh
zsh: permission denied: ./test.sh
但它没有给出太多信息。 但是,如果我们再次 strace
它:
$ strace ./test.sh
execve("./test.sh", ["./test.sh"], 0x7ffd2b4a52d0 /* 33 vars */)= -1 EACCES (Permission denied)
strace: exec: Permission denied
+++ exited with 1 +++
它从系统调用返回错误代码和描述:EACCES (Permission denied)
。 错误代码始终是一个好的起点。 在 fs/exec.c
中搜索 EACCES
会将我们引导到 do_open_execat
函数中的检查
if (WARN_ON_ONCE(!S_ISREG(file_inode(file)->i_mode)) ||
path_noexec(&file->f_path))
return ERR_PTR(-EACCES);
从 do_open_execat
回溯调用堆栈,我们发现它是在 do_execveat_common
中 bprm
结构的设置期间被调用的,do_execveat_common
是 execve
系统调用的入口点:
bprm = alloc_bprm(fd, filename, flags);
if (IS_ERR(bprm)) {
retval = PTR_ERR(bprm);
goto out_ret;
}
现在,了解 path_noexec
如何检查文件中的 执行 权限涉及很多其他内容,例如了解 kernel 如何处理文件系统。 但那将是未来的帖子。
编辑
- 将
where
的用法切换为which
,因为它是一个仅限 zsh 的命令。 这些添加到我的令人困惑的命令列表中,就像adduser
和useradd
一样。 感谢 u/pihkal。 - 我已将 Linux 的 “传统二进制格式” 更正为 “常规二进制格式”。 尽管 ELF 多年来一直是常规格式,但将其称为传统格式可能不正确。 谢谢 /u/Admqui。 一些关于 ELF 和旧
a.out
格式的资料:
加入 Reddit 上的讨论。 标签:kernel-adventures , linux , investigation , c , os