创建 vDSO:Colonel 的另一只鸡
vDSO(虚拟动态共享对象)是 GNU/Linux 内核提供的、在一定程度上周期昂贵的系统调用接口的替代方案。但是,在我解释如何烹制您自己的 vDSO 之前,在这篇简短的操作系统之旅中,我将介绍 vDSO 的一些基础知识,它们是什么以及为什么它们有用。本文的主要目的是说明如何向 Linux 内核添加自定义 vDSO,以及如何使用您的劳动成果。本文并非 vDSO 101 入门教程;如果您想了解更深入的信息,请参阅本文“资源”部分中的链接。
vDSO 基础知识用户空间应用程序和内核之间通信的传统机制称为系统调用。系统调用作为软件中断实现,为用户空间应用程序提供一些内核功能。例如,gettimeofday()
和 fork()
都是系统调用。系统调用存在的原因是 Linux 内核分为两个主要的内存段:用户空间和内核空间。用户空间是常见程序(包括守护进程和服务器)执行的地方。内核空间是内核调度进程并执行其所有巧妙的内核特定魔法的地方。内存中的这种划分充当用户应用程序和内核之间的安全屏障。用户应用程序甚至可以接触内核的唯一方式是通过系统调用通信。因此,内核的健壮性和完整性受到它提供给用户空间访问的有限例程(系统调用)的保护。
为了完成系统调用,内核必须翻转内存上下文:存储用户空间 CPU 寄存器,在系统调用的中断向量中查找系统调用(系统调用向量在启动时初始化),然后处理系统调用。一旦在内核空间中处理完系统调用,内核必须从先前存储的用户空间上下文中恢复寄存器。这样就完成了系统调用;但是,正如您可以想象的那样,这一系列事件并非没有代价。仅仅为了进行这些特殊类型的函数调用,就消耗了大量的周期。
虽然这种分段听起来对安全世界来说很棒,但它并不总是提供最有效的通信方式。某些不写入任何数据而仅返回存储在内核中的值的函数,例如 gettimeofday()
,本质上相对安全,并且不会对请求的用户空间应用程序造成内核威胁。如果您可以让安全函数不必进行内存屏障的复杂过程,那不是很好吗?好吧,您可以使用 vDSO 来做到这一点!
您可能想知道 vDSO 首先是如何通过传统的系统调用放置到程序中的。嗯,vDSO 钩子是通过 glibc 库提供的。链接器将链接 glibc vDSO 功能,前提是这样的例程具有随附的 vDSO 版本,例如 gettimeofday()
。当您的程序执行时,如果您的内核不支持 vDSO,则将进行传统的系统调用。对 vDSO 功能的这种测试由从 glibc 链接的代码提供。当然,您不想仅仅为了让您自制的 vDSO 运行而修改 glibc。下面描述的创建 vDSO 的方法不需要修改 glibc;相反,它依赖于修改内核,正如预期的那样。
这些安全的系统调用可以在虚拟内存页面上实现,该页面可以映射到每个运行进程的内存中。这种实现类似于其他动态共享对象(如共享库)映射到进程中的方式。事实上,如果您从内存中提取页面并反汇编它,结果将是一个共享库 ELF。换句话说,vDSO 只是一个共享库(抱歉打破了您的魔法)。有了这个驻留在用户空间应用程序中的安全系统调用例程页面,程序可以进行调用,而不必忍受传统系统调用所需的在用户和内核段之间进行内存切换的开销。一个完美的例子是 gettimeofday()
。此例程不仅对时间敏感,而且通常是高频率使用的例程。考虑到内核需要时间来切换内存段。一旦对时钟进行采样,就必须花费周期来翻转内存段。这花费的时间越长,返回的时间值就越不准确。
理论和那些胡言乱语已经够多了,让我们开始讨论本文的重点——制作您自己的 vDSO。本文假设使用 2.6.37 Linux 内核的 64 位 x86 处理器。您可能会惊讶于这有多么容易。它甚至比制作传统的系统调用还要简单。令人困惑的部分在于尝试通过内核和用户空间之间的变量共享数据。
让我们创建一个执行基本操作的系统调用——例如,生成一个整数值,哦,野兽的数字,666。为了所有教学目的,让我们将此函数称为 number_of_the_beast()。因为我不确定野兽的真实数字是否是静态的(嘿,野兽可能会改变),所以让我们让这个函数做到这一点,告诉我们野兽的数字。(它可能像总统一样,每隔几年就更换一次。)在 linux-2.6.37/arch/x86/vdso/ 中创建一个名为 vnumber_of_the_beast.c 的文件,并在其中定义您的函数
#include <asm/linkage.h>
notrace int __vdso_number_of_the_beast(void)
{
return 0xDEAD - 56339;
}
这里唯一有趣/不寻常的事情是 notrace
宏。它在 linux-2.6.37/arch/x86/include/asm/linkage.h 中定义为
#define notrace __attribute__((no_instrument_function))
上面的 GNU 扩展告诉 gcc 编译器,当它编译函数时,排除支持性能分析反馈的钩子。如果删除 notrace
宏并且在编译时将 gcc 标志 -finstrument-functions
传递给 gcc,则可以内置性能分析反馈(请参阅“资源”中列出的 GCC 手册)。
您还需要告诉编译器链接一个用户空间可访问的名为 number_of_the_beast
的函数,它也是一个弱符号。弱符号表示数据(例如函数调用),这些数据在运行时才解析。“弱”字仅仅表示符号可以被覆盖。如果符号不存在,则不会发出警告,因为在这种情况下不接受任何符号。别名将本地 __vdso_number_of_the_beast
关联到世界可访问的版本 number_of_the_beast
。在之前添加的函数之后添加以下代码段
int number_of_the_beast(void)
__attribute__((weak, alias("__vdso_number_of_the_beast")));
现在,您只需要将一些片段添加到链接器脚本中,以便在构建内核时,您的代码将被构建并链接到 vdso.so
共享对象中。这就是您在编写使用 vDSO 的代码时将使用的钩子。现在,拿出您的文本编辑器并修改 linux-2.6.37/arch/x86/vdso/vdso.lds.S 以添加您刚刚添加的函数名称
VERSION {
LINUX_2.6 {
global:
clock_gettime;
__vdso_clock_gettime;
gettimeofday;
__vdso_gettimeofday;
getcpu;
__vdso_getcpu;
/* ADD YOUR VDSO STUFF HERE */
number_of_the_beast;
__vdso_number_of_the_beast;
local: *;
};
}
还有一件事,您需要告诉编译器实际编译 vnumber_of_the_beast.c
中的信息。为此,只需将一些信息添加到位于 linux-2.6.37/arch/x86/vdso/Makefile 中的 Makefile 中。添加文件名,并将 .c 扩展名替换为 .o。并且,通过 make
的魔力和黑魔法,它将在编译时编译。再次,拿出文本编辑器,并将名称添加到变量 vobjs-y
的目标文件列表中。您的结果应类似于以下内容
# files to link into the vdso
vobjs-y := vdso-note.o vclock_gettime.o vgetcpu.o
↪vvar.o vnumber_of_the_beast.o
现在是一些特别的酱料
gettimeofday()
,一个特殊的时间变量被映射到内存中,内核在其中更新它,而用户空间(vDSO 版本)可以读取它。内核只是将其对时间的了解复制到该变量中,当进行 vDSO 调用时,该调用只是读取信息,从而节省了跨越内存段的开销。与基本的 vDSO 函数相比,内核变量的添加或访问相当复杂,但是由于 vDSO 的目的是访问内核信息(例如变量中提供的信息),因此我可能应该快速概述一下如何做到这一点。
为了说明目的,让我们添加一个驻留在内核空间中但从用户空间读取的值。当然,我之前说过这个神秘的数字可能会改变,您应该实现一个函数来返回它。嗯,您有一个函数,但是您现在只知道该值,而不知道它将来可能会变成什么。让我们让函数返回一个值,非常量。哇,这个用例变得非常不寻常。为了详细说明,让我们在内核请求时更新此变量。内核将在 linux-2.6.37/arch/x86/kernel 中的 update_vsyscall()
函数中更新 vDSO 变量。
如果您将其声明为 const int vnotb = 666;
,则在那里捕获的值将不会被设置(稍后会详细介绍)。
让我们将该值定义为野兽本身的神秘数字,我将其称为 vnotb
。这个数字将驻留在内核空间中,就像许多其他有用的值一样,例如时间,高效的 gettimeofday()
vDSO 将获取这些值。这就是 vDSO 的真正魔力所在。
让我们继续留在 linux-2.6.37/arch/x86/vdso 中,并修改这里的所有好东西。首先,通过 VEXTERN()
宏声明变量。在 vextern.h 中,将您的声明与所有其他声明一起添加
VEXTERN(vnotb)
此宏将创建一个变量,该变量是指向您关心的值的指针,并以 vdso_
为前缀。本质上,您已将 vnotb 声明为 int *vdso_vnotb;
。
vextern.h 提到
vDSO 中使用的任何内核变量都必须在主内核的 vmlinux.lds.S/vsyscall.h/proper__section 中导出,并放入 vextern.h 中,并作为带有 vdso 前缀的指针引用。主内核稍后会填充值(linux-2.6.37/arch/x86/vdso/vextern.h 中的注释)。
既然您已经有了一些 vDSO 代码,用户空间的东西和内核-用户空间映射,让我们开始使用它。在函数 vget_number_of_the_beast()
中,让我们返回该值
notrace int __vdso_number_of_the_beast(void)
{
return *vdso_vnotb;
}
不要忘记添加声明该值的头文件 vextern.h
,以及另一个将解析后者引用的某些数据的头文件 vgtod.h
#include <asm/vgtod.h>
#include "vextern.h"
总而言之,您需要让内核知道这个变量,以便它可以向其中泵入数据。您需要内核向用户空间提供一个值。嗯,您已经将其映射到上面指定的地址,但这毫无意义,除非桑德斯先生(上校)向其中推送一些数据。您需要向上移动一个目录(是的,这不是最简单的过程)。跳到 linux-2.6.37/arch/x86/kernel 中。您需要让链接器知道这个值,以便它可以映射内核和用户空间,所以您可能应该这样做。修改 vmlinux.lds.S,并在 vgetcpu_mode
代码段之后添加以下内容(请注意,在 vgetcpu_mode
之后或之前添加它不是必要的,但这是一个容易找到东西的地方)
.vnotb : AT(VLOAD(.vnotb)) {
*(.vnotb)
}
vnotb = VVIRT(.vnotb);
这将 vnotb
符号与变量 vnotb
链接起来。这在地址空间中设置了变量,供内核空间访问和写入。上面的宏 AT
、VLOAD
和 VVIRT
用于修改地址,以便引用 vnotb
处的正确数据片段。
现在,您需要声明内核空间将写入的值。在 linux-2.6.37/arch/x86/include/asm/vsyscall.h 中声明这个小家伙及其节,该节将通过您最近添加的上述链接器脚本条目插入
#define __section_vnotb __attribute__ ((unused,
↪__section__ (".vnotb"), aligned(16)))
如前所述,在此文件中,您还将声明内核空间变量,内核将写入该变量。为了使内容更具可读性,请将您的变量与 vgetcpu_mode
声明放在一起
extern int vnotb;
您还将定义内核可以读取的值(在我的示例中我没有使用它,但如果内核需要读取该值,则这是要读取的变量)
extern int __vnotb;
现在让我们将这些东西放入代码中并给它一个值。内核将通过可写的 vnotb
写入该值,您也可以通过 __vnotb
从内核和用户空间之间的共享内存中读取它。您将在内核空间版本的变量中写入该值,该变量是可写的。在 linux-2.6.37/arch/x86/kernel/vsyscall_64.c 中,最好在所有 #include 标头之后以及紧接在代码段之后:int __vgetcpu_mode __section_vgetcpu_mode;
,添加以下内容
int __vnotb __section_vnotb;
请记住,您使用链接器设置值时做了一个技巧。如果您像为 extern 一样全局设置该值,则您将不会获得值,链接器会覆盖它。您需要在运行时而不是在编译时静态设置此值。要将此值设置为内核更新的值,请使用以下内容修改 linux-2.6.37/arch/x86/kernel/vsyscall_64.c 中的 update_vsyscall() 例程
vnotb = 666;
此语句定义了先前在 vsyscall.h 中声明的值。
编译、链接和运行等等,添加 vDSO 就这些吗?嗯,是的。当然,如果该函数是 C 库(在我们的例子中是 glibc)支持的函数,您可以修改它以执行 vDSO 的检测,然后执行实际调用。但是,我提到过我们不会修改 glibc。而且,您也不需要这样做,因为让代码工作非常简单。在上述所有代码块都到位的情况下,是时候开始构建了。只需像通常一样配置和编译您的内核即可
make menuconfig
make bzImage
make modules
make modules_install
现在,安装并启动您新修改的 vDSO 内核。一旦启动并运行,就该测试一些东西了,主要是您刚刚添加的 vDSO 内容。让我们编译一个测试用例来练习 vDSO 调用
/* notb.c */
#include <stdio.h>
int main(void)
{
int notb = number_of_the_beast();
printf("His number is %d\n", notb);
return 0;
}
然后,将上面的代码编译为
gcc notb.c -o notb vdso.so
您链接的文件是 vdso.so,它提供了进行内核调用所需的符号解析。即使 number_of_the_beast()
的内核版本代码在 vdso.so 中完全不同,也会调用内核版本。vdso.so 位于哪里?它位于构建内核后的内核构建目录中:linux-2.6.37/arch/x86/vdso/vdso.so。
在运行时,当程序执行 number_of_the_beast
时,将调用内核代码,而不是 vdso.so 文件中 number_of_the_beast()
的版本。如果您修改内核,例如,让 number_of_the_beast()
返回 42
,那么除非您加载该内核,否则您仍然会得到 666
。即使您使用更新的修改为 42 的 vdso.so 编译上面的测试示例也是如此。
获取 vdso.so 文件的另一种方法是编写一个程序,从正在运行的可执行文件中提取 vDSO 内存。许多在线资源解释了如何执行此操作,但我在这里简要描述一下。vDSO 页面映射到每个运行进程的内存中,由于 Linux 的地址空间布局随机化 (ASLR),它可以在执行进程的非确定性内存范围内。要获取此地址,正在运行的程序可以从文件 /proc/self/maps 中找到其内存信息。在其中,存在一行带有文本 [vdso]
的行。该行包含执行进程中 vDSO 页面的地址范围。例如,您可以运行 cat /proc/self/maps
。
请注意,由于(如果您的内核支持)地址空间布局随机化,多次运行此命令会产生不同的 [vdso]
地址范围。
输出应类似于
...
7fff40d71000-7fff40d72000 r-xp 00000000 00:00 0 [vdso]
...
上面的范围显示,对于您刚刚执行的 cat
进程,vDSO 页面的地址范围位于从 7fff40d71000
开始到 7fff40d7200
结束的位置。减去起始范围和结束范围,您得到 0x1000 或 4096 字节。4096 是内核中常用的页面大小。列表 1 显示了从正在运行的内核中提取 vDSO 的代码,它基于“资源”中列出的“Examining the Linux VDSO”文章中的代码。
可以通过以下方式进行动态对象符号的简单转储
objdump -T vdso.so
由于共享库也是 elf,因此 readelf
工具也可以在 vdso.so 上使用。
/* extract_vdso.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv)
{
char buf[256], *mem;
const char *range_name;
FILE *rd, *wr;
long long start_addr, end_addr;
/* Open file for writing the vdso data to */
if (argc != 3)
{
fprintf(stderr,
"Usage: %s <file> <string>\n"
"\t<file>: File to write the vdso data to.\n"
"\t<string>: Name of the mapped in region, e.g. vdso\n",
argv[0]);
abort();
}
range_name = argv[2];
if (!(wr = fopen(argv[1], "w")))
{
perror("Error: fopen() - output file");
abort();
}
/* Get this process' memory layout */
if (!(rd = fopen("/proc/self/maps", "r")))
{
perror("Error: fopen() - /proc/self/maps");
abort();
}
/* Find the line in /proc/self/maps that contains
the substring [vdso] * */
while (fgets(buf, sizeof(buf), rd))
{
if (strstr(buf, range_name))
break;
}
fclose(rd);
/* Locate the end memory range for [vdso] */
end_addr = strtoll((strchr(buf, '-') + 1), NULL, 16);
/* Terminate the string so we can get the start
address really easily * */
*(strchr(buf, '-')) = '\0';
start_addr = strtoll(buf, NULL, 16);
/* Open up the memory page and extract the vdso */
if (!(rd = fopen("/proc/self/mem", "r")))
{
perror("Error: fopen() - /proc/self/mem");
abort();
}
/* Hop to the vdso portion */
fseek(rd, start_addr, SEEK_SET);
/* Copy the memory locally and then move it to the file */
mem = malloc(end_addr - start_addr);
if (!fread(mem, 1, end_addr - start_addr, rd))
{
perror("Error: read() - /proc/self/mem");
abort();
}
/* Write the data to the specified output file */
if (!fwrite(mem, 1, end_addr - start_addr, wr))
{
perror("Error: fwrite() - output file");
abort();
}
free(mem);
fclose(rd);
fclose(wr);
printf("Start: %p\nEnd: %p\nBytes: %d\n",
(void *)start_addr, (void *)end_addr, (int)(end_addr -
↪start_addr));
return 0;
}
安全影响
任何时候您涉足内核,都应该考虑安全影响。如果您认为可以通过创建自己的 vDSO 调用来“拥有”某人,您可能需要重新考虑一下。因为添加 vDSO 需要用户烘焙自己的内核,所以他们可能损害的唯一对象是他们的系统以及他们系统上的用户。当然,任何涉足内核资源的行为都应经过深思熟虑。请记住,玩 vDSO 好东西发生在用户空间中;但是,您的 vDSO 可以访问内核数据。并且,您的内核可以读取 vDSO 数据。这可能是一个问题,但我将其留给您作为练习,以寻找任何可利用的东西。
最后,本文只是关于如何烹制您自己的 vDSO 的一点点介绍。现在去制作一个冒烟的内核吧。
资源GNU/Linux 内核。2.6.37:https://linuxkernel.org.cn
“6.30 声明函数属性”(GCC 手册):http://gcc.gnu.org/onlinedocs/gcc/Function-Attributes.html
“弱符号”(维基百科):http://en.wikipedia.org/wiki/Weak_symbol
“Examining the Linux VDSO”(Truth, Computing and Fail):http://anomit.com/2010/04/18/examining-the-linux-vdso
Johan Peterson 的 “What is linux-gate.so.1?”:http://www.trilithium.com/johan/2005/08/linux-gate
Matt Davis 的 “Linux syscall, vsyscall, and vDSO...Oh My!”:http://davisdoesdownunder.blogspot.com/2011/02/linux-syscall-vsyscall-and-vdso-oh-my.html