home intel cve-2025-64893-dng-sdk-oob-read-memory-exposure
CVE Analysis 2025-12-09 · 8 min read

CVE-2025-64893: DNG SDK OOB Read Exposes Process Memory

Adobe DNG SDK ≤1.7.0 contains an out-of-bounds read in IFD/tile parsing that leaks heap memory and can crash the host application when processing a malformed DNG file.

#out-of-bounds-read#memory-exposure#dng-sdk#file-format-vulnerability#denial-of-service
Technical mode — for security professionals
▶ Attack flow — CVE-2025-64893 · Memory Corruption
ATTACKERRemote / unauthMEMORY CORRUPTIOCVE-2025-64893Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2025-64893 is an out-of-bounds read in Adobe DNG SDK versions 1.7.0 and earlier. The SDK ships as an embeddable C++ library consumed by Lightroom, Camera Raw, and dozens of third-party RAW processors. A crafted .dng file can force the parser to read past the end of a heap-allocated tile or strip buffer, leaking adjacent heap contents to any downstream operation that serialises the decoded pixel data — and crashing the process when the read crosses an unmapped page boundary.

CVSS 7.1 (HIGH) reflects the combination of attacker-controlled file content, reliable memory disclosure, and the requirement for a single user action: opening the malicious file.

Affected Component

The DNG SDK exposes a hierarchical IFD parser built around dng_ifd and a tile/strip reader in dng_image_writer / dng_image_reader. The relevant path for this vulnerability is the tile data reader inside dng_host-driven decode pipelines:

  • dng_ifd::ReadStage1Image() — orchestrates per-tile reads
  • dng_stream::Get() — low-level byte reader with a caller-supplied length
  • dng_ifd::TileByteCount() — returns the on-disk byte count for a tile

The mismatch between the on-disk byte count (attacker-controlled via the TileByteCounts IFD tag) and the allocated buffer size (derived from uncompressed pixel dimensions) is where the read escapes its allocation.

Root Cause Analysis

Root cause: dng_ifd::ReadStage1Image() allocates a decode buffer sized by uncompressed tile dimensions but passes the raw on-disk TileByteCount — an attacker-controlled IFD field — as the read length to dng_stream::Get(), allowing reads beyond the allocation boundary.

The IFD tag TileByteCounts (tag 0x0145) stores the compressed size of each tile as written to disk. For uncompressed or losslessly-compressed DNG, the SDK compares this against the expected uncompressed size as a sanity check. The bug manifests when this check is either absent or bypassable via a specially-crafted compression type field that causes the SDK to trust the on-disk count verbatim:


// dng_ifd.cpp — ReadStage1Image (reconstructed pseudocode, SDK ≤1.7.0)
void dng_ifd::ReadStage1Image(dng_host       &host,
                               dng_stream     &stream,
                               dng_image      &image,
                               uint32          tileIndex)
{
    // Uncompressed buffer size derived from pixel dimensions — correct
    uint32 tileWidth  = TileWidth();
    uint32 tileHeight = TileLength();
    uint32 pixelSize  = (fBitsPerSample[0] + 7) >> 3;
    uint32 bufferSize = tileWidth * tileHeight * fSamplesPerPixel * pixelSize;

    AutoPtr block(host.Allocate(bufferSize));
    uint8 *dst = block->Buffer_uint8();

    // On-disk byte count for this tile — comes directly from TileByteCounts IFD tag
    uint64 byteCount = TileByteCount(tileIndex);   // attacker-controlled

    stream.SetReadPosition(TileOffset(tileIndex));

    // BUG: byteCount is NOT clamped to bufferSize before the read.
    // If byteCount > bufferSize, Get() reads past the end of 'block'.
    stream.Get(dst, (uint32)byteCount);            // <-- OOB READ HERE
}

The dng_memory_block allocator wraps operator new[]; the block sits on the process heap. When byteCount exceeds bufferSize, dng_stream::Get() memcpy-reads past the allocation into adjacent heap metadata or other live objects.

The codec path matters: when fCompression == ccUncompressed (tag value 1) or certain lossless JPEG variants, no decompressor interposes between the stream read and the destination buffer. This makes the raw-length path reachable without triggering the JPEG decompressor's own length checks.


// dng_ifd.cpp — TileByteCount() — pure IFD field accessor, no validation
uint64 dng_ifd::TileByteCount(uint32 index) const
{
    if (fTileByteCountsType == ttLong)
        return fTileByteCountsLong[index];   // raw IFD LONG array value
    else
        return fTileByteCountsShort[index];  // raw IFD SHORT array value
    // BUG: no upper-bound check against expected uncompressed size
}

Memory Layout

The following diagram reflects a typical 64-bit host process heap state during a ReadStage1Image() call for a 64×64 px, 16-bit, 3-sample uncompressed tile. bufferSize = 64 × 64 × 3 × 2 = 24576 (0x6000) bytes. The malicious DNG sets TileByteCount[0] = 0x7800, an overread of 0x1800 bytes.


HEAP STATE — BEFORE dng_stream::Get() CALL:
┌──────────────────────────────────────────────────────────────┐
│  dng_memory_block header (operator new[])                    │
│    fAllocatedSize  = 0x6000                                  │
│    fBuffer         = → [tile pixel data region]              │
├──────────────────────────────────────────────────────────────┤
│  [+0x0000] tile pixel buffer   (0x6000 bytes allocated)      │
│            ← dst pointer starts here                         │
│            ← valid read region ends at +0x5FFF               │
├──────────────────────────────────────────────────────────────┤
│  [+0x6000] NEXT HEAP OBJECT: dng_memory_block (metadata buf) │
│    fAllocatedSize  = 0x200                                    │
│    fBuffer         = → [EXIF / XMP staging buffer]           │
│            ← byteCount=0x7800 read crosses into here         │
└──────────────────────────────────────────────────────────────┘

HEAP STATE — AFTER dng_stream::Get() (byteCount = 0x7800):
┌──────────────────────────────────────────────────────────────┐
│  tile pixel buffer [0x0000 – 0x5FFF]  ← legitimately written │
├──────────────────────────────────────────────────────────────┤
│  [+0x6000 – +0x77FF]  OOB READ: 0x1800 bytes of adjacent    │
│    heap contents copied into dst[0x6000..0x77FF]             │
│    MAY CONTAIN:                                              │
│      - heap allocator metadata (size, fwd/bck pointers)      │
│      - dng_memory_block fields from EXIF/XMP buffer          │
│      - stack-spilled pointers from prior decode calls        │
│      - ASLR-defeating image base / heap base addresses       │
└──────────────────────────────────────────────────────────────┘

CRASH SCENARIO (byteCount pushed to page boundary + 1):
  stream.Get() → memcpy(dst, src, 0xF001)
  src + 0xF000 = last mapped byte of heap arena
  src + 0xF001 = unmapped → SIGSEGV / ACCESS_VIOLATION
  rip/pc = inside memcpy (libc / ntdll)

Exploitation Mechanics

For an attacker targeting information disclosure rather than crash, the goal is to size the overread precisely so it lands in a live allocation containing useful pointers, then recover that data through any output channel the calling application exposes (thumbnail render, metadata display, error message, log file).


EXPLOIT CHAIN — memory disclosure via crafted DNG:

1. Craft a DNG with a single 64×64 uncompressed tile.
     IFD fields to set:
       TileWidth          = 64      (0x0142 = 0x0040)
       TileLength         = 64      (0x0143 = 0x0040)
       BitsPerSample      = 16      → bufferSize = 0x6000
       Compression        = 1       (ccUncompressed)
       TileByteCounts[0]  = 0x7800  → overread of 0x1800 bytes

2. Pad the on-disk tile data to exactly 0x7800 bytes so
   stream.Get() reads the full requested length without
   hitting a stream EOF before the heap overread occurs.

3. Target application (e.g. Lightroom, a custom RAW tool)
   calls ReadStage1Image() → dng_stream::Get() reads 0x7800
   bytes into the 0x6000-byte block, pulling 0x1800 bytes
   of adjacent heap into dst[].

4. The decoded "image" pixel data at rows 96–127 (the
   overread region mapped onto pixel rows) now contains
   raw heap bytes. Any path that serialises pixel data
   back to the attacker — e.g. a JPEG thumbnail export,
   a preview render returned via API, or an error-path
   log — leaks those bytes.

5. From 0x1800 bytes of heap, recover:
     - Heap base address (from allocator metadata)
     - Image base / DSO base (from vtable pointers
       in adjacent dng_memory_block or dng_image objects)
   → Sufficient to defeat ASLR for a follow-on write primitive.

6. For DoS: set TileByteCounts[0] such that
   TileOffset[0] + byteCount crosses a page boundary.
   stream.Get() → memcpy faults → process terminates.

Patch Analysis

The correct fix clamps byteCount to bufferSize before the Get() call, and separately validates that the IFD-declared byte count is not unreasonably larger than the expected uncompressed size. A secondary fix adds the missing validation inside TileByteCount().


// BEFORE (vulnerable — SDK ≤1.7.0):
uint64 byteCount = TileByteCount(tileIndex);
stream.SetReadPosition(TileOffset(tileIndex));
stream.Get(dst, (uint32)byteCount);   // no bounds check on byteCount


// AFTER (patched):
uint64 byteCount = TileByteCount(tileIndex);

// Primary fix: reject tiles where on-disk size exceeds a
// reasonable multiple of the uncompressed buffer size.
// For uncompressed data they must be equal; for lossless
// compressed data a 2× slack is generous.
uint64 maxAllowed = (uint64)bufferSize * 2;
if (byteCount > maxAllowed) {
    ThrowBadFormat();   // dng_exception — surfaces as decode error
}

