GHSA-hff9-5pqw-6w9f: Integer Overflow and Heap Buffer Overflow in imgproxy's ICO Loader
How I Found a Heap Overflow in imgproxy’s ICO Loader
In September 2026, I independently discovered and reported an integer overflow in imgproxy’s custom ICO loader. An image could make the loader allocate three bytes and then copy image data through a pointer already outside that allocation. I confirmed heap corruption and remotely triggerable denial of service in the official release and a deployment I controlled.
The final upstream advisory rates the vulnerability Critical, CVSS 9.8, and also identifies possible remote code execution as an impact. My own validation demonstrated heap corruption and remotely triggerable denial of service.
This article follows the investigation from the suspicious size calculation to the evidence that established its impact, then explains how I checked the fix.
| Item | Details |
|---|---|
| Advisory | GHSA-hff9-5pqw-6w9f |
| Affected versions | imgproxy >= 4.0.0 and <= 4.0.15, according to upstream |
| First fixed version | v4.0.16 |
| Official release I tested | ghcr.io/imgproxy/imgproxy:v4.0.15 |
| Affected component | Custom ICO loader, vips/icoload.c |
| My demonstrated impact | Heap corruption causing process termination and temporary service unavailability |
| Weaknesses | CWE-190 (Integer Overflow), CWE-122 (Heap-based Buffer Overflow), CWE-787 (Out-of-bounds Write) |
I am one of the two credited reporters of the public advisory. This write-up documents the vulnerability research and impact validation I performed independently; it does not reproduce unpublished research contributed by other participants in the coordinated disclosure.
Following image bytes into native code
imgproxy is a standalone service for resizing, processing, and converting images. Applications can use it to generate thumbnails, avatars, and other transformed images on demand. Its server is written in Go, with image processing crossing into native code through libvips and cgo.
That boundary mattered during my review. A request might pass URL parsing, signature verification, and source retrieval successfully while the retrieved image still contains hostile metadata. I wanted to understand which parsers received those bytes and which assumptions they made about lengths, offsets, and memory.
The broader investigation examined signing behavior, parser boundaries, and native image loading. The ICO path became the strongest lead when I found a file-controlled size used both to calculate an allocation and to request a subsequent read. The two operations looked consistent at first glance, but their arithmetic could produce different effective sizes.
I also checked reachability. imgproxy recognized ICO input, registered its custom loader, and dispatched ICO processing into that loader. A suspicious C expression would only matter if ordinary image processing could reach it with attacker-controlled bytes. The format-detection and dispatch paths established that connection.
Why an ICO loader adds a BMP header
An ICO file can contain several embedded images. Its directory entries describe those images, including their dimensions, data sizes, and offsets. The loader selected an entry and read its declared data_size into a 32-bit unsigned integer.
ICO entries can contain PNG data or bitmap data. For the bitmap case, imgproxy reconstructs the BMP file header before handing the buffer to the BMP loader. It therefore reserved an extra 14 bytes at the beginning of its allocation and read the embedded image after that space.
The relevant allocation in the vulnerable source was:
1
2
3
uint32_t data_size = GUINT32_FROM_LE(largest_image_header.data_size);
VipsPel *data = (VipsPel *) VIPS_MALLOC(NULL, data_size + BMP_FILE_HEADER_LEN);
void *actual_data = data + BMP_FILE_HEADER_LEN;
The subsequent source read used actual_data as its destination and the original data_size as its requested length.
The intended relationship was straightforward: allocate enough space for the embedded image plus the synthetic header, then copy the image into the region after that header. The missing condition was that the addition must preserve the intended size before the allocator receives it.
A size calculation that became smaller
On the tested build, data_size + BMP_FILE_HEADER_LEN was evaluated as 32-bit unsigned arithmetic. A sufficiently large declared size could wrap around before allocation.
The arithmetic example from my research was:
1
2
3
4
Declared size: 0xfffffff5 = 4,294,967,285
Additional header: 14
Mathematical sum: 4,294,967,299
Result modulo 2^32: 3
The allocator received three as the requested byte count. The pointer adjustment still added 14, and the read still used the original declared size.
1
2
3
4
5
6
7
8
9
Offsets relative to the allocation base:
0 1 2 3 14
[ allocated bytes ] | ^
allocation end actual_data
Requested allocation: 3 bytes
Read destination: base + 14
Destination position: 11 bytes beyond the allocation end
A 64-bit process does not automatically prevent this. The expression’s operand types determine its arithmetic. Passing the already-wrapped result to an allocator that accepts a wider size type cannot recover the lost value.
The wrapped size paired a tiny allocation with a destination beyond its bounds. Any available source bytes copied there could corrupt the heap.
Why an incomplete image could still corrupt memory
The declared size did not need to match the image’s actual length for damage to occur. A source reader can copy the bytes that are available before a later read discovers that the requested data is incomplete.
That ordering was decisive. Detecting an incomplete image after the copy could not undo a write through an invalid destination.
The Go source callback constructed an unsafe.Slice from the pointer and length supplied by native code, then passed that slice to the underlying reader. At that boundary, the caller had to supply a valid memory region. The slice construction could not establish that the native allocation was actually large enough.
This connected the arithmetic error to a concrete write: the loader supplied an undersized allocation, an out-of-bounds destination, and an unvalidated read length. The later image-format checks happened after that read.
Confirming the official release crashed
Source analysis gave me a hypothesis. I then checked the official v4.0.15 image in an isolated local environment, using a valid ICO as a control and a malformed fixture for the suspected size-handling failure.
The control succeeded with HTTP 200. The malformed image closed the client connection and terminated the process. Across three fresh processes, I observed the same allocator-corruption failure after the valid control had succeeded.
The release logs included these lines, with container prefixes omitted:
1
2
3
malloc(): corrupted top size
SIGABRT: abort
signal arrived during cgo execution
The stack included the custom ICO-loading path. I also confirmed the failure when the image came from a local HTTP origin, establishing that normal source retrieval could deliver the bytes to the loader.
These observations established a repeatable crash in the released product. A separate instrumented build helped identify the underlying memory error.
AddressSanitizer explained the write
I built the reviewed vulnerable source with AddressSanitizer, separately from the official release container. The instrumented build reported a heap-buffer-overflow during the copy.
Abbreviated output, with addresses and container prefixes omitted:
1
2
3
4
AddressSanitizer: heap-buffer-overflow
WRITE of size 64
[address] is located 11 bytes to the right of 3-byte region
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in runtime.slicecopy
The three-byte region matched the wrapped allocation. The destination’s position matched the 14-byte pointer adjustment relative to that allocation’s end. The reported write confirmed that available image bytes were copied outside the buffer.
The copy appeared in runtime.slicecopy, but the allocation mismatch originated in the C loader. The Go callback was operating on the invalid region supplied across the native boundary.
Together, the crash in the official release and the sanitizer report showed that ordinary processing could reach the bug and that the failure involved an out-of-bounds heap write.
Checking the impact on the service
I next validated the behavior on an Internet-accessible imgproxy deployment that I controlled. This let me observe the request response, health checks, process crash, and recovery together.
The deployment required signed processing URLs. I used a valid signed control and an altered-signature control to separate parser behavior from request authorization.
| Observation | Recorded result |
|---|---|
| Valid signed control image | HTTP 200 |
| Altered signature | HTTP 403 |
| Valid signed malformed image | Client observed HTTP 500 |
| Immediate health check afterward | HTTP 502 from the hosting platform |
| Service log | SIGSEGV: segmentation violation during cgo execution |
| Recovery | A new server-listening event approximately one second after the logged signal |
The crash and restart logs supplied the evidence that an HTTP error alone could not. A timeout or failed health request can have several causes; here, the service log recorded process termination and a subsequent start.
All hosted validation used infrastructure under my control. The findings describe those observed deployments and the loader behavior established in the release, without assuming that every imgproxy installation exposes the same delivery path.
What an attacker needed, and who was affected
The attacker-capability statement supported by my evidence was:
As an attacker who can cause imgproxy to process an image I control, I could corrupt and terminate its image-processing process, disrupting image delivery for the deployment operator and other users sharing that service.
The prerequisite is the ability to get attacker-controlled image bytes processed. Depending on the application, that might involve an unsigned processing endpoint or an upload or source-URL workflow that causes the application to create an authorized transformation request.
The demonstrated impact was process-level denial of service. A deployment without automatic recovery could remain unavailable until an operator restarted it. A supervised deployment could recover, as mine did, while still dropping work during the interruption. In a replicated service, the immediate effect would depend on which instance handled the image and how traffic was distributed.
Repeated processing could potentially cause repeated crashes if the same delivery path remained available. Caching and application workflows also affect whether the image is processed again.
How the final advisory describes severity
My original report established heap corruption and remote denial of service. The final coordinated advisory assesses the vulnerability as Critical, with a CVSS score of 9.8, and includes possible remote code execution.
That broader assessment belongs to the coordinated investigation. I did not demonstrate code execution, confidential-data access, or an integrity compromise with my own validation. The results presented here establish the memory-corruption mechanism and its availability impact.
The public GitHub Security Advisory credits me, mateofumis, and Jason Puglisi as Reporters, and Jacob Brackett as an Analyst.
What changed in v4.0.16
The fixing commit, included in v4.0.16, adds an upper bound for an ICO entry’s declared data size before the allocation calculation.
1
#define MAX_ICO_ENTRY_DATA_SIZE 10 * 1024 * 1024
The loader rejects entries larger than that 10 MiB limit before adding the synthetic header length. This prevents the original near-maximum 32-bit size from reaching the wrapping addition. The patch also adds allocation-failure handling, cleanup on read failure, and regression coverage for malformed ICO input.
The fix retains a 32-bit variable for the full size. Its protection against the original overflow comes from bounding the input before the addition.
Verifying the original failure was fixed
I retested the original fixture against the official v4.0.16 image and checked both the response and process state.
| Check | Recorded v4.0.16 result |
|---|---|
| Valid ICO control | HTTP 200 |
| Original malformed ICO | HTTP 500, with ICO image data is too large in the log |
| Ten consecutive attempts in the same container | Controlled errors; same process ID; zero restarts |
| Health check after those attempts | HTTP 200 |
| ASan build from v4.0.16 source | Original fixture rejected without a sanitizer finding |
| Hosted validation after confirmed v4.0.16 deployment | Controlled rejection; no observed crash or restart following the request |
The fixed service’s HTTP 500 deserved careful interpretation. The error status alone looked similar to one response from the vulnerable hosted service. The process behavior differed: the vulnerable run recorded a signal and restart, while the fixed run completed the request with a size-validation error and continued operating.
I confirmed remediation of the originally reported vulnerability on September 28. That conclusion was tied to the same input, the confirmed release version, and the observed absence of the original memory-corruption failure.
Disclosure timeline
| Date | Event |
|---|---|
| September 18, 2026 | Discovered the ICO size-overflow issue, confirmed release and sanitizer behavior, validated a deployment I controlled, and contacted upstream privately. |
| September 23, 2026 | Submitted the vulnerability through GitHub’s private reporting workflow. |
| September 28, 2026 | Upstream released v4.0.16. I validated the original issue’s remediation and sent confirmation. |
| October 6, 2026 | Upstream published GHSA-hff9-5pqw-6w9f. |
At the time of publication, GitHub has not yet assigned a CVE identifier to this advisory. This post will be updated when the CVE record becomes available.
Lessons from the investigation
The vulnerable allocation and read were only a few lines apart. Their apparent agreement depended on arithmetic preserving the size relationship, which untrusted metadata could break.
For parser reviews, I now treat allocation size, pointer offsets, and read length as one calculation to verify together. A file’s declared length needs validation before it influences those operations. An eventual short-read error does not protect memory that has already been written.
The investigation also reinforced the value of complementary evidence. Source review identified the inconsistency, the official release established a real crash, ASan explained the write, and service logs showed its effect on availability. Checking the fixed process and its health supplied the corresponding remediation evidence.
For operators, this vulnerability is fixed starting in v4.0.16. Deployments should upgrade to the latest supported imgproxy release rather than remain on an affected >= 4.0.0, <= 4.0.15 version.
