玩转二进制格式

作者:Alessandro Rubini

内核模块可以实现的一个角色是向运行中的系统添加新的二进制格式。“二进制格式”基本上是一个数据结构,负责执行程序文件——那些标记为可执行权限的文件。我将要介绍的代码在 2.0 版本的内核中运行。

内核模块旨在为 Linux 系统添加新功能,设备驱动程序是最著名的此类“功能”。 事实上,Linux 内核的高度模块化设计允许运行时插入许多设备驱动程序以外的功能——几个月前,我们看到了模块化代码如何创建 /proc 文件和 sysctl 入口点。

另一种可加载的功能是执行二进制格式的能力;这包括可执行文件和共享库。虽然加载编译后的程序文件和共享库的机制非常复杂,但普通的 Linux 用户可以轻松添加加载器,以调用新二进制格式的解释器。因此,用户可以通过名称调用数据文件并在对其调用 chmod +x 后“执行”它们。

文件如何被执行

让我们首先讨论一下 exec 系统调用在 Linux 中的实现方式。 这是内核中一个有趣的部分,因为执行程序的能力是系统运行的基础。

exec 的入口点位于源文件的架构相关树中,但所有有趣的代码都是 fs/exec.c 的一部分(此处的所有路径名都指向 /usr/src/linux/ 或您的源代码位置)。要检查特定于架构的详细信息,请键入命令

arch/*/kernel/*.c

在 fs/exec.c 中,顶层函数 do_execve() 的代码长度不到五十行。它的作用是检查错误,填充“二进制参数”结构 (struct linux_binprm) 并查找二进制处理程序。最后一步由 search_binary_handler() 执行,它是同一文件中的另一个函数。do_execve() 的魔力包含在最后一个函数中,该函数非常简短。它的工作包括扫描已注册的二进制格式列表,并将 binprm 结构传递给所有二进制格式,直到其中一个成功为止。 如果没有处理程序能够处理可执行文件,则内核会尝试通过 kerneld 加载新的处理程序,并再次扫描列表。 如果没有二进制格式能够运行可执行文件,则系统调用返回 ENOEXEC 错误代码(“Exec format error”)。

这种实现方式的主要问题在于保持 Linux 与标准 Unix 行为的兼容性。 也就是说,任何以 #! 开头的可执行文本文件都必须由它要求的解释器执行,而任何其他可执行文本都由 /bin/sh 运行。 前一个问题很容易通过专门运行解释器文件的二进制格式 (fs/binfmt_script.c) 来处理,而解释器本身是通过再次调用 search_binary_handler() 来运行的。此函数被设计为可重入的,并且 binfmt_script 检查是否被双重调用。后一个问题主要是历史遗留问题,内核只是忽略了它。尝试执行该文件的程序会处理它。这样的程序通常是您的 shell 或 make。有趣的是,虽然最新版本的 gmake 在脚本没有前导 #! 行时可以正确执行,但以前的版本不会调用 shell,从而导致从 Makefile 中运行未修饰的脚本时出现“cannot execute binary file”消息。

替换旧可执行映像的新映像所需的所有数据结构的神奇处理都由特定的二进制加载器执行,它基于内核导出的实用程序函数。如果您想查看此类代码,fs/binfmt_aout.c 中的函数 load_out_binary() 是一个很好的起点——比 ELF 加载器更容易。

二进制格式的注册

exec 的实现是有趣的代码,但 Linux 提供了更多功能:在运行时注册新的二进制格式。 实现非常简单,尽管它涉及到处理相当复杂的数据结构——代码或数据结构必须适应底层复杂性;复杂的数据结构比复杂的代码提供更大的灵活性。

二进制格式的核心在内核中由一个名为 struct<\!s>linux_binfmt 的结构表示,它在 linux/binfmts.h 文件中声明如下

struct linux_binfmt {
        struct linux_binfmt *next;
        long *use_count;
        int (*load_binary)(struct linux_binprm *,
                struct pt_regs *);
        int (*load_shlib)(int fd);
        int (*core_dump)(long signr,
                struct pt_regs *);
        };

二进制格式声明的三个函数或“方法”用于执行程序文件、加载共享库和创建核心文件。 next 指针由 search_binary_handler() 使用,而 use_count 指针跟踪模块的使用计数。每当进程 p 在模块化二进制格式的范围内执行时,内核都会跟踪 *(p->binfmt->use_count) 以防止意外删除模块。

然后,模块使用以下函数来加载和卸载自身

extern int register_binfmt(struct linux_binfmt *);
extern int unregister_binfmt(struct linux_binfmt *);

这些函数接收单个参数而不是通常的指针、名称对,因为 /proc 目录中没有文件列出可用的二进制格式。 因此,加载和卸载二进制格式的典型代码与以下代码一样简单

int init_module (void) {
  return register_binfmt(&bluff_format);
}
void cleanup_module(void) {
  unregister_binfmt(&bluff_format);
}
前面的行属于 bluff 模块(最终谬误格式的二进制加载器),其源代码可从 ftp://ftp.linuxjournal.com/pub/lj/listings/issue45/2568.tgz 公开下载。

表示二进制格式的结构可以将它提供的任何函数声明为 NULL;NULL 函数会被内核简单地忽略。 因此,最简单的二进制格式如下所示,这也是 bluff 模块使用的格式

struct linux_binfmt bluff_format = {
        NULL, &mod_use_count_, /* next, count */
        NULL, NULL, NULL    /* bin, lib, core */
};

是的,bluff 确实 是虚张声势;您可以随意加载和卸载它,但它绝对不做任何事情。

二进制参数

为了实现一些有用的二进制格式,程序员必须掌握一些关于传递给加载函数的参数的背景信息,即 format->load_binary。 第一个这样的参数包含二进制文件和参数的描述,第二个参数是指向处理器寄存器的指针。

第二个参数仅由 真正的 二进制加载器需要,例如作为 Linux 内核源代码一部分的 a.out 和 ELF 格式。 当内核用新的可执行文件替换可执行文件时,它必须将与当前进程关联的寄存器初始化为正常状态。 特别是,指令指针必须设置为新程序必须开始执行的地址。 内核导出函数 start_thread 以简化指令指针的设置。 在本文中,我不会深入到描述真正的加载器,而会将讨论限制在“包装器”二进制格式,类似于 binfmt_script 和 binfmt_java。

另一方面,即使是简单的加载器也必须使用 linux_binprm 结构,因此值得在此处描述。 该结构包含以下字段

  • char buf[128]: 此缓冲区保存可执行映像的前几个字节。 通常每个二进制格式都会查找它以检测文件类型。 如果您对用于检测不同文件格式的已知魔数感到好奇,可以查看文本文件 /usr/lib/magic(有时称为 /etc/magic)。

  • unsigned long page[MAX_ARG_PAGES]: 此数组保存用于携带新程序的环境和参数列表的数据页的地址。 这些页面仅在使用时才分配;当环境和参数列表很小时,不会浪费内存。 宏 MAX_ARG_PAGES 在 binfmts.h 标头中声明,目前设置为 32(128KB,Alpha 上为 256KB)。 如果您在尝试运行大型 grep 时收到消息“Arg list too long”,则需要扩大 MAX_ARG_PAGES

  • unsigned long p: 这是指向刚刚描述的页面中保存的数据的“指针”。 数据从高地址推送到低地址的页面,并且 p 始终指向此类数据的开头。 二进制格式可以使用指针来操作传递给正在执行的程序的初始参数,我将在下一节中展示这种用法。 有趣的是,p 是指向用户空间地址的指针,它表示为 unsigned long 以避免对其值进行不必要的解引用。 当地址表示通用数据(或内存“数组”中的偏移量)时,内核通常将其视为长整数。

  • struct inode *inode: 此 inode 表示正在执行的文件。

  • int e_uid, e_gid: 这些字段是执行程序的进程的有效用户 ID 和组 ID。 如果程序是 set-uid,则这些字段表示新值。

  • int argc, envc: 这些值表示传递给新程序的参数数量和环境变量的数量。

  • char *filename: 这是正在执行的程序的完整路径名。 此字符串存在于内核空间中,并且是 execve 系统调用接收的第一个参数。 尽管用户程序不会知道其完整路径名,但二进制格式可以使用该信息,因此他们可以玩弄参数列表。

  • int dont_iput: 二进制格式可以设置此标志,以告知上层 inode 已被加载器释放。

该结构还包含其他与简单二进制格式的实现无关的字段。 另一方面,相关的是 exec.c 导出的一对函数。 这些函数旨在帮助简单的二进制加载器的工作,例如我将在本文中介绍的那些。

unsigned long copy_strings(int argc,char ** argv,
        unsigned long *page, unsigned long p,
        int from_kmem);
void remove_arg_zero(struct linux_binprm *bprm);

第一个函数负责将 argc 字符串从数组 argv 复制到指针 p(用户空间指针,通常为 bprm->p)。 字符串将在指针 p 指向的地址之前复制(参数字符串向下增长)。 原始字符串,即 argv 中的字符串,可以驻留在用户空间或内核空间中,即使字符串存储在用户空间中,数组也可以在内核空间中。 from_kmem 参数用于指定原始字符串和数组是否都在用户空间 (0)、都在内核空间 (2) 或字符串在用户空间而数组在内核空间 (1)。 remove_arg_zero 通过递增 bprm->pbprm 中删除第一个参数。

示例实现:显示图像

为了将理论转化为实践,让我们尝试将我们的 bluff 扩展为 bloom(用于惊人炫耀模块的二进制加载器)。 新模块的完整源代码与 bluff 一起分发。

bloom 的作用是显示可执行图像。 给您的 GIF 图像执行权限并加载模块,然后像调用命令一样调用您的图像,xv 将显示它。

此代码既不是特别原创(大部分来自 binfmt_script.c),也不是特别智能(像我这样的纯文本用户宁愿使用 ASCII 查看器,例如,其他人更喜欢不同的查看器)。 我觉得这种例子无论如何都很有启发意义,并且任何可以运行 X 服务器并且具有 root 访问权限以加载模块的计算机的人都可以轻松运行它。

