An in-depth analysis of Easy Anti-Cheat's page encryption, covering memory protection, encryption mechanisms, reverse engineering, and security design.
Published by: Caruso Team
Last modified: · 30 min read · 1.2k views

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:
EAC's handler binary is mapped into a MEM_PRIVATE allocation — not a normal loaded module. This means:
PEB->Ldr module listThe 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).
EAC hooks the Windows exception dispatch mechanism. When any exception occurs in the process:
KiUserExceptionDispatchereac_exception_trampoline at 0x10FCF0) intercepts this before the normal VEH/SEH chaineac_syscall_dispatcher (for syscall-related exceptions) and eac_exception_dispatch (for page faults)This function receives the full exception context (CONTEXT record + EXCEPTION_RECORD) from the stack. It performs these checks:
0xC0000005 (EXCEPTION_ACCESS_VIOLATION); all others fall through to the normal handler-1 (0xFFFFFFFFFFFFFFFF): this is a stack overflow probe — routes to eac_stack_overflow_thunk0x199082B8D60 (ProtectedModules, currently 2 entries for the 2 slots) using the base/size decoding formulas to check if the faulting address falls within a protected moduleeac_page_decrypt_handler with the matched module contextNtContinue via obfuscated syscall to resume the faulting threadHere'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:
eac_log_exception to record the event for telemetryThe 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);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.
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 (96 bytes per page):
struct PageMetadataEntry {
uint64_t encoded_virtual_address; // +0x00 - Encoded virtual address of the page
uint64_t encoded_shadow_data_ptr; // +0x08 - Encoded shadow data pointer (encrypted page content location)
uint64_t shadow_ptr_or_sentinel; // +0x10 - Shadow pointer or magic sentinel (0x7866088CEE5B4180 = use main allocation)
volatile uint64_t state_lock_word; // +0x18 - Atomic state/lock word (bit 0: decrypted, bit 1: in-progress, bits 2+: wait queue ptr)
void* owner_teb; // +0x20 - Owner TEB pointer (thread holding page lock)
uint32_t encoded_page_protection; // +0x28 - Encoded original page protection
uint32_t relocation_table_offset; // +0x2C - Relocation table offset within PE .reloc section
uint8_t image_base_fixup_byte_count; // +0x30 - Image base fixup byte count
uint8_t image_base_fixup_addend; // +0x31 - Image base fixup addend
uint8_t _pad0[6]; // +0x32 - Padding to align next field
uint64_t encoded_timestamp; // +0x38 - Encoded timestamp (last access time)
uint8_t first_byte_of_page; // +0x40 - First byte of page (saved for instruction analysis)
uint8_t _pad1[7]; // +0x41 - Padding to align next field
uint64_t encoded_access_duration; // +0x48 - Encoded access duration counter
uint64_t encoded_decrypt_fn_selector; // +0x50 - Encoded hash/decrypt function selector
uint64_t valid_flag; // +0x58 - Valid flag (must be 1)
}; // Total size: 96 bytes (0x60)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)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:
If the page is already decrypted (bit 0 set), the function returns immediately. If decryption is in progress (bit 1 set), the current thread:
NtCreateEvent syscallNtWaitForSingleObject to sleepIf the page is unlocked and not decrypted, the current thread atomically sets the lock bit (state = 3) and proceeds with decryption.
The wait queue uses thread-local WaitNode structures on the stack:
struct WaitNode {
WaitNode* next; // +0x00 - Next waiter in queue (or previous state bits when enqueuing)
HANDLE event; // +0x08 - Event handle for NtWaitForSingleObject / NtSetEvent
}; // 16 bytes, stack-allocated per waiting threadFrom 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)
Module Info+0x108 (offset 264): (encoded + 0x74EB78F9B70729C8) ^ 0xED518186A5626B48shadow_base + page_offsetNtProtectVirtualMemory is called to make the shadow page readable (it was committed but not accessible)Path B: Direct pointer
(page[16] + 0x74EB78F9B70729C8) ^ 0xED518186A5626B48Step 6: Decryption
After copying, the page content is decrypted in-place. The decryption function pointer is obtained through a heavily obfuscated lookup:
The decryption involves:
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:
Module Info+0x100, offset 256)IMAGE_REL_BASED_DIR64)*(uint64_t*)(page + offset) += module_baseFrom 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_addendfixup_byte_count bytesStep 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:
__fastfail(0xEAC)// 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
__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:
NtSetEvent is called via obfuscated syscall to wake them__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:
eac_verify_page_hash)Module Info+0x90 (sentinel at +0x98, buckets at +0xA8, mask at +0xC0)eac_hashmap_insert)eac_increment_access_counter is calledeac_restore_page_protection is called — the page is put back to PAGE_NOACCESS, effectively re-encrypting it for that instruction addressThis is the 500-page-per-accessing-address limit.
The accessing address tracking system uses a custom FNV-1a hashmap:
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;
}Located at offset +0x90 from Module Info base.
struct AddressHashmap {
void* base; // +0x00 - Hashmap base pointer
HashmapNode* sentinel; // +0x08 - Sentinel node (head of linked list)
uint8_t _pad0[0x08]; // +0x10 - Padding
HashmapBucket* buckets; // +0x18 - Buckets array pointer
uint8_t _pad1[0x08]; // +0x20 - Unused/padding
uint64_t bucket_mask; // +0x28 - Bucket mask (bucket_count - 1, for hash & mask indexing)
};
struct HashmapBucket {
HashmapNode* first; // +0x00 - First node in bucket
HashmapNode* last; // +0x08 - Last node in bucket
}; // 16 bytes per bucketstruct HashmapNode {
HashmapNode* next; // +0x00 - Next pointer
HashmapNode* prev; // +0x08 - Previous pointer (doubly-linked)
uint64_t key; // +0x10 - Key (the 8-byte accessing address)
DecryptInfo* decrypt_info; // +0x18 - Pointer to DecryptInfo
};struct DecryptInfo {
uint64_t _unknown0; // +0x00 - Unknown
uint64_t _unknown1; // +0x08 - Unknown
uint64_t _unknown2; // +0x10 - Unknown
uint64_t access_counter; // +0x18 - Access counter
};
// When access_counter reaches 500 (0x1F4), 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
}
}EAC encodes all sensitive pointers and values in memory using these formulas:
decoded = (encoded + 0x74EB78F9B70729C8) ^ 0xED518186A5626B48
encoded = (decoded ^ 0xED518186A5626B48) - 0x74EB78F9B70729C8Used for: module base, shadow base, page data pointers, relocation table base
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
decoded = (encoded ^ 0x46E027FE) - 266204834 // 266204834 = 0x0FE027E2
encoded = (decoded + 0x0FE027E2) ^ 0x46E027FEdecoded = 0xEF6A89E6AACE44E3 * (encoded ^ 0xEF1516A79B86104D)
// inverse not observedThese 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.
EAC maintains a "shadow allocation" — a separate memory region that stores the encrypted/compressed page data for the protected module. This shadow:
MEM_PRIVATE regionModule Info+0x108 (offset 264)NtProtectVirtualMemory, its content is copied to the target, then decrypted in-placeFortnite 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:
Module Info+0x100 (offset 256){ VirtualAddress, SizeOfBlock, Type/Offset[] }(SizeOfBlock - 8) / 2IMAGE_REL_BASED_DIR64 (type 0xA): add the ASLR delta to the 8-byte value at the specified offsetEAC never calls ntdll functions directly. Instead, it uses a custom syscall dispatch system:
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.
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:
Each handler (eac_syscall_handler_*) decodes the SSN through its own unique multiplication/XOR chain and executes the syscall instruction inline.
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.
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:
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
}
}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.
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 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);
// ...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:
Generate patched code: Copies the original instructions to the trampoline, fixing up:
eac_fnv1a_hashmap_lookup to resolve new target addressesAtomic installation: Uses InterlockedCompareExchange64 to atomically overwrite the first 8 bytes of the target function:
PAGE_EXECUTE_READWRITEE9 (JMP rel32) instruction pointing to the trampolineFrom 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
}
}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;
}EAC hooks functions in:
eac_hook_ntdll_apis — 0x68292): 2 functions hooked (likely NtProtectVirtualMemory and NtMapViewOfSection or similar)eac_hook_kernel_apis — 0x685BC): Additional hookseac_hook_module_apis — 0x6AFD8): Hooks on game-specific functionsThese hooks allow EAC to monitor and intercept API calls made by the game and any injected DLLs, detecting tampering attempts.
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 trackingeac_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.
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.
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 catalogueEAC implements its own TLS 1.2 stack rather than using Windows SChannel. This prevents interception via OS-level TLS hooks.
Key Derivation (eac_tls_derive_keys — 0x53410):
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):
eac_tls_client_hello (0x102E6A) — sends ClientHelloeac_tls_process_server_hello (0x102FBA) — processes ServerHelloeac_tls_process_certificate (0x10350A) — validates server certificatePRF 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" labelsSupported Cipher Suites (from string data at 0x18A5D9-0x18A99D):
Connection Setup (eac_network_connect — 0x72170):
eac_socket_initHTTP CONNECT Proxy (eac_http_connect_proxy — 0x72572):
CONNECT %s:%d HTTP/1.1\r\nHost: %s\r\n\r\ncontent-length header for response bodyHTTP Client:
eac_http_get_request (0x8BCE) — builds GET {path} HTTP/1.1 with Connection: close headereac_http_post_request (0x8FDA) — builds POST requests for telemetryURL Parsing (eac_parse_url — 0x71BC2): Splits URL into scheme, host, port, path components.
eac_telemetry_loop (0xAEC8C, 6,742 bytes) runs as a continuous loop that:
eac_send_telemetry (0xB07BE)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_context stored at 0x1B3DC8). This structure contains:
Sub-initialization:
Module protection setup:
InMemoryOrderModuleListSelf-registration:
API hooking: Calls eac_hook_ntdll_apis. If this fails: __fastfail(0xEAC00003) — immediate process termination.
Game module protection: Calls sub_C64B4 to set up the page encryption for the game module.
Configuration notification: Calls sub_BAE20 with a configuration code (1073942528 = 0x40010000) to signal that initialization is complete.
Size: ~400 bytes. Stored in the ProtectedModules array at 0x199082B8D60.
struct ProtectedModuleDescriptor {
uint8_t _pad0[0xD8]; // +0x00 - Unknown/reserved
uint64_t encoded_base_address; // +0xD8 - Encoded base address
uint64_t encoded_size; // +0xE0 - Encoded size
uint64_t encoded_entry_point; // +0xE8 - Encoded entry point / PE header RVA
uint64_t encoded_hash_array_ptr; // +0xF0 - Encoded hash array pointer
uint32_t encoded_page_protection; // +0xF8 - Encoded page protection template
uint8_t _pad1[0x04]; // +0xFC - Alignment padding
uint64_t encoded_reloc_dir_base; // +0x100 - Encoded relocation directory base
uint64_t encoded_shadow_alloc_base; // +0x108 - Encoded shadow allocation base
uint64_t page_metadata_array; // +0x110 - Page metadata array pointer
uint64_t _reserved0; // +0x118 - Unused/reserved
uint64_t _reserved1; // +0x120 - Unused/reserved
uint64_t encoded_decrypt_key_table; // +0x128 - Encoded decrypt key table pointer
uint64_t encoded_unknown; // +0x130 - Encoded (purpose unknown)
uint64_t _pad2; // +0x138 - Padding
uint64_t encoded_session_timestamp; // +0x140 - Encoded session start timestamp
uint64_t suspicious_instr_counter; // +0x148 - Suspicious instruction counter (atomic)
uint8_t spinlock[0x18]; // +0x150 - Spinlock for integrity check
uint32_t integrity_thread_id; // +0x168 - Thread ID for integrity check
uint8_t _pad3[0x10]; // +0x16C - Padding
uint8_t integrity_check_done; // +0x17C - Integrity check completed flag
}; // Total size: ~400 bytes10,128 bytes, allocated in eac_main_init. Pointer stored at 0x1B3DC8.
struct EacGlobalContext {
void* vtable; // +0x00 - vtable pointer
void* sub_vtable; // +0x08 - sub-vtable pointer
uint32_t init_flag; // +0x10 - Initialization flag
uint8_t config[0x0C]; // +0x14 - Configuration data
uint8_t conn_ctx_1[0x9C0]; // +0x20 - First connection context (2496 bytes)
uint8_t conn_ctx_2[0x9C0]; // +0x9E0 - Second connection context (2496 bytes)
char module_info_str[0x60]; // +0x13A0 - String buffers (module info)
uint8_t socket_ctx_3[0x50]; // +0x1400 - Third socket context
uint8_t conn_params[0x64]; // +0x1450 - Connection parameters (timeouts, buffer sizes)
uint8_t _pad0[0x4C]; // +0x14B4 - Padding
uint8_t fnv1a_hashmap[0x70]; // +0x1500 - FNV-1a hashmap (address tracking)
uint8_t _pad1[0x28]; // +0x1570 - Padding
void* module_list_head; // +0x1598 - Linked list for module entries
uint8_t _pad2[0x228]; // +0x15A0 - Padding
uint8_t tls_state[0xC8]; // +0x17C8 - TLS-related state
void* hook_list_head; // +0x1890 - Linked list for hook entries
uint8_t _pad3[0x150]; // +0x1898 - Padding
uint64_t encoded_mod_bases[3]; // +0x19E8 - Encoded module bases (init: 0xEF1516A79B86104D)
uint8_t _pad4[0xD0]; // +0x1A00 - Padding
void* self_reference; // +0x1B20 - Self-reference pointer
void* protected_modules_head; // +0x1B28 - Linked list for protected modules
uint8_t _pad5[0x578]; // +0x1B30 - Padding
char str_buffers[0x98]; // +0x20A8 - String buffers
uint64_t encoded_mod_bases_2[0x08]; // +0x2140 - More encoded module bases
uint8_t _pad6[0x1F0]; // +0x2180 - Padding
uint64_t rate_limit_counter; // +0x2370 - Counter (for rate limiting)
uint8_t _pad7[0x2F0]; // +0x2378 - Padding
uint64_t misc_counter; // +0x2668 - Another counter
uint8_t _pad8[0x28]; // +0x2670 - Padding
HANDLE thread_guard; // +0x26A0 - Thread guard handle
}; // Total size: 10,128 bytes (0x2790)Lock-free pool using InterlockedCompareExchange128. Head at 0x1B4090, pool base at 0x1B4060.
struct ExceptionRecordEntry {
ExceptionRecordEntry* next; // +0x00 - Linked list next pointer
ExceptionRecordEntry* prev; // +0x08 - Linked list previous pointer
CONTEXT context; // +0x10 - Copied CONTEXT record (1,232 bytes)
uint8_t _pad0[0x08]; // +0x4E0 - Padding
uint32_t thread_id; // +0x4E8 - Thread ID
uint8_t _pad1[0x04]; // +0x4EC - Alignment padding
EXCEPTION_RECORD exception_record; // +0x4F0 - Copied EXCEPTION_RECORD (152 bytes)
uint8_t _pad2[0x10]; // +0x588 - Padding
uint64_t stack_base; // +0x580 - Stack base
uint64_t stack_limit; // +0x588 - Stack limit
uint64_t stack_capture_size; // +0x590 - Stack capture size
uint8_t stack_data[0x1000]; // +0x598 - Captured stack data (up to 4096 bytes)
uint64_t timestamp; // +0x1598 - Timestamp (from eac_get_tick_count)
}; // Total size: 5,536 bytes (0x15A0)
struct ExceptionPoolHead {
ExceptionRecordEntry* ptr; // +0x00 - Head pointer
uint64_t generation; // +0x08 - Generation counter (for ABA prevention)
}; // 128-bit CAS targetCustom 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:
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Address | Name | Size | Description |
|---|---|---|---|
| 0xAEC8C | eac_telemetry_loop | 0x1A56 | Main telemetry loop |
| 0xB07BE | eac_send_telemetry | 0x7F1 | Send telemetry POST |
| 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 |
| 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 |
| 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 |
Found this useful?
Pass it along to someone else running Caruso.