Talos Vulnerability Report

TALOS-2022-1634

OpenImageIO DDS scanline parsing code execution vulnerability

December 22, 2022
CVE Number

CVE-2022-41838

SUMMARY

A code execution vulnerability exists in the DDS scanline parsing functionality of OpenImageIO Project OpenImageIO v2.4.4.2. A specially-crafted .dds can lead to a heap buffer overflow. An attacker can provide a malicious file 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

9.8 - CVSS:3.0/AV:N/AC:L/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.

The DirectDraw Surface file format (.dds) is another one of the file formats that libOpenImageIO can handle. It’s primarily used for DirectX and can contain a large number of textures, both compressed and uncompressed. When using libOpenImageIO to interact with .dds files, the same basic work flow occurs, in that we create a generic ImageInput object and then call ReadImage() on our input file. After a certain amount of generic ImageInput files, we end up hitting the DDSInput object’s more specific handlers. Let’s quickly examine the structure of a .dds file before hitting code:

/// DDS file header.
typedef struct {
    uint32_t fourCC;   ///< file four-character code
    uint32_t size;     ///< structure size, must be 124
    uint32_t flags;    ///< flags to indicate valid fields
    uint32_t height;   ///< image height
    uint32_t width;    ///< image width
    uint32_t pitch;    ///< bytes per scanline (uncmp.)/total byte size (cmp.)
    uint32_t depth;    ///< image depth (for 3D textures)
    uint32_t mipmaps;  ///< number of mipmaps
    uint32_t unused0[11];
    dds_pixformat fmt;  ///< pixel format
    dds_caps caps;      ///< DirectDraw Surface caps
    uint32_t unused1;
} dds_header;

The header is 0x80 bytes long, read directly from our input file. The dds_pixformat and dds_caps structs are given below:

/// DDS pixel format structure.
///
typedef struct {
    uint32_t size;      ///< structure size, must be 32
    uint32_t flags;     ///< flags to indicate valid fields
    uint32_t fourCC;    ///< compression four-character code
    uint32_t bpp;       ///< bits per pixel
    uint32_t masks[4];  ///< bitmasks for the r,g,b,a channels
} dds_pixformat;

/// DDS caps structure.
///
typedef struct {
    uint32_t flags1;
    uint32_t flags2;
    uint32_t flags3;
    uint32_t flags4;
} dds_caps;

When ImageSpec->open(filename) is called by whatever code is utilizing libOpenImageIO, we eventually end up at the DDSInput::open() function for .dds file-specific handling:

