EAC and its CR3 shuffling

As you may or may not know, Easy Anti-Cheat has introduced a protection mechanism that replaces EPROCESS->DirectoryTableBase with an invalid value. This change will cause an exception with a consequent call to their custom exception handler when a process attempts to attach. You can read more about it here:

This could have been easily bypassed by either obtaining the CR3 value from other places or by brute-forcing it (example taken from one of my projects):

inline uint64_t bruteforce_directory_base(uint64_t peprocess)
{
	uint64_t base_address = kernel_get_section_base_address(peprocess);
	if (!base_address)
		return 0;

	defines::virtual_address_t virtual_address;
	virtual_address.value = base_address;

	defines::PPHYSICAL_MEMORY_RANGE physical_ranges = reinterpret_cast<defines::PPHYSICAL_MEMORY_RANGE>(kernel_get_physical_ranges());
	for (int i = 0; /**/; i++)
	{
		defines::PHYSICAL_MEMORY_RANGE current_element = { 0 };
		kernel_copy_memory(reinterpret_cast<uint64_t>(&current_element), reinterpret_cast<uint64_t>(&physical_ranges[i]), sizeof(defines::PHYSICAL_MEMORY_RANGE));
		if (!current_element.BaseAddress.QuadPart || !current_element.NumberOfBytes.QuadPart)
			return 0;

		uint64_t current_physical = current_element.BaseAddress.QuadPart;
		for (uint64_t j = 0; j < (current_element.NumberOfBytes.QuadPart / defines::page_size); j++, current_physical += defines::page_size)
		{
			defines::pte_t pml4e = { 0 };
			read_physical_address(current_physical + 8 * virtual_address.pml4_index, reinterpret_cast<uint64_t>(&pml4e), 8);
			if (!pml4e.present)
				continue;

			defines::pte_t pdpte = { 0 };
			read_physical_address((pml4e.page_frame << 12) + 8 * virtual_address.pdpt_index, reinterpret_cast<uint64_t>(&pdpte), 8);
			if (!pdpte.present)
				continue;

			defines::pte_t pde = { 0 };
			read_physical_address((pdpte.page_frame << 12) + 8 * virtual_address.pd_index, reinterpret_cast<uint64_t>(&pde), 8);
			if (!pde.present)
				continue;

			defines::pte_t pte = { 0 };
			read_physical_address((pde.page_frame << 12) + 8 * virtual_address.pt_index, reinterpret_cast<uint64_t>(&pte), 8);
			if (!pte.present)
				continue;

			uint64_t physical_base = translate_linear_address(current_physical, base_address);
			if (!physical_base)
				continue;

			char buffer[sizeof(IMAGE_DOS_HEADER)];
			read_physical_address(physical_base, reinterpret_cast<uint64_t>(buffer), sizeof(IMAGE_DOS_HEADER));

			PIMAGE_DOS_HEADER header = reinterpret_cast<PIMAGE_DOS_HEADER>(buffer);
			if (header->e_magic != IMAGE_DOS_SIGNATURE)
				continue;

			return current_physical;
		}
	}
}

Then, you would simply resolve the physical address and directly read it (somehow).

Recently, however, there has been another update from EAC. Now, the actual CR3 value changes every few seconds. This has created chaos, as brute-forcing this value can take several seconds, making it impractical to refresh it every time a memory operation fails, because the value becomes invalid.

Although I’ve seen some attempts to devise overengineered solutions, there’s a straightforward workaround: simply cache the underlying structures.

As you can see in the screenshot below, and if it wasn’t obvious already, EAC can’t swap the actual paging tables (for various reasons). While they might fuck with them in some ways, they haven’t done so yet. Therefore, the pml4e value and everything below it always remains consistent (for the same virtual address in the same process).

Screenshot of a console

Here is a simple pseudo-code example:

static defines::pte_t cached_pml4e[512];  // 512 is the max, 9 bits in CR3/dirbase
void update() 
{
	uint64_t dirbase = bruteforce_directory_base(target_peprocess);
	if (!dirbase)
		return;

	for (int i = 0; i < 512; i++) 
		read_physical_address(dirbase + 8 * i, reinterpret_cast<uint64_t>(&cached_pml4e[i]), 8);
}

Then, use this value in your virtual-to-physical conversion function:

defines::pte_t pml4e = cached_pml4e[virtual_address.pml4_index]
if (!pml4e.present)
	continue;

defines::pte_t pdpte = { 0 };
read_physical_address((pml4e.page_frame << 12) + 8 * virtual_address.pdpt_index, reinterpret_cast<uint64_t>(&pdpte), 8);
if (!pdpte.present)
	continue;

defines::pte_t pde = { 0 };
read_physical_address((pdpte.page_frame << 12) + 8 * virtual_address.pd_index, reinterpret_cast<uint64_t>(&pde), 8);
if (!pde.present)
	continue;

defines::pte_t pte = { 0 };
read_physical_address((pde.page_frame << 12) + 8 * virtual_address.pt_index, reinterpret_cast<uint64_t>(&pte), 8);
if (!pte.present)
	continue;

/* ... */

Keep in mind that while this method allows you to bypass reliance on the CR3 value, you should still refresh your cache every few seconds. If the process allocates a significant amount of memory (or if memory allocation is requested at an unusual address), some of the entries might become populated. Additionally, some of the memory can be freed. However, it’s certainly more efficient to cache in this manner than to retrieve the CR3 value every time if it’s too time-consuming.