ctfshow pwn record
ctfshow pwn record
cvestone- pwn入门
- Test_your_nc
- 前置基础
- 栈溢出
- 格式化字符串
- 整数安全
- Bypass安全机制
- 堆利用-前置基础
- 堆利用
- 中期测评
- 彩蛋
- 堆利用++
- 综合利用
- PWN技巧
- 其他漏洞
- LLVM-PWN
- WEB-PWN
- MIPS-PWN
- ARM-PWN
- RISCV-PWN
- Kernel-PWN
pwn入门
Test_your_nc
pwn0~pwn4
考察点:nc基操、ida基操、代码审计
pwn0-3:太简单,略 pwn4: 丢到ida,F5反编译main函数
意思就是获取输入与CTFshowPWN做比较,相等则执行execve_func函数,否则退出,看名字就像是执行系统命令的函数,双击进去看看:
那意思就是nc连接后,先输入CTFshowPWN,后面就可以直接输入系统命令了
前置基础
pwn5-pwn12:
考察点:汇编基础(常见寻址方式)
pwn5-12的题目全是关于汇编的基础知识,它们的汇编代码也都一样的 pwn5:直接运行给的32位elf文件 pwn6:
给的asm汇编文件中:
1 | ; 立即寻址方式 |
其实就相当于高级语言的:
1 | eax = 11; |
最终结果就是114514,即ctfshow{114514} pwn7:
1 | ; 寄存器寻址方式 |
ctfshow{0x36D} //注意大写 pwn8:
1 | ; 寄存器寻址方式 |
ctfshow{0x36D} //注意大写 pwn8:
1 | ; 直接寻址方式 |
但是问题在于msg的地址我们不知道, 结合给的汇编代码可以知道msg就是程序执行弹出的消息的内容:
1 | section .data |
丢到ida里看看:
所以这里的地址就是0x80490E8 (数数,57刚好在第8,注意从0开始数) pwn9:
1 | ; 寄存器间接寻址方式 |
那就丢到ida里找esi指向的地址的值: 定位到上面汇编语句的位置,双击进入到这个地址中:
可以发现其中存储的值就是636C6557,后面的h是十六进制后缀,所以flag就是ctfshow{0x636C6557} pwn10:
1 | ; 寄存器相对寻址方式 |
这里注意是把原来msg的地址加4,即0x80490E8 + 0x4 ,即0x80490EC,和上面同理定位到该地址:
这段字符串即flag pwn11:
1 | ; 基址变址寻址方式 |
计算ecx+edx_2 ,0x80490E8+0x2_2 = 0x80490EC,发现就是pwn10的flag pwn12:
1 | ; 相对基址变址寻址方式 |
计算,0x80490E8+0x8+0x2-0x6,为0x80490EC,还是pwn10的flag
pwn13~pwn16
考察点:编译链接基操
pwn13: 用gcc编译运行flag.c即出flag:
pwn14:
阅读所给的源码:
关键就是看if,如果当前目录中没有key这个文件,或者文件内容为空,则输出啥也没有,那么思路很明确了, 自己写个key文件,复制题目提示给的key:CTFshow,至于为什么匹配上这个key就能输出flag从源码中无法看出。
试着把key文件的内容改成错误的key值:
发现和前面不一样了,那很显然这个程序的逻辑就是把key文件的值用二进制表示出来进行输出罢了,说明题目设定的flag必须匹配上所给的key才会输出相对应唯一的二进制值,即flag pwn15:
1 | #.asm是汇编语言的源代码文件,windows上以.asm为主 |
和前面的不一样,汇编代码采取上面的方式编译链接
pwn16:
.s是汇编语言的源代码文件,linux上以.s为主
注意这里多次运行程序后,flag都是不一样的,但是有一部分是保持不变的,所以这部分才是真正的flag
pwn17
考察点:c代码审计、简单获取交互shell
老规矩,丢到ida里分析看看先: 观察反编译代码,还是和之前一样用switch执行选择逻辑, 定位到case 3,因为只有这里是最直观的和获取flag命令有关的,但是问题就在于这里卡着一个sleep()函数,出题人丧心病狂吗,是要让人睡整整一天半再回来看flag吗哈哈哈哈:
毕竟这里0x1BF52换算成十进制是114514秒,整整31小时!!(这里的u是Unsigned无符号型) 另找出路,发现这里就只有case 2更有利用价值了:
先获取我们的输入,但是输入被限制只能0xA字节(即9字节加1个\0结束符,这里的uLL是数据类型unsigned long long),然后给buf,再追加到dest字符串末尾,然后再调用系统执行函数。 其中: (1)strcat() 是 C 语言中的一个字符串操作函数,用于将一个字符串追加到另一个字符串的末尾; (2)read() 是一个系统调用函数,用于从文件描述符中读取数据:
1 | read(int fd, void *buf, size_t count); |
fd:表示文件描述符,指定要从哪个文件或设备读取数据。 buf:表示一个指向存储读取数据的缓冲区的指针。 count:表示要读取的最大字节数。 当 read() 函数中的文件描述符 fd 的值为 0 时,表示从标准输入读取数据,标准输入是程序默认从用户输入读取数据的地方。需要注意的是,标准输入通常是行缓冲的,意味着当用户输入一行数据并按下回车键时,输入的数据才会被传递给程序。因此,如果用户没有按下回车键,read() 函数可能会一直阻塞等待用户输入。
所以思路很简单了,我们只要能获取一个交互shell就可以执行不止一条系统命令,刚好/bin/bash和/bin/sh都满足长度需要,任选一个,如下:
pwn18
考察点:c代码审计
先看看给的文件:
64位的,丢到64位ida看看反编译后的源码:
分别进入fake()和real()看看是啥东东:
发现两个函数差不多,只是fake()是把干扰的flag追加到原真正flag后面,而real()则把干扰的flag覆盖了原真正flag。 整体程序逻辑就是获取输入,看看输入值是不是等于9,等于就执行fake(),否则执行real(),所以这里要注意的是开了靶机后,如果第一次不是输入9,输入其他的,那么真正的flag已经被覆盖了,无论nc多少次都拿不到真正的flag,如下:
而第一次输入flag才行:
所以这题看上去属于pwn题,实际不过就是c语言的代码审计
pwn19
考察点:
老规矩:
丢ida64反编译,顺便直接丢到chatGPT自动生成些注释,方便我们更好地理解代码逻辑(懒人福音!!嘿嘿)
对比pwn18的代码,会发现这里确实没有任何与输出流有关的函数(输出用户的输入),如echo,仅仅只是system()帮我们执行一下命令,但是看不到输出,然而这里又和ping和dns都没任何关系,所以web那套dnslog带外也行不通, 然而,我们通过接下来的小实验就可以举一反三解出这道题: 我们在类unix系统中,写两个这样的python程序: program1.sh:
1 |
|
program2.sh:
1 |
|
然后执行命令:
我们会发现程序1的输出通过管道符被程序2接收,作为程序2的标准输入流,最终输出程序1、2的内容,这实际上就是重定向的原理, 那我们是不是也可以试着用重定向来利用这道pwn题呢? 也就是说虽然我们输入了系统命令,无法获取到它的输出,但我们可以将执行命令后的输出重定向到标准输入中,即我们的命令小黑窗中
这里先要引入一个叫文件描述符的东西: 在Unix-like系统中,每个打开的文件都会被分配一个唯一的文件描述符。其中,0表示标准输入(stdin),1表示标准输出(stdout),2表示标准错误输出(stderr)。 然后我们可以用这样的重定向符号:>&0 重定向操作符: “>” 用于将输出重定向到文件,而 “&“用于指定一个文件描述符 知道了原理,就可以开干了: 先直接输出个系统命令:
发现IO错误,没有我们想要的输出 试着重定向: 注意由于给我们的不是可交互式的shell,所以每次都得断开nc再重连
说明利用成功了,发现我们目前位置在根目录下 ok,获取flag:
pwn20~pwn22
考察点:got和plt基础、保护机制基础(RELRO)
这里提到了got和plt,关于这个,可以参考Nu1L战队从0到1书中P339,已经解释得很通俗易懂了,以及下面的参考文章 (当然,既然都遇到了got和plt,建议不妨去把《程序员的自我修养》第4章静态链接和第7章动态链接知识也补了,如果可以再加上第2章编译和链接,这样对程序的编译链接有个比较清晰的基本认识,咱们不能为了做题而做题,题目只是巩固知识的一种手段而已,笔者比较提倡做题过程中遇到哪些不懂的,就去获取各种相关的资料,遇到新题再慢慢补充新知识,同时做好属于自己的思维导图,慢慢形成自己的知识体系,而不是说咱学个pwn,先把什么书从头到尾啃完再做题,那样说实话效率不高而且看了忘,深有体会,还是别踩这个坑了) https://blog.csdn.net/linyt/article/details/51635768 https://linyt.blog.csdn.net/article/details/51636753 https://linyt.blog.csdn.net/article/details/51637832 题目问.got表和.got.plt是否可写?看到这两个熟悉的名字就能想到《程序员的自我修养》里在讲动态链接的时候有介绍过。 运行程序提示中出现RELRO:
这是一个保护机制,主要和got与plt有关,从《ctf竞赛权威指南pwn》中找到了相关的介绍:
所以根据介绍可以得出: 当RELRO为Partial RELRO时,表示.got不可写而.got.plt可写; 当RELRO为FullRELRO时,表示.got不可写.got.plt也不可写; 当RELRO为No RELRO时,表示.got与.got.plt都可写。 首先看程序是否有保护机制一般都先用checksec来扫一下:
无RELRO保护,说明都是可写的,那flag前部分就是
1 | ctfshow{1_1_ |
然后.got和.got.plt的地址是包含在节头表信息中的,可以用readelf来查看:
往下翻,找到了:
把这两个地址再拼接到flag作为后部分就好了。
1 | 0x600f18_0x600f28} |
pwn21和pwn22解法同上。
pwn23
考察点:简单栈溢出
常规checksec检查,丢到ida反编译: main():
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
ctfshow():
1 | char *__cdecl ctfshow(char *src) |
strcpy危险函数,老演员了,这里代码审计也好分析,先本地读取/ctfshow_flag文件,如果我们执行程序并且带有1个以上命令参数,则参数传给ctfshow,参数被复制给dest,由于该参数 可控,故存在栈溢出,执行程序时参数附带好多个a,就行了:
pwn24
考察点:shellcraft模块生成简单shellcode
该程序比往常多了个RWX选项,是内存可读可写可执行:
由于ida中无法将ctfshow函数反编译,因为它有可能是在libc中,暂不考虑分析。 结合题目提示,说明可以用shellcraft模块生成shellcode来利用。
shellcraft模块是pwntools库中的一个子模块,用于生成各种不同体系结构的 Shellcode。 Shellcode 是一段以二进制形式编写的代码,用于利用软件漏洞、执行特定操作或获取系统权限。 shellcraft模块提供了一系列函数和方法,用于生成特定体系结构下的 Shellcode。
1 | # -*- coding: utf-8 -*- |
此处asm()函数用于将shellcraft生成的shellcode汇编指令转换为字节码(即机器码),且注意shellcode是没有通用的,依赖于特定处理器、操作系统等, 因此学会自己编写shellcode很重要。
pwn25
考察点:ret2libc
保护只开了NX,说明此时shellcode难利用了,丢到ida,
main():
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
ctfshow():
1 | ssize_t ctfshow() |
缓冲区buf长度132,而read限制长度0x100即256,显然此时read函数存在栈溢出。再跟进write(),发现返回的 还是write,但是无法继续反编译,说明可能来自libc中的write。
此时比较常规的思路就是泄露libc中的某个函数内存加载地址,从而计算出libc基地址,进而确定libc中的其他函数与参数等。 我们可以先用rabin2看看该程序的.plt表和.got.plt表中,调用外部即libc的函数有哪些:
这里只要是输出函数都可以用,因为我们要输出泄露的地址,比如选puts 首先肯定要先确认溢出偏移padding,经尝试这里用cyclic不太行得通,还可尝试从静态分析中看看栈结构:
然后就可以编写两次payload,第一次计算泄露,第二次进行利用,poc如下:
1 | # -*- coding: utf-8 -*- |
这里如果用write函数来泄露,则除了其他地方,注意payload1也要进行变化:
1 | payload1 = padding * b'a' + p32(write_plt) + p32(main_addr) + p32(0) + p32(write_got) + p32(4) |
而puts来泄露则是:
1 | payload1 = padding * b'a' + p32(puts_plt) + p32(main_addr) + p32(puts_got) |
很好理解,首先覆盖掉buf后,由于write/puts都是调用自libc,由动态链接的延迟绑定我们知道,所有来自调用外部的函数会在.plt
中有对应的表项,第一次是找不到外部函数真正的地址,当使用该函数时,.plt
会先去.got.plt
中寻找对应的函数表项,从而通过动态链接器再解析寻找到该外部函数真正的引用地址,第二次之后就不用这么麻烦,就会直接跳到该地址。
PS:更详细的分析可以参考本wp的
pwn45
。
pwn26-pwn28
考察点:ASLR保护基础
1 | ASLR (Address Space Layout Randomization) 是一种操作系统级别的安全保护机制,旨在增加 |
结合提示,执行如下命令即可getflag
1 | echo 0 > /proc/sys/kernel/randomize_va_space |
pwn29
考察点:ASLR和PIE保护基础
ASLR和PIE开启后,地址都会将随机化,这里值得注意的是,由于粒度问题,虽然地址都被随机化了, 但是被随机化的都仅仅是某个对象的起始地址,而在其内部还是原来的结构,也就是相对偏移是不会变化的。
1 | ctfshow{Address_Space_Layout_Randomization&&Position-Independent_Executable_1s_C0000000000l!} |
pwn30
考察点:
栈溢出
pwn35
描述:
1 | 正式开始栈溢出了,先来一个最最最最简单的吧 |
考察点:传长字符串触发段错误类溢出、signal函数
查看保护和程序运行情况: 丢到ida查看main函数:
分析:首先定义文件流指针Stream来读取ctfshow_flag文件并判断是否存在内容,然后fgets从文件中读取最多64个字符到flag数组,接着判断程序是否有接收参数,有则将第一个参数的指针传递给ctfshow函数,并将其内容输出。
查看ctfshow函数:
显然,这里就出现了栈溢出风险函数
strcpy
,首先声明大小104的dest数组,然后将用户输入的字符串复制到dest中,并最终返回dest数组的地址。这里的风险就在于并没有对用户输入长度做限制,能够>104
,导致栈溢出利用风险,因此此处我们可控。
另外,在main函数中我们还忽略了一个关键部分:
signal(11, (__sighandler_t)sigsegv_handler);
这个作用是设置一个自定义的信号处理函数,用于处理特定的信号。信号 11 代表
SIGSEGV(Segmentation Fault)
,通常表示程序试图访问未被允许的内存区域。这通常是由于程序中的错误(如数组越界、访问空指针等)导致的,这行代码将
sigsegv_handler
函数注册为处理 SIGSEGV
信号的处理器。当程序发生段错误时,操作系统会调用sigsegv_handler
函数,而不是直接终止程序。
继续查看sigsegv_handler
函数:
发现这里把flag内容输出了,显然到这里思路很明确了,让程序出现段错误异常,从而触发该自定义信号处理器函数即可,所以我们只需要输入超长字符串导致溢出,最终让ctfshow返回的
指针指向的是我们溢出覆盖后产生的无效位置就可以触发。 连接远程ssh,利用:
pwn36 ~ pwn38
考察点:cyclic计算padding、ret2win类栈溢出-后门函数有定义但不在main中、amd64_elf程序栈对齐问题处理
- pwn36:
描述:存在后门函数,如何利用?
查看保护和程序运行情况:
只是获取输入。 ida查看main函数:
除了有个ctfshow函数外没其他特别的:
功能简单,就获取输入,并且gets是典型栈溢出风险函数,不检查用户输入长度。到这里没发现任何与flag相关的函数。浏览函数窗口,发现有个
get_flag
函数,应该就是题目提示的后门函数了:
后门函数用于读取
ctfshow_flag
文件。综上,我们只要利用gets实现溢出,溢出位用后门函数地址来覆盖,就可以实现跳转到后门函数从而拿到flag,现在的问题就在于如何找到精确的padding即填充位的个数,从而再拼接后门函数地址,先用gdb动态调试的方式:
通过cyclic来确定填充位,这里匹配的目标是当前EIP所指向的字符串,因为此时由于溢出造成了段错误,关于cyclic的原理参考wiki
另外可以看一下后门函数在符号表中记录的地址:
至此,可以直接写exp了:
1 | # -*- coding: utf-8 -*- |
- pwn37:
描述:32位的 system(“/bin/sh”) 后门函数给你
查看保护和程序运行情况: ida查看main函数:
没特别的,查看ctfshow函数:
获取用户输入存放到buf数组,显然这里的0x32u转换成十进制是50,大于buf分配的长度14,因此存在溢出风险。
用cyclic计算出填充位padding的长度是22
1 | # -*- coding: utf-8 -*- |
- pwn38:
描述:64位的 system(“/bin/sh”) 后门函数给你
查看保护和程序运行情况:
ida查看各函数与上面对比几乎没差别,就buf的长度变了。
cyclic计算padding为18:
因为与i386程序架构的设计及其处理调用和栈的方式的差异,amd64程序在栈溢出程序发生段错误时,RSP(栈指针)寄存器指向当前的栈顶,也就是当前ret的控制返回地址要从RSP指向的位置来看;而i386发生段错误时,EIP(指令指针)寄存器存储了程序崩溃时的执行地址,因此检查EIP。
1 | # -*- coding: utf-8 -*- |
1 | # -*- coding: utf-8 -*- |
pwn39 ~ pwn40
考察点:ret2win类溢出-i386/amd64函数传参问题(注意别漏掉返回地址)、i386和amd64函数调用约定差异、单传参ROP
补充点:简单ROP脚本动态构造链(含寻找gadgets)的模板编写
- pwn39:
描述:32位的 system(); "/bin/sh"
查看保护与程序运行情况:
main函数: ctfshow函数:
hint函数:
这里的后门函数就叫system,并且同样返回system函数,也就是后门函数底层调用的函数,并且还可能存在
/bin/sh
参数传递给它从而返回shell,最后发现底层system和/bin/sh
都存在libc中。
同理还是先用cyclic,计算出padding为22。然后尝试用gdb寻找libc中的/bin/sh
被加载到内存后的地址:
exp如下:
1 | # -*- coding: utf-8 -*- |
这里构造的payload尤其要注意p32(0)
,因为这道题相对上面来说,传参时需要考虑函数调用栈的结构,除了给函数传递“/bin/sh“外,还需要传递函数返回地址,否则正常调用该函数时会出现问题,因此需要加p32(0)
将整数0转换为一个四字节地址,当然也可以替换成其他任意地址。
- pwn40:
描述:64位的 system(); "/bin/sh"
查看保护与程序运行情况:
ida反编译出的函数情况与i386时的差不多,不再赘述。
gdb计算出padding为18。
并且对比i386的padding会发现,只不过是比i386的填充位少了4个字节,正好对应上两个架构程序的内存地址对齐字节数的差异。所以实际上两者的padding是一样的,只不过因为需要考虑对齐问题结果就有些不同,如果不考虑栈对齐可能导致内存访问错误,甚至引发其他潜在的安全漏洞。
另外要注意两者传参时函数调用结构的差异,也就是要考虑好调用约定,与i386不同,amd64传参时参数要先由rdi、rsi、rdx、rcx。。的顺序存放,不够用时才考虑存放栈上,所以此时构造payload需要用到ROP的gadgets,即使原结构中的汇编指令缺少这几个寄存器,但我们可以通过ROP来构造。
查看程序有哪些可利用的gadgets,尤其是既包含ret指令,又仅包含上面需要的寄存器的部分(要注意顺序),因为需要用ret作为中间部分才能构成一条完整的ROP链: 可以用ropper搜索:
1 | ropper --file pwn40 | grep "ret" |
由于只需要传一个参数,我们只需要一个
pop rdi; ret
和ret
即可,如果说远程目标的gatgets不会变化,和打本地时一样的地址(即静态地址),构造exp如下:
1 | # -*- coding: utf-8 -*- |
而反之如果是动态的,搜索的过程可以单独写一个py脚本作为模板,使用时只需要传递想获取的gadgets,实现动态调用,模板如下:
rop_builder.py
:
1 | # -*- coding: utf-8 -*- |
主脚本exp2.py
:
1 | # -*- coding: utf-8 -*- |
验证,找到的gadgets也和静态时一样: 打通远程:
pwn41 ~ pwn42
考察点:system参数替换问题(能找到参数)
- pwn41:
描述:32位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代
查看保护与程序运行情况: ida反编译,main函数:
ctfshow:
hint:
虽然题目中说没有
/bin/sh
,但useful
函数中有出现sh
,printf输出前肯定得有先获取到sh
字符串,而在linux中,实际上system("sh")
也能起到与system("/bin/sh")
等效的作用,但前提是目标系统已经把环境变量设置好,也就是说依赖系统的环境变量$PATH
来查找
sh 可执行文件并执行:
先用gdb确定padding为22,exp与pwn39同理,只需做微小改变:
1 | # -*- coding: utf-8 -*- |
- pwn42:
描述:64位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代
查看保护与程序运行情况:
所有关键函数都与pwn41一样,只不过由于函数调用不同,导致payload构造时有差异。
先用gdb确定padding为18,exp用pwn40的,稍加修改,对于引入的ROP动态链构造模板,pwn40已有,不再赘述:
1 | # -*- coding: utf-8 -*- |
pwn43 ~ pwn44
考察点:ret2win栈溢出无sh类参数-向可写数据段内自行写入
补充点:在ida中计算padding(仅参考)
- pwn43:
描述:32位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法
查看保护与程序运行情况: 其他函数都几乎变化不大,除了:
ctfshow函数:
这里改成了用gets获取输入,gets不会判断输入长度,存在无限读,所以存在溢出。
hint函数:
在其中找到了system函数,但没有sh参数。
并且gdb用原来的cyclic计算padding时失效了,不管输入多少个,都没有导致程序段错误,寄存器中也无法找到我们输入的其中一部分值,而是:
其实并不是失效,实际上是因为我们的输入长度不够大,所以可以尝试增加长度,如:
所以padding是112。
实际上也可以直接通过ida静态分析来判断,但注意仅作为参考数据!因为实际的栈布局和数据顺序在运行时与静态时可能会有所不同!
该函数中,由于调用gets函数时,栈的结构从上往下(地址递增)依次是:局部变量s(104字节)、旧的(调用gets函数前的函数的)ebp(4字节指针)、返回地址(4字节),原伪代码处的注释表明该局部变量s的相对位置。所以当溢出时要覆盖到返回地址,则从局部变量s开始算,往下偏移6C溢出到ebp,再继续溢出0x4到返回地址,即padding
=
0x6C+0x4
。
而此时假如再用和pwn39一样的exp,就无法再找到“/bin/sh“了,即使是sh
也没有:
这也就意味着目标程序和libc中都没有sh类参数,我们无法从任何地方找到它。但是这并不代表着无法实现
system("/bin/sh")
了,实际上我们还可以自己写入/bin/sh
,但问题就在于写入到哪里?
当我们在gdb中用vmmap
查看内存分布时,发现存在一个DATA数据段是存在
写入权限的,显然我们就可以通过可控输入(利用gets函数)将/bin/sh
写入在该数据段内。
为了确保写入时不会出问题,有必要先用ida看看该数据段内都有哪些内容,快捷键
ctrl+s
:
最后,我们发现了.bss段中有一个未初始化的变量buf2可以利用:
显然我们可以将
/bin/sh
赋值给该变量,记下该地址0x804B060
.bss 段是一个用于存储未初始化的全局变量和静态变量的区域。
而又因为i386函数传参调用约定表明,传参时参数存储在栈上,所以构造exp如下:
1 | # -*- coding: utf-8 -*- |
注意这里的payload顺序,首先system作为gets的返回地址,传参时,第一个p32(bin_sh)
既作为gets的参数又作为system的返回地址,虽然是无效地址但必须指定;
第二个则是作为system的参数,从而getshell。
- pwn44:
描述:64位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法
查看保护与程序运行情况: ida中的函数都和i386时差不多,不再赘述。
gdb计算padding为18,ida中计算后也一样:
看看内存分布:
查看bss段同样发现了未初始化变量buf2可以利用:
地址是
0x602080
。
因此可以构造exp了:
1 | # -*- coding: utf-8 -*- |
ROP动态链构造模板见pwn39~pwn40
pwn45 ~
考察点:ret2win时无system也无sh-基本ret2libc利用流程、GOT表PLT表以及延迟绑定机制的深入理解、利用标准输入(缓冲区)获取泄露信息的意义
- pwn45:
描述:32位 无 system 无 "/bin/sh"
查看保护与程序运行情况: main:
ctfshow:
read存在溢出,初步计算padding =
0x6b+0x4
除此外并未找到system和sh参数。
gdb确认padding,就是111:
现在的问题就在于何处找system和sh,当目标程序内部没有时,实际上我们还可以尝试去libc中寻找。
因为这是动态链接的程序,当程序运行时,libc等其他链接项中的函数变量等可通过GOT、PLT表找到位置并使用,也就是ret2libc的玩法。
但是我们需要先知道目标用到的libc版本。由于各个函数在同一个libc中的相对偏移量几乎是固定的,所以我们可以通过利用GOT表泄露出libc中某个函数(通常是输出类函数)的地址,根据上述原理找到libc版本,进而通过该函数在libc中的偏移和实际运行时加载在内存中的偏移关系,推得libc基地址,最终计算出我们需要的函数、变量所在偏移(如system函数、
/bin/sh
)。
GOT表能泄露出libc中某个函数的地址,是由于 libc的延迟绑定机制,函数调用不会在程序启动时立即解析,而是在首次调用该函数时进行解析,即只有在第一次使用时才会去寻找函数地址,第二次调用开始就直接用该地址,这和web中的“懒加载”有些异曲同工之妙。 具体解析时的底层逻辑:当程序首次调用动态库中的函数时,控制流会跳转到 PLT 中的相关条目。接着PLT 中的条目执行一个跳转到动态链接器
ld.so
,动态链接器会解析该函数的实际地址并更新 GOT。之后的调用将直接跳转到实际地址,而无需再次解析。
PLT(Procedure Linkage Table):一个用于存储函数调用的跳转地址的表。初始时,PLT 中的函数调用指向一个特殊的处理函数。 GOT(Global Offset Table):存储动态链接库函数的实际地址。初始时,GOT 中的地址未被填充。
PS:学习阶段,建议动调一遍感受调用函数前后,拿到的函数地址的变化,然后试着寻找GOT、PLT。
既然要泄露就得有输出,要找与输出相关的函数,正好有write,先泄露再利用,因此构造payload时要分两次(即溢出两次)。
所以第一步要调用write,将其GOT表中的值(非实际地址)作为参数,从而让其输出延迟绑定执行后实际加载到内存中的write函数地址,这一步相当于我们直接走快车道手动还原一遍write的完整调用过程了,注意调用时要遵循write函数的原型:
ssize_t write(int fd,const void*buf,size_t count);
fd:是文件描述符(0标准输入、1标准输出、2标准错误);
buf:通常是一个字符串,需要写入的字符串; count:是每次写入的字节数
因此构造第一次payload如下:
1 | # -*- coding: utf-8 -*- |
调用write后返回main的原因在于,便于第二次构造payload时能够再溢出一次完成利用。
payload都好构造,但尤其需要注意的点就是尝试接收泄露的地址时的字符串处理(截取)问题,刚开始可以尝试用leak_write = u32(io.recv()[0:4])
是否能够成功从我们的标准输入中接收到泄露地址,若不能则必须通过gdb动调查看发送payload时都发生了什么。由于exp中开启了DEBUG模式,可以直接方便地查看,如下:
在这个阶段,要特别注意缓冲区的概念,此刻缓冲区就变得具象化了,另外实际上这些待接收数据是优先通过write被写入到了
标准输入缓冲区
中!而我们尝试读取接收到的数据也是优先从标准输入缓冲区
中读取!
这里有个非常关键的细节!为什么上面要取
fd=0
而不是fd=1
呢?第一眼看上去似乎该涉及思路与write的输出功能有些矛盾,根据write函数的常规用法,一般通过fd=1
标准输出将内容输出到终端或其他设备,如何选择除了基于目标程序的输入处理方式,关键还要看我们攻击者的需求!因为通过将泄露的数据写入到标准输入(缓冲区)中,可以确保程序后续能够读取到这些数据(比如后续搜索libc版本要用到该地址)。 想象一下假设把该地址写入到标准输出,很容易就和其他正常输出内容混合在一块,数量大的话根本难以辨认(即使在输出缓冲区中也不保险,缓冲区满后也会输出到终端被混合),更重要的是,标准输出的数据通常是不可直接被程序内部读取的!换句话说,程序无法从标准输出中直接获取之前写入的数据,除非这些数据被重定向到文件或通过管道传递。所以将泄露信息写入到标准输入是非常聪明的做法。
为什么会出现这个报错?实际上也给出提示了,表明解包需要4个字节大小的缓冲区。也就是说在调用
write
函数后,标准输入的缓冲区中没有足够的数据可供读取(例如,因缓冲机制没有刷新),那么
recv()
可能会返回少于 4
字节的数据,甚至可能返回空数据。所以此时仅仅用recv()
来接收远远不够;
显然需要再多接收一些,但是接收要有个限度,需要有个标准,回顾需求,我们只需要泄露的地址,而由于libc中的地址一般是以0xf7
开头(通过vmmap就能看出),这是重要特征且干扰较小(因为在该局部范围内除了该泄露地址外的其他数据几乎不会有这个特征),因此可作为接收的终点标志,因此我们就可以改用recvuntil()
;
recvuntil()
会在接收到特定的结束标志(该处即/xf7
)之前,持续从标准输入的缓冲区中读取数据,这意味着它会尽可能多地捕获输出,直到遇到该标志,这样就能够确保捕获到足够的字节,包括泄露的完整write函数实际地址。
由于此时接收到的数据长度很可能大于或等于 4
字节,因此可以安全地使用u32()
来解包最后的4个字节。
检验从标准输入的缓冲区中是否读取到的字节数不够,从下面给出的简单代码片段也能得到验证:
1 | 。。。 |
用
recvuntil()
也确实接收到了泄露地址:
0xf7e6b6f0
(注意该地址在实际测试中发现每次运行后并不是固定的,也就是程序运行后分配给write函数的实际内存地址)
接着就是搜索libc地址,记得开头引入库:
from LibcSearcher import *
1 | # 根据泄露地址搜索libc版本 |
注意这里出现了多个可能的libc结果,但是选项不多,可以挨个尝试,以最终是否能够打通来做验证(注意这题很容易出现本地打不通一直卡着而远程却能打通的情况,因为这道题对libc版本要求较为苛刻,然后环境变量默认指向的是本地默认的libc库),
另外发现返回的libc对象中,不仅打印出对应libc版本号,还有几个经常使用的符号(函数、字符串)在libc中的相对偏移量。
既然知道了libc版本,就可以继续计算出libc基地址以及更多需要的偏移:
1 | # 计算libc基地址与需要的偏移 |
验证成功。
最后一步,利用上面计算获取到的system和sh参数再溢出一次即可:
1 | pld2 = padding * b'a' + p32(system) + p32(main) + p32(binsh) |
补充:
另外,通过上述gdb调试过程中,当我们首次调用write前后,观察gdb的输出,能够更加具象化地体会到GOT、PLT之间的配合,以及各个输出代表的含义,许多地方都能一一对应上:
符号表获取到的:
首次调用write时:
注意这里找到
got.plt
后,不是直接跳转到实际的write地址处,而是要先经过_dl_runtime_resolve
_dl_runtime_resolve
是动态链接器中的一个关键函数, 当程序调用某个动态链接的函数时,运行时系统会通过它查找该符号的地址,解析出符号地址后, 动态链接器会在全局偏移表中更新相应的条目,以便后续调用能够直接使用这个地址,而不需要再次解析, 所以它在延迟绑定中起到非常关键的作用。
然后调试过程中尝试在内存中将返回地址修改为write函数地址,也就是write函数第一次调用完再重新调用一次,看看此时发生的变化:
ret前修改内存值为write的调用位置0x80483b0
:
再ni一步,看看是否成功修改了执行流,发现修改成功,并且第二次调用write时不再有
_dl_runtime_resolve
解析符号地址的步骤,印证了上述的分析:
- pwn46:
描述:64位 无 system 无 "/bin/sh"
查看保护与程序运行情况:
函数几乎与pwn45的没多大差别,计算出padding为120。整体利用思路也一样,无非就是amd64传参时需要借助gadgets来实现ROP以及传参时部分构造顺序的不同罢了,构造时举一反三即可。
首先用ropper大致摸排一下可用的gadgets大致情况:
发现第三个参数没办法用
rdx
来传递,但是存在r15,因此构造exp如下:
1 | # -*- coding: utf-8 -*- |
ROP构造链模板见pwn39~pwn40。
这里要注意amd64时,libc的地址开头变成了\x7f
,更重要的是,截取地址时一般取6位,ljust是用于自动满足栈对齐,然后指定用0x00
来填充。
pwn47
考察点:基本ret2libc、利用recvuntil动态接收变化的函数地址并用eval转地址为整数
描述:ez ret2libc
查看保护与程序运行状态: gdb计算出padding为160。 ida的main函数:
ctfshow函数:
main的useful看起来有些可疑,跟进看看:
发现给的就是
/bin/sh
的地址,即最初运行时对应的gift
:0x804b028
另外既然已经直接给出了一些常用函数的地址,就无需再像上面的题目一样利用延迟绑定来泄露。
直接构造exp:
1 | # -*- coding: utf-8 -*- |
然而并没有搜索到libc版本:
然而多次运行发现每次生成的这些函数地址都是不一样的。因此参考了官方的wp,给出了很好的exp方案:
只需要将puts和gift(binsh)获取方式由静态改为动态获取即可:
1 | io.recvuntil("puts: ") |
第一次的recvuntil主要用于跳过前面没用的提示语句并等待puts生成的地址;第二次则是继续接收生成的地址,知道遇到换行符,drop = True
表示在返回结果时去掉结束字符串(即不包括换行符),eval则用于执行,即计算字符串中的
Python
表达式(这里是十六进制地址的字符串),并返回字符串转整数的计算结果,这里用eval并不是空穴来风,因为传输的地址不能为字符串而应该是整数:
拿到flag:
pwn48
考察点:基本ret2libc
描述:没有write了,试试用puts吧,更简单了呢
查看保护与程序运行情况: 函数情况:
显然就是之前的程序,gdb计算出padding为111。
根据提示,把原来泄露目标由write改为puts即可。 exp:
1 | # -*- coding: utf-8 -*- |
pwn49(待解决残留问题)
考察点:静态编译程序ROP、利用mprotect修改内存属性写入shellcode(ret2BSS/ret2DATA)、内存页的理解
描述:静态编译?或许你可以找找mprotect函数
查看保护与程序运行情况:
发现此时保护中发生了些变化,存在Canary(但参考官方wp后,实际上是由于checksec版本较低导致的误报)。另外,观察相对较大的文件size,确实像是静态编译过的程序,通过vmmap查看,确实是静态的,因为没有任何的libc和其他库:
gdb计算padding为22。
查看ida,发现静态编译后的程序多了一大堆复杂名字的函数,有种除了main函数外其他都懒得看的感觉。
还是和前面的题类似,唯一不同的是,现在是静态编译,无法再通过ret2libc的方法来获取system函数和sh参数。
只能根据提示看看所谓的
mprotect
函数具体是什么内容:
总之就是能够实现修改某部分内存区域的权限,因此可以尝试在该区域写入shellcode,从而实现控制程序执行。(虽然程序开启了NX保护,但如果利用该函数在栈外的内存空间进行修改,此时就相当于绕过了NX)
其中,prot(即上面的a3)可以取以下几个值,并且可以用
|
将几个属性合起来使用:
1)PROT_READ:表示内存段内的内容可写;
2)PROT_WRITE:表示内存段内的内容可读;
3)PROT_EXEC:表示内存段中的内容可执行;
4)PROT_NONE:表示内存段中的内容根本没法访问。 5) prot=7
是可读可写可执行
但问题在于,具体该修改哪个内存区域?可以随便修改吗?
参考了官方wp,答案是不能,因为mprotect修改内存属性时有条件:
指定的内存区间必须包含整个内存页 (4K),起始地址 start 必须是一个内存页的起始地址,并且区间长度 len 必须是页大小的整数倍
在现代操作系统中,内存管理通常采用分页机制。每个进程的虚拟地址空间被划分为固定大小的页面,通常是 4KB(包括amd64和i386默认都是,但具体实现和支持的页面大小可能因操作系统和硬件配置而有所不同,amd64支持更大的,而i386有限),每个页都有一个对应的页表项,记录其物理地址和访问权限。内存页的引入,好处在于使操作系统可以更高效地管理内存,因为每个页面的大小是固定的,另外每个页面可以有不同的访问权限,单独管理,从而提高安全性。理解时,可以将整个虚拟内存空间类比成一本书,只不过每一页都只能是固定的页面尺寸,比如只能是A4。
起始地址必须是对齐的内存页起始地址,这是因为操作系统在管理内存时是以页为单位的。如果起始地址不对齐,会导致内存管理复杂化。假设内存页大小为 4KB(十进制4096),那么有效的起始地址应该是 0x0000、0x1000、0x2000 等(
0x1000H=4096D
,0x2000H=(4096*2)D
,也就是刚好能被每页的大小整除,所以这叫起始地址对齐)。而如果起始地址是 0x0100,那么第一页的0x0100前部分将被忽略,这会导致内存管理的不一致和浪费。区间长度必须是页大小的整数倍,这样可以确保整个区间覆盖完整的内存页,否则,最后一页的某一部分将被忽略或处理不当。总之,如果不满足这两个条件就破坏了内存页管理的完整性。
不同操作系统可以通过下面的方式来确认当前系统设置的页大小。 Linux: 默认页面大小为4KB,可通过
getconf PAGE_SIZE
命令验证。 支持大页需挂载hugetlbfs文件系统并配置内核参数。 Windows: 默认页面大小为4KB,可通过GetSystemInfo()
的dwPageSize
字段查询。 大页需显式调用API并满足系统要求。
所以接下来思路很明确了,在栈外的内存空间找一个满足mprotect利用条件的内存空间,记下起始地址;填充完padding后,先跳转到mprotect函数,寻找一个同时包含3个pop+末尾1个ret的gadget,传递mprotect的参数,接着跳转到read函数,同理用同一个gadget为read传参,因为我们需要用read读取标准输入中的shellcode到该可控属性的内存空间从而执行。
寻找满足条件的内存空间: ida中用
ctrl+s
查看各个段信息,只要看各个起始地址的后四位是否为(0x1000即4KB)的整数倍即可,虽然第一个也满足条件,但它是代码段,显然是在栈中,而.got.plt
则是在程序的全局数据段不属于栈内,所以需要的起始地址为0x80DA000
寻找满足条件的gadget:
0x080a019b
- 构造最终exp:
1 | # -*- coding: utf-8 -*- |
注意对于第二个pld的最后一个p32(mem_start)
不能漏,否则无法利用成功。
至于为什么要加这个,原因暂时还没研究出来,待解决。 (水委师傅给了很深刻的解释,待整理)
pwn50(待完善方法二)
考察点:动态链接程序利用mprotect修改内存属性写入shellcode(ret2BSS/ret2DATA)
描述:好像哪里不一样了 远程libc环境 Ubuntu 18
查看保护与程序运行情况: ida各函数:
gets读取不限长度,所以存在溢出。本地未找到system和sh。gdb计算padding为40。
依旧先尝试ret2libc:
1 | # -*- coding: utf-8 -*- |
注意由于是amd64,要注意栈对齐,因此这里比起pwn45多加了个ret。ROP动态链构造模板参考pwn40。
为了学习深入些,学习下官方wp中的方法,发现原来动态链接程序也是可以用mprotect
来利用,利用思路与pwn49一样,只不过前面依旧还是先泄露libc,把后边通过system和sh替换成mprotect
的修改属性并注入shellcode方式。
先查看是否有可作为修改内存属性的内存起始地址(除.text
外,因为受NX影响),可以通过ida的ctrl+s
或objdump:
显然只有
.got.plt
头符合mprotect
的利用条件(原理详解参考pwn49)
然后寻找可间接传递存储三个参数的gadgets:
接着可以构造exp,但是在尝试过程中发现实际上这样打不通,因为如果仅仅只是通过本地elf中的gadgets来实现利用,是无法成功的,
具体原因:
官方拿到的gadgets:
其中libc可以从在线libc数据库网站上直接下载:
但显然,这是由于之前方法一的ret2libc中经实践知道该libc版本是能打通的,所以选择它,而实际利用时,我们只能逐个尝试(注意要符合架构)
或者其他途径了。
接着,我们需要从该libc中指定需要的gadgets而不是从本地elf。
(待完善,发现即使用官方wp也打不通,原因暂时未知)
pwn51(待)
考察点:c++代码分析、
描述:I'm IronMan
查看保护与程序运行情况: ida中的函数:
首先分别进入这几个函数根据功能修改其函数名,方便分析:
(快捷键
N
)
跟进到ctfshow函数中,发现和以往分析的目标都不太一样,经了解这是c++写的程序,没有c++基础的师傅看到这里估计和我一样有些头疼,只好借助ai辅助分析下:
1 | int ctfshow() |
首先关注熟悉的部分,栈溢出常客read函数从标准输入读取32字节写入到同样是32字节的s中,可能存在栈溢出,因为别忽略了末尾的\x00
,接着就是另一位常客strcpy,v4是一个字符串指针,指向的值长度不确定,也有存在溢出的可能性,接下来就是一大堆陌生的std::
开头的代码,只需要知道整体做了什么就行,这些来自于C++
标准库中的 std::string
类,用于处理字符串,其中operator=:
用于将后面的字符串赋值给前面的字符串,operator+=:
用于将后面的字符串追加到前面的字符串后。整体来看(emmmmmm….暂时放弃了,官方wp说是将字符“I“替换成了“IronMan“,最后在strcpy的时候发生了溢出,但是苦于在代码中暂时分析不出来,先跳过了。。。)
pwn52
考察点:基本的传参ret2win
描述:迎面走来的flag让我如此蠢蠢欲动
查看保护与程序运行情况: ida中函数:
显然gets存在栈溢出,不限输入长度。
关注if,满足两个赋值条件后才能够输出文件流中存储的flag内容。
gdb计算出padding为112。所以思路很清晰了,通过gets的溢出,控制返回到flag函数,同时传递满足条件的参数,就能够拿到flag值。
1 | # -*- coding: utf-8 -*- |
发现没什么新鲜的考点,就是最基本的传参ret2win,无非加了点条件。
pwn53
考察点:canary原理的理解、canary爆破基本流程、-1绕过无符号型输入限制、strcmp逐字节比较
描述:再多一眼看一眼就会爆炸
查看保护与程序运行情况:
看来需要先写入一个canary在根目录,根据提示,这题考察canary原理。写入后,重新检查:
还是没有检测出canary,但是可以正常运行了。再次探针该功能:
所以第一部分输入指定buffer长度,第二部分指定要向buffer中写入的值,超过长度部分的会舍弃掉。
ida中函数:
从刚刚写入的canary文件流中读取4个字节的canary(一段随机字符串)写入到
global_canary
中。
出现了挺多变量,需要知道各自的用途,因为对于关键逻辑的理解都重要,while循环从标准输入中逐个读取字符串并存储,每次读取1字节,v2用于存储第一次输入中的字符串(表示写入buffer的字节数),v5控制着能输入的字符串最大长度,
if (v2[v5] == 10):
主要检查从输入中读取的字符是否为换行符(ASCII值10),如果是,则执行break退出循环;第二次输入前,sscanf从v2中读取字符串并解析成整数后存储到nbytes,接着第二个read从标准输入中读取nbytes个字符,写入到buf,而由于nbytes可控,如果值大于buf的长度,显然就会存在缓冲区溢出。最后的if则最为关键,用于栈保护检查,检查canary是否发生了更改,具体是比较当前存储的canary与原始设置的全局canary前四个字节,如果比较结果不为0(相减后不等于0,即两者不同),则说明可能是栈溢出将其覆盖了,则此时强制退出程序,这也就是canary保护的原理。不过要注意的是,这题只是模拟canary保护便于理解原理【参考相应wiki】,而并不是按照canary原本的设置机制,因此checksec没有检测出来。
所以此时绕过canary的思路也很清晰,也就是在溢出前我们需要想办法泄露(爆破)出设置的原始canary值,接着在栈溢出后,在合适的比较canary时的位置填充上该原始值,从而让其检测成功实现bypass,接着最终将控制流跳转到flag函数,即可拿到flag。但需要注意的是,通常情况下爆破canary的可能性较小,因为爆破意味着程序会出现大量的崩溃,而程序崩溃后canary值也会重新生成,值是动态的(会与TLS进行联动),且除去低位固定的起到截断作用的\x00
,剩余3个字节的爆破还有0x100^3
种情况(每个字节可选数值从0x00~0xFF,即256=0x100,但实际上由于canary的生成规则会小于这个值),而本题模拟的canary是静态的(与TLS无关),所以存在爆破的可能性。
从gdb中也能看出Canary是一个固定值:
但由于此处是本地自己随意写入到文件的,而不知道远程的是什么,要打通远程就得爆破。
至于为什么可以逐个字节爆破,是由于本题的canary检测逻辑就是逐字节读取的同时逐字节检测的,我们可以很容易从gdb的反汇编代码中看出,由ida结果可知校验逻辑在ctfshow函数的memcmp(memory
compare),那么就在gdb调试到其内部:
发现底层比较是
movzx
和cmp
,前者从memcmp
原型的str1
和str2
中分别加载一个字节,然后用cmp
进行比较,即实现了逐字节比较,比较完后接着又跳转回read继续读取下一个字节,然后继续比较,如此往复。
1 | int memcmp(const void *str1, const void *str2, size_t n) |
- str1 – 指向内存块的指针。
- str2 – 指向内存块的指针。
- n – 要被比较的字节数。
返回值: < 0
,则表示 str1 小于 str2。
> 0
,则表示 str1 大于 str2。 = 0
,则表示
str1 等于 str2。
另外经测试,当仅在payload中每次提供单个字节,也能佐证上述结论:
1 | # -*- coding: utf-8 -*- |
其中,本地canary文件如下: 对应的hex就是
0x6a6c666a
。
执行脚本:
而如果将payload中的canary单字节改成其他的如
0x6d
:
报错canary值错误,显然通过对比就能说明是逐字节检测。
注意:正常来说即使canary值检测到错误,
stdout
或stderr
中不会输出该提示语。本题有输出是因为仅模拟canary的检测,故意设置的。
其中第一次输入用-1
来绕过长度限制: 【学习自水委师傅的wp】
另外注意,0x20
即padding并不再像往常那样直接通过cyclic计算,因为此时有了canary,当多余的输入覆盖到canary位置,就会直接报错而不是返回段错误信号,因此程序并未中断也就计算不出来cyclic的值:
但本题可以通过是否返回报错提示语从而间接判断输入多少不会覆盖到canary。
同理,还可以接着附加第二个字节,同样也证明是逐字节检测:
把canary改成
0x6a
:
推测如果canary校验的底层逻辑是用如
xor
指令将对象视为整体来比较的,或许就没有办法逐字节爆破,因为不管前几个字节与canary的对应上,整体始终都是错的,每次的响应都是检测到canary不匹配,没有爆破的判断依据。
综上证明,能够爆破canary,而爆破需要有回显依据来判断当前字节是否爆破成功,即前面两种不同提示语,逐字节爆破每次都只需要考虑0-255
,显然比完整爆破更有效率,故编写payload如下,以下学习自官方wp并稍加修改:
1 | # -*- coding: utf-8 -*- |
其中爆破canary的整体逻辑几乎是通用的,根据题目设置做灵活调整即可;另外在接收响应中完整提示语之前,加一个io.recv(1)
的作用相当于sleep()
,这样做是确保打远程时不会因为发送太快而崩掉:
在爆破完canary后,劫持程序执行流到flag函数即可,注意这里的
p32(0) * 4
,即我们爆破的canary到ret返回地址的偏移量,这可以从ida的栈分布中看出:
pwn54
考察点:
- 描述:
再近一点靠近点快被融化
查看保护与程序运行情况:
显然,程序需要读取来自文件中正确的密码才能获取flag。 ida查看各函数:
显然现在问题关键就在于如何找到
password.txt
中的内容。到这里不知道这题要考察什么,故学习官方wp:
由于main函数中,输入buffer对应的变量v5
与读取flag文件内容赋予的变量s
在同一个栈结构中,v5
是可以覆盖到s
的:
两者间隔的偏移量是
0x160-0x60=0x100
,正好等于v5设定的长度256
,所以可以看作“padding“,将v5填满,后续中的put函数将输出v5的值,而存在风险的就是put函数,因为put函数可以无限输出,直到遇到换行符才停止,这就意味着,如果并非输入正常值而是0x100个垃圾数据(长度>=256
),那么最后一个换行符就不在if判断的区间范围内,没法读入/n
所以无法替换为\x00
从而结束,导致会继续输出紧跟其栈分布后的s
存储的值,即泄露密码值。
首先尝试本地打通,以验证上述结论,取cyclic(0x100)发送:
观察到该字符串后就是本地设置的密码,所以当我们接收时需要分两次,以字符串作为分隔符,然后输出其后的密码内容。所以,exp如下:
1 | from pwn import * |
有两种接收的方式,第一种是利用split
对接收到的全部数据根据分隔符进行分割,输出后面的部分;第二种则是读取分隔符后的内容,长度可以随意取更大的值。
泄露出密码后,nc连接远程(或写在前面的exp中新建立连接),此时输入正确密码和任意用户名即可得flag:
pwn55
考察点:
1 | from pwn import * |
pwn56 ~ pwn57
考察点:认识32、64位shellcode
pwn56:
- 描述:
先了解一下简单的32位shellcode吧
这题直接运行就可以拿到shell,这不重要,重要的是理解shellcode都做了什么。
首先查看保护: NX关闭,说明栈可执行shellcode。
ida查看函数:
代码逻辑一目了然。重点是看懂这里的反汇编代码:
1 | public start |
参考官方wp对其逐个分析学习下:
刚开始的连续三个push
指令,是为了先将需要传递给sys_execve
函数的参数存入栈中等待传递,有意思的地方在于实际上这三个push拼接后只作为sys_execve
的其中一个参数/bin/sh
而不是所有,如果直接传/bin/sh
不合适,因为这不符合对齐的原则:7%4≠0
,这里巧妙地将h
独立开来,保证该参数能够完整传递。
【长图警告 Σ( ° △
°|||)︴<点我查看>】
接着,将当前已经拼接完整的/bin///sh
参数地址存入ebx,然后通过两个xor将剩余两个参数即命令行参数和环境变量设置为NULL,然后
1 | push 0xB |
将0xB(11,是sys_execve的系统调用号)先压栈再弹栈存入eax,最后,int 0x80
中0x80
是特殊的中断号,会触发操作系统内核中的中断处理程序,通常用于用户态程序发起系统调用,此时控制权会转移到内核态,接收传递来的系统调用号和其他参数执行相应系统调用函数。
在现代操作系统中,通常使用更高效的方法(如 syscall 指令)来发起系统调用,但
int 0x80
仍然是理解和学习系统调用机制的重要部分。
综上可以体会到,一个小小的shellcode设计如此精妙且高效。
pwn57:
- 描述:
先了解一下简单的64位shellcode吧
amd64的shellcode和i386的整体过程差不多。刚开始将 rax
寄存器的值(通常用于存放函数返回值)压入栈中,目的是保留 rax
的值,以便后续使用;传递/bin/sh
时由于一次能传8字节,补一个/
就能满足对齐要求,同样也是先存入寄存器再压入栈中,然后根据调用约定顺序相互配合传给对应的寄存器。最终,同样将系统调用号0x59
传递从而触发syscall
。
pwn58 ~ pwn59
考察点:简单ret2shellcode、shellcraft模块的基本使用、函数传参时的对齐问题
pwn58:
- 描述:
32位 无限制
查看程序保护与执行情况:
触发了段错误并且根据提示是栈溢出然后ret2shellcode。 查看ida:
发现main函数无法反编译,其他函数可以,因此main函数只能分析反汇编代码:
根据报错提示定位到失败位置:
ctfshow函数中用了不安全的gets,显然漏洞点最有可能在这了:
查看其反汇编代码: 在调用ctfshow前后,发现多次出现了:
1 | lea eax, [ebp+s] |
刚开始它的作用是将传递给ctfshow(更确切来说是gets)的参数s从[ebp+s]取出压入栈,当ctfshow返回后,最终却直接
call eax
,也就是说获取到的输入又以一种看似循环的方式由存入[ebp+s]到仍旧存储在[ebp+s]中,并且还可以当函数来调用,同时[ebp+s]是在栈中,这就给shellcode的利用创造天然条件。因此,可用pwntools自带模块生成shellcode直接作为输入,从而调用执行:
1 | from pwn import * |
这题的关键就在于能读懂汇编代码,找到关键可疑位置处,能联想到和shellcode的利用条件有所关联。
pwn59:
原理和pwn58一样,只不过架构变了而已,且注意将shellcode生成的架构指定修改成amd64。
pwn60
考察点:简单ret2shellcode
- 描述:
入门难度shellcode
查看程序的保护与执行情况: 能够触发段错误,存在栈溢出。
查看ida:
显然漏洞点是
gets()
,无限制读取输入,然后通过strncpy()
将其复制到buf2中。如果此时buf2中具有可执行权限,那么就可以执行shellcode。查看buf2所在偏移:
在bss段中,通过内存映射查看该范围内的权限:
然而却发现,该段内存没有可执行权限,直到将程序放在另一台ubuntu18的靶机上发现此时映射的结果又不一样了:
查看官方wp后发现是libc版本的问题,正好是
glibc-2.27
,版本差异较大,所以对应的偏移等也有些差异。
所以思路很明确了,生成shellcode作为输入,然后跳转到buf2从而执行。
1 | from pwn import * |
这里的`ljust`是将shellcode未能填满的部分都填充为A。
pwn61
考察点:
- 描述:
输出了什么?
查看程序保护与运行情况:
可以发现这里的地址出现了随机值,并且出现段错误。
首先通过gdb先算出padding为24。 查看ida函数:
v5存储输入的值,并且v5所在地址会提前被打印出来,由于程序开启了PIE,所以每次该地址都是随机的。用gets来读取v5中输入的值,显然存在栈溢出。由于保护中表明栈可执行,按照习惯先用vmmap查看下具体是哪个部分(为了兼容libc环境,这几题都用的ubuntu18来做题):
但是并不像前面的题目一样,能够看出可利用的
vector
所在的偏移范围,到这里卡住了不知该如何前进。学习官方wp,让我们注意接下来的汇编指令leave
,该指令相当于MOV SP,BP;POP BP
,会释放栈空间,重置bp和sp指针,而当我们反汇编查看用shellcraft生成的shellcode:
1 | # -*- coding: utf-8 -*- |
可以发现在
回过头看ida发现v5所在地址距离上一个栈帧的指针(这里的
s
)偏移为0x10,
pwn62
考察点:
pwn71
描述:32位的ret2syscall
考察点:
保护: 运行功能如下,仅接收输入:
ida分析:
main函数很简单,就是gets获取输入,也是溢出点,同时system找不到,字符窗口也无与flag相关的有效信息:
gdb先确认padding为112: