神秘代码
大家好 我是周杰伦
明天给大家看个有意思的货色!
不仅有意思,还能学到常识。
话题从两行(精确的说是一行)神奇的代码聊起:
#include <stdio.h>
int main[] = { 232,-1065134080,26643,12517440,4278206464,12802064,(int)printf };
这是一段 C ++ 代码,猜猜看编译运行后,会输入什么?
可能,你会问:这 TM 连 main 函数都没有,能编译胜利?
还真能!
咱们别离在 Windows 平台下的 Visual Studio 和 Linux 平台下的 g++ 进行编译,而后别离执行看看成果:
Windows 下:
Linux 下:
不仅能编译胜利,还能失常运行,在 Windows 上输入了一个MZ,在 Linux 上输入了一个ELF。
相熟 PE 文件格式的同学可能晓得,MZ 是 PE 文件结尾的标记,另外,ELF 也是 Linux 上的可执行文件结尾的标记。
也就是说:下面这行代码执行后,把所在可执行文件头部的字符串给打印进去了!
反汇编假相
看到这里,你可能有两个问题:
- 为什么没有 main 函数还能通过编译?
- 为什么会输入这么一串信息?
对于第一个问题,置信大家应该也猜到了个八九不离十。尽管代码中没有 main 函数,然而有一个 main 数组啊!会不会跟它有关系?
是的没错,对于编译器而言,函数也好,变量也好,最终都解决成了一个个的符号 Symbol,而编译器并没有辨别这个符号是来自一个函数还是一个数组。所以,咱们用一个 main 数组,骗过了编译器。
也就是说:编译器把 main 数组当成了 main 函数,把 main 数组中的数据当成了 main 函数的函数体指令。
而要答复第二个问题,那就得看下这个 main 数组中的这一段奇怪的数字,到底是一段什么样的代码?
将 main 数组中的数值转换成 16 进制看看,依照一个 int 变量占 4 个字节对齐:
再进一步,应用反汇编引擎看看这段 16 进制数据是什么指令?
接下来,咱们逐条剖析这些指令。
call $+5
这是一条十分重要的指令,请记住:call 指令是在执行函数调用,执行 call 指令的时候,会将下一条指令的地址压入线程的栈顶,用于函数返回时取出找到回去的路 ,那下一条是谁?就是上面的pop eax 这条指令,所以执行这个 call 指令时,会把上面那个 pop eax 指令的地址压入栈顶。
再者,call 前面的指标地址是 $+5,也就是这条 call 指令地址 + 5 个字节的中央,同样是上面那条 pop eax 指令的地址,所以 call 的指标函数就是紧接着的上面 pop eax 指令开始的中央。
那这么吃力执行这个 call $+ 5 的意义何在?其实就是为了获取以后这段代码所在的内存空间地址,然而又没有方法间接读取指令寄存器 EIP 的值,所以借助一个 call,把这段代码的地址压入到堆栈中,随后再取出来就能晓得这段代码被搁置在内存中哪个地址在执行了。
这个手法,是黑客编写 shellcode 的习用手腕。
pop eax
留神,执行到这里的时候,线程的栈顶寄存的就是这条指令所在的地位,是下面那条 call 指令导致的后果。
接着,pop eax,将栈顶寄存的这个地址取出来,放到 eax 寄存器中。当初 eax 中寄存的就是以后指令的内存地址了。
add eax, 13h
下面费这么大劲拿到了这个地址有什么用呢?别急,看这条指令,给它加了 13h,也就是十进制的 19,回头看看 main 数组那个十六进制字节表,加了 19 后,正好是 main 数组最初一个元素所在的地位——外面寄存了 printf 函数的地址。
所以,截止到这里,后面这三条指令的目标就是为了能拿到 printf 函数的地址。
push 400000h↵↵拿到 printf 函数当前,开始调用。这里给 printf 传了一个参数:0x00400000,也就是要打印的字符串地址。
mov edi, 400000h↵↵这里同样是在给 printf 函数传参,这里和下面那条,一个通过堆栈传参,一个通过寄存器传参数,是为了同时兼容 Windows 平台和 Linux x64 平台上的函数调用约定。
而之所以传递的字符串地址是 0x00400000,是因为刚好,这个数字是两个平台上可执行文件加载的默认基地址。
Windows:
Linux:
(gdb) x /16c 0x00400000
0x400000: 127 '\177' 69 'E' 76 'L' 70 'F' 2 '\002' 1 '\001' 1 '\001' 0 '\000'
0x400008: 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000'
call dword ptr [eax]
还记得后面 eax 存储的是 main 数组的最初一个格子的地址,这个格子外面寄存的是 printf 函数的地址。
于是,通过一个指针调用 call,来调用 printf,实现打印输出。
pop eax
函数调用完了,得进行堆栈均衡,后面传参压栈了,这里就得弹出来。
retn
留神这个 retn 指令,retn 指令和 call 指令对应,call 用于调用函数,将返回地址压栈,而 retn 指令则将栈顶的数据弹出来作为返回地址,跳回去执行。
还记得吗,当初这段代码是处于被第一个 call 指令调用的上下文中的,失常状况下,执行 retn 是不是应该返回到 call 指令前面?那岂不是又回去 pop eax 走一遍乱了套了?但留神,当初栈顶的那个返回地址曾经提前被 pop 进去了(第二行那个 pop eax),那当初执行 retn,取出来的栈顶数据又是什么呢?
这个数据就是线程执行到整个 main 函数最开始的时候,栈顶保留的调用 main 函数的调用者的返回地址。所以这个 retn 不是返回到第一个 call 前面,而是返回到了上一级调用 main 函数的的那个中央。
至于具体是谁在调用 main 函数,这就不是这篇文章的重点了,属于 Linux 和 Windows 上各自的 C /C++ 运行时库 CRT 函数的领域。
到这里,你应该就能明确,这个程序是如何运行起来的,以及,为什么会有那样的输入信息。
几个注意事项
- 首先,为了可能顺利通过编译,在 Linux 上,须要应用 g++ 而不是 gcc 进行编译,因为对 main 这个全局变量初始化时,C 语言规定必须是常量,而不能是动静确定的(最初那个 printf 函数地址就是动静的),同时还得加上 -fpermissive 编译选项。
- 须要敞开模块的随机加载性能。古代操作系统为了抵制平安攻打,可执行文件的加载基地址都进行了随机化,避免被猜想,而这段代码可能失常运行的前提是可执行文件加载基址是 0x00400000。不能随机化,所以须要通过编译器来敞开。
- 最初,依据后面的剖析其实也晓得了,其实程序把 main 数组中的数据当成了代码在执行。在古代操作系统的安全性爱护下,默认状况下是拒绝执行数据所在的内存页面的,因为这些内存页面只有读写权限,而没有可执行权限,这一平安机制叫 DEP/NX。所以为了失常运行,须要把这个敞开。对于 g ++,增加 -z execstack 编译选项即可。
总结
其实这段代码的思路并非我的原创,在国外有一个 国内 C 语言凌乱代码大赛 (IOCCC, The International Obfuscated C Code Contest)。这个较量的特点就在于 写最骚的代码,实现最奇葩的成果,其中就有这样的获奖案例。
起初,国内一个大牛也原创了本人的版本,参考链接:
https://blog.csdn.net/masefee…
不过,这个版本仅实用于 Windows 平台,我在此基础之上,又改了当初这个版本,同时反对 Windows 和 Linux 平台。
这段代码自身没有任何意义,不具备实用价值,但透过代码去钻研代码和程序背地执行的底层原理,理解 CPU 如何调用函数、传递参数,跳转,操作堆栈,这些才是这篇文章的意义所在。
给大家留个思考题,上面这行代码能失常运行起来吗,运行起来又做了什么呢?
int main[] = {0xC3};