iPhone 4 Exploit Part 1: Gaining Access

In this series I talk about how I create Gala – iOS 4 jailbreak for iPhone 4.

Table of contents

Introduction

Several years ago I took an active part in developing tweaks for iOS. I have created many products and tools distributed in Cydia, which changed the behavior of the iOS system and added new features to SpringBoard. It was a really fun time and gave me valuable early career experience in reverse engineering closed source binaries, working directly with Objective-C runtime, and entrepreneurship. I am very grateful for these years.

However, one aspect of jailbreak development that has always seemed like black magic to me was the jailbreaking process itself. The prospect is quite remarkable: take any ready-made iPhone, then perform lewd rituals and cast creepy spells until the shackles fall off. The OS will now allow you to run any code you specify, regardless of whether that code has gone through Apple’s blessed signature process, paving the way for hard-working tweak developers like me.

A few weeks ago I wanted to take the mystery out of jailbreaks by writing my own. One caveat: the really interesting work here was done by my ancestors. I am especially grateful p0sixninja And axi0mxwho kindly shared their knowledge through open source.

Port of entry

First step. Buy the device. I don’t know anything about writing a jailbreak or what my approach would look like, so let’s start with something obvious. I buy iPhone 4 and 3GS on eBay. Older devices seem like a good starting point since their security seems to be worse, but you need to find a middle ground: really old devices extremely valuable.

Now that I have two, why should I stop there? eBay pulls back the tails of his dark coat to reveal a torso filled with old iPhones, special offer: two for the price of three.

The devices have arrived! I have a vague idea of ​​how they might be exploited, based on snippets I’ve read over the years: some PDF parsing bugs here, some vulnerable framebuffer code there. To try my hand at using any of these, I need to be able to run some code on the device. The imaginary path here is that I will be able to set up a toolchain that can build and install applications the way it was done back in 2010. Using this, I will then write an application and dig into the sandbox to explore the front end of the attack.

Hmm… it seems that the latest versions of Xcode do not allow targeting iOS versions older than a couple of years. Maybe we can download an older version of Xcode?

Well, to hell with it. I download some older versions of Mac OS X that I’m going to install in a virtual machine so I can run an older version of Xcode, and then I realize I’m bored. Even if I were able to configure the old toolchain, it’s unclear whether Apple would even sign a binary targeting a legacy version of iOS. Let’s try something else.

Finally I decided to test the boot ROM vulnerability. It has some interesting advantages, such as not having to set up an old toolchain and run in a virtual machine, since the boot ROM vulnerability is usually exploited by writing some code on the host machine that communicates with the device via USB.

I know that modern devices have well-known boot ROM vulnerability, and I’m going to iPhone Wiki to find out more. They have a section called Vulnerabilities and Exploits– Great! I read some of them and saw that they limera1n there is exploit code right on the Wiki page. It’s very interesting to just try this code and see what happens.

Retreat on trust

The Boot ROM, or SecureROM in Apple parlance, is the first step in the iOS boot process and initiates the next steps in the boot process. SecureROM’s responsibility is to ensure that whatever it downloads next is trusted – in other words, that it will only run the image that Apple sent and signed.

SecureROM will happily load one of two components, depending on what’s going on:

  1. If the device is booting “normally” from the file system, SecureROM will boot a component called Low Level BootloaderLLB, from disk partition to NOR.

  2. If the device is in DFU mode and connected to the computer via USB, the process Restore iPhonecan be initiated by sending the iBSS bootloader (iBoot Single Stage).

Just as SecureROM is responsible for verifying the reliability of the LLB or iBSS, both the LLB and iBSS must similarly ensure that what They load further is also reliable. Each successive stage ensures that he trusts what follows. The recovery process looks something like this:

This is ours chain Trust: Each stage only loads what it trusts, and therefore the final user-facing code is always trustworthy.

That is, unless we break this chain! Note that each subsequent stage is checked by the previous stage, except first stage. Our diagram actually looks like this:

SecureROM has implicit trust, and this is a heavy burden. While all other steps can be replaced if any vulnerabilities are found by releasing an updated version of iOS, the SecureROM is written to persistent memory when the device is manufactured. This means that every device manufactured using a particular version of SecureROM will be constantly vulnerable to any problems in that version.

