How we begin?——Ucore Lab1 with challenge

UCORE LAB1

参考资料:
网络:
ucore lab1 report:https://www.cnblogs.com/kongj/p/12507214.html
实验参考手册:http://oslab.mobisys.cc/_book/index.html
QEMU日志使用:https://blog.csdn.net/sinat_38205774/article/details/103370961
int指令:https://blog.csdn.net/qq_37340753/article/details/82147553
iret指令:https://baike.baidu.com/item/iret/1314268
我的gitee:https://gitee.com/wan-zesheng/kernel-pwn-lab/
书籍:
orange’s——一个操作系统的实现

0、写在前面

环境:Ubuntu20.04
工具:gdb(pwndbg),QEMU,vim

1、分析项目结构以及Makefile[Q1]

在分析Makefile的过程中,我们要抓住重点,从目标文件的生成来着手。Makefile中包含显示规则、隐晦规则、变量定义、文件指示和注释。除去看不见的隐晦规则与注释,我们能够看到的部分里面最重要的就是显式规则,它们定义了如何去生成目标文件。至于其中的变量和函数,我们可以在分析的过程中再去搜索。
该项目的Makefile组成分别为lab1目录下的Makefile,tool目录下的functions.mk,Makefile通过include的方式来包含functions.mk中的宏和过程。在Makefile中call所调用的过程,大多数都在functions.mk有定义。

1.1 ucore.img

首先,我们搜索ucore.img,它在Makefile中的定义为:

UCOREIMG        := $(call totarget,ucore.img)

$(UCOREIMG): $(kernel) $(bootblock)
        $(V)dd if=/dev/zero of=$@ count=10000
        $(V)dd if=$(bootblock) of=$@ conv=notrunc
        $(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc

$(call create_target,ucore.img)

UCOREIMG的生成需要kernel bootblock,$@为目标集合,在此处为UCOREIMG,dd为linux平台上的拷贝工具,首先开辟了10000个扇区的空间,然后在第0号扇区上拷贝bootblock作为引导扇区,从第一扇区开始拷贝内核。
可以看出,最终的映像ucore.img的组成需要bootblock和kernel部分,在启动时,BIOS会读入引导扇区并从该扇区头开始执行。此处我们不再赘述,继续深层寻找bootblock和kernel。

1.2 bootblock

在Makefile中,bootblock的显式规则如下

bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

bootblock = $(call totarget,bootblock)

$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
        @echo + ld $@
        $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
        @$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
        @$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
        @$(call totarget,sign) $(call outfile,bootblock) $(bootblock)

要看bootblock需要哪些依赖,我们需要找到bootfiles含有什么,我们找到bootfiles的定义为

bootfiles = $(call listf_cc,boot)

再下一层找到listf_cc(其实是listf)的定义

listf = $(filter $(if $(2),$(addprefix %.,$(2)),%),\
                  $(wildcard $(addsuffix $(SLASH)*,$(1))))

可以看出,listf是在参数一的后方加上/*,如:boot/*,然后用wildcard将其展开,变成boot目录下的所有文件,然后过滤掉所有不符合.c,.S,.h结尾的文件,那么结果就是,bootmain.c、bootasm.S,经过toobj转换后变成了bootmain.o,bootasm.o,再加上“|”符号后面的sign,还需要可执行文件sign.

bootmain.o,bootasm.o的由来是由该条语句批量生成的

$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

搜索cc_compile的宏定义找到了do_cc_compile,再一层嵌套找到cc_template,分析得传入的参数分别为:需编译的文件,编译器,编译器参数选项。

define cc_template
$$(call todep,$(1),$(4)): $(1) | $$$$(dir $$$$@)
        @$(2) -I$$(dir $(1)) $(3) -MM $$< -MT "$$(patsubst %.d,%.o,$$@) $$@"> $$@
$$(call toobj,$(1),$(4)): $(1) | $$$$(dir $$$$@)
        @echo + cc $$<
        #准确来说是这句
        $(V)$(2) -I$$(dir $(1)) $(3) -c $$< -o $$@
ALLOBJS += $$(call toobj,$(1),$(4))
endef

关注传参,发现编译器的选项定义在CFLAGS中,把相关联的几项摘出

CFLAGS  := -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc $(DEFS)
CFLAGS  += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
……
CFLAGS  += $(addprefix -I,$(INCLUDE))
……
$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc)
……
$(V)$(2) -I$$(dir $(1)) $(3) -c $$< -o $$@

从前到后拼接为

-I(dir) -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc $(DEFS) -fno-stack-protector -I$(INCLUDE) -Os -nostdinc -c $$< -o $$@  

我们可以根据其中的定义推得生成bootasm.o的直接命令为:

gcc -Iboot/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o

同理,生成bootmain.o的直接命令为:

gcc -Iboot/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o

生成sign的直接命令为:

gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign

最后将三者链接成一个的目标文件

ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o

1.3 kernel

详细的Makefile阅读方法已经在1.2节中介绍,再次不再赘述。利用相同的方法,我们可以得到kernel所需要的依赖项。
为了生成kernel,首先需要 kernel.ld init.o readline.o stdio.o kdebug.o kmonitor.o panic.o clock.o console.o intr.o picirq.o trap.o trapentry.o vectors.o pmm.o printfmt.o string.o。实际命令以init.o举例,与bootblock的组成部分生成大同小异。

gcc -Ikern/init/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c -o obj/kern/init/init.o

链接指令也不再赘述

ld -m    elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel  obj/kern/init/init.o obj/kern/libs/stdio.o obj/kern/libs/readline.o obj/kern/debug/panic.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/picirq.o obj/kern/driver/intr.o obj/kern/trap/trap.o obj/kern/trap/vectors.o obj/kern/trap/trapentry.o obj/kern/mm/pmm.o  obj/libs/string.o obj/libs/printfmt.o

1.4 参数解释

编译参数

  • -ggdb 生成可供gdb使用的调试信息。这样才能用qemu+gdb来调试bootloader or ucore。
  • -m32 生成适用于32位环境的代码。我们用的模拟硬件是32bit的80386,所以ucore也要是32位的软件。
  • -gstabs 生成stabs格式的调试信息。这样要ucore的monitor可以显示出便于开发者阅读的函数调用栈信息
  • -nostdinc 不使用标准库。标准库是给应用程序用的,我们是编译ucore内核,OS内核是提供服务的,所以所有的服务要自给自足。
  • -fno-stack-protector 不生成用于检测栈溢出的代码。
  • -Os 为减小代码大小而进行优化。根据硬件spec,主引导扇区只有512字节,我们写的简单bootloader的最终大小不能大于510字节。
  • -I<dir> 添加搜索头文件的路径
  • -fno-builtin 除非用__builtin_前缀,否则不进行builtin函数的优化

链接参数

  • -m 模拟为i386上的连接器
  • -nostdlib 不使用标准库
  • -N 设置代码段和数据段均可读写
  • -e 指定入口
  • -Ttext 制定代码段开始位置,将引导扇区代码段放在0x7c00处使得该代码在BIOS将其读入后就能执行。
  • -T <scriptfile> 让连接器使用指定的脚本

1.5 引导扇区[Q2]

BIOS 加载存储设备上的第一个扇区的512字节到内存的 0x7c00,一个扇区被认为是引导扇区的标准是其以0x55,0xAA结尾,在Makefile中可以看到,我们对生成的bootblock.out用sign程序做了处理生成了bootblock的二进制文件,在tools/sign.c中我们可以看到如下代码,该代码就达到了为该扇区添加特定尾部的效果。

    buf[510] = 0x55;
    buf[511] = 0xAA;
    FILE *ofp = fopen(argv[2], "wb+");
    size = fwrite(buf, 1, 512, ofp);

2、bootasm.s分析与调试

2.1 .gdbinit的书写与调试指令分析

为了灵活调试,我们必须看一看make debug时到底执行了何种shell命令,然后再灵活修改.gdbinit文件达到相应的调试效果。make debug-nox的目标与指令如下:

debug-nox: $(UCOREIMG)
        $(V)$(QEMU) -S -s -serial mon:stdio -hda $< -nographic &
        $(V)sleep 2
        $(V)$(TERMINAL) -e "gdb -q -x tools/gdbinit"

debug-nox时,首先使用对应系统的QEMU,然后以不同的参数来进行调整:

  • -S -s表示开启调试,即QEMU不直接运行客户机系统,而是等待gdb端口连接来进行调试
  • -serial mon:stdio表示利用命令行标准输出来进行调试,qemu提供了串口模式,可以直接把输出送到终端
  • -hda $<表示 硬盘文件为依赖文件ucore.img
  • -nographic表示非图形化界面

接着开启一个新的终端,利用-e在终端创建时执行gdb -q -x tools/gdbinit

  • -q表示不输出版本等信息
  • -x表示gdb开始时使用tool目录下的gdbinit

所以,gdb所要进行的初始化工作,应该在.gdbinit内实现。

  • 连接QEMU开放的端口
  • 设置架构等等信息
  • 设置断点并执行

调试bootasm.S的.gdbinit脚本如下(如果要调试BIOS,那么就不用设置断点,也不用continue)

set architecture i8086
target remote :1234
break *0x7c00
continue

2.2 调试BIOS

调试开始时输出如下,可以看到,调试开始于f000:fff0。但是输出的指令对应的指令码却是0x0。

0x0000fff0 in ?? ()
=> 0xfff0:	add    %al,(%eax)
   0xfff2:	add    %al,(%eax)

这是因为刚刚开始执行第一条指令时地址不只有EIP来决定,地址为Base + EIP = FFFF0000H + 0000FFF0H = FFFFFFF0H,而在第一条指令之后,“段寄存器左移四位加上eip寄存器的值”这种工作方式才会开始,所以执行的第一条指令其实是:

(gdb) x/2i 0xfffffff0
   0xfffffff0:  ljmp   $0x3630,$0xf000e05b
   0xfffffff7:  das

3、调试bootasm——分析进入保护模式的过程

[Q1][Q2][Q3]

注:在实验时,通过修改makefile中debug目标下的指令,加入 -d in_asm -D xxx.log来对QEMU执行过的指令进行记录的方法在本人的UbuntuX86-64的虚拟机上产生了不能正确反汇编的效果,所以我们用gdb查看内存的方式来查看调用bootmain之前所执行的指令。

   0x7c00 <start>:      cli

首先是关闭中断,该指令的作用是禁止中断发生,在cli起效之后,所有外部中断都被屏蔽,这样可以保证当前运行的代码不被打断,起到保护代码运行的作用。

   0x7c01 <start+1>:    cld

然后是设置位移寄存器的方向,约定了处理字符串时寄存器是默认增长还是默认减少。

   0x7c02 <start+2>:    xor    %eax,%eax
   0x7c04 <start+4>:    mov    %eax,%ds
   0x7c06 <start+6>:    mov    %eax,%es
   0x7c08 <start+8>:    mov    %eax,%ss

接下来与其是将ds es ss寄存器全部设置为0,不如说是将ds es ss全都设为与cs(0x0)相同,因为仅在bootblock一个扇区被读入时,此扇区中不只有代码,还会有接下来要说到的gdt描述符以及数据等等,我们要将其当作数据进行读写时默认需要ds es寄存器。

   0x7c0a <seta20.1>:   in     $0x64,%al
   0x7c0c <seta20.1+2>: test   $0x2,%al
   0x7c0e <seta20.1+4>: jne    0x7c0a <seta20.1>
   0x7c10 <seta20.1+6>: mov    $0xd1,%al
   0x7c12 <seta20.1+8>: out    %al,$0x64

   0x7c14 <seta20.2>:   in     $0x64,%al
   0x7c16 <seta20.2+2>: test   $0x2,%al
   0x7c18 <seta20.2+4>: jne    0x7c14 <seta20.2>
   0x7c1a <seta20.2+6>: mov    $0xdf,%al
   0x7c1c <seta20.2+8>: out    %al,$0x60

之后是打开A20地址线,保护模式与实模式的一大不同就是保护模式拥有32位寻址,而在实模式的地址中,只有20位(0~19号地址线)寻址,而管理第20号地址线的输出线被称为A20Gate:如果A20 Gate被打开,则当程序员给出100000H-10FFEFH之间的地址的时候,系统将真正访问这块内存区域;如果A20Gate被禁止,则当程序员给出100000H-10FFEFH之间的地址的时候,系统仍然进行模2^20的运算后访问。
在这里我们使用in和out来进行输入输出操作,首先等待8042键盘控制器空闲,发送写输出端口指令,再次等待其空闲,将标志位写入以打开A20Gate。

   0x7c1e <seta20.2+10>: lgdtl  (%esi)

在这里,esi的值在调试过程中为0x0,而在源代码bootasm.S中lgdtl指向的位置恰好就是gdtdesc,关于这点,我们观察该条指令日志中的机器码发现。

IN: 
0x00007c1e:  
OBJD-T: 0f01166c7c

其中尾部的两个字节作为小端操作数为0x7c6c,而在gdb中查看该处内存,恰好是gdtdesc的位置。所以该处是反汇编错误。

(gdb) x/2wx 0x7c6c
0x7c6c <gdtdesc>:       0x7c540017      0x89550000

接着我们来说gdt,gdt在本质上是一张线性表,其物理地址存储在gdtr寄存器中(这就保证了访问gdt不用经过地址转换),其中的每一个表项都描述了一段内存的起点,长度和段属性。它将段寄存器:段偏移地址映射到物理地址。在保护模式下,地址的转换规则发生变化,段寄存器的取值实际上是对应段表项在gdt内的偏移,硬件读出段起始点,加上段偏移成为新的物理地址。

gdt:
    SEG_NULLASM                                     # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel
gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt                                                             

因为上一句lgdtl反汇编错误,所以我们按照执行情况从0x7c23开始反汇编

(gdb) x/6i 0x7c23
   0x7c23 <seta20.2+15>:        mov    %cr0,%eax
=> 0x7c26 <seta20.2+18>:        or     $0x1,%ax
   0x7c2a <seta20.2+22>:        mov    %eax,%cr0
   0x7c2d <seta20.2+25>:        ljmp   $0xb866,$0x87c32

这段代码是要设置CR0寄存器的标志位,只有在该标志位置位后,寻址模式才真正变成保护模式的寻址模式,gdt才真正开始启用。之后的ljump是进入保护模式的节点(但是这里的反汇编仍存在错误)查看机器码执行:

IN: 
0x00007c2d:  
OBJD-T: ea327c0800

将机器码按照小端序操作数分开得到,ea 7c32 0008,所以跳转地址为0008:7c32,对照gdt偏移为0x8的表项得到其起始地址为0,那么转换的物理地址为0x7c32,验证结果。

(gdb) x/2i 0x7c32
=> 0x7c32 <protcseg>:   mov    $0x10,%ax
   0x7c36 <protcseg+4>: mov    %eax,%ds

进入protcseg过程,我们真正进入了保护模式,此时,利用分段机制,我们可以给不同的段寄存器赋予不同的段选择子(gdt偏移)来实现在一段代码中取指令和存取数据访问不同的内存段,此处ds es fs gs ss全部指向第二个段选择子所对应的内存,相当于是数据段。

(gdb) x/6i 0x7c32
=> 0x7c32 <protcseg>:   mov    $0x10,%ax
   0x7c36 <protcseg+4>: mov    %eax,%ds
   0x7c38 <protcseg+6>: mov    %eax,%es
   0x7c3a <protcseg+8>: mov    %eax,%fs
   0x7c3c <protcseg+10>:        mov    %eax,%gs
   0x7c3e <protcseg+12>:        mov    %eax,%ss
   0x7c40 <protcseg+14>:        mov    $0x0,%ebp
   0x7c45 <protcseg+19>:        mov    $0x7c00,%esp
   0x7c4a <protcseg+24>:        call   0x7d0c <bootmain>

4、bootloader分析

4.1 bootmain函数实现ELF文件加载[Q2]

与PE文件相似,符合ELF文件格式的文件都会有ELF文件头,其中描述了文件的各种属性,包括很重要的程序入口地址,programme header表的位置偏移,在将ELF文件加载入内存时,我们需要做如下几件事:

  • 将ELF文件头读入,获得Programme header的位置以及程序的入口虚拟地址。
  • 依次读取programme header内的每一个表项,从中得知每个段在磁盘文件中的位置以及它应该被加载到内存中的位置。
  • 读取对应位置的磁盘内容,将其放入对应内存。
  • 将控制权转交给内核(跳转EntryPoint)
	void
	bootmain(void) {
	    // 首先读取ELF的头部,其中8个扇区包含了ELFheader和programme header,读取到ELFHDR的位置,后面在计算Programme header位置时就以ELFHDR位置为基址加上RVA即可
	    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
	    // 通过储存在头部的幻数判断是否是合法的ELF文件
        //……
	    struct proghdr *ph, *eph;
        //……
	    //ph与eph分别记录了Programme的开头和结尾,其中的加法是结构体指针的加法,每加一次跳过一个结构
	    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
	    eph = ph + ELFHDR->e_phnum;
	
	    // 从磁盘offset处,读取memsz个字节到,ELFHDR+va的内存处
	    for (; ph < eph; ph ++) {
	        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
	    }
	    //以转化成void (void)型函数指针的方式“调用”入口点函数
	    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
        //……
	}

4.2 readseg函数实现磁盘读取[Q1]

在4.1中,我们分析了Loader将ELF格式的内核读入内存的过程,但是我们在调用readseg的时候提供的是文件偏移,在磁盘读取时,我们所需要的是扇区信息,所以我们需要在读取磁盘前做出转换。
首先,在Makefile中的拷贝指令中,内核文件在第一个扇区而不是第零扇区,所以,传入的offset也是从第一个扇区开始的偏移,故
内核起始扇区号=1
任意内核offset起始扇区号=内核起始扇区号+(offset/每扇区字节数)

	static void
	readseg(uintptr_t va, uint32_t count, uint32_t offset) {
	    uintptr_t end_va = va + count;
	    va -= offset % SECTSIZE;
	    uint32_t secno = (offset / SECTSIZE) + 1; 
	    for (; va < end_va; va += SECTSIZE, secno ++) {
	        readsect((void *)va, secno);
	    }
	}

最后就是真正读取磁盘的代码,我们使用24-bit LBA方式,通过对IO寄存器进行操作来写磁盘:
写0x1f1: 0
写0x1f2: 要读的扇区数
写0x1f3: 扇区号的0~7位
写0x1f4: 扇区号的8~15位
写0x1f5: 扇区号的16~23位
写0x1f6: 75位,111,第4位0表示主盘,1表示从盘,30位,扇区号的24~27位
写0x1f7: 0x20为读, 0x30为写
读0x1f7: 第4位为0表示读写完成,否则要一直循环等待
读0x1f0: 每次读取1个word,反复循环,直到读完所有数据

	static void
	readsect(void *dst, uint32_t secno) {
	    waitdisk();
	
	    outb(0x1F2, 1); 
	    outb(0x1F3, secno & 0xFF);
	    outb(0x1F4, (secno >> 8) & 0xFF);
	    outb(0x1F5, (secno >> 16) & 0xFF);
	    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);

	    outb(0x1F7, 0x20);
	    waitdisk();
	    insl(0x1F0, dst, SECTSIZE / 4);
	}

5、实现堆栈调用跟踪[Q1]

5.1 函数调用编程

获得并打印打印当前的ebp,eip,上一个函数栈的eip由当前的[ebp+4]得到,上一个函数栈的ebp由[ebp]得到,注意,要先更新ebp再更新eip,不能模仿函数清理栈帧时的表现,函数ret的时候根据esp获得上一个eip,而我们在编程时利用ebp,所以ebp最后一个更新。

void
print_stackframe(void) {
      uint32_t ebp=read_ebp();
      uint32_t eip=read_eip();
      int dpt=0;
      while(eip&&ebp&&dpt<STACKFRAME_DEPTH)
      {
	    cprintf("the value of ebp ==> 0x%08x\n",ebp);
	    cprintf("the value of eip ==> 0x%08x\n",eip);
	    cprintf("args:0x%08x 0x%08x 0x%08x 0x%08x\n",*(unsigned int*)(ebp+8),*(unsigned int*)(ebp+12),*(unsigned int*)(ebp+16),*(unsigned int*)(ebp+20) );
	    print_debuginfo(eip-1);
	    cprintf("\n");
	    eip=*(uint32_t*)(ebp+4);
	    ebp=*(uint32_t*)ebp;
	    dpt++;
      }
}

运行结果如下:

the value of ebp ==> 0x00007b38
the value of eip ==> 0x00100a90
args:0x00010094 0x00010094 0x00007b68 0x00100083
    kern/debug/kdebug.c:306: print_stackframe+26

the value of ebp ==> 0x00007b48
the value of eip ==> 0x00100db3
args:0x00000000 0x00000000 0x00000000 0x00007bb8
    kern/debug/kmonitor.c:125: mon_backtrace+14

the value of ebp ==> 0x00007b68
the value of eip ==> 0x00100083
args:0x00000000 0x00007b90 0xffff0000 0x00007b94
    kern/init/init.c:48: grade_backtrace2+23

the value of ebp ==> 0x00007b88
the value of eip ==> 0x001000a9
args:0x00000000 0xffff0000 0x00007bb4 0x00000029
    kern/init/init.c:53: grade_backtrace1+31

the value of ebp ==> 0x00007ba8
the value of eip ==> 0x001000ca
args:0x00000000 0x00100000 0xffff0000 0x00100043
    kern/init/init.c:58: grade_backtrace0+23

the value of ebp ==> 0x00007bc8
the value of eip ==> 0x001000ef
args:0x00000000 0x00000000 0x00000000 0x00103420
    kern/init/init.c:63: grade_backtrace+30

the value of ebp ==> 0x00007be8
the value of eip ==> 0x00100050
args:0x00000000 0x00000000 0x00000000 0x00007c4f
    kern/init/init.c:28: kern_init+79

the value of ebp ==> 0x00007bf8
the value of eip ==> 0x00007d71
args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8
    <unknow>: -- 0x00007d70 --

6、中断设置

6.1 中断与IDT[Q1]

外设在需要操作系统处理外设相关事件的时候,能够“主动通知”操作系统,即打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后在恢复操作系统和应用的正常执行。
在操作系统中,有三种特殊的中断事件。由CPU外部设备引起的外部事件如I/O中断、时钟中断、控制台中断等是异步产生的(即产生的时刻不确定),与CPU的执行无关,称中断。而把在CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件称作异常。把在程序中使用请求系统服务的系统调用而引发的事件,称作陷阱
发生中断后,x86根据终端去IDT中查找相应的描述符,并跳转到描述符指向的中断处理例程执行,进行中断初始化和处理。
查看kern/mm/mmu.h中的struct gatedesc

struct gatedesc {
    unsigned gd_off_15_0 : 16;
    unsigned gd_ss : 16;
    unsigned gd_args : 5;
    unsigned gd_rsv1 : 3;
    unsigned gd_type : 4;
    unsigned gd_s : 1;
    unsigned gd_dpl : 2;
    unsigned gd_p : 1;
    unsigned gd_off_31_16 : 16;
};

如图所示,存放在IDT中的表项为“门描述符”,所谓“门”,就是描述了一个调用地址,在保护模式下描述该种地址,需要一个选择子和相应的偏移,并且还需要描述该“门”被调用所需要的具体权限,即DPL,关于权限系统,我们在下一节再述。
在struct gatedesc共8字节,其中第0,1字节存放偏移低十六位,2,3字节存放选择子,6,7字节存放偏移的高16位。

6.2 实现中断 [Q2][Q3]

所以我们来理清思路,需要做的事情从前到后为这么几项:

  • 设计中断处理函数
  • 初始化IDT,利用lidt指令使其生效。
  • 将中断处理函数的选择子,DPL,偏移等属性挂载到对应IDT表项
  • 初始化8925A,开中断

各个部分分布有些杂乱,所需素材位置如下:
中断处理函数位置 trap.c trap_dispatch
IDT位置 trap.c idt[256]
IDT初始化函数位置 trap.c idt_init
lidt指令C语言定义位置 x86.h lidt
时钟定义位置 clock.c tick trap.c TICK_NUM print_ticks
代码段选择子定义位置:memlayout.h KERNEL_CS
自动初始化选择子宏 mmu.h SETGATE
中断统一执行打包函数 trapentry.S __alltraps

设计中断处理函数

case IRQ_OFFSET + IRQ_TIMER:
        ticks ++;
        if (ticks == TICK_NUM)
        {
            ticks = 0;
            print_ticks();
        }
        break;

初始化IDT,利用lidt指令使其生效

void
idt_init(void) {
      int isr = 0;
      for(; isr < 256; isr++) 
      	SETGATE(idt[isr], 0, KERNEL_CS, __vectors[isr], DPL_KERNEL);
      lidt(&idt_pd);
}

在这两段代码完成后,发生中断时,系统执行IDT中对应的代码,执行到__vecors[interrupt_code],__vectors[interrupt_code]执行__alltraps

  pushl $0
  pushl $1
  jmp __alltraps

alltraps在执行前会保存中断码,执行时,会保存状态后调用trap

__alltraps:
    pushl %ds
    pushl %es
    pushl %fs
    pushl %gs
    pushal
    movl $GD_KDATA, %eax
    movw %ax, %ds
    movw %ax, %es
    pushl %esp
    call trap
    popl %esp

trap函数最终调用我们定义在trap_dispatch中的中断函数,完成中断调用。

void
trap(struct trapframe *tf) {
    // dispatch based on what type of trap occurred
    trap_dispatch(tf);
}

效果如下

++ setup timer interrupts
100 ticks
100 ticks
……

7、特权级变换[challenge1]

7.1 int指令与iret指令

int指令执行时发生如下事件:

  • 标志寄存器入栈
  • CS:EIP 寄存器入栈
  • 通过中断号在中断描述符表中找到对应的描述符,并将对应的数据放入CS:EIP中

iret指令执行时发生如下事件:

  • 恢复IP
  • 恢复CS
  • 恢复中断前的标志寄存器的状态
    如果返回时的CS,IP发生权限变化,还会额外切换栈
  • 恢复ESP(返回权限发生变化)
  • 恢复SS(返回权限发生变化)

调用trap时从高地址到低地址栈为

7.2 设计思路——内核态->用户态

要切换到用户态,首先要确定用户态做对应的段,这就要依赖GDT中具体对于段权限的划分,在pmm.c中可以看到,在进入保护模式后,操作系统舍弃了原来bootblock中的那套GDT,更换了一套新的GDT,其中就有用户空间的描述符。

static struct segdesc gdt[] = {
    SEG_NULL,
    [SEG_KTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_KERNEL),
    [SEG_KDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_KERNEL),
    [SEG_UTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_USER),
    [SEG_UDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_USER),
    [SEG_TSS]    = SEG_NULL,
};

既然用户空间和内核空间是重叠的,那么我们可以将设计思路简化,使用alltraps中断返回时的iret指令进行权限切换,让其在中断返回后继续执行init.c中的代码,只不过权限变为用户权限,这样我们只需要更改CS和SS即可。
观察传参情况,传入trap_dispatch的参数tf由好几部分组成

esp入栈成为传入的参数tf,其值指向栈中gs的位置
……-------------通用寄存器
gs-------------
fs            | alltraps暂存的寄存器值
es            |
ds-------------
interrupt_code -----中断号
0              -----__vector调用时入栈的错误码
KEIP------------
KCS            |int指令入栈的资源
KFLAGS----------

在iret执行前,alltraps会

    popl %esp;清理参数指针
    popal    ;清理通用寄存器
    popl %gs ;清理段寄存器
    popl %fs
    popl %es
    popl %ds
    addl $0x8, %esp;清理中断号,错误码
    iret

所以iret执行时,栈结构为

KEIP------------
KCS            |int指令入栈的资源
KFLAGS----------

根据iret的指令内容,当我们要切换用户态时,iret还会在重置EIP,CS后继续重置SS和ESP,所以我们需要在int指令调用前留出两个位置

KEIP------------
KCS            |int指令入栈的资源
KFLAGS----------
user esp
user ss

所以,总结下来思路如下:

  • 调整esp,为ss和esp留下空间
  • 执行int指令,触发中断
  • 在终端执行程序里对传入的trap_frame进行修改,将其中的段寄存器全部改为用户态的选择子
  • 中断返回时触发特权级变换,以用户权限继续执行init.c接下来的代码

实现:
内联汇编调用int指令

static void
lab1_switch_to_user(void) {
    __asm__ __volatile__ (
		"sub $0x8,%%esp\n"
		"int %0\n"
		"movl %%ebp,%%esp"
		:
		:"i" (T_SWITCH_TOU)
	);
}

trap_dispatch中在对应中断处修改选择子

case T_SWITCH_TOU:
    if ((tf->tf_cs & 3) == 0) //若非用户态
    {
		tf->tf_cs = USER_CS;//调整代码段选择子
		tf->tf_ss = tf->tf_ds = tf->tf_es = tf->tf_gs = tf->tf_fs = USER_DS;//调整数据段选择子
		tf->tf_esp = (uint32_t)tf + sizeof(struct trapframe);//将esp调整为调用sub之前所指向的位置
		tf->tf_eflags |= FL_IOPL_MASK;//为了保证在用户态下也能使用I/O,将IOPL降低到了ring 3
	}

7.3 设计思路——用户态->内核态

进行第二次转换时,我们处于用户态,此时再次调用int中断指令时,因为中断处理在内核态,所以操作系统会先进入内核态切换栈,然后将我们的ss,esp等用户态的信息入内核栈,图为调试时临时内核栈中的用户态信息。
注:该处使用pwndbg插件,方便查看栈结构,本质与gdb相同。

00:0000│ esp 0x110d0c (stack0+1004) —▸ 0x1001d5 (lab1_switch_to_kernel+9)-eip
01:0004│     0x110d10 (stack0+1008) —▸ 27---------------------------------cs
02:0008│     0x110d14 (stack0+1012) —▸ 0x3206-----------------------------eflag
03:000c│     0x110d18 (stack0+1016) —▸ 0x7bb8-----------------------------esp
04:0010│     0x110d1c (stack0+1020) —▸ 35---------------------------------ss

所以,在执行iret时,我们还是在内核栈的,只要我们篡改trapframe中的段寄存器DPL为内核态,那么iret就不会进行特权级变换。但是话说回来,在中断后,栈要切换回”用户态“的栈,那么我们就需要在清理参数指针时将esp先行切换回用户栈,在用户栈上伪造特权级未变换的trapframe,然后进行iret。

case T_SWITCH_TOK:
    if (tf->tf_cs != KERNEL_CS) {
            tf->tf_cs = KERNEL_CS;//临时栈上的伪造
            tf->tf_ds = tf->tf_es = KERNEL_DS;
            tf->tf_eflags &= ~FL_IOPL_MASK;
            switchu2k = (struct trapframe *)(tf->tf_esp - (sizeof(struct trapframe) - 8));
            //在原用户栈上开辟一块大小为不带ss与esp的frame
            memmove(switchu2k, tf, sizeof(struct trapframe) - 8);
            //将伪造好的frame拷贝到用户栈上
            *((uint32_t *)tf - 1) = (uint32_t)switchu2k;
            //修改tf的参数指针,在返回时栈迁移到用户态栈
        }
        break;

注:此处要在IDT中将T_SWITCH_TOK的表项的DPL改为用户的DPL,否则ring3无法调用该IDT
内核临时栈情况

esp------------指向用户栈
……-------------通用寄存器
gs-------------
fs            | alltraps暂存的寄存器值
es            |
ds-------------
interrupt_code -----中断号
0              -----__vector调用时入栈的错误码
KEIP------------
KCS            |int指令入栈的资源
KFLAGS----------
user esp--------
user ss---------

用户栈情况

……-------------通用寄存器,内核态esp指向的位置,在pop esp时栈会迁移至此
gs-------------
fs            | alltraps暂存的寄存器值
es            |
ds-------------
interrupt_code -----中断号
0              -----__vector调用时入栈的错误码
KEIP------------
KCS            |因为内核态处理该栈时对该frame已经进行篡改,iret时不进行特权级变换
KFLAGS----------

challenge结果

emiya@emiya-virtual-machine:~/桌面/Comp/lab1$ make grade
Check Output:            (2.4s)
  -check ring 0:                             OK
  -check switch to ring 3:                   OK
  -check switch to ring 0:                   OK
  -check ticks:                              OK
Total Score: 40/40

8、键盘信号进行进一步特权级切换[challenge2]

8.1 设计思路——内核态->用户态

在第七节中,我们会手动调用特权级切换的动作,而在键盘中断处理时,我们没有办法在中断前重新压入ss和esp,所以,基本的思路还是伪造trap_frame,然而问题就在于在哪里伪造。
我们当然可以将整个trapframe向低地址移动8位,但是移动后势必会覆盖其它栈变量,那么,如果将地址比trapframe低的栈变量都向低地址移动8个字节,那么途中有很多存放栈帧ebp的值都会需要修改。所以直接考虑全部伪造。
将我们的fake_frame放置在esp-0x200的地方(大约)。因为如果要调用memmove函数时修改了函数返回地址等等就会引发错误,所以将fake_frame放的远一点。
然后仿照第七节中劫持参数tf的想法,将tf的地址劫持为fake_frame的地址,那么trap_dispatch返回时就会pop出fake_frame

case '3':
        asm volatile("movl %%esp, %0" : "=r" (esp0));
        //得到esp的值
		newtf=(struct trapframe *)(esp0-0x200);
        //找到伪造地址
		if (trap_in_kernel(tf)) {
			memmove(newtf, tf, sizeof(struct trapframe) - 8);
			newtf->tf_cs = USER_CS;
			newtf->tf_ss = newtf->tf_ds = newtf->tf_es = newtf->tf_gs = newtf->tf_fs = USER_DS;
			newtf->tf_esp = (uint32_t)tf + (sizeof(struct trapframe)-8);
            //将esp指向调用int时的栈顶
			newtf->tf_eflags |= FL_IOPL_MASK;
			*((uint32_t *)tf - 1) = (uint32_t)newtf;
            //劫持变量tf
		}
		break;

8.2 设计思路——用户态->内核态

用户态->内核态在调用int时没有进行空间的开辟,在伪造时和第七节UTOK没有太大的区别故不再赘述。

case '0':
    if (tf->tf_cs != KERNEL_CS) {
        tf->tf_cs = KERNEL_CS;
        tf->tf_ds = tf->tf_es = KERNEL_DS;
        tf->tf_eflags &= ~FL_IOPL_MASK;
        switchu2k = (struct trapframe *)(tf->tf_esp - (sizeof(struct trapframe) - 8));
        memmove(switchu2k, tf, sizeof(struct trapframe) - 8);
        *((uint32_t *)tf - 1) = (uint32_t)switchu2k;
    }
    break;

在init.c中加入显示内核特权级的函数,让其隔一段时间显示

long cnt = 0;
    while (1)
    {
    	if ((++cnt) % 6666666== 0)
	    lab1_print_cur_status();
    };

实验结果如下:

3: @ring 0
3:  cs = 8
3:  ds = 10
3:  es = 10
3:  ss = 10
100 ticks
100 ticks
serial 3
100 ticks
100 ticks
100 ticks
100 ticks
4: @ring 3
4:  cs = 1b
4:  ds = 23
4:  es = 23
4:  ss = 23
100 ticks
100 ticks
100 ticks
serial 0
100 ticks
100 ticks
100 ticks
5: @ring 0
5:  cs = 8
5:  ds = 10
5:  es = 10
5:  ss = 10

9、后记

做一个自由的人。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值