You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ENS ens-metadata-service avatar/header image fetch is a self-referential redirect loop that survives fix PR #197 (DoS/amplification on metadata.ens.domains)
Expected (recursion capped at 50 so the run terminates; in production the bound is the 7s fetch /
15s response timeout):
requests received by metadata service for ONE victim request: 51
blocked by x-ens-internal/origin guard (0 = guard bypassed): 0
max recursion depth reached (harness cap = 50): 50
request amplification factor: 51x
Stage 2 - novelty proof (does the open fix PR #197 stop it?)
node ens-avatar-selfref-poc-survives197.js
Expected:
=== NOVELTY PROOF: does PR #197 stop the redirect-follow loop? ===
A) master (unpatched) : 51 requests, 0 blocked-by-guard, depth 50
B) #197 applied : 51 requests, 0 blocked-by-guard, depth 50
C) control (trailing-dot URI) : 1 requests, 0 blocked-by-guard, 1 library-denylist-blocked
Amplification: A=51x B=51x (MAX_DEPTH+1 = 51)
B still amplifies => SURVIVES #197: true
C (library-denylist-blocked) = 1 => harness models #197 correctly: true
What the two stages prove
Stage 1: one victim request fans out to 51 internal self-requests, and the recursive-call guard
(blockRecursiveCalls) is fully bypassed (0 blocked).
Stage 2: applying PR #197 (which only expands the ens-avatar initial-URI urlDenyList for the
trailing-dot form and canonicalizes the queryNFT guard) changes nothing - it still amplifies 51x.
The control (C) confirms PR #197 DOES block the trailing-dot self-host direct URI, which proves the
harness faithfully models PR #197. Therefore stage 2 is a genuine "the fix does not stop my bug"
result.
Both PoCs are local, no-external-call harnesses; they model the exact production functions
(abortableFetch + blockRecursiveCalls + ens-avatar urlDenyList) plus the attacker redirect
server. No traffic is sent to the real service.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
ENS - ens-metadata-service avatar/header image fetch is a self-referential redirect loop that survives the open fix PR #197 (request amplification / DoS on metadata.ens.domains)
Verified carefully before writing anything up. The code is checked against the exact repo state
(master) and the exact npm deps it pins (node-fetch@2.7.0, ssrf-req-filter@1.1.1). The PoC is a
local, no-external-call harness that faithfully replicates the two relevant production functions
(abortableFetch + blockRecursiveCalls) plus the ens-avatar urlDenyList behaviour and the
attacker redirect server. I did NOT test against the live service (the program forbids live DoS
testing against project assets).
I also re-checked the repo for novelty: the open fix PR #197 and the open disclosure #198. Both are
analyzed below and neither covers this mechanism. A dedicated PoC proves the exploit survives
applying PR #197. Full evidence is in this report.
What this is about
The avatar/header image endpoints of metadata.ens.domains fetch the image URL stored in a name's
avatar/header text record. That fetch follows HTTP redirects, and nothing stops a redirect from
pointing back at metadata.ens.domains itself. The result is a self-referential loop: one victim
request makes the service fetch its own endpoint repeatedly until a timeout fires.
Concretely, an attacker who owns any name sets its avatar text record to a URL under their control
that 302-redirects to https://metadata.ens.domains/mainnet/avatar/<that-name>. Every request to
that avatar endpoint re-enters the same handler, which re-reads the same text record, which fetches
the same attacker URL, which 302s back in. Loop. Each hop performs real work (JSON-RPC avatar
resolution, JSDOM/DOMPurify sanitisation, rate-limiter accounting), so the amplification is not just
request count; every hop consumes the service's own budget and its shared rate-limit quota.
Affected components: src/service/avatar.ts (AvatarMetadata.getImage, lines 64, 107-115),
src/utils/abortableFetch.ts, src/utils/blockRecursiveCalls.ts, src/index.ts:71
(middleware mount), @ensdomains/ens-avatar, and the pinned ssrf-req-filter@1.1.1.
Impact: availability / request amplification on the metadata API. DoS/availability = Medium
(web track).
Summary
The service is normally protected against re-entering itself in three ways:
src/index.ts:71 mounts blockRecursiveCalls on every route. It rejects any request carrying the
x-ens-internal header or a same-host Origin/Referer.
src/service/avatar.ts:64 passes urlDenyList: ['metadata.ens.domains'] to the ens-avatar
library, so the library refuses an avatar URI whose host is metadata.ens.domains.
abortableFetch uses ssrf-req-filter to block private/reserved IPs.
None of these covers the avatar/header image download:
abortableFetch (src/utils/abortableFetch.ts) is node-fetch with default redirect: 'follow'.
When the avatar URL 302s to metadata.ens.domains, node-fetch follows it and the re-entered
request carries NO x-ens-internal, NO Origin, NO Referer. So blockRecursiveCalls passes.
ssrf-req-filter@1.1.1 only rejects private/reserved IP ranges (range !== 'unicast').
metadata.ens.domains is a public hostname, so it is allowed. The
stopPortScanningByUrlRedirection: true option that abortableFetch passes is not even read by
this version of the library.
The urlDenyList is applied by the ens-avatar library only to the INITIAL avatar URI host. The
library returns the image URI to the service, and the service fetches it itself via
abortableFetch; redirect destinations are never re-checked against the deny list. This is the key
asymmetry: ens-avatar's OWN internal fetcher validates the deny list, but the service does not use
that fetcher for the final image download.
The queryNFT path (same codebase) has a socket-level SELF_HOST_DENYLIST + INTERNAL_HEADER
tagging guard (createGuardedAgent). The avatar/header image path does not.
Root cause
The final image fetch in avatar.ts:107-115 is the only network hop that carries the actual image
bytes back to the victim, and it is the one hop where the deny list, the internal-header tag, and the
self-host socket guard are all absent. A redirect that points back into the service re-enters the
full Express pipeline each hop.
Why this is NOT the trailing-dot bug being fixed by PR #197
PR #197 ("canonicalize host in self-referential-fetch denylists") fixes the TRAILING-DOT FORM of a
DIRECT self-host avatar URL:
queryNFT.ts: the guarded agent compares canonicalHost(host).
Adds src/utils/canonicalHost.ts + tests.
PR #197 does NOT touch src/utils/abortableFetch.ts at all. It only expands the ens-avatar library
deny list that is checked against the INITIAL avatar-URI host, and canonicalizes the queryNFT agent
comparison.
The redirect-follow re-entry in this report does not involve a trailing dot and does not rely on the
initial-URI deny list ever being checked for a self-host. The initial avatar URI is the attacker's
own URL (not metadata.ens.domains), so the library deny list passes regardless. The redirect
target (metadata.ens.domains) is fetched by abortableFetch, which never re-checks the deny list
on a redirect hop.
Result: applying PR #197 changes nothing about this exploit. The PoC below demonstrates exactly that.
Attack chain
Attacker owns attacker.eth, sets its avatar text record to https://attacker.example/redir,
which responds 302 Location: https://metadata.ens.domains/mainnet/avatar/attacker.eth.
A victim (the ENS app rendering avatars, or any consumer of the metadata API) requests
https://metadata.ens.domains/mainnet/avatar/attacker.eth.
AvatarMetadata.getImage resolves the text record, gets https://attacker.example/redir, and
calls abortableFetch(avatarURI, { timeout: 7000 }).
The ens-avatar library checks the urlDenyList against the initial URI host
(attacker.example) and allows it. It returns the image URI to the service.
abortableFetch (node-fetch) follows the 302 to metadata.ens.domains. No internal header /
Origin / Referer is sent, so blockRecursiveCalls passes; the host is public, so
ssrf-req-filter passes; the deny list was already consumed on the initial URI.
The re-entered request re-resolves the same text record, fetches the same attacker URL, 302s
again. Loop until the 7s fetch timeout (or the 15s response timeout) aborts the outermost call.
Proof of Concept
Two local, dependency-free harnesses that replicate the exact production control flow
(abortableFetch + blockRecursiveCalls + ens-avatar urlDenyList), plus the attacker redirect
server. They run against local servers; no external/network dependency, no rate to the real service.
PoC 1 - reproduction (unpatched)
poc/ens-avatar-selfref-poc.js
Run: cd poc && node ens-avatar-selfref-poc.js
The recursion depth is capped at 50 only so the run terminates; in production the bound is the 7s /
15s timeout.
requests received by metadata service for ONE victim request: 51
blocked by x-ens-internal/origin guard (0 = guard bypassed): 0
max recursion depth reached (harness cap = 50): 50
request amplification factor: 51x
The blocked ... = 0 line is the point: the recursive-call guard does not stop a redirected-in
request. The 51x is one victim request; with the production timeout bound and a fast attacker
redirect server the hop count per request is higher, and the attacker can fan out many victim
requests concurrently.
PoC 2 - novelty proof: it survives PR #197
poc/ens-avatar-selfref-poc-survives197.js
Run: cd poc && node ens-avatar-selfref-poc-survives197.js
It runs the SAME exploit under three configurations and reports the comparison:
A) master (unpatched): urlDenyList = ['metadata.ens.domains'].
B) #197 applied: urlDenyList = ['metadata.ens.domains', 'metadata.ens.domains.'].
C) control: the initial avatar URI is set to the trailing-dot self-host
metadata.ens.domains. to confirm the harness faithfully models PR #197.
=== NOVELTY PROOF: does PR #197 stop the redirect-follow loop? ===
A) master (unpatched) : 51 requests, 0 blocked-by-guard, depth 50
B) #197 applied : 51 requests, 0 blocked-by-guard, depth 50
C) control (trailing-dot URI) : 1 requests, 0 blocked-by-guard, 1 library-denylist-blocked
Amplification: A=51x B=51x (MAX_DEPTH+1 = 51)
B still amplifies => SURVIVES #197: true
C (library-denylist-blocked) = 1 => harness models #197 correctly: true
Interpretation:
A and B both amplify 51x and both have 0 blocked-by-guard. Applying PR #197 changes nothing.
C shows the trailing-dot direct self-host IS blocked by the library deny list, which proves the
harness correctly models PR #197. Therefore the fact that B still amplifies is a genuine
"the fix does not stop my bug" result, not a harness artifact.
Impact
Request amplification / self-DoS on metadata.ens.domains: a small number of victim requests spawns
a large number of internal self-requests, each doing real work (RPC resolution, sanitisation,
rate-limit accounting) against the service's own budget. Because the self-requests originate from the
service's own egress (seen as the server/edge IP), they also consume the shared rate-limit quota,
further degrading service for legitimate users.
Honest framing:
I am NOT claiming data theft or RCE. ssrf-req-filter does block private/reserved ranges, so this
path does not reach internal addresses.
I am NOT claiming XSS. Per the program, XSS on static websites like metadata.ens.domains is rated
low.
The defensible impact is availability: taking down / degrading the metadata API via amplification,
which maps to Medium.
What I am NOT claiming
Not an SSRF to internal addresses (private ranges are blocked by ssrf-req-filter).
Not XSS-through-metadata (program rates metadata.ens.domains XSS as low).
Not a data leak of server-side secrets.
Not the trailing-dot self-host bypass (that is PR #197, which I am not re-reporting).
Not the IP-misclassification / JSDOM-CPU-DoS class from disclosure #198.
Novelty
PR #197 (open, Ghostiemoh) fixes the trailing-dot form. It patches the ens-avatar urlDenyList
(initial-URI only) and canonicalizes the queryNFT guarded-agent comparison. It does not touch
abortableFetch redirect handling. The PoC proves this mechanism still amplifies 51x after #197.
Disclosure #198 (open, Julik000) reports SSRF bypass via IPv6 site-local/NAT64 ranges and a
CPU/memory DoS via JSDOM instance creation. Different root cause, different triggering mechanism.
GH issue #191 (open, Louw115) is an unlabeled security report; no public text to compare against.
The queryNFT path received the self-host socket guard (createGuardedAgent); the avatar/header
image path is the one place it is missing. That asymmetry is the finding.
I could not verify Immunefi's private submissions database, so I cannot rule out a prior private
report of this exact redirect-follow mechanism. The PoC and the #197/#198 distinction are the
strongest evidence I can provide for non-duplication.
Version eligibility
Present in the current repo state on master and in the deployed service. The unguarded
abortableFetch for the avatar/header image path predates the queryNFT fix (83b3bf6,
2026-06-26) and is unchanged in PR #197, so the vulnerability is live in the deployed version.
Recommendation
Route the final avatar/header image fetch through the same guarded agent as queryNFT: a
socket-level self-host denylist (refuse any connection to metadata.ens.domains regardless of
redirect), plus INTERNAL_HEADER tagging so blockRecursiveCalls rejects a loop that comes back
in.
Apply the deny list on every redirect hop (as the ens-avatar library's own fetcher does), not just
on the initial URI.
Do not rely on ssrf-req-filter@1.1.1's stopPortScanningByUrlRedirection option (the pinned
version ignores it) and follow redirects manually with per-hop validation instead of node-fetch's
automatic redirect: 'follow'.
References
src/service/avatar.ts:64 - urlDenyList: ['metadata.ens.domains'] (initial-URI only; PR #197
changes this to include the trailing-dot form)
src/service/avatar.ts:107-115 - abortableFetch(avatarURI) with timeout: 7000 (the
redirect-following image fetch; unchanged by PR #197)