Motivation
Hacktron’s Hacking OpenAI research was one of the projects that pushed us to investigate libheif exploitation more deeply. It showed what can happen when a memory-safety bug in an image decoder sits behind an ordinary web upload. Their article described the wider attack chain, but the low-level details needed to reproduce the exploit were not published. It is related prior art rather than the same vulnerability or application stack: our work targets CVE-2026-32740 in a deliberately vulnerable Next.js and sharp lab.
We have always liked the idea of reaching native memory corruption through a web application, so we decided to start from the crash and work through the exploitation ourselves. The project brought together areas that are often treated separately: web application security, memory-corruption fuzzing, GDB debugging, allocator behaviour, and binary exploitation. The OWASP Top 10 remains a useful baseline, but modern application security cannot stop there when a web route can pass attacker-controlled files through a large native-code dependency chain.
We first built the exploit against the Ubuntu setup. We then repeated the work against Debian to test our assumptions and make the proof of concept more robust across different native stacks.
CVE-2026-32740 executive summary
CVE-2026-32740 is a heap-buffer overflow in HeifPixelImage::copy_image_to(), the libheif function that places decoded HEIF or AVIF tiles into a grid image. libheif stores the image as separate planes. The Y plane contains brightness samples at full resolution. The Cb and Cr planes contain blue and red colour-difference samples. In 4:2:0 format, Cb and Cr have half as many columns and half as many rows as Y, with odd values rounded up.
The vulnerable rounding
The vulnerable code calculates the chroma destination row and copy height separately. Both calculations round up, but the copy-height check does not account for the already rounded destination row:
copy_height = min(src_height,
channel_height(h - y0, chroma, channel));
ys = channel_height(y0, chroma, channel);
for (py = 0; py < copy_height; py++)
memcpy(out_data + (ys + py) * out_stride, ...);
Our payload stacks four tiles, each 33 pixels high, in a 132-pixel canvas. The complete Cb and Cr planes have ceil(132 / 2) = 66 rows, numbered 0 to 65. The fourth tile starts at Y row 99, so libheif calculates ys = ceil(99 / 2) = 50. It independently calculates copy_height = ceil(33 / 2) = 17. The loop writes chroma rows 50 through 66, and row 66 is outside the allocation.
The canvas is 116 pixels wide. An 8-bit 4:2:0 chroma row is therefore ceil(116 / 2) = 58 bytes. As a result, the malformed fourth tile supplies every byte in that final row, giving the attacker one controlled 58-byte write across the end of the Cb or Cr allocation.
From one extra row to code execution
A crash alone did not make the issue remotely exploitable. The final chain needed three additional capabilities:
- A separate returned-pixel disclosure to recover the randomized libvips base remotely.
- A constrained
write-what-whereprimitive, so the exploit can select both the 58 bytes and their destination. - A control-flow target that executes before another worker can disturb the corrupted state.
The write-what-where is constrained because the row length, object layout, and addresses are specific to the tested build. It is still a write-what-where: the Cr pixels provide the bytes, and a forged entry in libheif’s plane map provides the destination. The first chosen-address copy overwrites memcpy@GOT. The next row enters a native-library loader inside the already identified libvips image and loads a shared object submitted through the image-upload route.
Tested application and dependency stack
The proof targets a deliberately vulnerable Next.js lab with two routes. POST /api/upload saves an uploaded file in the application’s uploads/ directory. GET /api/optimize?file=NAME opens a saved file with sharp, converts it to PNG, and returns that PNG to the client. AVIF decoding happens inside sharp’s bundled libvips and libheif libraries.
- Next.js 15.5.23
- sharp 0.34.4
- bundled libvips 8.17.2
- bundled libheif 1.20.2
- Ubuntu profile: Node.js 25.8.1, PIE
ET_DYN, glibc 2.43 - Debian 13 profile: Debian APT Node.js 20.19.2, PIE
ET_DYN, glibc 2.41 - Linux x86-64 with ASLR and NX enabled
The Node executable is position independent, while sharp and its bundled native libraries are the normal prebuilt packages for this tested platform. The exploit does not need the randomized Node base because its control-flow target is inside libvips.
The application route itself is intentionally permissive. In particular, it preserves an uploaded ELF shared object under the image-looking name x.jpg. A real deployment with strong magic validation, generated storage names, or isolated object storage may prevent that staging step even if its decoder remains vulnerable.
Why CVE-2026-32740 needed more than a crash
The grid bug gives us one controlled 58-byte write immediately after a chroma buffer. On its own, that normally causes a crash. Four developments were essential to turn it into repeatable remote code execution:
- Find the current process: a separate AVIF makes the optimisation route return libvips pointers in PNG pixels, which reveals the randomized libvips base.
- Choose the destination: the 58-byte overflow preserves the adjacent glibc chunk header and redirects libheif’s Cr-plane lookup to a forged map entry. This creates the constrained write-what-where.
- Trigger immediately: the chosen write replaces
memcpy@GOTduring the image-copy loop. The very next row uses the changed GOT entry, leaving almost no delay between corruption and control-flow hijack. - Reuse a compatible native loader: the bundled libvips image contains an internal copy of GLib’s
g_module_open_full. This GModule routine wrapsdlopen, loading a native shared library from a file path. The hijacked copy already puts the controlled pathuploads/x.jpgin its first argument register. When the loader opens that file, the shared object’s constructor runs, without requiring a ROP chain or a Node code address.
g_module_open_full is one of the essential tricks. A random code address would not be enough. We needed code inside a module whose randomized base was already disclosed, and it had to tolerate the register state at the hijacked call site. The routine is at offset 0x3e995e in the exact bundled libvips artifact. The exploit adds that profile-verified offset to the recovered libvips base, so every runtime code address used by the final control-flow step comes from the same disclosed module.
The final CVE-2026-32740 RCE chain
1. Upload a malicious library
On the attacker machine, the exploit compiles a small shared object. Its constructor runs the fixed proof command /usr/bin/id and returns the command’s output to the listener. The target does not compile anything.
The application accepts the ELF through its ordinary image-upload route with the filename x.jpg and media type image/jpeg, then stores it as uploads/x.jpg. The short name is important because the later payload has only 16 bytes for the complete null-terminated library path.
2. Bypass ASLR using returned pixels
Address Space Layout Randomisation (ASLR) gives libvips a new base address whenever the target starts. The exploit uses the two lab routes in sequence:
POST /api/uploadstores a specially prepared ASLR-leak AVIF under a random filename.GET /api/optimize?file=NAMEasks sharp to decode that saved AVIF and convert it to PNG.- The optimisation route returns the PNG bytes in the HTTP response. In this image layout, some alpha-channel bytes come from uninitialised native memory and include pointers into the loaded libvips library.
This disclosure AVIF is separate from the final grid-overflow AVIF. The exploit repeats the upload and optimisation requests until one returned PNG supports exactly one profile and one libvips base. Later, it uploads the final AVIF through the same upload route and calls the same optimisation route again to trigger the overflow.
A real response contained these three shared values. Each pointer refers to a known slot in the bundled libvips build, so subtracting its file offset recovers the same page-aligned base:
0x7f6be25c3120 - 0x1c3120 = 0x7f6be2400000
0x7f6be25be1b0 - 0x1be1b0 = 0x7f6be2400000
0x7f6be25c3130 - 0x1c3130 = 0x7f6be2400000
The Ubuntu and Debian profiles use the same bundled libvips binary. In both environments, the returned pixels expose pointers at libvips_base + 0x1be1b0, libvips_base + 0x1c3120, and libvips_base + 0x1c3130. Subtracting any corresponding offset reveals the candidate randomized library base. Because these three anchors are shared, they confirm the libvips binary and recover its base, but cannot distinguish the Ubuntu native stack from the Debian native stack.
To distinguish the two tested profiles, the exploit requires one more leaked pointer. Ubuntu responses reliably contain a pointer at libvips_base + 0x33cd0c, while Debian responses contain one at libvips_base + 0x1de9c0. These are offsets from the current libvips base, not fixed runtime addresses. ASLR changes the base on every process start. A profile is selected only when all four pointers resolve to the same base. If they do not, the exploit stops instead of continuing with an uncertain profile.
A different libvips version or build would require additional profiling. The relative positions of the leak anchors, the memcpy GOT entry, and the internal loader routine may move even though the overall exploitation technique remains the same. A new target therefore needs its own binary fingerprint and profile, with the remotely leaked values validated against that build before exploitation.
The following simplified excerpt shows the core calculation. It extracts eight-byte values from the returned alpha pixels, subtracts every declared anchor, and counts only aligned candidates that meet each anchor’s repetition threshold:
alpha = image.getchannel("A").tobytes()
values = {
int.from_bytes(alpha[offset:offset + 8], "little")
for offset in range(0, len(alpha) - 7, 8)
}
for value in pointer_values:
for anchor in profile.libvips.leak.anchors:
candidate = value - anchor.offset
alignment = profile.libvips.leak.base_alignment
if candidate > 0 and not (candidate & (alignment - 1)):
repetitions[candidate, anchor.offset] += 1
anchors_by_base = defaultdict(set)
for (base, anchor_offset), hits in repetitions.items():
anchor = next(
item for item in profile.libvips.leak.anchors
if item.offset == anchor_offset
)
if hits >= anchor.minimum_repetitions:
anchors_by_base[base].add(anchor_offset)
supported = [
base for base, matched_anchors in anchors_by_base.items()
if len(matched_anchors) >= profile.libvips.leak.required_anchors
]
The exploit proceeds only if the complete manifest produces exactly one supported profile and base pair. It then adds the selected profile’s memcpy@GOT and internal GModule-loader offsets to that base. This is how the final chain handles a PIE Node executable: it never needs a Node address. Every runtime code address used by the overwrite belongs to libvips and is calculated from the recovered libvips base.
Which values are hardcoded?
The CVE-2026-32740 exploit needs several hardcoded profile values. The Linux distribution and glibc version alone are not enough. The values depend on the complete native stack. We keep them in a versioned native-stack profile instead of scattering constants through the exploit code. Each value comes from a specific part of the tested stack:
- The libvips leak profile declares the shared anchors
0x1be1b0,0x1c3120, and0x1c3130. Its fourth anchor identifies the surrounding stack:0x33cd0cfor Ubuntu and0x1de9c0for Debian. libvips.memcpy_got.offset = 0xf83098is thememcpyGOT offset in that same libvips binary. The runtime address is the leaked libvips base plus this offset.libvips.control_target.offset = 0x3e995elocates the internalg_module_open_fullroutine in the same libvips image. The runtime target is the leaked libvips base plus this offset. Because this internal routine is not exported as a dynamic symbol, the profile stores its first 32 bytes and the offline verifier checks those exact bytes.heap_calibration.anchor_page_low16 = 0x6000is the verified allocator page lane shared by the two tested PIE layouts.heap_calibration.fake_node_delta_from_anchor_pageis-0x690for Ubuntu and-0x3a0for Debian. Those relations produce the0x5970and0x5c60partial-pointer selectors respectively.- The profile’s 116 by 33 tile geometry and 64-byte canvas stride create the required 58-byte row and place the forged structure at the measured location.
The randomized libvips base and runtime loader address are not hardcoded. The exploit recovers the base from each process’s returned pixels, then calculates the loader as libvips base + 0x3e995e. The two-byte heap selector is a separate allocator-layout invariant in the exact PIE profile. A matching distribution or glibc version is not enough by itself; a different Node, sharp, libvips, libheif, compiler, or allocator layout can change one or more relative values and therefore needs its own verified profile.
How the exploit derives the heap selector
This is where the two PIE profiles differ. Both measured layouts place the relevant allocator page in low-16 lane 0x6000, but the forged node sits at a different offset from that page. The exploit calculates the selector from the profile relationship:
page_lane = 0x6000
ubuntu_selector = (page_lane - 0x690) & 0xffff # 0x5970
debian_selector = (page_lane - 0x3a0) & 0xffff # 0x5c60
The profile marker in the returned PNG chooses the native stack, and that profile supplies the matching page-to-node relation. The Debian relation was measured unchanged across five fresh diagnostic process lifetimes before it was added to the manifest. The selector is therefore a property of an exact verified native stack, not a value inferred from an OS name alone.
3. Turn the overflow into a GOT overwrite
How libheif stores image planes
heif_channel is an enum that names an image component. In this build, heif_channel_Y is 0, heif_channel_Cb is 1, and heif_channel_Cr is 2. libheif uses that enum as the key in std::map<heif_channel, ImagePlane> m_planes.
ImagePlane is libheif’s metadata for one component’s pixel buffer. It records the plane’s dimensions, bit depth, allocation details, mem, and stride. The mem field points to the aligned start of the usable pixel buffer. The stride field is the number of bytes from the start of one row to the start of the next. Stride can be larger than the visible row width because an allocator may add alignment padding.
row_address = mem + (row_number * stride)
sample_address = row_address + (column * bytes_per_sample)
std::map is an ordered key-value container. The C++ standard does not require one particular internal data structure, but the tested GNU libstdc++ implementation stores this map in std::_Rb_tree, a red-black tree. A red-black tree is a self-balancing binary search tree. Each node holds a key-value pair, a colour bit, and links to its parent, left child, and right child. The colour rules keep the tree approximately balanced, while a lookup compares the requested key and follows left or right links until it finds a match.
Here, the key is a heif_channel value and the value is its ImagePlane. When the article refers to the node’s right-child pointer, it means the right link in this GNU tree node, not anything inside the Node.js runtime. Corrupting that link changes which key-value entry the next get_plane(Cr) search visits.
The 58-byte overwrite
The overflowing Cb allocation is next to the map’s live root node in the measured heap layout. Those 58 controlled bytes have this job:
overflow +0x00..0x0f allocation tail, kept zero
overflow +0x10..0x1f next glibc chunk header, prev_size=0, size=0x75
overflow +0x20..0x37 first 24 bytes of the live tree node, kept zero
overflow +0x38..0x39 profile selector, 0x5970 on Ubuntu or 0x5c60 on Debian
To avoid an immediate allocator rejection, the payload rebuilds the glibc header with its expected 0x75 size field, including the PREV_INUSE bit. It also recreates the first tree fields. Only the final two bytes change the root node’s right-child pointer. Keeping the upper six pointer bytes untouched preserves the randomized heap prefix. Changing the low 16 bits selects a nearby address in the same 64 KiB heap region.
The forged plane-map node
That nearby address is not accidental. Earlier Cb rows have already copied attacker-controlled pixels there. The exploit arranges those bytes as a forged std::map node with the key heif_channel_Cr. Its embedded ImagePlane has a 58-byte width, a destination pointer at memcpy@GOT - 16, and a zero row stride:
struct.pack_into("<I", node, 32, 2) # key: Cr
struct.pack_into("<II", node, 48, 58, 66) # plane dimensions
struct.pack_into("<Q", node, 64, write_target) # ImagePlane::mem
struct.pack_into("<I", node, 88, 0) # ImagePlane::stride
This is what the earlier wording “fake plane descriptor” meant. More precisely, it is a forged plane-map node, stored in valid image pixels, whose value looks like an ImagePlane. When libheif next calls get_plane(Cr), the corrupted right-child pointer steers the tree search to the forged node. The matching Cr key makes the lookup return the forged mem and stride fields.
Why this is a write-what-where
The Cr copy loop now treats memcpy@GOT - 16 as the output plane. Since the forged stride is zero, every output row starts at the same address. Consequently, the pixels are the “what” and ImagePlane::mem is the “where”, which is the constrained write-what-where primitive used by the exploit.
The Global Offset Table (GOT) holds the runtime destinations of imported functions. Calls through memcpy@GOT normally resolve to glibc’s memcpy. Replacing that entry changes the destination of the next indirect call at the same copy site.
4. Load the uploaded native library
The first controlled row
Each controlled Cr row has a simple layout. Bytes 0 to 15 contain the null-terminated library path uploads/x.jpg. The next eight bytes contain the runtime address calculated as libvips base + 0x3e995e. Everything from byte 24 to byte 57 is zero. Since the destination begins 16 bytes before the GOT entry, the first copy leaves the path immediately before the slot and writes the GModule loader address into memcpy@GOT.
Why the helper fits the call site
The selected routine is libvips’ internal copy of g_module_open_full. Conceptually, it receives a library path, loader flags, and an optional GError **. It checks the named file and calls dlopen, which runs the shared object’s constructor as soon as the load succeeds.
On x86-64, the hijacked memcpy(dest, src, len) call has dest in RDI, src in RSI, and len in RDX. After the first controlled copy, RDI points to memcpy@GOT - 16, which now contains uploads/x.jpg. That is exactly where the GModule routine expects its path.
The other two registers do not contain normal GModule arguments. RSI still holds an image-row pointer and RDX still contains the 58-byte copy length. This exact loader implementation masks ESI down to the supported GModule flag bits. On a successful load, it never dereferences RDX as an error pointer. We verified that exact success path against the tested libvips binary before using it in the remote chain.
The first memcpy finishes after replacing its own GOT entry. The loop then processes the next Cr row through the same indirect call site. This time execution enters the GModule loader with RDI pointing to the stored path. The loader opens uploads/x.jpg, its constructor runs /usr/bin/id, and the result is sent to the listener.

0x3e995e, and RDI points to uploads/x.jpg.
dlopen receives uploads/x.jpg; frame 1 returns into libvips’ GModule loader.Measured results
We tested the complete exploit against 10 newly started Ubuntu Node processes and 10 newly started Debian Node processes. All 20 runs reached command execution and returned the output of /usr/bin/id. ASLR selected a different libvips address in every process, and the exploit calculated the corresponding GModule-loader address each time.
Before creating the final payload, the exploit may need several ASLR-leak attempts. Each attempt uploads the leak AVIF, processes it through the public optimisation route, and checks the returned PNG for four pointers that identify one supported profile and one libvips base. If the evidence is incomplete or ambiguous, the exploit retries instead of guessing. Ubuntu needed between 3 and 23 attempts, while Debian needed between 3 and 15.
The observed result was 20 successful runs out of 20 in the two tested environments. This does not mean the exploit will work against every sharp deployment. Different versions of Node, sharp, libvips, libheif, glibc, or libstdc++, and changes to the allocator or upload path, may require a new profile.
The project also has 96 automated regression tests. These are code-level checks, not 96 additional exploit runs. They test profile validation, returned-pointer classification, heap calibration, address calculation, payload construction, and safe failure when the evidence does not match a supported target.
Versioned native-stack profiles
The build-specific values live in strict manifests. profiles/native_stack_profiles.json describes the original ET_EXEC lab. profiles/native_stack_profiles_pie.json contains the Ubuntu and Debian ET_DYN profiles. Each profile records the exact Node, Next.js, sharp, libvips, libheif, glibc, and libstdc++ identities, plus the loader, relocation, returned-pointer anchors, C++ layouts, tile geometry, and allocator relationship.
A separate offline verifier checks the supplied binaries and package manifests before that profile is used. It validates SHA-256 hashes, ELF build IDs and type, the hidden loader offset and bytes, the memcpy jump slot, npm package versions, and the bundled libvips and libheif versions. The runtime profile loader also rejects unknown fields, missing values, unsafe paths, and inconsistent geometry before opening the callback listener or sending an HTTP request.
The exploit waits until four returned pointers identify exactly one supported profile and one libvips base. The selected profile then supplies the matching page-to-node relationship. If the evidence is incomplete or ambiguous, the exploit retries the ASLR-leak request before creating the final payload. The repository contains the exploit, the exact profiles, the automated tests, and the results from each of the 20 end-to-end runs.
Reproducing the proof in the authorised lab
The private repository contains a minimal reproducible lab, the native-stack profile and verifier, and one end-to-end exploit script. After installing the documented prerequisites, start the target:
cd lab
npm ci
npm run build
npm run start
Then run the exploit from the repository root:
python3 exploit.py \
--target http://127.0.0.1:3000 \
--callback-host 127.0.0.1 \
--profile-manifest profiles/native_stack_profiles_pie.json \
--json-output result.json \
--concise

/usr/bin/id output.Those 20 end-to-end acceptance runs each selected the correct profile, identified a different libvips base, calculated libvips + 0x3e995e, derived the profile’s heap selector, uploaded x.jpg, generated the runtime AVIF, and returned the target’s real /usr/bin/id output.
Deployment conditions and limitations
This proof demonstrates impact, not universal exploitability. A target must expose both a compatible vulnerable image-processing path and a way to preserve an attacker-controlled shared object, submitted as an image, at a short path reachable by the native loader.
The libvips loader offset, relocation offset, allocator page lane, and partial-pointer relationship are specific to a tested native stack. The loader target does not depend on the Node executable address, but a different Node or allocator layout can change the relation used by the forged-node selector. Such a deployment needs its own verified profile.
Mitigating CVE-2026-32740
- Upgrade to libheif 1.22.0 or later, then rebuild or replace every sharp and libvips package that bundles the vulnerable library.
- Confirm the native library actually loaded in production. Updating only a top-level JavaScript package may not replace a bundled binary.
- Disable or isolate AVIF and HEIF processing until the deployed native dependency chain has been verified.
- Validate file magic, generate server-side filenames, and keep uploads outside the application working directory on a
noexecfilesystem where possible. - Run image decoders in disposable, least-privileged workers without application secrets or unrestricted outbound network access.
- Alert on native image-worker crashes, unexpected module loads, or executable content opened from upload directories.
Conclusion
For us, the interesting part is how these primitives fit together. CVE-2026-32740 supplies the adjacent write, a separate returned-pixel disclosure defeats ASLR, the forged plane-map node turns the corruption into a constrained write-what-where, and the immediate memcpy@GOT transition reaches a libvips-relative native loader before another worker can intervene.
Across the two tested PIE labs, this chain produced 20 callbacks in 20 fresh processes, with 20 distinct randomized libvips bases. Every callback returned the output of /usr/bin/id. This is more than a decoder crash. Under the documented application conditions, an unauthenticated image upload reaches native command execution even though the Node executable is position independent.
Our PoC is available in the Fortbridge libheif-grid Next.js RCE repository.
Related research: WordPress libheif RCE examines a different flaw in a WordPress/ImageMagick/PHP-FPM pipeline.
References
- libheif security advisory for CVE-2026-32740
- Hacktron, Hacking OpenAI through its Discourse deployment
- sharp project repository
- Fortbridge private exploit repository
- GLib GModule documentation
- GNU libstdc++
std::mapimplementation