Talos Vulnerability Report


OpenImageIO RLE encoded BMP image out-of-bounds write vulnerability

December 22, 2022
CVE Number



A heap out-of-bounds write vulnerability exists in the way OpenImageIO v2.3.19.0 processes RLE encoded BMP images. A specially-crafted bmp file can write to arbitrary out of bounds memory, which can lead to arbitrary code execution. An attacker can provide a malicious file to trigger this vulnerability.


The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

OpenImageIO Project OpenImageIO master-branch-9aeece7a
OpenImageIO Project OpenImageIO v2.3.19.0


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


9.8 - CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H


CWE-123 - Write-what-where Condition


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.

With OpenImageIO, handling of different file formats is a relatively simple matter. auto inp = ImageInput::open(filename) takes care of all the intricate details of figuring out which file format we’re dealing with and redirects the codeflow to the appropriate file handlers. The first stop we hit for the BMP file format is as such:

BmpInput::open(const std::string& name, ImageSpec& newspec,
               const ImageSpec& config)
   // [...]
    // we read header of the file that we think is BMP file
    if (!m_bmp_header.read_header(ioproxy())) {
        errorfmt("\"{}\": wrong bmp header size", name);
        return false;
    if (!m_bmp_header.isBmp()) {   // [1]
        errorfmt("\"{}\" is not a BMP file, magic number doesn't match", name);
        return false;
     if (!m_dib_header.read_header(ioproxy())) { // [2]
    errorfmt("\"{}\": wrong bitmap header size", name);
    return false;

At [1], the basic bmp file headers are read in rather simply:

BmpFileHeader::read_header(Filesystem::IOProxy* fd)
    if (!fread(fd, &magic) || !fread(fd, &fsize) || !fread(fd, &res1)
        || !fread(fd, &res2) || !fread(fd, &offset)) {
        return false;

    if (bigendian())
    return true;

And at [2], the Dib Information is read in a little more variably:

DibInformationHeader::read_header(Filesystem::IOProxy* fd)
    if (!fread(fd, &size))
        return false;

    if (size == WINDOWS_V3 || size == WINDOWS_V4 || size == WINDOWS_V5
        || size == UNDOCHEADER52 || size == UNDOCHEADER56) {
        if (!fread(fd, &width) || !fread(fd, &height) || !fread(fd, &cplanes)
            || !fread(fd, &bpp) || !fread(fd, &compression)
            || !fread(fd, &isize) || !fread(fd, &hres) || !fread(fd, &vres)
            || !fread(fd, &cpalete) || !fread(fd, &important)) {
            return false;

        if (size == WINDOWS_V4 || size == WINDOWS_V5 || size == UNDOCHEADER52
            || size == UNDOCHEADER56) {
            if (!fread(fd, &red_mask) || !fread(fd, &green_mask)
                || !fread(fd, &blue_mask)) {
                return false;
            if (size != UNDOCHEADER52 && !fread(fd, &alpha_mask)) {
                return false;

        if (size == WINDOWS_V4 || size == WINDOWS_V5) {
            if (!fread(fd, &cs_type) || !fread(fd, &red_x) || !fread(fd, &red_y)
                || !fread(fd, &red_z) || !fread(fd, &green_x)
                || !fread(fd, &green_y) || !fread(fd, &green_z)
                || !fread(fd, &blue_x) || !fread(fd, &blue_y)
                || !fread(fd, &blue_z) || !fread(fd, &gamma_x)
                || !fread(fd, &gamma_y) || !fread(fd, &gamma_z)) {
                return false;

        if (size == WINDOWS_V5) {
            if (!fread(fd, &intent) || !fread(fd, &profile_data)
                || !fread(fd, &profile_size) || !fread(fd, &reserved)) {
                return false;
    } else if (size == OS2_V1) {
        // some of these fields are smaller than in WINDOWS_Vx headers,
        // so we read into 16 bit ints and copy.
        uint16_t width16  = 0;
        uint16_t height16 = 0;
        if (!fread(fd, &width16) || !fread(fd, &height16)
            || !fread(fd, &cplanes) || !fread(fd, &bpp)) {
            return false;
        width  = width16;
        height = height16;
    if (bigendian())
    return true;

Regardless, the only real way either of these functions can fail is if there aren’t enough bytes in the file to read. Assuming the reading occurs successfully, we end up with m_bmp_header and m_dib_header looking like the following:

 class OpenImageIO_v2_3::bmp_pvt::BmpFileHeader
    int16_t magic = 0x4d42
    int32_t fsize = 0x71c
    int16_t res1 = 0x0
    int16_t res2 = 0x0
    int32_t offset = 0x436

 class OpenImageIO_v2_3::bmp_pvt::DibInformationHeader
    int32_t size = 0x28
    int32_t width = 0x7f
    int32_t height = 0x40
    int16_t cplanes = 0x1
    int16_t bpp = 0x8
    int32_t compression = 0x1
    int32_t isize = 0x102e6
    int32_t hres = 0xf00
    int32_t vres = 0x67000000
    int32_t cpalete = 0x1
    int32_t important = 0xd6000000
    // [...]

Continuing back on in BmpInput::open, these headers are now validated a little. If the DibInformationHeader shows that there’s compression, we hit the following code:

if (m_dib_header.compression == RLE4_COMPRESSION
    || m_dib_header.compression == RLE8_COMPRESSION) {
    if (!read_rle_image()) { // [3]
        errorfmt("BMP error reading rle-compressed image");
        return false;

Looking at the entirety of read_rle_image() at [3], we quickly notice an issue:

    int rletype = m_dib_header.compression == RLE4_COMPRESSION ? 4 : 8;
    m_spec.attribute("compression", rletype == 4 ? "rle4" : "rle8");
    m_uncompressed.resize(m_spec.height * m_spec.width);  //[4]
    // Note: the clear+resize zeroes out the buffer
    bool ok = true;
    int y = 0, x = 0;
    while (ok) {
        unsigned char rle_pair[2];
        if (!ioread(rle_pair, 2)) {     //[5]
            ok = false;
        int npixels = rle_pair[0];
        int value   = rle_pair[1];
        if (npixels == 0 && value == 0) {
            // [0,0] is end of line marker
            x = 0;
        } else if (npixels == 0 && value == 1) {
            // [0,1] is end of bitmap marker
        } else if (npixels == 0 && value == 2) {
            // [0,2] is a "delta" -- two more bytes reposition the
            // current pixel position that we're reading.
            unsigned char offset[2];
            ok &= ioread(offset, 2);
            x += offset[0];
            y += offset[1];
        } else if (npixels == 0) {
            // [0,n>2] is an "absolute" run of pixel data.
            // n is the number of pixel indices that follow, but note
            // that it pads to word size.
            int npixels = value;
            int nbytes  = (rletype == 4)
                              ? round_to_multiple((npixels + 1) / 2, 2)
                              : round_to_multiple(npixels, 2);
            unsigned char absolute[256];
            ok &= ioread(absolute, nbytes);
            for (int i = 0; i < npixels; ++i, ++x) {
                if (rletype == 4)
                    value = (i & 1) ? (absolute[i / 2] & 0x0f)
                                    : (absolute[i / 2] >> 4);
                    value = absolute[i];
                if (x < m_spec.width)                            // [6]
                    m_uncompressed[y * m_spec.width + x] = value;  // [7]
        } else {
            // [n>0,p] is a run of n pixels.
            for (int i = 0; i < npixels; ++i, ++x) {
                int v;
                if (rletype == 4)
                    v = (i & 1) ? (value & 0x0f) : (value >> 4);
                    v = value;
                if (x < m_spec.width)                            // [8]
                    m_uncompressed[y * m_spec.width + x] = v;      // [9]
    return ok;

To summarize the code flow, two bytes are read in at [5]. These two bytes determine what type of action to take. For certain codeflows, more bytes are read in and then treated as the value to write with inside our memory buffer at [4]. The buffer size is taken directly from our bmp headers as m_spec.height * m_spec.width. Now, it’s extremely worth noting that in the two codeflows in which we can write to this m_uncompressed buffer, there is a check [6] and [8] to make sure we are not writing to something outside our memory buffer. As shown from the actual buffer writes at [7] and [9], they take both an x and a y co-ordinate, and there are no restrictions at all on the y co-ordinate vs the m_spec.height. As such, if we constantly use the [0,2] delta opcode, we can keep incrementing the y co-ordinate to whatever values we want, 0xFF at a time, and then uncompress bytes into out-of-bounds memory, resulting in a rather powerful write exploitation primitive.

Crash Information

==5451==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x62500000d000 at pc 0x7f14334b2f12 bp 0x7ffeb4cddad0 sp 0x7ffeb4cddac8
WRITE of size 1 at 0x62500000d000 thread T0
    #0 0x7f14334b2f11 in OpenImageIO_v2_3::BmpInput::read_rle_image() /oiio/oiio-
    #1 0x7f14334aaca2 in OpenImageIO_v2_3::BmpInput::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 0x7f1432c5d669 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-
    #3 0x7f1432b3c589 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-
    #4 0x55c94ad1b40f in LLVMFuzzerTestOneInput /oiio/fuzzing/./oiio_harness.cpp:77:16
    #5 0x55c94ac414e3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/oiio/fuzzing/fuzz_oiio.bin+0x414e3) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #6 0x55c94ac2b25f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/oiio/fuzzing/fuzz_oiio.bin+0x2b25f) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #7 0x55c94ac30fb6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/oiio/fuzzing/fuzz_oiio.bin+0x30fb6) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #8 0x55c94ac5add2 in main (/oiio/fuzzing/fuzz_oiio.bin+0x5add2) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)
    #9 0x7f142925bd8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #10 0x7f142925be3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #11 0x55c94ac25b24 in _start (/oiio/fuzzing/fuzz_oiio.bin+0x25b24) (BuildId: e9d97e110da8ca7129ca0569fb37dfa703dccc25)

Address 0x62500000d000 is a wild pointer inside of access range of size 0x000000000001.
SUMMARY: AddressSanitizer: heap-buffer-overflow /oiio/oiio- in OpenImageIO_v2_3::BmpInput::read_rle_image()
Shadow bytes around the buggy address:
  0x0c4a7fff99b0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff99c0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff99d0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff99e0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff99f0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x0c4a7fff9a00:[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff9a10: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff9a20: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff9a30: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff9a40: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c4a7fff9a50: 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

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


Discovered by Lilith >_> of Cisco Talos.