IA-32e模式学习小插曲

随笔 IA-32e模式学习小插曲

一、64-bit不使用ds选择子了吗?

1.1 问题提出

​ 最近在学习Linux系统的中断机制,在调试syscall入口点:entry_SYSCALL_64时突然发现一个有趣的地方:

image-20240517214533209

1.2 基础解释

​ 重点在于段寄存器ds\es的值为0x0,如果有保护模式的基础的话,不难想到值为0x0的段选择子对应的是GDT表中的0号描述符,而GDT的0号描述符是置空的,在保护模式下将会报GP异常,下图是intel白皮书给出的解释:

image-20240517215224844

  1. 尝试向CS和SS中加载空段选择子就会直接报GP异常;
  2. 尝试向DS、ES、FS、GS中加载空选择子是预期操作,但是如果使用该选择子来访问段就会报GP异常;

​ 我仔细的跟踪了一段时间DS寄存器的变化,发现在内核中其值从来没有被更新过,这让我百思不得其解,难道在64-bit的内核中已经不再使用DS寄存器了吗?答案是是的,在64-bit mode下,cpu大大弱化了段机制,可以认为其已经名存实亡了。

image-20240517220524352

在64-bit mode中,处理器不会对空选择子做运行时检查,当尝试通过该选择子访问内存时也不会报GP异常了。

1.3 详细说明

1.3.1 intel处理器架构
  • IA-32架构

    ​ IA-32架构支持三种基本的运行模式:保护模式(protected mode),实模式( real-address mode),系统管理模式(system management mode)。

    image-20240517223728641

  • 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)

    image-20240517221725223

    兼容模式的作用是:

    • 允许16位程序和32位程序可以在64位操作系统上正常运行,而不需要重新编译。

    兼容模式的特点:

    • 不再兼容虚拟8086运行模式下的应用程序。
    • 兼容模式和32位保护模式很像,二者都只能访问4GB的线性地址空间;二者的操作数大小都是16bit或者32bit;二者都可以通过PAE技术运行程序访问4GB以上的物理地址(该技术的核心在于地址总线扩展到了36位)。
  • 64-bit模式(64-bit mode)

    image-20240517222325259

    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两种模式的切换

​ 在保护模式下,代码段描述符是这样的:

image-20240517224246648

​ 而在IA-32e模式下,代码段描述符是这样的:

image-20240517224317947

​ 对比之下不难发现,在IA-32e模式下的代码段描述符启用了一个新位L位,是该描述符的高32位中的第21位(第53位)。

image-20240517224629770

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下的段机制

image-20240517230339079

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)等。

image-20240517231323777

可以看到,代码段描述符的内容仅基址和段限长被忽略了,剩余的属性位都是正常工作的。

​ 具体可以查阅“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异常的情况:

image-20240520154116167

image-20240520154134554

image-20240520154142834

​ 可以看到,在操作一个地址的内容时,发出异常的情况在保护模式和64-bit模式下是不太一样的,像在保护模式下需要做的段界限检查,在64-bit模式下只需要去做“规范”地址的检查了。

​ 现在我们来考虑一下,64-bit模式下,将ds设置为0x0,在运行mov指令时会出现异常吗?

  1. 要想指令正常执行,那么至少这条指令不能导致异常:

    • 虽然在保护模式下说到:如果ds寄存器包含一个空的段选择子,那么会发出一个GP异常;
    • 但是根据“1.2 基础解释”中的图片和上图“64-bit mode exceptions”可知,在64-bit模式下,已经允许ds设置为0x0,而不发出异常了。
  2. 仅仅是不发出异常是不够的,因为我们需要一个正确的地址去访问内存:

    • 在保护模式下,我们通过段基址+偏移得到线性地址,然后通过线性地址经过页表得到物理地址。
    • 在“1.3.4 IA-32e下的段机制”中说到,在64-bit模式下,段基址可以认为是禁用的,转而使用平坦的64位线性地址空间,处理器默认DS对应的段的基址是0。因此我们可以在DS=0x0的情况下,得到一个合法的线性地址。
  3. 谈一谈权限检查的事情

    ​ 在"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访问到了内核的数据,那就说明在访问内存时不会去进行权限检查。
  4. 为什么笔者想要证明访问内存时不会进行权限检查呢?

    • 如果在访问内存时会进行权限检查,那么DS=0x0,理论上在应用层访问(CPL=3)时也会因为对应的段的DPL为0(0号描述符值全0,相应的DPL位自然也是0),而出现异常。

    • 相反,如果在访存时不进行权限检查,仅在load ds时进行权限检查,那么只要保证一直不切换ds寄存器,就拥有不会出现这个情况下的异常。

      可惜笔者并没有完成这个实验,如果有朋友有思路,可以和笔者深入沟通~。

  5. 虽然第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位程序吧。

image-20240522114021506

2.2.1 内核中的相关数据结构

​ 首先我们来看看32位程序和64位程序使用的段寄存器有什么异同之处。

image-20240522113947595

​ 可以看到目前存在两个程序,分别名为m32m64,现在将其打包到文件系统中,并使用qemu启动64位Linux内核。

image-20240522114555569

​ 可以看到,32位程序运行时使用的cs段寄存器中的段选择子是0x23

image-20240522114802550

​ 可以看到64位应用程序运行时使用的cs段寄存器中的段选择子是0x33

image-20240522115506346

​ 根据段选择子各位来解析0x23和0x33,可以知道对应的描述符分别是GDT中的第4项和第6项。

image-20240522115929645

​ 代码段描述符结构如下:

image-20240522120024846

不难发现以下的结论:

  • 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位。

image-20240522143203663

  • 可以看到,如果是代码段,该位则是D位,若D = 1,则默认使用32位地址和32bit/8bit的操作数(如eax,al,ah);若D = 0,则默认使用16位地址和16bit/8bit操作数(如ax,ah,al)。
  • 同时,66H指令前缀用于翻转操作数大小(即使用与默认大小不一样的操作数);67H指令前缀用于翻转寻址方式。

image-20240522144049145

不难看出,对应rax这种64位的寄存器,它有自己的操作码;对应32位和16位的寄存器,依靠翻转前缀来区分。

​ 可以看到32位程序和64位程序在运行过程中,情况如下:

  • 32位程序:

    • 使用32位地址,默认使用32位操作数。
  • 64位程序:

    • 使用64位地址,默认使用32位操作数。

​ 也就是说,实际上32位程序和64位程序在运行上没有太大区别,我们只需要去改变一下32位程序运行过程中的cs段寄存器,从0x23变为0x33即可。当然我们需要注意,当cs发生0x230x33的改变后,此时代码段是运行在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 实现现象展示

image-20240522151854841

image-20240522151742533

image-20240522151906734

程序m32成功运行,并正常返回,并没有触发任何错误和异常。

三、总结

​ 本文从调试Linux 6.9系统调用时观察到的ds选择子清零出发,探索了intel 64下为什么允许一些段寄存器清零。在解决ds清零程序仍可以正常运行的过程中,了解到代码段描述符的CS.L位的含义,发出了32位程序该如何进行系统调用的疑问,然后笔者设计了实验来手动的在32位应用程序中进行64位的系统调用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值