And, as it turns out, such vulnerabilities exist and can be exploited!

limera1n

limera1n is the name given to one such exploit that was released by geohot and packaged into a jailbreak tool of the same name in 2010. limera1n can be used when a device in DFU mode is waiting for iBSS to be sent from the host via USB. SecureROM supplied with SoC A4is vulnerable, so the iPhone 4 I bought should be a great target.

One thing that is very interesting to me about limera1n is that no one knows exactly✱ how it works. geohot said he has no idea why it works, and p0sixninja came up with theories. Tools to help you understand what’s going on definitely exist (especially since the iBoot source code was leaked iOS 9), but to my knowledge no one has claimed to have put it all together. The crash that resulted in limera1n was discovered by fuzzing USB control messages and, apparently is a race condition that causes a heap overflow, allowing an attacker to inject and execute shellcode. Closed source binary fuzzing has given us alien technology: we can use it, it’s powerful, but we don’t know what it does.

✱ Note

Publicly at least

Reading data from a device in DFU mode

I started looking for limera1n implementations to figure out how to reproduce them. I quickly came across SecureROM pod2g dump, which helped me a lot. In one fell swoop he showed me:

  • How to implement limera1n

  • What code can be in the payload?

  • How to read memory from a device via USB

This last point was incredible. SecureROM runs on the device and if you want to analyze it you need to somehow delete it from the device. SecureROM pod2g dump copies the memory where SecureROM is displayed ( 0x0), to the USB receiving area. Then on the host side it sends USB control messages to readdata from the device.

As far as I know, no one has explicitly described the second half of this: not only can you write down data to the iOS device via USB, but the device will also respond to requests reading. I found this quite surprising, since I imagined the device as a black hole that absorbed pieces and never revealed anything about its own state.

This mechanism is not explained anywhere on the internet as far as I can see. Here’s my understanding:

  • MMU A4 maps SRAM base to 0x84000000.

  • Hosts interacting with a DFU device (for example, Apple software running on a Mac to restore an iPhone) can send the iBSS image piece by piece by sending USB management packs with request type 0x21and request ID 1. Data sent in control packets will be copied to SRAM starting from 0x84000000and moving to higher addresses as the host sends more packets (so as not to overwrite previous data). SecureROM maintains some internal counters that keep track of where the next packet should be copied to, and these counters can be cleared (presumably if the host wants to cancel the transfer and start over).

  • Device Also will respond if the host sends a control packet with request type 0xA1, request ID 2. The device will read the contents of the memory 0x84000000and send it to the host. This seems dubiously useful if this memory supposedly only contains data that the host has already sent, but it becomes really convenient when we have the ability to execute code on the device and copy whatever we want to 0x84000000.

  • So the dump above uses limera1n to execute a payload that copies memory 0x0(containing SecureROM) in 0x84000000, and then returns back to the original SecureROM DFU loop. The host then sends several A1:2read requests, essentially extracting a SecureROM dump from the device.

I don’t know much about USB✱ yet, so I’m curious if they have 0x21:1They 0xA1:2some deeper meaning or are they arbitrary values ​​hard-coded into the SecureROM business logic. In one Stack Overflow post they are meant to encode some standard information:

✱ Note

My main method for learning things like this is to implement them in axle, and I haven’t implemented an axle stack for USB yet.

The first byte (bmRequestType) in the installation package consists of 3 fields. The first (least significant) 5 bits are the destination, the next 2 bits are the type and the last bit is the direction.

p0sixninja presented presentation about this utility in 2013 at Hack In The Box Malaysia but as far as I can tell, there is an error in his slides: he says that the SecureROM pod2g dump is built on a SHAtter implementation (another SecureROM exploit developed at the same time as limera1n), but the pod2g utility actually uses the limera1n implementation.

I wrote my own implementation of limera1n based on the SecureROM pod2g dumper and tried to create a SecureROM dump as well. I was thrilled when it worked!

$ Дамп SecureROM

Writing a payload

Now I have the ability to execute code on this iPhone 4 and I’m ready to move in my direction. To begin with, I can run the build, but it’s not clear Where this build is running. Where’s my stack? What memory is my shellcode overwriting? What are the limits to the size of my shellcode program before I start overwriting something important in memory?

