Я, как и многие, пользуюсь дискордом и китайскими прокси клиентами, но, к сожалению, голосовой чат дискорда не поддерживает работу через прокси. Из-за этого постоянно приходится включать режим TUN/VPN, который заворачивает в туннель много лишнего.

Можно ли с этим что-то сделать? Конечно! В этой статье мы разберемся как подчинять и модифицировать чужие приложения, а так же тут вы можете скачать готовое решение.

Мне не интересны технические подробности, как мне установить это себе?

Будьте добры, у вас есть два способа установки:

Автоматическая установка

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

  1. v2rayN

  2. NekoRay / NekoBox

  3. Invisible Man - XRay (режим прокси должен быть socks)

Хотите удалить? Запустите инсталлятор еще раз, он предложит вам удалить установленные файлы.

Ручная установка

Хотите установить вручную? Никаких проблем.

  1. Для начала скачайте DWrite.dll и force-proxy.dll со страницы релиза

  2. Откройте в проводнике %LocalAppData%\Discord

  3. Найдите папку app с самой новой версией и поместите туда оба dll файла

  4. Создайте файл proxy.txt и впишите туда:

SOCKS5_PROXY_ADDRESS=ВАШ_ПРОКСИ_IP
SOCKS5_PROXY_PORT=ВАШ_ПРОКСИ_ПОРТ

Не забудьте перезапустить дискорд. Готово!

А теперь к делу

Дальше в этой статье будут подробности как это работает и как создавалось.

Как перенаправить трафик отдельного приложения в SOCKS5 прокси

Для приложения, которое само по себе не поддерживает работу через прокси, на ум приходят следующие варианты:

  1. Использовать уже готовые программы, к примеру Proxifier. Эти программы платные (и весьма не дешевые), так еще и возникают сложности с оплатой из РФ. Так что этот вариант нам не подходит.

  2. Взять в руки дизассемблер и пропатчить исполняемый файл нужной программы. Кроме трудоемкости этого подхода возникает еще проблема с тем, что наши модификации будут жить до ближайшего обновления. Так что тоже мимо.

  3. Написать или взять уже готовый драйвер, в котором мы будем фильтровать все пакеты ОС, вычленять связанные с конкретными процессами и перенаправлять их в прокси. Вариант на бумаге вполне жизнеспособный, но не в рамках петпрожекта одного человека. Первая проблема это относительная сложность реализации такого решения, сложность отладки и высокая цена ошибки - почти любая ошибка отправит компьютер пользователя в BSOD. Но с этим можно жить, у меня есть некий опыт таких проектов, но тут всплывает вторая проблема - чтобы любой пользователь мог загрузить ваш драйвер его нужно подписать сертификатом и отправить его на проверку в Microsoft. Причем не абы каким, а EV (Extended Validation), которые могут купить только организации. Так что для меня это не вариант. Хотя если бы я делал свой клиент, я поступил бы именно так.

  4. Каким-либо образом получить возможность исполнять код в контексте нужного нам процесса, в его памяти модифицировать несколько Winsock 2 API функций для того, чтобы они делали то, что нужно нам. Хорошим вариантом доставки кода являются библиотеки динамической линковки (DLL). Так и поступим.

Этот проект идеологически разделен на 2 части:

  1. @runetfreedom/force-proxy - это dll, которая осуществляет перехват API сокетов. Я вынес ее в отдельный проект, так как она никак не привязана к дискорду, а следовательно ее можно применять для других задач.

  2. @runetfreedom/discord-voice-proxy - это proxy dll (proxy в контексте dll hijacking), которая считывает конфиг и загружает force-proxy.dll в процессы дискорда. Установщик также является частью этого репозитория.

Реализация перехвата системных вызовов

В этой части статьи мы рассмотрим реализацию force-proxy.dll

Дисклеймер - примеры кода ниже будут приводиться в хронологическом порядке их написания. Это значит, что по ходу реализации новых функций я рефакторил уже написанное, так что не удивляйтесь странным временным решениям, скорее всего они были отрефакторены. Так же автор не является большим писателем на C++ (в той части, где он ++).

Как перехватывать вызов какой-либо функции в x86-64

Так как глубокое погружение в перехваты не является темой этой статьи, то описание будет достаточно поверхностым, просто чтобы обеспечить минимальное понимание как это работает.

Самый простой способ перехватить функцию это записать в самое ее начало инструкцию безусловного перехода (jmp). Это позволяет перепрыгнуть на исполнение кода по заданному адресу памяти без модификации стека и регистров (кроме RIP, конечно). Наша функция, в которую мы прыгаем называется Detour.

Инструкция jmp в данном случае занимает не менее 5 байт, так что первые инструкции оригинальной функции в первых 5 байтах перезаписываются. Но ведь функция не сможет работать без них, так что выделяется небольшая область исполняемой памяти, туда переносятся эти инструкции из начала оригинальной функции и вставляется еще один переход на продолжение оригинальной функции. Эта область памяти называется Trampoline

Так что на примере дальше:

  1. connect - оригинальная функция, в ее начало будет вставлен jmp на Mine_connect

  2. Mine_connect - наша detour функция, которая будет получать управление когда программа вызовет connect. Она может либо вернуть что-то свое, либо вернуть управление оригинальной функции connect через вызов Real_connect

  3. Real_connect - это trampoline, который содержит первые 5 байт из функции connect и jmp на инструкцию connect+5

К счастью, существует множество библиотек для автоматизации установки хуков, к примеру MinHook или Microsoft Detours. Обычно я использую MinHook для таких вещей, но в этом проекте мне захотелось попробовать Microsoft Detours. Всегда интересно пробовать что-то новое.

Перехват TCP подключений

Проще всего начать с перехвата TCP подключений. Фактически для этого достаточно вместо подключения по адресу назначения подключиться к прокси серверу, передать ему адрес назначения и все - дальше это подключение будет прозрачно пересылать пакеты через прокси. Так что тут будет достаточно одного хука - на функцию connect.

SOCKS5 протокол

В соответствии с RFC1928 после подключения мы должны выполнить рукопожатие и авторизоваться (опционально). Для этого мы должны отправить 3 байта: версию (0x05), количество методов авторизации (0x01) и метод авторизации (0x00) - NO AUTHENTICATION в нашем случае. В ответ мы должны получить 2 байта - версию (0x05) и подтверждение метода авторизации (0x00)

После этого мы должны отправить запрос CONNECT чтобы прокси сервер выполнил подключение к серверу назначения. Полностью запрос выглядит следующим образом:

  1. Версия прокси - 1 байт (0x05)

  2. Команда - 1 байт (0x01 для CONNECT)

  3. Зарезервировано - 1 байт (0x00)

  4. Тип адреса назначения - 1 байт (0x01 для IPv4)

  5. Адрес назначения - 4 байта

  6. Порт назначения - 2 байта

Реализуем это:

int ConnectToProxy(SOCKET s)
{
    SOCKADDR_IN proxyAddr;
    proxyAddr.sin_family = AF_INET;
    proxyAddr.sin_addr = g_ProxyAddress;
    proxyAddr.sin_port = g_ProxyPort;

    return Real_connect(s, (struct sockaddr*)&proxyAddr, sizeof(proxyAddr));
}

int SendSocks5Handshake(SOCKET s)
{
    //Send socks5 handshake
    uint8_t request[] = { 0x05, 0x01, 0x00 };
    send(s, (const char*)request, sizeof(request), 0);

    //Receive response
    uint8_t response[2];
    recv(s, (char*)response, sizeof(response), 0);

    if (response[0] != 0x05 || response[1] != 0x00) {
        return SOCKET_ERROR;  // Socks5 auth error
    }

    return ERROR_SUCCESS;
}

int ConnectThroughSocks5(SOCKET s, const struct sockaddr_in* targetAddr)
{
    if (ConnectToProxy(s) != ERROR_SUCCESS) {
        return SOCKET_ERROR;
    }

    if (SendSocks5Handshake(s) != ERROR_SUCCESS) {
        return SOCKET_ERROR;
    }

    // send CONNECT request
    uint8_t connectRequest[10] = { 0x05, 0x01, 0x00, 0x01 }; // SOCKS5, CONNECT, reserved, IPv4
    memcpy(connectRequest + 4, &targetAddr->sin_addr, 4); // Target ip
    memcpy(connectRequest + 8, &targetAddr->sin_port, 2); // Target port

    send(s, (const char*)connectRequest, sizeof(connectRequest), 0);
	
    uint8_t connectResponse[10];
    recv(s, (char*)connectResponse, sizeof(connectResponse), 0);

    if (connectResponse[1] != 0x00) {
        return SOCKET_ERROR;  // Connection error
    }

    return ERROR_SUCCESS;    
}

Реализация перехваченной функции connect

К прокси мы научились подключаться, теперь нужно объявить прототип функции connect и написать собственную реализацию:

Нужно учесть, что нас интересует перенаправление не каждого подключения, как минимум нам стоит пропускать подключения к прокси серверу и локалхосту. Так же нас пока интересует только пространство адресов IPv4 (AF_INET)

extern "C" {
	int (WINAPI* Real_connect)(SOCKET s, const sockaddr* name, int namelen) = connect;
}

int WINAPI Mine_connect(SOCKET s, const sockaddr* name, int namelen)
{
	const struct sockaddr_in* addr_in = reinterpret_cast<const struct sockaddr_in*>(name);
	
	char taget[INET_ADDRSTRLEN];
	inet_ntop(AF_INET, &(addr_in->sin_addr), taget, INET_ADDRSTRLEN);

	//skip connection to localhost and proxy server
	if (addr_in->sin_addr.s_addr == g_ProxyAddress.s_addr || !strcmp(taget, "0.0.0.0") || !strcmp(taget, "127.0.0.1")) {
		return Real_connect(s, name, namelen);
	}
	
	if (addr_in->sin_family == AF_INET) {
		return ConnectThroughSocks5(s, addr_in);
	}

	return Real_connect(s, name, namelen);
}

Перехватчик

В соответствии с документацией реализуем установку хука

void InitHooks()
{
	DetourTransactionBegin();
	DetourUpdateThread(GetCurrentThread());
	DetourAttach((PVOID*)(&Real_connect), Mine_connect);
	DetourTransactionCommit();
}

И вызовем его в нашем DllMain:

#pragma comment(lib, "ws2_32.lib")
#pragma comment(lib, "detours.lib")

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    if (DetourIsHelperProcess())
    {
        return TRUE;
    }

    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        DisableThreadLibraryCalls(hModule);
        DetourRestoreAfterWith();
        InitHooks();

        break;
    }
    return TRUE;
}

Отлично, в тестовой микропрограмме после загрузки dllки через LoadLibrary все дальнейшие TCP подключения перехватываются.

Перехват UDP

С UDP все значительно сложнее. Как известно, в UDP нет такого понятия как соединение, так еще и для socks5 мы должны инкапсулировать каждый udp пакет.

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

При этом каждая UDP датаграмма должна быть инкапсуллирована - мы должны добавить (и потом удалить) 10 байтный заголовок, в котором содержится адрес и порт назначения, и в котором сервер нам вернет адрес и порт отправителя.

Запрос UDP ASSOCIATE точно такой же, как и CONNECT, только второй байт должен быть 0x03. Заголовок датаграммы выглядит так:

  1. Зарезервировано - 2 байта (0x00 0x00)

  2. Флаг фрагментации - 1 байт (0x00)

  3. Тип адреса назначения / отправителя - 1 байт (0x01)

  4. Адрес - 4 байта

  5. Порт - 2 байта

Реализация запроса UDP ассоциации

bool InitializeSocks5UdpAssociation(sockaddr_in *udpProxyAddr) {
    //We need tmp socket to request udp association
    SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (s == INVALID_SOCKET) {
        return false;
    }

    if (ConnectToProxy(s) != ERROR_SUCCESS) {
        return false;
    }

    if (SendSocks5Handshake(s) != ERROR_SUCCESS) {
        return false;
    }

    // Request UDP associate
    // We don't have to specify dst since proxies usually get it from encapsulating header
    uint8_t udpAssociateRequest[10] = { 0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0 }; // SOCKS5, UDP ASSOCIATE, reserved, IPv4, dst addr, dst port
    send(s, (const char*)udpAssociateRequest, sizeof(udpAssociateRequest), 0);
	
    uint8_t udpAssociateResponse[10];
    recv(s, (char*)udpAssociateResponse, sizeof(udpAssociateResponse), 0);

    if (udpAssociateResponse[1] != 0x00) {
        return false;
    }

    Real_closesocket(s);

    // Get address and port to send UDP packets
    udpProxyAddr->sin_family = AF_INET;
    memcpy(&udpProxyAddr->sin_addr, udpAssociateResponse + 4, 4);
    memcpy(&udpProxyAddr->sin_port, udpAssociateResponse + 8, 2);

    return true;
}

В ответ прокси сервер возвращает нам ip и порт, куда мы должны слать свои UDP датаграммы. Очевидно, мы должны это запомнить и использовать в перехваченных функциях отправки данных.

Но в какой момент запрашивать ассоциацию? На мой взгляд варианта два - либо в момент создания сокета с типом SOCK_DGRAM или в момент вызова функции bind (но тогда надо проверить, что сокет все же имеет тип UDP). За бинд играет то, что это 1 перехват вместо 3х для создания сокета (socket, WSASocketA, WSASocketW).

Перехватим bind

std::shared_mutex g_SocketsMapsMutex;
std::map<SOCKET, SOCKADDR_IN> g_UDPAssociateMap;

bool IsUDPSocket(SOCKET s)
{
	int32_t sockOptVal;
	int32_t sockOptLen = sizeof(sockOptVal);

	if (getsockopt(s, SOL_SOCKET, SO_TYPE, (char*)&sockOptVal, &sockOptLen) != 0)
		return false;

	return sockOptVal == SOCK_DGRAM;
}

bool SocketExistsInUdpAssociationMap(SOCKET s)
{
	g_SocketsMapsMutex.lock_shared();
	bool exists = g_UDPAssociateMap.count(s);
	g_SocketsMapsMutex.unlock_shared();

	return exists;
}

int WINAPI Mine_bind(SOCKET s, const sockaddr* addr, int namelen)
{
	//not UDP or already exists
	if (!IsUDPSocket(s) || SocketExistsInUdpAssociationMap(s))
		return Real_bind(s, addr, namelen);

	sockaddr_in udpProxyAddr;
	if (!InitializeSocks5UdpAssociation(&udpProxyAddr)) {
		return Real_bind(s, addr, namelen);
	}

	g_SocketsMapsMutex.lock();
	g_UDPAssociateMap.insert(std::pair<SOCKET, SOCKADDR_IN>(s, udpProxyAddr));
	g_SocketsMapsMutex.unlock();

	return Real_bind(s, addr, namelen);
}

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

int WINAPI Mine_closesocket(SOCKET s)
{
	g_SocketsMapsMutex.lock();
	g_UDPAssociateMap.erase(s);
	g_SocketsMapsMutex.unlock();

	return Real_closesocket(s);
}

Перехватим функции отправки и приема данных

Вообще эти функции перехватываются парами (sendto + WSASendTo и recvfrom + WSARecvFrom). Но в этой статье я приведу пример реализации только по одной функции каждого типа, потому что они почти одинаковые.

Нужно учесть, что chrome под капотом дискорда использует в том числе mDNS (Multicast DNS). Очевидно, нам не стоит отправлять мультикаст пакеты в прокси, так что учтем это.

Давайте инкапсулируем пакет и отправим данные в наш ассоциированный прокси порт:

bool IsMultiCastAddr(const sockaddr *addr)
{
	uint32_t ip = ntohl(((SOCKADDR_IN*)addr)->sin_addr.s_addr);

	return (ip & 0xF0000000) == 0xE0000000;
}

void EncapsulateUDPPacket(WSABUF* target, char *buf, int len, const sockaddr* lpTo)
{
    target->len = len + 10; // packet len + encasulated size
    target->buf = (char *)malloc(target->len);

    target->buf[0] = 0; // Reserved
    target->buf[1] = 0; // Reserved
    target->buf[2] = 0; // Fragmentation flag
    target->buf[3] = 1; // IPv4

    const struct sockaddr_in* addr = reinterpret_cast<const struct sockaddr_in*>(lpTo);
    memcpy(&target->buf[4], &addr->sin_addr.s_addr, sizeof(addr->sin_addr.s_addr)); //ip addr
    memcpy(&target->buf[8], &addr->sin_port, sizeof(addr->sin_port)); // port

    memcpy(&target->buf[10], buf, len); //copy whole packet
}

int WINAPI Mine_sendto(SOCKET s, const char* buf, int len, int flags, const struct sockaddr* to, int tolen)
{
	if (SocketExistsInUdpAssociationMap(s) && !IsMultiCastAddr(to)) {
		g_SocketsMapsMutex.lock_shared();
		auto proxyAddr = &g_UDPAssociateMap[s];
		g_SocketsMapsMutex.unlock_shared();

		WSABUF destBuff;
		EncapsulateUDPPacket(&destBuff, (char *)buf, len, to);

		auto sended = Real_sendto(s, destBuff.buf, destBuff.len, 0, (const sockaddr*)proxyAddr, sizeof(*proxyAddr));
		free(destBuff.buf);
		
		return sended;
	}

	return Real_sendto(s, buf, len, flags, to, tolen);
}

Теперь нам нужно принимать пакеты и возвращать их приложению. Для работы RTC важно, чтобы поле from было корректным, так что заодно вытащим его из заголовка.

void ExtractSockAddr(char* buf, sockaddr* target)
{
    const struct sockaddr_in* addr = reinterpret_cast<const struct sockaddr_in*>(target);
    memcpy((void*)&addr->sin_addr.s_addr, &buf[4], sizeof(addr->sin_addr.s_addr)); //ip addr
    memcpy((void*)&addr->sin_port, &buf[8], sizeof(addr->sin_port)); // port
}

int WINAPI Mine_recvfrom(SOCKET s, char* buf, int len, int flags, struct sockaddr* from, int* fromlen)
{
	auto received = Real_recvfrom(s, buf, len, flags, from, fromlen);

	if (received != SOCKET_ERROR && SocketExistsInUdpAssociationMap(s)) {
		//Encapsulated header is 10 bytes
		if (received < 10) {
			return SOCKET_ERROR;
		}

		ExtractSockAddr(buf, from);

		memmove(buf, &buf[10], received -= 10);
	};

	return received;
}

Тестируем

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

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

Пока совершенно не понятно что пошло не так, особенно учитывая что в минипрограмме все работало. Причем ситуация усугубляется тем, что хромиум спавнит кучу процессов для разных задач, что значительно усложняет дебаг инструментами типа ida pro или x64dbg.

В этот момент я принимаю единственное правильное решение - иду компилировать chromium. Уверен, что отладить можно было и без этого, но бинарник размером в 200 мегабайт меня немного пугал, я посчитал, что в нем слишком легко потеряться и запутаться. А раз доступен исходный код - надо эти пользоваться. Компиляция хромиума заняла 1.5 часа на 14900kf и еще где-то минут 20 Visual Studio пыталась открыть солюшн.

Встроили в main хромиума LoadLibraryA, скомпилили, запустили дебаг и...

Установка TCP соединения в кишках хромиума
Установка TCP соединения в кишках хромиума

Понятно, хром использует неблокируемые сокеты и при попытке подключения ожидает, что мы вернем ему ошибку WSAEWOULDBLOCK. Хорошо, звучит не сложно, тогда нам для начала нужно определить, что сокет находится в неблокируемом режиме. Для этого перехватим WSAEventSelect, ioctlsocket и проверим переданные параметры. К сожалению, не существует api, благодаря которому мы могли бы узнать тип сокета.

std::map<SOCKET, long> g_NonBlockingSockets;

bool SocketExistsInNonBlockingMap(SOCKET s)
{
	g_SocketsMapsMutex.lock_shared();
	bool exists = g_NonBlockingSockets.count(s);
	g_SocketsMapsMutex.unlock_shared();

	return exists;
}

int WSAAPI Mine_WSAEventSelect(SOCKET s, WSAEVENT hEventObject, long lNetworkEvents)
{

	g_SocketsMapsMutex.lock();
	if (hEventObject != NULL && lNetworkEvents != 0) {
		g_NonBlockingSockets.insert(std::pair<SOCKET, long>(s, lNetworkEvents));
	} else {
		g_NonBlockingSockets.erase(s);
	}
	g_SocketsMapsMutex.unlock();

	return Real_WSAEventSelect(s, hEventObject, lNetworkEvents);
}

int WSAAPI Mine_ioctlsocket(SOCKET s, long cmd, u_long* argp)
{
	if (cmd == FIONBIO) {
		g_SocketsMapsMutex.lock();
		if (*argp) {
			g_NonBlockingSockets.insert(std::pair<SOCKET, long>(s, *argp));
		} else {
			g_NonBlockingSockets.erase(s);
		}
		g_SocketsMapsMutex.unlock();
	}

	return Real_ioctlsocket(s, cmd, argp);
}

Что же, теперь мы знаем тип сокета, можно работать с ним как с неблокируемым. Мы, очевидно, не можем сами подписываться на события сокетов, так как это сломает отправку событий для программы, так что обойдемся функцией select. Ну и добавим наши функции ожидания перед всеми send и recv при работе с socks5 сокетами.

bool WaitForWrite(SOCKET s, int timeoutSec)
{
    fd_set writeSet;
    FD_ZERO(&writeSet);
    FD_SET(s, &writeSet);

    timeval timeout = { timeoutSec, 0 };
    int result = select(0, NULL, &writeSet, NULL, &timeout);
    if (result > 0 && FD_ISSET(s, &writeSet)) {
        return true;
    }

    return false;
}

bool WaitForRead(SOCKET s, int timeoutSec)
{
    fd_set readSet;
    FD_ZERO(&readSet);
    FD_SET(s, &readSet);

    timeval timeout = { timeoutSec, 0 };
    int result = select(0, &readSet, NULL, NULL, &timeout);
    if (result > 0 && FD_ISSET(s, &readSet)) {
        return true;
    }

    return false;
}

bool SetNonBlockingMode(SOCKET s, bool nonBlocked)
{
    u_long mode = nonBlocked;
    return Real_ioctlsocket(s, FIONBIO, &mode) == NO_ERROR;
}

Измененный код с добавлением ожидания приводить не буду, так как он тривиален. Так же в подключение к прокси для TCP добавим возврат правильного статуса

   if (nonBlocking) {
       WSASetLastError(WSAEWOULDBLOCK);
       return SOCKET_ERROR;
   }

Во время тестирования возник еще один нюанс - если программа использует неблокирумые сокеты, а мы внутри bind делаем коннект обычным блокируемым сокетом, то после успешного завершения bind сокеты встают во взаимную блокировку. Я не совсем понял как связан мой отдельный сокет для общения с прокси сервером и неблокирумый сокет, который передается в bind, но тем не менее. Впрочем, проблема решилась просто переводом моего сокета в неблокируемый режим.

Мне было очень интересно, я пытался это отдебажить, но понял, что все встает после обращения к ядру через NtDeviceIoControlFile и мне стало лениво ставить виртуалку и дебажить ядро. Но если кто-то знает почему так - расскажите в комментариях.

На этом этапе у нас есть DLL, которая работает и перенаправляет трафик через прокси.

Как загрузить DLL в дискорд

Очевидно, мало иметь dll, надо еще суметь ее загрузить, причем желательно автоматически.

Вот какие варианты загрузки у нас есть:

  1. Использовать любой из сотен DLL инжекторов. Способ насколько очевидный, настолько и бесполезный. Хромиум спавнит кучу процессов, причем часто спавнит новые при подключениях к серверам, так что руки отсохнут инжектить.

  2. Использовать AppInit_DLLs - это параметр реестра, который просит ОС загружать указанную DLL ВО ВСЕ запускаемые процессы. Этот метод имеет кучу проблем: Требует подписи DLL сертификатом, создает огроменную дыру в безопасности, приведет к банам в онлайн играх, ибо античит не обрадуется загруженной DLL, которая еще и хуки на сетевые функции может ставить.

  3. Эксплуатировать старейшую уязвимость порядка поиска DLL файлов - DLL Hijacking. Отличный вариант, который требует от пользователя просто положить файл в папку с дискордом.

DLL Hijacking

Я не буду в подробностях расписывать, как работает DLL Hijacking, так как на хабре есть уже куча статей на эту тему, к примеру - вот

В двух словах - мы будем эксплуатировать то, что при попытке загрузить DLL она сначала ищется в папке с приложением, а только потом в C:\Windows\System32

Что нам нужно? Нам нужно найти библиотеку, которую загружает целевое приложение, создать свою с таким же именем и такими же экспортами, ну и перенаправить эти экспорты в оригинальную DLL. Наша DLL в данном случае будет называться proxy DLL. Наша цель состоит в том, чтобы был вызван DllMain нашей прокси dll, в котором мы осуществим загрузку полезной нагрузки.

Давайте посмотрим на импорты дискорда и найдем подходящую библиотеку

Ну, это было не сложно - сразу нашлась библиотека с одной единственной импортируемой функцией. Значит ее и будем использовать.

Для начала сделаем свою DLL, которая будет загружать оригинальную DWrite.dll и сохранять адрес функции DWriteCreateFactory в переменную.

HMODULE OriginalDLL;
uintptr_t OrignalDWriteCreateFactory;

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
	char path[MAX_PATH];
	switch (ul_reason_for_call)
	{
	case DLL_PROCESS_ATTACH:
	{
		DisableThreadLibraryCalls(hModule);
		CopyMemory(path + GetSystemDirectoryA(path, MAX_PATH - 12), "\\DWrite.dll", 13);
      
		OriginalDLL = LoadLibraryA(path);
		OrignalDWriteCreateFactory = (uintptr_t)GetProcAddress(OriginalDLL, "DWriteCreateFactory");
		break;
	}
	case DLL_PROCESS_DETACH:
	{
		FreeLibrary(OriginalDLL);
		break;
	}
	}
	return TRUE;
}

Отлично, мы загрузили исходную DLL и сохранили адрес нужной функции, теперь нужно создать свой экспорт с таким же именем и перенаправить туда вызов этой функции. Как мы уже знаем, самый простой способ сделать это - вставить jmp. Так как MSVC не поддерживает inline ассемблер, то создадим отдельный .asm файл для этого

.code

extern OrignalDWriteCreateFactory:qword

DWriteCreateFactory proc EXPORT
  jmp OrignalDWriteCreateFactory
DWriteCreateFactory endp

end

Загружаем force-proxy.dll

На этом этапе у нас есть полноценная прокси dll, а следовательно возможность выполнять любой код в контексте процесса дискорда. Воспользуемся этим чтобы распарсить конфиг и загрузить force-proxy.dll

void LoadForceProxy()
{
	std::ifstream file("proxy.txt");
	std::string line;

	while (file.is_open() && getline(file, line)) {
		std::stringstream ss(line);

		std::string key, value;
		if (getline(ss, key, '=') && getline(ss, value)) {
			SetEnvironmentVariableA(key.c_str(), value.c_str());
		}
	}

	LoadLibraryA("force-proxy.dll");
}

Да, вот так просто.

А что там с обновлениями дискорда?

У нас уже есть работающее решение, но дискорд при обновлении записывается в новую папку и удаляет старую, что приведет к удалению и наших библиотек.

После анализа процесса обновления дискорда стало понятно, что он после записи новых файлов сам вызывает Discord.exe из нового места, этим можно воспользоваться.

Давайте по имеющимся лекалам просто перехватим CreateProcessW, посмотрим что там запускается, и если это дискорд в новой папке, то просто скопируем в нее наши файлы

BOOL __stdcall Mine_CreateProcessW(LPCWSTR lpApplicationName,
	LPWSTR lpCommandLine,
	LPSECURITY_ATTRIBUTES lpProcessAttributes,
	LPSECURITY_ATTRIBUTES lpThreadAttributes,
	BOOL bInheritHandles,
	DWORD dwCreationFlags,
	LPVOID lpEnvironment,
	LPCWSTR lpCurrentDirectory,
	LPSTARTUPINFOW lpStartupInfo,
	LPPROCESS_INFORMATION lpProcessInformation)
{
	;
	WCHAR path[MAX_PATH];
	GetModuleFileNameW(NULL, path, MAX_PATH);

	std::vector<std::wstring> files = {
		L"proxy.txt",
		L"force-proxy.dll",
		L"DWrite.dll"
	};

	if (lpApplicationName != NULL) {
		auto targetAppName = std::wstring(lpApplicationName);
		auto currentPath = std::wstring(path);

		if (EndsWith(targetAppName, L"Discord.exe") && targetAppName != currentPath) {
			auto currentDir = fs::path(currentPath).parent_path();
			auto targetDir = fs::path(targetAppName).parent_path();

			for (const auto& file : files) {
				if (fs::exists(currentDir.wstring() + L"\\" + file)) {
					fs::copy(currentDir.wstring() + L"\\" + file, targetDir.wstring() + L"\\" + file, fs::copy_options::overwrite_existing);
				}
			}
		}
	}

	return Real_CreateProcessW(lpApplicationName,
		lpCommandLine,
		lpProcessAttributes,
		lpThreadAttributes,
		bInheritHandles,
		dwCreationFlags,
		lpEnvironment,
		lpCurrentDirectory,
		lpStartupInfo,
		lpProcessInformation);
}

