14

对于 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...

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

尽管确实需要将此值保留在堆栈上(重入问题、在PUSHing 和读回值之间的函数调用等),但是否有任何根本原因导致像这样写入 ESP 下方的堆栈无效?

4

5 回答 5

13

TL:DR: 不,有一些 SEH 极端案例在实践中可能使其不安全,并且被记录为不安全。 @Raymond Chen 最近写了一篇博文,您可能应该阅读它而不是这个答案。

他的代码获取页面错误 I/O 错误示例可以通过提示用户插入 CD-ROM 并重试来“修复”,这也是我对唯一实际可恢复的错误的结论,如果没有任何其他错误在 ESP/RSP 下存储和重新加载之间可能存在错误指令。

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

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



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

但是对于堆栈上的少量暂存空间还有其他用例,最好保存一些抵消 ESP/RSP 的指令。


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

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

堆栈使用(对于 x64)

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

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

硬件中断不能使用用户栈;这将使用户空间使内核崩溃mov esp, 0,或者更糟的是通过在中断处理程序运行时让用户空间进程中的另一个线程修改返回地址来接管内核。这就是为什么内核总是配置一些东西,以便将中断上下文推送到内核堆栈上。

现代调试器在单独的进程中运行,并且不是“侵入式”的。回到 16 位 DOS 时代,没有多任务受保护内存操作系统来为每个任务提供自己的地址空间,调试器将在单步执行时在任意两条指令之间使用与被调试程序相同的堆栈。

@RossRidge 指出,调试器可能希望让您在当前线程的上下文中调用函数,例如 with 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(结构化异常处理)是您在当前32 位和 64 位 Windows上为用户空间代码建议的唯一真正障碍。 (但未来的 Windows 可能会包含一个新功能。)我猜如果你碰巧要求你的调试器在目标进程/线程中调用一个函数,如上所述。

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


SEH(结构化异常处理)让用户空间软件具有硬件异常,例如除以零,交付方式与 C++ 异常有些相似。这些并不是真正的异步:它们是针对由您运行的指令触发异常,而不是针对发生在某些随机指令之后的事件。

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

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

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

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

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

一个 SEH 处理程序STATUS_IN_PAGE_ERROR可以在任何加载指令之前运行 Windows 可以分页它想要的任何页面,并在再次需要时透明地分页(虚拟内存分页)。但是,如果出现 I/O 错误,您的 Windows 会尝试让您的进程通过提供一个STATUS_IN_PAGE_ERROR

同样,如果这发生在当前堆栈上,我们就会被淹没。

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

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

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

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


您认为在当前 Windows 中可能存在问题的事情,以及为什么不是:

  • ESP 下方的保护页内容:只要您不低于当前 ESP 太远,您就会接触到保护页并触发更多堆栈空间的分配,而不是出错。这很好,只要内核不检查用户空间 ESP 并发现您在没有先“保留”它的情况下接触堆栈空间。

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

  • APC(异步过程调用):它们只能在进程处于“警报状态”时交付,这意味着只有在call一个函数内部时,如SleepEx(0,1). call一个函数已经在 E/RSP 下面使用了未知数量的空间,所以你已经必须假设每个都call破坏了堆栈指针下面的所有内容。因此,这些“异步”回调对于正常执行而言并不是真正异步的,就像 Unix 信号处理程序那样。(有趣的事实:POSIX async io 确实使用信号处理程序来运行回调)。

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

  • SetThreadContext: 当这个线程被挂起时,另一个线程可以异步地改变我们的 EIP/RIP,但是整个程序必须专门为此编写才有意义。除非它是使用它的调试器。当一些其他线程在你的 EIP 中乱作一团时,通常不需要正确性,除非情况得到很好的控制。

显然,没有其他方法可以让另一个进程(或该线程注册的东西)触发与 Windows 上用户空间代码执行异步执行的任何操作。

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

显然,任何会同步破坏它的东西(如 a call)也必须避免,这与在 x86-64 System V 调用约定中使用 red-zone 时相同。(有关更多信息,请参阅https://stackoverflow.com/tags/red-zone/info。)

于 2018-09-11T00:46:23.693 回答
7

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


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

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

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

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

所以尽管正式,这将在 Windows 用户模式下正确工作,更好(并且不需要)使用它


当然正式文件说:

堆栈使用

所有超出 RSP 当前地址的内存都被认为是易失的

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


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

于 2018-09-10T13:16:25.643 回答
6

一般情况下(与任何操作系统无关);在以下情况下,写在 ESP 下面是不安全的:

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

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

  • 其他东西取决于“正常”堆栈使用。这可以包括信号处理、(基于语言的)异常展开、调试器、“堆栈粉碎保护器”

如果它不是“不安全”,那么写在 ESP 下面是安全的。

请注意,对于 64 位代码,在 RSP 下方写入已内置到 x86-64 ABI(“红色区域”)中;并且通过在工具链/编译器和其他所有东西中对它的支持而变得安全。

于 2018-09-10T13:15:32.933 回答
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。使用软件定义的异常(throw在 VC++ 中)时会发生类似的情况。

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

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

于 2018-09-10T20:44:18.460 回答
2

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

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

特殊用户 APC 是在 RS5 中添加的一种机制(并通过 NtQueueApcThreadEx 公开),但最近(在内部构建中)通过新的系统调用 - NtQueueApcThreadEx2 公开。如果使用这种类型的 APC,线程会在执行过程中收到信号以执行特殊的 APC。

于 2020-09-19T08:51:18.493 回答