Before we can answer any of these questions, we’ll need some way to get debug data from the device. Memory read thread 0x84000000, used in SecureROM dump, seems like a really useful tool for this! I wrote some shellcode that copies the instruction pointer and stack pointer values ​​to 0x84000000and then used the same code on the host side to read the values ​​back. So I did print()poor man’s that allows me to communicate the information I collect on the device via memory dumps I receive on the host.

let communication_area_base = unsafe { 
    slice::from_raw_parts_mut(0x84000000 as *mut _, 1024) 
};
communication_area[0] = pc;
communication_area[1] = sp;

I wrote some scripts to automatically run my exploit and output the first few words from 0x84000000to the output window so I can check the copied instruction pointer and stack pointer values. These scripts allowed me to quickly iterate after making changes to my shellcode.

$ Инспекция среды

Looking at the first two words of the memory dump, we see that our shellcode is running 0x8402b048( 48b00284in the memory dump), and the stack pointer is at position 0x8403bfa0( a0bf0384). It makes sense! The stack pointer is within the normal stack area that SecureROM itself sets, and the instruction pointer is within the image receiving area. Since we exploited overflow to ensure code execution, it is not surprising that our code is executed from the delivery buffer.

Ascent from assembler

While making my life better, I also simplified the development of payload logic. Writing software directly in assembly language is useful in some cases, but here it’s just a hindrance. I installed a build system that allowed me to write a payload in Rust✱, which was then converted into shellcode and sent to the device. I chose Rust because I knew that one day I would blog about this work and it would be the choice that would get the most eyeballs. Plus, how cool is it to run a Rust exploit payload on a device that predates Rust?!

✱ Note

Project Rust stopped supporting this armv7-apple-iosgoals in early 2020, but it’s easy to revert to an older supported toolchain using rustup.

However, writing shellcode in any a higher-level language imposes some additional difficulties that do not exist in assembly language. It’s important to remember that when you compile code in a high-level language, you don’t get raw machine code: instead, the toolchains compile your code into a binary file .which contains a ton of metadata, including (but not limited to) instructions to the OS on how to set up the virtual address space as the program expects, symbol tables for debugging, and linker information. We don’t want any of that here! Our exploit gives us the ability to inject and traverse bytes into memory, and we don’t need all the capabilities that would normally be provided when compiling a binary in a controlled environment. It’s the Wild West, baby, and we’re programming strange car.

On macOS, binaries typically look like this:

In other words, the binary file (hosted in Mach-O format) is a collection of segmentseach of which contains chapter, representing certain data. One section may be dedicated to storing Objective-C metadata, and another to storing statically inlined C strings. Only one of these sections contains the raw machine code that we want to load onto the iPhone: section __textin the segment __TEXT. It so happened that I wrote strongarm, extensive Mach-O analysis libraryso I added a quick script to the build system: it compiles the payload and links it into Mach-O, then uses strongarm to extract the contents of the partition __TEXT,__textand writes it to a file. The contents of this file are what we use in limera1n to execute on the device.

from strongarm.macho import MachoParser


def dump_text_section_to_file(input_binary: Path, output_file: Path) -> None:
    with open(output_file.as_posix(), "wb") as f:
        f.write(dump_text_section(input_binary))


def dump_text_section(input_file: Path) -> bytes:
    parser = MachoParser(input_file)
    binary = parser.get_armv7_slice()
    text_section = binary.section_with_name("__text", "__TEXT")
    return binary.get_content_from_virtual_address(text_section.address, text_section.size)

Pacifying the Linker

Here we are not compiling a typical binary, but typical binaries have an agreement with the OS infrastructure about what their entry point will be called. By default, the linker expects our binary to define symbols startor _main, which are not needed for our use case. If we don’t tell the linker that we’re doing something unusual, it will stop us and complain that standard symbols are missing.

$ as -arch armv7 entry.s -o entry.o
$ ld entry.o
Undefined symbols for architecture armv7:
"_main", referenced from:
implicit entry/start for main executable
ld: symbol(s) not found for architecture armv7

Let’s tell the linker that we won’t provide them and give it a try!

$ ld entry.o -U _main
ld: dynamic executables or dylibs must link with libSystem.dylib for architecture armv7Упс, теперь ldдумает, что мы компилируем динамическую библиотеку. Ну, это нормально. Будет ли он отключен, если мы скажем ему, что установим связь libSystem.dylib? Давайте попробуем!

