Guide to Reversing and Exploiting iOS binaries Part 2: ARM64 ROP Chains


Welcome back my masochistic kings and queens... This is PART 2 of how to reverse engineer and exploit iOS binaries. 

Check out part 1 of How to Reverse Engineer and Patch iOS Applications here.

By the end of this blog post you will be able to reverse engineer an arm64 iOS binary and exploit it in two ways – first through a buffer overflow, and then through a ROP chain. Once again, I will walk you step-by-step through the following:

  • Building and compiling your own iOS binary 
  • Reverse engineering the binary 
  • Calculating the runtime function addresses without disabling ASLR 
  • Buffer overflow attack
  • ROP chain exploitation 

We will only be using FREE tools because I don’t like to spend money on nerd things. Therefore, for this blog post/tutorial, I have compiled and built you an iOS binary that you can use and abuse. I have also included the source code on GitHub for all my evil cheaters out there!!! Don’t think for a second that I don’t know you exist :P.

Download the exercise binary “dontpopme” from Github here. 

This blog post builds on Part 1’s knowledge of how to perform basic reversing on iOS apps. The difference is this blog post will focus on exploiting iOS arm64 binaries and we will take what we learn from reversing the binary to perform two attacks. As such, I will be introducing you to buffer overflows and ROP chain attacks.

This blog post is broken up into five sections, if you are familiar with my blog posts then this shouldn’t be a surprise. Anyway, these are the sections:

  • High level walk through of the steps we will take  
  • Tools 
  • Environment set up
  • Introduction to the binary we are exploiting 
  • Arm64 buffer overflow
  • Arm64 ROP Chain
  • A selfie of me uwu 

Below are the high-level step-through of what this blog post will cover. We will go through each little step below in detail. 

  1. Upload the iOS binary onto your jailbroken iOS via SFTP. If you don’t know how to jailbreak your phone, please refer to unC0ver guide.
  2. Run the binary to test it works
  3. Find the buffer overflow vulnerability
  4. Check the iOS crash logs to get the position of the link register (LR) so we can craft the payload
  5. Debug the binary to find interesting functions that we will jump to in the payload
  6. Calculate the function addresses at runtime (ASLR enabled on iOS)
  7. Execute the buffer overflow attack
  8. Find the next function we want to use for ROP chain 
  9. Execute a ROP chain

These are the tools we will use to perform the analysis:

In order for you to follow this walkthrough step-by-step, make sure you have your jailbroken phone ready with SSH / SFTP set up and connected on the same LAN. 

Step 1: Download the vulnerable binary from my GitHub page
Please only download the dontpopme binary and NOT the source code. We are going to perform this entire exercise as a black box attack.

Step 2: SFTP the binary onto your jailbroken iOS device to the /var/mobile directory

Step 3: Modify the permissions on the binary
Use chmod 777 dontpopme :)

Step 4: Run the binary and check it works :)

Step 5: Time to show the binary who's boss ^_^

This binary is called “dontpopme”, it’s a Mach-O 64-bit executable and it will serve us for the purpose of the buffer overflow and ROP exploitation. 

The buffer overflow vulnerability exists in the file-reading function “fread()” C function within the binary. This function basically reads from a file and is memory unsafe! The binary expects you to put a file named “resume.txt” in the same directory as the binary. It will then attempt to read the contents. What you put inside those contents can lead to a buffer overflow ;) 

Hidden inside the binary are two further secret functions that are NEVER called within main. The goal of the buffer overflow is to cause the program to jump to one of these functions. The goal of the ROP Chain is to buffer overflow > jump to function 1 > jump to function 2.

The way I compiled this C code to make it run on iOS is through using a combination of Theos and clang. Here is how I compiled and signed the C code:

Let’s reverse engineer the program and exploit the buffer overflow. 

Step 1: Understanding a buffer overflow attack
This is the simplest way I can think of to explain this attack without over-complicating it unnecessarily. I do at this step assume you have knowledge of what a stack is and how a stack grows. If you are confused about how this works, there are several blogs and videos on YouTube about it :)

In short, for a buffer overflow to occur, there is a piece of vulnerable code / function that is used in a program. For the sake of an easy example, the code can look something like this:

char userInput[5]; //Create a char array of size 5
gets(userInput);  //Read user input into the char array

The example code above defines a character array with a size of 5 bytes. However, the gets() function in C which reads in user-input does not check that the user is only entering 5 bytes. As such, the user can enter more than 5 bytes and overflow the buffer of 5, resulting in overwriting regions on the stack. 

The result is something like what’s depicted in the diagram I made below: 
The user passes in a series of input into the buffer (in this instance a bunch of “A”s) followed by the hexadecimal address of a region in memory. If you overflow the buffer enough, you can overflow to the point where you are overwriting IMPORTANT regions of the stack including the return address. By calculating exactly where the return address is on the stack through fuzzing your buffer input, you can place a new memory address at that point of the buffer, triggering your program to jump to this location. Evil times bring evil vibes. This is the high-level premise of how a buffer overflow works! 

Step 2: Fuzz the program to find the buffer overflow
Now that you understand what’s going on. We are going to trigger a buffer overflow on the program. As mentioned in the binary introduction section, the binary is expecting to read a file in the same directory named “resume.txt”. 

Let’s create a file on the iPhone named “resume.txt” and pipe in a series of values to fuzz and figure out the buffer size. Because this is a 64-bit system, we will be passing in 8 bytes per letter.

Step 3: Trigger the buffer overflow
Let’s run the program and allow it to read “resume.txt”. As expected, we have overflowed the buffer and we are met with an error of “bus error: 10”. 

This basically means that the program is trying to return or to access an area of memory that doesn’t exist. This is because we have managed to overwrite the return pointer with our random letters which does not point to a real address :)

Step 4: Examine the crash logs to determine the buffer size
Every time you crash a program in iOS, a corresponding crash dump is created. You can access these on your iPhone in the /private/var/mobile/Library/Logs/CrashReporter/<appname-date>* directory.

As you can see here, I have generated a crash dump for my “dontpopme” binary. 

If you scroll down in this crash dump, you will see all the various registers and what information is contained within them. The one register you need to take note of is “LR” or 0x30 register. This is called the link register which holds the return address ;). 

The value inside the link register is 0x4646464646454545 which is hexadecimal for FFFFFEEE. The reason it is backwards is because the system is little endian. This means at exactly the position of FFFFFEEE is where we need to place the address of a function we want to jump to.

How do you know the system is little endian or big endian? And how do you know what other controls are enabled? Let me show you a command you can run on your iOS device :) As depicted in the screenshot below, you can see "endian" is "little".

Step 5: Find an interesting function to jump to
To find these functions, let’s use radare2. This tool is free and you can install it onto your jailbroken iOS device. To run it you just type “r2 <binaryname>”.  Then we want to run:
  • aaa – to analyse the binary
  • afl – lists all the functions that exist in the binary 

As you can see from the screenshot above, there are two likely user-defined functions “change” and “runCode” and the rest are all C-native functions. Reading through these functions you can see “scanf” and “fread” which are all vulnerable to buffer overflow attacks. You can also see a function called “execl”. If you read the man page for this, you will see it functions similarly to the function “system()” which runs system commands. 

Let’s dig a little bit deeper into the runCode() function. You can do this by typing the commands:
  • s sym._runCode
  • pdf
Through looking at the asm below, you can see that this function triggers the execl() function. 

Looking at the function for change(), you can see that this function changes some variables and calls printf, but there is no execl() function. 

Step 6: Disassemble the functions to better understand what’s happening 
To do this, we are going to use the demo version of Hopper disassembler. Load up the binary and click on the function “runCode”. You will see it jump to the assembly output for the function.

From the screenshot above, you should be able to deduce that this function is running the execl() function and passing into it the arguments of “/bin/ls” and “-la”. This means if we jump to this function, we should expect it to run this command. 

Let’s take a closer look at the change() function using Hopper. In this particular function we can deduce that it’s changing variables to “/bin/uname” and “-a”. 

Step 7: Note down the address you want to jump to
We have examined the two functions in the program. To summarise we have worked out the following pieces of information:
  • runCode() – executes the command “/bin/ls -la”
  • change() – does not execute code but changes global variables to “/bin/uname -a”
For the sake of this buffer overflow, let’s jump to the runCode() function. Let’s note down the entry address of runCode() from our static disassembling: 0x100007c88.

Step 8: Get function addresses to calculate the slid
If you compare the main address from radare2 and Hopper, you will notice that the addresses are different to the leaked main address in the program (shown below). This is because ASLR is enabled on iOS, therefore the actual function addresses will be in different locations. We already know the main address as it's leaked by the program, we just need to calculate the REAL address location of the functions at runtime. This is very easy to do! Let me show you.

First get the leaked address of main from the program. Don't exit the program because the address will change each time. Your address will probably be different to mine :)

To calculate the slid (the memory difference from static address to the runtime address) we need to also get the static address of main(). We can do this through using LLDB and disassembling the main function:

As you can see above the static address for main is: 0x100007cc8. Write both these addresses down!

Step 9: Calculate the slide
The math is as simple as this: 

To calculate the slide, perform this calculation:
Leaked Main Address – Debugger Main Address = Difference

To calculate the real address position of the runCode() function at runtime do this:
Debugger runCode() Address + Difference = Real Address of RunCode() at runtime. 

Right now we have the following components:
Leaked main address: 0x1047c7cc8
Debugger main: 0x100007cc8
Difference: 0x1047c7cc8 - 0x100007cc8 = 0x47C0000

Let’s calculate the actual address of runCode():
Debugger runCode() address: 0x100007c88
Real runCode() address: 0x47C0000 + 0x100007c88 = 0x1047C7C88

Voila! The actual address of runCode() in this running program is 0x1047C7C88.

Step 10: Prepare the buffer payload
Now we are ready to perform the attack. We know that the LR register gets overwritten at the “FFFFFEEE” from our original payload. Therefore, our payload needs to look like this:

To make this work, let’s convert the text to their hexadecimal equivalent:
A = x41
B = x42
C = x43
D = x44
E = x45
F = x46

Since the processor is in little endian, let’s also convert the address of runCode() (0x1047C7C88) to match this. This makes it “\x88\x7c\x7c\x04\01”. Since this is 64-bit, all addresses have 8 bytes so let’s pad this address out to “\x88\x7c\x7c\x04\01\x00\x00\x00” which is the equivalent of 0x0000001047C7C88. 

Therefore, our payload needs to look like this:
echo -ne "\x41\x41\x41\x41\x41\x41\x41\x41\x42\x42\x42\x42\x42\x42\x42\x42\x43\x43\x43\x43\x43\x43\x43\x43\x44\x44\x44\x44\x44\x44\x44\x44\x45\x45\x45\x45\x45\x88\x7c\x7c\x04\x01\x00\x00\x00" > resume.txt

Step 11: Check the attack is successful
Now, let’s run our dontpopme binary and check that we executed runCode() successfully, resulting in the “ls -la” command being run. As you can see we succeeded!!! 1337haxor alert!

But if you read the program output, it says “if you figure out my name, you are hired”. It does not appear we were able to figure out the name just through a simple buffer overflow :( This means we need to do a ROP chain!! 

Are you ready for the real fun? We are going to learn how to do a ROP chain!!!!! YAY!

Step 1: Understanding how we will do the ROP Chain
If you are not familiar with the idea of a ROP chain, please check out this write-up here by CTF101. I will also explain it in the most simple way I can.

Let's refer to the basic diagram below, we are basically going to extend the initial buffer overflow exploit, by jumping into two functions, one after another in a chain. You can have multiple jumps, but for this example we are only going to do two. Typically, in more complex ROP chains, you will use something called a “gadget” which is a sequence of instructions that are present somewhere inside the program. These gadgets contain instructions that allow you to manipulate the registers or perform something that brings you closer to your exploitation goals and typically ends in a RET instruction.

The reason why we are learning ROP chains is because due to DEP, it prevents you from injecting your own malicious executable code into the stack. This means you're forced to use existing code and functions present in the program in order to execute your exploit. By scanning the program for useful gadgets and functions, you can repurpose the code in an order that allows you to achieve your end goals. 

In the context of the “dontpopme” program, we will be chaining the following two functions that we previously identified:
  • change() – Changes two global variables to “/bin/uname” and “-a”
  • runCode() – Executes execl() passing in the two global variables 
This will allow us to bypass the program completely and achieve the end goal of getting the “name” flag - which will be printed by the “/bin/uname -a” command. 

Let’s get into it :)

Step 2: Calculate slid
In order for us to generate the payload, we need to once again, gather the following values:
  • Leaked Main Address
  • Change() entry address
  • runCode() entry address
  • Debugger Main Address
  • Debugger Change() address
  • Debugger runCode() address
In the same format we did in the buffer overflow section, we will need to calculate the slid which is the difference between leaked and debugger main addresses:

Leaked Main Address – Debugger Main Address = Difference

The real runtime address for main is shown below: 0x102527cc8. Note this has changed because I re-ran the program :)

The static address for change() is shown below as 0x100007c50. We don’t want to gather the address of the entry but rather the second instruction (highlighted). I did this using radare2 using the instructions I provided in the Buffer Overflow section:

The static address for runCode at the entry point as shown below: 0x100007c88

This is a summary of the information we have collected:
Leaked main: 0x102527cc8
Debugger change(): 0x100007c50
Debugger runCode(): 0x100007c88
Debugger main(): 0x100007cc8

First let's calculate the slid again:
Leaked Main – Debugger Main: 4333927624 - 4294999240 = 38928384

Now let's calculate the real runtime function addresses using the slid:
Real runCode() Address: 38928384 + 4294999176 = 4,333,927,560 = 0x102527C88
Real change() address: 38928384 + 4294999120 = 4,333,927,504 = 0x102527C50

This leaves us with the two crucial pieces of information for the ROP chain:
  • change() entry point: 0x102527C50
  • runCode() entry point: 0x102527C88

Step 3: Prepare the payload
As we noted in the buffer overflow, the LR overwrite occurs at “FFFFFEEE”. However, the payload will look a bit different to the buffer overflow.

If you note, the last instruction in change() function before the “ret” is “LDP x29, x30”. This is basically popping the x29 and x30 registers from the stack which means that whatever is inside the x30 will be the return address that it jumps into.

For further context, the x30 register is “LR” aka the link register and x29 is “FP” aka the frame pointer. 

This means that our payload needs to be structured like this:
  • Address of change()  - \x50\x7c\x52\x02\x01\x00\x00\x00
  • 8 bytes of whatever - \xff\xff\xff\xff\xff\xff\xff\xff
  • Address of runCode() – \x88\x7c\x52\x02\x01\x00\x00\x00

Our exploit payload will look like this:
echo -ne "\x41\x41\x41\x41\x41\x41\x41\x41\x42\x42\x42\x42\x42\x42\x42\x42\x43\x43\x43\x43\x43\x43\x43\x43\x44\x44\x44\x44\x44\x44\x44\x44\x45\x45\x45\x45\x45\x50\x7c\x52\x02\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x88\x7c\x52\x02\x01\x00\x00\x00" > resume.txt

Step 4: Execute the ROP chain!
First let’s create “resume.txt” with our payload:

Next, let’s execute the program! Voila, we did it :) We got Darwin!

Please let me know if you want more tutorials like this. I want to share this cute photo of me from the weekend which inspired me to write this tutorial. uwu. This was the inspo pic <3 


Popular posts from this blog

Office365 Attacks: Bypassing MFA, Achieving Persistence and More - Part I

Forensic Analysis of AnyDesk Logs

Backdoor Office 365 and Active Directory - Golden SAML