RCE Endeavors 😅

November 24, 2021

Reverse Engineering REST APIs: Egress – Monitoring (8/12)

Filed under: Game Hacking,Programming,Reverse Engineering — admin @ 8:25 AM

Table of Contents:

We are at the home stretch: we’ve reverse engineered the egress flow to the point where we have plaintext data. We’ve identified a function that takes this plaintext data, along with its size, as arguments. We can now try to hook this function to inspect and modify the data. Let’s begin with the task at hand.

As before, we will be using Detours to set up the API hooks. We will be hooking the function by address, instead of by name, as in the earlier post. Because of this, we will need to add some extra logic since the game is not loaded at the same address each time. To handle this, we will perform a signature scan on the process memory: we will take some number of the bytes of the function’s assembly instructions and scan the entire process memory for them. This approach will find the function regardless of what address the game has been loaded, and also will still work if the game’s code has been modified — as long as this function was not directly modified or compiled differently.

Performing the signature scan is straightforward. Since we are already inside the address space of the process, we can just get the base address of the executable and then look through every committed memory region. If we’ve done everything correctly, we should find the function in memory. The implementation for this is below:

void* ScanMemoryForSignature(void* baseAddress, const std::span<unsigned char>& signature)
{
	MODULEINFO moduleInfo{};
	auto result{ GetModuleInformation(GetCurrentProcess(), (HMODULE)baseAddress, &moduleInfo,
		sizeof(MODULEINFO)) };
	if (!result) {
		std::cerr << std::format("Could not get process information. Error = {}.",
			GetLastError()) << std::endl;
		return nullptr;
	}

	for (auto base{ (DWORD_PTR)moduleInfo.lpBaseOfDll }; base < (DWORD_PTR)moduleInfo.lpBaseOfDll + moduleInfo.SizeOfImage; /**/) {
		MEMORY_BASIC_INFORMATION memInfo{};
		auto result{ VirtualQuery(baseAddress, &memInfo, sizeof(memInfo)) };
		if (!result) {
			std::cerr << std::format("Could not query memory region at {}. Error = {}", base, GetLastError())
				<< std::endl;
		}

		if (memInfo.State == MEM_COMMIT && memInfo.Protect != PAGE_NOACCESS) {
			for (auto i{ base }; i < base + memInfo.RegionSize - signature.size(); i++) {
				if (memcmp((void*)i, signature.data(), signature.size()) == 0) {
					return (void*)i;
				}
			}
		}

		base += memInfo.RegionSize;
	}

	return nullptr;
}

void* PerformSignatureScan(void* baseAddress, const std::span<unsigned char>& signature)
{
	void* result{ ScanMemoryForSignature(baseAddress, signature) };
	if (result == nullptr) {
		std::cerr << "Could not find function for signature." << std::endl;
	}
	else {
		std::cerr << std::format("Found function at address {}.", result);
	}

	return result;
}

void* FindSendPacketAddress(void* baseAddress)
{
    std::array<unsigned char, 45> signature = {
        0x48, 0x83, 0xEC, 0x38,                              /* sub rsp, 38h                            */
        0x83, 0x64, 0x24, 0x40, 0x00,                        /* and dword ptr[rsp + 40h], 0             */
        0x33, 0xC0,                                          /* xor eax, eax                            */
        0x48, 0x3B, 0x91, 0x58, 0x02, 0x00, 0x00,            /* cmp rdx, qword ptr[rcx + 258h]          */
        0x0F, 0x94, 0xC0,                                    /* sete al                                 */
        0x44, 0x8B, 0xD0,                                    /* mov r10d, eax                           */
        0x48, 0x8D, 0x44, 0x24, 0x40,                        /* lea rax, [rsp + 40h]                    */
        0x41, 0x8B, 0xD2,                                    /* mov edx, r10d                           */
        0x48, 0x89, 0x44, 0x24, 0x20,                        /* mov qword ptr[rsp + 20h], rax           */
        0x42, 0xFF, 0x94, 0xD1, 0x88, 0x02, 0x00, 0x00       /* call qword ptr[rcx + r10 * 8 + 288h]    */
    };

	return PerformSignatureScan(baseAddress, signature);
}

Once we have the address that we’re looking for, we need to install the hook on it. The HookEngine class of the earlier post can be expanded to support this functionality:

bool HookEngine::Hook(FncPtr originalAddress, HookFncPtr hookAddress)
{
	DetourTransactionBegin();
	DetourUpdateThread(GetCurrentThread());
	DetourAttach(&(PVOID&)originalAddress, (PVOID)hookAddress);
	auto result{ DetourTransactionCommit() };
	if (result != NO_ERROR) {
		std::cerr << std::format("Hook transaction failed with code {}.", result) << std::endl;
		return false;
	}

	m_hookToOriginal[hookAddress] = originalAddress;

	return true;
}

We can then put everything together and create our main function to get the function address and install the hook.

__declspec(dllexport) BOOL WINAPI DllMain(HINSTANCE hModule, DWORD dwReason, LPVOID reserved)
{
    static HookEngine hookEngine{};
    static HMODULE baseAddress{ GetModuleHandle(NULL) };
    static void* targetAddress{ FindSendPacketAddress(baseAddress) };

    if (dwReason == DLL_PROCESS_ATTACH) {
        DisableThreadLibraryCalls(hModule);
        if (AllocConsole()) {
            (void)freopen("CONOUT$", "w", stdout);
            (void)freopen("CONOUT$", "w", stderr);
            SetConsoleTitle(L"Console");
            SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE);
            std::cerr << "DLL Loaded" << std::endl;
        }

        std::cerr << "Base address is 0x" << std::hex << baseAddress << std::endl;
        std::cerr << "Send data function is at 0x" << std::hex << targetAddress << std::endl;

        (void)hookEngine.Hook(targetAddress, GameSendPacketHook);
    }

    if (dwReason == DLL_PROCESS_DETACH) {
        (void)hookEngine.Unhook(targetAddress, GameSendPacketHook);
    }

    return TRUE;
}

We’ve redirected the function that we want to hook to our GameSendPacketHook function. In our hook, we can do some simple logging to start with:

int WINAPI GameSendPacketHook(void* unknown, SOCKET socket, const char* buffer, int length, int* sentSize)
{
	auto output{ MakePrintableAscii(buffer, length) };
	auto [ipAddress, port] { GetPeerInfo(socket) };
	for (const auto& line : output) {
		std::cerr << std::format("[{}:{}] - Data: {}", ipAddress, port, line)
			<< std::endl;
	}

	auto original{ (GameSendPacketFnc)HookEngine::GetOriginalAddressFromHook(GameSendPacketHook) };
	return original(unknown, socket, buffer, length, sentSize);
}

If we launch the game and inject our DLL, we should see our hook being hit. The output will show the plaintext data that is going to be sent to the server.

It looks like everything works perfectly. If we navigate around in the game and perform some actions, we will see the corresponding requests being made. The basic level hook is now in place to get access to the data. From here we can expand a bit and add some functionality. The first thing to add is a wrapper around the request data. If we monitor the output closely enough, we notice that POST and PUT requests come in multiple parts: first the HTTP header data is sent in one call, then the content is sent in the next one. It would be nice to create a HttpRequest wrapper class that can take in raw HTTP data and construct a full request out of it for scenarios like this. We can create the function to do this as follows:

std::unordered_map<SOCKET /*Socket*/, HttpRequest /*Pending request*/> m_incompleteRequests;

...

HttpRequest GetRequest(SOCKET socket, const char* buffer, int length)
{
	HttpRequest request{};
	if (m_incompleteRequests.find(socket) != m_incompleteRequests.end()) {
		request = m_incompleteRequests[socket];
		auto content{ std::vector<char>{buffer, buffer + length} };
		request.Content().insert(request.Content().end(), content.begin(), content.end());
	}
	else {
		request = HttpRequest::FromRaw(buffer, length);
	}

	auto expectedLength{ request.Header("Content-Length") };
	if ((request.HttpType() == "POST" || request.HttpType() == "PUT") && expectedLength != request.Headers().end()) {
		if (request.Content().size() < std::stol(expectedLength->second)) {
			m_incompleteRequests[socket] = request;
		}
		else {
			m_incompleteRequests.erase(socket);
		}
	}

	return request;
}

Here we take in the socket, request data, and length. We construct an HttpRequest object out of it and return it to the caller. This HttpRequest object will either be a partial object, or successfully built. A partial object will be returned in the event that we get data that indicates a POST or PUT request is being sent out. At this point we will have the request headers, but the body will be coming in the next call. On the next call, we will find the partial request that was built previously and fill in the content body. Once this is done, the request is considered to be fully built and ready to send out. If we get a GET request, then we just create and return a fully built HttpRequest from it, as GET requests are sent out in one call.

We need to modify our hook function to support this change. In our hook, we will first check to see if we have a fully built request. If so, we will call a handler for the request type, and then send the request to the server. If the request is not yet fully built then we return a successful response to the caller and wait for the content body to come in the next call.

int WINAPI GameSendPacketHook(void* unknown, SOCKET socket, const char* buffer, int length, int* sentSize)
{
	auto request{ GetRequest(socket, buffer, length) };
	if (request.IsBuilt()) {
		auto connectionInfo{ GetPeerInfo(socket) };
		if (messageHandlers.find(request.MessageType()) != messageHandlers.end()) {
			messageHandlers[request.MessageType()](request, connectionInfo);
		}
		else {
			std::cerr << std::format("No handler found for message {}", request.Path())
				<< std::endl;
			HandleGenericMessage(request, connectionInfo);
		}

		auto original{ (GameSendPacketFnc)HookEngine::GetOriginalAddressFromHook(GameSendPacketHook) };
		int result{};
		if (original != nullptr) {
			auto serializedHeader{ request.ToBytesHttpHeader() };
			result = original(unknown, socket, serializedHeader.data(), static_cast<int>(serializedHeader.size()), sentSize);
			auto serializedContent{ request.ToBytesHttpContent() };
			if (serializedContent.size() > 0) {
				result = original(unknown, socket, buffer, length, sentSize);
			}
		}

		return result;
	}
	else {
		*sentSize = length;
		return 0;
	}
}

Since there are a large number of APIs being called, we should add handlers for ones of interest. In these handlers we can decide to do some request-specific logic to inspect or modify the data. These handlers are just kept in a simple unordered map

static std::unordered_map<MessageType /*Message type*/, MessageHandler /*Handler*/> messageHandlers = {
                { MessageType::ChatMessage, HandleMatchChat },
                { MessageType::ReportMatch, HandleReportMatch }
                ...
};

In our custom handler, we can choose to inspect and modify the data. In the source code, I have added support for a large set of APIs that were observed. I have additionally created special “view” objects for each request type that map over the request data, allowing for easy modification.

void HandleMatchChat(HttpRequest& httpRequest, const std::pair<std::string, std::string>& connectionInfo)
{
	auto chatMessageView{ PostMatchChatMessageView { httpRequest } };
	// ... Do something with the chat message request here
	HandleGenericMessage(httpRequest, connectionInfo, false);
}

Lastly, we handle logging of the request data to console, and to file, in a generic handler.

void HandleGenericMessage(HttpRequest& httpRequest, const std::pair<std::string, std::string>& connectionInfo)
{
	auto messageBytes{ httpRequest.ToBytes() };
	if (WRITE_REQUEST_DATA_TO_CONSOLE) {
		auto output{ MakePrintableAscii(messageBytes.data(), messageBytes.size()) };
		auto [ipAddress, port] { connectionInfo };
		for (const auto& line : output) {
			std::cerr << std::format("[{}:{}] - Data: {}", ipAddress, port, line)
				<< std::endl;
		}
	}

	if (WRITE_REQUEST_DATA_TO_LOG) {
		auto requestBeginIndex{ httpRequest.Path().find_last_of('/') };
		auto requestEndIndex{ httpRequest.Path().find_last_of('?') };
		auto requestName{ std::string{httpRequest.Path().substr(requestBeginIndex + 1)} };
		if (requestEndIndex != std::string::npos) {
			requestName.erase(requestName.end() - 1);
		}

		auto filePath{ std::format("{}.log", requestName) };
		if (!std::filesystem::exists(filePath)) {
			std::ofstream logFile{ filePath, std::ofstream::binary };
			if (!logFile.is_open()) {
				std::cerr << std::format("Could not open {}", filePath)
					<< std::endl;
			}
			else {
				logFile.write(messageBytes.data(), messageBytes.size());
			}
		}
	}
}

Here we log all requests coming through to the console, and a request to file as well. If we run this improved hook, we should not observe any change in functionality. Everything in-game should appear to work as it did before, meaning our logic to build a HttpRequest object from the content and send it out only when ready has had no impact on the proper functioning of the game.

At this point, we have achieved what we set out to do: we have successfully found the location where we can intercept the plaintext data, and have written the logic to allow us to inspect, log, and modify it at-will. The egress flow is now finished, but we don’t know what a response to any of these requests looks like yet. That will be the subject of the remainder of this series.

No Comments »

No comments yet.

RSS feed for comments on this post. TrackBack URL

Leave a comment

 

Powered by WordPress