home intel cve-2026-5441-orthanc-psmct-rle1-oob-read
CVE Analysis 2026-04-09 · 7 min read

CVE-2026-5441: OOB Read in Orthanc PSMCT_RLE1 Decoder Leaks Heap

The DecodePsmctRle1 function in Orthanc's DicomImageDecoder.cpp fails to validate escape markers near end-of-buffer, leaking heap contents into rendered DICOM image output.

#dicom-decoder#out-of-bounds-read#heap-leak#rle-compression#information-disclosure
Technical mode — for security professionals
▶ Attack flow — CVE-2026-5441 · Memory Corruption
ATTACKERRemote / unauthMEMORY CORRUPTIOCVE-2026-5441Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-5441 is an out-of-bounds read in the DecodePsmctRle1 function inside DicomImageDecoder.cpp, part of the Orthanc DICOM server's image decoding pipeline. The bug lives in the handler for the proprietary Philips PMSCT_RLE1 compressed image format. When the decoder encounters a specially placed escape marker sequence near the tail of the compressed data buffer, it reads past the allocated region and folds that heap memory directly into the decoded pixel output — which the server may then transmit back to a requesting client.

CVSS 7.1 (HIGH). No authentication is required to upload a DICOM file to a default Orthanc instance. No exploitation in the wild has been confirmed as of publication.

Affected Component

  • File: OrthancFramework/Sources/Images/DicomImageDecoder.cpp
  • Function: DecodePsmctRle1
  • Format: Philips Compression PMSCT_RLE1 (DICOM Transfer Syntax)
  • Versions: Orthanc ≤ 1.12.10
  • Trigger: Upload a crafted DICOM file with PMSCT_RLE1 pixel data; request rendering via the REST API

Root Cause Analysis

The PMSCT_RLE1 scheme encodes pixel deltas with single-byte escape markers (0x80) followed by a literal two-byte value. The decoder loops over the compressed byte stream, advancing a pointer on each iteration. The termination check compares the current pointer against the end-of-buffer sentinel before consuming the escape marker, but it does not re-validate after consuming the marker byte itself. If the escape marker sits at position end - 1 or end - 2, the subsequent read of the literal bytes walks one or two bytes past the allocation.

// DicomImageDecoder.cpp — DecodePsmctRle1 (vulnerable, Orthanc <= 1.12.10)
static bool DecodePsmctRle1(std::string&       output,
                             const std::string& input)
{
    const uint8_t* src     = reinterpret_cast(input.data());
    const uint8_t* src_end = src + input.size();   // one-past-end sentinel

    output.resize(/* expected pixel count */ ...);
    uint8_t* dst = reinterpret_cast(&output[0]);

    int16_t  pixel = 0;

    while (src < src_end) {          // bounds check: OK on entry
        uint8_t b = *src++;

        if (b == 0x80) {
            // BUG: no check that (src + 2) <= src_end before reading literal
            uint16_t raw = static_cast(src[0])        // OOB read if src == src_end
                         | static_cast(src[1]) << 8;  // OOB read if src == src_end - 1
            pixel  = static_cast(raw);
            src   += 2;              // advances past the two literal bytes
        } else {
            // delta-encoded byte: sign-extend 7-bit value
            pixel += static_cast(b & 0x7F) * ((b & 0x80) ? -1 : 1);
        }

        *dst++ = static_cast(pixel & 0xFF);
        *dst++ = static_cast((pixel >> 8) & 0xFF);
    }

    return true;
}

The while guard src < src_end is evaluated before decoding b. Once b == 0x80 is detected, the code performs two array accesses — src[0] and src[1] — with no secondary bounds check. An attacker who positions the 0x80 marker at src_end - 1 causes a 2-byte over-read; positioning it at src_end - 2 causes a 1-byte over-read. The read bytes are incorporated into the raw value, written as a decoded pixel, and ultimately returned to the caller as valid image data.

