65.9K
CodeProject 正在变化。 阅读更多。
Home

sprintf_s: 加速器前方

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (18投票s)

2015年6月1日

CPOL

33分钟阅读

viewsIcon

29326

downloadIcon

157

本文档记录了我在新的安全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_ssizeOfBuffer参数的值。表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的返回值。
    • 由于定义了_UNICODEsizeof(*(string))对应于sizeof ( TCHAR ),等于2

以下示例使用来自测试程序的实际值,这将使可视化过程更容易。

2列出了在仔细调试过程中记录的实际值,这些值用于下面的示例,其中字符串值不起作用。

base

字符串

sizeInWords

retvalue

sizeof *(string)

十进制

4,454,748

384

148

2

十六进制

0x0043f95c

0x00000180

0x00000094

0x00000002

以下示例使用表2中的十进制值。

宏表达式

((_Size) - (_Offset))) * sizeof(*(_String))

展开表达式

((sizeInWords) - (retvalue + 1)))) * sizeof(*(string))

代入值

((384) - (148 + 1)))) * 2)

求值,步骤1

(384 – 149) * 2

求值,步骤2

235 * 2

结果

470

将此结果与缓冲区中实际的剩余字节数进行对比。

宏表达式

((_Size) - (_Offset))) * sizeof(*(_String))

展开表达式

((sizeInWords) - (retvalue + 1)))) * sizeof(*(string))

代入值

((260) - (148 + 1)))) * 2)

求值,步骤1

(260 – 149) * 2

求值,步骤2

111 * 2

结果

222

总结一下,缓冲区溢出的尺寸是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来补偿。第一个和第二个参数,stringsizeInWords,从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寄存器被保存起来,用于在遵循__cdecl调用约定的函数返回时进行堆栈指针的健全性检查。

EDI

扩展目标索引

此寄存器有两个作用。

¨         在调试构建的函数序言中,ESI寄存器与ECX寄存器(稍后讨论)一起用于初始化为自动变量预留的内存。

¨         当一个函数调用嵌套在另一个函数调用中时,它会履行通常由ESI寄存器扮演的角色,因为ESI忙于跟踪外部函数的堆栈帧。

ECX

扩展计数器

  • 在调试构建的序言中,ECX寄存器被加载为为函数自动变量预留的机器字数,这告诉rep stosd指令何时停止初始化。
  • 当调用实例方法时,对象的指针(无处不在的this变量)在调用指令通过call指令调用该方法之前立即放入ECX寄存器。由于调试构建的序言需要ECX,因此它是推送到堆栈上的最后一个项,然后才设置和运行内存初始化代码。初始化后,ECX从堆栈中弹出,然后将其副本保存到函数自动存储区的最顶部。

EIP

扩展指令指针

在其整个运行过程中,EIP寄存器指向即将执行的指令。由于调用例程在函数返回时期望从上次中断的地方恢复,因此在将控制权转移到被调用函数的第一个指令之前,call指令会将紧随其后的指令的地址推送到堆栈上。

调用本身是通过跳转到被调用例程的第一个指令的地址来执行的。像任何其他跳转一样,这会改变EIP以指向该位置的指令。

EAX

扩展累加器

无论函数遵循哪种常见的调用约定,几乎所有函数都将其返回值放在EAX寄存器中。最常见的两种约定__cdecl__stdcall都通过EAX返回值。

堆栈只是一个大的内存块,由加载程序在启动时分配给进程,并映射到高于程序代码的地址。默认情况下,大多数应用程序获得一个兆字节的堆栈,听起来很多,直到您意识到有多少东西会进入其中。当进程启动时,其堆栈指针(ESP)指向为堆栈保留的地址空间中的最高地址。

由于堆栈指针指向其顶部地址,因此当将项添加到堆栈时(通过将其推送到堆栈上),其值会减小,而在从中删除项时(通过将其从堆栈中弹出),其值会增大。

  • 当堆栈首次分配给进程时,堆栈指针(ESP)和基址指针(EBP)指向同一个位置,但这种情况很快就会改变。任何例程做的第一件事就是将EBP的当前值推送到堆栈上,然后设置EBP为ESP的新值,该值比推送执行前少了4个字节。
  • EBP寄存器在调用另一个例程之前不会再次改变,此时上述过程会重复。每次代码深入到较低级别的例程、进行系统调用或调用运行时库例程(如printf)时,都会重复此过程。由于许多库例程使用辅助例程,因此调用可以很快深入。
  • 在函数的生命周期中,有两种情况会导致堆栈指针(ESP)发生变化。
    • 序言将其值减少了为自动变量需要保留的字节数。此调整的效果是,后续添加到堆栈的操作发生在函数局部存储区下方,从而防止后续的堆栈使用覆盖其数据。
    • 当一个函数调用另一个函数时,参数(如果有)会从右到左(根据其原型中的出现顺序)推送到堆栈上,因此第一个参数最后放入。例如,当您使用格式控制字符串和一系列要替换的变量调用printf时,格式字符串最后放入堆栈。如前所述,一旦最后一个参数入栈,call指令就会推送紧随其后的指令的地址,并将控制权转交给被调用的例程。
  • 当每个函数完成其工作并准备返回时,序言期间发生的过程会被逆转。项会以相反的顺序从堆栈中弹出,并且移动堆栈指针到调用者保留代码下方的减法会通过将相同的量添加到堆栈指针来逆转。最后,函数将其自己的基址指针复制到堆栈指针,然后堆栈指针指向调用者的基址指针被推送的位置。然后将其弹出,函数返回。如果调用约定是__stdcall,则返回指令有一个修改器,它告诉它需要将多少字节添加到堆栈指针以考虑函数的参数。否则,返回指令只是简单地弹出返回地址,该地址成为反向跳转的目标。

重要提示:尽管参数和返回地址实际上并未从堆栈中移除,但当每个函数返回给调用者时增加堆栈指针可以节约为堆栈保留的有限空间,该空间可用于后续函数调用。

自动变量的内存

上一节提到了函数序言为使用其局部(自动)变量而预留的一块内存。要理解为什么会发生缓冲区溢出,最后一个需要掌握的概念是如何从这块内存中为变量分配内存。

由于它使用的是从堆栈分配的内存,因此得知变量分配从顶部开始,向下工作,所以每个新变量的地址都比上面定义的变量低,这并不令人惊讶。重要的是,变量分配是在变量定义时立即进行的,即使初始化被推迟,就像示例应用程序中Exercise_stprintf_s的演示例程顶部的第二个变量szBuffer一样。这是必要的,因为编译器必须避免将另一个变量分配到同一地址,否则会导致严重且频繁的混乱。表4显示了这是如何影响Exercise_stprintf_s中定义的局部变量的,其中szBuffer的地址比rintResult528字节。之所以有这么大的差距,是因为它需要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通过memsetcount参数推导出的缓冲区容量。像任何好的程序一样,memset会服从其主人,填充从swprintf写入文本末尾开始指定数量的字节。接下来发生的事情由表5的最后一列清楚地说明。由于填充值是0xfe(十进制254),结果足以导致机器通过强制终止应用程序来包含损害。具体消息是:“运行时检查失败#2 - 'rintResult'变量周围的堆栈已损坏。”

5总结了断言的缓冲区大小与缓冲区上方的堆栈帧之间的关系,堆栈帧包含参数列表,并指向调用者。

测试用例

1

2

3

4

szBuffer的地址

3,274,948

3,274,948

3,274,948

3,274,948

断言的缓冲区大小(TCHARs)

128

255

260

384

1个TCHAR的大小

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键)都可以访问内存窗口。

Output Buffer, showing output followed by backfill

1显示了缓冲区溢出在Visual Studio 2013调试器中的外观。合法文本在缓冲区的顶部,后面是填充。对于您中的鹰眼,此处显示的机器地址与示例中的不同,因为我的开发机器安装了EMET并配置为强制执行地址空间布局随机化(ASLR)。

当代码从桌面或命令提示符执行时,错误报告将以图2中显示的大而丑陋的消息框的形式出现。由于消息框的应用程序模态标志已禁用,因此您可以无障碍地查看图3中的输出窗口。这非常方便,因为默认操作是Abort,它会立即终止程序,如果您是从桌面启动的,则会导致其输出消失。

Buffer overrun message from debug build, run from command prompt

2是在从命令提示符运行调试版本时报告致命错误的对话框。

Command Window, showing evidence of the buffer overflow, which overwrote the argument list

3是命令窗口,由于消息框的应用程序模态标志已关闭,因此可以激活它。错误的测试号进一步证明了缓冲区溢出,因为消息应该显示“Test # 4 Done”。

重要提示:swprintf的发布版本不进行填充,因为_SECURECRT__FILL_STRING宏的发布版本是空的(即,它不生成任何代码)。

有两个原因让我欣喜地发现零售版不填充缓冲区。

  1. 将缓冲区大小设置得过高不会导致溢出。
  2. 填充会浪费处理器周期和时间。

与大多数此类工程问题一样,这是一种权衡。

¨         尽管不进行填充,但如果打印操作使用的空间超过了为缓冲区实际保留的空间,您仍然可能遇到缓冲区溢出。如果运气好的话,溢出会导致一次壮观的崩溃。

¨         从积极的方面来看,即使是新重载的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++应用程序的文章的演示,这个解决方案是完全独立的。但是,有几件事我必须提请您注意。

  1. 混合语言:组成此项目的模块针对三种不同的编程语言,每种语言都有自己的编译器。
    1. 两个主要模块SecurePrintFHazard.cppExercise_stprintf_s.CPP是用C++实现的。
    2. 两个辅助例程之一ProgramIDFromArgV,定义在模块ProgramIDFromArgV.C中,是从另一个项目中导入的,并且是用纯ANSI C实现的。
    3. 另一个辅助例程CPURegisterPeek,定义在模块CPURegisterPeek.ASM中,是用汇编语言编写的,必须由旧版汇编器MASM 6.11汇编。需要旧版汇编器的原因在下面的第4项中有解释。
  2. 无预编译头文件:由于C和C++模块之间头文件使用存在不可避免的重叠,因此预编译头文件不切实际。由于此项目只有3个针对C/C++编译器的模块,因此整个项目从头开始构建只需几秒钟,而且它们并不被错过。
  3. 无stdafx.h:由于我放弃了预编译头文件,因此我将stdafx.h重命名为SecurePrintFHazard.h。我几乎总是这样做,以提醒自己预编译头文件已禁用。同时,我删除了stdafx.cpp,如果您使用文件浏览器删除它,并且在下次尝试构建解决方案之前忘记从解决方案资源管理器中移除它,它将导致致命的编译器错误并使项目构建失败。
  4. 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所示。

  1. 第一个参数puintTestNumber用于几个消息中,否则被忽略。
  2. 第二个参数puintAssertedSize只有在第三个参数penmOutputMethodSPH_INDIRECT2)时才会被忽略,这在主例程的最外层循环的第二次迭代中成立。
  3. 您可能已经猜到,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仍然很直接。

  1. 四个变量以函数作用域声明,其中三个是标量(两个INT32和一个int),这三个变量都在声明时初始化。
  2. 接下来,定义了两个_invalid_parameter_handler函数指针,第一个用于保存指向默认无效参数处理程序的指针,第二个则初始化为指向自定义处理程序SPH_InvalidParameterHandler的地址,该处理程序在SecurePrintFHazard.h中声明,并在Exercise_stprintf_s.CPP的末尾附近定义。回顾一下,我本可以更好地编写_CrtSetReportMode ( _CRT_ASSERT , CRTDBG_MODE_FILE ),然后是_CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR),将标准的断言消息重定向到控制台窗口。
  3. 接下来是五个相当普通的wprintf调用(通过通用的TCHAR映射宏_tprintf)。格式字符串包含两个格式项,0x%08x,后面紧跟着%d。第一个格式项使替换它的参数表示为十六进制字符串,而第二个格式项将相同的项格式化为无符号十进制整数。
  4. 尽管上述技术对指向字符串的指针效果很好,但显示整数的地址和值需要更多的工作。这是SPH_ShowAddressAndValueOfInt32的领域,它接受整数的地址(例如,第一个调用中的&rintResult)和指向两个字符串的指针(例如,( LPCTSTR ) &m_szRetCdeAddrTpl1( LPCTSTR ) &m_szScalarValueTpl)
    1. SPH_ShowAddressAndValueOfInt32首先使用变量的地址和第一个格式字符串调用SPH_ShowAddressOfScalar
    2. SPH_ShowAddressOfScalar包装了一个简单的wprintf调用(如上,通过_tprintf宏),返回它写入的字符数。此函数可以折叠到SPH_ShowAddressAndValueOfInt32中,或者用宏替换。我两者都没有做,因为将其作为单独的例程进行彻底测试和文档记录更容易,并且因为相同的例程或取代它的宏可以应用于显示任何标量的地址。考虑到这一点,我将其plpScalar参数转换为const void *而不是const INT32 *
    3. 第二个wprintf调用使用第二个格式字符串,在将pintValue传递给wprintf之前(因此,在参数列表中它前面有一个星号),以便打印语句显示其值,并解释为什么pintValue被转换为const INT32 *而不是const void *
  5. 接下来是一对打印语句,显示EBP和ESP寄存器的当前值。虽然打印语句是相同的,但读取CPU寄存器的函数CPURegisterPeek值得简要解释。该例程的原始版本使用了列表12中显示的两个简单的直接内联汇编代码片段。它的后继者CPURegisterPeek可以报告除EFL(标志寄存器)之外的任何通用寄存器的当前内容。由于其复杂性,并且它100%是汇编语言,因此CPURegisterPeek在此被视为一个黑盒子,并且超出了范围。我已经对其进行了足够的测试,以覆盖适用于此程序的用例,并且源代码在主解决方案目录中。我可能会在未来的文章中对其进行剖析。
  6. 最后,解决了一个简单的乘法问题,以生成足够多的材料来编写一份小型但非平凡的报告,然后调用一两个函数来打印它。前四个案例直接通过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.

列表10Exercise_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.hProgramIDFromArgV.C省略了,它编译和链接都很好。在SecurePrintFHazard.h中声明ProgramIDFromArgV并将ProgramIDFromArgV.C包含在项目中足以编译和链接它。

学到的教训

我从这次练习中学到了几件事,其中一些更像是粗暴的提醒。

参考文献

1

“sprintf, _sprintf_l, swprintf, _swprintf_l, __swprintf_l,”经典的缓冲格式化打印函数系列,在https://msdn.microsoft.com/en-us/library/ybk95axf.aspx及其他地方有文档。

2

“sprintf_s, _sprintf_s_l, swprintf_s, _swprintf_s_l,”相应的“安全”重载集合,在https://msdn.microsoft.com/en-us/library/ce3zzk1k.aspx有文档。

3

“Secure Template Overloads”文档介绍了旨在简化升级的模板宏,网址为https://msdn.microsoft.com/en-us/library/ms175759.aspx

4

“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寄存器在内存管理中的使用情况的表格中的技术错误。

© . All rights reserved.