92439 w a insight unescaped ens name in telegram expiry notification template enables persistent markdown link injection reaching third party users
Submitted on Sep 9th 2026 at 20:04:53 UTC by @asian13 for Audit Competition | ENS
- Report ID: #92439
- Report Type: Websites & Apps
- Report severity: Insight
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/workers
- Impacts:
- Injecting/modifying the static content on the target application without JavaScript (persistent), such as:
- HTML injection without JavaScript
- Replacing existing text with arbitrary text
- Arbitrary file uploads, etc.
- Redirecting users to malicious websites (open redirect)
Description
Brief/Intro
Expiry-notification messages sent via the ENS Telegram bot (parse_mode: 'Markdown') interpolate the event's name field directly into the message text with no escaping of Markdown metacharacters. An attacker can register an ENS name whose label contains a backtick, breaking out of the template's inline-code span and injecting a fully-formed, entity-balanced Markdown link with attacker-chosen display text and URL. The result is delivered as a normal, successfully-parsed message from the real, already-trusted ENS Telegram bot to every user who favourited the malicious name — not just the attacker.
Vulnerability Details
workers/api-worker/src/services/delivery/templates/telegram.ts builds the expiry-alert text as:
const ownerText = payload.isOwner
? `Your domain \`${payload.name}\` will expire in *${daysLeft} days*.\n\n...`
: `The domain \`${payload.name}\` you're watching will expire in *${daysLeft} days*.`
payload.name comes from ExpiryEvent.name, typed as an unconstrained v.string() intypes/events/index.ts, and passed through event-ingestion.ts's collectRecipientsForEventwith no normalization or character filtering anywhere in the pipeline(services/expiry-discovery/indexer.ts performs no sanitization either).
Because the name is wrapped in a single pair of backticks, a name containing its own backtickcloses that code span early. A crafted label such as:
evil` 🎁 [Claim your free ETH refund here](https://evil-attacker-site.example/phish) 🎁 `y
keeps every Markdown entity in the final message balanced (Telegram's Bot API rejects a messageoutright if entities are unbalanced), while injecting a complete, independent [text](url) linkas ordinary message text in between.
**On-chain registerability independently confirmed (not just cited from the known-issues doc):**we ran the project's own e2e/scripts/seed-name.ts against the real V2 ETHRegistry/registrarcontracts (the project's standard Anvil-forked-Sepolia harness — the same contracts the Managerapp itself talks to) and successfully completed a full commit-reveal registration for the labelevil`test — the registrar accepted the raw backtick with no rejection. This removes anydoubt that a malicious label of this shape is registerable, not merely a display-layerassumption.
Impact Details
This is submitted as at minimum Medium ("open redirect"), but we believe it sits closer to High:
- Delivery through an already-trusted, first-party channel. The injected link arrives fromthe real, official ENS Telegram bot, in the same message the user already relies on forlegitimate expiry alerts, directly alongside a genuine "🔄 Renew Now" button.
- One-to-many amplification. A single malicious name registration reaches every account that ever favourites that name, indefinitely, each time an expiry-related event fires — astructurally worse blast radius than a classic single-URL open redirect.
- Directly analogous to the program's own High bucket — "Injecting/modifying the staticcontent on the target application without JavaScript (persistent)" — just delivered via thisin-scope backend's bot-message channel rather than a web page.
- Zero-interaction targeting — a victim never interacts with the attacker; favouriting apublic ENS name is sufficient.
Realistic end-to-end scenario: ENS's user base is by construction 100% wallet-connected. Anattacker links to a pixel-perfect clone of app.ens.domains's "Renew"/"Connect Wallet" flow, worded like the legitimate surrounding copy. The only thing distinguishing the malicious buttonfrom the real one, inside the same trusted-bot message, is the underlying URL — not visible in Telegram's UI without long-pressing the link. This is a realistic, low-friction on-ramp tofund-loss outcomes, though completing that chain requires a separate, out-of-scope phishing page.
This report also completes the missing precondition for the known EXP-INPUT-009 issue: theidentical unescaped payload.name flows into the push-notification template (services/delivery/templates/push.ts, data.url = https://app.ens.domains/${payload.name}),which that known issue's own writeup flagged as requiring "control of, or injection into, the notification payload" as an unconfirmed precondition — now demonstrated end-to-end via the samepipeline. We further confirmed the registrar equally accepts / and ? in a label, so a crafted name can direct a tapped push notification at an arbitrary existing in-app route with arbitraryquery parameters, not merely an arbitrary-looking path segment (we checked every Manager routeconsuming URL search params for a dangerous auto-triggered action; none currently exists, but weflag this for completeness and future-proofing).
Distinct from the already-known EXP-INPUT-005 (scoped by the team to UI display confusion in the Manager/Explorer search input only) — this is a different code path (notificationtemplates) with a materially more severe consequence (phishing-link injection reaching thirdparties).
References
- workers/api-worker/src/services/delivery/templates/telegram.ts
- workers/api-worker/src/services/delivery/templates/push.ts
- workers/api-worker/src/types/events/index.ts
- workers/api-worker/src/services/expiry-discovery/indexer.ts
- workers/api-worker/src/queues/event-ingestion.ts
Proof of Concept
Proof of Concept
-
Standalone reproduction (no monorepo build needed),
poc_telegram_markdown_injection.mjs— a verbatim copy of the realnameExpiryTemplatefunction. Run with plain Node:const maliciousName = 'evil
🎁 [Claim your free ETH refund here](https://evil-attacker-site.example/phish) 🎁y' nameExpiryTemplate({ name: maliciousName, expiryDate: Date.now()+5*86400_000, isOwner: false }).textProduces: "⚠️ Domain Expiration Alert\n\nThe domain
evil🎁 Claim your free ETH refund here 🎁yyou're watching will expire in 5 days." — every Markdown entity pairs correctly. -
Live-confirmed against the real Telegram Bot API: sent the exact message via
POST https://api.telegram.org/bot<token>/sendMessagewithparse_mode=Markdown. Telegram accepted it (HTTP 200) and its own response confirms the parsed entity list includes{"type":"text_link","offset":47,"length":31,"url":"https://evil-attacker-site.example/phish"}— Telegram's own server-side parser independently confirms the render, not just our prediction. Screenshot attached (evidence_telegram_markdown_injection_live.png) shows the rendered message: the alert title, the injected clickable phishing link, and the legitimate "Renew Now" button in the same message. -
On-chain registerability confirmed via the project's own e2e harness:
LABEL='evil`test' STATE=active pnpm --filter @ens-apps/e2e seed:name
Output: "[makeName] ✅ registered evil`test-.eth" — full commit-reveal registration succeeded against the real forked V2 registrar contracts, confirming the backtick-containing label is genuinely registerable, not a display-layer-only assumption.
-
Attack path: attacker registers a name containing the crafted label → any user who favourites the name (an ordinary product action) is added as a notification recipient via
collectRecipientsForEvent→ when the name approaches expiry, every recipient receives the crafted message from the real ENS Telegram bot.