Easy Anti-Cheat's Page Encryption Analysis

Last Modified: May 15, 2026Published by: Interpreter

Table of Contents

  1. Architecture Overview
  2. Module Layout and Deployment
  3. Exception Handling Chain
  4. Page Encryption and Decryption
  5. Accessing Address Tracking and the 500-Page Limit
  6. Data Encoding Schemes
  7. Shadow Allocation and Relocation Fixups
  8. Obfuscated Syscall System
  9. API Resolution by Hash
  10. Code Hook / Trampoline Engine
  11. Integrity Checks and Violation Reporting
  12. TLS and Network Communication
  13. Initialization Sequence
  14. Key Data Structures
  15. Named Functions Reference

Architecture Overview

EAC protects the Fortnite executable's .text section by encrypting every page (4KB) and marking them PAGE_NOACCESS. When the game executes code on an encrypted page, a page fault occurs. EAC intercepts this fault through a hook on KiUserExceptionDispatcher and transparently decrypts the page, applies relocation fixups, restores the correct page protection, and resumes execution. The game never knows its code was encrypted.

The entire EAC handler is a manually-mapped module (~22MB) loaded into private memory with no PE headers. It has no imports, no exports, and no section names — everything is resolved at runtime through hash-based API lookup and obfuscated direct syscalls. The binary contains 38,045 functions (after recovery analysis), the vast majority of which are obfuscated VM-generated code (functions 50KB-150KB with no string references). The meaningful hand-written code is approximately 500-1000 functions concentrated in a few address ranges.

The system has these major subsystems:

  • Exception dispatch — intercepts page faults and routes them
  • Page decrypt/encrypt — the core on-demand decryption engine
  • Access tracking — FNV-1a hashmap tracking which instruction addresses trigger decryptions, with a 500-page limit per address
  • Syscall obfuscation — all NT API calls go through encoded syscall stubs, never through ntdll
  • Code hooking — runtime binary patching with atomic trampoline installation
  • Integrity verification — page hash checks, executable hash catalogue validation, module scanning
  • TLS networking — custom TLS 1.2 implementation for telemetry and anti-cheat communication
  • Telemetry — periodic heartbeat/report posting to EAC servers

Module Layout and Deployment

EAC's handler binary is mapped into a MEM_PRIVATE allocation — not a normal loaded module. This means:

  • No PE headers in memory (stripped after mapping)
  • Not visible in PEB->Ldr module list
  • Not discoverable by standard module enumeration
  • All API addresses resolved at runtime via hash walking of export tables

The binary starts at an arbitrary base (the dump was rebased to 0x0 in IDA) and contains:

RegionAddress RangeSizeContents
Dispatch Tables0x0000–0x4FFF20 KBTwo encoded dword arrays (4096 + 1024 entries) with monotonically increasing values. High words 0x89D8/0x89D9 and 0x8B1C/0x8B1D respectively. Purpose unknown — possibly function dispatch or address encoding tables.
Code Region 10x5000–0x125FFF~1.1 MBCore hand-written functions: exception handling, page decrypt, syscalls, API resolution, hooking engine, TLS, HTTP, telemetry. ~315 recovered functions.
Data Section0x126000–0x20DFFF~920 KBCrypto lookup tables, unwind data, cipher suite strings (0x18A5D9–0x18A99D), error messages, DH parameters (0x18A268), BSS-style zeroed regions.
Code Region 20x20E000–0x1570FFF~19.4 MBBulk obfuscated VM-generated code. ~37,700 functions, most 50KB–150KB with no strings. This is the VM dispatcher and handler soup.
Tail Data0x1571000–0x1582FFF~72 KBTrailing data tables and padding.

Total: 38,045 functions identified after recovery (4 created from gaps, 490 recovered as tail chunks, 42 strings recovered, 1,864 data regions defined).


Exception Handling Chain

The Hook: KiUserExceptionDispatcher Trampoline

EAC hooks the Windows exception dispatch mechanism. When any exception occurs in the process:

  1. The kernel delivers the exception to user mode via KiUserExceptionDispatcher
  2. EAC's trampoline (eac_exception_trampoline at 0x10FCF0) intercepts this before the normal VEH/SEH chain
  3. The trampoline calls eac_syscall_dispatcher (for syscall-related exceptions) and eac_exception_dispatch (for page faults)

Exception Dispatch

This function receives the full exception context (CONTEXT record + EXCEPTION_RECORD) from the stack. It performs these checks:

  1. Stack frame validation — verifies the exception frame hasn't been corrupted by checking stack pointer alignment and bounds
  2. Exception code check — only processes 0xC0000005 (EXCEPTION_ACCESS_VIOLATION); all others fall through to the normal handler
  3. Second-chance filter — ignores continuable exceptions with specific flags
  4. Faulting address classification:
    • If the accessed address is -1 (0xFFFFFFFFFFFFFFFF): this is a stack overflow probe — routes to eac_stack_overflow_thunk
    • If the access was a read (parameter[0] != 1): iterates the protected modules list to find which module owns the faulting page
  5. Module matching — walks an array at 0x199082B8D60 (ProtectedModules, currently 2 entries for the 2 slots) using the base/size decoding formulas to check if the faulting address falls within a protected module
  6. Dispatch to decrypt handler — calls eac_page_decrypt_handler with the matched module context
  7. Resume via syscall — on successful decrypt, encodes and executes NtContinue via obfuscated syscall to resume the faulting thread

Here's the core module matching and dispatch logic from eac_exception_dispatch (0x3515E):

// Exception code 0xC0000005 = EXCEPTION_ACCESS_VIOLATION
if (ExceptionRecord->ExceptionCode == -1073741819)  // STATUS_ACCESS_VIOLATION
{
    faulting_addr = ExceptionRecord->ExceptionInformation[1];

    // Stack overflow detection: faulting address == -1
    if (faulting_addr == -1)
    {
        while (_InterlockedCompareExchange(&eac_exception_spinlock, 1, 0))
            ;
        // Walk ProtectedModules array at 0x199082B8D60 (2 slots)
        module_ptr = 0x199082B8D60;
        while (1)
        {
            // Decode module base: affine transform over 64-bit ring
            module_base = (*(QWORD *)(module_ptr[0] + 216) + 0x74EB78F9B70729C8)
                        ^ 0xED518186A5626B48;
            // Decode module size: multiply + XOR
            module_size = (0xA525BF950B28AAF9 * *(QWORD *)(module_ptr[0] + 224))
                        ^ 0x553C4882D735B247;
            if (rsp >= module_base && rsp < module_base + module_size)
                break;
            module_ptr += 8;
            if (module_ptr == 0x199082B8D68)  // past last slot
            {
                _InterlockedDecrement(&eac_exception_spinlock);
                goto fallthrough;
            }
        }
        eac_stack_overflow_thunk(&context, &exception_record, &context);
    }

    // Read fault (not write): find owning module and decrypt the page
    if (ExceptionRecord->ExceptionInformation[0] != 1)
    {
        faulting_page = faulting_addr & 0xFFFFFFFFFFFFF000;
        // ... same module-matching loop ...
        result = eac_page_decrypt_handler(&context, &exception_record,
                                          &exception_record, module_context, &context);
        _InterlockedDecrement(&eac_exception_spinlock);

        if (result)
        {
            // Encode NtContinue SSN through modular exponentiation
            ssn_lo = eac_encode_syscall_lo(0x30BEB489453C14D, 0x45F16D3EEC73C99);
            ssn_hi = eac_encode_syscall_hi(rdx, 0x264CA0BC5C8448);
            // Decode to function pointer and call — bypasses all ntdll hooks
            NtContinue = (0xFE75EEE69CDC5E95 * (ssn_lo | (ssn_hi << 32)))
                       ^ 0x7459B3697ABDE93F;
            NtContinue(&context, &exception_record, &exception_record, &context);
        }
    }
}

After dispatch, the function also:

  • Acquires a spinlock to allocate an exception record from a lock-free pool
  • Copies the CONTEXT and EXCEPTION_RECORD into the pool entry
  • Captures up to 4096 bytes of stack
  • Calls eac_log_exception to record the event for telemetry

The function returns to a hardcoded address (0x7FF959850060) which is the original KiUserExceptionDispatcher continuation — this is the address that was patched when the hook was installed.

The exception record pool uses a lock-free 128-bit CAS (cmpxchg16b) for allocation. The pool has 10 pre-allocated entries of 5,536 bytes each:

// Lock-free pool allocation using InterlockedCompareExchange128
// Pool head at 0x1B4090 is a 128-bit value: [pointer, generation_counter]
__int128 slot = 0;
_InterlockedCompareExchange128(&eac_exception_queue_head, 0, 0, &slot);

do {
    if ((QWORD)slot)
    {
        // Reuse a freed slot: follow the next pointer
        next = *(QWORD *)slot;
        new_gen = (DWORD)(slot >> 64) + 1;  // bump generation to prevent ABA
        target = (QWORD *)slot;
    }
    else if (HIDWORD(slot >> 64) > 9)
    {
        _mm_pause();  // all 10 slots in use, spin
        target = NULL;
    }
    else
    {
        // Allocate from pre-allocated array: slot = base + index * 692_qwords
        target = &eac_exception_pool_base[692 * HIDWORD(slot >> 64) + 8];
        new_gen = ((HIDWORD(slot >> 64) + 1) << 32) | LODWORD(slot >> 64);
        next = 0;
    }
} while (!_InterlockedCompareExchange128(&eac_exception_queue_head, new_gen, next, &slot)
         || !target);

// Fill the allocated record
_InterlockedIncrement64(eac_exception_pool_base);  // bump global counter
target[0] = 0x19906866BE0;                         // link into exception list
target[1] = &eac_exception_list_tail;
eac_memcpy(&context, &target[2], 1232);            // copy CONTEXT (1232 bytes)
*(DWORD *)(target + 312) = NtCurrentTeb()->ClientId.UniqueThread;
eac_memcpy(&exception_record, &target[157], 152);  // copy EXCEPTION_RECORD
target[691] = eac_get_tick_count();                 // timestamp the capture

// Capture up to 4096 bytes of the faulting thread's stack
stack_size = min(StackBase - Rsp, 4096);
eac_memcpy(Rsp, &target[179], stack_size);
eac_log_exception(&context, &exception_record, &context, &exception_record);

Concurrency Control

A global spinlock (eac_exception_spinlock at 0x1C1A48) serializes access to the protected modules array during exception handling. This uses InterlockedCompareExchange in a spin loop with _mm_pause() for backoff.


Page Encryption and Decryption

The Core Handler

This is the largest and most important function (4,742 bytes). It handles the complete lifecycle of a page fault on a protected page.

Input: exception context, exception record, protected module descriptor (Module Info)

Step 1: Page identification

The faulting address is page-aligned (& 0xFFFFFFFFFFFFF000) and converted to a page index relative to the module base using the base decoding formula. The page index indexes into a per-page metadata array (96 bytes per entry) at Module Info+0x110 (offset 272 in the decompilation).

Each page metadata entry contains:

  • Offset +0: Encoded virtual address of the page
  • Offset +8: Encoded shadow data pointer (where the encrypted page content is stored)
  • Offset +16: Shadow pointer or magic sentinel (0x7866088CEE5B4180 means "use main allocation")
  • Offset +24: State/lock word (atomic, holds wait queue and state bits)
  • Offset +32: Owner TEB pointer (which thread currently holds the page lock)
  • Offset +40: Encoded original page protection
  • Offset +44: Relocation table offset
  • Offset +48: Image base fixup byte count
  • Offset +49: Image base fixup addend
  • Offset +56: Encoded timestamp (last access time)
  • Offset +64: First byte of page (saved for instruction analysis)
  • Offset +72: Encoded access duration counter
  • Offset +80: Encoded hash/decrypt function selector
  • Offset +88: Valid flag (must be 1)

Step 2: RIP validation

If the faulting RIP is not inside the protected module itself, EAC calls eac_validate_rip_in_module (0x78360) to check if the RIP is in an allowed region (the EAC module itself, or other whitelisted ranges). If validation fails:

  • eac_restore_page_protection is called (restores PAGE_NOACCESS)
  • The exception is passed through to the normal handler
  • This prevents external code from triggering decryption

If the page protection value indicates 0x4930914A (encoded value for a specific protection), the page was already decrypted and something went wrong — this triggers a violation report.

Step 3: Page lock acquisition

The state word at page+24 uses atomic operations for a multi-state lock:

  • Bit 0: Page is decrypted (ready)
  • Bit 1: Decryption in progress (locked)
  • Bits 2+: Pointer to wait queue head

If the page is already decrypted (bit 0 set), the function returns immediately. If decryption is in progress (bit 1 set), the current thread:

  1. Creates a wait event via obfuscated NtCreateEvent syscall
  2. Atomically enqueues itself onto the wait queue (CAS on the state word)
  3. Calls obfuscated NtWaitForSingleObject to sleep
  4. When woken, rechecks the state and either returns (if bit 0 now set) or retries

If the page is unlocked and not decrypted, the current thread atomically sets the lock bit (state = 3) and proceeds with decryption.

From eac_page_decrypt_handler — the page lock state machine with inline syscalls:

// State word layout (page_metadata + 24):
//   Bit 0: page is decrypted (ready)
//   Bit 1: decryption in progress (locked)
//   Bits 2+: pointer to wait queue head
event_handle = NULL;
do {
    while (1)
    {
        state = *(volatile QWORD *)(page_metadata + 24);

        if (state & 2)  // another thread is decrypting
        {
            if (!event_handle)
            {
                // Create a wait event via obfuscated NtCreateEvent syscall
                ssn = eac_encode_syscall_lo(0x36817B72BA9588B, 0x45F16D3EEC73C99);
                eac_encode_syscall_hi(rdx, 0x2BE871CD4CB550);
                syscall;  // NtCreateEvent
                if (525018615 * ssn < 0)
                {
                    eac_report_violation(3938459685, ...);
                    __fastfail(0xEAC);
                }
            }
            // Enqueue ourselves onto the wait queue (CAS on state word)
            wait_node.next = state & ~3;
            wait_node.event = event_handle;
            if (state == _InterlockedCompareExchange64(
                    &page_metadata[24],
                    (QWORD)&wait_node | (state & 3),
                    state))
            {
                // NtWaitForSingleObject — sleep until woken
                ssn = eac_encode_syscall_lo(0x20CEE6EDEA4CEC6, 0x45F16D3EEC73C99);
                eac_encode_syscall_hi(rdx, 0x53B0FBFF83ABEF);
                decoded_ssn = (878860529 * ssn) ^ 0xB5820D92;
                syscall;  // NtWaitForSingleObject
            }
            else
                _mm_pause();
            continue;
        }

        if (state & 1)  // already decrypted — we're done
        {
            if (event_handle)
            {
                // Close the event handle we created
                eac_encode_syscall_lo(0x4558DCE046C1E5B, 0x45F16D3EEC73C99);
                eac_encode_syscall_hi(rdx, 0x54FF2D96896DEF);
                syscall;  // NtClose
            }
            return 1;
        }
        break;
    }
    // Try to acquire the lock: CAS 0 -> 3 (locked + will-be-decrypted)
} while (state != _InterlockedCompareExchange64(&page_metadata[24], 3, state));

// We hold the lock. Record the owning TEB.
_InterlockedExchange64(&page_metadata[32], __readgsqword(0x30));

Step 4: Integrity check

On first access to any module, EAC runs eac_integrity_check (0x81788) under the module-level lock (at Module Info+336). This verifies the EAC module's own code hasn't been tampered with. If it fails: eac_report_violation + __fastfail(0xEAC).

A global flag at Module Info+380 tracks whether the integrity check has been performed. The check also verifies the calling thread ID matches expectations.

Step 5: Page content retrieval

Two code paths depending on the sentinel value at page+16:

Path A: Shadow allocation (page+16 == 0x7866088CEE5B4180)

  • The page content is stored in a separate "shadow" memory allocation
  • The shadow base is decoded from Module Info+0x108 (offset 264): (encoded + 0x74EB78F9B70729C8) ^ 0xED518186A5626B48
  • Content is at: shadow_base + page_offset
  • First, NtProtectVirtualMemory is called to make the shadow page readable (it was committed but not accessible)
  • 4096 bytes are copied from shadow to the target page's location in the module's address space

Path B: Direct pointer

  • page+16 contains an encoded pointer to the encrypted content
  • Decoded as: (page[16] + 0x74EB78F9B70729C8) ^ 0xED518186A5626B48
  • 4096 bytes copied from this location

Step 6: Decryption

After copying, the page content is decrypted in-place. The decryption function pointer is obtained through a heavily obfuscated lookup:

  • page+80 contains an encoded selector
  • This is decoded through multiple XOR/multiply chains to produce a function pointer
  • The function is called with the page data, a key derived from the page index, and hash parameters

The decryption involves:

  • A per-page XOR key derived from the page's position in the module
  • A hash function (appears to be a custom construction, not standard AES/etc.)
  • The key material is stored in an array accessed via Module Info+0x128 (offset 296)

The decryption function pointer resolution is the most heavily obfuscated code in the entire binary. From eac_page_decrypt_handler:

// page_metadata + 80 contains an encoded function selector
selector = 0xB45FCE88B86A7D * (*(QWORD *)(page_metadata + 80) ^ 0xBF8C9117EC18D26C);

// Compute the page index and hash it through a custom construction:
//   page_va   = decode(page_metadata[0])
//   module_va = decode(Module Info + 256)
//   page_idx  = (page_va - module_va) >> 12
//   key_index = hash(page_idx ^ 0x432E)  -- the hash uses 0x6EED0E9DA4D94A4F
page_idx = ((decode_base(page_metadata[0]) - decode_base(Module Info[256])) >> 12) ^ 0x432E;
hashed = 0x6EED0E9DA4D94A4F * page_idx;
folded = hashed ^ (hashed >> 32 >> (hashed >> 60));  // folded hash

// The key table is at Module Info + 296, indexed by the folded hash mod 50
key_table_entry = *(QWORD *)(Module Info[296] + 16 * (folded - 50 * (folded / 50)));

// Three more layers of encode/decode to get the final function pointer
decrypt_fn = (0x1AB31A3E9EBA4915
           * ((0x5CDCB983D541C21 * (key_table_entry ^ 0x3EE14C8E86F0032F)
              + 0x473DD32F688B82DF)
              ^ 0x6C9424E9AA80BE93))
           - 0x369D4AF4E931567D;

// Call the decryption function with derived key parameters
decrypt_fn(a5, a4,
           selector - 0x34241E2A372B6A84 + 203,   // key param 1
           selector - 0x34241E2A372B6A84 + 80,     // key param 2
           page_buffer);

Step 7: Relocation fixups

After decryption, the raw page content has relocations at their original (pre-ASLR) positions. EAC applies PE relocation fixups:

  1. The relocation table offset is read from page+44
  2. The relocation block is read from the module's PE relocation directory (base decoded from Module Info+0x100, offset 256)
  3. Each relocation entry is a 16-bit value:
    • High nibble = type (0xA = IMAGE_REL_BASED_DIR64)
    • Low 12 bits = offset within the page
  4. For each DIR64 relocation: *(uint64_t*)(page + offset) += module_base
  5. Special handling for offsets >= 0xFF9 (cross-page boundary relocations use a shifted base)

From the decompiled relocation loop inside eac_page_decrypt_handler:

reloc_offset = *(DWORD *)(page_metadata + 44);
if (reloc_offset)
{
    // Decode the relocation directory base from Module Info + 256
    reloc_base = (*(QWORD *)(Module Info + 256) + 0x74EB78F9B70729C8) ^ 0xED518186A5626B48;

    // SizeOfBlock field at +4 gives the total block size; subtract header (8 bytes),
    // divide by 2 for the number of 16-bit relocation entries
    block_addr = reloc_base + reloc_offset;
    entry_count = (*(DWORD *)(block_addr + 4) - 8) / 2;
    if (entry_count < 2)
        entry_count = 1;

    for (i = 0; i < entry_count; i++)
    {
        entry = *(WORD *)(block_addr + 2 * i + 8);

        if ((entry & 0xF000) == 0xA000)  // IMAGE_REL_BASED_DIR64
        {
            offset_in_page = entry & 0xFFF;

            if (offset_in_page < 0xFF9)
            {
                // Normal relocation: add module base to the 8-byte value
                *(QWORD *)(page_buffer + offset_in_page) += reloc_base;
            }
            else
            {
                // Cross-page boundary: shifted base handles the split
                shifted_base = reloc_base << (-8 * (BYTE)entry);
                *(QWORD *)(page_buffer + 4088) += shifted_base;
            }
        }
    }
}

Step 8: Image base fixup

If page+48 is non-zero, a small fixup is applied to the first bytes of the page. This handles cases where the page starts with an address that encodes the image base (e.g., a jump table or vtable at the start of a page):

  • fixup_value = (module_base >> (-8 * fixup_byte_count)) + original_first_qword + fixup_addend
  • This value is written back over the first fixup_byte_count bytes

Step 9: Page protection restore

eac_protect_virtual_memory is called to set the page to its original protection (decoded from page+40 using (encoded ^ 0x46E027FE) - 266204834). If this fails: violation + __fastfail(0xEAC).

The old protection must be PAGE_NOACCESS (1) — if it's anything else, something has gone wrong and EAC reports a violation.

Step 10: Instruction analysis

If the faulting instruction (saved in context RIP) points to the page that was just decrypted, EAC saves the first byte of the page (page+64) and checks if it's a "suspicious" instruction:

  • Bytes 0xC3 (RET), 0xCC (INT3), 0xC2 (RET imm16), or 0xF4 (HLT) at the page start
  • If found, an atomic counter at Module Info+328 is incremented
  • If this counter exceeds 999 (decimal): violation + __fastfail(0xEAC)
  • This detects patterns consistent with memory corruption or hook installation
// Suspicious instruction detection from eac_page_decrypt_handler
if (context->Rip == faulting_page               // RIP is on the just-decrypted page
    && page_metadata[40] != 0x4930914A          // page wasn't already decrypted
    && (0xA525BF950B28AAF9 * page_metadata[56]) == 0x553C4882D735B247)  // first access
{
    page_metadata[64] = *faulting_page;  // save first byte for analysis

    first_byte = (unsigned int)page_metadata[64] - 195;  // 195 = 0xC3 (RET)
    if (first_byte <= 0x31)  // range check: 0xC3..0xF4
    {
        // Bitmask for suspicious opcodes: RET(0xC3), RET imm(0xC2), INT3(0xCC), HLT(0xF4)
        bitmask = 0x2400000001B01;
        if (_bittest64(&bitmask, first_byte))
        {
            // Atomically increment the suspicious instruction counter
            count = _InterlockedExchangeAdd64(&Module Info[328], 1);
            if (count - 999 <= 0xFFFFFFFFFFFFFC17)  // count >= 1000
            {
                eac_memset(0, &stack, stack_size);
                eac_report_violation(3938459671, ...);
                __fastfail(0xEAC);  // instant process termination
            }
        }
    }
}

Step 11: Timestamp and access count

  • The current tick count is encoded and stored in page+56
  • The access duration counter at page+72 is incremented (capped at 60)
  • A global timestamp at Module Info+320 is checked — if more than 120,000ms have elapsed since it was set: violation + __fastfail(0xEAC). This is a session timeout.
// Timestamp and session timeout from eac_page_decrypt_handler
tick = eac_get_tick_count();

// Encode and store current time in page metadata
*(QWORD *)(page_metadata + 56) = 0x280079DF5B39F749 * (tick ^ 0x553C4882D735B247);

// Increment access duration counter, capped at 60
decoded_count = (0xA525BF950B28AAF9 * *(QWORD *)(page_metadata + 72)) ^ 0x553C4882D735B247;
new_count = (decoded_count + 1 < 60) ? decoded_count + 1 : 60;
*(QWORD *)(page_metadata + 72) = 0x280079DF5B39F749 * (new_count ^ 0x553C4882D735B247);

// Session timeout: 120 seconds since initialization
session_start = (0xA525BF950B28AAF9 * *(QWORD *)(Module Info + 320)) ^ 0x553C4882D735B247;
if (tick > session_start + 120000)  // 120,000ms = 2 minutes
{
    eac_memset(0, &stack, stack_size);
    eac_report_violation(3938459652, ...);  // session timeout violation
    __fastfail(0xEAC);
}

Step 12: Wake waiting threads

The state word is atomically set to 1 (decrypted, unlocked), and the old value's wait queue pointer is extracted. The queue is walked as a linked list:

  • Each entry has: [next_pointer, event_handle]
  • For each waiter, NtSetEvent is called via obfuscated syscall to wake them
  • If the syscall fails: violation + __fastfail(0xEAC)
// Atomically set state to 1 (decrypted, unlocked) and extract old wait queue
old_state = _InterlockedExchange64(&page_metadata[24], 1);
waiter = (WaitNode *)(old_state & ~3);  // mask off the 2 state bits

while (waiter)
{
    next_waiter = waiter->next;
    event_handle = waiter->event;

    // Wake the waiting thread via obfuscated NtSetEvent
    ssn = eac_encode_syscall_lo(0x5E2DA311841966, event_handle,
                                 0x313178663434196, 0x45F16D3EEC73C99);
    eac_encode_syscall_hi(0x5E2DA311841966, event_handle, rdx, 0x5E2DA311841966);
    syscall;  // NtSetEvent(event_handle)

    waiter = next_waiter;

    if (-691044163 * ssn < 0)  // NTSTATUS check via obfuscated multiply
    {
        eac_memset(0, &stack, stack_size);  // wipe stack before crash
        eac_report_violation(3938459685, ...);
        __fastfail(0xEAC);
    }
}

Step 13: Access counter tracking

After decryption, if the page was accessed by an instruction already inside the module (not the page that was just decrypted), EAC performs access counting:

  1. The faulting RIP page is verified as a different, already-decrypted page of the same module
  2. A page hash is computed and verified against the hash catalogue (eac_verify_page_hash)
  3. A bitmask check determines if the specific bit for this page is set in a coverage bitmap — if the bit is already set, decryption was expected
  4. If not in the bitmap, the faulting instruction's address is looked up in the accessing address hashmap:
    • FNV-1a 64-bit hash of the 8-byte instruction address
    • Lookup in the hashmap rooted at Module Info+0x90 (sentinel at +0x98, buckets at +0xA8, mask at +0xC0)
    • Each node: key at +0x10, DecryptInfo pointer at +0x18
    • DecryptInfo+0x18 = access counter
  5. If the address is not found, a new entry is created (via eac_hashmap_insert)
  6. If found, eac_increment_access_counter is called
  7. The counter at DecryptInfo+0x18 is checked against 0x1F3 (499)
  8. If the counter exceeds 499: eac_restore_page_protection is called — the page is put back to PAGE_NOACCESS, effectively re-encrypting it for that instruction address

This is the 500-page-per-accessing-address limit.


Accessing Address Tracking and the 500-Page Limit

The accessing address tracking system uses a custom FNV-1a hashmap:

FNV-1a Hash Computation

Decompiled directly from eac_fnv1a_hashmap_lookup (0x34CBE):

__int64 eac_fnv1a_hashmap_lookup(QWORD *out_result, QWORD *hashmap, __int64 *key)
{
    // FNV-1a 64-bit hash of the 8-byte key
    __int64 hash = 0xCBF29CE484222325;     // FNV offset basis
    for (int i = 0; i < 8; i++)
        hash = 0x100000001B3 * (((BYTE *)key)[i] ^ hash);  // XOR-fold then multiply

    // Index into bucket array: hash & mask (power-of-2 bucket count)
    __int64 sentinel = hashmap[1];  // sentinel node (empty marker)
    __int64 buckets  = hashmap[3];  // bucket array base
    __int64 bucket_offset = 16 * (hashmap[6] & hash);  // mask = bucket_count - 1

    // Walk the bucket's chain (doubly-linked list)
    __int64 node = *(QWORD *)(buckets + bucket_offset + 8);  // tail of bucket
    if (node != sentinel)
    {
        if (*key == *(QWORD *)(node + 16))  // key match at node+0x10
            goto found;

        __int64 head = *(QWORD *)(buckets + bucket_offset);
        while (node != head)
        {
            node = *(QWORD *)(node + 8);  // follow prev pointer
            if (*key == *(QWORD *)(node + 16))
                goto found;
        }
    }
    node = 0;  // not found

found:
    *out_result = node ? node : sentinel;
    return *out_result;
}

Hashmap Structure

Offset from Module Info:

  • +0x90: Hashmap base pointer
  • +0x98: Sentinel node (head of linked list)
  • +0xA8: Buckets array pointer
  • +0xB0: (unused/padding)
  • +0xC0: Bucket mask (bucket_count - 1, for hash & mask indexing)

Each bucket is 16 bytes: [first_node_ptr, last_node_ptr]

Node Structure

  • +0x00: next pointer
  • +0x08: prev pointer (doubly-linked)
  • +0x10: key (the 8-byte accessing address)
  • +0x18: pointer to DecryptInfo

DecryptInfo Structure

  • +0x00: (unknown)
  • +0x08: (unknown)
  • +0x10: (unknown)
  • +0x18: access counter (uint64)

When the counter reaches 500 (0x1F4), any further page faults from that instruction address will NOT be decrypted — the page stays PAGE_NOACCESS and the fault propagates, crashing the game or causing an access violation.

The 500-page limit enforcement from eac_page_decrypt_handler:

// Access tracking: runs when a decrypted page is accessed by code on another page
faulting_rip_page = (context->Rip >> 12);
page_index = (faulting_rip_page - decode_base(Module Info[216])) >> 12;

// Verify the page hash of the page containing the faulting RIP
eac_verify_page_hash(session, &result, Module Info, (page_index + 266204834) ^ 0x46E027FE);

// Check the coverage bitmap — if the bit is already set, this is expected
if (result != Module Info[1])  // not the sentinel
{
    bitmap_entry = *(QWORD *)(result + ((faulting_rip >> 3) & 0x1F8) + 24);
    if (_bittest64(&bitmap_entry, faulting_rip))
        goto decrypt_page;  // expected access, proceed normally
}

// Look up the faulting RIP in the accessing-address hashmap
rip_addr = context->Rip;
eac_mutex_lock(Module Info + 136);                      // acquire read lock
eac_fnv1a_hashmap_lookup(&result, Module Info + 144, &rip_addr);

if (result == sentinel)
{
    // First time this address has caused a page fault — insert new entry
    eac_mutex_unlock(Module Info + 136);
    eac_mutex_lock_exclusive(Module Info + 136);         // upgrade to write lock
    eac_fnv1a_hashmap_lookup(&result2, Module Info + 144, &rip_addr);

    if (result2 == sentinel)
    {
        // Allocate new DecryptInfo node (72 bytes), init mutex + counter
        info = eac_heap_alloc(72);
        eac_init_mutex(info);
        eac_init_counter(info + 8);
        eac_hashmap_insert(&entry, Module Info + 144, &rip_addr);
    }
    eac_mutex_unlock_exclusive(Module Info + 136);
}
else
{
    access_count = *(QWORD *)(info + 24);

    if (access_count <= 0x1F3)  // 499 — still under the limit
    {
        eac_mutex_lock_exclusive(info);
        if (*(QWORD *)(info + 24) <= 0x1F3)
            eac_increment_access_counter(&result, info + 8, &page_index);
        eac_mutex_unlock_exclusive(info);
    }
    else
    {
        // LIMIT EXCEEDED: re-encrypt the page, deny access
        eac_restore_page_protection(session, Module Info, session, Module Info);
        // The page fault will now propagate as a real access violation
    }
}

Data Encoding Schemes

EAC encodes all sensitive pointers and values in memory using these formulas:

Base Address Encoding

decoded = (encoded + 0x74EB78F9B70729C8) ^ 0xED518186A5626B48
encoded = (decoded ^ 0xED518186A5626B48) - 0x74EB78F9B70729C8

Used for: module base, shadow base, page data pointers, relocation table base

Size / Counter Encoding

decoded = (0xA525BF950B28AAF9 * encoded) ^ 0x553C4882D735B247
encoded = 0x280079DF5B39F749 * (decoded ^ 0x553C4882D735B247)

Note: 0x280079DF5B39F749 is the modular multiplicative inverse of 0xA525BF950B28AAF9 mod 2^64.

Used for: module size, timestamps, access counts, durations

Page Protection Encoding

decoded = (encoded ^ 0x46E027FE) - 266204834  // 266204834 = 0x0FE027E2
encoded = (decoded + 0x0FE027E2) ^ 0x46E027FE

Shadow Pointer Encoding

decoded = 0xEF6A89E6AACE44E3 * (encoded ^ 0xEF1516A79B86104D)
// inverse not observed

These encoding schemes are not cryptographically strong — they're invertible affine transformations over the 64-bit integer ring. Their purpose is to prevent casual memory scanning from finding meaningful pointers.


Shadow Allocation and Relocation Fixups

Shadow Memory

EAC maintains a "shadow allocation" — a separate memory region that stores the encrypted/compressed page data for the protected module. This shadow:

  • Is allocated as a separate MEM_PRIVATE region
  • Its base address is encoded in Module Info+0x108 (offset 264)
  • Has the same page structure as the main module (one shadow page per module page)
  • Pages are initially committed but marked inaccessible
  • When a page fault occurs, the shadow page is made readable via NtProtectVirtualMemory, its content is copied to the target, then decrypted in-place

Relocation Processing

Fortnite is compiled with ASLR (/DYNAMICBASE), so its PE contains a relocation table (.reloc section). When EAC decrypts a page, the raw content has relocations at their compile-time positions. EAC applies fixups using the standard PE relocation format:

  1. The relocation directory base is decoded from Module Info+0x100 (offset 256)
  2. For the current page, the relocation block is located using the offset stored in page metadata (+44)
  3. Each block has: { VirtualAddress, SizeOfBlock, Type/Offset[] }
  4. Entry count = (SizeOfBlock - 8) / 2
  5. For IMAGE_REL_BASED_DIR64 (type 0xA): add the ASLR delta to the 8-byte value at the specified offset
  6. Cross-page relocations (offset >= 0xFF9) receive special handling with shifted base computation

Obfuscated Syscall System

EAC never calls ntdll functions directly. Instead, it uses a custom syscall dispatch system:

Syscall Number Encoding

Two functions encode the syscall number (SSN):

eac_encode_syscall_lo (0x8032) and eac_encode_syscall_hi (0x8084) both use modular exponentiation — the same math behind RSA — to encode syscall numbers:

// eac_encode_syscall_lo — modular exponentiation: base^3 mod modulus
__int64 eac_encode_syscall_lo(__int64 a1, __int64 a2, uint64_t base, uint64_t modulus)
{
    uint64_t exp = 3;       // exponent is always 3
    uint64_t result = 1;
    do {
        if (exp & 1)
            result = (unsigned __int128)base * result % modulus;
        base = (unsigned __int128)base * base % modulus;
        exp >>= 1;
    } while (exp > 0);
    return (uint32_t)result;
}

// eac_encode_syscall_hi — same algorithm, hardcoded modulus
__int64 eac_encode_syscall_hi(__int64 a1, __int64 a2, __int64 a3, uint64_t base)
{
    uint64_t exp = 3;
    uint64_t result = 1;
    do {
        if (exp & 1)
            result = (unsigned __int128)base * result % 0x626CD7ABCFE703;  // fixed modulus
        base = (unsigned __int128)base * base % 0x626CD7ABCFE703;
        exp >>= 1;
    } while (exp > 0);
    return (uint32_t)result;
}

The full encoded value is combined as lo | (hi << 32), then each call site decodes it with a unique multiply+XOR to recover the actual syscall number:

// Example: NtProtectVirtualMemory call from eac_protect_virtual_memory (0x45B57)
ssn_lo = eac_encode_syscall_lo(prot, region, 0x1CBEE55676D11AF, 0x45F16D3EEC73C99);
eac_encode_syscall_hi(prot, region, rdx, 0x36F8C1EA3D5472);
actual_ssn = (1980972537 * ssn_lo) ^ 0xDB3961CA;  // per-callsite decode constants
syscall;  // direct ring-0 transition, ntdll completely bypassed

// If STATUS_CONFLICTING_ADDRESSES (-1073741755), commit memory first and retry
if (actual_ssn == 0xC0000018)
{
    // Encode another syscall (NtAllocateVirtualMemory) with different constants
    ssn2 = eac_encode_syscall_lo(prot, region, 0x37F77A6093B735A, 0x45F16D3EEC73C99);
    commit_fn = (0x98D6DA32C17C47CF * (ssn2 | (ssn2_hi << 32))) ^ 0x1C70A46E939CCAAC;
    commit_fn(prot, region, size, base);
    // ... retry NtProtectVirtualMemory
}

Each syscall call site has unique encoding constants, so the SSN is never visible as a plaintext immediate. The use of modular exponentiation (rather than simple XOR) means you can't trivially invert the encoding without knowing the modular inverse.

Syscall Dispatcher

This is a large function (3,123 bytes) that acts as a dispatch table. It receives an SSN and maps it to one of ~128 handler functions. The handlers cover all NT APIs that EAC needs:

  • NtProtectVirtualMemory, NtAllocateVirtualMemory, NtFreeVirtualMemory
  • NtQueryVirtualMemory
  • NtCreateEvent, NtSetEvent, NtWaitForSingleObject
  • NtClose
  • NtContinue (for resuming after page fault)
  • NtQuerySystemInformation, NtQueryInformationProcess, NtQueryInformationThread
  • And approximately 100 more

Each handler (eac_syscall_handler_*) decodes the SSN through its own unique multiplication/XOR chain and executes the syscall instruction inline.

Protection Against Hooking

Because EAC uses direct syscall instructions rather than calling ntdll, standard ntdll hooks (used by other security tools, debuggers, etc.) are completely bypassed. The SSN encoding also prevents static analysis from easily determining which syscall is being made at each call site.


API Resolution by Hash

API Hash Resolution

This function walks a module's export table and computes a hash of each exported function name, comparing it against a target hash. When a match is found, the function's address is returned.

This is used during initialization to resolve functions from:

  • ntdll.dll
  • kernel32.dll
  • ws2_32.dll (Winsock)
  • bcrypt.dll (cryptographic primitives)
  • Other system DLLs

Module Handle Resolution

eac_get_module_handle (0x43AFB) walks the PEB's InMemoryOrderModuleList to find loaded modules by name. Decompiled:

__int64 eac_get_module_handle(__int64 a1, __int64 a2, __int64 a3, const char *dll_name)
{
    if (dll_name)
    {
        // Convert ASCII name to wide string, then search PEB module list
        size_t len = eac_strlen(dll_name);
        if (len > 259) len = 259;
        WCHAR wide_name[260];
        for (int i = 0; i < len; i++)
            wide_name[i] = (WCHAR)dll_name[i];  // simple ASCII->UTF16
        wide_name[len] = 0;
        return sub_43B79(a1, dll_name, 0, wide_name);  // LdrGetDllHandle equivalent
    }
    else
    {
        // No name = return own module base from PEB
        // TEB->PEB->Ldr->InMemoryOrderModuleList->Flink->DllBase
        __int64 peb = __readgsqword(0x30) + 96;  // TEB.ProcessEnvironmentBlock
        __int64 ldr = *(QWORD *)(peb + 24);       // PEB.Ldr
        __int64 entry = *(QWORD *)(ldr + 16);     // InMemoryOrderModuleList.Flink
        return *(QWORD *)(entry + 48);             // LDR_DATA_TABLE_ENTRY.DllBase
    }
}

Obfuscated DLL Names

DLL names like "ntdll.dll" are never stored as plaintext strings. Instead, they're constructed at runtime through multiple obfuscation layers. From eac_hook_ntdll_apis:

// "ntdll.dl" packed as a QWORD in little-endian
*(QWORD *)&name = 0x6C642E6C6C64746E;  // 'n','t','d','l','l','.','d','l'
WORD5(name) = 0;                         // null terminator area

// XOR-decrypt the remaining suffix bytes with a rolling key
uint32_t key = -1978496737;
for (int i = 0; i < 2; i++)
{
    name_bytes[8 + i] = key ^ encrypted_data[i];  // decrypt "l\0"
    key >>= 8;
}

// API function names use a more complex scheme: multiply + XOR PRNG
uint32_t api_key = -748580485;
for (int i = 0; i < 4; i++)
{
    api_name_dwords[i] = encrypted_api_name[i] ^ api_key;
    api_key = -24915 * api_key - 5468702;  // LCG-style rolling key
}

// Second API name uses an xorshift PRNG for decryption
uint32_t prng = -1273801266;
for (int i = 0; i < 5; i++)
{
    prng = ROL32(prng ^ (prng << 13)
                      ^ ((prng ^ (prng << 13)) >> 7)
                      ^ ((prng ^ (prng << 13) ^ ((prng ^ (prng << 13)) >> 7)) << 17),
                 1);
    api_name2_dwords[i] = prng ^ encrypted_api_name2[i];
}

Each API name uses a different PRNG variant (LCG, xorshift, etc.), making bulk decryption of all hooked function names non-trivial.


Code Hook / Trampoline Engine

Code Hook Installation

This is the largest identifiable hand-written function (5,918 bytes). It patches arbitrary code locations with trampoline jumps, used to hook ntdll and kernel32 functions.

