扭转乾坤: 改变程序的行为

print-FLOAT程序的功能是通过 sprintf() 函数的 %f 功能来格式化一个 FLOAT 类型的变量, 然后使用 strcmp() 函数来检查字符串是否正确. 等等, %f 不是用来格式化一个 float 类型的变量的吗? 那该如何实现让 %f 来格式化一个 FLOAT 类型的变量?

你可能会想修改uclibc的源代码, 然后重新编译. 这是一个能行的方法, 但需要进行库函数的交叉编译, 代价比较大, 有兴趣的同学可以到uclibc的官网上下载源代码并尝试修改.

既然不修改uclibc的源代码, 那么就从运行时的代码下手吧! 这正是这次任务最有魅力的地方: 你将要通过对 libc.a 进行攻击, 劫持相应的执行流, 来改变 %f 的行为!

为了方便调试, 我们先在GNU/Linux中实施攻击, 成功后再将程序移植到NEMU中运行. 框架代码已经为我们准备好生成相应文件的配置了(见 testcase/Makefile.part ). 在工程目录下执行

make pa2-7

就会生成 obj/testcase/print-FLOAT-linux 这一可以在GNU/Linux中直接运行的可执行文件. 生成它时, lib-common/uclibc/lib 目录下的 crt?.o 会参与链接, 它们用于提供与GNU/Linux相兼容的运行时环境. 编译过程会通过gcc定义宏 LINUX_RT, 从而控制 testcase/src/print-FLOAT.c 采用 printf() 来输出结果. 代码期望首先通过 init_FLOAT_vfprintf() (在 FLOAT.a 中定义) 对 libc.a 进行劫持攻击, 之后通过 %f 进行格式化时就会执行我们编写的劫持目标代码, 该代码会根据 FLOAT 的编码方式进行格式化, 从而实现通过 %fFLOAT 变量进行格式化的功能.

运行 obj/testcase/print-FLOAT-linux, 你会发现输出的结果为 0.000000, 这是因为我们还没有对 libc.a 进行劫持, 无法正确输出 FLAOT 类型的数据. 你的第一个任务就是要对 libc.a 中负责进行字符串格式化的 _vfprintf_internal() 函数进行劫持. 在劫持前, 格式化 %fvfprintf_internal() 会运行如下代码:

else if (ppfs->conv_num <= CONV_A) { /* floating point */ ssize_t nf; nf = _fpmaxtostr(stream, (__fpmax_t) (PRINT_INFO_FLAG_VAL(&(ppfs->info),is_long_double) ? *(long double *) *argptr : (long double) (* (double *) *argptr)), &ppfs->info, FP_OUT ); if (nf < 0) { return -1; } *count += nf; return 0; } else if (ppfs->conv_num <= CONV_S) { /* wide char or string */

其中会调用uclibc中的 _fpmaxtostr() 函数来实现 float 类型数据的格式化, 其函数原型说明如下:

extern ssize_t _fpmaxtostr(FILE * fp, __fpmax_t x, struct printf_info *info, __fp_outfunc_t fp_outfunc) attribute_hidden;

我们的目标是实现 lib-common/FLOAT/FLOAT_vfprintf.c 中的 modify_vfprintf() 函数, 它负责在运行时刻修改与上述c代码对应的二进制代码, 使其调用 lib-common/FLOAT/FLOAT_vfprintf.c 中的 format_FLOAT() 函数, 而不是 _fpmaxtostr() 函数. 要如何下手呢? 首先当然需要找到相应的二进制代码的位置.

知己知彼

obj/testcase/print-FLOAT-linux 进行反汇编, 找到与上述c代码对应的二进制代码的地址范围.

我们期望劫持后的代码如下:

else if (ppfs->conv_num <= CONV_A) { /* floating point */ ssize_t nf; nf = format_FLOAT(stream, *(FLOAT *) *argptr); if (nf < 0) { return -1; } *count += nf; return 0; } else if (ppfs->conv_num <= CONV_S) { /* wide char or string */

分析我们期望的代码, 我们只要做到以下几点即可:

  • 将函数调用的目标改为 format_FLOAT()
  • 设置好正确的函数调用参数
  • 清理因为调用 _fpmaxtostr() 而留下的浮点指令

首先我们来修改函数调用的目标. RTFM后得知, e8 开头的是 call REL32 形式的指令, 因此后面跟的是一个相对于当前eip的偏移量. 因此, 我们只需要修改这一偏移量, 就可以达到修改函数调用目标的目的了. 为此, 我们需要得知这条 call 指令的地址. 假设从反汇编结果中得知该指令的地址为 p, 那么 p+1 就指向了我们需要修改的偏移量. 该如何修改这一偏移量呢? 其实我们只需要让它加上 _fpmaxtostr() 的首地址和 format_FLOAT() 的首地址之间的差即可. 由于 format_FLOAT() 就在 lib-common/FLOAT/FLOAT_vfprintf.c 中定义, 所以可以直接引用它来得到其首地址. 但 _fpmaxtostr() 却在 libc.a 中定义, 需要先通过外部引用来对其进行声明. 事实上, 框架代码中已经对其进行声明了.

狸猫换太子

我们知道 _fpmaxtostr() 是一个函数, 但框架代码的外部引用却把它声明成一个 char 类型的变量, 这样做会有问题吗? 思考一下, 在这种情况下, 编译器和链接器是如何处理这一声明的?

理解了上述内容之后, 你就可以在 modify_vfprintf() 中编写代码, 来修改函数调用的目标了. 不过可别高兴太早了, 我们编写代码重新编译之后, 由于代码的大小发生了变化, 可能会导致我们修改的那条 call 指令在链接重定位阶段后的地址不再是之前从反汇编结果中看到的 p! 这下可麻烦了, 难道每次编写完代码之后, 都要重新回过头来调整 p 的值? 更麻烦的是, 如果链接的用户程序改变了, p 也同样会改变! 这意味着, 这种把 p 写死的方法来修改指令是不能拓展到其它用户程序中的. 但天无绝人之路, 虽然这条 call 指令的位置会变化, 但它相对于 _vfprintf_internal() 函数首地址的偏移量是不变的! 这样, 我们只需要先计算出这一偏移量 o, 我们就可以通过 _vfprintf_internal + o 的方式找到这条 call 指令了. 由于 _vfprintf_internal() 函数的首地址是在链接重定位时确定的, 在运行时刻通过这种方式必定能找到 call 指令正确的位置.

偷龙转凤

modify_vfprintf() 中编写代码, 修改函数调用的目标, 使得 _vfprintf_internal() 在对 %f 进行格式化的时候调用 format_FLOAT(), 而不是 _fpmaxtostr().

编写代码后, 编译并运行 obj/testcase/print-FLOAT-linux, 你会发现程序触发了段错误! 这是因为在GNU/Linux下, 程序的代码是只读的, 而我们的劫持需要在运行时刻修改程序的代码, 进行了违规的写操作, 从而触发了段错误. 为了使得我们的劫持可以顺利进行, 我们需要借助系统调用 mprotect() 的帮助. mprotect() 可以改变一段内存区间的访问权限, 详细信息请参考 man 2 mprotect. 我们需要在 modify_vfprintf() 修改代码之前为相应的内存区间设置可写权限:

#include <sys/mman.h> mprotect((void *)((???) & 0xfffff000), 4096*2, PROT_READ | PROT_WRITE | PROT_EXEC);

其中, 你需要填写 ??? 中的内容, 内容为 call 指令所在地址的往前(backward) 100 字节所在的地址. 往前 100 字节是因为我们接下来还需要修改这一区间的代码, 而 100 字节已经足够覆盖这一区间. 重新编译后运行, 如果你的代码实现正确, 你应该能看到目前通过 %f 输出了一个十六进制数.

但输出的这个十六进制数并不是我们在调用 printf() 时传入的 FLOAT 数据, 这是因为我们仅仅改变了函数调用的目标, _vfprintf_internal() 仍然按照调用 _fpmaxtostr() 那样压入参数, 但 format_FLOAT() 却以为 vfprintf_internal() 已经压入了正确的参数.

锱铢必较

你知道现在通过 %f 输出的十六进制数具体是怎么得到的吗?

接下来我们需要修改传入参数的代码了.

知己知彼(2)

阅读相应的汇编代码, 并回答以下问题:

  • 调用 _fpmaxtostr() 的参数是如何压栈的?
  • 变量 argptr 存放在哪一个寄存器中?
  • 变量 argptr 指向的内容是什么?
  • 变量 stream 的地址在哪里?
  • 调用 _fpmaxtostr() 返回后会清理栈上若干字节的内容, 这若干字节都有些什么内容?

对比劫持前后的代码, 我们发现第一个参数 stream 都是一样的, 因此传入 stream 的代码不需要进行改动. 为了让 format_FLOAT() 拿到正确的实参 f, 我们需要在 _vfprintf_internal() 中将它的值放置到栈中正确的位置上. 原来的代码中是通过一条占用三个字节浮点指令来放置第二个参数的, 我们需要修改它, 将它换成一条 push 指令, 用于将 argptr 指向的内容压栈即可. 正所谓细节决定成败, 我们还需要处理以下的细节问题:

  • 使用的 push 指令不能超过三个字节
  • 如果使用的 push 指令不足三个字节, 剩下的字节要通过 nop 来覆盖, 保证原来那条三个字节的浮点指令"不留任何痕迹"
  • 由于使用的 push 指令改变了栈的深度, 为了正确维护栈的深度, 我们还需要修改前一条用于分配栈空间大小的指令
  • 由于 format_FLOAT() 只会使用两个参数, 传入的其余参数可以暂时忽略

偷龙转凤(2)

modify_vfprintf() 中编写代码, 修改 _vfprintf_internal() 的代码, 使其压入正确的参数, 让 format_FLOAT() 正确输出 FLOAT 数据的十六进制表示. 如果你仍然觉得毫无头绪, 你需要重新思考 知己知彼(2) 中的问题.

偷龙转凤(3)

现在的 format_FLOAT() 已经可以正确地获得 FLOAT 类型的实参了. 你需要修改 format_FLOAT() 的实现, 把 FLOAT 类型数据的十进制小数表示作为格式化的结果. 我们约定格式化结果通过截断的方式保留6位小数.

瞒天过海

print-FLOAT.c 中, 传入 FLOAT 类型参数时都用到了宏 FLOAT_ARG(). 尝试不使用这个宏, 而是直接传入 FLOAT 类型参数, 观察输出结果, 你发现了什么问题? 为什么会这样?

最后, 我们需要清除这段代码中的其它浮点指令, 来让print-FLOAT可以在NEMU中运行. 清除的方式很简单, 只需要用 nop 指令覆盖它们即可.

漏网之鱼

乍看之下, 如果不清除这些浮点指令, print-FLOAT-linux的运行也不会受到影响. 但如果通过 %f 进行格式化的次数足够多, 还是会出现问题的. 尝试在print-FLOAT-linux中进行10次含有 %f 的输出, 观察输出结果. 你知道为什么会出现这样的情况吗?

另外在NEMU中运行之前, 记得去掉代码中的 mprotect() 系统调用, 因为目前NEMU还不能支持系统调用的执行.

偷龙转凤(4)

在NEMU中运行print-FLOAT, 你会发现仍然遇到浮点指令. 仔细分析后, 你会发现这次遇到的浮点指令位于 _ppfs_setargs() 中. 你的最后一个任务就是编写 lib-common/FLOAT/FLOAT_vfprintf.c 中的 modify_ppfs_setargs() 函数, 让其对 ppfs_setargs() 函数的运行时二进制进行修改, 达到绕过上述浮点指令的目的.

主要的相关代码是一个switch-case语句, 通过不同的格式说明符来获取不同长度的数据. %f 对应的是一个64位的数据( float 类型不是32位吗?), 事实上也可以把它看成一个 long long 类型的数据来获取, 这样就能绕过 float 相应分支的浮点指令来获取64位的数据了. 你的修改目标就是在 float 分支的开头放置一条 jmp 指令, 跳转到 long long 分支. 更多的代码细节请阅读 modify_ppfs_setargs() 函数中的注释. 有了修改 _vfprintf_internal() 函数的经验, 这次的任务当然也难不倒聪明的你啦!

实现正确后, 你就可以在NEMU中成功运行用户程序print-FLOAT了.

不过在真实的操作系统中, 要进行这种劫持代码的攻击其实并非易事. 回顾我们在GNU/Linux的实现, 我们需要先通过 mprotect() 系统调用打开代码的可写权限, 才能进行劫持. 而一般的程序并不会主动打开代码的可写权限, 因此黑客们也在绞尽脑汁思考其它更巧妙的攻击方式. 不过可以肯定的是, 没有对计算机系统的深入理解, 是不可能写出这样的代码的.

和代码玩游戏

在程序设计基础课上, 你学会了如何对数据做一些基本操作, 学会了play with the data, 例如算阶乘, 用链表组织学生信息, 但你从来都不知道代码是什么东西. 而上述练习其实就是在play with the code, 你会发现修改程序的行为犹如探囊取物. 和代码玩游戏的最高境界就要数self-modifying 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 ""