Guides · security

CVE-2022-35583: the wkhtmltopdf SSRF that will never be patched — and how to contain it if you cannot migrate this quarter

Published 2026-08-17 · every number below was pulled from its primary source on the day of writing, including the disagreement between them. Sources linked inline.

Somebody filed a ticket with a 9.8 on it and your name next to it. Before you write either the migration plan or the risk acceptance, three things are worth knowing, and only the first one is widely reported:

  1. The score is real, the vulnerability is real, and no patch will ever exist — the repository has been read-only since 2022.
  2. Two of the largest distribution security teams looked at the same behaviour and rated it far lower than NVD did. Ubuntu calls it medium and deferred it; Debian marked it unimportant and unfixed. You need to know why before you quote either number in a document.
  3. Migrating does not remove the SSRF. We measured the replacement everyone recommends: Chromium fetched every internal address we injected, and the response body came back as extractable text inside the PDF. Migration removes the unpatchable part. It does not remove this class of bug.

This page is written for whoever has to close the ticket, including the case where the honest answer is "not this quarter". Containment first, migration second.

What the vulnerability is

CVE-2022-35583 is server-side request forgery — CWE-918, as classified by NVD. The mechanism is one line long: HTML that reaches the renderer can contain an <iframe> whose src is an address inside your network. wkhtmltopdf fetches it, renders whatever comes back, and places it in the PDF. The PDF then goes wherever your PDFs go — usually to the person who supplied the HTML.

So the attacker gets to make requests from inside your perimeter, with your renderer's network position and credentials-by-location, and read the answers. The classic targets are cloud instance metadata (169.254.169.254), internal admin panels, health and debug endpoints, and anything else that trusts callers because they are on the right network.

FieldValue (pulled 2026-08-17)
CVSS 3.1 base9.8 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
WeaknessCWE-918, server-side request forgery
AffectedNVD's CPE names 0.12.6 — the last release, published 2020-06-10
Published / last modified2022-08-22 / 2026-06-17
NVD statusAnalyzed
Fix availableNone. Repository archived and read-only since 2022-11-22

Why two distributions rated it far lower than 9.8

This is the part your risk acceptance stands or falls on, and it is almost never mentioned in the write-ups. Three security teams have published a position on this CVE and they do not agree:

SourceRatingStatus
NVD 9.8 CriticalAnalyzed
Ubuntu medium "Vulnerable, fix deferred" on its supported releases including 24.04 LTS, since 2023-07-18
Debian unimportantunfixed; bullseye and bookworm marked vulnerable

Both distributions explain themselves, and their reasoning is the same. Ubuntu's tracker note, in full:

"As of 2023-07-18, no fix for this vulnerability was made available by upstream. That is because this is not being considered a wkhtmltopdf issue, but instead an issue with applications using wkhtmltopdf without first sanitizing input that is user-supplied HTML/JS. In the related GitHub issue page it is possible to see that this issue is possibly being disputed."

Debian's note reaches the same place in fewer words: "By design, wkhtmltopdf retrieves external resources. If it is employed inside a protected network in an automated way, a malicious actor may access internal resources. A user of wkhtmltopdf should restrict such access."

In other words: fetching the resources your HTML references is the entire job. A tool that refused would be a broken tool. The vulnerability is not a coding mistake inside wkhtmltopdf — it is what happens when a fetching engine is pointed at attacker-controlled input with no egress boundary around it. Ubuntu cites commit 5426e55 as the maintainers' acknowledgement; that commit, from 2020-06-10, is the one that created the project's status page — the page that says, in the maintainer's own words:

"Do not use wkhtmltopdf with any untrusted HTML — be sure to sanitize any user-supplied HTML/JS, otherwise it can lead to complete takeover of the server it is running on! Please consider using a Mandatory Access Control system like AppArmor or SELinux."
How to use this, and how not to. "Debian says unimportant" is not a defence on its own, and quoting it as one is how a risk acceptance gets rejected. What the disagreement actually tells you is that the score is conditional on your deployment: 9.8 assumes attacker-controlled input reaching a renderer with useful network reach. If both halves are true for you, treat it as critical regardless of what the distributions say. If neither is, you have a real argument — and the distributions' published reasoning is the citation that supports it.

The other numbers a risk acceptance needs

CVSS describes a worst case. Two other public numbers describe the current situation, and both belong in the document because a reviewer will ask:

SignalValueWhat it means here
EPSS 0.117 — 95.7th percentile
model date 2026-08-16
An 11.7% modelled probability of observed exploitation attempts in the next 30 days. High relative to most CVEs, and consistent with public exploit write-ups existing. It is a population-level forecast, not a statement about your host.
CISA KEV Not listed
catalog 2026.08.14, 1,665 entries
No confirmed in-the-wild exploitation on CISA's record, so no BOD 22-01 remediation date attaches to it. Absence from KEV is not evidence of safety — it means the deadline is yours to set rather than the government's.
Exploit maturity Public write-up and PoC NVD's reference list tags a Packet Storm entry and a third-party write-up as Exploit. This is not theoretical work.

Why no patch can arrive, stated precisely

Pulled from the GitHub API on 2026-08-17: archived: true, last push 2022-11-22, 1,352 open issues, 14,568 stars, project started 2009-08-07.

The ticket filed against the project itself — issue #5249, opened 2022-08-26 and titled "[Security 9.8 CRITICAL] CVE-2022-35583" — is still open today, with seven comments. It cannot be closed and it cannot be fixed: the repository is read-only, so there is no one to accept a pull request and no release to put it in. If your process wants a link showing the upstream state, that issue is a better citation than any advisory.

The deeper reason is structural rather than administrative. wkhtmltopdf is built on Qt 4, unsupported since 2015, containing a QtWebKit whose engine has not been meaningfully updated since 2012. QtWebKit was deprecated in 2015 and removed from Qt in 2016. Even a willing maintainer would have no upstream to send a browser-engine fix to.

Your package manager has an opinion too

Worth checking before you plan anything, because it decides whether you are on a clock:

DistributionState (pulled 2026-08-17)
Debian 0.12.6-2 in oldstable (bookworm), 0.12.6-1 in oldoldstable (bullseye). Not present in stable, testing or unstable — source: ftp-master madison.
Ubuntu Last published in noble 24.04 LTS as 0.12.6-2build2. Marked DNE — does not exist — in every release after it, per the Ubuntu CVE tracker.

Two consequences. First, apt install wkhtmltopdf still succeeds today on the LTS a great many people run, and installs 0.12.6 with this CVE open — so a base image rebuild will not save you, and a fresh install is not a fresh start. Second, the package is already gone from newer releases, which means your next distribution upgrade breaks the build rather than merely warning you. That is your real deadline, and it is usually sooner than the security ticket's.

Am I actually exposed? A triage that takes ten minutes

Two conditions have to hold simultaneously. Answer both with evidence, not with recollection — the answers go in the document.

1. Can attacker-influenced HTML reach the renderer?

Not "do we accept HTML uploads" — almost nobody does. The question is whether any value that a user can influence ends up interpolated into the template. That includes: names and addresses on an invoice, a free-text note or comment, a company logo URL, a rich-text description, a filename, a support ticket body, a product title from a marketplace feed, a webhook payload field. Any of those is an injection point if it is not escaped, and template engines that emit HTML unescaped by default are common.

Grep for where your template interpolates, then check the escaping at each site. If every byte reaching the renderer is generated by your own code from your own data, this condition is false and your exposure genuinely is low. Write down how you verified it.

2. Can the renderer reach anything worth reaching?

Run the check rather than reasoning about it — from the renderer's own network position, not from your laptop:

# 1. Put a listener somewhere the renderer can reach.
python3 -m http.server 8099 &

# 2. Feed it HTML that tries to reach that listener, plus the
#    cloud metadata address, the way an attacker's payload would.
cat > probe.html <<'HTML'
<iframe src="http://127.0.0.1:8099/probe"></iframe>
<iframe src="http://169.254.169.254/latest/meta-data/"></iframe>
<img src="http://127.0.0.1:8099/pixel.png">
HTML

# 3. Render it through your real pipeline, then check both sides:
#    - nothing in the listener's access log
#    - nothing fetched visible in the PDF: pdftotext probe.pdf -
#    If either shows a hit, your egress controls are not on.

If the listener logs a hit, or if the metadata iframe renders anything other than an error, your egress controls are not doing what the architecture diagram says. That is the single most common finding here, and it is usually a surprise.

Containment, in order of how much risk it removes

If migration is a month of work and you have a week, this is the order that actually buys you something. The first item is worth more than all the others combined.

  1. Stop attacker-influenced HTML from reaching the renderer. Escape at the interpolation site — every one of them. Where a template genuinely needs user-supplied markup, allow-list the tags through a real sanitiser and drop <iframe>, <object>, <embed>, <link>, and any attribute that takes a URL. This closes the vulnerability rather than reducing its blast radius, and it is the fix both distribution security teams say is yours to make.
  2. Take away the network. The maintainer's own AppArmor page contains the most useful sentence in this entire subject and buries it near the bottom: remove #include <abstractions/nameservice> from the profile and "any attempt to make a network connection by wkhtmltopdf will get denied". That is a kernel-enforced egress kill switch for an SSRF, on Ubuntu, Debian and SUSE, in one commented-out line. Red Hat and Fedora do not ship AppArmor — use SELinux, or a network namespace with no route out.
    #include <tunables/global>
    /usr/local/bin/wkhtmltopdf {
      #include <abstractions/base>
      #include <abstractions/fonts>
      #include <abstractions/openssl>
    
      # THE LINE THAT MATTERS. Present = the process may open sockets.
      # Removed  = the kernel denies every network connection it attempts,
      #            which is CVE-2022-35583 with nowhere to go.
      # #include <abstractions/nameservice>
    
      deny capability sys_ptrace,
      /proc/*/maps r,
      /usr/local/bin/wkhtmltopdf mr,
      /var/cache/fontconfig/* r,
      /tmp/** rwlk,
    
      # Only the directories your own templates and output need:
      /opt/pdf-workdir/** rwk,
    }

    Adapted from the maintainer's published policy; the working-paths section has to be customised for your application. Load it with systemctl reload apparmor and confirm with sudo aa-status that the profile is listed in enforce mode. If your templates do load remote assets, keep the socket access but put an egress allow-list in front of it, and block link-local addresses so 169.254.169.254 is unreachable.

  3. Leave local file access disabled. In 0.12.6, --disable-local-file-access is the documented default, and --allow <path> exists to open specific directories. Search your codebase for --enable-local-file-access — wrappers and Stack Overflow answers hand it out freely to make a local logo or stylesheet load, and it re-opens the file-read surface that the default was closing. Note the maintainer's caveat: that flag "will block access to the local filesystem", but "an attacker able to exploit a CVE in the prebuilt binaries may be able to bypass this setting", which is exactly why the AppArmor layer is not redundant.
  4. Turn off what your templates do not use. -n / --disable-javascript if the document is static — it removes a large class of payload from a 2012 engine. --no-images if you have no images. --disable-external-links and --disable-internal-links where link annotations are pointless. Every flag verified against the shipped usage documentation.
  5. Assume the process will be taken over and make that boring. Unprivileged user, its own container, read-only root filesystem, no cloud credentials on the instance role it can borrow, no shared secrets in its environment, a short timeout and a memory cap. Rendering untrusted input is a job for a machine that owns nothing.
  6. Put an expiry date on the acceptance. A risk acceptance with a date is a plan; one without is a decision never to migrate, taken quietly. Pin it to the distribution deadline from the table above — that one is not negotiable with you.

Check your wrappers, not just the binary

An NVD keyword search for "wkhtmltopdf" returns eight CVEs today. Only two are against the engine, and five of the remaining six are in code that calls it — which is where the recent activity is, and where a scanner that only fingerprints the binary will miss things.

CVEScoreWhereNote
CVE-2022-355839.8the engine this page — unfixed, unfixable
CVE-2020-213657.5the engine fixed. Directory traversal through 0.12.5; Ubuntu's tracker records it as released upstream in 0.12.6-1. If you are on 0.12.6 this one is closed — do not carry it in your ticket.
CVE-2026-167709.8Perl PDF::WebKit ≤ 1.2 2026-08-13. Argument injection: the library turns <meta name="pdf-webkit-KEY"> tags in your document into command-line options. Content becomes flags.
CVE-2026-167669.8Perl Catalyst::View::Wkhtmltopdf < 0.6.1 2026-07-25. Render options passed to the shell unsanitised; 0.6.0 was an incomplete fix.
CVE-2024-132859.8Drupal wkhtmltopdf module 2025-01-09, advisory SA-CONTRIB-2024-049
CVE-2020-103907.2PHPKB The configurable wkhtmltopdf path was the injection point — an admin setting became code execution.
CVE-2018-148656.5Odoo Insecure options when handing documents to wkhtmltopdf → local file read

The pattern in the wrapper bugs is one thing repeated: a CLI with dozens of flags, called through string concatenation, with user data somewhere near the flags. If you keep wkhtmltopdf for now, audit the layer that builds the command line — that is where the 2026 criticals are, and unlike the engine, those have fixes you can install.

The same class is not unique to this tool, which is the tell that it is a design property and not a bug. CVE-2025-51591 (2025-07-11) is the identical injected-iframe SSRF in Pandoc, scored 3.7 Low, with the mitigation being Pandoc's --sandbox option. Any HTML converter that fetches what its input references can be pointed inward. What differs between tools is whether a sandbox switch exists and whether anyone is still shipping fixes.

Measured: migrating does not remove the SSRF

This is the part worth taking away even if you close the ticket by migrating, and we have not seen it stated anywhere, so we tested it rather than asserting it. The standard recommendation is Puppeteer or Playwright — modern engine, actively patched, all true. The question nobody asks is whether the successor fetches injected internal addresses too.

It does. Same three carriers, one page, Chromium 151.0.7922.34 driven by Playwright 1.62.1, on 2026-08-17:

import http from 'node:http';
import fs from 'node:fs';
import { chromium } from 'playwright';

// Stand-in for an internal asset: reachable from the renderer,
// not from the internet. Your metadata endpoint, your admin panel.
const hits = [];
const srv = http.createServer((req, res) => {
  hits.push(req.url);
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end('<body>INTERNAL-SECRET-8f3a</body>');
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const I = `http://127.0.0.1:${srv.address().port}`;

// The shape CVE-2022-35583 describes -- an injected iframe pointing at an
// internal address -- plus the two carriers people forget about.
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(`<h2>Customer note</h2>
  <iframe src="${I}/admin?probe=iframe" width="440" height="90"></iframe>
  <img src="${I}/pixel.png?probe=img">
  <link rel=stylesheet href="${I}/theme.css?probe=css">`,
  { waitUntil: 'load' });
await page.waitForTimeout(1000);
fs.writeFileSync('/tmp/out.pdf', await page.pdf({ format: 'A4' }));
await browser.close(); srv.close();

console.log('requests that reached the internal server:', hits.length);
// then: pdftotext /tmp/out.pdf - | grep INTERNAL-SECRET-8f3a
requests that reached the internal server: 3
    /admin?probe=iframe   /theme.css?probe=css   /pixel.png?probe=img

$ pdftotext /tmp/out.pdf - | grep -c INTERNAL-SECRET-8f3a
1

All three requests arrived: the iframe, the stylesheet and the image. And the fetched body is not merely requested — it is rendered into the document and extractable as text, which pdftotext confirms. So this is not blind SSRF. Whoever receives the PDF reads the internal response.

Nothing about that is a Chromium defect, and it is not an argument against migrating. It is the same by-design fetching behaviour Debian described, in a browser that is still maintained. The honest conclusion:

Migration fixes "no patch will ever exist". It does not fix "our renderer fetches whatever the input tells it to." The controls that fix the second one — escaping at the interpolation site, and an egress boundary around the renderer — are the same controls before and after the migration. Do them first; they are cheaper and they work on both sides of the move.

On the new stack you at least get an in-process lever. Playwright and Puppeteer can refuse the request themselves, which is a reasonable belt to wear with the braces of network policy — in our run this took the count from 3 to 0 while still producing the PDF:

const ALLOW = ['https://cdn.example.com/', 'https://fonts.example.com/'];

await page.route('**/*', (route) => {
  const url = route.request().url();
  if (url.startsWith('data:')) return route.continue();
  if (ALLOW.some((prefix) => url.startsWith(prefix))) return route.continue();
  return route.abort();          // everything else, including 169.254.169.254
});

Use it as a second layer, not the only one. It runs inside the same process an attacker is trying to influence, so a network-level boundary still does the load-bearing work.

When you do migrate

The mechanics of the move — which replacement, how the flags map, and the one feature most replacements silently drop — are covered separately: wkhtmltopdf is archived: the 2026 migration guide. If your PDFs contain form fields a human is supposed to type into, read what replaces --enable-forms before you choose, because that requirement is invisible in your template code and every Chromium-based replacement drops it without a word.

One structural option belongs in the security conversation specifically: if the renderer lives outside your network entirely — a hosted API, ours (snapdok.io) or any of the others — then the SSRF target list shrinks to whatever that vendor's network can reach, and your internal addresses stop being reachable from the rendering step at all. That is a real reduction and worth naming, with the trade-off named just as plainly: your HTML now leaves your network, so you have moved a confidentiality decision rather than deleted a risk. For a document assembled from customer data, that trade deserves an actual decision, not a default.

What to write in the ticket

A shape that tends to survive review, with the blanks being the work:

The short version

The CVE is real, unpatchable, and conditional on your deployment — which is why two distribution security teams scored it far below NVD and published their reasoning. If attacker-influenced HTML reaches your renderer and that renderer can reach anything interesting, treat the 9.8 as accurate and fix the input path today. Then take away the renderer's network, keep local file access off, and put a date on the acceptance. Migrate on the distribution's schedule rather than the scanner's — and do not expect the migration itself to close the SSRF, because we measured the recommended replacement doing exactly the same thing.

Sources pulled 2026-08-17: NVD 2.0 API · Ubuntu CVE tracker · Debian security tracker · FIRST EPSS API · CISA KEV · Debian madison · GitHub API · wkhtmltopdf status page · AppArmor policy · usage docs. The Chromium SSRF measurement is our own and reproducible with the script above.