-
-
[原创][原创]9.滴水中级班(内核驱动)——长调用与短调用
-
发表于: 2025-9-22 13:38 651
-

远跳转是可以实现段间的跳转,如果要实现跨段的跳转那么就要学习长调用,本节课的学习内容就是了解各种不同版本的Call指令是如何让堆栈变化的。

好的,这张图展示了x86汇编语言中一个非常基础且核心的概念:短调用(near call) 以及与之相关的堆栈操作。这对于理解程序执行流程、函数调用机制以及逆向工程和漏洞利用都至关重要。
让我为你详细解读一下图中的知识点:核心概念——函数调用与堆栈
在程序中,当一个函数(调用者,Caller)需要执行另一个函数(被调用者,Callee)时,CPU需要一种机制来“记住”执行完被调用函数后应该回到哪里继续执行。这个“记忆”就是通过堆栈(Stack) 来实现的。
这张图主要解释了CALL和RET两个指令如何协同工作,利用堆栈来完成函数的调用和返回。
1. CALL 指令:发起调用
CALL指令用于将程序的控制权转移到一个新的地址(即被调用函数的入口)。图中左半部分详细描述了CALL指令执行时的动作:
指令格式: CALL 立即数/寄存器/内存 这表示CALL的目标地址可以是一个直接写在代码里的地址(立即数),也可以是存储在寄存器或内存中的地址。
压入返回地址: 在跳转到目标函数之前,CPU会自动将CALL指令下一条指令的地址压入堆栈。这个地址被称为返回地址(Return Address)。这样做是为了在被调用函数执行完毕后,程序能知道从哪里继续执行。
堆栈指针(ESP)的变化: 堆栈的增长方向是从高地址向低地址。当返回地址被压入堆栈时,堆栈指针ESP会减小,指向新的栈顶(也就是刚刚压入的返回地址)。
图中CALL执行前ESP指向原始栈顶。
执行CALL后,返回地址入栈,CALL执行后ESP指向了这个新的栈顶。
指令指针(EIP)的变化: CPU会将指令指针EIP的值修改为CALL指令指定的目标函数地址。这样,下一条要执行的指令就是被调用函数的第—条指令了。
总结:CALL指令做了两件关键事情:
将返回地址压入堆栈,ESP减小。
跳转到目标函数执行,EIP更新为目标函数地址。
2. RET 指令:返回调用者
RET (Return) 指令通常是被调用函数的最后一条指令,它的作用是把控制权交还给调用者。图中右半部分描述了这个过程:
弹出返回地址: RET指令执行时,会假设当前ESP正指向一个有效的返回地址。它会将这个地址从堆栈中弹出,并加载到EIP寄存器中。
堆栈指针(ESP)的变化: 因为数据被弹出,堆栈指针ESP会相应地增加,指向上一个栈顶,恢复到CALL指令执行前的状态。
指令指针(EIP)的变化: EIP被更新为从堆栈中弹出的返回地址,于是CPU下一条指令就会从调用CALL的地方继续执行。
请注意:图中有个小笔误,TET执行后ESP 应该是 RET执行后ESP。
总结:RET指令做了与CALL相反的操作:
从堆栈顶部弹出返回地址到EIP,ESP增加。
程序流程回到调用者的下一条指令继续执行。
发生改变的寄存器:ESP 和 EIP
图的底部总结了这两个关键寄存器:
ESP (Stack Pointer): 栈顶指针。在函数调用和返回过程中,它随着数据的压入(push)和弹出(pop)而改变,用于管理函数的调用栈帧。
EIP (Instruction Pointer): 指令指针。它始终指向下一条将要执行的指令的地址。CALL和RET的核心作用就是修改EIP以改变程序的执行流。
这为什么重要?
理解CALL和RET指令对堆栈的操作方式,是你学习逆向工程和软件安全的基础。例如:
缓冲区溢出攻击 (Buffer Overflow): 一种经典的攻击方式就是向一个程序输入超长的数据,覆盖掉堆栈上的局部变量,并最终覆盖CALL指令存放在堆栈上的返回地址。攻击者可以将返回地址修改为一段恶意代码(Shellcode)的地址。这样,当函数执行RET指令时,程序就会跳转到攻击者指定的恶意代码执行,从而夺取程序的控制权。
逆向分析: 在没有源码的情况下分析一个程序时,通过追踪CALL指令和观察堆栈变化,可以清晰地梳理出程序的函数调用关系和执行逻辑。
希望这个解释能帮助你更好地理解这张图背后的原理!这部分知识非常关键,是深入学习底层技术和安全领域的基石。
先来介绍一下短调用,CALL指令+立即数/寄存器/内存,无论哪种形式,其关键在于这个指令要执行时,CALL要执行的代码在哪里,所以立即数/寄存器/内存就是告诉我们要执行的代码在哪里。
在这个CALL这里下断点,观察堆栈的值,当前堆栈的值是0018FF14,F7执行后,堆栈发生了压栈的操作,压入了一个值也就是返回地址——CALL的下一条指令,并且堆栈减4,CALL指令会影响两个寄存器,一个是ESP的变化,也就是右下角那个堆栈,还有一个就是影响EIP的值,会给EIP赋值。CALL指令何时会返回呢?每一个函数执行完毕后都会通过RET指令来返回到调用它的界面,所以RET指令其实也可以拆为两个动作,一个是ESP+4的动作,一个是把ESP之前的栈顶的值写入EIP。
总结一下,短调用对寄存器的影响就是ESP和EIP,对堆栈的影响就是只压入了一个返回地址。
长调用的指令格式和短调用区别很大,CALL+CS:EIP,CPU真正执行这个指令的时候,是不使用EIP的,EIP实际上是被废弃掉的,前面的CS是一个选择子,CPU要查表也就是GDT表的,到这个表里面去找段描述符,这个段描述符必须是一个调用门!执行长调用的时候,最终需要执行的代码在哪里不是由这个EIP决定的,而是通过这个CS的段选择子,通过这个选择子找到的调用门,通过这个调用门找到的符号算出来的,这个在下一节课会详细讲解。这一节课只要记住长调用的指令格式就行。
当执行长调用的时候,会有两种情况,1.要调用到的CS段选择子,如果段与段是同级别的,比如说当前CPL是3,要跳转的段的CPL也是3,那么我们称这种情况为跨段不提权,如果是这种情况,那么堆栈的变化和短调用不同的是——会先压入调用者的CS,把当前的CS的段选择子压入到堆栈中,然后执行。执行后ESP的值会加8,加8以后指向的仍然是返回地址。这时候如果我们执行完了这个长调用想返回的时候,就不能像原来那样使用一个短的、普通的RET,因为倘若这样就无法把CS的值赋值给原来的段寄存器的,所以我们通过长调用执行完代码以后,必须通过长返回来从另外一个段跳转回来,即长调用返回的不是普通的RET而是RETF( return far)。RETF执行前堆栈指针指向的是返回地址,执行时,会修改ESP的值,并且将CS的值重新赋值给CS段寄存器,就是将原来保存的段选择子重新赋值给CS段寄存器,这一点是和短调用不同的地方。总结一下,当长调用跨段但不提权的时候,发生改变的寄存器有ESP、EIP、CS,堆栈的变化就是会压入两个值,一个是调用者的CS,还有一个就是返回地址,这个就是长调用的第一种情况。2.第二种情况接着下面这个图讲

