-
-
[原创]X86分段
-
发表于: 2026-6-9 00:55 515
-
什么是段寄存器?
当我们用汇编读写某一个地址时,比如用下面的代码:
mov dword ptr ds:[0x123456],eax
其实我门真正读写的地址是:d5.ba5e+x123456。并不是123456,不过正好的是ds段寄存器的基址是0而已。
一些段寄存器
段寄存器有这几个:ES、CS、SS、DS、FS、GS、LDTR、TR,它们各有自己特殊的用途。
段寄存器的结构可用下图表示:

一个完整的段是有96位组成的,不可见的 80 位中,64 位来自段描述符(8 字节),另外 16 位是处理器内部维护的属性扩展,而非
段寄存器具有96位,但我们可见的只有16位。
| 部分 | 位数 | 名称 | 可见性 | 作用 |
|---|---|---|---|---|
| 第一部分 | 16 位 | 段选择子(Selector) | 完全可见 | 程序员可通过指令读写,包含索引、TI 位、RPL 特权级 |
| 第二部分 | 80 位 | 描述符高速缓存器(Descriptor Cache) | 完全不可见 | 处理器自动维护,包含 32 位基址 + 32 位段界限 + 16 位属性 |
只有 16 位(段选择子)对程序员 / 内核可见,64 位段描述符存储在内存的 GDT/LDT 中,需通过选择子索引获取,且加载后存入 80 位不可见缓存器。内核也无法直接读写这 80 位缓存器,只能通过修改 GDT/LDT 并重新加载选择子来间接更新。
16 位是段选择子,是程序员主动加载的(如mov ax, selector; mov ds, ax),用于索引 GDT/LDT 中的 64 位段描述符;而不可见缓存器中的 16 位属性是直接从段描述符中提取的,并非计算得出。
我们可以用0D随意加载一个程序,如下图所示:

既然是寄存器了,那就可以进行读写操作,如下将介绍读写段寄存器的操作:
·MoV指令:MOV AX,E5,但只能读16位的可见部分;mov Ds,Ax写段寄存器,写的是96位。
·读写LDTR的指令为:SLDT/LLDT
读写TR的指令为:STR/LTR
段寄存器属性探测:我介绍过段寄存器有6位,但我们只能看见16位,那如果证明Attribute、Base、Limit的存在呢?我们将在下面进行初步探测。
段寄存器成员简介:既然讨论段寄存器属性,首先要知道它们存着啥,下面表格的内容是我从虚拟机里查询到的值,可能和我的不一样,但无所谓。它们的属性我已查询并把它们的权限写到表格中,之所以为什么我之后将会介绍。
Windows操作系统并不会使用GS寄存器,故用-表示。

探测Attribute:

这个是错误的,因为mov ds,ax是错误的,cs段是不可写的(权限是这样),所以报错了。翻译过来就是地址访问冲突,这是什么原因呢?这就是由于Cs段寄存器是可读的,而不是可写的。原来的s是可读可写的,但将cs通过ax赋值给ds时候,ds不再是原来的ds,而是cs,故会引发此错误。
探测Base:老生常谈程序的罗地址无法访问。但零地址一定是无法访问吗?我们将用以下代码进行验证base:

这里的mov eax,es:[0]实际取得就是fs,fs在内核里面是KPCR的一个结构,实际上取得是KPCR这个结构的首个4字节成员,才会用这种方式去取的。
探测Limit
我们将用以下代码进行验证Limit:

由于fs最大寻址范围是0xFFF,所以这里肯定会出问题,
踩坑问题
里面有一些坑我还没让你深,你髁一深看看。请你思考出答案或者百思不得其解的时候再来看答案。
1.在验证属性的时候,用下面的代码,结果运行mov dword ptr ds:[a],10正常通过,放开程序跑后内存访问冲突。

这其实是编译问题,为什么会出现这个问题呢?我明明代码很正常但就不行,难道只是因为局部变量的问题呢。但全局变量和局部变量在内存上根本没有区别。全局变量只是一个死地址,局部变量是该变量所在函数临时的地址,但访问上根本没有区别。我们看一看编译器到底把咱们的内联汇编到底翻译成了什么?为什么会出现这个问题呢?我明明代码很正常但就不行呢,难道只是因为局部变量的问题呢。但全局变量和局部变量在内存上根本没有区别。全局变量只是一个死地址,局部变量是该变量所在函数临时的地址,但访问上根本没有区别。我们看一看编译器到底把咱们的内联汇编到底翻译成了什么?
mov ax,cs; mov ax,cs mov ds,ax mov ds,ax mov dword ptr ds:[a],10; dword ptr [ebp-4],0Ah } return 0; xor eax,eax
前面的内敛汇编编译器给我一五一十的直接翻译了,但这个 mov dword ptr ds:[a],10; 内联汇编给我翻译成了啥,过分了!结果压根没有用 ds 的权限来访问,而是默认的 ss 来访问,这和预期结果一样才怪。
- 在探测 Base 属性的时候,使用
gs作为试验寄存器,单步执行到mov eax,gs:[0]时,出现内存访问冲突错误。
#include "stdafx.h"int a = 0;int main(int argc, char* argv[]){
__asm {
mov ax, fs;
mov gs, ax;
mov eax, gs:[0];
mov dword ptr ds:[a], eax;
}
return 0;}踩坑说明:是因为每次单步调试,就会触发单步调试异常,进入内核,内核会把 gs 清零了,故导致实验无法成功。
32 位 Windows 用户态段寄存器规则:
FS寄存器在用户态被系统固定指向TEB(线程环境块),段描述符由操作系统合法配置,因此mov eax,fs:[0]是合法操作(TEB+0 位置为TEB.NT_TIB.Self,指向 TEB 自身)。GS寄存器在 32 位 Windows 用户态默认值为 0,无合法段描述符,直接访问会触发异常;代码中用mov gs,ax复制了fs的段选择子,但单步调试会破坏这个值。单步调试的核心影响:调试器单步执行时,CPU 会设置
TF(陷阱标志),每执行一条指令就触发#DB(调试异常),进入内核态的异常处理流程:- 内核会保存 / 恢复用户态寄存器上下文,但GS 这类用户态不常用的段寄存器会被重置为默认值 0(内核不保留用户态修改的 GS 值);
- 异常处理返回用户态时,
GS已被清零,此时执行mov eax,gs:[0]会以GS=0为段基址,访问非法内存地址,触发访问冲突。 解决方案补充:
- 避免单步调试这段代码,直接运行程序(不触发调试异常,GS 寄存器的值不会被内核重置);
- 或直接使用
FS寄存器访问 TEB(mov eax,fs:[0]),无需复制到 GS; - 若必须使用 GS,可通过关闭调试器单步陷阱、或在内核态 / 驱动程序中操作(不受用户态 GS 限制)。
GDT 与 LDT
GDT 是全局描述符表。LDT 为局部描述符表,但 Windows 并没有使用它,故不再介绍,感兴趣请查询 Intel 白皮书。当我们执行类似 MOV DS, AX 指令时,CPU 会查表,根据 AX 的值来决定查找 GDT 还是 LDT,并找到对应的段描述符。段描述符将会在后面部分进行介绍。
GDT 表存在于内存之中。CPU 要想找到它,就必须知道它的位置。于是乎 CPU 有一个寄存器,它被称之为 GDTR,存储了 GDT 表的位置和大小,是一个 48 位的寄存器,用 C 语言表示如下:
struct GDTR{
DWORD GDTBase; // GDT表的地址
SHORT limit; // GDT表的大小
};一个段寄存器是96位的,有16位在内核中是可见的,然后这个段寄存器的64位就是存在GDT表里面,
那么,我们如何通过 WinDbg 来获取 GDT 的地址、大小和 GDT 表里的成员呢?首先提一句,段描述符大小为 64 位。有关其操作见下图:
kd> r gdtr gdtr=8003f000 kd> dq 8003f000 l20 ReadVirtual: 8003f000 not properly sign extended 8003f000 00000000`00000000 00cf9b00`0000ffff 8003f010 00cf9300`0000ffff 00cffb00`0000ffff 8003f020 00cff300`0000ffff 80008b04`200020ab 8003f030 ffc093df`f0000001 0040f300`00000fff 8003f040 00000f200`0400ffff 00000000`00000000 8003f050 80008955`22000068 80008955`22680068 8003f060 00009302`2f40ffff 0000920b`80003fff 8003f070 ff0092ff`700003ff 80009a40`0000ffff 8003f080 80009240`0000ffff 00009200`00000000 8003f090 00000000`00000000 00000000`00000000 8003f0a0 89008992`8bb80068 00000000`00000000 8003f0b0 00000000`00000000 00000000`00000000 8003f0c0 00000000`00000000 00000000`00000000 8003f0d0 00000000`00000000 00000000`00000000 8003f0e0 00000000`8003f100 00009200`0000ffff 8003f0f0 8003984d`9b2084cf 00009200`0000ffff kd> r gdtl gdtl=00003ff
r gdtr 指令表示读取 GDT 表的地址;r gdtl 指令表示读取 GDT 的大小,元素一共有128 个;dq 8003f000 l20 指令表示从 0x8003f000 地址(即为 GDT 表的地址)读取 0x20 个 64 位数据,如果没有 l20,默认 0x10 个。这些都是以后常用的指令,需要熟练掌握。如果用dd的话,显示方式是有问题的。
段选择子
段选择子结构简单,那我先介绍它。它是一个 16 位的描述符,指向了定义该段的段描述符(段描述符比较复杂,后面将会完整介绍)。段选择子结构如下图所示:

我们在用户层看到的是16位的数据,
它的成员解释如下:
- RPL:请求特权级别,通俗的讲我用什么权限来请求。
- TI:TI=0 时,查 GDT 表;TI=1 时,查 LDT 表。
- Index:处理器将索引值乘以 8再加上 GDT 或者 LDT 的基地址,就是要加载的段描述符。
是不是很简单?该结构一定要牢记于心,后面将给出练习训练。
比如0x23,拆出来就是0010 0011,在前面补充上0,那么结构就非常清晰了:0000 0000 0010 0011,所以RPL特权请求级是3(用户层3环),TI是0也就是GDT全局描述符表,Index是00100是4,所以index索引是4。
那么我们去查就行:

从0开始数,数4个就行,就是下面的00cff300`0000ffff。
段描述符如图所示

段描述符有很多成员,它的成员将会在下面详细介绍,学习的时候一定要按照我介绍的顺序进行学习:
P 位
P = 1 段描述符有效,P = 0 段描述符无效。
Base
Base 被分成了三个部分,从图可知:Base 的低 16 位被放到了段描述符的低四个字节,高 16 位被均分到段描述符的高四个字节的头和尾。把它们依次拼接起来就是完整的 Base。
Limit
由图可知,把段描述符中所有的 Limit 拼接起来就只有 20 位。上一节教程说它有 32 位的 Limit,那就是要看 G 位了。
G 位
如果 G = 0,说明段描述符中的 Limit 的单位是字节,段长度 Limit 范围可从 1B~1MB,即在 20 位的前面补 3 个 0 即可;如果 G = 1,说明段描述符中的 Limit 的单位是 4KB,即段长度 Limit 范围可从 4KB~4GB,在 20 位的后面补充 FFF 即可。举个例子,如果 Limit 拼接后的为 FFFFF,如果 G 为 0 则为 000FFFFF,反之为 FFFFFFFF。
S 位
S = 1 代码段或者数据段描述符,S = 0 系统段描述符。
TYPE 域
TYPE 域 是比较复杂的成员,它表示的含义受 S 位 的影响。
当 S 位为 1 时此时段描述符表示的是代码段或者数据段,如下图所示:

对于表格中 Type 域的属性和含义,如下表格所示:
| 属性 | 含义 | 属性 | 含义 |
|---|---|---|---|
| A | 访问位 | E | 向下扩展位 |
| R | 可读位 | W | 可写位 |
| C | 一致位 |
对于比较特殊的属性,我们将进一步介绍:
C 位
C = 1:一致代码段;C = 0:非一致代码段。什么是一致代码段,什么是非一致代码段,将在后面的教程进行介绍。
E 位
什么是向下拓展位,我们以 fs 为例来看一下如下示意图:

那么接下来比如我们现在对着这个图来拆一下我们的0x23的段选择子的值:00cff300`0000ffff。
这里有几个小技巧,首先是Base我们知道除了Fs是0x7FFFDE000以外,其他的都是0,那么可以拆成00 cff3 00 ` 0000 ffff。注意这里的G是控制这个段的范围(也就是Limit),所以是控制上下那两个Limit的数加起来是多少。D/B位是表示用32位还是16,但是16位本质上属于实模式,现在没人用实模式,所以这个位基本默认就是1的。x86的第21位用不到所以默认是0,但是x64是会用到的。
type域的值受到S位的影响。
左边表示向上拓展,右边是向下拓展。即向上拓展 `base` 到 `base+limit` 之间区域有效,其余无效;向下拓展 `base` 到 `base+limit` 之间的区域无效,其余有效。这个位针对数据段有效。 当S位为0时 此时段描述符表示的是系统段,系统段有很多种,将会在后面的教程进行详细讲解。Type域每一个数值的含义如下图所示:

红色表示向下拓展能寻址的范围。可以看出,如果 D = 0,就算原来能寻址 4GB,因为 DB 位的限制导致最大范围是 64KB。
DPL
DPL (Descriptor Privilege Level),即描述符特权级别,规定了访问该段所需要的特权级别是什么。如果通俗的理解,就是:如果你想访问我,那么你应该具备什么权限。
AVL
AVL 指示是否可供系统软件使用,由操作系统来使用,CPU 并不使用它。
加载段描述符至段寄存器
除了 MOV 指令,我们还可以使用 LES、LSS、LDS、LFS、LGS 指令修改寄存器。CS 不能通过上述指令进行修改,CS 为代码段,CS 的改变会导致 EIP 的改变,要改 CS,必须要保证 CS 与 EIP 同时改,后面会讲解。
给一个代码模板,具体如下,作为本节的一个作业:
char buffer[6];// 自己构造一个段选择子// 自己自行赋值,你构造的段描述符位置不一样,段选择子就不一样_asm{
les ecx, fword ptr ds:[buffer] // 高2个字节给es,低四个字节给ecx}本节练习
本节的答案将会在下一节进行讲解,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。
俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习比较多,请保质保量的完成,不得使用任何拆分工具。
比如找这个00cf9300`0000ffff,
第 1 张图完整内容
俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习比较多,请保质保量的完成,不得使用任何拆分工具。
如下是从虚拟机读取的 GDT 表的前 18 个段描述符,下面的实验均按照此进行练习。
plaintext
1 8003f000 00000000`00000000 00cf9b00`0000ffff 2 8003f010 00cf9300`0000ffff 00cffb00`0000ffff 3 8003f020 00cff300`0000ffff 80008b04`200020ab 4 8003f030 ffc093df`f0000001 0040f300`0000ffff 5 8003f040 0000f200`0400ffff 00000000`00000000 6 8003f050 80008955`22000068 80008955`22680068 7 8003f060 00009302`2f40ffff 0000920b`03003fff 8 8003f070 ff0092ff`700003ff 80009a40`0000ffff 9 8003f080 80009240`0000ffff 00009200`00000000
1 练习读取 GDT 表的位置和长度,并显示 GDT 表前 48 个段描述符。2 在给定的段描述符中,进行拆分练习(至少 10 个)。3 拆分如下段选择子。002B 0023 0010 001B 003B
4 快速辨别给定段描述符是否可用以及段基址、段长(至少 10 个)。5 从给定段描述符,请按照下面的要求进行练习(全部):
- 快速找出所有数据段,并分析该段属性:只读、已访问、可读可写、拓展方向
- 快速找出所有代码段,并分析该段属性:只执行、可读可执行、已访问、一致代码
- 快速找出所有系统段,并分析属性6 自行构造段选择子和段描述符,并用 加载段描述符至段寄存器 中的代码模板和要求取得成功。如果有时间同样把 LSS 、LDS 、 LFS 、 LGS 的实验也类比做了。7 如何在调试器中快速判断程序在几环权限。8 自学修改 GDT 表的相关知识,并思考如下问题。
第 2 张图完整内容
8 自学修改 GDT 表的相关知识,并思考如下问题。
plaintext
1 r gdtr 2 dq 8003f090 00cffb00`0000ffff 3 r gdtr
8003f090 是 GDT 表中的一个段描述符的地址,更改后发现并没有更改,请思考为什么会这样。
第 3 张图完整内容
CPL/RPL/DPL
- CPL: CPU 当前的权限级别
- DPL: 如果你想访问我,你应该具备什么样的权限(CPL)
- RPL: 用什么权限去访问一个段
RPL 存在的意义
举个例子,我们本可以用 读写 的权限去打开一个文件,但为了避免出错,有些时候我们使用 只读 的权限去打开。
一致代码段与非一致代码段
对于一致代码段,也称为共享段:
- 特权级高的程序不允许访问特权级低的数据:核心态不允许访问用户态的数据
- 特权级低的程序可以访问到特权级高的数据,但特权级不会改变:用户态还是用户态
对于非一致代码段:
- 只允许同级访问
- 绝对禁止不同级别的访问:核心态不是用户态,用户态也不是核心态
数据段的权限检查
数值上, CPL ≤ DPL 且 RPL ≤ DPL 。同时满足上述条件才能通过。
代码段的权限检查
下面的比较都是数值上的比较:
- 如果是非一致代码段,要求:
CPL == DPL 且 RPL <= DPL - 如果是一致代码段,要求:
CPL >= DPL
第 4 张图完整内容
代码跨段基础
代码跨段本质就是修改 CS 段寄存器。前面的教程介绍过段寄存器读写,除 CS 外,其他的段寄存器都可以通过 MOV / LES / LSS / LDS / LFS / LGS 指令进行修改。但是 CS 为什么不可以直接修改呢? CS 的改变意味着 EIP 的改变,改变 CS 的同时必须修改 EIP ,故我们无法使用上面的指令来进行修改,这个也是 CPU 不允许的。
代码间的段间跳转
段间跳转,有 2 种情况,即要跳转的段是一致代码段还是非一致代码段,它们不同做的权限检查就不同。同时修改 CS 与 EIP 的指令如下:JMP FAR / CALL FAR / RETF / INT / IRETED本篇只介绍段间跳转,故只使用 JMP FAR ,即为长跳转。下面我举个示例来进行讲解:
CPU 如何执行这行代码 JMP 0x20:0x004183D7 ?
1 段选择子拆分0x20 对应二进制形式:0000 0000 0010 0000
- 解析结果:○ RPL = 0○ TI = 0○ Index = 4
2 查表得到段描述符TI=0 所以查 GDT 表,Index=4 找到对应的段描述符。注意四种情况可以跳转:代码段、调用门、TSS 任务段、任务门。后面的几种将会在以后的教程详细讲解。
3 权限检查请参考本节的代码段的权限检查
4 加载段描述符通过上面的权限检查后,CPU 会将段描述符加载到 CS 段寄存器中。
5 代码执行CPU 将 CS.Base + Offset 的值写入 EIP 然后跳转到将要执行的 CS:EIP 处的代码,段间跳转结束。
第 5 张图完整内容
5 代码执行
CPU 将 CS.Base + Offset 的值写入 EIP 然后跳转到将要执行的 CS:EIP 处的代码,段间跳转结束。
直接对代码段进行 JMP 或者 CALL 的操作,无论目标是一致代码段还是非一致代码段,CPL 都不会发生改变。如果要提升 CPL 的权限,只能通过调用门。
本节练习
本节的答案将会在下一节进行讲解,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。
俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习很少,请保质保量的完成。
1 记住代码段间跳转的执行流程。2 自己实现一致代码段的段间跳转。3 自己实现非一致代码段的段间跳转。
第 6 张图完整内容
门
本篇文章涉及调用门、中断门、陷阱门这三个重要的 “门”,那么 “门” 到底是什么。打个比方,“门” 就类似于你去办理身份证必须要进入派出所的门一样。你想办理身份证就必须通过这个门,如果你进不了门就办理不了。调用门、中断门、陷阱门也是如此,通过它们你可以修改段的属性,甚至能提权去做一些应用层做不了的事情。
调用门
在将所有的知识之前,先讲一下调用门,因为后面的知识频繁用到了调用门的概念,调用门的结构如下图所示。它和普通的段描述符结构十分相似。低四个字节改为段选择子,如果指向的段描述符的 DPL 小于 CPL,则会提权。高四个字节低 5 个位是调用调用门需要的参数数目。低四个字节的低 16 位和高四个字节的高 16 位拼接为跳转后新的在段中的偏移,也就是调用后 EIP 的位置。

介绍长调用,我先来讲一下什么是 短调用。短调用就是我们在汇编常见的 CALL 指令,调用格式为:CALL 立即数/寄存器/内存 。为什么是短调用,我们来看一下执行该指令时堆栈的变化:
调用 CALL 指令之后, CPU 只将当前的 EIP 压入堆栈后跳转到目标地址,发生改变的寄存器只有 ESP 和 EIP ,即所谓的短调用。
长调用分为两种,一种提权,一种不提权,调用格式为:指令格式: CALL CS:EIP ,其中 EIP 是废弃的, CS 为指向调用门的段选择子。但是值得注意的是 CS 一旦更换,它的 EIP 和 SS 要同时更换。为什么 EIP 需要更换我就不说了。在代码执行的时候,一定会用到堆栈,堆栈的权限必须与 CS 匹配,这就是为什么 SS 必须更换。我们接下来看看长调用到底是何方神圣。
长调用不提权
当段选择子指向的调用门不提权时。发生改变的寄存器有 ESP 、 EIP 和 CS ,比短调用多一个 CS 。执行情况如下图所示:

当段选择子指向的调用门不提权时。发生改变的寄存器有 ESP 、 EIP 、 CS 和 SS 。执行情况如下图所示:
心细的你获取发现, SS 并没有压入堆栈之中,还有 ESP0 也没用通过已知方式获取得到。它从哪里来呢?下一节教程将会讲解。

进程断链
方法1:

方法2:

[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!
赞赏
- [原创]X86分段 516
- [原创] CE 句柄提权内核驱动源代码 2666
- [原创]Ini解析器 911
- [讨论]Windbg学习使用2 1223
- [讨论]WindbgPreview的使用 1626