Table of Contents
- Architecture Overview
- Module Layout and Deployment
- Exception Handling Chain
- Page Encryption and Decryption
- Accessing Address Tracking and the 500-Page Limit
- Data Encoding Schemes
- Shadow Allocation and Relocation Fixups
- Obfuscated Syscall System
- API Resolution by Hash
- Code Hook / Trampoline Engine
- Integrity Checks and Violation Reporting
- TLS and Network Communication
- Initialization Sequence
- Key Data Structures
- 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->Ldrmodule 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:
| Region | Address Range | Size | Contents |
|---|---|---|---|
| Dispatch Tables | 0x0000–0x4FFF | 20 KB | Two 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 1 | 0x5000–0x125FFF | ~1.1 MB | Core hand-written functions: exception handling, page decrypt, syscalls, API resolution, hooking engine, TLS, HTTP, telemetry. ~315 recovered functions. |
| Data Section | 0x126000–0x20DFFF | ~920 KB | Crypto lookup tables, unwind data, cipher suite strings (0x18A5D9–0x18A99D), error messages, DH parameters (0x18A268), BSS-style zeroed regions. |
| Code Region 2 | 0x20E000–0x1570FFF | ~19.4 MB | Bulk obfuscated VM-generated code. ~37,700 functions, most 50KB–150KB with no strings. This is the VM dispatcher and handler soup. |
| Tail Data | 0x1571000–0x1582FFF | ~72 KB | Trailing 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:
- The kernel delivers the exception to user mode via
KiUserExceptionDispatcher - EAC's trampoline (
eac_exception_trampolineat 0x10FCF0) intercepts this before the normal VEH/SEH chain - The trampoline calls
eac_syscall_dispatcher(for syscall-related exceptions) andeac_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:
- Stack frame validation — verifies the exception frame hasn't been corrupted by checking stack pointer alignment and bounds
- Exception code check — only processes
0xC0000005(EXCEPTION_ACCESS_VIOLATION); all others fall through to the normal handler - Second-chance filter — ignores continuable exceptions with specific flags
- Faulting address classification:
- If the accessed address is
-1(0xFFFFFFFFFFFFFFFF): this is a stack overflow probe — routes toeac_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
- If the accessed address is
- 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 - Dispatch to decrypt handler — calls
eac_page_decrypt_handlerwith the matched module context - Resume via syscall — on successful decrypt, encodes and executes
NtContinuevia 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_exceptionto 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 (
0x7866088CEE5B4180means "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_protectionis 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:
- Creates a wait event via obfuscated
NtCreateEventsyscall - Atomically enqueues itself onto the wait queue (CAS on the state word)
- Calls obfuscated
NtWaitForSingleObjectto sleep - 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,
NtProtectVirtualMemoryis 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:
- The relocation table offset is read from page+44
- The relocation block is read from the module's PE relocation directory (base decoded from
Module Info+0x100, offset 256) - Each relocation entry is a 16-bit value:
- High nibble = type (0xA =
IMAGE_REL_BASED_DIR64) - Low 12 bits = offset within the page
- High nibble = type (0xA =
- For each DIR64 relocation:
*(uint64_t*)(page + offset) += module_base - 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_countbytes
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,
NtSetEventis 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:
- The faulting RIP page is verified as a different, already-decrypted page of the same module
- A page hash is computed and verified against the hash catalogue (
eac_verify_page_hash) - 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
- 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
- If the address is not found, a new entry is created (via
eac_hashmap_insert) - If found,
eac_increment_access_counteris called - The counter at DecryptInfo+0x18 is checked against 0x1F3 (499)
- If the counter exceeds 499:
eac_restore_page_protectionis 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 & maskindexing)
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_PRIVATEregion - 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:
- The relocation directory base is decoded from
Module Info+0x100(offset 256) - For the current page, the relocation block is located using the offset stored in page metadata (+44)
- Each block has:
{ VirtualAddress, SizeOfBlock, Type/Offset[] } - Entry count =
(SizeOfBlock - 8) / 2 - For
IMAGE_REL_BASED_DIR64(type 0xA): add the ASLR delta to the 8-byte value at the specified offset - 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:
-
Find a cave: Scans virtual memory within ±64KB of the target function for a suitable region to host the trampoline code. Uses
NtQueryVirtualMemoryto find gaps, thenNtAllocateVirtualMemoryto 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); // ... -
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) -
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
-
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_lookupto resolve new target addresses
-
Atomic installation: Uses
InterlockedCompareExchange64to 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)
- Temporarily changes page protection to
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 (likelyNtProtectVirtualMemoryandNtMapViewOfSectionor 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:
| Code | Meaning |
|---|---|
| 3938459649 | Integrity check failed |
| 3938459650 | RIP on already-decrypted page accessing another encrypted page |
| 3938459651 | Failed to set page protection after decrypt |
| 3938459652 | Session timeout exceeded (120 seconds) |
| 3938459653 | Page protection verification failed during re-encryption |
| 3938459671 | Suspicious instruction pattern at page start (RET/INT3/HLT) exceeded 999 count |
| 3938459685 | Syscall 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 failureerror_system_configuration— unexpected system stateerror_violation— generic anti-cheat violationerror_system_version— unsupported OS versionerror_file_version— wrong game versionerror_tool_forbidden— detected forbidden tool (debugger, etc.)error_module_forbidden— detected forbidden DLLerror_file_forbidden— detected forbidden fileerror_virtual— running under virtual machine detectederror_corrupted_network— network integrity failureerror_catalogue_not_found— hash catalogue missingerror_file_not_found— required file missingerror_catalogue_corrupted— hash catalogue tamperederror_certificate_revoked— TLS certificate revocationexecutable_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 ClientHelloeac_tls_process_server_hello(0x102FBA) — processes ServerHelloeac_tls_process_certificate(0x10350A) — validates server certificate
PRF Functions:
eac_tls_prf_sha256/eac_tls_prf_sha1_md5— the TLS pseudo-random functioneac_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-lengthheader for response body - Reads response body if present
HTTP Client:
eac_http_get_request(0x8BCE) — buildsGET {path} HTTP/1.1withConnection: closeheadereac_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.
-
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. -
State allocation: Allocates a 10,128-byte global state structure (
g_eac_contextstored 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
-
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
-
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
- Reads PEB to get the game's base address from
-
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
-
API hooking: Calls
eac_hook_ntdll_apis. If this fails:__fastfail(0xEAC00003)— immediate process termination. -
Game module protection: Calls
sub_C64B4to set up the page encryption for the game module. -
Configuration notification: Calls
sub_BAE20with 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.
| Offset | Size | Description |
|---|---|---|
| +0xD8 (216) | 8 | Encoded base address |
| +0xE0 (224) | 8 | Encoded size |
| +0xE8 (232) | 8 | Encoded entry point / PE header RVA |
| +0xF0 (240) | 8 | Encoded hash array pointer |
| +0xF8 (248) | 4 | Encoded page protection template |
| +0x100 (256) | 8 | Encoded relocation directory base |
| +0x108 (264) | 8 | Encoded shadow allocation base |
| +0x110 (272) | 8 | Page metadata array pointer |
| +0x118 (280) | 8 | (unused/reserved) |
| +0x120 (288) | 8 | (unused/reserved) |
| +0x128 (296) | 8 | Encoded decrypt key table pointer |
| +0x130 (304) | 8 | Encoded something |
| +0x138 (312) | 8 | (padding) |
| +0x140 (320) | 8 | Encoded session start timestamp |
| +0x148 (328) | 8 | Suspicious instruction counter (atomic) |
| +0x150 (336) | 8+ | Spinlock for integrity check |
| +0x168 (360) | 4 | Thread ID for integrity check |
| +0x17C (380) | 1 | Integrity 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.
| Metric | Value |
|---|---|
| Functions identified | 38,045 |
| Code regions recovered | 494 |
| Data regions defined | 1,906 |
| Functions annotated | 155+ |
Cleanup patterns addressed:
- GAP-based function recovery with prologue pattern scanning
- Orphan code block recovery via cross-reference analysis
- String and data region identification in unanalyzed gaps
- Module header structure definition and dispatch table mapping
Named Functions Reference
Page Protection Core
| Address | Name | Size | Description |
|---|---|---|---|
| 0x3515E | eac_exception_dispatch | 0x567 | Main exception handler entry |
| 0x339C2 | eac_page_decrypt_handler | 0x1286 | Page fault handling + decrypt |
| 0x10FCF0 | eac_exception_trampoline | 0xF | KiUserExceptionDispatcher hook |
| 0x7DBA4 | eac_encrypt_page_and_resume | 0x143 | Re-encrypt page + wake waiters |
| 0xB8822 | eac_zero_and_reprotect_page | 0x78 | Zero + set PAGE_NOACCESS |
| 0xBBC44 | eac_reencrypt_page | 0xF6 | Re-encryption logic |
| 0x78360 | eac_validate_rip_in_module | 0x1E0 | Check if RIP is in allowed range |
| 0x786AA | eac_restore_page_protection | 0x5 | Set page back to NOACCESS |
| 0x34C48 | eac_verify_page_hash | 0x75 | Hash-verify decrypted page |
| 0x149FF | eac_set_page_protection | 0x59 | Set page protection wrapper |
| 0x106F80 | eac_check_address_in_region | 0x8E | Check addr in allowed region |
Hashmap / Access Tracking
| Address | Name | Size | Description |
|---|---|---|---|
| 0x34CBE | eac_fnv1a_hashmap_lookup | 0x70 | FNV-1a hash + bucket lookup |
| 0x3509A | eac_hashmap_find_or_insert | 0x68 | Find or create entry |
| 0x34D2E | eac_hashmap_insert | 0x36B | Insert new node |
| 0x24F3A | eac_increment_access_counter | 0x15C | Increment + check 0x1F3 limit |
| 0xBD32 | eac_hashmap_init | 0x91 | Initialize hashmap |
| 0x39F44 | eac_hashmap_resize | 0xA2 | Grow bucket array |
| 0xF4ED6 | eac_hashmap_insert_entry | 0x4E | Low-level insert |
| 0xF4F24 | eac_hashmap_remove_entry | 0x56 | Low-level remove |
Syscall System
| Address | Name | Size | Description |
|---|---|---|---|
| 0x32D8E | eac_syscall_dispatcher | 0xC33 | SSN dispatch table |
| 0x8032 | eac_encode_syscall_lo | 0x52 | Encode SSN low 32 bits |
| 0x8084 | eac_encode_syscall_hi | 0x5A | Encode SSN high 32 bits |
| 0xA038E | eac_syscall_handler_generic | 0x492 | Generic syscall handler |
| 0xA0820-0xA0BD2 | eac_syscall_handler_* | ~0x38 each | 18 specific handlers |
Memory Management
| Address | Name | Size | Description |
|---|---|---|---|
| 0x637DE | eac_heap_alloc | 0x6E | Allocate memory |
| 0x6392A | eac_heap_free | 0x80 | Free memory |
| 0x575A | eac_heap_free_buffer | 0x41 | Free buffer |
| 0x46AE3 | eac_virtual_alloc | 0xFA | NtAllocateVirtualMemory wrapper |
| 0x46CC9 | eac_virtual_free | 0x151 | NtFreeVirtualMemory wrapper |
| 0x45B57 | eac_protect_virtual_memory | 0x1F9 | NtProtectVirtualMemory wrapper |
| 0x45D50 | eac_query_virtual_memory | 0x126 | NtQueryVirtualMemory wrapper |
| 0x46BDD | eac_protect_and_commit | 0xEC | Protect + commit region |
| 0x4FFB6 | eac_alloc_executable_region | 0xFA | Alloc RWX region |
Synchronization
| Address | Name | Size | Description |
|---|---|---|---|
| 0x4443B | eac_spinlock_acquire | 0x5D | Spin-wait lock |
| 0x44498 | eac_spinlock_release | 0x5D | Release spinlock |
| 0x444F5 | eac_wait_for_object | 0x6A | NtWaitForSingleObject |
| 0x46E1A | eac_close_handle | 0x4B | NtClose |
| 0x10079C | eac_init_mutex | 0x67 | Initialize reader-writer lock |
| 0x100804 | eac_mutex_lock_exclusive | 0x5D | Write lock |
| 0x100862 | eac_mutex_unlock_exclusive | 0x5D | Write unlock |
| 0x1008C0 | eac_mutex_lock | 0x5D | Read lock |
| 0x10091E | eac_mutex_unlock | 0x5D | Read unlock |
Code Hooking
| Address | Name | Size | Description |
|---|---|---|---|
| 0x10C092 | eac_install_code_hook | 0x171E | Atomic trampoline installer |
| 0x10D7B0 | eac_code_hook_cleanup | 0xBA | Remove hook |
| 0x10D86A | eac_calc_patched_size | 0x5F | Compute trampoline size |
| 0x10FEB8 | eac_init_disasm_context | 0x4F | Init disassembler |
| 0x10FFE0 | eac_disassemble_insn | 0xF1 | Disassemble one instruction |
| 0x1100D4 | eac_decode_instruction | 0x6D | Decode instruction fields |
| 0x1136C4 | eac_resolve_branch_target | 0xF6 | Compute branch destination |
API Resolution
| Address | Name | Size | Description |
|---|---|---|---|
| 0x44892 | eac_resolve_api_by_hash | 0x3D8 | Hash-walk exports |
| 0x43AFB | eac_get_module_handle | 0x7E | PEB module list walk |
| 0xE4C29 | eac_resolve_all_apis | 0x2674 | Master API resolver |
| 0xEF667 | eac_resolve_crypto_apis | 0x457 | bcrypt API resolution |
| 0x16872 | eac_resolve_ntdll_apis | 0x1DD | ntdll API resolution |
| 0x41028 | eac_resolve_kernel32_apis | 0x3ED | kernel32 API resolution |
| 0x935CC | eac_resolve_network_apis | 0x38C | Network API resolution |
| 0xB612F | eac_resolve_winsock_apis | 0x44D | ws2_32 API resolution |
| 0x754A7-0x76A49 | eac_resolve_nt_syscall_0-14 | ~0x180 each | Per-syscall SSN resolution |
Hook Installation
| Address | Name | Size | Description |
|---|---|---|---|
| 0x68292 | eac_hook_ntdll_apis | 0x2BD | Hook 2 ntdll functions |
| 0x685BC | eac_hook_kernel_apis | 0xAC7 | Hook kernel32 functions |
| 0x6AFD8 | eac_hook_module_apis | 0x616 | Hook game module functions |
Integrity / Violations
| Address | Name | Size | Description |
|---|---|---|---|
| 0xBADA6 | eac_report_violation | 0x5 | Record violation event |
| 0xF05F8 | eac_format_error_message | 0x58A | Format error for reporting |
| 0xCA5F6 | eac_verify_executable_hash | 0x1A2 | Verify game hash catalogue |
| 0xC7696 | eac_spawn_hash_verify_thread | 0x4C | Spawn verification thread |
| 0x81788 | eac_integrity_check | 0x5 | Self-integrity verification |
| 0xC29AB | eac_module_init_and_guard | 0x12F | Module guard + PEB check |
| 0xCB08F | eac_log_exception | 0x1DB | Log exception for telemetry |
TLS / Crypto
| Address | Name | Size | Description |
|---|---|---|---|
| 0x553BC | eac_tls_handshake | 0x257D | Full TLS 1.2 handshake |
| 0x53410 | eac_tls_derive_keys | 0x5BC | Master secret + key expansion |
| 0x102E6A | eac_tls_client_hello | 0x150 | Send ClientHello |
| 0x102FBA | eac_tls_process_server_hello | 0x300 | Process ServerHello |
| 0x10350A | eac_tls_process_certificate | 0x54E | Validate certificate |
| 0x539CC | eac_tls_prf_sha1_md5 | 0x3C | PRF (SHA1+MD5) |
| 0x53A08 | eac_tls_prf_sha256 | 0x3C | PRF (SHA256) |
| 0x53AD6 | eac_tls_prf_verify_sha256 | 0xCA | Finished verify (SHA256) |
| 0x53BA0 | eac_tls_prf_verify_sha1 | 0xBC | Finished verify (SHA1) |
| 0xA21A2 | eac_tls_lookup_cipher | 0x56 | Cipher suite lookup |
| 0xA2257 | eac_tls_init_cipher_ctx | 0x71 | Initialize cipher context |
| 0xA22C8 | eac_tls_setup_iv | 0xEA | Set up IV |
| 0xF2115 | eac_tls_set_cipher_key | 0x5F | Set cipher key |
| 0xCD256 | eac_tls_context_init | 0xB8 | Init TLS context |
| 0xCDD68 | eac_tls_session_init | 0xB9A | Init TLS session |
| 0xCF0C4 | eac_tls_session_cleanup | 0x86 | Cleanup session |
| 0xCD3DA | eac_tls_context_cleanup | 0x53 | Cleanup context |
| 0xCEACC | eac_tls_set_min_version | 0x6A | Set minimum TLS version |
| 0xCEB36 | eac_tls_set_max_version | 0x6A | Set maximum TLS version |
Network
| Address | Name | Size | Description |
|---|---|---|---|
| 0x72170 | eac_network_connect | 0x402 | Establish connection |
| 0x72572 | eac_http_connect_proxy | 0xA46 | HTTP CONNECT tunnel |
| 0x8BCE | eac_http_get_request | 0x40C | HTTP GET |
| 0x8FDA | eac_http_post_request | 0x644 | HTTP POST |
| 0x71BC2 | eac_parse_url | 0x49C | URL parser |
| 0x7205E | eac_set_http_header | 0x112 | Set HTTP header |
| 0x731EC | eac_build_host_header | 0x2B8 | Build Host: header |
| 0xB269C | eac_async_connect | 0x2A0 | Async socket connect |
| 0xB2DEE | eac_socket_init | 0x72 | Initialize socket |
| 0xCD42E | eac_tls_connect_callback | 0x41 | TLS connect callback |
Telemetry
| Address | Name | Size | Description |
|---|---|---|---|
| 0xAEC8C | eac_telemetry_loop | 0x1A56 | Main telemetry loop |
| 0xB07BE | eac_send_telemetry | 0x7F1 | Send telemetry POST |
String Utilities
| Address | Name | Size | Description |
|---|---|---|---|
| 0x87A8 | eac_string_copy | 0xDB | Copy string |
| 0x8AC6 | eac_string_assign | 0x95 | Assign from C string |
| 0x25E70 | eac_string_append | 0x45 | Append string |
| 0x27FE4 | eac_string_append_raw | 0x135 | Append raw bytes |
| 0x27928 | eac_string_from_data | 0x139 | Create from data |
| 0x27652 | eac_string_destroy | 0x65 | Free string |
| 0x31DBA | eac_string_contains | 0x5F | Substring check |
| 0x31E1A | eac_string_find_reverse | 0x7C | Reverse find |
| 0x31E96 | eac_string_prepend | 0xD1 | Prepend string |
| 0x31F67 | eac_string_find | 0x8A | Find substring |
| 0x31FF2 | eac_string_clear | 0x4F | Clear string |
| 0x32042 | eac_map_insert | 0x101 | Ordered map insert |
| 0x730B0 | eac_map_find | 0xF1 | Ordered map find |
| 0x731A2 | eac_string_find_char | 0x4A | Find character |
| 0xF9988 | eac_snprintf | 0x1017 | String format |
| 0xFBDA7 | eac_string_split | 0x268 | Split by delimiter |
| 0x1254C0 | eac_strlen | 0xA8 | String length |
| 0x124880 | eac_memcpy | 0x66D | Memory copy |
| 0x125120 | eac_memset | 0x388 | Memory set |
Initialization
| Address | Name | Size | Description |
|---|---|---|---|
| 0x16A4F | eac_main_init | 0x1495 | Main initialization |
| 0x109BE | eac_init_counter | 0x70 | Initialize counter object |
| 0x8EFE4 | eac_register_callback | 0x4F | Register event callback |
| 0x481D1 | eac_get_tick_count | 0x95 | Get system tick count |
| 0x6130 | eac_release_ref | 0x37 | Release reference count |
| 0x72FB8 | eac_buffer_write_grow | 0xF7 | Vector-like buffer grow |
Global Variables
| Address | Name | Description |
|---|---|---|
| 0x1B3DC8 | g_eac_context | Global EAC state pointer (10,128 byte struct) |
| 0x1C1A48 | eac_exception_spinlock | Spinlock for exception dispatch |
| 0x1B4068 | eac_exception_record_pool | Exception record pool lock |
| 0x1B4090 | eac_exception_queue_head | 128-bit CAS queue head |
| 0x1B4060 | eac_exception_pool_base | Pool base + generation counter |
| 0x1C18E0 | eac_exception_list_tail | Exception list tail pointer |