第二种情况就是跨段并提权,指令格式是一样的,左边图中是还没有执行前的堆栈图,这里是指向ESP3,表示调用的代码是一个普通的CPL为3的3环代码,它要调用的地方是0环的权限,一旦涉及到权限的变化,那么堆栈将会随之切换。右侧的堆栈已经不是原来的堆栈了,它是另外一个堆栈,ESP0,假设要从3环的程序跳到0环的段里,那么堆栈的变化就是右侧这样。我们再观察一下数据的变化,首先被压入的是调用者的SS;接着是调用者的ESP,因为我们调用的是别的程序的代码,所以调用完后还要回来,所以这里需要记录原来的栈顶在哪里,所以这里有ESP;接着就是调用者的CS,调用代码之前的CS是多少也要记录在内;最后一个就是它的返回地址。跨段并提权相比于跨段不提权,其变化就是调用者的SS和调用者的ESP会压到栈里。这里着重说一下为什么要保留调用者的SS和ESP,因为当跨栈提权的时候,堆栈已经发生变化了,即右侧的堆栈已经不是原来的堆栈了,右侧是一个0环的堆栈(用ESP0表示),这里举例的是从3环到0环的例子,3到1或者3到2都一样。大家需要理解的是——这行代码执行的时候,这个堆栈不是它原来的堆栈,而是另外一个0环的堆栈。那么0环的堆栈是哪里来的?原来的堆栈没有使用而是使用-环的堆栈,这个堆栈可不是随便写的、也不是我们提供的,那么是谁提供的呢?而且0环堆栈里面存储的是调用者的SS,那么它自己的SS呢?当我们执行代码跨段提权操作的时候,堆栈(即ESP)发生了切换、SS发生了切换、CS也发生了切换。
不提权的时候只有CS发生了切换,堆栈不发生切换,SS也不切换。提权的时候不一样,CS、ESP和SS都发生了切换,大家要记住这个堆栈图。

执行返回的时候是一样的。PS:注意一下英文字母的意思,图中右侧的返回地址实际就是EIP。
当程序发生特权级切换(例如从用户态切换到内核态)时,CPU会自动从一个叫做TSS(Task State Segment,任务状态段)的特殊内存区域中,加载新的栈指针SS和ESP。下面我们来分步解析这个过程。
什么是TSS(任务状态段)?TSS(Task State Segment)是x86架构在保护模式下为了实现多任务而设计的一个特殊的数据结构。你可以把它理解为一个“任务档案馆”,它保存在内存中,用来存放一个任务(在现代操作系统中通常指一个线程或进程)的所有关键信息。作用:TSS的主要作用是保存任务的完整上下文。当CPU需要从一个任务切换到另一个任务时,它会把当前任务的所有寄存器状态(如EAX, EBX, CS, EIP, SS, ESP等)完整地保存到当前任务的TSS中,然后再从新任务的TSS中加载其上下文,从而恢复新任务的运行。定位:CPU通过一个专门的寄存器——TR(Task Register,任务寄存器)——来找到当前正在运行任务的TSS。TR寄存器里存放的不是TSS的直接地址,而是一个指向GDT(全局描述符表)中TSS描述符的选择子,CPU通过这个描述符最终定位到TSS在内存中的位置。
为什么需要切换堆栈?正如你图片中总结的第一点所说:“跨段调用时,一旦有权限切换,就会切换堆栈。”这是保护模式下一个核心的安全机制。操作系统通常运行在最高的特权级(Ring 0,内核态),而应用程序运行在最低的特权级(Ring 3,用户态)。
隔离与安全:如果用户态程序和内核共用一个堆栈,那么用户程序中的错误(如栈溢出)就可能破坏内核的数据,导致整个系统崩溃。为了防止这种情况,每个特权级都必须拥有自己独立的、受保护的堆栈。切换过程:当一个用户态程序通过系统调用(如INT指令)或调用门(CALL FAR)请求内核服务时,CPU的特权级会从Ring 3提升到Ring 0。此时,CPU必须弃用Ring 3的堆栈,转而使用为Ring 0准备好的、更安全的内核堆栈。
为什么SS和ESP从TSS中来?现在我们来回答最关键的问题:当特权级从Ring 3切换到Ring 0时,新的堆栈(即内核堆栈)的地址(SS和ESP的值)是从哪里来的?答案就在TSS中。
TSS的结构中预留了专门的字段,用来存放高特权级堆栈的指针。具体来说,TSS中包含了以下几个关键字段:SS0, ESP0:用于存放Ring 0的堆栈段选择子和栈顶指针。SS1, ESP1:用于存放Ring 1的堆栈段选择子和栈顶指针。SS2, ESP2:用于存放Ring 2的堆栈段选择子和栈顶指针。
工作流程如下:一个在Ring 3运行的程序触发了一个中断或进行了一个系统调用,需要切换到Ring 0执行。
CPU硬件会自动执行以下操作:通过TR寄存器找到当前任务的TSS。从TSS中读取SS0和ESP0字段的值。将SS0的值加载到SS寄存器,将ESP0的值加载到ESP寄存器。
至此,CPU的堆栈已经成功切换到了内核态堆栈。
然后,CPU会将用户态的堆栈指针(旧的SS和ESP)、返回地址(旧的CS和EIP)等信息压入到这个新的内核堆栈中,以便内核代码执行完毕后能够安全地返回用户态。
总结:简单来说,TSS就像是为每个任务准备的一个“应急包”。当任务需要“升级”去执行内核代码时,CPU会自动打开这个应急包,从中拿出预先准备好的、专供内核使用的“工具”(即内核堆栈的地址SS0和ESP0),然后开始工作。这样既保证了内核有自己独立、安全的工作空间,也确保了任务切换的流畅性。值得一提的是,虽然Intel设计了复杂的基于TSS的硬件任务切换机制,但现代操作系统(如Linux和Windows)为了更高的灵活性和效率,并没有完全使用它。它们通常采用软件方式进行任务调度,但仍然会利用TSS来完成最核心的功能——特权级切换时的堆栈切换。
好的,这个课后练习的核心是理解并记住 CALL 指令在不同情况下是如何影响堆栈的。CALL 指令的主要作用是在跳转到子程序之前,将返回地址保存到堆栈中,以便子程序执行完毕后能通过 RET 或 RETF 指令正确返回。
CALL 指令执行时的堆栈变化主要分为以下三种情况:
- 近调用 (Near Call)
当调用的子程序与 CALL 指令位于同一个代码段时,执行的是近调用。
- 操作过程:
- 将**下一条指令的地址(EIP 的值)**压入堆栈。
- 跳转到子程序的入口地址。
- 堆栈变化:
ESP = ESP - 4(在32位模式下,地址占4个字节)。[SS:ESP]处存放的是返回地址(旧的 EIP)。
示例: 假设 CALL SubRoutine 指令之后紧跟着一条 MOV EAX, 1 指令。 执行 CALL 时,CPU 会把 MOV EAX, 1 的地址压入堆栈,然后跳转到 SubRoutine。子程序最后通过 RET 指令从堆栈中弹出这个地址到 EIP,从而返回继续执行 MOV 指令。
- 远调用 (Far Call) - 无特权级变化
当调用的子程序与 CALL 指令位于不同的代码段,但特权级相同时,执行的是远调用。
- 操作过程:
- 将当前的**代码段寄存器(CS)**的值压入堆栈。
- 将**下一条指令的地址(EIP)**的值压入堆栈。
- 跳转到目标代码段中的子程序入口。
- 堆栈变化:
ESP = ESP - 8(在32位模式下,CS 和 EIP 各占4个字节,通常先压入CS,再压入EIP)。[SS:ESP+4]处存放的是返回的代码段(旧的 CS)。[SS:ESP]处存放的是返回的指令地址(旧的 EIP)。
子程序需要使用 RETF(远返回)指令,它会依次弹出 EIP 和 CS,实现跨段返回。
- 远调用 (Far Call) - 有特权级变化
当调用发生特权级切换时(例如,从用户态 Ring 3 调用内核态 Ring 0 的代码),这是最复杂的情况。
- 操作过程:
- 切换堆栈:CPU 从 TSS (任务状态段) 中加载目标特权级(例如 Ring 0)的堆栈指针(
SS0和ESP0)到 SS 和 ESP 寄存器。CPU 切换到了新的、更高权限的堆栈。 - 在新堆栈中保存旧堆栈指针:将调用者(低权限)的堆栈指针(旧的
SS和ESP)压入新的堆栈。 - 在新堆栈中保存返回地址:将调用者的返回地址(旧的
CS和EIP)压入新的堆栈。 - 加载目标代码的
CS和EIP,并跳转。
- 堆栈变化(发生在新堆栈上):
ESP指向从 TSS 加载的新地址。- 依次压入旧
SS、旧ESP、旧CS、旧EIP,ESP 相应减少。
为了方便你记忆,下表总结了不同 CALL 指令对堆栈的操作:
调用类型 | 目标位置 | 特权级变化 | 压入堆栈的内容 (从先到后) | 堆栈指针 (ESP) 变化 (32位) |
近调用 (Near Call) | 同一代码段 | 无 | 返回地址 (EIP) | ESP = ESP - 4 |
远调用 (Far Call) | 不同代码段 | 无 | 代码段 (CS)、返回地址 (EIP) | ESP = ESP - 8 |
远调用 (Far Call) | 不同代码段 | 有 | 切换到新堆栈后,压入:旧SS、旧ESP、旧CS、旧EIP | ESP 指向新堆栈地址,并相应减少 |
简单来说,记住这个核心:CALL 的本质是把“回家的路”存到堆栈里再出门。近调用只需要记下在哪条街(EIP),远调用则要记下在哪个城市哪条街(CS:EIP),而要去“皇宫”(内核)办事,则需要换上“官服”(新堆栈)并把自己的“家庭住址”和“来时路线”都记在一个新的本子上。