feat(arsenal): subdomain takeover detection built-in (#134)
* feat(arsenal): add subdomain-takeover classifier + fingerprint table Pure, I/O-free detection logic split into src/arsenal/takeover.ts so it is fully unit-testable without DNS or network: - TAKEOVER_FINGERPRINTS: curated CNAME + unclaimed-resource-body signatures for 15 high-frequency services (S3, GitHub Pages, Heroku, Azure, Fastly, Shopify, Netlify, ...) - classifySubdomainTakeover(): conservative verdict (confirmed / potential / none) driven by a dangling CNAME and/or a service body fingerprint; no cross-service false matches - renderTakeoverReport(): human-readable output block * feat(arsenal): register subdomain_takeover_check built-in + bump tool count 108->109 - New keyless, self-contained built-in tool 'subdomain_takeover_check' (recon): resolves the CNAME, checks whether the target still resolves (dangling), fetches the live body via the scope-gated targetFetch, and classifies via classifySubdomainTakeover. Emits a real finding. - Added to the Recon operator's default toolkit so the swarm can reach it (operator-toolkits coverage invariant). - Bumped the advertised arsenal size 108 -> 109 in lockstep across the count-honesty test, README, and verify-claims (73 adapters + 36 built-in). * test(arsenal): cover subdomain_takeover_check (classifier matrix + mocked DNS/fetch e2e) - classifySubdomainTakeover decision matrix: confirmed via body fingerprint, confirmed via dangling nxdomain-prone CNAME, potential (known service, no confirmation), potential (dangling unknown), none (no CNAME / resolves clean), and no cross-service false matching - renderTakeoverReport output shape - end-to-end handler test with DNS + fetch mocked, driving the confirmed S3 path
This commit is contained in:
7 files changed
+374
-5
No files matched your search
@@ -191,7 +191,7 @@ The framework is an 8-operator kill chain, and this table won't blow smoke about
|
|||||||
| Re-derivable measurement (`verify-claims`) | ✅ Stable | every headline recomputes from committed artifacts |
|
| Re-derivable measurement (`verify-claims`) | ✅ Stable | every headline recomputes from committed artifacts |
|
||||||
| Recon engine | ✅ Stable | drives nmap / DNS / HTTP / fingerprinting; every finding traces to real tool output |
|
| Recon engine | ✅ Stable | drives nmap / DNS / HTTP / fingerprinting; every finding traces to real tool output |
|
||||||
| Mission engine + War Room + Op Admiral | ✅ Stable | keyless through a connected local agent |
|
| Mission engine + War Room + Op Admiral | ✅ Stable | keyless through a connected local agent |
|
||||||
| Arsenal, MCP server, HTTP API | ✅ Stable | 35 built-in tools by default; 108 with the opt-in `T3MP3ST_FULL_ARSENAL` (+73 adapters, with dangerous/catalog-only drivers — metasploit, hydra, pacu, frida — behind narrow approved paths rather than generic execution) — both counts re-derive via `verify-claims`. `security_recon` over MCP |
|
| Arsenal, MCP server, HTTP API | ✅ Stable | 36 built-in tools by default; 109 with the opt-in `T3MP3ST_FULL_ARSENAL` (+73 adapters, with dangerous/catalog-only drivers — metasploit, hydra, pacu, frida — behind narrow approved paths rather than generic execution) — both counts re-derive via `verify-claims`. `security_recon` over MCP |
|
||||||
| Egress-scope containment | ✅ Stable (on by default) | once a mission target is set, built-in networked tools refuse off-scope public hosts — not the target/subdomains, not loopback/private (`SCOPE DENIED`) — a tightened default, not a bare tool runner |
|
| Egress-scope containment | ✅ Stable (on by default) | once a mission target is set, built-in networked tools refuse off-scope public hosts — not the target/subdomains, not loopback/private (`SCOPE DENIED`) — a tightened default, not a bare tool runner |
|
||||||
| Coordinated-disclosure pipeline | ✅ Stable | OSV novelty + live PoC + refuter panel + CVSS; drafts only, a human sends |
|
| Coordinated-disclosure pipeline | ✅ Stable | OSV novelty + live PoC + refuter panel + CVSS; drafts only, a human sends |
|
||||||
| White-box source analysis | ⚠️ Experimental | Multi-language ingest via web-tree-sitter (Python/JS/TS/Go/Java/C/C++); Python retains its regex parser, while other languages fail open to no extracted blocks; multi-model decomposition costs more tokens, not fewer |
|
| White-box source analysis | ⚠️ Experimental | Multi-language ingest via web-tree-sitter (Python/JS/TS/Go/Java/C/C++); Python retains its regex parser, while other languages fail open to no extracted blocks; multi-model decomposition costs more tokens, not fewer |
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ check('VERIFY gate implemented', /VERIFY gate|never appeared in tool output/.tes
|
|||||||
check('REFLECT gate implemented', /REFLECT gate/.test(bench), 'forced mid-run pivot');
|
check('REFLECT gate implemented', /REFLECT gate/.test(bench), 'forced mid-run pivot');
|
||||||
|
|
||||||
// ── CLAIM 4: capability breadth ─────────────────────────────────────────────
|
// ── CLAIM 4: capability breadth ─────────────────────────────────────────────
|
||||||
console.log('\nCLAIM 4 — capability: 108 tools, 8-operator kill-chain');
|
console.log('\nCLAIM 4 — capability: 109 tools, 8-operator kill-chain');
|
||||||
// DISTINCT tools = external-binary adapters (catalog.ts TOOL_ADAPTERS id:) + custom
|
// DISTINCT tools = external-binary adapters (catalog.ts TOOL_ADAPTERS id:) + custom
|
||||||
// built-in/external tools (index.ts top-level name:). NOT a name:/id: regex count
|
// built-in/external tools (index.ts top-level name:). NOT a name:/id: regex count
|
||||||
// (that double-counts each tool's id+name AND every parameter name).
|
// (that double-counts each tool's id+name AND every parameter name).
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ describe('arsenal count honesty (advertised = real registered surface)', () => {
|
|||||||
// README / verify-claims headline together — that is the point of the lock.
|
// README / verify-claims headline together — that is the point of the lock.
|
||||||
expect(
|
expect(
|
||||||
total,
|
total,
|
||||||
`arsenal size drifted from the advertised 108 (adapters=${TOOL_ADAPTERS.length}, ` +
|
`arsenal size drifted from the advertised 109 (adapters=${TOOL_ADAPTERS.length}, ` +
|
||||||
`built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` +
|
`built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` +
|
||||||
'update the README / verify-claims headline to match',
|
'update the README / verify-claims headline to match',
|
||||||
).toBe(108);
|
).toBe(109);
|
||||||
expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate
|
expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Coverage for the `subdomain_takeover_check` built-in tool.
|
||||||
|
* - The pure classifier (classifySubdomainTakeover) holds all decision logic and is tested directly.
|
||||||
|
* - One integration test drives the real tool handler with DNS + fetch mocked, proving the wiring
|
||||||
|
* (CNAME chain → dangling check → live-body fingerprint → verdict/finding).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { classifySubdomainTakeover, renderTakeoverReport, TAKEOVER_FINGERPRINTS } from '../arsenal/takeover.js';
|
||||||
|
|
||||||
|
describe('classifySubdomainTakeover — decision logic', () => {
|
||||||
|
it('CONFIRMS a takeover when the CNAME service and the live body fingerprint both match', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'my-bucket.s3.amazonaws.com', cnameResolves: true, body: '<Error><Code>NoSuchBucket</Code></Error>' });
|
||||||
|
expect(v.confidence).toBe('confirmed');
|
||||||
|
expect(v.vulnerable).toBe(true);
|
||||||
|
expect(v.service).toBe('AWS/S3');
|
||||||
|
expect(v.severity).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CONFIRMS on a dangling CNAME to an nxdomain-prone service (S3) even without a body', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'gone.s3.amazonaws.com', cnameResolves: false });
|
||||||
|
expect(v.confidence).toBe('confirmed');
|
||||||
|
expect(v.vulnerable).toBe(true);
|
||||||
|
expect(v.service).toBe('AWS/S3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is only POTENTIAL for a service whose dangling CNAME is not decisive and no fingerprint matched', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'app.herokudns.com', cnameResolves: false });
|
||||||
|
expect(v.confidence).toBe('potential');
|
||||||
|
expect(v.vulnerable).toBe(false); // not a confirmed finding
|
||||||
|
expect(v.service).toBe('Heroku');
|
||||||
|
expect(v.severity).toBe('medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT confirm a known service that still resolves and serves a normal page', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'user.github.io', cnameResolves: true, body: '<html>welcome to my blog</html>' });
|
||||||
|
expect(v.confidence).toBe('potential');
|
||||||
|
expect(v.vulnerable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a dangling CNAME to an UNRECOGNIZED service as potential', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'thing.unknown-vendor.example', cnameResolves: false });
|
||||||
|
expect(v.confidence).toBe('potential');
|
||||||
|
expect(v.service).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "none" when there is no CNAME (takeover is CNAME-based)', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: null, cnameResolves: false });
|
||||||
|
expect(v.confidence).toBe('none');
|
||||||
|
expect(v.vulnerable).toBe(false);
|
||||||
|
expect(v.severity).toBe('info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "none" for a CNAME that resolves and matches no fingerprint', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'cdn.some-cdn.example', cnameResolves: true, body: 'ok' });
|
||||||
|
expect(v.confidence).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not confirm on a fingerprint from a DIFFERENT service (no cross-matching)', () => {
|
||||||
|
// GitHub Pages body signature but the CNAME points at S3 → not a GitHub confirmation.
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'x.s3.amazonaws.com', cnameResolves: true, body: "There isn't a GitHub Pages site here" });
|
||||||
|
expect(v.confidence).toBe('potential'); // S3 matched by CNAME, but its own fingerprint didn't
|
||||||
|
expect(v.service).toBe('AWS/S3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every fingerprint entry is well-formed (service + cname regex)', () => {
|
||||||
|
for (const f of TAKEOVER_FINGERPRINTS) {
|
||||||
|
expect(f.service).toBeTruthy();
|
||||||
|
expect(f.cname).toBeInstanceOf(RegExp);
|
||||||
|
expect(typeof f.nxdomainVuln).toBe('boolean');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderTakeoverReport — output shape', () => {
|
||||||
|
it('renders a confirmed verdict with the CNAME chain and severity', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: 'b.s3.amazonaws.com', cnameResolves: false });
|
||||||
|
const out = renderTakeoverReport('blog.example.com', ['b.s3.amazonaws.com'], v);
|
||||||
|
expect(out).toContain('VULNERABLE (confirmed)');
|
||||||
|
expect(out).toContain('blog.example.com → b.s3.amazonaws.com');
|
||||||
|
expect(out).toContain('Severity: high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a non-candidate cleanly when there is no CNAME', () => {
|
||||||
|
const v = classifySubdomainTakeover({ cname: null, cnameResolves: false });
|
||||||
|
const out = renderTakeoverReport('www.example.com', [], v);
|
||||||
|
expect(out).toContain('Not a takeover candidate');
|
||||||
|
expect(out).toContain('(none — www.example.com has no CNAME)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Integration: the real tool handler with DNS + fetch mocked ──────────────────────────────────
|
||||||
|
vi.mock('dns', () => {
|
||||||
|
const ok = (result: unknown) => (...args: unknown[]) => (args[args.length - 1] as (e: unknown, r: unknown) => void)(null, result);
|
||||||
|
const fail = (code: string) => (...args: unknown[]) => { const e = new Error(code) as Error & { code: string }; e.code = code; (args[args.length - 1] as (e: unknown) => void)(e); };
|
||||||
|
return {
|
||||||
|
// resolveCname is overridden per-test via the exported mock below
|
||||||
|
resolveCname: vi.fn((_n: string, c: (e: unknown, r: unknown) => void) => c(null, ['dead-bucket.s3.amazonaws.com'])),
|
||||||
|
resolve4: vi.fn(fail('ENOTFOUND')), // CNAME target does not resolve → dangling
|
||||||
|
resolve: vi.fn(fail('ENOTFOUND')),
|
||||||
|
resolveMx: vi.fn(ok([])), resolveTxt: vi.fn(ok([])), resolveNs: vi.fn(ok([])),
|
||||||
|
reverse: vi.fn(ok([])), lookup: vi.fn(ok('127.0.0.1')),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subdomain_takeover_check handler (integration, mocked DNS + fetch)', () => {
|
||||||
|
const origFetch = global.fetch;
|
||||||
|
afterEach(() => { global.fetch = origFetch; vi.clearAllMocks(); });
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn(async () => ({ ok: false, status: 404, text: async () => '<Error><Code>NoSuchBucket</Code></Error>', headers: new Headers() } as unknown as Response)) as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirms an S3 takeover end-to-end and emits a high-severity finding', async () => {
|
||||||
|
const { BUILTIN_TOOLS } = await import('../arsenal/index.js');
|
||||||
|
const tool = BUILTIN_TOOLS.find((t) => t.name === 'subdomain_takeover_check');
|
||||||
|
expect(tool).toBeTruthy();
|
||||||
|
const res = await tool!.handler({ parameters: { target: 'blog.example.com' } } as never);
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.output).toContain('VULNERABLE (confirmed)');
|
||||||
|
expect(res.output).toContain('AWS/S3');
|
||||||
|
expect(res.findings?.[0]?.severity).toBe('high');
|
||||||
|
expect(res.findings?.[0]?.title).toContain('blog.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports "not a candidate" when the host has no CNAME', async () => {
|
||||||
|
const dns = await import('dns');
|
||||||
|
(dns.resolveCname as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce((_n: string, c: (e: unknown) => void) => { const e = new Error('ENODATA') as Error & { code: string }; e.code = 'ENODATA'; c(e); });
|
||||||
|
const { BUILTIN_TOOLS } = await import('../arsenal/index.js');
|
||||||
|
const tool = BUILTIN_TOOLS.find((t) => t.name === 'subdomain_takeover_check');
|
||||||
|
const res = await tool!.handler({ parameters: { target: 'www.example.com' } } as never);
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.output).toContain('Not a takeover candidate');
|
||||||
|
expect(res.findings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import * as net from 'net';
|
|||||||
import * as dns from 'dns';
|
import * as dns from 'dns';
|
||||||
import * as tls from 'tls';
|
import * as tls from 'tls';
|
||||||
import { ApprovalController, isGatedRisk, type ApprovalRequest } from './approval.js';
|
import { ApprovalController, isGatedRisk, type ApprovalRequest } from './approval.js';
|
||||||
|
import { classifySubdomainTakeover, renderTakeoverReport } from './takeover.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
import type {
|
import type {
|
||||||
@@ -34,6 +35,7 @@ const dnsResolveMx = promisify(dns.resolveMx);
|
|||||||
const dnsResolveTxt = promisify(dns.resolveTxt);
|
const dnsResolveTxt = promisify(dns.resolveTxt);
|
||||||
const dnsResolveNs = promisify(dns.resolveNs);
|
const dnsResolveNs = promisify(dns.resolveNs);
|
||||||
const dnsReverse = promisify(dns.reverse);
|
const dnsReverse = promisify(dns.reverse);
|
||||||
|
const dnsResolveCname = promisify(dns.resolveCname);
|
||||||
|
|
||||||
import { CVE_DATABASE } from '../stubs/index.js';
|
import { CVE_DATABASE } from '../stubs/index.js';
|
||||||
import type { CVEEntry } from '../stubs/index.js';
|
import type { CVEEntry } from '../stubs/index.js';
|
||||||
@@ -1886,6 +1888,81 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── subdomain_takeover_check ────────────────────────────────────────────────────────────────
|
||||||
|
// HOW TO VERIFY (backend, no UI needed):
|
||||||
|
// npm run build
|
||||||
|
// node -e "import('./dist/arsenal/index.js').then(async m=>{const t=m.BUILTIN_TOOLS.find(x=>x.name==='subdomain_takeover_check');console.log((await t.handler({parameters:{target:process.argv[1]}})).output)})" blog.example.com
|
||||||
|
// → resolves the live CNAME chain and prints a confirmed/potential/none verdict.
|
||||||
|
// The CONFIRMED path (dangling CNAME or an unclaimed-resource body fingerprint) is exercised
|
||||||
|
// end-to-end with DNS + fetch mocked in src/__tests__/subdomain-takeover.test.ts, and the pure
|
||||||
|
// decision matrix is covered there via classifySubdomainTakeover(). ONLY scan hosts you own or
|
||||||
|
// are authorized to test.
|
||||||
|
{
|
||||||
|
name: 'subdomain_takeover_check',
|
||||||
|
description: 'Detect a dangling / unclaimed subdomain (CNAME pointing at a de-provisioned third-party service such as S3, GitHub Pages, Heroku, Azure, Fastly). Resolves the CNAME and matches known takeover fingerprints.',
|
||||||
|
category: 'recon',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'target', type: 'string', description: 'Subdomain / hostname to check (e.g. blog.example.com)', required: true },
|
||||||
|
{ name: 'timeout', type: 'number', description: 'Per-request timeout in ms', required: false, default: 8000 },
|
||||||
|
],
|
||||||
|
handler: async (context) => {
|
||||||
|
// Normalize to a bare host: drop scheme, path, port, and a trailing FQDN dot (any of which
|
||||||
|
// would otherwise break the service fingerprint's host-suffix match).
|
||||||
|
const target = ((context.parameters.target as string) || context.target?.address || '')
|
||||||
|
.trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '').replace(/:\d+$/, '').replace(/\.$/, '').toLowerCase();
|
||||||
|
if (!target) return { success: false, error: 'No target specified' };
|
||||||
|
const timeout = (context.parameters.timeout as number) || 8000;
|
||||||
|
|
||||||
|
// 1) Resolve the CNAME chain. No CNAME → not a takeover candidate (takeovers are CNAME-based).
|
||||||
|
const chain: string[] = [];
|
||||||
|
let cname: string | null = null;
|
||||||
|
try {
|
||||||
|
const cnames = await dnsResolveCname(target);
|
||||||
|
if (cnames.length) { chain.push(...cnames); cname = cnames[cnames.length - 1]; }
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
// ENODATA/ENOTFOUND = no CNAME for this name; anything else is a real lookup failure.
|
||||||
|
if (!/ENODATA|ENOTFOUND|ENOTIMP|SERVFAIL/i.test(msg)) {
|
||||||
|
return { success: false, error: `CNAME lookup failed for ${target}: ${msg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Does the CNAME target still resolve? A dangling (non-resolving) CNAME is a strong signal.
|
||||||
|
let cnameResolves = false;
|
||||||
|
if (cname) {
|
||||||
|
try { cnameResolves = (await dnsResolve4(cname)).length > 0; }
|
||||||
|
catch { try { cnameResolves = (await dnsResolve(cname, 'AAAA') as string[]).length > 0; } catch { cnameResolves = false; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Best-effort fetch of the live response to confirm an "unclaimed resource" fingerprint.
|
||||||
|
// Never fatal — a takeover is often confirmable from DNS alone. Scope-gated by execute().
|
||||||
|
let body: string | undefined;
|
||||||
|
if (cname) {
|
||||||
|
for (const scheme of ['https', 'http']) {
|
||||||
|
try {
|
||||||
|
const resp = await targetFetch(`${scheme}://${target}`, { method: 'GET', signal: AbortSignal.timeout(timeout), redirect: 'manual' });
|
||||||
|
body = (await resp.text()).slice(0, 20000);
|
||||||
|
break;
|
||||||
|
} catch { /* try next scheme, then give up */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = classifySubdomainTakeover({ cname, cnameResolves, body });
|
||||||
|
const output = renderTakeoverReport(target, chain, verdict);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output,
|
||||||
|
findings: verdict.confidence === 'none' ? [] : [{
|
||||||
|
title: verdict.confidence === 'confirmed'
|
||||||
|
? `Subdomain takeover${verdict.service ? ` (${verdict.service})` : ''}: ${target}`
|
||||||
|
: `Possible subdomain takeover${verdict.service ? ` (${verdict.service})` : ''}: ${target}`,
|
||||||
|
severity: verdict.severity,
|
||||||
|
details: verdict.reasons.join(' '),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'version_detect',
|
name: 'version_detect',
|
||||||
description: 'Detect software versions from response headers, meta tags, and known paths',
|
description: 'Detect software versions from response headers, meta tags, and known paths',
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Subdomain-takeover detection — pure classification logic, separated from the Arsenal tool
|
||||||
|
* handler so it is fully unit-testable without DNS or network I/O.
|
||||||
|
*
|
||||||
|
* A subdomain takeover happens when a DNS record (almost always a CNAME) points at a third-party
|
||||||
|
* service resource that has been de-provisioned or never claimed. An attacker who registers that
|
||||||
|
* dangling resource then serves content from the victim's subdomain. Detection has two independent
|
||||||
|
* signals, either of which is decisive:
|
||||||
|
* 1. the CNAME target no longer resolves (dangling) — the backing resource is gone; and/or
|
||||||
|
* 2. the live response body carries a known "unclaimed resource" fingerprint for that service.
|
||||||
|
*
|
||||||
|
* Fingerprints are short, factual service error strings (the same public signals the community
|
||||||
|
* `can-i-take-over-xyz` catalog documents). `nxdomainVuln` marks services where a dangling CNAME
|
||||||
|
* alone is a strong takeover signal (the platform will happily serve a freshly-claimed name).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TakeoverFingerprint {
|
||||||
|
service: string;
|
||||||
|
/** Matches the CNAME target host that indicates this third-party service. */
|
||||||
|
cname: RegExp;
|
||||||
|
/** Matches the live-response "unclaimed resource" body signature (null = body is not distinctive). */
|
||||||
|
fingerprint: RegExp | null;
|
||||||
|
/** A dangling (non-resolving) CNAME to this service is itself a strong takeover signal. */
|
||||||
|
nxdomainVuln: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TAKEOVER_FINGERPRINTS: TakeoverFingerprint[] = [
|
||||||
|
{ service: 'AWS/S3', cname: /(\.s3[.-]|\.s3\.amazonaws\.com|\.amazonaws\.com)/i, fingerprint: /NoSuchBucket|The specified bucket does not exist/i, nxdomainVuln: true },
|
||||||
|
{ service: 'GitHub Pages', cname: /\.github\.io$/i, fingerprint: /There isn't a GitHub Pages site here|For root URLs \(like http:\/\/example\.com\/\) you must provide an index/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Heroku', cname: /(\.herokudns\.com|\.herokuapp\.com|\.herokussl\.com)$/i, fingerprint: /No such app|herokucdn\.com\/error-pages\/no-such-app\.html/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Fastly', cname: /\.fastly(\.net|lb\.net)$/i, fingerprint: /Fastly error: unknown domain/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Azure', cname: /(\.azurewebsites\.net|\.cloudapp\.net|\.cloudapp\.azure\.com|\.trafficmanager\.net|\.blob\.core\.windows\.net|\.azureedge\.net|\.azure-api\.net)$/i, fingerprint: null, nxdomainVuln: true },
|
||||||
|
{ service: 'Shopify', cname: /\.myshopify\.com$/i, fingerprint: /Sorry, this shop is currently unavailable/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Surge.sh', cname: /\.surge\.sh$/i, fingerprint: /project not found/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Bitbucket', cname: /\.bitbucket\.io$/i, fingerprint: /Repository not found/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Ghost', cname: /\.ghost\.io$/i, fingerprint: /The thing you were looking for is no longer here|Domain error/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Pantheon', cname: /\.pantheonsite\.io$/i, fingerprint: /The gods are wise|404 error unknown site/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Tumblr', cname: /\.domains\.tumblr\.com$/i, fingerprint: /Whatever you were looking for doesn't currently exist at this address/i, nxdomainVuln: false },
|
||||||
|
{ service: 'WordPress.com', cname: /\.wordpress\.com$/i, fingerprint: /Do you want to register .*\.wordpress\.com/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Read the Docs', cname: /\.readthedocs\.io$/i, fingerprint: /unknown to Read the Docs/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Zendesk', cname: /\.zendesk\.com$/i, fingerprint: /Help Center Closed/i, nxdomainVuln: false },
|
||||||
|
{ service: 'Netlify', cname: /\.netlify\.(app|com)$/i, fingerprint: /Not Found - Request ID/i, nxdomainVuln: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface TakeoverSignal {
|
||||||
|
/** The resolved CNAME target host (last hop in the chain), or null when the name has no CNAME. */
|
||||||
|
cname: string | null;
|
||||||
|
/** Whether the CNAME target itself resolves to an address (false = dangling). */
|
||||||
|
cnameResolves: boolean;
|
||||||
|
/** Live-response body, if one was fetched (used to confirm a service fingerprint). */
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TakeoverVerdict {
|
||||||
|
/** True only for a CONFIRMED takeover (fingerprint match, or dangling CNAME to an nxdomain-prone service). */
|
||||||
|
vulnerable: boolean;
|
||||||
|
confidence: 'confirmed' | 'potential' | 'none';
|
||||||
|
service: string | null;
|
||||||
|
severity: 'high' | 'medium' | 'info';
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a subdomain-takeover signal into a verdict. Pure — no I/O. Conservative by design:
|
||||||
|
* a bare "CNAME points at service X" without a dangling target or a body fingerprint is only ever
|
||||||
|
* `potential`, never a confirmed finding.
|
||||||
|
*/
|
||||||
|
export function classifySubdomainTakeover(sig: TakeoverSignal): TakeoverVerdict {
|
||||||
|
if (!sig.cname) {
|
||||||
|
return {
|
||||||
|
vulnerable: false,
|
||||||
|
confidence: 'none',
|
||||||
|
service: null,
|
||||||
|
severity: 'info',
|
||||||
|
reasons: ['No CNAME record — subdomain takeover is CNAME-based, so this host is not a candidate.'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = TAKEOVER_FINGERPRINTS.find((f) => f.cname.test(sig.cname as string));
|
||||||
|
const dangling = !sig.cnameResolves;
|
||||||
|
const bodyMatch = !!(match && match.fingerprint && sig.body && match.fingerprint.test(sig.body));
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
if (bodyMatch) {
|
||||||
|
return {
|
||||||
|
vulnerable: true,
|
||||||
|
confidence: 'confirmed',
|
||||||
|
service: match.service,
|
||||||
|
severity: 'high',
|
||||||
|
reasons: [
|
||||||
|
`CNAME points to ${match.service} (${sig.cname}) and the live response carries that service's "unclaimed resource" fingerprint.`,
|
||||||
|
'The backing resource appears unclaimed — a takeover of this subdomain is likely possible.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (dangling && match.nxdomainVuln) {
|
||||||
|
return {
|
||||||
|
vulnerable: true,
|
||||||
|
confidence: 'confirmed',
|
||||||
|
service: match.service,
|
||||||
|
severity: 'high',
|
||||||
|
reasons: [
|
||||||
|
`CNAME points to ${match.service} (${sig.cname}) but that target no longer resolves (dangling).`,
|
||||||
|
`${match.service} serves any freshly-claimed name at this target, so this subdomain is takeover-prone.`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
vulnerable: false,
|
||||||
|
confidence: 'potential',
|
||||||
|
service: match.service,
|
||||||
|
severity: 'medium',
|
||||||
|
reasons: [
|
||||||
|
`CNAME points to ${match.service} (${sig.cname}). No unclaimed-resource fingerprint was confirmed`,
|
||||||
|
dangling ? 'but the CNAME target does not resolve — verify whether the resource can be claimed.'
|
||||||
|
: 'and the target still resolves — verify the resource is genuinely owned by the target.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dangling) {
|
||||||
|
return {
|
||||||
|
vulnerable: false,
|
||||||
|
confidence: 'potential',
|
||||||
|
service: null,
|
||||||
|
severity: 'medium',
|
||||||
|
reasons: [
|
||||||
|
`CNAME points to ${sig.cname}, which no longer resolves (dangling) — a possible takeover of an unrecognized service.`,
|
||||||
|
'Confirm whether the pointed-to resource can be registered by a third party.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vulnerable: false,
|
||||||
|
confidence: 'none',
|
||||||
|
service: null,
|
||||||
|
severity: 'info',
|
||||||
|
reasons: [`CNAME points to ${sig.cname}, which resolves normally and matches no known takeover fingerprint.`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a verdict as the human-readable tool output block. */
|
||||||
|
export function renderTakeoverReport(host: string, chain: string[], verdict: TakeoverVerdict): string {
|
||||||
|
const badge = verdict.confidence === 'confirmed' ? '🚨 VULNERABLE (confirmed)'
|
||||||
|
: verdict.confidence === 'potential' ? '⚠️ POTENTIAL — needs manual verification'
|
||||||
|
: '✅ Not a takeover candidate';
|
||||||
|
const lines = [
|
||||||
|
`Subdomain takeover check — ${host}`,
|
||||||
|
`Verdict: ${badge}`,
|
||||||
|
verdict.service ? `Service: ${verdict.service}` : null,
|
||||||
|
verdict.confidence !== 'none' ? `Severity: ${verdict.severity}` : null,
|
||||||
|
chain.length ? `CNAME chain: ${[host, ...chain].join(' → ')}` : `CNAME chain: (none — ${host} has no CNAME)`,
|
||||||
|
'',
|
||||||
|
...verdict.reasons.map((r) => `• ${r}`),
|
||||||
|
].filter((l): l is string => l !== null);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
|||||||
description: 'Specialized in OSINT, network discovery, and asset enumeration',
|
description: 'Specialized in OSINT, network discovery, and asset enumeration',
|
||||||
mitreTactics: ['TA0043'],
|
mitreTactics: ['TA0043'],
|
||||||
primaryPhases: [KillChainPhase.RECON],
|
primaryPhases: [KillChainPhase.RECON],
|
||||||
defaultTools: ['dns_lookup', 'reverse_dns', 'whois_lookup', 'subdomain_enum', 'nmap_scan', 'port_scan', 'network_trace', 'version_detect', 'robots_txt_fetch', 'cidr_expand', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'api_endpoint_discovery'],
|
defaultTools: ['dns_lookup', 'reverse_dns', 'whois_lookup', 'subdomain_enum', 'subdomain_takeover_check', 'nmap_scan', 'port_scan', 'network_trace', 'version_detect', 'robots_txt_fetch', 'cidr_expand', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'api_endpoint_discovery'],
|
||||||
toolCategories: ['recon', 'web'],
|
toolCategories: ['recon', 'web'],
|
||||||
capabilities: ['osint', 'dns_enum', 'subdomain_discovery', 'port_scanning', 'service_detection'],
|
capabilities: ['osint', 'dns_enum', 'subdomain_discovery', 'port_scanning', 'service_detection'],
|
||||||
techniques: ['T1595', 'T1592', 'T1589', 'T1590', 'T1591'],
|
techniques: ['T1595', 'T1592', 'T1589', 'T1590', 'T1591'],
|
||||||
|
|||||||
Reference in new issue
Block a user