Talos Vulnerability Report

TALOS-2022-1654

OpenImageIO Project OpenImageIO IFFOutput channel interleaving information disclosure vulnerability

December 22, 2022
CVE Number

CVE-2022-43596

SUMMARY

An information disclosure vulnerability exists in the IFFOutput channel interleaving functionality of OpenImageIO Project OpenImageIO v2.4.4.2. A specially crafted ImageOutput Object can lead to leaked heap data. An attacker can provide malicious input 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.

OpenImageIO Project OpenImageIO v2.4.4.2

PRODUCT URLS

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

CVSSv3 SCORE

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

CWE

CWE-125 - Out-of-bounds Read

DETAILS

OpenImageIO is an image processing library with easy to use interfaces and a sizable amount 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) and is also used by Blender for reading Photoshop .psd files.

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++) {  
            // write x-tiles
            for (uint32_t tx = 0; tx < tile_width_size(m_spec.width); tx++) {  
                // channels
                uint8_t channels = m_iff_header.pixel_channels;                 // [8]

                // 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;         

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

As one might expect, we iterate over the height and width of our input image data and eventually start writing tile-by-tile. At [8] we also see the amount of channels our data has is taken from the m_iff_header.pixel_channels, which is essentially the m_spec.nchannels from our input specification. Continuing on to how the channel variable is used when we’re dealing with 16-bit pixel data:

            // 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);

                    // set map
                    std::vector<uint8_t> map;            // [9]
                    if (littleendian()) {
                        int rgb16[]  = { 0, 2, 4, 1, 3, 5 };
                        int rgba16[] = { 0, 2, 4, 7, 1, 3, 5, 6 };
                        if (m_iff_header.pixel_channels == 3) {
                            map = std::vector<uint8_t>(rgb16, &rgb16[6]);   
                        } else {
                            map = std::vector<uint8_t>(rgba16, &rgba16[8]); 
                        }

                    } else {
                        int rgb16[]  = { 1, 3, 5, 0, 2, 4 };
                        int rgba16[] = { 1, 3, 5, 7, 0, 2, 4, 6 };
                        if (m_iff_header.pixel_channels == 3) {
                            map = std::vector<uint8_t>(rgb16, &rgb16[6]); 
                        } else {
                            map = std::vector<uint8_t>(rgba16, &rgba16[8]);
                        }
                    }

                    // map: RRGGBB(AA) to BGR(A)BGR(A)

As explained by the comment at the bottom of the code, for cases where there’s more than one channel and 16-bit pixel data, we must interleave the data into a slightly different format. A map is created at [9], where the contents are determined by both the number of pixel channels and also the endianness of the system. It can either be six or eight elements long. We continue into the code immediately following this to examine how the map is used:

                    // map: RRGGBB(AA) to BGR(A)BGR(A)
                    
                    for (int c = (channels * m_spec.channel_bytes()) - 1; c >= 0; --c) {     //  [10]
                        int mc = map[c];                                                       //  [11]

                        std::vector<uint8_t> in(tw * th);
                        uint8_t* in_p = &in[0];

                        // set tile
                        for (uint16_t py = ymin; py <= ymax; py++) {
                            const uint8_t* in_dy
                                = &m_buf[0]
                                  + (py * m_spec.width)
                                        * m_spec.pixel_bytes();

                            for (uint16_t px = xmin; px <= xmax; px++) {
                                // get pixel
                                uint8_t pixel;
                                const uint8_t* in_dx  = in_dy + px * m_spec.pixel_bytes() + mc;     // [12]
                                memcpy(&pixel, in_dx, 1);
                                // set pixel.
                                *in_p++ = pixel;
                            }
                        }

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

At [10] we enter a loop to iterate over all of the channels. At [11] we end up grabbing an element from our map to know where we should read from our input pixel data at [12]. An issue exists, however, in the fact that certain input file types can have more than four channels. For instance, if we look at the .tiff file format:

tiffinput.cpp:        if (pvt::limit_channels && m_spec.nchannels > pvt::limit_channels) {
libOpenImageIO/imageio.cpp:int limit_channels(1024);

There really is only a check to make sure the channels are less than 1024. Alternatively, since we’re only dealing with the output code, and this is a library, we cannot be sure that the input specification will have a channel that is <= 4. For instance, when we grab our map element at at [11] into mc, it ends up reading out of bounds on the heap, which causes us to add a potentially out of bounds index into the calculation at [12]. This results in out-of-bounds data being read into our in vector before being compressed at [13]. Depending on how this code is utilized and the state and implementation of the heap at the time of IFFOutput::close() being called, this could quickly result in an information disclosure of sensitive heap data.

Crash Information

==826108==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000020f9 at pc 0x7ffff3c02895 bp 0x7fffffffae50 sp 0x7fffffffae48
READ of size 1 at 0x6020000020f9 thread T0
[Detaching after fork from child process 826118]
    #0 0x7ffff3c02894 in OpenImageIO_v2_4::IffOutput::close() /oiio/oiio-2.4.4.2/src/iff.imageio/iffoutput.cpp:373:38
    #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)

0x6020000020f9 is located 1 bytes to the right of 8-byte region [0x6020000020f0,0x6020000020f8)
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 0x7ffff3bf16b9 in void std::vector<unsigned char, std::allocator<unsigned char> >::_M_range_initialize<int*>(int*, int*, std::forward_iterator_tag) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/stl_vector.h:1582:14
    #5 0x7ffff3bef979 in std::vector<unsigned char, std::allocator<unsigned char> >::vector<int*, void>(int*, int*, std::allocator<unsigned char> const&) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/stl_vector.h:657:4
    #6 0x7ffff3c024fb in OpenImageIO_v2_4::IffOutput::close() /oiio/oiio-2.4.4.2/src/iff.imageio/iffoutput.cpp:357:39
    #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:373:38 in OpenImageIO_v2_4::IffOutput::close()
Shadow bytes around the buggy address:
  0x0c047fff83c0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  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 fd fd fa fa fd fa fa fa fd fd fa fa 00[fa]
  0x0c047fff8420: fa fa fa fa fa fa fa fa fa fa fa 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
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
==826108==ABORTING
[Thread 0x7fffe4cf9640 (LWP 826117) exited]
[Thread 0x7fffe54fa640 (LWP 826116) exited]
[Thread 0x7fffe5cfb640 (LWP 826115) exited]
[Thread 0x7fffe64fc640 (LWP 826114) exited]
[Thread 0x7fffe6cfd640 (LWP 826113) exited]
[Thread 0x7fffe74fe640 (LWP 826112) exited]
[Thread 0x7fffe7cff640 (LWP 826111) exited]
[Inferior 1 (process 826108) exited with code 01]
TIMELINE

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

Credit

Discovered by Lilith >_> of Cisco Talos.