For my internship, I was tasked by my mentor Le Qi to analyze CVE-2024-30088, a double-fetch race condition bug in the Windows Kernel Image ntoskrnl.exe. A public POC demonstrating EoP from Medium Integrity Level to SYSTEM is available on GitHub here.

Additionally, I was challenged (more like forced 💀) to chain the exploit to escape the Chrome Renderer Sandbox, achieving EoP from Untrusted Integrity Level to SYSTEM.

Easy, right? 🤡

Note: CVE-2024-30088 came out before 24H2, so I analyzed it using a 23H2 Windows VM instead

The Hunt Begins: Finding the Trigger for CVE-2024-30088

The bug can be triggered from NtQueryInformationToken when its TokenInformationClass field is set to the TOKEN_ACCESS_INFORMATION constant. At first glance, this function looks completely innocent, just letting processes retrieve information about access tokens if it has the appropriate access rights:

__kernel_entry NTSYSCALLAPI NTSTATUS NtQueryInformationToken(
  [in]  HANDLE                  TokenHandle,
  [in]  TOKEN_INFORMATION_CLASS TokenInformationClass,
  [out] PVOID                   TokenInformation, // usermode buffer
  [in]  ULONG                   TokenInformationLength,
  [out] PULONG                  ReturnLength
);
  • This is a read function, but that does not mean we cannot achieve arbitrary write with it
  • For TOCTOU, it’s important to trace TokenInformation since it is a buffer that can be controlled by the user
    • Take note of when the kernel writes to it

Down the Rabbit Hole: Following the Breadcrumbs

Looking through the switch case for the TOKEN_ACCESS_INFORMATION constant in NtQueryInformationToken, only SepCopyTokenAccessInformation stood out to me since it was the only function that takes in TokenInformation (the user-supplied buffer) as an argument.

NtQueryInformationToken( ... ) {
    
    ...
        
    case TokenAccessInformation: // case 22
    
        ...		
            
        if ( v5 < TokenAccessInformationBufferSize )
        goto LABEL_58;
        SepCopyTokenAccessInformation(
            v30,
            userBuffer, // TokenInformation field
            v5,
            v147,
            v157,
            Handle,
            v156,
            v155,
            v154,
            v153,
            v152,
            v151,
            v150,
            v110,
            v109);
    
       ...
           
}

Following SepCopyTokenAccessInformation, I discovered it copies a bunch of data about the token into the user buffer before calling AuthzBasepQueryInternalSecurityAttributesToken to copy over the security attributes of the token object.

__int64 __fastcall SepCopyTokenAccessInformation(
        _TOKEN *Token,
        _TOKEN_ACCESS_INFORMATION *userBuffer,
        UINT64 userBufferLength,
        UINT64 PrivilegeCount,
        UINT64 GroupsLength,
        UINT64 GroupsSALength,
        UINT64 RestrictedSidsLength,
        UINT64 RestrictedSidsSALength,
        UINT64 PackageSidLength,
        UINT64 CapabilitySidsLength,
        UINT64 CapabilitySidsSALength,
        UINT64 TrustSidLength,
        UINT64 SecurityAttributesLength,
        UINT8 UseNewTrust,
        void *NewTrustSid) {
        
	...

    userBuffer->AuthenticationId = Token->AuthenticationId; // copy over some token data
    v16 = PrivilegeCount;
    userBuffer->TokenType = Token->TokenType;
    userBuffer->ImpersonationLevel = Token->ImpersonationLevel;
    userBuffer->Flags = Token->TokenFlags;
    userBufferEnd = userBuffer + userBufferLength;
    
    ...
    
    AuthzBasepQueryInternalSecurityAttributesToken(
        Token->pSecurityAttributes,
        userBufferSecurityAttributes, // copy over token security attributes
        userBufferEnd - userBufferSecurityAttributes,
        &v38
    );
    v36 = &userBufferSecurityAttributes[SecurityAttributesLength];
    userBuffer->SecurityAttributes = userBufferSecurityAttributes;
	return SepConvertTokenPrivilegesToLuidAndAttributes(Token, v36->Privileges);
  
  }