源文件由 50 多行代码组成,能够执行 GIF、TIFF 和各种 PBM 格式;不用说,您必须提前给您的图像执行权限 (chmod +x)。 查看器在加载时可配置,默认为 /usr/X11R6/bin/xv。 这是从我的文本控制台复制的示例会话

# insmod bloom.o
# ./snowy.tif
xv: Can't open display
# rmmod bloom
# insmod bloom.o viewer="/bin/cat"
# ./snowy.tif | wc -c
1067564

如果您使用默认查看器并在图形会话中工作,您的图像文件将在显示器上绽放。

如果您迫不及待地想下载源文件,您可以在清单 1 中看到 bloom 的有趣部分。请注意,bloom.c 属于 GPL,因为其大部分代码是从 binfmt_script.c 复制的。

清单 1. bloom.c 的核心

kerneld 注册格式

我听到您问的下一个问题是“我如何设置,以便 kerneld 可以自动加载我的模块?”

嗯,实际上并非总是可能的。 fs/exec.c 中的代码仅在至少前四个字节之一不可打印时才尝试使用 kerneld。 此行为旨在避免在执行的文件是没有 #! 行的文本文件时在 kerneld 上花费太多时间。 虽然真正的二进制格式在前四个字节中有一个不可打印的字节,但这对于通用数据类型并不总是正确的。

此行为的最终结果是,当调用 GIF 文件或按名称调用 PBM 文件时,您无法自动加载 bloom 查看器。 这两种格式都以文本字符串开头,因此将被自动加载器忽略。

另一方面,当文件在前四个字节中包含不可打印字符时,内核会发出对 binfmt-number 的 kerneld 请求,其中确切的字符串由此语句生成

sprintf(modname, "binfmt-%hd",
        *(short*)(&bprm->buf));

由上述语句生成的二进制格式的 ID 表示磁盘文件的前两个字节。 如果您尝试执行 TIFF 文件,kerneld 会查找 binfmt-19789binfmt-18761。 gzipped 文件调用 binfmt--29921(负数)。 另一方面,由于 GIF 文件的前导文本字符串,它们被传递给 /bin/sh shell。 如果您想知道与每个二进制格式关联的编号,请查看 /usr/lib/magic 文件并将值转换为十进制。 或者,您可以将 debug 参数传递给 kerneld,并在您执行数据文件并且它尝试加载相应的二进制格式时查看其消息。

有趣的是,内核版本 2.1.23 及更高版本通过使用以下行切换到更简单且更有意义的 ID

sprintf(modname, "binfmt-%04x",
              *(unsigned short *)(&bprm->buf[2]));

这个新的 ID 字符串表示二进制文件的第三和第四个字节,并且是十六进制而不是十进制(因此导致具有更好格式且没有丑陋的“减号减号”的字符串,现在和那时出现)。

这有什么价值?

虽然按名称调用图像可能很有趣,但它在计算机系统中没有真正的作用。 我个人更喜欢按名称调用我的查看器,并且我不相信这种方法的面向对象性。 我认为这种功能最适合文件管理器,可以在文件管理器中通过适当的配置文件进行定制,而不会引入内核膨胀来妨碍任何计算路径。

关于二进制格式,真正 有趣 的是能够运行不属于方便的 #! 表示法的程序文件。 这包括属于其他操作系统或平台的可执行文件,以及并非为 Unix 操作系统设计的解释型语言——所有那些抱怨第一行中有 #! 的语言。

如果您想玩这样的游戏,您可以尝试 fail 模块。 这种“自动解释 Lisp 的格式”是一个包装器,用于在任何时候通过名称调用字节编译的 e-lisp 程序时调用 Emacs。 这种做法绝对容易失败,因为它调用几兆字节的程序代码来运行几行 lisp 几乎没有意义。 此外,Emacs-lisp 不适合命令行处理。 与 fail 一起,您还将找到一对示例 lisp 可执行文件来进行测试。

真实的 Linux 系统中充满了有趣的解释型二进制格式示例,例如 Java 二进制格式。 其他示例包括允许 Alpha 平台运行 Linux-x86 二进制文件的二进制格式,以及最新 DOSEMU 发行版中包含的能够透明地运行旧 DOS 程序的二进制格式(尽管程序必须提前专门定制)。

内核 2.1.43 及更高版本包括对解释型二进制格式的通用支持。 binfmt_misc 有点像 bloom,但功能更强大。 您可以通过将相关信息写入文件 /proc/sys/fs/binfmt_misc 来向模块添加新的解释型二进制格式。

清单 1 和本文中提到的所有其他程序都可以通过匿名下载文件 ftp.linuxjournal.com/pub/lj/listings/issue45/2568.tgz 获得。

Playing with Binary Formats
Alessandro Rubini 过去常常在他的大学帐户中阅读电子邮件,但后来因为被迫写文章而放弃了学术界。 他现在以 rubini@linux.it 的身份阅读电子邮件,并且仍然写文章。
加载 Disqus 评论