[TOC]
前文:以下是我ctfshow的PWN做题记录,欢迎各位前来观看,小弟领教!还有就是ctfshow-pwn是我入门PWN的第一套题,比较基础,看官方writeup可以知道比较简洁,但有些我看不懂,这是我自己做的过程中写的,都是发自内心理解它们的话,有些可能比较啰嗦,但也必须这样,因为站在新手角度来看必须要这种啰嗦方式去理解才能更深刻,有耐心的朋友可以一直看下去,当然也欢迎PWN大佬前来挑战我!!!😄
Test_your_nc
PWN_000
专用虚拟机镜像,全套在这里,提取码show;虚拟机镜像,用户名为ctfshow,密码是ctfshow。
看了课也啥都不会或者工具不会下看这–>CTF Wiki-PWN
解题过程:
ssh连接(记得给虚拟机kali\Ubuntu或者主机设置打开SSH):
ssh ctfshow@pwn.challenge.ctf.show -pxxxx
一开始会让你输入yes/no的,直接yes,然后输入密码,等进程结束会有个交互shell

用命令ls打开看一下有啥文件,看到有个ctfshow_flag的文件,猜是个装flag的文件夹,直接用cat命令打开就好。注意斜杠/。
PWN_001
题目 :提供后门函数,连上即可得到flag
这里有用到“nc”连接,可以了解一下,方便后续做题。当然Kali自带nc连接的,你用Kali干PWN也行。

这题送分的,nc连接一下就爆flag了。
不过说实话我是真不知道PWN_001文件给来干嘛的,直接nc交互一下就出flag了,这题没搞懂ctfshow意图在哪。

不过我用了下刚学的指令checksec看了看ELF文件信息,发现是没开canary保护的,可以利用栈溢出漏洞…
可以看到是64位仅关闭Canary保护。
接着用64位IDA打开查看main函数(按F5进入反汇编):

怪不得直接爆flag出来呢。后门system函数直接运行了。
PWN_002
题目: 给你一个shell,这次需要你自己去获得flag
到这里度过新手期了。

这里需要我们输入指令了,一般来说做题习惯看到这样直接ls秒的,不过像我这样入门级别的还得用IDA琢磨琢磨:


可以看到是没canary保护的

看到这里有个疑问,为什么system有这么大威力?,这次system里边是一个/bin/sh的软链接,很类似我们的文件地址
system("/bin/sh");是漏洞利用中获取系统 shell 的核心操作,其工作原理涉及函数调用、系统调用和进程创建三个层次,本质是通过标准库函数system启动一个/bin/sh进程(即命令行解释器),让攻击者获得交互权限。
system 是 C 标准库(libc)中的函数,原型为:
|
|
它的功能是执行参数
command指向的字符串作为系统命令,相当于在终端中输入该命令并执行。
system()函数先fork一个子进程,在这个子进程中调用/bin/sh -c来执行command指定的命令。/bin/sh在系统中一般是个软链接,指向dash或者bash等常用的shell,-c选项是告诉shell从字符串command中读取要执行的命令(shell将扩展command中的任何特殊字符)。父进程则调用waitpid()函数来为变成僵尸的子进程收尸,获得其结束状态,然后将这个结束状态返回给system()函数的调用者。
system("/bin/sh")的工作原理可简化为: 通过system函数创建子进程 → 子进程执行/bin/sh程序 → 启动交互式 shell 环境。这也是漏洞利用中最常用的获取权限的方式 —— 无论通过栈溢出、ROP 还是 ret2libc,最终目标都是让程序执行这条语句(或等效的系统调用)。
PWN_003
题目: 哪一个函数才能读取flag?

通过前面的分析,我们可以很明显的看出来选项”6”为我们所需要的后门函数,其他的均不会得到我们所想要的flag,在system函数里面的命令都是在终端执行的,赋予system权限较大,在我所写的文章里,包括PWN入门知识讲解和操作系统都明确指出SYSTEM是与操作系统内核接触的,比Administrators的权限还要稍大。
直接选6秒了,但除这种送分的,其实还需要搞清运行的逻辑:

跟进一下menu函数里边:

回到main跟进一下这个“6”的函数,看看里头是不是真在执行system后门函数:


可以看到,确实是执行了。那么我们也就能得到我们需要的flag。
PWN_004
题目: 或许需要先得到某个神秘字符
真够阴间的,啥也不给了,才第几道啊就上强度了。

checksec看看,然后IDA反编译看看:

64位二进制保护全开…

可以看到有几个似曾相识的函数:strcpy(复制函数把xx复制给s1,也可起到赋值的作用)、strcmp(比较函数,对比,判断作用)、execve();我曾说过汇编语言的cmp就有比较作用,这也算是类比吧,回想起来的,不过用AI搜一下也行。
来说下execve吧,上题讲过system函数,这个也应该知道的:
execve 是 Unix/Linux 系统中最核心的系统调用(system call) 之一,用于在当前进程中加载并执行一个新的程序,替换当前进程的代码、数据和堆栈,实现进程 “变身”。它是所有程序启动和替换的底层基础,包括 system 函数、shell 执行命令等最终都会依赖 execve 完成。
看到这段代码,大致意思是,让s1等于“CTFshowPWN”这段字符串,然后到if判断语句这进行比较:如果s1和s2相等(这里的!不是我们C语言中的“不”的意思啊),就执行execve函数,启动交互权限,从而得到flag。

至此:下一节就开始前置基础了,开始应用PWN-1、PWN-2、PWN-3所学的知识了。
前置基础
PWN_005
题目:运行此文件,将得到的字符串以ctfshow{xxxxx}提交。
如:运行文件后 输出的内容为 Hello_World
提交的flag值为:ctfshow{Hello_World}
注:计组原理题型后续的flag中地址字母大写
Welcome_to_CTFshow.asm文本打开后发现是汇编语言文件:
|
|
checksec一下这个PWN文件:

ohoh这个保护全关的!IDA的x32位

这个没main函数,那我们回看那段汇编语言asm,这段汇编语言更像是在帮助我们了解汇编语言的逻辑语法规则的,在PWN-1文章中就有讲过汇编语言,这里就直接跳过详解
这题开始就有点变样了,“Welcome_to_CTFshow.asm”这文件是一个存放汇编语言的文本文件,需要运行一下转换成像exe这样的可执行文件,在PWN-1讲过elf文件:
将汇编语言(.asm)文件转换为可执行文件,需要经过汇编(Assemble) 和链接(Link) 两个核心步骤.
在Kali/Linux上下载nasm编辑器:
|
|
然后下载ld链接器(通常自带):
|
|
汇编转换:将 .asm 转换为目标文件(.obj 或 .o)
|
|
汇编生成可执行文件:
|
|
(Welcome_to_CTFshow1是可执行文件)
我的建议汇编转换和生成可执行文件的过程都在Kali/Linux/Ubuntu上,比较方便,windows下载比较麻烦。
生成这个可执行文件之后,直接运行就能得到输出结果了,按照题目要求,加个{}包皮去提交就好了。


PWN_006
题目:立即寻址方式结束后eax寄存器的值为?
按照PWN-005那样去转换生成这个可执行文件
汇编转换:将 .asm 转换为目标文件(.obj 或 .o)
1nasm -f elf Welcome_to_CTFshow.asm汇编生成可执行文件:
1ld -m elf_i386 -s -o Welcome_to_CTFshow1 Welcome_to_CTFshow.o(Welcome_to_CTFshow1是可执行文件)

Welcome_to_CTFshow_PWN???是flag,错了,题目是立即寻址方式结束后eax寄存器的值为?
那就是得把这个转换出的可执行文件放到IDA上,不过之前记得checksec一下看看是x32还是x64位哦…
|
|

那就加减法咯:11+114504-1=114514
ctfshow{114514}
PWN_007
题目:寄存器寻址方式结束后edx寄存器的值为?
按照上述方法去得出可执行文件,放入IDA:

|
|
所以ctfshow{0x36D}
PWN_008
题目:直接寻址方式结束后ecx寄存器的值为?
|
|
当你编译后发现找不到地址[msg],然后我们倒回来找题目的Welcome_to_CTFshow文件,用IDA:
![]()
故flag:ctfshow{0x80490E8}
其实不用去进行编译的,在题目给的文件就能做,编译出来的其实就是题目给的文件。
PWN_009
题目:寄存器间接寻址方式结束后eax寄存器的值为?
|
|

不是80490E8哈别被误导了,这是虚拟地址哦
点击跟进:
![]()
dd 636C6557h:dd 是汇编指令中的伪操作符,意为 “定义双字(define double word)”,表示在当前地址处存放一个 4 字节的数值。这里存放的数值是 636C6557h(十六进制)。
flag:ctfshow{0x636C6557}
PWN_010
题目:寄存器相对寻址方式结束后eax寄存器的值为?
|
|

然后我们跟进看一下地址:

也就是这里将msg的地址(0x80490E8)+ 4 处所执向的地址的值赋给eax
4是十进制的,地址是16进制的,记得转换一下:
hex(0x80490E8+4)–> 080490EC
对应地址得值是ome_to_CTFshow_PWN
故flag:ctfshow{ome_to_CTFshow_PWN}
PWN_011
题目:基址变址寻址方式结束后的eax寄存器的值为?
|
|
同样的,放到IDA上找这个基址变址寻址方式:

双击点dword去跟进到地址:

计算最终也是 [0x80490E8 + 2*2 ] = [0X80490EC]
其实也是和PWN_010一样:hex(0x80490E8+4)
就是说hex(0x80490E8 + 2*2 )=0X80490EC,对应的是“ome_to_CTFshow_PWN”。
flag:ctfshow{ome_to_CTFshow_PWN}
PWN_012
题目:相对基址变址寻址方式结束后eax寄存器的值为?
|
|

跟进offset dword_80490E8:

那就是:[8 + 0x80490E8 + 1*2 - 6] = [0x80490EC]
故flag:ctfshow{ome_to_CTFshow_PWN}
PWN_013
题目:如何使用GCC?编译运行后即可获得flag PWN_013.c
学过C语言都会安装配置编译器环境的,我这里使用的是visual-studio-code。

这段代码是一个简单的 C 程序,它使用字符数组 flag 存储了一个加密的字符串,并通过 printf函数将其打印出来。
在这段代码中, flag 数组存储了一串整数值,这些整数值代表了字符的 ASCII 码。通过将这些整数值转换为相应的字符,就可以还原出原始的字符串。
运行该程序, printf 函数使用 %s 格式字符串将 flag 数组作为参数进行打印。由于 flag 数组的最后一个元素为零(NULL 字符), printf 函数会将其之前的字符依次打印,直到遇到 NULL 字符为止。根据给定的整数值数组,还原出的字符串为: ctfshow{hOw_t0_us3_GCC?} 。
PWN_014
题目:请你阅读以下源码,给定key为”CTFshow”,编译运行即可获得flag。 PWN_014.c
这个文件有强度了,直接编译运行会说nothing is here,不给flag,先读一下这个C语言文件在干什么的:
|
|
也就是说我们需要一个名为key的文件,让这个程序知道,然后他就能把flag字符串吐出来了。
|
|

答案是ctfshow{01000011_01010100_01000110_01110011_01101000_01101111_01110111_00001010}
题目要求在key里加CTFshow字符串,加其它是错的,输出结果不同。
PWN_015
题目:编译汇编代码到可执行文件,即可拿到flag。 flag.asm
汇编转换:将 .asm 转换为目标文件(.obj 或 .o)
|
|
汇编生成可执行文件:
|
|
(flag1是可执行文件)

比较简单,和PWN_005很类似,都是汇编语言编译运行之类的。
ctfshow{@ss3mb1y_1s_3@sy}
PWN_016
题目:使用gcc将其编译为可执行文件。 flag.s

|
|
可以使用 gcc 命令直接编译汇编语言源文件( .s 文件)并将其链接为可执行文件。 gcc 命令具有适用于多种语言的编译器驱动程序功能,它可以根据输入文件的扩展名自动选择适当的编译器和链接器。
故flag:ctfshow{daniuniuda}
PWN_017
题目:有些命令好像有点不一样?不要一直等,可能那样永远也等不到flag。

比较奇葩的是,这里选择3的话,一直在loading的,可能是不通的,我换到主机的nc也不行,那就是选2进程的

选了2后,程序开始发癫了….,一直循环搞得我Kali虚拟机卡死了,进到IDAx64去看:

这个case2的read函数只读取10字节以内的,也就是说ctfshow_flag(12byte)这串是超字节限制了。不过之前学了那个指令有通配符的cat ctf*(因为当你选择”ls ./“,打开全部文件后,发现只有一个ctfshow_flag时由ctf字眼的,那我们正好可以绕过超字节的限制了)
搜索打开由ctf这串字符的文件内容:

PWN_018
题目:仔细看看源码,或许有惊喜;假作真时真亦假,真作假时假亦真。
连接nc后一上来就问:

这我哪知道啊?去看IDAx64:


分别跟进一下real和fake:


解读一下发现一个机制:当v4不等于9时就会执行real函数,这时real会执行echo 'flag is here'>/ctfshow_flag,这个命令将字符串 ‘flag is here’ 覆盖写入 /ctfshow_flag 文件中。 > 符号表示以覆盖的方式写入文件,如果文件不存在则创建新文件。如果 /ctfshow_flag 文件已经存在,那么该命令会将文件中原有的内容替换为 ‘flag is here’ 。也就是说你第一次和靶机交互时没输入9,你以后都不得不到flag了,只能重开靶机…..比较CS吧…
echo 'flag is here'>>/ctfshow_flag这个命令将字符串 ‘flag is here’ 追加写入 /ctfshow_flag 文件中。 » 符号表示以追加的方式写入文件,如果文件不存在则创建新文件。如果 /ctfshow_flag 文件已经存在,那么该命令会在文件的末尾添加 ‘flag is here’ 。
这两个命令都用于将 ‘flag is here’ 写入 /ctfshow_flag 文件中,不同之处在于写入方式的不同。第一个命令使用追加方式,在文件末尾添加内容;第二个命令使用覆盖方式,将文件内容替换为新内容。具体使用哪个命令取决于需求和文件操作的预期结果。也就是所假的其实是我们需要的真的,真的反而是假的在远程环境中,我们需要在第一次读到flag,否则后续得到的flag都已经被覆写再追加,真实的flag内容已经没了。

PWN_019
题目:关闭了输出流,一定是最安全的吗?

在IDA上看到,是由read和system函数的,按道理我直接cat /ctf*应该就出flag了啊…
问题就出在这个fclose函数(确实没辙了,只能用AI解释一下了)作用是关闭文件输出流,标准输出被关闭了(无法显示)。
那还有输入流啊,可以用重定向:1>&0
这是 Linux 的 I/O 重定向语法:
1代表标准输出(stdout)(正常情况下,命令的输出会送到这里,显示在终端);0代表标准输入(stdin)(正常情况下,用户输入从这里读取,通常指向终端);1>&0表示:将标准输出重定向到标准输入所指向的位置。
因为代码中标准输出被关闭了(无法显示),但标准输入(通常是终端)仍然可用,所以通过这个重定向,cat 命令的输出会被 “转移” 到标准输入对应的终端,从而让内容能显示出来。
简单说就是:正常显示内容的 “输出通道” 被关了,但接收输入的 “输入通道” 还能用。通过 1>&0 把原本该从 “输出通道” 显示的内容,转到 “输入通道” 对应的终端上,这样就能看到 flag 了。ctfshow{a390a91b-5203-46ae-812d-0396de95184f}
本质上是利用了 “输入通道和终端还连着” 这个漏洞,让内容 “借道” 显示出来~。

PWN_020
题目:提交ctfshow{【.got表与.got.plt是否可写(可写为1,不可写为0)】,【.got的地址】,【.got.plt的地址】}
例如 .got可写.got.plt表可写其地址为0x400820 0x8208820
最终flag为ctfshow{1_1_0x400820_0x8208820}
若某个表不存在,则无需写其对应地址
如不存在.got.plt表,则最终flag值为ctfshow{1_0_0x400820}
checksec一下:

在这里只开了NX保护。

在PWN-1的Linux安全防护机制这一章的学习中,我们有认识到RELRO这个保护。很显然no RELRO意味着.got和.got.plt表都可写。
用指令查表地址:
|
|

耐心找一下就找到了,故flag:ctfshow{1_1_0x600f18_0x600f28}
PWN_021
题目:提交ctfshow{【.got表与.got.plt是否可写(可写为1,不可写为0)】,【.got的地址】,【.got.plt的地址】}
例如 .got可写.got.plt表可写其地址为0x400820 0x8208820
最终flag为ctfshow{1_1_0x400820_0x8208820}
若某个表不存在,则无需写其对应地址
如不存在.got.plt表,则最终flag值为ctfshow{1_0_0x400820}
checksec一下:

要还不记得Relro保护机制原理的就回PWN-1去看吧…
这里是 GOT 表的前半部分(.got.plt)设置为只读,后半部分(.got)仍可写。
用readelf命令去爱看表地址就好了:

flag:ctfshow{0_1_0x600ff0_0x601000}
PWN_021
题目:提交ctfshow{【.got表与.got.plt是否可写(可写为1,不可写为0)】,【.got的地址】,【.got.plt的地址】}
例如 .got可写.got.plt表可写其地址为0x400820 0x8208820
最终flag为ctfshow{1_1_0x400820_0x8208820}
若某个表不存在,则无需写其对应地址
如不存在.got.plt表,则最终flag值为ctfshow{1_0_0x400820}
道理一样checksec看relro的保护、readelf看表…..


这次就只有一个.got表了,那就是flag:ctfshow{0_0_0x600fc0}
PWN_023
题目:用户名为 ctfshow 密码 为 123456 请使用 ssh软件连接
|
|
不是nc连接

没有canary保护,这可以是个突破点:栈溢出漏洞。
看回IDA:

跟进ctfshow函数

可以知道,一开始dgets函数读取flag字符串到内存中
跟进signal函数:

这行代码会将内存中存储的 flag 字符串,输出到标准错误流(stderr)。因为程序启动时已经通过 fgets 从 /ctfshow_flag 文件读取了 flag 内容到 flag 变量中,所以这里能直接打印出正确的 flag。
所以说我们让程序报错也就是让它栈溢出,覆盖后的地址还不能是有效地址,让它全部报错,也就是我们可以输入好多个a就行,几十个a的16进制地址是无效地址。

关于栈溢出这部分,请看PWN-2详细学习,PWN板块占比也是重要的。
逐渐开始上强度了,这时需要仔细看IDA的代码分析了,看看是什么工作原理。
flag:ctfshow{0f207f1f-71bb-4cdd-bdab-7bfb9acde27a}
PWN_024
题目:
你可以使用pwntools的shellcraft模块来进行攻击
我来介绍一下shellcraft模块:它是 pwntools 库中的一个子模块,用于生成各种不同体系结构的 Shellcode(这里的不同体系是我们之前学过的操作系统基础的有关shell的那一章节)有zsh、bash等。Shellcode 是一段以二进制形式编写的代码,用于利用软件漏洞、执行特定操作或获取系统权限。shellcraft 模块提供了一系列函数和方法,用于生成特定体系结构下的Shellcode。
生成的汇编代码可直接通过 asm() 函数转换为机器码(二进制),无需手动处理格式,例如:
|
|
IDA:可以看到这题似乎和ret2shellcode有关

看题目提示,进入新大陆了…用pwntools了。

这里有提示,可以看一下PWN-1-NX保护,有啥启发?
在IDA分析可知,ctfshow函数无法跟进源代码,只能看它的伪代码去分析了(比较长):
|
|
如果之前是打逆向或者强行入门PWN的话,最坐牢的我相信应该是用IDA吧,除了找明文字符串flag外,你学了汇编语言你也看不懂这是啥意思,感觉一小串源代码转换成汇编语言非常长、枯燥,其实UP主也深有体会,在系统性入门后,也初步知道IDA是怎么用的,这里我把下划线“===== S U B R O U T I N E =====”也复制进来了,按我理解这是函数分界,分隔线上方是前一个函数的结尾(通常以
retn指令结束,代表函数返回)过了这条线,说明汇编语言在描述的就是另外一个函数了。也就是说这上下分界线是代表囊括着一个函数,例如这ctfshow函数。另外我想说的是汇编语言这么长是有道理的,它更像是一种“啰嗦”,它能在每一段代码运行后不断向你汇报地址变化,这种 “啰嗦” 本质上是对计算机底层操作的直接映射,这种特性确实让它在跟踪程序运行细节、分析逻辑和排查问题时具有独特优势。
从这里开始就要用pwntools了,这里介绍一下:
from pwn import *是在 Python 中使用pwntools库的常见导入方式 。pwntools是一个功能强大的用于二进制漏洞利用(binary exploitation)的 Python 库,在 CTF(Capture The Flag,夺旗赛 )竞赛、安全研究以及漏洞分析等领域广泛应用,以下是其具体作用:与目标程序交互
- 本地进程交互:
pwntools能轻松创建本地进程,模拟用户输入、获取程序输出。例如,p = process('./vulnerable_program')可以启动本地的可执行文件vulnerable_program,然后通过p.sendline('input data')向程序发送数据,用p.recvuntil('prompt')接收程序输出直到遇到特定字符串。- 远程服务器交互:在面对远程存在漏洞的服务时,
pwntools提供了便捷的方法。比如r = remote('target.com', 8080)可以连接到target.com服务器的 8080 端口,后续同样能发送和接收数据,方便对远程服务进行测试和漏洞利用。二进制数据处理
- 数据打包和解包:在二进制程序中,经常需要处理不同字节序(大端序、小端序)的数据。
pwntools提供了pack和unpack函数,比如p32(0x12345678)可以将整数0x12345678按照小端序打包成 4 字节的二进制数据;u32(data)则可以将 4 字节的二进制数据按照小端序解包为整数。- 数据转换:它还支持各种数据格式之间的转换,如将字符串转换为字节串,或者进行十六进制与二进制数据之间的转换等。
辅助漏洞利用
- 生成 Payload:对于缓冲区溢出等漏洞,需要精心构造 Payload(攻击载荷)。
pwntools提供了生成填充数据、构造 ROP(Return-Oriented Programming,返回导向编程 )链等功能。比如cyclic(100)可以生成一个 100 字节的循环模式字符串,用于确定缓冲区溢出的偏移量;通过ROP模块可以方便地构建 ROP 链来绕过一些安全机制,实现代码执行。- 符号解析:在分析二进制程序时,有时需要解析程序中的函数地址、变量地址等。
pwntools可以与ELF(Executable and Linkable Format,可执行与可链接格式,常用于 Linux 系统中的可执行文件、共享库等 )文件交互,获取这些符号信息,例如elf = ELF('./target_binary')可以加载目标二进制文件,然后通过elf.symbols['main']获取main函数的地址。密码学相关辅助
在 CTF 中,有时会涉及密码学题目,
pwntools也提供了一些基础的密码学辅助功能,比如常见加密算法的简单实现、编码解码等。多线程和异步支持
pwntools一定程度上支持多线程和异步操作,这在同时与多个目标程序交互,或者需要在等待程序输出的同时执行其他任务时非常有用。
所以为啥要学Python,当然能用豆包、DeepSeek之类的AI帮你写代码exp,但你得先了解Python再让它写才对的,有时会出错,你得有判断力。
这里推荐使用Pycharm、Visual Studio Code;或者你可以在Kali上用终端来运行,这样你在Kali用nc连接靶机时在同一系统运行交互比较方便,当然Pycharm、Visual Studio Code也有终端功能,只不过要配PWN环境稍微麻烦而已,看你习不习惯用Linux系统吧。
题目后续:
讲得比较啰嗦,这里开始分析这段汇编代码(重新粘贴了一遍,和上面一样):
|
|
这代码是在看不懂可以上AI嗦一下,让它帮你解析一下,毕竟刚入门……(这段代码是 IDA 反编译出的 32 位 x86 架构程序中ctfshow函数)
checksec一下:记住上面的参数,后面写exp要用…

可知,对这些保护有点遗忘的可以回去看PWN-1的Linux安全保护机制
| 保护项 | 状态 | 对利用的影响 |
|---|---|---|
| Arch | i386-32-Little | 32 位 x86 架构,shellcode 需用 32 位版本(对应shellcraft.i386.sh()) |
| Canary(栈溢出保护) | No canary found | 无栈溢出保护,缓冲区溢出后可直接覆盖栈上数据(无需绕过 canary) |
| NX(栈不可执行) | NX disabled | 栈段可执行,shellcode 直接放在栈上就能运行(无需 ROP 绕过 NX) |
| PIE(地址随机化) | No PIE (0x8048000) | 程序地址固定,ctfshow函数、buf缓冲区地址不会变(无需泄露地址) |
| RWX segments | Has RWX segments | 存在 “可读可写可执行” 的内存段,进一步确保 shellcode 能正常执行 |
总结:程序保护机制极弱,直接用 “栈溢出注入 shellcode” 即可,无需复杂绕过。
回到本题目,在nc连接之后交互一会发现并无实际效果,PWN大致思路是找到后门函数或者系统调用函数进行提权,提权之后就进行交互去执行一些靶机的终端命令例如:cat ctfshow_flag、ls之类的。那在此之前都得先找到这“后门”,也就是/bin/sh、zsh、system函数等等,但我发现这个程序并无这些地址存在,所以说这个程序从被制造出来都没想过要执行这些有关提权的代码…..

因为NX、canary没开、又有RWX,注入一段我们设计的有关执行/bin/sh的exp还是可以的,大致思路有了,接下来开始执行吧。
围绕 “直接执行 shellcode” 设计方案
可以用pwntools的脚本啊,单纯直接调用函数的,而且pwntools有局限啊,就那用这个pwntools的shellcraft模块生成的shellcode比较晦涩难懂,而且注释放到Kali上比较杂乱,而且没有涉及到汇编语言调用,比较简单我就不用了,我这里手敲shellcode方便大家了解一下底层逻辑:
|
|
创建一个python文件,输入这些代码放到终端上运行,记得提前打开另外一个窗口进行nc连接远程靶机。
上面其它的python代码没啥可讲的,这是远程交互的模板代码,直接套就好,我现在重在讲解这个手搓的shellcode,它和shellcraft模块产生的shellcode效果一样的,而且还好看懂:
|
|
这 7 行代码通过精准设置寄存器和栈数据,完成了
execve系统调用的参数准备,最终实现 “用/bin/sh替换当前进程” 的效果,是 CTF 中获取 shell 的基础手段,逻辑清晰且精简(仅 23 字节)。
逐行解析手敲shellcode
思路:注入一段能执行/bin/sh的汇编语言代码
① mov eax, 0xb
eax寄存器在 32 位 Linux 系统调用中专门用于传递 “系统调用号”(告诉内核要执行哪个系统调用)。0xb是十六进制,转换为十进制是11,而11正是execve系统调用对应的编号(内核通过这个值识别要执行execve)。
② mov ecx, 0
ecx寄存器用于传递execve的第二个参数argv(参数列表)。- 传
0(即NULL)表示 “没有参数”,等价于在命令行直接输入/bin/sh(不带任何参数)。
③ mov edx, 0
edx寄存器用于传递execve的第三个参数envp(环境变量列表)。- 传
0(即NULL)表示 “没有环境变量”,内核会使用默认环境变量。
④ push 0x0068732f
-
push指令将数据压入栈中(栈是向下增长的内存区域)。 -
10x0068732f是十六进制的 “小端序” 存储(x86 架构默认小端序,即低地址存低位字节),转换为 ASCII 码:
0x2f→/0x73→s0x68→h0x00→ 字符串结束符\0所以这行实际是往栈上压入字符串"/sh\0"。
⑤ push 0x6e69622f
-
同样是压栈操作,
0x6e69622f转换为 ASCII 码:0x2f→/0x62→b0x69→i0x6e→n所以这行往栈上压入字符串"/bin"。
结合上一行,栈上现在的内容是
"/bin/sh\0"(因为栈先压入"/bin",再压入"/sh\0",栈顶到栈底的顺序就是/b/i/n//s/h/\0)。
⑥ mov ebx, esp
esp是栈指针寄存器,永远指向当前栈顶的地址。- 经过两次
push后,栈顶正好是"/bin/sh\0"字符串的起始地址(第一个字符'/'的位置)。 - 这行指令将栈顶地址存入
ebx,而ebx寄存器在execve系统调用中用于传递第一个参数filename(程序路径),所以ebx现在指向我们要执行的"/bin/sh"。
⑦ int 0x80
int是中断指令,0x80是 Linux 系统调用的中断号。- 执行这条指令会触发一个软中断,让 CPU 从用户态切换到内核态,内核会根据
eax中的系统调用号(11)执行execve,并使用ebx、ecx、edx中的参数。
总之:必须要定义好这几个寄存器,在最底层操作系统运行时都会用到的,这里不详细讲述,反正你知道这几个寄存器一开始就得这么定义就好啦。(不耐烦了)
|
|
push压栈时要记得字节限制,因为架构是x32,所以push最多4个字节,也就是push两个:/bin、/sh的地址(不满四个字节如/sh要记得用00填满哦不然会报错)
然后就是记得esp寄存器是动态的…
这是调用pwntools的shellcraft模块的exp:
|
|
这是它的shellcode(原版未修改过,直接复制粘贴上来的):
|
|

看着确实比较乱…..😅
最后的最后我想对这题做些评价,其实题不难主要是我初次做,讲得比较啰嗦,如果你会手敲shellcode或者调用pwntools的shellcraft模块的话还是很简单秒掉的。只能怪小生太cai了😄

大致思路知道就好,靶机的flag是动态变化的。
PWN_025
题目:开启NX保护,或许可以试试ret2libc
根据题目,也就是说具体攻击手法为:ret2libc
ret2libc攻击
ret2libc 是一种在二进制漏洞利用中的经典技术,全称为 “return - to - libc”,中文可理解为 “返回至 libc 库”,常用于绕过栈不可执行(NX,No - eXecute)保护机制。
ret2libc实现步骤
- 信息泄漏:通过栈溢出漏洞,结合一些函数(如
write函数),泄漏出程序中libc库函数的真实地址。因为在不同系统中,libc库加载的基地址是随机的(ASLR保护机制),但库内函数之间的相对偏移是固定的,所以需要先获取一个已知函数(比如write)的真实地址,进而计算出其他函数(如system)的地址。- 计算关键地址:根据泄漏出的
libc函数地址,计算出system函数地址和/bin/sh字符串在内存中的地址。由于libc中函数和字符串的相对位置固定,所以可以通过已知函数地址与偏移量来计算其他关键地址。- 构造 payload:再次利用栈溢出漏洞,构造合适的 payload。payload 一般包含填充数据(用于填满缓冲区直到覆盖返回地址)、
system函数地址(覆盖返回地址,使程序返回时跳转到system函数)、一个无效的返回地址(system函数执行完后要返回的地址,随便填充一个地址,因为获取到 shell 后通常不会再关注这个返回操作 )、/bin/sh字符串的地址(作为system函数的参数,这样system函数执行时就相当于执行了system("/bin/sh"),从而打开一个 shell )。- 发送 payload:将构造好的 payload 发送给存在漏洞的程序,使程序按照攻击者期望的流程执行,最终获取到一个交互式 shell,达到漏洞利用的目的。
checksec一下,发现开了NX保护,也就是说不能用shellcode了。

跟进ctfshow函数:


可以发现buf大小为 132 字节,但read允许读取 0x100(256)字节存在栈溢出漏洞。
跟进write函数:

IDAx32分析,也没有/bin/sh、后门system:

这时候就需要使用利用libc(动态链接库)的system函数和/bin/sh字符串(这题思路很类似PWN_024的编写shellcode一样,程序本身没有后门可入只能从外部链接进这些函数,从而达到进入交互…),这些东西在程序运行时的真实地址计算方法为:@got真实地址 = libc基址 + 偏移,所以要先计算出偏移。
以write函数为例 ,偏移 = 真实地址 - libc基址,真实地址存在write@got、write@got可以通过write@plt来运行这个函数从而泄露出来,libc 版本和基址可以导入 libcSearcher 库来查找计算这里需要去系统学习一下动态链接的过程(有关.got、.plt表的知识),简单画个图说明一下黄色部分是静态的,绿色部分是动态变化的:

elf.got[‘write’]的作用是获取 write函数真实地址的存储位置,即write@got的地址;
elf.plt[‘write’]的作用是获取 write函数跳板地址的存储位置,即write@plt的地址;
具体思路:
- 解析目标程序的 ELF 文件,读取write@plt的地址 、write@got的地址和ctfshow的函数地址。
- 以栈溢出漏洞作为入口,跳转到write@plt执行函数泄露write@got的地址,再跳转回ctfshow函数。
- 读取泄露出的write@got的地址,用 libcSearcher 库查找正确的 libc 版本,再计算偏移。
- 计算system函数和/bin/sh字符串的真实地址。
- 继续从ctfshow函数里的栈溢出作为入口,跳转到system函数真实地址,同时将/bin/sh作为参数传入,从而拿到权限。
这里之前我是跳过的,比较难,我做到PWN_044之后才倒回来做的。
PWN044 的利用逻辑是 “先调用
gets向bss段写入/bin/sh,再调用system执行”(类似文档中 PWN044 的gets调用场景)。此时调用gets仅需其 “函数入口” 即可完成数据写入,无需知道gets在libc中的真实地址 —— 直接使用gets的 PLT 表地址(p32(gets)本质是p32(gets_plt)),就能触发gets执行,实现写入/bin/sh的目标。这就是为什么在PWN_044是直接是p32(gets),而在这调用write函数是要分.got和.plt,因为它俩功能都不同。
write_plt负责 “触发write执行”,write_got负责 “提供待泄露的真实地址”,二者缺一不可,因此必须明确区分。
总之:ret2libc 核心逻辑:“泄露地址→计算地址→构造调用”
exp备注版:
|
|
原版exp:
|
|
这部分主要涉及到栈溢出的章节知识点,我已经讲解的很详细很啰嗦了,如果看不懂就跳过吧,把栈溢出搞懂后再跳回来做。我当时也是跳过的,之后再回来写wp的。
感觉强度上来了,做得有点吃力。
PWN_026
题目:设置好 ASLR 保护参数值即可获得flag。为确保flag正确,本题建议用提供虚拟机运行。 PWN libc-2.27.so (提取码show)
这题坑到我了,运行程序发现直接爆flag,交上去发现是假的/(ㄒoㄒ)/~~
IDAx64分析,有system后门函数:

而且可以知道这个flag是有几个printf、puts共同打印出的、字符串由地址拼接(%p打印指针或者地址,以16进制形式)而成。
运行一下:

每次flag都不一样,说明地址一直在变化,也就是地址随机化。
在Linux保护机制有讲过ASLR,关于ASLR保护可以看回PWN-1:ASLR(Address Space Layout Randomization)是一种操作系统级别的安全保护机制,旨在增加软件系统的安全性。它通过随机化程序在内存中的布局,使得攻击者难以准确地确定关键代码和数据的位置。
gdb调试一下,题目早给出了2,在这里单纯演示一下;
|
|

|
|
虽然 checksec 输出中没有直接的 ASLR 开启状态,但可以结合系统层面的设置(通过 cat/proc/sys/kernel/randomize_va_space 查看,输出 0 表示关闭 ASLR,1 表示部分开启,2 表示完全开启 )来判断程序运行时的 ASLR 情况。
既然地址随机化保护,我们就把它关掉(记得提权root):
|
|

这里有个点就是我的flag是ctfshow{0x400687_0x400560_0x6032a0_0x7ffff7fbf6b0}
但不知为啥和官方不一样flag:ctfshow{0x400687_0x400560_0x603260_0x7ffff7fd64f0}
但我的程序运行了三次,ASRL也关了,flag出来的是一样的,代表地址是真实的,flag没错的。反正大体思路是正确的。
总之记住:/proc/sys/kernel/randomize_va_space
PWN_027
题目:设置好 ASLR 保护参数值即可获得flag。libc-2.27.so PWN
这题也是一样的,”If the result is 0 or 1, then you get the correct flag!“将ASLR保护的数值改成1或0就好。(套上题的公式)
怪怪的:

官方flag是ctfshow{0x400687_0x400560_0x603260}
欸我发现….0x603260的后面的6变成a了,有点招笑…应该是Kali环境和Ubuntu不同,我去网上查了下,发现大部分PWN的WP都是用Ubuntu解的:

这里的flag就是正常的了。从现在开始我将彻底使用Ubuntu了。
PWN_028
题目:设置好 ASLR 保护参数值即可获得flag;libc-2.27.so、 PWN (show)

这题送分的,ASLR直接关的,flag还是地址拼出来的,那flag就不变得就是真的了。不信可以多运行几次,你看flag会变吗?
flag is :ctfshow{0x400687_0x400560}
PWN_029
题目:ASLR和PIE开启后;libc-2.27.so、 PWN
试着ASLR和PIE保护开启后,地址都会将随机化,这里值得注意的是,由于粒度问题,虽然地址都被随机化了,但是被随机化的都仅仅是某个对象的起始地址,而在其内部还是原来的结构,也就是相对偏移是不会变化的。

|
|
直接运行……
flag(有点长但是对的…):ctfshow{Address_Space_Layout_Randomization&&Position-Independent_Executable_1s_C0000000000l!}
这题只是让你了解下ASLR和PIE保护的特点,flag不是目的:



PWN_030
题目:关闭PIE后;程序的基地址固定,攻击者可以更容易地确定内存中函数和变量的位置。
checksec一下:没开canary和PIE,开了NX不好用Shellcode。

IDAx32看一下:

buf ,用于存储从标准输入读取的数据。该变量在栈上分配,相对于函数栈帧指针 ebp 的偏移为-0x88 。调用 read 函数从标准输入读取数据。 read 函数的第一个参数是文件描述符,这里使用 0 表示标准输入。第二个参数是指向存储数据的缓冲区的指针,这里是 &buf 。第三个参数是要读取的最大字节数,这里是 0x100u ,即 256 字节,但这char定义buf是132字节,所以存在栈溢出漏洞。
并无找到后门/bin/sh,通过ret2libc,那从栈溢出下手:
|
|
无备注代码:
|
|
需要注意的点:
|
|
这里的地址是可以手写的,但我不建议,AI给的是确切地址,这里我给改了,下次AI写脚本,地址一定不要手动硬写出来,帮AI改一下改成自动获取的,比如上面这样,系统会自动获取地址的,这样比较准确不会出错崩溃。
说实话,给我肯定写不到的,不熟悉编写python远程交互脚本,但我可以读懂,我可以用AI帮我写代码,我就把意愿告诉它,把IDA汇编代码以及跟进的函数复制发给它,附上我的思路,不如已经知道是利用栈溢出漏洞编写ret2libc、知道开启了哪些保护、一步步修改得到的。
PWN_031
题目:开启 ASLR 和 PIE 的情况下,仍可能被利用;
这道题是考察关于程序基址和调用约定中ebx的作用,难度偏大
checksec一下,无栈保护:

跟进ctfshow函数:

发现同样的代码,有溢出,似乎可用上题的exp,不过checksec看到的保护是不一样的,exp肯定不一样了….
运行之后莫名其妙,会有报随机化的地址:

IDA看看main函数:

噢我知道了这是main函数的地址,看了看发现和上一题的思路差不多,但保护开了个FULL RELRO。
Full RELRO(RELocation Read-Only)的核心作用是 将程序的 GOT 表(全局偏移表)设置为只读,彻底阻断 “通过修改 GOT 表劫持函数流程” 的攻击方式(如 ret2got、GOT 覆写)。具体影响包括:
- GOT 表不可写:原本可通过栈溢出修改 GOT 表中函数的地址(比如将
printf的 GOT 项改为system),但 Full RELRO 后,写入 GOT 表会触发段错误(权限不足);- 只能 “直接调用函数”:攻击者无法再通过修改 GOT 表间接劫持流程,只能通过栈溢出 构造合法的函数调用栈(比如直接调用
write泄露地址、system执行命令),才能完成攻击。
核心逻辑就是栈溢出破坏了栈上保存的原始
ebx值,必须通过溢出数据将其覆盖回 “正确的正常值”,才能让后续函数调用恢复正常。
回到正题,因为开启了PIE和FULL RELRO保护,使我们的函数调用更繁琐了些,不过思路还是那样,在前面一个main函数的真实地址被泄露了,这里就可以得到elf基址了,这个和libc基址性质差不多的,都是偏移量恒定不变的。
elf基址计算:
|
|
好,现在来计算ebx寄存器的真实地址,先找大概位置:

当然这不是真实地址,因为有PIE保护,下次还是随机的,只是找大概位置在哪。

也就是说ebx = ebp -4在我们栈溢出那块先填充0x88-4,接着覆盖上ebx真实地址不让程序崩溃,再接续后面的流程就好。当然0x88-4等于132,也是s的容量大小。
偏移地址在IDA里用快捷键Ctrl+S查看:

所以ebx真实地址:
|
|
大致思路就是接收main函数的地址,计算出elf基址,再推出ebx真实地址从而能通过栈溢出调用write@plt来泄露自身的write的真实地址,接着得出libc版本及libc基址,进而反推出所需要的后门函数的真实地址。
exp:
|
|
标准的 libc(C 标准库)中,不存在
main函数的偏移量。
要是这题觉得很难,我建议先跳过,等熟悉了栈溢出漏洞利用手法再回来完成这题。
FORTIFY缓冲区边界检查
FORTIFY_SOURCE 是 GCC 提供的一种轻量级缓冲区溢出保护机制,通过对危险函数(如 strcpy、memcpy 等)进行增强检查,降低缓冲区溢出漏洞的利用风险。它有 3 个级别:_FORTIFY_SOURCE=0(关闭)、_FORTIFY_SOURCE=1(基础保护)、_FORTIFY_SOURCE=2(强化保护),各级别区别如下:
| 级别 | 启用条件 | 核心检查逻辑 | 防护范围 | 性能影响 |
|---|---|---|---|---|
| 0 | 默认(无需定义宏) | 无任何检查,使用原始函数 | 无 | 无 |
| 1 | -D_FORTIFY_SOURCE=1 -O1 | 检查 “源长度 < 目标缓冲区长度”(仅限编译期可知) | 部分静态场景 | 极小 |
| 2 | -D_FORTIFY_SOURCE=2 -O2 | 检查 “操作长度 ≤ 目标缓冲区实际大小”(含运行时) | 静态 + 动态场景,覆盖更多函数 | 轻微 |
FORTIFY_SOURCE仅能防护部分已知的危险函数,无法替代NX、Stack Canary等更全面的保护机制。- 必须配合优化选项(
-O1/-O2)才能生效,否则编译器会忽略该宏定义。 - 对于动态分配的内存(如
malloc分配的缓冲区),FORTIFY_SOURCE无法获取其大小,因此无法防护相关操作(如memcpy(malloc_buf, src, n))。
PWN_032
题目:FORTIFY_SOURCE=0:
禁用 Fortify 功能。 不会进行任何额外的安全检查。 可能导致潜在的安全漏洞。
这题主要是在函数逻辑运行,知道运行的原理就好。
IDA分析main:

跟进undefined函数:

可以看到,关键在于成功进入到undefined函数才有机会下一步获得flag:
从main函数出发:
|
|
大致是:起初先打印ctfshow的logo信息(其实一开始那个v3把我搞懵了,其实对解题过程没啥帮助的),在最顶端的__fastcall main已经把一些参数给定义了比如argv数组,第一个参数argv[1]被赋给v4,v4作中介把数值传递给了buf1,之后程序将v4[10]数组内容赋值给buf1[10],接着通过strcpy函数,把字符串“CTFshowPWN”复制/赋值给buf2,printf打印buf1、buf2的当前的字符串内容,最后fget读取_bss_start的11个字符给buf1,打印buf1和num的信息,最后通过if判断argc是否大于4(默认情况下argc是等于1的,因为argv[0]必定存在,我们输入一个参数,argc就会等于2,依次递推),大于4就进入undefined函数内
因为本题目FORTIFY_SOURCE没有开启,代表我们启动函数直接输入4个参数(这时argc=5 > 4,为什么五个?因为你输入的4个+argc[0] = 5)就行了,而且这4个参数没有长度限制,如果开启FORTIFY_SOURCE就不好说了,因为开启了之后,由于程序存在strcpy和memcpy函数会检测长度,如果长度超过了限制,可能会使程序抛出异常而退出执行。

PWN_033
题目:FORTIFY_SOURCE=1:
启用 Fortify 功能的基本级别。 在编译时进行一些安全检查,如缓冲区边界检查、格式化字符串检查等。 在运行时进行某些检查,如检测函数返回值和大小的一致性。 如果检测到潜在的安全问题,会触发运行时错误,并终止程序执行。
IDAx64分析:

FORTIFY_SOURCE被打开了,代码和PWN_032几乎一样的,memcpy和strcpy这两个函数被替换成了__mencpy_chk和__strcpy__chk安全函数,可以看到这两个函数相比前两个函数只是加上了11LL这个参数加以限制,因为buf1和buf2在声明的时候的长度就是11,所以程序为了防止溢出,使用后两个函数加上这两个数组的长度加以限制以防溢出。
但这不妨碍我们拿flag啊,只要运行./pwnme时输入的字符总长度不超过11就好:
|
|

PWN_034
题目:FORTIFY_SOURCE=2:
启用 Fortify 功能的高级级别。 包括基本级别的安全检查,并添加了更多的检查。 在编译时进行更严格的检查,如更精确的缓冲区边界检查。 提供更丰富的编译器警告和错误信息。
IDA分析,题目描述也说了该程序包括基本级别的安全检查,并添加了更多的检查。 在编译时进行更严格的检查,如更精确的缓冲区边界检查。 提供更丰富的编译器警告和错误信息。 使用ida64反编译的加过略微与前两道题目有所不同,大部分还是一样的,还是把危险函数替换成了安全函数。如下:

大致能看出原函数是什么吧,这里就不多解释了…
# 格式化函数与解题操作说明 __printf__chk 函数与 printf 的区别在于:不能使用 %x$n 不连续地打印,也就是说如果要使用 %3$n,则必须同时使用 %1$n 和 %2$n。在使用 %n 的时候会做一些检查。 这涉及到格式化字符串漏洞,但本题不涉及此漏洞,所以对本道题几乎没有阻碍。后续我们还是通过 SSH 连接后运行文件,输入 4 个长度为 1 的参数,不出意外就能拿到 flag!

栈溢出
PWN_035
题目:正式开始栈溢出了,先来一个最最最最简单的吧
用户名为 ctfshow 密码 为 123456 请使用 ssh软件连接
|
|
**不是nc连接 **
checksec一下:

没开canary保护,对应了本章主题——栈溢出。
IDA分析:

跟进函数:


由此可知:ctfshow函数存在栈溢出漏洞,超过104字节就会发生栈溢出;
对于SIGSEGV函数,当栈溢出触发 SIGSEGV 时,sigsegv_handler 函数会被调用,然后触发函数内部——fprintf打印出flag字符串,也正好对应了fget函数读取的flag值。
栈溢出会破坏栈上的 “返回地址”“栈帧信息” 或其他关键内存结构,最终引发非法内存访问(比如函数返回时跳转到无效地址),触发
SIGSEGV信号。
所以说我们只要在运行的时候给程序打入超过104个的字符串就好,可以是105个“a”。

/bin/sh地址的跳转利用
相较于32位程序,64 位程序调用函数需满足 栈对齐,因此构造 payload 时需注意:
在实际解题中,ret 地址需要针对当前题目动态获取,用 ROPgadget 工具直接搜索程序中的 ret 指令(最常用):
|
|
PWN_036
题目:存在后门函数,如何利用?

非常不安全!!!!没有canary保护。可以用ret2shellcode、ret2libc。

这题开始怪怪的,找不到/bin/sh的后门,sigsegv_handler 函数没有我们想要的flag,不过在左边函数目录看到熟悉的故人——get_flag函数,直接跟进看看:

可以看到下面的fgets读取和返回的printf(s)打印函数,输出flag字符串,主函数没有引用它啊,怎么办?我们只需要利用栈溢出漏洞覆盖返回地址,将程序的执行流程转向 get_flag 函数,从而获取flag。(可以用ret2shellcode、ret2libc)
main函数的突破点在这:

没开canary保护,s数组可存储大小范围是36,超过了就覆盖返回地址了。
gets 读取函数是非常不安全的,容易导致缓冲区溢出漏洞。因为它无法限制输入的长度,可能会超出s 数组的容量,导致覆盖栈上的其他数据或执行任意代码。这也是明显的栈溢出漏洞,s距ebp仅有0x28,而gets不限制输入长度。
安全性来看一般都会用fgets函数,搭配FORTIFY 缓冲区边界检查来提高程序自身安全性。
exp:
|
|
这里的44个‘a’是0x28 + 4 ,0x28(十进制 40)是缓冲区到 ebp(栈基址寄存器)的距离,+4 是覆盖 ebp 本身(32 位程序中寄存器占 4 字节),总共 44 字节后,后续数据才能覆盖函数的 “返回地址”,从而跳转到 get_flag 函数。当然你可以用cyclic(0x28 + 4),产生44字节的垃圾值。

PWN_037
题目:32位的 system(“/bin/sh”) 后门函数给你。
checksec发现是有无栈溢出保护的,关了canary。
IDA分析:

可以发现main函数没有什么可以利用的,但是在函数目录可以看到有个backdoor的函数有猫腻….

这个函数居然有/bin/sh!!!!!
跟进ctfshow函数:

有read读取函数,那我们可以以此为注入点,用垃圾值0x12+4先覆盖函数变量和寄存器ebp本身,然后再上传一个后门函数的地址。
exp:
|
|

脚本exp万岁。
PWN_038
题目:64位的 system(“/bin/sh”) 后门函数给你。
checksec:

没开canary保护,开NX保护(不能ret2shellcode)

buf数组距离ebp寄存器为0xAh,加上本身寄存器ebp的8字节。
跟进backdoor函数看到/bin/sh,本题思路和上题PWN_037一样,只不过架构变成64位而已。


64 位程序调用
system需考虑堆栈平衡,本质是 64 位 ABI 对 “栈对齐” 的硬性要求—— 若不满足,函数执行时访问栈内存会崩溃;而 32 位 ABI 无此要求,仅需调用后清理参数栈即可,因此无需额外关注平衡。
所以说exp需要考虑到堆栈平衡加上ret返回地址:

在实际解题中,ret 地址需要针对当前题目动态获取,常用方法:
工具查找:用 ROPgadget 工具直接搜索程序中的 ret 指令(最常用):
|
|
exp:
|
|
PWN_039
题目:32位的 system(); “/bin/sh”。
checksec一下:

可以看到没开启canary、PIE保护的,存在可利用栈溢出漏洞的可能。
IDA:

跟进ctfshow函数发现栈溢出:

可以读取50个字符给buf,但buf容量只有14,所以超过14就会造成栈溢出。
接着在函数目录看到hint函数有猫腻:

那么大致的exp思路就来了:也就是先cyclic函数生成垃圾值覆盖局部变量和ebp寄存器本身;但是只有system函数和/bin/sh,却没有system(“/bin/sh”);的后门,所以我们只能构造出来。
在编写exp时,因为程序是32位,所以说不用去考虑它的堆栈平衡:
|
|
这里的p32(0)等于4字节,可以用四个a或者cyclic(4)代替也行,这时32位下system函数的返回地址占位符,然后就到执行/bin/sh,那就是跳转到它的地址:

这样子我们就构造出system("/bin/sh");了,然后进入交互模式获得flag。
这题主要考察我们对于函数的构造以及函数地址的拼接、栈的结构组成。需要多看看我的PWN-2啊。。。。
PWN_040
题目:64位的 system(); “/bin/sh”。
IDA64位分析,跟进ctfshow函数:

checksec看到未打开canary保护,而且在ctfshow函数这发现有栈溢出。
跟进hint函数,发现后门。

用ROPgadget命令查询ret地址:
|
|

查询/bin/sh的地址:

exp:
|
|
欸为什么exp的payload中的参数不太一样?
32位和64位可执行程序的参数传递顺序
即使思路一样但架构不一样,在写payload的时候注入地址的顺序也是不一样的:
- 32 位程序(PWN039):32 位程序中,函数参数通过栈传递。在 PWN039 里,
system函数地址紧跟在填充数据之后,这是因为溢出后直接覆盖返回地址为system函数地址,让程序执行system函数。在payload = 'a'*(0x12 + 4) + p32(system) + p32(0) + p32(bin_sh)中,'a'*(0x12 + 4)用于填充缓冲区并覆盖原返回地址,p32(system)将返回地址修改为system函数地址 ,p32(0)是为了满足栈结构,作为system函数执行后的返回地址(实际执行中并不重要,可随意填充 4 字节),p32(bin_sh)则是system函数的参数,即/bin/sh字符串的地址。这样的顺序符合 32 位程序通过栈传参和控制程序执行流的特点。- 64 位程序(PWN40):64 位程序的函数调用规则有所不同,前 6 个参数优先通过寄存器传递,其中第一个参数存放在
rdi寄存器。在 PWN40 中,payload = 'a'*(0xA + 8) + p64(pop_rdi) + p64(bin_sh) + p64(ret) + p64(system),'a'*(0xA + 8)用于填充缓冲区和覆盖原返回地址;p64(pop_rdi)将pop_rdi指令地址放入栈中,执行pop_rdi指令时,会把紧跟其后的p64(bin_sh)中的/bin/sh地址弹出到rdi寄存器,作为system函数的第一个参数;p64(ret)用于栈对齐(64 位程序调用函数前需要保证栈 16 字节对齐,ret指令可调整栈指针);最后p64(system)将system函数地址放入栈中,当程序执行到这里时,会跳转到system函数执行,此时rdi寄存器中已正确设置了参数/bin/sh的地址。
PWN_041
题目:
32位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代。
checksec一下,然后IDA分析

不能用ret2shellcode了。
这里和上题差不多,找一下/bin/sh:

找不到,但找到sh:
/bin/sh和sh的核心区别
关键场景:能否替代取决于「调用函数的要求」
PWN 中最常用的是通过
execve、system函数执行 Shell(如构造execve("/bin/sh", NULL, NULL)获得交互权限),此外也可能涉及system函数,两者对参数的要求不同:场景 1:
execve函数(必须用绝对路径,sh不可替代/bin/sh)
execve是 Linux 系统调用,作用是加载并执行新程序,其第一个参数必须是程序的绝对路径(或相对路径,但绝对路径是 PWN 中的常规选择),否则会报错「No such file or directory」。
- 正确用法:
execve("/bin/sh", ...)(系统直接定位到/bin/sh,执行成功)。- 错误用法:
execve("sh", ...)(系统不知道sh在哪里,会遍历$PATH查找,但execve不支持通过$PATH解析命令名,直接失败)。因此,在构造
execve调用时,只能用/bin/sh,不能用sh。场景 2:
system函数(sh和/bin/sh效果一致,可替代)
system函数的底层逻辑是「调用/bin/sh -c来执行参数中的命令」,其参数支持「命令名」或「绝对路径」:
- 若传
system("sh"):system会自动通过$PATH找到sh(即/bin/sh),等价于执行/bin/sh -c sh,最终启动 Shell。- 若传
system("/bin/sh"):等价于执行/bin/sh -c /bin/sh,同样启动 Shell。因此,在使用
system函数时,sh和/bin/sh效果完全一致,可以互相替代。特殊情况:环境变量
$PATH被篡改(sh可能失效,/bin/sh更可靠)虽然多数情况下
$PATH包含/bin,但如果目标程序运行时篡改了$PATH(如删除/bin目录),此时:
- 用
sh会因$PATH中找不到sh而失败;- 用
/bin/sh因是绝对路径,不受$PATH影响,依然能成功执行。PWN 利用中为了「兼容性和稳定性」,即使是
system函数,也更推荐写/bin/sh而非sh(避免因环境变量异常导致利用失败)。
这里跟进hint函数:

发现这有现成的system函数,也就是说可以调用sh去进行提权。
知道了sh地址是0x80487BA,就可以写exp了:
|
|
PWN_042
题目:64位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代。
来看IDA64位分析:

checksec:

在跟进hint函数也是如此,和PWN_041一样的,这次程序是64位,在exp编写上有所不同,需要pop_rdi:ret、ret的地址:

ret:0x40053e
pop_rdi:0x400843
再找sh的地址吧:

exp:
|
|
flag就不给了,因为靶机给的flag是动态的。
PWN_043
题目:32位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法。
IDA32位跟进ctfshow函数:

gets函数很危险的,一般都用fgets,用这个必溢出的好吧……
跟进hint函数:

我们发现了system函数,但却不提供sh、/bin/sh给我们,只能利用gets函数。(gets函数进行读入数据,它可以无限读取到内存,不会判断上限,可以包含空格,以回车结束读取。所以这里就存在了明显的溢出)
那大致的exp思路来了;先用cyclic产生垃圾值填充缓冲区和ebp本身,出现栈溢出后,将返回值覆盖为gets函数地址,加上ebx寄存器弹栈,gets函数读取数据,把我们所给的/bin/sh字符串写入这个内存里面,再次利用栈溢出,将返回地址覆盖为 system 函数的地址,使得 gets 执行完后,程序自动跳转到 system 执行,执行 system("/bin/sh") 获取 shell。
这个内存去哪找?
在bss字段(未初始化字段)找到了buf2变量:

查询pop_ebx的地址

exp:
|
|
这里有个疑问点就是为什么要用buf2内存变量呢?我直接调用system函数后再注入/bin/sh不就好了吗?这显然不行,因为此时的/bin/sh是一个普通的字符串,并没有分配地址,可执行程序的PIE和ASLR保护机制会让布局发生随机化改变,你给的字符串没地址,而system函数需要接受地址才能执行,而非直接注入无地址的纯字符串,buf2是bss字段的全局变量,地址固定,能给/bin/sh一个固定地址,让system精准找到它并执行。
PWN_044
题目:64位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法。
IDA分析:



和上一题区别是参数传递,64位的前6参数是靠寄存器rdi传递的。查询pop rdi;ret汇编命令的地址:

pop rdi ; ret:0x4007f3
ret:0x4004fe
那exp大致不变,主要是payload吧:
|
|
可以发现这是错误的,进入不了交互,错就错在ret,这里不需要进行手动调栈堆平衡:
gets执行完后的总偏移是 44 字节(36+8)。后续执行
pop_rdi(8 字节)+buf2地址(8 字节)后,总偏移变为 44+8+8=60 字节。60 字节 ÷ 16 字节 = 3 余 12?不对 —— 此时调用
system前,rsp指向system函数的地址,而60 + 8(system地址本身)= 68 字节,68 ÷ 16 = 4 余 4?这显然不对。正确理解:不用纠结静态数字,而是要看到
gets的执行引入了一次 “额外的栈弹出”(8 字节),恰好抵消了部分偏移的 “非对齐量”,使得最终调用system时,rsp刚好落在 16 字节对齐的地址上 —— 这是动态执行中栈自然调整的结果,而非静态计算的总和。
在 64 位 PWN 题中,“动态对齐” 是否需要加
ret,核心看 “中间是否有函数执行并自动调整栈指针”。
正确的exp:
|
|
PWN_045
题目:32位 无 system 无 “/bin/sh”。
checksec一下:

32位关闭栈保护关闭PIE。
IDA分析main:

跟进ctfshow函数:

还是明显的溢出漏洞了,只是其中的偏移变了,大差不差,现在我们关心的是如何去找到我们所需要的东西呢?现在既没有system,也没有“/bin/sh”
思路:地址随机化+NX保护禁用ret2shellcode,那就是暗示我用ret2libc咯,用LibcSearcher获取当前libc版本,利用write泄露出write本身函数地址,再获取程序中因开启NX保护时地址随机化的write虚拟地址,两个地址相减得到地址偏移量,接着就能反推出system、/bin/sh的真实地址了。在此之前要记得先填充好变量的缓冲区。
上半部分exp:
|
|
这时候已经把write函数地址泄露出来了,不过看到这里比较懵逼哈,payload为什么要上传这么多,直接调用write函数不行了吗?还要got、plt啥的(堆plt、got不熟悉的可以看回PWN-1中plt和got表的讲解)。让我给你细分一下:先调用write_plt,这指write函数的执行入口,告诉write我要使用你了,调用write后,会从栈上读取这个地址作为下一条指令的位置。这里指定 main 函数,是为了让程序在泄露地址后回到主函数,继续接收我们的第二次payload(用于后续调用 system 获取 shell),避免程序直接退出。第一次payload是获得write地址,第二次payload是执行system函数。
接着到p32(1) + p32(write_got) + p32(4):write 函数的三个参数:

32 位程序中参数通过栈从右往左传递,因此栈上顺序为:fd → buf → n,也就是 write(1, write_got, 4)
来下半部分exp:
|
|
write = u32(io.recvuntil(’\xf7’)[-4:])这一段比较长,做过之前题目对此有点印象,不应该是write = u32(io.recv(4))?
核心原因是程序输出中存在干扰信息,导致无法直接读取到纯净的 4 字节地址,你执行一下程序就知道了,会有“O.o?”冒出来,这是干扰。
io.recv(4) 是有局限性的,io.recv(4) 的作用是从程序输出中读取固定的 4 字节数据,但它有一个严格前提:程序必须只输出我们需要的 4 字节地址,没有任何多余内容(如提示文本、换行符、其他数据等)。此时recv直接读取4个字节,那就会读到O.o?的字节导致错误。
recvuntil('\xf7')[-4:]:适用于程序输出存在干扰信息,且地址以 \xf7 开头(32 位 libc 典型特征)的场景,通过特征定位 + 截取来提取正确地址。
|
|
PWN_046
题目:64位 无 system 无 “/bin/sh”。
checksec一下:

64位关闭栈保护关闭PIE。
IDA64分析:


确实是64位 无 system 无 “/bin/sh”,全得自己构造。
大致思路就是泄露write真实地址,得出libc基址和对应libc版本,再反推出一些所需的函数的真实地址。
在此之前需要一些寄存器帮助传参,就得需要用到ROPgadget指令查询:
|
|

0x0000000000400803 : pop rdi ; ret
0x0000000000400801 : pop rsi ; pop r15 ; ret
但是这里的带rsi的gadget不是单独的,还带有r15,不过在这里r15寄存器用不上,我们到时得用0去覆盖它。
payload1:
|
|
write函数有两个参数,第一个由寄存器rdi控制、第二个由rsi控制,rdi存储1,rsi存储write_got因为多出一个r15寄存器所以统一存储无用值0消除它带给程序的影响,接着再覆盖上write@plt地址,调用write函数,去泄露write的真实地址。
接收write函数真实地址:
|
|
payload2:
|
|
到了payload2,用LibcSearcher模块去搜索得到write函数对应的libc版本,从而得出基址,最后反推出system函数的真实地址和/bin/sh字符串的真实地址,从而构造出后门函数来getshell。
最终的exp:
|
|
PWN_047
题目:ez ret2libc
就提示用ret2libc
checksec一下:

IDA32位分析:

运行程序:

一上来就给了几个函数的地址,目前来看还没给出system的地址。
跟进ctfshow函数还发现了gets函数:

那就是通过栈溢出ret2libc来搞flag咯。
在data字段还找到了/bin/sh:

你有无发现:这个/bin/sh的地址就是gift

那思路就是用puts进行泄露地址,得出偏移量再反推出system函数的真实地址
|
|
PWN_048
题目:没有write了,试试用puts吧,更简单了呢。

IDA32分析:

跟进ctfshow函数:

编写exp泄露puts的地址,得出偏移量区反推出system函数、/bin/sh的地址。
exp:
|
|
PWN_049
题目:静态编译?或许你可以找找mprotect函数。
checksec一下,没开canary、PIE、NX保护。
IDA32:

可以看到有非常多的函数,这就是静态编译。
跟进ctfshow函数,看到read有栈溢出漏洞。

找mprotect函数:

Mprotect函数
|
|
代码解析:
- 函数参数:
a1:要修改权限的内存起始地址(需页对齐)。a2:内存区域的长度(字节数)。a3:新的保护权限(如PROT_READ、PROT_WRITE、PROT_EXEC、PROT_NONE、prot=7)。prot可以取这几个值,并且可以用“|”将几个属性合起来使用。
- 核心逻辑:
- 调用
sys_mprotect:这是真正的内核级系统调用(用户态到内核态的桥梁),负责实际执行内存权限修改操作。- 错误处理:
sys_mprotect的返回值若>= 0xFFFFF001(这是 Unix/Linux 中系统调用错误码的典型范围,通常表示操作失败),则通过_syscall_error()处理错误(例如设置errno并返回错误码)。- 成功返回:若系统调用成功,直接返回
sys_mprotect的结果(通常为 0)。
这段代码是用户态 mprotect 函数的封装,本质是通过调用内核的 sys_mprotect 系统调用来实现功能,最终目的是:
允许进程动态修改某段内存的读写执行权限(例如将只读内存改为可写,或给数据段添加执行权限),常用于内存管理、动态代码加载、漏洞利用等场景。
简单说,这段代码是用户程序与内核交互的 “中间层”,让开发者可以通过简单的函数调用修改内存权限,而无需直接操作内核接口。
那么我们可以利用它修改内存的权限为可读可写可执 行(RWX),然后我们就可以往栈上写入shellcode、执行,最后获取shell。
在构造 payload 前,需先确定 3 个核心要素:目标内存地址、mprotect 参数、控制寄存器的 Gadget,这些是 payload 的 “积木”。
我们要选择的内存地址是0x80DA000,等会?这不是bss段吧?

- 选择原因:
mprotect要求操作的内存地址必须 页对齐(即地址是 4KB/0x1000 的整数倍,32 位系统默认页大小为 4KB)(64位就是8kb咯)。 - 程序 BSS 段起始地址(如
0x80DB320)不满足页对齐,而0x80DA000是内存页的起始地址(末尾三位为 000,符合 4KB 对齐),且属于程序可访问的内存区域。
然后就是mprotect函数参数
|
|
a1是start起始地址,也就是0x80DA000;a2是长度范围,这里选择0x1000,足够我们容纳shellcode大小了;a3是prot,也就是修改成的权限范围,这里直接改为RWX,也就是简写0x7。
最后是控制函数参数的工具——关键gadgets
32位程序用栈传参的,这里的mprotect函数有三个参数,且调用后需继续控制流程,因此需要 pop ebx ; pop esi ; pop ebp ; ret 这样的 Gadget,用ROPgadget指令查询地址:

pop ebx ; pop esi ; pop ebp ; ret的地址是0x080a019b
exp:
|
|
对于payload:
|
|
先填充缓冲区,调用mprotect函数,接着输入mprotect函数的3个参数,在此之前就要用到gadget:pop_ebx_esi_ebp_ret进行传参,按照mprotect函数的三个参数对应意义进行注入start、size、proc,这时已经给bss内存片段开放了可执行权限,然后读取read函数地址进而调用read函数,再用gadget:pop_ebx_esi_ebp_ret进行传read的3个参数,读取 后面我们所上传的shellcode 到目标内存来执行。最后上传shellcode。
其实你可以发现这也是ret2shellcode的其中一种,只不过多加了mprotect函数控制流程罢了。
整个流程的核心逻辑就是通过栈溢出构建 “函数调用链”,逐步完成 “开权限→写代码→执行代码” 的攻击流程。
对于这道题而言对我感触比较深,加深我对寄存器、gadget、整个函数调用流程、参数传递过程的理解!如果你觉得很难而跳过,那你就错过了一次PWN入门者的提升机会,博主当时啃这题花了快两天时间,凌晨才写完这题的writeup呢,花点耐心去学,PWNer!
PWN_050
题目:好像哪里不一样了;远程libc环境 Ubuntu 18。
当然,你可以继续使用ret2libc来完成这题,甚至会更简单,而我的目的是为了让大家进一步学mprotect函数。
这里叫我们启动Ubuntu 18,就ctfshow配给我们的,那我们就听劝用它的虚拟机。虽说比较老……

跟进ctfshow、main函数:


明显看到危险的gets函数,ctfshow函数这有栈溢出漏洞:
主要流程如下(4步payload):
- 泄漏内存地址,通过计算得到libc地址
- 通过mprotect函数来修改一段区域的权限,位rwx
- 向这段区域写入shellcode
- 跳转到写入shellcode的区域,并执行
我们要利用mprotect函数改变内存执行权限,那我们需要找地址,正好这次和上次一样不是页对齐:

bss_start_address = 0x602000
64位程序靠寄存器传参,用ROPgadget命令查询程序本身的gadget:

pop_rdi_ret = 0x00000000004007e3
用ROPgadget命令查libc库的通用gadget:
|
|

pop_rdx_ret1 = 0x0000000000001b96
pop_rsi_ret1 = 0x0000000000023a6a
前面加个1是因为,后续还有一个类似的gadget变量,好区分一些。
为什么第一个gadget可以从程序本身去查到,而第二三个gadget地址却要在libc库获取?
从这可以看到程序本身是不带第二、三个的gadget的,因为我们要利用漏洞去获取flag,而本身程序运行是不用后面俩的gadget的。
exp;
|
|
让我给你逐步解释:
|
|
这里前面有个1是方便区分,这两个gadget是在libc库上的通用gadget,受ASLR保护,地址是动态变化的,需要用所有函数的真实地址 = libc 基地址 + 函数的本地偏移得出地址:
|
|
泄露puts在本地程序的地址,再泄露puts再libc库的地址推出偏移量,已知本地程序的每个函数地址偏移量相同,知道偏移量后,泄露libc标准库的gadget(/lib/x86_64-linux-gnu/libc.so.6)就能反推出本地程序gadget的真实地址:
|
|
这里有个问题:为什么gets = libc.sym[‘gets’]就是真实地址呢,你会认为它就是libc库的通用地址,是不是应该加上本地偏移量呢?
libc.sym['gets']最终是真实地址的原因是:
- 它原本是 “偏移量”(相对于 libc 基地址);
- 当你设置
libc.address = libc_base后,pwntools自动将 “基地址 + 偏移量” 合并,得到真实地址。
libc.address不是一个函数,而是pwntools中ELF类的一个属性(attribute),用于存储和设置 ELF 文件(这里特指 libc 库)在内存中的基地址。当你通过
libc = ELF('libc.so.6')加载 libc 库后,libc.address默认值为0(表示尚未设置基地址)。
总的来说这题质量不错,耗了我两天空闲时间去攻克。
PWN_051
题目:I‘m IronMan;

IDA抽象🤦😄…

跟进main函数,这次函数名字比较难记,可以改个名字:


跟进ctfshow函数:

很明显这是用C++写的,这里我看的不太懂,借用AI解读…
在往s中read的时候大小没有问题,但是程序在下面将字符"I"替换成 了"IronMan",最后在strcpy的时候发生了溢出。只需要简单计算一下,那么在输入16个“I” 就会被替换为 16 * 7 = 112个字符,而s距ebp的距离为 0x6c = 108。
正常溢出覆盖到返回地址就是 cyclic(0x6c+4) = cyclic(112) ,输入16个I刚好替换成 16个"IronMan",刚好是112个,也就刚好覆盖到返回地址,但用其它字符比如a不可以,因为会再调用 std::string::operator+= 将输入的 "a"*112 拼接到这个固定前缀之后。此时,字符串总长度已变为 “固定前缀长度 + 112 字节”,必然超过 112 字节。

让我发现了cat /ctfshow_flag,可以利用栈溢出来执行这个catflag的命令:


找它地址:

地址:804902E
exp:
|
|
PWN_052
题目:迎面走来的flag让我如此蠢蠢欲动;

IDA32位分析,跟进ctfshow函数:

让我看到了gets函数。
跟进flag函数:

这段 C 语言代码定义了一个名为flag的函数,主要功能是读取并输出特定文件中的内容,但有条件限制。我们来分析一下: 函数首先尝试打开/ctfshow_flag文件用于读取, 如果文件打开失败,会输出错误信息并退出程序; 如果文件打开成功,会读取文件中的内容到s数组。(最多 64 个字符)
函数的返回逻辑: 当参数a1等于 0x36C 且a2等于 0x36D 时,使用printf(s)输出文件内容并返回;否则,直接返回fgets的结果。(即读取到的字符串)
也就是说我们利用栈溢出漏洞覆盖返回地址,返回到这个flag函数并执行就能获得flag。
exp:
|
|
类canary爆破
PWN_053
题目:再多一眼看一眼就会爆炸;
checksec看信息….没开canary、PIE,有NX
跟进flag函数:

函数的执行流程如下: 定义一个 64 字节的字符数组s和一个文件指针stream。尝试打开路径为/ctfshow_flag的文件用于读取。
如果文件打开失败(stream为 NULL),则输出错误信息并退出程序;如果文件打开成功,使用fgets从文件中读取最多 64 个字符到数组s中,通过puts(s)输出读取到的内容(会自动在末尾添加换行符),调用fflush(stdout)刷新标准输出缓冲区,并将其返回值作为函数的返回值。
这段代码的逻辑比上一个版本更直接,不需要特定参数即可输出 flag 内容,只要成功打开文件就会直接打印文件中的内容。fflush(stdout)的作用是确保内容立即输出到终端,而不会停留在缓冲区中。也就是说只要执行到flag函数就直接给flag字符串。
在函数目录上看到了canary函数,这还是第一次见到手动放置的canary:

这段代码的功能是从标准输入读取用户输入的字节数,并将相应字节数的数据读取到缓冲区 buf 中。然后,它会检查堆栈的完整性,如果堆栈被破坏,则输出错误信息并终止程序。
跟进ctfshow函数:

解释下这段函数,也就是说开局我给程序输入一个1000,它就在后续给我设置1000容量大小的缓冲区:
|
|
那输入1000,程序在后续就让我能输入1000字节的东西。
看main函数:

可以看到先经过canary的栈溢出检查,如果发现栈溢出立刻终止程序进行,那就得绕过这个canary保护了。
手动放置的canary函数中的canary.txt的值是不变的,况且是4字节大小的值,也就是说我们编一个循环四次,从0~255范围找字节的双重循环就可以爆破出canary值,从而绕过memcmp的检查,实现栈溢出返回flag函数地址去执行flag函数获得flag字符串。
python2 exp:
|
|
这里定义了一个PWN1,起初为空值,只要每爆破出一位正确字节的canary值,就会通过PWN1 += chr(k)被储存进PWN1内,chr(k) 将这个整数转换为对应的字符(字节),用于拼接测试 payload,爆破 canary 的每一个字节。
发送 payload 的方式(
sendlinevssend)是关键差异之前的题目好像对发送payload的函数并不严格要求,但在这题就必须要求严格了,要用send函数,如果是sendline的话,会在payload后面多加一个换行符\n,因附加换行符破坏栈布局。也会导致一直循环爆破却最终爆破失败。
PWN_054
题目:再近一点靠近点快被融化;
IDA32:
跟进flag函数:

分析main函数:


只要密码正确,输出欢迎信息 “Welcome! Here’s what you want:",然后main就会调用flag函数直接获取flag字符串;但密码不正确就会输出”You has been banned!“的提示。
我们查看重要参数在堆栈中的位置:



从v5和s的位置来看,它俩所处的栈位置距离比较近欸!0x160 - 0x60 = 0x100。

这里的v5数组的容量刚好0x100可以填满,这不是巧合,突破点就在这的。当你用0x100填满v5后,正好把puts的终止符**\x00**给破坏了,这就会导致puts继续执行,直接把s一起输出了。
怎么判断终止符 \x00 存储在数组内部?
看 “数组容量” 与 “字符串最大长度” 的关系:若数组容量 > 字符串最大长度,终止符必在数组内部:
v5数组容量为256字节(0x100),而读取用户名的fgets(v5, 256, stdin)最多读取255字节(留1字节自动补\x00)判断
\x00是否存储在数组内部,可按优先级依次验证:
- 优先看 数组容量与字符串最大长度的关系(最直接,如
数组容量 > 最大读取长度);- 再看 字符串的生成 / 处理函数(如
fgets自动补\x00、strchr替换生成\x00);- 最后看 后续字符串操作是否正常(反向验证
\x00位置合法)。 三者均指向\x00在数组内部时,即可确定该结论。
exp1,得出密码:
|
|

整串复制,不要只复制h3r3!!

PWN_055
题目:你是我的谁,我的我是你的谁;
IDA,跟进main函数:

跟进flag函数:

要想flag字符串被打印出来,就要满足第一个条件:flag1 && flag2 && a1 == 0xBDBDBDBD
flag1、flag2须为真值,而a1要等于0xBDBDBDBD就行。
分别跟进flag_func1、flag_func2函数:


大致思路就是:先调用flag_func1函数,使flag1等于1,再调用flag_func2函数,满足函数条件将flag2赋值为1,最后再调用flag函数,给参数,最终打印flag字符串,exp:
|
|
1payload = cyclic(0x2c+4) + p32(flag_func1) + p32(flag_func2) + p32(flag) + p32(0xACACACAC) + p32(0xBDBDBDBD)这里你想必把有点懵,为什么要先flag再到参数,不是应该先执行完函数、传完参再到另外一个函数吗?感觉很乱啊….
32 位程序调用函数时,遵循两个关键规则(这是理解 payload 的前提):
- 参数压栈顺序:函数的参数要按 “从右到左” 的顺序,依次放到栈上;
- 返回地址跟着参数:参数压完后,要压入 “函数执行完后要跳转到的地址”(即下一个函数的地址),最后才是 “当前要调用的函数地址”。
现在这些题目需要我们对代码具有一定阅读理解能力,理解代码的运作原理,而不再像新手入门那样无脑套ret2shellcode、ret2libc、脚本了。
认识32位和64位的shellcode
PWN_056
题目:先了解一下简单的32位shellcode吧。
IDA32分析:

这里就是送分的了,把/bin/sh赋值给v1,然后调用execve函数(类似system函数)去执行,这不就是直接终端交互吗?
exp:
|
|
现在来看汇编语言:
|
|
这段代码是x86汇编语言的代码,用于在Linux系统上执行一个系统调用来执行execve("/bin/sh", NULL, NULL) 。让我们逐行解析代码的功能:
|
|
这行代码将十六进制值 0x68 (104的十进制表示)压入栈中。这是为了将后续的字符串 “/bin/sh"的长度(11个字符)放入栈中,以便后续使用。
|
|
这行代码将十六进制值 0x732f2f2f 压入栈中。这是字符串 “/bin/sh” 的前半部分字符的逆序表示,即 “sh//"。这是因为x86架构是小端字节序的,字符串需要以逆序方式存储在内存中。(但其实2f2f可以换成0000,补空位,那就是0x732f0000:sh,不要//都可以的)
|
|
这行代码将十六进制值 0x6e69622f 压入栈中。这是字符串 “/bin/sh” 的后半部分字符的逆序表示,即 “/bin”。
|
|
这行代码将栈顶的地址(即字符串 “/bin/sh” 的起始地址)复制给寄存器 ebx 。 ebx 寄存器将用作execve 系统调用的第一个参数,即要执行的可执行文件的路径。
|
|
这两行代码使用异或操作将 ecx 和 edx 寄存器的值设置为零。 ecx 和 edx 分别将用作 execve系统调用的第二个和第三个参数,即命令行参数和环境变量。在此情况下,我们将它们设置为 NULL,表示没有命令行参数和环境变量。(这不就是手敲shellcode的那段代码吗?在一开始的wp:PWN_024)过这么久了忘得差不多了,在这里回忆一下啊。
|
|
这两行代码将值 11 ( 0xb )压入栈中,然后从栈中弹出到寄存器 eax 。 eax 寄存器将用作系统调用号, 11 表示 execve 系统调用的系统调用号。
|
|
这行代码触发中断 0x80 ,这是Linux系统中用于执行系统调用的中断指令。通过设置适当的寄存器值( eax 、 ebx 、 ecx 、 edx ), int 0x80 指令将执行 execve("/bin/sh”, NULL, NULL) 系统调用,从而启动一个新的 shell 进程。
总结起来,这段汇编代码的功能是利用系统调用在Linux系统上执行 execve("/bin/sh”, NULL,NULL) ,即打开一个新的shell进程。
PWN_057
题目:先了解一下简单的64位shellcode吧。
IDA64:

内联汇编
syscall; LINUX -的作用
syscall:是 x86-64 架构下的汇编指令,用于触发 Linux 系统调用(调用操作系统内核提供的功能,如进程退出、文件操作等)。LINUX -可能是简化或注释残留,实际有效的指令只有syscall。系统调用的具体功能由rax寄存器的值决定(例如rax=60表示exit系统调用,用于终止当前进程)。
总的来说这俩题是让我们认识shellcode的样子,以便后面使用…
exp同上。
重点还是看汇编:
|
|
这段代码是x86-64汇编语言的代码,用于在Linux系统上执行 execve("/bin/sh", NULL,NULL) 。让我们逐行解析代码的功能:
|
|
这行代码将 rax 寄存器的值(通常用于存放函数返回值)压入栈中。这里的目的是保留 rax 的值,以便后续使用。
|
|
这两行代码使用异或操作将 rdx 和 rsi 寄存器的值设置为零。 rdx 和 rsi 分别将用作 execve系统调用的第三个和第二个参数,即环境变量和命令行参数。在此情况下,我们将它们设置为 NULL,表示没有环境变量和命令行参数。
|
|
这行代码将字符串 ‘/bin//sh’ 的地址赋值给 rbx 寄存器。字符串 ‘/bin//sh’ 是我们要执行的可执行文件的路径。在x86-64汇编中,字符串被当作地址处理。
|
|
这行代码将 rbx 寄存器的值(字符串 ‘/bin//sh’ 的地址)压入栈中。这是为了将可执行文件路径传递给 execve系统调用的第一个参数。
|
|
这两行代码将栈顶的地址(即字符串 ‘/bin//sh’ 的地址)弹出到 rdi 寄存器。 rdi 寄存器将用作execve 系统调用的第一个参数,即可执行文件路径。
|
|
这行代码将 al 寄存器设置为值 59 , 59 是 execve 系统调用的系统调用号。
|
|
这行代码触发系统调用。通过设置适当的寄存器值( rax 、 rdi 、 rsi 、rdx), syscall 指令将执行 execve("/bin/sh", NULL, NULL) 系统调用,从而启动一个新的 shell 进程。
总结起来,这段汇编代码的功能是利用系统调用在Linux系统上执行 execve("/bin/sh”, NULL,NULL) ,即打开一个新的shell进程。与前一个示例相比,这段代码是x86-64架构下的汇编代码,使用通用寄存器进行操作。
pwn_058
题目:32位 无限制。
32位仅部分开启RELRO,其他保护全关,并且有可读,可写,可执行段。可以用shellcode。
看不到源码,那就只能看汇编代码:

大致就是:会调用puts函数
看text段:

这里看到调用gets函数了,参数对应的是 [ebp+s] 的地址,也就是在返回地址上一栈内存单元处,接着往下就是调用puts函数了:

紧接着就是ctfshow函数:

从这可看出,gets函数读入存进[ebp+s]这块的内存,到puts、ctfshow主函数都会读取其中内容调用,那我们往这块注入shellcode不就好了吗?
exp:
|
|
当然shellcode这部分你可以选择手敲汇编代码进去,这里用了shellcraft模块。
PWN_059
题目:64位 无限制。
分析流程同上,需要注意的是在生成shellcode的时候需要注明架构为64位:
exp:
|
|
PWN_060
题目:入门难度shellcode。
IDA32分析:

发现gets函数。
使用strncpy函数将对应的字符串复制到 buf2 处。跟进查看:

可以看到buf2在bss段,地址:0x804A080
用gdb调试查看bss是否有执行权限:
|
|

buf2所在的bss内存段确实有rwx权限,那我们直接注入shellcode到buf2运行就可以。
exp:
|
|
你会看到为什么是112?不应该是104吗?100字节+ebp本身的4字节,还有8字节哪来的?记住以下规则:
“多填充 8 字节” 并非随意设置,而是由程序实际栈帧结构决定的—— 可能是编译器的栈对齐策略,也可能是隐式局部变量占用空间,最终通过调试确认 “只有填充至 112 字节才能覆盖返回地址”。这也符合 PWN 题的通用原则:栈溢出的填充长度需以 “实际调试 / 逆向结果” 为准,而非仅依赖理论计算的 “缓冲区大小 + 旧 EBP”。
不过在新版本的Ubuntu虚拟机中就可能会误导你:

灵活掌控shellcode长度
PWN_061
题目:输出了什么?
checksec查看保护信息:没栈溢出保护、没开NX,可以尝试注入shellcode:

IDA64:

欸我们分析这个main函数看到v5地址在每次程序运行都会打印出来,但因为是有PIE保护,所以每次地址都会随机化,所以在构造exp时需要接收这个v5地址。
gets函数会读取v5参数然后存入内存段中。
可以看到会打印出v5地址:

exp大致思路是先覆盖缓冲区,然后跳转到v5地址,注入shellcode,但要注意:

这里执行了leave指令,
等价于两条汇编指令的组合:
mov rsp, rbp:将栈指针rsp(栈顶指针)指向栈底指针rbp,此时rsp从 “v5起始地址” 跳转到 “rbp所在地址”,直接跳过v5占用的栈空间;pop rbp:将栈中rbp地址处存储的 “旧 RBP 值” 弹出到rbp寄存器,同时rsp自动增加 8 字节(64 位系统栈操作以 8 字节对齐)。
v5[0]、v5[1]、旧rbp,再加上预留给shellcode的返回地址的8字节长度,总共32字节,这32字节不能使用。等放到32之后shellcode的内容才能正被执行。
exp:
|
|
在这里解释下:
|
|
输入流中首先接收数据直到遇到 ‘[’ 字符为止。接下来再次从输入流中接收数据,直到遇到 ‘]’ 字符为止,将其保存在变量 v5 中。最后,将变量 v5 解析为一个十六进制的整数,并将其存储回变量 v5 中。
p64(v5+32),将 “v5 地址 + 32 字节” 这个目标地址,打包为 64 位小端序二进制数据,用于覆盖程序的返回地址,最终让程序跳转到该目标地址执行 shellcode
若 v5 是字符串(如
'7ffefd78ff20'),Python 无法对字符串进行加法运算,这就是为什么需要v5 = int(v5, 16)的原因。
PWN_062
题目:短了一点。
checksec一下:

IDA64分析:

和上题差不多,但这里换成read函数了,read(0, buf, 56uLL)意思是读取56长度的数据到buf中。
在text字段查看read函数附近的汇编指令:
可以看到这里和上题一样调用完read函数后会执行leave汇编指令。
运行程序依旧一样,有PIE保护情况下,buf地址动态变化。
但这里有个问题是,read只读取56位长度字符,56本身减去shellcode地址长度8字节、buf的16字节长度(buf[0]、buf[1]各占8字节长度)以及ebp本身8字节,共32,即56-32=24,那就是说只留给24位长度给到shellcode的内容长度限制,pwntools的shellcraft模块的shellcode长度(29字节)超过了24。因此需要另寻shellcode,这里我推荐一个shellcode,刚好24字节长度https://www.exploit-db.com/shellcodes/43550。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16global _start section .text _start: push 59 pop rax cdq push rdx mov rbx,0x68732f6e69622f2f push rbx push rsp pop rdi push rdx push rdi push rsp pop rsi syscall
1 2 3 4 5 6 7 8 9 10#include <stdio.h> #include <string.h> char code[] = "\x6a\x3b\x58\x99\x52\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x53\x54\x5f\x52\x57\x54\x5e\x0f\x05"; // char code[] = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"; int main() { printf("len:%d bytes\n", strlen(code)); (*(void(*)()) code)(); return 0; }
exp:
|
|
PWN_063
题目:又短了一点。
checksec,不多说:

IDA64:

这次更狠了,read只读取55长度了…
还是这个网站,找一个23字节的shellcode😄。
exp:
|
|
建议可以把一些长度较短的shellcode保存到桌面,下次遇到断网的线下赛就可以直接套用了。
PWN_064
题目:有时候开启某种保护并不代表这条路不通。
checksec:

IDA32分析:

看到一个新函数mmap,mmap(memory map)是 Linux 系统调用,主要功能是将文件或设备映射到进程的虚拟内存空间,但在 PWN 漏洞利用中,更常用其 “匿名映射” 功能,参数通过寄存器传递(rdi, rsi, rdx, r10, r8, r9)
|
|
address(rdi 寄存器):期望的内存起始地址,通常设为
0(让系统自动分配)。
length(rsi 寄存器):分配的内存大小(字节),需根据 shellcode 长度设置(如文档中设为
0x1000,即 4KB,足够存放 ORW shellcode)。
prot(rdx 寄存器):内存保护属性,PWN 中常用
PROT_READ | PROT_WRITE | PROT_EXEC(对应值0x7),表示内存可读、可写、可执行。
flags(r10 寄存器):映射类型标志,漏洞利用中常用
MAP_ANONYMOUS | MAP_PRIVATE(对应值0x22):
MAP_ANONYMOUS:匿名映射,不关联文件;MAP_PRIVATE:私有映射,修改不影响其他进程。
fd(r8 寄存器):文件描述符,匿名映射时设为
-1(或0,不影响)。
offset(r9 寄存器):文件偏移量,匿名映射时设为
0。
这行代码使用 mmap 函数分配一块内存区域,将其起始地址保存在变量 buf 中。 mmap 函数通常用于在内存中分配一块连续的地址空间,并指定相应的权限和属性。
这里buf用mmap映射了地址,可读可写可执行,直接传入shellcode,((void (*)(void))buf)()。调用了buf,运行shellcode 即可获取shell。
那我们完全可以用pwntools的shellcraft模块下的shellcode(29字节长度)
exp:
|
|
PWN_065
题目:你是一个好人。
checksec:

居然开了RELRO完全保护。
运行下看看:

可以发现这里要我们输入一个符合要求的字符串才行。
IDA64看不了源码,只能看汇编代码:

在这里我们看到我们输入的字符经read函数读取然后通过jg比较是var_8否大于零,符合要求就跳转到loc_11AC

cdqe使用eax的最高位拓展rax高32位的所有位 movzx则是按无符号数传送+扩展(16-32) EAX是32位的寄存器,而AX是EAX的低16位,AH是ax的高8位,而AL是ax的低8位大致就是将我们输入的字符串每一位进行比较,如果不在0x60~0x7A这个范围就跳转剩下几个就是跳转的范围。意思可能就是从第一个字符开始一直做循环判断,如果每一个字符都在一定区间,结束最后一个字符判断时就能够绕过上面的 loc_11B8 来执行 shellcode 了。

1.第一组范围标识:允许 0x60~0x7A(对应 ASCII ‘`’~‘z’)
对应代码段(loc_11B8至loc_1236):
|
|
2.第二组范围标识:允许 0x40~0x5A(对应 ASCII ‘@’~‘Z’)
对应代码段(loc_11DA至loc_1236):
|
|
3.第三组范围标识:允许 0x2F~0x3F(对应 ASCII ‘/’~’?’)
对应代码段(loc_11FC至loc_1236):
|
|
综上:这段汇编通过 cmp al, 60h、cmp al, 7Ah、cmp al, 40h、cmp al, 5Ah、cmp al, 2Fh 这 5 处cmp指令,明确标识了输入字符的允许范围(0x2F~0x5A 或 0x60~0x7A)。其实就是要我们输入的字符都是可打印字符,shellcode 有些是不可打印字符,这个叫string.printable,就是可见字shellcode。这里使用alpha3就可以生成了:
|
|
刚好pwntools的shellcode是有不可打印的字符:
|
|
jhh///sh/bin\x89�h\x814$ri1�Qj\x04�Q��1�j\x0b̀

将shellcode重定向到一个文件中 切换到alpha3目录中,使用alpha3生成string.printable:
|
|
把输出的shellcode可见字符串粘贴到exp上的变量。
exp:
|
|
PWN_066
题目:简单的shellcode?不对劲,十分得有十二分的不对劲。
checksec、IDA64分析:

看到了熟悉的mmap函数;buf存在溢出点,往buf里写入shellcode,然后程序会执行shellcod但是有一个check函数,跟进查看:

check函数的作用是:验证输入字符串a1中的每个字符是否都属于unk_400F20指向的允许字符集。
继续跟进unk_400F20:


我们输入的shellcode的每一位字符要在unk_400F20中,检查的时候*j==0会退出,可以使用\x00来突破程序队shellcode的字符白名单校验;而常规的shellcraft.sh()生成的 shellcode 包含大量白名单外字符(如\x3b、\x0f等),无法直接通过校验,因此必须绕过。
如何绕过从check函数下手:

看到这里你会发现while里面是一个a1参数,如果我让a1 == 0,while的循环条件不成立,直接退出循环。
所以exp:
|
|
\x00\xc0是 **“截断校验 + 合法执行” 的双重工具 **:\x00负责突破check函数的逐字节白名单限制,\xc0负责确保指令合法以避免执行错误,二者组合是该题目绕过字符合法性校验、成功执行 shellcode 的核心关键。
nop sled空操作雪橇
“nop”(No Operation 的缩写):是一条汇编指令,在 32 位 x86 架构下,其机器码为
0x90。nop 指令执行时,CPU 不进行实际的运算或数据处理操作,仅将程序计数器(如 32 位架构下的 EIP 寄存器)的值增加 1,指向下一条指令,起到占位和使程序流程顺序执行的作用。“sled”(雪橇):形象地描述了攻击原理。通过在 shellcode 前面填充大量的 nop 指令,形成一个 “指令雪橇”。当程序的执行流被劫持到这个 “雪橇” 区域内的任意位置时,就像雪橇从高处滑下一样,程序会顺着一个个 nop 指令依次执行,最终到达并执行位于 “雪橇” 后方的 shellcode。这一过程可类比为在不确定 shellcode 确切起始地址的情况下,扩大可命中的范围,只要返回地址能落入 nop 指令组成的区域,就能保证 shellcode 被执行。
PWN_067
题目:32bit nop sled;
checksec:

这次只有栈溢出保护了。
IDA32分析:


这个v5地址被打印输出,但一直是动态变化的。
代码的大概意思是先输出一些信息,然后输出的会有栈中的地址,从query_position函数可以发现函数返回值是v1的地址加上v2的值,而v1是局部变量,那么它的地址就是栈里的地址,加上v2就代表接收函数返回值的变量position=v1的地址+v2的值(即&v1+v2)。然后程序会让我们输入大小为4096的字符串给seed变量,之后再让我们输入一个地址,将其赋给v5,然后使用v5从我们输入的这个地址执行这个地址的代码。
因此我们可以从堆栈中执行。向程序提供 shellcode 很容易,因为它只要求输入。现在我们只需要找到一种方法来跳转到我们的shellcode的地址从而执行这个shellcode。
刚好有个 fgets 可以读取 shellcode 到 seed,但是我们并不知道 seed 的地址。
跟进query_position函数:

char *query_position() 作用是生成一个基于随机偏移的栈地址,得到一个范围在-668到 668之间的随机整数,并将其存储在变量 v2 中。该地址后续用于构造 “nop 雪橇” 攻击以绕过栈地址随机性。
看到这里就有点印象了,这不就是类似ASLR(Address Space Layout Randomization)保护吗?
我们这里需要了解一下 nop sled 空操作雪橇:
攻击者通过输入字符串注入攻击代码。在实际的攻击代码前注入很长的 nop 指令(无操作,仅使程序计数器加一)序列,只要程序的控制流指向该序列任意一处,程序计数器逐步加一,直到到达攻击代码的存在的地址,并执行。由于栈地址在一定范围的随机性,攻击者不能够知道攻击代码注入的地址,而要执行攻击代码需要将函数的返回地址更改为攻击代码的地址(可通过缓冲区溢出的方式改写函数返回地址)。所以,只能在一定范围内(栈随机导致攻击代码地址一定范围内随机)枚举攻击代码位置(有依据的猜) 。
不用 nop sled , 函数返回地址 ——-> 攻击代码。
使用 nop sled , 函数返回地址 ——-> nop 序列(顺序执行) 直到攻击代码地址。
为了安全地“绕过”不知道缓冲区的确切开始位置,我们可以:
1、将 shellcode 填充为以 1336 nop 条指令开头 ( 0x90 )。
2、使用 的返回值 query_position ,添加 0x2d (如前所述),然后添加 668。
python3运行的exp:
|
|
在这里特别解释一下:
|
|
addr:query_position() 返回的栈地址参考(v1 的地址);
+0x2d:补偿栈帧偏移( 计算得出:0x15 + 4 + 4 + 0x10 = 0x2d),将 “v1 的参考地址” 修正为 “实际 payload 缓冲区(seed 数组)的起始地址”;
| 较低地址 | ⇒ | ⇒ | ⇒ | 更高地址 |
|---|---|---|---|---|
| stk[ebp-0x15] | ebp⇒<旧ebp>[4] | <返回地址>[4] | padding[0x10] | buffer[0x1000] |
21 (0x15) + 4 + 4 + 16 (0x10) = 45 字节 (0x2d)
+668:覆盖程序的最大负偏移(-668),确保跳转地址落入 1336 个 nop 的范围内(无论随机偏移如何波动,地址都能命中 nop 序列)。
0x10(padding)是编译器插入的栈对齐填充字节,无实际业务意义,仅为了满足 CPU 内存访问的对齐规则;在0x2d的计算中,它是「v1 到 seed [0]」固定偏移的一部分,必须加进去才能让地址校准到正确的 payload 注入点。

看回*query_position函数:

可以看到v1在query_position函数ebp-0x15的位置。

在这里我想说的是nop sled 并非专门用于突破 ASLR 保护,其核心作用是 “扩大 shellcode 的可命中地址范围”,可用于应对多种导致 “shellcode 地址不确定” 的场景,ASLR 保护只是其中一种。
ASLR(地址空间布局随机化)的核心是让程序的栈、堆、库等内存区域的起始地址每次运行时随机变化( PWN_061、PWN_062 等题目开启 PIE,本质是 ASLR 的延伸),导致攻击者无法提前确定 shellcode 的精确地址。
nop sled 的作用是:通过在 shellcode 前填充大量
\x90(nop 指令),形成一个 “指令序列区域”—— 只要程序执行流跳转到该区域的任意位置,都会顺着 nop 指令顺序执行,最终命中后续的 shellcode。从这个角度看,nop sled可以用于突破 ASLR 带来的地址随机性,但这并非其唯一用途。
PNW_068
题目:64bit nop sled。
这是64位程序,保护机制和上一题一样。
唯一不同的就是这:

把上一题的0x2d换成0x35就好(0x15+8+8+16(栈对齐)=0x35)
exp:
|
|
PWN_069
题目:可以尝试用ORW读flag flag文件位置为/ctfshow_flag。
checksec一下:

64位仅部分开启RELRO,其他保护全关。
IDA查看main函数(依据函数功能修改对应函数名):

复习下mmap函数作用,主要用途有三个:
1、将一个普通文件映射到内存中,通常在需要对文件进行频繁读写时使用,这样用内存读写取代I/O读写,以获得较高的性能;
2、将特殊文件进行匿名内存映射,可以为关联进程提供共享内存空间;
3、为无关联的进程提供共享内存空间,一般也是将一个普通文件映射到内存中。
把从0x123000开始的地址,大小为0x1000的长度,权限改为可写可执行。
跟进seccomp函数:

seccomp主要用于配置和加载 Seccomp(Secure Computing Mode,安全计算模式) 策略,来限制程序可以执行的系统调用,以增强程序的安全性。
wow这里新大陆….沙盒过滤:
|
|

只有read,write,open,exit可以使用,使用 open–>read–>write 这样的orw的方式。
ORW指的是Open-Read-Write技术,是一种利用系统调用读取文件内容(如flag文件)的攻击方法。
ORW通过以下三个系统调用实现:
open:打开目标文件,获取文件描述符。
read:通过文件描述符读取文件内容到缓冲区。
write:将缓冲区的内容写入标准输出。
跟进ctfshow函数:

这里有明显的栈溢出漏洞,在上面我提及过mmap函数,在main函数已经执行过一次了,在0x123000这里已经有可写可执行权限了。到这里,攻击思路就比较清晰了,我们想办法往mmap给的这个地址段里面写shellcode(ORW),然后跳转到这里执行,就OK了。
那我们的orw_shellcode:
|
|
shellcode已经编好了,现在编payload让程序听我们话,将shellcode注入进内存中去。
shellcraft.read的作用是执行read函数的后续流程——读取0x100长度的字符(也就是后续的orw_shellcode),文件描述符fd=3(0、1、2已被标准文件占用)
shellcraft.write(1, mmap, 0x100) 的 1 是 Linux 标准输出(stdout)的固定文件描述符,其功能是将 mmap 区域中存储的 \ctfshow_flag 内容输出到屏幕,当成printf也行。
payload:
|
|
我在汇编代码分析的过程中找到关键的jmp_rsp后门地址,可以调用这行汇编代码来实现跳转回起始地址:

payload = asm(shellcraft.read(0, mmap, 0x100)) 的意思是启动read函数,开启“读取模式”处于待读取状态,等待后面的orw_shellcode的出现,并存到mmap的可读可写区域(2+4);
最正确理解:执行后因缺少输入数据(orw_shellcode)才进入 “等待状态”。
asm(“mov rax, 0x123000; jmp rax”)的意思是跳到mmap=0x123000区域,也就是指向这个区域,等待后续orw_shellcode注入执行。
payload = asm(shellcraft.read(0, mmap, 0x100)) + asm(“mov rax, 0x123000; jmp rax”)加起来不足0x28个字节(0x20+8),让buf刚好充满,以便后续将 rsp 调整到 buf 缓冲区起始地址(buf复原)。
python3的exp:
|
|
PWN_070
题目:可以开始你的个人秀了 flag文件位置为/flag。
checksec:

64位程序部分开启RELRO,开启栈保护。告诉了我们flag文件位置,hint中还是让我们用ORW读flag。

具体规则逻辑:
- 0000-0001 行:检查程序架构,仅允许
ARCH_X86_64(64 位 x86 架构),否则终止程序(跳转到 0007 行返回KILL)。- 0002 行:将当前系统调用号加载到寄存器
A。- 0003-0004 行:过滤 “非标准系统调用”(编号 >=
0x40000000的内核私有调用),仅允许编号为0xffffffff的特殊调用(实际很少用到),其他此类调用会被终止。- 0005 行:明确禁止
execve系统调用(编号0x3b,用于执行新程序),触发时会终止程序。- 0006 行:允许所有未被上述规则禁止的系统调用(返回
ALLOW)。- 0007 行:对被禁止的情况,直接终止程序(返回
KILL)。
完全支持**
ORW**操作!唯一被禁止的是execve(执行新程序),但 ORW 操作不依赖它,因此可以正常执行。
跟进到is_printable函数:

条件是 a1[i] <= 31 || a1[i] == 127。这意味着如果字符的 ASCII 值小于等于 31,或者等于 127,则认为该字符不可打印。
可以在开头加上\x00 \xc0,做过pwn66,有strlen 的话可以找\x00开头的shellcode,
\x00是 ASCII 码为 0 的字符(也叫 空字符),它在 C 语言中唯一的特殊作用是 标记字符串的结束。这里的\x00只是 “告诉strlen停止计算”,没有任何 “赋值” 操作(既不修改寄存器,也不修改内存值)。
这波直接不给看main的C伪代码,只能看汇编语言了。
我的python3的exp(没用到ORW,巧合):
|
|
手写汇编的exp:
|
|
建议大家在以后要多学学汇编语言,有时候手敲是真的实用,好理解。
ret2syscall
ret2syscall的核心思想是通过控制程序的执行流程,使程序执行系统调用(syscall)指令来获取系统资源或执行特定操作。系统调用是操作系统提供给用户程序的接口,用于请求内核服务。例如,在Linux系统中,execve系统调用可以用来启动一个新的程序(如/bin/sh),从而获取shell。
ret2syscall 是通过 拼接程序中的 gadget(指令片段),手动设置系统调用所需的寄存器值,最终触发 int 0x80(32 位 Linux 系统调用入口)来执行命令的攻击方法。
1.在32位Linux系统中,系统调用通过int 0x80指令触发,需要设置以下寄存器:
eax:系统调用号(例如,execve的调用号为0xb)
ebx:第一个参数(例如,/bin/sh的地址)
ecx:第二个参数(通常为0)
edx:第三个参数(通常为0)
2.在64位系统中,系统调用通过syscall指令触发,寄存器分配有所不同:
rax:系统调用号(例如,execve的调用号为0x3b)
rdi:第一个参数
rsi:第二个参数
rdx:第三个参数
PWN_071
题目:32位的ret2syscall。
checksec:

32位关闭栈保护与PIE。
IDA32位分析:


有明显栈溢出漏洞、gets函数;题目描述以及各处都提示了让用ret2syscall来进行攻击,我们可以利用程序中的 gadgets 来获得shell,而对应的 shell 获取则是利用系统调用。
大致思路是先栈溢出填充缓冲区,利用gadgets调用syscall,也就是传递它的调用号0xb,用rax寄存器传递,然后调用execve函数:
|
|
需要用到eax系统调用参数传递的寄存器的gadget:
|
|

execve函数有三个参数,32位架构靠栈传递参数,所以要有参数寄存器:ebx(第 1 参)、ecx(第 2 参)、edx(第 3 参),也就是:
|
|
同理,int 0x80调用号的gadget地址亦是如此:


/bin/sh的地址:

同理int 0x80
我以为的exp……
|
|
试半天爆EOF,也是无语,后来才知道想起这个程序是静态编译的,而运行程序是动态的,在IDA上呈现的数据可能是有偏差,这让我想起了缓冲区大小,寄存器esp是动态寄存器,ebp-64h可能是错误的,于是我开始用动态调试pwngdb,既然gets是我们唯一能和程序交互的地方,我们可以通过gets函数,输入一些垃圾值,那就aaaa吧,刚好4字节(建议不要太大,万一溢出了程序就崩溃了,当然aaa、ctf也行),让esp把我所输入的值运走,这会就能看到esp的地址了,然后用ebp地址和它相减得到偏移量了。

在右边的运行框上,先给程序进行break main指令给main函数断点进行动态调试准备,然后开始了,用n指令(也就是next缩写,一步步进行,左边的调试框也会看到变化),接着回车换行一直进行下一步操作,直到看到有熟悉的ctfshow交互界面和让你输入的交互操作时就可以了:

这时输入AAAA,左边跟着变化,esp地址出现了。
查询栈帧底部ebp寄存器地址:
|
|

ebp - esp => 0xffffcf58 - 0xffffceec => 6C = 108
这是偏移量,再覆盖ebp本身,那就是112了
最终exp:
|
|
所以说后续看到静态编译一定要多注意偏移量,后续要熟练运用pwngdb动态调试和pwndbg。
PWN_072
题目:接着练ret2syscall,多系统函数调用。
checksec一下:

32位关闭栈保护与PIE。
IDA分析,依旧静态编译:

看到gets函数,和上题差不多,都是利用栈溢出漏洞。
/bin/sh在IDA上搜索不到,不能返回地址执行getshell了,那就找另外一种方法。确实难找,read衍生函数很多,找到个单纯“read”的函数是真难……

不会就丢给AI吧,这确实有点难去读懂……
有read函数那我们可以用它去读取字符串到内存呐,利用read函数来进行手动写入“/bin/sh”字符串。
在main函数中没看到调用read函数,只能我们写exp时利用syscall去调用read函数,搭配gadgets去让read通过寄存器去读取/bin/sh字符串。可以看到read函数需要三个参数,加上自身系统调用号,我们就需要使用eax、ebx、ecx、edx的gadgets。
gadget查询:


pop_edx_ecx_ebx_ret:0x0806ecb0
pop_eax_ret:0x080bb2c6
int 0x80:0x08049421
调用read函数,read函数系统调用号是0x3,用eax寄存,三个参数用ebx、ecx、edx分别暂时寄存,int 0x80去开启执行read函数,把/bin/sh写入内存bss段后,接着执行execve函数,也是用一样的gadgets:

read函数的解释:
a1:表示文件描述符(file descriptor),是一个非负整数。在 Linux 系统中,文件描述符是一个指向已打开文件的引用,常见的值有:0代表标准输入(stdin)。1代表标准输出(stdout)。2代表标准错误输出(stderr)。- 当打开一个普通文件或其他类型的文件时,系统会分配一个大于 2 的文件描述符。
a2:指向用于存储读取数据的缓冲区的地址。也就是说,从文件描述符a1所指向的文件中读取的数据,会被存储到a2所指向的内存区域中。a3:表示要读取的字节数,即指定从文件描述符a1所指向的文件中读取多少个字节的数据到a2所指向的缓冲区中。总的来说,
read函数的作用就是从指定的文件描述符所对应的文件中,读取指定字节数的数据到给定的缓冲区中,并且在多线程环境下,对异步取消等情况进行了一定的处理。
想起上一题,我怕有坑,还特定去用pwngdb调了下,发现真有猫腻:

这确实和IDA静态分析的偏移量大小不一样,所以下次看到静态编译的程序我直接留一手心眼用pwngdb看一眼偏移量先😄。
exp:
|
|
执行后,程序会阻塞等待用户输入,此时发送
bin_sh = "/bin/sh\x00",read会将该字符串写入bss段(0x080eb000地址处);此时
bss地址指向的内容就是"/bin/sh\x00",满足execve对 “命令路径参数” 的要求。
PWN_078
补发ret2syscall-x64
题目:64位ret2syscall。

64位程序,静态编译;开启NX部分开启RELRO。
IDA64分析:

这题因为开了NX,所以不能注入shellcode了,只能用ret2syscall。
程序是64位,和32位不同的是,前6位参数它需要利用寄存器传参,所以说需要构造ROP攻击链,main函数看到了gets函数,就有栈溢出漏洞,利用栈溢出覆盖到返回地址,返回到bss可执行段,可以调用read函数将ROPgadget读入bss段。
read函数的调用号是0x0,read函数有三个参数:read(int fd, void *buf, size_t count);,经传参后就是read(0, bss, 0x10);0x10十进制是16,我们注入/bin/sh外加\x00结束符就是16字节了,bss字段注意一下页对齐就好。
ROPgadget查询所需传参用的寄存器的gadget有rdi、rsi、rdx、rax:

注意.bss段的页对齐:

那么payload:
|
|
与32位不同,需要注意以下几点:
- 存储参数的寄存器名不同;
- ret返回的函数名不同;
- 32位为int 0x80,64位为syscall ret.
exp:
|
|
PWN_073
题目:愉快的尝试一下一把梭吧!
checksec:

看出来是静态编译了:


IDA分析main函数:

还是明显的栈溢出,但是由于是静态编译,我们无法再使用ret2libc来进行getshell,程序中也没有system函数,我们可以尝试直接使用ROPgadget来帮助我们构造一个ROP链:
|
|
|
|
exp:
|
|
可以看到,它已经很智能的帮我们构造好了这些,我们仅仅需要将它构造好的payload提取出来,然后填充上偏移即可进行我们的攻击。
这个比较类似杂项的一把梭的感觉,在这个阶段不要我们学会,当然现在的我也不会,以后就会了。
使用前提:程序为 “静态编译” 且无现成的
system/execve函数、关闭 PIE 且开启 NX(栈不可执行)、存在 “可控栈溢出” 且溢出空间足够。使用该指令的核心原因是程序为 静态编译,
file pwn显示statically linked,且未直接提供system、execve等可直接 getshell 的函数:
- 静态编译的程序会将所有依赖的库函数(如
read、write、int 0x80系统调用入口)打包到自身中,不存在 “动态链接依赖 libc” 的问题,ROPgadget能从程序自身提取到完整的 ROP 组件(如pop edx; ret、mov [edx], eax; ret、int 0x80等),足以构造出调用execve("/bin/sh")的完整 ROP 链;- 若程序为动态编译(依赖外部 libc),且未泄露 libc 基址,
ROPgadget无法获取 libc 内部的 gadget(如system地址、/bin/sh字符串地址),自动生成的 ROP 链会因 “缺少关键组件” 失效,无法直接使用 “一把梭”。
one_gadget攻击
PWN_074
题目:噢?好像到现在为止还没有了解到one_gadget?
checksec:

64位保护全开….我都懵了。
相对于现阶段,看到这种保护全开的题可能会有点迷茫,但是实际上在堆阶段后面基本上都是保护全开的题比较多一点,这里也是为了让大家提前了解一些攻击手法。
IDA64分析:

开局直接泄露了pringtf的地址我们,但因为地址动态变化,所以我们可以泄露libc库地址,算出这个程序的函数偏移量。
通过 __isoc99_scanf 函数从用户输入中读取一个长整数,并将其存储在 v4 数组的第一个元素中。
再将 v4 数组的第一个元素的值赋给了数组的第二个元素。继续通过函数指针调用了 v4 数组的第一个元素所指向的函数。这个部分利用函数指针的特性,将其转换为函数并进行调用。
使用用户输入来获取函数指针,并通过函数指针调用相应的函数。需要注意的是,这种通过用户输入来获取函数指针并调用函数的做法极有可能会带来安全隐患,因为恶意用户可以输入不安全的函数指针,导致程序出现问题。
到这里引出下一个攻击手段——one gadget
one_gadget是libc中存在的一些执行execve("/bin/sh", NULL, NULL)的片段,当可以泄露libc地址,并且可以知道libc版本的时候,可以使用此方法来快速控制指令寄存器开启shell。
相比于system("/bin/sh"),这种方式更加方便,不用控制RDI、RSI、RDX等参数。运用于不利构造参数的情况。
|
|

one_gadget并不总是可以获取shell,它首先要满足一些条件才能执行成功,后面提示就是在调用one_gadget前需要满足的条件。
约束条件满足:one - gadget 是 libc 中执行
execve("/bin/sh", NULL, NULL)的代码片段,但需要满足特定约束条件才能执行成功。0x4f2c5的约束条件为rsp & 0xf == 0且rcx == NULL,0x4f322的约束条件为[rsp + 0x40] == NULL,0x10a2fc的约束条件为[rsp + 0x70] == NULL。在 PWN074 场景中,程序栈布局使得[rsp + 0x70]天然为 NULL(因为该地址超出当前函数栈帧范围,属于未使用的空闲栈区域,在 Linux 系统中默认填充为 0) ,满足0x10a2fc的约束条件,而其他两个地址的约束条件难以满足。例如,rsp & 0xf == 0要求栈指针低 4 位为 0,在程序未提供调整栈指针对齐的可控途径时,很难实现;同时程序未对rcx寄存器进行控制,rcx大概率不为 NULL,不满足0x4f2c5的条件;[rsp + 0x40]在程序运行时存储有临时数据,无法通过用户输入清空,不满足0x4f322的条件。
本题仅仅是一个工具引进使用,具体可以自行调试一下便知。省了很大一部分时间和精力,对于初学者来说,libc版本是个坑。因此在训练题型上平台一般弄了几个比较相对固定的libc版本,实际比赛时libc的版本更加多样复杂。
当然,在这里如果不是使用的附带虚拟机,则需要自行去泄漏libc,当然前面经过这么多题的练习,相信这些也是没难度了。
exp:
|
|
one_gadget + libc_base:计算得到 one_gadget 在内存中的真实地址
*str(one_gadget+libc_base):one_gadget + libc_base 是十进制整数),main 函数明确使用 __isoc99_scanf("%ld", v4) 读取用户输入,也就是说要的是用户输入的字符串,不要数值本身的意义。(one_gadget 真实地址为 0x7f8a9b12d95c(十进制数值为 140239165128028),程序要求输入的是字符串 "140239165128028",而非数值140239165128028 本身)大概就这么理解,这是python的自身规则。
栈迁移
栈迁移是在二进制漏洞利用(尤其是栈溢出漏洞场景)中常用的一种技术手段,目的是改变程序执行时栈的布局和执行流程,绕过一些安全防护机制, 实现攻击者想要的代码执行。
在存在栈溢出漏洞的程序中,攻击者原本可以通过覆盖返回地址来劫持程序执行流,让程序跳转到攻击者指定的地址(如 shellcode 地址)执行恶意代码。但随着系统安全机制(如 NX 保护,使栈上数据不可执行 )的引入或者栈空间过小,直接在栈上执行注入的 shellcode 变得困难。同时,一些程序还可能存在栈金丝雀保护(Stack Canary)来检测栈是否被非法篡改,这也增加了传统栈溢出利用的难度。因此,栈迁移技术应运而生,以绕过这些安全防护。
| 问题场景 | 传统方法限制 | 栈迁移解决方案 |
|---|---|---|
| 栈溢出空间不足 | ROP链无法完整布置 | 迁移到大容量内存区域 |
| NX保护(栈不可执行shellcode) | 无法直接执行shellcode | 迁移到可执行内存区域 |
| 只能覆盖有限数据 | 无法控制完整控制流 | 只需要覆盖关键指针即可 |
| ASLR随机化 | 难以预测绝对地址 | 使用相对稳定的堆地址 |
原理
- 改变栈的位置:正常情况下,程序按照默认的栈布局执行,栈迁移的核心是将栈的位置移动到一个对攻击者有利的地方,比如具有可写可执行权限的内存区域(如
bss段) 。在栈溢出漏洞发生时,攻击者可以通过覆盖某些关键寄存器(如esp,在 32 位程序中;rsp,在 64 位程序中 )的值,使程序在后续执行中,将新的栈帧设置在攻击者指定的内存位置。 - 构造新栈帧:在新的栈位置,攻击者精心构造栈帧,放置需要执行的指令地址、函数参数等数据。例如,攻击者可以将
execve("/bin/sh", NULL, NULL)系统调用的相关参数和指令地址按照正确的顺序放置在新栈帧中,当程序执行到相应指令时,就会按照攻击者的意图执行系统调用,获取 shell。
PWN_075
题目:栈空间不够怎么办?
checksec一下:

32位开启NX保护,部分开启RELRO。
随便运行一下看看:

这是重点!后续编写exp要注意的细节。
IDA分析,查看main中的ctfshow函数:

其实在连靶机进行交互时,无论你输入什么,都是招原样给你打印出来的,主要还是看read、memset函数,况且没开canary保护有站处理漏洞可以利用。
还看到了system函数。
跟进hackerout函数:

看到有system函数,不过里边不是binsh,问题不大可以构造出来。
看回ctfshow函数的栈溢出漏洞,可以发现s容量是36,但s到ebp距离有0x28=40,加上覆盖ebp本身的4字节,仅 8 字节(44-36=8)溢出空间,无法直接布置system("/bin/sh"); 所需的 ROP 链(需覆盖返回地址 + 传递参数,至少 12 字节以上),那么这里就应该要想到栈迁移了。
栈迁移的本质是通过 leave 指令改变栈顶指针(esp)的位置,将原本狭小的栈空间转移到我们可控的大空间。那我们就得调用levae汇编指令。
leave指令是栈帧操作的 “便捷指令”,其本质是 自动完成栈帧的清理工作;它的功能可以拆解为两条经典指令的组合,具体行为依赖于架构(x86/x64 一致)。
leave指令等价于:
1 2mov esp, ebp ; 将栈指针(esp)指向基指针(ebp),回收当前栈帧的局部变量空间 pop ebp ; 从栈顶弹出之前保存的ebp值(恢复上一层栈帧的基指针)
看回ctfshow函数的栈溢出漏洞,可以发现s容量是36,但s到ebp距离有0x28=40,加上覆盖ebp本身的4字节,也就是说只有8字给到我们放东西,无法进行我们的ROP链构造,那么这里就应该要想到栈迁移了。

前置准备:
|
|
leave汇编指令和ebp、esp有关联,那我们就需要去获得ebp的地址。

看回ctfshow函数以及在Ubuntu上NC连接的运行界面你就会发现第一次输入后有个ptintf函数会输出,也就是第一次输出,那我们把缓冲区给覆盖了,那么就会把ebp4字节的地址给打印出来,用后续的u32函数进行复制保存下来,这就是ebp泄露了。
泄露ebp地址:
|
|
找栈迁移的地方,我们要迁移到攻击者可控的全局缓冲区(通常是第一次输入时构造的、用于存储 system("/bin/sh") 调用和参数的内存区域)。
栈迁移目的地:

可以看到buf比s更靠下,说明整个栈帧中,buf空间更大(比s大),可以放gadget。
那么现在需要明确一下思路:有两个输入点
-
利用第一个输入点来泄露ebp的值,动调找一下buf在栈上的位置,用ebp去表示。
-
第二个输入点输入system(/bin/sh),利用两次leave将栈迁移到buf处,执行buf里的指令,进行getshell。
上述已经泄露好了。
ebp已经泄露了真实地址,现在需要表示出buf真实地址,那我们可以用ebp去表示buf的真实地址,那就需要固定偏移量:
一步步来
第一步,先运行这个调试代码(python2):
|
|

第二步,右边pwndbg输入n(next)命令进行下一步,让a垃圾字符串注入程序:

第三步,看栈帧,输入stack 50(也够了,你输入60、70也行,小点也行不过看不到ebp就尬了…)
完整结果如下(有图有代码就怕你看不懂):
|
|

可以看到“GPNU”、“aaaa”字符串都被注入到程序里啦,能看到esp和ebp距离,算一算就是0x38(0xffa868a8-0xffa86870)。

这里的 0x38 是 ebp 到 “目标可控缓冲区” 的固定偏移,我们可以用ebp-0x38来表示buf的地址。
在第一次输入完后,进入到第二次输入,我们要往buf中写入system("/bin/sh"),同时还要将栈劫持返回buf地址,然后就执行了我们想要的system("/bin/sh");
构造payload:
|
|
程序有给system函数,直接调用。
我们在构造 Payload 时,会将 system 调用相关的代码提前布置到 buf 中,而 buf 作为栈迁移的目标缓冲区,其内部字节布局是固定的(32 位系统下,每个指针 / 地址占 4 字节)
Payload 整体分为 “攻击代码段” 和 “栈迁移控制段” 两部分:
1、攻击代码段:
|
|
| 内存地址范围 | 存储内容 | 占用字节 | 作用说明 |
|---|---|---|---|
buf 0 ~ buf + 3 |
system 函数地址 |
4 | 触发 system 调用的入口 |
buf 0 + 4 ~ buf + 7 |
垃圾数据p32(‘aaaa’) || p32(0) | 4 | 占位 system 调用后的 “返回地址” |
buf 0 + 8 ~ buf + 11 |
buf 0 + 12(参数地址) |
4 | 指向 /bin/sh\x00 的地址,即 system 的参数 |
buf 0 + 12 ~ 后续 |
/bin/sh\x00 字符串 |
8 | system 调用的核心参数(需以 \x00 结尾确保字符串终止) |
为什么是 buf + 12?
从上述布局可知,
/bin/sh\x00字符串的起始地址恰好是buf + 12,原因是:
- 前 3 个 “4 字节块”(
system地址、垃圾数据、参数地址)共占用3 × 4 = 12字节;- 因此,
/bin/sh\x00会从buf起始地址向后偏移 12 字节的位置开始存储,即buf + 12。
2、栈迁移控制段:
|
|
各字段功能如下:
| 字段 | 内容 / 作用 | 关联文档依据 |
|---|---|---|
p32(buf - 4) |
覆盖栈上的 ebp 寄存器为 buf - 4:这是 “假 ebp”,为后续 leave 指令第一步(mov esp, ebp)做准备 |
buf - 4 是为 pop ebp 留位 |
p32(leave) |
覆盖栈上的 “返回地址” 为 leave 指令地址(leave = 0x08048766):让程序执行完当前函数后,优先执行 leave 指令触发栈迁移 |
leave 指令 = mov esp, ebp; pop ebp |
p32(buf-4) 是将ebp覆盖成buf的地址-4 为什么要-4?这是因为我们利用的是两个leave,但是第二个leave的pop ebp,在出栈的时候会esp+4。就会指向esp+4的位置,p32(leave) ,将返回地址覆盖成leave,到这里,我们成功将栈劫持到了我们的buf处,接下来就会执行栈里的内容。累死了!!!
完整exp(python2):
|
|
总结:这个栈迁移手法还是比较重要的,为数不多栈考点的难点之一,重在使用gdb调试出固定偏移和payload构造,对栈帧结构要加深理解。
PWN_076
题目:还是那句话,理清逻辑很重要;
checksec一下:

32位关闭PIE部分开启RELRO,栈保护与NX是开启的,这里在IDA查看后发现像是静态编译的,用指令确认看一下:

IDA分析函数:

等等??

我当年杂项入门CTF的,这东西我看很敏感的😄。
跟进Base64Decode函数:

说实话看不懂这函数在干嘛的,直接上gdb调试:


按照函数表面意思,我初步判断是一个base64的解码函数,那我就将一段base64编码注入进程序让它解码,由调试界面可知,我的推断是正确的。
当然初期使用gdb调试会比较慢上手,但如果是直接进行伪代码代码审计的话一定会劝退你学PWN的,我鼓励大家多使用gdb调试,能节省一点时间去理解代码。当然,不可否认,大部分时间都是没错的,这里也仅仅是为了提醒一下大家。(你看到的东西不一定都是对的!!!我一直强调过很多遍,希望能在今后帮助到大家)
跟进correct函数,发现后门函数!!

跟进auth函数:

auth函数会生成解码内容的md5哈希值,并且与程序中保存的哈希值进行对比
重看main函数:
|
|
v7是base64解码后的长度,当v7 > 0xC(十进制 12)的时候,会输出“Input Error!”然后退出进程。
memcpy(&input, v5, v7);会把从 v5 开始的 v7 个字节的数据,完整地复制到 input 变量所在的内存地址处。(这里有个坑,就是如果对伪代码不熟悉的话,你会误以为这是input函数的地址,其实这个input 是一个数据存储的变量(参数)😄);要注意的是,input参数被控制在12字节(0xC)大小内,不足与我们进行注入payload,那我们就需要进行栈迁移
看回汇编代码:

在最后结束时给我们执行了一次leave汇编指令。
栈迁移到哪?
在main函数中,还有一个memcpy,把解码后的数据copy填充到了input地址处,在程序关闭PIE的情况下,input的地址已知,我们可以通过栈劫持指针的方式,把数据布置到input所在的bss段:

大致思路就是栈迁移调用/bin/sh来gethell,不过需要事先将payload进行一次base64编码加密,因为程序会将我们注入的payload进行一次base64编码解码。
payload:
|
|
main函数有leave ret == move esp ebp; pop ebp;ret;
所以esp寄存器的值要+4(pop弹栈会多出四个字节空间,需要用4字节垃圾值填充),所以payload的前四个字节填充垃圾数据。
python2的exp:
|
|
PWN_077
题目:Ez ROP or Mid ROP ?

64位关闭Canary、PIE保护。
IDA64分析:


没有后门函数,64位(考虑栈对齐),这里的ctfshow是一个有连续读取功能的函数,fgetc读取到换行就会停止,但是没有检查长度的功能,所以这里依旧有栈溢出漏洞可利用,那么大致思路就是泄露一个函数真实地址puts或者fgetc也行,调用puts@plt打印它的真实地址,接收保存后用LibcSearcher模块查找libc版本,然后算出Libc基址,再反推构造出后门函数就可以了。
以下三种解法——泄露不同函数、用本地libc打。
这里有个疑问就是,\x18(十进制是24)是啥玩意?其实这里我也很疑惑,我一开始做这题是一个个尝试的,\x00、\x22等等,我更倾向于写偶数,因为这是64位,涉及16倍数栈对齐。‘
这题官方WP也没有更好的解释,如果你有更好的理解,欢迎来交流,希望有大佬能来DDW!!
python3-exp1(泄露fgetc函数):
|
|
python3-exp2(泄露puts函数):
|
|
python2—exp3(官方WP版,用ctfshow配的的Ubuntu18.04的Libc):
|
|
三种方法各取所好,都比较简单,逻辑清晰。
ret2reg攻击
ret2reg(Return to Register)是 栈溢出漏洞利用中的一种基础 ROP 手法,核心思路是:利用程序自身的指令序列,将栈上的可控数据 “搬运” 到寄存器中,再通过寄存器传递参数、跳转执行目标代码(如system、execve)—— 本质是 “用寄存器做中间桥梁,解决栈数据无法直接作为函数参数 / 跳转目标的问题”。
它不像ret2libc(跳 libc 函数)、ret2syscall(直接调系统调用)那样有明确的 “固定流程”,而是一种 灵活的 “数据搬运技巧”,常作为复杂 ROP 链的 “前置步骤”(比如栈上有/bin/sh字符串,但函数需要rdi寄存器传参,就用ret2reg把栈中字符串地址搬到rdi)。
栈溢出后,我们能控制栈上的数据,但 x86-64/32 位程序的函数调用、系统调用有明确的 “参数传递规则”:
- 64 位程序:函数参数优先存在
rdi、rsi、rdx等寄存器(而非栈); - 32 位程序:函数参数存在栈上,但有时需要寄存器作为临时存储(如跳转地址、数据指针)。
PWN_079
题目:你需要注意某些函数,这是解题的关键!

32位程序,仅部分开启RELRO.
IDA32分析:


从标准输入中读取用户输入的数据到 input 数组,最多读取 2048 字节,将用户输入的数据传递给ctfshow 函数进行处理,ctfshow函数内有strcpy函数,作用是将input字符串复制给buf数组中,但是strcpy不会检查长度,这里就有栈溢出漏洞了。
这道题的方法很多的,我这里用ret2reg,先用gdb调试看看哪个寄存器指向栈溢出的缓冲区空间的,可以输入一些字符串进去看看,
一步步尝试,先打开gdb界面:

n步进到输入界面:

输入一些可辨识的字符:

可以看到“AAAA”字符输入进栈里,ESP、EAX、ECX寄存器指向它。
可以选择ECX或者EAX,建议不要选择ESP,毕竟ESP是随时动态变化的指针。
那么这个怎么利用呢?此时寄存器指向这里,在我们栈溢出填充缓冲区时,可以把这AAAA一并填充进去,当覆盖到ebp+4—返回地址时,覆写一个gadget,一个调用这个寄存器的gadget,加上之前这个寄存器指向这个栈,调用栈里的东西也就是AAAA+一些垃圾值,那如果把AAAA替换成shellcode的话岂不是相当于调用shellcode?
那这我们可以试试,先ROPgadget查询:
|
|

只找到了0x080484a0 : call eax,那就用这个。
exp:
|
|
这里的(0x208+4)是缓冲区长度加旧的ebp本身4字节长度,也就是0x20C,这个长度刚好覆盖到ebp+4这,刚好到返回地址的存储位置,这时再上传p32(call_eax),返回地址变成调用eax的地址,eax又指向栈,而栈里又存了shellcode,这就getshell了。
不难,就是比较绕。
PWN_080(待定)
题目:盲打 blind rop (不是忘记放附件,是本身就没附件!!!)
PWN_081
题目:ROP变种。

64位仅关闭Canary。
IDA64分析:

可以看到程序先打开动态链接库libc.so.6,然后回去其中的system函数地址,再打印出system函数的地址。
跟进ctfshow函数:

这里就是明显的栈溢出漏洞了,但这次开启了PIE地址随机化保护,不过之前已经泄露了system函数的真实地址,需要计算libc_base基址:
|
|
用LibcSearcher的话我打不通,我直接用本地打的,我知道ctfshow是用18.04出的题,这也算投机取巧吧😄。
写exp记得栈对齐(ret)!!
exp:
|
|
ret2dllresolve攻击
ret2dlresolve 是 ROP 进阶手法中的 “动态链接利用神器”,核心原理是 劫持程序的动态链接过程(借助系统的动态链接器 ld-linux.so),在 不泄露 libc 基址、不依赖已知 libc 版本 的情况下,动态解析出 system、execve 等敏感函数并执行 —— 完美突破 PIE(程序地址随机化)、ASLR(libc 地址随机化)等保护,是 CTF 中应对 “无 libc 地址泄露” 场景的终极方案之一。
常规
ret2libc有个致命缺陷:必须泄露 libc 基址(如通过puts地址),且要精准匹配 libc 版本,否则计算出的system、/bin/sh地址都是错的。而
ret2dlresolve恰好解决这些问题:它利用程序自身的 动态链接机制(程序启动时,ld会解析 libc 函数地址并填入 GOT 表),让ld主动帮我们 “找到并执行system”,全程不需要知道 libc 基址和版本。
对于这个高级ROP攻击的学习,我参考至ret2dlresolve - CTF Wiki,详细了解,我就不细说了。
在了解这个高级ROP攻击之前,我们需要理解动态链接的基本过程以及 ELF 文件中动态链接相关的结构,关于这部分知识,在我的文章的PWN-1、PWN-2有所提及,可以看看。
在 Linux 中,程序使用 _dl_runtime_resolve(link_map_obj, reloc_offset) 来对动态链接的函数进行重定位。那么如果我们可以控制相应的参数及其对应地址的内容是不是就可以控制解析的函数了呢?答案是肯定的。这也是 ret2dlresolve 攻击的核心所在。
要理解 ret2dlresolve,必须先搞懂程序的 延迟绑定(Lazy Binding)—— 这是动态链接的核心优化机制;简化流程(以 puts 为例):
|
|
ret2dlresolve 的适用场景与关键条件
适用场景:
- 程序开启 PIE + ASLR,无法泄露 libc 基址;
- 题目未提供 libc 文件,无法匹配版本;
- 程序中无可用的泄露函数(如
puts、printf被移除);- 栈溢出漏洞可控制足够大的栈空间(需布置伪造的结构体和字符串)。
关键条件:
- 程序存在栈溢出漏洞,可控制栈上数据;
- 能找到
pop rdi ; ret、pop rsi ; ret等传参 gadget;- 程序的
plt[0]地址已知(PLT表地址固定,即使开启 PIE,相对程序基址的偏移也固定);- 程序的
DT_DEBUG指针地址已知(link_map参数,固定在程序的.dynamic段中)。
PWN_082
题目:高级ROP 32 位 NO-RELRO。

32位仅开启NX保护,可以看到RELRO保护是完全关闭状态。
IDA32分析:


可以看到show函数存在栈溢出漏洞的。
先来分析下main函数,
格式化字符串
PWN_091
题目:开始格式化字符串了,先来个简单的吧。
checksec:

32位关闭PIE,部分开启RELRO;格式化字符串漏洞。
IDA32位分析:

也就是说让daniu等于6时就会执行后门函数;跟进ctfshow函数:

可以看到这里的ptintf(s)明显的存在格式化字符串漏洞。

可以看到daniu变量存储在.bss段上,我们就需要利用格式化字符串漏洞去改写daniu的值从而getshell。
测一下格式化字符串的偏移:

A的ASCII是41,也就是说格式化字符串的偏移量为7。
再来验证一下:

我们需要让daniu = 6,现在又有格式化字符串漏洞,我们就可以使用其任意地址写功能将daniu的值修改为6即可获得shell。
这里使用pwntools模块中的fmtstr模块直接进行改写:
exp:
|
|
这里的解题过程有些地方可能比较懵,这是正常的,可以去学习格式化字符串漏洞的原理:PWN-3
PWN_092
题目:可能上一题没太看懂?来看下基础吧。

64位保护全开。
IDA分析:

C语言学得好就知道这里有个格式化字符串漏洞了,我们看到printf(format, s);这个flagishere函数的流程是用scanf函数获取你输入的东西,赋值到format参数进去,然后这个s是存储flag的,如果你输入一个"%s"就构成了printf("%s",s);这就让程序吐出flag了。
exp:
|
|
PWN_093
题目:emmm,再来一道基础原理?
checksec完后IDA分析,找到了exit0函数有flag:

这题很简单,输入7就有flag,但我们重在理解这道题,可以每个菜单都查看一下,了解FMT的原理(格式化字符串的偏移、计量值等等)。
exp:
|
|
PWN_094
题目:好了,你已经学会1+1=2了,接下来继续加油吧。
checksec:

32位开启NX,部分开启RELRO。
IDA32位分析ctfshow函数:

进入一个循环,然后其中有明显的格式化字符串漏洞;函数目录又找到一个sys函数:
这也是一个格式化字符串漏洞,将printf@got 指针指向的地址改为 system@plt在printf(),就相当于system()了,然后借read函数将buf读进system里,这个buf我们可以输进/bin/sh,这就构成一个后门函数啦。
exp:
|
|
PWN_095
题目:加大了一点点难度,不过对你来说还是so easy 吧。

IDA分析,跟进ctfshow函数:

这里看很明显就是格式化字符串漏洞,而且我找不到system函数和/bin/sh之类的,只能自己构造了,那就会用到ret2libc方面的知识,需要得出基地址才能反推出后门函数的地址。
大致思路就是泄露出一个函数的地址然后得到对应的libc版本,利用这个版本去得出后门函数和被泄露函数的偏移量,利用这个被泄露函数的偏移量算出这个程序的基地址,利用这个基地址和libc的后门函数偏移量就能得出这个后门函数在这个程序的真实地址并成功实现调用。
找格式化字符串偏移量:6

我们就泄露printf函数吧,找它的真实地址,payload:
|
|
exp(python3):
|
|
PWN_096
题目:先找一下偏移。

IDA分析:

然后运行一下:

可以确定这是一个格式化字符串漏洞,我输入一个参数,printf就会同样输出来。查看偏移:

你可以发现你注入许多%p很难找到格式化字符串的偏移量。你拉去解码就会发现:

有看到ctfshow_flag的flag{}包头
IDA分析那里告诉我们flag在栈上,然后通过下面printf去显示flag,那么我们直接泄漏就行了,由于内存是小端存储的,所以我们需要倒序输出。以下是用(python2)exp:
|
|
PWN_097
题目:覆写某个值满足某条件好像就可以了。

IDA32位分析:



这里对check有一个检查,变量 check 被用作条件判断。如果 check 的值为非零(真值),则会执行特权升级的消息输出和命令提示,然后调用 flag() 函数。否则,如果 check 的值为零(假值),则会输出 “Permission denied.” 消息。也就是说check==1就可以getshell了,而且这里也有格式化字符串漏洞。
跟进check:

可以利用格式化字符串漏洞去修改任意地址。
找格式化字符串的偏移量:11

exp:
|
|
PWN_098
题目:Canary?有没有办法绕过呢?

32位开启Canary,NX,部分开启RELRO。
IDA32位分析:

_stack_check函数给了后门函数:

很明显的有栈溢出漏洞和格式化字符串漏洞,但是由于开启了Canary,直接溢出的话肯定是不行的,因此我们需要先通过格式化字符串漏洞去泄漏Canary的值。
先找canary的位置:

上gdb调试看看canary地址的格式化字符串的偏移量:15

exp:
|
|
|
|
PWN_099
题目:fmt盲打(不是忘记放附件,是本身就没附件!!!)
运行:你也要来起舞吗?

有提示:flag还在栈上,而且还知道这程序是存在格式化字符串漏洞的。
格式化字符串的偏移量为6()用处不大:

既然告诉了我们flag在栈上,那么我们直接利用格式化字符串漏洞的任意读功能去尝试读一下试试,exp:
|
|
我的pythono不是很好,这里我直接盲打出flag:



ctfshow{W0w_y0u_c@n_r3@11y_d@nce!}
PWN_100
题目:有些东西好像需要一定条件。
注:本题有参考其它大佬的WP
checksec两眼一黑:

64位保护全开。
IDA64位分析main函数:

跟进menu函数:

跟进fmt_attack函数:

存在格式化字符串漏洞,这里还有个a1值在校验fmt_attack()函数的运行次数,还要去修改a1为0,从而通过if( *a1 > 0 )的检验,所以需要多次利用格式化字符串漏洞手法。
跟进leak函数:

跟进get_flag函数:

close(1)关闭了输出流,也就是说下面的flag输出不了,如果我们能直接跳转到红框部分就能拿到flag,可以利用跳转地址的方式绕过close(1),从fd=….这里开始。
按Tab键查看汇编代码块:

来,我们看回fmt_attack函数:

这里有很多参数,但a1是作为第一个参数的,我们得知这是一个64位程序,前6位参数是靠寄存器传参的,所以说a1是归寄存器rdi管的,在执行printf时,由于程序没有运行完毕,参数没有归位,a1还是在寄存器rdi中,我们可以找出a1的地址从而通过格式化字符串漏洞进行任意改写。
看汇编语言:

这里rbp+var_48也就是rbp-0x48就是存 a1地址的地方,而且右下方的就是第二个a1的位置,然后gdb调试看看地址,给fmt_attack函数断点看看a1的变化后的地址在哪:
var_48是 IDA 对栈偏移的 “符号化命名”,[rbp+var_48]本质就是[rbp-0x48]—— 下划线仅为命名规范,不影响实际偏移计算。

可以看到起初rbp地址是0x7fffffffda38,后面rbp-0x38变成0x7fffffffda38,可以发现[rbp-0x48]对应的值是0,接着一直n步进到printf/read函数可以发现:当我们n跳过mov dword ptr [rax], 1,发现rbp-0x48位置的存储值变成1了:看图1、2的变化。


看到图2:第一个红框的是 rbp 的地址,减去0x48就是第三个红框里的地址,这里指向a1的地址,此时a1已经是1 了;那我们目的是要改写第二个a1的值变成0,看看它的格式化字符串偏移:7
其实可以换种方式理解:
a1在栈上的第二个位置,对于 printf 来说,rdi 是函数本身,第一个参数是 rsi,然后是 rdx、rcx、r8、r9,栈上第一个,栈上第二个的a1就是第七个参数。
我们只要在每次的 payload 开始写上%7$n即可将a1改为0…
你也许看不懂这里的%7$n是什么意思,我在这里给你详细解释:
%7$n本身不是一个 “具体数值”,而是一个格式化字符串指令—— 它的作用是:把 “当前已输出的字符数” 作为数值,覆盖到第7个栈参数指向的内存地址(也就是你调试中的0x7fffffffda9c,即a1指向的地址),那么:
- 你的 payload 是
b'%7$n':开头没有任何额外字符,执行到%7$n时,已输出字符数 = 0 → 所以用来覆盖的数值是 0。- 如果 payload 是
b'abc%7$n':执行到%7$n时已输出abc3 个字符 → 覆盖的数值就是 3。
exp太复杂了,我一步步来尝试:

exp:
|
|

可以看到这个我们可以多次利用这个fmt_attack函数了,可以多次利用这个格式化字符串漏洞。
接下来就是寻找后门函数的地址了

|
|

所以能得知main函数的返回地址ret = main_rbp - 0x28
找rbp地址的格式化字符串的偏移量:16

这时就可以泄露rbp的真实地址
PIE 不会影响栈地址,但会影响 “后门函数的地址计算”,这和你泄露的
main_rbp无关 ——main_rbp是栈地址,PIE 不干预,泄露后可直接用。
|
|
受ASLR保护影响,每次输出的值都是随机的,但不影响我们利用,因为这个值是在同一进程下情况下去利用的。
ret地址已经有了,可以随时利用这个格式化字符串漏洞去进行任意覆写,写上后门函数的真实地址。
现在来找后门函数的真实地址(因为开启了PIE保护,需要计算出elf基址,这样加上后门函数的elf偏移量就能推出这个后门函数的真实地址,从而进行调用getshell了):
我们现在来栈上找个地址来泄露,好计算 ELF的基地址:

看回IDA:

这里有个困惑点:就是gdb界面看到的是main+118,而这是main+76,这是混淆点:
- 反汇编中
main+76的76是十六进制(0x76); - gdb 调试界面的
main+118的118是十进制。
用fmtarg查看格式化字符串偏移,得到17 ("\%16$p"):是格式化字符串的第 17 个参数,%17$p泄露出来。
看main函数的汇编代码,对应的elf偏移地址是0x102C。
本程序的elf基址 = 真实地址 - 相对的偏移地址
|
|
到最后一步了,任意写,对ret的地址进行操作,把ret_value改为后门地址,gdb调试:
|
|

找到了地址的格式化字符串偏移。
所以payload3 = ('%'+str((elf_base+0xf56)&0xffff).encode()+b'c%10$hn').ljust(0x10,b'a') + p64(ret_address)
exp:
|
|
总结一下吧,这题比较难啊,入门PWN压力有点大…
思路就是:
- 先解除格式化字符串漏洞的利用限制(每次运行fmt_attack函数就将a1覆盖成0避开检查);
- 泄露main函数的栈低rbp真实地址(即使开PIE保护也没事,因为PIE保护是每次运行都会改变地址,而这个地址都是在同一程序进程下去利用的),从而计算得到fmt_attack函数的返回地址;
- 泄露返回地址上的值,也就是main函数的某个真实地址,再和对应的偏移地址相减算 elf 基址;
- elf基地址+偏移地址 = 后门真实地址,写入fmt_attack()函数返回地址;
- 跳转执行后门地址,得到flag。
整数安全
PWN_101
题目:先学点东西吧。
checksec看到是64位保护全开(两眼一黑…)

IDA分析:

这有一个gift的后门函数。

先看到useful函数,看到赋值v4 = 0x80000000;v5 = 0x7FFFFFFF,用户需要输入两个整数,并存储在v4和v5中,这里还看到unsigned int,返回值被强制转换为无符号数然后与2进行比较。满足后进入下一个if语句,不满足则输出错误信息。
我们要想给v4和v5输入什么值呢?这里就需要参考我们的PWN-4,看输入什么值能getshell,只要满足(v4 == 0x80000000 && v5 == 0x7FFFFFFF)就行了:
但是照着来是错的:

我知道了,因为程序中使用的 %d 格式符只接受十进制整数输入,所以我们只需输入十进制的数进去就可以了:
-2147483648 2147483647

PWN_102
题目:还是简单的知识。
checksec,还是64保护全开。
IDA64位分析:


还是个后门函数。
看到main函数,只需要v4的值满足让if判断为-1才可getshell,这里scanf输入的是一个%u无符号十进制的整数,想办法让v4==-1,这里有个细节,我们可以查看-1的十六进制(按“H”键)

按“D”键转换成十进制:

也就是说此时我们只要注入4294967295就可以getshell。

做了这两题你就可以更好的理解整数安全了,它其实考察范围并不广,但不代表不重要,在实际网络安全领域中,程序设计稍加不注意就会被绕过利用漏洞从而被攻击。
PWN_103
题目:看着好像还是不难。
checksec,还是64保护全开。
IDA分析:


gift()是一个后门函数,逻辑还是很简单,满足其条件进入gift函数即可。或者两次输入“0”即可。

PWN_104
题目:有什么是可控的?

64位程序关闭Canary与PIE,部分开启RELRO。
IDA分析:


存在栈溢出漏洞,通过read(0, buf, (unsigned int)nbytes);将我们输入的数据转为无符号整数存到buf数组中。简单来说我们只需要先利用&nbytes控制溢出长度,然后再使用buf实现溢出控制程序到后们函数即可。
exp:
|
|
PWN_105
题目:看着好像没啥问题。

32位关闭Canary 与PIE。
IDA分析:



可以看到,这里有栈溢出漏洞,需要通过if判断,让s的值复制给dest,dest溢出覆盖返回地址(返回地址改写成success函数地址——后门函数)。
在此之前,ctfshow函数有unsigned __int8 v3;:声明了一个 无符号 8 位整数变量 v3。由于是 8 位无符号整数,v3 的取值范围是 0 到 255(0x00 到 0xFF),超出了255就会发生“溢出截断”,例如赋值256就会被截断成0,赋值257就会被截断成1,我们只需要注入填充缓冲区+ebp本身4字节+覆盖的地址,再用垃圾值填补至能让v3发生截断的长度,且能被截断成范围在3u<v3<=8u的长度内。

此时payload总共长度是260,,经过v3的截断变成4,通过了if的判断。
exp:
|
|
PWN_106
题目:还是非常简单。

32位关闭Canary与PIE。
IDA32位分析:




跟进到check_passwd函数发现unsigned __int8 v3;v3范围是0 ~ 255,超过了255就会被截断且if ( v3 > 3u && v3 <= 8u )只会看截断出的那部分长度,接着s被赋值给到dest,此时s先前的被截断忽略掉的那0 ~ 255那部分在这里还是有意义的。
exp:
|
|
对于payload:
|
|
(0x14+4)栈溢出到返回地址需要填充的垃圾值大小,getshell是后门函数的地址,接着在此长度基础上补全到0x104长度大小,0x104转换为十进制就是260,在截断后变成4,正好通过if判断语句的检查。
PWN_107
题目:类型转换。
checksec:

32位关闭Canary和PIE保护。
IDA32位分析:


show函数会等待用户输入数据并将其存储进(int类型)nptr,然后代入getch函数内的参数。v2 = atoi(nptr);是将字符串 nptr 转换为整数,并赋值给变量v2,但在getch函数跟进后发现:v2代入给a2,但在getch函数内,a2是无符号类型,那么我们输入负数可以达到整数溢出的效果了。

此时v2出来后就是很大的一个整数,getch((int)nptr, v2);就能读取很大长度的值给nptr了,这就能接续后面的栈溢出了。
大致思路:
- 输入负数超出长度限制,为后续栈溢出打下基础;
- 泄露printf的真实函数,再利用
LibcSearcher模块查找到libc版本; - 在此基础上,泄露了printf地址,而且有printf的再这个libc版本下的偏移量,反推出程序的基地址,进而能反推出我们getshell所需的函数比如
system函数、/bin/sh字符串等; - 编写payload,溢出覆盖返回地址去执行
system("/bin/sh");。
exp:
|
|
PWN_108(待定)
题目:学累了吧,来玩个游戏。

64位保护全开。
IDA64分析:

在main函数中,有泄露puts真实地址,这能算出libc基址(libc_base),

不允许数组的前两个元素同时为 0xc5 和 0xf2 ,或者 0x22 和 0xf3 ,或者 0x8c 和 0xa3。
PWN_109(FMT)
题目:多种姿势。

32位关闭Canary与NX有可读可写可执行的段。
那就可以尝试注入shellcode。
IDA64分析一番、给函数记名:


可以发现这有格式化字符串漏洞。

接着这里又泄露了buf栈地址。
接收了栈地址后,推出了ret返回地址(ret+0x408+0x4,也就是ret+0x40C)应该利用payload_fmtstr函数去修改返回地址,exp1:
|
|
可是一直报错,应该是缓冲区大小错或者地址出错,上gdb调试一下:
|
|
运行跳出gdb界面后一直ni步进调试(过程较长耐心…):

|
|
这里可以看到有汇编指令了,意思是该地址对应的 汇编指令:“将栈顶指针(ESP)增加 0x10(16 字节)”,作用是栈平衡(清理函数调用时压入栈的参数)。
ni步进到lea esp, [ecx - 4]再步进一步:

可以看到下方红框的是ebp+4,和之前泄露ret的地址是一样的:

下方的图你看不太懂也没事,上面三张应该能看懂了😄。

我们知道了实际的动态缓冲区:0x41C

最终exp:
|
|
这道题让我很惊讶,坑到我了,在覆盖ret返回地址前多了一个汇编指令。这道题和整数安全毫无关系….
PWN_110
题目:溢出溢出溢出。

32位⼏乎保护全关。
IDA32分析:


v1 是 unsigned __int16(无符号 16 位),若用户输入 v1 = 0xFFFF(虽代码中用 %hd 读取为有符号 16 位,但实际存储是无符号),可能绕过 >1024 的检查(取决于编译器实现),直接读取远超 1024 字节的数据,彻底覆盖栈帧。
程序主要逻辑很简单,使⽤ scanf 函数读取⼀个短整数( %hd 表示短整数格式)并将其存储在 v1中。判断它是否⼤于1024,如果大于的话则直接退出;小于的话,就让你输⼊字符,输⼊的字节数为刚才输⼊的数字。这个函数结束之后,开始⽆限循环去执行puts打印刚才输⼊的字符。
在输⼊buf的大小时,可以控制buf的大小,输⼊-1后buf的大小是65535,这里有整数溢出,并且打印出buf的地址。
那么大致思路就是先输入-1造成整数溢出,从而能让read读取超长字符串,造成buf[1025]溢出,填充至ebp+4(0x41B+4),此时刚好填充到返回地址的储存位置,在之前已经泄露了buf地址,我们就可以返回到buf,执行buf里面的内容,我们可以在buf内写入shellcode就好了。
exp:
|
|
Bypass安全机制
各种栈的安全机制绕过的进阶手法。
PWN_111
题目:没难度。

64位仅开启NX保护。
IDA64分析:


这还是个后门函数,那么简单了,只需要栈溢出,将返回地址覆盖成这个函数的地址就可以拿到flag了。
exp:
|
|
PWN_112
题目: 满足一定条件即可。

32位保护全开,其中部分开启RELRO。
IDA32分析:

连续跟进几次函数就看到后门函数了:

也就是说需要var[13] != 17LL才能getshell,但此前已经被赋值为0了,那就可以注入垃圾数据填充var[0]~var[13],var[14]填充成17就可以了。
exp:
|
|
PWN_113(待定)
题目:理清逻辑,题目不难。

64位程序完全开启RELRO保护,开启NX保护。
IDA64分析:
PWN_114
题目:现在你应该学会了吧。

64位仅关闭Canary。
IDA64分析:




程序定义了一个信号量,当出现这个信号量(非法内存访问)的时候,会执行SIG_IGN,即当我们非法内存访问的时候,会忽略此信号,当我们非法内存访问的时候,会将我们的flag通过标准错误打印出来(fflush(stderr));
栈溢出触发后直接输出flag字符串,题非常简单,溢出后即可获得flag。

输入Yes完后直接手敲0x100个A给程序就行了,exp都省了😄。
PWN_115
题目:Bypass Canary 姿势1。

32位开启了Canary保护与NX保护,部分开启RELRO保护。
IDA32分析:


有两次read函数调用且有明显的溢出漏洞还有格式化字符串漏洞。

我们可以填充缓存区刚好200长度,正好把\x00结束符给覆盖了,printf函数就会一直打印,刚好把canary值打印出来:
|
|
exp:
|
|
PWN_116
题目:Bypass Canary 姿势2
checksec检查保护机制:

32位开启了Canary保护与NX保护,部分开启RELRO保护。
IDA32位分析:


这题还是要求我们绕过canary保护去利用栈溢出漏洞,还有一点就是存在格式化字符串漏洞。
哎这题跟上题差不多,我不想多说了,思路和参数都差不多,exp1:
|
|
这里展示第二种方法,利用FMT去泄露canary值。
返回IDA看汇编代码块:

这里就是canary函数的汇编代码:
|
|
→ Canary 位于 ebp-0xC 的位置(32 位程序,占 4 字节)。
gdb调试找格式化字符串偏移量:

给ctfshow函数断点,r启动。
接着ni步进到read函数:

输入AAAA,存进栈里。

可以看到AAAA存进栈了,接下来stack 50命令看栈:

似乎没找到canary地址,懒得一个个看了,输入指令:
|
|

偏移量是15。
那我们就给读取%15$08x到buf,接着printf又会读取这个%15$08x,恰好把canary值打印出来给我们接收😄。
exp2:
|
|
PWN_117
题目:Bypass Canary 姿势3。

64位开启了Canary保护与NX保护,部分开启RELRO保护。
IDA64分析:

flag 被读取到全局变量 buf 中。

乍一看好像没啥能溢出利用的地方。

_stack_chk_fail()函数定义如下:
|
|
溢出看看:

可以看到栈溢出报错是输出文件名——*** stack smashing detected ***: ./pwn/pwn terminated。
也就是说__libc_argv[0]指向pwn文件名的,这是一个数组,肯定是有指针指向地址的,那我能不能通过栈溢出覆盖到这,用我要的flag地址覆盖它,借助这个_stack_chk_fail()的报错打印功能去输出flag呢(・∀・(・∀・(・∀・*)?
通过gdb调试找__libc_argv[0]的位置:
第一步先输入超长字符(0x120个A)让程序栈溢出:

大概就是这样的名字,不过这个地址可不是,地址太低了。

Linux 中栈是向下生长的(从高地址→低地址分配)。比如程序启动时,内核先在高地址区域初始化
argv/envp,之后函数调用时,栈帧(局部变量、返回地址等)在更低的地址创建。
也就是说__libc_argv[0]的地址非常高。
在stack 300找到了:

前面也有很多这样的,这是怎么判断呢?
|
|
位置知道了,现在计算偏移量:

也就是说填充504个垃圾值就能到达argv[0]的数据储存位置,此时再覆盖上目标flag地址就能跳转打印这个地址上的flag了。
exp:
|
|

果真如此,这道题有意思,报错机制都能被利用👍。
PWN_118
题目:Bypass Canary 姿势4。

32位开启Canary与NX保护。
IDA查看main函数,跟进ctfshow函数:



栈溢出会有报错信息。
这题和上题一样有__stack_chk_fail,不过这里我选择利用格式字符串劫持 __stack_chk_fail的got表,不想手搓的可以用pwntools的fmtstr_payload。
exp:
|
|
PWN_119(fork+puts来泄露canary)
题目:Bypass Canary 姿势5。

32位开启Canary与NX保护,部分开启RELRO保护。

IDA查看main函数:

程序中存在fork函数,⽽且还是不断循环,跟进ctfshow函数:

这里用的方法是canary爆破,但问题是每次重启程序的canary值都是随机的,每次进程重启后的Canary是不同的,但是同⼀个进程中的Canary都是⼀样的。并且 通过 fork 函数创建的子进程的 Canary 也是相同的,因为 fork 函数会直接拷贝父进程的内存。因此我们可以考虑进行one by one 爆破。(这题就类似ctfshow-pwn入门的PWN_053)
爆破过程:
|
|
canary只需爆破后三个字节就好,每个字节范围是0~256,在填充s时,s容量是100(在栈帧中表示的缓冲区大小就是0x70-0xC),接着就是canary值校验,尝试爆破不同的值,在前面运行程序时尝试栈溢出发现会有stack smashing detected的打印,也就是说canary逐个字节校验,一旦发生错误就会出现这个报错,那也就是说溢出后继续填充字符时没有出现报错,那个字符就是canary真实值的字符(假如canary真实值是9169,循环语句刚好爆破到9且把9填充进canary上就刚好不会报错,如果不是9就立刻报错,那依次类推,不报错的值就保存赋值进一个变量内,出现报错立刻舍去这个值继续进行下一次循环)
exp(python2):
|
|
这个exp是我自己写的(之前做过,对这类canary爆破脚本有印象),不过exp不太好调试错误,如下图:

还是养成好习惯吧😄,向官方WP靠拢:
官方exp:
|
|
PWN_120(劫持TLS绕过canary)
题目:Bypass Canary 姿势6。
checksec:

64位仅关闭PIE。
IDA查看main函数:

创建了⼀个线程,跟进看⼀下:

readn是 “增强版 read”,核心是强制读满指定字节数,在 CTF 中是栈溢出 / 栈迁移的 “黄金函数”—— 正因为它能精准、完整地写入大量数据。

确实是创建了进程,有start的进程在其中。
在开启canary的情况下,且程序在创建线程的时候,会创建一个TLS(Thread Local Storage),这个TLS会存储canary的值,而TLS会保存在stack高地址的地方。那么,当我们溢出足够大的字节覆盖到TLS所在的地方,就可以控制TLS结构体,进而控制canary到我们想要的值,也就是劫持canary来进行栈溢出getshell。
刚好本题创建了一个线程
pthread_create(newthread, 0LL, start, 0LL);构造ROP攻击链来进行栈迁移到bss段执行one_gadget。
TLS 劫持只能篡改执行流,但无法提供 ROP 链的执行载体(TLS 不是栈,原栈不可控)→ 必须通过栈迁移将rsp指向可控内存,让 ROP 链有稳定的执行空间。
简单说:TLS 劫持负责 “让程序听你的话”,栈迁移负责 “给程序搭一个能执行你指令的舞台”,二者缺一不可。
栈迁移?这个手法有点忘记了,不会就回到PWN_075、PWN_076、PWN_077吧😄。
需要什么?合适的bss位置、ROP所需的gadget、libc_base(需泄露一个函数反推出Libc基址)、leave指令调用地址。

堆前置基础
前言:为防止题目难度跨度太大,135-140为演示题目阶段,你可以轻松获取flag,但是希望你能一步步去调试,而不是仅仅去拿到flag。
PWN_135
题目:如何申请堆?
这题主要是让我们知道realloc,calloc,malloc三个函数的作用自己用gdb调试就行了。
57.malloc、realloc、calloc的区别 - CodeMagicianT - 博客园
PWN_136
题目:如何释放堆?
PWN_137
题目:sbrk and brk example。
PWN_138
题目:Private anonymous mapping exampl。
PWN_139
题目:演示将flag写入堆中并输出其内容。
PWN_140
题目:多线程支持。


