.o88b. d8P Y8 8P 8b o |\ _ Y8b d8 /|/| | |/ |/ `Y88P' | |_/|/|_/|_/ ________________________________________________________________________________ Remote Code Execution in Lethal League ====================================== My first real vulnerability, and I think quite an interesting one to exploit! If you don't already know, Lethal League is a projectile fighting game released on Steam in 2014, and was somewhat popular following a burst of internet attention. It's far less active than it used to be, but it still has a community of players to this day. Discovery --------- My first indication that there might be a security issue came completely accidentally. I was messing around with function call hooking and interception with Lethal League as my poor test subject, and attempted to set my in-game name to a different value. When I got this working, and noticed it didn't have the same limit imposed by Steam of 32 characters, I of course promptly set a name big enough to cover the screen in text and joined a friend's lobby. But something unexpected happened. He messaged me and said his game had crashed! Sort of amusing in itself, but as a vulnerability researcher alarm bells were ringing in my head. A larger input than expected crashing a program? That smells an awful lot like a buffer overflow... Testing Setup ------------- Now how do we actually recreate and debug this? The bug is triggered over the internet through a Steam lobby, so it isn't quite as simple as connecting to a local port. We'll need two Steam accounts at the very least to join the same Steam lobby, but I'd rather not buy the game again. Enter Spacewar: a hidden but free game on Steam designed to show off Steam's networking functionality for developers. Combine this with the fact that Steam games don't actually know their own app IDs, and just trust the Steam client or a text file to tell them, and you have both a very useful testing environment and a favourite tactic of game pirates everywhere. By putting a steam_appid.txt file in the game's directory containing an app ID and running the executable directly outside of the Steam client, the game will simply trust the ID it's given and run as whatever app the ID corresponds to. Spacewar is a perfect app ID to run as, as it's completely free and all networking features are fully supported. We can simply copy the game files to a virtual machine, log in to a second Steam account and run the game as Spacewar on both ends! Now we can fully inspect both ends of a Steam lobby connection and start looking at this bug properly. The Actual Bug -------------- My first move was to just recreate the exact same large name situation that crashed the game before. I should mention at this point that the developers very helpfully (intentionally or not) did not strip symbols from the Linux release of the game! The game is written in C++ and the binary contains full class descriptions and function names, which made my job far easier. I set up the exact same name modification as before, and ran the game through gdb on the other end. After joining the lobby with the huge name, sure enough, we crash: *** stack smashing detected ***: terminated Thread 1 "LethalLeague" received signal SIGABRT, Aborted. 0xf7fc7579 in __kernel_vsyscall () I got the following backtrace (edited for brevity): #0 0xf7fc7579 in __kernel_vsyscall () #1 0xf728ea27 in __pthread_kill_implementation () at pthread_kill.c:43 #2 0xf728eaaf in __pthread_kill_internal () at pthread_kill.c:78 #3 0xf723b327 in __GI_raise (sig=6) at ../sysdeps/posix/raise.c:26 #4 0xf7222121 in __GI_abort () at abort.c:79 #5 0xf72231b6 in __libc_message () at ../sysdeps/posix/libc_fatal.c:150 #6 0xf733c3b3 in __GI___fortify_fail () at fortify_fail.c:24 #7 0xf733d29f in __stack_chk_fail () at stack_chk_fail.c:24 #8 0x080de3d3 in CharacterSelectState_Netplay::poll () #9 0x41414141 in ?? () #10 0x41414141 in ?? () #11 0x41414141 in ?? () [snip] The function we were executing before the stack was trashed and the stack canary check failed seems to be CharacterSelectState_Netplay::poll, so I had a look at it in Ghidra. Here's a manually cleaned up decompilation: void CharacterSelectState_Netplay::poll(void *this, sf::Time *arg2) { if (SteamWrapper::is_initialized(Game::getSteamWrapper(this->s_wrap))) { while (true) { uint32 size; if (!SteamNetworkingWrapper::IsP2PPacketAvailable(&size, 2)) break; CSteamID sender; char buf[512]; if (SteamNetworkingWrapper::ReadP2PPacket(buf, size, &size, &sender, 2)) { P2PData p2p; p2p.sender = CSteamID::ConvertToUint64(&sender); sf::Packet::append(&p2p.data, buf, msg_size); CharacterSelectState_Netplay::handle_p2p_data(this, &p2p); } } } } So what's the issue here? Comparing it to the example code in the Steam API documentation may enlighten us: uint32 msgSize = 0; while (SteamNetworking()->IsP2PPacketAvailable(&msgSize)) { void *packet = malloc(msgSize); CSteamID steamIDRemote; uint32 bytesRead = 0; if (SteamNetworking()->ReadP2PPacket(packet, msgSize, &bytesRead, &steamIDRemote)) { // message dispatch code goes here } free(packet); } In both cases, the size of the packet to read is retrieved from IsP2PPacketAvailable, but crucially the official documentation uses this size to dynamically allocate a buffer to hold the packet, and then read a packet of that size from the other client. The Lethal League code instead uses a fixed size 512-byte buffer while still reading a packet of whatever size is waiting! There's nothing principally wrong with using a fixed size buffer here as long as the size of the buffer is reflected in the size argument to ReadP2PPacket, but that is not the case. If I had to guess, I would imagine they copied the documentation example and later switched to a fixed 512-byte buffer as no packet the game naturally sends exceeds this size, but neglected to update the size of the packet being read. After checking every instance of ReadP2PPacket in the binary I confirmed the same vulnerable code pattern was present in every single one. Linux Exploitation ------------------ We seem to have a classic buffer overflow on our hands! But this is the modern day, so it's not going to be that easy. The game has stack canaries, a non-executable stack and ASLR. It looked like I would have to either achieve code execution by overwriting other stack variables to manipulate the game's logic in some way and/or somehow leak the stack canary over the network. The first idea was defeated pretty quickly by modern compilers and their annoying security features. The compiler had strategically placed the buffer right before the canary, so I couldn't overwrite anything interesting without instantly corrupting the canary and aborting the program at the end of the function. Leaking the stack cookie seemed more plausible, so I spent a while looking at all the different packet types the game accepted and their code paths (having symbols made this WAY less tedious than it could have been) but I just couldn't find anything. I was running out of ideas, so I turned to the library Lethal League used as an abstraction for its networking and many other things: SFML. SFML provides a simple API to access things like windows, audio, networking and graphics in a high level and operating system independent manner, and as Lethal League uses it for networking there was a small chance I could find a bug in the library itself to leak some stack memory. The library is open source, which made it easy to audit but did not fill me with hope that there would be any obvious bugs. The networking wrapper in question is SFML's sf::Packet class, which abstracts serialisation and deserialisation for various data types to send and receive over the network. Some of the data types are extremely simple to serialise, such as integers and floats, but std::strings were a lot more interesting, especially as I knew that Lethal League made use of them in its network protocol. As they vary in size, SFML actually stores the length in the packet and uses that length when deserialising. If it's part of the packet, we can control it. This is the code for deserialising an std::string: Packet& Packet::operator>>(std::string& data) { // First extract string length std::uint32_t length = 0; *this >> length; data.clear(); if ((length > 0) && checkSize(length)) { // Then extract characters data.assign(reinterpret_cast<char*>(&m_data[m_readPos]), length); // Update reading position m_readPos += length; } return *this; } It seems they do actually validate the length in checkSize. This is the code for checkSize: bool Packet::checkSize(std::size_t size) { m_isValid = m_isValid && (m_readPos + size <= m_data.size()); return m_isValid; } This ensures the length cannot exceed the length of the rest of the packet. This logic is actually sound, so is there no bug here? There is, but it's a little subtle. While this check does generally work, in the specific case where m_readPos is overflowed by adding size to it, it will wrap around to a smaller value and pass the check, allowing us to smuggle in a gigantic string size that far surpasses the buffer. When I had this thought I decided to test my theory locally and wrote this small test program which constructs a serialised string with a custom length and attempts to deserialise it: #include <iostream> #include <cstdlib> #include <cstdint> #include <SFML/Network/Packet.hpp> struct serial_str { uint32_t length; char data[2]; }; int main(void) { serial_str str; str.length = 0xffffffff; str.data[0] = 'h'; str.data[1] = 'i'; sf::Packet packet; packet.append(&str, sizeof(str)); std::string out; packet >> out; std::cout << out << std::endl; return EXIT_SUCCESS; } m_readPos will be incremented by reading the size, and once 0xffffffff is added to it the check should overflow and pass. However when I tried it, I got no output. Seemingly the check had worked properly and prevented the string from being created. After a bit more thinking I figured out the issue - having compiled this on my 64-bit machine, size_t was defined to be 64 bits wide, and both variables in the comparison are of type size_t. Meanwhile, the length read from the packet was always defined as the 32-bit uint32_t. This means that the comparison is done in 64 bits and the addition of the 32-bit size can never overflow it. Lethal League is a 32-bit binary however, so I just added -m32 to my compiler flags, compiling the program in 32-bit mode. This redefines size_t to be 32 bits wide and produces this output: terminate called after throwing an instance of 'std::length_error' what(): basic_string::_M_replace [1] 7001 IOT instruction (core dumped) ./test That certainly looks promising, but isn't the huge dump of stack memory I had hoped for. After some research I found out that std::strings in C++ actually have a maximum size, defined by std::string::max_size, and if this size is exceeded an std::length_error exception is thrown. Another problem is that even had the stack leak succeeded, it would read so far out of bounds that the program would either crash or throw an access violation exception. This might be a solvable problem if m_readPos was so large already that the size required to overflow was small enough to not trigger either of these exceptions. In the absence of another bug to desync m_readPos from the buffer, the only way to do this would be to send an sf::Packet in the gigabytes. Steam certainly won't allow this and it would be very impractical even if possible, so this approach too seems to have reached a dead end. Keep this bug in mind for later though! Windows Exploitation -------------------- Having run out of ideas, I decided to check the Windows release of the game just in case something was different. The Windows release was stripped, but after a bit of string cross-referencing and matching code patterns in both releases I found the same vulnerable function as we found in the Linux version. To my surprise, there actually was a meaningful difference - two new strange values pushed onto the stack at the beginning of the function: push 0xffffffff push 0x54cf0b The second value seemed to be the address of a function, which eventually ended up jumping to __CxxFrameHandler3. Apparently this formed part of Windows' Structured Exception Handling (SEH), and overwriting the exception handler function pointers is a well-known Win32 exploitation technique. The full setup looks like this: push 0xffffffff push 0x54cf0b mov eax, dword ptr fs:[0] push eax mov dword ptr fs:[0], esp A similar routine is performed in reverse at the end of the function to restore the original value of fs:[0]. What's happening here? First we need to understand how SEH works. SEH uses a linked list of exception handlers (function pointers) that it traverses to find an exception handler that can handle the current exception when one is raised. A node in this linked list has this structure: struct _EXCEPTION_REGISTRATION_RECORD { struct _EXCEPTION_REGISTRATION_RECORD *Next; PEXCEPTION_ROUTINE Handler; }; The first node in the list is stored in the Thread Environment Block (TEB) which can be accessed via offsets from the fs segment register. The address of the head of the SEH chain is the first entry in the TEB, located at fs:[0]. You can ignore the 0xffffffff (-1) push which just helps deal with nested try/catch statements, and focus on the effect of the instructions after it. The new exception handler address is pushed to the stack as the Handler in the new node, the old address of the list head is saved on the stack as the new Next pointer, and the list head at fs:[0] is overwritten with the current stack pointer, which points at the newly created SEH node on the stack. Now the new node points to the old head of the list, and the head of the list points to this new node, essentially prepending this new exception handler to the SEH chain. This process is reversed and the old value restored at the end of the function. The most interesting thing about this system is that the function pointers are just placed right there on the stack. The compiler does place it behind the stack canary, but there's still a way to exploit this. If we can overwrite the exception handler and then trigger an exception inside the function before the stack canary is checked, we should be able to control the instruction pointer! After creating a more controlled exploit script that directly interacts with the Steam SDK to connect to the lobby and send custom packets, I tried my first payload. 512 bytes of As to fill the buffer and 4 more for the stack canary, and we should be at the SEH structure. I overwrote the next SEH pointer with 0xffffffff to signify the end of the SEH chain (though it doesn't particularly matter) and the actual exception handler with 0xcafebabe. Running the other end through x64dbg I saw this in the SEH overview:(sorry to break up the lovely ASCII aesthetic) Looks like we control the exception handler! Now we just need a way to get the function to throw an exception before it returns. Do you remember the SFML bug from earlier? std::string throws an exception when it's allocated with a size that large, which is actually perfect! The game's own protocol identifies packet types via the first byte of the packet, which then calls a handling function. One of these is called on_player_update, which eventually reads five 1-byte values from the packet and then an std::string, all through SFML's sf::Packet wrapper. If we can lead the game down this code path and get it to deserialise our string, which triggers the bug inside SFML and causes an std::length_error to be thrown, we can get the function to call our corrupted exception handler! I added this to my script and changed the exception handler pointer to a real mapped address and saw this:
It seems to have worked, sort of? The std::string exception was thrown, but the program crashes complaining about an invalid exception handler. What I had stumbled into was a security mitigation known as SafeSEH. Overwriting the SEH chain has been a known exploitation technique for a long time (though why they put it right there on the stack in the first place eludes me), so Microsoft implemented SafeSEH, a security feature at compile time which hardcodes a table of valid exception handlers which is checked at runtime to ensure any executed exception handlers have not been corrupted, or at least not corrupted to an address which is not a real exception handler. Once again, I hit a roadblock. I didn't have a way to defeat this without another bug somewhere. Just to be sure, I checked every DLL packaged with the game in the hopes one wouldn't have SafeSEH enabled, as the SafeSEH table is local to each DLL. If one DLL had it disabled, I could use corrupted exception handlers in that DLL's address space unchecked. To this end I ran PESecurity against everything in the game's directory. Here's some of what I got: PS C:\Users\User\lethalleague> Get-PESecurity -directory . FileName : C:\Users\User\lethalleague\LethalLeague.exe ARCH : I386 DotNET : False ASLR : True DEP : True Authenticode : False StrongNaming : N/A SafeSEH : True ControlFlowGuard : False HighentropyVA : N/A FileName : C:\Users\User\lethalleague\libconfig++.dll ARCH : I386 DotNET : False ASLR : False DEP : True Authenticode : False StrongNaming : N/A SafeSEH : True ControlFlowGuard : False HighentropyVA : N/A FileName : C:\Users\User\lethalleague\libsndfile-1.dll ARCH : I386 DotNET : False ASLR : False DEP : False Authenticode : False StrongNaming : N/A SafeSEH : False ControlFlowGuard : False HighentropyVA : N/A [snip] FileName : C:\Users\User\lethalleague\titan_ggpo.dll ARCH : I386 DotNET : False ASLR : False DEP : True Authenticode : False StrongNaming : N/A SafeSEH : True ControlFlowGuard : False HighentropyVA : N/A Do you see that? For some reason libsndfile-1.dll is compiled with no protections at all, neither SafeSEH or even ASLR! Jackpot! Now I can use a false exception handler within libsndfile-1.dll's address space, with no SafeSEH table to contend with. Even better, I can start ROP chaining using fixed address gadgets from libsndfile due to the lack of ASLR. First I tried just changing the address of the SEH exception handler to point inside libsndfile-1.dll's address space:
It works! We have control of the instruction pointer! From here it's pure ROP, as DEP (Windows' name for a non-executable stack) is in effect for the whole process. I won't bore you with the entire process, there was a lot of trial and error and hacky workarounds but eventually I got the following exploit structure working: - Add a large value to the stack pointer to find our buffer after the SEH jump - Set up dummy values on the stack for a call to VirtualProtect - Manually set each parameter to the call using ROP, to set execute permissions where our shellcode is located - Write the shellcode location as the return address for VirtualProtect - Move the stack where our call is set up, with a ROP gadget to execute it - VirtualProtect is called with the set up parameters, setting execute permissions for our shellcode, then returning into the shellcode - We have arbitrary code execution! There are a lot of gory details with how the stack is set up, weird gadgets I had to use to achieve what I wanted and jumping around different parts of the buffer to accomodate said weird ROP gadgets. I may update this post with more details in the future. This article was really helpful for me in understanding how to properly exploit ROP on Windows, I recommend giving it a read if you're trying something similar. You can see a video of the exploit in action from the victim's perspective here. The victim creates a lobby in-game, then I get their lobby ID from their Steam profile and run the exploit in the background. This was done on a vanilla Windows 10 installation. This whole process was a lot of fun! I learnt a lot about Windows internals and exploitation, and even managed to combine a separate bug in a library for the exploit chain. It's also interesting that I couldn't find an exploit on Linux while I could on Windows, but you can reach whichever conclusions from that you wish to :)