Talos Vulnerability Report

TALOS-2023-1707

OpenImageIO Project OpenImageIO TGAInput::read_tga2_header information disclosure vulnerability

March 30, 2023
CVE Number

CVE-2023-24473

SUMMARY

An information disclosure vulnerability exists in the TGAInput::read_tga2_header functionality of OpenImageIO Project OpenImageIO v2.4.7.1. A specially crafted targa file can lead to a disclosure of sensitive information. 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.7.1

PRODUCT URLS

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

CVSSv3 SCORE

5.3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/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.

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);
    if (!ioproxy_use_or_open(name))
        return false;
    ioseek(0);

    // 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 = (ioproxy()->size() > 26 + 18);
if (check_for_tga2 && !ioseek(-26, SEEK_END)) {
    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)  // [1]
    && ioread(&m_foot.signature, sizeof(m_foot.signature), 1)
    && !strncmp(m_foot.signature, "TRUEVISION-XFILE.", 17)) {
    //std::cerr << "[tga] this is a TGA 2.0 file\n";
    m_tga_version = 2;
    if (!read_tga2_header())   //[2]
        return false;
} else {
    m_tga_version = 1;
}

Assuming that the correct magic bytes are found in the branch at [1], we read in the tga2 header starting at [2]:

bool
TGAInput::read_tga2_header()
{
    // read the extension area
    if (!ioseek(m_foot.ofs_ext)) {
        return false;
    }
    // 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))                                                // [3]
        return false;
    //std::cerr << "[tga] extension area size: " << s << "\n";
    if (s >= 495) {
        union {
            unsigned char c[324];  // so as to accommodate the comments // [4]
            uint16_t s[6];
            uint32_t l;
        } buf;

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

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

The comments above give the flow, but for extra context, we read in the size of our extension information at [3] and then start reading the various fields into the buf union at [4]. It’s important to note that the populating function ioread does not null terminate nor truncate the input at all, so the line at [5] ends up completely filling up the buf union, which becomes important soon.

        // concatenate the lines into a single string
        std::string tmpstr = Strutil::safe_string((const char*)buf.c, 81);   // [6]
        if (buf.c[81]) {
            tmpstr += "\n";
            tmpstr += Strutil::safe_string((const char*)&buf.c[81], 81);
        }
        if (buf.c[162]) {
            tmpstr += "\n";
            tmpstr += Strutil::safe_string((const char*)&buf.c[162], 81);
        }
        if (buf.c[243]) {
            tmpstr += "\n";
            tmpstr += Strutil::safe_string((const char*)&buf.c[243], 81);
        }
        if (tmpstr.length() > 0)
            m_spec.attribute("ImageDescription", tmpstr);

        // timestamp
        if (!ioread(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);
            m_spec.attribute(
                "DateTime",
                Strutil::fmt::format("{:04}:{:02}:{:02} {:02}:{:02}:{:02}",
                                     buf.s[2], buf.s[0], buf.s[1], buf.s[3],
                                     buf.s[4], buf.s[5]));
        }

While the calls to Strutil::safe_string starting at [6] correctly limit the amount of bytes that get read in from our temporary buf buffer (a change made in response to a similar vulnerability in this function), and subsequent reads are non-string based, if we look further into the function, we see an issue:

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

        // job time
        if (!ioread(buf.s, 2, 3))
            return false;
        if (buf.s[0] || buf.s[1] || buf.s[2]) {
            if (bigendian())
                swap_endian(&buf.s[0], 3);
            m_spec.attribute("targa:JobTime",
                             Strutil::fmt::format("{}:{:02}:{:02}", buf.s[0],
                                                  buf.s[1], buf.s[2]));
        }

        // software
        if (!ioread(buf.c, 41, 1))
            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
            std::string soft((const char*)buf.c);                     // [8]
            soft += Strutil::fmt::format(" {}.{}", n / 100, n % 100);
            if (l != ' ')
                soft += l;
            m_spec.attribute("Software", soft);
        }

Since there is no forced null termination on the buf buffer, it is possible for our input file to just not have a null inside the read at [5]. This leads the m_spec.attribute call at [7] and the std::string creation at [8] to both read out of bounds on the stack when generating our metadata, since they both search for a terminating null byte to determine the string length. If the buf variable was one byte longer with a null at the end, or if buf was zeroed and then the ioread at [5] was one byte less, this would not be an issue. Alas, depending on how the compiler arranges our stack variables, different leaked information will be stored into the resultant image object.

Crash Information

==591817==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fffffff8b74 at pc 0x5555555c4e56 bp 0x7fffffff8950 sp 0x7fffffff8118
READ of size 327 at 0x7fffffff8b74 thread T0
[Detaching after fork from child process 591841]
    #0 0x5555555c4e55 in strlen (/oiio/fuzzing/fuzz_oiio.bin+0x70e55) (BuildId: c47f4a88ed888e6af376abd149e3cfe6bd24ceea)
    #1 0x7ffff0096e5c in std::char_traits<char>::length(char const*) /usr/bin/../lib/gcc/x86_64-linux-gnu/12/../../../../include/c++/12/bits/char_traits.h:395:9
    #2 0x7fffeff004c3 in OpenImageIO_v2_4::basic_string_view<char, std::char_traits<char> >::basic_string_view(char const*) /oiio/oiio-2.4.7.1/src/include/OpenImageIO/string_view.h:108:41
    #3 0x7ffff3c03e91 in OpenImageIO_v2_4::TGAInput::read_tga2_header() /oiio/oiio-2.4.7.1/src/targa.imageio/targainput.cpp:371:46
    #4 0x7ffff3bfe099 in OpenImageIO_v2_4::TGAInput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_4::ImageSpec&) /oiio/oiio-2.4.7.1/src/targa.imageio/targainput.cpp:267:14
    #5 0x7ffff3c0a007 in OpenImageIO_v2_4::TGAInput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_4::ImageSpec&, OpenImageIO_v2_4::ImageSpec const&) /oiio/oiio-2.4.7.1/src/ta
rga.imageio/targainput.cpp:298:12
    #6 0x7ffff2cebce9 in OpenImageIO_v2_4::ImageInput::create(OpenImageIO_v2_4::basic_string_view<char, std::char_traits<char> >, bool, OpenImageIO_v2_4::ImageSpec const*, OpenImageIO_v2_4::Filesystem::IOProxy*, OpenImageIO_v2_4::basic_string_view<char, std::char_traits<
char> >) /oiio/oiio-2.4.7.1/src/libOpenImageIO/imageioplugin.cpp:786:27
    #7 0x7ffff2bc51c9 in OpenImageIO_v2_4::ImageInput::open(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, OpenImageIO_v2_4::ImageSpec const*, OpenImageIO_v2_4::Filesystem::IOProxy*) /oiio/oiio-2.
4.7.1/src/libOpenImageIO/imageinput.cpp:112:16
    #8 0x55555566f40f in LLVMFuzzerTestOneInput /oiio/fuzzing/./oiio_harness.cpp:77:16
    #9 0x5555555954e3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/oiio/fuzzing/fuzz_oiio.bin+0x414e3) (BuildId: c47f4a88ed888e6af376abd149e3cfe6bd24ceea)
    #10 0x55555557f25f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/oiio/fuzzing/fuzz_oiio.bin+0x2b25f) (BuildId: c47f4a88ed888e6af376abd149e3cfe6bd24ceea)
    #11 0x555555584fb6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/oiio/fuzzing/fuzz_oiio.bin+0x30fb6) (BuildId: c47f4a88ed888e6af376abd149e3cfe6bd24ceea)
    #12 0x5555555aedd2 in main (/oiio/fuzzing/fuzz_oiio.bin+0x5add2) (BuildId: c47f4a88ed888e6af376abd149e3cfe6bd24ceea)
    #13 0x7fffebfc9d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #14 0x7fffebfc9e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #15 0x555555579b24 in _start (/oiio/fuzzing/fuzz_oiio.bin+0x25b24) (BuildId: c47f4a88ed888e6af376abd149e3cfe6bd24ceea)

Address 0x7fffffff8b74 is located in stack of thread T0 at offset 372 in frame
    #0 0x7ffff3bfff2f in OpenImageIO_v2_4::TGAInput::read_tga2_header() /oiio/oiio-2.4.7.1/src/targa.imageio/targainput.cpp:305

  This frame has 40 object(s):
    [32, 34) 's' (line 315)
    [48, 372) 'buf' (line 320)
    [448, 464) 'agg.tmp' <== Memory access at offset 372 partially underflows this variable
    [480, 496) 'agg.tmp32' <== Memory access at offset 372 partially underflows this variable
    [512, 544) 'tmpstr' (line 337) <== Memory access at offset 372 partially underflows this variable
    [576, 608) 'ref.tmp' (line 340) <== Memory access at offset 372 partially underflows this variable
    [640, 672) 'ref.tmp81' (line 344) <== Memory access at offset 372 partially underflows this variable
    [704, 736) 'ref.tmp108' (line 348)
    [768, 784) 'agg.tmp133'
    [800, 816) 'agg.tmp134'
    [832, 848) 'agg.tmp209'
    [864, 880) 'agg.tmp210'
    [896, 928) 'ref.tmp211' (line 360)
    [960, 976) 'agg.tmp286'
    [992, 1008) 'agg.tmp287'
    [1024, 1040) 'agg.tmp335'
    [1056, 1072) 'agg.tmp336'
    [1088, 1120) 'ref.tmp337' (line 379)
    [1152, 1154) 'n' (line 387)
    [1168, 1169) 'l' (line 388)
    [1184, 1216) 'soft' (line 393)
    [1248, 1249) 'ref.tmp395' (line 393)
    [1264, 1296) 'ref.tmp400' (line 394)
    [1328, 1332) 'ref.tmp401' (line 394)
    [1344, 1348) 'ref.tmp404' (line 394)
    [1360, 1376) 'agg.tmp431'
    [1392, 1408) 'agg.tmp432'
    [1424, 1440) 'agg.tmp474'
    [1456, 1460) 'gamma' (line 423)
    [1472, 1488) 'agg.tmp539'
    [1504, 1520) 'agg.tmp540'
    [1536, 1552) 'agg.tmp550'
    [1568, 1584) 'agg.tmp551'
    [1600, 1632) 'ref.tmp552' (line 432)
    [1664, 1680) 'agg.tmp566'
    [1696, 1712) 'agg.tmp630'
    [1728, 1730) 'res' (line 473)
    [1744, 1760) 'agg.tmp685'
    [1776, 1792) 'agg.tmp698'
    [1808, 1824) 'agg.tmp715'
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/fuzz_oiio.bin+0x70e55) (BuildId: c47f4a88ed888e6af376abd149e3cfe6bd24ceea) in strlen
Shadow bytes around the buggy address:
  0x10007fff7110: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fff7120: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fff7130: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fff7140: f1 f1 f1 f1 02 f2 00 00 00 00 00 00 00 00 00 00
  0x10007fff7150: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fff7160: 00 00 00 00 00 00 00 00 00 00 00 00 00 00[04]f2
  0x10007fff7170: f2 f2 f2 f2 f2 f2 f2 f2 00 00 f2 f2 00 00 f2 f2
  0x10007fff7180: 00 00 00 00 f2 f2 f2 f2 f8 f8 f8 f8 f2 f2 f2 f2
  0x10007fff7190: f8 f8 f8 f8 f2 f2 f2 f2 f8 f8 f8 f8 f2 f2 f2 f2
  0x10007fff71a0: 00 00 f2 f2 00 00 f2 f2 00 00 f2 f2 00 00 f2 f2
  0x10007fff71b0: f8 f8 f8 f8 f2 f2 f2 f2 00 00 f2 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
==591817==ABORTING
TIMELINE

2023-02-07 - Vendor Disclosure
2023-02-13 - Vendor Patch Release
2023-03-30 - Public Release

Credit

Discovered by Lilith >_> of Cisco Talos.