Заключение

На этом наша увлекательная рыбалка на WinAPI подошла к концу.

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

force-proxy.dll точно не работает в хроме с включенным режимом sandbox. Я не разбирался почему именно, но кажется очевидным что у процессов очень сильно урезаются права.

Так что было бы здорово, если бы кто-то протестировал force-proxy.dll в других приложениях и, совсем идеально, предложили бы PRы с доработками.

Комментарии (6)


  1. FlyHighOnTheSky
    01.11.2024 07:54

    Из-за этого постоянно приходится включать режим TUN/VPN, который заворачивает в туннель много лишнего.

    Использовал Nekobox, где в Tun Settings включил Whitelist Mode и прописал discord и браузеры в Proxy Process Name.

    Чем такой вариант хуже? В этом случае он всё равно все приложения заворачивает в TUN?
    У меня с такими настройками была проблема с онлайн игрой Deadlock, которая временами лагала и разрывала соединения. (В итоге перешел на sing-box с почти теми же настройками и пока проблем не наблюдаю)


    1. runetfreedom Автор
      01.11.2024 07:54

      Да, sing-box на бумаге имеет возможность заворачивать отдельные процессы, но его реализация TUN достаточно слабая и костыльная.

      Во первых, это заметно снижает сетевую производительность и увеличивает задержки. Вы, возможно, могли замечать, что даже если вы не завернули процесс игры в туннель, у вас все равно, скорее всего, вырастет пинг.

      Во вторых, sing-box сильно вмешивается в сетевой трафик, что можешь приводить к проблемам с кучей протоколов, отличных от TCP и UDP. Я видел много жалоб на проблемы с, к примеру, сетевыми дисками.

      Хорошим примером странного вмешательства в трафик является ICMP - попробуйте попингать любой (даже заведомо недоступный) IP адрес - вы всегда получите ICMP "ответ" с задержкой <1ms. Его зачем-то встроит sing-box.

      В третьих, TUN режим оперирует не доменными имена и HTTP запросами как прокси, а только IP адресами, что в значительной степени ломает маршрутизацию на основе правил, базирующихся на доменных именах. Да, можно включить TUN + прокси, но не все об этом знают и помнят.

      Ну и так далее... Так что, учитывая количество проблем с этим, хотелось бы использовать этот TUN режим пореже.


  1. m0xf
    01.11.2024 07:54

    Можно сделать dll с именем ws2_32, тогда не потребуются хуки с патчингом памяти процесса. Если реализовать все функции - переходники, перенаправляющие вызов на оригинальную dll, то получится универсальное решение, которое будет работать для любой программы.


    1. runetfreedom Автор
      01.11.2024 07:54

      Нет, к сожалению, нельзя. В Windows есть механизм, называемый KnownDLLs

      По сути это список основных системных библиотек, которые всегда будут загружаться из System32.

      ws_32.dll туда тоже входит, можете посмотреть в реестре по пути HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs


  1. 413x
    01.11.2024 07:54

    Сделать бы еще универсальное решение с ддлкой - "wsock32.dll" (x86/x64), дав "проброс функций" на оригинальную дллку. Такой библиотеки крайне не хватает.


    1. runetfreedom Автор
      01.11.2024 07:54

      К сожалению так не получится, в win10/11 wsock32.dll это просто заглушка, которая никуда фактически не загружается.

      Там ws_32 судя по коду поддерживает несколько "провайдеров", но лично у меня все вызовы отправляеются в mswsock.dll, в функции вроде WSPConnect, WSPSendTo и так далее.

      Но и тут засада - эти функции не экспортируются, вместо этого экспортируется только функция WSPStartup, которая чертвертым аргументом заполняет у вызывающего тот самый массив "провайдеров" и передает таблицу вызовов (аргумент WSPUPCALLTABLE *UpcallTable)

      Так что это не вариант - чуть ОС обновилось, добавилась 1 новая функция в эту таблицу и все сломалось...

      Ну а про прокси dll для ws_32 смотрите выше.