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
.
__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._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
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:

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.

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 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.

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.

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.

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.

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.

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:

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.

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.

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.