Beyond the Pixel: Dissecting the Attack Surface of Shared Native Image Pipelines in Modern Web Frameworks

SummaryUsing CVE-2026-84383 as a case study, this article dissects the shared Sharp, libvips, and libheif native image pipeline in Next.js, Nuxt, and Astro, and explains how low-level memory corruption becomes remotely reachable from the Web.

Web SecurityRemote Code ExecutionNext.jsSharplibheif

Beyond the Pixel explores how attacker-controlled image input crosses modern Web-framework abstractions and becomes server-side code execution in native codecs. Using Next.js, Nuxt.js, and Astro as examples, it traces the path from libheif to RCE in server-side image pipelines.

0x01 The Native Parsing Chain Behind Modern Web Frameworks

Images in modern Web frameworks such as Next.js, Nuxt.js, and Astro are no longer purely static assets. To support responsive dimensions, first-paint placeholders, and dynamic WebP/AVIF transcoding, frameworks commonly embed complete real-time image-processing services on the server.

An image-processing service follows a straightforward pipeline. After data reaches the server, it reads file-header magic bytes to identify the true format and dimensions, invokes the matching loader to decompress the stream into raw pixels, performs resizing, rotation, or color conversion such as YUV to RGBA in memory, and finally re-encodes, caches, or returns the result.

At the application layer, however, developers often see nothing more than a minimal component or a one-line call chain:

JS
// Frontend component declaration
<Image src={image} width={800} height={600} />

// Or a simple backend call
sharp(input)
  .resize(800, 600)
  .toBuffer();

Whether the framework is Next.js, Nuxt, or Astro, once these <Image /> components trigger server-side processing, the core engine behind them is almost always Sharp—the familiar sharp(input).resize().toBuffer() pipeline.

However, Sharp does not decode image formats itself. Its role is to bridge JavaScript logic into libvips through Node-API.

Once toBuffer() runs, untrusted binary data crosses Node-API directly into libvips. Content sniffing selects the matching Foreign Loader, which dispatches into underlying C/C++ libraries for memory allocation and pixel writes.

In only a few milliseconds, this call crosses four layers: the JavaScript runtime, Node addon, image engine, and native decoding library.

Once data enters libvips, the low-level execution path is determined entirely by the input's actual data format. It branches into three main native paths with very different complexity and call depth:

  • AVIF / HEIF path: The deepest execution path. heifload dispatches libheif, which calls dav1d or libaom for AV1 decoding, performs channel transformations, and converts the color space from YUV to RGBA.
  • JPEG path: The shortest path. jpegload passes directly to libjpeg-turbo for dequantization and color-space conversion.
  • TIFF path: As a multilayer container, TIFF allows each strip to embed a JPEG, Deflate, WebP, or Zstd decoder, creating a complex decoder-within-a-container structure.
Inside libvips, the input format selects the native decoding path: AVIF is deepest, JPEG shortest, and TIFF nests codecs inside a container
Inside libvips, the input format selects the native decoding path: AVIF is deepest, JPEG shortest, and TIFF nests codecs inside a container

The same entry point reaches entirely different native codecs depending on the format. The depth of the chain is itself a difference in attack surface.

Untrusted image input is therefore not confined to a JavaScript sandbox. It moves through JavaScript, C, C++, Rust, and hand-written assembly, often across several native thread pools. Beneath the framework's image-optimization abstraction lies an entire dependency stack of system-level native libraries.

The abstraction gap between a one-line framework call and the native decoding stack beneath it
The abstraction gap between a one-line framework call and the native decoding stack beneath it

Everything from Sharp down to the codec is native code: hundreds of thousands of lines spread across multiple projects and thread pools.

A developer writes one <Image /> line, and the framework transparently feeds attacker-controlled binary data into this entire row of native parsers.

0x02 From a Low-level Parser Bug to a Framework-level Remote Attack Surface

Server-side image processing is not a new security frontier. From remote command execution through ImageMagick delegates (ImageTragick) to memory corruption in Ghostscript, SVG entity parsing, font engines, and multimedia parsers, this area has long been a major target for vulnerability research.

Today, however, these flaws have a very different exposure surface. Image processing used to be an occasional standalone CLI invocation or offline script. Modern full-stack frameworks turn dynamic optimization into built-in routes that start with the service by default:

  • Next.js: /_next/image
  • Nuxt.js (via IPX): /_ipx/...
  • Astro: /_image

Once one of these routes is hit, the framework automatically fetches the stream, builds a Buffer, detects the format, invokes Sharp for transcoding, and writes the result to cache—all without the application layer being aware of it.

This design completes the attack path. From an offensive-security perspective, whether a parser flaw becomes a critical threat depends not only on the memory corruption itself, but also on how malicious data can reach it. Modern frameworks' automated pipelines remove what used to be a high delivery barrier for attackers.

  • Local CLI scenario: If a flaw can be triggered only through a local CLI (for example, by running heif-dec input.heic output.png), the issue requires prior execution capability on the host and remains a low-level bug constrained to a local environment.
  • Framework scenario: The framework exposes receipt of an external URL and full decoding as a public interface. The same low-level memory bug therefore gains an unauthenticated remote trigger and becomes remote code execution.

Because Sharp and libvips are shared infrastructure across frameworks, a vulnerability in this pipeline can affect an entire class of technology stacks.

0x03 CVE-2026-84383: Mechanism and Framework Exploitation Paths

While researching new attack surfaces this June, I found CVE-2026-84383 (GHSA-g89c-p67h-r497). Unfortunately, before my submission had even settled, the Hacktron team had already reported it.

Screenshot of the CVE-2026-84383 vulnerability submission
Screenshot of the CVE-2026-84383 vulnerability submission

Even so, this remains a representative case. The vulnerability affects libheif 1.22.0 to 1.23.1; the fix was released in 1.23.2.[1][2]

The flaw is conceptually straightforward. An attacker uses nested iden and auxl items to create an inconsistent state. During HeifPixelImage:: scale_nearest_neighbor(), the bit depth is mismatched and 16-bit pixels are written into Alpha memory allocated for only 8 bits, causing a heap buffer overflow.

Let's briefly examine how the vulnerability works.

1. HEIF Item Topology and the Violation of Channel Uniqueness

HEIF uses the ISOBMFF item model and naturally supports complex reference relationships: dimg declares image derivations, iden represents identity transformations, and auxl attaches auxiliary data such as Alpha channels or depth maps.[3]

How an independently attached Alpha channel introduces duplicate channels into m_storage
How an independently attached Alpha channel introduces duplicate channels into m_storage

This mechanism allows an Alpha channel to exist independently of the primary color data and bind to its host image through a single auxl relationship.

After decoding, libheif manages the resulting pixel planes through a HeifPixelImage object. Every channel component is stored in the underlying m_storage vector:

CPP
HeifPixelImage {
    std::vector<ComponentStorage> m_storage = {
        Y,
        Cb,
        Cr,
        Alpha
    };
};

Downstream processing assumes that each channel has exactly one backing allocation.

However, transfer_channel_from_image_as() does not deduplicate channels (the source even retains an unimplemented // TODO ): [4]

CPP
// TODO: check that dst_channel does not exist yet
plane.m_channel = dst_channel;
m_storage.push_back(plane);

The new plane is pushed directly into m_storage. If an 8-bit Alpha plane is inserted first and a 16-bit Alpha plane second, the object ends up holding two channels with the same name:

TEXT
[Y, Cb, Cr, Alpha(8-bit), Alpha(16-bit)]

2. Split Views Lead to an Out-of-bounds Heap Write

When the object contains multiple channels with the same name, two parts of libheif develop inconsistent views of the same state:

  • Metadata lookup (first match wins): get_bits_per_pixel() and similar APIs depend on find_storage_for_channel() to look up channels by name. It returns as soon as it finds the first matching plane and therefore misidentifies the Alpha channel as only 8-bit.

  • Pixel scaling (full iteration): The actual pixel-processing function, scale_nearest_neighbor(), ignores the name-to-channel mapping and iterates over the entire m_storage vector (for (const auto& component : m_storage)), processing every channel in the vector.

The same HeifPixelImage exposes inconsistent views to property lookup and pixel iteration
The same HeifPixelImage exposes inconsistent views to property lookup and pixel iteration

When scaling begins, the code first allocates memory for the output image:

CPP
out_img->add_channel(
    heif_channel_Alpha,
    width,
    height,
    get_bits_per_pixel(heif_channel_Alpha),
    limits
);

As noted above, get_bits_per_pixel() stops at the first same-named channel and returns 8 bits, so the output Alpha buffer is allocated at exactly one byte per sample.

The loop then processes each channel in turn:

  1. The first 8-bit Alpha plane takes the SDR branch, uses uint8_t* and writes correctly;
  2. When it reaches the second 16-bit Alpha plane, m_bit_depth > 8, control flow enters the HDR planar branch:
CPP
const uint16_t* in_data =
    static_cast<const uint16_t*>(plane.mem);

uint16_t* out_data =
    out_img->get_channel_memory<uint16_t>(
        heif_channel_Alpha,
        &out_stride
    );

The fatal type mismatch occurs here. out_img allocated Alpha memory only once. get_channel_memory<uint16_t>() returns the same address previously allocated for 8-bit samples and forcibly treats it as a uint16_t*. The subsequent pixel write is:

CPP
out_data[y * out_stride + x] =
    in_data[iy * in_stride + ix];

The write width for each sample instantly doubles to two bytes. At a resolution of 128 × 128:

  • Allocated size: 128 × 128 × 1 byte = 16384 bytes
  • Actual write: 128 × 128 × 2 byte = 32768 bytes

The write volume becomes exactly twice the allocated space, crossing the heap-chunk boundary and overwriting a full 16 KB beyond it.

Heap overflow caused by allocating for 8-bit samples and writing 16-bit samples
Heap overflow caused by allocating for 8-bit samples and writing 16-bit samples

The ASan log pinpoints the crash: the scale_nearest_neighbor() function's HDR branch reports a WRITE of size 2, and the corrupted target is the heap block previously allocated by add_channel() allocation. The out-of-bounds heap write is deterministic and reproduces on every decode. With a suitable heap layout, execution can return normally after overwriting adjacent live pixel memory, yielding a stable OOB condition.

3. Constructing the PoC State: A Nested Item-derivation Chain

Triggering the vulnerability requires a carefully constructed, multilayer reference structure:

Item Type Geometry Topological role
1 mski 16×16 / 8-bit Base image; auxl attaches Item 2
2 mski 16×16 / 8-bit The Alpha channel attached to Item 1
3 iden 16×16 Identity-derived from Item 1; auxl attaches Item 4
4 mski 16×16 / 16-bit The Alpha channel attached to Item 3
5 mski 128×128 / 8-bit Primary image whose Alpha reference points to Item 3

The construction's cleverest detail is that it avoids any dependency on HEVC/AV1 video codecs. The PoC uses the mask image type built into the HEIF specification (mski) to carry its pixels.

Although the input claims to be AVIF (ftyp=avif), libheif itself performs everything from unpacking through Alpha merging, bypassing external dav1d/libaom plugins. Any runtime that reaches the generic heif_decode_image() entry point is potentially exploitable, even in a stripped-down libheif build. The real attack surface may therefore be broader than expected.

Derivation and Alpha-reference relationships among the five HEIF items in the official PoC
Derivation and Alpha-reference relationships among the five HEIF items in the official PoC

The decoding flow is as follows:

  1. The decoder starts from primary Item 5 and first resolves its auxl -referenced Alpha item, Item 3.
  2. Item 3 is an iden node. While resolving associated Item 1, its decode entry point starts the full decoding flow directly: return imgitem->decode_image(...). This also unpacks Item 1's 8-bit Alpha channel (Item 2) and assembles it into the HeifPixelImage.
  3. Control then returns to Item 3. The code detects its attached 16-bit Alpha plane (Item 4) and calls transfer_channel_from_image_as() to merge it. With no deduplication check, the object irreversibly ends up holding two Alpha planes.
  4. Item 3 (16 × 16) now differs in size from primary Item 5 (128 × 128), forcing the system to invoke scale_nearest_neighbor() scaling. The type-confused plane is then fed into the scaling loop, triggering the heap overflow.

At its core, the vulnerability is a severe representation invariant violation inside an object. The official fix in 1.23.2 is direct: it blocks the operation at the entrance to transfer_channel_from_image_as(), rejecting the plane whenever a channel with the same name already exists. This restores the invariant that each logical channel has a single storage plane. The patch also removes the iden node's overly permissive dimension check, eliminating the risk of the ispe declaration from diverging from the actual pixel geometry.

4. Framework Call Chains and Loader Reachability

Sharp is what turns this heap-overflow flaw into a remote Web attack surface. The chain depends on whether attacker-controlled data can reach the VipsForeignLoadHeif loader. Both URL suffixes and the HTTP Content-Type can be forged, but libvips ignores them and identifies the format from the file content, passing malicious data directly to the HEIF loader.[5][6]

Next.js (/_next/image)

A typical Next.js request looks like this: [7]

HTTP
GET /_next/image?url=<source>&w=1920&q=75

After the server validates the source against remotePatterns, fetches the data and converts it into a Buffer, then passes it to imageOptimizer() to start the Sharp pipeline:

JAVASCRIPT
sharp(buffer, {
  limitInputPixels,
  sequentialRead
})
  .timeout(...)
  .rotate()
  .resize(...)
  .toBuffer();

These parameters are only surface details. If the content is an AVIF/HEIF file, it can still reach VipsForeignLoadHeif even when it uses another filename extension.

Next.js published a security advisory for the vulnerability (affected releases are fixed in 15.5.24 and 16.3.3 ). Its defense-in-depth fix skips online AVIF optimization at the framework layer and blocks the HEIF loader through a low-level API when Sharp is initialized: [8]

JAVASCRIPT
sharp.block({
  operation: ['VipsForeignLoad']
});

It then explicitly allows only required formats (JPEG, GIF, PNG, SVG, TIFF, and WebP), removing VipsForeignLoadHeif.[9]

Nuxt.js (/_ipx/...)

Nuxt Image's self-hosted mode is based on IPX, which still relies on Sharp. After a client declares transformation parameters in a component, Nuxt maps them into a URL containing modifiers:

TEXT
/_ipx/w_800,q_80,f_webp/<source>

After the Nitro server receives the request, it reads the data through the IPX Storage backend and wraps it in a Buffer:

JAVASCRIPT
const sourceData = await storage.getData(id, opts);
return Buffer.from(sourceData);

It then instantiates Sharp and maps the transformation operators:

JAVASCRIPT
const Sharp = await getSharp();
let sharp = Sharp(sourceData, { animated, ...options.sharpOptions });
// Map resize / rotate / format, etc.
processedImage = await sharp.toBuffer();

This chain has the same source-to-sink topology as Next.js. If the libheif version used by IPX at runtime falls within the affected range, the same exploitation point exists.[10]

Astro (/_image)

Astro's runtime image endpoint accepts requests such as /_image?href=...&w=800.[11]

After Astro stores external data in inputBuffer, it passes the buffer to the built-in Sharp module:

JAVASCRIPT
sharp(inputBuffer, {
  failOn: 'none',
  pages: -1,
  limitInputPixels: ...
});

Its failOn: 'none' greatly relaxes validation, preventing carefully malformed streams from being rejected early. The subsequent rotate() and toBuffer() calls that follow inevitably cause libvips to perform full low-level pixel decoding, leaving the vulnerable path reachable by default.

This vulnerability was reported upstream. Astro has now fixed it in 7.2.8, raising the minimum Sharp dependency to the security-patched 0.35.4.[12]

Three framework image entry points converge on the same Sharp, libvips, and libheif native pipeline
Three framework image entry points converge on the same Sharp, libvips, and libheif native pipeline

0x04 From Attack-surface Types to Security Boundaries

The impact of this risk is not limited to modern Web frameworks. Hacktron's Hacking OpenAI research is a representative real-world example.[13]

That case involved Discourse, its underlying libheif 1.19.x dependency, and missing system-package patches—an independent historical flaw. Discourse normally used FastImage for routine image checks, but because FastImage could not process HEIF, the conversion logic automatically fell back to ImageMagick, which ultimately dispatched into libheif. The result was a code-execution chain spanning the application and native-library layers:

TEXT
User image upload -> Discourse -> ImageMagick -> libheif -> native memory corruption -> RCE on the Discourse host

Because Discourse was containerized, ASLR was no longer a major obstacle either.

The case also demonstrates that checking only whether an application uses Sharp is not enough to determine exposure. The better question is whether untrusted data flows are automatically passed into a native parser.

From this perspective, the attack surface falls into three categories of security boundary:

1. Runtime Dynamic Conversion (RCE in the Main Web Process)

  • Typical scenario: Real-time image services such as Next.js, Nuxt/IPX, and Directus.
  • Boundary analysis: In remotePatterns, many applications allowlist their own CDN. If an attacker can upload a resource to that CDN, they can induce the server to fetch it. On a cache miss, the framework immediately invokes Sharp for full decoding. If image processing is not isolated from the main process, the attacker may gain the Web service's privileges.

2. Media Uploads (Privilege Escalation to Host Control)

  • Typical scenario: CMS and forum platforms such as Discourse and Strapi.
  • Boundary analysis: In a CMS or forum, the attack surface often hides in asynchronous workflows. After a low-privilege user submits an ordinary-looking image, a background job queue automatically invokes transcoding and may deliver malicious data to the vulnerable code path, resulting in RCE.

3. Build-time Static Generation (CI/CD Supply-chain Poisoning)

  • Typical scenario: Static-site generators such as Astro and Gatsby.
  • Boundary analysis: A malicious image need not be sent directly to a production endpoint. It can enter the repository through an external pull request, a Markdown dependency change, or a headless CMS. When continuous integration runs astro build, the build script parses and prerenders images in bulk on the build runner. A vulnerable parser can therefore yield RCE. CI nodes often carry production deployment tokens, code-signing certificates, and container-registry credentials, so code execution can poison the entire delivery chain.

0x05 Conclusion

Looking back at the development of Web security, attack surfaces rarely appear from nowhere. They usually enter applications alongside new foundational capabilities.

After databases became ubiquitous on the Web, SQL injection became a persistent problem. As dynamic pages and browser capabilities expanded, XSS and CSRF became foundational Web-security topics. When languages such as Java turned object serialization into general infrastructure, deserialization opened another class of code-execution paths.

These vulnerabilities look very different, but they share a common pattern: Whenever a complex capability is packaged into something simple enough to become a widely used default, the trust boundary behind it expands as well.

Developers see a more convenient API and easier product features. Attackers see a new path from input to complex execution logic.

What is changing now is that frameworks are repackaging more and more processing that once belonged to the local machine as Web infrastructure.

Frameworks and platforms now automatically perform image resizing, document previews, font rendering, audio/video transcoding, SVG rasterization, archive extraction, and even model-file loading.

Application code may see only a component, an upload endpoint, or a preview API, while untrusted data is already entering a lower-level native parser.

libheif is only one exposed region of a much larger surface. As more heavyweight processing becomes a default Web feature, similar problems will emerge in native pipelines for fonts, PDFs, audio and video, compressed archives, Office documents, SVGs, and model files.

Binary vulnerabilities that once required local files and local programs are increasingly becoming remotely reachable through HTTP, upload endpoints, CMS content, and CI build pipelines.

From this perspective, the most valuable research target is not one CVE in one low-level component, but a broader intersection:

Web Reachability × Native Attack Surface