对于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下的堆栈无效吗?
答案 0 :(得分:8)
TL:DR:不,有一些SEH极端案例可能使它在实践中不安全,并被记录为不安全。 @Raymond Chen recently wrote a blog post,您可能应该阅读而不是此答案。
他的代码提取页错误I / O错误示例,可以通过提示用户插入CD-ROM并重试来“修复”,这也是我对于唯一可以在实际上恢复的故障(如果没有)的结论。在ESP / RSP下的存储和重新加载之间还有其他可能出错的说明。
或者,如果您要求调试器在要调试的程序中调用一个函数,它也会使用目标进程的堆栈。
此答案列出了一些您认为可能会踩到ESP以下的内存但实际上没有的东西,这可能很有趣。在实践中似乎只有SEH和调试器会出现问题。
首先,如果您关心效率,是否可以避免在通话习惯中使用x87? movd xmm0, eax
是返回整数寄存器中的float
的更有效方法。 (而且通常可以避免使用SSE2整数指令首先将FP值移至整数寄存器,而使用SSE2整数指令为log(x)
取指数/尾数,或为nextafter(x)
取整数加1。)但是如果您需要支持非常旧的硬件,那么您需要程序的32位x87版本以及高效的64位版本。
但是在堆栈上还有其他一些用于少量暂存空间的用例,可以很好地保存一些抵消ESP / RSP的指令。
尝试收集其他答案和讨论的综合智慧,并在其下的评论中(以及对此答案):
Microsoft明确将其记录为不安全:(对于64位代码,我没有找到等效的32位代码声明,但我我肯定有一个)
Stack Usage(对于x64)
超出RSP当前地址的所有内存都被视为易失性:操作系统或调试器可能会在用户调试会话或中断处理程序中覆盖此内存。
这就是文档,但是声明的中断原因对于用户空间堆栈没有意义,仅对内核堆栈没有意义。重要的是,他们将其记录为不能保证安全,而不是给出的原因。
硬件中断不能使用用户堆栈。这会使用户空间使用mov esp, 0
使内核崩溃,或者更糟糕的是,在中断处理程序运行时,让用户空间进程中的另一个线程修改返回地址来接管内核。这就是内核始终配置事物以便将中断上下文推送到内核堆栈的原因。
现代调试器在单独的进程中运行,并且不是“侵入式”的。回到16位DOS时代,没有多任务保护内存的操作系统来给每个任务分配自己的地址空间,调试器将在单步执行时在任意两条指令之间使用与被调试程序相同的堆栈。
@RossRidge指出调试器可能希望让您在当前线程的上下文中调用函数,例如与SetThreadContext
。这将在ESP / RSP刚好低于当前值的情况下运行。显然,这可能会对正在调试的进程产生副作用(这是运行调试器的用户的故意行为),但是,将当前函数的局部变量破坏在ESP / RSP以下将是不希望的且出乎意料的副作用。 (因此,编译器无法将它们放在那里。)
(在红色区域位于ESP / RSP之下的调用约定中,调试器可以在进行函数调用之前通过减小ESP / RSP来遵守该红色区域。)
现有的程序在调试时会故意中断,并认为这是一项功能(以防止对它们进行反向工程)。
相关:x86-64系统V ABI(Linux,OS X,所有其他非Windows 系统)确实为用户定义了red-zone空间代码(仅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下方的保护页面内容:只要您不距离当前ESP太远,就会触及保护页面并触发分配更多堆栈空间,而不是出错。只要内核不检查用户空间ESP并发现您正在触摸堆栈空间而不先“保留”它就可以了。 ESP / RSP下的页面的内核回收:显然Windows当前不这样做。因此,一次使用大量堆栈空间将使这些页面在整个过程生命周期unless you manually APC(Asynchronous Procedure Calls):仅当进程处于“可更改状态”时才可以传递它们,这意味着仅当在 用于ctrl-C和其他事件的控制台应用程序回调( 显然,对于Windows上用户空间代码的执行,另一个进程(或该线程注册的内容)没有其他方法可以异步触发任何事情的执行。 如果没有SEH处理程序可以尝试恢复,则Windows或多或少在ESP下有一个4096字节的红色区域(或者如果您逐渐触摸它,可能会有更多的红色区域?),但是RbMm说没有人在实践中利用它。这不足为奇,因为MS拒绝这样做,而且您无法始终知道您的呼叫者是否使用SEH做过某些事情。 显然,也必须避免任何会同步破坏它的东西(例如
VirtualAlloc(MEM_RESET)
them中分配。 (不过,允许内核执行 这样的操作,因为文档说RSP下的内存是易失性的。如果需要,内核可以有效地异步将其归零,然后将其写复制映射到零页,而不是在内存压力下将其写入页面文件。)call
内到{{1} }。 SleepEx(0,1)
使用一个函数已经在E / RSP下使用了未知数量的空间,因此您已经必须假设每个call
都会破坏堆栈指针下的所有内容。因此,相对于Unix信号处理程序的正常执行,这些“异步”回调并不是真正的异步。 (有趣的事实:POSIX async io确实使用信号处理程序来运行回调)。SetConsoleCtrlHandler
)。这看上去完全像注册Unix信号处理程序一样,但是在Windows中,该处理程序在具有自己堆栈的单独线程中运行。 (See RbMm's comment)call
:当该线程挂起时,另一个线程可以异步更改我们的EIP / RIP,但是为了使它有意义,必须专门编写整个程序。除非它是使用它的调试器。除非情况得到很好的控制,否则当其他一些线程使您的EIP混乱时,通常不需要正确性。SetThreadContext
),再次与在x86-64 System V调用约定中使用红色区域时相同。 (有关更多信息,请参见https://stackoverflow.com/tags/red-zone/info。)
答案 1 :(得分:7)
在一般情况下( x86 / x64 平台)-中断可以随时执行,它会覆盖下面的内存堆栈指针(如果它在当前堆栈上执行)。因此,即使是临时保存一些波纹管堆栈指针,在内核模式下也无效-中断将使用当前的内核堆栈。但是在用户模式下,另一个-Windows会以这样的方式构建中断表(IDT):当引发中断时-它将始终在内核模式和内核堆栈中执行。结果,用户模式堆栈(位于堆栈指针下方)将不受影响。并可能临时使用一些位于它下面的堆栈空间的指针,直到您不执行任何函数调用为止。如果发生异常(例如访问无效地址)-空间波纹管指针也将被覆盖-CPU异常当然开始在内核模式和内核堆栈中执行,但是内核已经通过ntdll.KiDispatchExecption
在用户空间中执行回调当前堆栈空间。因此,通常这在Windows用户模式下(在当前实现中)有效,但是您需要很好地了解自己在做什么。但是我认为这很少使用
当然,在Windows 用户模式中,我们可以在注释中指出的正确性如下:没有记录或保证。
但这是非常基本的-不太可能改变:中断总是仅在特权内核模式下执行。并且内核模式将仅使用内核模式堆栈。用户模式上下文根本不受信任。如果用户模式程序设置的堆栈指针不正确怎么办?说
mov rsp,1
或mov esp,1
?在此指令之后,将引发中断。如果开始在这种无效的esp / rsp上执行该怎么办?所有操作系统都崩溃了。正是因为此中断将仅在内核堆栈上执行。并且不覆盖用户堆栈空间。
还需要注意,堆栈空间有限(即使在用户模式下也是如此),请在1页以下访问它(4Kb),但已发生错误(需要逐页进行堆栈探测,以便向下移动保护页)。
最后,实际上实际上通常不需要访问[ESP-4], EAX
-首先减少什么问题ESP
?即使我们需要在循环中访问堆栈空间也要花费大量时间-递减堆栈指针只需要执行一次-1条附加指令(不在循环中),性能或代码大小都不会改变。
因此,尽管正式,这在Windows用户模式下仍是正确的工作,更好(且不需要)使用此功能
当然,正式文件说:
超出RSP当前地址的所有内存都被视为易失性
但是这是常见的情况,包括内核模式。我写了关于用户模式并基于当前实现的
可能会在将来的Windows中添加“直接” apc或一些“直接”信号-线程进入内核后(在通常的硬件中断期间),将通过回调执行一些代码。在此之后,尤其是在esp以下将是未定义的。但直到不存在为止。直到此代码始终有效(在当前版本中)之前。
答案 2 :(得分:5)
通常(与任何操作系统无关);在以下情况下,在ESP之下编写代码是不安全的:
代码可能会被中断,并且中断处理程序将以相同的特权级别运行。注意:对于“用户空间”代码来说,这通常是不太可能的,但对于内核代码来说,这是极有可能的。
您调用任何其他代码(call
或被调用例程使用的堆栈可能会破坏您存储在ESP下的数据)
其他情况取决于“常规”堆栈的使用。这可以包括信号处理,(基于语言的)异常展开,调试器,“堆栈粉碎保护程序”
如果不是“不安全”的话,可以在ESP之下写。
请注意,对于64位代码,x86-64 ABI(“红色区域”)内置了以下RSP编写功能;并通过在工具链/编译器以及其他所有方面的支持而变得安全。
答案 3 :(得分:3)
创建线程后,Windows会为线程的堆栈保留虚拟内存的连续区域,该区域的大小可配置(默认值为1 MB)。最初,堆栈看起来像这样(堆栈向下增长):
--------------
| committed |
--------------
| guard page |
--------------
| . |
| reserved |
| . |
| . |
| |
--------------
ESP
将指向已提交页面内的某处。保护页面用于支持自动堆栈增长。保留页区域可确保请求的堆栈大小在虚拟内存中可用。
考虑问题中的两条说明:
MOV [ESP-4], EAX
FLD [ESP-4]
有三种可能性:
EXCEPTION_CONTINUE_EXECUTION
。只要第二条指令紧跟在第一条指令之后(它不在异常处理程序中或不在其后),那么第二条指令将不会执行。因此,您仍然很安全。从异常处理程序所在的堆栈帧继续执行。EXCEPTION_CONTINUE_EXECUTION
。从引发异常的同一条指令继续执行(可能使用处理程序修改的上下文)。在此特定示例中,第一个将被重新执行以写入低于ESP
的值。没问题。如果第二条指令引发异常,或者有两条以上指令,则在将值写入ESP
下之后,该异常可能会发生。当异常处理程序被调用时,它可能会覆盖该值,然后返回EXCEPTION_CONTINUE_EXECUTION
。但是,当执行恢复时,假定写入的值仍然存在,但现在不再存在。在这种情况下,写在ESP
下面是不安全的。即使所有指令都连续放置,这也适用。感谢@RaymondChen指出这一点。通常,如果没有紧接放置这两个指令,则如果要写入ESP
之后的位置,则不能保证写入的值不会被损坏或覆盖。我可以想到的一种情况是结构化异常处理(SEH)。如果发生硬件定义的异常(例如被零除),则将在内核模式下调用(KiUserExceptionDispatcher
)内核异常处理程序,这将调用处理程序({{1})的用户模式端}。从用户模式切换到内核模式,然后又回到用户模式时,RtlDispatchException
中的任何值都将被保存和恢复。但是,用户模式处理程序本身使用用户模式堆栈,并将遍历已注册的异常处理程序列表,每个异常处理程序都使用用户模式堆栈。这些功能将根据需要修改ESP
。这可能会导致丢失您在ESP
之后编写的值。使用软件定义的异常(在VC ++中为ESP
)时,也会发生类似的情况。
我认为您可以通过在其他任何异常处理程序之前注册您自己的异常处理程序来处理此问题(因此首先调用它)。调用处理程序后,您可以将数据保存到throw
之外的其他地方。稍后,在展开期间,您将获得cleanup的机会将数据还原到堆栈上的相同位置(或任何其他位置)。
您还需要类似地注意异步过程调用(APC)和回调。
答案 4 :(得分:2)
这里有几个答案提到了APC(异步过程调用),说它们仅在进程处于“可更改状态”时才可以交付,并且相对于Unix信号处理程序的正常执行而言并不是真正的异步>
Windows 10版本1809引入了特殊用户APC,可以像Unix信号一样随时触发。有关低级详细信息,请参见this article。
特殊用户APC是一种在RS5中添加的机制(并通过NtQueueApcThreadEx公开),但是最近(在内部构建中)是通过新的系统调用NtQueueApcThreadEx2公开的。如果使用这种类型的APC,则在执行过程中向线程发出信号以执行特殊的APC。