【问题标题】:Is it valid to write below ESP?写在ESP下面是否有效?
【发布时间】:2019-02-14 22:10:58
【问题描述】:

对于 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 下方的堆栈无效?

【问题讨论】:

  • 在windows用户模式下这个有效,因为中断从不使用用户模式堆栈,在内核模式下这个无效——因为中断可以随时执行
  • 为什么不按设计使用堆栈指针?有点堆栈和堆栈指针的全部意义,因此您可以轻松分配本地内存。为什么不这样做呢?我的评论与x86无关,一般按设计使用处理器。
  • @old_timer 原因并不相关——例如,假设它的性能更高。或者想象你在现有的代码库中发现了类似的东西。问题是关于正确性和记录在案的行为(你会“修复”它吗?它真的坏了吗?)。当然,我们希望尽可能地惯用,但我想在这里区分正确和惯用。
  • 带有一个约束列表确保您不必移动堆栈指针,但该列表可能会崩溃,这肯定是一个非常坏的习惯。该列表是什么应该很明显。
  • 可能在未来的窗口中添加“直接” apc 或一些“直接”信号 - 一些代码将在线程进入内核后通过回调执行(在通常的硬件中断期间)。在此之后,esp 以下的所有内容都将是未定义的。但直到这不存在。直到此代码始终(在当前版本中)正确工作。

标签: windows assembly x86 abi stack-pointer


【解决方案1】:

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

p>

Windows 10 版本 1809 引入了特殊用户 APC,它可以像 Unix 信号一样随时触发。请参阅this article 了解底层细节。

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

【讨论】:

    【解决方案2】:

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

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

    或者如果你让调试器调用被调试程序中的一个函数,它也会使用目标进程的堆栈。

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



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

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


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

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

    Stack Usage(适用于 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 处理程序最初是在展开堆栈的上下文中调用的,并且可以选择忽略异常并在发生异常的点继续执行。)

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

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

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

    所以我们可以排除 STATUS_ACCESS_VIOLATION 是一个问题,因为如果我们在访问堆栈内存时遇到了这个问题,我们无论如何都会被淹没。

    STATUS_IN_PAGE_ERROR could run before any load instruction 的 SEH 处理程序。 Windows 可以分页出它想要的任何页面,并在再次需要时透明地分页(虚拟内存分页)。但如果出现 I/O 错误,您的 Windows 会尝试通过提供 STATUS_IN_PAGE_ERROR

    让您的进程处理故障

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

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

    代码中的 I/O 错误分页想要读取我们存储在 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 所做的那样。)那是因为 MS 说它不安全,而且他们不知道是否存在可以尝试在 SEH 后恢复的 SEH 处理程序。


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

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

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

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

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

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

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

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

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

    【讨论】:

    • 如果调试器想要在给定线程的上下文中强制异常或函数调用,它需要能够将东西压入堆栈。 SEH 处理程序最初是在展开堆栈的上下文中调用的,并且可以选择忽略异常并在异常发生的点继续执行。最后,另一个进程可以通过 SetThreadContext 触发用户空间代码的异步执行。
    • 系统可以随时注入结构化异常,如STATUS_IN_PAGE_ERRORSTATUS_ACCESS_VIOLATION
    • @HadiBrais 结构化异常处理程序也可以请求恢复。只需返回EXCEPTION_CONTINUE_EXECUTION 以重新启动失败的指令(可能是在尝试修复问题之后)。
    • Agner 建议如果“内存极低”,系统可能会丢弃低于 ESP 的页面。您提到这在当前系统上不会发生。我不知道谁是对的,但我想我会提到它。我找到了here 的引用,但也许他在其他地方更多地谈论它。
    • Windows 10 版本 1809 引入了特殊用户 APC,它可以像 UNIX 信号一样随时触发。见repnz.github.io/posts/apc/user-apc/…
    【解决方案3】:

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

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

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

    考虑问题中的两条说明:

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

    有三种可能:

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

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

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

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

    【讨论】:

      【解决方案4】:

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


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

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

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

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

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


      当然是正式的文件说:

      Stack Usage

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

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


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

      【讨论】:

        【解决方案5】:

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

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

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

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

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

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

        【讨论】:

        • 这个问题是关于 Windows 的。 Linux/MacOS 有一个红色区域,因为它是 x86-64 System V ABI 的一部分。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-09-22
        • 2018-09-30
        • 1970-01-01
        • 2010-10-12
        相关资源
        最近更新 更多