Reverse Engineering Hyperion: Bypassing Control Flow Guard

In this post, we will analyze how Hyperion modifies Windows' Control Flow Guard (CFG) to enforce its own validation checks. Instead of relying on the default CFG implementation, Hyperion overwrites it with a custom function that validates indirect call targets before execution. This implementation prevents unauthorized hooks from being placed on protected functions, such as task scheduler jobs.

Background Knowledge

Control Flow Guard

Control Flow Guard (CFG) is a security feature in Windows designed to prevent exploits that hijack a program’s control flow. It protects indirect function calls by ensuring that execution only jumps to known, valid locations, mitigating attacks such as return-oriented programming (ROP) and function pointer overwrites.

CFG is enforced at runtime by validating target addresses before execution, blocking any attempt to execute code from unexpected locations. This is typically done through indirect call validation, which ensures that function pointers only point to legitimate destinations. Two key components of CFG related to this enforcement are __guard_dispatch_icall_fptr and _guard_dispatch_icall_nop.

  1. __guard_dispatch_icall_fptr: This function pointer is set by the system at runtime and points to the CFG validation routine responsible for checking whether an indirect call is allowed. When an indirect call is made, execution flows through this pointer to verify the target before the actual function is called. Hyperion overwrites this pointer to hijack the validation mechanism and enforce its own security checks.
  2. _guard_dispatch_icall_nop: This is a default no-operation (NOP) implementation of the CFG validation function. If CFG is disabled or not supported, the system may point __guard_dispatch_icall_fptr to _guard_dispatch_icall_nop.

Reverse Engineering The Check

Before proceeding, it's important to note that all the code presented here has been extensively reverse-engineered and, where applicable, deobfuscated. As a result, the code you encounter in the binary may appear slightly different from what is shown.

CFG In Action

As mentioned, CFG guards all indirect calls in the binary. In the Roblox client, thousands of references to the __guard_dispatch_icall_fptr routine can be found. Below is one example:

An example of CFG guarding rax.

In the screenshot above, CFG validates the virtual address stored in rax. These values are then sent to a custom handler for further evaluation.

CFG's Validation Process

Now that we understand where CFG is used and what it protects, we can examine how it determines whether a pointer is legitimate. The following routine is what __guard_dispatch_icall_fptr points to.

How CFG processes a target location before jumping to it.

As you can see, this routine is relatively simple. It performs a quick check to determine whether the virtual address is present in a cache of recently processed locations. If the address is found, execution jumps directly to it. Otherwise, CFG processes the address further.

The second stub that processes a new target before jumping to it.

The subroutine above is referenced in the previous routine. It takes the jump target and passes it to HandleTarget for further analysis. Once that call completes, execution jumps to the target. This confirms that whether the virtual address is trusted or not, CFG ultimately redirects control flow accordingly.

Inspecting CFG's Target Handler

When CFG encounters a virtual address (jump target) it has not seen before, it undergoes multiple validation checks. Based on these checks, CFG determines whether the address is authorized or unauthorized. If deemed unauthorized, it triggers a crash.

The initial checks of the HandleTarget routine.

CFG proceeds only if the target address is 0xfffffffffffffff3. This is because Hyperion’s forced exceptions start from 0xfffffffffffffff4 to 0xfffffffffffffffe, which it avoids processing. I covered these exceptions in a previous blog post, which you can read here.

Now, let's examine the syscall. Due to Hyperion's SSN encryption, the C pseudocode is not entirely accurate. To gain a clearer understanding, we need to analyze it at the assembly level.

The end of the syscall stub.

After taking a closer look at the parameters, we can confirm that this is a call to NtQueryVirtualMemory. After correcting the parameters, the reconstructed C pseudocode of the call will look like this:

MEMORY_BASIC_INFORMATION memoryBasicInfo;

NTSTATUS status = NtQueryVirtualMemory(
    GetCurrentProcess(),              // Process handle (current process)
    virtualAddress,                   // The base address  
    MemoryBasicInformation,           // Queries basic information about memory
    &memoryBasicInfo,          
    sizeof(MEMORY_BASIC_INFORMATION), 
    NULL                              
);

The handler is clearly querying basic information about the provided address. This includes the base address and the size of the memory region where the virtual address resides. Let's now move on to the code preceding this call.

The TargetCache structure being populated.

Here, we encounter the TargetCache structure again, this time during its population. If the system successfully retrieves the size and base address of the memory region containing the target address, each page within that region is added to the cache. Otherwise, only the specific page containing the target address is stored.

Once all pages have been added to the cache, we move on to the next check.

Roblox addresses are automatically accepted.

Here, Hyperion decrypts the base address of the Roblox Client and only proceeds if the jump target is outside the client. This means CFG effectively ignores any jump locations within the game, allowing execution to continue normally inside the client. Looking past this, we can see CFG initializing its next check.

Initialization of loaded module cache.

Here, we can see the start and end pointers of the LoadedModuleCache being retrieved. Each entry consists of three keys that, when decoded, provide the base address of the module and its size in bytes. Looking further, we can see this process in detail:

CFG's encoded module address range lookup.

Here, BaseAddress, the base address of the memory region containing the jump target, is compared against each module entry. If the address falls within the range of any module in the cache, BaseAddress is set to 1, and the next check is skipped. Otherwise, execution jumps to label_1816982ab (shown in the previous screenshot), BaseAddress is reset, and the process continues with the next validation step.

The virtualAddress being looked up in a hash map.

Here we can see a unique hash being calculated for the provided virtualAddress. Then a bucket which would contain the entry for this address is retrieved and compared. If the entry exists within this map, then the routine exits successfully. Otherwise, CFG classifies this address as unauthorized and proceeds with a detection.

Hyperion only performs this hash map lookup on older versions of Windows. It checks two values from the shared user data region: NtMajorVersion at 0x7FFE026C and NtBuildNumber at 0x7FFE0260. If the major version is less than 11, or the build number is 17133 or lower, the check runs. Otherwise, it’s skipped entirely. This suggests the hash map is a fallback used when newer control flow protections aren’t available or reliable.

CFG setting Hyperion's trap flags for future crash.

Finally, we reach the last part of the function: detection. Here, CFG generates a random timestamp, encrypts it, and stores it in a pointer. This value is later accessed to trigger a crash.

If CFG has flagged you before, you won’t crash immediately. Instead, after a delay of approximately 15 seconds, Roblox’s task scheduler queries these trap flags, ultimately forcing a crash.

Bypassing The Mechanism

Since CFG is relatively simple, bypassing this validation is straightforward. There are several ways to trick CFG into recognizing an untrusted memory address as legitimate. Implementations of these bypasses can be found here.

Modifying The Target Cache

The simplest bypass involves manipulating the TargetCache. As shown in the analysis, a jump location is only processed if it is not already present in the TargetCache structure. By forcing our address into the cache before applying hooks, we can prevent CFG from detecting our modifications. If we want, we could also disable the mechanism simply by setting every bit in the cache bitmap.

Adding to the Loaded Module List

Another approach is to reverse Hyperion’s bitfield-encoded vector of module entries and insert a custom module into the list. This method is fairly complex, but there is likely a routine responsible for handling these insertions. Due to time constraints, I decided to skip implementing this method.

Inserting Into Whitelisted Page Map

The final method involves inserting all pages in your memory region into the hash map identified earlier in the analysis. By locating the appropriate insertion function, you can add any virtual address to this map.

Through testing, this method did not always work consistently, most likely due to testing on an incompatible Windows build.