CVE-2022-43598,CVE-2022-43597
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.
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
OpenImageIO - https://github.com/OpenImageIO/oiio
8.1 - CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE-122 - Heap-based Buffer Overflow
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.
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.
=================================================================
==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
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;
}
}
2022-11-14 - Vendor Disclosure
2022-12-03 - Vendor Patch Release
2022-12-22 - Public Release
Discovered by Lilith >_> of Cisco Talos.