dustland

dustball in dustland

玩具内核V0.2

玩具内核V0.2

LDT和TSS

0.1内核版本时内核只用到了GDT,没有用到LDT.

这就导致内核和所有用户任务占用的内存空间是放到一起存储的

内核当然不愿意和肤浅的用户程序在同一层上

于是使用LDT让用户任务成为人下人

image-20220902150426682

TSS:Task Segment State,任务状态表,当任务被切换时,本次执行到最后的堆栈指针,程序计数器,各种通用寄存器等都要保存,方便下一次继续执行本任务的时候恢复状态

TSS结构

image-20220902160852184

这里除了ESP指向的堆栈,还额外定义了三个堆栈,即ESP0,1,2分别指向的堆栈

这是因为堆栈段的权限必须始终保持和当前代码段权限相同

用户程序权限最低,它可能调用2,1,0环上的代码,那么就得跟着切换堆栈

多个附加堆栈段都会在程序自己的LDT中注册,在TSS中只会保留这些附加堆栈的选择子和段内偏移量

LDT:局部段描述符表,只维护一个任务的分段,比如堆栈段,数据段,代码段

LDTR:LDT寄存器,负责记录当前正在执行的任务的LDT基地址,LDTR只有一个,哪个任务正在执行就用LDTR记录哪一个的LDT基址

TR:任务状态表寄存器,也是只有一个,指向当前正在执行的任务

在段选择子中第三位(位2)

image-20220902160111155

就是区分该索引是指向GDT的还是LDT的,由于这个段选择子是当前任务给出的,因此此时的LDT必然是当前任务的LDT,此时LDTR必然爆粗的是当前任务的LDT基地址,只需要拿着索引去查LDT就可以获得段地址了

LDT和GDT还有一点不同是,GDT[0]是空的不使用,但是LDT[0]是可用的

私有空间和公有空间

image-20220902161503214

每个程序都有自己的代码段数据段堆栈段等私有空间,多个程序共享内核的系统调用还有运行库,这属于公有空间

一般情况下程序运行在私有空间,只有需要系统调用的时候或者需要调用库函数的时候,才会转到公有空间去执行

特权级

特权级定义

CPL:当前正在执行的代码的权限

RPL:段选择子权限

实际由于CS总是指向当前正在执行的代码段你,大多数时候RPL和CPL是一个东西

只有低级函数调用高级依存代码段函数时有别,后面会提到

DPL:段描述符权限

IOPL:IO当前代码段你得IO特权级

权限占用两位,能够标识的特权级就是0,1,2,3,数字越大则权限越低,0环为最高权限.

image-20220902162251067

操作系统内核一般运行在0环,权限最高.

用户程序的权限最低,在3环

一个位于三环的用户程序,不管访问什么段,其设置的段选择子中RPL只能是3

比如

如果其使用系统调用则cs中Index设置为操作系统代码段,RPL=3

如果其引用了运行库的一个全局数据,则ds中的Index设置为运行库数据段,RPL=3

而一个位于0环的段,其段描述符中的DPL就是0

特权指令:只能由特权级0的代码执行的指令,比如停机指令HLT,还有对控制寄存器CR0的写操作的指令,lgdt,lldt,ltr等等

IOPL:IOPL是处理器中程序状态字的两位,用来表征当前正在执行的代码段的IO权限

image-20220902164920415

严禁低级代码段访问高级数据段

数据段的特权级规则

数据段的DPL决定了其最低访问权限,只允许权限至少相同或者更高的代码段访问

0环的数据段只能被0环的代码段访问(门当户对)

3环的数据段可以被0,1,2,3环的代码段访问(DPL=3,RPL=0,1,2,3)

内核数据段是在0环上的,用户程序的代码段是在3环上的,显然用户程序是没法访问内核数据段的

严禁控制向低特权级转移

代码段的特权级规则

代码段有依从的和非依从的之分

非依从的代码段讲究一个门当户对,2环的代码段只允许被2环的代码段啊调用,0,1,3都不行.也就是权限高的不行,权限低的也不行

依从的代码段还允许被特权低的代码段调用,即2环的依从的代码段可以被2,3环的代码段调用,不允许被0,1环的代码段调用.这是因为特权级越高的代码段,其安全性越高,0环的代码段基本都是写死的不会出事.从3环调用0环是系统调用的方向,安全性只能是越来越高

但是0环不敢随意调用3环,如果用户自己写一个3环的病毒,被操作系统调用了就寄了

这貌似根数据段的特权规则正好相反,

数据段只允许权限同级或者更高的代码段调用.

而代码段顶多允许同级或者权限更低的代码段调用.

这就好比将校可以任意获取军士的情报,但是军士不能获取将校的情报

军士可以执行将校的命令,但是将校不能执行军士的命令

显然一个将军相比于一个士兵是更不容易叛变的,并且保密等级更高的

如何区分一个代码段是不是依从的呢?

代码段描述符的TYPE字段有一个C位,

如果C=0则是非依从的,只能被同级调用.

如果C=1则是依从的,允许同级和下级调用

image-20220902171216715

