运行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 , 代码将会使用这个浮点变量进行处理:

double a = 0.0; // ... if( the conversion specifier is "%lf" ) { a = get_value(); /* format the double value */ }

虽然我们没有在代码中使用浮点数, 但执行格式化的代码还是需要对 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的信息也可以达到我们的目的, readelfobjdump 的结果已经包含了我们需要的所有信息, 至于如何发现和利用它们, 就交给你来思考吧!

修改成功后, 将修改后的目标文件重新加入归档文件中:

ar r libc.a lib_a-svfprintf.o

现在的 libc.a 中的 _svfprintf_r() 函数已经经过我们的处理了, 使用它进行链接产生的hello-str程序已经不会再含有我们之前去掉的那两条浮点指令.

在NEMU中运行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 或去掉两者, 然后重新进行编译, 你会看到发生错误. 请分别解释为什么会发生这些错误? 你有办法证明你的想法吗?
  • 编译与链接
    1. nemu/include/common.h 中添加一行 volatile static int dummy; 然后重新编译NEMU. 请问重新编译后的NEMU含有多少个 dummy 变量的实体? 你是如何得到这个结果的?
    2. 添加上题中的代码后, 再在 nemu/include/debug.h 中添加一行 volatile static int dummy; 然后重新编译NEMU. 请问此时的NEMU含有多少个 dummy 变量的实体? 与上题中 dummy 变量实体数目进行比较, 并解释本题的结果.
    3. 修改添加的代码, 为两处 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 对工程进行打包, 最后将压缩包提交到指定网站.

results matching ""

    No results matching ""