在本系列的第一部分中,我们开始深入研究 Mimikatz。这个想法很简单就是为了揭示 Mimikatz 是如何施展它的魔力的以及说明如何开发自定义的和特定的有效载荷。 如果你还没有机会去一探究竟,请先移步到本系列文0章的第一部分。 接下来,在这篇文章中,我们将回顾一下突破微软新增的防止凭证转储的安全控制(如 Credential Guard)的一种好方法并提取受害者提供的凭证。 当然,这也是 Mimikatz 支持 SSP 的原因。
安全支持提供程序(Security Support Provider,SSP)是一个 DLL,它允许开发人员公开在某些身份验证和授权事件期间调用的许多回调。 正如我们在上一篇文章中看到的,WDigest 提供了使用这个确切的接口进行凭证缓存的能力。
Mimikatz 提供了一些不同的技术来利用 SSP。 首先是"Mimilib",它是一个带有各种功能的 DLL,其中一个就是实现 Security Support Provider 接口。 其次,还有"memssp",这是另外一种有趣的实现相同目标的方式,但它依赖于内存补丁,而不是通过加载一个 DLL 实现。
让我们从加载 SSP 的传统方法——Mimilib 开始探索之旅。
注意: 正如在上一篇文章中提到的,这篇文章大量使用了 Mimikatz 的源代码以及开发人员花费的无数时间。 感谢 Mimikatz, Benjamin Delpy 和 Vincent Le Toux 出色的研究工作。
Mimilib
Mimilib 有点像变色龙,支持 ServerLevelPluginDll 通过 RPC、DHCP 服务器 Callout 进行横向移动,甚至还可以作为 WinDBG 的扩展。 然而,出于我们的目的,我们将研究这个库如何作为一个 SSP,为攻击者提供一种方法来检索由受害者输入的凭证。
Mimilib 的工作原理是利用 SSP 接口使用明文凭证调用Security Support Provider。 这意味着凭证被以明文的方式漏出。 Mimilib 的 SSP 功能的入口点可以在 kssp.c 文件中找到,主要的入口是 kssp_SpLsaModeInitialize。 该函数通过 mimilib.def 这个定义文件从 DLL 中导出名为 SpLsaModeInitialize 的函数,并由 lsass 进程用于初始化一个包含多个回调的结构体。
以 Mimilib 为例,注册的回调方式如下:
· SpInitialize – 用于初始化 SSP 并提供函数指针列表
· SpShutDown – 在卸载 SSP 时提供释放资源的机会
· SpGetInfoFn – 提供有关于 SSP 的信息,包括版本、名称和描述
· SpAcceptCredentials – 接收 LSA 传递的明文凭证,并由 SSP 进行缓存
当然,如果你阅读了上一篇文章,你会发现 WDigest 使用 SpAcceptCredentials 来缓存凭证,因此,这导致产生了我们多年来一直在“享用”的漏洞。
随着每个回调的填充,并且知道系统将通过一个明文凭证的副本来调用 SpAcceptCredentials,之后 Mimilib 所需要做的就是按提供的方式存储凭证,这正是 kssp_SpAcceptCredentials 所做的事情:
NTSTATUS NTAPI kssp_SpAcceptCredentials(SECURITY_LOGON_TYPE LogonType, PUNICODE_STRING AccountName, PSECPKG_PRIMARY_CRED PrimaryCredentials, PSECPKG_SUPPLEMENTAL_CRED SupplementalCredentials) { FILE *kssp_logfile; #pragma warning(push) #pragma warning(disable:4996) if(kssp_logfile = _wfopen(L"kiwissp.log", L"a")) #pragma warning(pop) { klog(kssp_logfile, L"[%08x:%08x] [%08x] %wZ\\%wZ (%wZ)\t", PrimaryCredentials->LogonId.HighPart, PrimaryCredentials->LogonId.LowPart, LogonType, &PrimaryCredentials->DomainName, &PrimaryCredentials->DownlevelName, AccountName); klog_password(kssp_logfile, &PrimaryCredentials->Password); klog(kssp_logfile, L"\n"); fclose(kssp_logfile); } return STATUS_SUCCESS; }
现在,我不认为 mimikatz.exe 提供了支持直接加载 Mimilib 的功能,但是我们从微软的文档中可以知道,通过添加注册表键和并重新启动,就可以添加 SSP。
然而,经过一番搜索,我发现了这条推文:
推文的意思当然是在说对 API AddSecurityPackage 的引用,这实际上是@mattifestation 编写的 Install-SSP.ps1 脚本中加载 SSP 所使用的方法。 这意味着,实际上我们可以在不重新启动系统的情况下加载 Mimilib。 加载后,我们发现每次提取凭证的尝试都会导致凭证被写入到 kiwissp.log 文件中:
现在,在成熟的环境中使用 SSP 有一个缺点,那就是 SSP 必须在 lsass 进程中进行注册。 这使得防御者在试图跟踪恶意活动时可以使用大量的工件,无论是为引用 SSP 而创建的注册表项,还是 lsass 进程中的某一个不正常的 DLL 模块。 我们还可以看到 SSP 公开了名称和注释这两个字段,可以使用 EnumerateSecurityPackages 函数进行枚举,如下:
#define SECURITY_WIN32 #include <stdio.h> #include <Windows.h> #include <Security.h> int main(int argc, char **argv) { ULONG packageCount = 0; PSecPkgInfoA packages; if (EnumerateSecurityPackagesA(&packageCount, &packages) == SEC_E_OK) { for (int i = 0; i < packageCount; i++) { printf("Name: %s\nComment: %s\n\n", packages[i].Name, packages[i].Comment); } } }
正如我们可以在下面所看到的,输出结果显示了每个已加载的 SSP 的信息,这意味着 Mimilib 可能比较显眼:
那么,我们该怎么做才能避免一眼就看出 Mimilib 呢? 显而易见的做法是修改 Mimilib 的 SpGetInfo 回调所返回的描述,这个回调被硬编码为如下内容:
NTSTATUS NTAPI kssp_SpGetInfo(PSecPkgInfoW PackageInfo) { PackageInfo->fCapabilities = SECPKG_FLAG_ACCEPT_WIN32_NAME | SECPKG_FLAG_CONNECTION; PackageInfo->wVersion = 1; PackageInfo->wRPCID = SECPKG_ID_NONE; PackageInfo->cbMaxToken = 0; PackageInfo->Name = L"KiwiSSP"; PackageInfo->Comment = L"Kiwi Security Support Provider"; return STATUS_SUCCESS; }
我们改变了 Name 和 Comment 字段后,输出结果如下:
显然这样做还是不够好(即使已经修改过 Name 和 Comment 字段)。 请记住, Mimilib 不需要移除和重新编译,它包含了一系列功能,而不仅仅是充当一个 SSP。
那么我们如何解决这个问题呢? 还好 Mimikatz 也支持 misc::memssp,它为我们提供了一个不错的选择。
MemSSP
MemSSP 可以追溯到处理 lsass 进程内存的过程,这一次是通过识别和补丁函数来实现重定向执行。
让我们看看最开始时所执行的函数 kuhl_m_misc_memssp。 这里我们看到 lsass 进程被打开,并开始搜索 msv1_0.dll 这个 DLL,它是一个支持交互式身份验证的身份验证包:
NTSTATUS kuhl_m_misc_memssp(int argc, wchar_t * argv[]) { ... if(kull_m_process_getProcessIdForName(L"lsass.exe", &processId)) { if(hProcess = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION, FALSE, processId)) { if(kull_m_memory_open(KULL_M_MEMORY_TYPE_PROCESS, hProcess, &aLsass.hMemory)) { if(kull_m_process_getVeryBasicModuleInformationsForName(aLsass.hMemory, L"msv1_0.dll", &iMSV)) { ...
下一步是在内存中搜索一个模式,同样类似于我们在 WDigest 中看到的:
... sSearch.kull_m_memoryRange.kull_m_memoryAdress = iMSV.DllBase; sSearch.kull_m_memoryRange.size = iMSV.SizeOfImage; if(pGeneric = kull_m_patch_getGenericFromBuild(MSV1_0AcceptReferences, ARRAYSIZE(MSV1_0AcceptReferences), MIMIKATZ_NT_BUILD_NUMBER)) { aLocal.address = pGeneric->Search.Pattern; if(kull_m_memory_search(&aLocal, pGeneric->Search.Length, &sSearch, TRUE)) { ...
现在,让我们暂停对代码的审计,然后进入 Ghidra,搜索正在使用的模式后跳到了下图所示的代码处:
这里我们揭示了实际发生的情况… memssp 被用于 HOOK msv10.dll 的 SpAcceptCredentials 函数来恢复凭证。 让我们跳转到调试器,看看添加钩子后的效果如何。
首先,我们确认 SpAcceptCredentials 包含一个钩子:
接下来,当我们单步执行时,我们发现我们被绑定到一个存根上,通过在堆栈上构建一个文件名并将其传递给 fopen 来创建一个日志文件:
一旦打开这个日志文件,传递给 SpAcceptCredentials 的凭证就会被写入这个文件:
最后,执行回到了 msv1_0.dll 中的代码:
如果你想查看这个钩子的代码,实际的源代码可以在 kuhl_m_misc.c 文件的 misc_msv1_0_SpAcceptCredentials 函数中找到。
那么,我们使用这种技术的风险是什么呢? 我们可以看到,上面的钩子是通过 kull_m_memory_copy 复制到 lsass 进程中的,它实际上使用了 WriteProcessMemory 这个 API。 根据环境的不同,对另一个进程执行 WriteProcessMemory 调用可能会被杀软检测到或被标记为可疑行为,当针对 lsass 进程执行这样的操作时更是如此。
现在,探索 Mimikatz 内部技术细节的好处之一就是允许我们改变与 lsass 进程互动的配置文件,使得防御者更难说清楚他们的检测逻辑并说"啊,我以前见过这一连串的事件,那是 Mimikatz!" 。 那么让我们来看看我们能做些什么来迷惑一下防御者。
不用 WriteProcessMemory 重新创建 memssp 的方法
在回顾了上面提供的技术之后,我们可以知道每种技术都有优点和缺点。
第一种方法(Mimilib)依赖于注册 SSP,可以通过 EnumerateSecurityPackages 返回注册的提供程序列表显示已注册的 SSP。 此外,如果 Mimilib 库没有进行修改,那么还有一大堆附加功能与 DLL 捆绑在一起。 此外,当加载 AddSecurityProvider 时,注册表值将被修改为在重新系统期间保持 SSP。 尽管如此,这种技术依旧有一个很大的优势就是,它不需要一个具有潜在风险的 WriteProcessMemory API 调用来达到其目的。
第二个方法(memssp)严重依赖于受监控的 API 调用,比如 WriteProcessMemory,该 API 用于将钩子加载到 lsass进程 中。 然而,这种技术的一个很大的优点是,它不会出现在已注册的 SSP 列表中,也不会作为加载的 DLL 出现。
那么,我们能做些什么来改变现状呢? 我们可以潜在地将这两种方法结合起来,使用 AddSecurityProvider 加载我们的代码,同时避免显示在已注册的 SSP 列表中暴露我们自己的 SSP。 我们是否可以找到一种避免直接调用 AddSecurityProvider API 的方法,这个方法应该有助于解决任何烦人的 AV 或 EDR HOOK 这个函数。
首先,让我们来看看 AddSecurityPackage 是如何注册一个 SSP 的,这意味着我们将需要进行一些逆向工作。 我们将从公开这个 API 的 secur32.dll 这个 DLL 文件开始。
在 Ghidra 打开这个 DLL 文件,我们很快发现这实际上只是一个调用 sspcli.dll 的包装器:
在 sspcli.dll 中反汇编 AddSecurityPackage,特别需要注意的是这个函数所使用的 API 调用,我们看到了对 NdrClientCall3 的引用,这意味着这个函数使用了 RPC 调用方式。这是有道理的,因为这个调用需要以某种方式向 lsass 进程发出信号,告诉 lsass 进程应加载新的 SSP:
在调用 NdrClientCall3 时,我们发现传递了以下参数:
这里给出了一个值是3 的 nProcNum 参数,如果我们深入研究 sspirpc_ProxyInfo 结构体,就会发现 RPC 接口的 UUID 为 4f32adc8-6052-4a04-8701-293ccf2096f0:
现在我们已经有足够的信息利用 RpcView 分析 sspisrv.dll 暴露出的 SspirCallRpc 这个 RPC 调用:
要使用这个调用,我们需要知道传递的参数,这些参数当然可以从 RpcView 恢复,具体如下:
long Proc3_SspirCallRpc( [in][context_handle] void* arg_0, [in]long arg_1, [in][size_is(arg_1)]/*[range(0,0)]*/ char* arg_2, [out]long* arg_3, [out][ref][size_is(, *arg_3)]/*[range(0,0)]*/ char** arg_4, [out]struct Struct_144_t* arg_5);
但是,在实现这个调用之前,我们需要知道作为参数 arg_2 所传递的参数值(arg_1 被标记为 arg_2 的大小,arg_3、arg_4和 arg_5 都被标记为“out”)。 我发现最简单的方法就是启动调试器,在 AddSecurityPackage 调用 NdrClientCall3 之前添加一个断点:
一旦暂停执行之后,我们就可以转储传递给每个参数的参数值。 让我们使用 dq rsp+0x20 L1 获取在 arg_1 参数中传递的缓冲区的大小:
因此我们可以知道,在这种情况下,传递的缓冲区长度为 0xEC 个字节。 现在我们就可以转储参数 arg_2:
经过一番挖掘,我能够将这些参数值中的大部分联系起来。 让我们把输出的请求重新格式化为 QWORD 并标记出来,这样我们就可以看到我们所处理的是什么内容了:
现在我们已经映射了大部分被传递的数据,我们可以尝试并发出一个 RPC 调用,而不必直接调用 AddSecurityPackage 这个 API。 你可以在这个 Gist 中获得我精心编写的代码。
现在我们已经实现通过不直接调用 AddSecurityPackage 加载包的能力,接下来,让我们看看是否可以进一步混合这些内容。
让我们将 sspisrv.dll 丢到 Ghidra 中,并分析服务端是如何处理 RPC 调用的。 我们在反汇编 SspirCallRpc 时所遇到的直接问题是,执行是通过 gLsapSspiExtension 传递的:
这实际上是一个指向函数数组的指针,通过 lsasrv.dll 填充,并指向 LsapSspiExtensionFunctions:
我们对 SspiExCallRpc 非常感兴趣,它与我们在 RPCView 中发现的函数非常相似。这个函数验证参数并将执行传递给 LpcHandler:
LpcHandler 负责在最终将执行传递给 DispatchApi 之前进一步检查所提供的参数:
同样,另一个函数指针数组用于分发调用,由 LpcDispatchTable 指向这个函数指针数组:
现在,我们应该对这个数组产生兴趣,因为我们可能会根据它的名称来查找 s_AddPackage,而且索引也与我们在请求中找到的0xb "Function ID" 索引相匹配。
让我们进一步深入这个“兔子洞”,我们分析到了 WLsaAddPackage,这个函数首先检查我们是否有足够的特权通过模拟连接客户机调用 RPC 方法,然后试图用读和写权限打开 HKLM\System\CurrentControlSet\Control\Lsa 的注册表项:
如果这个操作能够成功(注意这可能是一个新的提权后门) ,那么执行就会转移到 SpmpLoadDll 上,它通过 LoadLibraryExW 将我们提供的 SSP 加载到 lsass 进程中:
如果 SSP 成功加载,DLL 就会被添加到注册表中以便自动加载:
从上面的整个分析过程来看,我们可能希望跳过最后的注册表那个点,因为我们不会用它来实现权限的持久性,而且最好避免读写注册表。 在理想情况下,我们也希望避免在有怀疑的情况下,像 ProcessExplorer 之类的分析工具在 lsass 进程中列出我们自己的 DLL。 因此,我们可以做的就是使用 RPC 调用传递我们的 DLL,并通过从这个 DLL 的 DllMain 入口函数返回 FALSE 来强制 SSP 在加载时失败。 这样就可以跳过注册表的修改操作,也意味着从进程中卸载掉了我们的 DLL。
我使用 Mimikatz memssp 作为模板,精心制作了一个通过 RPC 调用加载的 DLL,这个 DLL 会使用 Mimikatz 所使用的钩子技术对 SpAddCredentials 进行补丁。可以在这个 Gist 获得代码。
让我们看看如何使用 AddSecurityPackage 的原始 RPC 调用加载 DLL:
[点击观看视频]通过 AddSecurityPackage 的原始 RPC 调用加载 memssp DLL
你也不必局限于从本地系统加载 DLL,因为通过 RPC 调用传递一个 UNC 路径也可以正常加载 DLL(尽管你需要确保目标环境中的 EDR 不会将这种方式标记为可疑行为)。
当然,你也不应该仅限于使用 AddSecurityPackage 加载 DLL。 因为我们已经精心制作了一个独立的 DLL 来执行 memssp 补丁。让我们从上一篇博客文章中提取 SAMR RPC 脚本,让这个脚本通过 LoadLibrary 加载我们的 DLL,并通过 SMB 共享写回登录尝试:
当然,除此之外还有很多方法可以提高这些例子的效率,但是和第一部分文章一样,我希望这篇文章能够为你提供一个关于如何制作你自己的 SSP 的想法。 虽然这篇文章只是介绍了几种把 SSP 加载到 lsass 进程中时可能用到的方法,但是通过了解 Mimikatz 提供这种功能的内部细节之后,在你试图绕过 AV 或 EDR 时,或者只是想测试一下防御者所标记的 Mimilib 和 memssp 之外的检测能力时,希望你有能力根据目标环境调整你的有效载荷。