Root cause: DecodePsmctRle1 checks buffer bounds before consuming the escape marker byte but never re-validates that two additional literal bytes remain in the buffer before reading them, allowing a 1–2 byte heap over-read whose contents are embedded in the decoded image output.

Memory Layout

Orthanc decodes pixel data into heap-allocated std::string objects. The compressed input buffer and the decoded output buffer are adjacent heap allocations in typical glibc / jemalloc layouts. The over-read can reach metadata belonging to the next chunk header or, in a fragmented heap, arbitrary heap content.

// Relevant allocation context in DicomImageDecoder.cpp
struct PsmctDecodeContext {
    /* +0x00 */ std::string  compressed;   // attacker-controlled input
    /* +0x18 */ std::string  decoded;      // output written back to client
    /* +0x30 */ size_t       width;
    /* +0x38 */ size_t       height;
    /* +0x40 */ uint32_t     bitsAlloc;
};
// std::string heap buf layout (libstdc++ SSO disabled path):
// [ size_t capacity | size_t length | char data[] ]
// compressed.data() + compressed.size() == src_end
// Bytes immediately past src_end belong to the next allocator chunk header
HEAP STATE — before DecodePsmctRle1 over-read:

  [chunk hdr][ compressed pixel data, N bytes          ][chunk hdr][ decoded output buf ]
              ^                                        ^
              src (start)                          src_end
                                                     ^^
                                 attacker places 0x80 here (src_end - 1)

HEAP STATE — during over-read (marker at src_end - 1):

  ... [ ...| 0x80 ] | [ chunk_size_lo | chunk_size_hi | fd | bk | ... ]
                        ^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^
                        src[0] OOB read  src[1] OOB read
                        both bytes folded into 'raw', written as decoded pixel
                        and transmitted to HTTP client in rendered image response

On a system running glibc ptmalloc2, the two leaked bytes are the low 16 bits of the next chunk's size field (which encodes allocation size plus the PREV_INUSE flag). On jemalloc (common in FreeBSD / Docker deployments of Orthanc), the bytes may overlap run metadata or a pointer's low bytes. Either way, heap layout entropy leaks.

Exploitation Mechanics

EXPLOIT CHAIN — information disclosure via crafted DICOM upload:

1. Attacker crafts a DICOM file with TransferSyntaxUID matching PMSCT_RLE1
   and sets PixelData to a compressed buffer where the final byte is 0x80
   (escape marker placed at src_end - 1).

2. POST /instances  HTTP/1.1
   Content-Type: application/dicom
   [crafted DICOM body, ~512 bytes total]
   Server stores the instance; returns {"ID": "", "Status": "Success"}.

3. GET /instances//frames/0/raw  HTTP/1.1
   Server calls DicomImageDecoder::DecodeUncompressedImage ->
   DecodePsmctRle1 -> over-read of 2 bytes past compressed buffer.

4. Two bytes of heap memory (chunk header / allocator metadata) are folded
   into pixel[last] of the decoded frame and returned in the HTTP response body.

5. Attacker extracts pixel[last] from the raw frame response:
   leaked_word = pixel_data[-2] | (pixel_data[-1] << 8)
   This is the raw uint16_t constructed from the two OOB bytes.

6. Repeated uploads with varying compressed buffer sizes (heap grooming) allow
   the attacker to position known allocations adjacent to the compressed buffer,
   converting the 2-byte leak into a partial heap pointer disclosure.

7. Partial pointer disclosure can defeat ASLR at the heap-base granularity,
   providing an oracle for a follow-on heap exploitation primitive
   (e.g., CVE-2026-5437 or CVE-2026-5438 chained).

Patch Analysis

The correct fix requires a secondary bounds check after consuming the escape marker byte and before reading the two literal bytes. The patched logic must ensure (src_end - src) >= 2 at the point of the literal read, treating a truncated escape sequence as a decode error rather than a silent over-read.

// BEFORE (vulnerable, Orthanc <= 1.12.10):
while (src < src_end) {
    uint8_t b = *src++;

    if (b == 0x80) {
        // No validation: src may be src_end or src_end - 1 here
        uint16_t raw = static_cast(src[0])
                     | static_cast(src[1]) << 8;
        pixel = static_cast(raw);
        src  += 2;
    } else {
        pixel += static_cast(b & 0x7F) * ((b & 0x80) ? -1 : 1);
    }

    *dst++ = static_cast(pixel & 0xFF);
    *dst++ = static_cast((pixel >> 8) & 0xFF);
}

// AFTER (patched):
while (src < src_end) {
    uint8_t b = *src++;

    if (b == 0x80) {
        if ((src_end - src) < 2) {
            // BUG FIX: truncated escape sequence — treat as decode failure
            return false;
        }
        uint16_t raw = static_cast(src[0])
                     | static_cast(src[1]) << 8;
        pixel = static_cast(raw);
        src  += 2;
    } else {
        pixel += static_cast(b & 0x7F) * ((b & 0x80) ? -1 : 1);
    }

    *dst++ = static_cast(pixel & 0xFF);
    *dst++ = static_cast((pixel >> 8) & 0xFF);
}

The fix is minimal and correct: (src_end - src) < 2 uses pointer arithmetic on the same buffer, avoiding integer overflow. Returning false propagates an error up to the caller, which already checks the return value and falls back to an uncompressed path or surfaces a decode error — no secondary patch is needed at the call site.

Detection and Indicators

Network: A DICOM file triggering this path will have TransferSyntaxUID = 1.3.46.670589.33.1.4.1 (Philips RLE) and a PixelData element whose value ends in 0x80. A single-frame file of unusually small size (under 64 bytes of pixel data) combined with this transfer syntax is anomalous.

Application log: With debug logging enabled, a decode failure in the patched version emits a structured error referencing DecodePsmctRle1. In the unpatched version, no log entry is produced — the decode silently succeeds with leaked bytes.

ASAN / sanitizer output from unpatched build:

==AddressSanitizer: heap-buffer-overflow on address 0x6020000002d1
READ of size 1 at 0x6020000002d1 thread T0
    #0 DecodePsmctRle1(std::string&, std::string const&)
         DicomImageDecoder.cpp:LINE
    #1 DicomImageDecoder::DecodeUncompressedImage(...)
    #2 Orthanc::DicomImageDecoder::Decode(...)
0x6020000002d1 is located 1 bytes to the right of 1-byte region
  [0x6020000002d0, 0x6020000002d1)
allocated by thread T0:
    #0 operator new(unsigned long)
    #1 std::__cxx11::basic_string<...>::_M_mutate(...)

Heap grooming indicator: Repeated POST /instances requests with PMSCT_RLE1 files of incrementally varying PixelData sizes (differing by 8–16 bytes) from a single source IP, followed immediately by GET /instances/<uid>/frames/0/raw, is consistent with a heap-layout probing loop.

Remediation

  • Upgrade: Apply the vendor patch addressing CVE-2026-5441 once released for the 1.12.x branch. Monitor the CERT/CC advisory VU#536588 for patch availability.
  • Workaround: If PMSCT_RLE1 / Philips compression is not required in your deployment, disable or reject DICOM instances with TransferSyntaxUID 1.3.46.670589.33.1.4.1 at the ingestion layer via Orthanc's AcceptedTransferSyntaxes configuration key.
  • Defense-in-depth: Run Orthanc inside a memory-safe sandbox (e.g., seccomp-BPF profile or gVisor). Enable ASLR and deploy with -fsanitize=address or equivalent in staging environments to surface additional latent issues across the nine CVEs in this advisory cluster.
  • Audit scope: The same escape-marker pattern is present in related Philips proprietary decoders. Audit any adjacent DecodePsmct* variants in the same file for structurally identical missing-bounds-check patterns.
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 →