DEV Community

Cover image for The Telegram Export XSS Is Not About Telegram. It Is About Every App That Generates HTML.
jamilxt
jamilxt

Posted on

The Telegram Export XSS Is Not About Telegram. It Is About Every App That Generates HTML.

A bot you have never heard of sends one message with an invisible payload. Someone forwards it into a group of 200,000 people. The payload sits there, dormant, for months. Then one day a compliance officer, a journalist, or a lawyer needs a record of that conversation, so they export the chat to HTML and double-click the file. That is the moment the script runs.

No second click. No warning dialog. Every message on the page, plus sender names, timestamps, and the local file path, can quietly travel to a server on the internet. And the page the victim is staring at can be rewritten into anything the attacker wants.

This is not a hypothetical. It is the stored cross-site scripting vulnerability that security researchers Denis Rostilov and Aleksander Rostilov of ExPatch Vulnerability Research found in Telegram Desktop's HTML export feature, disclosed on September 12, 2026 after Telegram patched it in July. No CVE number was ever assigned. Telegram published no dedicated security advisory. And the vulnerable line of code sat in stable releases for roughly two years and four months.

If you write any software that generates HTML files, whether that is an export feature, a report generator, an invoice PDF pipeline with an HTML intermediate step, or a test-result dashboard written to disk, this bug is worth fifteen minutes of your attention. Not because Telegram is special, but because the mistake is one of the most common in the industry, and the circumstances that made it dangerous are circumstances your app probably shares.

The one line that caused it

Telegram Desktop can export any chat to a set of HTML pages that open in your browser. Inside export_output_html.cpp, the code has a function called SerializeString(). It does exactly what a responsible export function should do: it escapes the five HTML-dangerous characters (<, >, &, ", ') into entities, converts newlines into <br> tags, and hex-encodes ASCII control characters so nothing sneaks through.

The export applied SerializeString() to message text. It applied it to sender names. It applied it to the other user-controlled fields.

It did not apply it to inline keyboard button text. The vulnerable line was simply:

block.append(button.text.toUtf8())
Enter fullscreen mode Exit fullscreen mode

The fix, commit 8457d13a by Telegram Desktop developer John Preston, is one function call:

block.append(SerializeString(button.text.toUtf8()))
Enter fullscreen mode Exit fullscreen mode

That is the entire vulnerability. One field, skipped in a codebase that otherwise does escaping correctly. The same commit also fixed a second injection in the same file, where copy-button content was interpolated into a JavaScript string inside an onclick handler without escaping backslashes and single quotes. Two misses, one file, both shipped for years.

This is the pattern to internalize: escaping failures almost never happen because a team does not know what escaping is. They happen because a template has eleven fields, ten get escaped, and the eleventh is a string that "is just a label." Labels come from users. Everything that crosses into an HTML document from outside must go through the same gate, with no exceptions for fields that seem inert.

Why the app itself was never vulnerable

Here is the part that fooled everyone, including the Telegram client's own rendering.

Telegram Desktop renders messages through its own UI framework, not a browser engine. So when a bot sent button text containing a literal <script> tag, the desktop app displayed it as plain characters. A script tag is just text if nothing interprets it as markup. The researchers padded the payload with invisible Unicode characters, and the button looked completely empty in the app they tested.

The vulnerability only materialized when the export pipeline, which does interpret the text as HTML, wrote the same string into a file. Then a browser opened that file, and the characters became code.

This is a split-brain trust problem, and it is worth naming precisely: the same data passes through two consumers with different rules, and the safe treatment for one consumer is silently assumed to apply to the other. Any place your architecture does this is a candidate for the same bug. A classic modern example is storing user input as JSON and later serializing it into HTML emails, or into a webview, or into a PDF generator that renders HTML. The database did not sanitize it. The original UI escaped it at render time. The new consumer never did.

The delivery chain is the scary part

A stored XSS usually requires the attacker to have write access to the place where the payload lands. This one did not, because of three Telegram-specific mechanics that combined into something much worse:

  • Bots can attach inline keyboards to messages, and the bot fully controls the button text. The Bot API accepts arbitrary Unicode there, HTML tags included.
  • URL-only inline keyboards survive forwarding. A message whose buttons are all web links keeps those buttons when any user forwards it.
  • The bot never needs to join the target group. An attacker sends the crafted message to a collaborator or their own account, someone forwards it into the target group, and the payload is now in that group's permanent history.

From there the payload waits. It waits through admin changes, through members joining and leaving, through the months that pass until someone runs an export. Telegram's bot privacy model, which properly hides ordinary group traffic from bots by default, is completely bypassed here: the bot's script never receives messages through the Bot API, it reads them out of the exported document in the victim's browser, after the fact.

Researchers rated the flaw CVSS 3.1 8.2 (High), with a scope-changed vector, because the impact lands in a context the Bot API was never supposed to reach.

What the script could actually do

When the export file was opened with JavaScript enabled, the injected script ran on page load and could reach everything rendered in that document:

  • Every message in the file, including text, sender names, and timestamps, parsed straight out of the DOM and shipped to an attacker-controlled server.
  • Chat metadata: the chat's name, whether it is private or a group, and its member count.
  • The local file path, via location.href, which leaks the operating system username and directory structure.
  • The page itself, because the script controls the DOM. The researchers' proof of concept replaced the whole export with a fake Telegram-branded "verification" form. The same control can silently alter rendered timestamps, sender names, and message text.

One honest scope note: Telegram Desktop splits long exports into files of 1,000 messages each, so one poisoned file exposes at most its own slice of the chat, not the entire account. That limitation is real. So is the fact that 1,000 messages from a work group or a legal discussion is often exactly the material someone cannot afford to leak or falsify.

The history-tampering angle deserves a paragraph of its own, because it is the part most coverage underplays. Chat exports are used as records. In disputes, investigations, and compliance reviews, an HTML export is evidence. A bug that lets an attacker rewrite what the export displays, without touching Telegram's servers, is a record-tampering vector at the presentation layer. The victim has no reason to doubt what they see: it is their own file, in their own browser, showing their own conversation.

The lingering problem: updating fixes the app, not your disk

Telegram fixed the code in early July 2026, in beta 6.9.4 on July 3 and stable 7.0.1 on July 14. But an export file generated by a vulnerable build is just a text file sitting on someone's disk, and patching the app does not reach into it. Any HTML export created before the fix can still contain a live payload.

ExPatch's recommendations, which are worth copying exactly:

  • Update Telegram Desktop to 7.0.1 or later (or 6.9.4+ on the beta channel).
  • Re-export any chats you exported to HTML before the fix, then delete the old files.
  • If you must keep an old export, open it only with JavaScript disabled.
  • Treat every pre-fix export from a large group as untrusted, since there is no way to eyeball which message carries the payload.

As of the September 12 disclosure, the researchers reported no evidence of real-world exploitation. That is reassuring and mostly irrelevant to the engineering lesson, because the same lesson applies to the next export feature, in whatever app ships it next.

The checklist for your own export features

If you generate HTML from user-controlled data anywhere, here is the checklist I would run against it, built from how this bug actually happened:

  • Inventory every field that reaches the output. Not the obvious ones. All of them, including button labels, alt text, filenames, header attributes, and anything embedded in onclick or other inline event handlers, which need JavaScript-string escaping on top of HTML escaping.
  • Funnel every field through one escaping function. If your codebase has a SerializeString(), the test is simple: search the output-writing code for any string concatenation that bypasses it. In Telegram's case, one grep for .append( in the export file would have surfaced the miss.
  • Do not trust the primary UI as your sanitization proof. The fact that data renders safely in your app means nothing for a second consumer like an exported file or a webview.
  • Assume forwarding, copying, and re-sharing. Ask what happens when user-generated content travels to a context you do not control and comes back later.
  • Version your output files. Old exports outliving a security fix is a structural problem. If you cannot patch files already on disk, at least mark them with the generating version so you can warn users later.
  • Prefer a templating engine with auto-escaping over manual string building, and audit any place you deliberately bypass it.

A transparency note: I have not used Telegram Desktop's export feature myself, and everything here comes from the ExPatch research writeup and the project's public commit history, not from personal experience with the flaw. The checklist above is the general lesson the case teaches, written for the export features most of us maintain somewhere in our own codebases.

The takeaway

Two years and four months is how long one unescaped field lived in a popular desktop application, in a file where the escaping function was already defined and already used ten lines away. Nobody at Telegram lacked security knowledge. The bug shipped anyway, because one field looked like a label instead of like input.

That is the whole story of XSS in 2026. It is not a sophisticated attack class. It survives on the gap between the fields we remember are dangerous and the fields we filed under "just text." Run the checklist on your own exporters before someone else's bot does it for you.


I write about backend engineering, security, and AI infrastructure every week. Subscribe, it is free.

Have you audited the export or report-generation features in your own projects recently? Did you find an unescaped field? Tell me about it in the comments.

If you found this useful, save it and run the checklist above against your codebase this week, before the next chat-export style disclosure lands.

Top comments (0)