程序员面试必考题(三十二)--强制类型转换
百度2010年曾经有一个招聘题目:
以下C语言程序代码的执行结果是什么?说明原因。
#include <stdio.h>
main()
{
double a = 10;
printf("a = %d\n", a);
}
这个题目的一个核心问题是如何把一个double型数据作为一个参数传递给printf()函数,然后强制按照带符号整数(格式符%d)进行打印输出。该程序的执行结果是,在基于IA-32的32位系统上执行结果为a=0;在基于x64的64位系统上执行结果可能为a=0,也可能是a=随机值。
其原因在《横扫Offer 程序员招聘真题详解700题》一书中已经给出了详尽的分析。分析过程中涉及到double型浮点数的表示,IA-32和x64系统中参数传递方式的不同。并且,在该书中还就a改为float类型后的执行结果也进行了讨论。其答案分析说明详见《横扫Offer 程序员招聘真题详解700题》中题4.56。
这里想要与大家分享的是另一个例子,也是关于强制类型转换的。
下图是网上的一个帖子:
想要得到该问题最清晰的结果,只有将该程序的可执行文件进行反汇编,根据反汇编结果进行分析。在分析过程中,发现了以下有趣的结果:
(1)在不同的操作系统中,该程序的执行结果完全不同。
(2)在同一种操作系统的不同编译开发环境中,该程序的执行结果完全不同。
(3)在同一种操作系统的同一个编译开发环境下,不同的目标程序版本(Debug或Release)的执行结果完全不同。
(4)对于同一种操作系统中同一个编译开发环境下的同一种目标程序版本,每次重新
执行结果完全不同。这是由于采用了预防缓冲区溢出攻击的措施导致的。
首先,我们在Linux系统中将使用gcc编译驱动程序生成的该程序的可执行文件进行反汇编,得到主要程序段的机器级表示如图1所示,其中有对应的C语言语句,以及对相应指令功能的说明。
图1 对应程序的机器级表示
根据上述机器级代码可以看出,局部变量a和p在栈中的存放位置如图2所示,其中,变量a被分配在栈中地址为R[esp]+0x28的位置,并被设置了初始值0xa(即十进制的10),指针型变量p被分配在栈中地址R[esp]+0x2c的位置,并被设置了初始值,该值为变量a的地址R[esp]+0x28。图中假定当前栈顶位置为R[esp]=0xbfff0000。
对于第一个printf()语句,在利用call指令进行过程调用之前,其参数*p的传递过程是,将地址R[esp]+0x2c中的变量p作为地址,再通过fldl指令(32位浮点处理器x87 FPU中的装入双精度指令),将该地址开始的64位机器数看成double类型,装入到浮点寄存器栈顶ST(0)中,然后,再通过fstpl指令,将刚才在ST(0)中的浮点数存储到栈中地址为R[esp]+0x4的位置,这个位置上的数据就是printf过程的第二个参数的位置(即*p对应的位置)。在图中当前栈顶位置为R[esp]=0xbfff0000的假定前提条件下,将要打印的值就是机器数0xbfff00280000000a所表示的double型数据的值,是一个大于-2的数。
对于第二个printf()语句,在利用call指令进行过程调用之前,其参数(double)a的传递过程是,将地址R[esp]+0x28中的变量a的值10先存到地址为R[esp]+0x1c的位置,然后再通过fildl指令将该位置上的带符号整数10先等值转换为double型再装入到ST(0)中。请注意,这里的fildl指令比处理第一个printf()语句时用的fldl指令多了一个i,说明应该将装入的机器数看成带符号整数(int型)等值转换为double型。显然,任何情况下打印都是10.000000。
根据上述分析可知,对*p的处理和对(double)a的处理是截然不同的。前者把变量a所在位置的64位机器数直接当成double类型,其中,变量a的32位机器数为低32位,变量a后面高地址中的32位为高位部分,打印结果与变量a后面的高地址中存放的结果密切相关,这个位置存放的是什么机器数与操作系统所规定的地址空间划分(即局部变量存放的栈区的地址特征)、编译器的局部变量地址分配、Debug或Release版本的不同处理等都有关系,因而不同情况下打印的结果千差万别。
(1)由于不同操作系统下的虚拟地址空间划分不同,因而存放局部变量的栈区地址范围不同。即使在Windows系统下的局部变量分配方式与图3.12所示的Linux系统下局部变量分配方式一样,也是按a和p的顺序从小地址向大地址连续分配,其打印结果也不同于Linux系统。通常,32位Linux系统中用户栈在靠近0xc0000000的更小地址位置,而在32位Windows中用户栈的位置大多是0x0012ffxx或0x0022ffxx等形式,即高8位地址应该是全0。若在Windows系统下打印的*p表示的机器数为0x0012ffxx0000000a,则结果为一个非规格化浮点数,其真值接近于2-1023,打印结果为0.000000。即该帖子中显示的结果。
图
3 Windows
系统下在栈区分配的局部变量地址
(2)即使在同一种操作系统中,不同编译开发环境下,该程序的执行结果也可能不同。因为不同编译开发环境下局部变量的分配顺序和对齐方式等都可能不同,因而,*p表示的机器数高32位不一定正好是上面所说的变量a的地址(即p的值)。如图3所示,给出了Windows系统中不同编译器的局部变量分配方式,显然三种情况都与图2中所示的Linux不同,都不是从小地址向大地址连续分配,而且三种情况下的局部变量地址也各不相同。由此可知,即使是相同操作系统,如果不同的编译开发环境,其执行结果也可能不同。
(3)在同一种操作系统的同一个编译开发环境下,不同的目标程序版本(Debug或Release)的执行结果也可能不一样。例如,在Windows下的VS环境中,Debug版本下的执行结果总是一个确定的负数。因为VS中变量a和p按大地址到小地址的顺序进行分配,而且Debug版本下程序会先将main函数的栈帧每个字节都初始化为0xcc,如图4所示,要打印的*p的机器数总是0xcccccccc0000000a,符号位为1(负数),阶码为100 1100 1100,指数为1228–1023 = 205,数量级为2205,即1061。
图 4 VS 开发环境下的 Debug 版本
在Release版本下,main的栈帧中最开始总是0,而且不会用0xcc初始化,因而,打印的机器数为0x000000000000000a,显然这是一个接近0的数,用%f格式打印结果为0.000000。如图5所示。
图 5 VS 开发环境下的 Release 版本
(4)对于同一种操作系统中同一个编译开发环境下的同一种目标程序版本,每次重新执行结果也可能不同。这是因为有些系统为了防止缓冲区溢出攻击而采用了一种地址空间随机化(栈随机化)策略。通过在加载程序时将生成的代码段、静态数据段、堆区、动态库和栈区各部分的首地址进行随机化处理,使每次启动时,程序各段被随机加载到不同地址起始处。随机的栈起始位置,使得攻击者不太容易确定一个过程的返回地址所存放的位置,也就较难把恶意代码起始地址植入准确的返回地址位置处。例如,在Linux中因为栈区起始地址是随机的,因而每次局部变量a所在的地址就是不同的,作为要打印的*p的高32位机器数就不同,因而每次打印结果有差别。图6所示的结果就是在Linux环境1(Kernel 4.4.0-34-generic i686、gcc(Ubuntu 5.4.0-6 ubuntu1~16.04.2) 5.4.0 20160609)中随机执行3次得到的不同结果,都是比-2大的负数。这种开发环境下,变量a的地址都是形如0xbfxxxxxx,显然a被分配在用户栈中。
图 6 在 Linux 系统环境 1 中的不同执行结果
如果换成Linux环境2(Kernel ***.0-32-generic x86_64、gcc(Ubuntu 4.8.4-2 ubuntu1~14.04.3) 4.8.4),结果也是每次都不同,打印的值是绝对值非常大的负数,如图7所示。这种开发环境下,变量a的地址都是形如0xffxxxxxx,作为64位double型机器数的高32位,其阶码非常大,因而绝对值很大,但符号位为1,即负数。特别是最后一个0xfff61e38,因为是全1阶码非0尾数,因而显示结果为非数-nan。
图7 在Linux系统环境2中的不同执行结果
从上述图6和图7中给出的两种不同Linux环境下每次执行结果不同可以看出,地址空间随机化(栈随机化)策略是Linux中使用的一种预防缓冲区溢出攻击的策略。
此外,还有一种栈破坏检测技术也可用于预防缓冲区溢出攻击。在一个过程的开始阶段,编译器在其栈帧中缓冲区底部与保存寄存器之间加入一个随机生成的特定值,称为“金丝雀”值;在过程的结束阶段,在恢复寄存器并返回到调用过程之前,先检查该值是否被改变。若改变则程序异常中止。因为插入在栈帧中的特定值是随机生成的,所以攻击者很难猜测出它是什么。如图8所示,给出了本题程序在Windows的VS开发环境中的Dubug版本的情况,可以看出,在对栈帧用0xcc进行初始化以后,在R[ebp]-4的位置加入了一个特殊的由Security cookie和R[ebp]异或后得到的值。
图8 栈破环检测代码示例
《横扫offer---程序员招聘真题详解700题》,开点工作室著,清华大学出版社出版,天猫、京东等各大网上书店及实体书店均已开始发售。