Talos Vulnerability Report

TALOS-2022-1655

OpenImageIO Project OpenImageIO IFFOutput alignment padding memory corruption vulnerability

December 22, 2022
CVE Number

CVE-2022-43598,CVE-2022-43597

SUMMARY

Multiple memory corruption vulnerabilities exist in the IFFOutput alignment padding functionality of OpenImageIO Project OpenImageIO v2.4.4.2. A specially crafted ImageOutput Object can lead to arbitrary code execution. An attacker can provide malicious input to trigger these vulnerabilities.

CONFIRMED VULNERABLE VERSIONS

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

OpenImageIO Project OpenImageIO v2.4.4.2

PRODUCT URLS

OpenImageIO - https://github.com/OpenImageIO/oiio

CVSSv3 SCORE

8.1 - CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-122 - Heap-based Buffer Overflow

DETAILS

OpenImageIO is an image processing library with easy-to-use interfaces and a sizable number of supported image formats. Useful for conversion and processing and even image comparison, this library is utilized by 3D-processing software from AliceVision (including Meshroom), as well as Blender for reading Photoshop .psd files.

We will be looking at two instances of a similar vulnerability; the first description will look at the general details of the bugs and the specifics of the first, whilst the descriptions of the subsequent vulnerabilities will focus on the differences.

CVE-2022-43597 - TypeDesc::UINT8

Along with parsing files of various formats, libOpenImageIO is also capable of creating new files in these formats. For instance, if we look briefly at the OpenImageIO iconvert utility as an example, there are two functions capable of doing this image creation:

static bool
convert_file(const std::string& in_filename, const std::string& out_filename)
{
    // [...]

    // Find an ImageIO plugin that can open the input file, and open it.
    auto in = ImageInput::open(in_filename);                              // [1]
    // [...]
    ImageSpec inspec         = in->spec();                                // [2]

    // Find an ImageIO plugin that can open the output file, and open it
    auto out = ImageOutput::create(tempname);                             // [3]
   
    // [...]

        if (!nocopy) { 
            ok = out->copy_image(in.get());                                   // [4]
            if (!ok)
                std::cerr << "iconvert ERROR copying \"" << in_filename
                          << "\" to \"" << out_filename << "\" :\n\t"
                          << out->geterror() << "\n";
        } else {
            // Need to do it by hand for some reason.  Future expansion in which
            // only a subset of channels are copied, or some such.
            std::vector<char> pixels((size_t)outspec.image_bytes(true));
            ok = in->read_image(subimage, miplevel, 0, outspec.nchannels,     // [5]
                                outspec.format, &pixels[0]);
            if (!ok) {
                std::cerr << "iconvert ERROR reading \"" << in_filename
                          << "\" : " << in->geterror() << "\n";
            } else {
                ok = out->write_image(outspec.format, &pixels[0]);              // [6]
                if (!ok)
                    std::cerr << "iconvert ERROR writing \"" << out_filename
                              << "\" : " << out->geterror() << "\n";
            }
      
       }
       
      ++miplevel;
    } while (ok && in->seek_subimage(subimage, miplevel, inspec));
}

out->close(); // [7]
in->close();

The most important pieces are that we have an ImageInput object [1], an input specification [2] and an output image (whose type is determined by the filename extension) [3]. An output specification can be copied from the input specification and modified in case of incompatibilities with the output format. Subsequently we can either call ImageOutput::copy_image(in.get()) [4] or read the input into a buffer at [5] and then write the buffer to our ImageOutput at [6]. Now, it’s worth noting we cannot really know how libOpenImageIO will get its input images and specifications, and so the ImageOutput vulnerabilities are all applicable only in situations where an attacker can control the input file or specification that is then used to generate an ImageOutput object (like above).

If we end up generating a .iff file, then we appropriately end up hitting code inside src/iff.imageio/iffoutput.cpp. Upon opening the output file via IffOutput::open, assorted m_iff_header fields are set and our object’s scratch m_buf is resized to accommodate any images we wish to convert. It’s only upon IffOutput::close(), however, that this image data is flushed into the scratch buffers and eventually written to our resultant file. Starting with inline bool IffOutput::close(void):

   if (m_fd && m_buf.size()) {
        // [...]
        
        // write y-tiles
        for (uint32_t ty = 0; ty < tile_height_size(m_spec.height); ty++) {  // [7]
            // write x-tiles
            for (uint32_t tx = 0; tx < tile_width_size(m_spec.width); tx++) {  // [8]
                // channels
                uint8_t channels = m_iff_header.pixel_channels;

                // set tile coordinates
                uint16_t xmin, xmax, ymin, ymax;

                // set xmin and xmax
                xmin = tx * tile_width();
                xmax = std::min(xmin + tile_width(), m_spec.width) - 1;         // [9]

                // set ymin and ymax
                ymin = ty * tile_height();
                ymax = std::min(ymin + tile_height(), m_spec.height) - 1;       // [10]

The code flow goes as one might expect. We iterate over the tiles of each row and also each column, writing pixels as we go along. At [7] and [8], we find the bounds of our image, with the tile_height_size(m_spec.height) reducing essentially down to (m_spec.height + 64 -1 ) / 64 and tile_width_size reducing similarly to (m_spec.width + 64 - 1 ) / 64. Worth noting here that both m_spec.height and m_spec.width are both uint32_t, for an idea of how many loops we can hit. Continuing on, we find where our current tile starts and ends at [9] and [10] with the xmin, xmax, ymin, and ymax variables, all of which are importantly uint16_t variables. Since we know that m_spec.width and m_spec.height are uint32_t, it follows that xmax and ymax can both be anywhere from 0x0 to 0xFFFF. Continuing further into IffOutput::close():

      // write y-tiles
        for (uint32_t ty = 0; ty < tile_height_size(m_spec.height); ty++) { 
            // write x-tiles
            for (uint32_t tx = 0; tx < tile_width_size(m_spec.width); tx++) { 
            
                uint8_t channels = m_iff_header.pixel_channels;
                
                // [...]
                // set width and height
                uint32_t tw = xmax - xmin + 1;     // [9] 
                uint32_t th = ymax - ymin + 1;     // [10]
                // [...]

                // length.
                uint32_t length = tw * th * m_spec.pixel_bytes(); // [11]

                // tile length.
                uint32_t tile_length = length;

                // align.
                length = align_size(length, 4);

                // append xmin, xmax, ymin and ymax.
                length += 8;


                // tile compression.
                bool tile_compress = (m_iff_header.compression == RLE); 

                // set bytes.
                std::vector<uint8_t> scratch;  // [12]
                scratch.resize(tile_length);
                
                // handle 8-bit data
                if (m_spec.format == TypeDesc::UINT8) {
                    if (tile_compress) {  // [13]

For each tile, the size is calculated at [9] and [10], and the appropriate tile_length is propagated via the calculation at [11]. We will also need to make a note of the creation of our scratch vector at [12], which gets resized to size tile_length, i.e. the length before it’s aligned and expanded. Continuing on, assuming our image only uses one byte for each pixel—also, importantly, if our image specification deems us to need RLE compression—then tile_compression is set and we hit the branch at [13]:

                if (m_spec.format == TypeDesc::UINT8) {
                    if (tile_compress) {  // [13]

                        uint32_t index = 0, size = 0;
                        std::vector<uint8_t> tmp;                             // [14]

                        // set bytes.
                        tmp.resize(tile_length * 2);

                        // map: RGB(A) to BGRA
                        for (int c = (channels * m_spec.channel_bytes()) - 1;
                             c >= 0; --c) {
                                // [...]
                            }

                            // compress rle channel
                            size = compress_rle_channel(&in[0], &tmp[0] + index,  // [15]
                                                        tw * th);
                            index += size;
                        }

A temporary byte vector is created at [14] and filled with compressed image data at [15]. The index variable immediately after [15] keeps track of how many bytes are written to tmp before we put this image data to use:

                         // [...]
                            // compress rle channel
                            size = compress_rle_channel(&in[0], &tmp[0] + index,  // [15]
                                                        tw * th);
                            index += size;
                        }

                        // if size exceeds tile length write uncompressed

                        if (index < tile_length) {                 // [16]
                            memcpy(&scratch[0], &tmp[0], index);

                            // set tile length
                            tile_length = index;

                            // append xmin, xmax, ymin and ymax
                            length = index + 8;

                            // set length
                            uint32_t align = align_size(length, 4);  // [17]
                            if (align > length) {
                                out_p = &scratch[0] + index;
                                // Pad.
                                for (uint32_t i = 0; i < align - length; i++) {
                                    *out_p++ = '\0';                     // [18]
                                    tile_length++; 
                                }
                            }
                        } else {
                            tile_compress = false;
                        }
                    }

Assuming that our tmp vector [14] ends up being smaller than our scratch vector [12], then we enter the branch at [16] to pad out the rest of the scratch data. A call to align_size[17] is made to figure out how many null bytes to add to scratch at [18]. Unfortunately, there seems to be an overlooked fact that the original scratch vector is never resized to accommodate these new null bytes, resulting in one to three null bytes being written past the edge of the heap buffer, depending on its size. Since tile_length is controlled by our input m_spec, the size of our heap chunk is also controlled along with whether to write one, two or three null bytes.

Crash Information

=================================================================
==817773==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000002113 at pc 0x7ffff3c007e8 bp 0x7fffffffae70 sp 0x7fffffffae68
WRITE of size 1 at 0x602000002113 thread T0
[Detaching after fork from child process 817783]
    #0 0x7ffff3c007e7 in OpenImageIO_v2_4::IffOutput::close() /oiio/oiio-2.4.4.2/src/iff.imageio/iffoutput.cpp:310:46
    #1 0x555555649a6d in convert_file(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /oiio/fuzzing_release/./iconvert.cpp:475:10
    #2 0x5555556453c8 in main /oiio/fuzzing_release/./iconvert.cpp:523:14
    #3 0x7fffeac23d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #4 0x7fffeac23e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #5 0x555555584ed4 in _start (/oiio/fuzzing/triage/iconvert_testing_dir/iconvert+0x30ed4) (BuildId: 8d9c54aeaee5ba79c4320b01f97dc76bf6e7ce61)

0x602000002113 is located 0 bytes to the right of 3-byte region [0x602000002110,0x602000002113)
allocated by thread T0 here:
    #0 0x555555642aed in operator new(unsigned long) (/oiio/fuzzing/triage/iconvert_testing_dir/iconvert+0xeeaed) (BuildId: 8d9c54aeaee5ba79c4320b01f97dc76bf6e7ce61)
    #1 0x7ffff160e411 in __gnu_cxx::new_allocator<unsigned char>::allocate(unsigned long, void const*) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/ext/new_allocator.h:127:27
    #2 0x7ffff160e293 in std::allocator_traits<std::allocator<unsigned char> >::allocate(std::allocator<unsigned char>&, unsigned long) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/alloc_traits.h:464:20
    #3 0x7ffff160cd2b in std::_Vector_base<unsigned char, std::allocator<unsigned char> >::_M_allocate(unsigned long) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/stl_vector.h:346:20
    #4 0x7ffff160a381 in std::vector<unsigned char, std::allocator<unsigned char> >::_M_default_append(unsigned long) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/vector.tcc:635:34
    #5 0x7ffff160826c in std::vector<unsigned char, std::allocator<unsigned char> >::resize(unsigned long) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/stl_vector.h:940:4
    #6 0x7ffff3bfe7f5 in OpenImageIO_v2_4::IffOutput::close() /oiio/oiio-2.4.4.2/src/iff.imageio/iffoutput.cpp:250:25
    #7 0x555555649a6d in convert_file(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /oiio/fuzzing_release/./iconvert.cpp:475:10
    #8 0x5555556453c8 in main /oiio/fuzzing_release/./iconvert.cpp:523:14
    #9 0x7fffeac23d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

SUMMARY: AddressSanitizer: heap-buffer-overflow /oiio/oiio-2.4.4.2/src/iff.imageio/iffoutput.cpp:310:46 in OpenImageIO_v2_4::IffOutput::close()
Shadow bytes around the buggy address:
  0x0c047fff83d0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c047fff83e0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c047fff83f0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c047fff8400: fa fa fd fd fa fa fd fd fa fa fd fd fa fa fd fd
  0x0c047fff8410: fa fa 03 fa fa fa fd fa fa fa 03 fa fa fa 01 fa
=>0x0c047fff8420: fa fa[03]fa fa fa 06 fa fa fa fd fa fa fa fa fa
  0x0c047fff8430: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c047fff8440: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c047fff8450: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c047fff8460: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c047fff8470: 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
==817773==ABORTING

CVE-2022-43598 - TypeDesc::UINT16

For instances where our input data is two bytes per pixel, the vulnerability is present at [19] as well in code that is very similar to the above:

                 // handle 16-bit data
                else if (m_spec.format == TypeDesc::UINT16) {
                    if (tile_compress) {
                        uint32_t index = 0, size = 0;
                        std::vector<uint8_t> tmp;

                        // set bytes.
                        tmp.resize(tile_length * 2);

                        // [...]

                            // compress rle channel
                            size = compress_rle_channel(&in[0], &tmp[0] + index,
                                                        tw * th);
                            index += size;
                        }

                        // if size exceeds tile length write uncompressed

                        if (index < tile_length) {
                            memcpy(&scratch[0], &tmp[0], index);

                            // set tile length
                            tile_length = index;

                            // append xmin, xmax, ymin and ymax
                            length = index + 8;

                            // set length
                            uint32_t align = align_size(length, 4);
                            if (align > length) {
                                out_p = &scratch[0] + index;
                                // Pad.
                                for (uint32_t i = 0; i < align - length; i++) {
                                    *out_p++ = '\0';     // [19]
                                    tile_length++;
                                }
                            }
                        } else {
                            tile_compress = false;
                        }
                    }
TIMELINE

2022-11-14 - Vendor Disclosure
2022-12-03 - Vendor Patch Release
2022-12-22 - Public Release

Credit

Discovered by Lilith >_> of Cisco Talos.