There is a kernel virtual memory mapped IO buffer overflow vulnerability in the vision DSP kernel driver of S20 Exynos devices. The vulnerability could potentially be used by a malicious system application to compromise the kernel and gain further privileges.

The vulnerability is only triggerable via compromised system applications due to a second access control bypass issue. In addition to achieving code execution in the kernel, the access control bypass issue itself may also be used by compromised system applications to directly take complete control over the DSP device itself.

Vulnerability Details

The Exynos DSP driver implements an ioctl call that allows applications to upload a custom model (graph) for the dsp device. During the processing of the provided elf files, the device driver reserves memory for the data and text sections in the dsp device’s program and data memory. The device memory is accessed through memory mapped IO regions, that are mapped into the kernel virtual memory. Due to multiple integer overflows during the processing of the provided elf files it is possible to overflow these regions with controlled data.

When the DSP_IOC_LOAD_GRAPH ioctl is called on /dev/dsp character device, the selected elf files are loaded into the kernel memory and the elf sections are parsed. Eventually, the dsp_dl_load_libraries function is called that calls the dsp_pm_manager_alloc_libs, dsp_dl_out_manager_alloc_libs and dsp_lib_manager_load_libs functions respectively. The dsp_pm_manager_alloc_libs function calculates the sum size of all text sections and allocates an appropriate region from the device’s program memory. The dsp_dl_out_manager_alloc_libs does the same with the various data sections and also sets their offsets within this region. Finally, dsp_lib_manager_load_libs copies the data from the elf file to the allocated regions.

The complete callchain to the dsp_dl_load_libraries function:

  • dsp_context_load_graph
  • dsp_graph_load
  • __dsp_graph_add_kernel
  • dsp_kernel_load
  • dsp_dl_load_libraries

There are multiple integer overflows while the size of the program and data memory is calculated. As a result the size of the reserved memory can be smaller than the sum of the different sections, and the offsets can point out from the valid destination memory regions. For the program memory the dsp_elf32_get_text_size function and for the data memory the dsp_elf32_get_mem_size function is used to calculate the appropriate section sizes. Both of these internally call __dsp_elf32_get_section_list_size which contains an integer overflow.

static unsigned int __dsp_elf32_get_section_list_size(
  struct dsp_list_head *head, struct dsp_elf32 *elf)
{
  // 1. the sum size is stored on a 4 byte value
  unsigned int total = 0;
  struct dsp_list_node *node;

  // 2. iterate all the relevant section headers
  dsp_list_for_each(node, head) {
    struct dsp_elf32_idx_node *idx_node =
      container_of(node, struct dsp_elf32_idx_node, node);
    struct dsp_elf32_shdr *sec = elf->shdr + idx_node->idx;
    // 3. this is an arbitrary 4 byte value
    unsigned int size = sec->sh_size;

    // 4. the addition can overflow
    total += __dsp_elf32_align_4byte(size);
  }
  return total;
}

The direct consequence of this overflow is that the size returned by the dsp_elf32_get_text_size and dsp_elf32_get_mem_size functions could be arbitrary large or small. Meanwhile the size of the individual sections can also have arbitrary sizes as their number is not limited and there are no further checks on them. The means that the returned sum size used for the allocation can be much smaller than the actual sum size of the sections.

A recent security patch (May 2021 Security Update) added extra checks to the copy functions (__dsp_lib_manager_load_pm, __dsp_lib_manager_load_bss_sec and __dsp_lib_manager_load_sec), that are called from dsp_lib_manager_load_libs. These extra checks are meant to ensure that the source pointer falls within the elf file and the destination remains within the bounds of the appropriate memory mapped IO region. These extra checks are now sufficient to prevent program memory overflow, however for the data memory the situation is more complicated and the code is still susceptible to overflow in the end. In the following I describe how, despite the new checks, the memory corruption can still be achieved.

Before analyzing the actual vulnerabilities I would like to define a terminology for the various data memory components. The kernel virtual memory mapped, device data memory will be referred to as the “data memory region”. This is part of the kernel virtual address space and it is the valid target of the initialization. The data sections are named in the elf files conforming to the following convention .data_memory_type_name.section_name. Where the data memory type can be one of the following values: DMb, DMb_local, TCMb, TCMb_local, and SFRw. This advisory will refer to these types as “segments”. The section name can be either bss, robss, data, or rodata. These will be called “sections”. The same segment can have multiple sections with the same name.

During the data memory allocation, offset calculation, and initialization there are a series of integer over and underflows that allow:

  • Arbitrary sized overflows from the data memory region
  • Arbitrary sized writes at an arbitrary offset from the end of the data memory region
  • Bypass of the newly introduced checks that are meant to prevent these scenarios

The entry point of the data memory setup for each elf library is the dsp_dl_out_alloc function. See the annotated digest of the function’s code below. This function first calls the dsp_dl_out_create function (see comment [1]) to calculate the offset and size of each segment within the data memory region. The size of the segment equals to the sum of the size of each data section belonging to the given segment. This is calculated by the above detailed dsp_elf32_get_mem_size function for each segment. The offset is calculated based on the sizes of the previous segments and some additional metadata stored at the beginning of the data memory.

int dsp_dl_out_alloc(struct dsp_lib *lib, int *pm_inv)
{
  size_t dl_out_size;
  [...]

  // [1] the segment offsets and sizes are calculated
  ret = dsp_dl_out_create(lib);
  [...]

  // [2] the allocation size is calculated based on the segment offsets and sizes
  dl_out_size = dsp_dl_out_get_size(lib->dl_out);
  DL_DEBUG("DL_out_size : %zu\n", dl_out_size);

  // [3] the minimum size accepted is 32 bytes (block size)
  alloc_ret = __dsp_dl_out_alloc_mem(dl_out_size, lib,
      pm_inv);
  [...]

  // [4] the size of the data segments is saved for later bound check
  lib->dl_out_data_size = dl_out_size - sizeof(*lib->dl_out);

  [...]

}

Once the segment offsets and sizes are saved dsp_dl_out_get_size is called (at [2]) to calculate the total size of memory that needs to be reserved from the “data memory region”. The __dsp_dl_out_alloc_mem function (at [3]) simply tries to reserve a contiguous memory block from the data memory region for the requested size. If this function fails the loading aborts. Finally, the size of the segments is saved without the extra metadata size (at [4]) to be used later for bound checks.

During these steps there are a number of integer over/under-flows. First, dsp_dl_out_create uses the dsp_elf32_get_mem_size function to calculate the size of each segment by adding together the size of the sections belonging to it. As introduced earlier this function has an integer overflow, which means that the returned size and offset can be small, while there are larger sized sections within. Furthermore, the offset is also stored on a 4 byte variable which can overflow. As a result, the offsets are not necessarily ascending and there could be arbitrary large offsets in the middle while the final offset remains sane (or also arbitrary).

int dsp_dl_out_create(struct dsp_lib *lib)
{
  unsigned int offset = 0;
  [...]

  lib->dl_out->DM_sh.offset = offset;
  // the returned size could be an arbitrary value here
  lib->dl_out->DM_sh.size = dsp_elf32_get_mem_size(&lib->elf->DMb,
      lib->elf);
  // this offset can overwrap after any of these additions
  offset += lib->dl_out->DM_sh.size;
  offset = __dsp_dl_out_offset_align(offset);

  lib->dl_out->DM_local.offset = offset;
  lib->dl_out->DM_local.size = dsp_elf32_get_mem_size(
      &lib->elf->DMb_local, lib->elf);
  offset += lib->dl_out->DM_local.size;
  offset = __dsp_dl_out_offset_align(offset);

  lib->dl_out->TCM_sh.offset = offset;
  lib->dl_out->TCM_sh.size = dsp_elf32_get_mem_size(&lib->elf->TCMb,
      lib->elf);
  offset += lib->dl_out->TCM_sh.size;
  offset = __dsp_dl_out_offset_align(offset);

  lib->dl_out->TCM_local.offset = offset;
  lib->dl_out->TCM_local.size = dsp_elf32_get_mem_size(
      &lib->elf->TCMb_local, lib->elf);
  offset += lib->dl_out->TCM_local.size;
  offset = __dsp_dl_out_offset_align(offset);

  lib->dl_out->sh_mem.offset = offset;

  lib->dl_out->sh_mem.size = dsp_elf32_get_mem_size(&lib->elf->SFRw,
      lib->elf);
  return 0;
}

The allocation size is calculated in dsp_dl_out_get_size (see [5]) based on the fixed size of the dl_out structure and the last segment offset and size saved in dsp_dl_out_create. These values can be arbitrary providing no guarantees that previous offsets and sizes were sane and even less guarantees that individual sections sizes were within bounds. The overflow for the size calculation can even happen at [5] allowing completely arbitrary allocation size values within the range of 0 - 0xffffffff.

size_t dsp_dl_out_get_size(struct dsp_dl_out *dl_out)
{
  // [5] if the sizes chosen carefully the overflow can happen here
  return sizeof(*dl_out) + dl_out->sh_mem.offset + dl_out->sh_mem.size;
}

Once the data memory is reserved in dsp_dl_out_alloc (at [3]), the sum of the size of the segments is saved for future bound checks. The arithmetic used at [4] is also prone to integer underflow. Remember that the dl_out_size is arbitrary due to previous integer overflows, the only constraint is that it needs to be at least 32 bytes which is the minimum block size for the allocation. The size of the (*lib->dl_out) structure is 56 bytes so any size value between 32 and 56 would produce an underflow resulting in a huge dl_out_data_size.

The final piece is the copy function (__dsp_lib_manager_load_bss_sec or __dsp_lib_manager_load_sec) that does the actual data memory region write. It first calculates the destination pointer into the data memory region (at [6]). The lib->dl_out->data is the kernel virtual address of the reserved memory within the data memory region. The sec.offset is the segment’s offset saved in dsp_dl_out_create, essentially an arbitrary controlled value due to the integer overflows. The lib->link_info->sec[ndx] is the individual section’s offset within the segment set up in __dsp_link_info_sec_alloc. This can also be an arbitrary value.

static int __dsp_lib_manager_load_sec(struct dsp_lib *lib,
  struct dsp_list_head *head, struct dsp_dl_out_section sec,
  int rev_endian)
{
  dsp_list_for_each(node, head) {
    struct dsp_elf32_idx_node *idx_node =
        container_of(node, struct dsp_elf32_idx_node, node);
    unsigned int ndx = idx_node->idx;
    struct dsp_elf32_shdr *mem_hdr = elf->shdr + ndx;
    unsigned char *data = (unsigned char *)(elf->data +
        mem_hdr->sh_offset);
    unsigned char *data_end = data + mem_hdr->sh_size;

    // [6] destination pointer is calculated
    unsigned char *dest = lib->dl_out->data + sec.offset +
        lib->link_info->sec[ndx];

    [...]
    // [7] bound check for destination offset
    if (sec.offset + lib->link_info->sec[ndx] + mem_hdr->sh_size >
      lib->dl_out_data_size) {
  DL_ERROR("invalid dest range(%u/%lu/%u/%zu)\n",
        sec.offset,
        lib->link_info->sec[ndx],
        mem_hdr->sh_size,
        lib->dl_out_data_size);
      return -1;
    }

    [...]
    // [8] write to the destination pointer
    memcpy(dest, data, mem_hdr->sh_size);
    [...]
}

The latest security patch added a bound check at [7], however the check itself is prone to integer overflows as all three values on the left side can be arbitrary. Furthermore, as detailed previously, the dl_out_data_size can be arbitrarily large due to the integer underflow at [4]. As a result the bounds check fails to prevent indexing out of the data memory region. Finally, at [8] the controlled data is copied to the controlled offset with a controlled size.

This vulnerability provides a write primitive into the kernel’s vmalloc virtual address space at a controlled arbitrary offset from the original buffer. The size and content of the copied data can also be controlled. The vmalloc address space is where, between many other things, kernel stacks reside. This vulnerability can be abused to overwrite the stack of a kernel process and gain arbitrary code execution within the kernel. Brandon Azad’s article details how kernel stacks can be sprayed deterministically and how a vmap overflow can be exploited until arbitrary code execution. aSiagaming et al. demonstrates how such exploit can be further improved to circumvent modern protection features on Samsung devices.

Access Control

The /dev/dsp character device has a very relaxed permission set, it can be opened and read by anyone due to the DAC permissions. The vendor_dsp_device selinux context allows various application contexts, including untrusted applications to open and issue ioctl to the dsp device. To exploit this vulnerability the content of the loaded elf file and the linker rule file (dsp_reloc_rules.bin) needs to be controlled. While we demonstrated a path traversal vulnerability previously it is not sufficient to reach this point of the parsing, as it provides no control over the rule file.

The request firmware API searches a predefined list of directories and one that is dynamically set at runtime. While previous versions of the device driver used the request_firmware function, it was recently modified to use the request_firmware_direct version without the user space side-loading fallback. Even with the direct version the dynamic path can be changed through the /sys/module/firmware_class/parameters/path file. This can be used to redirect the firmware request API’s search path to an attacker controlled directory. This sysfs file can only be written by system processes due to its DAC permission and it has the sysfs_firmware_class selinux label. Currently only the system_app, ueventd and vendor_init contexts can write this file.

While these attacks are only available from privileged applications, there is a distinct security boundary between the kernel and these apps that can be bridged by this vulnerability.

In addition, the fact that the intended paths can be modified has further security implications. Instead of using it to trigger the integer overflow, this method can also be used to control the content of all the images loaded by the DSP_IOC_BOOT and DSP_IOC_LOAD_GRAPH ioctls. This includes the rule file, the xml global kernel descriptor file, the various libraries and most importantly the DSP system binaries. Because neither the kernel nor the DSP device itself employs any additional layer of image verification on these files, using this method to load such files with chosen contents is a straightforward way to take control over the DSP device, reach a significant attack surface, and revive past bugs. In essence, this sysfs based bypass entirely revives the DSP firmware loading vulnerability that has been previously disclosed (see this article and the March 2021 Security Update).

Affected Devices (Verified)

Samsung S20 Exynos 990, SM-G980F

Fix

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

Timeline

  • 2021.05.25. Bug reported to Samsung Mobile Security, SVE-2021-21958 is assigned
  • 2021.10.01. Samsung releases security bulletin, CVE-2021-25467 is assigned, OTA firmware update distribution begins