写在 ESP 以下有效吗?

Is it valid to write below ESP?

对于 32 位 windows 应用程序,在不显式递减 ESP 的情况下使用低于 ESP 的堆栈内存进行临时交换 space 是否有效?

考虑一个 returns ST(0) 中的浮点值的函数。例如,如果我们的值当前在 EAX 中,我们会

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

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

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

除了在第一种情况下我们注意在使用内存之前递减堆栈指针,然后在之后递增它之外,这两种情况都会发生相同的事情。在后一种情况下,我们不这样做。

尽管确实需要将这个值保存在堆栈上(重入问题、PUSHing 和读回值之间的函数调用等),是否有任何根本原因为什么要写入 ESP 下面的堆栈,例如这将是无效的?

一般(与任何 OS 没有具体关系);在以下情况下写在 ESP 以下是不安全的:

  • 代码可能会被中断,中断处理程序将运行处于相同的特权级别。注意:这对于 "user-space" 代码来说通常不太可能,但对于内核代码来说极有可能。

  • 您调用任何其他代码(其中 call 或被调用例程使用的堆栈会破坏您存储在 ESP 下的数据)

  • 其他取决于 "normal" 堆栈使用。这可以包括信号处理、(基于语言的)异常展开、调试器、"stack smashing protector"

如果不是 "not safe",写在 ESP 以下是安全的。

请注意,对于 64 位代码,在 RSP 下方编写是内置于 x86-64 ABI 中的 ("red zone");并通过工具 chains/compilers 和其他所有工具对它的支持而变得安全。

在一般情况下(x86/x64 平台)- 中断可以随时执行,它会覆盖堆栈指针下方的内存(如果它在当前堆栈上执行)。因为这个,即使是临时保存在堆栈指针下面的东西,在内核模式下也是无效的——中断将使用当前的内核堆栈。但在用户模式情况下,另一个 - windows 构建中断 table (IDT) 这样当中断引发时 - 它将始终在内核模式和内核堆栈中执行。结果,用户模式堆栈(堆栈指针下方)将不受影响。并且可能临时使用一些堆栈 space 低于它的指针,直到你不做任何函数调用。如果异常将是(比如通过访问无效地址) - space 波纹管堆栈指针将被覆盖 - cpu 异常当然开始在内核模式和内核堆栈中执行,但比内核在用户中执行回调 space 通过 ntdll.KiDispatchExecption 已经在当前堆栈 space 上。所以一般来说这在 windows 用户模式(在当前实现中)是有效的,但你需要很好地理解你在做什么。但是我认为这很少使用


当然,我们可以在 windows 用户模式 ​​ 下写在堆栈指针下方的评论中指出的正确性 - 只是当前的实现行为。这没有记录或保证。

但这是非常基本的 - 不太可能改变:中断总是只会在特权内核模式下执行。并且内核模式将仅使用内核模式堆栈。用户模式上下文根本不受信任。如果用户模式程序设置了错误的堆栈指针会怎样?说 mov rsp,1 还是 mov esp,1?并且在这条指令中断之后将被引发。如果它在这样的无效 esp/rsp 上开始执行会怎样?所有操作系统都崩溃了。正是因为这个中断只会在内核堆栈上执行。并且不覆盖用户堆栈 space。

还需要注意堆栈是有限的space(即使在用户模式下),访问它低于 1 页(4Kb)已经错误(需要逐页进行堆栈探测,以便向下移动保护页)。

最后真的不需要通常访问 [ESP-4], EAX - 在什么问题中首先递减 ESP?即使我们需要在循环中访问堆栈 space 大量时间 - 递减堆栈指针只需要一次 - 1 条额外指令(不在循环中)性能或代码大小没有任何变化。

所以尽管正式,这在 windows 用户模式下是正确的,更好(并且不需要)使用它


当然正式文件说:

Stack Usage

All memory beyond the current address of RSP is considered volatile

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


将来可能 windows 并添加 "direct" apc 或一些 "direct" 信号 - 一些代码将在线程进入内核后立即通过回调执行(在通常的硬件中断期间)。在此之后,所有下面的 esp 都将是未定义的。但直到这不存在。直到此代码将始终(在当前版本中)正确工作。

创建线程时,Windows 为线程堆栈保留可配置大小(默认为 1 MB)的连续虚拟内存区域。最初,堆栈看起来像这样(堆栈向下增长):

