Talos Vulnerability Report

TALOS-2022-1651

OpenImageIO Project OpenImageIO DPXOutput::close() information disclosure vulnerability

December 22, 2022
CVE Number

CVE-2022-43592

SUMMARY

An information disclosure vulnerability exists in the DPXOutput::close() 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 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.

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(); 
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], 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).

When dealing with writing .dpx files, we take our input specification and pass it into DPXOutput::open() which looks like so:

bool
DPXOutput::open(const std::string& name, const ImageSpec& userspec,
                OpenMode mode)
{
    if (mode == Create) {
        m_subimage = 0;
        if (m_subimage_specs.size() < 1) {    // [8]
            m_subimage_specs.resize(1);
            m_subimage_specs[0]  = userspec;  // [9]
            m_subimages_to_write = 1;
        }
    } else if (mode == AppendSubimage) {
        // [...]
    } else if (mode == AppendMIPLevel) {
        errorf("DPX does not support MIP-maps");
        return false;
    }

    // From here out, all the heavy lifting is done for Create
    OIIO_DASSERT(mode == Create);

    if (is_opened())
        close();  // Close any already-opened file

    // [...]
    m_subimage = 0;

    ImageSpec& m_spec(m_subimage_specs[m_subimage]);  // alias the spec   // [10]

At [8], we deal with our initial code for image creation and allocate space for our m_subimage_specs vector, since a given .dpx file can have multiple subimages. For our purposes we’re only dealing with a single subimage, so we can ignore the other branches. Continuing on, we can see an assignment at [10] with a suitable comment. All references to m_spec in this function will now point to m_subimage_specs[0], which is a copy of the userspec argument [9]. It’s extremely important to note that in all other functions in dpxoutput.cpp, referencing m_spec is referring to the object member variable this->m_spec, which is an ImageSpec object. Continuing on inside DPXOutput::open:

// [...]
for (int s = 0; s < m_subimages_to_write; ++s) {
    prep_subimage(s, false);                  // [11]
    m_dpx.header.SetBitDepth(s, m_bitdepth);
    ImageSpec& spec(m_subimage_specs[s]);
    bool datasign = (spec.format == TypeDesc::INT8
                     || spec.format == TypeDesc::INT16);
    m_dpx.SetElement(
        s, m_desc, m_bitdepth, m_transfer, m_cmetr, m_packing, dpx::kNone,
        datasign, spec.get_int_attribute("dpx:LowData", 0xFFFFFFFF),
        spec.get_float_attribute("dpx:LowQuantity",
                                 std::numeric_limits<float>::quiet_NaN()),
        spec.get_int_attribute("dpx:HighData", 0xFFFFFFFF),
        spec.get_float_attribute("dpx:HighQuantity",
                                 std::numeric_limits<float>::quiet_NaN()),
        spec.get_int_attribute("dpx:EndOfLinePadding", 0),
        spec.get_int_attribute("dpx:EndOfImagePadding", 0));
    std::string desc = spec.get_string_attribute("ImageDescription", "");
    m_dpx.header.SetDescription(s, desc.c_str());
    // TODO: Writing RLE compressed files seem to be broken.
    // if (Strutil::iequals(spec.get_string_attribute("compression"),"rle"))
    //     m_dpx.header.SetImageEncoding(s, dpx::kRLE);
}

For each given subimage, we hit the prep_subimage function with the subimage index, not our current m_spec alias. Continuing into DPXOutput::prep_subimage:

bool
DPXOutput::prep_subimage(int s, bool allocate)
{
    m_spec = m_subimage_specs[s];  // stash the spec  // [12]

    // determine descriptor
    m_desc = get_image_descriptor();

    // transfer function
    std::string colorspace = m_spec.get_string_attribute("oiio:ColorSpace", "");
    if (Strutil::iequals(colorspace, "Linear"))
        m_transfer = dpx::kLinear;
    else if (Strutil::istarts_with(colorspace, "Gamma"))
        m_transfer = dpx::kUserDefined;
    else if (Strutil::iequals(colorspace, "Rec709"))
        m_transfer = dpx::kITUR709;
    else if (Strutil::iequals(colorspace, "KodakLog"))
        m_transfer = dpx::kLogarithmic;
    else {
        std::string dpxtransfer = m_spec.get_string_attribute("dpx:Transfer",
                                                              "");
        m_transfer              = get_characteristic_from_string(dpxtransfer);
    }

At [12], we grab the current subimage and stash it into the m_spec variable. Very important to note that this refers to the this->m_spec, and not the m_spec pointer from DPXOutput::open. While m_subimage_specs[0] does point to where the DPXOutput::open m_spec pointer points, there is a full copy that occurs here, since this->m_spec is not a pointer, but an object. Further into DPXOutput::prep_subimage, we see an interesting assignment to this->m_spec:

// [...]
switch (m_spec.format.basetype) {
    case TypeDesc::UINT8:
    case TypeDesc::UINT16:
    case TypeDesc::FLOAT:
    case TypeDesc::DOUBLE:
        // supported, fine
        break;
    case TypeDesc::HALF:
        // Turn half into float
        m_spec.format.basetype = TypeDesc::FLOAT;   // [13]
        break;
    default:
        // Turn everything else into UINT16
        m_spec.format.basetype = TypeDesc::UINT16;  // [14]
        break;
}

Whilst the initial InputSpec object we are given may have a TypeDesc of any of these, our this->m_spec copy can be assigned a different one at [13] or [14] and our m_subimage_specs[0] object remains the same. Thus, when we return back up to DPXOutput::open, we hit a very interesting piece of code:

// [...]

    // If user asked for tiles -- which this format doesn't support, emulate
    // it by buffering the whole image.
    if (m_spec.tile_width && m_spec.tile_height)
        m_tilebuffer.resize(m_spec.image_bytes()); // [15]

    return prep_subimage(m_subimage, true);
}

Even though our this->m_spec.format.basetype has been updated, inside DPXOutput::open, the m_spec alias’ underlying object has not been updated. The current call to m_spec.image_bytes() returns width * height * depth * nchannels * pixel_bytes(false). The pixel_bytes(false) call refers to the TypeDesc of our m_subimage_specs[0] object that is still a TypeDesc::HALF, and as such returns 0x2. When we finally reach the destructor of our DPXOutput object, we end up hitting a vulnerabilty:

bool
DPXOutput::close()
{
    if (!m_stream) {  // already closed
        init();
        return true;
    }

    bool ok = true;
    if (m_spec.tile_width) {
        // Handle tile emulation -- output the buffered pixels
        OIIO_DASSERT(m_tilebuffer.size());
        ok &= write_scanlines(m_spec.y, m_spec.y + m_spec.height, 0, // [16]
                              m_spec.format, &m_tilebuffer[0]);
        std::vector<unsigned char>().swap(m_tilebuffer);
    }

    ok &= write_buffer();
    m_dpx.Finish();
    init();  // Reset to initial state
    return ok;
}

The function call at [16] throws us into the generic ImageOutput::write_scanlines() code, and it’s important to note that the m_tilebuffer will be our data argument from now on:

bool
ImageOutput::write_scanlines(int ybegin, int yend, int z, TypeDesc format,
                             const void* data, stride_t xstride,
                             stride_t ystride)
{
    // Default implementation: write each scanline individually
    stride_t native_pixel_bytes = (stride_t)m_spec.pixel_bytes(true);
    if (format == TypeDesc::UNKNOWN && xstride == AutoStride)
        xstride = native_pixel_bytes;
    stride_t zstride = AutoStride;
    m_spec.auto_stride(xstride, ystride, zstride, format, m_spec.nchannels,
                       m_spec.width, yend - ybegin);
    bool ok = true;
    for (int y = ybegin; ok && y < yend; ++y) {                // [17]
        ok &= write_scanline(y, z, format, data, xstride);
        data = (char*)data + ystride;
    }
    return ok;
}

Which then loops us over the DPXOutput::write_scanline() function at [17]:

bool
DPXOutput::write_scanline(int y, int z, TypeDesc format, const void* data,
                          stride_t xstride)
{
    m_write_pending = true;

    m_spec.auto_stride(xstride, format, m_spec.nchannels);
    const void* origdata = data;
    data = to_native_scanline(format, data, xstride, m_scratch, m_dither, y, z);
    if (data == origdata) {
        m_scratch.assign((unsigned char*)data,                                 // [18]
                         (unsigned char*)data + m_spec.scanline_bytes());
        data = &m_scratch[0];
    }

    unsigned char* dst = &m_buf[(y - m_spec.y) * m_bytes];
    if (m_rawcolor)
        // fast path - just dump the scanline into the buffer
        memcpy(dst, data, m_spec.scanline_bytes());
    else if (!dpx::ConvertToNative(m_desc, m_datasize, m_cmetr, m_spec.width, 1,
                                   data, dst))
        return false;

    return true;
}

Because our input ImageSpec started with a TypeDesc::Format with a size of 0x2, but the m_spec->format turned into TypeDesc::FLOAT(size 0x4) without the m_tilebuffer/data buffer getting allocated with that in mind, the read at [18] ends up reading twice as many bytes as it should be. Thus, when we reach halfway through our loop at [17], we’ve already read through the entirety of the m_tilebuffer heap allocation and started reading out-of-bounds heap data into our output image, resulting in information disclosure.

Crash Information

iconvert ERROR copying "TODO/dpx_output_oobread/crash-0cb6c6a9d33386d778a216f145251d5d007b4268" to "test.dpx" :
        Failed OpenEXR read: Error reading pixel data from image file "TODO/dpx_output_oobread/crash-0cb6c6a9d33386d778a216f145251d5d007b4268". Tile (0, 0, 0, 0) is missing.
=================================================================
==17255==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7fffdd0b2c00 at pc 0x55555560749f bp 0x7fffffffb740 sp 0x7fffffffaf10
READ of size 6132 at 0x7fffdd0b2c00 thread T0
[Detaching after fork from child process 17273]
    #0 0x55555560749e in __asan_memmove (/oiio/fuzzing/triage/iconvert_testing_dir/iconvert+0xb349e) (BuildId: 8d9c54aeaee5ba79c4320b01f97dc76bf6e7ce61)
    #1 0x7ffff38aff82 in unsigned char* std::__copy_move<false, true, std::random_access_iterator_tag>::__copy_m<unsigned char>(unsigned char const*, unsigned char const*, unsigned char*) /usr/bin/../lib/gcc/x8
6_64-linux-gnu/11/../../../../include/c++/11/bits/stl_algobase.h:431:6
    #2 0x7ffff3a62efc in unsigned char* std::__copy_move_a2<false, unsigned char*, unsigned char*>(unsigned char*, unsigned char*, unsigned char*) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++
/11/bits/stl_algobase.h:494:14
    #3 0x7ffff3a62e6c in unsigned char* std::__copy_move_a1<false, unsigned char*, unsigned char*>(unsigned char*, unsigned char*, unsigned char*) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++
/11/bits/stl_algobase.h:522:14
    #4 0x7ffff3a62d83 in unsigned char* std::__copy_move_a<false, unsigned char*, unsigned char*>(unsigned char*, unsigned char*, unsigned char*) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/
11/bits/stl_algobase.h:530:3
    #5 0x7ffff3a62991 in unsigned char* std::copy<unsigned char*, unsigned char*>(unsigned char*, unsigned char*, unsigned char*) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/stl_algo
base.h:619:14
    #6 0x7ffff3a61ba7 in void std::vector<unsigned char, std::allocator<unsigned char> >::_M_assign_aux<unsigned char*>(unsigned char*, unsigned char*, std::forward_iterator_tag) /usr/bin/../lib/gcc/x86_64-linu
x-gnu/11/../../../../include/c++/11/bits/vector.tcc:321:20
    #7 0x7ffff3a5ff0c in void std::vector<unsigned char, std::allocator<unsigned char> >::_M_assign_dispatch<unsigned char*>(unsigned char*, unsigned char*, std::__false_type) /usr/bin/../lib/gcc/x86_64-linux-g
nu/11/../../../../include/c++/11/bits/stl_vector.h:1628:4
    #8 0x7ffff3a4a585 in void std::vector<unsigned char, std::allocator<unsigned char> >::assign<unsigned char*, void>(unsigned char*, unsigned char*) /usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include
/c++/11/bits/stl_vector.h:769:4
    #9 0x7ffff3a491fe in OpenImageIO_v2_4::DPXOutput::write_scanline(int, int, OpenImageIO_v2_4::TypeDesc, void const*, long) /oiio/oiio-2.4.4.2/src/dpx.imageio/dpxoutput.cpp:6
19:19
    #10 0x7ffff30e6b15 in OpenImageIO_v2_4::ImageOutput::write_scanlines(int, int, int, OpenImageIO_v2_4::TypeDesc, void const*, long, long) /oiio/oiio-2.4.4.2/src/libOpenImage
IO/imageoutput.cpp:113:15
    #11 0x7ffff3a1dc8f in OpenImageIO_v2_4::DPXOutput::close() /oiio/oiio-2.4.4.2/src/dpx.imageio/dpxoutput.cpp:596:15
    #12 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> > con
st&) /oiio/fuzzing_release/./iconvert.cpp:475:10
    #13 0x5555556453c8 in main /oiio/fuzzing_release/./iconvert.cpp:523:14
    #14 0x7fffeac23d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #15 0x7fffeac23e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #16 0x555555584ed4 in _start (/oiio/fuzzing/triage/iconvert_testing_dir/iconvert+0x30ed4) (BuildId: 8d9c54aeaee5ba79c4320b01f97dc76bf6e7ce61)

0x7fffdd0b2c00 is located 0 bytes to the right of 1569792-byte region [0x7fffdcf33800,0x7fffdd0b2c00)
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/al
loc_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 0x7ffff3a32e39 in OpenImageIO_v2_4::DPXOutput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_4::ImageSpec const&, OpenImageIO_v2_4::ImageOutp
ut::OpenMode) /oiio/oiio-2.4.4.2/src/dpx.imageio/dpxoutput.cpp:431:22
    #7 0x555555648895 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> > cons
t&) /oiio/fuzzing_release/./iconvert.cpp:422:31
    #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/fuzzing/triage/iconvert_testing_dir/iconvert+0xb349e) (BuildId: 8d9c54aeaee5ba79c4320b01f97dc76bf6e7ce61) in __asan_memmo
ve
Shadow bytes around the buggy address:
  0x10007ba0e530: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007ba0e540: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007ba0e550: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007ba0e560: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007ba0e570: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007ba0e580:[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x10007ba0e590: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x10007ba0e5a0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x10007ba0e5b0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x10007ba0e5c0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x10007ba0e5d0: 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
==17255==ABORTING
TIMELINE

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

Credit

Discovered by Lilith >_> of Cisco Talos.