《老码识途》读书笔记:第一章--欲向码途问大道,锵锵bit是吾刀(上)
1、赋值语句
对于全局变量赋值语句,例如下面这句:
1 int gi; 2 void main(int argc, char* argv[]) 3 { 4 gi = 12; 5 }
对于gi = 12;这句赋值语句来说,可查看其汇编表示形式为(内存地址为书中例子):
1 0041138E mov dword ptr ds:[00417140h],0ch
其中00417140h为十六进制数表示的全局变量gi存放在内存中的地址,0ch是十进制数12的十六进制表示,0041138E为十六进制数表示的赋值指令mov在内存中的存放地址。这句汇编指令的意思是,将十六进制数0ch以四个字节(dword)的形式存放入从内存地址00417140h开始的四个字节长度的内存空间中。再来观察其对应的机器码如下:
1 c7 05 40 71 41 00 0c 00 00 00
其中c7 05 代表mov指令,40 71 41 00 代表地址00417140h(小数端存储方式),0c 00 00 00 代表以四个字节表示的要进行赋值的数12。假设从内存地址00417140h开始的十个字节分别为:
1 0x00417140 11 11 11 11 11 11 11 11 11 11
则该条赋值语句执行完毕后该内存中的值应该为:
1 0x00417140 0c 00 00 00 11 11 11 11 11 11
因为一次修改了四个字节的内存空间,且小端机在内存中的字节数据是倒序存放的,因此前四个字节变成了0c 00 00 00。
如果要修改赋值语句的机器码,例如将上面语句中的12改为894567,则需先求出894567的十六进制表示为0xda667。同时还要考虑到小端机内存字节数据倒序存放的特点,即可完成对赋值语句字节码的修改,修改后的机器码如下:
1 c7 05 40 71 41 00 67 a6 0d 00
根据上面的分析我们知道指令不过就是一些字节的组合,因此我们可以抛开C语言,自己在内存中构造指令来执行。具体思路为可以先在内存中分配一块区域,存放我们要执行的指令的机器码。然后在正常的函数中通过jmp指令跳转到存放我们构造的指令所在的内存地址,因为控制读取指令的EIP寄存器中的值总是指向当前指令之后的内存地址,因此还必须在我们构造的指令之后再多构造一条jmp指令,使得程序在执行完构造的指令后还能通过jmp指令跳转回原程序中,其主函数代码如下:
1 void main() 2 { 3 void* code = buildCode(); 4 _asm { 5 mov address, offset _lb1 6 } 7 8 gi = 12; 9 printf("gi = %d\n", gi); 10 _asm jmp code // 跳转到相应的内存地址去执行构造的指令 11 gi = 13; 12 13 _lb1: 14 printf("gi = %d\n", gi); // 打印的结果为18而不是13 15 getchar(); 16 }
其中第3行调用buildCode获取新构建代码的首地址,第4到6行将第13行代码的地址赋值给address用以在执行完新构建的代码之后返回原函数,第10行跳转到指针code指向的地址。我们将在新构建的代码中将变量gi赋值为18,并在第14行打印这个赋值后的结果。(显而易见第11行的赋值语句就这样被跳过忽略了,可怜的孩纸)。
然后就是真正构建指令的过程了,mov指令的机器码我们之前已经了解过了,同样地通过反汇编我们同样能看到jmp指令的机器码格式为:
1 ff 25 12 34 56 78
其中ff 25是jmp指令的机器码表示,后面的四个字节则是要跳转的目标内存地址的十六进制表示。因此两条指令总共需要16字节的内存空间,然后分别将对应的机器码存入到这些内存空间中,代码如下所示:
1 void* buildCode() 2 { 3 char* code = (char *)malloc(16); 4 char* pMov = code; 5 char* pJmp = pMov + 10; 6 char* pAddress; 7 8 //mov gi, 18 9 pMov[0] = 0xc7; 10 pMov[1] = 0x05; 11 pAddress = pMov + 2; 12 *((int *)pAddress) = (int)&gi; 13 *((int *)(pAddress + 4)) = 18; 14 15 //jmp address 16 pJmp[0] = 0xff; 17 pJmp[1] = 0x25; 18 *((int *)(&pJmp[2])) = (int)&address; 19 20 return code; 21 }
在上面的代码中,首先使用malloc函数分配一块长度为16字节的内存空间。然后将该内存空间划分为两部分,前一部分存放mov指令的机器码,后一部分存放jmp指令的机器码。在第9到13行,将mov指令的机器码按字节存入内存中。在16到18行,将jmp指令的机器码存放在紧邻mov指令之后的内存空间中。最后返回该内存空间的首地址供主函数进行跳转。
2、理解指针和指针强制转换
学习过C/C++的人都知道指针的值其实就是一个内存地址,但是指针同时又有类型的区别,例如int* 和 float*。那么为什么区区一个内存地址还要有类型的区别呢?编译器怎么判断一个指针的类型?这些有关于类型的信息究竟存储在什么地方呢?来看一看下面的这一段代码:
1 int gi; 2 int *pi = NULL; 3 void main() 4 { 5 6 pi = &gi; 7 8 *pi = 12; 9 }
对其中的pi = &gi; 这一赋值语句,与其对应的汇编代码为:
1 00411452 mov dword ptr ds:[00417164h], 417168h
其中00417164h是pi的内存地址,417168h是gi的内存地址,因此这条赋值语句的作用是获取全局变量gi所在的内存地址并将该地址赋值给指针pi,即将gi的地址放入一个4字节的变量pi中(书中原话)。因为变量gi的地址长度刚好为4个字节,所以指针确实只存储了变量的地址,那么指针的类型信息究竟储存在什么地方呢?
再来看看 *pi = 12 这一句的汇编代码如下:
1 0041145C mov eax, dword ptr ds:[00417164h] 2 00411461 mov dword ptr [eax], 0ch
在第二条语句中,除了要赋的值(0ch)、被赋值的地址外,还有一个dword符号。是它回答了我们的问题:“写几字节?”dowrd表明将在内存中写入四个字节的信息,因此指针的类型信息决定了赋值/读取时写/读多少字节。
读/写多少字节的信息不是存放在指针变量中,而是放到了与该地址相关的赋值指令中,mov指令中的dword指明了这个信息。
为了验证上面的结论,再来看下面这段赋值语句的汇编代码:
1 short gi; 2 short *pi; 3 4 int main() { 5 pi = &gi; 6 00413762 mov dword ptr ds:[417165h], 417168h 7 8 *pi = 12; 9 0041376C mov eax, 0ch 10 00413771 mov ecx, dword ptr ds:[417164h] 11 00413777 mov word ptr ds:[ecx], ax 12 }
*pi = 12 对应的三条指令进行的操作分别为:
1、mov eax, 0ch:将12放入eax中,eax为4字节,12存放在eax的低2字节即ax中。
2、mov ecx, dword ptr ds:[417164h]:将pi存储的地址即gi的地址放入ecx中(pi的地址是417164h,[417164h]中存储的是gi的地址)。
3、mov word ptr ds:[ecx], ax:将eax的低2字节存储的内容(就是要赋值的12)存入ecx指向的地址(即gi的地址)中。"word"表明了如果向gi所在地址存储,将写入2字节。
根据上面的分析可知,指针类型信息short*体现在赋值指令mov中,而不是存放在指针变量中,指针变量只存放了地址。C语言的指针类型包含两方面信息:一是地址,存放在指针变量中;二是类型信息,关乎读写的长度,没有存储在指针变量中,位于用该指针读写是的mov指令中,不同的读写长度对应的mov指令不同。
C语言之所以要包装出指针的概念,是在汇编地址的内涵上增加了另一层含义,即读/写多少字节。不同类型指针,访问字节数不同。int*访问4字节,short*访问2字节,char*访问1字节。这样就方便我们操控一个地址,否则如果只有地址信息,每次访问它还要附加说明访问的字节数。同时,指针的加减也并不是简单地只是加减1字节,而是与其每次能访问的字节长度有关。例如int*加一是加4字节,而short*加一则是加2字节。
关于指针的强制类型转换的问题,联系上面的知识很容易就能够明白,先来看下面的代码:
1 int i; 2 int *pi; 3 short *ps; 4 char *pc; 5 6 void main(int argc, char* argv[]) 7 { 8 pi = &i; 9 0041138e mov dword ptr ds:[417148h], 41714ch 10 11 ps = (short *)&i; 12 00411398 mov dword ptr ds:[417144h], 41714ch 13 14 pc = (char *)&i; 15 004113a2 mov dword ptr ds:[417140h], 41714ch 16 }
从上面的代码可以看出,只有赋值地址的三条指令没有产生任何与类型相关的指令。可知,在指针变量赋值上,强制转换只是编译器的一个善意提醒,没有产生实际的指令。
指针强制转换的影响不是在转换的时候发生,而是在用转换的身份去访问内存时体现到了指令中,例如下面的代码:
1 *pi = 0x1234; 2 004113ac mov eax, dword ptr ds:[417148h] 3 004113b1 mov dword ptr [eax], 1234h 4 5 *ps = 0x1234; 6 004113b7 mov eax, 1234h 7 004113bc mov ecx, dword ptr ds:[417144h] 8 004113c2 mov word ptr [ecx], ax 9 10 *pc = 0x12; 11 004113c5 mov eax, dword ptr ds:[417140h] 12 004113ca mov byte ptr [eax], 12h
从上面的代码可以看出,在用之前经过强制转换之后赋值的不同类型的指针,虽然指向的都是同一个内存地址,但是其可操作的内存空间大小却是不一样的。要考虑什么情况下强制转换是安全的,就要看用这个转换后的身份去访问内存是否安全,简单说有以下原则:
如果转换后指针指向的数据类型大小小于原数据类型大小,那么用该转化后的指针访问就不会越过原数据的内存,是安全的,否则危险,要越界。
在上面的例子中, ps = (short *)&i; 强制转换后,用ps来访问内存是2字节,而i本身是4字节,所以不会越界,是安全的。而下面的代码就是危险的:
1 short s; 2 int *p; 3 p = (int *)&s;
因为p指向的是short变量s,大小为2字节,而p为整数指针,用它访问指向的内存将生成访问4字节的指令,访问将会越界。