RCE Endeavors 😅

December 1, 2021

Reverse Engineering REST APIs: Conclusion (12/12)

Filed under: Game Hacking,Programming,Reverse Engineering — admin @ 9:16 AM

Table of Contents:

Reverse engineering the REST APIs that power the Age of Empires IV multiplayer lobby system is complete at this point.

Throughout the series, we took different approaches at achieving this goal. Initially, we started off using third-party tools that allowed us to set up a reverse proxy and route the game’s traffic through it. From this, we were able to see the decrypted request and response content. We were fortunate that the game did not use certificate pinning, otherwise this technique would have been rendered useless. The series then took a turn towards debugging the game and getting some basic information from the game’s strings dump. From this, it was found that the game used an OpenSSL feature that allowed the generated keys to be written out to a file specified by the SSLKEYLOGFILE environment variable. We were able to import the key information from this file and decrypt captured request and response data in Wireshark.

The series then got more technical as the game was reverse engineered. By attaching a debugger and setting breakpoints on the Winsock send and recv functions, we were able to walk the call stack backwards until we had a point in the code where we had access to the plaintext data. The functions responsible for encrypting and decrypting the data were reverse engineered and their prototypes were extracted. Using these prototypes, we were able to create and set our hook functions. From within these hook functions, we had direct access to the plaintext request and response data. In the example source code, we logged the data out to console, or a file, but we can do whatever we want with it.

Hopefully this series has been helpful in describing what goes in to reverse engineering a processes network communication. In the best case scenario, it can be a trivial task that can be accomplished with the use of third-party tools. In the more complex scenario, it is a very technical process that involves a deep dive into the code and a strong understanding of reverse engineering at the assembly level, dynamic analysis via a debugger, and an understanding of what to look for. I hope that readers of this series have learned something and walk away with a better understanding of what it takes to reverse engineer software.

Reverse Engineering REST APIs: Ingress – Monitoring (11/12)

Filed under: Game Hacking,Programming,Reverse Engineering — admin @ 9:13 AM

Table of Contents:

This post will cover the home stretch of the series: hooking the response decrypt function and showing the complete request-response flow. The technique to do this is the same as what was done to output the request data, so the code snippets here will be more brief. As before, we will start with a signature scan of the process memory for the response decrypt function.

void* FindDecryptPacketAddress(void* baseAddress)
{
    std::array<unsigned char, 39> signature = {
        0x48, 0x89, 0x5C, 0x24, 0x08,                        /* mov qword ptr ss:[rsp+8],rbx            */
        0x48, 0x89, 0x6C, 0x24, 0x10,                        /* mov qword ptr ss:[rsp+10],rbp           */
        0x48, 0x89, 0x74, 0x24, 0x18,                        /* mov qword ptr ss:[rsp+18],rsi           */
        0x57,                                                /* push rdi                                */
        0x48, 0x81, 0xEC, 0x20, 0x01, 0x00, 0x00,            /* sub rsp,120                             */
        0x48, 0x63, 0xC2,                                    /* movsxd rax,edx                          */
        0x49, 0x8B, 0xD9,                                    /* mov rbx,r9                              */
        0x49, 0x8B, 0xF8,                                    /* mov rdi,r8                              */
        0x48, 0x8B, 0xF1,                                    /* mov rsi,rcx                             */
        0x48, 0x8D, 0x2C, 0x40                               /* lea rbp,qword ptr ds:[rax+rax*2]        */
    };

    return PerformSignatureScan(baseAddress, signature);
}

Here we take the bytes that make up the instructions in the response decrypt function that was found in the previous post. We then scan the process memory for these instructions and return the address at which they were found. After finding this address, we place a hook on the function

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

    if (dwReason == DLL_PROCESS_ATTACH) {
        // Some code omitted here ...
        (void)hookEngine.Hook(targetSendAddress, GameSendPacketHook);
        (void)hookEngine.Hook(targetDecryptAddress, GameDecryptPacketHook);
    }

    if (dwReason == DLL_PROCESS_DETACH) {
        (void)hookEngine.Unhook(targetSendAddress, GameSendPacketHook);
        (void)hookEngine.Unhook(targetDecryptAddress, GameDecryptPacketHook);
    }

    return TRUE;
}

We can define our hooks to simply output the request and response data

int WINAPI GameDecryptPacketHook(void* unknown, int alwaysZero, char* decryptBuffer,
	size_t decryptBufferMaxSize, char* errorFlag)
{
	auto original{ (GameDecryptPacketFnc)HookEngine::GetOriginalAddressFromHook(GameDecryptPacketHook) };
	int result{};
	if (original != nullptr) {
		result = original(unknown, alwaysZero, decryptBuffer, decryptBufferMaxSize, errorFlag);
	}

	while (result == -1) {
		std::cerr << "Decrypt failed... retrying..." << std::endl;
		result = original(unknown, alwaysZero, decryptBuffer, decryptBufferMaxSize, errorFlag);
	}

	auto output{ MakePrintableAscii(decryptBuffer, result) };
	for (const auto& line : output) {
		std::cerr << std::format("Decrypted Response: {}", line)
			<< std::endl;
	}

	return result;
}

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) };
	int result{};
	if (original != nullptr) {
		result = original(unknown, socket, buffer, length, sentSize);
	}

	return result;
}

Since we are hooking a function that calls SSL_read, we can add some additional logic to avoid hitting the error-handling code that we did not reverse engineer in the previous post. Per the documentation on SSL_read, we can retry the call if the function returns -1, hence the addition of the while loop in GameDecryptPacketHook.

Lets see this in action: launch Age of Empires IV and inject the DLL containing these hooks into the process. After the console is created, perform some actions in-game to cause a request to be sent out.

From this we can see that the hooks are working correctly: each request, and its corresponding response, is shown. As we did before, we can choose to do whatever we want to the data: log it, modify it, prevent it from reaching the caller, and so on. At this point we have fully achieved what we set out to do; the request and response data, which we saw as being encrypted when inspecting the network traffic, is now clearly visible. We have successfully reverse engineered the REST APIs that make the multiplayer lobby system function!

November 30, 2021

Reverse Engineering REST APIs: Ingress – Reversing the Response Decrypt Function (10/12)

Filed under: Game Hacking,Programming,Reverse Engineering — admin @ 9:05 AM

Table of Contents:

We last left off in the middle of a function where we had a pointer to where decrypted response data would be written in to. The plan was to take a closer look at this function and see what it does; if we can hook into it and manipulate the decrypted buffer then our mission of reverse engineering the request-response flow is complete. Fortunately, this function is pretty simple — the assembly listing has been reproduced below:

00007FF7BFB0FAC4 | 48:895C24 08                   | mov qword ptr ss:[rsp+8],rbx                      |
00007FF7BFB0FAC9 | 48:896C24 10                   | mov qword ptr ss:[rsp+10],rbp                     |
00007FF7BFB0FACE | 48:897424 18                   | mov qword ptr ss:[rsp+18],rsi                     |
00007FF7BFB0FAD3 | 57                             | push rdi                                          |
00007FF7BFB0FAD4 | 48:81EC 20010000               | sub rsp,120                                       |
00007FF7BFB0FADB | 48:63C2                        | movsxd rax,edx                                    |
00007FF7BFB0FADE | 49:8BD9                        | mov rbx,r9                                        |
00007FF7BFB0FAE1 | 49:8BF8                        | mov rdi,r8                                        |
00007FF7BFB0FAE4 | 48:8BF1                        | mov rsi,rcx                                       |
00007FF7BFB0FAE7 | 48:8D2C40                      | lea rbp,qword ptr ds:[rax+rax*2]                  |
00007FF7BFB0FAEB | E8 0093A501                    | call reliccardinal.7FF7C1568DF0                   |
00007FF7BFB0FAF0 | 48:8B8CEE E0020000             | mov rcx,qword ptr ds:[rsi+rbp*8+2E0]              |
00007FF7BFB0FAF8 | B8 FFFFFF7F                    | mov eax,7FFFFFFF                                  |
00007FF7BFB0FAFD | 48:3BD8                        | cmp rbx,rax                                       |
00007FF7BFB0FB00 | 48:8BD7                        | mov rdx,rdi                                       |
00007FF7BFB0FB03 | 0F47D8                         | cmova ebx,eax                                     |
00007FF7BFB0FB06 | 48:8B49 08                     | mov rcx,qword ptr ds:[rcx+8]                      |
00007FF7BFB0FB0A | 44:8BC3                        | mov r8d,ebx                                       |
00007FF7BFB0FB0D | E8 1E9DB801                    | call reliccardinal.7FF7C1699830                   |
00007FF7BFB0FB12 | 48:63F8                        | movsxd rdi,eax                                    |
00007FF7BFB0FB15 | 85C0                           | test eax,eax                                      |
00007FF7BFB0FB17 | 7E 1C                          | jle reliccardinal.7FF7BFB0FB35                    |
00007FF7BFB0FB19 | 48:8BC7                        | mov rax,rdi                                       |
00007FF7BFB0FB1C | 4C:8D9C24 20010000             | lea r11,qword ptr ss:[rsp+120]                    |
00007FF7BFB0FB24 | 49:8B5B 10                     | mov rbx,qword ptr ds:[r11+10]                     |
00007FF7BFB0FB28 | 49:8B6B 18                     | mov rbp,qword ptr ds:[r11+18]                     |
00007FF7BFB0FB2C | 49:8B73 20                     | mov rsi,qword ptr ds:[r11+20]                     |
00007FF7BFB0FB30 | 49:8BE3                        | mov rsp,r11                                       |
00007FF7BFB0FB33 | 5F                             | pop rdi                                           |
00007FF7BFB0FB34 | C3                             | ret                                               |

We can begin by setting a breakpoint at the top of this function and stepping. After doing this, we can see that the buffer gets decrypted after the call to reliccardinal.7FF7C1699830 is executed, as shown below:

00007FF7BFB0FB0D | E8 1E9DB801                    | call reliccardinal.7FF7C1699830                   |
00007FF7BFB0FB12 | 48:63F8                        | movsxd rdi,eax                                    | rdi:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\"0000

If the result of this call is less than or equal to zero, then we jump to reliccardinal.7FF7BFB0FB35. If we look around the instructions at this address, we notice some debug strings mentioning OpenSSL:

00007FF65657FBF4 | FF15 26E9AA05                  | call qword ptr ds:[<&WSAGetLastError>]            |
00007FF65657FBFA | 48:8B0E                        | mov rcx,qword ptr ds:[rsi]                        |
00007FF65657FBFD | 48:8D15 9CD0B102               | lea rdx,qword ptr ds:[7FF65909CCA0]               | 00007FF65909CCA0:"OpenSSL SSL_read: %s, errno %d"
00007FF65657FC04 | 44:8BC8                        | mov r9d,eax                                       |
00007FF65657FC07 | 4C:8BC3                        | mov r8,rbx                                        |
00007FF65657FC0A | E8 B1C20502                    | call reliccardinal.7FF6585DBEC0                   |

This logic is very similar to what we saw when we were encrypting the request data, though now we are on the read path instead of the write path. Whereas before we had a debug string referencing SSL_write, now we have one referencing SSL_read. Based on stepping through the code, the documentation of SSL_read, and the debug strings, we can make a reasonable assumption that reliccardinal.7FF7C1699830 is SSL_read. As in the request decryption code, [RCX+0x8] is passed as the first parameter, followed by a pointer to the decrypt buffer, and the buffer size. From the previous reverse engineering session regarding the request encryption flow, we found that [RCX+0x8] holds the SSL opaque pointer.

This function that we are looking at seems like a reasonable place to hook. The logic here is very similar to the encryption function that was hooked earlier in this series, and we can verify that this function only gets called when we have made a REST request. The last thing that we need to do is to define the function prototype. We can derive most of the prototype based on what we’ve seen already. To start with, we see four arguments to this function being moved to different registers at the top


00007FF6541AFADB | 48:63C2                        | movsxd rax,edx                                    |
00007FF6541AFADE | 49:8BD9                        | mov rbx,r9                                        |
00007FF6541AFAE1 | 49:8BF8                        | mov rdi,r8                                        |
00007FF6541AFAE4 | 48:8BF1                        | mov rsi,rcx                                       |

We can see these registers being passed a little lower to the call to SSL_read:

00007FF6541AFB00 | 48:8BD7                        | mov rdx,rdi                                       |
00007FF6541AFB03 | 0F47D8                         | cmova ebx,eax                                     |
00007FF6541AFB06 | 48:8B49 08                     | mov rcx,qword ptr ds:[rcx+8]                      |
00007FF6541AFB0A | 44:8BC3                        | mov r8d,ebx                                       |
00007FF6541AFB0D | E8 1E9DB801                    | call reliccardinal.7FF655D39830                   |

From this, we can deduce that RCX holds the structure that wraps the SSL and HTTP request info logic, R8 holds the pointer to the decrypt buffer, and R9 holds the size of the decrypt buffer. This might be unclear from the partial assembly listings, but if you start with the call to SSL_read and work backwards from where the arguments come from, it becomes more clearly visible. This gets us to identifying three arguments, but there are more.

At the top, we move the second argument into RAX. This argument is then used in two places:

00007FF6541AFAE7 | 48:8D2C40                      | lea rbp,qword ptr ds:[rax+rax*2]                  |
00007FF6541AFAEB | E8 0093A501                    | call reliccardinal.7FF655C08DF0                   |
00007FF6541AFAF0 | 48:8B8CEE E0020000             | mov rcx,qword ptr ds:[rsi+rbp*8+2E0]              |
...
00007FF6541AFB35 | 48:8B8CEE E0020000             | mov rcx,qword ptr ds:[rsi+rbp*8+2E0]              |
...

It is used as an index into the structure passed in the first parameter. From debugging, we notice that its value is always zero, and we can just leave this argument alone since we aren’t interested in reverse engineering the SSL/HTTP request wrapper internals.

The last argument is an out parameter. If we look through the path where SSL_read returns less than or equal to 0, we can see it being set:

00007FF6541AFB64 | 48:8B8424 50010000             | mov rax,qword ptr ss:[rsp+150]                    |
00007FF6541AFB6C | C700 51000000                  | mov dword ptr ds:[rax],51                         | 51:'Q'
...
00007FF65657FC0F | 48:8B8424 50010000             | mov rax,qword ptr ss:[rsp+150]                    |
00007FF65657FC17 | C700 38000000                  | mov dword ptr ds:[rax],38                         | 38:'8'

Given that this is on the error path, we will also choose to ignore it. Since we are placing a hook, we can just forward the result of this back to the caller without having to worry about performing any modifications on it. At this point we have all of the arguments to the function. We can define the prototype as

using GameDecryptPacketFnc = int (WINAPI*)(void* unknown, int alwaysZero, char* decryptBuffer, size_t decryptBufferSize, char* errorFlag);

We are now done reverse engineering the decryption routine. We’ve found that it is a suitable function to hook in order to get at the decrypted response buffer. From within our hook, we can choose to inspect or modify this buffer before returning it to the caller. The next post will quickly cover how to do this, and wrap up the series.

November 29, 2021

Reverse Engineering REST APIs: Ingress – Walking the Call Stack (9/12)

Filed under: Game Hacking,Programming,Reverse Engineering — admin @ 9:38 AM

Table of Contents:

We now can see plaintext data for outgoing requests, but we have no idea what a response to any of these requests looks like. Fortunately, finding the response data follows a similar process to getting the request data. We can set a breakpoint on the recv function and work backwards in the call stack until we see the decrypted response. We can trigger a call to recv at-will by performing actions in-game to send out a request. When the server returns a response, our breakpoint get hit and we can begin walking the call stack.

To begin, launch Age of Empires IV and attach to the RelicCardinal.exe executable. Set a breakpoint on recv and refresh the multiplayer lobby list.

If you’ve done this, you will immediately notice that recv gets called constantly, likely preventing you from even being able to refresh the lobby list. If you were quick enough to actually hit the refresh button, it will still be hard to be certain that your call stack corresponds to the code responsible for reading a REST response. You can help ensure that you are looking at the proper code by setting a conditional breakpoint instead. Since REST responses tend to be quite large, and especially so for something like a multiplayer lobby refresh, we can set a conditional breakpoint on the value of the length argument to recv. This value will be stored in the R8 register per the x64 calling convention. Lets choose a high value like 0x1000 (4096) bytes.

Once this conditional breakpoint is in place, our debugger should no longer be breaking constantly. We can now go back in-game and refresh the multiplayer lobby list. After doing this, our breakpoint will get hit. We can put this cause-and-effect together and have high confidence that we are broken in the call stack responsible for reading a REST response. We can now take a look at the call stack window and see where we have to go from here.

As before, we set breakpoint on each of the functions in user code and resume execution of the process.

After resuming execution for a bit, we come to a place where we can clearly see the plaintext response. This happens in 00007FF7BFB0FB12, which is about halfway down the call stack. The function disassembly is shown below:

00007FF7BFB0FAC4 | 48:895C24 08                   | mov qword ptr ss:[rsp+8],rbx                      |
00007FF7BFB0FAC9 | 48:896C24 10                   | mov qword ptr ss:[rsp+10],rbp                     | [rsp+10]:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\
00007FF7BFB0FACE | 48:897424 18                   | mov qword ptr ss:[rsp+18],rsi                     |
00007FF7BFB0FAD3 | 57                             | push rdi                                          | rdi:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\"0000
00007FF7BFB0FAD4 | 48:81EC 20010000               | sub rsp,120                                       |
00007FF7BFB0FADB | 48:63C2                        | movsxd rax,edx                                    |
00007FF7BFB0FADE | 49:8BD9                        | mov rbx,r9                                        |
00007FF7BFB0FAE1 | 49:8BF8                        | mov rdi,r8                                        | rdi:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\"0000
00007FF7BFB0FAE4 | 48:8BF1                        | mov rsi,rcx                                       |
00007FF7BFB0FAE7 | 48:8D2C40                      | lea rbp,qword ptr ds:[rax+rax*2]                  |
00007FF7BFB0FAEB | E8 0093A501                    | call reliccardinal.7FF7C1568DF0                   |
00007FF7BFB0FAF0 | 48:8B8CEE E0020000             | mov rcx,qword ptr ds:[rsi+rbp*8+2E0]              | [rsi+rbp*8+2E0]:&"0²|Ä÷\x7F"
00007FF7BFB0FAF8 | B8 FFFFFF7F                    | mov eax,7FFFFFFF                                  |
00007FF7BFB0FAFD | 48:3BD8                        | cmp rbx,rax                                       |
00007FF7BFB0FB00 | 48:8BD7                        | mov rdx,rdi                                       | rdi:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\"0000
00007FF7BFB0FB03 | 0F47D8                         | cmova ebx,eax                                     |
00007FF7BFB0FB06 | 48:8B49 08                     | mov rcx,qword ptr ds:[rcx+8]                      |
00007FF7BFB0FB0A | 44:8BC3                        | mov r8d,ebx                                       |
00007FF7BFB0FB0D | E8 1E9DB801                    | call reliccardinal.7FF7C1699830                   |
00007FF7BFB0FB12 | 48:63F8                        | movsxd rdi,eax                                    | rdi:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\"0000
00007FF7BFB0FB15 | 85C0                           | test eax,eax                                      |
00007FF7BFB0FB17 | 7E 1C                          | jle reliccardinal.7FF7BFB0FB35                    |
00007FF7BFB0FB19 | 48:8BC7                        | mov rax,rdi                                       | rdi:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\"0000
00007FF7BFB0FB1C | 4C:8D9C24 20010000             | lea r11,qword ptr ss:[rsp+120]                    |
00007FF7BFB0FB24 | 49:8B5B 10                     | mov rbx,qword ptr ds:[r11+10]                     |
00007FF7BFB0FB28 | 49:8B6B 18                     | mov rbp,qword ptr ds:[r11+18]                     |
00007FF7BFB0FB2C | 49:8B73 20                     | mov rsi,qword ptr ds:[r11+20]                     | r11+20:"p’\v®3\x01"
00007FF7BFB0FB30 | 49:8BE3                        | mov rsp,r11                                       |
00007FF7BFB0FB33 | 5F                             | pop rdi                                           | rdi:"HTTP/1.1 200 OK\r\nDate: Wed, 24 Nov 2021 14:24:03 GMT\r\nContent-Type: application/json;charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nRequest-Context: appId=X\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nRequest-Path: /game/advertisement/findAdvertisements\r\n\r\n67ed\r\n[0,[[10132504,0,\"{\\\"templateName\\\":\\\"GameSession\\\",\\\"name\\\":\\\"X\\\",\\\"scid\\\":\\\"0000
00007FF7BFB0FB34 | C3                             | ret                                               |

At our breakpoint, we see that the plaintext response is held in the address at RDI, which in turn came from the R8 register, which itself corresponds to the third argument passed into this function. We are at a point where we have direct access to the plaintext response buffer, and we can also verify that this function gets called only when decrypting a REST response. This seems like a good place to begin a deeper dive into. We know that the function we are looking at takes in the decrypt buffer as a parameter. We can see how this function is called, determine its prototype, and see if we should place a hook on it. If we’ve chosen wisely, we should have a hook that gives us access to the decrypted response buffer. The next post will cover the deep dive into this function and what information we can extract from it.

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.

Older Posts »

Powered by WordPress