Talos Vulnerability Report

TALOS-2022-1628

OpenImageIO TGA Format Stack Buffer Overflow Vulnerability

December 22, 2022
CVE Number

CVE-2022-41981

SUMMARY

A stack-based buffer overflow vulnerability exists in the TGA file format parser of OpenImageIO v2.3.19.0. A specially-crafted targa file can lead to out of bounds read and write on the process stack, which can lead to arbitrary code execution. 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.3.19.0

PRODUCT URLS

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

CVSSv3 SCORE

8.1 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-121 - Stack-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.

When reading in Targa (.tga) files, the libOpenImageIO code flows are pretty quick and easy, matching the relative simpleness of the file format itself. Starting with TGAInput::open(const std::stirng &name, ImageSpec& newspec):

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

    DBG("TGA opening {}\n", name);
    uint64_t filesize = Filesystem::file_size(name);
    m_file            = Filesystem::fopen(name, "rb");
    if (!m_file) {
        errorf("Could not open file \"%s\"", name);
        return false;
    }

    // Due to struct packing, we may get a corrupt header if we just load the
    // struct from file; to address that, read every member individually save
    // some typing. Byte swapping is done automatically. If any fail, the file
    // handle is closed and we return false from open().
    if (!(read(m_tga.idlen) && read(m_tga.cmap_type) && read(m_tga.type)
          && read(m_tga.cmap_first) && read(m_tga.cmap_length)
          && read(m_tga.cmap_size) && read(m_tga.x_origin)
          && read(m_tga.y_origin) && read(m_tga.width) && read(m_tga.height)
          && read(m_tga.bpp) && read(m_tga.attr))) {
        errorfmt("Could not read full header");
        return false;
    }

The file is opened and we start reading the appropriate fields into the following struct:

<(^.^)>#ptype m_tga
type = struct {
    uint8_t idlen;
    uint8_t cmap_type;
    uint8_t type;
    uint16_t cmap_first;
    uint16_t cmap_length;
    uint8_t cmap_size;
    uint16_t x_origin;
    uint16_t y_origin;
    uint16_t width;
    uint16_t height;
    uint8_t bpp;
    uint8_t attr;
}

Assorted validation on these parameters occurs, but is not really worth delving into. Assuming we pass all the checks, we end up reading the last 26 bytes of the file to see if it’s a TGA 2.0 image, which will require extra parsing:

