Amlegit Reverse Engineered

Overview

Amlegit is an Apex legends cheat bundled with a HWID spoofer, their user base is a little over three thousand users. The cheat iself offers a 2d box esp, silent aim, and a few other features. As you will see in this extensive writeup this cheat is nothing more than a paste of publicly released exploits and source code. The communication method as shown below is a basic IOCTL hook of a system driver.

Launcher

The launcher I will be talking about is actually their “second stage” launcher. The first stage of the launcher downloads and puts all the QT files/executables inside of a random temp folder and then starts the second launcher. Inside of this temp folder there are three files that stick out, buffer.dll, inject.dll, and mmap.dll. All of these files are VMP’ed but their export tables are still parseable. Opening them each in Ida presented me with at least two exports each (start and another function defined by the programmers of amlegit) Although the export tables are parseable, the functions themselves are not, even viewing the functions data at runtime resembled nothing understandable. In order for me to call these export functions myself I would need to know both the calling convention (most likely fastcall), parameters, and return type. This can be achieved by looking at what calls these exported functions. Lucky for me the second stage launcher had xref’s to all of these functions besides GetDriver (which was referenced inside of inject.dll dump).

Export connect takes zero parameters and returns a bool. This function sets up communication between the user mode process and the kernel driver.

.text:00007FF7D93BA7E1 ; --------------------------------------------------------------------------- .text:00007FF7D93BA7E1 call export_connect .text:00007FF7D93BA7E3 test al, al

Export inject takes two parameters, the first is the name of the dll on disk to inject into the process specified by the second parameter. The second parameter takes the classname of a window. This function is used to inject the dll into the game.

.text:00007FF7D93BABFE ; --------------------------------------------------------------------------- .text:00007FF7D93BABFE lea rdx, aRespawn001 ; "Respawn001" .text:00007FF7D93BAC05 lea rcx, aLapexDll ; "lapex.dll" .text:00007FF7D93BAC0C .text:00007FF7D93BAC0C loc_7FF7D93BAC0C: ; DATA XREF: sub_7FF7D95E2D10-97C6D↓o .text:00007FF7D93BAC0C call export_inject_addr ; ExportInject("lapex.dll", "Respawn001"); .text:00007FF7D93BAC0E test al, al

Export load takes no parameters and returns a bool. This function loads the intel lan driver. (part of kdmapper).

.text:00007FF7D93BAD9E ; --------------------------------------------------------------------------- .text:00007FF7D93BAD9E call export_loader_addr ; ExportLoad() .text:00007FF7D93BADA0 test al, al

Export map takes one parameter which is a char pointer and returns a bool. This function is used to map an unsigned driver into the kernel.

.text:00007FF7D93BAE03 ; --------------------------------------------------------------------------- .text:00007FF7D93BAE03 lea rcx, aDriverSys ; "driver.sys" .text:00007FF7D93BAE0A call export_map_addr ; ExportMap("driver.sys") .text:00007FF7D93BAE0C test al, al

Mmap.dll

As shown above mmap.dll exports two functions (three total including dllmain). Although these functions are exported from mmap.dll you will not be able to parse what they do since they are heavily virtualized and obfuscated. This being said if we are able to parse a function call to anyone of these functions we will most likely be able to make out parameters and return types.

So why are we looking inside of the module if we cant parse what these functions are doing? Well simply put mmap.dll actually loads buffer.dll and calls GetDriver. This will allow us to see the parameters and return type for GetDriver.

.text:00000000000034FD lea rcx, aBufferDll ; "buffer.dll" .text:0000000000003504 call cs:GetModuleHandle .text:000000000000350A xor r12d, r12d .text:000000000000350D test rax, rax .text:0000000000003510 jz loc_390E .text:0000000000003516 lea rdx, aGetdriver ; "GetDriver" .text:000000000000351D mov rcx, rax ; hModule .text:0000000000003520 call cs:GetProcAddress_1 .text:0000000000003526 test rax, rax .text:0000000000003529 jz loc_390E .text:000000000000352F mov [rsp+0A8h+driver_size], 1 .text:0000000000003537 lea rcx, [rsp+0A8h+driver_size] .text:000000000000353C call rax .text:000000000000353E mov r15, rax

Let's break this assembly down. The first thing that we see is a call to GetModuleHandle. You can see that GetModuleHandle takes a char pointer since we move the address of a string in this case “buffer.dll” into rcx (which if you don't already know is the first register used in the fastcall calling convention to pass an integer value). Next we see a test rax, rax. In short this instruction tests to see if rax is zero, it then stores the result in the EFLAGS register. There is a lot more that is going on behind the scenes with the test instruction but i'll leave that up to you and google. This test instruction also gives us very important information about the return value (and size) for the function. In fastcall rax is the register that contains the return value for values that are 64 bits or less in size (byte, word, dword, qword). By reading the assembly we can tell that the entire 64 bits of data is being used (which means it returns a long long). This matches our expectations for GetModuleHandle which takes a long pointer to a c style string and returns a value that matches 64 bits in size. Keep in mind HMODULE can be 32 bit but this module is 64 bit.

            //HMODULE GetModuleHandleA(LPCSTR lpModuleName);
            
            .text:00000000000034FD                 lea     rcx, aBufferDll ; "buffer.dll"
            .text:0000000000003504                 call    cs:GetModuleHandle
            .text:000000000000350A                 ...
            .text:000000000000350D                 test    rax, rax
            
         

After we call GetModuleHandle we are going to make a call to GetProcAddress_1 which is a wrapper function around the real GetProcAddress. First we move the address of “GetDriver” into rbx which is the register used for the second parameter (for integer values). Then we move the result from GetModuleHandle into rcx which as you probably know by now is the register for the first integer value parameter. The result is going to be 64 bits in length, this is inferred by the test and branch operation right. It also lines up with MSDN definitions of GetProcAddress.

//FARPROC GetProcAddress(HMODULE hModule, LPCSTR  lpProcName);

.text:0000000000003516                 lea     rdx, aGetdriver ; "GetDriver"
.text:000000000000351D                 mov     rcx, rax        ; hModule
.text:0000000000003520                 call    cs:GetProcAddress_1
.text:0000000000003526                 test    rax, rax

Now that we have the address of GetDriver inside of rax we are going to call it. If you were to glance over this assembly you would assume we are putting 1 into rcx but we are actually loading the effect address of 1 (lea). So this tells use that GetDriver takes a pointer as its one and only parameter. The return value is also inferred by the subsequent operations with rax.

//std::uintptr_t GetDriver(unsigned* size);

.text:000000000000352F                 mov     [rsp+0A8h+driver_size], 1
.text:0000000000003537                 lea     rcx, [rsp+0A8h+driver_size]
.text:000000000000353C                 call    rax
.text:000000000000353E                 mov     r15, rax

Since we know the parameters and return type of GetDriver we can actually call this function with our own code. Just to be clear the function prototype looks like this uint64_t GetDriver(unsigned*);

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
//--- amlegit dll functions
namespace amlegit
{
    //--- function is only for extracting the driver
    static std::tuple<std::uintptr_t, std::size_t> get_driver()
    {
        auto get_driver_temp =
            reinterpret_cast<__int64(*)(unsigned*)>(
                GetProcAddress(LoadLibrary(L"buffer.dll"), "GetDriver"));
        unsigned driver_size;
        if (get_driver_temp)
            return { get_driver_temp(&driver_size), driver_size };
        return { {}, {} };
    }
}
 1
 auto [driver_ptr, driver_size] = amlegit::get_driver();

Driver

Now that we have the driver lets take a look at how it communicates with the cheats usermode counterpart. This cheat IOCTL hooks by changing the major function pointer of a legitimate windows driver to point to their ioctl function. They also change the unload function to point to theirs so if/when that driver gets unloaded they can remove the IOCTL hook.

INIT:000000014000C268 ; --------------------------------------------------------------------------- INIT:000000014000C268 INIT:000000014000C268 loc_14000C268: ; CODE XREF: ioctl_hook_setup+FF↑j INIT:000000014000C268 lea rax, IRP_MJ_CREATE INIT:000000014000C26F mov [rbx+70h], rax INIT:000000014000C273 lea rax, IRP_MJ_CLOSE INIT:000000014000C27A mov [rbx+80h], rax INIT:000000014000C281 lea rax, IOCTL_HOOK_FUNCTION INIT:000000014000C288 mov [rbx+0E0h], rax INIT:000000014000C28F mov rcx, cs:hooked_driver_object_ptr INIT:000000014000C296 mov cs:pdriver_obj, rbx INIT:000000014000C29D call create_symbolic_link INIT:000000014000C2A2 or eax, eax INIT:000000014000C2A4 jns short loc_14000C2B2 INIT:000000014000C2A6 lea rcx, aFailedToCreate_0 ; "Failed to create symlink\n" INIT:000000014000C2AD call debug_with_prefix

Considering that we know how the driver communicates with the user mode process let's look at the IOCTL hook function itself. The IOCTL supports reading from a process given its PID, write to a process given its PID, allocate memory in a process given its PID, and spoofing hwid’s. The two options that stick out to me are the hwid spoofer and the allocation of memory because keep in mind this cheat is internal so they must hide their memory right? Wrong, as you will see they just call ZwAllocateVirtualMemory after calling ZwOpenProcess. They do not hide their memory at all.

if ( IOCTL_CODE == 0x224986 ) { v18 = *(unsigned int **)(PIRP_1 + 24); PsLookupProcessByProcessId(*v18, &v25); debug_with_prefix((__int64)"Allocating with size %llu...\n", v18[4]); v19 = *(_QWORD *)(PIRP_1 + 8); if ( *(_BYTE *)(v19 + 10) & 5 ) v20 = *(_QWORD **)(v19 + 24); else v20 = MmMapLockedPagesSpecifyCache((PMDL)v19, 0, MmCached, 0i64, 0, (MM_PAGE_PRIORITY)0x40000010); v26 = *v18; v27 = 0i64; v28 = 48; v29 = 0i64; v31 = 0; v30 = 0i64; _mm_storeu_si128((__m128i *)&v32, (__m128i)0i64); v4 = (unsigned int)ZwOpenProcess(&v34, 0x1FFFFFi64, &v28, &v26); debug_with_prefix((__int64)"Got process handle %x with status %x\n", v34, v4); v21 = 0i64; v33 = 0i64; v35 = v18[4]; if ( (int)v4 >= 0 ) { v4 = (unsigned int)ZwAllocateVirtualMemory(v34, &v33, 0i64, &v35, 4096, 64); debug_with_prefix((__int64)"Allocated at %llx with status %x\n", v33, v4); v21 = v33; } *v20 = v21; v20[2] = 0i64; v20[1] = 0i64; }

Before I dive into the spoofer I would just like to make it clear that the developers of amlegit did not discover a new method of spoofing hwid’s. In other words they just pasted together public code and started selling it. If you don't feel like reading about github repositories then you can skip this section and move onto the next.

The spoofer in this driver is public code that can be found here. It is anything but undetected and using it will result in a ban. Let's first look at the IOCTL option to spoof hwid’s. The debug print statements tell us what each subsequent function correlates to. This makes it actually really easy to cross reference the public github repository to ensure what we are looking for is truly someone else's work.

if ( IOCTL_CODE == 0x235C42 ) { debug_with_prefix((__int64)"Initializing...\n"); init_spoof(); debug_with_prefix((__int64)"Disks...\n"); spoof_disk(); debug_with_prefix((__int64)"Volumes...\n"); spoof_volumes(); debug_with_prefix((__int64)"NIC...\n"); spoof_nic(0i64); debug_with_prefix((__int64)"SMBIOS...\n"); spoof_smbios(); debug_with_prefix((__int64)"GPU...\n"); spoof_gpu(); v22 = "Done\n"; }

Let's look at spoof_disk first. This function is 1:1 with public source as you will see. Since the function is quite large I'm going to put it on another page for you to cross reference it with the github repo.

IDA Pseudocode

Spoof Disk Function

Media

Here is a copy of the youtube video demonstrating the launcher just incase it goes private or deleted. As you will see in the video the console that opens contains the same information that is printed when you run btbd's spoofer, blatant paste.... (1:50)

Here is a video of the cheat in action. The account that was demonstrating this cheat is already banned since the cheat is detected. As you can see its nothing special.