前言
个人感觉pwn和web是安全俩个大方向,与逆向相比,pwn要更加底层,所以暂时并不打算过于深入的学习pwn,旨在了解一些简单的二进制漏洞即可
感觉pwn比逆向难学多了,诶,慢慢来吧,着急不得,这里暂时先把那几个常见漏洞的原理,常见利用,和加固了解一下,学的太艰难了
常见pwn工具的使用
GDB 的使用方法
GDB(GNU Debugger)是 Linux/Unix 系统中常用的程序调试工具
1 | gdb 文件名 #调试可执行文件 |
联动pwn脚本进行调试
只需要在脚本想要打上断点的地方写入
1 | ## 调试程序 |
使用流程如下:
脚本中:gdb.attach(p) 附加调试器,pause() 挂起脚本执行,等待手动操作
GDB 中:输入pwndbg> fin fin 让程序从 read 内部返回,此时 payload 还未发送(因为脚本还在 pause 状态
脚本终端:按任意键,脚本继续执行 p.sendline(payload),触发 read 读取输入
GDB 中:read 执行完毕,此时栈已被 payload 覆盖。
后续:n 两次(leave→ret)即可看到 RIP 跳转到 backdoor
Pwntools 的使用
checksec
检查安全机制开启情况
1 | RELRO: Partial RELRO #Partial RELRO:.got不可写,got.plt可写 |
ROPgadget
ROPgadget常用于寻找一些可以被利用的汇编代码段,Ret2syscall找链子就是用的这个工具
1 | ROPgadget --binary 文件名 --string '/bin/sh' #获取字符串地址 |
编写攻击脚本
Pwntools 脚本编写
1 |
|
struct 在脚本编写中的使用
struct 模块可以将某些特定的结构体类型打包成二进制流的字符串
pwntools 里的 p32 / p64 本质就是 struct 的封装。
1 | #打包 |
| 类型 | struct 格式 | 字节 | pwntools |
|---|---|---|---|
| uint8 | B | 1 | p8 |
| uint16 | H | 2 | p16 |
| uint32 | I | 4 | p32 |
| uint64 | Q | 8 | p64 |
| float | f | 4 | 无(需 struct.pack) |
| double | d | 8 | 无(需 struct.pack) |
ctypes 在脚本编写中的使用
因为我们编写环境是python有的时候需要使用到c的函数
ctypes 在 Python 中 加载和调用 C 动态库。实现在python环境中调用c函数
1 | from ctypes import * |
常见二进制普适漏洞及利⽤
栈缓冲区溢出
栈溢出指的是程序向栈中的某个变量写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。
基础知识
栈是程序运行时用于管理函数调用、返回地址、局部变量、部分参数和寄存器保存的内存结构。为什么要设计栈?
因为函数调用和返回满足后进先出的顺序,所以用栈最自然。并且返回地址、局部变量、临时数据等函数汇编数据生命周期都和当前函数调用一致,访问也集中在栈顶附近,因此适合单独划出一块连续内存来管理。每个线程有自己的栈,栈由栈指针维护,分配和释放通常只是移动栈指针。
栈指针分为两个
sp——stack point——堆栈指针寄存器,始终指向当前栈顶,是push和pop的参考坐标,也是ret的操作对象
bp——base point——基础指针,在函数执行期间固定,作为当前函数栈帧的基准点,访问栈帧时常用 [BP+偏移] 访问返回地址和栈上传入参数,用 [BP-偏移] 访问局部变量。并且当前 BP 指向当前栈帧基址,而 [BP] 中保存的是调用者的 BP。
通过sp不断读取 [BP],可以沿调用链回溯调用者的栈帧。

如上图所示,x86 硬件直接支持的栈是从高地址向低地址增长的,也就是说数据是从下面这个口往里面加的,sp 寄存器始终指向栈顶,也就是当前栈中最低的已使用地址,即pop的时候,sp变大,push的时候sp变小
32位代码说明
下面就是32位函数调用的汇编代码和对应的栈
1 | main: |
需要注意的是
1 | mov esp, ebp |
上述两条汇编指令在部分程序中也会写做leave,二者是等价的
32位栈位大小位4字节
1 | ↑ |
sink点
gets函数
gets函数是一个危险函数。因为它不检查输入的字符串长度,而是以回车来判断结束
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
如上述代码输入超过 15 字节,就会覆盖s 后面的栈空间,而s是main函数中的临时变量,后紧跟saved RBP和return address,溢出就会覆盖返回地址
strcpy函数
1 | char s[32]; // 栈上的固定大小缓冲区 |
memcpy函数
1 | char s[32]; // 栈上的固定大小缓冲区 |
read函数
1 | ssize_t vulnerable_function() |
ret2text
ret2text 即控制程序执行程序本身已有的的代码(.text)。即存在危险函数如system或execv,可以直接劫持返回地址到目标函数地址上
如何利用后门函数
64位如何直接调用函数
可以直接调用,但是64 位系统高版本需要在调用前函数前加个ret平衡堆栈,glibc2.27 以后引入 xmm 寄存器,记录程序状态,在执行 system() 函数时会执行 movaps 指令,要求 rsp 按 16 字节对齐,需要在进入 system() 函数之前加上一个 ret 指令的地址来平衡堆栈
1 | from pwn import * |
为什么加个ret就能平衡堆栈哪?加ret难道不会影响后面的函数跳转吗?
比如说注入点是gets函数,注入完成之后函数返回
1 | ; 函数返回(epilogue) |
因为返回地址是ret,所以RIP为当前插入的ret的地址,rsp指向栈中的fuc_addr
此时再执行一次ret,RIP为当前插入的fuc_addr的地址
所以等于说加个ret,RIP调用结果没有变但是RSP += 8
为什么需要RSP += 8?
函数内容如下
1 | .text:0000000000401186 push rbp |
可以看到函数执行的时候,会先push rbp,栈里面多了8字节的数据,此时RSP -= 8
其实我们只要保证lea rdi, command call _system这两行汇编代码执行就行了,所以我们可以选择p64(40118A)或者p64(401187)作为调用地址,只要不执行push rbp,就不需要ret
没有system(“/bin/sh”),但可以自己构造
程序存在system函数,但是程序没有调用system("/bin/sh"),这个时候我们自己调用该函数实现利用
32位构造
当system@plt_addr作为返回地址弹出后到 EIP被调用
下述是system@plt_addr在ida中的形式
1 | int system(const char *command) |
执行 ret 后,system@plt_addr 被弹出到 EIP,ESP 指向 aaaa
system 函数会从栈上的 [ESP + 4] 位置获取参数 bin_sh_addr,进而执行相当于 system(“/bin/sh”) 的操作。
1 | # 没有system("/bin/sh"),但可以自己构造 |
这里的 b’aaaa’ 其实是填充一个 4 字节的数据,写成 p32(0) 或者 p32(0xdeadbeef) 也是一样的
64位构造
64 位程序优先使用寄存器来传递参数,前 6 个参数是通过寄存器(RDI、RSI、RDX、RCX、R8、R9)传递的,多余的参数才通过栈传递
这里pop rdi会取出bin_sh_addr存入rdi,然后ret调用system_addr
1 | # 没有system("/bin/sh"),但可以自己构造 |
ret2shellcode
程序存在溢出,并且还要能够控制返回地址,但是并没有现成的system函数供我们调用,此时如果存在bss变量可控,并且该变量所属段可写可执行(默认情况下可读、可写、不可执行),我们就可以自己填充shellcode 引导程序执行触发
利用要求 NX关闭,有写入的方法
使用 gdb 调试查看bss段是否有可执行的权限
1 | gdb ret2shellcode #使用gdb |
需要注意的是Ubuntu 20及以后的系统,即使程序没有启用 NX 和 ASLR,且程序设置了bss段可执行,但是系统还是会把BSS 段和数据段也不会被标记为可执行。注意题目中的系统版本,并且本地测试使用Ubuntu 18及以下版本系统
1 | from pwn import * |
如果没有给现成的写入shell方法,但是程序有read函数,可以调用read函数写shell了
1 | payload=b'a'*(0x38)+p32(read_addr) |
ret2shellcode还支持手写shell
1 | shellcode = ''' |
Ret2syscall
Ret2syscall 可用于绕过沙箱保护,或者针对静态编译等没有 libc 的场景
利用要求必须要有pop eax/rax; int 0x80/syscall 等gadget
调用分为两种一种是函数调用,和我们自定义的函数本质上没有什么区别,绝大多数函数(如 strcpy、gets、printf、fgets、memcpy、strlen、system)都不是系统调用,但是libc函数实现可能是系统调用的用户态封装
还有一种是系统调用,和我们自定义的函数不同的是,系统调用函数通过系统调用号来调用函数,如read、write、open、execve等
系统调用的调用方法
1 | ;32位系统调用函数的调用方法 |
和我们前面学汇编的输入输出调用方法很像,突出一个方便快捷
Ret2syscall的原理就是如果栈溢出没有system函数,我们可以控制程序执行系统调用获取 shell,如构造系统调用:execve("/bin/sh", 0, 0)
利用条件:需要有四个寄存器的pop|ret和int 0x80|syscall
32位execve执行execve("/bin/sh", NULL, NULL);
1 | payload = b'a' * (0x20 + 0x4) |
64位execve执行
1 | payload = b'a' * (0x20 + 0x8) |
Ret2libc
适用于程序中没有 system() 函数和 “/bin/sh”,或者程序开启了 PIE 地址随机化,需要泄露程序运行时的地址来计算偏移地址
利用要求,需要是动态链接编译,并且需要泄露出一个函数的真实地址
动态链接
静态链接是将编译期将所有库代码直接拷贝进可执行文件
动态链接,系统会将常见的函数放在libc.so动态链接库中,程序运行时需要调用哪个函数就从 libc.so库中找
动态链接相比静态链接更加节省内存、磁盘空间,并且调用起来更加灵活,方便对系统模块进行扩展兼容
那么动态链接是怎么做到的那?
动态链接的程序通常会有 .plt、.got、.got.plt、.dynamic、.dynsym 等与动态加载相关的节区。静态链接的程序则没有这些节区,代码调用是直接的,不会经过 .plt 这类中间跳转表
GOT
GOT 位于 .got / .got.plt 段,本质是一个 指针数组
.got 存放的是“作为数据被使用的地址”,包括全局变量地址、函数指针、vtable 等;
.got.plt 只服务于“通过 PLT 调用的函数”,保存真实的函数地址
PLT
为什么需要PLT?
程序代码段是 只读(RX),不能在代码中直接修改 call 目标,所以我们需要一个中转指针去映射GOT表
即call → PLT
PLT → 查 GOT
GOT → 真函数
延迟绑定
延迟绑定其实指的就是动态链接的实现过程
初始情况下puts@plt和puts@got相互指引
.got.plt 初始值指向 plt+6,第一次执行 PLT 时跳过间接跳转,进入负责延迟绑定的指令序列;
动态链接需要在运行时填 GOT,第一次访问puts@plt时,动态链接器就会去动态共享模块中查找 puts函数的真实地址然后将真实地址保存到puts@got中(.got.plt);
第二次访问puts@plt时,就直接跳转到puts@got中去。
Full RELRO 的本质是:
放弃延迟绑定,在程序启动阶段一次性解析所有符号,即一开始就把got表写好,然后把 GOT 设为只读。
实例
实例其实跟ret2text的构造system相似,都是直接调用plt。只是Ret2libc是在libc中找
对于上面动态链接的知识,我们只需要记住在第二次加载libc中的函数时,GOT 表项里存的是函数的真实地址
并且system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。所以我们只要得到libc的版本,就可以知道了system函数和/bin/sh的偏移量。知道偏移量后,再找到libc的基地址,就可以得到system函数的真实地址
libc基地址 + 函数偏移量 = 函数真实地址
首先需要泄露出一个函数的真实地址
由于 libc 的延迟绑定机制,我们可以泄漏已经执行过的函数的地址
实例一 32位通过write泄露read的真实地址
1 | io = remote("node4.buuoj.cn", 25051) |
实例二 64位通过puts泄露puts的真实地址
1 | puts_plt = elf.plt['puts'] |
通过泄露出的got地址计算出libc中的system和/bin/sh的地址
需要注意的是如果程序有 ASLR 保护,会针对于地址中间位进行随机。也就是说如果开启了 ASLR 保护,每开一次连接,ASLR 都会换一个新基址,第一次获取到的某个函数的真实地址,第二次就会不一样,不能直接用需要重新泄露
如果已知 libc 可直接使用
1 | libc = ELF("libc路径") |
如果未知则使用 LibcSearcher,需要确定 libc 版本
1 | from LibcSearcher import * |
最后和跟ret2text的构造system一样,调用plt执行system函数
栈迁移
栈迁移(Stack Pivoting)并不是一种独立的攻击方法,而是一种辅助利用技术,通常与 ret2text、ret2syscall 等ROP 链配合使用。
对于 ret2text,如果目标程序中存在可直接复用的 system(“/bin/sh”) 代码片段,则通常只需覆盖返回地址即可完成控制流劫持
而对于 ret2syscall 等ROP 链,通常需要多个 gadget 和 ret 指令来设置寄存器、传递参数并触发系统调用。当栈溢出可覆盖的空间不足以容纳完整利用链时,就需要使用栈迁移技术:通过劫持栈指针(ESP/EBP),将其重定向到攻击者可控的内存区域(如 .bss、堆区或栈上其他可控位置),从而扩展可控空间执行后续 ROP 链
具体实现就是将栈中ebp的值改为可控地址,然后将返回地址改为leave_ret_addr
leave_ret_addr的内容为
1 | mov esp, ebp |
等于说是更改了esp到可控地址,然后使用ret执行后续链子
64位 栈迁移+传参构造system 实例说明
题目描述
1 | _BYTE buf[12]; // [rsp+4h] [rbp-Ch] BYREF |
py脚本
1 | # ---- gadget / 关键地址(全部硬编码,因为二进制非 PIE)---------------------- |
绕过 Canary
基础知识
在程序的函数入口处中保存一个随机值(canary),溢出覆盖返回地址前必须先覆盖 canary;程序在返回时检测 canary 是否改变,若改变则触发异常。
Canary是从线程中获取的,每次进程重启的 Canary 都不同,无法静态获取,十六进制通常以 ‘\x00’ 结尾,例如:0x29a30f00。在同一次运行中,Canary 的值是不会变的,并且同一个进程中的不同线程的 Canary 是相同的,通过 fork 函数创建的子进程的 Canary 也是相同的
canary在ida中的表现形式
一般放在函数开头位置
在 64 位程序中,表示从 FS 段寄存器指向的线程本地存储(TLS)中读取一个 8 字节(QWORD)数据,通常为 FS:28hv2 = __readfsqword(0x28u);
在 32 位程序中,表示从 GS 段寄存器指向的线程本地存储(TLS)中读取一个 4 字节(DWORD)数据,通常为 GS:14hv2 = __readgsdword(0x14u);
通常 Canary 在栈中是位于 RBP 上方的 8 字节(与 RBP 相邻),但是 Canary 的位置不一定总是与 RBP 相邻
具体需要看变量v2的位置,变量v2的位置就是carry在栈中的位置
覆盖低字节输出 Canary
1 | int i; |
puts会一直打印直到遇到\x00,通过 puts 可泄露 Canary
然后在第二次循环的时候填充正确的 canary即可绕过canary机制
1 | pay = b'A' * 0x38 + b'^'#小端排序下Canary 的 最高字节是 \x00,需要覆盖,他这个设计也是为了防止被打印出来 |
格式化字符串泄露 Canary
1 | void __noreturn ctfshow() |
Canary在栈上直接就能用%P打印出来
1 | # 高地址 |
格式化字符串漏洞
在调用printf函数时, printf() 会严格的按照 format 参数所规定的格式逐个从栈中取出并输出参数,如果没有给出传参或者传入参数小于format 参数,就会开始依次打印栈中的数据
64位系统程序会先打印RDI,RSI,RDX,RCX,R8,R9,如果参数超过6个才将栈上地址视为参数地址

已知地址修改对应的值
32位
而printf() 将出现在 %n 之前的字符数量存储到对应的参数所指向的变量中,根据这个设定配合$位置参数,我们就可以实现任意可写数据修改
漏洞代码
1 | int c = 789; |
s是可控的我们想要修改c为16,
首先我们需要找到我们传入的s的值,存储在栈的什么地方
1 | from pwn import* |
可见在打印第11个传参时,开始出现我们传入的AAAA(0x41414141),所以我们可以确认第11个参数是可控的,即格式化字符串本身是在第11参数后开始输出的。后面的0x2d70252d其实就是-%p-
%11$n,就是把第11个参数当成指针地址,并计算之前输出的字符数存储到指针所指的位置
1 | from pwn import * |
这里第11个参数就是我们传入的c变量的地址,需要凑够16个字符,c变量的地址会被当成指针,更改这个指针所指向的值为16
pwntools对这个利用有封装payload=fmtstr_payload(11, {x_addr:16})
64位
64位脚本编写和32位差别不大,但是需要注意的有两点
一是上面是32位寻找可控变量,如果是64位,为了对齐 64 位的参数槽大小需要改为AAAAAAAA-%p-%p-,并且因为打印出来的前5个变量都是寄存器,所以一般可控变量都在%6$附近
第二点就比较重要了
printf也属于字符串函数,c的字符串函数一般都存在 \0 截断的问题,这就要求我们在最终的payload里面不能有\x00,上面32位那个案例里面需要修改的变量地址为0x0804A02C恰好没有\x00,不然上面那个脚本还得改
程序的变量、.bss、GOT 等各段都在用户态地址上,Linux 的用户态地址空间落在低位规范地址范围里,但是x86-64 的低位规范地址上限是 0x00007FFFFFFFFFFF,这就使得64位变量地址一定会包含\x00,我们就需要把变量地址放到最后
1 | target=0x6020CC |
got劫持
利用条件
- 需要是动态链接:有 GOT/PLT,才能 printf@plt -> system@plt
- Partial RELRO:plt 可写
- non-PIE:printf@got、system@plt 地址要已知
- 第一个参数可控:被覆盖的函数在调用时第一个参数必须是一个可控的字符串,方便传入’/bin/sh’
1 | while (1) { |
1 | # 2. 可控参数是6,printf 0x804a010 -> system 0x8048400 |
已知地址泄露对应的值
上面是利用printf() 的 %n特性实现了变量修改
而printf() 的 %s可以获取一个指针对应的值,%s用于打印字符串,会将对应参数视为 char *,打印指向的字符串
所以我们确定可控参数后就可以泄露任意地址对应的值
1 | from pwn import * |
GOT 表项的值:该地址处存储的内容,才是 scanf 函数在运行时被解析后的真实内存地址
沙箱绕过
程序创建沙箱的方式
sandbox() 函数通常有两种方式开启沙箱:prctl 函数调用、seccomp 库函数
prctl(38, 1LL, 0LL, 0LL, 0LL)
当第一个参数为 38 时,表示禁用系统调用
第二个参数设置为 1,则禁用 execve 系统调用,对子进程同样生效prctl(22,2, &v1)
当第一个参数为 22 时,表示设置沙箱规则,从而可以实现改变函数的系统调用
第二个参数设置为 1,只允许调用 read、write、_exit(not exit_group)、sigreturn 这几个 syscall。第二个参数设置为 2,过滤模式,通过参数 3 的结构体自定义过滤规则来对 syscall 进行限制
使用 seccomp() 函数调用开启的沙箱
1 | __int64 sandbox() |
ORW读取 flag 文件
下面是Shellcode 型题目
1 | void (__fastcall *buf_1)(_QWORD); |
就是任意代码执行,然后使用 open、read、write:打开文件,将文件内容读取存储到某个位置,将文件内容打印出来
32 位 ORW 构造
1 | context(os='linux', arch='i386', log_level='debug') |
手写表示
1 | ORW = ''' xor edx,edx; mov ecx,0; mov ebx,0x804a094; mov eax,5; int 0x80; ''' |
64 位 ORW 构造
1 | context(os='linux', arch='amd64', log_level='debug') |
手写表示
1 | # fd = open('/flag', 0) |
字符串 \0 截断
c 的字符串函数依赖 ‘\0’ 作为结束符,而底层输入函数按字节读取,不把 ‘\0’ 当特殊字符。这种“输入”与“处理”的语义错位,正是许多安全漏洞的根源。
案例
1 | /* 1. strlen 截断:长度检查被绕过,后续拷贝溢出 */ |
这是系统底层设计,大二的时候我们写汇编代码的时候字符串也是要求必须以\00结尾,不然会连读
但是c的内存操作函数,如memcpy就不会出现字符串 \0 截断
例如strlen_ifunc(p_n0x48) <= 0x20,那么我们就可以在33开始使用\0 截断
payload = 32 * b'a' + 32 * b'\x00'+p64(REPAIR_COMPLETE)
当然如果字符串开头没有检测的化也可以在开头就截断,这时len=0
1 | payload = b'\x00' # ★ 让 strlen(buf1) = 0 |
伪随机数
伪随机数:由一个确定性算法从初始状态(种子)出发,按固定递推关系产生、统计上近似随机的数列。
c的随机数rand和php随机数函数mt_rand()一样都是伪随机数,只要种子一样,生成的数字序列也一样
因为Python 的 random 和 glibc 的 rand() 是两个完全无关的算法,同样的种子结果毫无关系。
所以在编写脚本的时候,我们需要使用ctypes调用本地的c库里面的rand函数
1 | # ---------------- leve3:猜数字 srand(time(0)); rand()%60 ---------------- |
整数溢出
原码反码补码
现代计算机的运算器通常以加法器为核心,没有减法器从而简化复杂度。为了用统一的加法电路处理正负数,需要为有符号整数规定二进制编码。
原码、反码、补码是历史上三种有符号整数表示方案。最终补码成为现代通用计算机中有符号整数的主流表示。原码和反码在现代通用整数运算中已很少作为存储和运算格式
原码:将整数的二进制形式表示出来,最高位作为符号位
0001+0010=0011,1+2=3;
0000+1000=1000,+0+(-0)=-0;
0001+1001=1010,1+(-1)=-2
可见原码只适用于正数的加法,无法处理正数与负数相加,或负数与负数相加
反码:正数的反码还是等于原码;负数的反码就是它的原码除符号位外,按位取反
0001+1110=1111,1+(-1)=-0;
0001+1101=1110,1+(-2)=-1
需要循环进位:最高位进位不能简单丢弃,必须加回最低位。
0010+1110=10000,循环进位0001 2+(-1)=1
1110+1100=11010,循环进位1011 (-1)+(-3)=-4。
反码存在的问题:
存在两个零:0000 是 +0,1111 是 -0,比较和判断要额外处理。
硬件上可以实现:把进位输出接回进位输入即可实现循环进位,但比补码多这一点逻辑;加上双零问题,整体不如补码简洁。
补码:正数的补码等于它的原码;负数的补码可以理解为反码+1
0111+1000=1111,7+(-8)=-1
0001+1111=10000,丢最高进位得0000,1+(-1)=0;
0010+1111=10001,丢最高进位得0001,2+(-1)=1;
1111+1101=11100,丢最高进位得1100,(-1)+(-3)=-4;
四位二进制补码的范围是-8 ~ +7,但是四位二进制原码和反码范围:-7 ~ +7
补码额外多出的那个最小负数,在同位数原码/反码中没有表示,就不能套用“反码+1”,所以负数的补码不一定等于反码+1
其实上面的定义只是巧合,就是负数的补码正好就是其对应的反码+1而已。补码的本质可以定义为整数在模 2^n 下的同余表示
如 4 位补码的计算如下所示
-1 mod 16 = 15 = 1111,所以 -1 的 4 位补码是 1111
-2 mod 16 = 14 = 1110,所以 -2 的 4 位补码是 1110
-3 mod 16 = 13 = 1101,所以 -3 的 4 位补码是 1101
-8 mod 16 = 8 = 1000,所以 -8 的 4 位补码是 1000
+7 mod 16 = 7 = 0111,所以 +7 的 4 位补码是 0111
实例
溢出
溢出例子:
0111+0001=1000,同号相加结果异号,溢出,7+1=8超出4位补码范围;
1000+1111=10111,丢最高进位得0111,同号相加结果异号,溢出,(-8)+(-1)=-9超出4位补码范围;
有符号/无符号整数转换漏洞
如 read、memcpy 等函数的长度参数一般都是无符号整数,如果程序把一个有符号负整数传进去,它会被隐式转换成巨大的无符号值
32位示例如下
1 | int len; |
整数截断
当变量赋值时,将一个大变量赋给一个小变量,如果大变量的值超过了小变量的范围,就会低位截断
1 | unsigned __int8 n0x60; // 8 位 = 1 字节,范围 0 ~ 255 |
数组越界
栈是由高往低增长的,而数组的存储是由低位往高位存的,如果越界的话,会把当前函数的ebp 和下一跳的指令
地址覆盖掉
1 | void __cdecl vuln() |
1 | import sys |