【渗透测试之二进制安全系列】格式化漏洞揭秘(一)

相信学习过C语言的童鞋儿们,都有接触过比较基础的输入输出函数(例如,scanf和printf等),那么对于%s、%d、%f、%c、%x等格式化符号应该并不会感到陌生。学习过汇编语言,并且有逆向工程基础的童鞋儿们,应该都对C语言翻译成汇编语言代码的大概格式会有所了解!

我们应该知道,函数参数和函数内部的局部变量的值内容都是存储在栈中的。不知道”栈“这个名词的童鞋儿们可以去学习下数据结构中的”栈结构“,这是非常重要的基础知识!必须要理解并且熟练掌握!”栈结构“的”先进后出“原则,大家一定要深入理解!

对于函数参数而言,在32位操作系统中,最后一个参数最先入栈,第一个参数最后入栈,第一个参数最先出栈,最后一个参数最后出栈,正是所谓的”先进而后出”!在栈中数据排列的顺序中,栈中第一个函数参数数据下面的栈空间中存储着函数的返回地址,栈中函数返回地址下面的栈空间中存储着EBP寄存器的值内容。

注意,在32位操作系统中,函数参数值大小一般情况下为4个字节(即每次PUSH入栈的字节长度为4个字节)(但double、long long类型是个例外,遇到 double、long long 类型的函数参数,每个 double、long long 类型的函数参数会以PUSH两次的方式(高32位先入栈,低32位后入栈)将8字节(64位)长度数据入栈)。无论是字符型(char)还是整型(int),亦或者是地址类型(*)的函数参数,都会以4字节的数据长度大小入栈(PUSH <数据内容值>)!

数组本质上,是内存地址!结构体本质上,是内存地址!函数本质上,还是内存地址!

当遇到数组、结构体、函数类型的函数参数传递时,传递的也是内存地址,也就是(*)地址型数据!那么传递结构体类型的指针变量的值到函数中(指针变量作为函数参数)和传递结构体类型的普通变量到函数中(普通变量作为函数参数)会存在什么区别吗?

这里,我们要弄清楚,结构体变量的本质是什么!结构体类型的指针变量的本质是什么!

那么,结构体变量的本质是什么呢?是内存地址!内存地址!内存地址!重要的事情说三遍!

结构体类型的指针变量更像一个盘子,而盘子上摆放着结构体变量在内存区域中的起始地址(也可简称为“首地址”)

结构体类型的普通变量和结构体类型的指针变量作为函数参数进行传递时,传递的内容是相同的!

结构体类型的指针变量中存放的是结构体类型的普通变量在内存区域中的起始地址!

结构体类型的普通变量,直接代表着结构体类型的普通变量在内存区域中的起始地址!

根据值传递原则,由于结构体类型的普通变量和结构体类型的指针变量都指向了结构体类型的普通变量在内存区域中的起始地址,所以在使用结构体类型的普通变量和结构体类型的指针变量进行函数参数传递时,传递的值是相同的!此类原则,在传递数组型的普通变量和指向数组的指针变量时,同样的适用!对于初学者,应搞清楚数组、结构体、指针变量等概念的内容本质!这很重要!

那么,在64位操作系统中,函数参数又是在栈中如何进行存储的呢?

在64位操作系统中,函数参数的存储方式对比32位操作系统而言,是有明显变化的!

在64位操作系统中,部分函数参数的值内容被存储在寄存器中部分函数参数的值内容被存储在栈结构空间中!

64位操作系统中,不同编译器使用不同寄存器传递函数参数

MSVC编译器中,会使用RCX、RDX、R8、R9寄存器传递前四个函数参数其它函数参数仍然会利用栈空间结构以“先进后出”的方式进行函数参数传递!

GCC编译器中,会使用RDI、RSI、RDX、RCX、R8、R9寄存器传递前六个函数参数其它函数参数仍然会利用栈空间结构以“先进后出”的方式进行函数参数传递!

当然,总会有例外情况存在!对于浮点型的数据(float、double)不同编译器有时也会采用XMM系列寄存器来进行函数参数传递XMM系列寄存器128位长度的,与其它的寄存器不同,XMM系列的寄存器只能使用特定的SSE指令集对寄存器中的数据进行操作!

我们来看两段代码,这两段代码分别是C语言源码和反汇编代码。

先看C语言源码:

#include <stdio.h>

void show_1(char string[] ){
    printf("%s\n",string);
}

void show_2(char * string ){
    printf("%s\n",string);
}

int main() {
    char string[17] = {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f','\0'};
    char * string_point = string;
    show_1(string);
    show_1(string_point);
    show_2(string_point);
    return 0;
}

接下来,再看反汇编代码:

; Attributes: bp-based frame

; void __cdecl show_1(char *string)
public show_1
show_1 proc near

string= qword ptr -8

; __unwind { // 55829E03C000
push    rbp
mov     rbp, rsp
sub     rsp, 10h
mov     [rbp+string], rdi
mov     rax, [rbp+string]
mov     rdi, rax        ; s
call    _puts
nop
leave
retn
; } // starts at 55829E03D139
show_1 endp

; Attributes: bp-based frame

; void __cdecl show_2(char *string)
public show_2
show_2 proc near

string= qword ptr -8

; __unwind { // 55829E03C000
push    rbp
mov     rbp, rsp
sub     rsp, 10h
mov     [rbp+string], rdi
mov     rax, [rbp+string]
mov     rdi, rax        ; s
call    _puts
nop
leave
retn
; } // starts at 55829E03D154
show_2 endp

; Attributes: bp-based frame

; int __fastcall main(int argc, const char **argv, const char **envp)
public main
main proc near

string= byte ptr -20h
string_point= qword ptr -8

; __unwind { // 55829E03C000
push    rbp
mov     rbp, rsp
sub     rsp, 20h
mov     rax, 3736353433323130h
mov     rdx, 6665646362613938h
mov     qword ptr [rbp+string], rax
mov     qword ptr [rbp+string+8], rdx
mov     [rbp+string+10h], 0
lea     rax, [rbp+string]
mov     rdi, rax        ; string
call    show_1
lea     rax, [rbp+string]
mov     [rbp+string_point], rax
mov     rax, [rbp+string_point]
mov     rdi, rax        ; string
call    show_1
mov     rax, [rbp+string_point]
mov     rdi, rax        ; string
call    show_2
mov     eax, 0
leave
retn
; } // starts at 55829E03D16F
main endp

发现了什么吗?注意直接将数组做为函数参数和将数组指针作为函数参数之间存在什么不同呢?

注意观察:

lea     rax, [rbp+string]
mov     rdi, rax        ; string
call    show_1

mov     rax, [rbp+string_point]
mov     rdi, rax        ; string
call    show_1

mov     rax, [rbp+string_point]
mov     rdi, rax        ; string
call    show_2

通过观察上面的代码,我们会发现!最终传递到RDI寄存器GCC编译器下,在64位系统环境使用RDI寄存器传递函数第一个参数的值)中的内容,都是string这个字符数组在内存中的起始地址(首地址)!也就是说,直接使用数组名和直接使用保存了数组首地址的指针变量来作为函数参数,其达到传参效果是几乎相同的!数组名和指向数组的指针变量都指向一个相同的内存地址!

话题说回本文正题,对于格式化漏洞,我们还要提一下函数的汇编语言实现方式!

我们知道 printf 函数,可以只传递一个参数(例如,纯字符串数据),也可以传递多个参数(例如,第一个函数参数为包含格式化符号的字符串数据,其它函数参数,则按照第一个函数参数中的格式化符号的类型与排列顺序进行相应的函数参数传递)。这些都是比较常规的 printf 函数用法。

但是,如果我们 printf 函数传递非常规内容呢?

例如,我们使用这样的方式(printf("%s");)来调用 printf 函数,会发生什么呢?!

前面,我们介绍了函数汇编语言实现方式,我们了解到,在32位系统中,将使用栈结构空间来进行“先进后出方式函数参数传递!我们使用汇编语言中的PUSH指令来实现函数参数入栈!那么,大家有没有思考过,printf 函数的参数来源于哪里?又存储在哪里呢?答案是:printf 函数参数存储在栈结构空间中!大家再思考一下,格式化符号“%s”的本质是什么?如何利用格式化符号”%s“在命令行环境中显示出一个字符串?如何界定一个字符串的开始与结束呢?大家是否还记得 ’\0' 这个字符?‘\0' 的ASCII码是 0,对应的十六进制表示为 0x00 !大家还记得,操作系统检测一个字符串结束,是以 ’\0' 字符字符串结束标志吗?!

大家思考一下,如果我们仅仅向 printf 函数传递包含格式化符号的字符串(例如,包含 格式化符号”%s“ 的字符串),而并不传递格式化符号对应的其它参数内容给 printf 函数,会发生什么?!

答案是:会产生格式化漏洞

格式化漏洞的起因,是因为 printf 函数并未对传递的格式化符号对应具体参数值进行匹配性安全检查!导致,即使我们不向 printf 函数传递格式化符号对应的相应参数值,printf 函数的代码也会被照常执行,并打印出会让普通程序员感到有些费解,而却令专业黑客非常感兴趣的内容!那就是栈结构空间的内容,从低位地址向高位地址的内容开始输出栈结构空间的所有内容(遇到 ”\0“ 字符后停止)!这就是格式化漏洞危害

如果一些程序员在使用 printf 函数打印相应内容时,并没有对传递给 printf 函数参数进行安全检查,那么就会导致格式化漏洞的产生!大家要注意,栈空间中是存储着一些函数返回地址的!一些函数参数如果是函数指针类型的数据,那么一些函数指针对应的内存地址也会被暴露

由于篇幅内容过多,这篇《【渗透测试之二进制安全系列】格式化漏洞揭秘》的文章已经进行了分章节处理!对于格式化漏洞的内容而言,涉及到的知识点内容很多!在复杂的编程环境中,即使是函数参数传递这块的内容,也会存在很多的变化!例如,传递的函数参数中是否存在浮点型数据,会影响到汇编语言对于寄存器的调用!而浮点型函数参数的传递顺序,也会影响着对于寄存器的调用!

更多内容,请阅读下一篇!

  • 25
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值