首页 > 教程资料

中断门详解:从32位返回栈到Win11 x64完整实验

malloc 2026-09-21 17:03:57 人看过

行以后,调试程序时,我们经常用到 INT 3,也就是熟悉的 CC 断点。但这条指令执行以后,CPU 去哪里寻找处理程序?如果从 3 环进入了 0 环,原来的代码位置和堆栈又保存在哪里?

这两节课围绕这些问题展开:先在 32 位环境下认识中断门,构造描述符并观察返回栈,再到 Win11 x64 中完成一次进入和返回实验。本文按视频里的顺序,把每一步及其对应结果整理出来。

一、先分清 IDT 和 IDTR

IDT 是中断描述符表,IDTR 是记录这张表位置和界限的寄存器。第一课先在 WinDbg 中查看 IDTR,再用 dq 读取表中的内容。

r idtr
dq @idtr

32 位环境中的门描述符占 8 字节,dq 显示的一个 8 字节数就对应一个表项。执行 INT 3 时,使用的是编号 3,也就是 IDT[3]。表项从 0 开始计数,不能把屏幕上的第三个数直接当成它。

图1:第一课通过编号3定位IDT中的门描述符

图1:第一课通过编号3定位IDT中的门描述符

这与前面的调用门查找有所不同:调用门需要先拆段选择子,再根据索引查表;这里的中断编号直接用于定位 IDT 表项。

但找到表项,还没有得到完整的跳转信息。表项里存放的是门描述符,CPU 还要继续解释其中的字段。

二、拆开门描述符,找到代码入口

第一课把中断门与调用门的结构放在一起理解。阅读中断门时,重点看三部分:入口偏移、段选择子,以及权限和类型字段。

32 位入口偏移被分成高、低两个 16 位字段,把它们拼接起来,才得到完整的 32 位偏移。段选择子则指向目标代码段,需要继续到 GDT 中查找相应描述符。

因此,查找过程可以连成一条线:

中断编号 → IDT中的门 → 门内的段选择子 → GDT中的目标代码段
                   └→ 拼接入口偏移,确定处理代码的位置

构造实验用的门时,视频填写的属性字节是 EEh:P 为 1、DPL 为 3,类型为 Eh,对应本实验的中断门。门的 DPL 与目标代码段的权限要分开看,不能因为门的 DPL 是 3,就认为处理代码也只能在 3 环执行。

例如,第一课的目标选择子是 0008。按前面课程学过的拆法,它指向 GDT 中索引为1的表项。这样,门里的选择子、GDT里的代码段描述符,以及最后执行处理代码的位置,才建立起对应关系。只拼出函数地址,却没有看选择子指向哪里,仍然没有把整个跳转过程读完整。

这也是为什么实验还要填写目标代码段选择子:门解决入口问题,选择子进一步指向要进入的代码段。

三、进入0环以后,旧的堆栈怎么办?

第一课接下来强调的是换栈。对于课中从 3 环进入 0 环的情况,CPU 会切换到内核栈,其位置来自 TSS。课程在这里先说明来源,没有继续展开 TSS 的内部结构。

换栈以后,返回原程序仍然需要旧的栈位置;跳转以后,也需要知道应该回到哪条指令。因此,这些信息要先保存到新栈中。

图2:32位跨级进入时,内核栈保存的返回信息

图2:32位跨级进入时,内核栈保存的返回信息

图中从栈顶向下,依次为返回 EIP、旧 CS、旧 EFLAGS、旧 ESP、旧 SS。它们分别保留了返回地址、原代码段、标志寄存器和原栈位置。

理解这里,不妨联系普通函数调用:调用后要能返回,就必须留下返回信息。中断门涉及的状态更多,跨级时还换了栈,所以保存的内容也更多。

与调用门对照时,课程特别指出了 EFLAGS。中断返回需要恢复这部分状态,不能直接套用调用门的返回方式。第一课实际代码使用 IRETD,与这里的 32 位返回栈对应。

