所以,我知道 Linux 使用 x86 处理器的四个默认段(内核代码、内核数据、用户代码、用户数据),但它们都有相同的基数和限制(0x00000000 和 0xfffff),这意味着每个段映射到相同一组线性地址。
鉴于此,为什么还要有用户/内核段?我理解为什么代码和数据应该有单独的段(只是由于 x86 处理器如何处理 cs 和 ds 寄存器),但为什么不具有单个代码段和单个数据段?内存保护是通过分页完成的,用户和内核段无论如何都映射到相同的线性地址。
所以,我知道 Linux 使用 x86 处理器的四个默认段(内核代码、内核数据、用户代码、用户数据),但它们都有相同的基数和限制(0x00000000 和 0xfffff),这意味着每个段映射到相同一组线性地址。
鉴于此,为什么还要有用户/内核段?我理解为什么代码和数据应该有单独的段(只是由于 x86 处理器如何处理 cs 和 ds 寄存器),但为什么不具有单个代码段和单个数据段?内存保护是通过分页完成的,用户和内核段无论如何都映射到相同的线性地址。
x86 体系结构将类型和特权级别与每个段描述符相关联。描述符的类型允许将段设为只读、读/写、可执行等,但不同段具有相同基数和限制的主要原因是允许使用不同的描述符特权级别 (DPL)。
DPL 是两位,允许对值 0 到 3 进行编码。当特权级别为 0 时,则称其为ring 0,即最高特权。Linux 内核的段描述符是 ring 0,而用户空间的段描述符是 ring 3(最低特权)。大多数分段操作系统都是如此。操作系统的核心是 ring 0,其余的是 ring 3。
正如您所提到的,Linux 内核设置了四个部分:
四个的base和limit都是一样的,但是内核段为DPL 0,用户段为DPL 3,代码段可执行可读(不可写),数据段可读可写(不可执行) .
也可以看看:
x86 内存管理架构同时使用分段和分页。非常粗略地说,段是进程地址空间的一个分区,它有自己的保护策略。因此,在 x86 架构中,可以将进程看到的内存地址范围拆分为多个连续的段,并为每个段分配不同的保护模式。分页是一种将进程地址空间的小(通常为 4KB)区域映射到实际物理内存块的技术。因此,分页控制段内的区域如何映射到物理 RAM。
所有进程都有两个部分:
一个段(地址 0x00000000 到 0xBFFFFFFF)用于用户级、特定于进程的数据,例如程序代码、静态数据、堆和堆栈。每个进程都有自己独立的用户段。
一个段(地址 0xC0000000 到 0xFFFFFFFF),其中包含内核特定的数据,例如内核指令、数据、内核代码可以在其上执行的一些堆栈,更有趣的是,该段中的一个区域直接映射到物理内存,因此内核可以直接访问物理内存位置,而不必担心地址转换。同一个内核段被映射到每个进程中,但是进程只有在受保护的内核模式下才能访问它。
因此,在用户模式下,进程只能访问小于 0xC0000000 的地址;任何对高于此地址的访问都会导致错误。但是,当一个用户模式进程开始在内核中执行时(例如,在进行系统调用之后),CPU 中的保护位将更改为超级用户模式(并且一些分段寄存器会更改),这意味着该进程是从而能够访问 0xC0000000 以上的地址。
参考自:这里
在 X86 - linux 段寄存器用于缓冲区溢出检查 [参见下面的代码片段,它在堆栈中定义了一些 char 数组]:
static void
printint(int xx, int base, int sgn)
{
char digits[] = "0123456789ABCDEF";
char buf[16];
int i, neg;
uint x;
neg = 0;
if(sgn && xx < 0){
neg = 1;
x = -xx;
} else {
x = xx;
}
i = 0;
do{
buf[i++] = digits[x % base];
}while((x /= base) != 0);
if(neg)
buf[i++] = '-';
while(--i >= 0)
my_putc(buf[i]);
}
现在如果我们看到 gcc 生成的代码的反汇编代码。
函数 printint 的汇编代码转储:
0x00000000004005a6 <+0>: push %rbp
0x00000000004005a7 <+1>: mov %rsp,%rbp
0x00000000004005aa <+4>: sub $0x50,%rsp
0x00000000004005ae <+8>: mov %edi,-0x44(%rbp)
0x00000000004005b1 <+11>: mov %esi,-0x48(%rbp)
0x00000000004005b4 <+14>: mov %edx,-0x4c(%rbp)
0x00000000004005b7 <+17>: mov %fs:0x28,%rax ------> obtaining an 8 byte guard from based on a fixed offset from fs segment register [from the descriptor base in the corresponding gdt entry]
0x00000000004005c0 <+26>: mov %rax,-0x8(%rbp) -----> pushing it as the first local variable on to stack
0x00000000004005c4 <+30>: xor %eax,%eax
0x00000000004005c6 <+32>: movl $0x33323130,-0x20(%rbp)
0x00000000004005cd <+39>: movl $0x37363534,-0x1c(%rbp)
0x00000000004005d4 <+46>: movl $0x42413938,-0x18(%rbp)
0x00000000004005db <+53>: movl $0x46454443,-0x14(%rbp)
...
...
// function end
0x0000000000400686 <+224>: jns 0x40066a <printint+196>
0x0000000000400688 <+226>: mov -0x8(%rbp),%rax -------> verifying if the stack was smashed
0x000000000040068c <+230>: xor %fs:0x28,%rax --> checking the value on stack is matching the original one based on fs
0x0000000000400695 <+239>: je 0x40069c <printint+246>
0x0000000000400697 <+241>: callq 0x400460 <__stack_chk_fail@plt>
0x000000000040069c <+246>: leaveq
0x000000000040069d <+247>: retq
现在如果我们从这个函数中删除基于堆栈的字符数组,gcc 将不会生成这个保护检查。
即使对于内核模块,我也看到了 gcc 生成的相同内容。基本上,我在捕获一些内核代码时看到了崩溃,并且出现了虚拟地址 0x28 的故障。后来我发现我认为我已经正确初始化了堆栈指针并正确加载了程序,但我在 gdt 中没有正确的条目,这会将基于 fs 的偏移量转换为有效的虚拟地址。
然而,在内核代码的情况下,它只是忽略了错误,而不是跳转到类似 __stack_chk_fail@plt> 的内容。
在 gcc 中添加此保护的相关编译器选项是 -fstack-protector 。我认为这是默认启用的,它编译用户应用程序。
对于内核,我们可以通过配置 CC_STACKPROTECTOR 选项启用这个 gcc 标志。
配置 CC_STACKPROTECTOR 699 bool “启用 -fstack-protector 缓冲区溢出检测(实验)” 700 取决于 SUPERH32 701帮助 702 此选项打开 -fstack-protector GCC 功能。这个 703 功能在函数的开头放置一个金丝雀值 704 返回地址之前的堆栈,并验证 705 实际返回之前的值。基于堆栈的缓冲区 706 溢出(需要覆盖此返回地址)现在也 707 覆盖金丝雀,它被检测到然后攻击 708 通过内核恐慌中和。 709 710 此功能需要 gcc 4.2 或更高版本。
这个gs/fs所在的相关内核文件是linux/arch/x86/include/asm/stackprotector.h
内核内存不应从在用户空间中运行的程序中读取。
程序数据通常是不可执行的(DEP,一种处理器功能,有助于防止执行溢出的缓冲区和其他恶意攻击)。
这都是关于访问控制的——不同的段有不同的权限。这就是为什么访问错误的段会给你一个“段错误”。