在这篇文章中,我们描述了在 Ivanti LanDesk 软件中发现的一个漏洞,以及如何利用它通过任意代码执行来实现本地权限提升。
Ivanti 在 2024 年 5 月 28 日的公告中披露了这个漏洞,并分配了 CVE-2024-22058。
这个漏洞影响 Ivanti Endpoint Manager (EPM) 2021.1 SU5 及之前的版本,EPM 2022 及以后版本中不存在此漏洞。
根本原因分析
在客户端通过端口 9535/TCP 建立与 LanDesk 应用程序的连接后,子程序 sub_4A7A20
开始通过该连接接收数据的过程。这是通过子程序 sub_4A7C70
(从现在起称为 ConnectionReceiver
),它调用 ws2_32!recv
来完成的。
在第一次调用 ConnectionReceiver
期间,应用程序从客户端接收了总共 12 个字节。这 12 个字节类似于数据包头。
从 ConnectionReceiver
返回后,接收到的数据的第一个 DWORD 与静态值 0x31484352
进行比较,该值等于字符 1HCR
。
如果此检查成功,它将比较下一个 DWORD,该 DWORD 可以假定为存储数据包主体的大小。
大小字段的值可以设置为 0x7FFC
,否则将被设置为此上限:
之后,发出另一个对 ConnectionReceiver
的调用,数据包大小现在是函数参数之一。
这意味着下一次调用 ws2_32!recv
的 len
参数可以高达 0x7FFC
。
再次,数据包头中的一个值用于另外两个检查,这次是字节比较而不是整个 DWORD。
为了沿着导致错误的代码路径,将该值设置为 0xA
,这导致采取第一个跳转并不采取第二个跳转:
跟随执行将导致最终调用 sub_473570
的节点:
虽然这个子程序包含相对较多的代码,但到达我们感兴趣的函数调用是相当直接的。
mov esi, ecx
cmp dword ptr [esi+7Ch], 0
jnz loc_4738B5
在这个检查中,并没有验证数据包,而是检查了在执行过程中很早就创建的堆对象。接下来的检查,依旧使用之前的数据包中检索到的值。
在 loc_4735DB
处,应用程序再次将 EDX 与 0xA
进行比较,这意味着它与之前的检查几乎相同。
跟随那个跳转会导致跳到右下角的基本块(在图形视图中),该基本块调用 sub_473070
并传入两个参数。
第一个参数是来自数据包中另一个字段的值,很可能与数据包大小有关。第二个参数是数据包主体的起始地址。
这个子程序包含一个 switch-case 结构,并允许发送数据包的用户通过在数据包中指定操作码来决定采取的代码路径。可以假设这个函数是某种“选项菜单”,允许你选择想要触发的功能。
通过将操作码值设置为 0x15
,可以到达易受攻击的代码。
这将导致执行流程到达 loc_463185
(跳转表案例 21),它调用 sub_472F80
并传入两个函数参数。
第一个参数是来自数据包中大小字段(0x4b29
)的值;另一个参数再次是数据包主体的起始地址。
子程序 sub_472F80
相当简单。如果大小字段中的值(由用户指定)大于 3,将通过调用 strncpy
执行字符串复制操作。
不是将源数据写入栈或堆,目标是 issuser.exe
(LanDesk)映像本身 .data 段中的一个变量:
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 已经标出这个变量是一个大小为 260 的字符数组。尽管如此,由于可以使用用户控制的大小字段来指定复制的字符数量,并且没有边界检查,所以很容易溢出这个数组。
尽管这并不影响可利用性,但人们可能需要考虑,在地址 0x7e5000
处,经过 19,232 字节后,内存最终会变成 READ_ONLY
。
为了避免在使用 strncpy
时碰到 READ_ONLY
页面,数据包头中的 size 字段应该设置为 0x4b20
或更小。
然而,我们利用这个错误的方法涉及通过尝试写入确切的那些 READ_ONLY
页面来引起多次访问违规。
在目标内存地址——大小为 260 的字符数组——和 READ_ONLY
内存页面之间有多个包含数据和函数指针的地址。
如果执行了 __CxxFrameHandler3
,那么其中一个函数指针最终会被调用。
当尝试使用 strncpy
函数写入 READ_ONLY
内存页面时,将启动一个 C++ 特定的异常处理器——__CxxFrameHandler3
,而不是 Windows 结构化异常处理器(SEH)。
覆盖上述函数指针,然后使 __CxxFrameHandler3
被调用,最终将导致控制指令指针。
如上图中 IDA 的邻近视图图所示,__CxxFrameHandler3
最终将导致对 ___vcrt_FlsGetValue
的调用,然后调用 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
是 MSVC vcruntime 的一部分,在高层次上可以与 GetProcAddress 相比较。
在这种情况下,try_get_function
被调用时使用的函数 id 为 2
:
在 try_get_function
中,这个函数 ID 被用来从 .data
段获取一个函数指针。
在这种情况下,最终获取函数指针的虚拟地址是 4 * 2 + 0x7E3068 = 0x7e3070
。
考虑到我们能够从 0x7e04e0
写入任意数据直到 0x7e5000
,就有可能覆盖 try_get_function
检索到的这个函数指针。
函数指针返回给调用函数 (___vcrt_FlsGetValue
) 后,然后使用间接函数调用来调用它:
这样,就有可能通过覆盖被调用的函数指针来劫持指令指针,无论是用任意数据还是一个内存地址。
利用
LanDesk 二进制文件既没有使用 CFG 保护,也没有使用 ASLR,所以我们只需要绕过 DEP。
与 LanDesk 一起提供的 RollingLog.dll 被选为 ROP 工具的来源,因为这个模块相比其他 LanDesk DLLs 不太可能被重新定位。
在大多数情况下,RollingLog.dll 会被加载到虚拟地址 0x10000000
。
issuser.exe 导入了 kernel32!VirtualProtect
和 kernel32!VirtualAlloc
,这两个函数可以用来绕过 DEP。
在这种情况下,我们决定使用 VirtualAlloc 来设置持有数据包的栈内存为可执行。
尽管有多个函数指针我们可以覆盖并用于劫持控制流,但我们决定覆盖地址 0x7e3070
处的一个。
为了让 LanDesk 调用那个函数指针,需要实际触发一个异常。走这条路最简单的方法是使用前面提到的 strncpy
调用,并尝试写入 READ_ONLY
内存。
这样做将导致 __CxxFrameHandler3
被调用,这是一个 C++ 特定的函数,将尝试处理异常。
__CxxFrameHandler3
函数接着调用 __InternalCxxFrameHandler
,它又调用 ___vcrt_getptd
,它立即调用 ___vcrt_getptd_noexit
。
最后,___vcrt_getptd_noexit
调用 ___vcrt_FlsGetValue
其中包含间接函数调用:
跟随执行直到包含检索到的函数指针的 call esi
指令,结果就是控制指令指针:
此时,可以使用如 retn 7420
这样的栈迁移工具,返回到 ws2_32!recv
函数存储在栈上的初始数据包。
执行这个工具后,栈指针将指向 ROP 链的开始,当 __vcrt_FlsGetValue
尝试返回给它的调用函数时,实际上将返回到我们的 ROP 链。
[...]
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 ;
[...]
在 ROP 链执行后,包含 shellcode 的栈内存页将被标记为 PAGE_EXECUTE_READWRITE
,我们就可以实现任意代码的执行:
时间线
-
2024.02.06:首次尝试联系 Ivanti -
2024.02.13:第二次尝试联系 Ivanti -
2024.02.19:第三次尝试联系 Ivanti -
2024.02.21:收到 Ivanti 的首次回复 -
2024.02.23:Ivanti 确认了漏洞 -
2024.03.07:Ivanti 预留了 CVE -
2024.04.29:跟进 Ivanti -
2024.05.28:Ivanti 公告中公开披露 -
2024.05.29:发布这篇博客文章
原文始发于微信公众号(3072):CVE-2024-22058 Ivanti LanDesk LPE 漏洞分析