2023年1月

简介

STATUS_INVALID_CRUNTIME_PARAMETER,值为0xC0000417,又称CRT参数无效异常,其定义如下:

//
// MessageId: STATUS_INVALID_CRUNTIME_PARAMETER
//
// MessageText:
//
// An invalid parameter was passed to a C runtime function.
//
#define STATUS_INVALID_CRUNTIME_PARAMETER ((NTSTATUS)0xC0000417L)    // winnt

说明

此异常主要是在CRT的相关库函数对传进来的参数进行有效性检测而引发的。比如指针参数是否为空,缓冲区大小和传入的长度等。一般都是通过_invalid_parameter_noinfo->_invalid_parameter->_invoke_watson->_call_reportfault抛出。

异常结构填充

ExceptionAddress: 0f2846a9
ExceptionCode: 0xC0000417
ExceptionFlags: 00000001
NumberParameters:0

前面有两个随笔介绍了这两个异常(《异常STATUS_INVALID_PARAMETER(0xC000000D)》和《关于异常STATUS_INVALID_CRUNTIME_PARAMETER(0xC0000417)》),它们都是参数无效的异常,但针对的对象不一样。STATUS_INVALID_CRUNTIME_PARAMETER从这个命名来看,就是针对C/C++运行时库库函数的参数校验的,而STATUS_INVALID_PARAMETER是针对Windows系统服务和内核函数的参数校验。

经常在调试分析dmup时,会看到很多线程栈在函数的后面会带上FPO,如下所示:

00 00eff818 777beb0d ffffffff 00000000 0107a2ec ntdll!NtTerminateProcess+0xc (FPO: [2,0,0])
01 00eff8f0 762c4f32 00000000 77e8f3b0 ffffffff ntdll!RtlExitUserProcess+0xbd (FPO: [Non-Fpo])
02 00eff904 71e74ebc 00000000 00eff958 71e7518e KERNEL32!ExitProcessImplementation+0x12 (FPO: [1,0,0])
03 00eff910 71e7518d 00000000 dd53d81d 00000000 MSVCR120!__crtExitProcess+0x15 (FPO: [Non-Fpo]) (CONV: cdecl) [f:\dd\vctools\crt\crtw32\startup\crt0dat.c @ 774]
04 00eff958 71e751b2 00000000 00000000 00000000 MSVCR120!doexit+0x115 (FPO: [Non-Fpo]) (CONV: cdecl) [f:\dd\vctools\crt\crtw32\startup\crt0dat.c @ 678]
*** WARNING: Unable to verify checksum for ConsoleApplication3.exe
05 00eff96c 01391a02 00000000 a2730342 01391a53 MSVCR120!exit+0xf (FPO: [Non-Fpo]) (CONV: cdecl) [f:\dd\vctools\crt\crtw32\startup\crt0dat.c @ 417]
06 00eff9a4 762c0419 00caa000 762c0400 00effa10 ConsoleApplication3!__tmainCRTStartup+0x114 (FPO: [Non-Fpo]) (CONV: cdecl) [f:\dd\vctools\crt\crtw32\dllstuff\crtexe.c @ 649]
07 00eff9b4 777c66dd 00caa000 65564319 00000000 KERNEL32!BaseThreadInitThunk+0x19 (FPO: [Non-Fpo])
08 00effa10 777c66ad ffffffff 777e53d5 00000000 ntdll!__RtlUserThreadStart+0x2f (FPO: [SEH])
09 00effa20 00000000 01391a53 00caa000 00000000 ntdll!_RtlUserThreadStart+0x1b (FPO: [Non-Fpo])
由于不影响具体问题的分析,一直没关注,今天又看到,且有点闲,就查了相关资料,补齐这块知识。

什么是FPO

要知道答案,你必须回到史前时代。英特尔的8088处理器有一组非常有限的寄存器(我忽略了段寄存器),它们是:

AX BX CX DX IP
SI DI BP SP FLAGS

有了这样一组有限的寄存器,这些寄存器都被指定了特定的用途。AX、BX、CX和DX是“通用”寄存器,SI和DI是“索引”寄存器,SP是“堆栈指针”,BP是“帧指针”,IP是“指令指针”,and FLAGS是一个只读寄存器,它包含若干位,这些位表示处理器当前状态的信息(例如,前一个算术或逻辑指令的结果是0)。
BX、SI、DI和BP寄存器是特殊的,因为它们可以用作“索引”寄存器。索引寄存器对编译器至关重要,因为它们用于通过指针访问内存。换句话说,如果您的结构位于内存中的偏移量0x1234处,则可以将索引寄存器设置为值0x1234并访问相对于该位置的值。例如:

MOV    BX, [Structure]
MOV    AX, [BX]+4

将BX寄存器设置为[Structure]指向的内存值,并将AX的值设置为相对于该结构开头的第4个字节处的字。
需要注意的一点是,SP寄存器不是索引寄存器。这意味着要访问堆栈上的变量,需要使用不同的寄存器,这就是BP寄存器的来源-BP寄存器专门用于访问堆栈上的值。
当386出现时,他们将各种寄存器扩展到32位,并且他们修正了只有BX、SI、DI和BP可以用作索引寄存器的限制。

EAX EBX ECX EDX EIP
ESI EDI EBP ESP FLAGS

这是件好事,突然之间,编译器可以使用其中的6个,而不是被限制在3个索引寄存器中。
因为索引寄存器是用来访问结构的,所以对于编译器来说,它们就像黄金一样——更多的索引寄存器是一件好事,而获得更多索引寄存器几乎是值得的。
一些非常聪明的人意识到,由于ESP现在是一个索引寄存器,EBP寄存器不再需要专门用于访问堆栈上的变量。下面是使用EBP:

MyFunction:
    PUSH    EBP
    MOV     EBP, ESP
    SUB      ESP, <LocalVariableStorage>
    MOV     EAX, [EBP+8]
      :
      :
    MOV     ESP, EBP
    POP      EBP
    RETD

要访问堆栈上的第一个参数(EBP+0是EBP的旧值,EBP+4是返回地址),可以执行以下操作:

MyFunction:
    SUB      SP, <LocalVariableStorage>
    MOV     EAX, [ESP+4+<LocalVariableStorage>]
      :
      :
    ADD     SP, <LocalVariableStorage>
    RETD

突然之间,EBP可以重新利用,并用作另一个通用寄存器!编译人员称这种优化为“帧指针省略(Frame Pointer Omission)”,它的首字母缩写是FPO。FPO是一种优化,它压缩或者省略了在栈上为该函数创建框架指针的过程。这个选项加速了函数调用,因为不需要建立和移除框架指针(ESP,EBP)了。同时,它还解放出了一个寄存器,用来存储使用频率较高的变量。只在IntelCPU的架构上才有这种优化。目前已经讨论过的任何一种调用约定都保存了前一函数中栈的信息(压栈ebp,然后让ebp = esp,再移动esp来保存局部变量)。一个FPO的函数可能会保存前一函数的栈指针(ESP,EBP),但是并不为当前的函数调用设立EBP。相反,他使用EBP来存储一些其他的变量。debugger 会计算栈指针,但是debugger必须得到一个使用FPO的提醒,该提醒是基于FPO类型的信息的来完成的。这项特性可以在MS Visual C++专业版和企业版中开启。使用的是编译器的/Oy选项。

FPO的数据结构可以在Microsoft的SDK中的winnt.h中找到,其中包含了描述栈框架内容的信息。这些信息被使用在debugger上,或者其他的需要在栈中寻找FPO函数的程序中。KV命令可以显示出包括FPO信息在内的额外的运行时信息。
但FPO有一个小问题。如果您查看MyFunction的pre-FPO示例,您会注意到例程中的第一条指令是PUSH EBP,然后是MOV EBP,特别是它有一个有趣且非常有用的副作用。它实际上创建了一个单独的链接列表,将每个调用者的帧指针链接到一个函数。从一个例程的EBP中,可以恢复一个函数的整个调用堆栈。这对于调试器来说是难以置信的有用——这意味着调用栈是相当可靠的,即使没有被调试器的所有模块的符号。不幸的是,当FPO被启用时,堆栈帧的列表丢失了——信息根本没有被跟踪。
为了解决这个问题,编译器人员将启用FPO时丢失的信息放入二进制文件的PDB文件中。因此,当有模块的符号时,可以恢复所有堆栈信息。
在NT 3.51中,所有的Windows二进制文件都启用了FPO,但是在Vista中的Windows二进制文件却被关闭了,因为它不再是必需的—机器从1995年开始变得足够快,以至于FPO所实现的性能改进不足以抵消FPO在调试和分析中所带来的痛苦。

FPO: [2,0,0]代表什么?

我们已经知道FPO是什么了,可是[2,0,0]是什么呢,看如下的表

FPO数据表示形式 (FPO: [ebp addr][x,y,z])
x代表 作为参数压栈了的DWORDS个数
y代表 作为局部变量压栈了的DWORDS个数
z代表 在开场代码中(prologue)压栈了的寄存器个数
ebp addr代表 仅在EBP在开场代码中保存了的时候显示

本文最开始的例子中,由于调试器有正确的symbol,所以调试器会计算出栈底(Frame Pointer)的位置,而不是在EBP之中保存它。比如说,第一个参数的位置是栈底+0x8,返回值的位置是栈底+0x4. 开启了FPO之后,这些值就不能通过ebp + 0x8这样拿到了,跟ebp等值的栈底(Frame Pointer)需要计算才能拿到。

当我们在windbg里执行lm指令后,回见到如下情况:

 

有的模块名后面跟的时pdb symbols,表示已加载符号,且后面还跟着符号的详细路径。有的模块后面跟着的是deferred,表示模块已经加载,但是调试器还没有尝试加载它的符号。符号将在需要的时候被加载。那么什么是延迟加载?

符号延迟加载

默认情况下,目标模块加载时并没有实际加载符号信息。符号是在调试器需要使用时才加载的。这称为延迟符号加载(deferred symbol loading 或 lazy symbol loading)。当该选项启用时,调试器在遇到不认识的符号时才会进行符号加载。当延迟符号加载被禁用时,进程的启动可能变得慢很多,因为所有符号在模块加载时就会被加载起来。在WinDbg中,对于没有模块前缀的符号,延迟符号加载的特性可以使用Debug菜单的Resolve Unqualified Symbols选项来更改。

通过使用ld (Load Symbols)命令或.reload (Reload Module)命令和/f选项,可以忽略延迟符号加载 。他们强制指定符号被立即加载起来,即使其他符号的加载是延迟的。如果符号路径改变了,符号不会自动重新加载。默认情况下,延迟符号加载是启用的。在CDB和KD中, -s 命令行选项可以关掉它。在CDB中也可以通过tools.ini文件的LazyLoad选项来关掉。当调试器运行起来之后,可以通过.symopt+0x4或.symopt-0x4命令来打开或关闭。

在这里工作的人时常感到困惑的一个问题是,如果你碰巧遇到了一个条件,这个条件触发了VC8的“无效参数”处理程序,并且你在这个进程上附加了一个失败的调试器,那么这个进程神秘地退出,而没有给调试器一个检查程序状态的机会问题。


对于那些不熟悉这个概念的人,“无效参数”处理程序是微软CRT的一个新添加,如果遇到各种无效状态,它会终止进程。例如,如果幸运的话,取消引用发布版本中的伪迭代器可能会触发无效的参数处理程序(如果不幸运的话,您可能会看到随机内存损坏)。


这里没有调试器交互的原因是默认的CRT无效参数处理程序(如果您手头有CRT源代码,则出现在invagg.c中)调用UnhandledExceptionFilter,试图(可能)让调试器看到异常。不幸的是,实际上,如果进程附加了调试器,UnhandledExceptionFilter将立即返回,假设这将导致标准SEH dispatcher逻辑将事件传递给调试器。因为默认的无效参数处理程序并没有真正通过SEH分派器,而实际上只是直接调用UnhandledExceptionFilter,所以这不会向调试器发出任何通知。


当您试图调试一个问题时,这种违反直觉的行为可能会让您有点困惑,因为从调试器中,您可能会看到,在一个错误的迭代器取消引用这样的情况下:

0:000:x86> g
ntdll!NtTerminateProcess+0xa:
00000000`7759053a c3              ret

如果我们提取一个堆栈跟踪,那么事情就会变得更有信息性:

0:000:x86> k
RetAddr           
ntdll32!ZwTerminateProcess+0x12
kernel32!TerminateProcess+0x20
MSVCR80!_invoke_watson+0xe6
MSVCR80!_invalid_parameter_noinfo+0xc
TestApp!wmain+0x10
TestApp!__tmainCRTStartup+0x10f
kernel32!BaseThreadInitThunk+0xe
ntdll32!_RtlUserThreadStart+0x23

然而,虽然我们可以通过一个简单的单线程程序获得触发无效参数事件的线程的堆栈跟踪,但是添加多个线程将破坏此场景的可调试性。例如,使用以下简单的测试程序,在继续初始进程断点后,在调试器下运行进程时,我们可能会看到以下情况(此示例在Vista x64下作为32位程序运行,但其他地方也应适用相同的原则):

0:000:x86> g
ntdll!RtlUserThreadStart:
sub     rsp,48h
0:000> k
Call Site
ntdll!RtlUserThreadStart

怎么了?好吧,这里进程中的最后一个线程碰巧是新创建的线程,而不是名为TerminateProcess的线程。更糟糕的是,另一个线程(导致实际问题的线程)已经不见了,被TerminateProcess杀死,它的堆栈也被吹走了。这意味着我们不能通过请求进程中所有线程的堆栈跟踪来查明发生了什么:

0:000> ~*k

.  0  Id: 1888.1314 Suspend: -1 Unfrozen
Call Site
ntdll!RtlUserThreadStart

不幸的是,这种情况在实践中相当常见,因为大多数非平凡程序出于某种原因使用多个线程。除此之外,许多操作系统提供的api在内部创建或使用工作线程。
在这样的场景中,有一种方法可以获得有用的信息,但不幸的是,事后并不容易做到,这意味着您将需要附加一个调试器,并在发生故障之前可以随意使用。在这里最简单的抓捕罪犯的方法就是在ntdll上设置断点!NtTerminateProcess。(如果进程经常调用TerminateProcess,则可以使用条件断点检查第一个参数中的NtCurrentProcess((HANDLE)-1),但情况通常不是这样,通常只需在例程上设置一个盲断点就足够了。)
例如,对于所提供的测试程序,我们可以通过设置断点获得更有用的结果:

0:000:x86> bp ntdll32!NtTerminateProcess
0:000:x86> g
Breakpoint 0 hit
ntdll32!ZwTerminateProcess:
mov     eax,29h
0:000:x86> k
RetAddr           
ntdll32!ZwTerminateProcess
kernel32!TerminateProcess+0x20
MSVCR80!_invoke_watson+0xe6
MSVCR80!_invalid_parameter_noinfo+0xc
TestApp!wmain+0x2e
TestApp!__tmainCRTStartup+0x10f
kernel32!BaseThreadInitThunk+0xe
ntdll32!_RtlUserThreadStart+0x23

对于完全错误的线程,这比堆栈跟踪更容易诊断。
注意,从错误报告的角度来看,可以通过注册无效的参数处理程序(通过设置无效的参数处理程序)来捕获这些错误,这与为纯虚拟函数调用失败注册自定义处理程序的机制非常相似。