严禁控制向低特权级转移

严禁控制向低特权级转移

严禁控制向低特权级转移

针对这句话貌似可以找很多茬:

一是,当系统调用返回到用户函数时,不就是从0环的内核代码段返回到3环的用户程序代码段了吗?实际上依存的代码段执行时,当前特权级CPL不会改变,即保持用户程序的权限3环.那么这样系统调用整个过程都是在3环上的,没有涉及控制匆匆高级到低级转移.

二是,win32程序设计时,操作系统是可以主动调用窗口过程回调函数的,这种情况是否属于0环的操作系统调用了3环的用户函数?有了茬一的解释,茬二也可以解释

消息循环

1
2
3
4
5
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}

DispatchMessage是被3环程序调用的3环API,往后即使调用0环的依存内核函数,CPL也一直保持3环不变.回调函数wndproc是在啥时候调用的呢?可以理解为:

用户程序,with CPL=3->DispatchMessage->内核高DPL函数->回调函数wndproc->返回

从一开始用户程序,CPL就是3了,这条链上一直是3,因此没有违背"严禁控制向低特权级转移"这条规则

门描述符

在特权级之间转移控制的另一种方法,即使用门

每一个符号都需要一个调用门,之所以叫做"门"是因为它确实就像一个门,在调用一个符号之前需要先经过它的门,让门来检测第一关,能否调用该符号

调用门:

jmp far 将控制转移到高级代码段,不改变CPL

call far 将控制转移到高级代码段,改变CPL

也是不允许从将控制从高级转移到低级,但是允许从高级返回到低级,即call far之后的返回是从高级转移到低级的

IO操作比如访问硬盘,必须使用调用门

门实际上是一个段描述符,在LDT或者GDT中,占用的大小和普通的段描述符一样,都是32字节,但是每一位的意义和普通的段描述符不同

最低和最高的两个字是段内偏移量的低字和高字

第二个字是例程所在代码段的选择子

第三个字是属性

属性 意义
P 1有效,如果是0则调用该门导致处理器异常中断
DPL 访问目标代码段需要的权限下限(目标代码段的DPL决定的是上限)
0 未使用固定为0
TYPE
000 未使用,固定为0
栈传递的参数个数 存放该门对应的代码调用需要几个函数,
方便切换堆栈的时候把参数从老栈搬到新栈上来
由于参数个数一共有5位,因此最多可以传31个参数

当栈切换时,SS:ESP会被自动更新成新的堆栈,然后根据调用门的栈传递参数个数从老栈顶上取下相同字节数来拷贝到新栈上作为参数.这个事是处理器自动干的,程序员不用管

如果是用寄存器传递参数则根本不需要拷贝

关于

image-20220902213223828

如果一个调用门的DPL=2,这是一个下限,那么只有同级或者更高权限的特权级为0,1,2的代码可以通过该门.

钻法律空子

现在已经有了一些游戏规定

1.在不使用调用门时

数据段只能被同级或者高级代码段访问

代码段只能被同级或者低级代码段调用,但是CPL不变

2.在使用调用门时

call far将控制转移到高级代码段时,会将CPL从低级改成高级,并且允许从高级返回到低级并再修改CPL为低级

3.有一个0环的系统调用read_hard_disk(logicalnumber,selector,offset),为用户程序提供读取磁盘到内存的服务

它接收三个参数,逻辑扇区号logicalnumber,写入内存的段选择子selector,写入内存的段偏移量offset

意思是读取指定logicalnumber扇区,拷贝到内存的selector:offset这个位置

上述游戏规则看似完备,实际上还是存在漏洞,这样想:

操作系统开发者希望的用户这样使用该系统调用:

1
read_hard_disk(逻辑扇区号,用户程序数据段选择子,用户程序数据段偏移量);

翻译到图上就是

正确用法

但是还有外门邪道的用法,如果用户通过一些手段知道了内核数据段的选择子,比如通过wingdb内核调试等方式,反正只要想,就能获取,它这样使用该系统调用

1
read_hard_disk(逻辑扇区号,内核数据段选择子,内核数据段偏移量);

画到图上

image-20220902180334115

你不是不允许3环上的用户程序访问0环上的内核数据段吗?

我想办法让0环上的代码访问0环的内核数据段不就可以了吗?

我得先想办法让我的CPL从3环的用户应用提升到0环,怎么提升呢?显然只有一个call far可以做到

而该系统调用恰好就是call far调用的,自然而然就把CPL提升到0了,此时0环上的内核例程代替3环上的用户程序,向0环的内核数据段写入了磁盘扇区数据

如果该用户提前将恶意数据写到该扇区,或者说用一个没用的扇区覆盖了该内核数据段的所有,如果该数据段是比较重要的比如内核堆栈,内核就寄了

那么能否禁用call far呢?使用jmp far调用不行吗?还真不行,

如果可以使用jmp far,就意味着CPL一直在3环保持不变,内核例程去访问磁盘时还是在3环上,

显然这是不可以的,因为访问磁盘的函数必须是非依存0环权限的,

否则如果是0环依存权限,则用户程序可以不通过系统调用,而是直接调用该访问磁盘的函数了,这样0环就没有存在的意义了

完善法律

可以先用jmp far跳转到依存的内核检查例程,

该检查例程作为入口在call far进入0环的访问磁盘例程呢,

访问磁盘例程把数据先读到内核入口例程的缓冲区,

等磁盘例程返回后,内核例程回复到3环权限再把缓冲区尝试写到目标内存中.

这样在3环上是不可能往0环的内核数据段写东西的

这个猜想正是现行的解决方案,即"再增加一道门卫",这个检查例程就是门卫

1
2
3
4
5
6
7
8
9
10
11
12
graph LR
B(ring0检查例程,依存)
C(ring0磁盘读写例程,非依存)
D(ring0内存读写例程,依存)
A(调用者,可能是用户函数,也可能是系统函数)
A--"jmp far,CPL=0"-->B
subgraph 系统调用

B--"1.call far"-->C
C--"2.return"-->B
B--"3.jmp far"--->D
end

如果是应用程序调用该系统调用,则读写内存的时候一定CPL=3,如果参数selector是一个内核段的选择子,则CPL=3显然是无法访问的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
graph LR
A(ring3应用程序)
B(ring0检查例程,依存)
C(ring0磁盘读写例程,非依存)
D(ring0内存读写例程,依存)

subgraph ring 3
A--"jmp far,CPL=3"-->B
D
end
subgraph ring 0
B--"1.call far,CPL=0"-->C
C--"2.return,CPL=3"-->B
B--"3.jmp far,CPL=3"--->D
end

如果是系统函数调用该系统调用则全程在ring0,不需担心任何问题

1
2
3
4
5
6
7
8
9
10
11
12
13
graph LR
A(ring0系统函数)
B(ring0检查例程,依存)
C(ring0磁盘读写例程,非依存)
D(ring0内存读写例程,依存)

subgraph ring 0
A--"jmp far,CPL=0"-->B
B--"1.call far,CPL=0"-->C
C--"2.return,CPL=0"-->B
B--"3.jmp far,CPL=0"--->D
end

也就是说,调用者调用该系统调用的时候,操作系统会根据调用者的段选择子中的RPL设置CPL,每当发生mov ds,ax这种指令的时候,处理器就会检查

image-20220902202919564

编程实现

主引导扇区和上一个内核时相同,内核的头部也是相同的,这属于内核和主引导扇区之间的协议,理论上是永远不变的

两个内核都是从start标号处开始执行,内核开始执行时,主引导扇区中已经给内核准备好了代码段,数据段,堆栈段,并且处理器也运行在保护模式了

然后两个内核都打印了CPU信息,之后的逻辑就不同了

在进入内核start之前,GDT是这样的

image-20220830192340336

start

内核开始的标号

上来先打印字符串还有CPU信息,不再分析了

安装调用门

遍历符号表,给每个符号(不管是函数还是变量符号)都在GDT中建立一个门描述符

为啥要建立门描述符?

内核中公用例程段的符号权限都是0,也就相当于系统调用,如果想让用户程序也使用他们,那就必须安装调用门

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
       mov ecx,core_data_seg_sel          ;使ds指向核心数据段 
mov ds,ecx

;以下开始安装为整个系统服务的调用门。特权级之间的控制转移必须使用门
mov edi,salt ;C-SALT表的起始位置
mov ecx,salt_items ;C-SALT表的条目数量
.b3:
push ecx ;保存循环变量
mov eax,[edi+256] ;该条目入口点的32位偏移地址 ;每个条目的前256个字符是符号名,然后紧跟着符号地址
mov bx,[edi+260] ;该条目入口点的段选择子 ;符号地址后面紧跟着段选择子
mov cx,1_11_0_1100_000_00000B ;特权级3的调用门(3以上的特权级才 ;gdt属性
;允许访问),0个参数(因为用寄存器
;传递参数,而没有用栈)
call sys_routine_seg_sel:make_gate_descriptor ;构造门描述符
call sys_routine_seg_sel:set_up_gdt_descriptor ;写入全局段描述符表
mov [edi+260],cx ;将返回的门描述符选择子回填
add edi,salt_item_len ;指向下一个C-SALT条目
pop ecx ;还原循环变量
loop .b3

这里调用了sys_routine_seg_sel:make_gate_descriptor函数,该函数的调用约定是:

eax存放门代码的段偏移量

bx存放门代码的段选择子

cx存放段属性,按照段描述符高16位的格式存放

返回值edx:eax表示完整的调用门描述符

然后调用set_up_gdt_descriptor在GDT中注册调用门,用cx返回该GDT描述符的段选择子

该选择子被回填到原来的符号选择子位置(原来存放的是符号所在段选择子)

这里回填只改变了选择子,没有改变偏移量

调用门描述符中会保存该符号的段选择子和段内偏移,原来该符号也会保存一个段内偏移

因此段内偏移会被保存两次

内核符号表表项的变化:

符号成员 段内偏移 大小 原意义 新意义
符号名 0(edi指向该处) 256Bytes 符号名 --
符号偏移量 256 4Bytes 符号段内偏移量 --
符号段选择子 260 2Bytes 符号所在段选择子 符号调用门的选择子

现在要调用一个符号,查符号表获得的是其调用门选择子,

用该调用门选择子查GDT获得的是调用门描述符

用调用门描述符中的段选择子查GDT获得符号所在段地址

加上调用门描述符中的符号段内偏移才得到符号的线性地址

循环为每一个内核符号建立调用门后,GDT表长这样

image-20220903215205939

测试调用门

1
2
3
4
5
6
7
;对门进行测试 
mov ebx,message_2
call far [salt_1+256] ;通过门显示信息(偏移量将被忽略)

mov ebx,message_3
call sys_routine_seg_sel:put_string ;在内核中调用例程不需要通过门

如果想要使用调用门,要么是jmp far,要么是call far

打印message_2时,使用call far调用[salt_1+256] 这个位置,正好是put_string符号的段内偏移量起始地址

call far会将其目的操作数开始的低32个字节作为段内偏移量,将高16字节作为段选择子,如果这是个调用门则忽略段内偏移量,因为调用门描述符中还会记录这个符号的段内偏移量

打印message_3时,没有使用call far或者jmp far指令,也就是说不会使用调用门.实际上确实如此,这里直接用段选择子:段内偏移量进行调用,根本没有访问符号表.因为内核代码本来就是最高权限0环,内核代码调用内核公用例程段是门当户对的

这里提示我们即使一个符号有调用门,也不一定非要使用调用门调用该符号,老方法对于内核代码调用内核代码还是可用的

创建任务控制块

TCB,Task control Block,任务控制块(可以理解为进程控制块),这个玩意纯粹是我们为了维护程序执行状态,比如各种通用寄存器,堆栈指针,程序计数器等设置的,不是处理器的硬性要求.

对于一个允许多任务轮转的操作系统,必须要有数据结构维护任务执行上下文,方便下一次调度到该任务的时候可以继续执行下去

1
2
3
4
5
;创建任务控制块。这不是处理器的要求,而是我们自己为了方便而设立的
mov ecx,0x46
call sys_routine_seg_sel:allocate_memory
call append_to_tcb_link ;将任务控制块追加到TCB链表

allocate_memory函数使用ecx作为希望分配的字节数,使用ecx返回申请地址空间的线性首地址

这里申请了0x46字节的内存空间,接着给append_to_tcb_link注册为TCB

append_to_tcb_link是一个近函数,它使用ecx作为TCB(Task control Block)的线性地址,将该TCB加到已有任务链上

这个玩具内核使用链表维护所有的TCB数据,每个TCB的最开始的4个字节是指向下一个TCB的指针,剩下0x42个字节是存放本任务控制信息的

image-20220903215749487

多个TCB以链表形式连接,这就是append_to_tcb_link函数的工作了

image-20220903215916565

下面详细研究一下append_to_tcb_link干了啥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 tcb_chain        dd  0	;内核数据段定义的TCB链条首地址指针
...
append_to_tcb_link: ;在TCB链上追加任务控制块
;输入:ECX=TCB线性基地址
push eax
push edx
push ds
push es

mov eax,core_data_seg_sel ;令DS指向内核数据段 ;这里有两种寻址方式,tcb_chain是定义在内核数据段的,只能使用内核数据段:偏移量的方式进行寻址
mov ds,eax
mov eax,mem_0_4_gb_seg_sel ;令ES指向0..4GB段;每一个TCB都是allocate_memory申请的地址,使用线性地址寻址,这里4G地址空间段的段地址为0,就相当于线性地址了
mov es,eax

mov dword [es: ecx+0x00],0 ;当前TCB指针域清零,以指示这是最后一个TCB
;显然要进行尾插法了

mov eax,[tcb_chain] ;TCB表头指针,在没有插入任何任务之前,[tcb_chain]=0
or eax,eax ;链表为空?
jz .notcb ;如果链表为空则跳转notcb(没有tcb)

.searc: ;否则,即链表不空,需要首先遍历到链表尾,searc就是干这个事的
mov edx,eax ;如果链表非空则tcb_chain这个头节点指针是会指向第一个TCB的
mov eax,[es: edx+0x00] ;每个TCB的第一个双字都是下一个TCB的地址,如果该值为0说明找到了最后一个TCB,否则还得继续往后遍历
or eax,eax ;判断下一个TCB的指针是否是0
jnz .searc ;如果不是则继续向后遍历

mov [es: edx+0x00],ecx ;刚找到的"最后一个TCB"的指针域指向要新加入的TCB,完成尾插法
jmp .retpc ;跳转函数尾声

.notcb:
mov [tcb_chain],ecx ;若为空表,直接令表头指针指向TCB

.retpc:
pop es
pop ds
pop edx
pop eax

ret

教材上把这个过程总结成了流程图

image-20220903221859132

加载器加载用户程序

1
2
3
4
5
6
7
push dword 50                      ;用户程序位于逻辑50扇区 ;栈次顶函数
push ecx ;压入任务控制块起始线性地址 ;栈顶参数

call load_relocate_program

mov ebx,do_status
call sys_routine_seg_sel:put_string

加载器使用栈传递参数,栈顶作为加载目标地址,栈次顶作为用户程序所在的逻辑扇区号

由于加载器要给用户程序建立LDT,而原来的加载器只是将用户程序的各段一并注册到GDT,

因此两章的加载器会有较大不同,下面研究一下本章的加载器干了啥

栈传参数

加载器使用栈传递参数,执行call命令之后,栈上是这样的

image-20220903223350815
开端

函数开端时又压栈保护调用者寄存器

1
2
3
4
5
6
pushad

push ds ;即使压栈的是16位的寄存器,栈顶依然下降4字节
push es

mov ebp,esp ;为访问通过堆栈传递的参数做准备

这三步执行完毕之后,栈的状态

image-20220903225454843

在加载器返回直线,ebp就一直指向调用者寄存器的最顶上,局部变量的最底下的位置,也就是加载器正儿八经的栈帧底部

设置es段选择子

设置es段选择子指向4G数据段

1
2
mov ecx,mem_0_4_gb_seg_sel
mov es,ecx
申请LDT空间,写入TCB
1
2
3
4
5
6
7
mov esi,[ebp+11*4]                 ;从堆栈中取得TCB的基地址

;以下申请创建LDT所需要的内存
mov ecx,160 ;允许安装20个LDT描述符
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x0c],ecx ;登记LDT基地址到TCB中
mov word [es:esi+0x0a],0xffff ;登记LDT初始的界限到TCB中

ecx=160向allocate_memory申请160字节的内存空间,用于存放本任务的LDT表,由于一个LDT表项占用8字节,并且0号表项可用,因此该LDT表最多放20个描述符

返回该内存空间的首地址放到[es:esi+0x0c],实际上是一个线性寻址,es段基址为0

es:esi保存的是TCB的内存基地址

[es:esi+0x0c]即TCB的0x0c偏移处,LDT基地址

[es:esi+0x0a]即TCB的0x0a偏移处,LDT当前界限值

image-20220903215749487

也就是说把allocate_memory申请的空间作为LDT,然后将其基地址写到TCB中.并且将LDT当前界限值设为最大0xffff

读取用户程序头
1
2
3
4
5
6
7
;以下开始加载用户程序 
mov eax,core_data_seg_sel
mov ds,eax ;切换DS到内核数据段

mov eax,[ebp+12*4] ;从堆栈中取出用户程序起始扇区号
mov ebx,core_buf ;读取程序头部数据
call sys_routine_seg_sel:read_hard_disk_0

ds指向内核数据段

ebp+12*4指向之前作为加载器的参数压栈的逻辑扇区号

image-20220903230748207

将逻辑扇区号放到eax,将core_buf内核缓冲区地址放到ebx,作为参数调用read_hard_disk_0函数

该函数读取eax指定的逻辑扇区,将数据拷贝到ds:ebx指定的内内存地址上

判断程序大小
1
2
3
4
5
6
7
;以下判断整个程序有多大
mov eax,[core_buf] ;程序尺寸
mov ebx,eax
and ebx,0xfffffe00 ;使之512字节对齐(能被512整除的数低
add ebx,512 ;9位都为0
test eax,0x000001ff ;程序的大小正好是512的倍数吗?
cmovnz eax,ebx ;不是。使用凑整的结果

效果是获取程序占用的扇区对应的总内存数,最后一个扇区如果占不满则向上取整为一个扇区

由于刚才已经将程序头512字节放到了core_buf指向的内核缓冲区上,现在core_buf指向的就是用户程序头的基地址,用户程序头上是啥又涉及到协议了

项目 大小(bytes) 文件偏移(bytes) 原意义 新意义
program_length program_end 4 0 程序总大小 --
head_len header_end 4 4 程序头大小 程序头段选择子
stack_seg -- 4 8 接收栈段选择子 程序堆栈段选择子
stack_len -- 4 12 程序建议的栈长度 --
prgentry start 4 16 入口点文件文件偏移 --
code_seg section.code.start 4 20 代码段文件偏移 程序代码段选择子
code_len code_end 4 24 代码段长度 --
data_seg section.data.start 4 28 数据段文件偏移 程序数据段选择子
data_len data_end 4 32 数据单长度 --
salt_items (header_end-salt)/256 4 36 符号表项数 --
符号1... 符号值(字面量) 256Bytes 40 第一个符号 --
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号 --
...

[core_buf+0x00]即程序总大小

为整个程序申请空间并注册
1
2
3
mov ecx,eax                        ;实际需要申请的内存数量
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x06],ecx ;登记程序加载基地址到TCB中

eax中放的是向上取整之后的程序总大小字节数,通过ecx传递给allocate_memory申请相应大小的内存,

然将该内存基地址后写到[es:esi+0x06],即TCB的程序加载基地址处

读取整个程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
       mov ebx,ecx                        ;ebx -> 申请到的内存首地址
xor edx,edx
mov ecx,512
div ecx
mov ecx,eax ;总扇区数

mov eax,mem_0_4_gb_seg_sel ;切换DS到0-4GB的段
mov ds,eax

mov eax,[ebp+12*4] ;起始扇区号
.b1:
call sys_routine_seg_sel:read_hard_disk_0
inc eax
loop .b1 ;循环读,直到读完整个用户程序

edx:eax除以512得到总扇区数放到ecx作为循环变量,下面要循环读入这些扇区

起始扇区号还是使用压栈传递给加载器的参数

循环读入这些扇区到ebx指定的内存中

获取程序基地址
1
mov edi,[es:esi+0x06]              ;获得程序加载基地址

[es:esi+0x06] 是之前给整个程序申请空间之后写到TCB中的

建立程序头部段描述符

edi指向程序基地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
;建立程序头部段描述符
mov eax,edi ;程序头部起始线性地址
mov ebx,[edi+0x04] ;段长度
dec ebx ;段界限
mov ecx,0x0040f200 ;字节粒度的数据段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor

;安装头部段描述符到LDT中
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt

or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [es:esi+0x44],cx ;登记程序头部段选择子到TCB
mov [edi+0x04],cx ;和头部内

程序头部段基地址放到eax

[edi+0x04]在协议中一开始是程序头段长度(后来被改成程序头段选择子) 段长度-1得到段界限放到ebx

属性0x40f200放到ecx

调用make_seg_descriptor,eax,ebx,ecx作为参数传递

edx:eax作为返回值,返回完整的段描述符

esi从加载器函数开始时就被设置指向TCB基址,ebx获得其拷贝

调用fill_descriptor_in_ldt,它使用edx:eax作为描述符,ebx作为TCB基地址,该函数将描述符安装在LDT中(LDT基地址通过查TCB表得知),更新LDT界限.返回CX作为描述符选择子.

注意fill_descriptor_in_ldt并没有lldt,即没有更新ldtr,其更新需要等整个LDT建立完毕之后

将cx返回的段选择子加上特权级3,分别写到TCB和程序头部的程序头部段选择子中

建立程序代码段描述符
1
2
3
4
5
6
7
8
9
10
11
;建立程序代码段描述符
mov eax,edi
add eax,[edi+0x14] ;代码起始线性地址
mov ebx,[edi+0x18] ;段长度
dec ebx ;段界限
mov ecx,0x0040f800 ;字节粒度的代码段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x14],cx ;登记代码段选择子到头部

过程类似建立程序头部段描述符,但是最后没有将代码段写到TCB中,因为TCB不需要记录,这是TSS需要干的,而对于TSS的设置还在后面

建立程序数据段描述符
1
2
3
4
5
6
7
8
9
10
11
;建立程序数据段描述符
mov eax,edi
add eax,[edi+0x1c] ;数据段起始线性地址
mov ebx,[edi+0x20] ;段长度
dec ebx ;段界限
mov ecx,0x0040f200 ;字节粒度的数据段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x1c],cx ;登记数据段选择子到头部
建立程序堆栈段描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;建立程序堆栈段描述符
mov ecx,[edi+0x0c] ;4KB的倍率
mov ebx,0x000fffff
sub ebx,ecx ;得到段界限
mov eax,4096
mul ecx
mov ecx,eax ;准备为堆栈分配内存
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;得到堆栈的高端物理地址
mov ecx,0x00c0f600 ;字节粒度的堆栈段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x08],cx ;登记堆栈段选择子到头部

与前面的段不同的是,程序堆栈段是需要另申请空间的

重定位用户程序符号

用户程序的符号表,在链接之前保存的是符号名,现在遍历用户程序符号表,将用户符号引用解析到内核符号

算法和上一章相同,对于每个用户程序符号,遍历整个内核符号表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
       ;重定位SALT 
mov eax,mem_0_4_gb_seg_sel ;这里和前一章不同,头部段描述符
mov es,eax ;已安装,但还没有生效,故只能通
;过4GB段访问用户程序头部 ;es指向整个4G内存地址空间
mov eax,core_data_seg_sel ;ds指向内核数据区
mov ds,eax

cld

mov ecx,[es:edi+0x24] ;U-SALT条目数(通过访问4GB段取得) ,edi是程序基址的线性地址,配合es使用
add edi,0x28 ;U-SALT在4GB段内的偏移
.b2: ;外圈循环
push ecx
push edi

mov ecx,salt_items ;内核符号每次都从最头上开始
mov esi,salt
.b3:
push edi ;中圈循环需要使用,因此将外圈循环变量压栈保存
push esi
push ecx

mov ecx,64 ;检索表中,每条目的比较次数
repe cmpsd ;每次比较4字节 ,esi随着移动
jnz .b4 ;符号不匹配,跳转b4
mov eax,[esi] ;若匹配,则esi恰好指向其后的地址,即符号的偏移地址
mov [es:edi-256],eax ;将字符串改写成偏移地址 ,原符号名处改成符号的偏移地址
mov ax,[esi+4] ;esi再加4指向段选择子
or ax,0000000000000011B ;以用户程序自己的特权级使用调用门
;故RPL=3
mov [es:edi-252],ax ;回填调用门选择子
.b4:

