如果想写出真正高效优质的程序,必须了解程序的执行细节,即程序在机器级别上的执行情况;然而由于C语言的高度抽象性,已经掩盖了程序在机器上的具体实现情况。所以,我们需要借助研究程序机器指令的汇编代码来了解这些细节。通过阅读这些细节,我们会发现被抽象化所掩盖的诸多真相,诸如存储器的越界引用,并由此理解缓冲区溢出攻击。本章的核心在于培养如何阅读一个程序机器指令的反汇编代码。
1.获得程序的汇编代码
方法一:在对file.c编译时,中断在汇编阶段,如gcc -o1 -S file.c
方法二:在对可执行文件file利用反汇编器反汇编,如objdump -D file
difference:方法一中的文件没有经过Link,所以全局变量的地址仍未确定;而方法二中是对已经Link过的文件进行反汇编,其中的全局变量已经有了具体的地址
2.汇编代码的格式
Linux默认的是ATT格式,而Microsoft以及来自Intel的文档都是Intel格式的。在Linux下可以使用命令来得到Intel格式:gcc -o1 -S -masm-intel file.c 注意它们的区别,对于Intel代码而言:
省略了指示大小的后缀——mov,add
省略了寄存器前面的%符号——eax,esp
采用不同方式来描述存储器中的位置——DWORD PTR [ebp 8]而不是8(p)
对于多个操作数的操作列出的顺序相反——add eax $5而不是add $5 eax
×××
了解了基本的汇编格式,来具体了解汇编指令与C语言的翻译关系
×××
3.汇编数据格式&操作数指示符
由于是从16位体系结构扩展成32位的,Intel术语用word-16bits,用double words-32bits,用quad words-64bits;
汇编代码后缀:
b--1Byte
w--2Byte
l--4Byte
对于操作数而言;
常数(立即数)——$Imm, Exa: $0x23
寄存器——x
存储器——
绝对寻址:Imm--->*Imm
间接寻址:(x)--->*Value(x)
通用格式:Imm(E1,E2,s)--->Imm E1 E2*s s=1,2,4,8
4.汇编指令——数据传送指令
mov(movb,movw,movl) S, D ( D<--S)
movs(符号扩展传送)
movz(零扩展传送)
pushl S <==> sub $4, %esp
movl S,(%esp)
popl D <==> movl (%esp),D
add $4,%esp
(IA32中的栈是向下增长的,即栈顶元素地址是所有栈中元素地址中最低的)
5.汇编指令——算术和逻辑操作
(1)加载有效地址指令(load effective address):leal S,D<==>D<--&S. leal指令形式为从存储器读数据到寄存器,但实际上根本没有引用存储器,真正的操作是将有效地址写入到目的操作数。
Exa: leal 6(%ebp), %eax
(2)一元操作
++:INC D<==> D<--D+1
--: DEC D<==> D<--D-1
-:NEG D<==> D<--(-D)
~:NOT D<==> D<--~D
(3)二元操作
ADD S,D<==>D<--D+S
SUB S,D<==>D<--D-S
IMUL S,D<==>D<--D*S
XOR S,D<==>D<--D^S
OR S,D<==>D<--D|S
AND S,D<==>D<--D&S
PS:对于这里的二元操作符来说,第二个操作数既是源又是目的
(4)移位操作
左移位,右侧通通补0
SAL
k,D<==>D<--D<SHL
右移位
算术右移(右侧补最高符号位)SAR
逻辑右移(右侧补0)
6.汇编代码结构——C语言控制结构的翻译
通常情况下C语言中的语句和机器代码中的指令都是按照它们在程序中出现的次序顺序执行的,如果要更改程序的执行流程,需要使用jmp指令跳到制定的目的地址。
条件码——可以通过条件码来设定依据不同的条件来实现指令流程的跳转。对于条件码寄存器(1bit),如CF,OF,ZF,SF,我们可以使用两个基本的比较指令:CMP S2,S1 based on S1-S2;TEST S2, S1 based on S1&S2。CMP用来比较S1和S2的大小,而TEST后面跟两个相同的操作数时用来判断其符号(正负0)
jump指令——
直接跳转:jmp .L1(这里L1是Label 1)
间接跳转:jmp *%eax ; jmp *(%eax)
Key:理解跳转指令的目标如何编码。汇编代码中,通常采用PC-relative编码目标地址,即以目标指令的地址与紧跟在跳转指令后面的那条指令的地址之间的差作为编码。也可以使用4个字节直接指定目标作为绝对地址给出。
条件分支——将条件码指令与跳转指令结合起来便可以实现条件分支结构。如:
testl %eax, %eax
jle .L3(若%eax的值小于等于0,则跳转到标号.L3处)
循环机构——循环结构将do-while结构,while结构,for结构通通转化为do-while结构进行
*
do-while结构
loop:
body-statement
t=test-expr;
if(t)
goto loop;
*
while循环
t=test-expr;
if(!t) //这里需要先判断条件是否成立
goto done;
loop:
body-statement
t=test-expr;
if(t)
goto loop;
done;
*
for循环
init-expr
t=test-expr;
if(!t)
goto done;
loop:
body-statement
update-expr;
t=test-expr;
if(t)
goto loop;
done;
*******
*******
Switch语句——Switch语句的实现同样需要反复的使用条件分支实现。要注意区分:标号的范围;默认标号的指令,使用相同指令的标号等。
7.过程——栈帧结构
一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。像IA32之类的机器提供的指令很简单,只负责转移控制到过程和从过程中转移出控制。数据传递,局部变量的分配和释放通过操纵程序栈来实现。
×××××××××××××××
【20120523补充】今天为了准备学习《黑客反汇编》这本书,拿出来又看了一遍这部分。IA32实现过程调用时,代码间的控制传递有专门的Call和Ret指令完成,而数据的传递则全部依靠栈帧。所谓栈:指的是整体是由高地址向低地址增长的栈结构;所谓帧:父子过程的区分用帧来划界,即当前的%ebp指向的是当前控制所在的过程。而过程开始时首先压栈返回地址,紧接着压栈父过程的帧地址,为了子过程结束时可以找到父过程数据段继续执行。注意,指令和数据是两码事。
×××××××××××××××
*
call&ret
call next<==>push %eip
jmp next
ret <==>pop %eip
jmp %eip
*
寄存器使用惯例
调用者保存寄存器——%eax,%edx,%ecx。这些寄存器在发生过程调用时由父过程保存,子过程可以覆盖这些寄存器,而不会破坏父过程所需要的数据
被调用者寄存器——%ebx,%esi,%edi。这些寄存器在发生过程调用时由子过程保存,因此需要先将原先的值入栈保存,过程结束返回时再出栈回复寄存器的原先值。
通常建立一个栈的最初步骤:
push %ebp
movl %esp, %ebp
pushl %ebx(when need)
C语言中的其他结构如数组,结构体,联合体等也有相对应的汇编代码实现。
8.缓冲区溢出
由于C对于数组引用不进行任何边界检查,而且局部变量和状态信息(如ret)都放在栈中,这就会导致当实际输入的字节长度超过分配的长度时会导致合法数据被重写,严重的情况如果ret被重写,就会执行任意恶意代码。防范缓冲区溢出攻击在Linux中,通过修改GCC有两种机制:
栈随机化——使得每次分配栈的时候地址都不同,这样在一台机器上调试成功的缓冲区溢出程序在另外的机器上便很难成功
栈破坏检测——在缓冲区实现需要修改的栈的关键字节中间插入我们的标识字段,若缓冲区溢出则会修改标识字段,一旦标识字段被修改便报警受到攻击。
限制可执行代码区域——通常只有保存编译器产生的代码的那部分存储器才需要是可执行的,其余部分只赋给读和写的权限。
以上三种机制可以用于最小化缓冲区攻击漏洞,但并不能从根本上防止缓冲区溢出攻击。
【20120523补充】
9.数据对齐
所谓的数据对齐其实指的是基本类型数据的地址都是某个K值的倍数。之所以这样规定,是为了使处理器从存储器中读写数据时减少数据错位的次数。比如处理器一次可以读取8个字节,如果数据不是8-对齐,那么很有可能读取的数据分列在两个8字节块中,处理器就需要读取两次才可以完成。为了避免这种情况,大多数时候都要求数据对齐,而对齐的基本规则是基本数据类型是K个字节,它的数据存储就要求是K-对齐的,即所有数据的地址都是K的倍数。
阅读(2246) | 评论(0) | 转发(0) |