// now try and see if it's a TGA 2.0 image
// TGA 2.0 files are identified by a nifty "TRUEVISION-XFILE.\0" signature
bool check_for_tga2 = (filesize > 26 + 18);                             // [1]
if (check_for_tga2 && Filesystem::fseek(m_file, -26, SEEK_END) != 0) {
    errorfmt("Could not seek to find the TGA 2.0 signature.");
    return false;
}
if (check_for_tga2 && read(m_foot.ofs_ext) && read(m_foot.ofs_dev)     // [2]
    && fread(&m_foot.signature, sizeof(m_foot.signature), 1)
    && !strncmp(m_foot.signature, "TRUEVISION-XFILE.", 17)) {            // [3]
    //std::cerr << "[tga] this is a TGA 2.0 file\n";
    m_spec.attribute("targa:version", 2);
    m_tga_version = 2;

    // read the extension area
    if (!fseek(m_foot.ofs_ext)) {   // [4]
        return false;
    }

At [1], we make sure we have the required 44 bytes, and assuming so, we read all the fields of the m_foot struct at [2]:

<(^.^)>#ptype m_foot
type = struct {
    uint32_t ofs_ext;
    uint32_t ofs_dev;
    char signature[18];
}

If the signature is found via the strncmp at [3], then we read more specific information after that from the absolute offset into the file given by m_foot.ofs_ext at [4]:

    // check if this is a TGA 2.0 extension area
    // according to the 2.0 spec, the size for valid 2.0 files is exactly
    // 495 bytes, and the reader should only read as much as it understands
    // for < 495, we ignore this section of the file altogether
    // for > 495, we only read what we know
    uint16_t s;
    if (!read(s))
        return false;
    //std::cerr << "[tga] extension area size: " << s << "\n";
    if (s >= 495) {                 // [5]
        union { 
            unsigned char c[324];  // so as to accommodate the comments
            uint16_t s[6];
            uint32_t l;
        } buf;

        // load image author
        if (!fread(buf.c, 41, 1))
            return false;
        if (buf.c[0])
            m_spec.attribute("Artist", (char*)buf.c); // [6]

        // load image comments
        if (!fread(buf.c, 324, 1))     // [7]
            return false;

If the size of our extension area is greater than or equal to 495, then we end up hitting the branch at [5], where we start reading into the stack-based union buf, which is 324 bytes long. The basic codeflow after this is to just read bytes into the buf union and then call m_spec.attribute to set various attributes of our resultant object (the first example is seen at [6]). A clear oversight can be seen on line [7] however, as the entirety of the buf buffer is filled up by fread, a function that does not null terminate the end result. If we continue on in TGAinput::open(), we can quickly see an assortment of instances where this causes out-of-bounds stack data to be read:

        // concatenate the lines into a single string
        std::string tmpstr((const char*)buf.c);
        if (buf.c[81]) {
            tmpstr += "\n";
            tmpstr += (const char*)&buf.c[81];
        }
        if (buf.c[162]) {
            tmpstr += "\n";
            tmpstr += (const char*)&buf.c[162];
        }
        if (buf.c[243]) {
            tmpstr += "\n";
            tmpstr += (const char*)&buf.c[243]; 
        }

Each of these tmpstr += ... buf.c[] lines will read out of bounds assuming there are no null bytes within the buffer, but we can keep looking to see a more interesting instance:

        // timestamp
        if (!fread(buf.s, 2, 6))
            return false;
        if (buf.s[0] || buf.s[1] || buf.s[2] || buf.s[3] || buf.s[4]
            || buf.s[5]) {
            if (bigendian())
                swap_endian(&buf.s[0], 6);
            sprintf((char*)&buf.c[12], "%04u:%02u:%02u %02u:%02u:%02u",   
                    buf.s[2], buf.s[0], buf.s[1], buf.s[3], buf.s[4],
                    buf.s[5]);
            m_spec.attribute("DateTime", (char*)&buf.c[12]);
        }

        // job name/ID
        if (!fread(buf.c, 41, 1))
            return false;
        if (buf.c[0])
            m_spec.attribute("DocumentName", (char*)buf.c);

        // job time
        if (!fread(buf.s, 2, 3))
            return false;
        if (buf.s[0] || buf.s[1] || buf.s[2]) {
            if (bigendian())
                swap_endian(&buf.s[0], 3);
            sprintf((char*)&buf.c[6], "%u:%02u:%02u", buf.s[0], buf.s[1],  
                    buf.s[2]);
            m_spec.attribute("targa:JobTime", (char*)&buf.c[6]);
        }

        // software
        if (!fread(buf.c, 41, 1))       // [8]
            return false;
        uint16_t n;
        char l;
        if (!read(n) || !read(l))
            return false;
        if (buf.c[0]) {
            // tack on the version number and letter
            sprintf((char*)&buf.c[strlen((char*)buf.c)], " %u.%u%c",  // [10]
                    n / 100, n % 100, l != ' ' ? l : 0);
            m_spec.attribute("Software", (char*)buf.c);
        }

For each of these code blocks, more bytes are read in from the file and if there are any non-null bytes, a sprintf occurs. But if we look at the block starting at [8], we read in 41 new bytes and then use those bytes to formulate a Software attribute at the sprintf at [10]. But we can quickly see that the Software write occurs inside buf.c[strlen(buf.c)], which if we’ll remember, is not necessarily null terminated. Thus, the strlen(buf.c) can return a value greater than the size of the temp buf buffer, and the sprintf can out-of-bounds write to other variables in the stack. The exact location is obviously dependent on how the software is compiled, but it could potentially result in code execution.

It should be noted that this vulnerability is only an out-of-bounds read in the current master branch commit, as sprintf(buf[strlen(buf)]) is not used, and so we can only disclose out-of-bounds stack data instead.

Crash Information

=================================================================
==507317==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fffffff9574 at pc 0x5555555c4e56 bp 0x7fffffff9070 sp 0x7fffffff8838
READ of size 82 at 0x7fffffff9574 thread T0
[Detaching after fork from child process 507321]
    #0 0x5555555c4e55 in strlen (/oiio/fuzzing/unpatched_setup/fuzz_oiio.bin+0x70e55) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #1 0x7ffff7eca987 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator+=(char const*) (/lib/x86_64-linux-gnu/libstdc++.so.6+0x14d987) (BuildId: 725ef5da52ee6d881f9024d8238a989903932637)
    #2 0x7ffff35ed433 in OpenImageIO_v2_3::TGAInput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_3::ImageSpec&) /oiio/oiio-2.3.19.0-unpatched/src/targa.imageio/targainput.cpp:338:24
    #3 0x7ffff35f8d49 in OpenImageIO_v2_3::TGAInput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_3::ImageSpec&, OpenImageIO_v2_3::ImageSpec const&) /oiio/oiio-2.3.19.0-unpatched/src/targa.imageio/targainput.cpp:499:12
    #4 0x7ffff2577669 in OpenImageIO_v2_3::ImageInput::create(OpenImageIO_v2_3::string_view, bool, OpenImageIO_v2_3::ImageSpec const*, OpenImageIO_v2_3::Filesystem::IOProxy*, OpenImageIO_v2_3::string_view) /oiio/oiio-2.3.19.0-unpatched/src/libOpenImageIO/imageioplugin.cpp:758:27
    #5 0x7ffff2456589 in OpenImageIO_v2_3::ImageInput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_3::ImageSpec const*, OpenImageIO_v2_3::Filesystem::IOProxy*) /oiio/oiio-2.3.19.0-unpatched/src/libOpenImageIO/imageinput.cpp:105:16
    #6 0x55555566f40f in LLVMFuzzerTestOneInput /oiio/fuzzing/./oiio_harness.cpp:77:16
    #7 0x5555555954e3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/oiio/fuzzing/unpatched_setup/fuzz_oiio.bin+0x414e3) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #8 0x55555557f25f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/oiio/fuzzing/unpatched_setup/fuzz_oiio.bin+0x2b25f) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #9 0x555555584fb6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/oiio/fuzzing/unpatched_setup/fuzz_oiio.bin+0x30fb6) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #10 0x5555555aedd2 in main (/oiio/fuzzing/unpatched_setup/fuzz_oiio.bin+0x5add2) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #11 0x7fffe8b75d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #12 0x7fffe8b75e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #13 0x555555579b24 in _start (/oiio/fuzzing/unpatched_setup/fuzz_oiio.bin+0x25b24) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)

Address 0x7fffffff9574 is located in stack of thread T0 at offset 1236 in frame
    #0 0x7ffff35e171f in OpenImageIO_v2_3::TGAInput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_3::ImageSpec&) /oiio/oiio-2.3.19.0-unpatched/src/targa.imageio/targainput.cpp:168

  This frame has 45 object(s):
    [32, 48) 'agg.tmp'
    [64, 80) 'agg.tmp10'
    [96, 112) 'agg.tmp13'
    [128, 288) 'ref.tmp' (line 242)
    [352, 360) 'agg.tmp519'
    [384, 400) 'agg.tmp534'
    [416, 432) 'agg.tmp573'
    [448, 464) 'agg.tmp574'
    [480, 736) 'id' (line 272)
    [800, 816) 'agg.tmp605'
    [832, 848) 'agg.tmp606'
    [864, 880) 'agg.tmp678'
    [896, 898) 's' (line 305)
    [912, 1236) 'buf' (line 310)
    [1312, 1328) 'agg.tmp718' <== Memory access at offset 1236 partially underflows this variable
    [1344, 1360) 'agg.tmp719'
    [1376, 1408) 'tmpstr' (line 327)
    [1440, 1441) 'ref.tmp732' (line 327)
    [1456, 1472) 'agg.tmp806'
    [1488, 1504) 'agg.tmp808'
    [1520, 1536) 'agg.tmp939'
    [1552, 1568) 'agg.tmp941'
    [1584, 1600) 'agg.tmp972'
    [1616, 1632) 'agg.tmp974'
    [1648, 1664) 'agg.tmp1051'
    [1680, 1696) 'agg.tmp1053'
    [1712, 1714) 'n' (line 376)
    [1728, 1729) 'l' (line 377)
    [1744, 1760) 'agg.tmp1119'
    [1776, 1792) 'agg.tmp1121'
    [1808, 1824) 'agg.tmp1160'
    [1840, 1844) 'gamma' (line 410)
    [1856, 1872) 'agg.tmp1224'
    [1888, 1904) 'agg.tmp1227'
    [1920, 1936) 'agg.tmp1237'
    [1952, 1968) 'agg.tmp1239'
    [1984, 2016) 'ref.tmp1240' (line 419)
    [2048, 2064) 'agg.tmp1252'
    [2080, 2096) 'agg.tmp1317'
    [2112, 2114) 'res' (line 460)
    [2128, 2144) 'agg.tmp1369'
    [2160, 2176) 'agg.tmp1383'
    [2192, 2208) 'agg.tmp1401'
    [2224, 2240) 'agg.tmp1443'
    [2256, 2272) 'agg.tmp1474'
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (/oiio/fuzzing/unpatched_setup/fuzz_oiio.bin+0x70e55) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25) in strlen
Shadow bytes around the buggy address:
  0x10007fff7250: f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8
  0x10007fff7260: f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8
  0x10007fff7270: f2 f2 f2 f2 f2 f2 f2 f2 00 00 f2 f2 00 00 f2 f2
  0x10007fff7280: 00 00 f2 f2 02 f2 00 00 00 00 00 00 00 00 00 00
  0x10007fff7290: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fff72a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00[04]f2
  0x10007fff72b0: f2 f2 f2 f2 f2 f2 f2 f2 00 00 f2 f2 00 00 f2 f2
  0x10007fff72c0: 00 00 00 00 f2 f2 f2 f2 f8 f2 00 00 f2 f2 00 00
  0x10007fff72d0: f2 f2 00 00 f2 f2 00 00 f2 f2 00 00 f2 f2 00 00
  0x10007fff72e0: f2 f2 00 00 f2 f2 00 00 f2 f2 f8 f2 f8 f2 00 00
  0x10007fff72f0: f2 f2 00 00 f2 f2 00 00 f2 f2 f8 f2 00 00 f2 f2
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
==507317==ABORTING
[Thread 0x7fffe2ff9640 (LWP 507320) exited]
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.