home intel cve-2026-5445-dicom-palette-oob-read-android
CVE Analysis 2026-04-09 · 8 min read

CVE-2026-5445: DICOM Palette OOB Read Leaks Heap via Android Image Decoder

DecodeLookupTable in DicomImageDecoder.cpp fails to bounds-check pixel indices against palette size, exposing heap memory through crafted PALETTE COLOR DICOM images on Android.

#out-of-bounds-read#lookup-table-decoding#palette-color-image#dicom-image-processing#memory-corruption
Technical mode — for security professionals
▶ Attack flow — CVE-2026-5445 · Memory Corruption
ATTACKERRemote / unauthMEMORY CORRUPTIOCVE-2026-5445Android · CRITICALCODE EXECArbitrary codeas target processCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-5445 is a CVSS 9.1 critical out-of-bounds read in Android's DICOM image decoding pipeline. The vulnerability resides in DecodeLookupTable within DicomImageDecoder.cpp and is triggered when processing PALETTE COLOR photometric interpretation images. When a crafted DICOM file contains pixel index values exceeding the declared lookup table (LUT) size, the decoder reads beyond the allocated LUT buffer, writing raw heap bytes directly into the output image raster. An attacker who can deliver a malicious DICOM file — via a medical imaging app, file share, or MMS — to a target device can exfiltrate heap contents from the decoding process's address space.

Root cause: DecodeLookupTable indexes into the LUT buffer using raw pixel values without validating that those values are less than the allocated table entry count, allowing heap memory past the LUT to be read and surfaced in decoded image output.

Affected Component

The bug lives in the Android media framework's DICOM decoder, specifically the palette-color expansion path. The decoder is reachable from any context that calls into the image decoding pipeline — including privileged system processes that handle medical imaging attachments. Refer to the NVD entry for the full list of affected Android versions. The code path is compiled into libdicomdecoder.so (or equivalent vendor-bundled library).

Key identifiers:

  • File: DicomImageDecoder.cpp
  • Function: DecodeLookupTable
  • Trigger tag: DICOM tag (0028,0004) — Photometric Interpretation = PALETTE COLOR
  • LUT tags: (0028,1101), (0028,1201) (Red LUT Descriptor + Data)

Root Cause Analysis

The DICOM standard defines palette color images as having three lookup tables (Red, Green, Blue). Each LUT descriptor specifies the number of entries and the bit depth. DecodeLookupTable allocates output buffers based on the declared descriptor, then iterates over raw pixel data, using each pixel value as a direct index into the LUT. The missing piece: no check that pixel_value < lut_entry_count.


// DicomImageDecoder.cpp — DecodeLookupTable (reconstructed from disassembly)

static bool DecodeLookupTable(
    const uint8_t  *pixel_data,   // raw pixel indices from DICOM pixel data tag
    size_t          pixel_count,
    const uint16_t *lut_red,      // allocated from LUT Data tag (0028,1201)
    const uint16_t *lut_green,
    const uint16_t *lut_blue,
    uint32_t        lut_entry_count,  // from LUT Descriptor (0028,1101)[0]
    uint8_t        *out_rgba)
{
    for (size_t i = 0; i < pixel_count; i++) {
        uint16_t idx = ((const uint16_t *)pixel_data)[i];

        // BUG: idx is not validated against lut_entry_count before use as array index.
        // If idx >= lut_entry_count, reads past the end of lut_red/lut_green/lut_blue
        // heap allocations, surfacing adjacent heap memory in the output raster.
        out_rgba[i * 4 + 0] = lut_red[idx]   >> 8;
        out_rgba[i * 4 + 1] = lut_green[idx] >> 8;
        out_rgba[i * 4 + 2] = lut_blue[idx]  >> 8;
        out_rgba[i * 4 + 3] = 0xFF;
    }
    return true;
}

The allocation site confirms the buffer is sized strictly to the declared descriptor count:


// Earlier in the DICOM parse path — LUT allocation (simplified)
uint32_t lut_entry_count = ReadUint16(descriptor_tag);   // attacker-controlled field
if (lut_entry_count == 0) lut_entry_count = 65536;       // DICOM spec edge case

size_t lut_bytes = lut_entry_count * sizeof(uint16_t);

uint16_t *lut_red   = (uint16_t *)malloc(lut_bytes);     // e.g. 0x200 bytes for 256 entries
uint16_t *lut_green = (uint16_t *)malloc(lut_bytes);
uint16_t *lut_blue  = (uint16_t *)malloc(lut_bytes);

// Pixel data is then parsed separately from tag (7FE0,0010).
// An attacker declares lut_entry_count=256 but encodes pixel indices up to 0xFFFF.
// DecodeLookupTable is called — no re-validation of idx vs lut_entry_count.

The attacker controls two independent DICOM fields: the LUT Descriptor (which governs allocation size) and the Pixel Data (which supplies the indices). Decoupling these with no cross-validation is the root failure.

Memory Layout

Consider a crafted image declaring lut_entry_count = 256 (0x100 entries → 0x200 bytes per channel) while embedding pixel indices of 0x1000 (4096). Each LUT is a small heap chunk; adjacent chunks hold allocator metadata or other decoder-internal buffers.

Relevant struct layout for the LUT allocation header (Scudo/jemalloc-class Android allocator):


// Approximate Android Scudo chunk header (16-byte header before user data)
struct scudo_chunk_header {
    /* +0x00 */ uint64_t  checksum_and_meta;  // class_id, state, checksum packed
    /* +0x08 */ uint32_t  offset;             // offset from chunk start to user data
    /* +0x0C */ uint32_t  unused;
    /* +0x10 */ uint16_t  lut_data[];         // user data — LUT entries begin here
};

HEAP STATE — lut_entry_count=256 (0x200 bytes/channel)

  [chunk A] lut_red   @ 0xb4001f8000  size=0x200  (512 bytes, 256 x uint16_t)
  [chunk B] lut_green @ 0xb4001f8210  size=0x200
  [chunk C] lut_blue  @ 0xb4001f8420  size=0x200
  [chunk D] scratch_buf @ 0xb4001f8630  size=0x800  ← decoder-internal buffer
             contains: partial JPEG2000 tile data / OpenSSL BN state / etc.

PIXEL INDEX idx=0x1000 (4096) — read via lut_red[0x1000]:
  base:  0xb4001f8000 + (0x1000 * 2) = 0xb4001f8000 + 0x2000
       = 0xb4002f8000  ← FAR past chunk A

  In practice with tighter spacing (idx=0x108):
  lut_red[0x108] = *(uint16_t*)(0xb4001f8000 + 0x210)
                 = read 2 bytes from start of chunk B's Scudo header
                 → leaks allocator metadata / pointer fragments

AFTER DECODE — out_rgba raster:
  pixels 0x00–0xFF  : legitimate LUT-mapped colors
  pixels 0x100+     : raw heap bytes reinterpreted as RGB(A) color values
                      → heap leak embedded in image output

Exploitation Mechanics


EXPLOIT CHAIN — heap disclosure via crafted DICOM PALETTE COLOR image:

1. Craft DICOM file:
   - Tag (0028,0004): "PALETTE COLOR"
   - Tag (0028,1101): LUT Descriptor = [256, 0, 16]  → declares 256-entry LUT
   - Tag (0028,1201): Red LUT Data = 256 x uint16_t  (0x200 bytes, legitimate)
   - Tags (0028,1202),(0028,1203): Green/Blue LUT Data similarly
   - Tag (7FE0,0010): Pixel Data containing 16-bit indices in range [0x100..0x13F]
     These 64 "pixels" index 0x100–0x13F into lut_red (declared size: 0x100 entries)
     → reads 0x00–0x7E bytes past end of lut_red into adjacent chunk

2. Deliver file to target via medical imaging app, file provider, or attachment.
   The app calls into the DICOM decoder; DecodeLookupTable executes.

3. Decoder writes out_rgba to a Bitmap object returned to Java layer.
   The Bitmap is renderable, saveable, and — critically — its pixel buffer is
   accessible via Bitmap.copyPixelsToBuffer() or Canvas rendering output.

4. Attacker reads the returned Bitmap pixels. Each out-of-bounds heap byte
   is encoded in the R/G/B channel of the corresponding output pixel:
     pixel[0x100].red   = *(heap + 0x200) >> 8
     pixel[0x100].green = *(heap + 0x200) [green LUT OOB equivalent]
     ...

5. Reconstruct heap bytes:
   leaked_byte[i] = (pixel[256 + i].red & 0xFF)   (8-bit precision post >>8)
   Repeat with 16-bit LUT entries for full word reconstruction.

6. With heap pointer fragments recovered, defeat ASLR and chain into a
   subsequent write primitive (e.g., type confusion in the same decoder)
   for full compromise. This step requires a separate bug; CVE-2026-5445
   alone is a disclosure primitive.

Patch Analysis

The correct fix is a single bounds check inserted before the LUT index dereference. A secondary hardening is to clamp rather than reject, avoiding denial-of-service on slightly malformed but benign files — though strict rejection is more correct for a security boundary.


// BEFORE (vulnerable) — DicomImageDecoder.cpp:
static bool DecodeLookupTable(
    const uint8_t  *pixel_data,
    size_t          pixel_count,
    const uint16_t *lut_red,
    const uint16_t *lut_green,
    const uint16_t *lut_blue,
    uint32_t        lut_entry_count,
    uint8_t        *out_rgba)
{
    for (size_t i = 0; i < pixel_count; i++) {
        uint16_t idx = ((const uint16_t *)pixel_data)[i];
        // No bounds check — idx may exceed lut_entry_count
        out_rgba[i * 4 + 0] = lut_red[idx]   >> 8;
        out_rgba[i * 4 + 1] = lut_green[idx] >> 8;
        out_rgba[i * 4 + 2] = lut_blue[idx]  >> 8;
        out_rgba[i * 4 + 3] = 0xFF;
    }
    return true;
}

// AFTER (patched):
static bool DecodeLookupTable(
    const uint8_t  *pixel_data,
    size_t          pixel_count,
    const uint16_t *lut_red,
    const uint16_t *lut_green,
    const uint16_t *lut_blue,
    uint32_t        lut_entry_count,
    uint8_t        *out_rgba)
{
    if (lut_entry_count == 0) return false;  // additional hardening

    for (size_t i = 0; i < pixel_count; i++) {
        uint16_t idx = ((const uint16_t *)pixel_data)[i];

        // FIX: reject any pixel index that would read outside the allocated LUT.
        // Strict mode: fail the entire decode on malformed input.
        if ((uint32_t)idx >= lut_entry_count) {
            ALOGE("DecodeLookupTable: idx %u >= lut_entry_count %u, aborting",
                  (unsigned)idx, lut_entry_count);
            return false;
        }

        out_rgba[i * 4 + 0] = lut_red[idx]   >> 8;
        out_rgba[i * 4 + 1] = lut_green[idx] >> 8;
        out_rgba[i * 4 + 2] = lut_blue[idx]  >> 8;
        out_rgba[i * 4 + 3] = 0xFF;
    }
    return true;
}

An alternative clamping approach (idx = MIN(idx, lut_entry_count - 1)) prevents the OOB read while producing a decodable (though color-incorrect) image. For a security-critical parser, hard rejection is preferable — callers should handle false return by refusing to display the image.

Detection and Indicators

Static file indicators — a crafted DICOM file will exhibit:

  • Tag (0028,0004) = PALETTE COLOR
  • LUT Descriptor (0028,1101)[0] declares N entries
  • Pixel Data (7FE0,0010) contains 16-bit values ≥ N
  • Unusually small LUT size relative to declared bit depth (e.g., 256 entries with 16-bit pixel depth)

Runtime detection — on a patched build, logcat will emit:


E DicomImageDecoder: DecodeLookupTable: idx 4096 >= lut_entry_count 256, aborting
E BitmapFactory: DICOM decode returned null — malformed PALETTE COLOR LUT

Crash signatures on unpatched builds — if heap layout causes the OOB read to hit an unmapped page (uncommon but possible with large indices):


signal 11 (SIGSEGV), code 1 (SEGV_MAPERR)
fault addr 0xb4002f8000
    x0  0xb4001f8000  x1  0x0000000000000100  x2  0x0000000000001000
    ...
backtrace:
  #00 pc 0x0001a3c8  /system/lib64/libdicomdecoder.so (DecodeLookupTable+0x94)
  #01 pc 0x00019f10  /system/lib64/libdicomdecoder.so (DicomImageDecoder::Decode+0x2c4)
  #02 pc 0x0000e844  /system/lib64/libimageio.so

YARA rule for malicious DICOM files:


rule DICOM_Palette_OOB_Exploit {
    meta:
        description = "DICOM PALETTE COLOR with oversized pixel indices (CVE-2026-5445)"
        author      = "CypherByte Research"
        cvss        = "9.1"
    strings:
        $dicom_magic  = { 44 49 43 4D }                        // "DICM" preamble offset 128
        $palette_tag  = { 50 41 4C 45 54 54 45 20 43 4F 4C 4F 52 }  // "PALETTE COLOR"
    condition:
        $dicom_magic at 128 and $palette_tag
        // Further: parse (7FE0,0010) and check for uint16 values > (0028,1101)[0]
}

Remediation

Apply vendor patches as referenced in the NVD entry for CVE-2026-5445. If patches are unavailable for your Android version:

  • Disable DICOM file handling at the app layer — reject files with application/dicom MIME type or .dcm extension at the intake boundary.
  • Sandbox the decoder — process DICOM files in an isolated process with seccomp filtering and no access to sensitive heap neighbors (keys, tokens, session data). A heap disclosure in a sandboxed decoder with no valuable neighbors has near-zero impact.
  • Enable hardware MTE (Memory Tagging Extension) on ARMv8.5+ devices — MTE will fault on the first out-of-bounds LUT access, converting the information disclosure into a clean abort.
  • App developers using third-party DICOM libraries should audit DecodeLookupTable equivalents in their own dependency trees; this pattern (LUT index without bounds check) is common in medical imaging codebases predating security review.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →