March 15, 2021

    How hackers use LLDB to cheat with non-jailbroken devices

    In our recent blog post on how to prevent mobile game tampering, we explained how easily bad actors can use memory-tampering techniques to cheat in mobile games, and how game developers can employ simple security and prevention techniques to stop this. 

    We primarily focused on open source tools available online that can be used to manipulate a game’s memory on a jailbroken iOS device, such as iGameGod. In this post, we’ll explain how hackers can also get similar results using LLDB / Xcode on a non-jailbroken device. 

    Jailbroken Device Hacking vs. Non-Jailbroken

    Tools like iGameGod run with higher privileges on a jailbroken system, so they inject themselves into the running application and can scan the application memory at any time. However, this can also be done on non-jailbroken devices. In this post, we will show: 

    • How to scan device memory and look for certain values
    • How LLDB can easily be used to test and run code in an application’s process on the fly

    The iOS platform provides a number of functions to query and manage the memory regions of a process. For example, enumerating regions can be done with mach_vm_region(..).

    Given a memory address, mach_vm_region retrieves the information of the corresponding region. If the address doesn’t point to any allocated page, it searches for the next consecutive region in the address space and returns its information. The returned information contains the start address of the region, its size, permissions and more. 

    Before we present you with the complete scan and overwrite snippet, let’s start with how mach_vm_region(..) can iterate the virtual memory:

    uint64_t size, addr = 0;
    vm_region_basic_info_data_64_t info;
    
    if(mach_vm_region(task, &addr, &size, &info, ...) == SUCCESS){
        while(1){
            // TODO: Search for a specific value & overwrite it
            *addr += *size
            if(mach_vm_region(&addr, &size, &info, ...) != SUCCESS){
                break;
            }
        }
    }
    

    With some additional tests we can further narrow down the regions to the ones likely to contain our game’s value:

    • Check if the region is writable

    if(region.info.protection == (VM_PROT_WRITE | VM_PROT_READ))
    { /* `region` is writable */ }
    • Check if the region is reserved by the system(not accessible for the user)

    if(!info.reserved)
    { /* `region` is not reserved */ }

    Finally, we search for our value and overwrite if it matches.

    for(uint64_t *addr=(uint64_t*)region_start_addr;
          (uint64_t)tmp_addr < region_start_addr+region_size; 
           addr++) // ++ operator increments a pointer by 8 bytes
    {
       if(*addr == old_value){
            *addr = new_value; // printf("Replacing!");
       }
    }

    A complete code snippet should look like the one below

    // Set arguments
    uint64_t old_value = 1340;
    uint64_t new_value = 9990;
     
    // Prepare configurations for mach_vm_region API
    mach_msg_type_number_t count = 9; // VM_REGION_BASIC_INFO_COUNT_64 = 9;
    const uint64_t permission_RW = 3; // (VM_PROT_WRITE | VM_PROT_READ)
    const uint64_t VM_REGION_BASIC_INFO = 10;
     
    uint64_t size, addr = 0;
    vm_region_basic_info_data_64_t info;
    mach_port_t object_name;
     
    vm_map_t task = (vm_map_t)mach_task_self();
     
    // Fetch the first memory region from the address space
    if(!(int)mach_vm_region(task, &addr, &size, VM_REGION_BASIC_INFO,
                            (vm_region_info_t)&info, &count, &object_name)){
         while(1){
           if(info.protection == permission_RW && !info.reserved){
              for ( uint64_t *tmp_addr=(uint64_t*)addr;
                    (uint64_t)tmp_addr < addr+size; 
                    tmp_addr++) {
                       if(*tmp_addr == old_value){
                         // Overwriting the old value
                         *tmp_addr = new_value; 
                       }
               }
           }
           addr += size;
     
           // Search for the next region
           if((int)mach_vm_region(task, &addr, &size, VM_REGION_BASIC_INFO,
                             (vm_region_info_t)&info, &count, &object_name)){
                    break;
             }
         }
    }
    

    Using LLDB exactly the same way as before, we can use its expression command to compile, inject and execute our code snippet in the app context. Once the script is finished, simply resume the process in LLDB and restart the app:

    (lldb) expression -l c --

    In this example, we paused the application and used a simple for-loop to search through its memory. However, if you want to extend this example further, you should consider using mach_vm_read_overwrite instead of accessing the memory directly.

    An application’s memory is in constant motion as new variables are continuously allocated and freed from the heap. Therefore, a direct memory access may cause a segmentation fault. mach_vm_read_overwrite returns an error if a certain memory can’t be accessed. Therefore it is safer to use and will prevent you from corrupting the memory of the app.

    < Return to Blog: Cheating is Easy: How to Prevent Mobile Game Memory Tampering

    Build_the_Best_Mobile_Gaming_Experience

    Discover how Guardsquare provides industry-leading protection for mobile apps.

    Request Pricing

    Other posts you might be interested in