--------------
|  committed |
--------------
| guard page |
--------------
|     .      |
| reserved   |
|     .      |
|     .      |
|            |
--------------

ESP 将指向已提交页面内的某处。保护页用于支持自动堆栈增长。保留页面区域确保请求的堆栈大小在虚拟内存中可用。

考虑问题中的两条说明:

MOV    [ESP-4], EAX
FLD    [ESP-4]

三种可能:

  • 第一条指令执行成功。两条指令之间没有任何可以使用用户模式堆栈执行的东西。所以第二条指令将使用正确的值(@RbMm 在他的回答下的评论中说明了这一点,我同意)。
  • 第一条指令引发异常,异常处理程序不会 return EXCEPTION_CONTINUE_EXECUTION。只要第二条指令紧跟在第一条指令之后(它不在异常处理程序中或放在它之后),那么第二条指令就不会执行。所以你还是安全的。从存在异常处理程序的堆栈帧继续执行。
  • 第一条指令引发异常和异常处理程序 returns EXCEPTION_CONTINUE_EXECUTION。从引发异常的同一指令继续执行(可能使用处理程序修改的上下文)。在此特定示例中,将重新执行第一个以在 ESP 以下写入一个值。没问题。如果第二条指令引发了异常或者有两条以上的指令,那么异常可能发生在ESP下方写入值之后的位置。当调用异常处理程序时,它可能会覆盖该值,然后 return EXCEPTION_CONTINUE_EXECUTION。但是当执行恢复时,假设写入的值仍然存在,但它不再存在了。这种情况写在 ESP 以下是不安全的。即使所有指令都是连续放置的,这也适用。感谢@RaymondChen 指出这一点。

一般来说,如果两条指令没有背靠背放置,如果您要写入超出 ESP 的位置,则无法保证写入的值不会被损坏或覆盖。我能想到的一种可能发生这种情况的情况是结构化异常处理 (SEH)。如果发生硬件定义的异常(例如被零除),内核异常处理程序将在内核模式下被调用(KiUserExceptionDispatcher),这将调用处理程序的用户模式端(RtlDispatchException).当从用户模式切换到内核模式然后再回到用户模式时,ESP 中的任何值都将被保存和恢复。但是,用户模式处理程序本身使用用户模式堆栈,并将遍历已注册的异常处理程序列表,每个异常处理程序都使用用户模式堆栈。这些函数将根据需要修改 ESP。这可能会导致丢失您写入的超出 ESP 的值。使用软件定义异常时会出现类似情况(throw in VC++)。

我认为您可以通过在任何其他异常处理程序之前注册您自己的异常处理程序来处理这个问题(以便首先调用它)。当您的处理程序被调用时,您可以将数据保存在 ESP 之外的其他地方。稍后,在展开期间,您有机会 cleanup 将数据恢复到堆栈中的相同位置(或任何其他位置)。

您还需要同样注意异步过程调用 (APC) 和回调。

TL:DR: 不,有一些 SEH 极端情况会使其在实践中变得不安全,并且被记录为不安全。 @Raymond Chen recently wrote a blog post 您可能应该阅读而不是这个答案。

他的代码获取页面错误 I/O 错误示例可以 "fixed" 提示用户插入 CD-ROM 并重试,这也是我对唯一实际的结论 -如果在 ESP/RSP.

以下的存储和重新加载之间没有任何其他可能出错的指令,则可恢复故障

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

这个答案列出了一些您认为可能会踩到低于 ESP 的内存但实际上不会踩到内存的东西,这可能很有趣。似乎只有 SEH 和调试器在实践中可能是个问题。



首先,如果你关心效率,你不能在你的调用约定中避免 x87 吗? movd xmm0, eax 是 return 整数寄存器中的 float 的更有效方法。 (而且您通常可以首先避免将 FP 值移动到整数寄存器,使用 SSE2 整数指令为 log(x) 挑选指数/尾数,或为 nextafter(x) 挑选整数加 1。)但是如果你需要支持非常旧的硬件,那么您需要一个 32 位 x87 版本的程序以及一个高效的 64 位版本。

但是还有其他用例用于堆栈上的少量划痕 space,最好保存一些偏移 ESP/RSP.

的指令

试图收集其他答案的综合智慧并在它们下面的评论中讨论(以及关于这个答案):

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

Stack Usage (for x64)

All memory beyond the current address of RSP is considered volatile: The OS, or a debugger, may overwrite this memory during a user debug session, or an interrupt handler.

这就是文档,但所述中断原因对用户-space 堆栈没有意义,只有内核堆栈才有意义。重要的是他们将其记录为 not gua运行teed 安全,而不是给出的原因。

硬件中断不能使用用户栈;这会让 user-space 使内核崩溃 mov esp, 0,或者更糟的是让 user-space 进程中的另一个线程在中断时修改 return 地址来接管内核处理程序是 运行ning。这就是内核总是配置一些东西以便将中断上下文推送到内核堆栈的原因。

现代调试器 运行 在一个单独的进程中,而不是 "intrusive"。回到 16 位 DOS 时代,没有多任务保护内存 OS 来为每个任务分配自己的地址 space,调试器将使用与被调试程序相同的堆栈, 单步执行时任意两条指令之间。

@RossRidge 指出 调试器可能想让您在当前线程的上下文中调用函数 ,例如SetThreadContext。这将 运行 和 ESP/RSP 刚好低于当前值。这显然会对正在调试的进程产生副作用(用户 运行 宁调试器是有意的),但是在 ESP/RSP 以下破坏当前函数的局部变量将是不受欢迎和意外的副作用。 (所以编译器不能把它们放在那里。)

(在红色区域低于 ESP/RSP 的调用约定中,调试器可以通过在进行函数调用之前递减 ESP/RSP 来遵守该红色区域。)

有些现有程序在调试时会故意中断,并认为这是一个功能(以抵御对它们进行逆向工程的努力)。


相关:x86-64 System V ABI(Linux,OS X,所有其他非Windows systems) does defined a for user-space code (64-bit only): 128 bytes below RSP that is gua运行teed not be asynchronously破坏了。 Unix 信号处理程序可以在任意两个 user-space 指令之间异步 运行,但内核通过在旧 user-space RSP 下方留出 128 字节的间隙来尊重红区,以防万一它正在使用中。在没有安装信号处理程序的情况下,即使在 32 位模式下(ABI not gua运行tee a red-zone,你也有一个有效的无限红区。编译器生成的代码,或库代码,当然不能假设整个程序(或程序调用的库)中没有其他任何东西都安装了信号处理程序。

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

据我们所知,SEH (Structured Exception Handling) 是您为 current[=156 上的用户space 代码提出的唯一真正障碍=] 32 位和 64 位 Windows.(但未来 Windows 可能包含新功能。) 而且我想调试如果你碰巧要求你的调试器调用目标 process/thread 中的函数,如上所述。

在这种特定情况下,不接触堆栈以外的任何其他内存,或做任何其他可能出错的事情,即使是 SEH 也可能是安全的。


SEH(结构化异常处理)让用户-space 软件有硬件异常,比如被零除,在某种程度上类似于 C++ 异常。这些并不是真正的异步:它们是针对 指令触发的异常 运行,而不是针对某些 运行dom 指令之后发生的事件。

但与普通异常不同的是,SEH 处理程序可以做的一件事是从发生异常的地方恢复。 (@RossRidge 评论:SEH 处理程序最初是在展开堆栈的上下文中调用的,并且可以选择忽略异常并在异常发生的地方继续执行。)

所以即使当前函数中没有 catch() 子句,这也是一个问题。

一般硬件异常只能同步触发。例如通过 div 指令,或通过可能出现 STATUS_ACCESS_VIOLATION 错误的内存访问(Windows 相当于 Linux SIGSEGV 分段错误)。您可以控制使用的指令,因此可以避免 可能 出错的指令。

如果您将代码限制为仅在存储和重新加载之间访问堆栈内存,并且尊重堆栈增长保护页面,您的程序将不会因访问 [esp-4] 而出错。 (除非您达到最大堆栈大小(堆栈溢出),在这种情况下 push eax 也会出错,并且您无法真正从这种情况中恢复,因为没有可供 SEH 使用的堆栈 space。 )

所以我们可以排除 STATUS_ACCESS_VIOLATION 的问题,因为如果我们在访问堆栈内存时得到它,我们无论如何都会被清理。

STATUS_IN_PAGE_ERROR could run before any load instruction.Windows 的 SEH 处理程序可以调出它想要的任何页面,并且 运行 可以将其调回如果再次需要它(虚拟内存分页)。但是,如果出现 I/O 错误,您的 Windows 会尝试通过传递 STATUS_IN_PAGE_ERROR

让您的进程处理故障

再一次,如果当前堆栈发生这种情况,我们就完蛋了。

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

代码中的 I/O 错误分页想要读取我们存储在 ESP 下的内容,这排除了读取它的任何机会。如果您无论如何都不打算这样做,那也没关系。 不知道这段特定代码的通用 SEH 处理程序无论如何都不会尝试这样做。我认为通常 STATUS_IN_PAGE_ERROR 最多会尝试打印一条错误消息或记录一些东西,而不是尝试进行任何正在发生的计算。

在存储和重新加载到低于 ESP 的内存之间访问其他内存可能会触发 that 内存的 STATUS_IN_PAGE_ERROR。在库代码中,您可能不能假设您传递的其他一些指针不会很奇怪并且调用者期望为它处理 STATUS_ACCESS_VIOLATION 或 PAGE_ERROR。

当前的编译器不利用 space below ESP/RSP on Windows,即使它们 利用了红色-x86-64 System V 中的区域(在需要溢出/重新加载某些东西的叶函数中,就像您为 int -> x87 所做的一样。)那是因为 MS 说它不安全,而且他们不知道是否存在可以在 SEH 之后尝试恢复的 SEH 处理程序。


您认为在当前 Windows 中可能存在问题的事情及其原因:

  • ESP 下方的保护页内容:只要不低于当前 ESP 太远,就会触及保护页并触发分配更多堆栈 space 而不是犯错。这很好,只要内核不检查 user-space ESP 并发现您正在触摸堆栈 space 而没有先 "reserved" 它。

  • 以下页面的内核回收 ESP/RSP:显然 Windows 目前没有这样做。因此,一旦使用大量堆栈 space 将在剩余的进程生命周期 中分配这些页面。 (不过,内核将 允许 这样做,因为文档说 RSP 以下的内存是易失性的。如果内核愿意,它可以有效地将其异步归零,写时复制映射它到一个零页面而不是在内存压力下将它写入页面文件。)

  • APC (Asynchronous Procedure Calls):它们只能在进程处于 "alertable state" 中时传递,这意味着只有在 call 中传递给函数喜欢 SleepEx(0,1)call 函数已经在 E/RSP 下面使用了未知数量的 space,因此您已经必须假设每个 call 都会破坏堆栈指针下方的所有内容。因此,这些 "async" 回调相对于 Unix 信号处理程序的正常执行而言并不是真正的异步。 (有趣的事实:POSIX 异步 io 确实使用信号处理程序来 运行 回调)。

  • ctrl-C 和其他事件的控制台应用程序回调 (SetConsoleCtrlHandler). This looks exactly like registering a Unix signal handler, but in Windows the handler runs in a separate thread with its own stack. ()

  • SetThreadContext:另一个线程可以在该线程挂起时异步更改我们的 EIP/RIP,但必须专门为此编写整个程序才有意义。除非它是使用它的调试器。除非情况非常可控,否则当某些其他线程正在扰乱您的 EIP 时,通常不需要正确性。

而且显然没有其他方法可以让另一个进程(或这个线程注册的东西)触发异步执行任何关于 Windows 上的用户 space 代码的执行。 =46=]

如果没有可以尝试恢复的 SEH 处理程序,Windows 或多或少在 ESP 下方有一个 4096 字节的红色区域(或者如果你逐渐触摸它可能更多?),但 RbMm 说没有人在实践中利用它。这不足为奇,因为 MS 说不要这样做,而且您不能总是知道您的调用者是否可能对 SEH 做了什么。

显然,任何会同步破坏它的东西(比如call)也必须避免,再次与在 x86-64 中使用红色区域时一样系统 V 调用约定。 (有关更多信息,请参阅 https://whosebug.com/tags/red-zone/info。)

这里的几个答案提到了 APC(异步过程调用),说它们只能在进程处于“可警告状态”时传递,并且相对于 Unix 信号处理程序的正常执行而言并不是真正的异步

Windows10版本1809引入特殊用户APCs,可以像Unix信号一样随时触发。有关底层详细信息,请参阅 this article

The Special User APC is a mechanism that was added in RS5 (and exposed through NtQueueApcThreadEx), but lately (in an insider build) was exposed through a new syscall - NtQueueApcThreadEx2. If this type of APC is used, the thread is signaled in the middle of the execution to execute the special APC.