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