Process:

  1. Find a cave: Scans virtual memory within ±64KB of the target function for a suitable region to host the trampoline code. Uses NtQueryVirtualMemory to find gaps, then NtAllocateVirtualMemory to claim them.

    From the cave-finding loop in eac_install_code_hook:

    // Search outward from the target address in 64KB increments
    search_lo = (target_addr >= 0x10000) ? target_addr - 0x10000 : 0;
    search_hi = target_addr + 0x10000 - 64;
    if (search_hi < target_addr) search_hi = -1;  // overflow check
    
    // First: check if any existing trampoline regions have free slots
    eac_spinlock_acquire(qword_204B60);
    node = MEMORY[0x199082B79C0];  // linked list of trampoline regions
    
    while (node != 0x199082B79C0)  // sentinel check
    {
        region_base = (0xA525BF950B28AAF9 * node[2]) ^ 0x553C4882D735B247;
        if (region_base < search_hi && region_base + 0x10000 >= search_lo)
        {
            // Check bitmap for free 64-byte slots
            bitmap = node[3];
            for (int i = 0; i < 16; i++)
                if (bitmap[i + 3] != -1) break;  // found free slot
            // ... allocate slot from bitmap via atomic bittest
        }
        node = node->next;
    }
    
    // If no existing region has space: allocate a new 64KB region
    handle = eac_virtual_alloc(64, -1, 0x10000, ...);  // MEM_RESERVE
    // Scan with NtQueryVirtualMemory for free aligned 64KB gap
    scan = (search_lo + 0xFFFF) & ~0xFFFF;
    while (scan < search_hi)
    {
        eac_query_virtual_memory(&mem_info, scan);
        if (mem_info.State == MEM_FREE && mem_info.RegionSize >= 0x10000)
        {
            aligned = (mem_info.BaseAddress + 0xFFFF) & ~0xFFFF;
            trampoline = eac_protect_and_commit(aligned, PAGE_READWRITE, handle, 0x10000);
            if (trampoline)
            {
                // Commit as RWX for the trampoline code
                rwx_base = eac_protect_and_commit(trampoline, PAGE_EXECUTE_READ, handle, 0x10000);
                // ...
    
  2. Build the trampoline: Allocates a per-hook context structure (152 bytes). From the decompilation:

    ctx = eac_heap_alloc(152);
    ctx[0]  = section_handle;                                    // +0:   NtAllocateVirtualMemory handle
    ctx[1]  = (0xEF6A89E6AACE44E3 * region_base) ^ 0xEF1516A79B86104D;  // +8:  encoded shadow ptr
    ctx[2]  = (rwx_base ^ 0xED518186A5626B48) - 0x74EB78F9B70729C8;     // +16: encoded base ptr
    ctx[3]  = 1;                                                 // +24:  reference count
    memset(&ctx[4], 0, 120);                                     // +32..151: zeroed (linked list ptrs,
                                                                  //           saved bytes, metadata)
    
  3. Disassemble the target: Uses a built-in x86-64 disassembler (eac_disassemble_insn, eac_decode_instruction) to analyze instructions at the hook point:

    • Determines how many bytes to overwrite (minimum 5 for a relative JMP)
    • Identifies instructions with RIP-relative operands that need fixup
    • Handles conditional branches, calls, and jump instructions
  4. Generate patched code: Copies the original instructions to the trampoline, fixing up:

    • RIP-relative addresses (LEA, MOV, CALL, Jcc)
    • Branch targets that point into the overwritten region
    • Converts short jumps to long jumps if necessary
    • Uses eac_fnv1a_hashmap_lookup to resolve new target addresses
  5. Atomic installation: Uses InterlockedCompareExchange64 to atomically overwrite the first 8 bytes of the target function:

    • Temporarily changes page protection to PAGE_EXECUTE_READWRITE
    • Writes an E9 (JMP rel32) instruction pointing to the trampoline
    • Restores original page protection
    • Retries up to 32 times if the CAS fails (another thread modified the target)

From the atomic hook installation in eac_install_code_hook (0x10C092):

// Build the JMP rel32 instruction: E9 <offset>
original_bytes = *(QWORD *)target_function;         // save original 8 bytes
(*hook_context)->saved_bytes = original_bytes;

jmp_offset = trampoline_addr - target_function - 5;  // relative offset
if ((unsigned __int64)(jmp_offset + 0x7FFFFFFB) >> 32)
    goto cleanup;  // target too far for rel32 JMP

// Encode the JMP instruction into a QWORD for atomic CAS
patch_qword = original_bytes;  // start with original bytes
*(BYTE *)&patch_qword  = 0xE9;                       // JMP rel32 opcode
*(DWORD *)((char *)&patch_qword + 1) = jmp_offset;   // 32-bit relative offset
*(QWORD *)(hook_context->trampoline + 24) = patch_qword;

// Atomic installation with up to 32 retries
if (eac_protect_virtual_memory(8, target_function, PAGE_EXECUTE_READWRITE, &old_prot))
{
    success = (original_bytes == _InterlockedCompareExchange64(
        (volatile __int64 *)target_function,
        patch_qword,
        original_bytes));

    eac_protect_virtual_memory(8, target_function, old_prot, &old_prot);

    if (!success)
    {
        attempt++;
        if (attempt == 32)  // give up after 32 CAS failures
            goto cleanup;
        continue;  // retry — another thread raced us
    }
}

ntdll Hook Installation

eac_hook_ntdll_apis (0x68292) demonstrates the full hook pipeline — DLL name decryption, export resolution by hash, and hook installation:

__int64 eac_hook_ntdll_apis(void)
{
    // Decrypt "ntdll.dll" from XOR-encoded constants
    *(QWORD *)&name = 0x6C642E6C6C64746E;  // "ntdll.dl" little-endian
    WORD name[8] = 0;
    // XOR-decrypt remaining bytes with rolling key -1978496737
    uint32_t key = -1978496737;
    for (int i = 0; i < 2; i++)
    {
        name_bytes[8 + i] = key ^ encrypted_suffix[i];
        key >>= 8;
    }

    // Walk PEB to find ntdll.dll base address
    QWORD ntdll_base = eac_get_module_handle(&name);
    if (!ntdll_base)
        return 0;

    // Decrypt first API name with XOR + multiply rolling key
    uint32_t api_key = -748580485;
    for (int i = 0; i < 4; i++)
    {
        *(DWORD *)(&api_name + 4*i) = encrypted_api_1[i] ^ api_key;
        api_key = -24915 * api_key - 5468702;  // rolling PRNG
    }

    // Resolve the function by walking ntdll's export table
    QWORD target_1 = eac_resolve_api_by_hash(ntdll_base, &api_name, ntdll_base, 0);
    if (target_1)
        eac_install_code_hook(ntdll_base, sub_9D6B4, target_1, &hook_ctx_1);

    // Second API: decrypt name with xorshift PRNG
    uint32_t prng = -1273801266;
    for (int i = 0; i < 5; i++)
    {
        prng = ROL32(prng ^ (prng << 13) ^ ((prng ^ (prng << 13)) >> 7)
                     ^ ((prng ^ (prng << 13) ^ ((prng ^ (prng << 13)) >> 7)) << 17), 1);
        *(DWORD *)(&api_name2 + 4*i) = prng ^ encrypted_api_2[i];
    }

    QWORD target_2 = eac_resolve_api_by_hash(ntdll_base, &api_name2, ntdll_base, 0);
    if (target_2)
        eac_install_code_hook(ntdll_base, sub_9D994, target_2, &hook_ctx_2);

    return 1;
}

Hooked Functions

EAC hooks functions in:

  • ntdll.dll (eac_hook_ntdll_apis — 0x68292): 2 functions hooked (likely NtProtectVirtualMemory and NtMapViewOfSection or similar)
  • kernel32.dll (eac_hook_kernel_apis — 0x685BC): Additional hooks
  • Game module APIs (eac_hook_module_apis — 0x6AFD8): Hooks on game-specific functions

These hooks allow EAC to monitor and intercept API calls made by the game and any injected DLLs, detecting tampering attempts.


Integrity Checks and Violation Reporting

Page Hash Verification

eac_verify_page_hash (0x34C48) computes a hash of a decrypted page and compares it against a stored expected hash. The hash is computed using a rolling XOR-multiply chain:

hash = 0x16F11FE89B0D677C  // initial value
for each 8-byte qword in the 4096-byte page:
    temp = 0x6EED0E9DA4D94A4F * (qword ^ hash ^ 0x432E)
    hash = 0x6EED0E9DA4D94A4F * ((temp >> 32 >> (temp >> 60)) ^ temp)

The expected hashes are stored in a separate array, accessed via another encoded pointer.

The actual hash computation from eac_page_decrypt_handler, operating on the 4096-byte page after relocation fixups are undone:

// Custom non-cryptographic hash of a 4096-byte page
// Used for integrity verification, NOT for encryption
__int64 hash = 0x16F11FE89B0D677C;  // initial seed
for (int offset = -8; offset < 0xFF8; offset += 8)
{
    QWORD qword = *(QWORD *)(page_buffer + offset);
    __int64 temp = 0x6EED0E9DA4D94A4F * (qword ^ hash ^ 0x432E);

    // Fold the upper bits using a data-dependent shift amount:
    // shift = temp >> 60 (top 4 bits = 0..15)
    // folded = (temp >> 32 >> shift) ^ temp
    hash = 0x6EED0E9DA4D94A4F * ((HIDWORD(temp) >> (temp >> 60)) ^ temp);
}

// Compare against the expected hash from the catalogue
page_index = ... ; // derived from page VA and module base
expected = *(QWORD *)(0xFD1D5B02F4EEE7F7
         * (*(QWORD *)(Module Info + 240) ^ 0xF1683011C8882DA9)
         + 8 * page_index);

if (hash == expected)
    // Page is valid — proceed with access tracking

Executable Hash Catalogue

eac_verify_executable_hash (0xCA5F6) validates that the game executable matches a known-good hash catalogue. This is spawned as a separate thread (eac_spawn_hash_verify_thread — 0xC7696). If verification fails: violation report.

Violation Reporting

eac_report_violation (0xBADA6) is a thin function that records violation events. It's called with a violation code and context information. Violations include:

CodeMeaning
3938459649Integrity check failed
3938459650RIP on already-decrypted page accessing another encrypted page
3938459651Failed to set page protection after decrypt
3938459652Session timeout exceeded (120 seconds)
3938459653Page protection verification failed during re-encryption
3938459671Suspicious instruction pattern at page start (RET/INT3/HLT) exceeded 999 count
3938459685Syscall returned error (NtSetEvent, NtWaitForSingleObject, etc.)

All violations are followed by __fastfail(0xEAC) which immediately terminates the process via a hardware exception that cannot be caught.

Error Categories

From the error string table:

  • error_corrupted_memory — memory integrity failure
  • error_system_configuration — unexpected system state
  • error_violation — generic anti-cheat violation
  • error_system_version — unsupported OS version
  • error_file_version — wrong game version
  • error_tool_forbidden — detected forbidden tool (debugger, etc.)
  • error_module_forbidden — detected forbidden DLL
  • error_file_forbidden — detected forbidden file
  • error_virtual — running under virtual machine detected
  • error_corrupted_network — network integrity failure
  • error_catalogue_not_found — hash catalogue missing
  • error_file_not_found — required file missing
  • error_catalogue_corrupted — hash catalogue tampered
  • error_certificate_revoked — TLS certificate revocation
  • executable_not_hashed — game executable not in catalogue

TLS and Network Communication

EAC implements its own TLS 1.2 stack rather than using Windows SChannel. This prevents interception via OS-level TLS hooks.

TLS Implementation

Key Derivation (eac_tls_derive_keys — 0x53410):

  • Supports both SHA-256 and SHA-1/MD5 PRF variants
  • Uses "master secret" / "extended master secret" / "key expansion" labels
  • Derives encryption keys, IVs, and MAC keys for both directions
  • Supports AES-CBC, AES-GCM cipher suites (from the cipher suite string table)

From the decompiled key derivation — note the "extended master secret" / "master secret" / "key expansion" labels visible as plaintext strings:

__int64 eac_tls_derive_keys(__int64 a1, __int64 a2, QWORD *a3, __int64 session)
{
    crypto_ctx  = *(QWORD *)(session + 128);
    conn_state  = *(QWORD *)(session + 96);
    cipher_info = *(QWORD *)(session + 88);

    // Select PRF and hash functions based on cipher suite
    hash_version = *(DWORD *)(*(QWORD *)crypto_ctx + 20);
    cipher_suite = eac_tls_lookup_cipher(session, hash_version);

    // Choose SHA-256 or SHA-1/MD5 variant for PRF
    prf_fn     = (hash_version == 7) ? eac_tls_prf_sha1_md5 : eac_tls_prf_sha256;
    hash_fn    = (hash_version == 7) ? eac_tls_hash_update_sha1 : eac_tls_hash_update_sha256;
    verify_fn  = (hash_version == 7) ? eac_tls_prf_verify_sha1 : eac_tls_prf_verify_sha256;

    // Store function pointers for later use during record processing
    *(QWORD *)(conn_state + 1272) = prf_fn;
    *(QWORD *)(conn_state + 1256) = hash_fn;
    *(QWORD *)(conn_state + 1264) = verify_fn;

    // Derive master secret from pre-master secret
    if (*(DWORD *)(conn_state + 2392) == 1)  // extended master secret extension
        prf_fn(session, conn_state + 1352, "extended master secret", handshake_hash);
    else
        prf_fn(session, conn_state + 1352, "master secret", conn_state + 1288);

    // Wipe pre-master secret immediately after use
    for (int i = 0; i < 1024; i++)
        *(BYTE *)(conn_state + 1352 + i) = 0;

    // Swap client/server random for key expansion (RFC 5246 §6.3)
    // key_block = PRF(master_secret, "key expansion", server_random + client_random)
    prf_fn(session, 48, cipher_info + 56, "key expansion", conn_state + 1288);

    // Partition the key block into individual keys:
    //   client_write_MAC_key, server_write_MAC_key,
    //   client_write_key, server_write_key,
    //   client_write_IV, server_write_IV
    eac_memcpy(&key_block[2 * mac_len + key_len], &crypto_ctx[48], iv_len);  // client IV
    eac_memcpy(&key_block[2 * mac_len + key_len + iv_len], &crypto_ctx[64], iv_len);  // server IV
    eac_tls_setup_iv(&key_block[mac_len], key_block, crypto_ctx + 80, mac_len);
    eac_tls_set_cipher_key(&key_block[2 * mac_len], crypto_ctx + 128, cipher_bits, 1);
    eac_tls_set_cipher_key(&key_block[2 * mac_len + key_len], crypto_ctx + 216, cipher_bits, 0);

    // Wipe key material from stack
    for (int i = 0; i < 256; i++)
        key_block[i] = 0;

    return 0;
}

Handshake (eac_tls_handshake — 0x553BC, 9,597 bytes):

  • Full TLS 1.2 handshake state machine
  • eac_tls_client_hello (0x102E6A) — sends ClientHello
  • eac_tls_process_server_hello (0x102FBA) — processes ServerHello
  • eac_tls_process_certificate (0x10350A) — validates server certificate

PRF Functions:

  • eac_tls_prf_sha256 / eac_tls_prf_sha1_md5 — the TLS pseudo-random function
  • eac_tls_prf_verify_sha256 / eac_tls_prf_verify_sha1 — compute Finished verify data using "client finished" / "server finished" labels

Supported Cipher Suites (from string data at 0x18A5D9-0x18A99D):

  • TLS_RSA_WITH_AES_128_CBC_SHA
  • TLS_RSA_WITH_AES_256_CBC_SHA
  • TLS_RSA_WITH_AES_128_CBC_SHA256
  • TLS_RSA_WITH_AES_256_CBC_SHA256
  • TLS_RSA_WITH_AES_128_GCM_SHA256
  • TLS_RSA_WITH_AES_256_GCM_SHA384
  • And several more RSA+AES combinations
  • DH parameters at 0x18A268

Network Stack

Connection Setup (eac_network_connect — 0x72170):

  • Allocates an 8KB connection context
  • Initializes socket via eac_socket_init
  • Configures TLS context and session
  • Sets TLS version bounds (min/max)
  • If proxy is configured (byte at context+136 == 1): routes through HTTP CONNECT proxy

HTTP CONNECT Proxy (eac_http_connect_proxy — 0x72572):

  • Formats: CONNECT %s:%d HTTP/1.1\r\nHost: %s\r\n\r\n
  • Parses HTTP response, extracts status code
  • Handles content-length header for response body
  • Reads response body if present

HTTP Client:

  • eac_http_get_request (0x8BCE) — builds GET {path} HTTP/1.1 with Connection: close header
  • eac_http_post_request (0x8FDA) — builds POST requests for telemetry

URL Parsing (eac_parse_url — 0x71BC2): Splits URL into scheme, host, port, path components.

Telemetry

eac_telemetry_loop (0xAEC8C, 6,742 bytes) runs as a continuous loop that:

  • Periodically calls eac_send_telemetry (0xB07BE)
  • Sends HTTP POST requests with collected anti-cheat data
  • Includes exception records, violation reports, timing data
  • Uses the custom TLS stack for all communication

Initialization Sequence

Main Initialization

This is the main initialization function, called once when EAC's handler is first loaded.

  1. Module verification: Iterates a lookup table at qword_17A610 (53 entries of 3 QWORDs each), computing SHA-like hashes of the calling module's name and comparing against expected values. The expected hash for the valid caller has a tag value of 217.

  2. State allocation: Allocates a 10,128-byte global state structure (g_eac_context stored at 0x1B3DC8). This structure contains:

    • Socket contexts (multiple, for different connections)
    • FNV-1a hashmaps for various tracking purposes
    • Linked lists for exception records, module entries, hook contexts
    • Configuration values (timeouts: 3000ms connect, 10000ms read, 1052672 buffer size)
    • TLS session state
    • Access counters and rate limiters
  3. Sub-initialization:

    • Initializes socket subsystem
    • Creates hashmaps for address tracking
    • Sets up exception record pools (128 entries, 64 bytes each)
    • Initializes mutex and counter objects
  4. Module protection setup:

    • Reads PEB to get the game's base address from InMemoryOrderModuleList
    • Encodes it with the base encoding formula
    • Sets up the ProtectedModules array entry
  5. Self-registration:

    • Computes FNV-1a hash of its own encoded address
    • Inserts itself into a global tracking hashmap
    • Sets vtable pointers to different stages of initialization
  6. API hooking: Calls eac_hook_ntdll_apis. If this fails: __fastfail(0xEAC00003) — immediate process termination.

  7. Game module protection: Calls sub_C64B4 to set up the page encryption for the game module.

  8. Configuration notification: Calls sub_BAE20 with a configuration code (1073942528 = 0x40010000) to signal that initialization is complete.


Key Data Structures

Protected Module Descriptor (Module Info struct)

Size: ~400 bytes. Stored in the ProtectedModules array at 0x199082B8D60.

OffsetSizeDescription
+0xD8 (216)8Encoded base address
+0xE0 (224)8Encoded size
+0xE8 (232)8Encoded entry point / PE header RVA
+0xF0 (240)8Encoded hash array pointer
+0xF8 (248)4Encoded page protection template
+0x100 (256)8Encoded relocation directory base
+0x108 (264)8Encoded shadow allocation base
+0x110 (272)8Page metadata array pointer
+0x118 (280)8(unused/reserved)
+0x120 (288)8(unused/reserved)
+0x128 (296)8Encoded decrypt key table pointer
+0x130 (304)8Encoded something
+0x138 (312)8(padding)
+0x140 (320)8Encoded session start timestamp
+0x148 (328)8Suspicious instruction counter (atomic)
+0x150 (336)8+Spinlock for integrity check
+0x168 (360)4Thread ID for integrity check
+0x17C (380)1Integrity check completed flag

Global EAC Context (g_eac_context)

10,128 bytes, allocated in eac_main_init. Pointer stored at 0x1B3DC8.

Major sections:

  • +0: vtable pointer
  • +8: sub-vtable pointer
  • +16: initialization flag
  • +20-32: configuration
  • +32-2528: first connection context
  • +2528-5024: second connection context
  • +5024-5120: string buffers (module info)
  • +5120-5200: third socket context
  • +5200-5300: connection parameters (timeouts, buffer sizes)
  • +5360-5472: FNV-1a hashmap (address tracking)
  • +5528: linked list for module entries
  • +6080-6280: TLS-related state
  • +6280: linked list for hook entries
  • +6624-6688: encoded module bases (3 entries, initialized to 0xEF1516A79B86104D = encoded null)
  • +6944: self-reference pointer
  • +6952: linked list for protected modules
  • +8360-8512: string buffers
  • +8520-8576: more encoded module bases
  • +9072: counter (for rate limiting)
  • +9824: another counter
  • +9888: thread guard handle

Exception Record Pool

Lock-free pool using InterlockedCompareExchange128:

  • Head at 0x1B4090 (128-bit CAS target: [pointer, generation_counter])
  • Pool base at 0x1B4060
  • Each entry: 692 QWORDs (5,536 bytes):
    • +0: linked list next pointer
    • +8: linked list previous pointer
    • +16-1248: copied CONTEXT record (1,232 bytes)
    • +1256: thread ID
    • +1264-1408: copied EXCEPTION_RECORD (152 bytes)
    • +1424: stack capture size
    • +1408: stack base
    • +1416: stack limit
    • +1432-5536: captured stack data (up to 4096 bytes)
    • +5528: timestamp (from eac_get_tick_count)

Binary Cleanup

Custom IDAPython scripts were used to recover unanalyzed code and clean up obfuscation artifacts.

MetricValue
Functions identified38,045
Code regions recovered494
Data regions defined1,906
Functions annotated155+

Cleanup patterns addressed:

  1. GAP-based function recovery with prologue pattern scanning
  2. Orphan code block recovery via cross-reference analysis
  3. String and data region identification in unanalyzed gaps
  4. Module header structure definition and dispatch table mapping

Named Functions Reference

Page Protection Core

AddressNameSizeDescription
0x3515Eeac_exception_dispatch0x567Main exception handler entry
0x339C2eac_page_decrypt_handler0x1286Page fault handling + decrypt
0x10FCF0eac_exception_trampoline0xFKiUserExceptionDispatcher hook
0x7DBA4eac_encrypt_page_and_resume0x143Re-encrypt page + wake waiters
0xB8822eac_zero_and_reprotect_page0x78Zero + set PAGE_NOACCESS
0xBBC44eac_reencrypt_page0xF6Re-encryption logic
0x78360eac_validate_rip_in_module0x1E0Check if RIP is in allowed range
0x786AAeac_restore_page_protection0x5Set page back to NOACCESS
0x34C48eac_verify_page_hash0x75Hash-verify decrypted page
0x149FFeac_set_page_protection0x59Set page protection wrapper
0x106F80eac_check_address_in_region0x8ECheck addr in allowed region

Hashmap / Access Tracking

AddressNameSizeDescription
0x34CBEeac_fnv1a_hashmap_lookup0x70FNV-1a hash + bucket lookup
0x3509Aeac_hashmap_find_or_insert0x68Find or create entry
0x34D2Eeac_hashmap_insert0x36BInsert new node
0x24F3Aeac_increment_access_counter0x15CIncrement + check 0x1F3 limit
0xBD32eac_hashmap_init0x91Initialize hashmap
0x39F44eac_hashmap_resize0xA2Grow bucket array
0xF4ED6eac_hashmap_insert_entry0x4ELow-level insert
0xF4F24eac_hashmap_remove_entry0x56Low-level remove

Syscall System

AddressNameSizeDescription
0x32D8Eeac_syscall_dispatcher0xC33SSN dispatch table
0x8032eac_encode_syscall_lo0x52Encode SSN low 32 bits
0x8084eac_encode_syscall_hi0x5AEncode SSN high 32 bits
0xA038Eeac_syscall_handler_generic0x492Generic syscall handler
0xA0820-0xA0BD2eac_syscall_handler_*~0x38 each18 specific handlers

Memory Management

AddressNameSizeDescription
0x637DEeac_heap_alloc0x6EAllocate memory
0x6392Aeac_heap_free0x80Free memory
0x575Aeac_heap_free_buffer0x41Free buffer
0x46AE3eac_virtual_alloc0xFANtAllocateVirtualMemory wrapper
0x46CC9eac_virtual_free0x151NtFreeVirtualMemory wrapper
0x45B57eac_protect_virtual_memory0x1F9NtProtectVirtualMemory wrapper
0x45D50eac_query_virtual_memory0x126NtQueryVirtualMemory wrapper
0x46BDDeac_protect_and_commit0xECProtect + commit region
0x4FFB6eac_alloc_executable_region0xFAAlloc RWX region

Synchronization

AddressNameSizeDescription
0x4443Beac_spinlock_acquire0x5DSpin-wait lock
0x44498eac_spinlock_release0x5DRelease spinlock
0x444F5eac_wait_for_object0x6ANtWaitForSingleObject
0x46E1Aeac_close_handle0x4BNtClose
0x10079Ceac_init_mutex0x67Initialize reader-writer lock
0x100804eac_mutex_lock_exclusive0x5DWrite lock
0x100862eac_mutex_unlock_exclusive0x5DWrite unlock
0x1008C0eac_mutex_lock0x5DRead lock
0x10091Eeac_mutex_unlock0x5DRead unlock

Code Hooking

AddressNameSizeDescription
0x10C092eac_install_code_hook0x171EAtomic trampoline installer
0x10D7B0eac_code_hook_cleanup0xBARemove hook
0x10D86Aeac_calc_patched_size0x5FCompute trampoline size
0x10FEB8eac_init_disasm_context0x4FInit disassembler
0x10FFE0eac_disassemble_insn0xF1Disassemble one instruction
0x1100D4eac_decode_instruction0x6DDecode instruction fields
0x1136C4eac_resolve_branch_target0xF6Compute branch destination

API Resolution

AddressNameSizeDescription
0x44892eac_resolve_api_by_hash0x3D8Hash-walk exports
0x43AFBeac_get_module_handle0x7EPEB module list walk
0xE4C29eac_resolve_all_apis0x2674Master API resolver
0xEF667eac_resolve_crypto_apis0x457bcrypt API resolution
0x16872eac_resolve_ntdll_apis0x1DDntdll API resolution
0x41028eac_resolve_kernel32_apis0x3EDkernel32 API resolution
0x935CCeac_resolve_network_apis0x38CNetwork API resolution
0xB612Feac_resolve_winsock_apis0x44Dws2_32 API resolution
0x754A7-0x76A49eac_resolve_nt_syscall_0-14~0x180 eachPer-syscall SSN resolution

Hook Installation

AddressNameSizeDescription
0x68292eac_hook_ntdll_apis0x2BDHook 2 ntdll functions
0x685BCeac_hook_kernel_apis0xAC7Hook kernel32 functions
0x6AFD8eac_hook_module_apis0x616Hook game module functions

Integrity / Violations

AddressNameSizeDescription
0xBADA6eac_report_violation0x5Record violation event
0xF05F8eac_format_error_message0x58AFormat error for reporting
0xCA5F6eac_verify_executable_hash0x1A2Verify game hash catalogue
0xC7696eac_spawn_hash_verify_thread0x4CSpawn verification thread
0x81788eac_integrity_check0x5Self-integrity verification
0xC29ABeac_module_init_and_guard0x12FModule guard + PEB check
0xCB08Feac_log_exception0x1DBLog exception for telemetry

TLS / Crypto

AddressNameSizeDescription
0x553BCeac_tls_handshake0x257DFull TLS 1.2 handshake
0x53410eac_tls_derive_keys0x5BCMaster secret + key expansion
0x102E6Aeac_tls_client_hello0x150Send ClientHello
0x102FBAeac_tls_process_server_hello0x300Process ServerHello
0x10350Aeac_tls_process_certificate0x54EValidate certificate
0x539CCeac_tls_prf_sha1_md50x3CPRF (SHA1+MD5)
0x53A08eac_tls_prf_sha2560x3CPRF (SHA256)
0x53AD6eac_tls_prf_verify_sha2560xCAFinished verify (SHA256)
0x53BA0eac_tls_prf_verify_sha10xBCFinished verify (SHA1)
0xA21A2eac_tls_lookup_cipher0x56Cipher suite lookup
0xA2257eac_tls_init_cipher_ctx0x71Initialize cipher context
0xA22C8eac_tls_setup_iv0xEASet up IV
0xF2115eac_tls_set_cipher_key0x5FSet cipher key
0xCD256eac_tls_context_init0xB8Init TLS context
0xCDD68eac_tls_session_init0xB9AInit TLS session
0xCF0C4eac_tls_session_cleanup0x86Cleanup session
0xCD3DAeac_tls_context_cleanup0x53Cleanup context
0xCEACCeac_tls_set_min_version0x6ASet minimum TLS version
0xCEB36eac_tls_set_max_version0x6ASet maximum TLS version

Network

AddressNameSizeDescription
0x72170eac_network_connect0x402Establish connection
0x72572eac_http_connect_proxy0xA46HTTP CONNECT tunnel
0x8BCEeac_http_get_request0x40CHTTP GET
0x8FDAeac_http_post_request0x644HTTP POST
0x71BC2eac_parse_url0x49CURL parser
0x7205Eeac_set_http_header0x112Set HTTP header
0x731ECeac_build_host_header0x2B8Build Host: header
0xB269Ceac_async_connect0x2A0Async socket connect
0xB2DEEeac_socket_init0x72Initialize socket
0xCD42Eeac_tls_connect_callback0x41TLS connect callback

Telemetry

AddressNameSizeDescription
0xAEC8Ceac_telemetry_loop0x1A56Main telemetry loop
0xB07BEeac_send_telemetry0x7F1Send telemetry POST

String Utilities

AddressNameSizeDescription
0x87A8eac_string_copy0xDBCopy string
0x8AC6eac_string_assign0x95Assign from C string
0x25E70eac_string_append0x45Append string
0x27FE4eac_string_append_raw0x135Append raw bytes
0x27928eac_string_from_data0x139Create from data
0x27652eac_string_destroy0x65Free string
0x31DBAeac_string_contains0x5FSubstring check
0x31E1Aeac_string_find_reverse0x7CReverse find
0x31E96eac_string_prepend0xD1Prepend string
0x31F67eac_string_find0x8AFind substring
0x31FF2eac_string_clear0x4FClear string
0x32042eac_map_insert0x101Ordered map insert
0x730B0eac_map_find0xF1Ordered map find
0x731A2eac_string_find_char0x4AFind character
0xF9988eac_snprintf0x1017String format
0xFBDA7eac_string_split0x268Split by delimiter
0x1254C0eac_strlen0xA8String length
0x124880eac_memcpy0x66DMemory copy
0x125120eac_memset0x388Memory set

Initialization

AddressNameSizeDescription
0x16A4Feac_main_init0x1495Main initialization
0x109BEeac_init_counter0x70Initialize counter object
0x8EFE4eac_register_callback0x4FRegister event callback
0x481D1eac_get_tick_count0x95Get system tick count
0x6130eac_release_ref0x37Release reference count
0x72FB8eac_buffer_write_grow0xF7Vector-like buffer grow

Global Variables

AddressNameDescription
0x1B3DC8g_eac_contextGlobal EAC state pointer (10,128 byte struct)
0x1C1A48eac_exception_spinlockSpinlock for exception dispatch
0x1B4068eac_exception_record_poolException record pool lock
0x1B4090eac_exception_queue_head128-bit CAS queue head
0x1B4060eac_exception_pool_basePool base + generation counter
0x1C18E0eac_exception_list_tailException list tail pointer
Built with v0