Talos Vulnerability Report

TALOS-2022-1652

OpenImageIO Project OpenImageIO DPXOutput::close() denial of service vulnerability

December 22, 2022
CVE Number

CVE-2022-43593

SUMMARY

A denial of service vulnerability exists in the DPXOutput::close() functionality of OpenImageIO Project OpenImageIO v2.4.4.2. A specially crafted ImageOutput Object can lead to null pointer dereference. 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:N/I:N/A:H

CWE

CWE-476 - NULL Pointer Dereference

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], 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 hitting the functions to output a .dpx file, a curious code flow can occur upon hitting the necessary out->close() at [7]:

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,   // [8]
                              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;
}

Assuming that our output specification has a .tile_width, we end up hitting the ImageOutput::write_scanlines function at [8] such that our buffered pixels can actually be written to our output file:

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) {
        ok &= write_scanline(y, z, format, data, xstride);  // [9]
        data = (char*)data + ystride;
    }
    return ok;
}

Since the ImageOutput class is generic, it must call into the more specific DpxOutput::write_scanline function [9] to actually know how to write each scanline:

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

    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,
                         (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;
}

We don’t particularly care about much in this function except that m_write_pending is set to true immediately at [10], no matter what. Regardless of what else occurs, we eventually return back up to DPXoutput::close():

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,   // [11]
                              m_spec.format, &m_tilebuffer[0]);
        std::vector<unsigned char>().swap(m_tilebuffer);
    }

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

With m_write_pending set to true in [11], we then hit DPXOutput::writebuffer:

bool
DPXOutput::write_buffer()
{
    bool ok = true;
    if (m_write_pending) {
        ok = m_dpx.WriteElement(m_subimage, &m_buf[0], m_datasize);   // [13]
        if (!ok) {
            const char* err = strerror(errno);
            errorf("DPX write failed (%s)",
                   (err && err[0]) ? err : "unknown error");
        }
        m_write_pending = false;
    }
    return ok;
}

Without getting too deep into the libdpx code, it suffices to say that the m_buf variable gets written inside m_dpx.WriteElement [13]. There are never really any checks to make sure that m_buf has been initialized. This ends up being our vulnerability, in that data gets written to a null dereferenced pointer. But how do we manage to get this far into the code without m_buf ever being initialized? Normally the m_buf is allocated inside DPXOutput::prep_subimage, so let us briefly look at the function:

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

    // determine descriptor
    m_desc = get_image_descriptor();                // [13]
 
    // [...]

    // check if the client is giving us raw data to write
    m_rawcolor = m_spec.get_int_attribute("dpx:RawColor")
                 || m_spec.get_int_attribute("dpx:RawData")  // deprecated
                 || m_spec.get_int_attribute("oiio:RawColor");

    // see if we'll need to convert color space or not
    if (m_desc == dpx::kRGB || m_desc == dpx::kRGBA || m_spec.nchannels == 1) {
        // shortcut for RGB/RGBA, and for 1-channel images that don't
        // need to decode color representations.
        m_bytes    = m_spec.scanline_bytes();
        m_rawcolor = true;                                                          
    } else {
        m_bytes = dpx::QueryNativeBufferSize(m_desc, m_datasize, m_spec.width,      // [14]
                                             1);
        if (m_bytes == 0 && !m_rawcolor) {                                          // [15]
            errorf("Unable to deliver native format data from source data");
            return false;
        } else if (m_bytes < 0) {
            // no need to allocate another buffer
            if (!m_rawcolor)
                m_bytes = m_spec.scanline_bytes();
            else
                m_bytes = -m_bytes;
        }
    }

    if (m_bytes < 0)
        m_bytes = -m_bytes;

    // allocate space for the image data buffer
    if (allocate)
        m_buf.resize(m_bytes * m_spec.height);       // [16]

    return true;
}

At [16], we clearly see the allocation we needed to hit to avoid the crash. However, there also exists an early exit from DPXOutput::prep_subimage at [15], which is entirely dependent on the m_desc variable. Since dpx::QueryNativeBufferSize[14] essentially is a switch-case based on the input m_desc, we also need to quickly look at how get_image_descriptor behaves at [13]:

dpx::Descriptor
DPXOutput::get_image_descriptor()
{
    switch (m_spec.nchannels) {
    case 1: {
        std::string name = m_spec.channelnames.size() ? m_spec.channelnames[0]
                                                      : "";
        if (m_spec.z_channel == 0 || name == "Z")
            return dpx::kDepth;
        else if (m_spec.alpha_channel == 0 || name == "A")
            return dpx::kAlpha;
        else if (name == "R")
            return dpx::kRed;
        else if (name == "B")
            return dpx::kBlue;
        else if (name == "G")
            return dpx::kGreen;
        else
            return dpx::kLuma;
    }
    case 3: return dpx::kRGB;
    case 4: return dpx::kRGBA;
    default:
        if (m_spec.nchannels <= 8)
            return (dpx::Descriptor)((int)dpx::kUserDefined2Comp    // [17]
                                     + m_spec.nchannels - 2);
        return dpx::kUndefinedDescriptor;
    }
}