Then, AuthzBasepQueryInternalSecurityAttributesToken calls AuthzBasepCopyoutInternalSecurityAttributes, which is where the bug can be found.

__int64 __fastcall AuthzBasepQueryInternalSecurityAttributesToken(
        _AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION *tokenSecurityAttributes,
        _DWORD *userBufferSecurityAttributes,
        unsigned int securityAttributesSize,
        unsigned int *a4) {

	...
		
        result = AuthzBasepCopyoutInternalSecurityAttributes( // bug in here
        tokenSecurityAttributes, 
        userBufferSecurityAttributes,
        securityAttributesSize);

	...

}

The Eureka Moment: Understanding the Race

The bug occurs when the _UNICODE_STRING struct in TokenObject->SecurityAttributesList gets copied over to the user buffer.

typedef struct _UNICODE_STRING {
  USHORT Length;
  USHORT MaximumLength;
  PWSTR  Buffer; // note: this is a pointer to the string (not the string itself!)
} UNICODE_STRING, *PUNICODE_STRING;

Particularly, it emerges due to the following sequence of operations:

  1. Kernel stores a pointer pBuffer inside the user buffer
  2. Kernel copies a string to the memory pointed to by pBuffer
__int64 __fastcall AuthzBasepCopyoutInternalSecurityAttributes(
        _AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION *tokenSecAttr,
        int *userBufferSecurityAttributes,
        unsigned int a3) {
    
    ...
    
    ADJ(offsetToUserBuffer)->unicodeString.MaximumLength = maxLength;
    ADJ(offsetToUserBuffer)->unicodeString.Length = 0;
    ADJ(offsetToUserBuffer)->unicodeString.Buffer = pBuffer; // [1] specify addr to write to
    RtlCopyUnicodeString(&ADJ(offsetToUserBuffer)->unicodeString, (SA_Entry + 0x20)); // [2] copy over string
    
    v9 = AuthzBasepCopyoutInternalSecurityAttributeValues(SA_Entry, offsetToUserBuffer - 104, v18, v6 - v18, &v20);
    if ( (v9 & 0x80000000) != 0 )
    goto LABEL_18;
    
    ...
        
}

Between steps [1] and [2], we can use a racing thread to change pBuffer (the address the kernel writes to), obtaining a partial write primitive.

Weaponizing the Discovery

Since there are already public POCs out there, I will just describe the exploit process at a high-level:

write where Get race address by parsing output buffer of NtQueryInformationToken (i.e. SecurityAttributesList.Flink + 0x20)
write what Get token address of exploit process via NtQuerySystemInformation

From here, we can use a racing thread to overwrite the SeDebugPrivilege bit of the token to escalate to SYSTEM. For most processes, the kernel will overwrite the target location using the Unicode string L"TSA://ProcUnique" (32 bytes).

Entering Chrome Renderer Sandbox

If you are unfamiliar with Chrome Internals (like me), I would suggest checking out these articles. These resources were absolute lifesavers:

Essentially, Chrome uses a multi-process architecture, where a single privileged process (aka the broker) controls multiple sandboxed target processes. The target processes run with restricted privileges, and communicate with the broker via IPC mechanisms.

In particular, the Chrome renderer process operates under an especially restrictive sandbox since it handles untrusted web content containing JavaScript.

Restriction Description
Token No Privileges
Integrity Level Untrusted
Job Disallow Creation of Child Process
Alternate Desktop Third Desktop, separate from Default & Logon Desktop

Roadblock #1: The Integrity Police

Running the exploit in the Chrome renderer, the first roadblock I encountered was that NtQuerySystemInformation, which was used to retrieve the token address of the renderer process, failed with the error code 0xC0000022 (STATUS_ACCESS_DENIED).

To find out why, we have to analyze NtQuerySystemInformation, which calls ExpQuerySystemInformation:

NTSTATUS __fastcall NtQuerySystemInformation(int a1, unsigned int *a2, unsigned int a3, ULONG *a4) {

    ...

    return ExpQuerySystemInformation(a1, p_Group, v6, a2, a3, a4); // here
}
NTSTATUS __fastcall ExpQuerySystemInformation(int a1, void *a2, unsigned int a3, unsigned int *a4, unsigned int Length, ULONG *a6)
{
    
    ...
    
    switch ( a1 ) {
        case 64: // SystemExtendedHandleInformation
            
            ...

            if ( ExIsRestrictedCaller(PreviousMode) ) // here
                return 0xC0000022; // STATUS_ACCESS_DENIED
            SystemBasicInformation = ExpGetHandleInformationEx(a4, Length, &v130);
            break;
            
            ...
            
    }
}

Turns out the code errors out because of ExIsRestrictedCaller. Let’s take a look at what it does:

__int64 __fastcall ExIsRestrictedCaller(char a1) {

    ...
    
    SeCaptureSubjectContext(&SubjectContext);
    pass_check = SeAccessCheck(
        SeMediumDaclSd,
        &SubjectContext,
        0,
        0x20000u,
        0,
        0,
        &ExpRestrictedGenericMapping,
        1,
        &GrantedAccess,
        &AccessStatus);
    SeReleaseSubjectContext(&SubjectContext);
    if ( !pass_check )
        return 1;
    LOBYTE(v1) = AccessStatus < 0;
    return v1;
}

Looking at the first two arguments of SeAccessCheck, it seems that the SubjectContext (renderer process) is compared against SeMediumDaclSd, which points to SepMediumDaclSd — a security descriptor representing Medium Integrity Level.

Since the exploit had previously succeeded outside of Chrome, we can infer that the check must have failed due to one of the security mechanisms of the renderer sandbox. Particularly, the renderer process runs at the Untrusted Integrity Level, so when its context gets compared against SeMediumDaclSd, the check fails.

Solution: Breaking the Security Descriptor

First, we have to familiarize ourselves with the _SECURITY_DESCRIPTOR struct that SepMediumDaclSd uses:

typedef struct _SECURITY_DESCRIPTOR {
  BYTE                        Revision;
  BYTE                        Sbz1;
  SECURITY_DESCRIPTOR_CONTROL Control; // bit flags, indicates if Sacl/Dacl are present. 2 bytes long
  PSID                        Owner; 
  PSID                        Group;
  PACL                        Sacl; // contains ACE entries, used for auditing, MAC (integrity level check)
  PACL                        Dacl; // contains ACE entries, used for DAC (allow/deny SIDs)
} SECURITY_DESCRIPTOR, *PISECURITY_DESCRIPTOR;

Then, let’s examine SepMediumDaclSd using the debugger to inspect its fields:

WINDBG>x nt!SepMediumDaclSd
fffff80104f55f20 nt!SepMediumDaclSd = <no type information>

WINDBG>!sd fffff80104f55f20
->Revision: 0x1
->Sbz1    : 0x0
->Control : 0x10
            SE_SACL_PRESENT // here
->Owner   : S-1-5-18
->Group   : S-1-5-18
->Dacl    :  is NULL
->Sacl    : 
->Sacl    : ->AclRevision: 0x2
->Sacl    : ->Sbz1       : 0x0
->Sacl    : ->AclSize    : 0x20
->Sacl    : ->AceCount   : 0x1
->Sacl    : ->Sbz2       : 0x0
->Sacl    : ->Ace[0]: ->AceType: SYSTEM_MANDATORY_LABEL_ACE_TYPE // specifies mandatory access level (i.e. integrity level)
->Sacl    : ->Ace[0]: ->AceFlags: 0x0
->Sacl    : ->Ace[0]: ->AceSize: 0x14
->Sacl    : ->Ace[0]: ->Mask : 0x00000002
->Sacl    : ->Ace[0]: ->SID: S-1-16-8192

At this point, I hypothesized that if the Control bitflag SE_SACL_PRESENT (0x10) is zeroed out, SeAccessCheck will assume that there are no SACL entries in the security descriptor and hence skip the Mandatory Integrity Control check. I tested this out by patching the Control field to zero using the debugger and sure enough, it worked!

The Precision Surgery Problem

Since SepMediumDaclSd is a global variable in ntoskrnl.exe, we can use a prefetch-side channel to bypass kASLR and get its address. Then, we can leverage on the partial write primitive we obtained from CVE-2024-30088 to overwrite the Control field in SepMediumDaclSd.

However, when I tried to overwrite using the exploit instead of patching, I got a BSOD with the following error message:

*** Fatal System Error: 0x0000003b ( // SYSTEM_SERVICE_EXCEPTION
    0x00000000C0000005, // STATUS_ACCESS_VIOLATION
	0xFFFFF8010447BC7A, // faulting instruction addr (the code here tries to access the Owner field)
	0xFFFFF880DA5DDF20,
	0x0000000000000000
)

From this, I realized that since the exploit overwrites 32 bytes, this meant that on top of overwriting the Control field, the exploit also corrupted the Owner field which originally held a valid pool address. As a result, Windows crashes when SeAccessCheck tries to access the now-invalid pointer.

// before exploit
PAGEDATA:FFFFF80104F55F20 ; _SECURITY_DESCRIPTOR SepMediumDaclSd
PAGEDATA:FFFFF80104F55F20 SepMediumDaclSd db 1                    ; Revision
PAGEDATA:FFFFF80104F55F21 db 0                                    ; Sbz1
PAGEDATA:FFFFF80104F55F22 dw 10h                                  ; Control
PAGEDATA:FFFFF80104F55F24 db 0, 0, 0, 0
PAGEDATA:FFFFF80104F55F28 dq 0FFFFBB0A8786B520h                   ; Owner
PAGEDATA:FFFFF80104F55F30 dq 0FFFFBB0A8786B520h                   ; Group
PAGEDATA:FFFFF80104F55F38 dq 0FFFF980E588AF5D0h                   ; Sacl
PAGEDATA:FFFFF80104F55F40 dq 0                                    ; Dacl
// after exploit (overwrite 32 bytes using L"TSA//ProcUnique", starting from 0xFFFFF80104F55F1F)
PAGEDATA:FFFFF80104F55F20 ; _SECURITY_DESCRIPTOR SepMediumDaclSd
PAGEDATA:FFFFF80104F55F20 SepMediumDaclSd db 0                    ; Revision
PAGEDATA:FFFFF80104F55F21 db 53h                                  ; Sbz1
PAGEDATA:FFFFF80104F55F22 dw 4100h                                ; Control // zero out lower byte to bypass check
PAGEDATA:FFFFF80104F55F24 db 0, 3Ah, 0, 5Ch
PAGEDATA:FFFFF80104F55F28 dq offset unk_503030785C303078          ; Owner // invalid pool addr, error when accessed
PAGEDATA:FFFFF80104F55F30 dq offset unk_550063006F007200          ; Group // not accessed
PAGEDATA:FFFFF80104F55F38 dq offset unk_7500710069006E00          ; Sacl // only accessed if SE_SACL_PRESENT is set
PAGEDATA:FFFFF80104F55F40 dq offset unk_6500                      ; Dacl // only accessed if SE_DACL_PRESENT is set

Hence, we have to avoid overwriting the pool address in the Owner field, particularly the upper bytes. It is possible to overwrite the lower bytes as long as the resulting address falls within a valid pool region.

My final exploit starts overwriting at SepMediumDaclSd - 0n23. I chose this address to satisfy three conditions:

  1. Avoid corrupting the Owner field of SepNullDaclSd, which lies directly above SepMediumDaclSd.
  2. Zero out the (lower byte of the) Control field of SepMediumDaclSd
  3. Minimize the number of overwritten bytes in the Owner field of SepMediumDaclSd
// start overwriting at 0xFFFFF80104F55F09
PAGEDATA:FFFFF80104F55EF8 ; _SECURITY_DESCRIPTOR SepNullDaclSd
PAGEDATA:FFFFF80104F55EF8 SepNullDaclSd db 1                      ; Revision
PAGEDATA:FFFFF80104F55EF9 db 0                                    ; Sbz1
PAGEDATA:FFFFF80104F55EFA dw 0                                    ; Control
PAGEDATA:FFFFF80104F55EFC db 0, 0, 0, 0
PAGEDATA:FFFFF80104F55F00 dq 0                                    ; Owner
PAGEDATA:FFFFF80104F55F08 dq offset unk_3A00410053005400          ; Group // overwrite 7 bytes
PAGEDATA:FFFFF80104F55F10 dq offset unk_720050002F002F00          ; Sacl  // overwrite 8 bytes
PAGEDATA:FFFFF80104F55F18 dq offset unk_6E00550063006F00          ; Dacl  // overwrite 8 bytes
PAGEDATA:FFFFF80104F55F20 ; _SECURITY_DESCRIPTOR SepMediumDaclSd

PAGEDATA:FFFFF80104F55F20 SepMediumDaclSd db 0                    ; Revision // overwrite 1 byte
PAGEDATA:FFFFF80104F55F21 db 69h                                  ; Sbz1     // overwrite 1 byte
PAGEDATA:FFFFF80104F55F22 dw 7100h                                ; Control  // overwrite 2 bytes (zero out lower byte)
PAGEDATA:FFFFF80104F55F24 db 0, 75h, 0, 65h									 // overwrite 4 bytes
PAGEDATA:FFFFF80104F55F28 dq 0FFFFBB0A8786B500h                   ; Owner    // overwrite 1 byte (zero out least significant byte)
PAGEDATA:FFFFF80104F55F30 dq 0FFFFBB0A8786B520h                   ; Group
PAGEDATA:FFFFF80104F55F38 dq 0FFFF980E588AF5D0h                   ; Sacl
PAGEDATA:FFFFF80104F55F40 dq 0                                    ; Dacl

Roadblock #2: The Job Object Prison

Now that we have bypassed the Integrity Level check in NtQuerySystemInformation to obtain the token address, we can escalate privileges by overwriting the SeDebugPrivilege bit of the token. At this point, it might seem like we could simply pop a shell — right?

Unfortunately, attempting to call CreateProcess with the renderer process fails with error code 5 (ERROR_ACCESS_DENIED). This is because although the renderer process now holds elevated privileges, it is still managed by a Job object that explicitly prohibits the creation of child processes.

Solution: The Great Escape

To circumvent this, I used the privileged renderer process to inject code into winlogon.exe and got it to call CreateProcess instead. This time, there were no errors but I did not see any shell to interact with. Using System Informer, I saw that cmd.exe was created but I could not see it.

As in turns out, since I was using a Hyper-V Virtual Machine, there were two instances of winlogon.exe — one for the VM Desktop and another for the host Desktop. To resolve this, I disabled Hyper-V’s Enhanced Session Mode so that only the VM’s winlogon.exe remained. Then, I ran the exploit again and sure enough, a shell appeared!

Demo

Victory never tasted so sweet.

Summary

Remember to turn off Enhanced Session Mode if you are using Hyper-V

  1. Overwrite Control bitfield of SepMediumDaclSd using CVE-2024-30088 to skip the Integrity Level check
  2. Call NtQueryInformationSystem to get token address of renderer process
  3. Overwrite SeDebugPrivilege bit of token to escalate privileges of renderer process
  4. Inject shellcode into winlogon.exe to pop a shell, bypassing restrictions of renderer Job object

Afterthoughts

Since this was my first time working with vulnerability research, I thought it would be interesting to share my reflections. Especially for those who are just starting out:

When I was analyzing of CVE-2024-30088, I was too focused on trying to find bugs without any context. What I should have done instead was to reverse the program as thoroughly as possible first to understand its logic so that it is easier to identify flaws.

VR often involves unguided exploration, so it’s essential to experiment with unconventional ideas. Figure out why things work or don’t work, and don’t be afraid to test things out.

Lastly, it’s easy to rabbit hole and get stuck. One tip that I found especially helpful was to revisit all the assumptions I made and challenge each one.