推荐一本书《C++反汇编与逆向分析技术揭秘》
业精于勤而荒于嬉,行成于思而毁于随。
计算机执行机器代码,用字节序列编码低级的操作
编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例
经过一系列的阶段生成机器代码
GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示
然后GCC调用汇编器和链接器,根据汇编代码生产可执行的机器代码
高级语言(如Java语言 C语言 等),机器屏蔽了程序的细节,即机器级的实现
与此相反,早期程序员用汇编代码编程时,必须指定程序用来执行计算的低级指令
高级语言提供的抽象级别比较高,在这种抽象级别上工作效率会更高,也更靠谱
用高级语言编写的程序可以在很多不同的机器上编译和执行
而汇编代码则是与特定的机器密切相关
对程序员来说,能够阅读和理解会变代码是一项重要的技能
能理解编译器的优化能力,分析代码中隐含的低效率
许多攻击利用了系统程序中的漏洞重写信息,了解这些漏洞如何出现,如何防御
需要具备程序的机器表示的知识
源代码与对应的汇编代码的关系通常不太容易理解,这是一种逆向工程(reverse engineering)
通过研究系统和逆向工作,来试图了解系统的穿件过程
32位机器只能使用大概4GB的随机访问处理器
64位机器能够使用躲达256TB的内存空间,很容易就能扩展至16EB
Intel处理器系列,俗称x86,经历了一个长期,不断变化的发展过程
GCC C编译器,是Linux上面的默认编译器
-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级
使用较高级别优化产生的代码会严重变形
实际上GCC命令调用了一整套的程序,将源代码转换成可执行代码
首先,C预处理器扩展源代码,插入所有用#include命令指定的文件
并扩展所有用#define声明指定的宏
其次,编译器产生两个源文件的汇编代码,p1.s p2.s
接下来,汇编器会将汇编代码转换成二进制目标代码文件p1.o p2.o
目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但还没有填入全局值地址
最后,链接器将两个目标文件与实现库函数(如printf函数)的代码合并
并最终生成可执行文件p (有命令行指示符 -o p 指定的)
环境:
.i 文件:
.s 文件:
.o 文件:
用objdump来查看
计算机使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节
对于机器编程来说,其中两种抽象非常重要
第一种是由指令集体系结构 或 指令集结构(ISA),来定义机器级程序的格式和行为
它定义了处理器状态,指令的格式,以及每条指令对状态的影响
第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型是看上去非常大的字节数组
在整个编译过程中,编译器会完成大部分工作,将用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令
汇编代码表示,非常接近机器代码,与机器代码的二进制格式相比
汇编代码的主要特点是它用可读性更好的文本格式表示
能够理解汇编代码以及它与原始C代码的联系,是理解计算机如何执行程序的关键一步
X86-64 的机器代码和原始的C代码差别非常大,一些通常对C语言程序员隐藏的处理器状态都是可见的:
程序计数器(通常称为 PC,在x86-64 中用%rip表示),给出将要执行的下一条指令在内存中的地址
程序内容包括:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回运行时栈,以及用户分配的内存块
一条机器指令只执行一个非常基本的操作
如,存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据
编译器必须产生这些指令的序列,从而实现程序结构
查看一下这个函数的汇编表示形式
环境: 为了可观,这里我们用windows
Windows10 1511, vs2017
每句C语言都对应一些汇编语言,汇编指令的左边显示了机器代码,16进制
这就是机器代码和汇编代码还有C代码的对应关系
关于机器代码和它的反汇编表示的特性值得注意:
假设文件demo.c有下面这样的函数:
还有一个 mstore.c
生成可执行文件 demo
然后用objdump –d demo 查看反汇编,这里我们保存为demo.txt,打开查看
GCC 产生的汇编代码不客观,包含一些不需要关心的信息,它不提供任何程序的描述或它是如何工作的描述,例如利用如下代码生成文件 mstore.s
Gcc –Og –S mstore.c
Mstore.s的完整内容如下:
所有以 . 开头的行都是指导汇编器和链接器工作的伪指令
Intel汇编代码格式:
由于是从16为体系结构扩展成32位的,Intel用术语“字”表示16位数据类型
因此,称32位数为“双字”(double words),称64位为“四字”(quad words)
一个x86-64的中央处理单元包含一组16个存储64位值的通用目的寄存器
用来存储整数数据的指针
它们的名字都以%r开头,后面还跟着一些不同的命名规则的名字
这是由于指令集历史演化造成的
最开始8086中有8个16位的寄存器 %ax 到 %sp
扩展到IA32 架构时,这些寄存器也就扩展成32位寄存器, %eax 到 %ebp
扩展到x86-64后,原来8个寄存器扩展成64位, %rax 到 %rbp
还增加了9个新的寄存器,%r8 到 %r15
大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置
指令编码结构,参考英特尔开发手册
X86-64支持多种操作数格式
源数据值可以以常数形式给出,或是从寄存器或内存中读出
结果可以存放在寄存器或内存中
因此,各种不同的操作数的可能性被分为三种类型:
第一种类型是立即数(immediate),用来表示常数值,在ATT格式的汇编代码中书写形式是‘$’后面跟一个用标准C表示法表示的整数,比如, $-577 $0x1F
不同指令允许的立即数范围不同
第二种类型是寄存器(register),它表示某个寄存器的内容,参考上图
第三类操作数是内存引用,它会根据计算出来的地址(有效地址)访问某个内存地址,参考上图
有多种不同的寻址模式,允许不同形式的内存引用,参考上图
Intel汇编操作数方向:Mov 目标操作数,源操作数
ATT汇编操作数方向相反
源操作数指定的值是一个立即数,存储在寄存器或内存中
目标操作数指定一个位置,要么是寄存器,要么是一个内存地址
传输指令的两个操作数都不能同时为内存
两个操作数的大小必须相同
可看到生成的汇编,两个赋值都是有3条汇编指令,中间借助寄存器,而不是直接内存到内存
C语言中,所谓的指针,随便你把它看作什么,就是一串数据
(如果你能把指针玩得非常熟练的情况下)
Push指令,把数据压入到栈上,而pop指令是弹出数据
这些指令都只有一个操作数—压入的数据源和弹出的数据目的
Push rax 等价于 sub rsp,8 mov [rsp],rax 先执行前面的,然后执行后面的
Pop rax 等价于 mov rax,[rsp] add rsp,8 同上
根据栈是从高地址向低地址扩展,push 和 pop 得按图上这样的顺序压入 弹出
加载有效地址实际上是mov指令的变形
例如,lea eax,dword ptr ds:[xxx]
取的不是XXX内存地址的值,而是XXX的内存地址
它们描述了最近的算数或逻辑操作的熟悉,可以检测这些寄存器来执行条件分支指令
访问条件码
条件码通常不会直接读取,常用的使用方法有三种
1.可以根据条件码的某种组合将一个字节设置为0或者1
2.可以条件跳转到程序的某个其他部分
3.可以有条件地传送数据
环境:windows10 1511, vs2017 Debug x86
//汇编代码
//程序流程图
JGE(大于或等于)判断,判断依据:SF OF 同时为0 或 1
JLE(小于或等于)判断,判断依据:ZF == 1 | SF != OF
JNE(如果不等于)判断,判断依据:ZF == 0
JE(如果等于)判断,判断依据: ZF == 1
对于这些,我是死记硬背的
有一个结论,反汇编中的判断和C语言的判断,是相反的
环境:windows10 1511, vs2017 Debug x86
//汇编代码
屏幕太小,不好截图,谅解
//程序流程图
红色的线为不成立,绿色的线为成了,蓝色的线为每个分支执行完后跳转到的地址
While循环
//汇编代码
//程序流程图
红色线为不成立,绿色线为成立,蓝色线为执行完后跳往哪,后边就不重复了
可以看到,程序执行下来首先判断,如果判断不成立(反汇编层),就执行循环体
循环体执行完后又返回刚下来判断的地方,以此类推
//汇编代码
//程序流程图
可以看到,程序下来就直接JMP到判断的地方,如果不成立(红线)就执行循环体
循环体执行完后(蓝线)就跳转到让NUM局部变量自增1,随后又跳转到判断的地方
以此类推
Do…while 循环
//汇编代码
//程序流程图
Do…while循环,不管判断成不成立,至少会循环一次,由图可见
JL 如果小于,SF != OF
首先看下小于4个的情况下
//汇编代码
//程序流程图
和IF…ELSE相比,可以看出,switch结构将所有条件跳转都放置在一起
而IF…ELSE会在条件跳转后边紧跟着语句块
由图可以看出,Break;语句就直接跳转到了结束处
下面来看下超过4个且连续的情况
//汇编代码
屏幕分辨率小,无法展示
//程序流程图
可以看出,生成了一张表
程序下来,用变量x和十进制10相减,得到的一个数值其实是这张表的索引
如果索引大于4,就跳转到结尾处
Index * 4 + 表的首地址 , 就是该跳往的地址
注意我的编译环境是: windows10 1511, vs2017 x86
三种调用约定:
C/C++默认是 _cdecl
__stdcall 从右至左的顺序将参数压栈, 调用者平衡栈
__cdecl 从右至左的顺序将参数压栈,调用者平衡栈
__fastcall 使用ECX和EDX寄存器传送前两个DWORD或更小的参数,其他参数依次从右至左入栈,被调用者平衡栈
环境: windows7 64 vc6.0
//汇编代码
//从MAIN开始,一行一行跟一下就行了
关于栈,后进先出,有借有还-再借不难
CALL 0x12345678
可以看成
Push 下一条指令的地址
Mov eip, 0x12345678 当然这样式不能修改EIP的,但是相当于这样
RET
Mov EIP,当前ESP的值
ESP 再加4
如果后边跟操作数,那就让esp+4+操作数
环境: xp vc6
只是内存上一串连续的数据,定义的类型不同,分配的空间也就不同
测试环境: XP vc6.0
当执行完arr[4] = (int)test 发现原来的返回地址被修改了
关于缓冲区溢出简单的演示一遍,详情看书吧
…