Bypassing Hyperion's Working Set Detections

External cheat providers often rely on continuous data manipulation within Roblox, from reading and writing to memory, to enable their functionality. Hyperion counters these cheats by exploiting the process's working set, allowing it to detect external processes that access or modify a designated memory pool containing player data.

In this blog, we will reverse-engineer Hyperion's detection mechanisms and craft a bypass for educational purposes.

Getting Started

Before we can begin, we need to acquire a decrypted version of the Roblox Player. You may use any tool you like, but I recommend vulkan, a tool I designed specifically for processes protected by Hyperion (this includes Roblox).

For this reversal, I used the version-37cf60402a5648b4 build of Roblox.

Tools Used

  • IDA 7.7: Used for reversing the Player with the ClassInformer plugin.
  • Binary Ninja: Used for analyzing the Hyperion module (RobloxPlayerBeta.dll).

Initial Impressions

After loading the Player into IDA and resolving RTTI, you may notice that all instances being watched have a unique deleter type, RBX::Creatable::Deleter2.

List of reference counters for watched instances.

What's unique about these instances is that Roblox accesses them infrequently. As a result, moving the memory they're allocated in and out of the working set doesn't significantly impact the client's performance.

After searching for cross-references to the virtual function table of the std::_Ref_count_resource with the unique deleter, you will see exactly where the instance is allocated.

Stub that acquires the custom allocation routine.

As you can see, first the Player queries Hyperion for the custom allocation routine, which will return a block of memory in the watched memory pool. If the global Hyperion::AllocateWatchedMemory function pointer isn't set, Roblox will attempt to acquire it again.

Exception based communication between Roblox and Hyperion.

This pattern (Hyperion::Acquire::) you see is actually quite common throughout the Player. This is how the Player communicates with the Hyperion module. If you look at Hyperion::Acquire::AllocateWatchedMemory, you can see that a function call to an invalid memory location is invoked.

The creation of a smart pointer for an instance allocated in watched memory.

Otherwise, a default allocator is used and a smart pointer with the regular deleter (RBX::Creatable::Deleter) is created.

The creation of a smart pointer for an instance allocated in regular memory.

Diving Deeper

If you take a look, Hyperion::AllocateWatchedMemory is just a pointer to a virtual address. This address resides in the Hyperion module's memory.

The Hyperion::AllocateWatchedMemory function pointer.

As you can see, it points to an absolute address: 0x7FFFDB7EB150. Now that the Hyperion module has been dumped from memory, we can search for that absolute address within the dumped memory.

For the analysis of the Hyperion module, I used Binary Ninja and resolved most of the static obfuscation. You may not see the same output when reversing yourself.

As expected, the routine takes two parameters: a pointer to a memory pool (this is where the allocated memory is passed) and a size, which corresponds to the size of the object we're allocating memory for.

The function definition for the custom allocation routine.

You will initially notice a lot of complex code with tons of nested if-statments. These are part of Hyperion's lazy importer, which they use to obscure system calls and Windows API calls. This function is hard to trace, so let's see what it returns and try to trace it back.

Return sequence for Hyperion::AllocateWatchedMemory

As you can see above, the routine sets the memoryPool parameter with the pointer to the newly reserved memory, and a return value is set. If no memory is available (or something went wrong) a return value of 5 is passed. This forces the Roblox Player to use an alternative method of allocation.

After searching for references to allocation in the routine, you will find an interesting check.

Internal size check.

The watched memory pool stores a size that represents available space in bytes. If the number of bytes we're trying to allocate exceeds this value, allocation is set to null and an error arises.

Now a theory arises: Can we trick Hyperion into thinking that the watched memory pool is out of memory and force the Roblox Player to use alternative allocators?

Testing The Theory

To proceed, we must first locate the watched memory pool. Conveniently, it is the only private, committed memory region with read and write permissions and a fixed size of 0x200000, making it straightforward to identify.

Furthermore, this will only work at the initialization of Roblox, since that's when the instances get allocated. Here are the steps we must follow:

  1. Wait for the Roblox Player to load.
  2. Find the region containing the watched memory pool.
  3. Set the number of free bytes to 0x20 (minimum number of bytes).

This was actually incredibly easy to implement, and I did it in 50 lines of C++ code using wincpp. You can check out the GitHub repository here.

Conclusion

Through careful reverse engineering of both the Roblox Player and Hyperion, we were able to craft a simple program that forces the Player to use the default allocator for all instances, effectively disabling the working set detections entirely.

If you're looking to safeguard your native applications and make reverse engineering significantly more challenging, check out Nestra. Our web-based PE obfuscation service provides robust protection tailored to your needs.