bool
DDSInput::open(const std::string& name, ImageSpec& newspec)
{
    m_filename = name;

    if (!ioproxy_use_or_open(name))
        return false;

    static_assert(sizeof(dds_header) == 128, "dds header size does not match");
    if (!ioread(&m_dds, sizeof(m_dds), 1))
        return false;

// [...]

// sanity checks - valid 4CC, correct struct sizes and flags which should
// be always present, regardless of the image type, size etc., also check
// for impossible flag combinations
if (m_dds.fourCC != DDS_MAKE4CC('D', 'D', 'S', ' ') || m_dds.size != 124
    || m_dds.fmt.size != 32 || !(m_dds.caps.flags1 & DDS_CAPS1_TEXTURE)
    || !(m_dds.flags & DDS_CAPS) || !(m_dds.flags & DDS_PIXELFORMAT)
    || (m_dds.caps.flags2 & DDS_CAPS2_VOLUME
        && !(m_dds.caps.flags1 & DDS_CAPS1_COMPLEX
             && m_dds.flags & DDS_DEPTH))
    || (m_dds.caps.flags2 & DDS_CAPS2_CUBEMAP
        && !(m_dds.caps.flags1 & DDS_CAPS1_COMPLEX))) {
    errorf("Invalid DDS header, possibly corrupt file");
    return false;
}

// make sure all dimensions are > 0 and that we have at least one channel
// (for uncompressed images)
if (!(m_dds.flags & DDS_WIDTH) || !m_dds.width
    || !(m_dds.flags & DDS_HEIGHT) || !m_dds.height
    || ((m_dds.flags & DDS_DEPTH) && !m_dds.depth)
    || (!(m_dds.fmt.flags & DDS_PF_FOURCC)
        && !(m_dds.fmt.flags
             & (DDS_PF_RGB | DDS_PF_LUMINANCE | DDS_PF_ALPHA
                | DDS_PF_ALPHAONLY)))) {
    errorf("Image with no data");
    return false;
}

At [1], we read in the 0x80 bytes for our struct dds_header m_dds object variable. Following that, assorted validation of the file occurs. Continuing on:

  bool
    DDSInput::open(const std::string& name, ImageSpec& newspec)
    { 
    // [...]
    
    // validate the pixel format
    if (m_dds.fmt.flags & DDS_PF_FOURCC) {              // [2]
        // [...]
    }

    // [...]

    // determine the number of channels we have
    if (m_compression != Compression::None) {         
        m_nchans = GetChannelCount(m_compression,
                                   m_dds.fmt.flags & DDS_PF_NORMAL);
    } else {
        m_nchans = ((m_dds.fmt.flags & (DDS_PF_LUMINANCE | DDS_PF_ALPHAONLY))
                        ? 1
                        : 3)
                   + ((m_dds.fmt.flags & DDS_PF_ALPHA) ? 1 : 0);
        // also calculate bytes per pixel and the bit shifts
        m_Bpp = (m_dds.fmt.bpp + 7) >> 3;                              // [3]
        if (!(m_dds.fmt.flags & DDS_PF_LUMINANCE)) {
            for (int i = 0; i < 4; ++i)
                calc_shifts(m_dds.fmt.masks[i], m_BitCounts[i],
                            m_RightShifts[i]);
        }
    }

Assuming that we don’t have the DDS_PF_FOURCC flag set [2], our ImageSpec’s compression is set to Compression::None. This brings us to the code path at [3] and causes our int m_Bpp to be set to a value controlled directly from the input file. Still continuing in DDSInput::open():

bool
DDSInput::open(const std::string& name, ImageSpec& newspec){
     // [...]

    // fix depth, pitch and mipmaps for later use, if needed
    if (!(m_dds.fmt.flags & DDS_PF_FOURCC && m_dds.flags & DDS_PITCH))
        m_dds.pitch = m_dds.width * m_Bpp;
    if (!(m_dds.caps.flags2 & DDS_CAPS2_VOLUME))
        m_dds.depth = 1;
    if (!(m_dds.flags & DDS_MIPMAPCOUNT))
        m_dds.mipmaps = 1;
    // count cube map faces
    if (m_dds.caps.flags2 & DDS_CAPS2_CUBEMAP) {
        m_nfaces = 0;
        for (int flag = DDS_CAPS2_CUBEMAP_POSITIVEX;
             flag <= DDS_CAPS2_CUBEMAP_NEGATIVEZ; flag <<= 1) {
            if (m_dds.caps.flags2 & flag)
                m_nfaces++;
        }
    } else
        m_nfaces = 1;

    seek_subimage(0, 0);   // [4]
    newspec = spec();
    return true;
}

Again, more setting of object variables, but we enter an important call to seek_subimage(0, 0); at [4]:

bool
DDSInput::seek_subimage(int subimage, int miplevel)
{
    if (subimage != 0)
        return false;

    // early out
    if (subimage == current_subimage() && miplevel == current_miplevel()) {
        return true;
    }

    // [...] 

    // for cube maps, the seek will be performed when reading a tile instead
    unsigned int w = 0, h = 0, d = 0;
    TypeDesc::BASETYPE basetype = GetBaseType(m_compression);

Aside from more initialization, there is an important call to GetBaseType() at [5], which opaquely tells the generic ImageSpec object how many bytes a given pixel is. The function itself is rather simple:

static TypeDesc::BASETYPE
GetBaseType(Compression cmp)
{
    if (cmp == Compression::BC6HU || cmp == Compression::BC6HS)
        return TypeDesc::HALF;
    return TypeDesc::UINT8;
}

For the Compression::BC6HU and Compression::BC6HS types, each pixel is two bytes, and for the rest only one byte. As such, continuing with our assumption of having Compression::None, our TypeDesc is TypeDesc::UINT8 and each pixel is one byte. With that all covered, we can now examine how libOpenImageIO reads in the input scanline data from our .dds file:

bool
DDSInput::read_native_scanline(int subimage, int miplevel, int y, int z,
                               void* data)
{
    lock_guard lock(*this);
    if (!seek_subimage(subimage, miplevel))
        return false;

    // don't proceed if a cube map - use tiles then instead
    if (m_dds.caps.flags2 & DDS_CAPS2_CUBEMAP)
        return false;
    if (m_buf.empty())
        readimg_scanlines();                     // [6]

    size_t size = spec().scanline_bytes();
    memcpy(data, &m_buf[0] + z * m_spec.height * size + y * size, size);
    return true;
}

Assuming we’re dealing with scanlines, the first .dds-specific function we hit is DDSInput::read_native_scanline. On first call to this function, we end up hitting the branch at [6], since our object’s m_buf vector has not been initialized yet. To procede into readimg_scanlines:

bool
DDSInput::readimg_scanlines()
{
    //std::cerr << "[dds] readimg: " << ftell() << "\n";
    // resize destination buffer
    m_buf.resize(m_spec.scanline_bytes() * m_spec.height * m_spec.depth          // [7]
                 /*/ (1 << m_miplevel)*/);

    return internal_readimg(&m_buf[0], m_spec.width, m_spec.height,              // [8]
                            m_spec.depth);
}

The overall flow of the function is rather simple, we allocate our m_buf vector at [7] and then read in that many bytes at [8]. Figuring out how many bytes are allocated at [7] is rather annoying, but m_spec.height and m_spec.depth are read in directly from our dds_header from the first 0x80 bytes of the file; m_spec.scanline_bytes follows a few more function calls:

imagesize_t
ImageSpec::scanline_bytes(bool native) const noexcept
{
    if (width < 0)
        return 0;
    return clamped_mult64((imagesize_t)width, (imagesize_t)pixel_bytes(native));  // [9] 
}

At [9], the ImageSpec::width variable corresponds to the dds_header.width from our file. This value is multiplied against the return value of pixel_bytes(false):

size_t
ImageSpec::pixel_bytes(bool native) const noexcept
{
    if (nchannels < 0)
        return 0;
    if (!native || channelformats.empty())
        return clamped_mult32((size_t)nchannels, channel_bytes()); // [10]

At [10], we see the ImageSpec::nchannels value being multiplied against channel_bytes(). Still going with the Compression::None assumption, nchannels is set via the dds_header.fmt.flags:

bool
DDSInput::open(const std::string& name, ImageSpec& newspec)
{
    // [...]
    m_nchans = ((m_dds.fmt.flags & (DDS_PF_LUMINANCE | DDS_PF_ALPHAONLY))
                    ? 1
                    : 3)
               + ((m_dds.fmt.flags & DDS_PF_ALPHA) ? 1 : 0);
    // [...]

Which brings us back to the channel_bytes function:

OpenImageIO_v2_4::ImageSpec::channel_bytes (this=0x7ffff7fbbb20) at oiio-2.4.4.2/src/include/OpenImageIO/imageio.h:367
367         size_t channel_bytes() const noexcept { return format.size(); }

To stop from going deeper into defining macros, format.size() is given by the TypeDesc of our object. Since we’re dealing with Compression::None, we have a DDSInput.basetype == TypeDesc::UINT8;, and the size of our format is 0x1. Finally, putting all of these subsequent function calls together, we end up with our m_buf vector at [7] having a size of m_spec.height * m_spec.dept * m_spec.width * m_spec.nchannels * format.size(), with format.size() only able to be 0x1 or 0x2 and m_spec.nchannels able to only be 0x1 through 0x4. The rest of the values are controlled directly by the input file. Thus, let us see how the call to internal_readimg(&m_buf[0], m_spec.width, m_spec.height, m_spec.depth) behaves at [8]:

bool
DDSInput::internal_readimg(unsigned char* dst, int w, int h, int d)
{
    if (m_compression != Compression::None) {
        // [...]
    } else {
        // uncompressed image     // [11]

        // HACK: shortcut for luminance
        if (m_dds.fmt.flags & DDS_PF_LUMINANCE) {  
            return ioread(dst, w * m_Bpp, h);          // [12]
        }

        std::vector<uint8_t> tmp(w * m_Bpp);
        for (int z = 0; z < d; z++) {
            for (int y = 0; y < h; y++) {
                if (!ioread(tmp.data(), w, m_Bpp))
                    return false;
                size_t k = (z * h * w + y * w) * m_spec.nchannels;
                for (int x = 0; x < w; x++, k += m_spec.nchannels) {
                    uint32_t pixel = 0;
                    OIIO_DASSERT(tmp.size() >= size_t(x * m_Bpp + m_Bpp));
                    memcpy(&pixel, tmp.data() + x * m_Bpp, m_Bpp);
                    for (int ch = 0; ch < m_spec.nchannels; ++ch) {
                        dst[k + ch]
                            = bit_range_convert((pixel & m_dds.fmt.masks[ch])
                                                    >> m_RightShifts[ch],
                                                m_BitCounts[ch], 8);
                    }
                }
            }
        }
    }
    return true;
}

Since we’re again dealing with Compression::None, we hit the branch at [11]. Assuming we have the DDS_PF_LUMINANCE flag set in our dds_header, we hit the call to ioread at [12], which results in our m_buf of size w * m_bpp * h, or m_spec.width * m_bpp * m_spec.height. If we look again at the size of m_buf however, we see an issue:

m_spec.height * m_spec.depth * m_spec.width * m_spec.nchannels * format.size()

Since m_spec.height and m_spec.width are in both the size of the allocation and subsequent read, they don’t matter too much. They end up being m_bpp versus m_spec.depth * m_spec.nchannels * format.size(). Unfortunately for the library, there are no restrictions or validation of m_bpp versus the others. As such, if m_spec.depth * m_spec.nchannels * format.size() is 0x1 (which it can be), and our m_bpp is 0x2, we end up reading twice the size of the m_buf buffer, resulting in a heap overflow and subsequent code execution.

Crash Information

=================================================================
==409483==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60c000000c00 at pc 0x5555555ca52e bp 0x7fffffff9410 sp 0x7fffffff8be0
WRITE of size 134 at 0x60c000000c00 thread T0
[Detaching after fork from child process 409487]
    #0 0x5555555ca52d in fread (/oiio/fuzzing_release/fuzz_oiio.bin+0x7652d) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #1 0x7fffeb62030a in OpenImageIO_v2_4::Filesystem::IOFile::read(void*, unsigned long) /oiio/oiio-2.4.4.2/src/libutil/filesystem.cpp:1161:16
    #2 0x7ffff2d52901 in OpenImageIO_v2_4::ImageInput::ioread(void*, unsigned long, unsigned long) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:1220:25
    #3 0x7ffff3787c67 in OpenImageIO_v2_4::DDSInput::internal_readimg(unsigned char*, int, int, int) /oiio/oiio-2.4.4.2/src/dds.imageio/ddsinput.cpp:754:20
    #4 0x7ffff378e6e6 in OpenImageIO_v2_4::DDSInput::readimg_scanlines() /oiio/oiio-2.4.4.2/src/dds.imageio/ddsinput.cpp:790:12
    #5 0x7ffff379039e in OpenImageIO_v2_4::DDSInput::read_native_scanline(int, int, int, int, void*) /oiio/oiio-2.4.4.2/src/dds.imageio/ddsinput.cpp:829:9
    #6 0x7ffff2d2b0c8 in OpenImageIO_v2_4::ImageInput::read_native_scanlines(int, int, int, int, int, void*) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:399:19
    #7 0x7ffff2d2bdfd in OpenImageIO_v2_4::ImageInput::read_native_scanlines(int, int, int, int, int, int, int, void*) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:420:16
    #8 0x7ffff2d27880 in OpenImageIO_v2_4::ImageInput::read_scanlines(int, int, int, int, int, int, int, OpenImageIO_v2_4::TypeDesc, void*, long, long) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:336:15
    #9 0x7ffff2d498d9 in OpenImageIO_v2_4::ImageInput::read_image(int, int, int, int, OpenImageIO_v2_4::TypeDesc, void*, long, long, long, bool (*)(void*, float), void*) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:967:23
    #10 0x55555566fa75 in LLVMFuzzerTestOneInput /oiio/fuzzing_release/./oiio_harness.cpp:90:18
    #11 0x5555555954e3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/oiio/fuzzing_release/fuzz_oiio.bin+0x414e3) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #12 0x55555557f25f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/oiio/fuzzing_release/fuzz_oiio.bin+0x2b25f) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #13 0x555555584fb6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/oiio/fuzzing_release/fuzz_oiio.bin+0x30fb6) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #14 0x5555555aedd2 in main (/oiio/fuzzing_release/fuzz_oiio.bin+0x5add2) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #15 0x7fffec2d5d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #16 0x7fffec2d5e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #17 0x555555579b24 in _start (/oiio/fuzzing_release/fuzz_oiio.bin+0x25b24) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)

0x60c000000c00 is located 0 bytes to the right of 128-byte region [0x60c000000b80,0x60c000000c00)
allocated by thread T0 here:
    #0 0x55555566c90d in operator new(unsigned long) (/oiio/fuzzing_release/fuzz_oiio.bin+0x11890d) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #1 0x7ffff13d2411 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 0x7ffff13d2293 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 0x7ffff13d0d2b 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 0x7ffff13ce381 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 0x7ffff13cc26c 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 0x7ffff378de4e in OpenImageIO_v2_4::DDSInput::readimg_scanlines() /oiio/oiio-2.4.4.2/src/dds.imageio/ddsinput.cpp:787:11
    #7 0x7ffff379039e in OpenImageIO_v2_4::DDSInput::read_native_scanline(int, int, int, int, void*) /oiio/oiio-2.4.4.2/src/dds.imageio/ddsinput.cpp:829:9
    #8 0x7ffff2d2b0c8 in OpenImageIO_v2_4::ImageInput::read_native_scanlines(int, int, int, int, int, void*) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:399:19
    #9 0x7ffff2d2bdfd in OpenImageIO_v2_4::ImageInput::read_native_scanlines(int, int, int, int, int, int, int, void*) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:420:16
    #10 0x7ffff2d27880 in OpenImageIO_v2_4::ImageInput::read_scanlines(int, int, int, int, int, int, int, OpenImageIO_v2_4::TypeDesc, void*, long, long) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:336:15
    #11 0x7ffff2d498d9 in OpenImageIO_v2_4::ImageInput::read_image(int, int, int, int, OpenImageIO_v2_4::TypeDesc, void*, long, long, long, bool (*)(void*, float), void*) /oiio/oiio-2.4.4.2/src/libOpenImageIO/imageinput.cpp:967:23
    #12 0x55555566fa75 in LLVMFuzzerTestOneInput /oiio/fuzzing_release/./oiio_harness.cpp:90:18
    #13 0x5555555954e3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/oiio/fuzzing_release/fuzz_oiio.bin+0x414e3) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #14 0x55555557f25f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/oiio/fuzzing_release/fuzz_oiio.bin+0x2b25f) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #15 0x555555584fb6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/oiio/fuzzing_release/fuzz_oiio.bin+0x30fb6) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #16 0x5555555aedd2 in main (/oiio/fuzzing_release/fuzz_oiio.bin+0x5add2) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396)
    #17 0x7fffec2d5d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

SUMMARY: AddressSanitizer: heap-buffer-overflow (/oiio/fuzzing_release/fuzz_oiio.bin+0x7652d) (BuildId: f09352d3bef5f556105d0e82941fa30a26694396) in fread
Shadow bytes around the buggy address:
  0x0c187fff8130: fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa fa
  0x0c187fff8140: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x0c187fff8150: fa fa fa fa fa fa fa fa 00 00 00 00 00 00 00 00
  0x0c187fff8160: 00 00 00 00 00 00 00 00 fa fa fa fa fa fa fa fa
  0x0c187fff8170: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c187fff8180:[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c187fff8190: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c187fff81a0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c187fff81b0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c187fff81c0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c187fff81d0: 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
==409483==ABORTING
[Thread 0x7fffe65f9640 (LWP 409486) exited]
[Inferior 1 (process 409483) exited with code 01]
TIMELINE

2022-10-19 - Initial Vendor Contact
2022-10-20 - Vendor Disclosure
2022-11-01 - Vendor Patch Release
2022-12-22 - Public Release

Credit

Discovered by Lilith >_> of Cisco Talos.