运行hello-str程序
hello-str程序的功能是通过 sprintf()
函数将字符串格式化打印到一个缓冲区中, 然后使用 strcmp()
函数来检查字符串是否正确. hello-str程序编译通过之后, 你就可以在NEMU中运行它了. 不过在运行过程中, 你可能会发现还有一些指令没有在NEMU中实现, 你需要实现它们. 另外 libc.a
中用到的 cmovcc
指令是在i386之后添加, 你在i386手册上找不到它们, 这个页面提供了它们的信息.
捣蛋鬼的恶作剧
你还会发现在 sprintf()
格式化的执行过程中遇到了两条浮点指令(下面的例子中涉及到的地址可能与你看到的反汇编结果不一样):
80482c8: 89 c7 mov %eax,%edi
80482ca: 89 85 74 ff ff ff mov %eax,-0x8c(%ebp)
80482d0: d9 ee fldz
80482d2: 29 d0 sub %edx,%eax
80482d4: c7 85 7c ff ff ff 00 movl $0x0,-0x84(%ebp)
80482db: 00 00 00
80482de: dd 9d 10 ff ff ff fstpl -0xf0(%ebp)
80482e4: c7 85 78 ff ff ff 00 movl $0x0,-0x88(%ebp)
80482eb: 00 00 00
我们在代码中并没有使用浮点数, 这两条浮点指令是怎么来的呢? Intel有一套x87指令集专门处理浮点运算, 查阅相关资料后发现, fldz
是把浮点数0压入浮点堆栈ST(0), 而 fstpl
则是把浮点堆栈ST(0)的值弹出到内存中. 我们可以推测这两条指令是在对一个浮点变量进行初始化, 如果格式化字符串中含有浮点说明符 %lf
, 代码将会使用这个浮点变量进行处理:
虽然我们没有在代码中使用浮点数, 但执行格式化的代码还是需要对 a
变量进行初始化, 因此在执行过程中就会遇到因此而产生的两条浮点指令.
如果这两条指令仅仅是为了初始化一个我们不会用到的浮点变量, 有没有办法能够绕过它们呢? 你可能会想修改newlib的源代码, 然后重新编译. 这是一个能行的方法, 但代价比较大, 有兴趣的同学可以到newlib的官网上下载源代码并尝试修改.
如果能把这两条浮点指令去掉就好了... 没错, 我们要想办法把这两条浮点指令去掉, 既然不能从源代码着手, 我们就直接修改编译后的ELF文件吧!
体验黑客的乐趣
这好像黑客做的事情一样, 听上去真有意思! 不过, 黑客能做一些我们认为很厉害的事情, 是因为他们对计算机系统的构成了如指掌. 事实上, 你在学习完ICS之后, 就已经具备一些黑客的入门知识了. 不信? 那就动手试试吧!
我们的破解目标是在ELF文件中找到上述两条浮点指令, 然后去掉它们. 在NEMU中运行报出的"invalid opcode"错误已经告诉我们这两条浮点指令在哪一个地址了, 你也可以根据 objdump
的结果找到浮点指令前后的指令, 方便进行对照.
回想一下loader加载程序的过程, ELF文件中已经存储了每一个segment的静态内容, 加载程序的过程就是把这些静态内容从ELF文件中读入到正确的内存位置. 现在我们已经找到浮点指令的地址, 我们就可以反过来推导它们在ELF文件中的位置了. 以我们编译出来的hello-str程序为例, 通过readelf命令查看hello-str的program header信息:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x09bdc 0x09bdc R E 0x1000
LOAD 0x00a000 0x08052000 0x08052000 0x0098c 0x009f8 RW 0x1000
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x4
上述的第一条浮点指令 fldz
的地址是 0x80482d0
, 由于
0x8048000 = segment1.VirtAddr <= instr.VirtAddr < segment1.VirtAddr + segment1.FileSiz = 0x8048000 + 0x9bdc
所以第一条浮点指令落在第一个segment中, 因此我们可以算出 fldz
指令在ELF文件中的偏移量:
instr.offset_in_ELF = segment1.Offset + (instr.VirtAddr - segment1.VirtAddr) = 0x0 + (0x80482d0 - 0x8048000) = 0x2d0
找到了浮点指令在ELF文件中的偏移量之后, 我们就可以动手去掉它了. 使用 vim
打开hello-str可执行文件:
vim -b hello-str
使用 -b
参数代表以二进制方式打开, 可以避免一些和中文字符相关的编码问题. 哎呀, 打开之后竟然是一片乱码! 要怎么改呢? 别着急, 我们需要 xxd
工具的帮助, 在 vim
中键入以下内容:
:%!xxd
xxd
工具已经把hello-str可执行文件转化成十六进制表示了, 这下ELF文件中的所有内容就一览无余了, 就连ELF文件首部的魔数也无所遁形. 根据刚才计算的结果, 我们直捣黄龙, 直接找到ELF文件中偏移量为 0x2d0
的位置, 和 objdump
结果进行对照, 发现正好是 fldz
指令的编码.
找到这个捣蛋鬼之后, 接下来就是要收拾它. 不过可别一时冲动, 把 fldz
指令对应的两个字节直接删掉, 因为这样会影响segment的长度, 需要修改program header中的相应信息, 同时还要修改相应section header的信息... 总之冲动是魔鬼, 差点就功亏一篑了. 能不能在不影响segment属性的情况下把 fldz
指令去掉呢? 或者有没有什么指令可以把 fldz
替换掉, 同时不影响程序其它部分的功能? 这当然难不倒聪(yin)明(xian)的你, 你默默地把i386手册翻到 nop
的页面... 这下你知道 nop
的指令长度为什么是1个字节了吧. 于是 fldz
这个捣蛋鬼就被悄悄地抹杀了.
把 fldz
干掉之后, 在 vim
中键入以下内容:
:%!xxd -r
xxd
工具会把十六进制表示转换回二进制表示的ELF. 保存后退出 vim
, 用 objdump
查看修改后的hello-str可执行文件:
80482c8: 89 c7 mov %eax,%edi
80482ca: 89 85 74 ff ff ff mov %eax,-0x8c(%ebp)
80482d0: 90 nop
80482d1: 90 nop
80482d2: 29 d0 sub %edx,%eax
80482d4: c7 85 7c ff ff ff 00 movl $0x0,-0x84(%ebp)
80482db: 00 00 00
80482de: dd 9d 10 ff ff ff fstpl -0xf0(%ebp)
80482e4: c7 85 78 ff ff ff 00 movl $0x0,-0x88(%ebp)
80482eb: 00 00 00
抹杀成功! 接下来用同样的方式把 fstpl
指令也干掉, hello-str程序就可以在NEMU中成功运行了. 不过如果你打算使用其它编辑器, 你可能需要在shell中使用 xxd
工具把十六进制表示的结果输出到一个临时文件中, 修改后再使用 xxd
转换成二进制文件. 关于如何使用 xxd
, 请查阅
man xxd
不过你会发现, 如果重新编译hello-str程序, 讨厌的捣蛋鬼又起死回生了. 为了斩草除根, 我们需要修改 libc.a
文件.
libc.a
是一个归档文件, 其中包含了很多用于链接的目标文件. 直接在 libc.a
中定位到需要修改的位置会比较麻烦, 我们可以将需要修改的那个目标文件抽取出来, 然后单独修改它. 我们知道需要修改的代码在 _svfprintf_r()
函数中, 可以使用如下指令找到这个函数在哪一个目标文件中:
nm -o libc.a | grep '_svfprintf_r'
命令的执行结果显示, _svfprintf_r()
函数在归档文件中的 lib_a-svfprintf.o
中定义. 接下来将这个目标文件从归档文件中抽取出来:
ar x libc.a lib_a-svfprintf.o
抽取出目标文件之后, 就可以通过上文提到的方法来去掉浮点指令了. 不过这次需要修改的文件是没有经过链接的目标文件, 目标文件中没有segment的信息, 不过仅仅利用section的信息也可以达到我们的目的, readelf
和 objdump
的结果已经包含了我们需要的所有信息, 至于如何发现和利用它们, 就交给你来思考吧!
修改成功后, 将修改后的目标文件重新加入归档文件中:
ar r libc.a lib_a-svfprintf.o
现在的 libc.a
中的 _svfprintf_r()
函数已经经过我们的处理了, 使用它进行链接产生的hello-str程序已经不会再含有我们之前去掉的那两条浮点指令.
根据上述讲义内容, 在NEMU中运行hello-str程序.
在程序设计基础课上, 你学会了如何对数据做一些基本操作, 学会了play with the data, 例如算阶乘, 用链表组织学生信息, 但你从来都不知道代码是什么东西. 而上述练习其实就是在play with the code, 你会发现修改程序的行为犹如探囊取物. 和代码玩游戏的最高境界就要数self-modifying code了, 它们能够在运行时刻修改自己, 但这种代码极难读懂, 维护更是难上加难, 因此它们成为了黑客们炫耀的一种工具.
这一切是否引起你的思考: 代码的本质是什么? 代码和数据之间的区别究竟在哪里?
你需要在实验报告中用自己的语言, 尽可能详细地回答下列问题.
- 编译与链接 在
nemu/include/cpu/helper.h
中, 你会看到由static inline
开头定义的instr_fetch()
函数和idex()
函数. 选择其中一个函数, 分别尝试去掉static
, 去掉inline
或去掉两者, 然后重新进行编译, 你会看到发生错误. 请分别解释为什么会发生这些错误? 你有办法证明你的想法吗? - 编译与链接
- 在
nemu/include/common.h
中添加一行volatile static int dummy;
然后重新编译NEMU. 请问重新编译后的NEMU含有多少个dummy
变量的实体? 你是如何得到这个结果的? - 添加上题中的代码后, 再在
nemu/include/debug.h
中添加一行volatile static int dummy;
然后重新编译NEMU. 请问此时的NEMU含有多少个dummy
变量的实体? 与上题中dummy
变量实体数目进行比较, 并解释本题的结果. - 修改添加的代码, 为两处
dummy
变量进行初始化:volatile static int dummy = 0;
然后重新编译NEMU. 你发现了什么问题? 为什么之前没有出现这样的问题? (回答完本题后可以删除添加的代码.)
- 在
- 了解Makefile 请描述你在工程目录下敲入
make
后,make
程序如何组织.c和.h文件, 最终生成可执行文件obj/nemu/nemu
. (这个问题包括两个方面:Makefile
的工作方式和编译链接的过程.) 关于Makefile
工作方式的提示:Makefile
中使用了变量, 函数, 包含文件等特性Makefile
运用并重写了一些implicit rules- 在
man make
中搜索-n
选项, 也许会对你有帮助 - RTFM
PA2到此结束. 请你编写好实验报告(不要忘记在实验报告中回答必答题), 然后把命名为 学号.pdf
的实验报告文件放置在工程目录下, 执行 make submit
对工程进行打包, 最后将压缩包提交到指定网站.