随笔 IA-32e模式学习小插曲
文章目录
一、64-bit不使用ds选择子了吗?
1.1 问题提出
最近在学习Linux系统的中断机制,在调试syscall入口点:entry_SYSCALL_64
时突然发现一个有趣的地方:
1.2 基础解释
重点在于段寄存器ds\es的值为0x0,如果有保护模式的基础的话,不难想到值为0x0的段选择子对应的是GDT表中的0号描述符,而GDT的0号描述符是置空的,在保护模式下将会报GP异常,下图是intel白皮书给出的解释:
- 尝试向CS和SS中加载空段选择子就会直接报GP异常;
- 尝试向DS、ES、FS、GS中加载空选择子是预期操作,但是如果使用该选择子来访问段就会报GP异常;
我仔细的跟踪了一段时间DS寄存器的变化,发现在内核中其值从来没有被更新过,这让我百思不得其解,难道在64-bit的内核中已经不再使用DS寄存器了吗?答案是是的,在64-bit mode下,cpu大大弱化了段机制,可以认为其已经名存实亡了。
在64-bit mode中,处理器不会对空选择子做运行时检查,当尝试通过该选择子访问内存时也不会报GP异常了。
1.3 详细说明
1.3.1 intel处理器架构
-
IA-32架构
IA-32架构支持三种基本的运行模式:保护模式(protected mode),实模式( real-address mode),系统管理模式(system management mode)。
-
Intel 64架构
Intel 64架构增加了IA-32e模式,下一小节有详细介绍。
具体可以查阅“Intel® 64 and IA-32 Architectures Software Developer’s Manual - Volume 1 Basic Architecture - CHAPTER 3 BASIC EXECUTION ENVIRONMENT - 3.1 MODES OF OPERATION”。
1.3.2 IA-32e的两种子模式
-
兼容模式(Compatibility mode)
兼容模式的作用是:
- 允许16位程序和32位程序可以在64位操作系统上正常运行,而不需要重新编译。
兼容模式的特点:
- 不再兼容虚拟8086运行模式下的应用程序。
- 兼容模式和32位保护模式很像,二者都只能访问4GB的线性地址空间;二者的操作数大小都是16bit或者32bit;二者都可以通过PAE技术运行程序访问4GB以上的物理地址(该技术的核心在于地址总线扩展到了36位)。
-
64-bit模式(64-bit mode)
64-bit模式的作用:
- 运行应用程序访问64位的线性地址空间。
64-bit模式的特点:
- 增加了寄存器数量。
- 通用寄存器的长度扩展到了64位。
- 默认地址大小是64bit,默认操作数大小是32位。
具体可以查阅“Intel® 64 and IA-32 Architectures Software Developer’s Manual - Volume 1 Basic Architecture - CHAPTER 3 BASIC EXECUTION ENVIRONMENT”。
1.3.3 IA-32e两种模式的切换
在保护模式下,代码段描述符是这样的:
而在IA-32e模式下,代码段描述符是这样的:
对比之下不难发现,在IA-32e模式下的代码段描述符启用了一个新位L位,是该描述符的高32位中的第21位(第53位)。
IA-32e模式下:
- 如果CS.L = 0同时IA-32e模式是激活的,那么处理器将运行在兼容模式。
- 如果CS.L = 1同时IA-32e模式是激活的,此时还要考虑CS.D位的值
- 如果CS.D = 0,此时是合法的,该组合暗示着默认操作数大小是32位,默认地址长度是64位。
- 如果CS.D = 1,该组合是保留使用的,如果使用的代码段描述符中有这样的组合,会导致一个#GP异常。
注意,上面提到的CS并不是代码段选择子,而是指代码段(code segment)。
具体可以查阅“Intel® 64 and IA-32 Architectures Software Developer’s Manual - Volume 3 System Programming Guide - CHAPTER 5 PROTECTION”。
1.3.4 IA-32e下的段机制
IA-32e模式下,段基址的影响取决于处理器的运行模式:
- 如果运行在兼容模式,那么的段的功能和在IA-32架构下是一致的。
- 如果允许在64-bit模式,那么段基址可以认为是被禁用的(但不完全),使用平坦的64位线性地址。处理器将CS\DS\ES\SS的基址视作从0开始,此时线性地址就等价于有效地址。FS/GS段除外。
具体可以查阅“Intel® 64 and IA-32 Architectures Software Developer’s Manual - Volume 1 Basic Architecture - CHAPTER 3 BASIC EXECUTION ENVIRONMENT - 3.7 OPERAND ADDRESSING”。
这里我要多说一句,上文说到“处理器将CS\DS\ES\SS的基址视作从0开始”并不意味着CS寄存器在64-bit模式也失去了作用,事实上它的作用仍然有很多,包括标识代码段、指明RPL(即CPL)等。
可以看到,代码段描述符的内容仅基址和段限长被忽略了,剩余的属性位都是正常工作的。
具体可以查阅“Intel® 64 and IA-32 Architectures Software Developer’s Manual - Volume 3 System Programming Guide - CHAPTER 5 PROTECTION”。
1.3.5 测试保护模式下应用层能否使用指向DPL=0的段的ds段选择子
我本来是想通过实验来证明,权限检查是发生在load段寄存器时的,当通过段选择子去访问数据时,并不会再次触发权限检查。但是由于实现环境中,无论是我直接设置ds寄存器还是通过在CPL=0时内核代码设置ds寄存器,都会导致将$ds寄存器清零,因此实验无法成功(查看了一些文献和提问,自认为是qemu/bochs模拟器的问题,他们拒绝这种危险情况的出现)。
目前我只能确定,CPL=3时尝试加载DS=0x10,会导致#GP异常;如果CPL=3且DS=0x10,能否访问内核数据还不清楚,是否会触发权限检查还不清楚。(但是如果尝试使用0x0号段选择子去引用内存,就会报GP异常。)
1.4 小结
以MOV指令为例,来看看发生CPU异常的情况:
可以看到,在操作一个地址的内容时,发出异常的情况在保护模式和64-bit模式下是不太一样的,像在保护模式下需要做的段界限检查,在64-bit模式下只需要去做“规范”地址的检查了。
现在我们来考虑一下,64-bit模式下,将ds设置为0x0,在运行mov指令时会出现异常吗?
要想指令正常执行,那么至少这条指令不能导致异常:
- 虽然在保护模式下说到:如果ds寄存器包含一个空的段选择子,那么会发出一个GP异常;
- 但是根据“1.2 基础解释”中的图片和上图“64-bit mode exceptions”可知,在64-bit模式下,已经允许ds设置为0x0,而不发出异常了。
仅仅是不发出异常是不够的,因为我们需要一个正确的地址去访问内存:
- 在保护模式下,我们通过段基址+偏移得到线性地址,然后通过线性地址经过页表得到物理地址。
- 在“1.3.4 IA-32e下的段机制”中说到,在64-bit模式下,段基址可以认为是禁用的,转而使用平坦的64位线性地址空间,处理器默认DS对应的段的基址是0。因此我们可以在DS=0x0的情况下,得到一个合法的线性地址。
谈一谈权限检查的事情
在"1.3.5 测试保护模式下应用层能否使用指向DPL=0的段的ds段选择子"中我写了一部分内容,看着可能会觉得有些奇怪,为什么笔者会尝试做那样的实验呢?
- 我们先假设在在访问内存时,需要通过段选择子去获取对应的段描述符,然后进行CPL、RPL与段的DPL的比较(权限检查)。
- DS=0x10,意味着RPL=0,当执行访问内存时,就会去获取GDT表中2号描述符的DPL,而2号描述符值为内核数据段,因此DPL项也是0。在三环以CPL=3去访问DPL=0的段肯定是会报异常的。
- 因此,如果实验能够正常做起来,而且正常的通过DS=0x10访问到了内核的数据,那就说明在访问内存时不会去进行权限检查。
为什么笔者想要证明访问内存时不会进行权限检查呢?
如果在访问内存时会进行权限检查,那么DS=0x0,理论上在应用层访问(CPL=3)时也会因为对应的段的DPL为0(0号描述符值全0,相应的DPL位自然也是0),而出现异常。
相反,如果在访存时不进行权限检查,仅在load ds时进行权限检查,那么只要保证一直不切换ds寄存器,就拥有不会出现这个情况下的异常。
可惜笔者并没有完成这个实验,如果有朋友有思路,可以和笔者深入沟通~。
虽然第4点没有被实验验证,有些可惜,但是根据mov指令"protected mode exceptions"和"64-bit mode exceptions"中的异常情况来看,并没有去做CPL和RPL的权限检查,因此可以推测,在mov中取地址时,并不会进行段权限检查。
在这部分中笔者仅讨论的ds的使用,其余相关寄存器理论上是类似的,读者有兴趣可以查阅验证。
二、IA-32e模式下运行x86程序如何进入内核呢?
2.1 问题提出
提出这个问题是相当自然的,我们根据上文的知识知道IA-32e模式分为兼容模式(Compatibility mode)和64-bit模式(64-bit mode)。而当运行32位应用程序时,程序运行在兼容模式下;当运行64位应用程序时,程序运行在64-bit模式下。同时64位操作系统的内核总是运行在64-bit模式下,那么其上的32位应用程序是如何进行系统调用的呢?
其实这个我们在“1.3.3 IA-32e两种模式的切换”中就已经在理论上解决了,也就是说通过不同段描述符的L位为0/1来指示兼容模式和64-bit模式的转换。以在IA-32e架构CPU上运行64位操作系统为例,其内核总是64-bit模式的,那么我们的32位应用程序想要进行系统调用,就一定需要先从兼容模式转换到64-bit模式,然后才能使用64-bit下的一些特殊寄存器,进而完成系统调用。
2.2 实验验证
要逐行跟踪32位应用程序,直到修改cs寄存器指向L位设置为1的代码段描述符着实有些枯燥,不如我们来自行实现一个不借助其他库文件就能在64位操作系统中进行系统调用的32位程序吧。
2.2.1 内核中的相关数据结构
首先我们来看看32位程序和64位程序使用的段寄存器有什么异同之处。
可以看到目前存在两个程序,分别名为m32
和m64
,现在将其打包到文件系统中,并使用qemu启动64位Linux内核。
可以看到,32位程序运行时使用的cs段寄存器中的段选择子是0x23
。
可以看到64位应用程序运行时使用的cs段寄存器中的段选择子是0x33
。
根据段选择子各位来解析0x23和0x33,可以知道对应的描述符分别是GDT中的第4项和第6项。
代码段描述符结构如下:
不难发现以下的结论:
0x23
段选择子对应描述符为:0x00cffb000000ffff
- 其CS.G = 1, CS.D = 1, CS.L = 0, CS.AVL = 0
0x33
段选择子对应描述符为:0x00affb000000ffff
- 其CS.G = 1, CS.D = 0, CS.L = 1, CS.AVL = 0
正如“1.3.3 IA-32e两种模式的切换”小节所述,CS.L = 1, CS.D = 0是合法的,指示着默认操作数位32位,默认地址长度位64位。
- 可以看到,如果是代码段,该位则是D位,若D = 1,则默认使用32位地址和32bit/8bit的操作数(如eax,al,ah);若D = 0,则默认使用16位地址和16bit/8bit操作数(如ax,ah,al)。
- 同时,66H指令前缀用于翻转操作数大小(即使用与默认大小不一样的操作数);67H指令前缀用于翻转寻址方式。
不难看出,对应rax这种64位的寄存器,它有自己的操作码;对应32位和16位的寄存器,依靠翻转前缀来区分。
可以看到32位程序和64位程序在运行过程中,情况如下:
32位程序:
- 使用32位地址,默认使用32位操作数。
64位程序:
- 使用64位地址,默认使用32位操作数。
也就是说,实际上32位程序和64位程序在运行上没有太大区别,我们只需要去改变一下32位程序运行过程中的cs段寄存器,从0x23
变为0x33
即可。当然我们需要注意,当cs发生0x23
到0x33
的改变后,此时代码段是运行在64-bit模式了,此时大量64位寄存器开放使用了,包括系统调用也需要使用64位的寄存器;但是由于程序编译时指定为32位程序编译,无法识别rax等寄存器(没有对应的指令来操作),因此需要通过二进制码的方式将代码嵌入32位应用程序的binary文件中。
2.2.2 实现代码解释
// gcc -m32 -static -fno-pie -g m32.c -o m32
#include <stdio.h>
//#include <stdlib.h>
static char far_jmp[10] = {0xbb, 0x97, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00};
static char bstart[8] = {0};
int main(){
__asm__(
"mov $1,%%eax\n"
"nop\n"
".byte 0xEA\n" // opcode for ljmp
".byte 0x91\n" // offset (least significant byte)
".byte 0x97\n" // offset
".byte 0x04\n" // offset
".byte 0x08\n" // offset (most significant byte)
".byte 0x33\n" // segment selector (least significant byte)
".byte 0x00\n" // segment selector (most significant byte)
"1:\n"
"xor %%eax,%%eax\n"
".byte 0x48\n" // mov $0x0,%rdi
".byte 0xc7\n"
".byte 0xc7\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x48\n" // lea 0xaaaaaaaa(%rip),%rsi
".byte 0x8d\n"
".byte 0x35\n"
".byte 0x7b\n"
".byte 0x4b\n"
".byte 0x0a\n"
".byte 0x00\n"
".byte 0x48\n" // mov $0x2,%rdx
".byte 0xc7\n"
".byte 0xc2\n"
".byte 0x02\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x48\n" //mov $0x0,%rax
".byte 0xc7\n"
".byte 0xc0\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x00\n"
".byte 0x0f\n" // syscall
".byte 0x05\n"
".byte 0x48\n" // lea 0x00000000(%rip),%rax
".byte 0x8d\n"
".byte 0x05\n"
".byte 0xb0\n"
".byte 0x38\n"
".byte 0x0a\n"
".byte 0x00\n"
".byte 0x48\n" // ljmp tword ptr [rax] // back to x86
".byte 0xff\n"
".byte 0x28\n"
"2:\n"
"xor %%eax,%%eax\n"
"mov %%ds:(0x8049775),%%eax\n" // for privilege checks
:
: "m" (bstart)
: "eax","edi","edx"
);
printf("%s\n", bstart);
}
将上面的代码翻译一下:
#include <stdio.h>
//#include <stdlib.h>
static char far_jmp[10] = {0xbb, 0x97, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00};
static char bstart[8] = {0};
int main(){
__asm__(
"mov $1,%%eax\n"
"nop\n"
"ljmp 0x33,0x8049791\n" // 即下一行,切换cs为0x33段选择子
"1:\n"
"xor %%eax,%%eax\n" // 无明确意义
"mov $0x0,%%rdi\n" // read系统调用第一个参数,即标准输入的fd
"lea 0x0a4b7b35(%%rip),%%rsi\n" // read系统调用第二个参数,即用户buf
"mov $0x2,%%rdx\n" // read系统调用第三个参数,即读入数据长度
"mov $0x0,%%rax\n" // 系统调用号
"syscall\n"
"lea 0x0a38b005(%%rip),%%rax\n" // 将far_jmp的地址放入rax寄存器
".byte 0x48\n" // ljmp tword ptr [rax],切换cs为0x23段选择子
// back to x86,由于内联汇编不支持该指令,因此物理32/64位都需要使用字节码嵌入
".byte 0xff\n"
".byte 0x28\n"
"2:\n" // 无明确意义
"xor %%eax,%%eax\n" // 无明确意义
"mov %%ds:(0x8049775),%%eax\n" // 无明确意义
:
: "m" (bstart)
: "eax","edi","edx"
);
printf("%s\n", bstart);
}
其实代码很简单,就是先通过ljmp跳转到当前指令的下一步(主要是切换到0x33段选择子),然后将64位syscall需要的参数放进对应的寄存器,在系统调用完毕后,通过ljmp tword ptr [rax]
再跳到当前指令的下一步(主要是切换回0x23段选择子),然后就结束了。(注:此处虽然引入了stdio.h库文件,但是仅仅是为了能够使用printf,实际上如果想要打印bstart,也可以通过write系统调用向标准输出打印字符,这样就不需要引入stdio.h了)
2.2.4 实现现象展示
程序m32
成功运行,并正常返回,并没有触发任何错误和异常。
三、总结
本文从调试Linux 6.9系统调用时观察到的ds选择子清零出发,探索了intel 64下为什么允许一些段寄存器清零。在解决ds清零程序仍可以正常运行的过程中,了解到代码段描述符的CS.L位的含义,发出了32位程序该如何进行系统调用的疑问,然后笔者设计了实验来手动的在32位应用程序中进行64位的系统调用。