iBoot Main Task & Boot Modes

In the last post I discussed how iBoot handles the creating of tasks, and how they are started. I also mentioned the large amount of tasks that are created within iBoot to carry out it's functionality, for example the USB stack creates multiple tasks to carry out it's responsibilities. The main task handles the primary functions of iBoot, being more hardware and platform initialisation, finding and loading a boot image for the next stage, in most cases the Kernel, and booting Recovery Mode if required.

The primary aim of this post is to discuss three things; The iBoot main task, Recovery Mode and Normal boot. There are certain areas, such as the USB stack and recovery mode, that I won't cover reverse engineered code, instead I will discuss how these areas work in theory. This is due to the code being vastly complex and myself not having a good enough understanding of how it works to be able to accurately explain it to you.

I'd also like to move onto the XNU kernel fairly soon, so this will be the second-to-last post covering iBoot, with the next covering the final booting process and jumping to a kernel image. I will most likely publish other content regarding iBoot in the future though. As this post is a fairly long one, I've included some navigation here for you to quickly jump around.

Main Task

The Tasking System basically means iBoot is multi-threaded, with the main task being the center of iBoot with tasks for USB, UART and other functionality that run alongside it. There's a fair list of jobs the main task has to carry out before the system can boot the Kernel, such as:

  • Check for power-off and Recovery Mode button requests.
  • Check if the device requires charging, and set a performance level accordingly.
  • Reinitialise Security.
  • Further platform initialisation, for example NVME startup.
  • Find possible boot images.
  • Intialise the display and draw the Apple logo.
  • Late platform initialisation, for example USB startup.

After this, the main task takes one of two routes. As you will know iOS has something called Recovery Mode in case of an event that requires the device to be restored to factory settings due to the OS being unavailable, or if the user intentionally attempts to restore this way, possibly due to buggy software. A full iOS factory restore will reinstall everything on the device apart from the SecureROM and SEPROM (Secure Enclave). This is in contrast to an OTA (Over The Air) update, which only replaces firmware components that have been modified since the last release.

I mentioned there are two routes; Normal boot and Recovery Mode. A normal boot is always attempted first by searching for available boot images and attempting to load one of them. However if this is unsuccessful at any stage the next thing iBoot does is tries to load Recovery Mode. Failing that, the device will bootloop. We'll start by discussing Recovery Mode, although a Normal boot is attempted first - this is because the recovery mode section is far shorter and the discussion regarding normal boot continues into the next post.

Recovery Mode

Recovery Mode is like DFU mode in the sense that it allows one to restore an iOS device if it's unable to boot the kernel and userland successfully, or if the user has a specific reason to do a factory reset - for example forgetting ones passcode, or clearing the device off before selling. Recovery Mode differs from DFU Mode in a number of ways, particularly that Recovery Mode is a higher-level than DFU, so an update rather than a plain restore is possible. This means that the data on the device is not necassarily wiped using Recovery Mode, whereas with DFU everything on the device is wiped.

The iBoot main task sets up for Recovery Mode. The recovery mode UI, being the outline drawing of a Mac and the Apple Support URL on modern iOS device, is drawn and some platform initialisation is carried out. This is where some required USB tasks are created and spun up, these are responsible for accepting data of USB and determining what to do with it.

Two tasks are then created and started. poweroff and command. Starting the poweroff task first does not mean we're turning the device off, instead it means that iBoot is now constantly checking for the state of the powerbutton. Essentially this is the point at which we are able to manually turn off the device during boot. The command task enables our ability to send commands to iBoot over USB, this can be done with a tool called iRecovery.

We can look at the main task now and see everything I've just explained:

__int64 _main_task()
{
    ...
    recovery_mode_ui();

    platform_debug_init();
    
    /* Create and start the poweroff task */
    task_start(task_create("poweroff", poweroff_task, NULL, 0x200));

    /* Create and start the command prompt */
    task_start(task_create("command", menu_task, NULL, 0x1C00));
}

First of all we have recovery_mode_ui() which, as I've already mentioned, draws the recovery mode screen which on modern devices is an outline of a macbook with an Apple support URL listed above.

Next is the call to platform_debug_init(), this handles the USB initialisationa and particular USB task creation. This is essential for Recovery Mode so firmware can be sent over USB by iTunes or iDeviceRestore, and commands can be sent via iTunes and iRecovery to the iBoot Recovery Mode console. Recovery Mode works by accepting payloads over USB, so the program being used to perform the update/restore would upload each element of the firmware image in a carefully defined order. iBoot can then decode and decrypted (if required) these images and handle the accordingly.

The command Task

If you have ever used iRecovery you are probably familiar with this. The command task starts a recovery command prompt where iBoot can accept commands over USB. While this console is rich with features in a development environment, the actual feature set we as users get on shipped firmware is not so rich. Despite this, are a few fun things one can do with the prompt, such as changing the background colour of a device in Recovery Mode, and potentially saving a device that is stuck in a recovery mode bootloop (where the device constantly boots into recovery mode).

The code for the command task is about as simple as a command prompt could be. An endless loop continously accepts input, maximum of 512 bytes, and calls another function to process the input. The input string is "Tokenised", this basically means taking the string input - for example "boot x y z", and turns each word into a token, removing whitespace. So with that example we would be left with four tokens, "boot", "x", "y" and "z".

The first token is taken as the command, with the remainder being the arguments. The command token is compared to all the implemented commands until there is a match. Providing there is a match, the argument tokens will be parsed, counted and passed to the correct command function along with the count. If there is no match, an issue with parsing the arguments or an issue with the actual command, an error is displayed in the recovery console.

Restore Mode

There is a third mode aside from normal boot, along with DFU and Recovery mode. It's called Restore Mode and is entered to allow a device to run a ramdisk that has been uploaded to Recovery mode.

A ramdisk is an image uploaded to Recovery Mode that handles the majority of the restore/update process. Tools like iTunes and iDeviceRestore do not handle the restore process, they simply prepare the files and upload them in the correct order. There are two types of ramdisk: Restore and Update. They only differ in the respect that the Update ramdisk does not wipe NAND flash, so user data is preserved - whereas Restore ramdisk wipes NAND.

Normal Boot

Providing nothing goes wrong while iBoot is initialising and no Recovery mode request was made the device will begin searching for the next boot stage - the Kernel. Turning our attention back to the main task, we skipped over most of the code and jumped straight to where recovery mode is initialised. However, iBoot tries to find a boot device before resorting to recovery mode, this is done by calling find_boot_images() to find all the possible boot images, and do_iboot_autoboot() to handle loading and booting the appropriate one.

__int64 main_task()
{
    ...
    find_boot_images();
    ...
    do_iboot_autoboot();
    ...
};

Each device type has a set of image devices and can differ depending on the platform, they are defined with their name and an offset which will be important later on. We have been focusing on the T8010 platform so far so we can examine the image devices for the T8010:

struct image_dev {
    char *name;
    int offset;
};

struct image_dev platform_image_devices[] = {
    { "nvme_firmware0", 0x0000}
};

To find a boot image we need to look at these device[s] and search for something called a "block device", or "blockdev". These are structures with a set of properties and IO handlers to interact with a particular storage device, such as NVME. Essentially flash storage devices are divided up into "blocks" (Like sectors on traditional hard drives), with each block being a fixed size, usually 512 bytes or 4kB - in this case, for iBoot, NVME blocks are 512 bytes. The blockdev code forms a basis for storage drivers, so the iBoot NVME driver builds ontop of the generic blockdev structure, adding particular properties and handlers specific to NVME.

The main task will have already done general storage device initialisation, so any blockdevs will have been registered. We're going through the list of blockdev's until we find one that matches. Once there is a match, we use that blockdev to search for images. I mentioned the offset that is defined with the name of an image device? This is called the Image Table Offset and basically points to a 'block' where images are stored.

Let's look at some code to get an idea of where we are at. Here we have code searching for a blockdev that matches one of the storage devices defined in the global platform_image_devices list. If one is found, image_search_bdev() is called which runs some common code and then hands off to either Image3 or Image4 code. Otherwise, we try the next device. Either way we return to the main task, but when it comes to booting one of the images we would default to recovery mode if no boot image was successfully found.

The function lookup_blockdev() just compares the given device name to all the blockdev's in a list, and returns a struct blockdev if it is successful.

void find_boot_images ()
{
    struct blockdev *bdev;

    // In this case, there is only one image deivce.
    for (int i = 0; i < sizeof(platform_image_devices); i++) {
        bdev = lookup_blockdev(platform_image_devices[i].name);
        if (!bdev) 
            continue;           // Try the next one

        image_search_bdev(bdev, platform_image_devices[i].offset, 0);
    }
}

The call to image_search_bdev() is made to collect the information of all the Image4's stored on the given block device. Three arguments are given: the bdev struct for the device we want to search, the image table offset, and a flag called imageOptions that is not used yet. Now, image_search_bdev() is a wrapper for imageX_process_superblock(), bug there is some code that is shared across platforms supporting Image3 and Image4. This shared code reads 512 bytes from the image table offset of the blockdev into a buffer before calling imageX_process_superblock().

That function will return an integer representing the amount of Image4's found in the given bdev. This is returned to find_boot_images(), which just ignores the result.

__int64 image_search_bdev(void *bdev, int offset, uint32_t imageOptions)
{
    void *buf = 0;
    int found = 0;

    blockdev_read (bdev, buf, offset, 512);

    if (found == 0) {
        found = image4_process_superblock (buf, bdev, offset, imageOptions);
        ...
    }
    ...
    if (buf)
        free(buf);

    return found;
}
__int64
image4_process_superblock (void *sblock, void *bdev, int offset, uint32_t imageOptions);

Now, image4_process_superblock() is where images are actually identified. There is a maximum amount of images there can be on a single device (this is undefined so I am unsure of the value) so we loop from 0 to MAX_IMAGE_COUNT. With each cycle, we do the following:

  • Check that the offset passed to this function does not try to access an area beyond the end of the block device. This is done by testing that offset is not larger than the total length of the given bdev, which can be accessed by writing bdev->total_len. If, for whatever reason, the offset is beyond the end of the block, we return with a value of zero which will eventually cause us to boot into recovery mode.

  • We only want to read 30 bytes at a time as 30 bytes is the size of an Image4 header. There is some playing around with the length of the given bdev, starting with bdev->total_len - offset and then checking that result is not less than 30 (in the case that it is, we'd return 0 and eventually boot recovery mode). Otherwise the result is set to 30, regardless of whether it was greater than 30 or not. See this code representing this procedure: ```c int tmp = bdev->total_len - offset; if (tmp < 30) break; buf_bytes = tmp; if (buf_bytes > 30) buf_bytes = 30;


* Check whether we are in the first loop cycle, this can be done by checking whether `sblock` currently has a value. If so, some bytes have already been read from the given block device so we can use that instead. 30 bytes (`buf_bytes`) is copied from `sblock` into a local `buf`, and `sblock` is set to `NULL` so this step doesn't repeat on the next loop.

   * If the cycle is not the first, we instead attempt to read 30 bytes from the blockdev at the given offset. This offset begins as the image table offset, and then is incremented by the size of whatever Image4 is found. I'll mention this in a moment.

   * ```c
       if (!sblock) {
           memcpy(buf, sblock, buf_bytes);
           sblock = NULL;
       } else {
           // Repeat blockdev_read from before, just read into buf this time,
           // and only read 30 bytes.
       }
   ```

* Provided nothing crashed and burned, `buf` should now contain a pointer to an Image4. A call to `image4_get_partial()` giving `buf` and `buf_bytes` as the first two arguments, then `&type` and `&size` as references so they can be updated - we should end up with the type tag of the Image4 in `type`. The Image4 type represents the type of firmware image contained within the payload, so for example the type tag `sepi` represents a SEP OS image, `ibot` represents an iBoot image, `dtre` a device tree, `krnl` the XNU kernel, etc.

```c
   // Non-zero return codes are a sign of a failure.
   if (image4_get_partial(buf, buf_bytes, &type, &size) != 0)
       break;
  • We now have the type of payload extracted from the Image4, and the size. A quick check is performed to ensure that the image does not overflow the block device.
   // Remember tmp == bdev->total_len - offset;
   if (size > tmp)
       break;
  • We now have enough information from the image stored on the block device to construct an image_info struct - containing a few properties such as the bdev the image is stored on, the offset at which the image was found, the image size, type and imageOptions. This new structure is added to a global images list which is accessed when attempting to boot the images.

  • The final step is to increment the offset by the size of the image. If there are any other images stored on this block device, they should be stored directly after it, so we increment the offset by the size which should land us right before the next image. This loop will continue until there are no more images left on the device.

    offset += size;

The count of how many images were found on the device is returned to image_search_bdev(), and the boot process continues. Once find_boot_images() completes the next step to boot is calling `do_iboot_autoboot() which will begin parsing and decrypting these images, and verifiying signatures etc.

Image4 Format

Image4 is the replacement for the Image3 file format and was introduced with the Apple A7 Application Processor and new 64-bit iOS devices (Apple watch continued to use Image3 for a while). This revision of the Image format brings a number of changes over Image3. Prior to iOS 10 all Image4 files contained in IPSWs were encrypted, however Apple began shipping iOS with decrypted Kernel caches and device trees. In contrast, 32-bit device since iOS 10 began shipping with ALL boot files decrypted.

On 64-bit device all IM4P files for encrypted firmware are encrypted with the same KBAG format that was used with Image3. An Image4 file is an ASN.1 DER Encoded file and differs from Image3 in that it uses an open standard, rather than a proprietary file format. Image4 works in a strange but interesting way, data is rarely contained within a full .IMG4 file, rather they are split between IM4P, IM4M and occasionally IM4R. Let's explain these working backwards:

  • IM4R: Image4 Restore are rarely used with them only currently found in MobileDevice.framework.

  • IM4M: Image4 Manifest's are commonly used and contain signatures for one, or multiple, .im4p's (or the payload section of an .img4). They are designed in a way that they can be kept seperate from their payload, or be combined into a single .img4. The following is the structure of an IM4M, obtained from theiphonewiki:

   sequence [
       0: string "IM4M"
       1: integer version                  - currently 0
       2: set [
           tag MANB [                      - manifest body
               set [
                   tag MANP [              - manifest properties
                       set [
                           tag <manifest property> [
                               content
                           ]
                           ...             - tags describing other properties
                       ]
                   ]
                   tag <type> [            - ibot, illb, sepi, krnl, NvMR, etc...
                       ...
                   ]
                   ...                     - tags for other images   
               ]
           ]
       ]
       3: octet string signature
       4: sequence [                       - certificate chain
           certificates
       ]
   ]
  • IM4P: Image4 Payload's are the most commonly used variant of Image4's and are primarily used for the firmware files, such as iBoot, iBEC, Kernel, etc, within an IPSW restore file. An im4p as a standalone file will contain particular properties decribing the data, but otherwise would contain the payload and something called a KBAG if the image is encrypted (more on this later). Let's look at how IM4P's are constructed, using an iBoot image as an example:
    sequence [
        0: string "IM4P"
        1: string "ibot"                    - Payload type.
        2: string "iBoot-7000.0.00"         - Payload version.
        3: octetstring                      - encrypted/raw data
        4: octetstring                      - containing DER encoded KBAG values
            sequence [
                sequence [
                    0: int: 01
                    1: octetstring: iv
                    2: octetstring: key
                ]
                sequence [
                    0: int: 02
                    1: octetstring: iv
                    2: octetstring: key
                ]
            ]
    ]
KBAG

The Application Processor (AP) of an iDevice has two seperate keys: Group ID (GID) and User ID (UID). The UID is primarily used for encrypting user data on the device, therefore it is unqiue to each induvidual iDevice. However the UID is not our focus here.

The GID key on the other hand is an AES-256bit key specific to each Application Processor, for example every iPhone 8, 8 Plus and X all share the same t8015 AP, therefore they all share the same GID key. The key is responsible for the encryption and decryption of the boot files. Direct access to the GID cannot be achieved via software, although it's theoretically possible that one could obtain the GID key be physically removing the AP from the device. The way the GID key is used is via the AES engine built into SecureROM and iBoot, that is how the decryption keys are posted publicly for different firmware version - via an iBoot/SecureROM exploit. The GID does not change, ever! Once the device has been manufactured there is no way Apple can change either the GID, UID or SecureROM without physically having access to the device. This is lucky for us as it means an iPhone X running it's original software can decrypt iPhone X, 8 & 8 Plus firmware files for iOS 14.

So what is a KBAG? A KBAG (or KeyBag) is the AES decryption IV & Key combined into a single string, and then encrypted with the GID key. Looking at the output below, I have taken a recent SEPOS firmware image (specifically for iOS 14.0 D10AP, see here) and used my own tool img4helper to print all the IM4P data from the files header. Let's go over what this shows.

  • Ignoring the "Loaded" tag, we have the "Image4 Type" which correctly identifies this iamge as an IM4P. You could give a file with no extension and if it was an Image4 file of any variant the tool would detect it.

  • The "Component" tag correctly identifies the type of firmware image - based on the type tag from the header being sepi. I added a more human-friendly names for the firmware components for users to read.

  • Now we have the IM4P section. If this were an .img4 there would be a section for all the components making up the file. Here we see the type tag I just mentioned, correctly labeled at sepi. Next is the description, which is unreadable as SEP is heavily protected with obfuscation, compression and proprietary file formats. With a file such as iBoot, the desc tag would read something like "iBoot-7000.00.00~0". The size is accurately listed.

  • Finally we have two KBAGS. Production devices that we buy have different GIDs to those used by Apple for development, and obviously Apple have to test production firmware before shipping, so the firmware files have KBAGs for both the Production and Development GIDs. The most likely reason that there are two different GIDs is so unhappy engineers cannot leak the GID for 20 million devices.

 h3adsh0tzz@h3-iMac-1140   (~/Research/binaries/iOS/14.0/iPhone9,1) %  img4helper -a sep-firmware.d10.RELEASE.im4p 
Loaded:     sep-firmware.d10.RELEASE.im4p
Image4 Type:     IM4P
Component:     Secure Enclave OS (SEP OS)

  IM4P: ------
    Type: sepi
    Desc: ff86cbb5e06c1f301d1604696d706c31
    Size: 0x0014af80

    KBAG Production [1]:
    41af429472dc049a8d615d066b84637020674d3432b891458969e9740d7af9d70d95c2822c8b874e4753673213413388

    KBAG Development [2]:
    91e14fc9f576cf44c2b3e7b26ff296f99221d6eef262801e8612f3415c362d99ec4cbf86bb971ab0160af7193eeae68a

For all components apart from the Secure Enclave, the GID key is enough to decrypt the KBAG. However, the SEP works a little differently and cannot be decrypted by the AP GID.

Summary

Thank you for reading all the way to this point, like always I'm very grateful and hope you learned something (and that I didn't screw up). In this post we have covered the role of iBoot's main, command and poweroff tasks, how Recovery Mode, Restore Mode and Normal boot function, the format for Image4's and the use of KBAGs and the GID key.

Next time we will discuss the loading of images and how iBoot redirects itself to that images. We can then begin to look into the XNU Kernel.

Again, if you have any feedback please let me know! I want this content to be as good as it can be so anything you may suggest will be very valuable to me. You can contact me via Twitter @h3adsh0tzz and by email me@h3adsh0tzz.com.