Grassroot DICOM RLECodec::DecodeByStreams out-of-bounds read vulnerability
Grassroot DICOM 3.024版本中存在一个越界读取漏洞,攻击者可通过构造恶意DICOM文件触发该漏洞,导致堆内存数据泄露。 2025-12-15 23:59:0 Author: talosintelligence.com(查看原文) 阅读量:0 收藏

SUMMARY

An out-of-bounds read vulnerability exists in the RLECodec::DecodeByStreams functionality of Grassroot DICOM 3.024. A specially crafted DICOM file can lead to leaking heap data. An attacker can provide a malicious file to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

Grassroot DICOM 3.024

PRODUCT URLS

Grassroot DICOM - https://sourceforge.net/projects/gdcm/

CVSSv3 SCORE

7.4 - CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-119 - Improper Restriction of Operations within the Bounds of a Memory Buffer

DETAILS

Grassroots DiCoM is a C++ library for DICOM medical files. It is accessible from Python, C#, Java and PHP. It supports RAW, JPEG, JPEG 2000, JPEG-LS, RLE and deflated transfer syntax. It comes with a super fast scanner implementation to quickly scan hundreds of DICOM files. It supports SCU network operations (C-ECHO, C-FIND, C-STORE, C-MOVE). PS 3.3 & 3.6 are distributed as XML files. It also provides PS 3.15 certificates and password based mecanism to anonymize and de-identify DICOM dataset

A specially crafted DICOM file can trigger an out-of-bounds read in the RLECodec::DecodeByStreams function. This vulnerability is due to the absence of proper size checks, which means the function does not verify whether memory accesses stay within the bounds of the source buffer during processing. As a result, when a malformed DICOM file is loaded, the following situation occurs:

Program received signal SIGSEGV, Segmentation fault.
In file: /src/gdcm-3.0.24/Source/MediaStorageAndFileFormat/gdcmRLECodec.cxx:804
   799   length /= numSegments;
   800   for(unsigned long i = 0; i<numSegments; ++i)
   801     {
   802     unsigned long numberOfReadBytes = 0;
   803     std::streampos pos = is.tellg() - start;
 ► 804     if ( frame.Header.Offset[i] - pos != 0 )

While inspecting variables during debugging, it becomes immediately apparent that an out-of-bounds issue is present. Reviewing the code responsible for the crash and examining variables such as frame.Header.Offset and numSegments, which controls the for-loop, provides further indication of the problem:

pwndbg> p frame.Header.Offset[0]
$2 = 64
pwndbg> p frame.Header.Offset[i]
Cannot access memory at address 0x5d4eb2150000

pwndbg> p/x numSegments
$5 = 0x800002

The code crashes due to a read memory outside of scope. Below is an excerpt from the RLECodec::DecodeByStreams function:

LINE 1. bool RLECodec::DecodeByStreams(std::istream &is, std::ostream &os)
LINE 2. {
LINE 3.   std::streampos start = is.tellg();
LINE 4.   // FIXME: Do some stupid work:
LINE 5.   char dummy_buffer[256];
LINE 6.   std::stringstream tmpos;
LINE 7. 
LINE 8.   RLEFrame &frame = Internals->Frame;
LINE 9.   if( !frame.Read(is) )
LINE 10.      return false;
LINE 11.   unsigned long numSegments = frame.Header.NumSegments;
LINE 12. 
LINE 13.   unsigned long length = Length;
LINE 14.   assert( length );
LINE 15.   // Special case:
LINE 16.   assert( GetPixelFormat().GetBitsAllocated() == 32 ||
LINE 17.           GetPixelFormat().GetBitsAllocated() == 16 ||
LINE 18.           GetPixelFormat().GetBitsAllocated() == 8 );
LINE 19.   if( GetPixelFormat().GetBitsAllocated() > 8 )
LINE 20.     {
LINE 21.     RequestPaddedCompositePixelCode = true;
LINE 22.     }
LINE 23. 
LINE 24.   assert( GetPixelFormat().GetSamplesPerPixel() == 3 || GetPixelFormat().GetSamplesPerPixel() == 1 );
LINE 25.   // A footnote:
LINE 26.   // RLE *by definition* with more than one component will have applied the
LINE 27.   // Planar Configuration because it simply does not make sense to do it
LINE 28.   // otherwise. So implicitly RLE is indeed PlanarConfiguration == 1. However
LINE 29.   // when the image says: "hey I am PlanarConfiguration = 0 AND RLE", then
LINE 30.   // apply the PlanarConfiguration internally so that people don't get lost
LINE 31.   // Because GDCM internally set PlanarConfiguration == 0 by default, even if
LINE 32.   // the Attribute is not sent, it will still default to 0 and we will be
LINE 33.   // consistent with ourselves...
LINE 34.   if( GetPixelFormat().GetSamplesPerPixel() == 3 && GetPlanarConfiguration() == 0 )
LINE 35.     {
LINE 36.     RequestPlanarConfiguration = true;
LINE 37.     }
LINE 38.   length /= numSegments;
LINE 39.   for(unsigned long i = 0; i<numSegments; ++i)
LINE 40.     {
LINE 41.     unsigned long numberOfReadBytes = 0;
LINE 42.     std::streampos pos = is.tellg() - start;
LINE 43.     if ( frame.Header.Offset[i] - pos != 0 )
LINE 44.       {
LINE 45.       // ACUSON-24-YBR_FULL-RLE.dcm
LINE 46.       // D_CLUNIE_CT1_RLE.dcm
LINE 47.       // This should be at most the \0 padding
LINE 48.       //gdcmWarningMacro( "RLE Header says: " << frame.Header.Offset[i] <<
LINE 49.       //   " when it should says: " << pos << std::endl );
LINE 50.       std::streamoff check = frame.Header.Offset[i] - pos;//should it be a streampos or a uint32? mmr
LINE 51.       // check == 2 for gdcmDataExtra/gdcmSampleData/US_DataSet/GE_US/2929J686-breaker
LINE 52.       //assert( check == 1 || check == 2);
LINE 53.       (void)check; //warning removal
LINE 54.       is.seekg( frame.Header.Offset[i] + start, std::ios::beg );
LINE 55.       }
[...]
LINE 98. }

To understand why frame.Header.Offset[i] at LINE 43 leads to an access violation, it’s necessary to examine the source code in more detail. At LINE 8, frameis defined as a type of RLEFrame and is assigned values from Internals->Frame. By inspecting the Internals variable, we can gather important clues about the underlying issue:

pwndbg> p *Internals
$6 = {
  Frame = {
    Header = {
      NumSegments = 8388610,
      Offset = {64, 30490, 0 <repeats 13 times>}
    },
    Bytes = std::vector of length 0, capacity 0
  },
  SegmentLength = std::vector of length 0, capacity 0
}

We can observe that the number of Offset in this object corresponding to frame.Header.Offset is 15. Below is the declaration of an RLEFrame:

99. class RLEFrame
100. {
101. public:
102.   bool Read(std::istream &is)
103.     {
104.     // read Header (64 bytes)
105.     is.read((char*)(&Header), sizeof(uint32_t)*16);
106.     assert( sizeof(uint32_t)*16 == 64 );
107.     assert( sizeof(RLEHeader) == 64 );
108.     SwapperNoOp::SwapArray((uint32_t*)&Header,16);
109.     uint32_t numSegments = Header.NumSegments;
110.     if( numSegments >= 1 )
111.       {
112.       if( Header.Offset[0] != 64 ) return false;
113.       }
114.     // We just check that we are indeed at the proper position start+64
115.     return true;
116.     }
117.   void Print(std::ostream &os)
118.     {
119.     Header.Print(os);
120.     }
121. //private:
122.   RLEHeader Header;
123.   std::vector<char> Bytes;
124. };

At LINE 109, we see that numSegments is derived from the private variable Header.NumSegments. Examining the Header class provides further context:

126. class RLEHeader
127. {
128. public:
129.   uint32_t NumSegments;
130.   uint32_t Offset[15];
131. 
132.   void Print(std::ostream &os)
133.     {
134.     os << "NumSegments:" << NumSegments << "\n";
135.     for(int i=0; i<15; ++i)
136.       {
137.       os << i << ":" << Offset[i] << "\n";
138.       }
139.     }
140. };

We can observe that Offset is a fixed-size array of uint32_t with a length of 15. If the NumSegments variable—sourced from frame.Header.NumSegments at LINE 11—is set to a value larger than the maximum number of entries in the Offset array LINE 130, this leads to an out-of-bounds access and causes the crash. A malformed DICOM file can manipulate the value of NumSegments to control the behavior of the for-loop, potentially resulting in the exposure of sensitive information.

Crash Information

Program received signal SIGSEGV, Segmentation fault.
0x00005d4e91546831 in gdcm::RLECodec::DecodeByStreams (this=0x7ffd85430080, is=..., os=...) at /src/gdcm-src/Source/MediaStorageAndFileFormat/gdcmRLECodec.cxx:804
804	    if ( frame.Header.Offset[i] - pos != 0 )
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────────────────────────────────────────────────────
*RAX  0x5d4eb2107db0 ◂— 0x4000800002
 RBX  0
*RCX  0
*RDX  0x12093
*RDI  0x7ffd8542f610 ◂— 0xffffffffffffffff
*RSI  0xffffffffffffffff
 R8   0
*R9   8
*R10  0x71e3875bfdd0 ◂— 0
*R11  0
*R12  1
*R13  0x5d4e914f76b9 (main) ◂— endbr64 
*R14  0x5d4e918b3798 (__do_global_dtors_aux_fini_array_entry) —▸ 0x5d4e914f7430 (__do_global_dtors_aux) ◂— endbr64 
*R15  0x71e387bb9040 (_rtld_global) —▸ 0x71e387bba2e0 —▸ 0x5d4e912f6000 ◂— 0x10102464c457f
*RBP  0x7ffd8542fa70 —▸ 0x7ffd85430000 —▸ 0x7ffd85430100 —▸ 0x7ffd85430140 —▸ 0x7ffd85430170 ◂— ...
*RSP  0x7ffd8542f590 ◂— 0
*RIP  0x5d4e91546831 (gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+729) ◂— mov eax, dword ptr [rax + rdx*4 + 4]
─────────────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────────────────────────────────────
 ► 0x5d4e91546831 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+729>    mov    eax, dword ptr [rax + rdx*4 + 4]     <Cannot dereference [0x5d4eb2150000]>
   0x5d4e91546835 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+733>    mov    ebx, eax
   0x5d4e91546837 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+735>    lea    rax, [rbp - 0x460]
   0x5d4e9154683e <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+742>    mov    rdi, rax
   0x5d4e91546841 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+745>    call   std::fpos<__mbstate_t>::operator long() const <std::fpos<__mbstate_t>::operator long() const>

   0x5d4e91546846 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+750>    cmp    rbx, rax
   0x5d4e91546849 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+753>    setne  al
   0x5d4e9154684c <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+756>    test   al, al
   0x5d4e9154684e <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+758>    je     gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+873 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+873>

   0x5d4e91546850 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+760>    mov    rax, qword ptr [rbp - 0x498]
   0x5d4e91546857 <gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+767>    mov    rdx, qword ptr [rbp - 0x4b0]
───────────────────────────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]───────────────────────────────────────────────────────────────────────────────────────
In file: /src/gdcm-3.0.24/Source/MediaStorageAndFileFormat/gdcmRLECodec.cxx:804
   799   length /= numSegments;
   800   for(unsigned long i = 0; i<numSegments; ++i)
   801     {
   802     unsigned long numberOfReadBytes = 0;
   803     std::streampos pos = is.tellg() - start;
 ► 804     if ( frame.Header.Offset[i] - pos != 0 )
   805       {
   806       // ACUSON-24-YBR_FULL-RLE.dcm
   807       // D_CLUNIE_CT1_RLE.dcm
   808       // This should be at most the \0 padding
   809       //gdcmWarningMacro( "RLE Header says: " << frame.Header.Offset[i] <<
───────────────────────────────────────────────────────────────────────────────────────────[ STACK ]───────────────────────────────────────────────────────────────────────────────────────────
00:0000│ rsp 0x7ffd8542f590 ◂— 0
01:0008│-4d8 0x7ffd8542f598 —▸ 0x7ffd8542fe70 —▸ 0x71e387b4d870 (vtable for std::__cxx11::basic_stringstream<char, std::char_traits<char>, std::allocator<char> >+64) —▸ 0x71e387a6b830 (non-virtual thunk to std::__cxx11::basic_stringstream<char, std::char_traits<char>, std::allocator<char> >::~basic_stringstream()) ◂— endbr64 
02:0010│-4d0 0x7ffd8542f5a0 —▸ 0x7ffd8542fcd0 —▸ 0x71e387b4d848 (vtable for std::__cxx11::basic_stringstream<char, std::char_traits<char>, std::allocator<char> >+24) —▸ 0x71e387a6b8d0 (std::__cxx11::basic_stringstream<char, std::char_traits<char>, std::allocator<char> >::~basic_stringstream()) ◂— endbr64 
03:0018│-4c8 0x7ffd8542f5a8 —▸ 0x7ffd85430080 —▸ 0x5d4e919e4fd8 (vtable for gdcm::RLECodec+16) —▸ 0x5d4e91543388 (gdcm::RLECodec::~RLECodec()) ◂— endbr64 
04:0020│-4c0 0x7ffd8542f5b0 ◂— 0
05:0028│-4b8 0x7ffd8542f5b8 —▸ 0x71e387b8a37c (check_match+316) ◂— test eax, eax
06:0030│-4b0 0x7ffd8542f5c0 ◂— 0x12093
07:0038│-4a8 0x7ffd8542f5c8 ◂— 0
─────────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]─────────────────────────────────────────────────────────────────────────────────────────
 ► 0   0x5d4e91546831 gdcm::RLECodec::DecodeByStreams(std::istream&, std::ostream&)+729
   1   0x5d4e91545425 gdcm::RLECodec::Decode(gdcm::DataElement const&, gdcm::DataElement&)+295
   2   0x5d4e9157d43a gdcm::Bitmap::TryRLECodec(char*, bool&) const+578
   3   0x5d4e9157d6d5 gdcm::Bitmap::GetBufferInternal(char*, bool&) const+247
   4   0x5d4e9157be08 gdcm::Bitmap::ComputeLossyFlag()+52
   5   0x5d4e91586d85 gdcm::PixmapReader::ReadImageInternal(gdcm::MediaStorage const&, bool)+14433
   6   0x5d4e91583522 gdcm::PixmapReader::ReadImage(gdcm::MediaStorage const&)+44
   7   0x5d4e915004be gdcm::ImageReader::ReadImage(gdcm::MediaStorage const&)+70
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
TIMELINE

2025-07-15 - Vendor Disclosure
2025-08-04 - Talos Follow-up
2025-09-01 - Talos Follow-up
2025-10-06 - Talos Follow-up
2025-12-16 - Public Release

Discovered by Emmanuel Tacheau of Cisco Talos.


文章来源: https://talosintelligence.com/vulnerability_reports/TALOS-2025-2214
如有侵权请联系:admin#unsafe.sh