AP-0611AI-written & agentic codeSeverity · Critical

A request value the image renderer reads as markup

A value an outsider controls, a query parameter or a form field, is passed into something that renders it: an SVG, a template, a generated image. The renderer reads it as markup, not text, and whatever the markup can reach, the attacker can reach.

At a glance

Controls
OWASP A03:2021 · CWE-116 · SOC 2 CC6.6 · ISO 27001 A.8.28
LikelihoodHigh: preview images are everywhere
Effort to exploitLow: one crafted parameter
Blast radiusA broken image or the server, set by what the renderer touches
What review catchesLittle: five lines that work

Every product renders something it did not write: a share image with the page title in it, a PDF with the customer's name, an email built from a template. The value arrives from a request and is placed into markup the renderer parses. If nothing escapes it on the way, the renderer reads the value as instructions, and the attacker is now writing the document.

Why it happens

Rendering libraries are treated as the end of the pipeline, not as an input boundary. The code that places a value into an SVG title or a template field is five lines long and passes every test the team wrote. Escaping is a thing people remember for HTML forms and SQL. Image generators, PDF builders and template engines sit in a blind spot, and the value that reaches them has usually crossed no boundary at all since it left the URL.

How the attack unfolds

The attacker changes one parameter. A page title becomes a fragment of SVG. The renderer parses it, and what happens next depends entirely on what that parsed markup can reach. In Satori, the library behind Next.js image generation, a crafted value was read as SVG markup and rated moderate. Inside a Next.js server the same markup could travel into other dependencies, and Vercel rated it critical: remote code execution. One bug, two scores, and the difference was the neighbourhood, not the code. The attacker then repeats the request against every preview and branch deployment still answering on the old release.

The bug did not get worse between the library and the framework. It got somewhere.MATT Research

Why review misses it

A reviewer sees a value placed in a title element and a working image on the pull request. There is no obvious sink: no database, no shell. The danger lives two dependencies away, in a library the team never imported directly, and it surfaces only when someone sends a crafted value and follows where it goes. Dependency scanners cannot help before the bug has a number. On the day of this disclosure, npm audit did not flag the affected release.

Where it hides

What a reviewer can look for in the code and in the behaviour, before anyone proves it.

  • A route builds an image, PDF or template from values read off the request: query string, path, form body, headers.
  • The value goes into markup the renderer parses (SVG, HTML, XML) with no escaping between the two.
  • The rendering library is treated as output, so nobody reviews it as an input boundary.
  • Preview and branch deployments keep answering on the version they were built with.

A real instance

Next.js ImageResponse, September 2026. Vercel's advisory shows a route reading a value from the request URL and placing it inside an SVG title element. Satori rated the escaping bug moderate. In Next.js the resulting markup could reach other dependencies, and the same bug was rated critical, with remote code execution as the outcome.

Details altered to protect the customer. Pattern, timing and outcome are as found.

How MATT tests it

The proof runs against the deployed product, on every release, whoever wrote the code.

  1. 01Map every route that generates an image, document or template, and trace each rendered value to its source.
  2. 02Send crafted markup in each attacker-controlled value and confirm it is escaped or rejected, never parsed.
  3. 03Follow the dependency chain: what can parsed markup reach inside the renderer process?
  4. 04Enumerate the deployments that still answer, not only production, and run the same check on each.

The evidence

Before the fix
  1. GET /api/og?title=<crafted>200 · imageValue rendered as SVG markup; reaches downstream libraries
  2. GET /api/og on a stale preview URL200 · imageOld release still answers with the vulnerable renderer
After the fix
  1. GET /api/og?title=<crafted>200 · imageValue escaped; the markup is drawn as plain text
  2. GET /api/og on a stale preview URL404Vulnerable previews deleted; the surface matches production
The rule, in plain English

No value an outsider controls reaches the content, attributes or styles of anything the product renders as markup.

Executable check

For every route that generates an image, document or template, a crafted value in each outsider-controlled input must be escaped or rejected, on every release and on every deployment that still answers.

Every rule MATT holds →

What the fix looks like

  1. 01

    Escape at the boundary

    Treat every value that reaches a renderer as untrusted: escape it as XML or text before it is placed in SVG, HTML or a template.

  2. 02

    Upgrade the renderer

    Take the patched release (Next.js 16.3.6 for this instance) and pin it, so the fix cannot drift out on the next install.

  3. 03

    Retire what still answers

    Delete preview and branch deployments built on the vulnerable version; the surface is every URL that responds.

  4. 04

    Keep the check

    Run the crafted-value test on every release, because the next renderer will have the same bug under a different name.

A check that was never written is found by proving the deployed behaviour, fixed with the engineer who shipped it, and kept fixed by running the control on every change. What is ExploitOps? →

ExploitOps discovery call

Understand where your security loop breaks.

A 15-minute call with a MATT co-founder to understand your product and whether an ExploitOps Review is worth doing. If there is a real need, we scope one with the research team.