← writing

how I made $200 million in a single day

If you're here, you probably fell for the age old technique of clickbait.

However unlike other places where you fell for this, I'm actually going to explain how I (and soon the reader as well) could virtually make how much ever 'money' I wanted.

Backstory

I was recently reading how memory works in operating systems and something stumped me:

...allowing multiple programs to reside concurrently in memory makes protection an important issue; you don't want a process to be able to read, or worse, write some other process's memory.

This didn't make any sense to me. This was exactly the method I used to fake my bank balance in GTA Online and buy all those cars and the fancy penthouse tower.

How was it done

Turns out one can read and write another processes memory while its running but with a catch; they must have privileged access.

It finally made sense.

And this lined up with the what I used to gather all that wealth: Cheat Engine

How it works is pretty intuitive once you realize how its used.

The game (remote process) is ran along side the engine (local process) but with privileged access (ie as administator on windows or sudo on linux).

This lets the engine access the remote processes memory through a low level API or syscall and read/write to it while its running. The syscall bypasses the operating systems internal kernel buffer and moves the data directly from the remote to the local(or vice versa for writing).

Building A Toy Engine

Now that we figured the secret(not so much) tricks of the engine, we can finally build one of our own.

To implement this on a smaller scale, well have two parts to this:

  1. Remote Process: The process we want to write new memory to
  2. Local Process: The engine we will use to read and write to the remote process.

Remote Process

This is the easy part. Lets define a simple C program with a counter taht increments every so often

int main(){
    pid_t process = getpid(); // we'll use this to get the process ID
    int real_money = 0;
 
    printf("%d\n",process); // we'll use this to verify the engines working
    printf("Counter Address: %p\n",(void *)&real_money);
 
    while(1){
        sleep(5);
        real_money++;
        printf("bank balance: %d\n",real_money);
    }
    return 0;
}

The engines goal here would be to hijack the memory address of the real_money variable and put in whatever value we like.

And that leads us to the engine itself.

The Engine

Now we can start building the engine. There are multiple parts to building the engine out fully but we'll focus on the primary part: reading and writing over the remote's memory.

The other cogs of the engine includes:

  1. locating the stack/heap/data of the remote process in the memory
  2. making the engine user driven rather than static code

and a couple of smaller bits which wont be our primary focus.

Another point to mention is that each OS exposes a different API/syscall for the same things. In our case, In linux based systems, we'd use process_vm_readv & process_vm_writev to read from and write to another running process whereas in Windows its exposed as ReadProcessMemory & WriteProcessMemory.

The engine we'll be building here will use rust as the primary language inside the arch linux operating system.

The process_vm_readv (and the similar writing analog) takes in 4 arguments:

  • local_iov -> pointer to an array of iovec structs that describe memory buffers and its location
  • remote_iov -> similar to local_iov but for the remote process
  • liovcnt -> number of element in local_iov array
  • riovcnt -> number of element in remote_iov array
  • flags

The first step in our journey is to decide how well walk through the memory and find our target variables memory address.

We are going to assume we already know the PID as well as the memory addresses (stack/heap/data) of the remote process. These can be found out manually pretty easily(In arch: /proc/[PID]/maps for the memory addresses).

Step 1: Reading The Memory

To efficiently read the memory, well be reading the memory as chunks of 4KB.

Well define it as follow:

    let buffer_size = 4096usize;
    let mut buffer: Box<[MaybeUninit<_>]> = Box::<[u8]>::new_uninit_slice(buffer_size);
    let mut addresses = Vec::new();
    let mut total_read = 0;
    while total_read < total_size {
        let remaining = total_size - total_read;
        let current_reading = if remaining < buffer_size {
            remaining
        } else {
            buffer_size
        };

And now we define the iovecs needed and finally the syscall(with a little formating in the end):

    let local = iovec {
            iov_base: (buffer.as_mut_ptr()).cast(),
            iov_len: current_reading,
        };
    let remote = iovec {
        iov_base: unsafe { (start_address.add(total_read)).cast() },
        iov_len: current_reading,
    };
 
    let read_bytes = unsafe { process_vm_readv(pid, &local, 1, &remote, 1, 0) };
    if read_bytes == -1 {
        eprintln!("{}", std::io::Error::last_os_error());
        break;
    }
    // println!("The syscall bytes = {}", read_bytes);
    total_read += read_bytes as usize;
    if read_bytes < current_reading as isize {
        println!("It didnt read as much as it was supposed to broski");
    };
    let initialized = unsafe {
        std::slice::from_raw_parts(buffer.as_ptr() as *const u8, read_bytes as usize)
    };

given the addresses of the stack, initialized will return us a humongus slice with a bunch of 0s at the start and a few trailing numbers at the end just as we expected (with a stack).

Step 2: Finding the target memory address

now using intialized to read the values and match it against out target, we get:

for i in 0..=(initialized.len() - 4) {
    let value = i32::from_ne_bytes([
        initialized[i],
        initialized[i + 1],
        initialized[i + 2],
        initialized[i + 3],
    ]);
    if value == target {
        let address = start_address as usize + total_read - read_bytes as usize + i;
        // println!("Found the value at: 0x{:x}", address);
        addresses.push(address);
    }
}

and then we finally return the addresses. The entirety could be wrapped into a single function to give us:

fn memory_dump(total_size: usize, pid: pid_t, start_address: *mut u8, target: i32) -> Vec<usize> {
    let buffer_size = 4096usize;
    let mut buffer: Box<[MaybeUninit<_>]> = Box::<[u8]>::new_uninit_slice(buffer_size);
    let mut addresses = Vec::new();
    let mut total_read = 0;
    while total_read < total_size {
        let remaining = total_size - total_read;
        let current_reading = if remaining < buffer_size {
            remaining
        } else {
            buffer_size
        };
 
        let local = iovec {
            iov_base: (buffer.as_mut_ptr()).cast(),
            iov_len: current_reading,
        };
        let remote = iovec {
            iov_base: unsafe { (start_address.add(total_read)).cast() },
            iov_len: current_reading,
        };
 
        let read_bytes = unsafe { process_vm_readv(pid, &local, 1, &remote, 1, 0) };
        if read_bytes == -1 {
            eprintln!("{}", std::io::Error::last_os_error());
            break;
        }
        total_read += read_bytes as usize;
        if read_bytes < current_reading as isize {
            println!("It didnt read as much as it was supposed to broski");
        }
        let initialized = unsafe {
            std::slice::from_raw_parts(buffer.as_ptr() as *const u8, read_bytes as usize)
        };
 
        for i in 0..=(initialized.len() - 4) {
            let value = i32::from_ne_bytes([
                initialized[i],
                initialized[i + 1],
                initialized[i + 2],
                initialized[i + 3],
            ]);
            if value == target {
                let address = start_address as usize + total_read - read_bytes as usize + i;
                addresses.push(address);
            }
        }
    }
    addresses
}

pretty nice so far.

But we face a roadblock. Using just a single value we cant say for sure its the right memory address. We could get a bunch of address that points to god knows where that had the same value as our target for that very moment.

Hence we run this multiple times(reffered to as scanning), where we input the new value of target, rerun the function to find the memory address and find the common addresses between the two runs. doing this multiple times will narrow it down and help us get the right address.

Lets bend the rules a little bit to make our lives easier. Lets assume we know how the remote process works.

We use this to our advantage and model our engine to find the right addresses. The alternative to this is not so hard to implement, we just make the engine user driven(input is taken for as long as the engine runs)where the user would provide the new target values to scan for and thus find the right address.

And thus we find the right address to our target variable:

//program specific
let mem_start: *mut u8 = 0x7ffee7c7b000 as *mut u8;
let mem_end: *mut u8 = 0x7ffee7c9c000 as *mut u8;
let size = mem_end as usize - mem_start as usize;
let pid = 37233;
let mut target = 23;
 
let mut output = vec![0usize; 32];
let mut epochs = 10;
let mut n = 0;
while (epochs != 0 && output.len() != 1) {
    let mut run = memory_dump(size, pid, mem_start as *mut u8, target + n);
    println!("{run:?}");
    unsafe { sleep(5) };
    let mut run2 = memory_dump(size, pid, mem_start as *mut u8, target + n + 1);
    println!("{run2:?}");
    let set: HashSet<_> = run.into_iter().collect();
    run2.retain(|add| set.contains(add));
    n += 1;
    epochs -= 1;
    output = run2;
}
for i in output {
    println!("Address: {:#X}", i);
}

NOTE: Often times, there maybe more than one memory address in the output even after running for many epochs. This can be a result of many possibilties:

  • Multiple views of the same memory
  • Crazy Coincidence (They're real)

along with other possibilties

Step 3: Making money

With everything we've done so far, this should be a piece of minecraft cake.

It would look something like this:

fn make_money(addresses: Vec<usize>, mut money_wanted: i32, pid: pid_t) {
    let mut local = Vec::new();
    let mut remote = Vec::new();
    for mut i in addresses {
        local.push(iovec {
            iov_base: (&mut money_wanted as *mut i32).cast(),
            iov_len: std::mem::size_of::<i32>(),
        });
        remote.push(iovec {
            iov_base: i as *mut _,
            iov_len: std::mem::size_of::<i32>(),
        });
    }
    let bytes = unsafe {
        process_vm_writev(
            pid,
            local.as_ptr(),
            local.len() as _,
            remote.as_ptr(),
            remote.len() as _,
            0,
        )
    };
    if bytes == -1 {
        panic!("Faking money failed");
    }
}

Step 4: Putting it all together

Putting it all together, well finally get the following outcomes:

  • We're rich (in main.c)
  • We figured out how such an engine works
  • We're gonna be very rich(in main.c)

The logs of the remote process shows us this indeed works:

bank balance: 398
bank balance: 399
bank balance: 400
bank balance: 401 // hit the process with `make_money()`
bank balance: 100001
bank balance: 100002
bank balance: 100003
bank balance: 100004
bank balance: 100005

The Missing Pieces

The engine isnt complete and no where near perfect. There are alot of corner that have been cut and could be polished out before we can confidently say we cracked the system.

The engine isnt fool proof either. Modern systems use kernel level anti cheat software and can easily block such attempts.

Learnt this first hand trying to once again make a couple more millions a few year later and ended up with a lifetime ban from rockstar :D.


repo

References

← writing