In this post we describe a vulnerability we discovered in the Ivanti LanDesk software and how it can be exploited to achieve local privilege escalation via arbitrary code execution.
Ivanti disclosed the vulnerability in their advisory on May 28th 2024 assigning it CVE-2024-22058.
This vulnerability affects the Ivanti Endpoint Manager (EPM) up to version 2021.1 SU5, it does not exist in EPM 2022 and later.
Root cause analysis
After a client establishes a connection to the LanDesk application over port 9535/TCP, the subroutine
sub_4A7A20
starts the process of receiving data over that connection. This is done via the subroutine sub_4A7C70
(from now on called ConnectionReceiver
), that calls ws2_32!recv
.During that first call to
ConnectionReceiver
, the application receives a total of 12 bytes from the client. Those 12 bytes resemble the packet header.After returning from
ConnectionReceiver
, the first DWORD of the received data is compared with the static value 0x31484352
, which is equal to the characters 1HCR
.If this check succeeds it will compare the next DWORD that can be assumed to store the size of the packet body.
The value of the size field can be set up to
0x7FFC
, otherwise it will get set to this upper limit:Afterwards, another call to
ConnectionReceiver
is issued, the packet size now being one of the function parameters.This means the
len
parameter for the next call to ws2_32!recv
can be up 0x7FFC
.Once again, a value from the packet header is used for another two checks, this time a byte comparison instead of a whole DWORD.
In order to follow the code path leading to the bug, said value is set to
0xA
, which results in the first jump being taken and the second one not being taken:Following the execution leads to the following node that eventually calls
sub_473570
:While this subroutine contains relatively much code, reaching the function call we are interested in is pretty straight forward.
mov esi, ecx cmp dword ptr [esi+7Ch], 0 jnz loc_4738B5
This check does not verify the packet but a heap object created way earlier during execution. The next checks, however, use values retrieved from the packet as before.
At
loc_4735DB
, the application once again compares EDX with 0xA
, meaning it is pretty much the same check as before.Following that jump leads to a basic block at the bottom right (in the graph view) that calls
sub_473070
with two arguments.The first argument is the value from another field in the packet which is most likely related to the packet size. The second argument is the start address of the packet body.
This subroutine contains a switch-case and allows the user who sent the packet to dictate the code path taken by specifying an opcode in the packet. It could be assumed that this function is some kind of “options menu” that lets you choose the functionality you want to trigger.
The vulnerable code can be reached by setting this opcode value to
0x15
.This will result in the execution flow reaching
loc_463185
(jumptable case 21) which calls sub_472F80
with two function parameters.The first parameter is the value from the size field (
0x4b29
) in the packet; the other parameter once again is the start address of the packet body.The subroutine
sub_472F80
is fairly simple. If the value in the size field (specified by the user) is larger than 3, a string-copy-operation is conducted via a call to strncpy
.Instead of writing the source data to either the stack or the heap the destination is a variable in the .data segment in the
issuser.exe
(LanDesk) image itself:issuser+0x72f9b: 00472f9b 68e0047e00 push offset issuser+0x3e04e0 (007e04e0) 0:005> !address 007e04e0 [...] Usage: Image Base Address: 007de000 End Address: 007e5000 Region Size: 00007000 ( 28.000 kB) State: 00001000 MEM_COMMIT Protect: 00000004 PAGE_READWRITE Type: 01000000 MEM_IMAGE Allocation Base: 00400000 Allocation Protect: 00000080 PAGE_EXECUTE_WRITECOPY Image Path: issuser.exe Module Name: issuser
IDA already highlights that this variable is a char array with a size of 260. Nonetheless, since it is possible to dictate the amount of characters copied using the user-controlled size-field and since there are no boundary checks, it is easily possible to overflow this array.
Even though this does not influence exploitability, one might want to consider that the memory eventually becomes
READ_ONLY
after 19,232 bytes, at address 0x7e5000
.In order not to hit the
READ_ONLY
pages with the strncpy
, the size field in the packet header should be set to 0x4b20
or less.However, our technique to exploit this bug involves causing multiple access violations by trying to write to exactly those
READ_ONLY
pages.Between the destination memory address – the char array of size 260 – and the
READ_ONLY
memory pages are several addresses containing data and function pointers.One of those function pointers eventually gets called if
__CxxFrameHandler3
gets executed.When trying to write to the
READ_ONLY
memory pages using the strncpy
function, a C++ specific exception handler – __CxxFrameHandler3
will kick in instead of the Windows structured exception handler (SEH).Overwriting said function pointer and then causing
__CxxFrameHandler3
to get called will eventually lead to control over the instruction pointer.As displayed in IDA’s proximity view graph above,
__CxxFrameHandler3
will eventually lead to a call to ___vcrt_FlsGetValue
which then calls try_get_function
.static void* __cdecl try_get_function( function_id const id, char const* const name, module_id const* const first_module_id, module_id const* const last_module_id ) noexcept
try_get_function
is part of the MSVC vcruntime and at a high level could be compared to GetProcAddress.In this case,
try_get_function
is called with the function id 2
:In
try_get_function
this function id is used to obtain a function pointer from the .data
segment.In this case, the final virtual address to obtain the function pointer from is
4 * 2 + 0x7E3068 = 0x7e3070
.Considering that we are able to write arbitrary data from
0x7e04e0
up to 0x7e5000
, it is possible to overwrite this function pointer retrieved by try_get_function
.After the function pointer is returned to the caller function (
___vcrt_FlsGetValue
), it is then called using an indirect function call:This way it is possible to hijack the instruction pointer by overwriting the called function pointer with arbitrary data or a memory address.
Exploitation
The LanDesk binary is neither protected with CFG nor with ASLR, leaving us solely with DEP to bypass.
RollingLog.dll shipped with LanDesk has been chosen as the source for ROP gadgets as this module is less likely to get rebased compared to the other LanDesk DLLs.
In most cases RollingLog.dll will get loaded at the virtual address
0x10000000
.issuser.exe imports both,
kernel32!VirtualProtect
and kernel32!VirtualAlloc
, which can be used to bypass DEP.In this case, we decided to go with VirtualAlloc to set the stack memory holding the packet to executable.
While there are multiple function pointers we could overwrite and use for hijacking the control flow, we decided to overwrite one at the address
0x7e3070
.In order to get LanDesk to call that function pointer it is necessary to actually trigger an exception. The easiest way to go down this path is to use the
strncpy
call from earlier and try to write to the READ_ONLY
memory.Doing so will cause
__CxxFrameHandler3
to get called, a C++ specific function that will try to handle the exception.The
__CxxFrameHandler3
function then calls __InternalCxxFrameHandler
, which goes on to call ___vcrt_getptd
, which immediately calls ___vcrt_getptd_noexit
.Finally,
___vcrt_getptd_noexit
calls ___vcrt_FlsGetValue
which contains the indirect function call:Following that execution up to the
call esi
instruction containing the retrieved function pointer results in control over the instruction pointer:At this point it is possible to return back to the initial packet body stored on the stack by the
ws2_32!recv
function by using a stack pivoting gadget such as retn 7420
.After this gadget got executed, the stack pointer will point to the start of the ROP chain, and when
__vcrt_FlsGetValue
tries to return to its caller function, it will in fact return to our ROP chain.[...] payload = b"\x15" payload += pack('<H',0x4b29) # size for strncpy payload += b'A' * 0x2b90 payload += pack('<L', rollinglog_base + 0x13294) # retn 0x7420 (stack pivoting gadget) payload += b'1' * (0x41d5 - len(va)) payload += va # ROP skeleton ''' ROP CHAIN GOES HERE ''' # This chunk obtains a pointer to the ROP skeleton and saves it rop = pack('<L', rollinglog_base + 0x3918) # push esp ; sbb eax, 0xE58B0000 ; pop ebp ; ret ; [...]
After the ROP chain executed the memory page(s) on the stack containing the shellcode, part of the packet will be marked as
PAGE_EXECUTE_READWRITE
and we can successfully execute arbitrary code:Timeline
- 06.02.2024: Initial try to contact Ivanti
- 13.02.2024: Second try to contact Ivanti
- 19.02.2024: Third try to contact Ivanti
- 21.02.2024: First answer from Ivanti
- 23.02.2024: Ivanti verified the vulnerability
- 07.03.2024: Ivanti reserved the CVE
- 29.04.2024: Follow-up with Ivanti
- 28.05.2024: Public disclosure in Ivanti’s advisory
- 29.05.2024: Release of this blog post