sprintf_s: 加速器前方






4.95/5 (18投票s)
本文档记录了我在新的安全CRT缓冲打印例程重载中发现的一些问题。
引言
几周前,我将一个最初使用Visual Studio 6开发并完全用C++编写的程序升级到了Visual Studio 2013自带的新CRT库。由于安全开发生命周期检查默认启用(即使在从Visual C++ 6升级的项目中也是如此),第一个编译器日志将许多对swprintf
[1]的调用标记为可能不安全,并建议将其替换为对swprintf_s
[2]的调用。
尽管Visual C++团队体贴地提供了方便的宏来简化转换,但除非输出缓冲区是静态数组 [3],否则无法使用它们。不幸的是,所涉及的swprintf
调用中,除了两个之外,都使用通过指针访问的静态缓冲区。然而,这并不是问题所在,因为拥有缓冲区的DLL导出了返回其大小的伴随函数。
问题出现在另外两个上,它们都使用较小的缓冲区,也通过指针访问。唉,当我向它们添加sizeOfBuffer
参数时,我习惯性地指定了与DLL导出的较大缓冲区相同的大小。突然间,我遇到了一个非常意外且糟糕的缓冲区溢出异常。发生了什么?
背景
由于被覆盖的内存包括拥有缓冲区的函数的堆栈帧,直接原因很明显,但为什么CRT例程会产生缓冲区溢出呢?答案深藏在区分swprintf_s
与其遗留处理器swprintf
的新代码中。为了消除干扰,我编写了一个非常简单的测试程序,它在局部堆栈上分配了一个静态缓冲区,就像出现问题的程序一样,并通过一个循环调用它,该循环改变用于swprintf_s
的sizeOfBuffer
参数的值。表1列出了4种情况,尽管在本问题中只有最后一种测试是重要的,但情况1值得一提。
表 1是演示程序实现的测试用例摘要。
情况 |
sizeOfBuffer |
相对于szBuffer |
测试结果 |
---|---|---|---|
1 |
128 |
较小 |
由于测试消息包含超过128个字符,导致_set_invalid_parameter_handler被调用 [4]。 |
2 |
255 |
较小 |
由于测试消息可以舒适地容纳,因此打印成功,而不会导致缓冲区溢出。 |
3 |
260 |
相同 |
结果与情况2相同。 |
4 |
384 |
较大 |
输出已格式化,随后调用_tprintf将其显示在控制台上成功。溢出直到测试例程尝试返回到主例程的循环时才被捕获和报告。 |
谨慎的单步调试识别出了问题所在,深藏在CRT库中。swprintf_s
的一个新特性是宏_SECURECRT__FILL_STRING
,它隐藏了一个对memset
的调用。然而,它不在swprintf_s
本身中;您必须深入一层,到_vswprintf_s_l
,并一直跟踪该例程直到接近末尾。
_vswprintf_s_l
中的最后一个有意义的语句(列表2)被实现为一个宏_SECURECRT__FILL_STRING(string, sizeInWords, retvalue + 1)
,如列表3所示,它展开为一个对CRT函数memset的调用。memset的原型如下。
void *memset(
void *dest,
int c,
size_t count
);
- 在
_vswprintf_s_l
的上下文中,参数值如下。
dest
= 空终止符之后的一个字节,该空终止符终止写入在string给出的地址开始的输出
c
= 填充字符,表示为一个整数,0xfe
count
= 要用字符c填充的字节数,从dest地址开始,由接下来的公式决定
- memset的第三个参数,指定要写入的字节数,是一个三元表达式,其中显著的部分是冒号后面的false块:
((_Size) - (_Offset))) * sizeof(*(_String))
。 - 将宏参数代入表达式得到以下表达式,它将成为替换宏的C代码的一部分:
((sizeInWords) - (retvalue + 1)))) * sizeof(*(string))
,其中sizeInWords
= 以字符(TCHAR)表示的(缓冲区大小)retvalue
= 由_vswprintf_helper
(格式化打印例程的工作引擎)写入的字符,也以字符(TCHAR
)表示,它最终将成为swprintf_s
的返回值。- 由于定义了
_UNICODE
,sizeof(*(string))
对应于sizeof ( TCHAR )
,等于2。
以下示例使用来自测试程序的实际值,这将使可视化过程更容易。
表 2列出了在仔细调试过程中记录的实际值,这些值用于下面的示例,其中字符串值不起作用。
base |
字符串 |
sizeInWords |
retvalue |
sizeof *(string) |
---|---|---|---|---|
十进制 |
4,454,748 |
384 |
148 |
2 |
十六进制 |
0x0043f95c |
0x00000180 |
0x00000094 |
0x00000002 |
以下示例使用表2中的十进制值。
宏表达式 |
|
展开表达式 |
|
代入值 |
|
求值,步骤1 |
|
求值,步骤2 |
|
结果 |
|
将此结果与缓冲区中实际的剩余字节数进行对比。
宏表达式 |
|
展开表达式 |
|
代入值 |
|
求值,步骤1 |
|
求值,步骤2 |
|
结果 |
|
总结一下,缓冲区溢出的尺寸是248字节,足以踩踏其上方的堆栈帧。
根据无效的size参数384计算的剩余空间............................... 470
根据实际缓冲区大小260计算的剩余空间............................... 222
溢出量....................................................................................... 248
证明:无效的缓冲区大小............................................................................................ 384
正确的缓冲区大小............................................................................................ 260
额外的TCHARs..................................................................................... 124
TCHAR的大小......................................................................................................... 2
溢出(字节)............................................................................. 248
列表1到列表3中的代码是直接从Microsoft Visual Studio 2013附带的CRT库源文件中复制的。它们的默认安装目录是C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\crt\src
。函数_vswprintf_s_l
和其他格式化打印例程调用一个函数_vswprintf_helper
来处理格式控制字符串和可选参数。由于该例程很长、很复杂,并且与缓冲区溢出无关,因此我将其从这些列表中省略了。如果您好奇,它也存在于CRT源文件中,在vswprint.c
中。
为了使源列表接近这些示例,叙述在列表3下方继续。
int __cdecl swprintf_s (
wchar_t *string,
size_t sizeInWords,
const wchar_t *format,
...
)
{
va_list arglist;
va_start(arglist, format);
return _vswprintf_s_l(string, sizeInWords, format, NULL, arglist);
}
列表 1是swprintf_s的全部函数,它创建一个对格式控制字符串后面的可选参数的私有引用,然后通过_vswprintf_s_l返回。
int __cdecl _vswprintf_s_l (
wchar_t *string,
size_t sizeInWords,
const wchar_t *format,
_locale_t plocinfo,
va_list ap
)
{
int retvalue = -1;
/* validation section */
_VALIDATE_RETURN(format != NULL, EINVAL, -1);
_VALIDATE_RETURN(string != NULL && sizeInWords > 0, EINVAL, -1);
retvalue = _vswprintf_helper(_woutput_s_l, string, sizeInWords, format, plocinfo, ap);
if (retvalue < 0)
{
string[0] = 0;
_SECURECRT__FILL_STRING(string, sizeInWords, 1);
}
if (retvalue == -2)
{
_VALIDATE_RETURN(("Buffer too small", 0), ERANGE, -1);
}
if (retvalue >= 0)
{
_SECURECRT__FILL_STRING(string, sizeInWords, retvalue + 1);
}
return retvalue;
}
列表 2是_vswprintf_s_l函数的所有行,它也看似简单,但包含宏_SECURECRT__FILL_STRING
,这是溢出的根源。如果成功,_vswprintf_helper
返回实际写入输出缓冲区的字符数,不包括尾随的空字符,宏_SECURECRT__FILL_STRING
通过将其加1来补偿。第一个和第二个参数,string
和sizeInWords
,从swprintf_s
原封不动地传递。
#if !defined (_SECURECRT_FILL_BUFFER_THRESHOLD)
#ifdef _DEBUG
#define _SECURECRT_FILL_BUFFER_THRESHOLD __crtDebugFillThreshold
#else /* _DEBUG */
#define _SECURECRT_FILL_BUFFER_THRESHOLD ((size_t)0)
#endif /* _DEBUG */
#endif /* !defined (_SECURECRT_FILL_BUFFER_THRESHOLD) */
#if _SECURECRT_FILL_BUFFER
#define _SECURECRT__FILL_STRING(_String, _Size, _Offset) \
if ((_Size) != ((size_t)-1) && (_Size) != INT_MAX && \
((size_t)(_Offset)) < (_Size)) \
{ \
memset((_String) + (_Offset), \
_SECURECRT_FILL_BUFFER_PATTERN, \
(_SECURECRT_FILL_BUFFER_THRESHOLD < ((size_t)((_Size) - (_Offset))) ? \
_SECURECRT_FILL_BUFFER_THRESHOLD : \
((_Size) - (_Offset))) * sizeof(*(_String))); \
}
#else /* _SECURECRT_FILL_BUFFER */
#define _SECURECRT__FILL_STRING(_String, _Size, _Offset)
#endif /* _SECURECRT_FILL_BUFFER */
#if _SECURECRT_FILL_BUFFER
#define _SECURECRT__FILL_BYTE(_Position) \
if (_SECURECRT_FILL_BUFFER_THRESHOLD > 0) \
{ \
(_Position) = _SECURECRT_FILL_BUFFER_PATTERN; \
}
#else /* _SECURECRT_FILL_BUFFER */
#define _SECURECRT__FILL_BYTE(_Position)
#endif /* _SECURECRT_FILL_BUFFER */
列表 3是宏_SECURECRT_FILL_BUFFER
及其依赖项,它们在internal.h
中定义。(请在CRT源代码安装目录中查找internal.h。我的在C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\crt\src\.
)
缓冲区溢出的解剖
在大多数情况下,知道传递给函数和自动变量的参数分配在堆栈的某个位置,并且它们的机器地址并不特别重要就足够了。在这种情况下,它非常重要,变量的定义顺序会产生重大影响,并暴露使用自动变量作为缓冲区的风险。
为了方便那些不完全熟悉C和C++编译器如何为参数和变量分配内存的读者,下一节提供了一个简短的图解教程。
如果您对此了如指掌,请随时跳过下一节(Kaboom!)。
参数和自动变量的内存
Windows程序中函数参数(形参)和自动变量的管理主要围绕两个CPU寄存器进行,而其他四个寄存器则起次要作用,汇总在表3中。
表 3总结了CPU寄存器在管理参数列表和自动变量中的作用。
缩写 |
全称 |
角色 |
---|---|---|
EBP |
扩展基址指针 |
在每个函数内部,其参数和局部(自动)变量的地址通常相对于存储在此寄存器中的地址进行偏移编码,该地址位于为堆栈保留的地址空间内的某个位置。 |
ESP |
扩展堆栈指针 |
堆栈指针主要用于将函数参数和返回地址进出堆栈。堆栈指针的第二个但相关的作用是标记函数工作存储区的边界,从该区域分配其自动变量。 |
ESI |
扩展源索引 |
在调试构建的函数体中,ESP寄存器被保存起来,用于在遵循 |
EDI |
扩展目标索引 |
此寄存器有两个作用。 ¨ 在调试构建的函数序言中,ESI寄存器与ECX寄存器(稍后讨论)一起用于初始化为自动变量预留的内存。 ¨ 当一个函数调用嵌套在另一个函数调用中时,它会履行通常由ESI寄存器扮演的角色,因为ESI忙于跟踪外部函数的堆栈帧。 |
ECX |
扩展计数器 |
|
EIP |
扩展指令指针 |
在其整个运行过程中,EIP寄存器指向即将执行的指令。由于调用例程在函数返回时期望从上次中断的地方恢复,因此在将控制权转移到被调用函数的第一个指令之前, 调用本身是通过跳转到被调用例程的第一个指令的地址来执行的。像任何其他跳转一样,这会改变EIP以指向该位置的指令。 |
EAX |
扩展累加器 |
无论函数遵循哪种常见的调用约定,几乎所有函数都将其返回值放在EAX寄存器中。最常见的两种约定 |
堆栈只是一个大的内存块,由加载程序在启动时分配给进程,并映射到高于程序代码的地址。默认情况下,大多数应用程序获得一个兆字节的堆栈,听起来很多,直到您意识到有多少东西会进入其中。当进程启动时,其堆栈指针(ESP)指向为堆栈保留的地址空间中的最高地址。
由于堆栈指针指向其顶部地址,因此当将项添加到堆栈时(通过将其推送到堆栈上),其值会减小,而在从中删除项时(通过将其从堆栈中弹出),其值会增大。
- 当堆栈首次分配给进程时,堆栈指针(ESP)和基址指针(EBP)指向同一个位置,但这种情况很快就会改变。任何例程做的第一件事就是将EBP的当前值推送到堆栈上,然后设置EBP为ESP的新值,该值比推送执行前少了4个字节。
- EBP寄存器在调用另一个例程之前不会再次改变,此时上述过程会重复。每次代码深入到较低级别的例程、进行系统调用或调用运行时库例程(如
printf
)时,都会重复此过程。由于许多库例程使用辅助例程,因此调用可以很快深入。 - 在函数的生命周期中,有两种情况会导致堆栈指针(ESP)发生变化。
- 序言将其值减少了为自动变量需要保留的字节数。此调整的效果是,后续添加到堆栈的操作发生在函数局部存储区下方,从而防止后续的堆栈使用覆盖其数据。
- 当一个函数调用另一个函数时,参数(如果有)会从右到左(根据其原型中的出现顺序)推送到堆栈上,因此第一个参数最后放入。例如,当您使用格式控制字符串和一系列要替换的变量调用
printf
时,格式字符串最后放入堆栈。如前所述,一旦最后一个参数入栈,call
指令就会推送紧随其后的指令的地址,并将控制权转交给被调用的例程。
- 当每个函数完成其工作并准备返回时,序言期间发生的过程会被逆转。项会以相反的顺序从堆栈中弹出,并且移动堆栈指针到调用者保留代码下方的减法会通过将相同的量添加到堆栈指针来逆转。最后,函数将其自己的基址指针复制到堆栈指针,然后堆栈指针指向调用者的基址指针被推送的位置。然后将其弹出,函数返回。如果调用约定是
__stdcall
,则返回指令有一个修改器,它告诉它需要将多少字节添加到堆栈指针以考虑函数的参数。否则,返回指令只是简单地弹出返回地址,该地址成为反向跳转的目标。
重要提示:尽管参数和返回地址实际上并未从堆栈中移除,但当每个函数返回给调用者时增加堆栈指针可以节约为堆栈保留的有限空间,该空间可用于后续函数调用。
自动变量的内存
上一节提到了函数序言为使用其局部(自动)变量而预留的一块内存。要理解为什么会发生缓冲区溢出,最后一个需要掌握的概念是如何从这块内存中为变量分配内存。
由于它使用的是从堆栈分配的内存,因此得知变量分配从顶部开始,向下工作,所以每个新变量的地址都比上面定义的变量低,这并不令人惊讶。重要的是,变量分配是在变量定义时立即进行的,即使初始化被推迟,就像示例应用程序中Exercise_stprintf_s
的演示例程顶部的第二个变量szBuffer
一样。这是必要的,因为编译器必须避免将另一个变量分配到同一地址,否则会导致严重且频繁的混乱。表4显示了这是如何影响Exercise_stprintf_s
中定义的局部变量的,其中szBuffer
的地址比rintResult
低528字节。之所以有这么大的差距,是因为它需要520字节,足以容纳MAX_PATH
个宽字符,再加上编译器在变量之间留下的8字节缓冲区。
int rintResult = SPH_TEST_SUCCEEDED;
TCHAR szBuffer [ SPH_BUFFER_ACTUAL_SIZE ] ;
INT32 intNumericVariable1of2 = SPH_NUMERIC_VARIABLE_VALUE_1 ;
INT32 intNumericVariable2of2 = SPH_NUMERIC_VARIABLE_VALUE_2 ;
_invalid_parameter_handler oldHandler , newHandler ;
oldHandler = _set_invalid_parameter_handler ( newHandler ) ;
列表 4是定义在函数Exercise_stprintf_s顶部的局部变量,赋予它们函数作用域。
表 4列出了测试函数Exercise_stprintf_s定义的局部(自动)变量的机器地址,并由其报告。请注意,堆栈指针的值比基址指针低864,这比其序言预留的空间多28字节。额外的空间被用于管理其异常处理程序的隐藏数据结构。
机器地址 |
目录 |
|||
Label |
十六进制 |
十进制 |
十六进制 |
十进制 |
基址指针 (EBP) |
0x0031FAF0 |
3,275,504 |
|
|
堆栈指针 (ESP) |
0x0031f78c |
3,274,636 |
|
|
rintResult |
0x0031fad4 |
3,275,476 |
0x00000000 |
0 |
szBuffer |
0x0031f8c4 |
3,274,948 |
|
|
intNumericVariable1of2 |
0x0031f8b8 |
3,274,936 |
0x00001000 |
4,096 |
IntNumericVariable2of2 |
0x0031f8ac |
3,274,924 |
0x0000ffff |
65,535 |
NewHandler |
0x0031f894 |
3,274,900 |
0x010010B9 |
16,781,497 |
oldHandler |
0x0031f8a0 |
3,274,912 |
0x00000000 |
0 |
Kaboom!
考虑到用于swprintf
的输出缓冲区是如何分配的,这个谜团几乎可以自行解决了。当宏_SECURECRT__FILL_STRING
(列表3)生成的代码调用memset
来填充缓冲区时,它使用新参数SizeInWords
通过memset
的count
参数推导出的缓冲区容量。像任何好的程序一样,memset
会服从其主人,填充从swprintf
写入文本末尾开始指定数量的字节。接下来发生的事情由表5的最后一列清楚地说明。由于填充值是0xfe
(十进制254),结果足以导致机器通过强制终止应用程序来包含损害。具体消息是:“运行时检查失败#2 - 'rintResult'变量周围的堆栈已损坏。”
表 5总结了断言的缓冲区大小与缓冲区上方的堆栈帧之间的关系,堆栈帧包含参数列表,并指向调用者。
测试用例 |
1 |
2 |
3 |
4 |
|
3,274,948 |
3,274,948 |
3,274,948 |
3,274,948 |
断言的缓冲区大小( |
128 |
255 |
260 |
384 |
1个 |
2 |
2 |
2 |
2 |
断言的缓冲区大小(字节) |
256 |
510 |
520 |
768 |
实际缓冲区大小 |
520 |
520 |
520 |
520 |
欠载或过载 |
-264 |
-10 |
0 |
248 |
头部空间 |
36 |
36 |
36 |
36 |
超出头部空间的重叠 |
|
|
|
212 |
值得庆幸的是,缓冲区溢出在Visual Studio调试器中很容易发现,尽管您需要显示内存窗口,并输入缓冲区的地址才能看到它。如果您错过了,在任何调试会话中,通过按ALT-6
(即键盘顶部行上的ALT键和数字6键)都可以访问内存窗口。
图 1显示了缓冲区溢出在Visual Studio 2013调试器中的外观。合法文本在缓冲区的顶部,后面是填充。对于您中的鹰眼,此处显示的机器地址与示例中的不同,因为我的开发机器安装了EMET并配置为强制执行地址空间布局随机化(ASLR)。
当代码从桌面或命令提示符执行时,错误报告将以图2中显示的大而丑陋的消息框的形式出现。由于消息框的应用程序模态标志已禁用,因此您可以无障碍地查看图3中的输出窗口。这非常方便,因为默认操作是Abort,它会立即终止程序,如果您是从桌面启动的,则会导致其输出消失。
图 2是在从命令提示符运行调试版本时报告致命错误的对话框。
图 3是命令窗口,由于消息框的应用程序模态标志已关闭,因此可以激活它。错误的测试号进一步证明了缓冲区溢出,因为消息应该显示“Test # 4 Done”。
重要提示:swprintf
的发布版本不进行填充,因为_SECURECRT__FILL_STRING
宏的发布版本是空的(即,它不生成任何代码)。
有两个原因让我欣喜地发现零售版不填充缓冲区。
- 将缓冲区大小设置得过高不会导致溢出。
- 填充会浪费处理器周期和时间。
与大多数此类工程问题一样,这是一种权衡。
¨ 尽管不进行填充,但如果打印操作使用的空间超过了为缓冲区实际保留的空间,您仍然可能遇到缓冲区溢出。如果运气好的话,溢出会导致一次壮观的崩溃。
¨ 从积极的方面来看,即使是新重载的swprintf
及其同类函数的零售版本也会失败,并报告一个可捕获的错误,如果指定的尺寸表明缓冲区不足以容纳格式化输出。有两种方法可以检测到此错误。
swprintf
返回的值是-2,可以通过将函数调用包装在switch或if语句中进行评估,而无需创建临时变量。- 当缓冲区太小时,swprintf会调用_invalid_parameter_handler例程。CRT库提供了一个默认的无效参数处理程序,在调试版本中会引发断言,在发布版本中会静默失败。然而,程序或其函数可以安装自己的处理程序。我就是这样做的,它在零售版本中执行时的输出在列表5的底部。在调试版本中生成的输出(列表6)更有用一些。
Begin Test # 1: Asserted buffer size = 0x00000080 (128 decimal):
Buffer Address = 0x003efc50 (4127824 decimal)
Actual Size (bytes) = 0x00000208 (520 decimal)
Actual Size (TCHARs) = 0x00000104 (260 decimal)
Actual Top = 0x003efe58 (4128344 decimal)
Numeric Variable 1 of 2: Address = 0x003efc4c (4127820 decimal)
Value = 0x00001000 (4096 decimal)
Numeric Variable 2 of 2: Address = 0x003efc48 (4127816 decimal)
Value = 0x0000ffff (65535 decimal)
Base Pointer (EBP) = 0x003efe5c (4128348 decimal)
Stack Pointer (ESP) = 0x003efc34 (4127796 decimal)
ERROR: Invalid parameter detected in function (null).
File: (null)
Line: 0
Expression: (null)
ERROR: Nothing printed!
Test # 1 Done
Total characters printed by last output statement = -1
Outcome of test # 1 = Success
列表 5是四项使用swprintf的测试中的第一项的输出,该测试失败是因为指定的缓冲区大小128个字符比格式化输出及其终端空字符所需的大小21个字符小,至少需要149个字符的缓冲区大小。
ERROR: Invalid parameter detected in function _vswprintf_s_l.
File: f:\dd\vctools\crt\crtw32\stdio\vswprint.c
Line: 280
Expression: ("Buffer too small", 0)
列表 6是我在调试版本中运行的无效参数处理程序生成的输出。调试版本的输出更有用,尽管仍有很大改进空间。然而,与列表5中显示同一例程在发布版本中运行时生成的输出相比,其第一行提供了一个起点。
Using the Code
演示项目是生成上述所有表和列表输出的程序。由于只有调试版本会显示缓冲区溢出,因此其输出目录(主项目目录下的\Debug
目录)最值得关注。尽管如此,我还是保留了零售版本,以便您可以快速自行查看它是否无事完成。
当您第一次在Visual Studio中打开项目时,IntelliSense数据库文件SecurePrintFHazard.sdf
重新生成时,解决方案目录的大小会急剧增大。为了减小包的总体积,我已将其从分发包中删除,因为Visual Studio在文件丢失时会重新创建它。
与我的许多项目不同,包括我之前写的两篇关于C++应用程序的文章的演示,这个解决方案是完全独立的。但是,有几件事我必须提请您注意。
- 混合语言:组成此项目的模块针对三种不同的编程语言,每种语言都有自己的编译器。
- 两个主要模块
SecurePrintFHazard.cpp
和Exercise_stprintf_s.CPP
是用C++实现的。 - 两个辅助例程之一
ProgramIDFromArgV
,定义在模块ProgramIDFromArgV.C
中,是从另一个项目中导入的,并且是用纯ANSI C实现的。 - 另一个辅助例程
CPURegisterPeek
,定义在模块CPURegisterPeek.ASM
中,是用汇编语言编写的,必须由旧版汇编器MASM 6.11汇编。需要旧版汇编器的原因在下面的第4项中有解释。
- 两个主要模块
- 无预编译头文件:由于C和C++模块之间头文件使用存在不可避免的重叠,因此预编译头文件不切实际。由于此项目只有3个针对C/C++编译器的模块,因此整个项目从头开始构建只需几秒钟,而且它们并不被错过。
- 无stdafx.h:由于我放弃了预编译头文件,因此我将
stdafx.h
重命名为SecurePrintFHazard.h
。我几乎总是这样做,以提醒自己预编译头文件已禁用。同时,我删除了stdafx.cpp
,如果您使用文件浏览器删除它,并且在下次尝试构建解决方案之前忘记从解决方案资源管理器中移除它,它将导致致命的编译器错误并使项目构建失败。 - CPURegisterPeek.ASM与MASM 12.0.31101.0不兼容:我使用了我安装在旧机器上的Microsoft ® Macro Assembler Version 6.11版本来汇编它。较新的汇编器在调用位于源代码文件底部的
endp
指令时发出了错误A2071:初始化器大小对于指定的大小来说太大了,尽管我怀疑真正的问题是第167行的表达式_ARG_UPPER_LIMIT equ ( __REG_INDEX_END - _REG_INDEX ) / _SIZEOF_DWORD
。然而,由于旧版汇编器可以汇编一个可证明正确的版本,因此我没有进一步调查。今天,我将CPURegisterPeek.ASM
从解决方案中删除,这样您和我都不必在构建引擎决定需要从头开始重建时处理它。这种情况比您想象的要常见;当项目配置中的任何内容发生变化时,它就会发生。
关注点
主例程具有通用的TCHAR
映射名称_tmain
和标准的两个参数签名,并在模块SecurePrintFHazard.cpp
中定义。在四个不需要进一步解释的静态数组之后,是第一个可执行块,它受到保护代码的良好保护,该代码确保除非同时定义了预处理器符号_DEBUG
和_PROGRAMIDFROMARGV_DBG
,否则它将被排除在编译之外。我可以将保护代码压缩到两边各一行,但我没有这样做,因为外部测试是一个事后想法。
#if defined ( _DEBUG )
#if defined ( _PROGRAMIDFROMARGV_DBG )
#pragma message ( "Preprocessor symbol _PROGRAMIDFROMARGV_DBG is defined.")
DebugBreak ( );
#else
#pragma message ( "Preprocessor symbol _PROGRAMIDFROMARGV_DBG is UNdefined.")
#endif /* #if defined ( _PROGRAMIDFROMARGV_DBG ) */
#endif /* #if defined ( _DEBUG ) */
列表7是调用Windows API例程DebugBreak
的保护代码,我用它来帮助我强制从命令提示符启动的调试版本进入Visual Studio调试器。
接下来是第一个应用程序定义的函数调用ProgramIDFromArgV
,该函数从调用它的基本名称SecurePrintFHazard
中提取程序的基本名称,该名称以指向argv数组第一个元素的指针的形式接收。由于它与当前主题无关,因此我将其分析留给特别好奇的读者。
例程的主体是嵌套在列表8所示的两个嵌套for
循环中的switch
语句。最外层的for
语句定义并使用无符号整数uintOutputMethod
来迭代两个元素的数组s_enmOutputMethod
。该数组填充了SPH_OUTPUT_METHOD
枚举的每个非零成员。
最里面的for
语句定义并使用另一个无符号整数uintTestIndex
来迭代s_auintAssertedSizes
数组,这是一个无符号整数集合,表示将在外部循环第二次迭代时依次传递给wsprintf
的缓冲区大小。
在最内层循环中发生的主要事件是调用另一个主要的应用程序定义的函数Exercise_stprintf_s
,它是Exercise_stprintf_s.CPP
中定义的第一且主要的例程。内部循环剩余的唯一任务是使用Exercise_stprintf_s
返回的值来确定显示三种结果消息中的哪一种。除了提请您注意通过其通用文本映射调用wprintf
之外,打印语句是无特色的。
由于我尽力避免重复计算,因此定义并使用uintTestNumber
来存储序测试编号(使用两次),它从一开始,尽管从内部循环的索引(一个从零开始的数组索引)派生它很简单。关于主例程,唯一要说的是,尽管两个循环的限制值的计算是数据驱动的,但它们在发出的代码中显示为常量,因为计算完全依赖于编译时已知的值,并且编译器会执行它们并将答案作为常量写入生成的代码中。
总的来说,对于任何由sizeof
变量或类型组成,或由这些表达式和基本算术运算符组成的表达式,这都是正确的。这个概念在Exercise_stprintf_s
以及我通常使用的许多宏中也起着关键作用。
for ( unsigned int uintOutputMethod = 0 ;
uintOutputMethod < sizeof ( s_enmOutputMethod ) / sizeof ( SPH_OUTPUT_METHOD ) ;
uintOutputMethod++ )
{
_tprintf ( TEXT ( "\nTest group %d: %s\n\n" ) ,
( uintOutputMethod + 1 ) , // The derivation of the test group from the array subscript is completely disposable.
s_szOutputMethodMsg [ s_enmOutputMethod [ uintOutputMethod ] ] ) ; // The descriptions are read from a parallen table of static strings.
for ( unsigned int uintTestIndex = 0;
uintTestIndex < sizeof ( s_auintAssertedSizes ) / sizeof ( unsigned int );
uintTestIndex++ )
{
unsigned int uintTestNumber = uintTestIndex + 1;
switch ( int intResult = Exercise_stprintf_s ( uintTestNumber , // const unsigned int puintTestNumber , // Ordinal number of test case
s_auintAssertedSizes [ uintTestIndex ] , // const unsigned int puintAsserteSize , // Tell _tsprintf_s that the buffer has a capacity of this many TCHARs.
s_enmOutputMethod [ uintOutputMethod ] ) ) // const SPH_OUTPUT_METHOD penmOutputMethod // Tell Exercise_stprintf_s whether to use _tprintf to output directory, or indirectly through _tstprintf_s.
{
case SPH_TEST_SUCCEEDED:
case SPH_TEST_FAILED:
_tprintf ( TEXT ( " Outcome of test # %d = %s\n\n" ) ,
uintTestNumber , // Substitute for token %d
s_szResultMsg [ intResult ] ); // Substitute for token %s.
break; // cases SPH_TEST_SUCCEEDED and SPH_TEST_FAILED do the same thing, and end here.
case SPH_TEST_REPORTING_ERROR:
_tprintf ( TEXT ( " Test # %d reported that a call to function _tprintf produced nothing.\n\n" ) ,
uintTestNumber ); // Substitute for token %d
break; // Case SPH_TEST_REPORTING_ERROR has a dedicated message, and ends here.
default:
_tprintf ( TEXT ( " Test # %d reported an unexpected result code of 0x%08x (%d decimal)\n\n" ) ,
uintTestNumber ,
intResult , // Hexadecimal (format token 0x%08x)
intResult ); // Decimal (format token %d)
} // switch ( int intResult = Exercise_stprintf_s ( uintTestNumber , s_auintAssertedSizes [ uintTestIndex ] , s_enmOutputMethod [ uintOutputMethod ] ) )
} // for ( unsigned int uintTestIndex = 0; uintTestIndex < sizeof ( s_auintAssertedSizes ) / sizeof ( unsigned int ); uintTestIndex++ )
} // for ( unsigned int uintOutputMethod = 0 ; uintOutputMethod < sizeof ( s_enmOutputMethod ) / sizeof ( SPH_OUTPUT_METHOD ) ; uintOutputMethod++ )
列表 8是主例程的核心,包括一个switch
语句,该语句评估应用程序定义的函数Exercise_stprintf_s
的返回值,该函数在嵌套的两个for
循环中最内层运行,这两个循环索引了它的两个主要参数,这些参数来自由这两个循环迭代的数组。
函数Exercise_stprintf_s
,测试程序的核心,在上面“缓冲区溢出的解剖”一节中已被完全解剖。该例程接受三个参数,所有参数都有效地被视为无符号整数,如列表9所示。
- 第一个参数
puintTestNumber
用于几个消息中,否则被忽略。 - 第二个参数
puintAssertedSize
只有在第三个参数penmOutputMethod
为SPH_INDIRECT
(2)时才会被忽略,这在主例程的最外层循环的第二次迭代中成立。 - 您可能已经猜到,
penmOutputMethod
决定了try
块内的简单数学问题结果是直接通过wprintf
打印,还是间接通过调用swprintf
然后将缓冲区发送到wprintf
来打印。
int __stdcall Exercise_stprintf_s
(
const unsigned int puintTestNumber , // Ordinal number of test case
const unsigned int puintAsserteSize , // Tell _tsprintf_s that the buffer has a capacity of this many TCHARs.
const SPH_OUTPUT_METHOD penmOutputMethod // Tell Exercise_stprintf_s whether to use _tprintf to output directory, or indirectly through _tstprintf_s.
) ;
列表 9是函数Exercise_stprintf_s
的原型,它是这个程序的真正主力。
尽管它是整个项目中最大的函数,但Exercise_stprintf_s
仍然很直接。
- 四个变量以函数作用域声明,其中三个是标量(两个
INT32
和一个int
),这三个变量都在声明时初始化。 - 接下来,定义了两个
_invalid_parameter_handler
函数指针,第一个用于保存指向默认无效参数处理程序的指针,第二个则初始化为指向自定义处理程序SPH_InvalidParameterHandler
的地址,该处理程序在SecurePrintFHazard.h
中声明,并在Exercise_stprintf_s.CPP
的末尾附近定义。回顾一下,我本可以更好地编写_CrtSetReportMode ( _CRT_ASSERT , CRTDBG_MODE_FILE )
,然后是_CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR)
,将标准的断言消息重定向到控制台窗口。 - 接下来是五个相当普通的
wprintf
调用(通过通用的TCHAR
映射宏_tprintf
)。格式字符串包含两个格式项,0x%08x
,后面紧跟着%d
。第一个格式项使替换它的参数表示为十六进制字符串,而第二个格式项将相同的项格式化为无符号十进制整数。 - 尽管上述技术对指向字符串的指针效果很好,但显示整数的地址和值需要更多的工作。这是
SPH_ShowAddressAndValueOfInt32
的领域,它接受整数的地址(例如,第一个调用中的&rintResult
)和指向两个字符串的指针(例如,( LPCTSTR ) &m_szRetCdeAddrTpl1
和( LPCTSTR ) &m_szScalarValueTpl)
。SPH_ShowAddressAndValueOfInt32
首先使用变量的地址和第一个格式字符串调用SPH_ShowAddressOfScalar
。SPH_ShowAddressOfScalar
包装了一个简单的wprintf
调用(如上,通过_tprintf
宏),返回它写入的字符数。此函数可以折叠到SPH_ShowAddressAndValueOfInt32
中,或者用宏替换。我两者都没有做,因为将其作为单独的例程进行彻底测试和文档记录更容易,并且因为相同的例程或取代它的宏可以应用于显示任何标量的地址。考虑到这一点,我将其plpScalar
参数转换为const void *
而不是const INT32 *
。- 第二个
wprintf
调用使用第二个格式字符串,在将pintValue
传递给wprintf
之前(因此,在参数列表中它前面有一个星号),以便打印语句显示其值,并解释为什么pintValue
被转换为const INT32 *
而不是const void *
。
- 接下来是一对打印语句,显示EBP和ESP寄存器的当前值。虽然打印语句是相同的,但读取CPU寄存器的函数
CPURegisterPeek
值得简要解释。该例程的原始版本使用了列表12中显示的两个简单的直接内联汇编代码片段。它的后继者CPURegisterPeek
可以报告除EFL(标志寄存器)之外的任何通用寄存器的当前内容。由于其复杂性,并且它100%是汇编语言,因此CPURegisterPeek
在此被视为一个黑盒子,并且超出了范围。我已经对其进行了足够的测试,以覆盖适用于此程序的用例,并且源代码在主解决方案目录中。我可能会在未来的文章中对其进行剖析。 - 最后,解决了一个简单的乘法问题,以生成足够多的材料来编写一份小型但非平凡的报告,然后调用一两个函数来打印它。前四个案例直接通过
wprintf
打印报告,这对于所有四个案例都成功了。第二个四个案例重复相同的计算,并调用swprintf
将报告写入缓冲区,预计这将导致第一个和第四个缓冲区大小失败。第一个案例预计不会写入任何内容,并报告缓冲区太小,而第四个案例是导致我创建此程序的溢出。
由于我不能完全确定测试例程的行为,所以我将数学问题和打印报告的例程放在一个try
块中,后面跟着一个省略号catch
块。我发现C++的try/catch
块和C风格的结构化异常处理都起不到作用,因为新CRT库中的更改强制任何检测到缓冲区溢出的程序终止。然而,由于其存在无害,我保留了try/catch块。
_invalid_parameter_handler oldHandler , newHandler ;
newHandler = SPH_InvalidParameterHandler ;
oldHandler = _set_invalid_parameter_handler ( newHandler ) ;
_CrtSetReportMode ( _CRT_ASSERT , 0 ) ; // Disable the message box for assertions.
列表10是Exercise_stprintf_s
中注册自定义无效参数处理程序并禁用断言消息框的部分,它取代了它。
int __stdcall SPH_ShowAddressAndValueOfInt32
(
const INT32 * pintValue , // Pointer to value, which MUST be passed by reference to yield the correct result.
LPCTSTR plpAddressFormat , // Format string for address, which must first specify a string format token (%s), followed by a decimal format token (%d).
LPCTSTR plpValueFormat // Format string for value, which must first specify a string format token (%s), followed by a decimal format token (%d).
)
{
if ( int rintRC = SPH_ShowAddressOfScalar ( pintValue , plpAddressFormat ) )
{
rintRC = _tprintf ( plpValueFormat , // Format string for value
*pintValue , // Hexadecimal representation
*pintValue ) ; // Decimal representation
return rintRC ;
} // TRUE (expected outcome) block, if ( int rintRC = SPH_ShowAddressOfScalar ( pintValue , plpAddressFormat ) )
else
{
return SPH_ERROR_NOTHING_PRINTED ;
} // FALSE (UNexpected outcome) block, if ( int rintRC = SPH_ShowAddressOfScalar ( pintValue , plpAddressFormat ) )
} // SPH_ShowAddressAndValueOfInt32
int __stdcall SPH_ShowAddressOfScalar
(
const void * plpScalar , // Pointer to the address to print
LPCTSTR plpFormat // Pointer to format string, which must first specify a string format token (%s), followed by a decimal format token (%d).
)
{
return _tprintf ( plpFormat , // Format string
plpScalar , // Hexadecimal representation, replaces 0x%08x format token.
plpScalar ) ; // Decimal representation, replacs %d format token.
} // SPH_ShowAddressOfScalar
列表11是函数SPH_ShowAddressAndValueOfInt32
,后面是其依赖函数SPH_ShowAddressOfScalar
。
{
VOID * szESPAddress = NULL;
__asm
{
lea eax , [ EBP ] ;
mov dword ptr [ szEBPAddress ] , eax ;
} /* __asm */
} // szEBPAddress goes out ot scope.
{
VOID * szESPAddress = NULL;
__asm
{
lea eax , [ ESP ] ;
mov dword ptr [ szESPAddress ] , eax ;
} /* __asm */
} // szESPAddress goes out ot scope.
列表12是CPURegisterPeek替换的内联汇编代码。
为了结束本节,我提请您注意,所有函数原型和宏都在SecurePrintFHazard.h
中。这种做法提供了最大的灵活性,因为将原型放在主头文件中意味着该函数可以定义在任何源文件中。除非原型需要其中定义的宏或typedefs
,否则定义它的文件可以省略主头文件。SecurePrintFHazard.h
被ProgramIDFromArgV.C
省略了,它编译和链接都很好。在SecurePrintFHazard.h
中声明ProgramIDFromArgV
并将ProgramIDFromArgV.C
包含在项目中足以编译和链接它。
学到的教训
我从这次练习中学到了几件事,其中一些更像是粗暴的提醒。
- 新的“安全”函数是一把双刃剑。
- 它们并非万能药,因为它们引入了新的危险,这些危险不一定会像本例那样明显。它们引入的错误可能更微妙,更难识别,并且比它们旨在解决的缺陷更危险。
- 在测试对包含此填充技术的任何函数的新调用时,请密切注意输出缓冲区。每当您使用输出到缓冲区的函数时,这都是一个不错的做法。值得庆幸的是,填充模式很容易识别(图1是从测试程序生成的)。
- 我发现了一种报告无效参数的新方法,尽管其接口施加的限制使得我不太可能使用它,除非我找到一种方法来利用本文的知识来扩展其功能,方法是查看前一个堆栈帧。
- 在调试版本中,其签名暴露了在用户代码调用处理程序时会非常有用的详细信息。然而,即使在调试版本中,通过其参数直接提供的信息也仅略有帮助。
- 由于其参数在发布版本中为空,因此像测试程序中的通用实现版本在发布版本中是无用的,并且可以像包装它以测试
_DEBUG
预处理器符号的存在一样来抑制用于挂钩它的代码。为了对抗代码膨胀,例程本身也应该被保护起来,尽管其原型可以安全地保持未受保护,因为其目的是为编译器提供一个模板来验证调用的语法。
- 研究其签名以发现错误报告中有哪些有用的信息,让我想起了所有回调函数对其注册和调用它们的代码施加的限制,以及在设计回调接口时预见未来需求的可取性。
- 我发现了CRT函数
_CrtSetReportMode
,它可以禁用断言消息框,如果它变得太烦人,或者将信息发送到STDERR
。 - 在定义之前使用的回调函数需要原型,即使它实现了系统定义的接口,如
_invalid_parameter_handler
。 - 我重新发现了
DebugBreak
,一个Windows API函数,可以用来强制任何进程进入调试器。由于Visual Studio IDE坚持要求程序的可完全限定文件名才能加载到调试器中,因此这是我唯一能监视它从命令提示符或批处理文件中通过其非限定名称调用时的行为的方法。我需要这样做是为了查找并修复辅助函数ProgramIDFromArgV
中的字符截断错误。 - 汇编语言模块可以整合到Visual Studio项目中,该项目附带一个汇编器。我可以一直使用当前汇编器来汇编
CPURegisterPeek.ASM
,直到我用一个模拟由多个sizeof
表达式构建的表达式行为的equate变得聪明。 - 任何额外的文件,即使是像New Project Wizard创建的
readme.txt
这样的文件,如果它不是代码或内容,并且您直接从文件系统中删除它,它仍然会显示在解决方案资源管理器中,并导致解决方案被永久标记为过时,每次启动调试器时都会提示重建。使用解决方案资源管理器中的上下文菜单将文件从解决方案中移除,消息就会消失。 - 您可以通过将目标的修改日期设置为当前时间来强制构建引擎跳过该目标,使其看起来比上次完整构建以来已修改。我使用了
FSTouch
的64位版本,这是一个由Funduc Software发布的免费实用程序,可在http://www.funduc.com/fstouch.htm获取。
参考文献
“sprintf, _sprintf_l, swprintf, _swprintf_l, __swprintf_l,”经典的缓冲格式化打印函数系列,在https://msdn.microsoft.com/en-us/library/ybk95axf.aspx及其他地方有文档。 |
|
“sprintf_s, _sprintf_s_l, swprintf_s, _swprintf_s_l,”相应的“安全”重载集合,在https://msdn.microsoft.com/en-us/library/ce3zzk1k.aspx有文档。 |
|
“Secure Template Overloads”文档介绍了旨在简化升级的模板宏,网址为https://msdn.microsoft.com/en-us/library/ms175759.aspx |
|
“Howto prevent process crash on CRT error C++",” 在http://stackoverflow.com/questions/10719626/howto-prevent-process-crash-on-crt-error-c,归功于指导我找到了解决方案,使我能够创建关于演示程序中四种测试用例中第一个的预期无效参数错误的详细报告,尽管我最终认为将断言重定向到STDERR会更简单。 |
|
5 |
“Using Static Buffers to Improve Error Reporting Success,”David A. Gray,2015年4月9日,https://codeproject.org.cn/Articles/894564/Using-Static-Buffers-to-Improve-Error-Reporting-Su。 |
历史
2016年1月20日-更正了描述CPU寄存器在内存管理中的使用情况的表格中的技术错误。