Penetration TestJan Kahmen7 min read

CVE-2026-90848: One Missing Escape in the AusweisApp and Its Consequences

An unescaped eID server error message in AusweisApp 2.5.4 led to XSS, manipulated dialogs and crashes. Root cause, attack and fix.

CVE-2026-90848: One Missing Escape in the AusweisApp and Its Consequences

The AusweisApp is the German federal government's official eID client, used by citizens to identify themselves online with their national ID card. We examined version 2.5.4 with static analysis, around 80 million fuzzing iterations under AddressSanitizer and live attacks from a purpose-built malicious eID server. We first ran the attacks in a container environment and then verified them on real hardware.

The core of the app held up. Cryptography, the EAC state machine, origin checks and the parsers for card and server data withstood every attack. What we found was a single root cause: an error message from the eID server was passed on as HTML without escaping. It led to JavaScript execution in the victim's browser (CVE-2026-90848, CVSS 3.1 6.1 by our rating, our advisory TP-2026-113), a manipulable app dialog with a zero-click tracking beacon, and a crash through the bundled Qt SVG library. We found the flaw and reported it to Governikus, who fixed it in 2.5.5 and 2.6.0. AusweisApp users should update to 2.6.0 or later.

Why a Desktop App Is a Web Application

The AusweisApp is a native C++/Qt client with no WebView and no JavaScript engine, so classic XSS seems out of the question. That impression is wrong. Under BSI TR-03124, a website starts the online ID function by sending the browser to a local service run by the app:

http://127.0.0.1:24727/eID-Client?tcTokenURL=https://provider.example/token

The app fetches the TC token, connects to the eID server named in it and talks to that server over the SOAP-based PAOS protocol. At the end it redirects the browser back or serves it an HTML error page. Two facts matter: the client of this service is the victim's browser, and the calling website chooses the eID server freely via tcTokenURL.

Attack flow: the link opens the AusweisApp's local service, the attacker's server replies with an HTML error message that arrives as a script in the browser and as an image in the app dialog

Attacker Model: Your Own eID Server Is Enough

A genuine eID server needs an authorization certificate (CVC) issued under BSI control, otherwise the ID card releases no data. That safeguard works. The interesting part is what happens before it is checked.

The binding between the TLS certificate and the authorization certificate only applies once both are present (CertificateChecker.cpp:70-72). On the server's first message they are not, so an ordinary Let's Encrypt certificate is enough. That first message may be an error: the app sends StartPAOS, the server replies with an error result, and the state machine jumps into the error path without card contact, PACE or certificate validation. The error text comes from the freely chosen ResultMessage field.

The attacker therefore needs a domain with a TLS certificate, an HTTPS server and a link the victim opens. No ID card, PIN, authorization certificate or network position is required.

The Root Cause: A Missing toHtmlEscaped()

The app knows that server texts can be dangerous. For 35 error codes it shows its own text instead of the server message (GlobalStatus::isMessageMasked()). One code is missing from that list: Paos_Generic_Server_Error, which the server triggers via the minor result dp#communicationError. For this code the app returns the server text directly, and from there it flows unescaped to two places: the HTML error page for the browser and the app's Qt dialog.

AuthModel.cpp shows how narrow the gap is. The file escapes the transaction info correctly on line 251 but not the server message on line 147:

// src/ui/qml/AuthModel.cpp:251 (v2.5.4), correct
didAuthenticateEAC1->getTransactionInfo().toHtmlEscaped();

// src/ui/qml/AuthModel.cpp:147 (v2.5.4), missing
errorDescription += QStringLiteral("<br/>(%1)").arg(externalInfo);

Impact 1: JavaScript in the AusweisApp Origin

The attacker sets the RefreshAddress in the TC token to a foreign origin. The same-origin check fails and the app answers the browser with an error page. The template engine (Template.cpp) inserts the error description as is. The attacker server's response:

<StartPAOSResponse>
  <Result>
    <ResultMajor>http://www.bsi.bund.de/ecard/api/1.1/resultmajor#error</ResultMajor>
    <ResultMinor>http://www.bsi.bund.de/ecard/api/1.1/resultminor/dp#communicationError</ResultMinor>
    <ResultMessage>&lt;script&gt;document.title='XSSPROOF'&lt;/script&gt;</ResultMessage>
  </Result>
</StartPAOSResponse>

The XML parser resolves the entities, the app returns HTTP 400 with Content-Type: text/html, and the script runs in the origin http://127.0.0.1:24727.

We do not want to overstate the impact. The origin has no cookies and no session, and read requests to the service were already possible thanks to Access-Control-Allow-Origin: *. What the attacker gains is trust: the address bar shows the federal ID app running locally, not the attacker's domain. A prompt there to enter the PIN "for confirmation" or to download software looks official. The details are in our advisory TP-2026-113.

Impact 2: Manipulated App Dialog and Zero-Click Beacon

The same text reaches the app's result dialog. No JavaScript runs there, but Qt text elements understand an HTML subset, and the GText component uses Text.AutoText. With a ResultMessage containing bold text, a link and an embedded image, our logging server recorded about 67 milliseconds after the server response:

[BEACON-HIT] GET /beacon-resultmsg.png UA=Mozilla/5.0

Nobody had clicked. The attacker learns the IP address, the time and that the AusweisApp is in use right now. They can render bold text, colors, lists, links and images, including base64-encoded SVG. A convincing fake success screen is not possible: the "Authentication failed" heading, the red error icon and the app's prefix stay visible. Below them, however, there can be green text with a check mark and "Verified". Someone who only looks at that and clicks the link still falls for it.

The app opens links with Qt.openUrlExternally(). On Android and iOS it registers itself as the handler for eid://, so an injected link starts a new ID process to the attacker with a single tap. We confirmed this in the source code; live we only verified the detour through the browser on desktop.

Impact 3: Crash Through the Bundled Qt SVG Library

Version 2.5.4 bundles Qt 6.10.2, which is affected by CVE-2026-6210 in Qt SVG. The injected img tag is the only way an attacker can feed the app an SVG. Our PoC, a line element that references itself as a marker, triggers infinite recursion until the stack guard page (SIGSEGV). We confirmed the crash against the library in version 6.10.2 and on real hardware. The path yields no write primitive, and we rule out code execution along this route.

What Held Up

No attack could read personal data from the ID card remotely. That boundary is enforced by the chip, not the app. The EAC state machine does not allow steps to be skipped or reordered, and requested rights are intersected with the authorization certificate. Around 80 million fuzzing iterations under AddressSanitizer, from the parsers for CVC, ASN.1, PAOS XML and card files to the image formats, found no memory error on a path reachable in the default build. The TR-03124 origin binding withstood 45 variants including Punycode, IPv6 notations, homoglyphs and control characters. Request smuggling, XXE and command-line parameter injection were not possible, and the updater pins the certificates of the Governikus servers.

The Fix by Governikus

Governikus responded quickly. Version 2.5.5 shipped on August 14, 2026, seven days after our report, and escapes every value in the template engine:

 // src/network/Template.cpp
-	mContext[pKey] = pValue;
+	mContext[pKey] = pValue.toHtmlEscaped();

Version 2.6.0 from October 6, 2026 additionally escapes the external information at the source in GlobalStatus.cpp, which also protects the app dialog against the ResultMessage. It also brought Qt 6.11.1, which closes CVE-2026-6210, and a time limit on the pairing code for "smartphone as card reader". Governikus thereby fixed impacts that were not part of our report.

What We Take Away

Our first audit rounds had ruled out XSS. The reasoning sounded solid: every place that echoes the request URL into HTML is protected by Qt's URL encoding, and there is no WebView in the source tree. Both were true, and the conclusion was still wrong. The server response took a different path into the same template, and the app needs no browser because it serves its HTML to the user's browser.

Two rules follow for every penetration test and code review. Any local HTTP service that serves text/html is a web application, whatever language it is written in. And when you find an unescaped value, the question is not what it can do at that spot, but where else it flows. The OWASP XSS Prevention Cheat Sheet applies to native clients too.

Timeline

DateEvent
2026-08-07Report to Governikus (PGP)
2026-08-14AusweisApp 2.5.5 fixes the browser error page
2026-09-15CVE-2026-90848 published
2026-10-06AusweisApp 2.6.0 fixes the app dialog and updates Qt to 6.11.1
2026-10-20Publication of this article