技術專欄 #CVE #Vulnerability #Windows #Kernel #Advisory

Streaming vulnerabilities from Windows Kernel - Proxying to Kernel - Part II

Angelboy 2024-10-05

English Version, 中文版本

這是一系列有關 Kernel Streaming 的相關的漏洞研究,建議先閱讀以下文章

在先前 Proxying to Kernel 的研究中,我們在 Kernel Streaming 中找到了多個漏洞以及一個被忽視的 Bug Class,並在今年 Pwn2Own Vancouver 2024 中利用漏洞 CVE-2024-35250CVE-2024-30084 成功攻下 Windows 11。

而在這篇研究中,我們將繼續延續這個攻擊面和這個 Bug Class,也將揭露另外一個漏洞和利用手法,亦發表於 HEXACON 2024 中。


在 Pwn2Own Vancouver 2024 之後,我們繼續針對 ks!KsSynchronousIoControlDevice 這個 bug pattern 去看看有沒有其他安全性上的問題,然而找了一段時間後,針對 KS Object 的 Property 操作中,並沒有找到其他可以利用的點,因而我們將方向轉往另外一個功能 KS Event 上。

KS Event

KS Event 與前一篇提到的 KS Property 類似。KS Object 中除了有自己的 Property Set 之外,也有提供設定 KS Event 的功能,比如說你可以設定設備狀態改變或是每個一段時間就觸發 Event,方便播放軟體等開發者定義後續的行為,而每個 KS Event 就如同 Property 一樣,要使用就必須該 KS Object 有支援。我們可以透過 IOCTL_KS_ENABLE_EVENTIOCTL_KS_DISABLE_EVENT 來註冊或關閉這些 Event。

KSEVENTDATA

而在註冊 KS Event 時,你可以藉由提供 KSEVENTDATA 來註冊你想要的事件,其中可以提供 EVENT_HANDLE 及 SEMAPHORE_HANDLE 等 handle 來註冊,當 KS 觸發這個事件時,就會藉由這個 handle 來通知你。

The work flow of IOCTL_KS_ENABLE_EVENT

其整個運作流程也與 IOCTL_KS_PROPERTY 雷同,在呼叫 DeviceIoControl 時,就會像下圖一樣,將使用者的 requests 依序給相對應的 driver 來處理

同樣會在第 3 步時做 32-bit 的 requests 轉換成 64-bit 的 requests。到第 6 步時 ks.sys 就會根據你 requests 的 Event 來決定要交給哪個 driver 及 addhandler 來處理你的 request。

最終再轉發給相對應的 Driver。如上圖中最後轉發給 ks 中的 KsiDefaultClockAddMarkEvent 來設置 Timer

在了解了 KS Event 功能及流程後,根據之前的 bug pattern 很快地又找到了一個可以利用的漏洞 CVE-2024-30090

Proxying to kernel again !

這次的問題點發生在 ksthunk 將 32-bit request 轉換成 64-bit request 的過程。

如下圖,當 ksthunk 接收到來自 WoW64 Process 的 IOCTL_KS_ENABLE_EVENT 時,會進行 32-bit 結構到 64-bit 結構的轉換

轉換過程會呼叫 ksthunk!CKSAutomationThunk::ThunkEnableEventIrp 來處理

__int64 __fastcall CKSAutomationThunk::ThunkEnableEventIrp(__int64 ioctlcode_d, PIRP irp, __int64 a3, int *a4)
{
  ...
  if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLE
    || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ONESHOT
    || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLEBUFFERED )  
  {
    // Convert 32-bit requests and pass down directly
  }
  else if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_QUERYBUFFER ) 
  {
    ...
    newinputbuf = (KSEVENT *)ExAllocatePoolWithTag((POOL_TYPE)0x600, (unsigned int)(inputbuflen + 8), 'bqSK');
    ...
    memcpy(newinputbuf,Type3InputBuffer,0x28);  //------------------------[1]
    ...
    v18 = KsSynchronousIoControlDevice( 
            v25->FileObject,
            0,
            IOCTL_KS_ENABLE_EVENT,
            newinputbuf,
            inputbuflen + 8,
            OutBuffer,
            outbuflen,
            &BytesReturned);  //-----------------[2]
    ...
  }
  ...
}

而在 CKSAutomationThunk::ThunkEnableEventIrp 中,明顯可以看到類似的 bug pattern,從 [1] 中可以看到,它會複製使用者的輸入到新分配出來的 Buffer 中,接著在 [2] 處,就會利用該 Buffer 來使用 KsSynchronousIoControlDevice 呼叫新的 IOCTL,。其中 newinputbuf 及 OutBuffer 都是使用者所傳入的內容。

呼叫 CKSAutomationThunk::ThunkEnableEventIrp 時的流程,大概如下圖所示 :

在 WoW64 的程式中呼叫 IOCTL 時,可以看到圖中第 2 步 I/O Manager 會將 Irp->RequestorMode 設成 UserMode(1),而在第 3 步時,ksthunk 會將使用者的 request 從 32-bit 轉換成 64-bit,這邊就會用 CKSAutomationThunk::ThunkEnableEventIrp 來處理。

之後第 5 步,就會透過 KsSynchronousIoControlDevice 重新呼叫 IOCTL ,而此時新的 Irp->RequestorMode 就變成了 KernelMode(0) 了,而後續的處理就如一般的 IOCTL_KS_ENABLE_EVENT 相同,就不另外詳述了,總之我們到這裡已經有個可以任意做 IOCTL_KS_ENABLE_EVENT 的 primitive 了,接下來我們必須尋找看看是否有可以 EoP 的地方。

The Exploitation

跟先前思路一樣,一開始還是會先分析入口點 ksthunk,然而我們找尋了一陣子之後,並沒有看到可以做為提權的地方,而且在 ksthunk 中,大多數只要看到 Irp->RequestMode 是 KernelMode(0) 就會直接往下傳遞而不另外做處理。因此我們將我們的目標轉向位在下一層的 ks,看看它在處理 event 的過程中,是否有可以用來提權的地方。

很快的就找到一個吸引我們目光的地方:

在 KspEnableEvent 的 Handler 中,有一處會先判斷你所傳入的 KSEVENTDATA 中的 NotificationType 來決定要怎麼註冊及處理你的事件,在一般情況下通常是給一個 EVENT_HANDLE 或是 SEMAPHORE_HANDLE,然而在 ks 中,如果是從 KernelMode 呼叫的就給以提供 Event Object 甚至 DPC 來註冊你的事件,讓整體的處理上更有效率。

也就是說我們可以藉由這個 KernelMode 的 DeviceIoControl 的 primitive 來提供任意 Kernel Object,讓它做後續處理,構造的好就有機會達成 EoP 但要看後續怎麼使用這個 Object 就是了。

但是我們在嘗試了一段時間後發現到……

__int64 __fastcall CKSAutomationThunk::ThunkEnableEventIrp(__int64 ioctlcode_d, PIRP irp, __int64 a3, int *a4)
{
  ...
  if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLE
    || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ONESHOT
    || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLEBUFFERED )  //-------[3]
  {
    // Convert 32-bit requests and pass down directly
  }
  else if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_QUERYBUFFER ) //-------[4]
  {
    ...
    newinputbuf = (KSEVENT *)ExAllocatePoolWithTag((POOL_TYPE)0x600, (unsigned int)(inputbuflen + 8), 'bqSK');
    ...
    memcpy(newinputbuf,Type3InputBuffer,0x28); //------[5] 
    ...
    v18 = KsSynchronousIoControlDevice( 
            v25->FileObject,
            0,
            IOCTL_KS_ENABLE_EVENT,
            newinputbuf,
            inputbuflen + 8,
            OutBuffer,
            outbuflen,
            &BytesReturned);  
    ...
  }
  ...
}

如果要提供任意 Kernel Object 去註冊事件,那麼 IOCTL 中所給定的 KSEVENT 的 flag,必須要是 KSEVENT_TYPE_ENABLE,也就是上面程式碼 [3] 的部分,然而在程式碼片段 [4] 處是觸發漏洞的地方卻是要是 KSEVENT_TYPE_QUERYBUFFER,並沒辦法如我們所想的一樣直接給一個 Kernel Object。

然而幸運的是整個 IOCTL_KS_ENABLE_EVENT 也是使用 Neither I/O 直接拿使用者的 Input buffer 來做資料上的處理,再次出現了 Double Fetch 的問題。

如上圖中所示,我們可以在呼叫 IOCTL 前把 flag 設置成 KSEVENT_TYPE_QUERYBUFFER,檢查時就會以 KSEVENT_TYPE_QUERYBUFFER 的功能處理,而在第二次呼叫 IOCTL 前,就把 flag 換成 KSEVENT_TYPE_ENABLE,這樣就可以成功觸發漏洞並構造特定的 Kernel Object 來註冊事件了。

Trigger the event

至於甚麼時候會用到你所構造的 KS Object 呢? 當事件觸發時, ks 會透過 DPC 呼叫 ks!ksGenerateEvent,此時就會依照你所給定的 NotificationType 來決定要怎麼處理你的事件。

我們就來看一下 KsGenerateEvent

NTSTATUS __stdcall KsGenerateEvent(PKSEVENT_ENTRY EventEntry)
{

  switch ( EventEntry->NotificationType )
  {
    case KSEVENTF_DPC:
      ...
      if ( !KeInsertQueueDpc(EventEntry->EventData->Dpc.Dpc, EventEntry->EventData, 0LL) )
        _InterlockedAdd(&EventEntry->EventData->EventObject.Increment, 0xFFFFFFFF); //--------[6]
      ...
    case KSEVENTF_KSWORKITEM:
      ...
      KsIncrementCountedWorker(eventdata->KsWorkItem.KsWorkerObject); //-----------[7]

  }
}

其實到這邊就有多種利用方式可以利用,最直接的莫過於直接構造 DPC 結構註冊 DPC 來達成任意 Kernel 程式碼執行,也就是上面程式碼片段 [6] 的地方,但在呼叫 KsGenerateEvent 時的 IRQL 是 DISPATCH_LEVEL 很難在 User space 下構造 DPC object 利用過程也會遇到許多問題

所以我們改用另外一條 KSEVENTF_KSWORKITEM,也就是程式碼片段 [7] 的部分,藉由傳入 Kernel 位置,讓他誤認為是 KSWORKITEM 的指標。

其中就會對該指標指向位置加上 0x5c 的地方加一,也就是可以達到任意 Kerenl Address 加一的寫入,其整個過程就如下圖:

在呼叫 IOCTL_KS_ENABLE_EVENT 時,構造 KSEVENTDATA 指向 Kernel 記憶體位置後,ks 處理時就會將它作為 Kernel Object 來操作,並註冊指定的事件

而到觸發時,ks 就會將我們給的記憶體位置內容 +1,因此我們這邊就有了一個 kernel 任意 +1 的 primitive 了。

Arbitrary increment primitive to EoP

從任意記憶體位置 +1 到提權有許多方法可以利用,其中最知名的莫過於 Abuse token privilege 以及 IoRing,原本以為到這邊就差不多結束了…

然而上述兩種方法在這個情境中都有一定的侷限:

Abuse token Privilege

如果是以 Abuse token privilege 方法來做提權,其關鍵在於覆寫 Privileges.Enable 及 Privileges.Present,而我們漏洞一次只能 +1 ,如果要拿到 SeDebugPrivilege 就必須兩個欄位都要寫到,這兩格欄位的預設數值為 0x6028800000x800000 必須要變成,0x602980000 及 0x900000,也就是說分別都要各寫 0x10 次,總共要 0x20 次的寫入,每次的寫入都要 race,需要花上不少時間,穩定度也大幅下降。

IoRing

透過 IoRing 來達到任意寫入,也許會是個更簡單的方法,只需覆寫 IoRing->RegBuffersCount and IoRing->RegBuffers 就可達到任意寫入,然而有個問題就發生了…

在觸發任意記憶體位置 +1 這個 primitive 時,如果原先的數值是 0 時,就會進到 KsQueueWorkItem 中,其中會有一些相對應複雜的處理,就會導致 BSoD, IoRing 的利用方式剛好就會遇到這狀況…

是不是真的沒辦法穩定利用了呢?

Let’s find a new way !

當傳統的利用方法遇到瓶頸時,深入探討技術的核心機制可能會是值得的。你或許會在此過程中意外發現新的方法。

經過幾天沉思之後,我們決定找尋新方法,但從頭找新的方法可能會花不少時間也可能找不到,於是我們決定從舊有的兩個方法中找尋新的靈感,首先來看的是 Abuse token privilege,其中最關鍵的就是利用漏洞拿到 SeDebugPrivilege 使得我們可以 Open 像是 winlogon 等高權限的 Process。

問題就來了,為什麼只要有 SeDebugPrivilege 就可以開啟高權限的 Process 呢?

這邊就要來看一下 PsOpenProcess,以下是 PsOpenProcess 的程式碼片段:

由此可見,當我們在 Open Process 時, kernel 會優先使用 SeSinglePrivilegeCheck 檢查你是否有 SeDebugPrivilege,如果你具有 SeDebugPrivilege 那就會給你 PROCESS_ALL_ACCESS 的權限,不會有其他 ACL 的檢查,讓你可以對任意 Process 去做任何事情,顧名思義就是讓你 Debug 用的,然而有一點值得注意的地方是 SeDebugPrivilege 是在 ntoskrnl.exe 上的全域變數。

它會是個 LUID 結構,會在系統啟動時初始化,實際數值為 0x14 ,表示在 Privileges.Enable 及 Privileges.Present 欄位中哪個 bit 是代表 SeDebugPrivilege。所以當我們在用 NtOpenProcess 時,系統去查看這個全域變數中的數值。

獲得要檢查的數值後,就會依照這個數值去檢查 Token 中的 Privileges 欄位是否有 Enable 及 Present 這個欄位,以 SeDebugPrivilege 來說就會檢查第 0x14 bit

然而有一件有趣的事情是…

nt!SeDebugPrivilege 這個全域變數是位於可寫的區段中!

因此一個新的想法就誕生了。

Make abusing token privilege great again !

預設情況下,一般權限的使用者會像這張圖一樣,僅有少數的 Privileges

不過我們可以注意到的是,大部分情況下都會有 SeChangeNotifyPrivilege 且是 Enable 的。這時我們就可以來看看初始化的地方,就可發現 SeChangeNotifyPrivilege 所代表是數值為 0x17。

那如果我們利用漏洞把 SeDebugPrivilege 從 0x14 換成 0x17 會發生甚麼事情呢?

如上圖,在原先 OpenProcess 的流程中,依舊會先去看 nt!SeDebugPrivilege 中的數值,而這時獲得的數值為 0x17(SeChangeNotifyPrivilege)

接下來的檢查就會以 0x17 對當前 Process token 做驗證,看看有沒有這個 Privilege,然而一般使用者都會有這個 Privilege,因此即使你沒有 SeDebugPrivilege 也會直接通過檢查,拿到 PROCESS_ALL_ACCESS也就是說任何擁有 SeChangeNotifyPrivilege 都可以 open 除了 PPL 之外的高權限的 Process

此外利用我們上述的漏洞來將 nt!SeDebugPrivilege 從 0x14 改成 0x17,因為原本的數值不是 0 是不會受到 KsQueueWorkItem 影響的,因此非常適合我們。

在可以 open 高權限的 Process 後,提權方式就與一般的 Abuse token privilege 方法相同就不再這邊多提了,最終我們又在一次利用 Proxying to kernel 成功在 Windows 11 23H2 上達成 EoP。

Remark

實際上來說,這個方法也適用於其他高權限的 Privilege 中

  • SeTcbPrivilege = 0x7
  • SeTakeOwnershipPrivilege = 0x9
  • SeLoadDriverPrivilege = 0xa

The Next & Summary

這兩篇文章中,主要著重於我們怎麼從過往的漏洞分析到發現新漏洞的過程,如何從過去的研究之中獲得新的想法、新的利用方式,新的漏洞以及新的攻擊面。關於這種 Proxy 類型的 Bug class 可能還存在很多,也可能不只侷限於 Kernel Streaming 和 IoBuildDeviceIoControlRequest,我認為算是 Windows 設計上的一個小缺陷,如果認真找可能還會找到一些漏洞,這類型的漏洞你需要關注的地方就是 Irp->RequestorMode 設置的時間點,如果設置 KernelMode 之後還有拿使用者的輸入做事情,就有機會出問題,而且這類型的漏洞往往都很好用。

在 Kernel Streaming 中,我認為應該不少潛在的安全性漏洞,他也還有很多元件像是 Hdaudio.sys 或是 Usbvideo.sys 可能也是個可以看的方向,也是個適合 fuzzing 的地方。如果你是個 Kernel driver 開發者最好不要只有檢查 Irp->Requestormode,Windows 架構下很有可能還是有問題。最後再次強烈建議大家盡速更新 Windows 到最新版本中。

Is that the end of it ?

實際上來說除了 Proxy 類型的漏洞之外,我們還有找到其他更多的 Bug class 使得我們在 Kernel Streaming 上找到超過 20 個漏洞,有些漏洞非常特別,敬請期待 Part III。

Reference