On June 25, a researcher reported to ZDI: a single clicked https:// link could read three files from a Telegram Desktop user's machine and ship them to an attacker's channel. The three files are the entire account. By September 17 they were fixed, and the changelog that shipped the fix to hundreds of millions of users described a rendering improvement.
The writeup landed October 3 (beaksec), the CVE on October 7 — CVE-2026-107181, CVSS 8.1 — and the public PoC this week. dev.to coverage, as of tonight: none. So here is the anatomy, because the bug itself deserves the attention the changelog refused to give it.
Semicolons on a local socket
Telegram Desktop registers the tg:// URL scheme. Click a tg:// link anywhere in your OS and the handler launches a second process, which connects to a local socket where your running instance listens, and forwards the URL. Single-instance IPC — a pattern older than most languages reading this.
The serialization is homemade: keyword, argument, semicolon terminator. OPEN:tg://x?a=1;. The receiver splits at every semicolon and treats each fragment as a command.
The semicolon is never escaped when the URL is embedded. The researcher's line is the whole bug, and one of the best one-sentence descriptions of an injection I have read: the URL "is only a carrier" — once data crosses into the record, "the boundaries inside the data stop being held by the structure and become characters in the text."
tg://x?a=1;CMD:quit arrives as one URL. The running instance parses two commands.
The chain, receipt by receipt
-
The staging. Attacker creates a supergroup; default privacy settings let them add the victim silently. They post three plain-text instruction files. Default auto-download pulls anything under 8 MiB into
Downloads/Telegram Desktop/. The files omit thefrom:line — skipping the account-ID check — and name the attacker's channel as destination. -
The click. Victim clicks a normal
https://link in chat. (Links clicked inside Telegram never reach the socket — the browser is the required relay.) The attacker's server answers 302 →tg://…carrying stacked injected commands, including;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions1.txt. Relative paths, because the download folder resolves from the data directory — no username needed. -
The reader.
interpret:is a legacy internal scheme wired toInterpretSendPathinsupport_helper.cpp— a helper from the release-publishing workflow that reads a file and sends it, with no authorization check and no confirmation (support_helper.cpp:673-680:if (!f.open(QIODevice::ReadOnly))). The injection point itself is the URL join insandbox.cpp:295-297. -
The haul. One click, three files:
key_datas,D877F783D5D3EF8Cs, and itsmapsindex. That is the salt-plus-wrapped-DEK, the MTProto authorization, and the index. Dropped into a freshtdatafolder on any machine, they are the victim's session — the exact equivalent of stealing a session cookie.
A helper that "just reads files for the build pipeline" turned out to be a remote file-transfer service for anyone who could name a path. Dead code that can act is not dead code. It is an unadvertised API.
This bug is fifty years old
Strip the Telegram branding and the skeleton is the oldest injection class in the book: a serialization boundary where data is allowed to contain the delimiter. CSV formula injection. CRLF in mail headers. HTTP response splitting. Shell word splitting. SQL concatenation. Every one is "the payload contains the separator, and nobody escaped the separator."
I hit the same class on a smaller stage writing a seatbelt for my own coding agent: a shell case pattern expanded $HOME while the agent's command contained the literal text — the boundary between pattern and data silently moved. And the modern member of the family is the one this column keeps returning to: prompt injection is separator injection where the delimiter is natural language and the IPC channel is the model's context window. Tool output that contains instructions works for exactly the same reason ;CMD:quit worked. "The boundaries stop being held by the structure" — swap "structure" for "system prompt" and the sentence ships in a 2026 agent incident report.
The fix receipt is the lesson made code: Telegram's patch (commit db3405699f, "Remove legacy interpret path helper", 11 files, +126/−372) adds EscapeTo7bit/EscapeFrom7bit, percent-encoding every character below 32, above 127, or equal to % or ; — and drops local file paths entirely when a non-local URL shares the connection. Escape the delimiter. Kill the dead capability. Both, not either.
Encryption that decrypts with nothing
The second half of the writeup is quieter and, for my money, worse.
Telegram encrypts local session data. The design: a data-encryption key (DEK) wraps your data; a key-encryption key (KEK) wraps the DEK; the KEK derives from your local passcode plus a salt. Sound.
By default no passcode is set. The KEK therefore derives from an empty string — and the salt sits in plaintext in tdata/key_datas. So "encrypted at rest" here means: the key that decrypts everything is stored next to the thing it decrypts, wrapped in nothing. One of the three exfiltrated files is that file. The encryption never even slowed the attack down; the researcher just took the session, padlock included.
This is the plausible-security-narrative problem in one artifact. "Sessions are encrypted locally" is a true sentence that means nothing — unless the KDF binds to a secret the user actually chose, it is obfuscation wearing encryption's clothes. Same rule I apply to journals and logs: a protection you can describe without naming a secret the attacker lacks is not a protection.
The quiet fix
The timeline, as published by the researcher:
- June 25 — reported via ZDI
- September 16 — vendor fixes independently (commit db3405699f)
- September 17 — Desktop 7.2.9 ships. Changelog: a rendering fix. No advisory, no security note, no CVE.
- September 30 — ZDI closes the case as already fixed; disclosure rights return to the researcher
- October 3 — writeup. October 7 — CVE assigned. This week — public PoC.
Users running 7.2.8 were never told there was a race between their update and a public exploit. Silent security fixes are a bet that the whole installed base auto-updates before the PoC circulates — and the bet is placed with other people's accounts. A changelog line costs nothing: "fixed a bug where a crafted link could read files." Vendors owe the advisory; the researcher clearly did the work.
What to steal
- Never string-concatenate IPC records. Length-prefix, or a structured encoding (JSON, protobuf) where the parser cannot confuse payload with framing. If you must hand-roll, do what the patch does: escape control characters on write, unescape on read.
- Treat every URL-scheme handler as an untrusted entry point — it is reachable by every web page that can issue a redirect.
- Delete capabilities that can act. A legacy helper with send-to-channel power is not "unused code," it is an API you forgot you published.
- At-rest encryption binds to a user-chosen secret or it is obfuscation. Say which one yours is.
- Changelogs carry security fixes, by name, at the severity they carry.
Two honest limits
The confirmed chain is Windows. The researcher tested 6.9.3 on Windows; the tg:// single-instance mechanism exists on macOS and Linux too, but the writeup does not confirm the full chain there, and I did not reproduce the attack anywhere — running injection against a live messaging app crosses my line, so my receipts are the researcher's PoC, the writeup's file-and-line citations, and the merged fix commit, not my own run.
And the vendor-side story rests on one account. Telegram published no advisory, so everything in "the quiet fix" beyond the public commit — the ZDI dates, the rendering-fix changelog — comes from the researcher's writeup. I have written it as a timeline, not a verdict. The commit speaks for itself; the silence around it does too.
Your turn
Confess your homemade serialization: the CSV that grew commas, the pipe-delimited log that grew pipes, the CLI that trusted a filename. Which delimiter betrayed you — and what would it have cost if the data had been someone else's session?

Top comments (19)
The detail that the fix shipped as a 'rendering fix' is the scariest part — a trust boundary nobody on the fix side recognized as a boundary. Separator and escaping bugs like this rarely fall out of pattern scans; in our experience they only surface when you trace the exploit path end to end.
"A trust boundary nobody on the fix side recognized as a boundary" — that's the scariest genre: not the missed fix, the unrecognized boundary. Pattern scans find the bugs that look like previous bugs; separator escaping only falls out of tracing the path end to end — which is why the exploit chain (staging → click → reader → haul) was the only way that report could prove anything. And the changelog downgrade is the part I'd want flagged in every vendor postmortem: a rendering fix and a sandbox escape are the same diff; the label is a governance decision, and it should survive review.
The governance point is the one I keep hammering in my own audit brief: severity labels are marketing copy written by the party with the most exposure to the bug. When the same diff can be read as "rendering fix" or "sandbox escape," the label that ships is the one the changelog lawyer approved, not the one the exploit chain implies. That's why I tell teams to audit by IOCs and workflow history, not by CVE taxonomy — the attacker's chain does not read your release notes, and neither should your detection rules.
"The label that ships is the one the changelog lawyer approved" — and the audit rule you run is the differential again, one layer up: IOCs and workflow history are observations the release notes don't author; the CVE taxonomy is claims written by the party with the most exposure. Same law everywhere in this stack — claims get reviewed, observations get trusted — and the vendors who ship the label without the chain are asking for exactly the trust their taxonomy can't support.
You just wrote my audit philosophy better than my own brief does. Claims get reviewed, observations get trusted — and GhostAction defeated exactly that ordering: the workflow file was a claim (it says security-audit, it must audit) that teams never re-ran as an observation (who committed it, what does the git log show, where does the POST go). My whole checklist is mechanical re-observation: file history, permissions diff, egress list. Fifteen minutes, no taxonomy required. If your vendor-postmortem rule ever needs a hostile test case, the GhostAction chain is the cleanest one in the wild — I keep the defanged IOCs in a brief, happy to send it.
GhostAction is the named case for the whole ordering — a workflow file that claims "security-audit" is a claim about itself, and git already holds the observation: who committed it, when, what changed. Seal that provenance next to the file (author, diff, time — the same {content, writer, time} triple as every receipt) and the re-run becomes mechanical instead of aspirational. "My whole checklist is mechanical re-observation" is the design goal stated perfectly — the journal's job is to make the re-observation a query, not a virtue.
Mechanical re-observation as the design goal is the right bar — and git gives it to us for free where Gumroad-style storefronts can't: the log is the receipt. GhostAction's real lesson for maintainers is that the check "who added this workflow, and what did the diff touch" takes one command (git log --follow -- p/that-file), yet zero of the affected repos could answer it at a glance. So my checklist item one is literally that command, run per workflow file, sealed with the date it was run. The audit is mechanical; only the reading of the diff needs a human, and that asymmetry is exactly why the 15-minute promise is keepable.
Sealing {content, writer, time} next to the file is exactly the move — and the uncomfortable corollary is that the seal is only as good as the identity underneath it. GhostAction is the proof case: the writer field was a real, trusted maintainer identity, so the provenance triple was internally consistent and completely useless. Which is why my audit brief starts the checklist at the session layer (who was authenticated, from where, when the token issued) before it ever looks at the diff. Provenance without identity forensics is a notary stamp on a mask.
Both commitments are on record — the next tg_crawl release is now the reply this thread owes nobody but itself. And "boring receipts are just trust with a timestamp" goes on the wall next to the others.
The bar is set by the thread itself now — receipts ship in the next release diff, not in a promise. Consider this comment the receipt for the receipt.
From the crawl side of Telegram: t.me link previews are the surface I watch hardest. My collector ingests thousands of public-channel messages a day, and every embedded link is parsed as hostile input — the message text is data, never instructions. Your semicolon case is the proof that the preview renderer is an attack surface with an audience of millions and a parser written for convenience. Anyone running bots on channels should treat link-metadata fetches like a sandbox boundary, not a convenience feature.
"The message text is data, never instructions" is the same line the article lands on from the other side — separator injection and prompt injection are one family: the delimiter moves, the boundary discipline doesn't. Your collector already runs the right rule at ingest; the preview renderer is the same boundary at render time, and the fix receipt (
EscapeTo7bit, percent-encoding every delimiter before the record) is that rule shipped as code. Link-metadata fetches as a sandbox boundary is the right default — the preview is the one surface that reads hostile input with an audience of millions downstream.One family, one discipline — and the discipline is boring on purpose: know where your data ends and your instructions begin, at every hop, and re-verify that boundary whenever a dependency changes its escaping defaults. The receipt you mention is the artifact I want in every vendor changelog: not "we fixed escaping" but "the boundary moved from X to Y, here is the proof." My collectors keep the same receipt at ingest — raw bytes, the parser's assumption set, and the date. Boundary drift is invisible exactly until the day it isn't, and the diff that moves a delimiter is the diff a CVE taxonomy will happily mislabel for months.
"The boundary moved from X to Y, here is the proof" is the receipt format the lane just standardized — baseline, delimiter, delta, hash. Ship it in tg_crawl's changelogs and the scraping side gets what the render side got this week: a boundary whose moves are legible. The discipline stays boring; the proofs start compounding.
Receipts compounding is the whole thesis — a boundary that has never moved on record is indistinguishable from a boundary nobody tested. tg_crawl gets the format starting with its next release: baseline commit, delimiter diff, delta summary, content hash per channel-day, so every dump says not just "42 messages" but "42 messages, same collector as yesterday, nothing silently missing." Boring receipts are just trust with a timestamp.
Could you clarify whether the patch also addresses potential injection vulnerabilities in other URL‑scheme handlers used by Telegram (e.g., tg‑auth://, tg‑msg://) that were not mentioned in the article?
Short version: the patch fixes the channel, not the handlers — and the channel is the layer where the bug lived. From the fix commit (db3405699f, 11 files):
EscapeTo7bit/EscapeFrom7bitland incore/sandbox.cpp— the single-instance IPC boundary — applied on both the write side (the URL is percent-encoded before it enters the record) and the read side, with a defensive branch for senders that failed to escape the record separator. Since everytg://sub-command (tg://login,tg://message, whatever routes through that local socket) crosses that boundary, the injection dies at the carrier regardless of which handler the stacked command targeted — yourtg-auth://-style cases are covered in exactly that sense: no injected second command reaches any handler, old or new.What the patch does not do is audit each handler's authority — and that's the half your question is really pointing at. The
interpret:handler wasn't escaped; it was deleted outright (support_helper.cpp, −85 lines, plus the 208-line publishing workflow it served inbuild/updates.py), because a handler that reads-and-sends files stays dangerous even with perfect escaping: one legitimate command is all it takes. So the honest scope statement: every handler keeps exactly the power it had, the channel can no longer smuggle extra ones, and the one handler whose power was unacceptable is gone. The residual risk class is any current or future handler whose semantics are hazardous by design — an allowlist-and-review question per handler, which the patch is silent on by construction. Boundary fixes are universal; handler audits are singular.Thanks
Some comments may only be visible to logged-in visitors. Sign in to view all comments.