index

The Hidden Cost of JIT - How Runtime Compilation Becomes a Cheater's Best Friend

· 6min

Performance at the cost of integrity - why JIT-compiled games are fundamentally harder to protect than their AOT counterparts.

Just-in-time compilation is one of the most elegant tricks in computer science. The idea is smimple: rather than compiling your program’s bytecode all the way down to native machine code ahead of time, you let a runtime do it on the fly ~ only compiling the paths that are actually executed, right before they run. For managed language runtimes like the CLR or the JVM, this is the secret sauce behind surprisingly competitive performance.

But in the context of game security, JIT compilation creates a problem that no amount of anticheat engineering can fully close: you cannot integrity-check code that doesn’t exist yet.

This post is about that problem ~ how it works, why it matters and why Mono based unity games are disproportionately more exposed to sophisticated cheat techniques than their IL2CPP counterparts.

First: what JIT actually does to your executable.

In a traditional AOT (ahead-of-time) compiled game ~ think a C++ title, or a Unity game built with IL2CPP ~ the code that ships t oplayers is already machine code. It lives in the .text section of your PE binary. That section is mapped into memory as read-only and executable (RX). It’s the same bytes onm every player’s machine (with some exceptions). This property is load-bearing for security: you can hash it, sign it and later check that what’s in memory matches what shipped on disk.

JIT changes all of this. In a Mono-based Untity game, what ships is not machine code - It’s CIL (Common Intermediate Language) bytecode packged into manage assemblies (.dll files). The actual machine code is produced by Mono’s JIT engine at runtime, written into freshly allocated executable memory regions, and then jumped to. Those regions never exised on disk. They were born into memory, from the runtime’s interpretation of bytecode that can itself be modified before it’s ever compiled.

Because JIT-compiled native code is generated at runtime into anonymous heap regions, it has no on-disk reference to verify against. Any integrity check you write either happens before JIT runs (checking bytecode, not native code) or after JIT runs (checking memory that an attacker has already had time to modify).

The attack surface this opens up

  • Hooking JIT-compiled code in the .text region

    • Here’s where it gets concrete. In a fully native executable, hooking a function ~ redirecting it’s execution to attacker controlled code requires patching the .text section. On modern Windows, that section is mapped with PAGE_EXECUTE_READ. Writing to it requires calling VirtualProtect to temporarily flip the page to writeable, which is noisy, auitable, and trips a lot of anticheat hooks.

    • In a Mono JIT environment, the story is different. The JIT writes generated code into memory regions it allocates itself ~ typically with PAGE_EXECUTE_READWRITE ~ because it needs to write the code and then execute it from the same mappin. That means attacker code running in the same process can write to JIT-emitted code pages without any permissions esclation at all. The memory is already writeable.

    MonoJitInfo* ji = mono_jit_info_table_find(domain, target_ip);
    void* code_sstart = mono_jit_info_get_code_start(ji);
    size_t code_size = mono_jit_info_get_code_size(ji);
    
    u8 hook[] = {
      0x48, 0xB8, // mov rax, imm64
      BYTES(our_replacement_fn),
      0xFF, 0xE0 // jmp rax
    }; //
    
    memcpy(code_start, hook, sizeof(hook));
    • An anticheat system scanning .text for byte-pattern modifications would never find this hook ~ because it isn’t in the text section. It’s in a dynamically allocated JIT region that the scanner either doesn’t know to look in, or can’t distinguish from legitmate JIT-compiled code.
  • Assembly-level injection via the managed runtime

    • Even before JIT compilation happens, the managed assembly layer is wide open. Mono exposes a rich embedding API ~ mono_assembly_load, mono_class_get_method_from_name, mono_runtime_invoke ~ that cheat developers can use to load arbitrary managed assemblies into the game’s domain and call methods as if they were game code. This is the backbone of tools like BepInex and most Unity mod loaders, and it works precisely because the managed runtime is designed to be embeddable and introspectable.

    More aggressively, a cheat can intercept CIL bytecode before the JIT sees it. Mono exposes a JIT compilation event hook (mono_jit_info_table_find, profiler APIs, etc.) that an injected module can use to rewrite method bodies on the fly ~ changing game logic at the bytecode level before nativew code is ever emitted.The resulting native code is indistinguishable from legitimately compiled game code.

  • Reflection as a backdoor

    • Mono’s reflection subsystem gives code running inside the managed domain the ability to inspect and invoke private fields and methods without any special privilege. A cheat module ~ loaded a managed DLL can walk the game’s assembly, find private fields like playerHealth or bulletVelocity and read or write them with standard reflection calls. There’s noothing about this that looks anomalous from the rutime’s perspectivve; reflection is a first-class language feature. Monitoring for it at runtime produces extreme false-positive rates.

IL2CPP transpiles managed C# into C++ source code, whichj is then compiled to a standard native binary. The output is a regular GameAssembly.dll (on Windows) with a proper text section, loaded with standard memory protections. Antichewat solutions can perform signature scanning on it, hash it, verify it against known-good values. Hooking requires either a native detour with a VirtualProtect call (auditable) or a kernel-mode driver (very auditable, and blocked by KPP on modern Windows).

Thisis why Valorant, for example, uses both IL2CPP-style native compilation and a kernel-mode driver (vgk.sys) ~ and why despite all that, cheating in Mono-based games often requires nothing more than a managed DLL and some reflection calls.

Why anticheats can’t just “check JIT memory”

The obvious counter is: why not just scan JIT-allocated memory for know hook patterns? A few reasons make this harder than it sounds.

First, JIT-compiled code is non-deterministic across runs. The same C# method can compile to different native byte sequences depending onregister allocation, inline decisions and runtime profiling state. There no stable byte pattern to match against. You’d need to re-JIT the method yourself, diff the output and detect deviations ~ a technique that exists in research but is expensive and fragile in production.

Second, the JIT regions are shared space. Legitimate modding frameworks, profilers and instrumentation tools all write into this memory. Distinguishing a legitimate BepInEx hook from a cheating hook at the memory-scan level is essentially a classification problem with no clean decision boundary.

Third, any scan you run is subject to a TOCTOU (time-of-check, time-of-use) race. A cheat can detect when the anticheat scanner is about to read a memory region, temporarily restore the origional bytes, let the scan pass and then restore the hook ~ all within a single scheduler timeslice. This technique, sometimes called “shadow patching” is well documented in the anticheat community and is practical on Mono targets precisely because the attacker has process level write access to JIT regions with any privilege escalation.

~ tbf