hide main() function

http://blog.csdn.net/masefee/article/details/6606813

main函数隐藏

我想大家应该都听过“国际C语言混乱代码大赛(IOCCC, The International Obfuscated C Code Contest)”吧,今天无意间在网上讨论到这个问题。我有意将main函数改变了一下,居然编译通过了,于是想利用这个特性,写一个“诡异”的代码。(写完之后发现,IOCCC居然也有类似的参赛获奖作品,悲剧,早知道我也去参赛了。。。)

进入正题吧,代码是这样的:

  1. #include <stdio.h>  
  2. int main[] = { 232,-1065134080,5138447,285147200,50008,(int)printf };  

将这两句代码copy到XXX.c里,用VC编译,能顺利通过,并执行,输出结果为:

This program cannot be run in DOS mode.
$

或者换一种写法:

  1. #include <stdio.h>  
  2. int ______ = ( int )printf;  
  3. int main[] = { 232,-394045440,5138441,285147200,50008 };  

输出结果一致。

这么一来,为什么这两段诡异的代码能够通过编译并运行呢?可以总结为两个疑问:

1. 为什么能通过编译?

2. 为什么能输出这么一句字符串?也没用看到调用printf,更没有看到该字符串。

我们一个一个解决,对于第一个疑问:

首先,这两段代码都必须使用.c文件进行编译,在.cpp文件下是不能通过编译的,也就是说必须用C编译器编译,C++编译器不能通过。

其次,正因为是C编译器,同时又是Visual studio环境,那么对于函数的参数个数,类型等的检查不会很严格,对于入口函数main,编译器在查找main函数符号并链接时,不会严格检查。因此,在这个地方将main函数用数组形式表达也能顺利链接。GCC下也是可以顺利编译通过的,只是要改一下代码才能成功运行,本文就不再累述了,这个不是重点。

到此,第一个疑问就解决了。那么第二个疑问就相对复杂很多了,我们一步一步分析。

首先,main数组被链接为main函数,那么main数组内部的整数(int,4字节)就是main函数执行的代码字节(机器码),这里有点类似shellcode的原理。至于什么是机器码,这里就不做解释了,可以在我之前的博客里或者网络上找到答案。既然是机器码,那么这些整数就一定代表具体的执行逻辑,由于是直接写的机器码(整数),我们只能看反汇编代码,我们以第一个例子为例:

00492000 E8 00 00 00 00   call        _main+5 (492005h) 
00492005 58                          pop         eax  
00492006 83 C0 0F              add         eax,0Fh 
00492009 68 4E 00 40 00   push        40004Eh 
0049200E FF 10                    call        dword ptr [eax] 
00492010 58                          pop         eax  
00492011 C3                          ret              
00492012 00 00                    add         byte ptr [eax],al 
00492014 E0 B0                    loopne      00491FC6 
00492016 42                          inc         edx  
00492017 00 E0                    add         al,ah 

在看main数组的内存:

0x00492000  e8 00 00 00 00 58 83 c0 0f 68 4e 00 40 00 ff 10 58 c3 00 00 e0 b0 42 00       ?....X??.hN.@...X?..??B.

0x00492018  e0 b0 42 00 —————————————————————————------       ??B..............HI.....

横杠为省略部分内存,大家可以发现,第一排的24个字节(6个int)正是main数组里的6个整数,最后4个字节即为printf函数的首地址:0x0042b0e0(在你的平台下通常不一样)。那么我们看上面的反汇编代码,前面的机器码也是内存里的24个字节,我们来分析一下反汇编代码:

头两句红色的汇编代码:

call 0x00492005

pop eax

这两句的功能主要是为了取得EIP寄存器的值,当执行完pop eax这句代码之后,eax的值为0x00492005,这样便取得了EIP的地址。

至于这两句代码为什么能取得EIP的地址在之前的博文里也有相关的讲解,我们知道call指令理解分为两步操作,一是将call指令的下一条指令的代码地址压栈,二是进行跳转。

这里call指令的下一句代码是pop eax,它的代码地址是0x00492005。call指令在压入这个地址值到栈里之后,再跳转到pop eax这句。此刻,pop eax就会将刚刚压入的代码地址(0x00492005)弹出到eax里,这样eax里就获得了pop eax的代码地址。为什么要这么麻烦呢,是因为内敛汇编不支持mov eax,eip这类的操作,所以就借助call的特性来获得EIP。

那么,为什么要获取这个地址呢,目的是为了获取后面printf函数的地址所存储的位置(也就是相对于main数组首地址的偏移量,也就是main数组最后一个int的内存地址),也就是0x00492014。这个地址也就是前面获取的EIP值0x00492005 + 0x0f。因此,上面绿色的那句汇编代码add eax, 0fh就不用再解释了吧,add之后,eax的值则为0x00492014

到此,printf函数的地址值存放的位置也知道了,这时该考虑怎么能够调用printf输出前面那串字符了。乍一看,这串字符似乎很熟悉,对!的确很熟悉,这串字符正是PE文件头里的信息,exe文件里有这么一个信息,那么我们去哪儿找到这串字符的内存地址然后传入printf呢?

