MacOS AssaultCube - reversing from x86_64 to AArch64 with Rosetta
2025–10–11
Introduction
During my study outside of formal tuition, I’d been looking into reverse engineering and malware analysis. There’s absolutely great textbooks on the subject, including a whole slew of CTFs that focus on reversing and PWN articles to help hone binary exploitation. I’ve struggled with these, and I’m not sure if it’s been either attention span, or the binary hasn’t been big enough for me.
Using a larger binary, I’ve felt I can compile a small list of questions to understand how that specific program works, to then understand a little bit on how all programs work. Great targets can come in the form of browsers, firmware, or frameworks like Cython or Bun.
I’ve also gotten back into games after a multi-year drought of not playing anything - so I thought I’d look into AssaultCube, an open-soruce game that has been extensively written on in the context of game hacking.
Game hacking is usually on Windows, but I wanted to provide a small update from MacOS pages I’ve seen online now M-series ARM is now the go for Mac devices from the last time this was written on.
Objectives of making a game hack
You want to do these overall steps when making a hack for a game:
- Alter or read a value in memory,
- This requires understanding memory offsets and ASLR (binaries being loaded randomly by the kernel)
- You will need to understand pointers, and how sections of memory can reference(point) and dereference(access) sections of memory.
- Read the control flow of the binary,
- This can be done internally via recompilation of the game, or by adding in code ahead of the binary executing, like a linked library (LD_PRELOAD) operation, or library inection, or…
- This can also be done externally, where another program reads these states and deduces memory locations in order to manipulate values and perform other actions external to the game.
This requires finding the memory relative to the location loaded in memory, called an offset. If we understand types and macros for types in languages, we can make assumes of what values would be when examining.
AssaultCube and 86_64 runnning in AArch64 (Rosetta)
Another example on MacOS hacking for Assaultcube used Frida, an easy to use abstraction of accessing kernel actions for reversing. This should work, but the main issue is that we aren’t actually running a game that is our architecture.
AssaultCube is running via Rosetta, where the TEXT section of the binary (where the code, put into ASM) was.
It’s a lot simpler than it seems in that it translates all x86 instructions to ARM when loading the binary instead of constant JIT compilation.
This is actually pretty slick around debugging – when there is an indirect reference for RET or JMP, we look up the instruction again. This will mean we’ll not really ‘see’ DATA sections as it’s likely performed by the lookup in Rosetta, but the lookup will go to the untranslated instruciton.
We’re going to, for most cases, get same-for-same whenever we look at a emulation of a x86 instruction before we’d do anything fancy with just-in-time compilation.
I had a look at possible flags to even get debugging going where it can present what assumed x86 instructions are being executed, but I didn’t do this due to lack of further understanding here.
; Example x86 to ARM from Rosetta 2 (stack, EAX, CALL)
; x86
push rbp
mov rbp, rsp
xor eax, eax
MOV EDI, 0x1
MOV ESI, 0x2
CALL next_function
; ARM (Rosetta)
STR X5, [X4, #-8]
MOV X5, X4
MOV W0, #0
TST W7, W7
MOV W6, #0x1
MOV W2, #0x2
BL next_function
One issue is indirect calling - if we are translating code to ARM, how are we going to handle calls to labels that have the return address in a register instead of the stack?
When looking at the health STR and LDR instructions in this page, it seems BL is used to the label which keeps it in another stack to maintain - I didn’t have any luck look at this further beyond seeing values that didn’t relate to my x86 image space going to the x22 register…but I do know where you would need to go without SIP to see the files!
NOTE: There’s a lot I am still understanding with how Rosetta is handling returns and appending ARM to sections of the existing binary - this makes finding DATA sections for globals a pain at times, since called subroutines are stored in a shadow stack and being referred there, or indirectly called even when it should be a base() + offset kind of deal.
BitSlicer and finding memory locations
Similar to Cheat Engine for Windows hosts, I used BitSlicer, which is using Mach Kernel APIs to chat to Rosetta in a friendly manner! With this, we can scan for values in memory and debug from there.
Using a variety of tutorials, we can go look from ammo values to health values. We’ll do health values for now to reduce and increase our health, and search for this exact value first.
We’ll quickly go down to a couple of addresses holding simple integers, and can simply change either value in BitSlicer to understand what is the value being put to UI and what is the actual value.
Finding instructions for Health
From the health value that is not the GUI value, we can check for read/write access and again take damage to understand what instructions occur when health decreases.

We get two instructions here! For me, investigating STR was more interesting since a store with an offset because of indirect addressing with a single instruciton - we’re getting a large space away to do one instruction - this could be the important needle!
; block of STR
str w3, [x15, #0x418]
Accessed 1 time
General Purpose Registers
x0 = 0x0 (0)
...
x15 = 0x7F9F962F3E00 (140323396206080)
; My health is kept from x15 +0x418!
; 0x7F9F962F4218 was my health location!
...
x22 = 0x10108B000 (4312313856) ; likely TEXT segments
...
x28 = 0x7FF809126A36 (140703280818742)
fp = 0x201223F20 (8608956192)
lr = 0x101280ED4 (4314369748)
sp = 0x10940DA70 (4450212464)
pc = 0x101280124 (4314366244) <- The address of instruction
cpsr = 0x88001000 (2281705472)
Float/Vector Registers
v0..v31
fpsr = 9F 00 00 08
fpcr = 06 00 00 00
r-x
NOTE: Remember I said that everything is being handled by Rosetta?
We are just debugging what the actual registers are and the provided ahead-of-time and JIT instructions clobbered together to make everything run.
We can do the actual same here without a GUI using watchpoints in lldb to watch our health value.
Pointers, pointers, pointers
From here, we can (or should, since the game runs pretty well) assume those two health location were actually stored in a pointer. We should look at what pointers have the value of this x15 register.
Huzzah! And from there, we can use the search pointers to variable function in Bit Slicer to get our actual value.
If we get a value address that is not much smaller than our other values as of now, we can safely assume we’ve hit the DATA section (with Rosetta) and our global variable for health!
With Rosetta translation, I still wasn’t able to get the immediate base_address and offset from here due to how we are trying to emulate return addressing from x86 - a lot of storing addresses for returning to help Rosetta be more efficent than using indirect branching from the SP means we are going to not have 1-to-1 RET to help Bit Slicer. That’s okay though, we learnt a hint about Rosetta and AssaultCube!
We’ll most likely get a pointer that again points to the absolute location. We can use this pointer value read at this address and go from here! For me, it is 0x10110DEF0.
Even without just the base() + offset, we know basic addition and subtraction, so let’s just get that base address ourselves when we write the cheat!
Getting the base address
Easy ways of getting this is just via vmmap <pid>/$(pgrep -x <name>) | grep -i <name>. We can also use LLDB here!
# lldb example
lldb (or lldb -n assaultcube)
(lldb) image list
Writing an internal cheat, it’s important to get the image list of a process in Mach - we will want to write our cheat by accessing either Mach tasks to talk to the kernel, or by using a library and understanding spaces of memory allocated to AssaultCube - we will need to know where the binary is located against other library files or Frameworks (such as the SDL2 engine running the game).
Here’s a sample of LLDB showing us the goods:
lldb -n assaultcube
(lldb_and_paws) process attach --name "assaultcube"
Process 74721 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
frame #0: 0x000000011b77b470 AppleMetalOpenGLRenderer`glrVertexFormatComponentAttribSizeTypeAlignment(MTLVertexFormat, unsigned char*, unsigned char*)
Target 0: (assaultcube) stopped.
Executable binary set to "/Applications/AssaultCube.app/Contents/gamedata/assaultcube.app/Contents/MacOS/assaultcube".
Architecture set to: x86_64-apple-macosx-. <- yerp, we are in Rosetta land
(lldb_and_paws)image list
[ 0] 5CD68DC6-860C-35FB-865A-10423F8323A0 0x0000000100f34000 /Applications/AssaultCube.app/Contents/gamedata/assaultcube.app/Contents/MacOS/assaultcube <-- Our image for AssaultCube (base address is 0x100f34000, image is 0)
I also wanted to write some examples of how we can request tasks in case we wanted to perform an external cheat.
Doing that, I went through the not-too-awesome documentation caves and created my own little tool in Rust that will dump all threads against a process and show us the base address region. This will be uploaded to my Codeberg soon!
I think there’s some work to be done in rewriting this to accept NotNull<<T>> to have Rust do handling for us, thank you to my friend Hailey for showing me!
After doing this, I realised that LLDB automations are much easier for this - but it’s nice to know we can get the task and maybe challenge the task with external memory writing!
Writing our cheat for health
Alright - we can now do a basic check of our offsets - (0x10110DEF0 - 0x100f34000) and get our final offset for health at 0x1D9EF0, with 0x418 being the offset in the STR instruction.
First of this, this gave me a suspicion that we’re accessing some sort of large struct or object that contains all of a players information, but we’ll just do health today.
With these offsets, we’ll just need to emulate what the process is doing:
- Provide a way for a user to activate the cheat (easy input stuff)
- Use DYLD functions to get the address space of just the binary, and get the header of it. The base address!
- Provide a list of our offsets (global_health from
base+0x1D9EF0, pointer at +0x0, then health at previous pointers value plus +0x418). We can walk it since anything that doesn’t follow this path isn’t our intended code, or hasn’t been allocated yet. - Edit that final health location in that final offset!
Here is a sample of internally accessing this value:
// We can just run through the list - if we technically did skip to 0x0,0x418 prematurely,
// something went super wrong anyway.
unsafe fn patch_h(offsets: Vec<usize>, base_address: usize) {
let mut addr = base_address;
for i in 0..offsets.len() - 1 {
// we can 'walk' the pointers here to come across the player struct.
unsafe {
addr = *(addr as *const usize).wrapping_add(offsets[i] / std::mem::size_of::<usize>());
}
if addr == 0 {
println!("Null pointer while walking offset chain");
return;
}
}
let final_offset = offsets[offsets.len() - 1];
let final_addr = (addr + final_offset) as *mut u64;
println!("Final patch address: {:#x?}", final_addr);
unsafe {
println!("Health is at: {:#x?}", *final_addr);
*final_addr = 9999;
println!("And set to 9999...");
}
}
Getting the base address is provided by _dyld_image_count and _dyld_get_image_header. I assumed a parameter of 0 since every load had the binary at 0. We also confirmed this via LLDB!
Since this is an old binary, we can actually use DYLD_INSERT_LIBRARIES, which is for all purposes similar to LD_PRELOAD, where we’re forcing a library to load with assaultcube. This is the internal cheat. If we wanted to write this externally, we’d use task_for_pid() and Mach tasks to modify the process.
Further investigation and decompilation learnings!
This was a great little project to write tooling against, but the objective is to understand how the program ran more than writing several cheats. We can assume ammo is the same integer type, and positional information of the player is stored in a collection of number values, like a vector (more likely since it’ll want to be in the heap).
Since I had a spideysense with the large offset of 0x418, a simple search of memory space around it provides some interesting results:
We can confirm player name, position and ammo including status effects is all in this region of memory.
Notably, there seems to be in a bunch of dead space - not too sure why it’s so large as of now!
Using BinaryNinja or another other decompilation software, we can look at the memory space for our DATA version of the health variable to get cross references on operations that could occur. This is a better way to look at instructions in x86 before Rosetta comes to turn it into ARM on first load, since we’ll be unable to assume instruction-per-instruction as easily without decompilation without Rosetta - I haven’t looked into how to decompile with Rosetta in mind just yet.

Looking at the cross references of our known health variable in data, we get similar commands that would be the Rosetta origin instruction in x86 - which comes to a similar address as to what we saw in our debugger in Bit Slicer as is.d
It’s a pretty huge function that manipulates many parameters! This is a great place for further investigation, since the assumption would be that this is the player struct being edited.
Looking at symbols, the game engine (Cube) is using OpenGL - an interesting easy wallhack could be utilising enums in OpenGL to render without consideration to obscurity by other objects to draw all players on a map continuously.
Conclusion
This was a great bit of fun looking at AssaultCube as a classic for getting into reversing and game hacking - and makes for a friendly place to do it without doing anything too awful as the anticheat is actually handled via the network protocol, meaning none of this works online.
My learnings did go a bit out of the actual development of further exploits, but this was mostly around how I would tackle a large binary, and I wanted to understand what tools I would need to create now that Frida cannot talk to the Rosetta translation, which was the previous writeup on MacOS.
Rosetta stuff
The interesting areas of AOT in Rosetta:
- /libexec/oah – oahd – oah-helper – debugserver
- /var/db/oah – might contain Rosettas oah executables
Seeing the ahead of time translation will cache the binary of any x86_64 file process by Rosetta on first run here, it’s safe to assume direct static analysis could be done here whilst dynamic is available on the debugger without changing SIP.
When looking at Rosetta, there wasn’t too much I looked into beyond this apart from seeing the size of the Rosetta runtime as AssaultCube played.
Looking at Koh’s post on Rosetta from 2021, this runtime initialises the emulation and maps the file that contains the translated instructions onto memory, and gaps are filled via JIT translation.
This makes reverse engineering of the actual binary as it would run in a M-series machine interesting - if looking at the post-Rosetta translation, statements handed to JIT (like switch statements) will have missing addresses that aren’t handled will be indirectly addressed. This is probably a nice avenue for tooling… but not related to the game hack at all…
However, it’s a great lesson in looking to keep your eyes open on different avenues on how to learn!
Other avenues in AssaultCube
I also found some basic GL symbols around glDrawElements, where we could simply set the enum to be GL_TRIANGLE_STRIP or GL_LINES on all calls to render every player as a wireframe.
Further view could be looking at access regions against the player_struct_edit function in the decompilation, and looking for a list of entities that are kept to be iterated against, allowing us to perform a wallhack/ESP cheat.
Other outlets for learning reversing away from CTFs
If you’re interested in chewing on bigger binaries, I reccomend using AssaultCube or any game that is providing permission for learning.
Beyond that, here are some idle resources I have enjoyed so far: