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位)定义了一个red-zone: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。)
esp
都将是未定义的。但在此之前不存在。在当前版本中,直到这个代码工作始终正确。 - RbMm