pop ecx
pop esi
add esi,salt_item_len ;esi循环变量后移一个符号
pop edi ;从头比较
loop .b3 ;固定用户程序中的符号,遍历内核符号

pop edi
add edi,256
pop ecx
loop .b2
建立并注册各特权级堆栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mov esi,[ebp+11*4]                 ;从堆栈中取得TCB的基地址

;创建0特权级堆栈
mov ecx,4096
mov eax,ecx ;为生成堆栈高端地址做准备
mov [es:esi+0x1a],ecx
shr dword [es:esi+0x1a],12 ;登记0特权级堆栈尺寸到TCB
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;堆栈必须使用高端地址为基地址
mov [es:esi+0x1e],eax ;登记0特权级堆栈基地址到TCB
mov ebx,0xffffe ;段长度(界限)
mov ecx,0x00c09600 ;4KB粒度,读写,特权级0
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0000 ;设置选择子的特权级为0
mov [es:esi+0x22],cx ;登记0特权级堆栈选择子到TCB
mov dword [es:esi+0x24],0 ;登记0特权级堆栈初始ESP到TCB

esi指向TCB基地址

esi+0x1a指向的0环栈以4KB为单位的长度

这里将4096放到该位置,然后右移12位,意思是4096右移12位得到1,即0环栈的大小是1×4K

ecx=4096B交给allocate_memory,为该堆栈申请空间

然后ecx保存返回地址,加上eax先前保存的空间大小,达到该空间的最大地址,由于堆栈往小地址生长,因此该地址就作为基地址了

写到esi+0x1e位置,即0特权级堆栈基地址

段大小是4KB那么段界限就是0xffffe

0x00c9600作为属性放到ecx准备制作段描述符

make_seg_descriptoredx:eax返回完整的段描述符

然后调用fill_descriptor_in_ldt,该函数使用edx:eax作为段描述符,使用ebx作为TCB基地址,将该段描述符写入TCB指向的LDT中

cx返回该段的选择子,该选择子将权限置为0后,放到TCB+0x22,即0环栈选择子上

然后将0环栈初始esp置为0

这就建立好了0环栈,下面1,2环栈类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
	
;创建1特权级堆栈
mov ecx,4096
mov eax,ecx ;为生成堆栈高端地址做准备
mov [es:esi+0x28],ecx
shr [es:esi+0x28],12 ;登记1特权级堆栈尺寸到TCB
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;堆栈必须使用高端地址为基地址
mov [es:esi+0x2c],eax ;登记1特权级堆栈基地址到TCB
mov ebx,0xffffe ;段长度(界限)
mov ecx,0x00c0b600 ;4KB粒度,读写,特权级1
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0001 ;设置选择子的特权级为1
mov [es:esi+0x30],cx ;登记1特权级堆栈选择子到TCB
mov dword [es:esi+0x32],0 ;登记1特权级堆栈初始ESP到TCB

;创建2特权级堆栈
mov ecx,4096
mov eax,ecx ;为生成堆栈高端地址做准备
mov [es:esi+0x36],ecx
shr [es:esi+0x36],12 ;登记2特权级堆栈尺寸到TCB
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;堆栈必须使用高端地址为基地址
mov [es:esi+0x3a],ecx ;登记2特权级堆栈基地址到TCB
mov ebx,0xffffe ;段长度(界限)
mov ecx,0x00c0d600 ;4KB粒度,读写,特权级2
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0010 ;设置选择子的特权级为2
mov [es:esi+0x3e],cx ;登记2特权级堆栈选择子到TCB

GDT中注册LDT
1
2
3
4
5
6
7
;在GDT中登记LDT描述符
mov eax,[es:esi+0x0c] ;LDT的起始线性地址
movzx ebx,word [es:esi+0x0a] ;LDT段界限
mov ecx,0x00408200 ;LDT描述符,特权级0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [es:esi+0x10],cx ;登记LDT选择子到TCB中

TCB+0x0c指向LDT线性基地址,放到eax

TCB+0x0a指向LDT当前界限值,放到ebx

0x00408200作为属性,放到ecx

调用make_seg_descriptor,set_up_gdt_descriptor将LDT描述符注册到GDT,并将该LDT的选择子写到TCB+0x10位置

创建TSS
image-20220904211634293

TSS是一个大块头,详细保存了程序上下文,共需要占用104字节

貌似TCB和TSS有很多重复保存的地方

确实两者都保存了附加堆栈的段选择子,LDT段在GDT中的选择子

但是TSS是一个动态变化的结构,TCB在进程创建之后就不再发生变化了

TSS中的各种寄存器都是会随着进程的执行发生变化

1
2
3
4
5
6
;创建用户程序的TSS
mov ecx,104 ;tss的基本尺寸
mov [es:esi+0x12],cx
dec word [es:esi+0x12] ;登记TSS界限值到TCB
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x14],ecx ;登记TSS基地址到TCB

esi还是指向TCB基地址

esi+0x12是TSS界限,设为104-1=103

然后调用allocate_memory给TSS申请104字节空间,将该空间的首地址放到TCB+0x14即TSS基地址上

登记基本TSS内容

由于进程开始执行之前,TSS和TCB中的很多内容都是重复的,因此TSS建立时,可以从TCB抄作业

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
;登记基本的TSS表格内容
mov word [es:ecx+0],0 ;反向链=0

mov edx,[es:esi+0x24] ;登记0特权级堆栈初始ESP
mov [es:ecx+4],edx ;到TSS中

mov dx,[es:esi+0x22] ;登记0特权级堆栈段选择子
mov [es:ecx+8],dx ;到TSS中

mov edx,[es:esi+0x32] ;登记1特权级堆栈初始ESP
mov [es:ecx+12],edx ;到TSS中

mov dx,[es:esi+0x30] ;登记1特权级堆栈段选择子
mov [es:ecx+16],dx ;到TSS中

mov edx,[es:esi+0x40] ;登记2特权级堆栈初始ESP
mov [es:ecx+20],edx ;到TSS中

mov dx,[es:esi+0x3e] ;登记2特权级堆栈段选择子
mov [es:ecx+24],dx ;到TSS中

mov dx,[es:esi+0x10] ;登记任务的LDT选择子
mov [es:ecx+96],dx ;到TSS中

mov dx,[es:esi+0x12] ;登记任务的I/O位图偏移
mov [es:ecx+102],dx ;到TSS中

mov word [es:ecx+100],0 ;T=0

GDT中注册TSS

为啥要在GDT中注册TSS?

TSS也是一块内存区域,在保护模式下,每块内存区域都需要现在GDT或者LDT中注册之后才能访问.在内核中可以使用4G的数据段访问全部内存,但是控制切换到用户程序之后就不能使用4G数据段了,要想还能访问TSS,内核就必须给用户程序准备好TSS

TSS描述符长这样

image-20220904213805641

其中TYPE.B位,表征任务当前状态,是"忙"还是"不忙",忙就是正在执行或者挂起,处理器会自动修改该位

1
2
3
4
5
6
7
;在GDT中登记TSS描述符
mov eax,[es:esi+0x14] ;TSS的起始线性地址
movzx ebx,word [es:esi+0x12] ;段长度(界限)
mov ecx,0x00408900 ;TSS描述符,特权级0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [es:esi+0x18],cx ;登记TSS选择子到TCB

注册TSS时用到的基址和界限信息还是从TCB中抄来的

加载器函数尾声
1
2
3
4
5
6
pop es                             ;恢复到调用此过程前的es段 
pop ds ;恢复到调用此过程前的ds段

popad

ret 8 ;丢弃调用本过程前压入的参数

ret 8退栈8个字节,正好给加载器传递参数时是两个4字节参数

这样加载器返回后堆栈平衡

加载ldtr,tr寄存器,ds段寄存器

1
2
3
4
5
6
7
8
9
10
11
mov ebx,do_status
call sys_routine_seg_sel:put_string

mov eax,mem_0_4_gb_seg_sel ;ds指向4G全内存段,后面要用线性地址
mov ds,eax

ltr [ecx+0x18] ;加载任务状态段 ;ecx指向TCB,TCB+0x18是TSS选择子
lldt [ecx+0x10] ;加载LDT,TCB+0x10是LDT选择子

mov eax,[ecx+0x44] ;ecx+0x44是用户程序头选择子
mov ds,eax ;切换到用户程序头部段

假装从调用门返回

我们之前一直是在0环的内核上工作的 ,而现在竟然让我们将控制放权给一个三环上的应用程序,显然正常情况下这是不可容忍的.

不使用调用门时,即使是依存的代码段也只允许从3环代码段调用0环代码

使用调用门时,也只允许控制向高级代码段转移,顶多有一个改不改CPI的区别

貌似各种情况都不允许控制从0环向3环转移

但是但是,call far调用之后,允许使用retf从0环返回到3环

即虽然不允许0环主动调用3环,但是允许从0环返回3环

这就是下面retf的作用

1
2
3
4
5
6
7
8
;以下假装是从调用门返回。摹仿处理器压入返回参数 
push dword [0x08] ;调用前的堆栈段选择子
push dword 0 ;调用前的esp

push dword [0x14] ;调用前的代码段选择子
push dword [0x10] ;调用前的eip

retf

压栈时操作数的段寄存器ds指向用户程序头段

ds:0x08指向用户程序段选择子

ds:0x14指向用户程序代码段选择子

ds:0x10指向用户程序入口点

image-20220904215017068

retf指令干了啥呢?

retf用于段间返回,将此时栈顶低32位作为段内偏移量,高32位作为段选择子(实际只使用了高16位,再高16位置零忽略)

retf执行之后会将栈顶的入口点段内偏移退栈交给eip,将程序段选择子退栈交给cs

image-20220904215323965

然后接着处理器就根据eip取指令继续执行了,就相当于从内核"返回"到用户程序了,虽然这是用户程序的第一次执行

此时栈顶是程序堆栈段,自然而然地被作为程序的

图解TSS,TCB,LDT

简略
详细?

有些地方还是没有彻底明白