We have identified several new heap buffer overflow vulnerabilities in Samsung’s baseband implementation (mainly used in Exynos chipsets): three different heap buffer overflows in the same function, to be precise.

The most critical of these vulnerabilities can be exploited to achieve arbitrary code execution in the baseband runtime.

The vulnerabilities we are disclosing in this advisory affected a wide range of Samsung devices, including phones on the newest Exynos chipsets. The vulnerability report covering all three that we reported together was assigned CVE-2023-41112, which was published in the 2023 November issue of Samsung Semiconductor Security Bulletin.

Vulnerability Details

Background: RLC Data Block Formats in GPRS vs E-GPRS

In GPRS, an LLC layer PDU can be up to 1560 bytes long, but the maximum size for an RLC data block is between 22 and 52 bytes for GPRS, depending on the Coding Scheme used (22/32/38/52 for the GPRS coding schemes CS-1/2/3/4, respectively).

While the GPRS RLC data block format is defined in 10.0a.1 and 10.2 of 44.060, the E-GPRS RLC data block format is defined in 10.0a.2 and 10.3a. E-GPRS uses different coding schemes (MCS1-9 instead of CS1-4) and accordingly, the data block formats are different and together with that, the segmentation and re-assembly procedures are different as well. (Sidenote: it actually gets even more complicated, with more changed in E-GPRS2 and then E-GPRS2B, but this is not important for this vulnerability.)

The most important difference is that E-GPRS supports something called 2 block re-segmentation. (This procedure is defined in clause 9 of the same specification 44.060.) As the specification explains, the idea is to group blocks based on coding scheme formats into sizes such that the four families of EGPRS RLC data blocks C, B, A and A padding based on a common size basis (22, 28, 37 and 68 octets respectively) enable link adaptation retransmission as described in sub-clause 9.

The idea behind retransmission is that when transmission with a higher coding scheme (e.g. MCS-8) fails, then re-transmission can be attempted with the same data split into several lower coding scheme type blocks, such as MCS-6 or MCS-3. (You can see J.3 appendix of 44.060 for a concrete example.) In fact, becssue of this re-transmission procedure, E-GPRS introduces a new concept of an “E-GPRS data unit” which may span multiple RLC data blocks on the (re-transmission downgraded) coding scheme.

Due to the above, RLC data block formats and also the segmentation/re-assembly procedure details, differ for GPRS and E-GRPS.

Background: Segment Re-Assembly in Samsung in GPRS vs E-GPRS

Precise details of fragment re-assembly in GPRS have been included in the advisory for CVE-2023-41111. For E-GRPS, we only need to consider the fact that one E-GPRS data unit may span several RLC blocks and therefore the way fragments are taken out of arriving RLC data blocks is different for E-GPRS. In fact, it is possible to save multiple fragments of a given LLC PDU when processing a single arriving E-GPRS data unit.

For this reason, the fragment saving logic for E-GPRS RLC data units in the Samsung code differs from how LLC PDU fragments extracted from GPRS RLC data blocks are saved. Despite these differences, however, the Samsung implementation uses shared code between GPRS and E-GPRS for much of the procedure: the same RLC_handle_DATA_IND, RLC_DecodeDLData, and RLC_addPDUFragm functions described in our advisory for CVE-2024-41111 are used when parsing the headers and storing fragments respectively, the only difference in code flow path is the usage of RLC_DecodeDLDataGPRS vs RLC_DecodeDLDataEGPRS for deciding what fragments to store (extract from an RLC data block), when to store fragments, and when to trigger concatenation. In the end, also the same one function, that we labeled rlc_DLPduConcatenate, handles LLC PDU re-assembly for both GPRS and E-GPRS. So the code flows for GPRS and E-GPRS respectively are:

  • RLC_handle_DATA_IND -> RLC_DecodeDLData -> RLC_DecodeDLDataGPRS -> RLC_addPDUFragm and/or rlc_DLPduConcatenate
  • RLC_handle_DATA_IND -> RLC_DecodeDLData -> RLC_DecodeDLDataEGPRS -> RLC_addPDUFragm and/or rlc_DLPduConcatenate

In the case of RLC_addPDUFragm, we can see that, unlike the GPRS case, an additional array of E-GPRS fragments may also be collected, when this function is reached via RLC_DecodeDLDataEGPRS as opposed to RLC_DecodeDLDataGPRS.

void RLC_addPDUFragm(uint sim,int bsn,big_ctx *ctx,rlc_fragms_desc *fragm_desc)

{

  /* E-GPRS */
  if (ctx->rlc_type[bsn] == 5) {
    fragm_desc->fragms[index] = ctx->rlc_ptrs[bsn];
    fragm_desc->egprs_plus_fragms[index] = (int)ctx->rlc_egprs_ptrs[bsn];
    ctx->rlc_egprs_ptrs[bsn] = (rlcmac_struct *)0x0;
    ctx->rlc_ptrs[bsn] = (rlcmac_struct *)0x0;
    dStack_30.val = sim * 0x40000 + 0x40000 | 0x3e1;
                    /*  State : VN_FIRST_OK_SECOND_OK bsn %d index %d rx_void_ptr is set to NULL  */
    dStack_30.ptr = &dbt_msg_434d1f18;
    pal_dbgLog(&dStack_30,bsn,index,&SUB_fecdba98);
  }
  /* GPRS */
  else {
    (...)
 }

  fragm_desc->block_offs[index] = ctx->rlc_offset[bsn];     

  fragm_desc->block_sizes[index] = ctx->rlc_lens[bsn];
  fragm_desc->is_alloced_fragm[index] = ctx->rlc_allocated[bsn];

  /*  n_blks number of fragments increase - no check! OVERFLOW ! */
  fragm_desc->n_blks = fragm_desc->n_blks + 1;

}

This difference explains the additional array (egprs_plus_fragms) in the fragment descriptor structure:

byte                state   
byte                bsn 
byte                LI_h_offset 
char                pad 
int                 pdu_len 
char[79]            block_offs  
char[79]            is_alloced_fragm    
char                pad2    
char                pad3    
int[79]             block_sizes 
rlcmac_struct *[79] fragms  
int[79]             egprs_plus_fragms   
int                 n_blks  

Finally, in the case of both GPRS and E-GPRS, the code flow reaches rlc_DLPduConcatenate. This function essentially loops through each previously stored plus the last arrived fragment, concatenates the LLC PDU, and either sends it to the upper layer, or executes the RLC Loopback upstream message sending when Test Mode for RLC is enabled.

The following decompiled pseudocode shows this function, first focusing on the path when n_blks is > 0.


uint rlc_DLPduConcatenate(uint sim,int data_length,int bsn,rlc_fragms_desc *rlc_fragm_desc)

{

  (...) /* local variable defs and logging */

  pdu_alloc_ = (char *)0x0;
  max_pdu_len_remaining = rlc_fragm_desc->pdu_len;
  data_offset = (uint)rlc_fragm_desc->LI_h_offset;
  is_edge_mode = get_is_edge_mode(sim);
  rlc_ctx = RLC_get_cxt_unk_sim(sim);

  if ((int)max_pdu_len_remaining < 1561) {
    if ((int)max_pdu_len_remaining < 0) {
      (...)
      goto RETURN;
    }
    if (max_pdu_len_remaining != 0) {
      pdu_size_over_1560 = 0;
      goto PROCESS_CONCATENATION;
    }
  }
  else {

    /*                      
    [1] we never alloc more than 1560, if it is over 1560, we alloc to 1560 and store
    the difference in a variable
                       
    BUGs come from the fact that this pdu_size_over_1560 variable is no actually
    taken into consideration everywhere it should be
    */

    pdu_size_over_1560 = max_pdu_len_remaining - 1560;
    rlc_fragm_desc->pdu_len = 1560;
    max_pdu_len_remaining = 1560;

PROCESS_CONCATENATION:

    pdu_alloc = (char *)pal_MemAlloc(4,max_pdu_len_remaining,
                                     "../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xb6b);
    if (pdu_alloc == (char *)0x0) {
                    /* NULL Pointer Return */
      dStack_34.ptr = &dbt_msg_434d1114;
      dStack_34.val = uVar8;
      pal_dbgLog(&dStack_34,&SUB_fecdba98);
      data_offset = 0;
      goto RETURN;
    }
    pdu_alloc_end_ptr = pdu_alloc + max_pdu_len_remaining;



    /* get the first fragment's data offset in case the LI_M_E header offset was not
       set (this would be the case for implicitly calc'd block length based on RLC
       header E bit, e.g.) */
    sim_ = (short)sim;
    if (data_offset == 0) {
      if (rlc_fragm_desc->n_blks == 0) {
        offset = g_L2_cxt[sim_].rlc_offset[bsn];
      }
      else {
        offset = rlc_fragm_desc->block_offs[0];
      }
      data_offset = (uint)offset;
    }
                    /* Start of DstPtr is 0x%x data_offset %d  */
    dStack_34.ptr = &dbt_msg_434d1158;
    dStack_34.val = uVar8;
    pal_dbgLog(&dStack_34,pdu_alloc,data_offset,&SUB_fecdba98,puVar11);
    index = 0;


    /* [2] the main loop to process fragments */

    do {
      n_blks = rlc_fragm_desc->n_blks;


      /* [4] final case, when handling the last block, that wasn't put on as a fragment
         into the fragment array. */
      if (n_blks == 0) {
        (...)
      }
      

      /* [5] regular case for each loop iteration except the last fragment: handle the
         next fragment stored previously in he fragments array */
      curr_rlc_len = rlc_fragm_desc->block_sizes[index];



      /* [6] normal case: data_offset less than current rlc data block len.
                       
         this is only not true for E_GPRS data unit cases, where the current data unit
         can span 2 rlc data blocks, in re-transmission. so for e-gprs mode that is
         also handled in the else branch.
                       
      */

      if (data_offset < curr_rlc_len) {
        curr_rlc_len = curr_rlc_len - data_offset;

        /* [8] this is where we would have detected OF ... but we just adjust, in our case
           by  0, and memcpy anyway!!!!!
                       
           afterwards we will exit. */
        max_pdu_len_remaining = max_pdu_len_remaining - curr_rlc_len;
        if ((int)max_pdu_len_remaining < 0) {
          curr_rlc_len = curr_rlc_len - pdu_size_over_1560;
        }
        

       

        if (curr_rlc_len != 0) {
          /* [9] first, copy in the current fragment - this is where the heap BOF occurs */
          memcpy(pdu_alloc,rlc_fragm_desc->fragms[index]->data + (data_offset - 3),curr_rlc_len);
          n_blks = rlc_fragm_desc->n_blks;
          pdu_alloc = pdu_alloc + curr_rlc_len;
        }
                    /* End of DstDataPtr is 0x%x , index %d , max_pdu_len %d  position_empty %d
                       gross_block_length %d  */
        dStack_34.ptr = &dbt_msg_434d1438;
        puVar11 = &SUB_fecdba98;
        dStack_34.val = uVar8;
        pal_dbgLog(&dStack_34,pdu_alloc,index,max_pdu_len_remaining,n_blks,curr_rlc_len,
                   &SUB_fecdba98);


        /* [10] now check if there was an additional e_fragm taken from this rlc data block as well
           notice the missing is_edge_mode check! The code will process this even if we are in GPRS mode! */

        if ((0 < (int)max_pdu_len_remaining) &&
           (rlc_e_fragm_curr = rlc_fragm_desc->egprs_plus_fragms[index], rlc_e_fragm_curr != 0)) {


          /* [11] e_gprs fragment size calculated implicitly from block size */
          size = rlc_fragm_desc->block_sizes[index] - 1;

          /* this size adjustment is a NOP if the overall allocated size is not over 1560! */
          max_pdu_len_remaining = max_pdu_len_remaining - size;
          if ((int)max_pdu_len_remaining < 0) {
            size = size - pdu_size_over_1560;
          }


          if (size != 0) {


            /* [12] !!!! THIS CHECK catches inconsistencies and prevents buffer overflow.
                    The problem is: it is missing from all other 3 cases: fragm above, fragm for offset > block_size branch, e_fragm for offset > block_size branch

            */
            if (pdu_alloc_end_ptr < pdu_alloc + size) {
                    /* Reducing gross_block_length (%d) to %d since it has exceeded dst_data_end_ptr
                       0x%x */
              dStack_34.ptr = &dbt_msg_434d14a8;
              dStack_34.val = uVar8;
              pal_dbgLog(&dStack_34,size,(int)pdu_alloc_end_ptr - (int)pdu_alloc,pdu_alloc_end_ptr,
                         &SUB_fecdba98,curr_rlc_len,puVar11);
              size = (int)pdu_alloc_end_ptr - (int)pdu_alloc;
            }
            memcpy(pdu_alloc,(void *)(rlc_e_fragm_curr + 1),size);
            pdu_alloc = pdu_alloc + size;
          }
                    /* End of DstDataPtr is 0x%x , index %d , max_pdu_len %d  position_empty %d  */
          dStack_34.ptr = &dbt_msg_434d1510;
          n_blks = rlc_fragm_desc->n_blks;
          dStack_34.val = uVar8;
          pal_dbgLog(&dStack_34,pdu_alloc,index,max_pdu_len_remaining,n_blks,&SUB_fecdba98,puVar11);
        }
      }

      /* [7] this else branch is here (SHOULD BE here) only for E-GPRS, where an E-GPRS
         data unit spans multiple RLC data blocks, hence the offset is larger than one
         RLC data block's len. 
                       
         but it is a BUG to not verify that this path would be reached only in E-GPRS
         mode. */

      else {
        n_blks = (int)puVar11;

        if (curr_rlc_len == data_offset) {
                    /* Data Offset inceremented %d  */
          dStack_34.ptr = &dbt_msg_434d154c;
          dStack_34.val = uVar8;
          pal_dbgLog(&dStack_34,data_offset,&SUB_fecdba98);
          data_offset = data_offset + 1;
          n_blks = (int)puVar11;
        }
        

        /* copying in an e_fragm that was in an e-gprs data unit that extended beyond the
           first rlc data block into the second, therefore having an offset that is more
           than block_sizes[idx] (still less than block_sizes*2) */


        if (rlc_fragm_desc->egprs_plus_fragms[index] != 0) {

          /* BUG: no verification that this won't underflow! Wouldn't happen normally, but can be triggered because of BSS corruption */

          curr_rlc_len = rlc_fragm_desc->block_sizes[index] * 2 - data_offset;


          /* once again: adjustment reaching < 0 len doesn't trigger an immediate abort and the curr_rlc_len adjustment is a NOP if the size wasn't > 1560 */
          max_pdu_len_remaining = max_pdu_len_remaining - curr_rlc_len;
          if ((int)max_pdu_len_remaining < 0) {
            curr_rlc_len = curr_rlc_len - pdu_size_over_1560;
          }
          if (curr_rlc_len != 0) {
            memcpy(pdu_alloc,
                   (void *)((rlc_fragm_desc->egprs_plus_fragms[index] + data_offset) -
                           rlc_fragm_desc->block_sizes[index]),curr_rlc_len);
            pdu_alloc = pdu_alloc + curr_rlc_len;
          }
                    /* End of DstPtr is 0x%x , index %d  */
          dStack_34.ptr = &dbt_msg_434d158c;
          dStack_34.val = uVar8;
          pal_dbgLog(&dStack_34,pdu_alloc,index,&SUB_fecdba98);
        }
      }

      /* [13] we are done with this fragm, free it.
         then, take care of the current e_fragm as well, if there was an e_fragm

         BUG: here again, we should be noticing that there is an e_fragm despite not being in is_edge_mode !
      */

      if (rlc_fragm_desc->is_alloced_fragm[index] == '\0') {
        curr_rlc = rlc_fragm_desc->fragms[index];
        if ((curr_rlc != 0x0) && (curr_rlc != (rlcmac_struct *)&DAT_42c3e0e1)) {
                    /* RLCREL=%08X index %d position empty %d */
          dStack_34.ptr = &dbt_msg_434d15d0;
          puVar11 = &SUB_fecdba98;
          dStack_34.val = uVar8;
          pal_dbgLog(&dStack_34,curr_rlc,index,rlc_fragm_desc->n_blks,&SUB_fecdba98);
          pal_MemFree((int)rlc_fragm_desc->fragms + iVar9,
                      "../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xc70);
          rlc_fragm_desc->fragms[index] = (rlcmac_struct *)0x0;
          if (rlc_fragm_desc->is_alloced_fragm[index] != '\0') goto ZERO_FRAGM_PTR;
        }
        curr_egprs_rlc_fragm = rlc_fragm_desc->egprs_plus_fragms[index];
        if ((curr_egprs_rlc_fragm != 0x0) && (curr_egprs_rlc_fragm != &DAT_42c3e0e1)) {
                    /* RLCREL=%08X index %d position empty %d  */
          dStack_34.ptr = &dbt_msg_434d1614;
          puVar11 = &SUB_fecdba98;
          dStack_34.val = uVar8;
          pal_dbgLog(&dStack_34,curr_egprs_rlc_fragm,index,rlc_fragm_desc->n_blks,&SUB_fecdba98);
          pal_MemFree((int)rlc_fragm_desc->egprs_plus_fragms + iVar9,
                      "../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xc79);
          rlc_fragm_desc->egprs_plus_fragms[index] = 0;
                    /* Rx_void_ptr is NULL */
          dStack_34.ptr = &dbt_msg_434d1644;
          dStack_34.val = uVar2;
          pal_dbgLog(&dStack_34,&SUB_fecdba98);
        }
      }
ZERO_FRAGM_PTR:
      if (((is_edge_mode == 1) && (rlc_ctx->is_ack_mode == 1)) &&
         (rlc_fragm_desc->is_alloced_fragm[index] == '\x01')) {
        if (rlc_fragm_desc->fragms[index] == (rlcmac_struct *)&DAT_42c3e0e1) {
          rlc_fragm_desc->fragms[index] = (rlcmac_struct *)0x0;
        }
        if ((undefined *)rlc_fragm_desc->egprs_plus_fragms[index] == &DAT_42c3e0e1) {
          rlc_fragm_desc->egprs_plus_fragms[index] = 0;
                    /* Rx_void_ptr is NULL */
          dStack_34.ptr = &dbt_msg_434d1674;
          dStack_34.val = uVar2;
          pal_dbgLog(&dStack_34,&SUB_fecdba98);
        }
      }

      /* [14] adjust num blocks and data_offset pointer */

      n_blks_new = rlc_fragm_desc->n_blks + -1;
      new_offs_ptr = (byte *)(rlc_fragm_desc->block_offs + index + 1);
      rlc_fragm_desc->n_blks = n_blks_new;
      if (n_blks_new == 0) {
        new_offs_ptr = g_L2_cxt[sim_].rlc_offset + bsn;
      }
      data_offset = (uint)*new_offs_ptr;




      /* [15] Finally: detect if max_pdu_len_remaining has turned negative and treat it as as "finished LLC PDU" condition
         This actually should never happen without the final fragment processed, but the code tries to handle it as normal anyway. */

      if ((int)max_pdu_len_remaining < 0) {
        puVar11 = &SUB_fecdba98;
                    /*  Max PDU LEN became < 0 : %d index %d pdu_index_ptr->position_empty %d */
        dStack_34.ptr = &dbt_msg_434d16d8;
        dStack_34.val = uVar8;
        pal_dbgLog(&dStack_34,max_pdu_len_remaining,index + 1,rlc_sending_loopback_state
                   &SUB_fecdba98);
FINISH_HANDLING_CONCAT:
                    /* max_pdu_len <= 0 */
        dStack_34.ptr = &dbt_msg_434d1708;
        dStack_34.val = uVar2;
        pal_dbgLog(&dStack_34,&SUB_fecdba98);
        rlc_ctx->pdu_concat_len = rlc_fragm_desc->pdu_len + rlc_ctx->pdu_concat_len;
        rlc_ctx->concat_rounds_counter = rlc_ctx->concat_rounds_counter + 1;
        rlc_sending_loopback_state_unk = *piVar3;


        /* 3 cases:

          - depending on loopback state, if test mode is turned on with no loopback required, then just release the PDU
          - normal case (no test mode turned on):
               -  inspect the PDU and if the values indicate it is a Loopback message, enable Test Mode
               -  otherwise, construct and send the Upper Layer PDU to the next layer! (ILM == Inter Layer Message)
          - test mode active, loopback active: send the loopback! (Only for a max 0x15 sized PDU)

        */
        if (rlc_sending_loopback_state == 3 || rlc_sending_loopback_state == 1) {
          pal_MemFree(&pdu_alloc_,"../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xce2);
        }
        else {


          /* 
            no loopback active state:
                       
            - check if loopback state is to be turned on, based on values of the PDU
            - otherwise, create the upper layer ILM messgae and send
          */
          if (rlc_sending_loopback_state == 0) {
            if (*pdu_alloc_ == 'A') {
              rlc_ctx->field330_0x150 = (uint)rlc_ctx->field369_0x5fd;
              if ((0x3f < (byte)pdu_alloc_[1]) && ((pdu_alloc_[2] & 1U) != 0)) {
                cVar1 = pdu_alloc_[3];
                bVar10 = cVar1 == '\x0f';
                if (bVar10) {
                  cVar1 = pdu_alloc_[4];
                }
                if (((bVar10 && cVar1 == '$') && (0x7fffffff < (uint)(int)pdu_alloc_[5])) &&
                   ((pdu_alloc_[7] & 1U) != 0)) {


                    /* Enable Test Mode: modifies the loopback state! */
                  *piVar3 = 2;
                    /* GPRS Test Mode : (%d) */
                  dStack_34.ptr = &dbt_msg_434d173c;
                  dStack_34.val = uVar2;
                  pal_dbgLog(&dStack_34,2,&SUB_fecdba98,rlc_ctx,puVar11);
                }
              }
            }
            else if ((rlc_ctx->field330_0x150 != 0) &&
                    (rlc_ctx->field330_0x150 = 0, *(int *)(&DAT_460e0b8c + sim * 4) != 0)) {
              RLC_handle_GRR_RLC_SUSPEND_REQ(sim);
            }
            rlc_outgoing_ilm_msg =
                 (undefined2 *)
                 pal_MemAlloc(4,0x14,"../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xcca);
            if (rlc_outgoing_ilm_msg == (undefined2 *)0x0) {
                    /* NULL Pointer Return */
              dStack_34.ptr = &dbt_msg_434d176c;
              dStack_34.val = uVar8;
              pal_dbgLog(&dStack_34,&SUB_fecdba98);
              pal_MemFree(&pdu_alloc_,"../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xcce);
              data_offset = 0;
              goto RETURN;
            }
            *(undefined4 *)(rlc_outgoing_ilm_msg + 4) = rlc_ctx->field326_0x149;
            *(char **)(rlc_outgoing_ilm_msg + 6) = pdu_alloc_;
            *(int *)(rlc_outgoing_ilm_msg + 8) = rlc_fragm_desc->pdu_len;
            FUN_41f59572(sim,0x3308,rlc_outgoing_ilm_msg);
            if (sim == 0) {
              *rlc_outgoing_ilm_msg = 0x37;
              uVar5 = 0x35;
            }
            else {
              *rlc_outgoing_ilm_msg = 0x33;
              uVar5 = FUN_41f58970(sim,0x35);
            }
            rlc_outgoing_ilm_msg[1] = (short)uVar5;
            *(undefined4 *)(rlc_outgoing_ilm_msg + 2) = 0x3308000c;
            pal_MsgRtkSend(uVar5,rlc_outgoing_ilm_msg);
          }

          /* loopback state active: send the loopback */
          else {
            
            curr_rlc_len = get_rlc_pdu_allocation(sim);
            if ((int)curr_rlc_len < 0x15) {
              rlc_loopback_send(sim,rlc_fragm_desc,pdu_alloc_);
            }
            else {
              pal_MemFree(&pdu_alloc_,"../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xcf1);
            }
          }
        }


        /* this adjustment here doesnt matter much, because the max_pdu_len is not > 0
           anymore if we are here, so the while condition will always quit afterwards
           right away. */
        data_offset = data_offset + data_length;
        rlc_fragm_desc->pdu_len = 0;
      }
      else if (max_pdu_len_remaining == 0) goto FINISH_HANDLING_CONCAT;
      iVar9 = iVar9 + 4;
      index = index + 1;
    } while (0 < (int)max_pdu_len_remaining); /* [3] fragment concatenation loop exit condition */
  }

RETURN:
  return data_offset;
}

The function is fairly convoluted, as it serves both the GPRS and EGPRS path simultaniously, same as the RLC_addPDUFragm function before.

Here is the summary of its behavior:

  • allocate a heap buffer for the to-be-concatenated LLC PDU
    • this allocation normally uses the size accumulated in the RLC_addPDUFragm function invocations and stored in the pdu_len field of the fragment descriptor struct while the not-last fragments are stored PLUS the last fragment’s size, which is a fragment that is not itself added to the fragment descriptor’s fragms array, but its size is also added into the total size that is stored as a field of the fragment descriptor (by the function that invokes rlc_DLPduConcatenate, RLC_DecodeDLDataEGPRS)
    • however, when that calculated total size is > 1560, the allocation instead happens with size 1560 and the difference (extra number of bytes) is stored in a local variable
  • start decrementing n_blks and handle each fragm pointer in increasing idx order in one large loop:
    • when n_blks is 0, handle specifically the last fragment (which has not been stored into the fragment descriptor structure since it has just arrived, it didn’t get RLC_addPDUFragm called on it)
      • in this path, the size of the fragment that is copied is calculated based on the LI value: this makes sense, since according to the specification, only the last fragment of an LLC PDU may have an LI that is non-0 and for all other cases (all previous fragments) the size will be implicitly calculated from block offset and block size
      • after concating in the fragment, also handle concating in the last element of egprs_plus_fragms[], if it is non-zero
      • finally, handle the concatenated PDU: either send the LLC PDU to the upper layer, or handle Loopback if Test Mode is enabled (or freshly enable Test Mode, based on the values of the re-assembled PDU)
    • for all other cases:
      • reduce max_pdu_len_remaining (the size of the total remaining length left in the LLC PDU allocation) by the size of the current to-be-added fragment
      • concat from fragms[i] to the concatenated pdu block_sizes - block_offs number of bytes
      • IF egprs_plus_fragms[i] is ALSO non-zero then also copy from this one a size similarly calculated based on block sizes and offset (as opposed to taken from an LI value)
      • after copying one or both of fragms[i] and egprs_plus_fragms[i], check whether is_alloced_fragm[i] is 0, if yes, then free fragms[i] and also free egprs_plus_fragms[i] if that is non-0
      • always after copying, decrement the remaining pdu_len with the amount just copied
      • if pdu_len remaining afterwards is no longer positive, then end the concatenation, same logic applied as for the n_blks == 0 case, with either the Loopback handled or the LLC PDU sent to the upper layer
    • if max_pdu_len_remaining remaining is no longer positive, exit the loop

The summary already makes an issue jump out: the max_pdu_len_remaining wrapping around to negative is used as an exit condition AFTER the current iteration’s copy has occured? That would be a vulnerability indeed, so let’s look at the code in question to see whether (and if yes then the ways in which) this can happen.

Vulnerability #1: Heap Overflow Due To Ignored Length Check

Breaking down the above code, we can identify the buffer overflow issue:

  • at [1] we adjust the size of the allocated pdu, maximizing it in 1560
  • at [2] we start the main while loop for processing fragments - it quits only when the remaining length is not positive anymore (at [3])
  • at [4] we handle the final fragment (cut here for brevity, see the next section), at [5] we handle all other fragments
  • there are two cases: at [6] we handle the case when the data offset points inside an rlc block, at [7] we handle the (E-GPRS re-transmission scenario specific) case where the data offset spans multiple rlc data blocks
  • within the first case, at [8] the maximum remaining length is adjusted and negative value is detected - but the BUG is the fact that this situation is not aborted, the copying will commence anyway! The only thing that happens is that the size is adjusted by the value that stored the access over 1560: but in cases when the total allocation size was under 1560, this is a NOP - this leads to the possibility of a heap BOF at [9]
  • finally for the concatenation on this path, at [10] we check if there is an E-GPRS fragment for this index: notice how the EDGE mode being on or not is not tested here, which is wrong! We can see that, following the spec, the E_GPRS fragment size is calculated implicitly from block size, at [11]. Also notice the check and adjustment at [12]: this case would guarantee that THIS memcpy can’t overflow, but the problem is that this same check/correction is missing from the other fragment copy cases
  • after the concatenation, at [13] we see if the current fragment(s) was heap allocated or not, and free them if they have been
  • at [14] the number of blocks, index, data offset variables are adjusted to the next fragment
  • finally at [15] another curious thing happens: we have a check to see if the remaining length has become negative: and if so, we treat it as “LLC PDU is completed” condition! That is a bug, since we are not in the n_blks == 0 branch of the loop, i.e. this MUST NEVER be the end of the LLC PDU. Nonetheless, the code copy-pastes the same logic that is used at the end of the n_blks == 0 branch in order to handle the re-assembled LLC PDU

As we can see, it is indeed theoretically possible to cause a heap buffer overflow IF the current fragment size adds up to more than the allocated size.

However - at this point this is just a “secure coding” issue, unless we can show that the condition can actually happen.

Here at first we can say, that is not actually possible to occur. After all, this is the branch where copy sizes are all taken from the block offsets, the same block offsets that are being used in RLC_addPDUFragm when accumulating the fragments, consequently the same accumulated length that is used in the allocation. Ergo, the subtractions must always result in reaching zero size, not a negative size.

The problem, however, is the BSS overflow described in our advisory for CVE-2023-41111. Recall from that advisory that due to the bugs in how malformed LI_M_E extension header bytes are parsed by the Samsung code, it is possible to create too many fragments and cause a BSS overflow of the rlc_fragm_desc structure! So let’s inspect what happens when we send more than 79 fragments.

Here’s the structure definition again:

byte                state   
byte                bsn 
byte                LI_h_offset 
char                pad 
int                 pdu_len 
char[79]            block_offs  
char[79]            is_alloced_fragm    
char                pad2    
char                pad3    
int[79]             block_sizes 
rlcmac_struct *[79] fragms  
int[79]             egprs_plus_fragms   
int                 n_blks  

As we can see, the structure is tightly packed, so the following things simultaniously occur when a single extra fragment (80th fragment) is stored into the array (worth noting that this means sending 81 fragments in total, since the 81th doesn’t get stored):

  • is_alloced_fragm[0] gets corrupted with a block offset: this turns out to be a blessing, because it results in the pdu freeing logic at [13] skipping fragms[0] altogether! packed_struct_OF_1

  • a padding byte gets corrupted, that doesn’t matter packed_struct_OF_2

  • block_sizes corrupts fragms[0], replacing a valid pointer with a small value depending on CS, e.g. 22! packed_struct_OF_3

  • a “phantom” E-GPRS fragment pointer appears at egprs_plus_fragms[0]! packed_struct_OF_4

As we can tell, the first two issues are NOPs in practice.

Normally we could think that a pointer getting overwritten with the value 22 would mean game over, since this pointer would become the source of the very first memcpy at [9]! However, we get lucky, because the Samsung baseband runtime maps its bootrom code at page 0 and it remains RX after boot. Ergo this copy would copy in “junk” but it would not fail. Obviously, an attempt to call free on this pointer would fail, but due to the simultanious corruption of is_alloced_fragm[0] we survive that too!

Finally, the appearance of the “phantom” fragment will cause that during the processing of the very first fragment, the code notices at [10] that an E-GPRS fragment exists, the bug of the missing mode check means it does not realize that this makes no sense and it proceeds at [11]-[12] to copy in this fragment! The copy size is implicitly calculated as block_size - 1.

The problem, of course, is that this “phantom” fragment was never added during RLC_addPDUFragm calls, therefore it wasn’t taken into account when calculating the allocation size! And, since the allocation order is different from the copying order, we can get a modulo mismatch that will cause the size to turn negative, i.e. cause a heap buffer overflow on a fragment! And as we saw, once the overall remaining size turns negative (AFTER the copy), the code happily concludes that the LLC PDU is complete and proceeds to send it.

To see the math mathing, consider the following allocation sizes vs copy order:

  • sent fragment sizes (i.e. allocation size): 78x3 + 20 + 3 + 1 = 262 (One full sized valid block (20), 79 corrupt LI_M_E header field blocks that have reduced size to 3, one final block with LI==1 just to trigger concat)
  • copy order: 3 + 22 + 77x3 + 20 = 276 (and then we exit, skipping frame 80 and 81 altogether)

As a final note on this vulnerability, it is interesting to mention what happens if we send more than 80 fragments, because this can lead to different corruptions - albeit not useful ones.

Negative Size Memcpy

If we send at least 82 fragments before triggering the concatenation, is_alloced_fragm[82] has now corrupted block_sizes[0] to be 0.

As listed above, when the fragms[0] is now concatenated, egprs_plus_fragms[0] will look like a valid pointer so it will be attempted to be copied in - but in this instance the above explained condition is hit (the code path at [7]): block_offs[i] > block_sizes[i]`.

In this case, as we can see in the decompiled code at [7], the copy size from the egprs_plus_fragms[i] will be 2*block_sizes[i] - block_offs[i], however we just corrupted the former to 0 while the block offset for the index is still the valid value!

This means that the copy size for the fake extra fragment will be 2*block_sizes[i] - block_offs[i] = 2*0 - block_offs[i] which wraps into a negative value.

Free(1)

As we saw above, if we use fragment size combinations that happen to sync with the “phantom” block_size - 1 copy, then the concatenation might just quit gracefully. But there are other combinations.

One such combination is that the size cs_size - 1 doesnt add so much such that we still reach the processing of the 80th index.

In this case, the code will attempt the copy from fragms[80] which is the same as egprs_plus_fragms[0] - but remember, this pointer did NOT get freed when it was used before, beacuse is_alloced_fragm[0] got corrupted to a positive number!

Therefore, the copy from fragms[80] will happen - but at this point egprs_plus_fragms[80] will ALSO be checked for being non-0 - and it will not be zero, because egprs_plus_fragms[80] is actually the n_blks field - and since we sent 81 blocks total, the idx will be exactly 1 at this stage.

So the end result will be that fragms[80] and egprs_plus_fragms[80] will both try to get freed and this will result in the action of free(0x1), which crashes the baseband.

Vulnerability #2 and #3: Heap Overflow Due To Unchecked Length, Integer Underflow to Heap Overflow Due To Unchecked Length

Finally, there are two additional heap buffer overflow vulnerabilities in the same function and neither of these require CVE-2023-41111, i.e. the BSS overflow due to too many fragments.

On the flip side, the control over the length of bytes written is not good in these cases, so these heap buffer overflows are not that great for an attacker.

In the above, we skipped the pseudocode of the path that handles the final fragment in the loop, where n_blks == 0, so let’s see that part now.

This code, unlike the other path, deals with the LI field values for fragment length. This makes total sense: as we know from the specification, final fragments of an LLC PDU may have a non-0 LI value, so unlike interim fragments, their size is not implicitly given based on block size, but explicitly signaled.

The problem as we’ll see is that the LI field potentially being corrupt is not taken into consideration properly and this manifests itself as two separate heap overflows. Recall that the attacker gets to control the LI value that flows into this function (data_length here) fully, e.g. in RLC_DecodeDLDataGPRS we had this (see the advisory for CVE-2023-41111 for more details):

    /* if LI != 0 */
    if (LIME >> 2 != 0) {
      data_ptr = frame_ptr->data;

      /* loop to handle until there are no more LI_M_E extensions to handle */
      do {
        data_ptr = data_ptr + 1;
        LI = (uint)(LIME >> 2);

        /* we know that LI != 0 must be the final fragment of an LLC PDU, so we concatenate it, then move on to potential other LI_M_E headers */

        if (frag_state) {

          /* !!! Notice how the LI value here is not verified yet, this is why rlc_DLPduConcatenate must take care to check total size, it could be over 1560 with it, even without games with LI_M_E header field stacking in fragments */

          rlc_frags_desc->pdu_len = rlc_frags_desc->pdu_len + LI;
          new_pdu_len_ = rlc_DLPduConcatenate(sim,LI,bsn,rlc_frags_desc);
          rlc_frags_desc->state = 2;
          LI = 0;
        }

And this call flows into the following path in rlc_DLPduConcatenate:

uint rlc_DLPduConcatenate(uint sim,int data_length,int bsn,rlc_fragms_desc *rlc_fragm_desc)

    (...)
    max_pdu_len_remaining = rlc_fragm_desc->pdu_len;
    data_offset = (uint)rlc_fragm_desc->LI_h_offset;
    is_edge_mode = get_is_edge_mode(sim);
    (...)

    do {
      n_blks = rlc_fragm_desc->n_blks;

      if (n_blks == 0) {

        /* curr_rlc_len is the block size */
        curr_rlc_len = g_L2_cxt[sim_].rlc_lens[bsn];


        /*        
           [1] if the offset/length says that we fit into the curr rlc data block, handle
           it by copying whole PDU from the bsn using the remaining length allowed
        */


        if ((data_offset < curr_rlc_len) && (data_length + data_offset <= curr_rlc_len)) {
          if (g_L2_cxt[sim_].rlc_ptrs[bsn] == 0x0) goto LOG_MAX_PDU_LEN_MISMATCH;
                    /*  Copy whole PDU from BSN %d , index %d  */
          dStack_34.ptr = &dbt_msg_434d119c;
          dStack_34.val = uVar6 | 0x362;
          pal_dbgLog(&dStack_34,bsn,index,&SUB_fecdba98,puVar11);
          memcpy(pdu_alloc,g_L2_cxt[sim_].rlc_ptrs[bsn]->data + (data_offset - 3),
                 max_pdu_len_remaining);
                    /* End of DstPtr is 0x%x ,index %d  */
          dStack_34.ptr = &dbt_msg_434d11dc;
          dStack_34.val = uVar8;
          pal_dbgLog(&dStack_34,pdu_alloc + max_pdu_len_remaining,index,&SUB_fecdba98);
        }

        /*
           [2] we can however easily have an else path happen, since unchecked LI could flow
           into this function, even in gprs mode, and that means that data_length (which
           is the unchecked LI) + data_offset can be higher than the current rlc block's
           block size
           
           this could be a valid thing for E-GPRS data units in re-transmission scenario
           spanning 2 rlc data blocks, but it is NOT VALID AT ALL as a scenario in GPRS.
           
           however, that is_egprs_mode check is missing here once again
        */
        else {

          /* [3] First variant is when the (intended) E_GPRS data unit fragment spans across two RLC data blocks,
                 meaning that it starts inside the first rlc data block, but ends after it */

          if ((data_offset < curr_rlc_len) && (curr_rlc_len < data_length + data_offset)) {
            curr_rlc = g_L2_cxt[sim_].rlc_ptrs[bsn];
            if (curr_rlc == 0x0) {
              curr_rlc_len = 0;
            }
            else {
              


              /* 
                  [5] HEAP BOF: if the LI value is malformed and adds up to more than 1560,
                      the allocation size is capped at 1560, but here there is no check for 
                      pdu_size_over_1560 at all! Meanwhile, if that final RLC data block that
                      contains the final fragment consists if a single LI header field, the
                      curr_rlc_len value will be block_size - 1 here.

                      If we e.g. use CS-4, block size - 3 is 48, if we send 32 valid fragments with full block size:
                       - 32*48 is 1536, so with an LI value of 51, we trigger concatenation, and this copy will
                      add 48 to 1538, copying 1586 in total, whereas the allocation was to 1560.
              */
              curr_rlc_len = curr_rlc_len - data_offset;
              memcpy(pdu_alloc,curr_rlc->data + (data_offset - 3),curr_rlc_len);
              pdu_alloc = pdu_alloc + curr_rlc_len;
                    /* End of DstPtr is 0x%x ,index %d */
              dStack_34.ptr = &dbt_msg_434d1218;
              dStack_34.val = uVar8;
              pal_dbgLog(&dStack_34,pdu_alloc,index,&SUB_fecdba98);
            }
            curr_rlc = g_L2_cxt[sim_].rlc_egprs_ptrs[bsn];
            if (curr_rlc == 0x0) {
               

              pdu_size_over_1560 = 0;
            }

            /* [6] same bug for E-GPRS fragment: the pdu_alloc was maxed out at 1560, but
                  we might go over that! Since pdu_size_over_1560 isnt used, the memcpy can BOF.

                  this would be a negative size copy here of course, because it subtracts
                  block_size from the remaining and uses that
            */
            else {

              /* [6] size calculation wraps around, leading to negative size memcpy */
              edge_copy_size = max_pdu_len_remaining - curr_rlc_len;
              memcpy(pdu_alloc,&curr_rlc->rlc1,edge_copy_size);
                    /* End of DstPtr is 0x%x , index %d  */
              dStack_34.ptr = &dbt_msg_434d1258;
              dStack_34.val = uVar8;
              pal_dbgLog(&dStack_34,pdu_alloc + edge_copy_size,index,&SUB_fecdba98);
              pdu_size_over_1560 = -edge_copy_size;
              data_offset = data_offset + 1;
            }
            max_pdu_len_remaining = pdu_size_over_1560 + (max_pdu_len_remaining - curr_rlc_len);
            if (max_pdu_len_remaining == 0) goto NORMAL_FINISH_HANDLING_CONCAT;
          }

          /* [4] Second variant is when the (intended) E_GPRS data unit fragment starts after the first RLC data block
                 In this case, since we know we are the final fragment, simply copy the total remaining size.
                 This would be safe, but also not a path we would trigger with GPRS, unless the data offset calculation
                 previously would be corrupt in some different way from CVE-2023-41111
          */

          else if (curr_rlc_len <= data_offset) {
              (...)
              memcpy(pdu_alloc,(void *)((int)curr_egprs_rlc + (data_offset - curr_rlc_len)),
                     max_pdu_len_remaining);
                    /* End of DstPtr is 0x%x vq_index %d */
              dStack_34.ptr = &dbt_msg_434d12d4;
              dStack_34.val = uVar8;
              pal_dbgLog(&dStack_34,pdu_alloc + max_pdu_len_remaining,bsn,&SUB_fecdba98);
              goto NORMAL_FINISH_HANDLING_CONCAT;
            }
          }
LOG_MAX_PDU_LEN_MISMATCH:
                    /* max_pdu_len is not zero=%d */
          dStack_34.ptr = &dbt_msg_434d130c;
          dStack_34.val = uVar6 | 0x382;
          pal_dbgLog(&dStack_34,max_pdu_len_remaining,&SUB_fecdba98);
        }
NORMAL_FINISH_HANDLING_CONCAT:
       /* identical implementation to the code at the end of the function, when during
          the handling of a given fragment, when n_blks > 0 still, we (unexpectedly)
          found that the pdu_size remaining has reached 0. */
        rlc_ctx->pdu_concat_len = rlc_fragm_desc->pdu_len + rlc_ctx->pdu_concat_len;
        rlc_ctx->concat_rounds_counter = rlc_ctx->concat_rounds_counter + 1;
        unk = *piVar3;
        if (unk == 3 || unk == 1) {
          pal_MemFree(&pdu_alloc_,"../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xc09);
        }
        else if (unk == 0) {
          (...)
          pal_MsgRtkSend(uVar5,rlc_outgoing_ilm_msg);
        }
        else {
          rlc_pdu_allocation = get_rlc_pdu_allocation(sim);
          if (rlc_pdu_allocation < 21) {
            rlc_loopback_send(sim,rlc_fragm_desc,pdu_alloc_);
          }
          else {
            pal_MemFree(&pdu_alloc_,"../../../HEDGE/GSM/GL2/GRLC/Code/Src/rlc_dl_datablock.c",0xc18);
          }
        }
        data_offset = data_offset + data_length;
        rlc_fragm_desc->pdu_len = 0;
        goto RETURN;
      }

If we follow the above, we can see that we can have the following sequences leading to additional Heap Buffer Overflows:

  • at [1] we have the first path that is not interesting: when the LI size says it is smaller than the block offset. this is of course the ONLY path that makes sense for GPRS - but the code was missing a check for the other paths to verify it only occurs due to being in E-GPRS mode
  • at [2] we have the second path, where the LI points “outside” the RLC data block: this is normal for an E-GPRS Data Unit in re-transmission mode (again, you can see the J.3 appendix example of 44.060 for examples) and it has two sub-cases:
    • at [3] the case where the fragment starts inside the first block but ends after it and at [4] the case where the E_GPRS data unit fragment starts after the first RLC data block; we care about option [3], because
    • at [5] and [6] we hit the two additional separate heap buffer overflow, as the comments explain, in both cases the problem is that the final LI adding up to over 1560 maximizes the allocation size, but the memcpy sizes do not account for the value of pdu_size_over_1560, neither do they use a pdu_alloc_end_ptr check similarly to the one case (explained above) where that check is in place to prevent an overflow
    • at [5] we therefore get a case that would allow to overwrite beyond the 1560 sized allocation by approx one block size, whereas at [6] we would get a negative sized memcpy

Exploitability

The first heap overflow vulnerability gives a very convenient corruption primitive to an attacker. We have decided to turn this one into code execution, the details of the exploit can be seen in our blogpost.

The severity of the last two heap overflows are limited by the OS implementation details.

A 1560-sized allocation will always fall into the 2048 pool class, so a single block size overflow doesn’t appear to be able to corrupt anything other that padding (guard) bytes.

The negative sized memcpy is the opposite: the attacker could trivially write far enough to cause a useful memory corruption, but than the copy would continue and most likely results in a data abort or watchdog reset. Nonetheless, in cases like this it is prudent to mention that with an RTOS environment handling unmaskable FIQs, it’s never quite right to state that such a memcpy is impossible to survive.

Affected Devices

All Samsung chipsets containing Samsung’s baseband implementation, including all Exynos chipsets.

Fix

Samsung OTA images, released after October 2023, contain the fix for the vulnerability.

Timeline

  • 2023.04.01. TASZK reports bug to Samsung PSIRT
  • 2023.04.15. Samsung PSIRT informs TASZK that Exynos vulnerabilities have been removed from the Samsung Mobile Security Program scope. Simultaneously, the report is forwarded to the Samsung Device Solution PSIRT
  • 2023.04.21. Samsung DS PSIRT confirms the reception of the issue and confirms that they do not have a reward program
  • 2023.05.17. TASZK asks for update on the report status
  • 2023.05.18. Samsung DS PSIRT provides the update that the vulnerabilities will be fixed and that they intend to create a new reward program
  • 2023.06.21. TASZK asks for update on the patch timeline
  • 2023.06.26. Samsung DS PSIRT replies that they decided to postpone creating a reward program
  • 2023.06.26., 08.07. TASZK asks for update on the patch timeline
  • 2023.08.14. TASZK asks for update on the patch timeline, asks if a CVE in the August bulletin matches the report
  • 2023.08.16. Samsung DS PSIRT confirms the CVE in the August bulletin is not related to the report, doesn’t provide a patch timeline
  • 2023.08.24., 08.31. TASZK asks for update on the patch timeline
  • 2023.08.31. Samsung DS PSIRT confirms that the vulnerabilities will be tracked as CVE-2023-41111 and CVE-2023-41112 and will be published on November 6th
  • 2023.09.26. TASZK informs Samsung about Hardwear.io talk, confirms that the vulnerabilities will be withheld because of the conference happening before November 6th
  • 2023.10.17. Samsung DS PSIRT informs TASZK about the now existence of their newly created disclosure program, and awards $2500 for each of the 2 CVEs
  • 2023.11.06. Samsung releases semiconductor security bulletin
  • 2023.12.07. Blogpost referencing the CVE released, vulnerability details withheld
  • 2024.01.20. Talk selected for CanSecWest
  • 2024.03.13. Samsung requests information about disclosure at CanSecWest, TASZK confirms details
  • 2024.03.20. Vulnerabilities published at CanSecWest
  • 2024.05.26. Vulnerabilities published at GeekCon
  • 2024.07.29. Advisory release