Oops, now ldthinks we are compiling a dynamic library. Well, that’s okay. Will he be disconnected if we tell him that we will establish a connection with libSystem.dylib? Let’s try!

$ ld entry.o -U _main -framework libSystem.dylib -o output.o
ld: framework not found libSystem.dylib

Let’s… let’s take our time. It is true that in libSystem.dylibthis is not available when cross-compiling in armv7, and we don’t have system root for iOS 4 at hand. I see a promising option in manual.

-static
    Produces a mach-o file that does not use the dyld.  Only used building the kernel.

Well, we’re definitely not building a core here. We create a binary that does not use dyld. Could we…? No, no, of course not… but maybe?

$ ld entry.o -U _main -o output.o -static
Undefined symbols for architecture armv7:
"start", referenced from:
-u command line option
ld: symbol(s) not found for architecture armv7

Cool, this is progress! Let’s just tell the linker that start We are also not going to determine…

$ ld entry.o -U _main -U start -static -o output.o
# Success

Great! Now we will pass it to strongarm to extract the contents __TEXT,__text

Traceback (most recent call last):
  File "payload_stage1/../dump_shellcode.py", line 15, in dump_text_section_to_file
    f.write(dump_text_section(input_file))
  File "payload_stage1/../dump_shellcode.py", line 7, in dump_text_section
    parser = MachoParser(input_file)
  File "strongarm/macho/macho_binary.py", line 847, in dyld_info
    raise LoadCommandMissingError()
strongarm.macho.macho_binary.LoadCommandMissingError

Oops, the strongarm is broken! This is because during the initial analysis of the binary file, strongarm expects to find LC_DYLD_INFOdownload command. Since we created a standalone binary that doesn’t use dyld at all, this download command is missing. This wasn’t handled because I’ve never encountered a binary like this before: most binaries use dyld! I added quick patch in strongarm to deal with it and everything is fine now.

Mindful of our limitations

As I expanded the payload, I started including quite a few values ​​in the memory dump. It got a little hard to remember “word 3 is the return value of this call, word 7 is the address of this function” so I made essentially a soft change to include some strings in the data I put in the link space. The code looks something like this:

let communication_area_base = unsafe {
    slice::from_raw_parts_mut(0x84000000 as *mut _, 2048)
};
let mut cursor = 0;
write_str(
    communication_area_base,
    &mut cursor,
    "Output from image3_decrypt_payload: ",
);
write_u32(
    communication_area_base,
    &mut cursor,
    ret
);

Let’s try!

$ Попытка передать строки

Hmm, that’s strange. It looks like something is broken. The reason becomes abundantly clear if we take a close look at our payload binary file before extracting its contents __TEXT,__text:

Oh! The static string in our source code was placed in __const, and our compiled Rust code tries to access the string by loading memory at the address where the binary requests the string be placed in the virtual address space. Because we completely discard everything except __TEXT,__textthese virtual address space mappings represent a powerless request from the binary, and the data in __constare never loaded into memory. So our code requests to load a string with a completely unmapped address, and our binary crashes. The fix is ​​quite simple and gives a good reason to use assembler to write our payloads: assembler gives the programmer explicit and direct control over the placement of static data, while compiled languages ​​attempt to process it on the programmer’s behalf.

To fix this, we need to make sure that all static data we define is inline __TEXT,__textto make sure they don’t get lost when extracting the shellcode. We also need to ensure that any access to static data uses instruction pointer-relative addressing rather than absolute addresses, since we cannot rely on loading to any stable memory address. At this point, I define any strings I want to use in the build and pass their addresses to the Rust payload entry point:

.text

shellcode_start:
    adr r0, msg1
    mov r1, msg1_len
    bl _rust_entry_point

# ...

msg1:
.asciz "Output from image3_decrypt_payload: "
msg1_len:
.equ . - msg1

We are in a very good place! Now we have this pipeline:

What’s next? From here we can do almost anything since we can run arbitrary code on the device. From one point of view, the game is over. However, on the other hand, the fun is just beginning. It’s one thing to be able to do anything in theory. Getting the device to do something interesting is another matter entirely.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *