在ESP以下编写代码是否有效?

15

32位Windows应用程序可否在未明确减少ESP的情况下使用ESP以下的堆栈内存作为临时交换空间?

考虑一个将浮点值返回到 ST(0)的函数。 如果我们的值当前位于EAX中,我们可以:

PUSH   EAX
FLD    [ESP]
ADD    ESP,4  // or POP EAX, etc
// return...

或者不修改ESP寄存器,我们可以只做:

MOV    [ESP-4], EAX
FLD    [ESP-4]
// return...

在这两种情况下,发生的事情是相同的,只是在第一种情况下,在使用内存之前我们要注意将堆栈指针减少,然后在使用完毕后再增加。而在后一种情况下,我们不需要这样做。

即使没有任何必要将该值保留在堆栈中(例如重新进入问题、在PUSH和读取该值之间调用函数等),是否存在任何根本原因导致像这样写入低于ESP的堆栈无效?


4
在Windows用户模式下是有效的,因为中断从不使用用户模式堆栈,在内核模式下这是无效的 - 因为中断可以在任何时候执行。 - RbMm
1
为什么不按设计使用堆栈指针呢?这正是堆栈和堆栈指针的全部意义,使您可以轻松分配本地内存。为什么不直接这样做呢?我的评论与x86无关,在一般情况下按照处理器的设计使用即可。 - old_timer
2
@old_timer 原因并不是很重要 - 比如假设它更高效。或者想象一下你在现有代码库中发现了这样的东西。问题在于正确性和文档化行为(你会“修复”它吗?它实际上是有问题的吗?)。当然,我们希望尽可能地符合惯用语言,除非有理由不这样做,但我想在这里区分正确和惯用语言。 - J...
有一个约束条件列表,确保您不必移动堆栈指针,但该列表可能会崩溃,这绝对是一个非常糟糕的习惯。显然应该知道那个列表是什么。 - old_timer
1
未来的Windows可能会添加“直接”APC或一些“直接”信号 - 一些代码将通过回调执行,就在线程进入内核时(在通常的硬件中断期间)。在此之后,所有下面的esp都将是未定义的。但在此之前不存在。在当前版本中,直到这个代码工作始终正确。 - RbMm
5个回答

16
TL:DR: 不,实际上有一些SEH边界情况会使得它变得不安全,而且已经被记录为不安全。@Raymond Chen最近写了一篇博客文章,你应该阅读那篇文章。

他的一个代码获取页面错误的示例可以通过提示用户插入CD-ROM并重试来“修复”,这也是我的结论,如果在ESP/RSP下没有任何其他可能导致故障的指令,则唯一可恢复的故障。

或者,如果您要求调试器调用正在被调试的程序中的函数,则它也将使用目标进程的堆栈。

这个答案列出了一些可能会踩到ESP下面内存的东西,但实际上并没有,这可能很有趣。看起来只有SEH和调试器在实践中可能会有问题。



首先,如果你关心效率,你能避免在调用约定中使用x87吗?movd xmm0, eax是一种更有效的方式来返回一个整数寄存器中的float。(而且你通常可以避免将FP值移动到整数寄存器中,使用SSE2整数指令来拆分指数/尾数以进行log(x),或者使用整数加1来进行nextafter(x)。)但是如果你需要支持非常旧的硬件,那么你需要一个32位的x87版本的程序以及一个高效的64位版本。
但是,在堆栈上有少量临时空间的其他用例中,保存一些抵消ESP/RSP的指令会很好。

尝试汇总其他答案和评论下的讨论的集体智慧:

Microsoft明确记录了它不是安全的:(对于64位代码,我没有找到32位代码的等效语句,但我相信有一个)

堆栈使用(针对x64)

当前RSP地址之后的所有内存都被视为易失性内存:操作系统或调试器可能在用户调试会话或中断处理程序期间覆盖此内存。

所以这就是文档,但所述的中断原因对于用户空间堆栈而言并没有意义,只有内核堆栈。重要的是,他们将其记录为不保证安全,而不是给出的原因。

硬件中断不能使用用户堆栈;否则,用户空间就可以通过 mov esp, 0 让内核崩溃,或者更糟糕的是,在中断处理程序运行时,用户空间进程中的另一个线程修改返回地址,从而接管内核。这就是为什么内核总是配置使中断上下文被推到内核堆栈上的原因。
现代调试器在单独的进程中运行,不会产生“侵入性”。在16位DOS时代,没有多任务保护内存的操作系统为每个任务提供自己的地址空间,调试器将在单步执行时在被调试程序的堆栈上使用相同的堆栈。
@RossRidge 指出,调试器可能希望让您在当前线程的上下文中调用函数,例如使用 SetThreadContext。这将在 ESP/RSP 稍低于当前值的情况下运行。这显然会对正在被调试的进程产生副作用(由运行调试器的用户故意引起),但破坏 ESP/RSP 下方当前函数的局部变量将是一个不必要且意外的副作用。(因此编译器不能将它们放在那里。)
(在 ESP/RSP 下面有红区的调用约定中,调试器可以通过在调用函数之前减少 ESP/RSP 来尊重该红区。)
已经存在程序会在被完全调试时故意中断,并将其视为一种功能(以防止反向工程努力)。

相关:x86-64 System V ABI(Linux、OS X和所有其他非Windows系统)确实为用户空间代码(仅限64位)定义了一个:RSP下方的128字节被保证不会异步破坏。Unix信号处理程序可以在任何两个用户空间指令之间异步运行,但内核通过在旧用户空间RSP下留下128字节的间隙来尊重红区,以防其被使用。如果没有安装信号处理程序,则即使在32位模式下(其中ABI并未保证红区),您也拥有有效的无限制红区。当然,编译器生成的代码或库代码不能假设整个程序中没有其他东西(或者程序调用的库)安装了信号处理程序。

因此问题变成了:Windows上是否有任何东西可以在两个任意指令之间使用用户空间堆栈异步运行代码?(即任何等效于Unix信号处理程序的东西。)

据我们所知,SEH (Structured Exception Handling) 是当前32位和64位Windows用户空间代码中唯一的真正障碍,(但未来的Windows可能会包含一个新功能)。如果您要求调试器调用目标进程/线程中的函数,则需要考虑调试。在这种特定情况下,只要不触及堆栈以外的任何其他内存,或者不执行任何可能导致故障的操作,甚至从SEH方面来看也可能是安全的。
SEH(结构化异常处理)让用户空间软件能够像C++异常一样接收硬件异常,例如除以零。但这些并不是真正的异步异常:它们是由你运行的指令引发的异常,而不是随机出现的事件。
与普通异常不同的是,SEH处理程序可以从异常发生的地方恢复执行。 (@RossRidge评论说:SEH处理程序最初在展开的堆栈上下文中调用,并且可以选择忽略异常并继续执行到异常发生的位置。)
通常情况下,硬件异常只能同步触发。例如,通过div指令或可能导致STATUS_ACCESS_VIOLATION(类似于Linux SIGSEGV段错误)的内存访问。您可以控制使用的指令,因此可以避免可能会出错的指令。
如果您限制代码只访问存储和重新加载之间的堆栈内存,并尊重堆栈增长保护页,那么您的程序在访问[esp-4]时不会出错。(除非您达到了最大堆栈大小(堆栈溢出),在这种情况下,push eax也会出错,并且您无法真正从这种情况中恢复,因为没有堆栈空间供SEH使用。)
因此,我们可以排除STATUS_ACCESS_VIOLATION作为问题,因为如果我们在访问堆栈内存时遇到它,我们就完了。
一个STATUS_IN_PAGE_ERROR的SEH处理程序可以在任何load指令之前运行。Windows可以将任何页面分页输出,并在需要时透明地将其重新分页回来(虚拟内存分页)。但是,如果有I/O错误,Windows会尝试让您的进程通过传递STATUS_IN_PAGE_ERROR来处理失败
同样,如果当前堆栈发生这种情况,我们就完了。

但代码获取可能会导致 STATUS_IN_PAGE_ERROR,您可以合理地从中恢复。但不能在异常发生的地方恢复执行(除非在高度容错的系统中我们可以以某种方式将该页面映射到另一个副本??),因此我们在这里可能仍然可以。

对于想要读取我们存储在 ESP 以下位置的内容的代码在分页输入/输出错误发生时无法读取它。如果您没有打算这样做,那么您就没问题了。一个不知道这个特定代码片段的通用 SEH 处理程序也不会试图这样做。我认为通常 STATUS_IN_PAGE_ERROR 最多只会尝试打印错误消息或记录一些内容,而不会继续进行任何计算。

在存储和重新加载 ESP 下方内存之间访问其他内存可能会触发 STATUS_IN_PAGE_ERROR,针对 那个 内存。在库代码中,您可能无法假设传递的某些其他指针不会有问题,调用者正在期望处理 STATUS_ACCESS_VIOLATION 或 PAGE_ERROR。

当前编译器在Windows上没有利用ESP/RSP下方的空间,尽管它们在x86-64 System V中利用了红区(在需要溢出/重新加载某些内容的叶函数中,就像将int转换为x87一样)。这是因为微软表示这不安全,并且他们不知道是否存在SEH处理程序可以尝试在SEH之后恢复。

在当前的Windows中可能会出现问题的事情,以及它们为什么不是问题:

  • ESP以下的守卫页面: 只要不过度下降当前ESP,就会触及守卫页面并触发分配更多堆栈空间,而不是出现故障。只要内核不检查用户空间ESP并发现您在未“保留”堆栈空间的情况下触及堆栈空间,这就很好。

  • ESP/RSP以下页面的内核回收: 显然Windows目前不会这样做。因此,只要使用了大量堆栈空间,这些页面就会在进程的整个生命周期中保持分配状态,除非您手动 VirtualAlloc(MEM_RESET) 释放它们。 (但内核是允许这样做的,因为文档说RSP以下的内存是易失性的。如果内存压力下,内核可以异步有效地将其清零,将其映射到零页面上,而不是将其写入页面文件。)

  • APC(异步过程调用):只有进程处于“可警报状态”时才能传递它们,这意味着只有在调用像SleepEx(0,1)这样的函数时才能传递。调用函数已经使用了未知数量的E/RSP下方空间,因此您必须假定每个call都会覆盖堆栈指针下方的所有内容。因此,这些“异步”回调与Unix信号处理程序相比,并不真正异步于正常执行。 (有趣的事实:POSIX异步io确实使用信号处理程序来运行回调。)

  • 控制台应用程序回调ctrl-C和其他事件(SetConsoleCtrlHandler)。这看起来 完全 像注册Unix信号处理程序,但在Windows中,处理程序在具有自己堆栈的单独线程中运行。 (请参见RbMm的评论

  • SetThreadContext: 另一个线程可能在此线程暂停时异步更改我们的EIP/RIP,但是整个程序必须专门编写才能使其有任何意义。除非是调试器在使用它。当其他线程正在搞乱您的EIP时,通常不需要正确性,除非情况受到严格控制。

显然,在Windows上,除了此线程注册的其他进程(或某些东西)触发任何异步执行用户空间代码的方式之外,没有其他方法。

如果没有SEH处理程序尝试恢复,则Windows在ESP下方有一个4096字节的红区(如果您逐步触摸它可能会更多),但RbMm说实践中没有人利用它。这并不奇怪,因为微软建议不要使用它,并且您不能总是知道调用者是否已经使用了SEH。

显然,任何会“同步”破坏它的事情(例如call)也必须避免,与使用x86-64 System V调用约定中的红区相同。 (有关更多信息,请参见https://stackoverflow.com/tags/red-zone/info。)


1
系统可能随时注入一个结构化异常,例如STATUS_IN_PAGE_ERRORSTATUS_ACCESS_VIOLATION - Raymond Chen
1
@HadiBrais 异常处理程序可以说“我修复了问题,继续执行。”在这种情况下,第二条指令确实会执行。 - Raymond Chen
2
@HadiBrais 结构化异常处理程序也可以请求恢复。只需返回“EXCEPTION_CONTINUE_EXECUTION”以重新启动失败的指令(假定在尝试修复问题后)。 - Raymond Chen
2
Agner建议,如果系统的内存“极度不足”,则可能会丢弃ESP以下的页面。您提到当前系统不会发生这种情况。我不知道谁是对的,但我想提一下。我在这里找到了引用链接,但也许他在其他地方谈论了这个问题。 - BeeOnRope
2
Windows 10版本1809引入了特殊用户APC,就像UNIX信号一样可以在任何时候触发。请参见https://repnz.github.io/posts/apc/user-apc/#ntqueueapcthreadex-meet-special-user-apc - Paul
显示剩余13条评论

7
在一般情况下(x86/x64平台)- 中断可以在任何时候执行,这会覆盖堆栈指针以下的内存(如果在当前堆栈上执行)。因此,即使是在堆栈指针下方暂时保存某些内容,在内核模式下也是无效的 - 中断将使用当前内核堆栈。但在用户模式下的情况不同 - Windows构建中断表(IDT),使得当中断被引发时,它将始终在内核模式和内核堆栈中执行。因此,用户模式堆栈(堆栈指针下方)不会受到影响。并且可能暂时使用一些在其指针下方的堆栈空间,直到您不调用任何函数为止。如果出现异常(例如访问无效地址),则堆栈指针下方的空间也将被覆盖 - 当然,CPU异常将在内核模式和内核堆栈中执行,但随后内核通过ntdll.KiDispatchExecption在当前堆栈空间上执行用户空间回调。因此,总的来说,这在Windows用户模式下是有效的(在当前实现中),但您需要很好地了解自己在做什么。但我认为这很少被使用。
当然,如评论中所指出的那样,在Windows用户模式下写入堆栈指针以下的位置只是当前实现的行为。这没有被记录或保证。
但这非常基础 - 很难改变:中断始终只在特权内核模式下执行。而内核模式将仅使用内核模式堆栈。用户模式上下文完全不受信任。如果用户模式程序设置了不正确的堆栈指针会发生什么?例如通过mov rsp,1mov esp,1?并且就在此指令之后,中断将被触发。如果在这样的无效esp/rsp上开始执行它,会发生什么?所有操作系统都会崩溃。正是因为这个中断只会在内核堆栈上执行,而不会覆盖用户堆栈空间。
还需要注意的是,堆栈是有限的空间(即使在用户模式下),访问低于1页(4Kb)的堆栈已经是错误的(需要逐页进行堆栈探测,以将警戒页向下移动)。

最后,通常没有必要访问[ESP-4], EAX - 如果需要在循环中大量访问堆栈空间,为什么要先减少ESP?即使我们需要在循环中访问堆栈空间很多次 - 减少堆栈指针只需要一次 - 1个额外的指令(不在循环中),性能或代码大小都不会改变。

因此,尽管形式上是正确的,在Windows用户模式下使用这种方法更好(也不需要)。


当然,正式文档说:

堆栈使用情况

除了当前 RSP 地址之外的所有内存都被视为易失性内存

但这是针对常见情况的,包括内核模式。我写的是关于用户模式的,基于当前实现。


在未来的Windows版本中可能会增加"direct" APC或一些"direct"信号 - 一些代码将通过回调执行,就在线程进入内核时(通常是硬件中断期间)。在此之后,所有esp以下的内容都将是未定义的。但在此之前,这段代码将始终正确地工作(在当前版本中)。


6

一般来说(不特定于任何操作系统),如果以下情况之一成立,向ESP以下的位置写入数据是不安全的:

  • 代码可能会被中断并且中断处理程序将以相同特权级别运行。注意:这对于“用户空间”代码通常是不太可能的,但对于内核代码极其可能。

  • 调用任何其他代码(其中要么call语句本身,要么被调用例程使用的堆栈可以破坏您存储在ESP以下的数据)

  • 其他某些内容依赖于“正常”堆栈使用。这可能包括信号处理、(基于语言的)异常展开、调试器、“堆栈溢出保护器”等

如果不“不安全”,则在ESP以下的位置写入数据是安全的。

请注意,在64位代码中,向RSP以下的位置写入数据已经内置在x86-64 ABI(“红区”)中;并且由工具链/编译器和其他所有支持此功能,因此是安全的。


5
这个问题涉及到Windows操作系统。Linux/MacOS有一个红区(red zone),因为它是x86-64 System V ABI的一部分。 - Michael Petch

3

当一个线程被创建时,Windows 会为该线程的堆栈保留一个连续的虚拟内存区域,大小可配置(默认为1 MB)。初始时,堆栈长这样(堆栈向下增长):

--------------
|  committed |
--------------
| guard page |
--------------
|     .      |
| reserved   |
|     .      |
|     .      |
|            |
--------------
ESP将指向已提交页面中的某个位置。保护页用于支持自动堆栈增长。保留页区域确保在虚拟内存中可用所请求的堆栈大小。
考虑问题中的两个指令:
MOV    [ESP-4], EAX
FLD    [ESP-4]

有三种情况:
- 第一个指令执行成功。在两个指令之间没有使用用户模式堆栈的语句,所以第二个指令将使用正确的值(@RbMm 在他的答案下面的评论中提到这一点,我同意)。 - 第一个指令引发异常,并且异常处理程序不返回EXCEPTION_CONTINUE_EXECUTION。只要第二个指令紧随第一个指令之后(它不在异常处理程序或放置在其后),那么第二个指令将不会执行。因此您仍然是安全的。执行从异常处理程序存在的堆栈帧继续。 - 第一个指令引发异常,并且异常处理程序返回EXCEPTION_CONTINUE_EXECUTION。执行将从引发异常的相同指令继续(可能由处理程序修改的上下文)。在这个特定的例子中,首先将被重新执行以写入ESP以下的值。没问题。如果第二个指令引发异常或有两个以上的指令,则异常可能会在写入ESP下面的值后发生。当调用异常处理程序时,它可能会覆盖该值,然后返回EXCEPTION_CONTINUE_EXECUTION。但是当执行恢复时,写入的值被认为仍然存在,但实际上已经不存在了。这是写入ESP以下内容不安全的情况。即使所有指令都是连续放置的,这也适用。感谢@RaymondChen指出了这一点。
通常情况下,如果两个指令不是紧挨着放置的,如果你正在写入超过ESP的位置,则不能保证所写的值不会被破坏或覆盖。我可以想到一个可能发生这种情况的情况是结构化异常处理(SEH)。如果发生硬件定义的异常(例如除零),则内核异常处理程序将在内核模式下调用(KiUserExceptionDispatcher),该程序将调用处理程序的用户模式侧面(RtlDispatchException)。当从用户模式切换到内核模式,然后再切换回用户模式时,将保存并恢复ESP中的任何值。但是,用户模式处理程序本身使用用户模式堆栈,并且将迭代注册的异常处理程序列表,每个处理程序都使用用户模式堆栈。这些函数将根据需要修改ESP。这可能会导致失去您已经写入ESP下面的值。使用软件定义异常(在VC++中使用throw)时也会出现类似的情况。
我认为您可以通过在任何其他异常处理程序之前注册自己的异常处理程序(以便首先调用)来解决这个问题。当调用您的处理程序时,您可以将ESP以下的数据保存在其他地方。稍后,在取消编织期间,您有机会清理并将数据恢复到堆栈上相同的位置(或任何其他位置)。

您还需要密切关注异步过程调用(APCs)和回调。


2

这里有几个回答提到了APCs(异步过程调用),说它们只能在进程处于“可警示状态”时传递,并且与Unix信号处理程序相比不是真正的异步执行。

Windows 10版本1809引入了特殊用户APC,可以像Unix信号一样随时触发。有关低级详细信息,请参见此文章

特殊用户APC是在RS5中添加的一种机制(通过NtQueueApcThreadEx公开),但最近(在内部版本中)通过新的系统调用 - NtQueueApcThreadEx2公开。如果使用此类型的APC,则在线程执行中间会向线程发送信号以执行特殊的APC。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接