格式化字符串的问题可以称得上是C语言的“家丑”之一,因为它是伴随着C
语言标准库中的格式化输入/输出函数而来的,而与C语言同时代的其他主要语言
如BASIC,Pascal等,都不会以这种风格来进行输入/输出操作。
本来,一种计算机语言有所欠缺,并不是什么大不了的事情。但是当这种语
言被用来编写当今世界上所有主流操作系统,以及为数众多的大型应用软件时,
它的这一缺陷就成为不可忽视的问题了。
那么究竟什么是格式化字符串问题呢?大多数C语言教材的入门章节都会有
一个“Hello, world!”或者类似的示例程序,出现在大家眼前的第一个函数便
是printf,问题也就出在它这类函数身上。我们就从它开始谈起。
printf的参数由一个格式化字符串和一个可变数目参数列表组成:
int printf (const char *szFormat, va_arg argumentlist);
格式化字符串szFormat中的大部分字符都会按照原样输出,只是在碰到以'%'打
头的格式指示符时会到后面的参数表argumentlist中取一个参数,将其按指定格
式转换成字符序列并取代格式指示符本身;再碰到一个格式指示符,再取一个参
数,再转换一次;如此继续下去,直到不再碰到新的格式指示符为止。
由于argumentlist声明成数目不定,printf的函数体无从知道每次调用时,
调用者给它提供了几个参数,因而它判断参数数目时是以对szFormat的分析结果
为准的,也就是说,szFormat中有多少个格式指示符,它就期待本次调用有多少
个参数。
但从调用者的角度来说,既然参数数目可变,对于同一个格式化字符串,提
供多少个参数都是合法的。事实上,问题就出在,C语言中并没有这样一种检查
机制,来保证szFormat中的格式指示符数目与argumentlist中的参数数目,两者
的一致性。
在提供的参数数目多于格式指示符数目的情形,大不了多余的参数被忽略,
还不至于造成什么后果。但如果少提供了参数,当argumentlist中的实参已被用
尽时,printf并不会意识到这一点,只要szFormat中还有剩余的格式指示符,它
就继续往下一个存储单元中取东西出来格式化。此时的运行结果,许多C语言的
参考资料上都说是“未定义”,这“未定义”三字中包含着诸多的不安全因素。
我们来看一个具体的例子。 ;=========演示格式化串的问题例子,由MASM编写==================
.386
.model flat, stdcall
option casemap: none
include windows.inc
include user32.inc
includelib user32.lib
.data
szMess db 128 dup (0)
.const
szMessCap db 'message', 0
szFmtStr db '%x', 0
.code
_NothingMuch proc
local loc_dwA: dword
mov loc_dwA, 12387645h
invoke wsprintf, offset szMess, offset szFmtStr ;%x = ???
invoke MessageBox, NULL, offset szMess, offset szMessCap, MB_OK
ret
_NothingMuch endp
START:
call _NothingMuch
ret
end START
;=========演示格式化串的问题例子,由MASM编写================== 在这个例子中,代替printf,我们用了wsprintf,它相当于是前者在Win32环境
中的某种“本地化”。
注意调用wsprintf一句,我们给它提供的格式化串szFmtStr中有一个格式指
示符"%x",理应给这个"%x"提供一个参数,但现在我们故意将它省略。为了说明
这样做造成的结果,在wsprintf的入口处设置断点,就可以看到此时栈的情况,
如下图所示:
wsprintf函数在所提供的格式化串中发现了格式指示符"%x",它就期待着参数
offset szFmtStr的下一个存储单元应该是为这个"%x"所准备的参数,但现在这
个参数我们并没有提供给它,结果就是,它把主调函数_NothingMuch中的局部变
量loc_dwA当成了"%x"的参数,将loc_dwA的内容格式化后写到了szMess中!根据
之前对loc_dwA所赋的值,可以预计输出结果应该是字符串"12387645",这个结
果可以自行验证一下。
不难想像,如果格式化字符串中再多来几个"%x",我们还可以继续访问到过
程_NothingMuch结束后的返回地址,甚至于整个程序结束后返回到系统核心dll
的地址!格式化字符串就是这么“牛”,如果它能被人操纵的话,很容易引起信
息泄露之类的问题。
如果说上面的"%x"格式符被误用造成的只是信息泄露,还不至于对软件运行
构成什么威胁的话,下面来看一个更猛的格式符:"%s",这个就很有来头了,当
前最流行的应用层调试器OllyDBG,想必大家都在使用它吧,现在大家手头的版
本都是经过了无数次改良,修正了诸多漏洞,这些漏洞中就包括一个输出调试字
符串"%s%s%s"的问题,可见在改良之前,象OllyDBG这么功能强大的调试器也经
不起"%s"的折腾。
这究竟是怎么回事呢?当使用"%s"格式符时,printf函数会把取来的参数数
值解释为字符指针,而去取出这个指针所指向的内存片段,作为字符串输出。这
就出现一个问题了,不是任何数值解释成的指针都可以被访问。譬如在Win32的
平坦内存模式里,象0x00000000(NULL)这类的指针值就没有办法访问,一旦访
问,就会引起“访问违例”异常。看,调试器进程中出现异常,它就会被Windows
崩掉,被调试程序就偷着乐了。
下面就来模拟这种情况,看这个例子: ;===========受控格式串的不安全性例子,由MASM编写====================
.386
.model flat, stdcall
option casemap: none
include windows.inc
include kernel32.inc
include user32.inc
includelib kernel32.lib
includelib user32.lib
DLG_MAIN equ 1000
EDIT_1 equ 1002
ID_BTN1 equ 1003
.data
hDlgMain dd ?
szUserName db 96 dup (0)
szBuff db 100 dup (0)
szMess db 200 dup (0)
.const
szHello db 'Hello, ', 0
szMessCap db 'message', 0
szTest db 'Test', 0
.code
_PrintGreetings proc
local loc_dwTemp1, loc_dwTemp2
mov loc_dwTemp1, 1
mov loc_dwTemp2, offset szTest
invoke GetDlgItemText, hDlgMain, EDIT_1, offset szUserName, 80
invoke lstrcpy, offset szBuff, offset szHello
invoke lstrcat, offset szBuff, offset szUserName
invoke wsprintf, offset szMess, offset szBuff
invoke MessageBox, hDlgMain, offset szMess, offset szMessCap, \
MB_ICONINFORMATION
ret
_PrintGreetings endp
_TheDialogProc proc uses ebx esi edi _hWnd, _uMsg, _wParam, _lParam
local loc_hTemp
mov eax, _uMsg
.if eax == WM_COMMAND
mov eax, _wParam
cmp eax, ID_BTN1
jne @F
call _PrintGreetings
invoke SendMessage, _hWnd, WM_CLOSE, 0, 0
@@:
.elseif eax == WM_INITDIALOG
push _hWnd
pop hDlgMain
invoke GetDlgItem, _hWnd, EDIT_1
mov loc_hTemp, eax
invoke SetFocus, loc_hTemp
invoke SendMessage, loc_hTemp, EM_LIMITTEXT, 80, 0
.elseif eax == WM_CLOSE
invoke EndDialog, _hWnd, 0
.endif
xor eax, eax
ret
_TheDialogProc endp
START:
invoke GetModuleHandle, NULL
invoke DialogBoxParam, eax, DLG_MAIN, NULL, \
offset _TheDialogProc, NULL
ret
end START
;===========受控格式串的不安全性例子,由MASM编写====================
资源脚本如下:
//=================================================================
#define IDD_DLG1 1000
#define IDC_STC1 1001
#define IDC_EDT1 1002
#define IDC_BTN1 1003
IDD_DLG1 DIALOGEX 6,6,331,33
CAPTION "IDD_DLG"
FONT 8,"MS Sans Serif",0,0
STYLE 0x10CF0000
BEGIN
CONTROL "Enter Your Name:",IDC_STC1,"Static",0x50000000,8,9,78,12
CONTROL "",IDC_EDT1,"Edit",0x50010000,100,7,144,15,0x00000200
CONTROL "OK",IDC_BTN1,"Button",0x50010001,254,7,66,15
END
//=================================================================
我们现在就扮演“被调试程序”的角色,向这个程序输入字符串。首先随便输入
一些常见的字符串譬如"John","Bob"之类的,程序的输出也相应地是"Hello,
John","Hello, Bob"等等。但是输入"%s%s",看看结果是什么:
程序崩掉了吧!作为验证,我们输入格式串"a1=%x a2=%x"看看结果:
这是怎么一回事呢?实际上,由于wsprintf的格式串参数中出现了格式指示符,
而又没有给它们提供对应的参数,如前所述,wsprintf就把_PrintGreetings过
程中的两个局部变量分析成参数,拿出来格式化了。这两个局部变量在进入函数
时已被赋值,因此,上图中的402044就是offset szTest,而1就是给loc_dwTemp1
所赋的那个1!如果输入的格式符是"%s%s"的话,数值1就会被解释成字符指针,
而访问0x00000001地址处的内存,当然会出错了!看完这出戏,大家想必对于当
年OllyDBG的“千里长堤”是怎样崩溃在几只小小格式符“蝼蚁”身上的过程,有
所了解了吧。
也许这个例子显得过于做作了些,不那么自然,然而这其中包含的道理却是
相当简明的:使用格式化输入/输出时,作为格式字符串的实参应尽量避免用来接
纳可变的数据。譬如要输出一个字符串str,形式printf("%s", str)是相对安全
的,而printf(str)就不好了,万一str中掺杂了格式指示符,后面这种形式就会
出问题。上面这个例子之所以会有毛病,原因正是由于受输入影响的字符串
szBuff被用来做了格式化串引起的。
PS:本来想打算在上面这个例子中挂一个异常处理程序,这样就能更好地
演示如何利用格式化串漏洞,可惜怎么挂都没有预期效果;printf系列函数本来
还有一个格式指示符"%n"甚至可以反过来向内存写入数据,可惜这个格式符在
VC里又被无效化了,所以有些我认为比较关键的东西都没有写进去,哎,Win32
平台在某些方面就是不如Linux好啊。
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
上传的附件: