In my previous post, I created a basic manual map injector for allocating my code and running it. Despite getting the injector up and running, there are still a few issues to address. For starters, it does nothing! Despite having the capability to put my code in Roblox, I have yet to create the code I would actually want it to run. The second issue is what I'll be covering in this blog post: Hyperion has a few countermeasures put in place to detect and remove any memory it deems malicious.
Though I can successfully inject code, Hyperion wipes the memory allocation containing it soon after. Others have written workarounds to keep injections persistent, but rather than rely on someone else's bypass, I decided to try my hand at writing my own.
The First Disassembly
The expected first step would be to throw Hyperion in a disassembler and begin analyzing its instructions. However, when I tried throwing the packaged RobloxPlayerBeta.dll into IDA pro, it froze and became entirely unusable. Seeing as static disassembly was getting me nowhere, I decided to try creating a runtime dump of the module.
This ended up working!
The byfron module I had dumped to a file ended up working with IDA, with the added benefit of being able to see some of its values in the .pdata and .data sections initialized, because it was taken from runtime. After analyzing the code for a bit, I found a recurring instruction pattern that made IDA's disassembly fail upon reaching it.
version-e1da58b32b1c4d64
In the image above, you can see the key instruction 'jz short near ptr loc_XXXXX+X', immediately followed by what looks like junk instructions and bytes. Because the jump is conditional, IDA thinks the junk bytes are able to be reached and executable, causing IDA to incorrectly interpret the invalid instructions.
The key here is to figure out and understand that, even though these jumps are conditional, they will always be followed during execution. For example, if you decipher the mov and cmp instructions you can see its hard coded to compare a negative number (92CB95B2h), setting the Sign Flag to 1, making the "js" always jmp no matter what.
If you patched the jump to be unconditional, IDA would disassemble up to the correct point, and you'll be left with readable code and fixed control flow.
In the image above, you can see a fixed version of the instructions. Instead of a conditional jump, the modified unconditional jump is followed by junk bytes that IDA doesn't try to interpret as instructions.
I modified my runtime dumper to disassemble, find, and patch the specific conditional jumps to unconditional. Afterwards, I was left with something far more readable - something IDA could turn into pseudocode.
Finding Important Code
The first thing I did after gaining a proper file to analyze was to try and identify useful functions. I had previously written a hook detector to find the inline hooks placed by Roblox, so I decided to use that same tool again and look into what these hooks were actually doing.
My program found that Roblox had placed around 40 or so inline hooks in ntdll.dll and then a couple more in KERNELBASE.dll. The inline hooks jumped to corresponding functions in Hyperion, so I went and labeled these functions in my disassembler. At this point, I opened the dll in Binary Ninja to see what its capabilities were.
Side Note: After comparison of the pseudocode that BNinja and the Hexrays Decompiler produced, I would recommend BNinja for analysis of control flow, as the pseudocode to me is far more readable. For xrefs and pseudocode accuracy, however, I found IDA to be the better option.
Through my earlier debugging of the injector, I had found that Roblox wasn't actually terminating the thread spawned on my allocated memory but was instead finding the executable memory storing my injected instructions and changing its protection to NO_ACCESS. As a result, any thread running on it would fail execution immediately after it was detected and changed.
Armed with this information and knowledge of the hooks Roblox had placed on parts of the WINAPI, I began digging to find out how it differentiated between 'legitimate' and 'illegitimate' memory.
Finding the Memory Whitelist
The functions I analyzed ended up being Hyperion's hooks of NtAllocateVirtualMemory and ZwProtectVirtualMemory. I figured if Roblox were able to run its own code, then somewhere in it's own allocation and protect functions, it would need to mark these portions of memory as "do not touch" or "whitelisted".
Searching through the NtAllocateVirtualMemory hook, I found references to the string "unordered_map/set too long" - a message that appears in the C++ std::unordered_set::insert method. It immediately clicked that a section of this code was, in fact, adding something to a set or a map.
Looking at the ZwProtectVirtualMemory hook, I found similar snippets of code that eventually called a function containing that same string, which made my theory about a memory whitelist seem more plausible.
I'll walk through an inspection of the ZwProtectVirtualMemory hook.
Binary Ninja disassembly, some unremoved junk code can be seen
First, the hook checks if the call is to an external process. If so, then no checks are made and the syscall is performed. If the specified process is Roblox, then other checks are performed.
Current process is checked. Variables start being decoded from globals
Before any more checks are made, this block of code is executed. Hyperion doesn't store all of its useful pointers and data unencoded in memory.
Here it takes an encoded version of some useful data and decodes it, byte by byte, into something meaningful. Luckily for us, we already have all the encoded variables and global values, so we can simply take them and identify what they're decoding.
This kind of encoding and decoding exists all over the Hyperion module.
This variable, for example, evaluates to the base of the Roblox module. This could be figured out after recreating and running the pseudocode with the same global values from the dump. You will also notice that Hyperion loves to perform inline syscalls. This may be necessary in a hook, but you will also find it done in plenty of other functions as well.
After a few checks for whether or not the protected memory region is for a Roblox module, it eventually checks if the NewProtection parameter of the protect memory call is executable memory. If so, the memory is queried and more checks are performed.
Eventually, we reach this vital code block. If the location of the memory is deemed valid (through both previously mentioned and not mentioned checks), the new protection is executable, and the old protection is not executable, then we reach the whitelist section of this hook.
Here it takes the base and size of the specified memory allocation and iterates over every 0x1000 page within it. Two variables I'll call hash1 and hash2 are computed, and the previously mentioned implementation of the std::unordered_map::insert function is called, passing in a reference to the whitelist map, a buffer for return information, and a reference to a computed hash for the given page.
When viewing the psuedocode above, it may appear that hash2 is unused. However it is important to note that hash2 is stored right below hash1 on the stack.
hash1 is a 4-byte variable, and hash2 is a 1-byte variable. When we pass in the reference to hash1 in the setInsert function, hash2 is used as setInsert later dereferences the passed-in reference to hash1 at the 4th index, right below hash1, which is hash2.
Essentially, any time memory has its protection changed from code within the Roblox process, a check is performed that determines whether this memory is valid. If it is, then it's added to a memory whitelist.
It's important to keep in mind that the values "0xE852B98E", "0xD7", the location of the whitelist map, and the location of the insert set function change with every update. The ideal solution to this would be an offset dumper that could automatically scan for these values in a dump.
Also, the code I provided exists in its own form with slightly different checks in the NtAllocateVirtualMemory hook as well; however, I choose to not analyze that one, as the setInsert function was inlined into the hook.
After writing some code to add my own allocated memory to the whitelist, simulating what this whitelisting pseudocode did, I was satisfied to find that my memory was then left untouched and kept as executable memory!
The code snippet below allows you to add your own allocated memory to the whitelist:
__forceinline void WhitelistMemory(ULONGLONG start, ULONG size) { using insert = void* (__fastcall*)(void*, void*, void*); struct whitelist_entry { ULONG hashKey; char hashXor; }; struct insert_return_type { ULONGLONG position; bool inserted; }; ULONGLONG byfron = (ULONGLONG)GetModuleHandleA("RobloxPlayerBeta.dll"); //dynamic values const LPVOID whitelist_set = (LPVOID)(byfron + 0x2a2280); const insert whitelist_set_insert = (insert)(byfron + 0xd868e0); const ULONGLONG whitelist_set_hash_key = 0xbe1aebd1; const ULONGLONG whitelist_set_hash_xor = 0x6d; // ULONGLONG alignedStart = (ULONGLONG)(start) & ~0xFFF; for (ULONGLONG page = alignedStart; page < start + size; page += 0x1000) { whitelist_entry entry = {}; ULONG pageHashKey = ((((ULONGLONG)(page) >> 0xC) ^ whitelist_set_hash_key)); ULONG pageHashXOR = ((((ULONGLONG)(page) >> 0x2C) ^ whitelist_set_hash_xor)); entry.hashKey = pageHashKey; entry.hashXor = pageHashXOR; insert_return_type result = {}; whitelist_set_insert(whitelist_set, &result, &entry); } }
Values produced by my dumper for version-78712d8739f34cb9, different to the one analyzed in this post
So where exactly is this whitelist used?
There are some functions using the whitelist I have yet to fully decipher, as either the function has no references or is really damn long. One instance of the whitelist being used that I could identify was a KiExceptionDispacter hook, where exception-based memory operations are done (something I won't get into here). The whitelist is also referenced in Hyperion's second main loop, which is called in its ZwContinue hook.
Conclusion
This pretty much covers the whitelist check that Hyperion implements, though there are still a lot of other checks like the Control Flow Guard (CFG) and Instrumentation Callback. Hyperion also runs two continuous threads that perform several other checks as well.
Feel free to reach out using any of the contacts at the bottom of this page.
Next, I'll go over a roadmap for creating a full exploit based on what I've learned thus far, such as Luau, Shuffles, Offsets, and the Roblox architecture. Cheers!