stream.SetReadPosition(TileOffset(tileIndex));

// Secondary fix: read only up to bufferSize bytes regardless.
// Decompressors that need the full on-disk byte count receive
// byteCount separately; the destination buffer is never exceeded.
uint32 readLen = (uint32)min(byteCount, (uint64)bufferSize);
stream.Get(dst, readLen);

// TileByteCount() — added upper-bound sanity gate:

// BEFORE:
uint64 dng_ifd::TileByteCount(uint32 index) const {
    if (fTileByteCountsType == ttLong)
        return fTileByteCountsLong[index];
    return fTileByteCountsShort[index];
}

// AFTER:
uint64 dng_ifd::TileByteCount(uint32 index) const {
    uint64 count;
    if (fTileByteCountsType == ttLong)
        count = fTileByteCountsLong[index];
    else
        count = fTileByteCountsShort[index];

    // BUG FIX: reject absurd byte counts before they reach the reader.
    // kMaxTileBytes = 256 MB; no valid DNG tile exceeds this.
    if (count > kMaxTileBytes) {
        ThrowBadFormat();
    }
    return count;
}

Detection and Indicators

Static file indicators: Parse the DNG IFD chain and flag any tile where TileByteCounts[i] > TileWidth × TileLength × SamplesPerPixel × ceil(BitsPerSample/8) by more than the compression ratio could plausibly explain. For Compression == 1 (uncompressed), any deviation is malicious.


import struct, sys

def check_dng_tile_bytecounts(path):
    """Flag DNG files where TileByteCounts exceed uncompressed tile size."""
    with open(path, "rb") as f:
        data = f.read()

    # Minimal IFD walk — assumes little-endian, no chain following
    endian = "<" if data[:2] == b"II" else ">"
    offset = struct.unpack_from(endian + "I", data, 4)[0]
    nentries = struct.unpack_from(endian + "H", data, offset)[0]

    ifd = {}
    for i in range(nentries):
        base = offset + 2 + i * 12
        tag, typ, cnt, val = struct.unpack_from(endian + "HHII", data, base)
        ifd[tag] = (typ, cnt, val)

    tile_w    = ifd.get(0x0142, (None, None, 0))[2]
    tile_h    = ifd.get(0x0143, (None, None, 0))[2]
    bps       = ifd.get(0x0102, (None, None, 8))[2]
    spp       = ifd.get(0x0115, (None, None, 1))[2]
    compress  = ifd.get(0x0103, (None, None, 1))[2]

    uncompressed = tile_w * tile_h * spp * ((bps + 7) // 8)

    if 0x0145 in ifd:
        typ, cnt, val_off = ifd[0x0145]
        fmt = endian + ("I" if typ == 4 else "H")
        sz  = struct.calcsize(fmt)
        for i in range(cnt):
            bc = struct.unpack_from(fmt, data, val_off + i * sz)[0]
            ratio = bc / max(uncompressed, 1)
            if compress == 1 and bc != uncompressed:
                print(f"[!] SUSPICIOUS tile {i}: TileByteCount={bc:#x} "
                      f"!= uncompressed={uncompressed:#x} (Compression=1)")
            elif ratio > 2.0:
                print(f"[!] SUSPICIOUS tile {i}: TileByteCount={bc:#x} "
                      f"is {ratio:.1f}x uncompressed size")

if __name__ == "__main__":
    check_dng_tile_bytecounts(sys.argv[1])

Runtime indicators: ASan will report a heap-buffer-overflow read with READ of size N inside dng_stream::Getmemcpy. On non-instrumented builds, expect SIGSEGV inside memcpy or libc's inlined variant with rsi/x1 pointing just past a heap arena boundary. Crash dumps will show the faulting PC inside a memory-copy routine called from dng_ifd::ReadStage1Image.

Remediation

  • Update immediately: Upgrade to DNG SDK 1.7.1 or later (or the patched version distributed with the relevant Adobe product update).
  • Integrators: If you vendor the DNG SDK source, apply the byteCount clamp and ThrowBadFormat() guard in dng_ifd::ReadStage1Image() and dng_ifd::TileByteCount() as described above.
  • Mitigations (if patching is delayed): Run DNG decode in a sandboxed process with no network/IPC egress to prevent leak exfiltration. On Linux, a seccomp-bpf filter that kills the process on write() after a failed decode is effective. On Windows, an ACG + CFG policy on the decode worker achieves similar containment.
  • Fuzzing: The IFD integer fields (TileByteCounts, TileOffsets, TileWidth, TileLength) are high-value mutation targets. libFuzzer with ASan on dng_validate (the SDK's bundled test tool) will reproduce this class of bug within minutes of campaign start.
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 →