Given an m_spec.nchannels of two or five through eight, we end up hitting an m_desc of anywhere from 0x96 to 0x90 at [17], all of which cause QueryNativeBufferSize to return 0, error out in DPXOutput::prep_subimage and eventually reach the null pointer dereference when DPXOutput is called either explicitly or during the DPXOutput destructor.

Crash Information

***********************************************************************************
***********************************************************************************
rax        : 0x62100140ed00                     | rip[L]     : 0x7fffeada8f4d <__memmove_evex_unal
rbx        : 0x25800                            | eflags     : 0x10202
rcx        : 0xc00                              | cs         : 0x33
rdx        : 0x1000                             | ss         : 0x2b
rsi        : 0x0                                | ds         : 0x0
rdi        : 0x62100140dd00                     | es         : 0x0
rbp        : 0x615000003f00                     | fs         : 0x0
rsp[S]     : 0x7fffffffa948                     | gs         : 0x0
r8         : 0x78                               | k0         : 0xfffffffe
r9         : 0xa0                               | k1         : 0xffffff
r10        : 0x0                                | k2         : 0xffffffff
r11        : 0x61d000a0f140                     | k3         : 0x0
r12        : 0x1000                             | k4         : 0x0
r13        : 0x1000                             | k5         : 0x0
r14        : 0x0                                | k6         : 0x0
r15        : 0x24800                            | k7         : 0x0
***********************************************************************************
   0x7fffeada8f40 <__memmove_evex_unaligned_erms>:      endbr64
   0x7fffeada8f44 <__memmove_evex_unaligned_erms+4>:    mov    rax,rdi
   0x7fffeada8f47 <__memmove_evex_unaligned_erms+7>:    cmp    rdx,0x20
   0x7fffeada8f4b <__memmove_evex_unaligned_erms+11>:   jb     0x7fffeada8f80 <__memmove_evex_unaligned_erms+64>
=> 0x7fffeada8f4d <__memmove_evex_unaligned_erms+13>:   vmovdqu64 ymm16,YMMWORD PTR [rsi]
   0x7fffeada8f53 <__memmove_evex_unaligned_erms+19>:   cmp    rdx,0x40
   0x7fffeada8f57 <__memmove_evex_unaligned_erms+23>:   ja     0x7fffeada9000 <__memmove_evex_unaligned_erms+192>
   0x7fffeada8f5d <__memmove_evex_unaligned_erms+29>:   vmovdqu64 ymm17,YMMWORD PTR [rsi+rdx*1-0x20]
   0x7fffeada8f65 <__memmove_evex_unaligned_erms+37>:   vmovdqu64 YMMWORD PTR [rdi],ymm16
***********************************************************************************
#0  __memmove_evex_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:317
#1  0x00007fffeac856e5 in _IO_new_file_xsputn (n=153600, data=?, f=?) at ./libio/fileops.c:1235
#2  _IO_new_file_xsputn (f=0x615000003f00, data=?, n=153600) at ./libio/fileops.c:1196
#3  0x00007fffeac7a057 in __GI__IO_fwrite (buf=0x0, size=1, count=153600, fp=0x615000003f00) at/libioP.h:947
#4  0x00005555555a10c0 in fwrite ()
#5  0x00007fffebb8e703 in OpenImageIO_v2_4::Filesystem::IOFile::write (this=0x60e00003a7a0, buf=0x0, size=153600) at/filesystem.cpp:1198
#6  0x00007ffff3a6438a in OutStream::Write (this=0x602000003610, buf=0x0, size=153600) at/OutStream.cpp:60
#7  0x00007ffff3ae2208 in OutStream::WriteCheck (this=0x602000003610, buf=0x0, size=153600) at/DPXStream.h:186
#8  0x00007ffff3af2d05 in dpx::Writer::WriteThrough (this=0x61d000a0f140, data=0x0, width=160, height=120, noc=2, bytes=4, eolnPad=0, eoimPad=0, blank=0x0) at/Writer.cpp:407
#9  0x00007ffff3aec1cf in dpx::Writer::WriteElement (this=0x61d000a0f140, element=0, data=0x0, size=dpx::kFloat) at/Writer.cpp:298
#10 0x00007ffff3a34ecc in OpenImageIO_v2_4::DPXOutput::write_buffer (this=0x61d000a0f080) at/dpxoutput.cpp:571
#11 0x00007ffff3a1dfc8 in OpenImageIO_v2_4::DPXOutput::close (this=0x61d000a0f080) at/dpxoutput.cpp:601
#12 0x0000555555649a6e in convert_file (in_filename=Python Exception <class 'gdb.error'>: There is no member named _M_p.
, out_filename=Python Exception <class 'gdb.error'>: There is no member named _M_p.
) at ./iconvert.cpp:475
#13 0x00005555556453c9 in main (argc=3, argv=0x7fffffffe598) at ./iconvert.cpp:523
***********************************************************************************
TIMELINE

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

Credit

Discovered by Lilith >_> of Cisco Talos.