第1章 99

Save this PDF as:
 WORD  PNG  TXT  JPG

Size: px
Start display at page:

Download "第1章 99"

Transcription

1 1.1 上机建立第一个工程 用 Visual Studio 创建工程 用 Visual Studio 查看汇编代码 简要复习常用的汇编指令 堆栈相关指令 数据传送指令 跳转与比较指令 C 函数的参数传递过程... 9

2 本书总是假设读者的 母语 是 C/C++ ( 是 Java 或者 PHP? 喔, 还是建议读者首先学习 C 语言 ) 由于 MSMicrosoft 并没有在 Windows 中附带所有的 C 代码, 因此读者遇到的很多将是汇编代码 实际上, 这些代码都是反汇编代码 那么理解汇编代码就成为当前最大的问题 汇编代码之所以看不懂, 除了汇编代码可读性本来就比较差之外, 更重要的一点原因是, 读者的母语是 C 语言 甚至会常常觉得, 自己虽然能看懂每一行指令, 却完全看不懂一段程序在做什么 但是实际上,Windows 的内核代码基本上都是由 C 语言代码编译而成的 这些生成的机器码, 再反汇编得到的汇编语言, 和 C 语言有着千丝万缕的联系 只要熟悉它们之间的对应关系, 像理解 C 语言一样理解反汇编代码, 就完全是可能的了 为了强化这种关系, 本书常常从汇编代码反写为 C 语言, 并称为反 C 何为软件反工程 ( 逆向工程 ) 反汇编与反 C 都是反工程 ( 又称逆向工程 ) 作为编程爱好者 程序员, 大多数人从事 软件工程 的工作 这种工作是从人类可以理解的高级语言编译为机器可以理解的机器码的过程 反工程则是相反的, 是从机器可以理解的机器码, 到人类可以理解的某种形式的过程 其目的在于, 通过别人出售的产品得到别人的工程技术 反工程往往被看作是旁门左道 因此在正式出版的计算机书籍中提及并不多, 可谓讳莫如深 ; 而破解者 黑客 病毒制造者则对此乐此不疲 反工程有专门的软件工具 知识体系, 许多 秘笈 在网络上流传 系统的反工程技术涉及从还原下层逻辑一直到大型软件的顶级架构 这些都不是本书的内容, 也不是作者有能力讲述的范围 本书仅仅利用逆向的最基础能力 : 阅读反汇编的代码, 以加深对 Windows 底层的理解, 增加读者解决下层 bug 的能力 有兴趣了解反工程的读者, 可以阅读 Reversing: 逆向工程解密 此书信息如下 : 英文名 :Reversing:Secrets of Reverse Engineering 中文名 :Reversing: 逆向工程解密作者 : Eldad Eilam;Elliot Chikofsky 译者 : 韩琪 杨艳等 3

3 天书夜读. 从汇编语言到 Windows 内核编程 出版社 : 电子工业出版社 不过, 回归主题, 建议读者还是先继续阅读本书, 继续做好 Windows 底层程序 员这份有前途的职业吧 1.1 上机建立第一个工程 一条指令可能有很多细节, 比如第二个操作数和第一个操作数中, 哪个只能是寄存器, 哪个不能用寄存器加立即数等 如果要用手写汇编语言来开发一个软件的话, 这些知识很重要 但是实际上, 现在需要的是用 C 语言或者更高级的语言进行开发, 只是需要理解一些没有 C 代码的部分 读者可以认为, 那些代码的汇编指令用法一定是正确的, 因为它们是编译器生成的 1.2 节 ( 简要复习常用的汇编指令 ) 将简要地回顾那些指令, 并保证这些回顾非常容易理解 用 Visual Studio 创建工程 建议读者的计算机中操作系统的版本最好是 Windows XP 或者更高的版本, 使用 Vista 问题也不大, 虽然可能操作细节和界面与本书描述的情况稍有不同 然后, 请安装某个版本的 Visual Studio, 推荐 Visual Studio 2003 或者更高的版本 下面的描述同时顾及 Visual Studio 2003 和 Visual Studio 2005 的情况, 语言版本为英文 下面的步骤将在 Visual Studio 上建立一个用于实验的工程 有经验的读者可以忽略下面的描述 打开 Microsoft Visual Studio 2005, 选择主菜单 File 选择子菜单 New 下面的 Project, 打开 New Project 对话框 左边选择 Visual C++,Win32; 右边选择 Win32 Console Application; 下面输入一个工程名, 然后单击 OK 按钮, 出现向导 一切都选择默认设置, 最后单击 Finish 按钮 此时新建立工程的主文件是一个扩展名为.cpp 的文件, 请将它改为扩展名为.c 的文件 4

4 Windows 底层代码基本上都是用 C 语言编写的, 研究 C 语言和汇编指令的关系是 本节的任务 为此, 这个文件必须改变为扩展名为.c 的文件, 这样 VC 才会自动以 C 语 言的方式进行编译 本书后面专门有一章的内容来研究 C++ 下的反汇编阅读 ( 第 5 章用 C++ 编写的内核程序 ) 如果读者使用的是 Visual Studio 2005, 直接在左边的 Solution Explorer 中用鼠 标右键单击文件 YourProjectName.cpp, 选择 Rename, 然后把.cpp 改为.c 即可 如果是更老的 Visual Studio 版本, 请单击右键,Remove 这个文件 然后在外面改 名, 再对工程单击右键, 选择 Add, 再选择 Exist Item, 追加进来 此时程序是这样的 : #include "stdafx.h" int _tmain(int argc, _TCHAR* argv[]) { return 0; } 用 Visual Studio 查看汇编代码 C 语言程序对应的汇编代码, 可以在 VC 中非常清楚地显示出对应关系 但是并不 是所有的读者都知道如何调出汇编指令窗口 这个诀窍在下面描述 VC 必须处于调试状态才能看到汇编指令窗口 因此, 请在 return 0 一句上设置 一个断点 : 把光标移到那一行, 然后按下 F9 键设置一个断点 按下 F5 键调试程序 当程序停止在这一行的时候, 打开菜单 Debug 下的 Windows 子菜单, 选择 Disassembly 这样, 出现一个窗口, 显示下面的信息 : --- f:\root\work\any\t12\t12\t12.c -- // t12.cpp : Defines the entry point for the console application. // #include "stdafx.h" int _tmain(int argc, _TCHAR* argv[]) { push ebp mov ebp,esp sub esp,0c0h push ebx A push esi 5

5 天书夜读. 从汇编语言到 Windows 内核编程 B push edi C lea edi,[ebp-0c0h] mov ecx,30h mov eax,0cccccccch C rep stos dword ptr es:[edi] return 0; E xor eax,eax } pop edi pop esi pop ebx mov esp,ebp pop ebp ret 如果上面的内容完全看不懂, 也许读者需要复习一下汇编指令 以上的汇编指令数 量非常的少, 只需要了解 push mov sub lea stos xor pop ret, 就可以继续本书 的学习之旅了 所以请不用担心, 接下来就会熟悉这些代码 1.2 简要复习常用的汇编指令 堆栈相关指令 对指令的详细了解, 应该查阅 Intel 发布的指令手册 但是从头开始阅读那些手册是一件令人望而生畏的事情 读者暂时可以只初步了解, 忽略一些细节, 当实际用到的时候, 再具体查阅相关指令 push: 把一个 32 位的操作数压入堆栈中 这个操作导致 esp 被减 4 esp 被形象地称为栈顶 我们认为顶部是地址小的区域, 那么, 压入堆栈的数据越多, 这个堆栈也就越堆越高,esp 也就越来越小 在 32 位平台上,esp 每次减少 4( 字节 ) pop: 相反,esp 被加 4, 一个数据出栈 pop 的参数一般是一个寄存器, 栈顶的数据被弹出到这个寄存器中 一般不会吧把 sub add 这样的算术指令, 以及 call ret 这样的跳转指令归入堆栈相关指令中 但是实际上在函数参数传递过程中,sub 和 add 最常用来操作堆栈 ;call 和 ret 对堆栈也有影响 所以这里做特殊处理 6

6 sub: 减法 第一个参数是被减数所在的寄存器 ; 第二个参数是减数 ( 对应的还有 add 指令 ) add: 加法 ret: 返回 相当于跳转回调用函数的地方 ( 对应的 call 指令来调用函数, 返回到 call 之后的下一条指令 ) call: 调用函数 说到这里, 有必要详述一些指令对堆栈的影响 某些指令会 自动 地操作堆栈, 这就是 call 和 jmp 的不同之处 call 指令会把它的下一条指令的地址压入堆栈中, 然后跳转到它调用的函数的开头处 ; 而单纯的 jmp 是不会这样做的 同时,ret 会自动地弹出返回地址 call 的本质相当于 push+jmp ret 的本质相当于 pop+jmp 不但 push pop call 和 ret 会操作堆栈,sub 和 add 也可以用于操作堆栈 如果我要一次在堆栈中分配 4 个 4 字节长整型的空间, 那么没有必要 4 次调用 push, 很简单地把 esp 减去 4*4=16 即可 当然, 也可以同样地用 add 指令来恢复它 这常常用于分配函数局部变量空间, 因为 C 语言函数的局部变量保存在栈里 数据传送指令 mov: 数据移动 第一个参数是目的, 第二个参数是来源 在 C 语言中相当于赋值号 这是最广为人知的指令 xor: 异或 这虽然是逻辑运算的指令, 但是有趣的是,xor eax,eax 这样的操作常常用来代替 mov eax,0 好处是速度更快, 占用字节数更少 lea: 取得地址 ( 第二个参数 ) 后放入到前面的寄存器 ( 第一个参数 ) 中 见到 xor eax,eax, 应该马上明白这是清零操作 但是实际上, 有时候 lea 用来做和 mov 同样的事情, 比如赋值 看下面一条指令 : lea edi,[ebp-0cch] 方括弧表示存储器, 也就是 ebp-0cch 这个地址所指的存储器内容 但是 lea 要求取 7

7 天书夜读. 从汇编语言到 Windows 内核编程 [ebp-0cch] 的地址, 那么地址也就是 ebp-0cch, 这个地址将被放入到 edi 中 换句话说, 这等同于 : mov edi,ebp-0cch 但是以上 mov 指令是错误的, 因为 mov 不支持后一个操作数中加入一个减号 但是 lea 支持, 所以可以用 lea 来代替它 指令的操作数能采用的运算符号有非常复杂的限制 如果需要使用, 应该查询指令手册 为了讲解 stos, 下面解说前面提到的代码 : mov mov rep ecx,30h eax,0cccccccch stos dword ptr es:[edi] stos 是串存储指令, 它的功能是将 eax 中的数据放入 edi 所指的地址中, 同时,edi 会增加 4( 字节数 ) rep 使指令重复执行 ecx 中填写的次数 方括弧表示存储器, 这个地址实际上就是 edi 的内容所指向的地址 这里的 stos 其实对应的是 stosd, 其他还有 stosb stosw, 分别对应于处理 个字节, 这里对堆栈中 30h*4(0c0h) 个字节初始化为 0cch( 也就是 int 3 指令的机器码 ), 这样发生意外时执行堆栈里面的内容会引发调试中断 跳转与比较指令 部分跳转指令如下 jmp: 无条件跳转 这也是多年后我依然未忘记的少量指令之一 jg: 顾名思义, 大于的时候跳转 通常前面有一条比较指令 jl: 顾名思义, 小于的时候跳转 通常前面有一条比较指令 jge: 顾名思义, 大于等于的时候跳转 通常前面有一条比较指令 类似的指令还有一些, 这里就不介绍了 下面介绍一条比较指令 cmp: 顾名思义, 比较 往往是 jg jl jge 之类的条件跳转指令的执行条件 本书在这里只是为了满足阅读后面章节的需要, 简单地介绍了部分汇编指令, 一些 8

8 后面才会碰到的指令则在遇到时再专门提及 对于希望进一步学习汇编语言的读者, 我推荐 Windows 环境下的 32 位汇编语言程序设计 一书 这本书的信息如下 : 书名 :Windows 环境下的 32 位汇编语言程序设计作者 : 罗云彬出版社 : 电子工业出版社相对于复杂艰深的 Intel 指令手册, 此书会比较容易入手 1.3 C 函数的参数传递过程 基础知识 函数调用的本质将在这里得到阐明 首先请读者理解堆栈的操作 函数和堆栈的关系密切, 这是因为 :C 语言程序通过堆栈把参数从函数外部传入到函数内部 此外, 在堆栈中划分区域来容纳函数的内部变量 调用 push 和 pop 指令的时候, 寄存器 esp 用于指向栈顶的位置 栈顶总是栈中地址最小的位置 push 执行的结果,esp 总是减少,pop 则增加 对于 C 程序默认的调用方式, 堆栈总是调用方把参数反序 ( 从右到左 ) 地压入堆栈中, 被调用方把堆栈复原 ( 这些我们会在后面见到 ) 这些参数对齐到机器字长,16 位 32 位 64 位 CPU 下分别对齐到 个字节 这种调用是 C 编译器默认的 C 方式 函数调用规则 在一个编写高级语言的程序员的观念中, 函数 ( 或者没有返回值的过程 ) 是必不可少的基础单元 C 语言的程序完全由函数构成, 所有的代码都在某一个函数中 Pascal 区分函数和过程, 但是本质依然是类似的 对计算机硬件而言, 这种区分毫无必要, 因为 CPU 只关心一条一条的指令, 并不关心它们是以怎样的结构组织的 call 指令和 ret 指令只是为了调用的方便而已, 绝不是函数存在的绝对证据 即使我们仅仅使用 jmp 并自己操作堆栈, 也一样可以实现函数的功能 因此, 一种高级语言如何实现函数调用, 并没有法律的约束, 所以出现了各种不同的函数调用规则 9

9 天书夜读. 从汇编语言到 Windows 内核编程 但是毫无疑问, 如果一个第三方提供的函数要能被使用, 那么必须有约定的函数调用规则 函数调用规则指的是调用者和被调用函数间传递参数及返回参数的方法, 在 Windows 上, 常用的有 Pascal 方式 WINAPI 方式 (_stdcall) C 方式 (_cdecl) _cdecl C 调用规则 : (1) 参数从右到左进入堆栈 ; (2) 在函数返回后, 调用者要负责清除堆栈, 所以这种调用常会生成较大的可执行程序 _stdcall 又称为 WINAPI, 其调用规则 : (1) 参数从右到左进入堆栈 ; (2) 被调用的函数在返回前自行清理堆栈, 所以生成的代码比 cdecl 小 Pascal 调用规则 : Pascal 调用规则主要用在 Win16 函数库中, 现在基本不用 (1) 参数从左到右进入堆栈 ; (2) 被调用的函数在返回前自行清理堆栈 (3) 不支持可变参数的函数调用 此外, 在 Windows 内核中还常见有快速调用方式 (_fastcall); 在 C++ 编译的代码中有 this call 方式 (_thiscall) 这些会在后面的章节中详细阐明 技术细节 在用 C 语言所写的程序中, 堆栈用于传递函数参数 写一个简单的函数如下 : void myfunction(int a,int b) { int c = a+b; } 这是标准的 C 函数调用方式 其过程是 : 调用者把参数反序地压入堆栈中 10

10 调用函数 调用者把堆栈清理复原 这就是 C 编译器默认的 _cdecl 方式, 而 Windows API 一般采用的 _stdcall 则是被调用者恢复堆栈 ( 可变参数函数调用除外 ) 至于返回值都是写入 eax 中, 然后返回的 在 Windows 中, 不管哪种调用方式都是返回值放在 eax 中, 然后返回 外部 从 eax 中得到返回值 _cdecl 方式下被调用函数需要做以下一些事情 (1) 保存 ebp ebp 总是被我们用来保存这个函数执行之前的 esp 的值 执行完毕 之后, 我们用 ebp 恢复 esp; 同时, 调用此函数的上层函数也用 ebp 做同样的事情 所 以先把 ebp 压入堆栈, 返回之前弹出, 避免 ebp 被我们改动 (2) 保存 esp 到 ebp 中 上面两步的代码如下 : ; 保存 ebp, 并把 esp 放入 ebp 中, 此时 ebp 与 esp 同 ; 都是这次函数调用时的栈顶 push ebp mov ebp,esp (3) 在堆栈中腾出一个区域用来保存局部变量, 这就是常说的所谓局部变量是保存 在栈空间中的 方法是 : 把 esp 减少一个数值, 这样就等于压入了一堆变量 要恢复时, 只要把 esp 恢复成 ebp 中保存的数据就可以了 (4) 保存 ebx esi edi 到堆栈中, 函数调用完后恢复 对应的代码如下 : ; 把 esp 往下移动一个范围, 等于在堆栈中放出一片新 ; 的空间用来存局部变量 sub esp,0cch push ebx ; 下面保存三个寄存器 :ebx esi edi push esi push edi (5) 把局部变量区域初始化成全 0cccccccch 0cch 实际是 int 3 指令的机器码, 这 是一个断点中断指令 因为局部变量不可能被执行, 如果执行了, 必然程序有错, 这时 11

11 天书夜读. 从汇编语言到 Windows 内核编程 发生中断来提示开发者 这是 VC 编译 Debug 版本的特有操作 相关代码如下 : lea edi,[ebp-0cch] ; 本来是要 mov edi,ebp-0cch, 但是 mov 不支持 - 操作 ; 所以对 ebp-0cch 取内容, 而 lea 把内容的地址, 也就 ; 是 ebp-0cch 加载到 edi 中 目的是把保存局部变量 ; 的区域 ( 从 ebp-0cch 开始的区域 ) 初始化成全 ; 部 0cccccccch mov ecx,33h mov eax,0cccccccch rep stos dword ptr [edi] ; 串写入 (6) 然后做函数里应该做的事情 参数的获取是 ebp+8 字节为第一个参数,ebp+12 为第二个参数, 依次增加 ebp+4 字节处是要返回的地址 (7) 恢复 ebx esi edi esp ebp, 最后返回 代码如下 : pop edi pop esi pop ebx mov esp,ebp pop ebp ret ; 恢复 edi esi ebx ; 恢复原来的 ebp 和 esp, 让上一个调 ; 用的函数正常使用 为了简单起见, 我的函数没有返回值 如果要返回值, 函数应该在返回之前, 把返 回值放入 eax 中 外部通过 eax 得到返回值 代码分析 用 VC 2003 编译 Debug 版本, 完整的反汇编代码如下 : void myfunction(int a,int b) { push ebp ; 保存 ebp, 并把 esp 放入 ebp 中 此时 ebp 与 esp 同 mov ebp,esp ; 都是这次函数调用时的栈顶 sub esp,0cch ; 把 esp 往上移动一个范围, 等于在堆栈中放出一片新 ; 的空间用来存储局部变量 push ebx ; 下面保存三个寄存器 :ebx esi edi push esi push edi lea edi,[ebp-0cch] ; 本来是要 mov edi,ebp-0cch, 但是 mov 不支持 ; - 操作, 所以对 ebp-0cch 取内容, 而 lea 把内容 ; 的地址, 也就是 ebp-0cch 加载到 edi 中 目的是 ; 把保存局部变量的区域 ( 从 ebp-0cch 开始的区域 ) 12

12 ; 初始化成全部 0cccccccch mov ecx,33h mov eax,0cccccccch rep stos dword ptr [edi] ; 写入 0cch 指令 ( 中断 ) int c = a+b; mov eax,dword ptr [a] ; 简单的相加操作 这里从堆栈中取得从外部 ; 传入的参数 那么,a 和 b 到底是怎么取得的呢 add eax,dword ptr[b] ; 通过 ida 反汇 ; 编可以看到, 其实这两条指令是 ;mov eax, [ebp+8],add eax, [ebp+0ch] ; 参数是通过 ebp 从堆栈中取得的 这里看到 ; 的是 VC 调试器的显示结果, 为了阅读方 ; 便, 直接加上了参数名 mov dword ptr[c],eax } pop edi ; 恢复 edi esi ebx pop esi pop ebx mov esp,ebp pop ebp ret 主程序中对这个函数的调用方式是 : ; 恢复原来的 ebp 和 esp, 让上一个调用的函数 ; 正常使用 mov eax,dword ptr[b] push eax mov ecx,dword ptr[a] push ecx call myfunction add esp,8 ; 把 b a 两个参数压入堆栈 ; 调用函数 myfunction ; 恢复堆栈 这样一来, 函数调用的过程就很清楚了 在下一章开始, 进一步介绍各种各样的 C 语言程序, 变成了怎样的汇编指令 重点观察那些涉及 call ret push 和 pop, 操作 ebp 和 esp 的指令, 就能看 到 C 语言函数的调用过程 13