四、32位实验:为什么选择 INT 20h?

实验目标是自己构造一个中断门,让程序通过它进入自定义函数。

先查看 IDT 的范围。视频中表界限为 0x7FF,对应大小为 0x800 字节;每项 8 字节,因此共有 0x100,也就是 256 项。

这里的界限是最后一个有效字节的偏移,计算总大小时要加1。编号从0开始,256项对应的编号范围是00h到FFh。视频先展开整张表,再选择实验位置,正是为了把表的范围、表项大小和编号关系放在一起看。

随后,课程在当时的表中选取偏移 0x100 的位置。这里要区分“字节偏移”和“中断编号”:

表项编号 = 0x100 ÷ 8 = 0x20

所以程序写的是 INT 20h。它要找的是 IDT[20h],该项在这张32位表中的位置才是“基址加0x100”。

接着运行测试程序,打印 GetRegister 的地址,并等待输入。等待期间,在 WinDbg 中填写门描述符。视频中的处理函数核心代码是:

void __declspec(naked) GetRegister()
{
    __asm {
        int 3
        mov tmp, 1
        iretd
    }
}

其中 INT 3 用于进入后停住观察,mov 是处理函数里的操作,IRETD 用于中断返回。主程序在等待结束后执行 INT 20h

主程序的顺序也值得对照:先打印处理函数和 main 的地址,再打印初始 tmp,通过 getchar() 等待,之后触发中断,最后再次打印 tmp。这个等待点给调试器留下了修改IDT的时间;处理函数地址尚未取得时,就无法完成入口字段的填写。

本次32位实验的入口为 010A1000,拆分后,高16位是 010A,低16位是 1000。再填入属性 EE、中间的 00 和目标选择子 0008,得到:

010A EE 00 0008 1000

合起来就是 010AEE0000081000。将它写入目标表项后,再读回确认,检查地址与属性是否填对。这里的地址来自视频当次运行,自己实验时应使用自己程序打印的地址。

读回时也按相同方向拆解:010A和末尾的1000还原入口,中间的0008对应选择子,EE对应属性。这样检查,能够把“命令写进去了”和“写进去的内容正确”区分开,避免只看到内存数值变化就继续执行。

视频还读取并调整了 CR4,关闭该实验涉及的 SMEP 保护,再恢复运行。随后调试器在自定义函数中的 INT 3 处停住,反汇编显示的正是 INT 3、变量赋值和 IRETD

图3:32位实验进入自定义函数,源代码与反汇编相互对应

图3:32位实验进入自定义函数,源代码与反汇编相互对应

五、用 WinDbg 核对返回栈

跳进函数以后,第一课继续执行:

dd esp

这一步把前面的栈示意图变成了实际数据。视频中的栈内容如下:

栈中的值对应内容
010A1116返回 EIP
0000001B旧 CS
00000212旧 EFLAGS
004FF7C0旧 ESP
00000023旧 SS

接着执行 u 010a1116,查看返回地址处的代码,再对照触发中断的位置,就能看出程序返回后会从哪里继续。

这里不要混淆两个地址:返回EIP指向要继续执行的代码,旧ESP指向进入之前的栈。前者用反汇编命令查看,后者则按内存内容查看。第一课正是把这些值一个个拿出来核对,而不是仅凭数值看起来像地址,就认定返回现场已经理解了。

图4:读取ESP指向的内容,并反汇编栈中保存的返回地址

图4:读取ESP指向的内容,并反汇编栈中保存的返回地址

旧 CS、旧 ESP 和旧 SS 同样不是无关数据。进入处理程序时,代码段和栈已经发生变化,返回时必须恢复原状态,才能继续执行原来的程序。

第一课演示到这里,重点是观察入口和返回栈。视频保留了用于调试的 INT 3,并说明完整体验返回流程时,应去掉该断点、重新编译再做实验;不能把这次停住观察直接写成已经展示了完整返回。

六、Win11 x64:先改结构,再改代码

第二课换到 Win11 x64,实验思路延续上一课,但门描述符扩展为16字节。

图5:64位门描述符,入口偏移分成三个字段

图5:64位门描述符,入口偏移分成三个字段

入口偏移由低16位、中间16位和高32位组成。视频打印的函数地址为 00007FF60EBC11A0,因此拆分为:

Offset 15..0  :11A0
Offset 31..16 :0EBC
Offset 63..32 :00007FF6

结构中还出现了 IST 字段。本次实验将其设为0,课程先使用已有的内核栈完成流程,不展开 IST 的其他用法。

代码方面,汇编改为单独的 ASM 文件。第二课去掉了处理函数中的断点和变量赋值,将其简化为:

GetRegister PROC
    iretq
GetRegister ENDP
DoInt20 PROC
    int 20h
    ret
DoInt20 ENDP

这次精简是课程主动限定实验范围:先验证描述符和返回流程。视频说明,继续加回断点、变量访问,会牵涉后续的代码段和页表内容,因此本课没有沿着这些问题展开。把32位函数中的操作原样搬过来,就改变了第二课实际演示的内容。

这里有两次不同的返回:IRETQ 从中断处理代码返回到 INT 20h 后面;随后 RET 再从 DoInt20 返回调用它的C代码。两条指令对应的是不同的进入方式。

七、定位表项,按正确顺序写入16字节

同样执行 INT 20h,现在每项占 0x10 字节,所以目标位置变为:

IDT基址 + 0x20 × 0x10 = IDT基址 + 0x200

视频直接使用表达式查看:

dq @idtr+20*10

这里按视频中的十六进制口径计算。不能继续沿用32位实验的 0x100 偏移。

另一个重点是 dq 的显示方式。它显示的每个数仍是8字节,因此两个相邻的数才组成一个16字节门描述符。低地址的8字节在前,高地址的8字节在后;不能照着结构图从上往下直接抄进命令。

填选择子时也重新核对:上一课是 0008,本次x64实验使用 0010。结合三段入口偏移、属性和保留字段,视频构造出的两个8字节数为:

低地址8字节:0EBCEE00001011A0
高地址8字节:0000000000007FF6

按这个顺序写入,再用 dq 读回:

eq @idtr+20*10 0ebcee00001011a0 0000000000007ff6
dq @idtr+20*10

核对时,把两个数重新拆回字段,检查入口地址是否仍是程序打印的值,选择子、属性和 IST 是否与本次构造一致。

八、恢复运行,观察第二次输出

完成描述符修改后,第二课同样检查并调整 CR4。画面中的值从 170678 改为 70678,随后读回确认,再继续运行。

这一步与写门描述符是分开的:前者调整本次实验的处理器保护设置,后者决定中断入口。视频两处都进行了读回检查。文中的函数地址、选择子与CR4数值描述的是当次虚拟机实验,不是所有机器都可以照抄的一组固定值。

控制台先前已经输出一次 tmp=0;执行 INT 20h 后,又出现第二次 tmp=0

图6:Win11实验结束,描述符读回结果与第二次输出

图6:Win11实验结束,描述符读回结果与第二次输出

为什么没有变成1?因为第二课已经去掉变量赋值,处理函数只剩 IRETQ。这次实验观察的是进入之后能否返回、主程序能否继续输出,而不是验证变量被修改。

至此,两课的过程对应起来了:32位课从 INT 3 引出IDT,构造自己的门,再用返回栈解释“怎样回来”;Win11课重新核对16字节结构和读写顺序,用精简代码跑通进入与返回。学习时把编号、表项、函数地址、返回栈逐个对上,才能真正读懂调试器里的这些数值。

版权声明:倡导尊重与保护知识产权。未经许可,任何人不得复制、转载、或以其他方式使用本站《原创》内容,违者将追究其法律责任。本站文章内容,部分图片来源于网络,如有侵权,请联系我们修改或者删除处理。

编辑推荐

热门文章