扭转乾坤
这个选做任务我们跟着指导手册做就可以了。
知己知彼
我们直接在/obj/testcase目录中的终端里面直接输入
./print-FLOAT-linux
可以直接运行这个可执行文件,输出正如手册中说,是0000000
然后我们对其进行反汇编,还是在那个目录中
objdump -d ./print-FLOAT-linux > print-FLOAT-linux.txt
可以把反汇编结果输入.txt文件里面,打开文件,找就完了。
下面是手册当中我们要找的c代码。
在第520行,我们找到了这个函数:
8048583 <_vfprintf_internal>:
然后在这个函数里面,758行找到了
8048889: e8 d8 0e 00 00 call 8049766 <_fpmaxtostr>
当然没有纯肉眼看,毕竟这个文件有足足有3000行。
研究一下那里的汇编语言,有了我之前在打印栈帧链里面写的前置知识之后,这里不难分析汇编语言了。
从746到763应该是,但还不够,还少else if的条件判断。
8048865: 74 04 je 804886b <_vfprintf_internal+0x2e8>
8048867: db 2a fldt (%edx)
8048869: eb 02 jmp 804886d <_vfprintf_internal+0x2ea>
804886b: dd 02 fldl (%edx)
804886d: 53 push %ebx
804886e: 53 push %ebx
804886f: 68 1c 85 04 08 push $0x804851c
8048874: 8d 84 24 a4 00 00 00 lea 0xa4(%esp),%eax
804887b: 50 push %eax
804887c: 83 ec 0c sub $0xc,%esp
804887f: db 3c 24 fstpt (%esp)
8048882: ff b4 24 8c 01 00 00 pushl 0x18c(%esp)
8048889: e8 d8 0e 00 00 call 8049766 <_fpmaxtostr>
804888e: 83 c4 20 add $0x20,%esp
8048891: 85 c0 test %eax,%eax
8048893: 0f 88 82 01 00 00 js 8048a1b <_vfprintf_internal+0x498>
8048899: 01 44 24 08 add %eax,0x8(%esp)
804889d: e9 63 01 00 00 jmp 8048a05 <_vfprintf_internal+0x482>
742,743行应该是下面的那个else if.
8048858: 83 f8 0f cmp $0xf,%eax
804885b: 77 45 ja 80488a2 <_vfprintf_internal+0x31f>
至于上面那个else if,我没有发现哪个跳转指令是跳到804886x的,应该就是没有专门的跳转,而是就像排除法那样了
偷龙转凤
我们打开/lib-common/FLOAT/FLOAT_vfpintf.c,修改modify_vfprintf()函数。
static void modify_vfprintf() {
/* TODO: Implement this function to hijack(劫持) the formating of "%f"
* argument during the execution of `_vfprintf_internal'. Below
* is the code section in _vfprintf_internal() relative to the
* hijack.
*/
#if 0
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 */
#endif
/* You should modify the run-time binary to let the code above
* call `format_FLOAT' defined in this source file, instead of
* `_fpmaxtostr'. When this function returns, the action of the
* code above should do the following:
*/
#if 0
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 */
#endif
}
首先我们先找到call指令:
void *p = (void *)(int)&_vfprintf_internal + (0x8048889-0x8048583);
这里的偏移量O是call指令的opcode地址和_vfprintf_internal()函数的首地址相减,每个NEMU应该都不太一样(我说的是地址,不太一样,但是偏移量都是一样的),这里不要直接抄(其实也可以,毕竟偏移量都一样),具体获得方法就在上一步中。
这里再解释一下为什么这么写可以得到地址。
首先在这个源文件一开始已经声明了外部符号,也就是函数名,表示它的定义存在于程序的其他地方(通常是其他文件里面,理论上同一个文件里也行)所以在这个程序里面可以直接引用它。
extern char _vfprintf_internal;
这里_vfprintf_internal是一个函数,但是按理说,extern一个函数不应该这样写,这明明是一个变量。理论上的正确写法应该是:
extern char _vfprintf_internal(参数1,参数2,...);
这表明此函数的返回值是一个char。
但是在c语言中,函数名本质上代表了函数代码块的起始地址。如果像上上面那么写,这应该是一个错误,但是编译器不会立即报错,特别是只extern而不调用的情况。所以编译器允许你获取这个函数的地址。因此上面p指针那一串的意思就是,先取地址,此时被编译器解释为char*,然后把这个地址转为int(注意可以这么干仅仅是因为这是nemu,地址的类型是32位的,可以赋给32位的int,但如果自己编程,那在现代的计算机上,地址通常都是64位的(这也是64位操作系统的由来),你不能把他赋给32位的int,除非你知道自己确实在这么干)然后又转为void *,这是c语言中的通用指针类型。
然后再加上偏移量O,就是call的位置了。
然后p++指向call的操作数。
之后再修改它,手册里已经说过,要加上_fpmaxtostr()的首地址和format_FLOAT()的首地址之间的差
*(int*)p += ((int)format_FLOAT - (int)&_fpmaxtostr);
编译器在处理函数名的时候,会自动转换为指针,所以按理说,不用写&。
这里为什么_ fpmaxtostr做减数,可以用向量来理解。假设p是向量_ fpmaxtostr,我们的目的是p加上另一个向量,变成format_FLOAT,那么所求向量就是从_ fpmaxtostr指到format_FLOAT的,由于向量相减,高中物理老师教我“减量指向被减量”的口诀,因此format_FLOAT就是被减量。
那么在这个函数里面,写上这三行,就完事了。
具体调试应该怎么做,就是先make run(编译)完事之后,直接去目标文件运行print-FLOAT-linux查看输出就ok了,不要什么make test和单步执行然后设置断点,输出变量啥的,这是运行的NEMU里面的样例,而指导手册里说,这个文件是直接在linux上运行的。同时也不要去测试print-FLOAT,还没到那一步。可恶阿,我就直接被这样硬控了一个小时
可恶,我又错了!
make run之后,还要重新
make pa2-7
才可以!因为之前生成的可执行文件已经可执行了,它自己已经可以执行了,如果print-FLOAT-linux不编译一遍,那不论make run多少次,它里面的二进制数据是不会变的,只有重新make pa2-7才能生成新的可执行文件
他妈的又一个小时
这就是因为转专业之前没学计组吗:(
因为这个文件是在linux运行的,可以通过printf()函数来辅助调试。
接下来我们设置权限。
首先头文件
#include <sys/mman.h>
然后由于p刚定义好之后指向的就是call(p++之前),所以直接p - 100就是前面100个字节了
mprotect((void*)(((int)p - 100) & 0xfffff000), 4096 * 2, PROT_READ | PROT_WRITE | PROT_EXEC);
知己知彼(2)
8048865: 74 04 je 804886b <_vfprintf_internal+0x2e8>
8048867: db 2a fldt (%edx)
8048869: eb 02 jmp 804886d <_vfprintf_internal+0x2ea>
804886b: dd 02 fldl (%edx)
804886d: 53 push %ebx
804886e: 53 push %ebx
804886f: 68 1c 85 04 08 push $0x804851c
8048874: 8d 84 24 a4 00 00 00 lea 0xa4(%esp),%eax
804887b: 50 push %eax
804887c: 83 ec 0c sub $0xc,%esp
804887f: db 3c 24 fstpt (%esp)
8048882: ff b4 24 8c 01 00 00 pushl 0x18c(%esp)
8048889: e8 d8 0e 00 00 call 8049766 <_fpmaxtostr>
804888e: 83 c4 20 add $0x20,%esp
8048891: 85 c0 test %eax,%eax
8048893: 0f 88 82 01 00 00 js 8048a1b <_vfprintf_internal+0x498>
8048899: 01 44 24 08 add %eax,0x8(%esp)
804889d: e9 63 01 00 00 jmp 8048a05 <_vfprintf_internal+0x482>
手册里说:
原来的代码 中是通过一条占用三个字节浮点指令来放置第二个参数的,
我们可以根据这句话找出这条指令就是
804887f: db 3c 24 fstpt (%esp)
这条指令是将一个浮点数压栈,作为一个参数。
根据我们所学过的栈帧知识,在某个函数栈帧里,先是因为call指令而被压栈的返回地址,然后是ebp旧值,然后是局部变量,这个函数调用下一个函数的参数,然后又是下一个函数的返回地址…
那么我们就可以从call往上找,有最近的4个参数压栈,就是参数了。
我们找到了
804886f: 68 1c 85 04 08 push $0x804851c
8048874: 8d 84 24 a4 00 00 00 lea 0xa4(%esp),%eax
804887b: 50 push %eax
804887c: 83 ec 0c sub $0xc,%esp
804887f: db 3c 24 fstpt (%esp)
8048882: ff b4 24 8c 01 00 00 pushl 0x18c(%esp)
8048889: e8 d8 0e 00 00 call 8049766 <_fpmaxtostr>
首先将一个数0x804851c压栈,然后是加载内存地址0xa4(%esp)放入eax,然后eax压栈,之后栈顶指针减12,即栈上空间加12个字节
fstpt这条指令是将FPU堆栈顶部的浮点数存储到esp指向的位置。之后将0x18c(%esp)地址的数据压栈。
_fpmaxtostr接收的参数和压栈的顺序相反
因此变量stream的地址是0x18c(%esp)。
argptr是一个指针,传入的参数是argptr解引用的值,即“FPU堆栈顶部的浮点数”,那我们要继续往前面找传入FPU堆栈顶部的那个指令。
804886b: dd 02 fldl (%edx)
想必我不说这条指令干什么的,也能猜到了吧。而argptr正是edx的值,(%edx)就是解引用。
argptr指向的就是我们要打印的FLOAT吧
偷龙转凤(2)
我们首先要将fstpt的三字节指令变成push (%edx),即
ff 32 push (%edx)
然后剩下的一个字节变成
90 nop
我们可以用上次的p指针来完成
p--; //指向call
p -= (0xb4 - 0xaa); //指向fstpt
*(char *)p = 0xff;
p++;
*(char *)p = 0x32;
p++;
*(char *)p = 0x90;//改三个字节
p -= 2; //指向fstpt
之后我们修改分配的栈空间,不用12字节,而是8字节
// 修改栈空间
p -= 3; //指向上一次sub esp
p += 2;
*(char *)p -= 0x4;
p -= 2;
别忘了我们要让所有的浮点指令全消失,包括这里
8048867: db 2a fldt (%edx)
8048869: eb 02 jmp 804886d <_vfprintf_internal+0x2ea>
804886b: dd 02 fldl (%edx)
所以
p -= (0x804887c-0x8048867);
*(char *)p = 0x90;
*(char *)(p+1) = 0x90;
*(char *)(p+4) = 0x90;
*(char *)(p+5) = 0x90;
偷龙转凤(3)
然后我们编写 format_FLOAT()
__attribute__((used)) static int format_FLOAT(FILE *stream, FLOAT f) {
/* TODO: Format a FLOAT argument `f' and write the formating
* result to `stream'. Keep the precision of the formating
* result with 6 by truncating. For example:
* f result
* 0x00010000 "1.000000"
* 0x00013333 "1.199996"
*/
//set_bp();
char buf[80];
int len = sprintf(buf, "0x%08x", f);
return __stdio_fwrite(buf, len, stream);
}
我们要把FLOAT转为float,存在buf中
__attribute__((used)) static int format_FLOAT(FILE *stream, FLOAT f) {
char buf[80];
// 符号
int len = 0;
if(f < 0) {
buf[len++] = '-';
f = -f;
}
// 整数部分,先是倒序,后面再掉头
int por = (f >> 16);
while(por != 0) {
buf[len++] = '0' + por % 10;
por /= 10;
}
char tmp;
int l = 0;
int r = len-1;
if(buf[0] == '-')
l++;
while(l < r) {
tmp = buf[l];
buf[l] = buf[r];
buf[r] = tmp;
l++;
r--;
}
buf[len++] = '.'; //小数点
// 小数部分(6位)
f &= 0xffff;
int i = 0;
for(i = 0; i < 6; i++) {
f *= 10;
buf[len++] = '0' + (f >> 16);
f &= 0xffff;
}
buf[len++] = 0;
return __stdio_fwrite(buf, len, stream);
}
偷龙转凤(4)
去掉mprotect(),运行NEMU
发现还有一些没有实现的指令,继续实现即可(因为我是运行一个样例,实现一个指令)
遇到了手册中同样的问题,有一个fldl指令
我们根据手册要求做即可。
为了获取函数地址,我们要首先
注:这个时候再查看汇编代码的话,那就要看print-FLOAT.txt了
extern char _ppfs_setargs;
获取地址
void *p =(void *)(int)&_ppfs_setargs;
p += (0x8011b8 - 0x801147); //0x71
这里就是lea的首地址了,即float分支的开头
8011b8: 8d 5a 08 lea 0x8(%edx),%ebx
8011bb: dd 02 fldl (%edx)
我们放一条jmp指令,跳到long long分支即可。
我们如何通过阅读汇编语言来找到long long 呢,我们只需要找那个case里面的数和汇编语言对应就行了
以下是我们要阅读的c代码:
enum { /* C type: */
PA_INT, /* int */
PA_CHAR, /* int, cast to char */
PA_WCHAR, /* wide char */
PA_STRING, /* const char *, a '\0'-terminated string */
PA_WSTRING, /* const wchar_t *, wide character string */
PA_POINTER, /* void * */
PA_FLOAT, /* float */
PA_DOUBLE, /* double */
__PA_NOARG, /* non-glibc -- signals non-arg width or prec */
PA_LAST
};
/* Flag bits that can be set in a type returned by `parse_printf_format'. */
/* WARNING -- These differ in value from what glibc uses. */
#define PA_FLAG_MASK (0xff00)
#define __PA_FLAG_CHAR (0x0100) /* non-gnu -- to deal with hh */
#define PA_FLAG_SHORT (0x0200)
#define PA_FLAG_LONG (0x0400)
#define PA_FLAG_LONG_LONG (0x0800)
#define PA_FLAG_LONG_DOUBLE PA_FLAG_LONG_LONG
#define PA_FLAG_PTR (0x1000) /* TODO -- make dynamic??? */
while (i < ppfs->num_data_args) {
switch(ppfs->argtype[i++]) {
case (PA_INT|PA_FLAG_LONG_LONG):
GET_VA_ARG(p,ull,unsigned long long,ppfs->arg);
break;
case (PA_INT|PA_FLAG_LONG):
GET_VA_ARG(p,ul,unsigned long,ppfs->arg);
break;
case PA_CHAR: /* TODO - be careful */
/* ... users could use above and really want below!! */
case (PA_INT|__PA_FLAG_CHAR):/* TODO -- translate this!!! */
case (PA_INT|PA_FLAG_SHORT):
case PA_INT:
GET_VA_ARG(p,u,unsigned int,ppfs->arg);
break;
case PA_WCHAR: /* TODO -- assume int? */
/* we're assuming wchar_t is at least an int */
GET_VA_ARG(p,wc,wchar_t,ppfs->arg);
break;
/* PA_FLOAT */
case PA_DOUBLE:
GET_VA_ARG(p,d,double,ppfs->arg);
break;
PA_DOUBLE是我们要修改的那个分支,同时PA_DOUBLE也是一个枚举类型(定义也在我贴出来的代码),为7。
再贴出这个分支的汇编语言
8011b3: 83 fb 07 cmp $0x7,%ebx
8011b6: 75 44 jne 8011fc <_ppfs_setargs+0xb5>
8011b8: 8d 5a 08 lea 0x8(%edx),%ebx
8011bb: dd 02 fldl (%edx)
正好有个cmp比较指令,而且也是7,如果不等于7,就jne跳走,如果是,那就执行这个块里面的指令。
而如果是long long分支,PA_INT|PA_FLAG_LONG_LONG值为0x800,我们找就完了。
8011ce: 81 fb 00 08 00 00 cmp $0x800,%ebx
8011d4: 74 14 je 8011ea <_ppfs_setargs+0xa3>
直接跳到8011fea
我们要跳的就是这个位置。
我们使用的是JMP rel32,对应opcode为E9,这个数,就应该是0x8011ea-0x8011b8 = 0x00000032
但这里使用的小端序,所以就是0x32000000
e9 44 JMP
后面的应该不用改nop了,因为这后面的指令已经不可能访问到了。
*(char *)p = 0xe9;
p++;
*(char *)p = 0x32;
p++;
*(char *)p = 0x00;
p++;
*(char *)p = 0x00;
p++;
*(char *)p = 0x00;
如果有没实现的指令,继续实现即可。
而且linux下的printf和nemu下的sprintf有些区别,printf就算前面的字符串有’\0’他也会输出后面的,sprintf后再看总的字符串,就会因为\0终止
所以为了通过测试样例,应该去掉format_FLOAT()中的
buf[len++] = 0;
调试了半天还是不行
这里
*(char *)p = 0x32;
应该改成2d,具体为什么,我是真不清楚了。跳到的那个地方也是有点刁钻
这样改了之后也是HIT GOOD TRAP了
补充:
24/9/18
jmp rel32指令的操作数是目标地址减去下一条指令的地址,而我这里直接是减去这一条指令的地址,所以正确的应该还要再减去指令长度5,0x32 - 5 = 0x2d。
我犯这个错误是因为i386手册里,我误以为eip是当前指令的地址,而不是下一套指令。