平时调试程序的时候应该能够注意到exe通常默认都会从0x00400000这个内存地址开始加载,当然也有时候不是这个地址开始的,例如在win7和vista下,如果编译器开启了随机基地址选项时,那么每次运行exe时,就会随机一个基地址进行加载,这时就不一定是从0x00400000这个内存地址开始加载了。本文只针对从0x00400000这个地址加载的情况进行分析。

好了,我们来看看0x00400000这个内存地址下的内存情况:

0x00400000  4d 5a 90 00 03 00 00 00 04 00 00 00 ff ff 00 00 b8 00 00 00 00 00 00 00          MZ?.............?.......
0x00400018  40 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    @.......................
0x00400030  00 00 00 00 00 00 00 00 00 00 00 00 e8 00 00 00 0e 1f ba 0e 00 b4 09 cd      ............?.....?..?.?
0x00400048  21 b8 01 4c cd 21 54 68 69 73 20 70 72 6f 67 72 61 6d 20 63 61 6e 6e 6f        !?.L?!This program canno
0x00400060  74 20 62 65 20 72 75 6e 20 69 6e 20 44 4f 53 20 6d 6f 64 65 2e 0d 0d 0a       t be run in DOS mode....
0x00400078  24 00
 00 00 00 00 00 00 25 3c f5 d5 61 5d 9b 86 61 5d 9b 86 61 5d 9b 86      $.......%<??a]??a]??a]??

可以看出,上面红色的部分从0x0040004e开始即为之前输出的那串字符,一直到'$'字符后才结束,即到0x00400079才结束输出。

分析到此,前面的反汇编里的黑色粗体一句的汇编就已经很明了了,它正是将0x0040004e这个地址传递给printf函数,让其输出这串字符。

之后的一句蓝色的call代码,即调用printf函数,前面已经将printf的地址值在main数组里的地址值存到了eax里,此刻只需要将eax下的地址值取出来,call过去就可以了,也就等价于:

call main[ 5 ]  // 伪代码

调用完printf输出之后,pop eax则是为了平衡栈,因为printf是__cdecl调用约定,所以调用者需要平衡栈。pop eax就等价于add esp,4,这里为了节约几个字节,pop eax只占一个字节。

好了,反汇编代码就分析得差不多了。原理其实很简单,至于第二种写法与第一种只是printf函数的地址存放的位置不一样。我们来看反汇编代码:

00492000 E0 B0                     loopne      00491FB2 
00492002 42                           inc         edx  
00492003 00                           db          00h  
00492004 E8 00 00 00 00    call        _main+5 (492009h) 
00492009 58                           pop         eax  
0049200A 83 E8 09               sub         eax,9 
0049200D 68 4E 00 40 00   push        40004Eh 
00492012 FF 10                     call        dword ptr [eax] 
00492014 58                           pop         eax  
00492015 C3                          ret              
00492016 00 00                     add         byte ptr [eax],al

绿色的一句代码变成了sub,向前减9个字节,刚好是0x00492000,也就是变量"______"的地址。printf函数的地址值就存在这里,所以需要减去9,也就是0x00492009 - 9。其他部分的代码与前面的一致。("______"变量和main数组的地址在内存上是连续的)

诡异的代码就如此的产生了,其实如果将main数组翻译为内敛汇编的版本,如下:

  1. 版本一:  
  2. int __declspecnaked ) main( void )  
  3. {  
  4.     __asm  
  5.     {  
  6.         call __geteip  
  7.   
  8. __geteip:  
  9.         pop  eax         // 获取EIP  
  10.         add  eax, 0dh  
  11.   
  12. __entry:  
  13.         push 0x0040004e  // 为printf函数压入参数  
  14.         call [ eax ]  
  15.         pop  eax         // 平衡栈  
  16.         ret  
  17.     }  
  18. }  
  19.   
  20. 版本二:  
  21. int __declspecnaked ) main( void )  
  22. {  
  23.     __asm  
  24.     {  
  25.         call __geteip  
  26.   
  27. __geteip:  
  28.         pop  eax  
  29.         sub  eax, 09h  
  30.   
  31. __entry:  
  32.         push 0x0040004e  
  33.         call [ eax ]  
  34.         pop  eax  
  35.         ret  
  36.     }  
  37. }  

这两个版本不能运行,只能通过编译。因为printf的函数地址存放的位置不能确定了,我们使用数组是可以确定的。另外在ret指令后,如果不是4的整数倍,那么写成main数组的时候,就需要填充字节,在main数组里我填充为0。

以上两个版本可能在某些时候不能运行成功,因为main数组处于数据段,数据段的内存可能没有执行权限,因此会出错。在实际中,可以修改内存权限。

综述:

1. 本文的例子不具有实用价值,只作为研究之用,其目的在于了解函数调用模型的本质以及汇编层的框架和指令的利用。重在原理性的研究,拓展思维。

2. 个人认为,很多不具有实际实用价值的东西并非不值得研究,研究的目的不在于结果,在于过程,吸收有利的,抛弃无用的。

3. 对于本文的实例,原理性的东西如函数调用模型,在实际中有用处很多,很典型的例子就是通过dump文件进行错误查找和分析,这里的dump文件可能是自定义的dump格式。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值