On 25 August 2026, Next.js shipped patches for two critical remote code execution vulnerabilities. The one that reaches ordinary self-hosted deployments is GHSA-2xp9-vwfh-vxw4 (CVSS 9.5), and it is not really a Next.js bug. It is GHSA-g89c-p67h-r497 (CVSS 9.8), a heap buffer overflow in libheif, reached through sharp whenever the image optimizer decodes an AVIF file whose bytes an attacker controls. The libheif advisory is direct about what that is worth: "We were able to get RCE using this on multiple applications."
The overflow happens because a decoded image can end up holding two alpha planes, and two functions in the same file disagree about how many are there. One of them allocates the output buffer. The other fills it.
The bug
A check written as a comment
mediumHeifPixelImage::transfer_channel_from_image_as() moves a plane from one image into another. Its body opened with // TODO: check that dst_channel does not exist yet and no check. An image can therefore hold two planes for heif_channel_Alpha.
First match wins
mediumfind_storage_for_channel() is a linear scan that returns the first plane matching a channel. has_channel(), get_width(), get_height(), get_bits_per_pixel() and get_channel_memory() are all built on it, so every size query describes the first alpha plane only. The second is invisible.
Allocate from one plane, iterate over all
highscale_nearest_neighbor() sizes the output alpha from get_bits_per_pixel(heif_channel_Alpha), which is the first plane's depth. The scaling loop then walks m_storage directly, which includes the duplicate.
One byte allocated, two bytes written
criticalThe loop branches on each plane's own m_bit_depth. A 10- or 12-bit duplicate takes the HDR path, casts the 8-bit destination to uint16_t*, and writes two bytes per sample into a one-byte-per-sample allocation, for as many samples as the first plane's width and height.
Getting two alpha planes into one image does not require a malformed file in the sense of a corrupt header. It requires a legal one. An iden item, the identity derivation, decodes to a fully formed referenced image that already has its alpha attached. decode_image() then attaches its own alpha from the auxl auxiliary reference on top. Both operations are correct in isolation. Together they produce the duplicate, and the container structure is entirely under the attacker's control.
Why first-match lookup is a memory safety problem
The interesting part of this bug is that no single function in the chain is wrong.
find_storage_for_channel() returning the first match is a reasonable API for a container that is not supposed to hold duplicates. Iterating m_storage directly is a reasonable way to touch every plane. Allocating from a bit depth you just queried is reasonable. The bug is that the container's invariant, one plane per channel, was documented in a comment instead of enforced in code, and two different parts of the codebase then answered the question "what planes are in here" differently.
That is the generalizable lesson, and it has nothing to do with C++ or with image formats. Any structure that permits duplicate keys while its accessors assume uniqueness has two views of itself. Code that reads through the accessor sees one thing. Code that iterates the backing store sees another. As long as every path uses the accessor, the difference is invisible. The moment one path iterates, sizes computed on one view get applied to memory belonging to the other.
libheif fixed the same class twice in this release. GHSA-2jg2-4ch7-h545, also rated critical, was mismatched plane sizes reaching crop(), scale_nearest_neighbor() and extract_area_plane() by a different route, and its patches add size validation to each of those functions independently. Two distinct ways to break the same assumption, both reachable from file structure.
Where
libheif/image/pixelimage.cc. The lookup, unchanged since it was written:
HeifPixelImage::ComponentStorage* HeifPixelImage::find_storage_for_channel(heif_channel channel)
{
for (auto& component : m_storage) {
if (component.m_channel == channel) {
return &component; // first match only; a second plane for this channel is unreachable here
}
}
return nullptr;
}
And the scaling loop that does not use it:
for (const auto& component : m_storage) { // every plane, including the duplicate
heif_channel channel = component.m_channel;
const ComponentStorage& plane = component;
uint32_t out_w = out_img->get_width(channel); // the FIRST plane's width
uint32_t out_h = out_img->get_height(channel);
if (plane.m_bit_depth <= 8) {
// ... 8-bit path
}
else {
// HDR planar: destination was allocated at 8 bits, written as 16
uint16_t* out_data = out_img->get_channel_memory<uint16_t>(channel, &out_stride);
// ...
out_data[y * out_stride + x] = in_data[iy * in_stride + ix];
}
}
The TODO that made this reachable was introduced on 15 January 2018, in commit 03f26530, "support for HEIF images with alpha channel". It was written in the same commit that created the function. The check landed on 23 August 2026, in commit f4fb8bde, eight years and seven months later.
How a Next.js application reaches libheif
This is the part worth checking in your own configuration, because the obvious guess is wrong.
packages/next/src/server/image-optimizer.ts blocks every sharp image loader and then explicitly unblocks a list. Before the patch, that list included VipsForeignLoadHeif, the loader that handles AVIF. The images.formats setting in next.config.js controls the format the optimizer emits. It does not control what it accepts. AVIF input decoding was on by default, and turning AVIF off in formats did not disable it.
The patch closes this twice. AVIF is added to BYPASS_TYPES, so the optimizer now passes AVIF through without touching it, and VipsForeignLoadHeif is removed from the unblock list, so sharp refuses the format even if something reaches it another way.
The precondition is that an AVIF whose bytes the attacker controls reaches the optimizer. In practice that means user uploads rendered through next/image, or an entry in images.remotePatterns pointing at a host that serves user-controlled content: an object store, a CDN bucket, a headless CMS. That is not every Next.js application on the internet. It is a common enough shape that it is worth assuming present until someone has actually checked.
The CVE with the number is the one you probably could not reach
The other advisory in the same release, CVE-2026-75604 (CVSS 9.0), got most of the coverage, because it has a CVE identifier and a memorable description. escapePathDelimiters escaped /, # and ?, but not \. On a Windows filesystem the backslash is a path separator, so a crafted route segment wrote outside the ISR cache directory and from there to code execution.
Read the preconditions. The server must run on a Windows filesystem, the application must use both the Pages Router and the App Router, and it must not use Cache Components. Linux and macOS are unaffected. The fix commit is titled "Fix ISR misses with backslashes in segments when deployed on Windows", which tells you how it was found.
Two critical advisories, same release, same day. One requires a specific operating system and a specific router combination. The other requires an image. A scanner reports both as critical and sorts them by CVSS, which puts the 9.0 with three preconditions above the 9.5 with one. That ordering is exactly backwards for most of the people reading it, and no amount of severity metadata will tell you so. Only the preconditions will.
Fixing it
-void HeifPixelImage::transfer_channel_from_image_as(...)+Error HeifPixelImage::transfer_channel_from_image_as(...) {- // TODO: check that dst_channel does not exist yet+ if (find_storage_for_channel(dst_channel) != nullptr) {+ return {heif_error_Invalid_input,+ heif_suberror_Unspecified,+ "Destination image already has a plane for this channel"};+ }The return type changed from void to Error, which forced both call sites to handle it, and the release added a regression test.
Affected and patched
Next.js 10.0.0 and later, before 15.5.24 and 16.3.3. Patched in 15.5.24 (Maintenance LTS) and 16.3.3 (Active LTS), both released 25 August 2026. libheif 1.23.1 and earlier, patched in 1.23.2. Applications on Vercel's managed platform were not affected.
Upgrading Next.js is the fix, and it disables AVIF optimization rather than repairing the decode, so upgrade libheif underneath sharp as well. If you cannot take the Next.js upgrade this week, the interim measure is the one the patch itself makes: stop the optimizer from decoding AVIF. Reject image/avif uploads at the boundary, and check whether anything in images.remotePatterns can serve a file you did not write. Neither of those helps with CVE-2026-75604, which has no workaround, but if you are running Next.js on a Windows filesystem with both routers, you already know which of these two is your afternoon.
