《老码识途》读书笔记:第一章(上)

《老码识途》读书笔记:第一章--欲向码途问大道,锵锵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字节的指令,访问将会越界。

 

 

posted @ 2013-03-04 17:11  CyrilZhao  阅读(890)  评论(0编辑  收藏  举报