
Auditing HEIF Decoding in Server-Side JavaScript After the HEIF Heist
Introduction: The RCE Chain That Starts With an Image Upload
This audit walks through the checks I use to find out whether a server-side JavaScript stack can be pushed from a crafted image upload into remote code execution. The case study is the "HEIF Heist: Image Flaws Let Attackers Gain RCE Across Meta, Slack and GitHub Enterprise" summary gbhackers published on 2026-09-21. That summary names three products and one bug class — image-parsing flaws chained into RCE — but no CVE identifiers, affected version ranges, or payload details. I am not going to fill those in myself.
What the Report States, Plainly
Image flaws were chained to remote code execution across Meta, Slack, and GitHub Enterprise. The common thread: server-side processing of user-supplied image files. Those two facts — the product names and the RCE class — are what I can treat as confirmed. Everything past that is either my own inference or something only the original advisory can settle.
The Lesson Is Not the Specific HEIF Bug
The interesting part is not which HEIF bug this was. It is that HEIF and AVIF decoding got added to upload pipelines over the last few years — because iPhones shoot HEIC and browsers now render AVIF — and almost nobody treated the decoder as untrusted code when they did it. Teams reasoned about it as a format change. Really it was a new native attack surface bolted onto an endpoint that already accepts attacker-controlled bytes from anonymous users.
That is a trust-boundary mistake, not a format mistake, and it applies to JPEG XL, AVIF, and the next container after those too.
What This Audit Covers
Below is the path I actually use when auditing HEIF decoding on a Node.js service: inventory every decode path, reproduce the failure class safely in a disposable container, then fix in the order isolation → limits → version currency → detection, with input validation explicitly last.
What HEIF Is and Why a Node.js Server Decodes It
HEIF Container versus Codec: Where the Parser Walks Attacker Bytes
HEIF is a container. The image data inside is HEVC (HEIC) or AV1 (AVIF); the wrapper is ISOBMFF, the same box-structured format MP4 uses, defined in ISO/IEC 14496-12 for the base media file format and ISO/IEC 23008-12 for HEIF's image-specific boxes. A file is a tree of [4-byte big-endian size][4-byte type] boxes: ftyp, meta, and inside meta things like iinf (item info), iloc (item location), iprp (item properties), iref (item references), plus Exif and XMP metadata items.
A server often touches that structure before it renders a pixel. Metadata extraction, orientation correction, and thumbnail generation all require parsing iloc offsets, resolving derived items, and reading item counts. That is where security lives: the parser walks attacker-controlled offsets and counts long before a decoded frame exists.
Where HEIF Enters a Server-Side JavaScript Stack
Common entries I have seen in real codebases:
- avatar and profile-photo upload handlers
- chat attachment preview generation
- markdown or issue-body image rendering that fetches and re-encodes
- thumbnail workers that run after an object lands in a bucket
- EXIF/metadata extraction for "photo taken at" features
- CDN image-transform workers that pass bytes through to a vendor API
Attack Surface: Why ISOBMFF Parsing Beats a Flat JPEG for Risk
A flat JPEG gives a parser one image, one set of dimensions, one entropy-coded stream. ISOBMFF gives it nested boxes, multiple items, derived items (grids, overlays), thumbnail sets, item references, and offset tables pointing anywhere in the file. More structure means more places to get length arithmetic wrong, and derived/grid items in particular have been a productive area for decoder memory-safety bugs. For a server that mostly reads metadata, I rank container parsing as a materially bigger risk surface than the compressed bitstream itself.
JavaScript Is Memory-Safe; the Decoder Is Not
Where the Native Boundary Actually Is
In practice your Node process calls into one of: sharp → libvips → libheif / libde265 / libaom; ImageMagick or GraphicsMagick via CLI or binding; ffmpeg; exiftool; a cloud transform API; or a WASM build of a native library such as wasm-vips. Only the first and last of those are technically "inside" the process, but all of them execute code written in C or C++ that is now parsing hostile input.
Why Worker Threads Do Not Isolate a Native Decoder
A heap overflow in libheif corrupts the address space of the process it is loaded into. worker_threads share that address space. So does a Node worker pool. There is no memory isolation between a decode in worker_threads and the credential-bearing request handler in the same process — the only real boundary is a separate process, a separate container, or a separate host.
A crash in a native decoder is a controlled outcome. Silent corruption is not. If a decode process dies under load, do not "fix" it by catching the error at the JS layer and retrying — that is a signal the boundary needs to move, not a signal the error needs suppressing.
Why Extension, MIME, and Magic-Byte Checks Do Not Move the Boundary
Checking the filename, the declared Content-Type, or the first bytes for ftyp decides which decoder you hand the file to. It does not reduce what that decoder parses. A file can carry a valid ftyp box, a valid meta box, and a corrupt iloc offset table. Any structure-aware mutation slips past all three checks. Validation is routing, not protection.
What the HEIF Heist Report Actually Claims
Confirmed from the Source
Product names: Meta, Slack, GitHub Enterprise. Bug class: image parsing chained to RCE. Common thread as reported: server-side processing of user-supplied images. Date of the public summary: 2026-09-21, publisher gbhackers.
Not Confirmed, and Not Safe to Guess
The public summary does not say which CVE IDs are involved, which versions were affected, whether the flaw lived in the products' own parsers or a third-party library, whether exploitation required authentication, or whether the chain needed an extra bug to travel from a crash to code execution. I do not know those answers. Treat any version range or CVE id you see attributed to this story elsewhere as needing the original advisory before you act on it.
Where HEIF Parsing Likely Happens in Each Product (Inference)
My read — inference, not tested — is that Meta's surface is an upload or preview pipeline, Slack's is attachment preview generation for a file a workspace member or an external guest can upload, and GitHub Enterprise's is rendering image content in issues, comments, or avatars. Those are the obvious decode paths for each product shape. I have not tested any of them, and the report does not say which one was used.
Map Every Decode Path You Own Before Touching Code
Inventory Commands: Find Every Decoder Your Stack Actually Ships
Start with the lockfile, then confirm against the runtime image — those two lists rarely match.
rg -n --hidden -g '!node_modules' \
-e 'sharp|jimp|libvips|imagemagick|graphicsmagick|"gm"|heif|avif|exiftool|ffmpeg' \
package.json pnpm-lock.yaml package-lock.json yarn.lock 2>/dev/null
Representative output shape from a mid-size monorepo — your versions will differ:
services/media/package.json:14: "sharp": "^0.33.4",
services/markdown/package.json:9: "heic-convert": "^2.1.0",
services/markdown/Dockerfile:6:RUN apt-get install -y imagemagick ffmpeg
Then ask the container what it actually links, because base images ship decoders nobody declared:
docker run --rm myapp:latest sh -lc '
command -v magick convert gm ffmpeg exiftool vips;
ldconfig -p | grep -Ei "vips|heif|de265|aom|Magick"'
/usr/bin/ffmpeg
/usr/local/bin/exiftool
libheif.so.1 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libheif.so.1
libvips.so.42 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libvips.so.42
The Decode Surface Table
| Entry point | Decoder | Mode | Network | FS | User | Blast radius |
|---|---|---|---|---|---|---|
POST /avatar (anonymous) | sharp → libvips → libheif | in-process | egress allowed | rw /tmp | app | API process and its DB credentials |
| Markdown image render | ImageMagick CLI | in-process | egress allowed | rw repo cache | renderer | cached tenant repos |
| Attachment preview | heic-convert | job worker | none | ro input | worker | one job, one tenant blob |
| CDN transform | vendor API | off-host | n/a | n/a | n/a | data egress, vendor-side |
Flag the Worst Rows First
The first row is the one I would bookmark. Anonymous upload, decode inline in the request process, shared address space with an authenticated API, no memory cap, no time cap, network reachable. If a decoder bug lands there, it is a full process compromise reachable without a login. Rows where decoding happens in a no-network sidecar with a read-only filesystem are a different risk category entirely, even with the same vulnerable library version.
Reproducing the Failure Class Safely
Generate a Corpus with Box-Aware Mutation
Blind byte flips mostly produce parse errors. Structure-aware mutation is far more productive for ISOBMFF.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
const seed = readFileSync(process.argv[2]); // one valid HEIC/AVIF
mkdirSync("corpus", { recursive: true });
// walk the top-level box tree: size(4) type(4) payload(...)
function topLevelBoxes(buf) {
const out = [];
let off = 0;
while (off + 8 <= buf.length) {
let size = buf.readUInt32BE(off);
const type = buf.toString("latin1", off + 4, off + 8);
if (size === 1) size = Number(buf.readBigUInt64BE(off + 8));
if (size === 0) size = buf.length - off;
if (size < 8 || off + size > buf.length) break;
out.push({ off, size, type });
off += size;
}
return out;
}
// in-place mutators only: length fields and the first count/offset words
const MUTATORS = {
len_minus1: (b, o) => b.writeUInt32BE((b.readUInt32BE(o) - 1) >>> 0, o),
len_max: (b, o) => b.writeUInt32BE(0xffffffff, o),
len_zero: (b, o) => b.writeUInt32BE(0, o),
count_ffff: (b, o) => b.writeUInt16BE(0xffff, o + 8),
off_eof: (b, o) => b.writeUInt32BE(b.length, o + 8),
};
let n = 0;
for (const box of topLevelBoxes(seed)) {
for (const [name, mutate] of Object.entries(MUTATORS)) {
const buf = Buffer.from(seed);
mutate(buf, box.off);
writeFileSync(`corpus/${box.type}-${name}.heic`, buf);
n++;
}
}
console.log(`wrote ${n} cases`);Run the Decoder Under Sanitizers, Off Production
Decode in a container that has no route to anything you care about:
docker run --rm \
--network none --read-only --tmpfs /tmp:rw,size=64m \
--user 65534:65534 --cap-drop ALL --security-opt no-new-privileges \
--memory 512m --memory-swap 512m --cpus 1 --pids-limit 64 \
-v "$PWD/corpus:/corpus:ro" \
heif-harness:asan \
sh -c 'for f in /corpus/*.heic; do timeout 10 /opt/harness/decode "$f" || echo "FAIL $f"; done'
Build the harness image with -fsanitize=address,undefined and -fno-omit-frame-pointer so a length bug lands as a report instead of silence.
Reading the Result: What Counts as a Real Finding
| Outcome | What it means | Real finding? |
|---|---|---|
libheif: cannot read box style error, exit 1 | Parser rejected the container | No — expected |
| Timeout at 10s | Possible algorithmic complexity issue in parsing | Needs triage |
| RSS climbing past the container cap | Decompression bomb or unbounded allocation | Yes, if declared dimensions are small |
| ASAN report with a stack in box parsing | Memory-safety bug in the decoder | Yes — highest priority |
The ASAN line you are hunting for has this shape; this is the form of a sanitizer report, not a captured log from the reported bugs, which I do not have:
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x... READ of size 4
#0 0x... in heif::Box_iloc::parse(...)
Limits of This Test
A local fuzz run does not prove or disprove the reported chain, and it will not reproduce the vendor-specific bugs in the report. What it does tell you: which of your decode paths fail unsafely on malformed containers, and how they fail. That is the actionable question.
Hardening the Decode Path
Isolation First: Move Decoding Out of the Request Process
Move decoding into a separate service or job runner. No network. Read-only root filesystem. Non-root user. A seccomp profile and dropped capabilities. Short-lived containers you expect to die. Treat a decoder crash as a normal outcome that the orchestrator retries or rejects — not as an incident that requires the error to be swallowed in a try/catch inside your API process.
Resource Limits That Actually Apply
- max upload bytes, enforced at the proxy and the handler
- max decoded pixel count and dimensions from the declared container metadata (a 200-byte HEIF can declare a 100,000×100,000 image)
- max wall clock per decode, with a real kill, not a promise
- max concurrent decodes per account and per IP
- mandatory re-encode to a fixed format before the bytes reach any renderer, writer, or email pipeline
const MAX_PIXELS = 40_000_000;
export async function makeAvatar(buf) {
const img = sharp(buf, {
limitInputPixels: MAX_PIXELS, // still parses the container, caps the output
failOn: "error",
unlimited: false,
});
const meta = await img.metadata();
if (meta.format !== "heif" && meta.format !== "avif") {
throw new Error("unsupported format");
}
return img.resize(256, 256, { fit: "cover" }).webp({ quality: 80 }).toBuffer();
}
Be clear about what this does: it caps resource use and normalizes the output. It does not fix a memory-safety bug in libheif. Only isolation and patching do that.
For ImageMagick, set resource policy in policy.xml — and verify the syntax against your major version with magick -list policy:
<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="map" value="512MiB"/>
<policy domain="resource" name="width" value="16KP"/>
<policy domain="resource" name="height" value="16KP"/>
<policy domain="resource" name="area" value="128MP"/>
<policy domain="resource" name="time" value="10"/>
Patch and Track Native Decoder Versions
Pin native decoder versions in the image build, not in a latest tag. Watch libvips, libheif, ImageMagick, and ffmpeg advisories, and rebuild on a schedule even when nothing changed. The piece most teams skip is a dependency-to-service map: when a decoder CVE drops, you need to answer "which services ship this library and which of them are internet-reachable" in minutes, not by grepping fifteen repos.
Log and Alert, Carefully
Log decode failures with account, source IP, and decoder error string. Count malformed-container errors per account. Then use it carefully.
Decode failure is a weak signal on its own. Broken HEIC files arrive from real phones constantly. Alert on a rate change per account or IP, or on the specific decoder error string, not on a single failure.
What I Would Fix First, and What I Did Not Test
Ranked Priorities
- Isolation. It converts a decoder RCE from a process compromise into a failed job. It is also the only item on this list that helps against the next decoder bug, which is the one that will actually bite you.
- Resource limits. Pixel count, dimensions, bytes, wall clock, concurrency. Prevents the decompression-bomb and complexity-denial-of-service class that needs no exploit at all.
- Version currency. Cheap once you have the dependency map, and it is what resolves a named CVE.
- Detection. Useful for catching someone actively probing your corpus, weak as a control.
- Input validation. Last. Extension, MIME, and magic-byte checks are routing decisions. They belong in the pipeline, but they are not the control that stops a decoder bug, and treating them as one is how HEIF ended up in an anonymous upload path in the first place.
Confirmed versus Inferred
Confirmed from the source: the three product names, the image-parsing-to-RCE class, and the 2026-09-21 publication date. Inferred by me: which specific surface in each product was decoded, whether a third-party library was involved, and whether the chain needed more than one bug — check the vendor advisories for each of those. Untested by me: every claim about my own harness output beyond its structure; I have not run these mutations against the reported implementations.
The test that would settle the open questions: pull the original advisories, map the named components to library versions, and check whether your own services ship those versions and decode user-supplied bytes in-process. If yes, your priority is isolation, not a version bump alone.
Further Reading
- gbhackers, "HEIF Heist: Image Flaws Let Attackers Gain RCE Across Meta, Slack and GitHub Enterprise" (2026-09-21) — the discovery link provided with this story; the primary advisory set is not included in it.
- strukturag/libheif security advisories — the decoder behind most server-side HEIF handling.
- libvips releases and changelog — tracks the vips layer that
sharplinks against. - ImageMagick security policy — resource limits and the policy file referenced above.
- FFmpeg security page — how FFmpeg handles and discloses decoder issues.
- OWASP File Upload Cheat Sheet — baseline upload controls to layer underneath the decode isolation.


