Skip to content

Sun raster files with RLE encoding and odd-width rows decode shifted #10370

Open

Description

@KirillZiborov

What did you do?

I opened a run-length-encoded (type 2) Sun raster image whose rows hold an odd number of bytes (an 8-bit grayscale image 3 pixels wide) using Image.open() on an in-memory file created by the self-contained script below.

In this format every stored row is padded to an even number of bytes, and the RLE stream encodes the padding bytes too (SUN_RAS.TXT: "Regardless of width, the stored scanlines are rounded up to multiples of 16 bits", "A run is not terminated at the end of a scan line").

What did you expect to happen?

row 0: 16 32 48
row 1: 64 80 96

What actually happened?

The padding byte at the end of row 0 was decoded as the first pixel of row 1, and every following row is shifted one more byte, without any error:

row 0: 16 32 48
row 1: 0 64 80

Any image whose pixels take an odd number of bytes per row is affected (8-bit and 24-bit images of odd width; 1-bit images 1–8, 17–24, … pixels wide; 4-bit images 1–2, 5–6, … pixels wide). Images with even-length rows decode correctly as well as uncompressed (type 1) Sun raster files.

What are your OS, Python and Pillow versions?

  • OS: Ubuntu 24.04, x86_64
  • Python: 3.12.3
  • Pillow: 13.0.0; also reproduced with 12.3.0 and current main (13.1.0.dev0, 1c5bdeb)
--------------------------------------------------------------------
Pillow 13.0.0
Python 3.12.3 (main, Aug 31 2026, 10:18:26) [GCC 13.3.0]
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 13.0.0
*** TKINTER support not installed
--- FREETYPE2 support ok, loaded 2.14.3
--- LITTLECMS2 support ok, loaded 2.19
--- WEBP support ok, loaded 1.6.0
--- AVIF support ok, loaded 1.4.2
--- JPEG support ok, compiled for libjpeg-turbo 3.2.0
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.4
--- ZLIB (PNG/ZIP) support ok, loaded 1.3, compiled for zlib-ng 2.3.3
--- LIBTIFF support ok, loaded 4.7.2
--- RAQM (Bidirectional Text) support ok, loaded 0.11.0, fribidi 1.0.13, harfbuzz 14.5.0
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------
import io
import struct

from PIL import Image

# 8-bit grayscale, 3x2 pixels, type 2 (run-length encoded).
# Each stored row is 3 pixel bytes + 1 padding byte.
header = struct.pack(">8I", 0x59A66A95, 3, 2, 8, 8, 2, 0, 0)
data = bytes([0x10, 0x20, 0x30, 0x00,  # row 0: pixels 16 32 48, padding
              0x40, 0x50, 0x60, 0x00])  # row 1: pixels 64 80 96, padding

with Image.open(io.BytesIO(header + data)) as im:
    rows = [[im.getpixel((x, y)) for x in range(3)] for y in range(2)]

print("decoded :", rows)
print("expected:", [[0x10, 0x20, 0x30], [0x40, 0x50, 0x60]])

Output:

decoded : [[16, 32, 48], [0, 64, 80]]
expected: [[16, 32, 48], [64, 80, 96]]

Passing the padded stride to the sun_rle decoder (the same stride the raw path already computes) fixes it in my testing — happy to send a PR.

Found by genproof.com Formal Verification Agent. Reviewed and approved by a human.

Activity

  1. hamarshehmhmd commented on Oct 31, 2026

    @hamarshehmhmd

    Reproduced this on 13.0.0 (your script gives [[16, 32, 48], [0, 64, 80]]).

    Root cause is in src/libImaging/SunRleDecode.c: the decoder treats a row as complete when state->x >= state->bytes (line 104), and state->bytes is the unpadded row size from _setimage in decode.c ((bits * xsize + 7) / 8). SunImagePlugin already rounds the stride up to 16 bits for the raw tile (line 108), but the sun_rle tile has no stride, so the padding byte that the RLE stream contains is read as the first byte of the next row.

    Fix I tested: keep counting up to the padded stride, but only write bytes that fall inside the real row into the buffer (the buffer is still state->bytes long):

    --- a/src/libImaging/SunRleDecode.c
    +++ b/src/libImaging/SunRleDecode.c
    @@ -23,6 +23,9 @@
         UINT8 *ptr;
         UINT8 extra_data = 0;
         UINT8 extra_bytes = 0;
    +    /* stored rows are padded to a multiple of 16 bits, and the
    +       RLE stream encodes the padding byte too */
    +    int stride = state->bytes + (state->bytes & 1);
     
         ptr = buf;
     
    @@ -42,7 +45,9 @@
                     /* Literal 0x80 (2 bytes) */
                     n = 1;
     
    -                state->buffer[state->x] = 0x80;
    +                if (state->x < state->bytes) {
    +                    state->buffer[state->x] = 0x80;
    +                }
     
                     ptr += 2;
                     bytes -= 2;
    @@ -75,14 +80,20 @@
                     */
                     n += 1;
     
    -                if (state->x + n > state->bytes) {
    +                if (state->x + n > stride) {
                         extra_bytes = n; /* full value */
    -                    n = state->bytes - state->x;
    +                    n = stride - state->x;
                         extra_bytes -= n;
                         extra_data = ptr[2];
                     }
     
    -                memset(state->buffer + state->x, ptr[2], n);
    +                if (state->x < state->bytes) {
    +                    memset(
    +                        state->buffer + state->x,
    +                        ptr[2],
    +                        state->x + n > state->bytes ? state->bytes - state->x : n
    +                    );
    +                }
     
                     ptr += 3;
                     bytes -= 3;
    @@ -92,7 +103,9 @@
                 /* Literal byte */
                 n = 1;
     
    -            state->buffer[state->x] = ptr[0];
    +            if (state->x < state->bytes) {
    +                state->buffer[state->x] = ptr[0];
    +            }
     
                 ptr += 1;
                 bytes -= 1;
    @@ -101,7 +114,7 @@
             for (;;) {
                 state->x += n;
     
    -            if (state->x >= state->bytes) {
    +            if (state->x >= stride) {
                     /* Got a full line, unpack it */
                     state->shuffle(
                         (UINT8 *)im->image[state->y + state->yoff] +
    @@ -126,12 +139,16 @@
                     break;  // assert
                 }
     
    -            if (extra_bytes >= state->bytes) {
    -                n = state->bytes;
    +            if (extra_bytes >= stride) {
    +                n = stride;
                 } else {
                     n = extra_bytes;
                 }
    -            memset(state->buffer + state->x, extra_data, n);
    +            memset(
    +                state->buffer + state->x,
    +                extra_data,
    +                n > state->bytes ? state->bytes : n
    +            );
                 extra_bytes -= n;
             }
         }

    What I checked, after building the 13.0.0 sdist with this patch:

    • your repro now gives [[16, 32, 48], [64, 80, 96]]
    • I generated RLE files for widths 1–8 and 33 (8-bit and 24-bit), with runs that cross row ends and literal 0x80 bytes. Each one decodes to the same bytes as the type-1 (raw) version of the same data. Stock 13.0.0 fails some of these.
    • I re-encoded MARBLES.SUN from test-images (1419 px wide, 24-bit, so rows are odd) as RLE. The patched build decodes it the same as the raw file; stock doesn't.
    • all the sunraster/ files in test-images (including the lena-*-rle.sun ones, which have even rows) decode exactly as before, and Tests/test_file_sun.py passes (apart from test_im1, which only failed because I built without zlib).
  2. radarhere commented on Nov 1, 2026

    @radarhere
    Member

    Any image whose pixels take an odd number of bytes per row is affected (8-bit and 24-bit images of odd width

    I found https://netghost.narod.ru/gff/sample/images/sunras/index.htm, containing https://netghost.narod.ru/gff/sample/images/sunras/marbles.sun, an image with a depth of 24 and a width of 1419. Using Pillow main, I opened it and saved it as a PNG.

    Image

    Using ImageMagick, I converted marbles.sun to a PNG, and received an identical image, pixel for pixel.

    It would appear that ImageMagick thinks we're doing the correct thing. Any thoughts?

  3. KirillZiborov commented on Nov 1, 2026

    @KirillZiborov
    Author

    I found https://netghost.narod.ru/gff/sample/images/sunras/index.htm, containing https://netghost.narod.ru/gff/sample/images/sunras/marbles.sun, an image with a depth of 24 and a width of 1419.

    That file isn't actually RLE, marbles.sun is type 1, so it goes through the raw path which already accounts for the padding and decodes fine. The issue in report is only about type 2.

    decode correctly as well as uncompressed (type 1) Sun raster files.

    The same pixels re-encoded as RLE:

    ffmpeg -i marbles.sun -c:v sunrast -rle 1 marbles_rle.sun
    from PIL import Image, ImageChops
    
    raw = Image.open("marbles.sun")
    rle = Image.open("marbles_rle.sun")
    print(ImageChops.difference(raw, rle).getbbox())

    On 13.0.0 this prints (0, 12, 1419, 990), each row is shifted one byte further from row 12 on.

    Image
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions